From a69eb3628ca60507997c48f5809509bf721cfa59 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 10:29:12 -0700 Subject: [PATCH 01/86] docs: add fresh agent platform implementation plan --- docs/plans/2026-04-18-fresh-agent-platform.md | 744 ++++++++++++++++++ 1 file changed, 744 insertions(+) create mode 100644 docs/plans/2026-04-18-fresh-agent-platform.md diff --git a/docs/plans/2026-04-18-fresh-agent-platform.md b/docs/plans/2026-04-18-fresh-agent-platform.md new file mode 100644 index 000000000..91f8452f3 --- /dev/null +++ b/docs/plans/2026-04-18-fresh-agent-platform.md @@ -0,0 +1,744 @@ +# Fresh Agent Platform Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Claude-shaped rich client with a shared fresh-agent platform, migrate `freshclaude` onto it, and ship `freshcodex` on the same foundation using the Codex app-server as the source of truth. + +**Architecture:** Introduce a provider registry plus runtime adapter layer that owns thread lifecycle, event streaming, capabilities, and durable history for all rich agent clients. The server normalizes each provider into a shared thread read model with provider-native extension payloads preserved, and the client renders a shared shell driven by capabilities rather than provider-specific branches. + +**Tech Stack:** TypeScript, React 18, Redux Toolkit, Express, WebSocket/Zod contracts, existing read-model lanes, Claude SDK bridge, Codex app-server, Vitest, Testing Library, Playwright-style e2e harnesses already in this repo. + +--- + +## Steady-State Product Behavior + +- `freshclaude` and `freshcodex` open the same shared rich-agent pane chrome, transcript layout, composer, review/diff surfaces, approval UI, child-thread tree, and mobile-responsive navigation. +- Session identity is explicit and stable: + - `sessionType` remains the user-facing identity (`freshclaude`, `freshcodex`, later `freshopencode`). + - `provider` remains the underlying runtime family (`claude`, `codex`, later `opencode`). + - Runtime sessions are addressed by provider-aware thread locators, never inferred from terminal panes. +- Rich panes resume, reconnect, and recover from refresh using provider runtime state plus durable history; raw terminal mode remains a separate pane type, not a hidden fallback. +- Forking, diffs, review, approvals/questions, subagents/child threads, worktree operations, and token/context indicators are shared features surfaced when the adapter capability says they are supported. +- Errors are explicit and user-friendly. If a provider runtime is unavailable or misconfigured, the pane shows a rich-client error with actionable guidance; it does not silently degrade into terminal scraping. +- `freshopencode` is not shipped in this plan, but the registry, capability model, and normalized read model must admit it without another architecture reset. + +## Contracts And Invariants + +### Provider/runtime boundaries + +- The provider registry is the single source of truth for rich-agent identities, labels, icons, default settings, and runtime adapter binding. +- Runtime adapters own lifecycle operations: `create`, `resume`, `fork`, `interrupt`, `send`, `answerQuestion`, `resolveApproval`, `listThreads`, `getThreadSnapshot`, `getTurnPage`, `getTurnBody`, `subscribe`, and capability-backed workspace actions. +- Terminal stdout is never the authoritative source for rich-agent transcript state. +- Adapters may expose provider-native extension payloads, but all shared UI reads from the normalized thread model first. + +### Normalized thread model + +- A normalized thread contains stable identifiers for thread, turn, item, approval, question, artifact, diff, child-thread, and worktree references. +- The model preserves provider-native detail in typed extension blobs instead of flattening every provider to the lowest common denominator. +- Read-model endpoints remain lane-aware (`critical`, `visible`, `background`) and revisioned; the client never mixes bodies from one revision with summaries from another. +- Durable history and live replay must merge into a single canonical thread view. The existing ledger strategy survives, but it moves behind the Claude runtime adapter rather than defining the platform contract. + +### Cutover invariants + +- By the end of the implementation, all rich-agent panes use the new `fresh-agent` runtime/read-model stack; the old `sdk.*` transport and `agentChatSlice` are removed or reduced to compatibility shims only where unavoidable inside the final architecture. +- Existing `freshclaude` sessions continue to appear in the sidebar as `sessionType: 'freshclaude'` and reopen into the shared rich pane. +- New `freshcodex` sessions persist as `sessionType: 'freshcodex'`, retain fork lineage/worktree metadata, and can be resumed from the sidebar/history surfaces. +- `freshopencode` support is represented in types/capabilities/registry design, but no user-visible OpenCode pane is shipped in this plan. + +## File Structure + +### Create + +- `shared/fresh-agent.ts` + - Shared Zod schemas and TypeScript types for runtime capabilities, thread locators, thread snapshots, turn items, review/diff refs, approvals, questions, child threads, worktrees, and WS message payloads. +- `server/fresh-agent/provider-registry.ts` + - Server-side registry binding `sessionType` and runtime provider names to adapter factories and capability declarations. +- `server/fresh-agent/runtime-adapter.ts` + - Core runtime adapter interfaces, operation/result types, and shared error taxonomy. +- `server/fresh-agent/runtime-manager.ts` + - Orchestrates active runtime sessions, subscriptions, replay, recovery, and thread locator resolution across adapters. +- `server/fresh-agent/router.ts` + - HTTP read-model routes for thread snapshot/turn pages/turn bodies and capability-backed actions that belong on REST. +- `server/fresh-agent/ws.ts` + - Shared rich-agent WebSocket message handlers/events replacing the current `sdk.*` protocol. +- `server/fresh-agent/adapters/claude/adapter.ts` + - Claude runtime adapter wrapping the existing SDK bridge and durable history ledger. +- `server/fresh-agent/adapters/claude/normalize.ts` + - Claude event/history to normalized thread model mapping. +- `server/fresh-agent/adapters/codex/adapter.ts` + - Codex runtime adapter built on the Codex app-server lifecycle, thread, fork, worktree, and review APIs. +- `server/fresh-agent/adapters/codex/client.ts` + - Codex app-server client, request marshalling, stream subscription, and error mapping. +- `server/fresh-agent/adapters/codex/normalize.ts` + - Codex protocol/thread history to normalized thread model mapping. +- `server/fresh-agent/adapters/shared/workspace.ts` + - Shared workspace/repo helpers used by review, diff, and worktree capability actions across adapters. +- `src/lib/fresh-agent-registry.ts` + - Client-side registry metadata for `freshclaude`, `freshcodex`, and future `freshopencode`. +- `src/lib/fresh-agent-capabilities.ts` + - Selectors/helpers for capability-driven UI rendering. +- `src/lib/fresh-agent-ws.ts` + - Client message handler for the new rich-agent WS protocol. +- `src/store/freshAgentTypes.ts` + - Normalized client state types keyed by thread locator and revision. +- `src/store/freshAgentSlice.ts` + - Redux slice for runtime session state, snapshot pages, streaming items, pending approvals/questions, and action errors. +- `src/store/freshAgentThunks.ts` + - Async thunks for snapshot/page/body loading and capability-backed actions. +- `src/components/fresh-agent/FreshAgentView.tsx` + - Shared top-level rich pane replacing `AgentChatView`. +- `src/components/fresh-agent/FreshAgentTranscript.tsx` + - Virtualized turn/item list with lazy body hydration and mobile-friendly rendering. +- `src/components/fresh-agent/FreshAgentComposer.tsx` + - Shared composer/action footer for send, interrupt, fork, and capability actions. +- `src/components/fresh-agent/FreshAgentSidebar.tsx` + - Child-thread/worktree/review navigation inside the pane. +- `src/components/fresh-agent/FreshAgentApprovalBanner.tsx` + - Shared approval UI. +- `src/components/fresh-agent/FreshAgentQuestionBanner.tsx` + - Shared question UI. +- `src/components/fresh-agent/FreshAgentDiffPanel.tsx` + - Shared diff/review presentation that supersedes the current Claude-only diff block. +- `src/components/fresh-agent/renderers/*` + - Focused item renderers for text, reasoning, tool calls/results, diffs, approvals, and provider extension blocks. +- `test/fixtures/fresh-agent/claude/*` + - Claude normalized fixture inputs/outputs for restore, approval, diff, and child-session flows. +- `test/fixtures/fresh-agent/codex/*` + - Codex normalized fixture inputs/outputs for create, fork, worktree, review, and child-session flows. +- `test/unit/server/fresh-agent/*.test.ts` + - Adapter contract tests, runtime manager tests, router tests, and protocol tests. +- `test/unit/client/fresh-agent/*.test.tsx` + - Slice, thunk, renderer, and view tests. +- `test/e2e/fresh-agent-*.test.tsx` + - Shared rich-agent flows covering both `freshclaude` and `freshcodex`. + +### Modify + +- `shared/ws-protocol.ts` + - Replace `sdk.*` rich-agent messages with `freshAgent.*` create/attach/subscribe/action events and update shared schemas. +- `server/ws-handler.ts` + - Route rich-agent WS traffic through `runtime-manager` instead of directly through `SdkBridge`. +- `server/index.ts` + - Mount fresh-agent router/services and inject them into bootstrap/server startup. +- `server/agent-timeline/*` + - Keep ledger/history logic, but narrow it to Claude adapter internals or move shared pieces behind adapter-agnostic names. +- `server/sdk-bridge.ts` + - Retain only Claude SDK process concerns that remain under the Claude adapter; remove top-level platform assumptions. +- `server/sdk-bridge-types.ts` + - Retire or rename Claude-specific transport types once their responsibilities move into `shared/fresh-agent.ts`. +- `server/sessions-router.ts` + - Teach resume/session metadata surfaces about `freshcodex` and rich-thread locators. +- `server/session-directory/*` + - Project normalized rich-thread metadata, fork lineage, child-thread hints, and capability badges into the sidebar/history directory. +- `server/coding-cli/providers/codex.ts` + - Keep non-rich terminal session indexing only; remove pressure to serve as the future rich runtime API. +- `src/components/agent-chat/*` + - Reuse narrowly useful pieces or retire them in favor of `src/components/fresh-agent/*`. +- `src/store/agentChatSlice.ts` + - Remove rich-client ownership after migration or collapse it into a thin backward-compatibility layer before deletion. +- `src/store/agentChatThunks.ts` + - Same as above; move read-model loading to `freshAgentThunks`. +- `src/components/panes/PaneContainer.tsx` + - Swap rich-agent pane rendering and create/resume behavior to the shared `FreshAgentView`. +- `src/store/paneTypes.ts` + - Add `freshcodex` and normalize rich pane type metadata for the new platform. +- `src/store/tabsSlice.ts` + - Open/resume shared rich panes based on registry data and persisted thread locators. +- `src/lib/session-type-utils.ts` + - Build resume content and labels for `freshcodex` using the shared registry. +- `src/lib/agent-chat-utils.ts` + - Remove or reduce to compatibility exports after the registry cutover. +- `src/components/Sidebar.tsx` + - Continue to use `sessionType` but source provider metadata and resume actions from the new registry/capability model. +- `docs/index.html` + - Update the nonfunctional mock if the visible fresh-agent experience changes materially. + +## Strategy Gate + +The direct path is to build the steady-state platform now, then migrate both rich clients onto it during the same implementation. Extending the current `sdk.*` contract into a pseudo-generic shape would preserve the wrong abstraction boundary: the current contract bakes Claude session semantics, Claude durable history assumptions, and message-array rendering into the platform. Codex app-server and future OpenCode server APIs are runtime products in their own right; treating them as terminal cousins would force permanent provider branches, duplicated UI, and another rewrite later. This plan therefore lands the final runtime-adapter architecture directly, preserves the proven Claude ledger strategy as an adapter implementation detail, and keeps `freshopencode` in scope only as a design constraint. + +### Task 1: Define the fresh-agent contract, registry, and capability model + +**Files:** +- Create: `shared/fresh-agent.ts` +- Create: `server/fresh-agent/runtime-adapter.ts` +- Create: `server/fresh-agent/provider-registry.ts` +- Create: `src/lib/fresh-agent-registry.ts` +- Create: `src/lib/fresh-agent-capabilities.ts` +- Modify: `shared/ws-protocol.ts` +- Modify: `src/store/paneTypes.ts` +- Test: `test/unit/server/fresh-agent/provider-registry.test.ts` +- Test: `test/unit/shared/fresh-agent-contract.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Add contract tests that pin the final schema and registry semantics: + +```ts +it('declares freshclaude and freshcodex as rich session types with explicit runtime providers', () => { + expect(resolveFreshAgentType('freshclaude')).toMatchObject({ runtimeProvider: 'claude' }) + expect(resolveFreshAgentType('freshcodex')).toMatchObject({ runtimeProvider: 'codex' }) +}) + +it('exposes capability flags without collapsing provider-specific extensions', () => { + expect(FreshAgentThreadSnapshotSchema.parse(snapshotFixture).capabilities.review).toBe(true) + expect(snapshotFixture.providerExtensions?.codex).toBeDefined() +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/provider-registry.test.ts test/unit/shared/fresh-agent-contract.test.ts` +Expected: FAIL because the fresh-agent contract and registry do not exist yet. + +- [ ] **Step 3: Write the minimal implementation** + +Implement the shared `fresh-agent` schema and runtime adapter interface. Define: + +- `FreshAgentSessionType = 'freshclaude' | 'freshcodex' | 'freshopencode'` +- `FreshAgentRuntimeProvider = 'claude' | 'codex' | 'opencode'` +- capability groups for transcript, approvals, questions, diffs/review, forking, worktrees, child threads, token budgets, and provider extension panels +- normalized thread locator, snapshot, turn page, turn body, item, approval, question, artifact, diff, child-thread, and worktree types +- registry entries for `freshclaude` and `freshcodex`, plus a disabled future-facing `freshopencode` config + +Update pane types and WS protocol stubs so the rest of the migration has a stable contract to target. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/provider-registry.test.ts test/unit/shared/fresh-agent-contract.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Refactor naming to make the boundary crisp: + +- shared/provider-agnostic types live in `shared/fresh-agent.ts` +- server lifecycle abstractions live in `runtime-adapter.ts` +- registry carries only declarative metadata, not runtime logic + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/provider-registry.test.ts test/unit/shared/fresh-agent-contract.test.ts test/unit/server/ws-handler-sdk.test.ts` +Expected: PASS with no diluted assertions. + +- [ ] **Step 6: Commit** + +```bash +git add shared/fresh-agent.ts server/fresh-agent/runtime-adapter.ts server/fresh-agent/provider-registry.ts src/lib/fresh-agent-registry.ts src/lib/fresh-agent-capabilities.ts shared/ws-protocol.ts src/store/paneTypes.ts test/unit/server/fresh-agent/provider-registry.test.ts test/unit/shared/fresh-agent-contract.test.ts +git commit -m "feat: define fresh agent platform contracts" +``` + +### Task 2: Build the runtime manager and replace the top-level rich-agent transport + +**Files:** +- Create: `server/fresh-agent/runtime-manager.ts` +- Create: `server/fresh-agent/ws.ts` +- Modify: `server/ws-handler.ts` +- Modify: `server/index.ts` +- Modify: `shared/ws-protocol.ts` +- Test: `test/unit/server/fresh-agent/runtime-manager.test.ts` +- Test: `test/unit/server/fresh-agent/ws.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Write tests that prove the server now routes rich-agent traffic through the new manager: + +```ts +it('routes freshAgent.create to the adapter selected by sessionType', async () => { + expect(adapter.createThread).toHaveBeenCalledWith(expect.objectContaining({ sessionType: 'freshcodex' })) +}) + +it('does not emit subscribed thread state when create cutover fails transactionally', async () => { + expect(messages).toContainEqual(expect.objectContaining({ type: 'freshAgent.create.failed' })) + expect(messages).not.toContainEqual(expect.objectContaining({ type: 'freshAgent.created' })) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/ws.test.ts` +Expected: FAIL because the manager and protocol handler do not exist. + +- [ ] **Step 3: Write the minimal implementation** + +Implement `runtime-manager` with: + +- adapter lookup from the provider registry +- create/attach/subscribe/send/interrupt/fork/action dispatch +- replay buffering and subscription sequencing analogous to the current `SdkBridge` guarantees +- a shared error taxonomy that maps provider failures to user-friendly transport errors + +Update `server/ws-handler.ts` to hand rich-agent messages to `server/fresh-agent/ws.ts`. Keep terminal paths untouched. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/ws.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Refactor for one authoritative transport path. Remove any duplicated `sdk.*` routing code that would leave parallel rich-client stacks alive. + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/ws.test.ts test/unit/server/ws-handler-sdk.test.ts` +Expected: PASS, with updated tests asserting `freshAgent.*` behavior instead of legacy `sdk.*` behavior where appropriate. + +- [ ] **Step 6: Commit** + +```bash +git add server/fresh-agent/runtime-manager.ts server/fresh-agent/ws.ts server/ws-handler.ts server/index.ts shared/ws-protocol.ts test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/ws.test.ts +git commit -m "feat: route rich agent sessions through runtime manager" +``` + +### Task 3: Migrate Claude runtime logic behind a Claude adapter and normalized read model + +**Files:** +- Create: `server/fresh-agent/adapters/claude/adapter.ts` +- Create: `server/fresh-agent/adapters/claude/normalize.ts` +- Modify: `server/sdk-bridge.ts` +- Modify: `server/sdk-bridge-types.ts` +- Modify: `server/agent-timeline/ledger.ts` +- Modify: `server/agent-timeline/history-source.ts` +- Modify: `server/agent-timeline/service.ts` +- Modify: `server/agent-timeline/router.ts` +- Test: `test/unit/server/fresh-agent/claude-adapter.test.ts` +- Test: `test/unit/server/fresh-agent/claude-normalize.test.ts` +- Test: `test/unit/server/fresh-agent/claude-restore-contract.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Add adapter contract tests for the tricky Claude behaviors we cannot afford to regress: + +- durable/live merge yields one canonical turn sequence +- attach/reconnect never fabricates a live-only snapshot when durable restore state is required +- approvals/questions/tokens normalize into shared item/state shapes +- session loss triggers recoverable runtime errors, not transcript corruption + +```ts +it('maps ledger-backed restore state into a normalized thread snapshot', async () => { + expect(snapshot.turns[0]?.items[0]?.kind).toBe('message') + expect(snapshot.revision).toBeGreaterThan(0) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts` +Expected: FAIL because the Claude adapter and normalized output do not exist. + +- [ ] **Step 3: Write the minimal implementation** + +Wrap the existing `SdkBridge` and ledger/history machinery inside the Claude adapter. The adapter should: + +- keep the current replay and restore guarantees +- normalize Claude message blocks into shared turn items +- project approvals/questions/tool blocks/token summaries into capability-backed thread state +- treat ledger internals as Claude implementation details, not platform types + +Keep user-visible behavior equivalent for `freshclaude`, except where the new normalized model enables the shared shell. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Delete or rename any leftover top-level Claude-specific abstractions that still pretend to be provider-generic. Keep shared reusable pieces only where the abstraction is real. + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/ws-sdk-session-history-cache.test.ts test/unit/server/ws-handler-sdk.test.ts` +Expected: PASS after migrating those tests to the new adapter/transport model. + +- [ ] **Step 6: Commit** + +```bash +git add server/fresh-agent/adapters/claude/adapter.ts server/fresh-agent/adapters/claude/normalize.ts server/sdk-bridge.ts server/sdk-bridge-types.ts server/agent-timeline/ledger.ts server/agent-timeline/history-source.ts server/agent-timeline/service.ts server/agent-timeline/router.ts test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts +git commit -m "refactor: move claude rich runtime behind adapter" +``` + +### Task 4: Implement the Codex adapter on the Codex app-server contract + +**Files:** +- Create: `server/fresh-agent/adapters/codex/client.ts` +- Create: `server/fresh-agent/adapters/codex/adapter.ts` +- Create: `server/fresh-agent/adapters/codex/normalize.ts` +- Modify: `server/fresh-agent/provider-registry.ts` +- Modify: `server/coding-cli/providers/codex.ts` +- Test: `test/unit/server/fresh-agent/codex-client.test.ts` +- Test: `test/unit/server/fresh-agent/codex-adapter.test.ts` +- Test: `test/unit/server/fresh-agent/codex-normalize.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Write contract tests against recorded Codex fixtures that pin: + +- thread create/resume +- fork lineage +- child thread/subagent refs +- review/diff/worktree projections +- token/context summaries +- approval/question style interactions if the app-server exposes them + +```ts +it('normalizes codex review and fork metadata into shared snapshot structures', async () => { + expect(snapshot.capabilities.review).toBe(true) + expect(snapshot.diffRefs[0]?.baseThreadId).toBeDefined() + expect(snapshot.childThreads[0]?.origin).toBe('subagent') +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/codex-client.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts` +Expected: FAIL because no Codex runtime adapter exists. + +- [ ] **Step 3: Write the minimal implementation** + +Implement a Codex app-server client and adapter that: + +- uses app-server lifecycle/thread APIs as the source of truth +- streams updates into runtime-manager subscriptions +- normalizes Codex thread items, reviews, diffs, worktrees, and subagent metadata into the shared model +- maps provider-native details into `providerExtensions.codex` + +Keep `server/coding-cli/providers/codex.ts` focused on terminal session indexing, not rich runtime behavior. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/codex-client.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Refactor shared workspace/review helpers so Codex and Claude use the same final abstractions where the behavior is actually shared. Do not add fake generic wrappers around provider-native protocol payloads. + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/codex-client.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts test/unit/server/fresh-agent/claude-adapter.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/fresh-agent/adapters/codex/client.ts server/fresh-agent/adapters/codex/adapter.ts server/fresh-agent/adapters/codex/normalize.ts server/fresh-agent/provider-registry.ts server/coding-cli/providers/codex.ts test/unit/server/fresh-agent/codex-client.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts +git commit -m "feat: add codex fresh agent adapter" +``` + +### Task 5: Replace the rich read-model routes with thread snapshot/page/body endpoints + +**Files:** +- Create: `server/fresh-agent/router.ts` +- Modify: `server/session-directory/projection.ts` +- Modify: `server/session-directory/service.ts` +- Modify: `server/session-directory/types.ts` +- Modify: `server/sessions-router.ts` +- Modify: `shared/read-models.ts` +- Test: `test/unit/server/fresh-agent/router.test.ts` +- Test: `test/unit/server/session-directory/fresh-agent-projection.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Add tests covering the final read-model contract: + +- thread snapshot endpoint returns revisioned capability-aware snapshots +- turn page/body endpoints respect lane/revision constraints +- session directory includes `freshcodex` with stable title/sessionType/fork/subagent metadata +- router errors are explicit when a runtime is unavailable or a revision is stale + +```ts +it('returns a stale-revision error instead of mixing thread revisions', async () => { + expect(response.status).toBe(409) + expect(response.body.code).toBe('STALE_THREAD_REVISION') +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/router.test.ts test/unit/server/session-directory/fresh-agent-projection.test.ts` +Expected: FAIL because the new router and projection do not exist. + +- [ ] **Step 3: Write the minimal implementation** + +Implement REST routes for: + +- `GET /api/fresh-agent/threads/:provider/:threadId` +- `GET /api/fresh-agent/threads/:provider/:threadId/turns` +- `GET /api/fresh-agent/threads/:provider/:threadId/turns/:turnId` +- action endpoints that belong on HTTP rather than WS when idempotent or lane-aware + +Update directory projection to carry the rich metadata needed by the sidebar/history surfaces for both `freshclaude` and `freshcodex`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/router.test.ts test/unit/server/session-directory/fresh-agent-projection.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Remove obsolete `agent-timeline` route entry points once their responsibilities are fully subsumed by the new thread routes. + +Run: `npm run test:vitest -- test/unit/server/fresh-agent/router.test.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/visible-first/read-model-route-harness.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add server/fresh-agent/router.ts server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/sessions-router.ts shared/read-models.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/session-directory/fresh-agent-projection.test.ts +git commit -m "feat: add fresh agent thread read models" +``` + +### Task 6: Replace client rich-agent state with normalized fresh-agent Redux state + +**Files:** +- Create: `src/store/freshAgentTypes.ts` +- Create: `src/store/freshAgentSlice.ts` +- Create: `src/store/freshAgentThunks.ts` +- Create: `src/lib/fresh-agent-ws.ts` +- Modify: `src/lib/ws-client.ts` +- Modify: `src/lib/sdk-message-handler.ts` +- Modify: `src/store/index.ts` +- Modify: `src/store/persistControl.ts` +- Modify: `src/store/tabsSlice.ts` +- Test: `test/unit/client/store/freshAgentSlice.test.ts` +- Test: `test/unit/client/store/freshAgentThunks.test.ts` +- Test: `test/unit/client/lib/fresh-agent-ws.test.ts` + +- [ ] **Step 1: Identify or write the failing tests** + +Add tests for the final client state semantics: + +- snapshot/page/body hydration is revision-safe +- live items merge into normalized turns without duplicate transcript blocks +- approvals/questions/action errors are stored in shared state, not provider slices +- `freshcodex` and `freshclaude` pending creates both resolve through the same path + +```ts +it('stores pending approvals by thread locator independent of provider-specific payload shape', () => { + expect(state.threads[key].pendingApprovals['req-1']).toMatchObject({ title: 'Run command?' }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts` +Expected: FAIL because the new state layer does not exist. + +- [ ] **Step 3: Write the minimal implementation** + +Implement normalized client state keyed by thread locator. Replace the `sdk-message-handler` flow with `fresh-agent-ws` handling and move all read-model fetching into `freshAgentThunks`. + +Persist only what is necessary for reconnect/resume: + +- thread locator +- session type/runtime provider +- active revision cursor anchors where appropriate + +Do not persist transient streaming or pending-action state. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Remove provider-specific state duplication from `agentChatSlice` and thunks once the shared slice owns the rich client. + +Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/sdk-message-handler.test.ts` +Expected: PASS after converting legacy tests to the new path or deleting them when the old handler is gone. + +- [ ] **Step 6: Commit** + +```bash +git add src/store/freshAgentTypes.ts src/store/freshAgentSlice.ts src/store/freshAgentThunks.ts src/lib/fresh-agent-ws.ts src/lib/ws-client.ts src/lib/sdk-message-handler.ts src/store/index.ts src/store/persistControl.ts src/store/tabsSlice.ts test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts +git commit -m "feat: add normalized fresh agent client state" +``` + +### Task 7: Build the shared fresh-agent UI shell and migrate freshclaude/freshcodex panes + +**Files:** +- Create: `src/components/fresh-agent/FreshAgentView.tsx` +- Create: `src/components/fresh-agent/FreshAgentTranscript.tsx` +- Create: `src/components/fresh-agent/FreshAgentComposer.tsx` +- Create: `src/components/fresh-agent/FreshAgentSidebar.tsx` +- Create: `src/components/fresh-agent/FreshAgentApprovalBanner.tsx` +- Create: `src/components/fresh-agent/FreshAgentQuestionBanner.tsx` +- Create: `src/components/fresh-agent/FreshAgentDiffPanel.tsx` +- Create: `src/components/fresh-agent/renderers/*.tsx` +- Modify: `src/components/panes/PaneContainer.tsx` +- Modify: `src/components/Sidebar.tsx` +- Modify: `src/lib/session-type-utils.ts` +- Modify: `src/lib/agent-chat-utils.ts` +- Modify: `src/components/icons/PaneIcon.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx` +- Test: `test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` + +- [ ] **Step 1: Identify or write the failing tests** + +Add component tests that pin the final shared behavior: + +- `freshclaude` and `freshcodex` render the same shell chrome +- transcript virtualization only hydrates visible turn bodies +- capability flags hide unsupported actions cleanly +- diff/review panel renders from normalized refs, not Claude-only tool blocks +- mobile layout moves secondary panes into drawers/sheets instead of crushing the transcript + +```tsx +it('renders fork and worktree actions for freshcodex but not freshclaude when capabilities differ', () => { + render() + expect(screen.getByRole('button', { name: /fork/i })).toBeInTheDocument() +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` +Expected: FAIL because the new shared UI does not exist. + +- [ ] **Step 3: Write the minimal implementation** + +Build the shared shell with: + +- a virtualized transcript backed by normalized turn summaries/bodies +- reusable approval/question banners +- capability-driven action/footer rendering +- a diff/review panel that can be opened from either provider +- responsive layout for narrow/mobile widths with deferred hydration for heavy transcript sections + +Switch `PaneContainer` and sidebar resume actions to the shared rich pane. `freshcodex` should appear anywhere `freshclaude` appears today. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Delete or fold old `src/components/agent-chat/*` pieces that are fully replaced. Keep only narrowly reusable presentational helpers if they still make sense under the new names. + +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx` +Expected: PASS after migrating or removing legacy tests with stronger coverage on the new shell. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/fresh-agent src/components/panes/PaneContainer.tsx src/components/Sidebar.tsx src/lib/session-type-utils.ts src/lib/agent-chat-utils.ts src/components/icons/PaneIcon.tsx test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx +git commit -m "feat: ship shared fresh agent pane shell" +``` + +### Task 8: Wire end-to-end create/resume/fork/review flows and update docs/mocks + +**Files:** +- Modify: `src/store/tabsSlice.ts` +- Modify: `src/components/context-menu/ContextMenuProvider.tsx` +- Modify: `src/components/HistoryView.tsx` +- Modify: `src/store/selectors/sidebarSelectors.ts` +- Modify: `server/sessions-router.ts` +- Modify: `docs/index.html` +- Test: `test/e2e/fresh-agent-create-resume.test.tsx` +- Test: `test/e2e/fresh-agent-review-flow.test.tsx` +- Test: `test/e2e/fresh-agent-mobile-layout.test.tsx` + +- [ ] **Step 1: Identify or write the failing tests** + +Add e2e coverage for the user-visible flows that justify the architecture: + +- create/resume `freshclaude` +- create/resume `freshcodex` +- fork a `freshcodex` thread and reopen it from the sidebar +- open a review/diff panel from both providers when supported +- mobile transcript remains usable while approvals/questions/diff panes are accessible + +```ts +it('reopens a freshcodex fork from the sidebar with its lineage and worktree metadata intact', async () => { + // assert sidebar entry, pane title, fork badge, and worktree action visibility +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- test/e2e/fresh-agent-create-resume.test.tsx test/e2e/fresh-agent-review-flow.test.tsx test/e2e/fresh-agent-mobile-layout.test.tsx` +Expected: FAIL because the end-to-end rich-agent cutover is incomplete. + +- [ ] **Step 3: Write the minimal implementation** + +Finish the wiring across sidebar/history/context-menu resume entry points, ensure metadata persistence stores `sessionType: 'freshcodex'`, and update `docs/index.html` to reflect the new shared shell where it materially differs from the previous Freshclaude-only experience. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- test/e2e/fresh-agent-create-resume.test.tsx test/e2e/fresh-agent-review-flow.test.tsx test/e2e/fresh-agent-mobile-layout.test.tsx` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Tighten naming and remove any leftover "agent chat" or Claude-only assumptions from user-facing resume/create paths. The shipped UI should read as one fresh-agent platform with two concrete clients. + +Run: `npm run test:vitest -- test/e2e/fresh-agent-create-resume.test.tsx test/e2e/fresh-agent-review-flow.test.tsx test/e2e/fresh-agent-mobile-layout.test.tsx test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/store/tabsSlice.ts src/components/context-menu/ContextMenuProvider.tsx src/components/HistoryView.tsx src/store/selectors/sidebarSelectors.ts server/sessions-router.ts docs/index.html test/e2e/fresh-agent-create-resume.test.tsx test/e2e/fresh-agent-review-flow.test.tsx test/e2e/fresh-agent-mobile-layout.test.tsx +git commit -m "feat: complete fresh agent create resume and review flows" +``` + +### Task 9: Remove obsolete rich-agent paths and run the full verification gate + +**Files:** +- Modify/Delete: `src/store/agentChatSlice.ts` +- Modify/Delete: `src/store/agentChatThunks.ts` +- Modify/Delete: `src/components/agent-chat/*` +- Modify/Delete: `server/sdk-bridge-types.ts` +- Modify/Delete: any other `sdk.*` rich-agent-only glue made obsolete by the final cutover +- Test: existing repo suites plus all new fresh-agent tests + +- [ ] **Step 1: Identify or write the failing tests** + +Use the existing repo checks as the red bar for dead-code removal. If any legacy tests only assert the old architecture, replace them with stronger fresh-agent coverage before deleting them. + +- [ ] **Step 2: Run tests to verify current failures or stale references** + +Run: `npm run test:vitest -- test/unit/client test/unit/server` +Expected: FAIL somewhere due to stale imports, dead files, or old `sdk.*` references after the cutover. + +- [ ] **Step 3: Write the minimal implementation** + +Delete obsolete files and imports, collapse compatibility shims that no longer carry real weight, and ensure the codebase has one rich-agent architecture instead of two. + +- [ ] **Step 4: Run tests to verify targeted suites pass** + +Run: `npm run test:vitest -- test/unit/client test/unit/server` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Run the full repository verification expected before finishing: + +Run: `npm run lint` +Run: `npm run test:status` +Run: `FRESHELL_TEST_SUMMARY=\"fresh agent platform\" npm test` +Run: `npm run check` +Expected: all PASS + +If any existing test fails, fix the defect rather than weakening the check. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: remove legacy rich agent architecture" +``` + +## OpenCode design constraint for later work + +Do not implement `freshopencode` in this change. Do ensure the platform can support it without another contract reset: + +- registry entry can exist as disabled/future-facing metadata +- normalized model already includes diff/review/worktree/child-thread concepts OpenCode will need +- runtime adapter interface permits server-driven event streams and explicit permission/command flows +- no client code assumes every provider has Claude-style block messages or Codex-style review objects + +## Implementation notes for the executing agent + +- Work directly in this worktree: `/home/user/code/freshell/.worktrees/fresh-agent-platform` +- Keep commits aligned to the tasks above; do not batch multiple tasks into one commit. +- Preserve unrelated changes if present. +- Do not add hidden terminal fallbacks. Show explicit rich-client errors when an adapter cannot operate. +- Prefer renaming/removing obsolete abstractions once stronger coverage exists rather than leaving long-lived compatibility layers. From 221955e757b39d3b81441ad0f59a7161ccab302c Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 10:37:06 -0700 Subject: [PATCH 02/86] docs: revise fresh agent implementation plan --- docs/plans/2026-04-18-fresh-agent-platform.md | 710 +++++++++--------- 1 file changed, 350 insertions(+), 360 deletions(-) diff --git a/docs/plans/2026-04-18-fresh-agent-platform.md b/docs/plans/2026-04-18-fresh-agent-platform.md index 91f8452f3..fcae95ce0 100644 --- a/docs/plans/2026-04-18-fresh-agent-platform.md +++ b/docs/plans/2026-04-18-fresh-agent-platform.md @@ -2,292 +2,311 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Replace the Claude-shaped rich client with a shared fresh-agent platform, migrate `freshclaude` onto it, and ship `freshcodex` on the same foundation using the Codex app-server as the source of truth. +**Goal:** Ship a shared `fresh-agent` platform that powers `freshclaude` and `freshcodex` from one architecture, preserves current Freshclaude behavior, and leaves a clean adapter seam for `freshopencode` later. -**Architecture:** Introduce a provider registry plus runtime adapter layer that owns thread lifecycle, event streaming, capabilities, and durable history for all rich agent clients. The server normalizes each provider into a shared thread read model with provider-native extension payloads preserved, and the client renders a shared shell driven by capabilities rather than provider-specific branches. +**Architecture:** Rename the rich-pane domain from `agent-chat` to `fresh-agent`, migrate persisted state/settings to that vocabulary, and drive all rich panes from a provider registry plus runtime adapters. Reuse the existing Claude ledger/history stack and the existing Codex app-server foundation, extend them where needed, and normalize both into one lane-aware read model and one shared UI shell. -**Tech Stack:** TypeScript, React 18, Redux Toolkit, Express, WebSocket/Zod contracts, existing read-model lanes, Claude SDK bridge, Codex app-server, Vitest, Testing Library, Playwright-style e2e harnesses already in this repo. +**Tech Stack:** TypeScript, React 18, Redux Toolkit, Express, WebSocket/Zod contracts, existing read-model scheduler, Claude SDK bridge, Codex app-server runtime/client, Vitest, Testing Library, Playwright browser e2e. --- +## Why The Previous Plan Would Fail + +- It created a second Codex app-server client under `server/fresh-agent/adapters/codex/client.ts` even though the repo already has a tested app-server stack in `server/coding-cli/codex-app-server/*`. That would fork the protocol surface and guarantee drift. +- It never migrated persisted `kind: 'agent-chat'` panes or `settings.agentChat`, so existing Freshclaude tabs/settings would break or fossilize the old naming forever. +- It ignored hidden Claude-backed `kilroy` support. Deleting or orphaning it would be a regression. +- It proposed vitest e2e files under `test/e2e/*.test.tsx` for flows that belong in the repo’s Playwright browser suite under `test/e2e-browser/specs/*.spec.ts`. +- It did not require preserving existing Freshclaude features that are already real product behavior: plugin injection, mid-session model/permission changes, durable restore, lost-session recovery, timeline hydration, input history, and current local/server settings behavior. +- It did not specify how Freshcodex gets rich live data from the Codex app-server. The existing client currently starts fresh threads with `experimentalRawEvents: false` for terminal/TUI flows; a rich pane needs explicit event/replay support. + ## Steady-State Product Behavior -- `freshclaude` and `freshcodex` open the same shared rich-agent pane chrome, transcript layout, composer, review/diff surfaces, approval UI, child-thread tree, and mobile-responsive navigation. -- Session identity is explicit and stable: - - `sessionType` remains the user-facing identity (`freshclaude`, `freshcodex`, later `freshopencode`). - - `provider` remains the underlying runtime family (`claude`, `codex`, later `opencode`). - - Runtime sessions are addressed by provider-aware thread locators, never inferred from terminal panes. -- Rich panes resume, reconnect, and recover from refresh using provider runtime state plus durable history; raw terminal mode remains a separate pane type, not a hidden fallback. -- Forking, diffs, review, approvals/questions, subagents/child threads, worktree operations, and token/context indicators are shared features surfaced when the adapter capability says they are supported. -- Errors are explicit and user-friendly. If a provider runtime is unavailable or misconfigured, the pane shows a rich-client error with actionable guidance; it does not silently degrade into terminal scraping. -- `freshopencode` is not shipped in this plan, but the registry, capability model, and normalized read model must admit it without another architecture reset. +- Rich panes use `kind: 'fresh-agent'`, not `kind: 'agent-chat'`. +- `freshclaude`, `freshcodex`, and hidden `kilroy` all come from one fresh-agent registry. +- `provider` keeps meaning runtime family (`claude`, `codex`, later `opencode`). +- `sessionType` keeps meaning user-facing identity (`freshclaude`, `freshcodex`, `kilroy`, later `freshopencode`). +- Existing Freshclaude sessions, settings, reopen entries, and sidebar items survive migration with no manual repair. +- Raw CLI terminals remain separate pane types. Rich panes never silently degrade into terminal scraping. +- Codex rich panes use the Codex app-server as the source of truth and surface fork, diff/review, worktree, child-thread, and token/context metadata when the runtime exposes them. +- OpenCode is not shipped here, but the registry, adapter interface, and normalized model already admit it. ## Contracts And Invariants -### Provider/runtime boundaries +### Naming and persistence -- The provider registry is the single source of truth for rich-agent identities, labels, icons, default settings, and runtime adapter binding. -- Runtime adapters own lifecycle operations: `create`, `resume`, `fork`, `interrupt`, `send`, `answerQuestion`, `resolveApproval`, `listThreads`, `getThreadSnapshot`, `getTurnPage`, `getTurnBody`, `subscribe`, and capability-backed workspace actions. -- Terminal stdout is never the authoritative source for rich-agent transcript state. -- Adapters may expose provider-native extension payloads, but all shared UI reads from the normalized thread model first. +- The final domain name is `fresh-agent`, not `agent-chat`. +- Persisted pane/layout data must migrate existing `agent-chat` leaves to `fresh-agent`. +- Settings must migrate `agentChat` to `freshAgent` while continuing to read legacy input during rollout. +- Session metadata remains keyed by `provider:sessionId`; updating `sessionType` must keep `derivedTitle`. -### Normalized thread model +### Registry and adapters -- A normalized thread contains stable identifiers for thread, turn, item, approval, question, artifact, diff, child-thread, and worktree references. -- The model preserves provider-native detail in typed extension blobs instead of flattening every provider to the lowest common denominator. -- Read-model endpoints remain lane-aware (`critical`, `visible`, `background`) and revisioned; the client never mixes bodies from one revision with summaries from another. -- Durable history and live replay must merge into a single canonical thread view. The existing ledger strategy survives, but it moves behind the Claude runtime adapter rather than defining the platform contract. +- One declarative registry owns labels, icons, settings visibility/defaults, runtime provider, and feature flags for `freshclaude`, `freshcodex`, `kilroy`, and disabled `freshopencode`. +- Runtime adapters own `create`, `resume`, `subscribe`, `send`, `interrupt`, `fork`, `answerQuestion`, `resolveApproval`, `listThreads`, `getSnapshot`, `getTurnPage`, `getTurnBody`, and capability-backed workspace actions. +- Claude runtime implementation stays behind the adapter boundary; the ledger/history strategy is preserved, not discarded. +- Codex runtime reuses `server/coding-cli/codex-app-server/*`; extend that stack instead of duplicating it. -### Cutover invariants +### Read model and UI -- By the end of the implementation, all rich-agent panes use the new `fresh-agent` runtime/read-model stack; the old `sdk.*` transport and `agentChatSlice` are removed or reduced to compatibility shims only where unavoidable inside the final architecture. -- Existing `freshclaude` sessions continue to appear in the sidebar as `sessionType: 'freshclaude'` and reopen into the shared rich pane. -- New `freshcodex` sessions persist as `sessionType: 'freshcodex'`, retain fork lineage/worktree metadata, and can be resumed from the sidebar/history surfaces. -- `freshopencode` support is represented in types/capabilities/registry design, but no user-visible OpenCode pane is shipped in this plan. +- All shared UI reads normalized fresh-agent data first and provider extensions second. +- Normalized entities have stable ids for thread, turn, item, approval, question, diff, artifact, child thread, and worktree references. +- Read-model routes stay revisioned and lane-aware and must never mix bodies from one revision with summaries from another. +- Existing Freshclaude UX stays intact unless the new shared shell makes it stronger. ## File Structure ### Create - `shared/fresh-agent.ts` - - Shared Zod schemas and TypeScript types for runtime capabilities, thread locators, thread snapshots, turn items, review/diff refs, approvals, questions, child threads, worktrees, and WS message payloads. -- `server/fresh-agent/provider-registry.ts` - - Server-side registry binding `sessionType` and runtime provider names to adapter factories and capability declarations. - `server/fresh-agent/runtime-adapter.ts` - - Core runtime adapter interfaces, operation/result types, and shared error taxonomy. +- `server/fresh-agent/provider-registry.ts` - `server/fresh-agent/runtime-manager.ts` - - Orchestrates active runtime sessions, subscriptions, replay, recovery, and thread locator resolution across adapters. - `server/fresh-agent/router.ts` - - HTTP read-model routes for thread snapshot/turn pages/turn bodies and capability-backed actions that belong on REST. -- `server/fresh-agent/ws.ts` - - Shared rich-agent WebSocket message handlers/events replacing the current `sdk.*` protocol. - `server/fresh-agent/adapters/claude/adapter.ts` - - Claude runtime adapter wrapping the existing SDK bridge and durable history ledger. - `server/fresh-agent/adapters/claude/normalize.ts` - - Claude event/history to normalized thread model mapping. - `server/fresh-agent/adapters/codex/adapter.ts` - - Codex runtime adapter built on the Codex app-server lifecycle, thread, fork, worktree, and review APIs. -- `server/fresh-agent/adapters/codex/client.ts` - - Codex app-server client, request marshalling, stream subscription, and error mapping. - `server/fresh-agent/adapters/codex/normalize.ts` - - Codex protocol/thread history to normalized thread model mapping. -- `server/fresh-agent/adapters/shared/workspace.ts` - - Shared workspace/repo helpers used by review, diff, and worktree capability actions across adapters. - `src/lib/fresh-agent-registry.ts` - - Client-side registry metadata for `freshclaude`, `freshcodex`, and future `freshopencode`. - `src/lib/fresh-agent-capabilities.ts` - - Selectors/helpers for capability-driven UI rendering. - `src/lib/fresh-agent-ws.ts` - - Client message handler for the new rich-agent WS protocol. - `src/store/freshAgentTypes.ts` - - Normalized client state types keyed by thread locator and revision. - `src/store/freshAgentSlice.ts` - - Redux slice for runtime session state, snapshot pages, streaming items, pending approvals/questions, and action errors. - `src/store/freshAgentThunks.ts` - - Async thunks for snapshot/page/body loading and capability-backed actions. - `src/components/fresh-agent/FreshAgentView.tsx` - - Shared top-level rich pane replacing `AgentChatView`. - `src/components/fresh-agent/FreshAgentTranscript.tsx` - - Virtualized turn/item list with lazy body hydration and mobile-friendly rendering. - `src/components/fresh-agent/FreshAgentComposer.tsx` - - Shared composer/action footer for send, interrupt, fork, and capability actions. - `src/components/fresh-agent/FreshAgentSidebar.tsx` - - Child-thread/worktree/review navigation inside the pane. - `src/components/fresh-agent/FreshAgentApprovalBanner.tsx` - - Shared approval UI. - `src/components/fresh-agent/FreshAgentQuestionBanner.tsx` - - Shared question UI. - `src/components/fresh-agent/FreshAgentDiffPanel.tsx` - - Shared diff/review presentation that supersedes the current Claude-only diff block. - `src/components/fresh-agent/renderers/*` - - Focused item renderers for text, reasoning, tool calls/results, diffs, approvals, and provider extension blocks. - `test/fixtures/fresh-agent/claude/*` - - Claude normalized fixture inputs/outputs for restore, approval, diff, and child-session flows. - `test/fixtures/fresh-agent/codex/*` - - Codex normalized fixture inputs/outputs for create, fork, worktree, review, and child-session flows. -- `test/unit/server/fresh-agent/*.test.ts` - - Adapter contract tests, runtime manager tests, router tests, and protocol tests. -- `test/unit/client/fresh-agent/*.test.tsx` - - Slice, thunk, renderer, and view tests. -- `test/e2e/fresh-agent-*.test.tsx` - - Shared rich-agent flows covering both `freshclaude` and `freshcodex`. ### Modify - `shared/ws-protocol.ts` - - Replace `sdk.*` rich-agent messages with `freshAgent.*` create/attach/subscribe/action events and update shared schemas. -- `server/ws-handler.ts` - - Route rich-agent WS traffic through `runtime-manager` instead of directly through `SdkBridge`. +- `shared/read-models.ts` +- `shared/settings.ts` +- `server/config-store.ts` - `server/index.ts` - - Mount fresh-agent router/services and inject them into bootstrap/server startup. -- `server/agent-timeline/*` - - Keep ledger/history logic, but narrow it to Claude adapter internals or move shared pieces behind adapter-agnostic names. +- `server/ws-handler.ts` - `server/sdk-bridge.ts` - - Retain only Claude SDK process concerns that remain under the Claude adapter; remove top-level platform assumptions. - `server/sdk-bridge-types.ts` - - Retire or rename Claude-specific transport types once their responsibilities move into `shared/fresh-agent.ts`. -- `server/sessions-router.ts` - - Teach resume/session metadata surfaces about `freshcodex` and rich-thread locators. -- `server/session-directory/*` - - Project normalized rich-thread metadata, fork lineage, child-thread hints, and capability badges into the sidebar/history directory. +- `server/agent-timeline/*` +- `server/coding-cli/codex-app-server/protocol.ts` +- `server/coding-cli/codex-app-server/client.ts` +- `server/coding-cli/codex-app-server/runtime.ts` +- `server/coding-cli/codex-app-server/launch-planner.ts` - `server/coding-cli/providers/codex.ts` - - Keep non-rich terminal session indexing only; remove pressure to serve as the future rich runtime API. -- `src/components/agent-chat/*` - - Reuse narrowly useful pieces or retire them in favor of `src/components/fresh-agent/*`. -- `src/store/agentChatSlice.ts` - - Remove rich-client ownership after migration or collapse it into a thin backward-compatibility layer before deletion. -- `src/store/agentChatThunks.ts` - - Same as above; move read-model loading to `freshAgentThunks`. -- `src/components/panes/PaneContainer.tsx` - - Swap rich-agent pane rendering and create/resume behavior to the shared `FreshAgentView`. +- `server/session-directory/*` +- `server/sessions-router.ts` +- `server/session-metadata-store.ts` - `src/store/paneTypes.ts` - - Add `freshcodex` and normalize rich pane type metadata for the new platform. +- `src/store/panesSlice.ts` +- `src/store/persistedState.ts` +- `src/store/persistMiddleware.ts` +- `src/store/store.ts` +- `src/store/persistControl.ts` - `src/store/tabsSlice.ts` - - Open/resume shared rich panes based on registry data and persisted thread locators. +- `src/store/settingsSlice.ts` +- `src/store/settingsThunks.ts` +- `src/store/browserPreferencesPersistence.ts` - `src/lib/session-type-utils.ts` - - Build resume content and labels for `freshcodex` using the shared registry. +- `src/lib/derivePaneTitle.ts` +- `src/lib/pane-activity.ts` +- `src/lib/session-utils.ts` +- `src/lib/tab-registry-snapshot.ts` +- `src/lib/ws-client.ts` - `src/lib/agent-chat-utils.ts` - - Remove or reduce to compatibility exports after the registry cutover. +- `src/lib/agent-chat-types.ts` +- `src/components/panes/PaneContainer.tsx` +- `src/components/panes/PanePicker.tsx` - `src/components/Sidebar.tsx` - - Continue to use `sessionType` but source provider metadata and resume actions from the new registry/capability model. +- `src/components/HistoryView.tsx` +- `src/components/context-menu/*` +- `src/components/icons/PaneIcon.tsx` +- `src/components/TabBar.tsx` +- `src/components/TabSwitcher.tsx` +- `src/components/MobileTabStrip.tsx` +- `src/components/settings/WorkspaceSettings.tsx` - `docs/index.html` - - Update the nonfunctional mock if the visible fresh-agent experience changes materially. + +### Delete Or Reduce To Shims + +- `src/store/agentChatSlice.ts` +- `src/store/agentChatThunks.ts` +- `src/components/agent-chat/*` +- `src/lib/sdk-message-handler.ts` +- any other `sdk.*` or `agent-chat` glue that no longer carries real behavior after cutover ## Strategy Gate -The direct path is to build the steady-state platform now, then migrate both rich clients onto it during the same implementation. Extending the current `sdk.*` contract into a pseudo-generic shape would preserve the wrong abstraction boundary: the current contract bakes Claude session semantics, Claude durable history assumptions, and message-array rendering into the platform. Codex app-server and future OpenCode server APIs are runtime products in their own right; treating them as terminal cousins would force permanent provider branches, duplicated UI, and another rewrite later. This plan therefore lands the final runtime-adapter architecture directly, preserves the proven Claude ledger strategy as an adapter implementation detail, and keeps `freshopencode` in scope only as a design constraint. +The right path is a direct cutover to the final architecture, not another “genericize `sdk.*` later” detour. The repo already contains the two foundations this work must respect: -### Task 1: Define the fresh-agent contract, registry, and capability model +- Claude has durable/live merge and restore semantics in `server/sdk-bridge.ts` and `server/agent-timeline/*`. +- Codex already has a shared app-server runtime/client/planner in `server/coding-cli/codex-app-server/*`. + +The plan therefore reuses both, renames the product domain to `fresh-agent`, migrates persistence/settings up front, and then lands one transport, one read model, and one UI shell. That is the cleanest route to the requested end state and the only route that avoids another rewrite when `freshopencode` arrives. + +### Task 1: Rename the domain to `fresh-agent` and migrate persisted settings/layouts **Files:** - Create: `shared/fresh-agent.ts` -- Create: `server/fresh-agent/runtime-adapter.ts` -- Create: `server/fresh-agent/provider-registry.ts` - Create: `src/lib/fresh-agent-registry.ts` -- Create: `src/lib/fresh-agent-capabilities.ts` -- Modify: `shared/ws-protocol.ts` +- Modify: `shared/settings.ts` +- Modify: `server/config-store.ts` +- Modify: `src/store/settingsSlice.ts` +- Modify: `src/store/settingsThunks.ts` +- Modify: `src/store/browserPreferencesPersistence.ts` - Modify: `src/store/paneTypes.ts` -- Test: `test/unit/server/fresh-agent/provider-registry.test.ts` -- Test: `test/unit/shared/fresh-agent-contract.test.ts` +- Modify: `src/store/panesSlice.ts` +- Modify: `src/store/persistedState.ts` +- Modify: `src/store/persistMiddleware.ts` +- Modify: `src/lib/agent-chat-utils.ts` +- Modify: `src/lib/agent-chat-types.ts` +- Test: `test/unit/shared/fresh-agent-registry.test.ts` +- Test: `test/unit/client/store/persisted-state.fresh-agent.test.ts` +- Test: `test/unit/server/config-store.fresh-agent-settings.test.ts` - [ ] **Step 1: Identify or write the failing tests** -Add contract tests that pin the final schema and registry semantics: +Add tests that pin the migration and registry rules: ```ts -it('declares freshclaude and freshcodex as rich session types with explicit runtime providers', () => { - expect(resolveFreshAgentType('freshclaude')).toMatchObject({ runtimeProvider: 'claude' }) - expect(resolveFreshAgentType('freshcodex')).toMatchObject({ runtimeProvider: 'codex' }) +it('migrates persisted agent-chat panes to fresh-agent panes', () => { + const parsed = parsePersistedPanesRaw(JSON.stringify({ + version: 6, + layouts: { + tab_1: { + type: 'leaf', + id: 'pane_1', + content: { kind: 'agent-chat', provider: 'freshclaude', createRequestId: 'req-1', status: 'idle' }, + }, + }, + })) + expect(findLeafContent(parsed!.layouts.tab_1)).toMatchObject({ kind: 'fresh-agent', sessionType: 'freshclaude' }) +}) + +it('migrates legacy settings.agentChat to settings.freshAgent', () => { + const settings = resolveServerSettings({ + agentChat: { defaultPlugins: ['/tmp/plugin'], providers: { freshclaude: { defaultModel: 'x' } } }, + } as any) + expect(settings.freshAgent.defaultPlugins).toEqual(['/tmp/plugin']) }) -it('exposes capability flags without collapsing provider-specific extensions', () => { - expect(FreshAgentThreadSnapshotSchema.parse(snapshotFixture).capabilities.review).toBe(true) - expect(snapshotFixture.providerExtensions?.codex).toBeDefined() +it('keeps kilroy as a hidden claude-backed fresh-agent type', () => { + expect(resolveFreshAgentType('kilroy')).toMatchObject({ runtimeProvider: 'claude', hidden: true }) }) ``` - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/server/fresh-agent/provider-registry.test.ts test/unit/shared/fresh-agent-contract.test.ts` -Expected: FAIL because the fresh-agent contract and registry do not exist yet. +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts` +Expected: FAIL because the registry and migrations do not exist yet. - [ ] **Step 3: Write the minimal implementation** -Implement the shared `fresh-agent` schema and runtime adapter interface. Define: +Implement the shared vocabulary and migrations: -- `FreshAgentSessionType = 'freshclaude' | 'freshcodex' | 'freshopencode'` +- `FreshAgentSessionType = 'freshclaude' | 'freshcodex' | 'kilroy' | 'freshopencode'` - `FreshAgentRuntimeProvider = 'claude' | 'codex' | 'opencode'` -- capability groups for transcript, approvals, questions, diffs/review, forking, worktrees, child threads, token budgets, and provider extension panels -- normalized thread locator, snapshot, turn page, turn body, item, approval, question, artifact, diff, child-thread, and worktree types -- registry entries for `freshclaude` and `freshcodex`, plus a disabled future-facing `freshopencode` config - -Update pane types and WS protocol stubs so the rest of the migration has a stable contract to target. +- registry entries for `freshclaude`, `freshcodex`, hidden `kilroy`, and disabled `freshopencode` +- persisted layout migration from `kind: 'agent-chat'` to `kind: 'fresh-agent'` +- server/local settings migration from `agentChat` to `freshAgent`, still accepting legacy input +- pane content shape that stores `sessionType` explicitly instead of overloading `provider` - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/server/fresh-agent/provider-registry.test.ts test/unit/shared/fresh-agent-contract.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** -Refactor naming to make the boundary crisp: +Refactor compatibility to one place only: -- shared/provider-agnostic types live in `shared/fresh-agent.ts` -- server lifecycle abstractions live in `runtime-adapter.ts` -- registry carries only declarative metadata, not runtime logic +- legacy `agent-chat` parsing belongs in persisted/settings migration code +- runtime code consumes only `fresh-agent` +- `agent-chat` helper modules become thin compatibility exports or are queued for removal -Run: `npm run test:vitest -- test/unit/server/fresh-agent/provider-registry.test.ts test/unit/shared/fresh-agent-contract.test.ts test/unit/server/ws-handler-sdk.test.ts` -Expected: PASS with no diluted assertions. +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/client/store/tabsSlice.merge.test.ts` +Expected: PASS - [ ] **Step 6: Commit** ```bash -git add shared/fresh-agent.ts server/fresh-agent/runtime-adapter.ts server/fresh-agent/provider-registry.ts src/lib/fresh-agent-registry.ts src/lib/fresh-agent-capabilities.ts shared/ws-protocol.ts src/store/paneTypes.ts test/unit/server/fresh-agent/provider-registry.test.ts test/unit/shared/fresh-agent-contract.test.ts -git commit -m "feat: define fresh agent platform contracts" +git add shared/fresh-agent.ts src/lib/fresh-agent-registry.ts shared/settings.ts server/config-store.ts src/store/settingsSlice.ts src/store/settingsThunks.ts src/store/browserPreferencesPersistence.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/lib/agent-chat-utils.ts src/lib/agent-chat-types.ts test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts +git commit -m "refactor: rename rich pane domain to fresh agent" ``` -### Task 2: Build the runtime manager and replace the top-level rich-agent transport +### Task 2: Build the shared fresh-agent transport and normalized read-model contract **Files:** +- Create: `server/fresh-agent/runtime-adapter.ts` +- Create: `server/fresh-agent/provider-registry.ts` - Create: `server/fresh-agent/runtime-manager.ts` -- Create: `server/fresh-agent/ws.ts` +- Create: `server/fresh-agent/router.ts` +- Modify: `shared/ws-protocol.ts` +- Modify: `shared/read-models.ts` - Modify: `server/ws-handler.ts` - Modify: `server/index.ts` -- Modify: `shared/ws-protocol.ts` - Test: `test/unit/server/fresh-agent/runtime-manager.test.ts` -- Test: `test/unit/server/fresh-agent/ws.test.ts` +- Test: `test/unit/server/fresh-agent/router.test.ts` +- Test: `test/unit/server/ws-handler-fresh-agent.test.ts` - [ ] **Step 1: Identify or write the failing tests** -Write tests that prove the server now routes rich-agent traffic through the new manager: +Write server tests that prove the final contract: ```ts -it('routes freshAgent.create to the adapter selected by sessionType', async () => { - expect(adapter.createThread).toHaveBeenCalledWith(expect.objectContaining({ sessionType: 'freshcodex' })) +it('routes freshAgent.create through the adapter selected by sessionType', async () => { + expect(adapter.create).toHaveBeenCalledWith(expect.objectContaining({ sessionType: 'freshcodex' })) }) -it('does not emit subscribed thread state when create cutover fails transactionally', async () => { - expect(messages).toContainEqual(expect.objectContaining({ type: 'freshAgent.create.failed' })) - expect(messages).not.toContainEqual(expect.objectContaining({ type: 'freshAgent.created' })) +it('returns 409 for stale thread revisions instead of mixing bodies from different revisions', async () => { + const response = await request(app).get('/api/fresh-agent/threads/codex/thread-1/turns/turn-9?revision=4') + expect(response.status).toBe(409) + expect(response.body.code).toBe('STALE_THREAD_REVISION') }) ``` - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/ws.test.ts` -Expected: FAIL because the manager and protocol handler do not exist. +Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts` +Expected: FAIL because the fresh-agent transport does not exist. - [ ] **Step 3: Write the minimal implementation** -Implement `runtime-manager` with: +Implement: -- adapter lookup from the provider registry -- create/attach/subscribe/send/interrupt/fork/action dispatch -- replay buffering and subscription sequencing analogous to the current `SdkBridge` guarantees -- a shared error taxonomy that maps provider failures to user-friendly transport errors +- provider registry lookup from `sessionType` +- runtime manager operations: `create`, `resume`, `subscribe`, `send`, `interrupt`, `fork`, `answerQuestion`, `resolveApproval` +- normalized snapshot/page/body read-model types +- `freshAgent.*` WS messages and `/api/fresh-agent/threads/...` routes +- explicit error taxonomy for runtime unavailable, stale revision, unsupported capability, and lost session -Update `server/ws-handler.ts` to hand rich-agent messages to `server/fresh-agent/ws.ts`. Keep terminal paths untouched. +Keep terminal WebSocket behavior untouched. - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/ws.test.ts` +Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** -Refactor for one authoritative transport path. Remove any duplicated `sdk.*` routing code that would leave parallel rich-client stacks alive. +Remove fake-generic `sdk.*` transport assumptions from shared code. Keep only Claude-specific implementation details under the Claude adapter boundary. -Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/ws.test.ts test/unit/server/ws-handler-sdk.test.ts` -Expected: PASS, with updated tests asserting `freshAgent.*` behavior instead of legacy `sdk.*` behavior where appropriate. +Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts test/unit/visible-first/read-model-route-harness.test.ts` +Expected: PASS - [ ] **Step 6: Commit** ```bash -git add server/fresh-agent/runtime-manager.ts server/fresh-agent/ws.ts server/ws-handler.ts server/index.ts shared/ws-protocol.ts test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/ws.test.ts -git commit -m "feat: route rich agent sessions through runtime manager" +git add server/fresh-agent/runtime-adapter.ts server/fresh-agent/provider-registry.ts server/fresh-agent/runtime-manager.ts server/fresh-agent/router.ts shared/ws-protocol.ts shared/read-models.ts server/ws-handler.ts server/index.ts test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts +git commit -m "feat: add fresh agent transport and read models" ``` -### Task 3: Migrate Claude runtime logic behind a Claude adapter and normalized read model +### Task 3: Move Claude runtime behavior behind the Claude fresh-agent adapter **Files:** - Create: `server/fresh-agent/adapters/claude/adapter.ts` @@ -304,35 +323,38 @@ git commit -m "feat: route rich agent sessions through runtime manager" - [ ] **Step 1: Identify or write the failing tests** -Add adapter contract tests for the tricky Claude behaviors we cannot afford to regress: - -- durable/live merge yields one canonical turn sequence -- attach/reconnect never fabricates a live-only snapshot when durable restore state is required -- approvals/questions/tokens normalize into shared item/state shapes -- session loss triggers recoverable runtime errors, not transcript corruption +Cover the Freshclaude behaviors that must survive: ```ts -it('maps ledger-backed restore state into a normalized thread snapshot', async () => { - expect(snapshot.turns[0]?.items[0]?.kind).toBe('message') - expect(snapshot.revision).toBeGreaterThan(0) +it('merges ledger-backed restore state and live stream into one canonical snapshot', async () => { + expect(snapshot.turns.map((turn) => turn.source)).toEqual(['durable', 'live']) +}) + +it('preserves plugin defaults and mid-session model/permission changes through the claude adapter', async () => { + expect(adapter.updateSessionSettings).toHaveBeenCalledWith(expect.objectContaining({ + defaultPlugins: ['/tmp/plugin'], + model: 'claude-sonnet-4-20250514', + permissionMode: 'plan', + })) }) ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts` -Expected: FAIL because the Claude adapter and normalized output do not exist. +Expected: FAIL because the adapter does not exist. - [ ] **Step 3: Write the minimal implementation** -Wrap the existing `SdkBridge` and ledger/history machinery inside the Claude adapter. The adapter should: +Wrap the existing Claude stack behind the adapter: -- keep the current replay and restore guarantees -- normalize Claude message blocks into shared turn items -- project approvals/questions/tool blocks/token summaries into capability-backed thread state -- treat ledger internals as Claude implementation details, not platform types +- preserve durable/live restore semantics +- preserve question/permission flows +- preserve model and permission-mode updates +- preserve plugin injection and token summaries +- normalize Claude block messages into shared fresh-agent items -Keep user-visible behavior equivalent for `freshclaude`, except where the new normalized model enables the shared shell. +Do not let Claude-specific types leak back into shared contracts. - [ ] **Step 4: Run tests to verify they pass** @@ -341,215 +363,214 @@ Expected: PASS - [ ] **Step 5: Refactor and verify** -Delete or rename any leftover top-level Claude-specific abstractions that still pretend to be provider-generic. Keep shared reusable pieces only where the abstraction is real. +Move only real Claude implementation details under `server/fresh-agent/adapters/claude/*`; delete any top-level abstractions that only existed to make Claude look generic. -Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/ws-sdk-session-history-cache.test.ts test/unit/server/ws-handler-sdk.test.ts` -Expected: PASS after migrating those tests to the new adapter/transport model. +Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/ws-sdk-session-history-cache.test.ts test/unit/server/sdk-bridge.test.ts` +Expected: PASS - [ ] **Step 6: Commit** ```bash git add server/fresh-agent/adapters/claude/adapter.ts server/fresh-agent/adapters/claude/normalize.ts server/sdk-bridge.ts server/sdk-bridge-types.ts server/agent-timeline/ledger.ts server/agent-timeline/history-source.ts server/agent-timeline/service.ts server/agent-timeline/router.ts test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts -git commit -m "refactor: move claude rich runtime behind adapter" +git commit -m "refactor: move claude runtime behind fresh agent adapter" ``` -### Task 4: Implement the Codex adapter on the Codex app-server contract +### Task 4: Extend the existing Codex app-server stack for rich Freshcodex sessions **Files:** -- Create: `server/fresh-agent/adapters/codex/client.ts` - Create: `server/fresh-agent/adapters/codex/adapter.ts` - Create: `server/fresh-agent/adapters/codex/normalize.ts` -- Modify: `server/fresh-agent/provider-registry.ts` +- Modify: `server/coding-cli/codex-app-server/protocol.ts` +- Modify: `server/coding-cli/codex-app-server/client.ts` +- Modify: `server/coding-cli/codex-app-server/runtime.ts` +- Modify: `server/coding-cli/codex-app-server/launch-planner.ts` - Modify: `server/coding-cli/providers/codex.ts` -- Test: `test/unit/server/fresh-agent/codex-client.test.ts` +- Test: `test/unit/server/coding-cli/codex-app-server/client.test.ts` +- Test: `test/unit/server/coding-cli/codex-app-server/runtime.test.ts` - Test: `test/unit/server/fresh-agent/codex-adapter.test.ts` - Test: `test/unit/server/fresh-agent/codex-normalize.test.ts` - [ ] **Step 1: Identify or write the failing tests** -Write contract tests against recorded Codex fixtures that pin: - -- thread create/resume -- fork lineage -- child thread/subagent refs -- review/diff/worktree projections -- token/context summaries -- approval/question style interactions if the app-server exposes them +Pin the rich Codex requirements without creating a duplicate client: ```ts -it('normalizes codex review and fork metadata into shared snapshot structures', async () => { - expect(snapshot.capabilities.review).toBe(true) - expect(snapshot.diffRefs[0]?.baseThreadId).toBeDefined() +it('starts fresh rich codex threads with raw events enabled', async () => { + await runtime.planCreate({ cwd: '/repo', richClient: true }) + expect(requestParams.experimentalRawEvents).toBe(true) +}) + +it('normalizes codex fork, review, worktree, and child-thread metadata into the shared snapshot', async () => { + expect(snapshot.capabilities.fork).toBe(true) + expect(snapshot.worktrees[0]?.path).toContain('.worktrees') expect(snapshot.childThreads[0]?.origin).toBe('subagent') }) ``` - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/server/fresh-agent/codex-client.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts` -Expected: FAIL because no Codex runtime adapter exists. +Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts` +Expected: FAIL because the existing app-server layer does not yet expose rich-session events/capabilities. - [ ] **Step 3: Write the minimal implementation** -Implement a Codex app-server client and adapter that: - -- uses app-server lifecycle/thread APIs as the source of truth -- streams updates into runtime-manager subscriptions -- normalizes Codex thread items, reviews, diffs, worktrees, and subagent metadata into the shared model -- maps provider-native details into `providerExtensions.codex` +Extend the current Codex app-server stack instead of copying it: -Keep `server/coding-cli/providers/codex.ts` focused on terminal session indexing, not rich runtime behavior. +- add protocol/client support for the notifications and RPCs needed by rich Freshcodex +- keep terminal-mode behavior intact +- allow rich-pane creation/resume to request raw events/replay where required +- normalize review, diff, fork lineage, worktree, token/context, and child-thread metadata into the shared model +- keep `server/coding-cli/providers/codex.ts` focused on indexing/terminal concerns - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/server/fresh-agent/codex-client.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts` +Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** -Refactor shared workspace/review helpers so Codex and Claude use the same final abstractions where the behavior is actually shared. Do not add fake generic wrappers around provider-native protocol payloads. +Make the adapter thin and keep the protocol source of truth in `server/coding-cli/codex-app-server/*`. -Run: `npm run test:vitest -- test/unit/server/fresh-agent/codex-client.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts test/unit/server/fresh-agent/claude-adapter.test.ts` +Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts test/unit/server/coding-cli/codex-provider.test.ts` Expected: PASS - [ ] **Step 6: Commit** ```bash -git add server/fresh-agent/adapters/codex/client.ts server/fresh-agent/adapters/codex/adapter.ts server/fresh-agent/adapters/codex/normalize.ts server/fresh-agent/provider-registry.ts server/coding-cli/providers/codex.ts test/unit/server/fresh-agent/codex-client.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts -git commit -m "feat: add codex fresh agent adapter" +git add server/fresh-agent/adapters/codex/adapter.ts server/fresh-agent/adapters/codex/normalize.ts server/coding-cli/codex-app-server/protocol.ts server/coding-cli/codex-app-server/client.ts server/coding-cli/codex-app-server/runtime.ts server/coding-cli/codex-app-server/launch-planner.ts server/coding-cli/providers/codex.ts test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts +git commit -m "feat: add codex rich runtime support" ``` -### Task 5: Replace the rich read-model routes with thread snapshot/page/body endpoints +### Task 5: Integrate fresh-agent sessions into session directory, metadata, and resume flows **Files:** -- Create: `server/fresh-agent/router.ts` - Modify: `server/session-directory/projection.ts` - Modify: `server/session-directory/service.ts` - Modify: `server/session-directory/types.ts` - Modify: `server/sessions-router.ts` -- Modify: `shared/read-models.ts` -- Test: `test/unit/server/fresh-agent/router.test.ts` +- Modify: `server/session-metadata-store.ts` +- Modify: `src/lib/api.ts` - Test: `test/unit/server/session-directory/fresh-agent-projection.test.ts` +- Test: `test/integration/server/session-metadata-api.test.ts` +- Test: `test/unit/client/lib/api.test.ts` - [ ] **Step 1: Identify or write the failing tests** -Add tests covering the final read-model contract: - -- thread snapshot endpoint returns revisioned capability-aware snapshots -- turn page/body endpoints respect lane/revision constraints -- session directory includes `freshcodex` with stable title/sessionType/fork/subagent metadata -- router errors are explicit when a runtime is unavailable or a revision is stale +Cover the resume/sidebar contract: ```ts -it('returns a stale-revision error instead of mixing thread revisions', async () => { - expect(response.status).toBe(409) - expect(response.body.code).toBe('STALE_THREAD_REVISION') +it('keeps derivedTitle when sessionType is updated to freshcodex', async () => { + await store.set('codex', 'sess-1', { derivedTitle: 'Sticky title' }) + await request(app).post('/api/session-metadata').send({ provider: 'codex', sessionId: 'sess-1', sessionType: 'freshcodex' }) + expect(await store.get('codex', 'sess-1')).toEqual({ derivedTitle: 'Sticky title', sessionType: 'freshcodex' }) +}) + +it('projects freshcodex sessions into the session directory with fork/worktree badges', async () => { + expect(page.items[0]).toMatchObject({ sessionType: 'freshcodex', provider: 'codex' }) }) ``` - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/server/fresh-agent/router.test.ts test/unit/server/session-directory/fresh-agent-projection.test.ts` -Expected: FAIL because the new router and projection do not exist. +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/client/lib/api.test.ts` +Expected: FAIL because fresh-agent metadata is not fully projected yet. - [ ] **Step 3: Write the minimal implementation** -Implement REST routes for: +Implement projection and metadata updates so that: -- `GET /api/fresh-agent/threads/:provider/:threadId` -- `GET /api/fresh-agent/threads/:provider/:threadId/turns` -- `GET /api/fresh-agent/threads/:provider/:threadId/turns/:turnId` -- action endpoints that belong on HTTP rather than WS when idempotent or lane-aware - -Update directory projection to carry the rich metadata needed by the sidebar/history surfaces for both `freshclaude` and `freshcodex`. +- sidebar/history pages show `freshclaude`, `freshcodex`, and `kilroy` correctly +- resume actions rebuild `kind: 'fresh-agent'` panes +- `sessionType` updates do not clobber `derivedTitle` +- session directory carries the metadata needed for fork/worktree/subagent badges - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/server/fresh-agent/router.test.ts test/unit/server/session-directory/fresh-agent-projection.test.ts` +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/client/lib/api.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** -Remove obsolete `agent-timeline` route entry points once their responsibilities are fully subsumed by the new thread routes. +Keep `provider` and `sessionType` semantics separate everywhere. Do not smuggle UI identity back into filesystem/provider fields. -Run: `npm run test:vitest -- test/unit/server/fresh-agent/router.test.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/visible-first/read-model-route-harness.test.ts` +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/session-directory/service.test.ts` Expected: PASS - [ ] **Step 6: Commit** ```bash -git add server/fresh-agent/router.ts server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/sessions-router.ts shared/read-models.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/session-directory/fresh-agent-projection.test.ts -git commit -m "feat: add fresh agent thread read models" +git add server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/sessions-router.ts server/session-metadata-store.ts src/lib/api.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/client/lib/api.test.ts +git commit -m "feat: project fresh agent sessions through sidebar metadata" ``` -### Task 6: Replace client rich-agent state with normalized fresh-agent Redux state +### Task 6: Replace client state and WS handling with the fresh-agent store **Files:** +- Create: `src/lib/fresh-agent-ws.ts` - Create: `src/store/freshAgentTypes.ts` - Create: `src/store/freshAgentSlice.ts` - Create: `src/store/freshAgentThunks.ts` -- Create: `src/lib/fresh-agent-ws.ts` -- Modify: `src/lib/ws-client.ts` -- Modify: `src/lib/sdk-message-handler.ts` -- Modify: `src/store/index.ts` +- Modify: `src/store/store.ts` - Modify: `src/store/persistControl.ts` - Modify: `src/store/tabsSlice.ts` +- Modify: `src/lib/ws-client.ts` +- Modify: `src/lib/session-type-utils.ts` - Test: `test/unit/client/store/freshAgentSlice.test.ts` - Test: `test/unit/client/store/freshAgentThunks.test.ts` - Test: `test/unit/client/lib/fresh-agent-ws.test.ts` +- Test: `test/unit/client/store/persistControl.fresh-agent.test.ts` - [ ] **Step 1: Identify or write the failing tests** -Add tests for the final client state semantics: - -- snapshot/page/body hydration is revision-safe -- live items merge into normalized turns without duplicate transcript blocks -- approvals/questions/action errors are stored in shared state, not provider slices -- `freshcodex` and `freshclaude` pending creates both resolve through the same path +Write client tests for the final state model: ```ts -it('stores pending approvals by thread locator independent of provider-specific payload shape', () => { - expect(state.threads[key].pendingApprovals['req-1']).toMatchObject({ title: 'Run command?' }) +it('stores threads by locator and revision without duplicating live and durable items', () => { + expect(state.threads['claude:thread-1'].turnOrder).toEqual(['turn-1', 'turn-2']) +}) + +it('persists sessionType and provider separately for resume identity', () => { + expect(update.tabUpdates?.sessionMetadataByKey?.['codex:sess-1']).toMatchObject({ sessionType: 'freshcodex' }) }) ``` - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts` -Expected: FAIL because the new state layer does not exist. +Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts` +Expected: FAIL because the fresh-agent state layer does not exist. - [ ] **Step 3: Write the minimal implementation** -Implement normalized client state keyed by thread locator. Replace the `sdk-message-handler` flow with `fresh-agent-ws` handling and move all read-model fetching into `freshAgentThunks`. - -Persist only what is necessary for reconnect/resume: +Implement the normalized client layer: -- thread locator -- session type/runtime provider -- active revision cursor anchors where appropriate +- thread state keyed by runtime locator +- revision-safe snapshot/page/body hydration +- WS handling for `freshAgent.*` +- pending approvals/questions/action errors in shared state +- resume identity helpers that work for Claude and Codex without Claude-only assumptions -Do not persist transient streaming or pending-action state. +Do not persist transient streaming/pending action state. - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts` +Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** -Remove provider-specific state duplication from `agentChatSlice` and thunks once the shared slice owns the rich client. +Convert shared selectors/helpers to consume `freshAgent` state, not `agentChat`. -Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/sdk-message-handler.test.ts` -Expected: PASS after converting legacy tests to the new path or deleting them when the old handler is gone. +Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts test/unit/client/components/App.ws-bootstrap.test.tsx` +Expected: PASS - [ ] **Step 6: Commit** ```bash -git add src/store/freshAgentTypes.ts src/store/freshAgentSlice.ts src/store/freshAgentThunks.ts src/lib/fresh-agent-ws.ts src/lib/ws-client.ts src/lib/sdk-message-handler.ts src/store/index.ts src/store/persistControl.ts src/store/tabsSlice.ts test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts -git commit -m "feat: add normalized fresh agent client state" +git add src/lib/fresh-agent-ws.ts src/store/freshAgentTypes.ts src/store/freshAgentSlice.ts src/store/freshAgentThunks.ts src/store/store.ts src/store/persistControl.ts src/store/tabsSlice.ts src/lib/ws-client.ts src/lib/session-type-utils.ts test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts +git commit -m "feat: add fresh agent client state" ``` -### Task 7: Build the shared fresh-agent UI shell and migrate freshclaude/freshcodex panes +### Task 7: Ship the shared fresh-agent UI shell and preserve current Freshclaude behavior **Files:** - Create: `src/components/fresh-agent/FreshAgentView.tsx` @@ -559,186 +580,155 @@ git commit -m "feat: add normalized fresh agent client state" - Create: `src/components/fresh-agent/FreshAgentApprovalBanner.tsx` - Create: `src/components/fresh-agent/FreshAgentQuestionBanner.tsx` - Create: `src/components/fresh-agent/FreshAgentDiffPanel.tsx` -- Create: `src/components/fresh-agent/renderers/*.tsx` +- Create: `src/components/fresh-agent/renderers/*` - Modify: `src/components/panes/PaneContainer.tsx` +- Modify: `src/components/panes/PanePicker.tsx` - Modify: `src/components/Sidebar.tsx` -- Modify: `src/lib/session-type-utils.ts` -- Modify: `src/lib/agent-chat-utils.ts` +- Modify: `src/components/HistoryView.tsx` +- Modify: `src/components/context-menu/*` - Modify: `src/components/icons/PaneIcon.tsx` +- Modify: `src/components/TabBar.tsx` +- Modify: `src/components/TabSwitcher.tsx` +- Modify: `src/components/MobileTabStrip.tsx` +- Modify: `src/components/settings/WorkspaceSettings.tsx` +- Modify: `src/lib/derivePaneTitle.ts` +- Modify: `src/lib/pane-activity.ts` +- Modify: `docs/index.html` - Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` - Test: `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx` - Test: `test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` +- Test: `test/unit/client/components/panes/PaneContainer.test.tsx` - [ ] **Step 1: Identify or write the failing tests** -Add component tests that pin the final shared behavior: - -- `freshclaude` and `freshcodex` render the same shell chrome -- transcript virtualization only hydrates visible turn bodies -- capability flags hide unsupported actions cleanly -- diff/review panel renders from normalized refs, not Claude-only tool blocks -- mobile layout moves secondary panes into drawers/sheets instead of crushing the transcript +Pin the user-visible shell: ```tsx -it('renders fork and worktree actions for freshcodex but not freshclaude when capabilities differ', () => { - render() - expect(screen.getByRole('button', { name: /fork/i })).toBeInTheDocument() +it('renders the same shell for freshclaude and freshcodex while honoring capability differences', () => { + render() + expect(screen.getByRole('button', { name: /fork/i })).toBeVisible() + render() + expect(screen.queryByRole('button', { name: /fork/i })).toBeNull() +}) + +it('preserves existing freshclaude settings, plugin controls, and question banners after migration', () => { + expect(screen.getByRole('button', { name: /send/i })).toBeEnabled() + expect(screen.getByRole('alert')).toHaveTextContent('Question') }) ``` - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` -Expected: FAIL because the new shared UI does not exist. +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx` +Expected: FAIL because the shared UI shell does not exist. - [ ] **Step 3: Write the minimal implementation** Build the shared shell with: -- a virtualized transcript backed by normalized turn summaries/bodies -- reusable approval/question banners -- capability-driven action/footer rendering -- a diff/review panel that can be opened from either provider -- responsive layout for narrow/mobile widths with deferred hydration for heavy transcript sections +- virtualized transcript backed by normalized turns/items +- approval and question banners reused across providers +- shared composer supporting send, interrupt, fork, and capability-backed actions +- mobile drawers/sheets for secondary panes +- preserved Freshclaude features: plugin defaults, settings popover, input history, restore hydration, session-lost recovery, timecodes, show thinking/tools toggles -Switch `PaneContainer` and sidebar resume actions to the shared rich pane. `freshcodex` should appear anywhere `freshclaude` appears today. +Switch pane picker, pane container, sidebar, history, and context menus to `kind: 'fresh-agent'`. - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx` Expected: PASS - [ ] **Step 5: Refactor and verify** -Delete or fold old `src/components/agent-chat/*` pieces that are fully replaced. Keep only narrowly reusable presentational helpers if they still make sense under the new names. +Fold or delete old `src/components/agent-chat/*` pieces once stronger fresh-agent coverage exists. -Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx` -Expected: PASS after migrating or removing legacy tests with stronger coverage on the new shell. +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/Sidebar.render-stability.test.tsx` +Expected: PASS - [ ] **Step 6: Commit** ```bash -git add src/components/fresh-agent src/components/panes/PaneContainer.tsx src/components/Sidebar.tsx src/lib/session-type-utils.ts src/lib/agent-chat-utils.ts src/components/icons/PaneIcon.tsx test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx +git add src/components/fresh-agent src/components/panes/PaneContainer.tsx src/components/panes/PanePicker.tsx src/components/Sidebar.tsx src/components/HistoryView.tsx src/components/context-menu src/components/icons/PaneIcon.tsx src/components/TabBar.tsx src/components/TabSwitcher.tsx src/components/MobileTabStrip.tsx src/components/settings/WorkspaceSettings.tsx src/lib/derivePaneTitle.ts src/lib/pane-activity.ts docs/index.html test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx git commit -m "feat: ship shared fresh agent pane shell" ``` -### Task 8: Wire end-to-end create/resume/fork/review flows and update docs/mocks +### Task 8: Add browser e2e coverage, remove obsolete code, and run the full verification gate **Files:** -- Modify: `src/store/tabsSlice.ts` -- Modify: `src/components/context-menu/ContextMenuProvider.tsx` -- Modify: `src/components/HistoryView.tsx` -- Modify: `src/store/selectors/sidebarSelectors.ts` -- Modify: `server/sessions-router.ts` -- Modify: `docs/index.html` -- Test: `test/e2e/fresh-agent-create-resume.test.tsx` -- Test: `test/e2e/fresh-agent-review-flow.test.tsx` -- Test: `test/e2e/fresh-agent-mobile-layout.test.tsx` +- Modify/Delete: `src/store/agentChatSlice.ts` +- Modify/Delete: `src/store/agentChatThunks.ts` +- Modify/Delete: `src/components/agent-chat/*` +- Modify/Delete: `src/lib/sdk-message-handler.ts` +- Create: `test/e2e-browser/specs/fresh-agent.spec.ts` +- Create: `test/e2e-browser/specs/fresh-agent-mobile.spec.ts` +- Modify: existing Playwright helpers only if needed +- Test: all targeted unit/integration/e2e suites below - [ ] **Step 1: Identify or write the failing tests** -Add e2e coverage for the user-visible flows that justify the architecture: - -- create/resume `freshclaude` -- create/resume `freshcodex` -- fork a `freshcodex` thread and reopen it from the sidebar -- open a review/diff panel from both providers when supported -- mobile transcript remains usable while approvals/questions/diff panes are accessible +Add the browser-level proof of the requested outcome: ```ts -it('reopens a freshcodex fork from the sidebar with its lineage and worktree metadata intact', async () => { - // assert sidebar entry, pane title, fork badge, and worktree action visibility +test('creates and resumes freshcodex with fork lineage and worktree metadata intact', async ({ page }) => { + await expect(page.getByRole('button', { name: /freshcodex/i })).toBeVisible() + await expect(page.getByText(/worktree/i)).toBeVisible() }) -``` -- [ ] **Step 2: Run tests to verify they fail** - -Run: `npm run test:vitest -- test/e2e/fresh-agent-create-resume.test.tsx test/e2e/fresh-agent-review-flow.test.tsx test/e2e/fresh-agent-mobile-layout.test.tsx` -Expected: FAIL because the end-to-end rich-agent cutover is incomplete. - -- [ ] **Step 3: Write the minimal implementation** - -Finish the wiring across sidebar/history/context-menu resume entry points, ensure metadata persistence stores `sessionType: 'freshcodex'`, and update `docs/index.html` to reflect the new shared shell where it materially differs from the previous Freshclaude-only experience. - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `npm run test:vitest -- test/e2e/fresh-agent-create-resume.test.tsx test/e2e/fresh-agent-review-flow.test.tsx test/e2e/fresh-agent-mobile-layout.test.tsx` -Expected: PASS - -- [ ] **Step 5: Refactor and verify** - -Tighten naming and remove any leftover "agent chat" or Claude-only assumptions from user-facing resume/create paths. The shipped UI should read as one fresh-agent platform with two concrete clients. - -Run: `npm run test:vitest -- test/e2e/fresh-agent-create-resume.test.tsx test/e2e/fresh-agent-review-flow.test.tsx test/e2e/fresh-agent-mobile-layout.test.tsx test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/store/tabsSlice.ts src/components/context-menu/ContextMenuProvider.tsx src/components/HistoryView.tsx src/store/selectors/sidebarSelectors.ts server/sessions-router.ts docs/index.html test/e2e/fresh-agent-create-resume.test.tsx test/e2e/fresh-agent-review-flow.test.tsx test/e2e/fresh-agent-mobile-layout.test.tsx -git commit -m "feat: complete fresh agent create resume and review flows" +test('freshclaude still restores durable history and surfaces approvals/questions', async ({ page }) => { + await expect(page.getByRole('alert')).toBeVisible() +}) ``` -### Task 9: Remove obsolete rich-agent paths and run the full verification gate - -**Files:** -- Modify/Delete: `src/store/agentChatSlice.ts` -- Modify/Delete: `src/store/agentChatThunks.ts` -- Modify/Delete: `src/components/agent-chat/*` -- Modify/Delete: `server/sdk-bridge-types.ts` -- Modify/Delete: any other `sdk.*` rich-agent-only glue made obsolete by the final cutover -- Test: existing repo suites plus all new fresh-agent tests - -- [ ] **Step 1: Identify or write the failing tests** - -Use the existing repo checks as the red bar for dead-code removal. If any legacy tests only assert the old architecture, replace them with stronger fresh-agent coverage before deleting them. - -- [ ] **Step 2: Run tests to verify current failures or stale references** +- [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/client test/unit/server` -Expected: FAIL somewhere due to stale imports, dead files, or old `sdk.*` references after the cutover. +Run: `npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts` +Expected: FAIL because the browser flows are not fully wired yet. - [ ] **Step 3: Write the minimal implementation** -Delete obsolete files and imports, collapse compatibility shims that no longer carry real weight, and ensure the codebase has one rich-agent architecture instead of two. +Delete obsolete `agent-chat` and legacy `sdk.*` client glue only after the new browser flows pass. Keep only compatibility code required for persisted migration or legacy setting reads. - [ ] **Step 4: Run tests to verify targeted suites pass** -Run: `npm run test:vitest -- test/unit/client test/unit/server` +Run: `npm run test:vitest -- test/unit/server/fresh-agent test/unit/client/components/fresh-agent test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts` +Run: `npm run test:vitest -- test/integration/server/session-metadata-api.test.ts` +Run: `npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** -Run the full repository verification expected before finishing: +Run the repo verification expected before handing off: Run: `npm run lint` Run: `npm run test:status` -Run: `FRESHELL_TEST_SUMMARY=\"fresh agent platform\" npm test` +Run: `FRESHELL_TEST_SUMMARY="fresh agent platform" npm test` Run: `npm run check` Expected: all PASS -If any existing test fails, fix the defect rather than weakening the check. +If a valid check fails, continue fixing the code. Do not weaken or delete good tests. - [ ] **Step 6: Commit** ```bash git add -A -git commit -m "refactor: remove legacy rich agent architecture" +git commit -m "refactor: remove legacy agent chat architecture" ``` ## OpenCode design constraint for later work -Do not implement `freshopencode` in this change. Do ensure the platform can support it without another contract reset: +Do not ship `freshopencode` here. Do ensure the final architecture already supports it: -- registry entry can exist as disabled/future-facing metadata -- normalized model already includes diff/review/worktree/child-thread concepts OpenCode will need -- runtime adapter interface permits server-driven event streams and explicit permission/command flows -- no client code assumes every provider has Claude-style block messages or Codex-style review objects +- disabled registry entry may exist now +- adapter interface already supports explicit permission/command flows and server-driven event streams +- normalized model already has diff, review, worktree, child-thread, and artifact concepts +- no client code assumes Claude block messages or Codex review objects are universal ## Implementation notes for the executing agent -- Work directly in this worktree: `/home/user/code/freshell/.worktrees/fresh-agent-platform` -- Keep commits aligned to the tasks above; do not batch multiple tasks into one commit. -- Preserve unrelated changes if present. -- Do not add hidden terminal fallbacks. Show explicit rich-client errors when an adapter cannot operate. -- Prefer renaming/removing obsolete abstractions once stronger coverage exists rather than leaving long-lived compatibility layers. +- Work only in `/home/user/code/freshell/.worktrees/fresh-agent-platform`. +- Reuse the existing Codex app-server runtime/client/planner instead of creating a parallel stack. +- Preserve current Freshclaude behavior while renaming the architecture underneath it. +- Preserve `kilroy` as a hidden Claude-backed fresh-agent type. +- Use Playwright for browser/mobile e2e, not vitest-only pseudo-e2e files. +- Broad runs go through the test coordinator. Check `npm run test:status` before them. From f0f217ed0260293562640996778d987ce476e430 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 10:43:44 -0700 Subject: [PATCH 03/86] docs: tighten fresh agent implementation plan --- docs/plans/2026-04-18-fresh-agent-platform.md | 179 +++++++++++------- 1 file changed, 114 insertions(+), 65 deletions(-) diff --git a/docs/plans/2026-04-18-fresh-agent-platform.md b/docs/plans/2026-04-18-fresh-agent-platform.md index fcae95ce0..a15e0ede0 100644 --- a/docs/plans/2026-04-18-fresh-agent-platform.md +++ b/docs/plans/2026-04-18-fresh-agent-platform.md @@ -2,9 +2,9 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Ship a shared `fresh-agent` platform that powers `freshclaude` and `freshcodex` from one architecture, preserves current Freshclaude behavior, and leaves a clean adapter seam for `freshopencode` later. +**Goal:** Ship a shared `fresh-agent` platform that powers `freshclaude` and `freshcodex` from one architecture, preserves existing Freshclaude behavior and saved state, and leaves a clean adapter seam for `freshopencode` later without another rewrite. -**Architecture:** Rename the rich-pane domain from `agent-chat` to `fresh-agent`, migrate persisted state/settings to that vocabulary, and drive all rich panes from a provider registry plus runtime adapters. Reuse the existing Claude ledger/history stack and the existing Codex app-server foundation, extend them where needed, and normalize both into one lane-aware read model and one shared UI shell. +**Architecture:** Cut over directly from the current `agent-chat` domain to a new `fresh-agent` domain that separates user-facing `sessionType` from runtime `provider`. Reuse Claude’s existing durable/live ledger stack and the existing Codex app-server runtime, normalize both into one read model plus one shared UI shell, and migrate every persistence/restore surface up front so current users keep their tabs, settings, history, and remote snapshots. **Tech Stack:** TypeScript, React 18, Redux Toolkit, Express, WebSocket/Zod contracts, existing read-model scheduler, Claude SDK bridge, Codex app-server runtime/client, Vitest, Testing Library, Playwright browser e2e. @@ -12,31 +12,34 @@ ## Why The Previous Plan Would Fail -- It created a second Codex app-server client under `server/fresh-agent/adapters/codex/client.ts` even though the repo already has a tested app-server stack in `server/coding-cli/codex-app-server/*`. That would fork the protocol surface and guarantee drift. -- It never migrated persisted `kind: 'agent-chat'` panes or `settings.agentChat`, so existing Freshclaude tabs/settings would break or fossilize the old naming forever. -- It ignored hidden Claude-backed `kilroy` support. Deleting or orphaning it would be a regression. -- It proposed vitest e2e files under `test/e2e/*.test.tsx` for flows that belong in the repo’s Playwright browser suite under `test/e2e-browser/specs/*.spec.ts`. -- It did not require preserving existing Freshclaude features that are already real product behavior: plugin injection, mid-session model/permission changes, durable restore, lost-session recovery, timeline hydration, input history, and current local/server settings behavior. -- It did not specify how Freshcodex gets rich live data from the Codex app-server. The existing client currently starts fresh threads with `experimentalRawEvents: false` for terminal/TUI flows; a rich pane needs explicit event/replay support. +- It correctly chose the target architecture, but it did not fully cover the persistence and restore surfaces that still encode `agent-chat`. An executor following it would get partway through the cutover and then discover breakage in local storage versioning, remote tab snapshots, tab registry history, and sidebar resume behavior. +- It did not account for [`src/store/storage-migration.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/storage-migration.ts:1), which currently hard-clears persisted browser state on incompatible version changes. A naive schema bump would erase exactly the saved Freshclaude tabs and settings the user asked to preserve. +- It did not include the remote snapshot and restore path through [`server/agent-api/layout-store.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/agent-api/layout-store.ts:1), [`server/tabs-registry/types.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/tabs-registry/types.ts:1), [`src/store/tabRegistryTypes.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/tabRegistryTypes.ts:1), and [`src/components/TabsView.tsx`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/components/TabsView.tsx:1). Without those, reopened remote tabs would still serialize and hydrate `agent-chat`. +- It did not include the layout bootstrap path through [`src/components/TabContent.tsx`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/components/TabContent.tsx:1), [`src/lib/tab-directory-preference.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/lib/tab-directory-preference.ts:1), and [`src/store/paneTreeValidation.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/paneTreeValidation.ts:1). Those still special-case `agent-chat`. +- It did not call out [`src/store/selectors/sidebarSelectors.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/selectors/sidebarSelectors.ts:1) and related session metadata helpers, which are where `provider` and `sessionType` semantics get merged for history/sidebar rendering. Missing them would reintroduce the wrong identity model after the store cutover. +- It did not call out [`server/platform-router.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/platform-router.ts:1), which is part of keeping hidden `kilroy` support wired through the platform feature flags. +- It was still too optimistic about “delete old `agent-chat` glue later”. Some of the old files are not merely legacy UI; they currently encode product-critical behavior that must be ported deliberately before deletion: restore hydration, question/approval state, plugin defaults, input history, and lost-session recovery. ## Steady-State Product Behavior - Rich panes use `kind: 'fresh-agent'`, not `kind: 'agent-chat'`. - `freshclaude`, `freshcodex`, and hidden `kilroy` all come from one fresh-agent registry. -- `provider` keeps meaning runtime family (`claude`, `codex`, later `opencode`). -- `sessionType` keeps meaning user-facing identity (`freshclaude`, `freshcodex`, `kilroy`, later `freshopencode`). -- Existing Freshclaude sessions, settings, reopen entries, and sidebar items survive migration with no manual repair. +- `provider` means runtime family (`claude`, `codex`, later `opencode`). +- `sessionType` means user-facing identity (`freshclaude`, `freshcodex`, `kilroy`, later `freshopencode`). +- Existing Freshclaude sessions, settings, reopen entries, local layouts, remote tab snapshots, and sidebar/history items survive migration with no manual repair. - Raw CLI terminals remain separate pane types. Rich panes never silently degrade into terminal scraping. -- Codex rich panes use the Codex app-server as the source of truth and surface fork, diff/review, worktree, child-thread, and token/context metadata when the runtime exposes them. -- OpenCode is not shipped here, but the registry, adapter interface, and normalized model already admit it. +- Freshcodex rich panes use the Codex app-server as the source of truth and surface fork, diff/review, worktree, child-thread, and token/context metadata when the runtime exposes them. +- OpenCode is not shipped here, but the registry, adapter interface, and normalized model already admit it without Claude- or Codex-specific assumptions leaking into shared code. ## Contracts And Invariants ### Naming and persistence - The final domain name is `fresh-agent`, not `agent-chat`. -- Persisted pane/layout data must migrate existing `agent-chat` leaves to `fresh-agent`. -- Settings must migrate `agentChat` to `freshAgent` while continuing to read legacy input during rollout. +- Persisted pane/layout data must migrate existing `agent-chat` leaves to `fresh-agent` leaves. +- Browser storage migration must preserve existing Freshell auth, layout, and settings data; do not solve this by clearing local storage. +- Server/local settings must migrate `agentChat` to `freshAgent` while continuing to read legacy input during rollout. +- Remote layout snapshots and tab registry records must serialize `fresh-agent`, not `agent-chat`. - Session metadata remains keyed by `provider:sessionId`; updating `sessionType` must keep `derivedTitle`. ### Registry and adapters @@ -52,6 +55,7 @@ - Normalized entities have stable ids for thread, turn, item, approval, question, diff, artifact, child thread, and worktree references. - Read-model routes stay revisioned and lane-aware and must never mix bodies from one revision with summaries from another. - Existing Freshclaude UX stays intact unless the new shared shell makes it stronger. +- Mobile and sidebar behavior remain first-class requirements, not follow-up cleanup. ## File Structure @@ -82,6 +86,8 @@ - `src/components/fresh-agent/renderers/*` - `test/fixtures/fresh-agent/claude/*` - `test/fixtures/fresh-agent/codex/*` +- `test/e2e-browser/specs/fresh-agent.spec.ts` +- `test/e2e-browser/specs/fresh-agent-mobile.spec.ts` ### Modify @@ -91,6 +97,7 @@ - `server/config-store.ts` - `server/index.ts` - `server/ws-handler.ts` +- `server/platform-router.ts` - `server/sdk-bridge.ts` - `server/sdk-bridge-types.ts` - `server/agent-timeline/*` @@ -102,33 +109,44 @@ - `server/session-directory/*` - `server/sessions-router.ts` - `server/session-metadata-store.ts` +- `server/agent-api/layout-store.ts` +- `server/tabs-registry/types.ts` - `src/store/paneTypes.ts` - `src/store/panesSlice.ts` - `src/store/persistedState.ts` - `src/store/persistMiddleware.ts` +- `src/store/storage-migration.ts` - `src/store/store.ts` - `src/store/persistControl.ts` - `src/store/tabsSlice.ts` - `src/store/settingsSlice.ts` - `src/store/settingsThunks.ts` - `src/store/browserPreferencesPersistence.ts` +- `src/store/paneTreeValidation.ts` +- `src/store/tabRegistryTypes.ts` +- `src/store/selectors/sidebarSelectors.ts` - `src/lib/session-type-utils.ts` - `src/lib/derivePaneTitle.ts` - `src/lib/pane-activity.ts` - `src/lib/session-utils.ts` +- `src/lib/tab-directory-preference.ts` - `src/lib/tab-registry-snapshot.ts` - `src/lib/ws-client.ts` +- `src/lib/api.ts` - `src/lib/agent-chat-utils.ts` - `src/lib/agent-chat-types.ts` - `src/components/panes/PaneContainer.tsx` - `src/components/panes/PanePicker.tsx` - `src/components/Sidebar.tsx` - `src/components/HistoryView.tsx` +- `src/components/TabContent.tsx` +- `src/components/TabsView.tsx` - `src/components/context-menu/*` - `src/components/icons/PaneIcon.tsx` - `src/components/TabBar.tsx` - `src/components/TabSwitcher.tsx` - `src/components/MobileTabStrip.tsx` +- `src/components/SettingsView.tsx` - `src/components/settings/WorkspaceSettings.tsx` - `docs/index.html` @@ -147,26 +165,30 @@ The right path is a direct cutover to the final architecture, not another “gen - Claude has durable/live merge and restore semantics in `server/sdk-bridge.ts` and `server/agent-timeline/*`. - Codex already has a shared app-server runtime/client/planner in `server/coding-cli/codex-app-server/*`. -The plan therefore reuses both, renames the product domain to `fresh-agent`, migrates persistence/settings up front, and then lands one transport, one read model, and one UI shell. That is the cleanest route to the requested end state and the only route that avoids another rewrite when `freshopencode` arrives. +The plan therefore reuses both, renames the product domain to `fresh-agent`, migrates persistence/settings/restore surfaces first, and then lands one transport, one read model, one store, and one UI shell. That is the cleanest route to the requested end state and the only route that avoids another rewrite when `freshopencode` arrives. -### Task 1: Rename the domain to `fresh-agent` and migrate persisted settings/layouts +### Task 1: Rename the domain to `fresh-agent` and migrate local/server persistence without data loss **Files:** - Create: `shared/fresh-agent.ts` - Create: `src/lib/fresh-agent-registry.ts` - Modify: `shared/settings.ts` - Modify: `server/config-store.ts` +- Modify: `server/platform-router.ts` - Modify: `src/store/settingsSlice.ts` - Modify: `src/store/settingsThunks.ts` - Modify: `src/store/browserPreferencesPersistence.ts` +- Modify: `src/store/storage-migration.ts` - Modify: `src/store/paneTypes.ts` - Modify: `src/store/panesSlice.ts` - Modify: `src/store/persistedState.ts` - Modify: `src/store/persistMiddleware.ts` +- Modify: `src/store/paneTreeValidation.ts` - Modify: `src/lib/agent-chat-utils.ts` - Modify: `src/lib/agent-chat-types.ts` - Test: `test/unit/shared/fresh-agent-registry.test.ts` - Test: `test/unit/client/store/persisted-state.fresh-agent.test.ts` +- Test: `test/unit/client/store/storage-migration.fresh-agent.test.ts` - Test: `test/unit/server/config-store.fresh-agent-settings.test.ts` - [ ] **Step 1: Identify or write the failing tests** @@ -195,6 +217,12 @@ it('migrates legacy settings.agentChat to settings.freshAgent', () => { expect(settings.freshAgent.defaultPlugins).toEqual(['/tmp/plugin']) }) +it('does not clear freshell layout storage during the fresh-agent migration', () => { + localStorage.setItem('freshell.layout.v3', '{"version":3}') + runStorageMigration() + expect(localStorage.getItem('freshell.layout.v3')).toBe('{"version":3}') +}) + it('keeps kilroy as a hidden claude-backed fresh-agent type', () => { expect(resolveFreshAgentType('kilroy')).toMatchObject({ runtimeProvider: 'claude', hidden: true }) }) @@ -202,10 +230,10 @@ it('keeps kilroy as a hidden claude-backed fresh-agent type', () => { - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts` Expected: FAIL because the registry and migrations do not exist yet. -- [ ] **Step 3: Write the minimal implementation** +- [ ] **Step 3: Write minimal implementation** Implement the shared vocabulary and migrations: @@ -214,11 +242,12 @@ Implement the shared vocabulary and migrations: - registry entries for `freshclaude`, `freshcodex`, hidden `kilroy`, and disabled `freshopencode` - persisted layout migration from `kind: 'agent-chat'` to `kind: 'fresh-agent'` - server/local settings migration from `agentChat` to `freshAgent`, still accepting legacy input +- storage migration that preserves saved Freshell state instead of clearing it - pane content shape that stores `sessionType` explicitly instead of overloading `provider` - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** @@ -229,13 +258,13 @@ Refactor compatibility to one place only: - runtime code consumes only `fresh-agent` - `agent-chat` helper modules become thin compatibility exports or are queued for removal -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/client/store/tabsSlice.merge.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/client/store/tabsSlice.merge.test.ts` Expected: PASS - [ ] **Step 6: Commit** ```bash -git add shared/fresh-agent.ts src/lib/fresh-agent-registry.ts shared/settings.ts server/config-store.ts src/store/settingsSlice.ts src/store/settingsThunks.ts src/store/browserPreferencesPersistence.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/lib/agent-chat-utils.ts src/lib/agent-chat-types.ts test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts +git add shared/fresh-agent.ts src/lib/fresh-agent-registry.ts shared/settings.ts server/config-store.ts server/platform-router.ts src/store/settingsSlice.ts src/store/settingsThunks.ts src/store/browserPreferencesPersistence.ts src/store/storage-migration.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/store/paneTreeValidation.ts src/lib/agent-chat-utils.ts src/lib/agent-chat-types.ts test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts git commit -m "refactor: rename rich pane domain to fresh agent" ``` @@ -275,7 +304,7 @@ it('returns 409 for stale thread revisions instead of mixing bodies from differe Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts` Expected: FAIL because the fresh-agent transport does not exist. -- [ ] **Step 3: Write the minimal implementation** +- [ ] **Step 3: Write minimal implementation** Implement: @@ -344,7 +373,7 @@ it('preserves plugin defaults and mid-session model/permission changes through t Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts` Expected: FAIL because the adapter does not exist. -- [ ] **Step 3: Write the minimal implementation** +- [ ] **Step 3: Write minimal implementation** Wrap the existing Claude stack behind the adapter: @@ -410,17 +439,17 @@ it('normalizes codex fork, review, worktree, and child-thread metadata into the - [ ] **Step 2: Run tests to verify they fail** Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts` -Expected: FAIL because the existing app-server layer does not yet expose rich-session events/capabilities. +Expected: FAIL because the existing app-server layer does not yet expose rich-session events and capabilities. -- [ ] **Step 3: Write the minimal implementation** +- [ ] **Step 3: Write minimal implementation** Extend the current Codex app-server stack instead of copying it: - add protocol/client support for the notifications and RPCs needed by rich Freshcodex - keep terminal-mode behavior intact -- allow rich-pane creation/resume to request raw events/replay where required +- allow rich-pane creation and resume to request raw events and replay where required - normalize review, diff, fork lineage, worktree, token/context, and child-thread metadata into the shared model -- keep `server/coding-cli/providers/codex.ts` focused on indexing/terminal concerns +- keep `server/coding-cli/providers/codex.ts` focused on indexing and terminal concerns - [ ] **Step 4: Run tests to verify they pass** @@ -441,7 +470,7 @@ git add server/fresh-agent/adapters/codex/adapter.ts server/fresh-agent/adapters git commit -m "feat: add codex rich runtime support" ``` -### Task 5: Integrate fresh-agent sessions into session directory, metadata, and resume flows +### Task 5: Integrate fresh-agent sessions into metadata, session directory, remote snapshots, and resume flows **Files:** - Modify: `server/session-directory/projection.ts` @@ -449,14 +478,26 @@ git commit -m "feat: add codex rich runtime support" - Modify: `server/session-directory/types.ts` - Modify: `server/sessions-router.ts` - Modify: `server/session-metadata-store.ts` +- Modify: `server/agent-api/layout-store.ts` +- Modify: `server/tabs-registry/types.ts` +- Modify: `src/store/tabRegistryTypes.ts` - Modify: `src/lib/api.ts` +- Modify: `src/lib/session-metadata.ts` +- Modify: `src/lib/session-type-utils.ts` +- Modify: `src/lib/tab-directory-preference.ts` +- Modify: `src/lib/tab-registry-snapshot.ts` +- Modify: `src/components/TabContent.tsx` +- Modify: `src/components/TabsView.tsx` +- Modify: `src/store/selectors/sidebarSelectors.ts` - Test: `test/unit/server/session-directory/fresh-agent-projection.test.ts` - Test: `test/integration/server/session-metadata-api.test.ts` +- Test: `test/unit/server/agent-api/layout-store.fresh-agent.test.ts` +- Test: `test/unit/client/components/TabsView.fresh-agent.test.tsx` - Test: `test/unit/client/lib/api.test.ts` - [ ] **Step 1: Identify or write the failing tests** -Cover the resume/sidebar contract: +Cover the resume and snapshot contract: ```ts it('keeps derivedTitle when sessionType is updated to freshcodex', async () => { @@ -465,45 +506,47 @@ it('keeps derivedTitle when sessionType is updated to freshcodex', async () => { expect(await store.get('codex', 'sess-1')).toEqual({ derivedTitle: 'Sticky title', sessionType: 'freshcodex' }) }) -it('projects freshcodex sessions into the session directory with fork/worktree badges', async () => { - expect(page.items[0]).toMatchObject({ sessionType: 'freshcodex', provider: 'codex' }) +it('serializes fresh-agent panes in remote layout snapshots and rehydrates them back into fresh-agent panes', () => { + expect(snapshot.panes[0]).toMatchObject({ kind: 'fresh-agent' }) + expect(restored.content).toMatchObject({ kind: 'fresh-agent', sessionType: 'freshclaude' }) }) ``` - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/client/lib/api.test.ts` -Expected: FAIL because fresh-agent metadata is not fully projected yet. +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` +Expected: FAIL because fresh-agent metadata and snapshot flows are not fully projected yet. -- [ ] **Step 3: Write the minimal implementation** +- [ ] **Step 3: Write minimal implementation** Implement projection and metadata updates so that: -- sidebar/history pages show `freshclaude`, `freshcodex`, and `kilroy` correctly +- sidebar and history pages show `freshclaude`, `freshcodex`, and `kilroy` correctly - resume actions rebuild `kind: 'fresh-agent'` panes - `sessionType` updates do not clobber `derivedTitle` -- session directory carries the metadata needed for fork/worktree/subagent badges +- remote layout snapshots and registry records store `fresh-agent`, not `agent-chat` +- session directory carries the metadata needed for fork, worktree, and subagent badges - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/client/lib/api.test.ts` +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** -Keep `provider` and `sessionType` semantics separate everywhere. Do not smuggle UI identity back into filesystem/provider fields. +Keep `provider` and `sessionType` semantics separate everywhere. Do not smuggle UI identity back into filesystem or provider fields. -Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/session-directory/service.test.ts` +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/session-directory/service.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx` Expected: PASS - [ ] **Step 6: Commit** ```bash -git add server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/sessions-router.ts server/session-metadata-store.ts src/lib/api.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/client/lib/api.test.ts -git commit -m "feat: project fresh agent sessions through sidebar metadata" +git add server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/sessions-router.ts server/session-metadata-store.ts server/agent-api/layout-store.ts server/tabs-registry/types.ts src/store/tabRegistryTypes.ts src/lib/api.ts src/lib/session-metadata.ts src/lib/session-type-utils.ts src/lib/tab-directory-preference.ts src/lib/tab-registry-snapshot.ts src/components/TabContent.tsx src/components/TabsView.tsx src/store/selectors/sidebarSelectors.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts +git commit -m "feat: project fresh agent sessions through metadata and snapshots" ``` -### Task 6: Replace client state and WS handling with the fresh-agent store +### Task 6: Replace client state and WebSocket handling with the fresh-agent store **Files:** - Create: `src/lib/fresh-agent-ws.ts` @@ -514,7 +557,9 @@ git commit -m "feat: project fresh agent sessions through sidebar metadata" - Modify: `src/store/persistControl.ts` - Modify: `src/store/tabsSlice.ts` - Modify: `src/lib/ws-client.ts` -- Modify: `src/lib/session-type-utils.ts` +- Modify: `src/lib/session-utils.ts` +- Modify: `src/lib/pane-activity.ts` +- Modify: `src/components/TabSwitcher.tsx` - Test: `test/unit/client/store/freshAgentSlice.test.ts` - Test: `test/unit/client/store/freshAgentThunks.test.ts` - Test: `test/unit/client/lib/fresh-agent-ws.test.ts` @@ -539,17 +584,18 @@ it('persists sessionType and provider separately for resume identity', () => { Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts` Expected: FAIL because the fresh-agent state layer does not exist. -- [ ] **Step 3: Write the minimal implementation** +- [ ] **Step 3: Write minimal implementation** Implement the normalized client layer: - thread state keyed by runtime locator -- revision-safe snapshot/page/body hydration +- revision-safe snapshot, page, and body hydration - WS handling for `freshAgent.*` -- pending approvals/questions/action errors in shared state +- pending approvals, questions, and action errors in shared state - resume identity helpers that work for Claude and Codex without Claude-only assumptions +- pane activity and tab switcher wiring that no longer read from `agentChat` -Do not persist transient streaming/pending action state. +Do not persist transient streaming or pending-action state. - [ ] **Step 4: Run tests to verify they pass** @@ -558,7 +604,7 @@ Expected: PASS - [ ] **Step 5: Refactor and verify** -Convert shared selectors/helpers to consume `freshAgent` state, not `agentChat`. +Convert shared selectors and helpers to consume `freshAgent` state, not `agentChat`. Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts test/unit/client/components/App.ws-bootstrap.test.tsx` Expected: PASS @@ -566,7 +612,7 @@ Expected: PASS - [ ] **Step 6: Commit** ```bash -git add src/lib/fresh-agent-ws.ts src/store/freshAgentTypes.ts src/store/freshAgentSlice.ts src/store/freshAgentThunks.ts src/store/store.ts src/store/persistControl.ts src/store/tabsSlice.ts src/lib/ws-client.ts src/lib/session-type-utils.ts test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts +git add src/lib/fresh-agent-ws.ts src/store/freshAgentTypes.ts src/store/freshAgentSlice.ts src/store/freshAgentThunks.ts src/store/store.ts src/store/persistControl.ts src/store/tabsSlice.ts src/lib/ws-client.ts src/lib/session-utils.ts src/lib/pane-activity.ts src/components/TabSwitcher.tsx test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts git commit -m "feat: add fresh agent client state" ``` @@ -588,8 +634,8 @@ git commit -m "feat: add fresh agent client state" - Modify: `src/components/context-menu/*` - Modify: `src/components/icons/PaneIcon.tsx` - Modify: `src/components/TabBar.tsx` -- Modify: `src/components/TabSwitcher.tsx` - Modify: `src/components/MobileTabStrip.tsx` +- Modify: `src/components/SettingsView.tsx` - Modify: `src/components/settings/WorkspaceSettings.tsx` - Modify: `src/lib/derivePaneTitle.ts` - Modify: `src/lib/pane-activity.ts` @@ -598,6 +644,7 @@ git commit -m "feat: add fresh agent client state" - Test: `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx` - Test: `test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` - Test: `test/unit/client/components/panes/PaneContainer.test.tsx` +- Test: `test/unit/client/components/SettingsView.fresh-agent.test.tsx` - [ ] **Step 1: Identify or write the failing tests** @@ -619,24 +666,24 @@ it('preserves existing freshclaude settings, plugin controls, and question banne - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx` +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx` Expected: FAIL because the shared UI shell does not exist. -- [ ] **Step 3: Write the minimal implementation** +- [ ] **Step 3: Write minimal implementation** Build the shared shell with: -- virtualized transcript backed by normalized turns/items +- virtualized transcript backed by normalized turns and items - approval and question banners reused across providers - shared composer supporting send, interrupt, fork, and capability-backed actions -- mobile drawers/sheets for secondary panes -- preserved Freshclaude features: plugin defaults, settings popover, input history, restore hydration, session-lost recovery, timecodes, show thinking/tools toggles +- mobile drawers or sheets for secondary panes +- preserved Freshclaude features: plugin defaults, settings popover, input history, restore hydration, session-lost recovery, timecodes, show thinking and tools toggles Switch pane picker, pane container, sidebar, history, and context menus to `kind: 'fresh-agent'`. - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx` +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx` Expected: PASS - [ ] **Step 5: Refactor and verify** @@ -649,11 +696,11 @@ Expected: PASS - [ ] **Step 6: Commit** ```bash -git add src/components/fresh-agent src/components/panes/PaneContainer.tsx src/components/panes/PanePicker.tsx src/components/Sidebar.tsx src/components/HistoryView.tsx src/components/context-menu src/components/icons/PaneIcon.tsx src/components/TabBar.tsx src/components/TabSwitcher.tsx src/components/MobileTabStrip.tsx src/components/settings/WorkspaceSettings.tsx src/lib/derivePaneTitle.ts src/lib/pane-activity.ts docs/index.html test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx +git add src/components/fresh-agent src/components/panes/PaneContainer.tsx src/components/panes/PanePicker.tsx src/components/Sidebar.tsx src/components/HistoryView.tsx src/components/context-menu src/components/icons/PaneIcon.tsx src/components/TabBar.tsx src/components/MobileTabStrip.tsx src/components/SettingsView.tsx src/components/settings/WorkspaceSettings.tsx src/lib/derivePaneTitle.ts src/lib/pane-activity.ts docs/index.html test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx git commit -m "feat: ship shared fresh agent pane shell" ``` -### Task 8: Add browser e2e coverage, remove obsolete code, and run the full verification gate +### Task 8: Remove obsolete code, add browser e2e coverage, and run the full verification gate **Files:** - Modify/Delete: `src/store/agentChatSlice.ts` @@ -663,7 +710,7 @@ git commit -m "feat: ship shared fresh agent pane shell" - Create: `test/e2e-browser/specs/fresh-agent.spec.ts` - Create: `test/e2e-browser/specs/fresh-agent-mobile.spec.ts` - Modify: existing Playwright helpers only if needed -- Test: all targeted unit/integration/e2e suites below +- Test: all targeted unit, integration, and e2e suites below - [ ] **Step 1: Identify or write the failing tests** @@ -675,7 +722,7 @@ test('creates and resumes freshcodex with fork lineage and worktree metadata int await expect(page.getByText(/worktree/i)).toBeVisible() }) -test('freshclaude still restores durable history and surfaces approvals/questions', async ({ page }) => { +test('freshclaude still restores durable history and surfaces approvals and questions', async ({ page }) => { await expect(page.getByRole('alert')).toBeVisible() }) ``` @@ -685,7 +732,7 @@ test('freshclaude still restores durable history and surfaces approvals/question Run: `npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts` Expected: FAIL because the browser flows are not fully wired yet. -- [ ] **Step 3: Write the minimal implementation** +- [ ] **Step 3: Write minimal implementation** Delete obsolete `agent-chat` and legacy `sdk.*` client glue only after the new browser flows pass. Keep only compatibility code required for persisted migration or legacy setting reads. @@ -720,15 +767,17 @@ git commit -m "refactor: remove legacy agent chat architecture" Do not ship `freshopencode` here. Do ensure the final architecture already supports it: - disabled registry entry may exist now -- adapter interface already supports explicit permission/command flows and server-driven event streams +- adapter interface already supports explicit permission and command flows plus server-driven event streams - normalized model already has diff, review, worktree, child-thread, and artifact concepts - no client code assumes Claude block messages or Codex review objects are universal ## Implementation notes for the executing agent - Work only in `/home/user/code/freshell/.worktrees/fresh-agent-platform`. -- Reuse the existing Codex app-server runtime/client/planner instead of creating a parallel stack. +- Reuse the existing Codex app-server runtime, client, and planner instead of creating a parallel stack. - Preserve current Freshclaude behavior while renaming the architecture underneath it. - Preserve `kilroy` as a hidden Claude-backed fresh-agent type. -- Use Playwright for browser/mobile e2e, not vitest-only pseudo-e2e files. +- Preserve saved tabs and settings; do not “solve” migration by clearing local storage. +- Update remote snapshot and tab registry code in the same migration as local pane persistence. +- Use Playwright for browser and mobile e2e, not vitest-only pseudo-e2e files. - Broad runs go through the test coordinator. Check `npm run test:status` before them. From 5f4386563550191ff62d73c3451a1c1ba399c548 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 10:53:23 -0700 Subject: [PATCH 04/86] docs: tighten fresh agent platform plan --- docs/plans/2026-04-18-fresh-agent-platform.md | 117 +++++++++++++++--- 1 file changed, 101 insertions(+), 16 deletions(-) diff --git a/docs/plans/2026-04-18-fresh-agent-platform.md b/docs/plans/2026-04-18-fresh-agent-platform.md index a15e0ede0..56f99f213 100644 --- a/docs/plans/2026-04-18-fresh-agent-platform.md +++ b/docs/plans/2026-04-18-fresh-agent-platform.md @@ -16,8 +16,10 @@ - It did not account for [`src/store/storage-migration.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/storage-migration.ts:1), which currently hard-clears persisted browser state on incompatible version changes. A naive schema bump would erase exactly the saved Freshclaude tabs and settings the user asked to preserve. - It did not include the remote snapshot and restore path through [`server/agent-api/layout-store.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/agent-api/layout-store.ts:1), [`server/tabs-registry/types.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/tabs-registry/types.ts:1), [`src/store/tabRegistryTypes.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/tabRegistryTypes.ts:1), and [`src/components/TabsView.tsx`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/components/TabsView.tsx:1). Without those, reopened remote tabs would still serialize and hydrate `agent-chat`. - It did not include the layout bootstrap path through [`src/components/TabContent.tsx`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/components/TabContent.tsx:1), [`src/lib/tab-directory-preference.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/lib/tab-directory-preference.ts:1), and [`src/store/paneTreeValidation.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/paneTreeValidation.ts:1). Those still special-case `agent-chat`. +- It sequenced the persistence cutover too early. If an executor migrates stored panes from `agent-chat` to `fresh-agent` before `PaneContainer`, `TabContent`, `crossTabSync`, pane-title helpers, and local snapshot parsing can read the new shape, the next reload or cross-tab hydrate will strand rich panes as unknown content. - It did not call out [`src/store/selectors/sidebarSelectors.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/selectors/sidebarSelectors.ts:1) and related session metadata helpers, which are where `provider` and `sessionType` semantics get merged for history/sidebar rendering. Missing them would reintroduce the wrong identity model after the store cutover. - It did not call out [`server/platform-router.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/platform-router.ts:1), which is part of keeping hidden `kilroy` support wired through the platform feature flags. +- It did not include the existing browser specs, Vitest e2e flows, context-menu tests, visible-first perf fixtures, and MCP help text that still hard-code `agent-chat` or `sdk.*`. An executor could finish the product code, hit the repo-wide verification gate, and then discover a second migration hidden in the test/tooling surface. - It was still too optimistic about “delete old `agent-chat` glue later”. Some of the old files are not merely legacy UI; they currently encode product-critical behavior that must be ported deliberately before deletion: restore hydration, question/approval state, plugin defaults, input history, and lost-session recovery. ## Steady-State Product Behavior @@ -56,6 +58,7 @@ - Read-model routes stay revisioned and lane-aware and must never mix bodies from one revision with summaries from another. - Existing Freshclaude UX stays intact unless the new shared shell makes it stronger. - Mobile and sidebar behavior remain first-class requirements, not follow-up cleanup. +- During the migration tasks, runtime readers must accept both legacy `agent-chat` persisted data and the new `fresh-agent` shape until every local bootstrap, cross-tab hydrate, and remote snapshot path has been switched. ## File Structure @@ -115,6 +118,7 @@ - `src/store/panesSlice.ts` - `src/store/persistedState.ts` - `src/store/persistMiddleware.ts` +- `src/store/crossTabSync.ts` - `src/store/storage-migration.ts` - `src/store/store.ts` - `src/store/persistControl.ts` @@ -127,6 +131,7 @@ - `src/store/selectors/sidebarSelectors.ts` - `src/lib/session-type-utils.ts` - `src/lib/derivePaneTitle.ts` +- `src/lib/pane-title.ts` - `src/lib/pane-activity.ts` - `src/lib/session-utils.ts` - `src/lib/tab-directory-preference.ts` @@ -141,21 +146,44 @@ - `src/components/HistoryView.tsx` - `src/components/TabContent.tsx` - `src/components/TabsView.tsx` -- `src/components/context-menu/*` +- `src/components/context-menu/ContextMenuProvider.tsx` +- `src/components/context-menu/context-menu-constants.ts` +- `src/components/context-menu/context-menu-types.ts` +- `src/components/context-menu/context-menu-utils.ts` +- `src/components/context-menu/menu-defs.ts` - `src/components/icons/PaneIcon.tsx` - `src/components/TabBar.tsx` - `src/components/TabSwitcher.tsx` - `src/components/MobileTabStrip.tsx` - `src/components/SettingsView.tsx` - `src/components/settings/WorkspaceSettings.tsx` +- `server/mcp/freshell-tool.ts` - `docs/index.html` ### Delete Or Reduce To Shims - `src/store/agentChatSlice.ts` - `src/store/agentChatThunks.ts` +- `src/store/agentChatTypes.ts` - `src/components/agent-chat/*` - `src/lib/sdk-message-handler.ts` +- `test/e2e/agent-chat-*.test.tsx` +- `test/e2e/pane-activity-indicator-flow.test.tsx` +- `test/e2e/pane-header-runtime-meta-flow.test.tsx` +- `test/e2e/sidebar-click-opens-pane.test.tsx` +- `test/e2e/title-sync-flow.test.tsx` +- `test/e2e/tool-coalesce.test.tsx` +- `test/e2e-browser/specs/agent-chat*.spec.ts` +- `test/e2e-browser/specs/pane-activity-indicator.spec.ts` +- `test/e2e-browser/specs/tab-management.spec.ts` +- `test/e2e-browser/perf/*` +- `test/unit/client/components/agent-chat/*` +- `test/unit/client/components/ContextMenuProvider.test.tsx` +- `test/unit/client/components/SettingsView.agent-chat.test.tsx` +- `test/unit/client/components/context-menu/agent-chat-actions.test.ts` +- `test/unit/client/components/context-menu/menu-defs.test.ts` +- `test/unit/client/store/crossTabSync.test.ts` +- `test/unit/server/ws-handler-sdk.test.ts` - any other `sdk.*` or `agent-chat` glue that no longer carries real behavior after cutover ## Strategy Gate @@ -183,12 +211,19 @@ The plan therefore reuses both, renames the product domain to `fresh-agent`, mig - Modify: `src/store/panesSlice.ts` - Modify: `src/store/persistedState.ts` - Modify: `src/store/persistMiddleware.ts` +- Modify: `src/store/crossTabSync.ts` - Modify: `src/store/paneTreeValidation.ts` - Modify: `src/lib/agent-chat-utils.ts` - Modify: `src/lib/agent-chat-types.ts` +- Modify: `src/lib/pane-title.ts` +- Modify: `src/components/panes/PaneContainer.tsx` +- Modify: `src/components/TabContent.tsx` +- Modify: `src/lib/tab-registry-snapshot.ts` - Test: `test/unit/shared/fresh-agent-registry.test.ts` - Test: `test/unit/client/store/persisted-state.fresh-agent.test.ts` - Test: `test/unit/client/store/storage-migration.fresh-agent.test.ts` +- Test: `test/unit/client/store/crossTabSync.test.ts` +- Test: `test/unit/client/components/panes/PaneContainer.createContent.test.tsx` - Test: `test/unit/server/config-store.fresh-agent-settings.test.ts` - [ ] **Step 1: Identify or write the failing tests** @@ -226,11 +261,28 @@ it('does not clear freshell layout storage during the fresh-agent migration', () it('keeps kilroy as a hidden claude-backed fresh-agent type', () => { expect(resolveFreshAgentType('kilroy')).toMatchObject({ runtimeProvider: 'claude', hidden: true }) }) + +it('hydrates a persisted fresh-agent pane without falling back to an unknown pane kind', () => { + const content = getLeafContentFromHydratedLayout({ + type: 'leaf', + id: 'pane_1', + content: { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', createRequestId: 'req-1', status: 'idle' }, + }) + expect(content).toMatchObject({ kind: 'fresh-agent', sessionType: 'freshclaude' }) +}) + +it('preserves canonical resume identity when cross-tab sync rehydrates a fresh-agent pane', () => { + const result = protectCanonicalPaneResumeIdentity( + buildLeaf('pane_1', { kind: 'fresh-agent', provider: 'claude', resumeSessionId: 'remote-id' }), + buildLeaf('pane_1', { kind: 'fresh-agent', provider: 'claude', resumeSessionId: 'local-id' }), + ) + expect(getLeafContent(result)?.resumeSessionId).toBe('local-id') +}) ``` - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts` Expected: FAIL because the registry and migrations do not exist yet. - [ ] **Step 3: Write minimal implementation** @@ -244,10 +296,11 @@ Implement the shared vocabulary and migrations: - server/local settings migration from `agentChat` to `freshAgent`, still accepting legacy input - storage migration that preserves saved Freshell state instead of clearing it - pane content shape that stores `sessionType` explicitly instead of overloading `provider` +- compatibility readers in `PaneContainer`, `TabContent`, `crossTabSync`, pane-title helpers, and local snapshot helpers so fresh-agent layouts can boot before the legacy client state is removed - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** @@ -255,16 +308,16 @@ Expected: PASS Refactor compatibility to one place only: - legacy `agent-chat` parsing belongs in persisted/settings migration code -- runtime code consumes only `fresh-agent` +- runtime readers accept `fresh-agent` first and tolerate legacy `agent-chat` only at bootstrap boundaries - `agent-chat` helper modules become thin compatibility exports or are queued for removal -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/client/store/tabsSlice.merge.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/client/store/tabsSlice.merge.test.ts` Expected: PASS - [ ] **Step 6: Commit** ```bash -git add shared/fresh-agent.ts src/lib/fresh-agent-registry.ts shared/settings.ts server/config-store.ts server/platform-router.ts src/store/settingsSlice.ts src/store/settingsThunks.ts src/store/browserPreferencesPersistence.ts src/store/storage-migration.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/store/paneTreeValidation.ts src/lib/agent-chat-utils.ts src/lib/agent-chat-types.ts test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts +git add shared/fresh-agent.ts src/lib/fresh-agent-registry.ts shared/settings.ts server/config-store.ts server/platform-router.ts src/store/settingsSlice.ts src/store/settingsThunks.ts src/store/browserPreferencesPersistence.ts src/store/storage-migration.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/store/crossTabSync.ts src/store/paneTreeValidation.ts src/lib/agent-chat-utils.ts src/lib/agent-chat-types.ts src/lib/pane-title.ts src/components/panes/PaneContainer.tsx src/components/TabContent.tsx src/lib/tab-registry-snapshot.ts test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts git commit -m "refactor: rename rich pane domain to fresh agent" ``` @@ -564,6 +617,7 @@ git commit -m "feat: project fresh agent sessions through metadata and snapshots - Test: `test/unit/client/store/freshAgentThunks.test.ts` - Test: `test/unit/client/lib/fresh-agent-ws.test.ts` - Test: `test/unit/client/store/persistControl.fresh-agent.test.ts` +- Test: `test/unit/server/ws-handler-sdk.test.ts` - [ ] **Step 1: Identify or write the failing tests** @@ -606,13 +660,13 @@ Expected: PASS Convert shared selectors and helpers to consume `freshAgent` state, not `agentChat`. -Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts test/unit/client/components/App.ws-bootstrap.test.tsx` +Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/server/ws-handler-sdk.test.ts` Expected: PASS - [ ] **Step 6: Commit** ```bash -git add src/lib/fresh-agent-ws.ts src/store/freshAgentTypes.ts src/store/freshAgentSlice.ts src/store/freshAgentThunks.ts src/store/store.ts src/store/persistControl.ts src/store/tabsSlice.ts src/lib/ws-client.ts src/lib/session-utils.ts src/lib/pane-activity.ts src/components/TabSwitcher.tsx test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts +git add src/lib/fresh-agent-ws.ts src/store/freshAgentTypes.ts src/store/freshAgentSlice.ts src/store/freshAgentThunks.ts src/store/store.ts src/store/persistControl.ts src/store/tabsSlice.ts src/lib/ws-client.ts src/lib/session-utils.ts src/lib/pane-activity.ts src/components/TabSwitcher.tsx test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts test/unit/server/ws-handler-sdk.test.ts git commit -m "feat: add fresh agent client state" ``` @@ -631,13 +685,18 @@ git commit -m "feat: add fresh agent client state" - Modify: `src/components/panes/PanePicker.tsx` - Modify: `src/components/Sidebar.tsx` - Modify: `src/components/HistoryView.tsx` -- Modify: `src/components/context-menu/*` +- Modify: `src/components/context-menu/ContextMenuProvider.tsx` +- Modify: `src/components/context-menu/context-menu-constants.ts` +- Modify: `src/components/context-menu/context-menu-types.ts` +- Modify: `src/components/context-menu/context-menu-utils.ts` +- Modify: `src/components/context-menu/menu-defs.ts` - Modify: `src/components/icons/PaneIcon.tsx` - Modify: `src/components/TabBar.tsx` - Modify: `src/components/MobileTabStrip.tsx` - Modify: `src/components/SettingsView.tsx` - Modify: `src/components/settings/WorkspaceSettings.tsx` - Modify: `src/lib/derivePaneTitle.ts` +- Modify: `src/lib/pane-title.ts` - Modify: `src/lib/pane-activity.ts` - Modify: `docs/index.html` - Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` @@ -645,6 +704,8 @@ git commit -m "feat: add fresh agent client state" - Test: `test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx` - Test: `test/unit/client/components/panes/PaneContainer.test.tsx` - Test: `test/unit/client/components/SettingsView.fresh-agent.test.tsx` +- Test: `test/unit/client/components/ContextMenuProvider.test.tsx` +- Test: `test/unit/client/components/context-menu/menu-defs.test.ts` - [ ] **Step 1: Identify or write the failing tests** @@ -666,7 +727,7 @@ it('preserves existing freshclaude settings, plugin controls, and question banne - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx` +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts` Expected: FAIL because the shared UI shell does not exist. - [ ] **Step 3: Write minimal implementation** @@ -678,25 +739,26 @@ Build the shared shell with: - shared composer supporting send, interrupt, fork, and capability-backed actions - mobile drawers or sheets for secondary panes - preserved Freshclaude features: plugin defaults, settings popover, input history, restore hydration, session-lost recovery, timecodes, show thinking and tools toggles +- fresh-agent-aware context menus, pane badges, and resume-command affordances with no stale `agent-chat` target assumptions Switch pane picker, pane container, sidebar, history, and context menus to `kind: 'fresh-agent'`. - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx` +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** Fold or delete old `src/components/agent-chat/*` pieces once stronger fresh-agent coverage exists. -Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/Sidebar.render-stability.test.tsx` +Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/Sidebar.render-stability.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts` Expected: PASS - [ ] **Step 6: Commit** ```bash -git add src/components/fresh-agent src/components/panes/PaneContainer.tsx src/components/panes/PanePicker.tsx src/components/Sidebar.tsx src/components/HistoryView.tsx src/components/context-menu src/components/icons/PaneIcon.tsx src/components/TabBar.tsx src/components/MobileTabStrip.tsx src/components/SettingsView.tsx src/components/settings/WorkspaceSettings.tsx src/lib/derivePaneTitle.ts src/lib/pane-activity.ts docs/index.html test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx +git add src/components/fresh-agent src/components/panes/PaneContainer.tsx src/components/panes/PanePicker.tsx src/components/Sidebar.tsx src/components/HistoryView.tsx src/components/context-menu/ContextMenuProvider.tsx src/components/context-menu/context-menu-constants.ts src/components/context-menu/context-menu-types.ts src/components/context-menu/context-menu-utils.ts src/components/context-menu/menu-defs.ts src/components/icons/PaneIcon.tsx src/components/TabBar.tsx src/components/MobileTabStrip.tsx src/components/SettingsView.tsx src/components/settings/WorkspaceSettings.tsx src/lib/derivePaneTitle.ts src/lib/pane-title.ts src/lib/pane-activity.ts docs/index.html test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts git commit -m "feat: ship shared fresh agent pane shell" ``` @@ -705,8 +767,31 @@ git commit -m "feat: ship shared fresh agent pane shell" **Files:** - Modify/Delete: `src/store/agentChatSlice.ts` - Modify/Delete: `src/store/agentChatThunks.ts` +- Modify/Delete: `src/store/agentChatTypes.ts` - Modify/Delete: `src/components/agent-chat/*` - Modify/Delete: `src/lib/sdk-message-handler.ts` +- Modify/Delete: `server/mcp/freshell-tool.ts` +- Modify/Delete: `test/e2e/agent-chat-*.test.tsx` +- Modify/Delete: `test/e2e/pane-activity-indicator-flow.test.tsx` +- Modify/Delete: `test/e2e/pane-header-runtime-meta-flow.test.tsx` +- Modify/Delete: `test/e2e/sidebar-click-opens-pane.test.tsx` +- Modify/Delete: `test/e2e/title-sync-flow.test.tsx` +- Modify/Delete: `test/e2e/tool-coalesce.test.tsx` +- Modify/Delete: `test/e2e-browser/specs/agent-chat.spec.ts` +- Modify/Delete: `test/e2e-browser/specs/agent-chat-input-history.spec.ts` +- Modify/Delete: `test/e2e-browser/specs/pane-activity-indicator.spec.ts` +- Modify/Delete: `test/e2e-browser/specs/tab-management.spec.ts` +- Modify/Delete: `test/e2e-browser/perf/audit-contract.ts` +- Modify/Delete: `test/e2e-browser/perf/run-sample.ts` +- Modify/Delete: `test/e2e-browser/perf/scenarios.ts` +- Modify/Delete: `test/e2e-browser/perf/seed-browser-storage.ts` +- Modify/Delete: `test/unit/client/components/agent-chat/*` +- Modify/Delete: `test/unit/client/components/ContextMenuProvider.test.tsx` +- Modify/Delete: `test/unit/client/components/SettingsView.agent-chat.test.tsx` +- Modify/Delete: `test/unit/client/components/context-menu/agent-chat-actions.test.ts` +- Modify/Delete: `test/unit/client/components/context-menu/menu-defs.test.ts` +- Modify/Delete: `test/unit/client/store/crossTabSync.test.ts` +- Modify/Delete: `test/unit/server/ws-handler-sdk.test.ts` - Create: `test/e2e-browser/specs/fresh-agent.spec.ts` - Create: `test/e2e-browser/specs/fresh-agent-mobile.spec.ts` - Modify: existing Playwright helpers only if needed @@ -734,12 +819,12 @@ Expected: FAIL because the browser flows are not fully wired yet. - [ ] **Step 3: Write minimal implementation** -Delete obsolete `agent-chat` and legacy `sdk.*` client glue only after the new browser flows pass. Keep only compatibility code required for persisted migration or legacy setting reads. +Delete obsolete `agent-chat` and legacy `sdk.*` client glue only after the new browser flows pass. Port or rename every existing browser spec, Vitest e2e flow, visible-first perf fixture, context-menu test, and MCP/tooling string that still encodes `agent-chat` or `sdk.*`; keep only compatibility code required for persisted migration or legacy setting reads. - [ ] **Step 4: Run tests to verify targeted suites pass** -Run: `npm run test:vitest -- test/unit/server/fresh-agent test/unit/client/components/fresh-agent test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts` -Run: `npm run test:vitest -- test/integration/server/session-metadata-api.test.ts` +Run: `npm run test:vitest -- test/unit/server/fresh-agent test/unit/client/components/fresh-agent test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts test/unit/server/ws-handler-sdk.test.ts test/unit/lib/visible-first-audit-contract.test.ts test/unit/lib/visible-first-audit-run-sample.test.ts test/unit/lib/visible-first-audit-scenarios.test.ts test/unit/lib/visible-first-audit-seed-browser-storage.test.ts` +Run: `npm run test:vitest -- test/integration/server/session-metadata-api.test.ts test/e2e` Run: `npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts` Expected: PASS From 1eec8771575e3df0bc589e2a837ff51dccf7d174 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 10:58:36 -0700 Subject: [PATCH 05/86] docs: tighten fresh agent implementation plan --- docs/plans/2026-04-18-fresh-agent-platform.md | 118 ++++++++++-------- 1 file changed, 64 insertions(+), 54 deletions(-) diff --git a/docs/plans/2026-04-18-fresh-agent-platform.md b/docs/plans/2026-04-18-fresh-agent-platform.md index 56f99f213..35d8b913b 100644 --- a/docs/plans/2026-04-18-fresh-agent-platform.md +++ b/docs/plans/2026-04-18-fresh-agent-platform.md @@ -21,6 +21,8 @@ - It did not call out [`server/platform-router.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/platform-router.ts:1), which is part of keeping hidden `kilroy` support wired through the platform feature flags. - It did not include the existing browser specs, Vitest e2e flows, context-menu tests, visible-first perf fixtures, and MCP help text that still hard-code `agent-chat` or `sdk.*`. An executor could finish the product code, hit the repo-wide verification gate, and then discover a second migration hidden in the test/tooling surface. - It was still too optimistic about “delete old `agent-chat` glue later”. Some of the old files are not merely legacy UI; they currently encode product-critical behavior that must be ported deliberately before deletion: restore hydration, question/approval state, plugin defaults, input history, and lost-session recovery. +- It still leaned too hard toward rebuilding the UI layer from scratch. The repo already contains reusable shared rendering pieces in `src/components/session/*` plus reusable diff and settings primitives. A plan that does not explicitly direct the executor to reuse and promote those pieces risks violating the user’s “reuse as much as possible” requirement and burning time on unnecessary rewrites. +- Its cleanup section was too deletion-oriented. Several files and tests listed as “modify/delete” are not dead weight; they are the current regression net for restore hydration, split-pane remounts, browser/mobile behavior, MCP tool help text, and visible-first performance contracts. Treating them as delete-first would cause avoidable backtracking and test dilution. ## Steady-State Product Behavior @@ -86,7 +88,6 @@ - `src/components/fresh-agent/FreshAgentApprovalBanner.tsx` - `src/components/fresh-agent/FreshAgentQuestionBanner.tsx` - `src/components/fresh-agent/FreshAgentDiffPanel.tsx` -- `src/components/fresh-agent/renderers/*` - `test/fixtures/fresh-agent/claude/*` - `test/fixtures/fresh-agent/codex/*` - `test/e2e-browser/specs/fresh-agent.spec.ts` @@ -134,12 +135,15 @@ - `src/lib/pane-title.ts` - `src/lib/pane-activity.ts` - `src/lib/session-utils.ts` +- `src/lib/input-history-store.ts` - `src/lib/tab-directory-preference.ts` - `src/lib/tab-registry-snapshot.ts` - `src/lib/ws-client.ts` - `src/lib/api.ts` - `src/lib/agent-chat-utils.ts` - `src/lib/agent-chat-types.ts` +- `src/components/session/MessageBubble.tsx` +- `src/components/session/ToolCallBlock.tsx` - `src/components/panes/PaneContainer.tsx` - `src/components/panes/PanePicker.tsx` - `src/components/Sidebar.tsx` @@ -157,34 +161,25 @@ - `src/components/MobileTabStrip.tsx` - `src/components/SettingsView.tsx` - `src/components/settings/WorkspaceSettings.tsx` +- `src/components/agent-chat/DiffView.tsx` - `server/mcp/freshell-tool.ts` - `docs/index.html` +- `test/unit/client/store/panesPersistence.test.ts` +- `test/unit/server/agent-layout-schema.test.ts` +- `test/unit/server/tabs-registry/types.test.ts` +- `test/integration/server/settings-api.test.ts` +- `test/integration/server/tabs-registry-store.persistence.test.ts` +- `test/integration/server/session-directory-router.test.ts` -### Delete Or Reduce To Shims +### Delete Only After Porting Behavior And Coverage - `src/store/agentChatSlice.ts` - `src/store/agentChatThunks.ts` - `src/store/agentChatTypes.ts` -- `src/components/agent-chat/*` - `src/lib/sdk-message-handler.ts` -- `test/e2e/agent-chat-*.test.tsx` -- `test/e2e/pane-activity-indicator-flow.test.tsx` -- `test/e2e/pane-header-runtime-meta-flow.test.tsx` -- `test/e2e/sidebar-click-opens-pane.test.tsx` -- `test/e2e/title-sync-flow.test.tsx` -- `test/e2e/tool-coalesce.test.tsx` -- `test/e2e-browser/specs/agent-chat*.spec.ts` -- `test/e2e-browser/specs/pane-activity-indicator.spec.ts` -- `test/e2e-browser/specs/tab-management.spec.ts` -- `test/e2e-browser/perf/*` -- `test/unit/client/components/agent-chat/*` -- `test/unit/client/components/ContextMenuProvider.test.tsx` -- `test/unit/client/components/SettingsView.agent-chat.test.tsx` -- `test/unit/client/components/context-menu/agent-chat-actions.test.ts` -- `test/unit/client/components/context-menu/menu-defs.test.ts` -- `test/unit/client/store/crossTabSync.test.ts` -- `test/unit/server/ws-handler-sdk.test.ts` -- any other `sdk.*` or `agent-chat` glue that no longer carries real behavior after cutover +- legacy `src/components/agent-chat/*` files that are provably dead after their behavior has been moved into `src/components/fresh-agent/*` or promoted shared primitives +- renamed or superseded test files only after the replacement tests cover the same restore, split-pane, session-lost, input-history, mobile, context-menu, and perf behaviors +- any other `sdk.*` or `agent-chat` glue that no longer carries real behavior after the new transport, persistence, and coverage are all green ## Strategy Gate @@ -192,6 +187,7 @@ The right path is a direct cutover to the final architecture, not another “gen - Claude has durable/live merge and restore semantics in `server/sdk-bridge.ts` and `server/agent-timeline/*`. - Codex already has a shared app-server runtime/client/planner in `server/coding-cli/codex-app-server/*`. +- The client already has reusable generalized rendering and event primitives in `src/components/session/*`, `src/components/agent-chat/DiffView.tsx`, `src/lib/input-history-store.ts`, and `src/lib/coding-cli-types.ts`. The plan therefore reuses both, renames the product domain to `fresh-agent`, migrates persistence/settings/restore surfaces first, and then lands one transport, one read model, one store, and one UI shell. That is the cleanest route to the requested end state and the only route that avoids another rewrite when `freshopencode` arrives. @@ -223,8 +219,11 @@ The plan therefore reuses both, renames the product domain to `fresh-agent`, mig - Test: `test/unit/client/store/persisted-state.fresh-agent.test.ts` - Test: `test/unit/client/store/storage-migration.fresh-agent.test.ts` - Test: `test/unit/client/store/crossTabSync.test.ts` +- Test: `test/unit/client/store/panesPersistence.test.ts` - Test: `test/unit/client/components/panes/PaneContainer.createContent.test.tsx` - Test: `test/unit/server/config-store.fresh-agent-settings.test.ts` +- Test: `test/integration/server/settings-api.test.ts` +- Test: `test/unit/server/agent-layout-schema.test.ts` - [ ] **Step 1: Identify or write the failing tests** @@ -282,7 +281,7 @@ it('preserves canonical resume identity when cross-tab sync rehydrates a fresh-a - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts` Expected: FAIL because the registry and migrations do not exist yet. - [ ] **Step 3: Write minimal implementation** @@ -297,10 +296,11 @@ Implement the shared vocabulary and migrations: - storage migration that preserves saved Freshell state instead of clearing it - pane content shape that stores `sessionType` explicitly instead of overloading `provider` - compatibility readers in `PaneContainer`, `TabContent`, `crossTabSync`, pane-title helpers, and local snapshot helpers so fresh-agent layouts can boot before the legacy client state is removed +- migration of browser preference and input-history surfaces that currently derive behavior from legacy `agent-chat` pane data - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** @@ -311,7 +311,7 @@ Refactor compatibility to one place only: - runtime readers accept `fresh-agent` first and tolerate legacy `agent-chat` only at bootstrap boundaries - `agent-chat` helper modules become thin compatibility exports or are queued for removal -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/client/store/tabsSlice.merge.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/unit/client/store/tabsSlice.merge.test.ts test/integration/server/settings-api.test.ts` Expected: PASS - [ ] **Step 6: Commit** @@ -547,6 +547,9 @@ git commit -m "feat: add codex rich runtime support" - Test: `test/unit/server/agent-api/layout-store.fresh-agent.test.ts` - Test: `test/unit/client/components/TabsView.fresh-agent.test.tsx` - Test: `test/unit/client/lib/api.test.ts` +- Test: `test/unit/server/tabs-registry/types.test.ts` +- Test: `test/integration/server/tabs-registry-store.persistence.test.ts` +- Test: `test/integration/server/session-directory-router.test.ts` - [ ] **Step 1: Identify or write the failing tests** @@ -567,7 +570,7 @@ it('serializes fresh-agent panes in remote layout snapshots and rehydrates them - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` Expected: FAIL because fresh-agent metadata and snapshot flows are not fully projected yet. - [ ] **Step 3: Write minimal implementation** @@ -579,17 +582,18 @@ Implement projection and metadata updates so that: - `sessionType` updates do not clobber `derivedTitle` - remote layout snapshots and registry records store `fresh-agent`, not `agent-chat` - session directory carries the metadata needed for fork, worktree, and subagent badges +- tabs-registry persistence and HTTP/session-directory routes expose the same migrated shape the UI hydrates - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** Keep `provider` and `sessionType` semantics separate everywhere. Do not smuggle UI identity back into filesystem or provider fields. -Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/session-directory/service.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx` +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/server/session-directory/service.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx` Expected: PASS - [ ] **Step 6: Commit** @@ -680,7 +684,6 @@ git commit -m "feat: add fresh agent client state" - Create: `src/components/fresh-agent/FreshAgentApprovalBanner.tsx` - Create: `src/components/fresh-agent/FreshAgentQuestionBanner.tsx` - Create: `src/components/fresh-agent/FreshAgentDiffPanel.tsx` -- Create: `src/components/fresh-agent/renderers/*` - Modify: `src/components/panes/PaneContainer.tsx` - Modify: `src/components/panes/PanePicker.tsx` - Modify: `src/components/Sidebar.tsx` @@ -698,6 +701,10 @@ git commit -m "feat: add fresh agent client state" - Modify: `src/lib/derivePaneTitle.ts` - Modify: `src/lib/pane-title.ts` - Modify: `src/lib/pane-activity.ts` +- Modify: `src/components/session/MessageBubble.tsx` +- Modify: `src/components/session/ToolCallBlock.tsx` +- Modify: `src/components/agent-chat/DiffView.tsx` +- Modify: `src/lib/input-history-store.ts` - Modify: `docs/index.html` - Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` - Test: `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx` @@ -738,6 +745,7 @@ Build the shared shell with: - approval and question banners reused across providers - shared composer supporting send, interrupt, fork, and capability-backed actions - mobile drawers or sheets for secondary panes +- promotion of reusable primitives instead of duplication: adapt `src/components/session/MessageBubble.tsx`, `src/components/session/ToolCallBlock.tsx`, and `src/components/agent-chat/DiffView.tsx` into provider-agnostic building blocks wherever possible - preserved Freshclaude features: plugin defaults, settings popover, input history, restore hydration, session-lost recovery, timecodes, show thinking and tools toggles - fresh-agent-aware context menus, pane badges, and resume-command affordances with no stale `agent-chat` target assumptions @@ -750,7 +758,7 @@ Expected: PASS - [ ] **Step 5: Refactor and verify** -Fold or delete old `src/components/agent-chat/*` pieces once stronger fresh-agent coverage exists. +Fold or delete old `src/components/agent-chat/*` pieces only when their behavior has been moved into `src/components/fresh-agent/*` or shared primitives and the corresponding tests have been ported without coverage loss. Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/Sidebar.render-stability.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts` Expected: PASS @@ -762,7 +770,7 @@ git add src/components/fresh-agent src/components/panes/PaneContainer.tsx src/co git commit -m "feat: ship shared fresh agent pane shell" ``` -### Task 8: Remove obsolete code, add browser e2e coverage, and run the full verification gate +### Task 8: Port remaining regression coverage, remove only provably dead code, and run the full verification gate **Files:** - Modify/Delete: `src/store/agentChatSlice.ts` @@ -770,28 +778,28 @@ git commit -m "feat: ship shared fresh agent pane shell" - Modify/Delete: `src/store/agentChatTypes.ts` - Modify/Delete: `src/components/agent-chat/*` - Modify/Delete: `src/lib/sdk-message-handler.ts` -- Modify/Delete: `server/mcp/freshell-tool.ts` -- Modify/Delete: `test/e2e/agent-chat-*.test.tsx` -- Modify/Delete: `test/e2e/pane-activity-indicator-flow.test.tsx` -- Modify/Delete: `test/e2e/pane-header-runtime-meta-flow.test.tsx` -- Modify/Delete: `test/e2e/sidebar-click-opens-pane.test.tsx` -- Modify/Delete: `test/e2e/title-sync-flow.test.tsx` -- Modify/Delete: `test/e2e/tool-coalesce.test.tsx` -- Modify/Delete: `test/e2e-browser/specs/agent-chat.spec.ts` -- Modify/Delete: `test/e2e-browser/specs/agent-chat-input-history.spec.ts` -- Modify/Delete: `test/e2e-browser/specs/pane-activity-indicator.spec.ts` -- Modify/Delete: `test/e2e-browser/specs/tab-management.spec.ts` -- Modify/Delete: `test/e2e-browser/perf/audit-contract.ts` -- Modify/Delete: `test/e2e-browser/perf/run-sample.ts` -- Modify/Delete: `test/e2e-browser/perf/scenarios.ts` -- Modify/Delete: `test/e2e-browser/perf/seed-browser-storage.ts` -- Modify/Delete: `test/unit/client/components/agent-chat/*` -- Modify/Delete: `test/unit/client/components/ContextMenuProvider.test.tsx` -- Modify/Delete: `test/unit/client/components/SettingsView.agent-chat.test.tsx` -- Modify/Delete: `test/unit/client/components/context-menu/agent-chat-actions.test.ts` -- Modify/Delete: `test/unit/client/components/context-menu/menu-defs.test.ts` -- Modify/Delete: `test/unit/client/store/crossTabSync.test.ts` -- Modify/Delete: `test/unit/server/ws-handler-sdk.test.ts` +- Modify: `server/mcp/freshell-tool.ts` +- Modify/Rename: `test/e2e/agent-chat-*.test.tsx` +- Modify/Rename: `test/e2e/pane-activity-indicator-flow.test.tsx` +- Modify/Rename: `test/e2e/pane-header-runtime-meta-flow.test.tsx` +- Modify/Rename: `test/e2e/sidebar-click-opens-pane.test.tsx` +- Modify/Rename: `test/e2e/title-sync-flow.test.tsx` +- Modify/Rename: `test/e2e/tool-coalesce.test.tsx` +- Modify/Rename: `test/e2e-browser/specs/agent-chat.spec.ts` +- Modify/Rename: `test/e2e-browser/specs/agent-chat-input-history.spec.ts` +- Modify/Rename: `test/e2e-browser/specs/pane-activity-indicator.spec.ts` +- Modify/Rename: `test/e2e-browser/specs/tab-management.spec.ts` +- Modify: `test/e2e-browser/perf/audit-contract.ts` +- Modify: `test/e2e-browser/perf/run-sample.ts` +- Modify: `test/e2e-browser/perf/scenarios.ts` +- Modify: `test/e2e-browser/perf/seed-browser-storage.ts` +- Modify/Rename: `test/unit/client/components/agent-chat/*` +- Modify: `test/unit/client/components/ContextMenuProvider.test.tsx` +- Modify/Rename: `test/unit/client/components/SettingsView.agent-chat.test.tsx` +- Modify/Rename: `test/unit/client/components/context-menu/agent-chat-actions.test.ts` +- Modify: `test/unit/client/components/context-menu/menu-defs.test.ts` +- Modify: `test/unit/client/store/crossTabSync.test.ts` +- Modify/Rename: `test/unit/server/ws-handler-sdk.test.ts` - Create: `test/e2e-browser/specs/fresh-agent.spec.ts` - Create: `test/e2e-browser/specs/fresh-agent-mobile.spec.ts` - Modify: existing Playwright helpers only if needed @@ -819,11 +827,11 @@ Expected: FAIL because the browser flows are not fully wired yet. - [ ] **Step 3: Write minimal implementation** -Delete obsolete `agent-chat` and legacy `sdk.*` client glue only after the new browser flows pass. Port or rename every existing browser spec, Vitest e2e flow, visible-first perf fixture, context-menu test, and MCP/tooling string that still encodes `agent-chat` or `sdk.*`; keep only compatibility code required for persisted migration or legacy setting reads. +Port or rename every existing browser spec, Vitest e2e flow, visible-first perf fixture, context-menu test, and MCP/tooling string that still encodes `agent-chat` or `sdk.*`; keep or adapt the coverage until the replacement tests prove the same behaviors in the fresh-agent world. Delete obsolete client glue only after the replacement coverage and browser flows pass. - [ ] **Step 4: Run tests to verify targeted suites pass** -Run: `npm run test:vitest -- test/unit/server/fresh-agent test/unit/client/components/fresh-agent test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts test/unit/server/ws-handler-sdk.test.ts test/unit/lib/visible-first-audit-contract.test.ts test/unit/lib/visible-first-audit-run-sample.test.ts test/unit/lib/visible-first-audit-scenarios.test.ts test/unit/lib/visible-first-audit-seed-browser-storage.test.ts` +Run: `npm run test:vitest -- test/unit/server/fresh-agent test/unit/client/components/fresh-agent test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts test/unit/server/ws-handler-sdk.test.ts test/unit/lib/visible-first-audit-contract.test.ts test/unit/lib/visible-first-audit-run-sample.test.ts test/unit/lib/visible-first-audit-scenarios.test.ts test/unit/lib/visible-first-audit-seed-browser-storage.test.ts test/e2e/agent-chat-restore-flow.test.tsx test/e2e/agent-chat-resume-history-flow.test.tsx test/e2e/agent-chat-context-menu-flow.test.tsx test/e2e/agent-chat-input-history-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/title-sync-flow.test.tsx` Run: `npm run test:vitest -- test/integration/server/session-metadata-api.test.ts test/e2e` Run: `npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts` Expected: PASS @@ -864,5 +872,7 @@ Do not ship `freshopencode` here. Do ensure the final architecture already suppo - Preserve `kilroy` as a hidden Claude-backed fresh-agent type. - Preserve saved tabs and settings; do not “solve” migration by clearing local storage. - Update remote snapshot and tab registry code in the same migration as local pane persistence. +- Reuse and promote existing shared renderer primitives before creating new ones. +- Port existing regression coverage forward; do not delete a test unless its behavior is demonstrably covered elsewhere. - Use Playwright for browser and mobile e2e, not vitest-only pseudo-e2e files. - Broad runs go through the test coordinator. Check `npm run test:status` before them. From 922e4bbdbb0afdae4ba2d3ccf8ee0927242b3821 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 11:04:40 -0700 Subject: [PATCH 06/86] docs: tighten fresh agent platform plan --- docs/plans/2026-04-18-fresh-agent-platform.md | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-04-18-fresh-agent-platform.md b/docs/plans/2026-04-18-fresh-agent-platform.md index 35d8b913b..939aeb517 100644 --- a/docs/plans/2026-04-18-fresh-agent-platform.md +++ b/docs/plans/2026-04-18-fresh-agent-platform.md @@ -18,6 +18,7 @@ - It did not include the layout bootstrap path through [`src/components/TabContent.tsx`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/components/TabContent.tsx:1), [`src/lib/tab-directory-preference.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/lib/tab-directory-preference.ts:1), and [`src/store/paneTreeValidation.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/paneTreeValidation.ts:1). Those still special-case `agent-chat`. - It sequenced the persistence cutover too early. If an executor migrates stored panes from `agent-chat` to `fresh-agent` before `PaneContainer`, `TabContent`, `crossTabSync`, pane-title helpers, and local snapshot parsing can read the new shape, the next reload or cross-tab hydrate will strand rich panes as unknown content. - It did not call out [`src/store/selectors/sidebarSelectors.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/selectors/sidebarSelectors.ts:1) and related session metadata helpers, which are where `provider` and `sessionType` semantics get merged for history/sidebar rendering. Missing them would reintroduce the wrong identity model after the store cutover. +- It did not call out [`server/coding-cli/session-indexer.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/coding-cli/session-indexer.ts:1) and [`server/coding-cli/types.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/coding-cli/types.ts:1), which are where stored session metadata, derived titles, and indexed Codex runtime metadata get merged before the session directory and sidebar consume them. Leaving that seam out would make the migration look complete in storage while the user-visible projections stayed wrong. - It did not call out [`server/platform-router.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/platform-router.ts:1), which is part of keeping hidden `kilroy` support wired through the platform feature flags. - It did not include the existing browser specs, Vitest e2e flows, context-menu tests, visible-first perf fixtures, and MCP help text that still hard-code `agent-chat` or `sdk.*`. An executor could finish the product code, hit the repo-wide verification gate, and then discover a second migration hidden in the test/tooling surface. - It was still too optimistic about “delete old `agent-chat` glue later”. Some of the old files are not merely legacy UI; they currently encode product-critical behavior that must be ported deliberately before deletion: restore hydration, question/approval state, plugin defaults, input history, and lost-session recovery. @@ -224,6 +225,7 @@ The plan therefore reuses both, renames the product domain to `fresh-agent`, mig - Test: `test/unit/server/config-store.fresh-agent-settings.test.ts` - Test: `test/integration/server/settings-api.test.ts` - Test: `test/unit/server/agent-layout-schema.test.ts` +- Test: `test/integration/server/platform-api.test.ts` - [ ] **Step 1: Identify or write the failing tests** @@ -281,7 +283,7 @@ it('preserves canonical resume identity when cross-tab sync rehydrates a fresh-a - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts` Expected: FAIL because the registry and migrations do not exist yet. - [ ] **Step 3: Write minimal implementation** @@ -300,7 +302,7 @@ Implement the shared vocabulary and migrations: - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** @@ -311,7 +313,7 @@ Refactor compatibility to one place only: - runtime readers accept `fresh-agent` first and tolerate legacy `agent-chat` only at bootstrap boundaries - `agent-chat` helper modules become thin compatibility exports or are queued for removal -Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/unit/client/store/tabsSlice.merge.test.ts test/integration/server/settings-api.test.ts` +Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/unit/client/store/tabsSlice.merge.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts` Expected: PASS - [ ] **Step 6: Commit** @@ -529,6 +531,8 @@ git commit -m "feat: add codex rich runtime support" - Modify: `server/session-directory/projection.ts` - Modify: `server/session-directory/service.ts` - Modify: `server/session-directory/types.ts` +- Modify: `server/coding-cli/session-indexer.ts` +- Modify: `server/coding-cli/types.ts` - Modify: `server/sessions-router.ts` - Modify: `server/session-metadata-store.ts` - Modify: `server/agent-api/layout-store.ts` @@ -543,6 +547,8 @@ git commit -m "feat: add codex rich runtime support" - Modify: `src/components/TabsView.tsx` - Modify: `src/store/selectors/sidebarSelectors.ts` - Test: `test/unit/server/session-directory/fresh-agent-projection.test.ts` +- Test: `test/unit/server/coding-cli/session-indexer.test.ts` +- Test: `test/unit/server/session-metadata-store.test.ts` - Test: `test/integration/server/session-metadata-api.test.ts` - Test: `test/unit/server/agent-api/layout-store.fresh-agent.test.ts` - Test: `test/unit/client/components/TabsView.fresh-agent.test.tsx` @@ -566,11 +572,18 @@ it('serializes fresh-agent panes in remote layout snapshots and rehydrates them expect(snapshot.panes[0]).toMatchObject({ kind: 'fresh-agent' }) expect(restored.content).toMatchObject({ kind: 'fresh-agent', sessionType: 'freshclaude' }) }) + +it('projects fresh sessionType and codex runtime metadata through the indexed session directory snapshot', async () => { + expect(projects[0]?.sessions[0]).toMatchObject({ + sessionType: 'freshcodex', + isSubagent: true, + }) +}) ``` - [ ] **Step 2: Run tests to verify they fail** -Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` Expected: FAIL because fresh-agent metadata and snapshot flows are not fully projected yet. - [ ] **Step 3: Write minimal implementation** @@ -580,26 +593,27 @@ Implement projection and metadata updates so that: - sidebar and history pages show `freshclaude`, `freshcodex`, and `kilroy` correctly - resume actions rebuild `kind: 'fresh-agent'` panes - `sessionType` updates do not clobber `derivedTitle` +- coding-cli indexing merges `sessionType`, derived titles, and Codex task metadata into the same summaries the session directory and sidebar render - remote layout snapshots and registry records store `fresh-agent`, not `agent-chat` - session directory carries the metadata needed for fork, worktree, and subagent badges - tabs-registry persistence and HTTP/session-directory routes expose the same migrated shape the UI hydrates - [ ] **Step 4: Run tests to verify they pass** -Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts` Expected: PASS - [ ] **Step 5: Refactor and verify** Keep `provider` and `sessionType` semantics separate everywhere. Do not smuggle UI identity back into filesystem or provider fields. -Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/server/session-directory/service.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx` +Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/server/session-directory/service.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx` Expected: PASS - [ ] **Step 6: Commit** ```bash -git add server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/sessions-router.ts server/session-metadata-store.ts server/agent-api/layout-store.ts server/tabs-registry/types.ts src/store/tabRegistryTypes.ts src/lib/api.ts src/lib/session-metadata.ts src/lib/session-type-utils.ts src/lib/tab-directory-preference.ts src/lib/tab-registry-snapshot.ts src/components/TabContent.tsx src/components/TabsView.tsx src/store/selectors/sidebarSelectors.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts +git add server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/coding-cli/session-indexer.ts server/coding-cli/types.ts server/sessions-router.ts server/session-metadata-store.ts server/agent-api/layout-store.ts server/tabs-registry/types.ts src/store/tabRegistryTypes.ts src/lib/api.ts src/lib/session-metadata.ts src/lib/session-type-utils.ts src/lib/tab-directory-preference.ts src/lib/tab-registry-snapshot.ts src/components/TabContent.tsx src/components/TabsView.tsx src/store/selectors/sidebarSelectors.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts git commit -m "feat: project fresh agent sessions through metadata and snapshots" ``` From c658be69ba2fbe578083e9dd091234de2e419b98 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 11:18:25 -0700 Subject: [PATCH 07/86] docs: tighten fresh agent plan review fixes --- docs/plans/2026-04-18-fresh-agent-platform.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-04-18-fresh-agent-platform.md b/docs/plans/2026-04-18-fresh-agent-platform.md index 939aeb517..18a0d21ed 100644 --- a/docs/plans/2026-04-18-fresh-agent-platform.md +++ b/docs/plans/2026-04-18-fresh-agent-platform.md @@ -254,6 +254,8 @@ it('migrates legacy settings.agentChat to settings.freshAgent', () => { }) it('does not clear freshell layout storage during the fresh-agent migration', () => { + // Use the real layout-storage key from storage-migration.ts in the implementation test. + // The literal below is illustrative only. localStorage.setItem('freshell.layout.v3', '{"version":3}') runStorageMigration() expect(localStorage.getItem('freshell.layout.v3')).toBe('{"version":3}') @@ -319,7 +321,7 @@ Expected: PASS - [ ] **Step 6: Commit** ```bash -git add shared/fresh-agent.ts src/lib/fresh-agent-registry.ts shared/settings.ts server/config-store.ts server/platform-router.ts src/store/settingsSlice.ts src/store/settingsThunks.ts src/store/browserPreferencesPersistence.ts src/store/storage-migration.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/store/crossTabSync.ts src/store/paneTreeValidation.ts src/lib/agent-chat-utils.ts src/lib/agent-chat-types.ts src/lib/pane-title.ts src/components/panes/PaneContainer.tsx src/components/TabContent.tsx src/lib/tab-registry-snapshot.ts test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts +git add shared/fresh-agent.ts src/lib/fresh-agent-registry.ts shared/settings.ts server/config-store.ts server/platform-router.ts src/store/settingsSlice.ts src/store/settingsThunks.ts src/store/browserPreferencesPersistence.ts src/store/storage-migration.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/store/crossTabSync.ts src/store/paneTreeValidation.ts src/lib/agent-chat-utils.ts src/lib/agent-chat-types.ts src/lib/pane-title.ts src/components/panes/PaneContainer.tsx src/components/TabContent.tsx src/lib/tab-registry-snapshot.ts test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts git commit -m "refactor: rename rich pane domain to fresh agent" ``` @@ -417,7 +419,7 @@ it('merges ledger-backed restore state and live stream into one canonical snapsh it('preserves plugin defaults and mid-session model/permission changes through the claude adapter', async () => { expect(adapter.updateSessionSettings).toHaveBeenCalledWith(expect.objectContaining({ defaultPlugins: ['/tmp/plugin'], - model: 'claude-sonnet-4-20250514', + model: expect.any(String), permissionMode: 'plan', })) }) @@ -565,7 +567,7 @@ Cover the resume and snapshot contract: it('keeps derivedTitle when sessionType is updated to freshcodex', async () => { await store.set('codex', 'sess-1', { derivedTitle: 'Sticky title' }) await request(app).post('/api/session-metadata').send({ provider: 'codex', sessionId: 'sess-1', sessionType: 'freshcodex' }) - expect(await store.get('codex', 'sess-1')).toEqual({ derivedTitle: 'Sticky title', sessionType: 'freshcodex' }) + expect(await store.get('codex', 'sess-1')).toMatchObject({ derivedTitle: 'Sticky title', sessionType: 'freshcodex' }) }) it('serializes fresh-agent panes in remote layout snapshots and rehydrates them back into fresh-agent panes', () => { @@ -613,7 +615,7 @@ Expected: PASS - [ ] **Step 6: Commit** ```bash -git add server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/coding-cli/session-indexer.ts server/coding-cli/types.ts server/sessions-router.ts server/session-metadata-store.ts server/agent-api/layout-store.ts server/tabs-registry/types.ts src/store/tabRegistryTypes.ts src/lib/api.ts src/lib/session-metadata.ts src/lib/session-type-utils.ts src/lib/tab-directory-preference.ts src/lib/tab-registry-snapshot.ts src/components/TabContent.tsx src/components/TabsView.tsx src/store/selectors/sidebarSelectors.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts +git add server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/coding-cli/session-indexer.ts server/coding-cli/types.ts server/sessions-router.ts server/session-metadata-store.ts server/agent-api/layout-store.ts server/tabs-registry/types.ts src/store/tabRegistryTypes.ts src/lib/api.ts src/lib/session-metadata.ts src/lib/session-type-utils.ts src/lib/tab-directory-preference.ts src/lib/tab-registry-snapshot.ts src/components/TabContent.tsx src/components/TabsView.tsx src/store/selectors/sidebarSelectors.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts git commit -m "feat: project fresh agent sessions through metadata and snapshots" ``` From 9c9225313de2b1ee31a6bfd2a7b6384197f10d41 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 11:23:57 -0700 Subject: [PATCH 08/86] docs: tighten fresh agent plan review findings --- docs/plans/2026-04-18-fresh-agent-platform.md | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-04-18-fresh-agent-platform.md b/docs/plans/2026-04-18-fresh-agent-platform.md index 18a0d21ed..b5fcdf2c7 100644 --- a/docs/plans/2026-04-18-fresh-agent-platform.md +++ b/docs/plans/2026-04-18-fresh-agent-platform.md @@ -197,6 +197,7 @@ The plan therefore reuses both, renames the product domain to `fresh-agent`, mig **Files:** - Create: `shared/fresh-agent.ts` - Create: `src/lib/fresh-agent-registry.ts` +- Create: `src/lib/fresh-agent-capabilities.ts` - Modify: `shared/settings.ts` - Modify: `server/config-store.ts` - Modify: `server/platform-router.ts` @@ -225,6 +226,7 @@ The plan therefore reuses both, renames the product domain to `fresh-agent`, mig - Test: `test/unit/server/config-store.fresh-agent-settings.test.ts` - Test: `test/integration/server/settings-api.test.ts` - Test: `test/unit/server/agent-layout-schema.test.ts` +- Test: `test/unit/client/store/tabsSlice.merge.test.ts` - Test: `test/integration/server/platform-api.test.ts` - [ ] **Step 1: Identify or write the failing tests** @@ -297,7 +299,7 @@ Implement the shared vocabulary and migrations: - registry entries for `freshclaude`, `freshcodex`, hidden `kilroy`, and disabled `freshopencode` - persisted layout migration from `kind: 'agent-chat'` to `kind: 'fresh-agent'` - server/local settings migration from `agentChat` to `freshAgent`, still accepting legacy input -- storage migration that preserves saved Freshell state instead of clearing it +- storage migration that preserves saved Freshell state instead of clearing it by rewriting the persisted rich-pane/settings payloads before any incompatible-version clear path runs; do not preserve data by skipping migration or suppressing future version bumps - pane content shape that stores `sessionType` explicitly instead of overloading `provider` - compatibility readers in `PaneContainer`, `TabContent`, `crossTabSync`, pane-title helpers, and local snapshot helpers so fresh-agent layouts can boot before the legacy client state is removed - migration of browser preference and input-history surfaces that currently derive behavior from legacy `agent-chat` pane data @@ -321,8 +323,14 @@ Expected: PASS - [ ] **Step 6: Commit** ```bash -git add shared/fresh-agent.ts src/lib/fresh-agent-registry.ts shared/settings.ts server/config-store.ts server/platform-router.ts src/store/settingsSlice.ts src/store/settingsThunks.ts src/store/browserPreferencesPersistence.ts src/store/storage-migration.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/store/crossTabSync.ts src/store/paneTreeValidation.ts src/lib/agent-chat-utils.ts src/lib/agent-chat-types.ts src/lib/pane-title.ts src/components/panes/PaneContainer.tsx src/components/TabContent.tsx src/lib/tab-registry-snapshot.ts test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts -git commit -m "refactor: rename rich pane domain to fresh agent" +git add shared/fresh-agent.ts src/lib/fresh-agent-registry.ts shared/settings.ts server/config-store.ts server/platform-router.ts src/store/settingsSlice.ts src/store/settingsThunks.ts src/lib/agent-chat-utils.ts src/lib/agent-chat-types.ts test/unit/shared/fresh-agent-registry.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts +git commit -m "refactor: add fresh agent registry and settings vocabulary" + +git add src/store/browserPreferencesPersistence.ts src/store/storage-migration.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/store/crossTabSync.ts src/store/paneTreeValidation.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/server/agent-layout-schema.test.ts test/unit/client/store/tabsSlice.merge.test.ts +git commit -m "refactor: migrate fresh agent persistence surfaces" + +git add src/lib/pane-title.ts src/components/panes/PaneContainer.tsx src/components/TabContent.tsx src/lib/tab-registry-snapshot.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx +git commit -m "refactor: add fresh agent bootstrap compatibility" ``` ### Task 2: Build the shared fresh-agent transport and normalized read-model contract @@ -368,7 +376,7 @@ Implement: - provider registry lookup from `sessionType` - runtime manager operations: `create`, `resume`, `subscribe`, `send`, `interrupt`, `fork`, `answerQuestion`, `resolveApproval` - normalized snapshot/page/body read-model types -- `freshAgent.*` WS messages and `/api/fresh-agent/threads/...` routes +- `freshAgent.*` WS messages and `/api/fresh-agent/threads/...` routes, using a dedicated fresh-agent namespace instead of overloading terminal envelopes - explicit error taxonomy for runtime unavailable, stale revision, unsupported capability, and lost session Keep terminal WebSocket behavior untouched. @@ -395,6 +403,7 @@ git commit -m "feat: add fresh agent transport and read models" ### Task 3: Move Claude runtime behavior behind the Claude fresh-agent adapter **Files:** +- Create: `test/fixtures/fresh-agent/claude/*` - Create: `server/fresh-agent/adapters/claude/adapter.ts` - Create: `server/fresh-agent/adapters/claude/normalize.ts` - Modify: `server/sdk-bridge.ts` @@ -464,6 +473,7 @@ git commit -m "refactor: move claude runtime behind fresh agent adapter" ### Task 4: Extend the existing Codex app-server stack for rich Freshcodex sessions **Files:** +- Create: `test/fixtures/fresh-agent/codex/*` - Create: `server/fresh-agent/adapters/codex/adapter.ts` - Create: `server/fresh-agent/adapters/codex/normalize.ts` - Modify: `server/coding-cli/codex-app-server/protocol.ts` @@ -539,6 +549,9 @@ git commit -m "feat: add codex rich runtime support" - Modify: `server/session-metadata-store.ts` - Modify: `server/agent-api/layout-store.ts` - Modify: `server/tabs-registry/types.ts` +- Modify: `src/store/tabRegistryConstants.ts` +- Modify: `src/store/tabRegistrySlice.ts` +- Modify: `src/store/tabRegistrySync.ts` - Modify: `src/store/tabRegistryTypes.ts` - Modify: `src/lib/api.ts` - Modify: `src/lib/session-metadata.ts` @@ -599,6 +612,7 @@ Implement projection and metadata updates so that: - remote layout snapshots and registry records store `fresh-agent`, not `agent-chat` - session directory carries the metadata needed for fork, worktree, and subagent badges - tabs-registry persistence and HTTP/session-directory routes expose the same migrated shape the UI hydrates +- tab-registry constants, sync reducers, and selectors continue to round-trip the migrated `sessionType` and `fresh-agent` pane shape with no legacy `agent-chat` assumptions left behind - [ ] **Step 4: Run tests to verify they pass** @@ -615,13 +629,14 @@ Expected: PASS - [ ] **Step 6: Commit** ```bash -git add server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/coding-cli/session-indexer.ts server/coding-cli/types.ts server/sessions-router.ts server/session-metadata-store.ts server/agent-api/layout-store.ts server/tabs-registry/types.ts src/store/tabRegistryTypes.ts src/lib/api.ts src/lib/session-metadata.ts src/lib/session-type-utils.ts src/lib/tab-directory-preference.ts src/lib/tab-registry-snapshot.ts src/components/TabContent.tsx src/components/TabsView.tsx src/store/selectors/sidebarSelectors.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts +git add server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/coding-cli/session-indexer.ts server/coding-cli/types.ts server/sessions-router.ts server/session-metadata-store.ts server/agent-api/layout-store.ts server/tabs-registry/types.ts src/store/tabRegistryConstants.ts src/store/tabRegistrySlice.ts src/store/tabRegistrySync.ts src/store/tabRegistryTypes.ts src/lib/api.ts src/lib/session-metadata.ts src/lib/session-type-utils.ts src/lib/tab-directory-preference.ts src/lib/tab-registry-snapshot.ts src/components/TabContent.tsx src/components/TabsView.tsx src/store/selectors/sidebarSelectors.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts git commit -m "feat: project fresh agent sessions through metadata and snapshots" ``` ### Task 6: Replace client state and WebSocket handling with the fresh-agent store **Files:** +- Create: `src/lib/fresh-agent-capabilities.ts` - Create: `src/lib/fresh-agent-ws.ts` - Create: `src/store/freshAgentTypes.ts` - Create: `src/store/freshAgentSlice.ts` @@ -666,6 +681,7 @@ Implement the normalized client layer: - revision-safe snapshot, page, and body hydration - WS handling for `freshAgent.*` - pending approvals, questions, and action errors in shared state +- capability helpers that normalize provider-backed actions into one client-facing surface for the shared shell - resume identity helpers that work for Claude and Codex without Claude-only assumptions - pane activity and tab switcher wiring that no longer read from `agentChat` @@ -810,12 +826,16 @@ git commit -m "feat: ship shared fresh agent pane shell" - Modify: `test/e2e-browser/perf/scenarios.ts` - Modify: `test/e2e-browser/perf/seed-browser-storage.ts` - Modify/Rename: `test/unit/client/components/agent-chat/*` +- Modify: `test/unit/client/components/HistoryView.mobile.test.tsx` - Modify: `test/unit/client/components/ContextMenuProvider.test.tsx` - Modify/Rename: `test/unit/client/components/SettingsView.agent-chat.test.tsx` - Modify/Rename: `test/unit/client/components/context-menu/agent-chat-actions.test.ts` - Modify: `test/unit/client/components/context-menu/menu-defs.test.ts` - Modify: `test/unit/client/store/crossTabSync.test.ts` +- Modify: `test/unit/client/lib/sdk-message-handler.session-lost.test.ts` +- Modify: `test/unit/client/ws-client-sdk.test.ts` - Modify/Rename: `test/unit/server/ws-handler-sdk.test.ts` +- Modify: `test/server/ws-sidebar-snapshot-refresh.test.ts` - Create: `test/e2e-browser/specs/fresh-agent.spec.ts` - Create: `test/e2e-browser/specs/fresh-agent-mobile.spec.ts` - Modify: existing Playwright helpers only if needed @@ -843,11 +863,11 @@ Expected: FAIL because the browser flows are not fully wired yet. - [ ] **Step 3: Write minimal implementation** -Port or rename every existing browser spec, Vitest e2e flow, visible-first perf fixture, context-menu test, and MCP/tooling string that still encodes `agent-chat` or `sdk.*`; keep or adapt the coverage until the replacement tests prove the same behaviors in the fresh-agent world. Delete obsolete client glue only after the replacement coverage and browser flows pass. +Port or rename every existing browser spec, Vitest e2e flow, visible-first perf fixture, context-menu test, and MCP/tooling string that still encodes `agent-chat` or `sdk.*`; keep or adapt the coverage until the replacement tests prove the same behaviors in the fresh-agent world. Move `src/lib/sdk-message-handler.ts` session-lost, orphan-create, and reconnect handling into `src/lib/fresh-agent-ws.ts` and the fresh-agent thunks before deleting it. Delete obsolete client glue only after the replacement coverage and browser flows pass. - [ ] **Step 4: Run tests to verify targeted suites pass** -Run: `npm run test:vitest -- test/unit/server/fresh-agent test/unit/client/components/fresh-agent test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts test/unit/server/ws-handler-sdk.test.ts test/unit/lib/visible-first-audit-contract.test.ts test/unit/lib/visible-first-audit-run-sample.test.ts test/unit/lib/visible-first-audit-scenarios.test.ts test/unit/lib/visible-first-audit-seed-browser-storage.test.ts test/e2e/agent-chat-restore-flow.test.tsx test/e2e/agent-chat-resume-history-flow.test.tsx test/e2e/agent-chat-context-menu-flow.test.tsx test/e2e/agent-chat-input-history-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/title-sync-flow.test.tsx` +Run: `npm run test:vitest -- test/unit/server/fresh-agent test/unit/client/components/fresh-agent test/unit/client/components/HistoryView.mobile.test.tsx test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/lib/sdk-message-handler.session-lost.test.ts test/unit/client/ws-client-sdk.test.ts test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts test/unit/server/ws-handler-sdk.test.ts test/server/ws-sidebar-snapshot-refresh.test.ts test/unit/lib/visible-first-audit-contract.test.ts test/unit/lib/visible-first-audit-run-sample.test.ts test/unit/lib/visible-first-audit-scenarios.test.ts test/unit/lib/visible-first-audit-seed-browser-storage.test.ts test/e2e/agent-chat-restore-flow.test.tsx test/e2e/agent-chat-resume-history-flow.test.tsx test/e2e/agent-chat-context-menu-flow.test.tsx test/e2e/agent-chat-input-history-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/title-sync-flow.test.tsx` Run: `npm run test:vitest -- test/integration/server/session-metadata-api.test.ts test/e2e` Run: `npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts` Expected: PASS @@ -867,7 +887,7 @@ If a valid check fails, continue fixing the code. Do not weaken or delete good t - [ ] **Step 6: Commit** ```bash -git add -A +git add server/mcp/freshell-tool.ts src/store/agentChatSlice.ts src/store/agentChatThunks.ts src/store/agentChatTypes.ts src/components/agent-chat src/lib/sdk-message-handler.ts test/e2e/agent-chat-restore-flow.test.tsx test/e2e/agent-chat-resume-history-flow.test.tsx test/e2e/agent-chat-context-menu-flow.test.tsx test/e2e/agent-chat-input-history-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/sidebar-click-opens-pane.test.tsx test/e2e/title-sync-flow.test.tsx test/e2e/tool-coalesce.test.tsx test/e2e-browser/specs/agent-chat.spec.ts test/e2e-browser/specs/agent-chat-input-history.spec.ts test/e2e-browser/specs/pane-activity-indicator.spec.ts test/e2e-browser/specs/tab-management.spec.ts test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts test/e2e-browser/perf/audit-contract.ts test/e2e-browser/perf/run-sample.ts test/e2e-browser/perf/scenarios.ts test/e2e-browser/perf/seed-browser-storage.ts test/unit/client/components/agent-chat test/unit/client/components/HistoryView.mobile.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/SettingsView.agent-chat.test.tsx test/unit/client/components/context-menu/agent-chat-actions.test.ts test/unit/client/components/context-menu/menu-defs.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/lib/sdk-message-handler.session-lost.test.ts test/unit/client/ws-client-sdk.test.ts test/unit/server/ws-handler-sdk.test.ts test/server/ws-sidebar-snapshot-refresh.test.ts git commit -m "refactor: remove legacy agent chat architecture" ``` From 311566dfaa90c4768a9c2078f0943bd4b95e4103 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 11:26:11 -0700 Subject: [PATCH 09/86] docs: resolve final fresheyes nits --- docs/plans/2026-04-18-fresh-agent-platform.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-04-18-fresh-agent-platform.md b/docs/plans/2026-04-18-fresh-agent-platform.md index b5fcdf2c7..f4ba5b95a 100644 --- a/docs/plans/2026-04-18-fresh-agent-platform.md +++ b/docs/plans/2026-04-18-fresh-agent-platform.md @@ -197,7 +197,6 @@ The plan therefore reuses both, renames the product domain to `fresh-agent`, mig **Files:** - Create: `shared/fresh-agent.ts` - Create: `src/lib/fresh-agent-registry.ts` -- Create: `src/lib/fresh-agent-capabilities.ts` - Modify: `shared/settings.ts` - Modify: `server/config-store.ts` - Modify: `server/platform-router.ts` @@ -841,6 +840,8 @@ git commit -m "feat: ship shared fresh agent pane shell" - Modify: existing Playwright helpers only if needed - Test: all targeted unit, integration, and e2e suites below +Rename or consolidate the old browser specs into `fresh-agent.spec.ts` and `fresh-agent-mobile.spec.ts` unless a surviving split is clearly better, in which case keep the replacement names on a `fresh-agent-*` pattern instead of leaving `agent-chat` names behind. + - [ ] **Step 1: Identify or write the failing tests** Add the browser-level proof of the requested outcome: From a952b7be8dd83651d7e17c1691bd1221f570f996 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 11:36:56 -0700 Subject: [PATCH 10/86] docs: add fresh agent platform test plan --- ...26-04-18-fresh-agent-platform-test-plan.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/plans/2026-04-18-fresh-agent-platform-test-plan.md diff --git a/docs/plans/2026-04-18-fresh-agent-platform-test-plan.md b/docs/plans/2026-04-18-fresh-agent-platform-test-plan.md new file mode 100644 index 000000000..92eb37188 --- /dev/null +++ b/docs/plans/2026-04-18-fresh-agent-platform-test-plan.md @@ -0,0 +1,210 @@ +# Fresh Agent Platform Test Plan + +The agreed testing strategy still holds after reconciling it against [2026-04-18-fresh-agent-platform.md](/home/user/code/freshell/.worktrees/fresh-agent-platform/docs/plans/2026-04-18-fresh-agent-platform.md:1). The plan expands the interaction surface beyond the earlier high-level summary, but it does not require paid APIs, external infrastructure, or manual validation. The main adjustment is emphasis: acceptance has to be led by the real migration and user-facing surfaces in this order: persisted layout/settings migration, remote snapshot and session-directory projection, fresh-agent WS and HTTP transport, rendered shared pane behavior, then browser flows for create/resume/fork/mobile. + +## Harness requirements + +- `fresh-agent-route-harness` + What it does: extends the existing `read-model-route-harness` / `supertest` route coverage to mount `/api/fresh-agent/threads/...` routes with revision-aware snapshot, page, and turn-body handlers. + Exposes: HTTP status/body assertions, revision conflict injection, lane/scheduler observation, route call logs. + Estimated complexity: low-medium. + Tests depending on it: 5, 6, 7. + +- `fresh-agent-ws-harness` + What it does: extends the existing `protocol-harness` / `WsHandler` integration setup so tests can send and observe `freshAgent.*` messages alongside legacy terminal traffic. + Exposes: ordered outbound WS messages, adapter call capture, reconnect and lost-session simulation, ready-handshake transcript. + Estimated complexity: medium. + Tests depending on it: 4, 8, 9, 15. + +- `adapter-fixture-harness` + What it does: loads recorded Claude ledger fixtures and Codex app-server fixtures through the real normalization path, then exposes normalized snapshots/pages/bodies to server and client tests. + Exposes: deterministic provider fixtures, normalized thread snapshots, extension payloads, capability flags, fork/worktree/subagent metadata. + Estimated complexity: medium. + Tests depending on it: 10, 11, 12, 13, 14, 16. + +- `rendered-fresh-agent-app` + What it does: extends the existing RTL app and pane harnesses so a `fresh-agent` pane can be rendered with the real reducers, tabs/panes persistence, fresh-agent store, and context-menu wiring. + Exposes: rendered transcript/composer/banners, tab and pane state, persisted layout state, outbound WS/API calls. + Estimated complexity: medium. + Tests depending on it: 1, 2, 3, 8, 9, 12, 13, 14. + +- `playwright-fresh-agent-fixtures` + What it does: extends the existing Playwright `freshellPage`/`harness` fixtures to seed migrated browser storage and provider fixtures for Freshclaude and Freshcodex browser flows. + Exposes: real browser UI, screenshots/snapshots, isolated server info, browser storage seeding, harness state inspection. + Estimated complexity: medium. + Tests depending on it: 15, 16, 17, 18. + +## Test plan + +1. **Name:** Reloading a saved Freshclaude tab migrates `agent-chat` storage to `fresh-agent` without losing the pane, settings, or resume identity + - **Type:** scenario + - **Disposition:** extend + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** Browser storage contains a combined layout payload with `kind: 'agent-chat'`, legacy `settings.agentChat`, pane titles, and a matching tab resume fallback. + - **Actions:** Run the real storage migration; bootstrap the app from the migrated browser storage; render the restored tab and pane. + - **Expected outcome:** Per the implementation plan sections `Steady-State Product Behavior`, `Contracts And Invariants`, and Task 1, the restored pane renders as `kind: 'fresh-agent'`, preserves `sessionType: 'freshclaude'`, keeps the existing pane/tab titles and resume identity, and does not clear unrelated Freshell browser state. Primary assertions are the rendered pane shell and persisted layout payload; supporting assertions may inspect the migrated JSON written back to the real storage keys. + - **Interactions:** `src/store/storage-migration.ts`, `src/store/persistedState.ts`, `src/store/paneTypes.ts`, `src/components/TabContent.tsx`, settings bootstrap. + +2. **Name:** Cross-tab hydrate preserves the local canonical resume identity when a remote snapshot still carries the older session id + - **Type:** regression + - **Disposition:** extend + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** Local store already holds a `fresh-agent` pane with canonical `resumeSessionId`; an incoming persisted layout broadcast contains the same pane id with an older resume id and matching session family. + - **Actions:** Feed the remote persisted layout through the real cross-tab sync path. + - **Expected outcome:** Per Task 1 and the plan invariant `runtime readers must accept both legacy agent-chat persisted data and the new fresh-agent shape until every ... hydrate path has been switched`, the hydrated layout keeps the local canonical resume id on the pane and tab fallback metadata while still applying non-conflicting remote layout updates. Primary assertions are the post-hydrate pane/tab state visible to the app. + - **Interactions:** `src/store/crossTabSync.ts`, `src/store/persistControl.ts`, pane hydration, tab merge. + +3. **Name:** Remote tab snapshots round-trip a `fresh-agent` pane back into a reopenable tab with the same session identity and pane kind + - **Type:** integration + - **Disposition:** new + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** A tab registry record or layout snapshot exists for a remote device with a rich agent pane, pane title metadata, and a session locator. + - **Actions:** Serialize the open tab via the real snapshot path; hydrate it through the real `TabsView` reopen flow; open the restored tab in the client. + - **Expected outcome:** Per `Remote layout snapshots and tab registry records must serialize fresh-agent, not agent-chat`, the reopened tab builds a `fresh-agent` pane, keeps `sessionType`, restores the session locator, and renders the right pane label/icon instead of falling back to picker or terminal mode. Primary assertions are the reopened tab/pane behavior in the rendered UI. + - **Interactions:** `server/agent-api/layout-store.ts`, `server/tabs-registry/types.ts`, `src/components/TabsView.tsx`, `src/lib/tab-registry-snapshot.ts`. + +4. **Name:** `freshAgent.create` routes to the adapter selected by `sessionType` while terminal WS traffic remains unchanged + - **Type:** integration + - **Disposition:** new + - **Harness:** `fresh-agent-ws-harness` + - **Preconditions:** The WS handler is mounted with a provider registry containing at least `freshclaude` and `freshcodex` adapters plus a terminal registry. + - **Actions:** Open a WS connection; send `freshAgent.create` for `freshcodex`; then send a normal `terminal.create`. + - **Expected outcome:** Per Task 2, the fresh-agent create call is dispatched to the Codex adapter chosen by `sessionType`, emits fresh-agent namespaced responses, and does not alter or intercept the terminal create flow. Primary assertions are the ordered outbound WS messages and adapter call log. + - **Interactions:** `server/ws-handler.ts`, `server/fresh-agent/runtime-manager.ts`, provider registry, existing terminal WS envelopes. + +5. **Name:** Fresh-agent thread routes reject stale revisions instead of serving mixed snapshot and body data + - **Type:** integration + - **Disposition:** new + - **Harness:** `fresh-agent-route-harness` + - **Preconditions:** A thread exists at revision `N`; the route harness can serve snapshot/page/body reads and inject stale revision conditions. + - **Actions:** Request the thread snapshot, turn page, and turn body with revision `N-1`; repeat with revision `N`. + - **Expected outcome:** Per Task 2 and the invariant `Read-model routes stay revisioned and lane-aware and must never mix bodies from one revision with summaries from another`, stale requests return `409` with the stale-revision code, while current-revision requests succeed and return the matching revision. Primary assertions are HTTP status and JSON payloads. + - **Interactions:** `shared/read-models.ts`, scheduler lane selection, fresh-agent router, revision handling. + +6. **Name:** Fresh-agent read-model routes stay lane-aware and do not regress visible-first route discipline + - **Type:** invariant + - **Disposition:** extend + - **Harness:** `fresh-agent-route-harness` + - **Preconditions:** The route harness is recording scheduled lane events for bootstrap, session directory, and fresh-agent thread reads. + - **Actions:** Fetch bootstrap, session directory, and fresh-agent thread routes with `critical`, `visible`, and `background` priorities. + - **Expected outcome:** Per the existing visible-first acceptance contract and Task 2, fresh-agent thread reads use the declared lane, do not force forbidden session-directory pre-ready routes, and preserve the scheduler event ordering already required elsewhere in the app. Primary assertions are scheduler event logs and route transcripts. + - **Interactions:** read-model scheduler, visible-first fixtures, bootstrap router, fresh-agent router. + +7. **Name:** Posting session metadata updates changes `sessionType` without clobbering the stored derived title + - **Type:** regression + - **Disposition:** extend + - **Harness:** `fresh-agent-route-harness` + - **Preconditions:** Session metadata store already contains `derivedTitle` for a Codex session. + - **Actions:** Call the real session metadata API with `{ provider: 'codex', sessionId, sessionType: 'freshcodex' }`; then read back the stored entry and session-directory projection. + - **Expected outcome:** Per Task 5 and the invariant `Session metadata remains keyed by provider:sessionId; updating sessionType must keep derivedTitle`, the session now reports `sessionType: 'freshcodex'` while the prior derived title remains intact in both storage and projection. Primary assertions are API response and projected session-directory JSON. + - **Interactions:** `server/session-metadata-store.ts`, `server/sessions-router.ts`, `server/session-directory/projection.ts`, index refresh. + +8. **Name:** A `fresh-agent` pane reconnects after lost-session transport errors by surfacing the explicit session-lost state and reloading through the fresh-agent transport + - **Type:** scenario + - **Disposition:** new + - **Harness:** `rendered-fresh-agent-app` plus `fresh-agent-ws-harness` + - **Preconditions:** A rendered `fresh-agent` pane is attached to an active thread; the WS harness can inject a lost-session error and a subsequent successful resume/snapshot. + - **Actions:** Deliver a fresh-agent lost-session error; trigger the pane’s retry or reconnect action; deliver the resumed snapshot/page stream. + - **Expected outcome:** Per Task 6 and Task 7, the pane shows a clear user-facing lost-session state, retry uses the fresh-agent transport rather than legacy `sdk.*`, and a successful retry restores the thread instead of degrading to a terminal or blank pane. Primary assertions are rendered error/recovery states and outbound WS/API calls. + - **Interactions:** `src/lib/ws-client.ts`, `src/lib/fresh-agent-ws.ts`, `src/store/freshAgentThunks.ts`, pane-level recovery UI. + +9. **Name:** The normalized fresh-agent client store merges live and durable updates by thread locator and revision without duplicating turns + - **Type:** invariant + - **Disposition:** new + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** The client store contains a thread locator, an initial snapshot, and a later live delta plus durable page for the same revision family. + - **Actions:** Dispatch the real fresh-agent snapshot, page, and body handlers in the order expected during resume and live streaming. + - **Expected outcome:** Per Task 6 and the architecture section `Shared normalized read model`, the store keys the thread by runtime locator, retains stable turn/item ids, and renders one transcript with no duplicate turns/items when live and durable sources overlap. Primary assertions are rendered transcript order and user-visible de-duplication. + - **Interactions:** `src/store/freshAgentSlice.ts`, `src/store/freshAgentThunks.ts`, read-model hydration, transcript rendering. + +10. **Name:** Claude adapter restores one canonical thread from ledger-backed durable history plus live stream state + - **Type:** integration + - **Disposition:** new + - **Harness:** `adapter-fixture-harness` + - **Preconditions:** Claude ledger fixtures cover durable backlog, live stream overlap, question state, approval state, and model/permission metadata. + - **Actions:** Load the fixtures through the real Claude fresh-agent adapter; request snapshot, page, and body data for the same thread. + - **Expected outcome:** Per Task 3 and the architecture section `Claude runtime implementation stays behind the adapter boundary; the ledger/history strategy is preserved`, the normalized snapshot contains one canonical thread, preserves questions/approvals/model settings, and exposes provider-native detail as extension data rather than flattening it away. Primary assertions are normalized snapshot/page/body outputs from the adapter harness. + - **Interactions:** `server/fresh-agent/adapters/claude/*`, `server/sdk-bridge.ts`, `server/agent-timeline/*`. + +11. **Name:** Codex adapter normalizes fork, worktree, review, token, and child-thread metadata into the shared fresh-agent model + - **Type:** integration + - **Disposition:** new + - **Harness:** `adapter-fixture-harness` + - **Preconditions:** Codex app-server fixtures include raw rich-session events with fork lineage, worktree info, review/diff references, token summaries, and subagent children. + - **Actions:** Load the fixtures through the real Codex adapter and request snapshot/page/body data. + - **Expected outcome:** Per Task 4 and `Freshcodex rich panes use the Codex app-server as the source of truth`, the normalized thread advertises the correct capabilities, exposes worktree and child-thread refs, and keeps provider-specific extensions for Codex-only metadata. Primary assertions are the normalized shared-model payloads. + - **Interactions:** `server/coding-cli/codex-app-server/*`, `server/fresh-agent/adapters/codex/*`, provider extension payloads. + +12. **Name:** The shared fresh-agent pane shell shows provider-specific capabilities without changing the core transcript, composer, or banner affordances + - **Type:** scenario + - **Disposition:** new + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** One normalized Freshclaude thread fixture and one normalized Freshcodex thread fixture are available with differing capability flags. + - **Actions:** Render the shared `FreshAgentView` with the Claude thread, then with the Codex thread; activate capability-backed controls such as interrupt, fork, and approval/question actions where available. + - **Expected outcome:** Per Task 7 and `Existing Freshclaude UX stays intact unless the new shared shell makes it stronger`, both providers render the same shell structure, Claude-only or Codex-only actions appear only when their capability flags permit them, and activating those controls produces the expected user-visible state changes or outbound actions. Primary assertions are the rendered controls and their activation effects. + - **Interactions:** `src/components/fresh-agent/*`, `src/lib/fresh-agent-capabilities.ts`, context menus, shared session rendering primitives. + +13. **Name:** Freshclaude input history survives the architecture cutover and remains scoped to the pane + - **Type:** regression + - **Disposition:** extend + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** A Freshclaude `fresh-agent` pane is rendered with a composer bound to a stable pane id. + - **Actions:** Send multiple prompts, navigate history with ArrowUp/ArrowDown, reload or remount the pane, and reopen history. + - **Expected outcome:** Per Task 7 and the requirement to preserve current Freshclaude behavior, the composer recalls sent prompts in order, preserves draft behavior, persists history across reload/remount, and keeps the history isolated to the pane id rather than global session state. Primary assertions are the rendered composer value and persisted input-history storage key contents. + - **Interactions:** `src/lib/input-history-store.ts`, composer state, pane persistence. + +14. **Name:** Fresh-agent context menus target the migrated pane/session surfaces and keep resume-command, diff, and copy actions working + - **Type:** scenario + - **Disposition:** extend + - **Harness:** `rendered-fresh-agent-app` + - **Preconditions:** A rendered fresh-agent transcript contains tool input, diff content, and a resumable session reference; tabs and panes are present in the real menu-building context. + - **Actions:** Open the context menu on transcript text, tool input, diff content, and the pane/tab chrome; activate the copy-resume-command and agent-content copy actions. + - **Expected outcome:** Per Task 7 and Task 8, the menu resolves targets against `fresh-agent` panes rather than `agent-chat`, exposes the right copy/resume items, and dispatches the expected action for each activated item. Primary assertions are rendered menu items and resulting clipboard/action calls. + - **Interactions:** `src/components/context-menu/menu-defs.ts`, session refs, resume-command helpers, transcript DOM attributes. + +15. **Name:** Browser user can create and resume Freshcodex with visible worktree and fork metadata in the shared pane + - **Type:** scenario + - **Disposition:** new + - **Harness:** `playwright-fresh-agent-fixtures` + - **Preconditions:** Playwright server fixture has Codex rich-session fixtures available and Freshcodex enabled in the pane picker. + - **Actions:** Open the pane picker, create a Freshcodex pane, allow the thread to load, navigate away and back or reload to resume it, then activate the fork/worktree UI affordances. + - **Expected outcome:** Per the user goal and Tasks 4, 6, and 7, the real browser UI shows a Freshcodex pane, preserves the resumed thread after reload, and visibly surfaces worktree/fork metadata through the shared shell. Primary assertions are browser-visible controls, labels, and thread content; supporting assertions may read harness state. + - **Interactions:** pane picker, fresh-agent transport, Codex adapter, browser storage persistence, shared pane shell. + +16. **Name:** Browser user can restore Freshclaude and still see approval and question banners after the migration + - **Type:** scenario + - **Disposition:** extend + - **Harness:** `playwright-fresh-agent-fixtures` + - **Preconditions:** Playwright browser storage or server fixture seeds a persisted Freshclaude rich pane with outstanding approval and question state. + - **Actions:** Load the app, open the restored pane, respond to the approval/question controls, and reload the page. + - **Expected outcome:** Per `Existing Freshclaude sessions, settings, reopen entries, local layouts, remote tab snapshots, and sidebar/history items survive migration with no manual repair`, the restored pane appears automatically, banners are visible and actionable, and their resolved state persists across reload. Primary assertions are the browser-visible alert/banner content and action effects. + - **Interactions:** persisted layout migration, Claude adapter, fresh-agent client store, banner actions. + +17. **Name:** Mobile browser flows keep the fresh-agent shell usable without regressing tab and sidebar navigation + - **Type:** scenario + - **Disposition:** extend + - **Harness:** `playwright-fresh-agent-fixtures` + - **Preconditions:** Playwright viewport is mobile-sized and the app starts with at least one rich agent tab plus standard tab strip controls. + - **Actions:** Open the mobile tab switcher, create or switch to a fresh-agent tab, open the sidebar/history surface, and return to the pane. + - **Expected outcome:** Per the implementation plan requirement `Mobile and sidebar behavior remain first-class requirements`, the mobile tab strip, tab switcher, sidebar open/close controls, and fresh-agent pane remain operable and visually coherent; the user can reach and return from the sidebar/history flows without losing the active rich pane. Primary assertions are browser-visible controls and resulting navigation state. + - **Interactions:** `src/components/MobileTabStrip.tsx`, `src/components/Sidebar.tsx`, `src/components/HistoryView.tsx`, rich pane mount/unmount behavior. + +18. **Name:** Full targeted verification replaces legacy `agent-chat` browser proofs with fresh-agent browser proofs before deletion + - **Type:** regression + - **Disposition:** new + - **Harness:** `playwright-fresh-agent-fixtures` + - **Preconditions:** Replacement browser specs exist for the flows currently covered by `agent-chat.spec.ts`, `agent-chat-input-history.spec.ts`, `pane-activity-indicator.spec.ts`, and the rich-pane portions of `tab-management.spec.ts`. + - **Actions:** Run the fresh-agent browser specs that cover create, resume, input history, pane activity, and tab restoration; compare their covered user-visible behaviors against the legacy spec inventory before any legacy rich-pane spec is deleted or renamed away. + - **Expected outcome:** Per Task 8 and `Port existing regression coverage forward; do not delete a test unless its behavior is demonstrably covered elsewhere`, the fresh-agent browser suite proves the same user-visible behaviors before legacy `agent-chat` browser coverage is removed. Primary assertions are passing browser scenarios and a one-to-one coverage mapping in the renamed replacement specs. + - **Interactions:** Playwright fixtures, pane activity indicators, input history, restored tabs, spec migration inventory. + +## Coverage summary + +- Covered action space: + storage migration on startup; local settings migration; persisted layout parsing and hydration; cross-tab persisted-layout broadcast handling; remote tab snapshot serialization and reopen; session metadata POST; session-directory projection refresh; fresh-agent WebSocket create/resume/reconnect/lost-session handling; fresh-agent thread snapshot/page/body HTTP reads; Claude and Codex adapter normalization; shared fresh-agent transcript/composer/banner/diff/context-menu actions; pane picker create flows; browser restore flows; mobile tab/sidebar navigation. + +- Explicitly excluded per the agreed strategy: + live external Claude/Codex/OpenCode processes, real paid provider APIs, production-only worktree/review backends outside the repo’s existing fixtures, and manual visual review. + +- Risks carried by those exclusions: + true subprocess timing issues, upstream protocol drift, or production-only review/worktree behaviors could still appear after local verification. The strongest practical mitigation here is fixture-backed adapter coverage plus real browser/UI coverage against the repo’s own transport and persistence paths, which this plan makes the acceptance gate. From 8c0b61fbce4443bd763ffc504e8bfb435105a11d Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 11:51:44 -0700 Subject: [PATCH 11/86] refactor: add fresh-agent persistence vocabulary --- shared/fresh-agent.ts | 156 ++++++++++ shared/settings.ts | 127 ++++++-- src/components/panes/PaneContainer.tsx | 28 +- src/lib/agent-chat-types.ts | 3 +- src/lib/agent-chat-utils.ts | 66 ++--- src/lib/derivePaneTitle.ts | 5 + src/lib/fresh-agent-registry.ts | 128 +++++++++ src/lib/session-type-utils.ts | 16 +- src/lib/tab-registry-snapshot.ts | 22 ++ src/store/browserPreferencesPersistence.ts | 13 +- src/store/crossTabSync.ts | 8 +- src/store/paneTreeValidation.ts | 18 ++ src/store/paneTypes.ts | 34 ++- src/store/panesSlice.ts | 64 ++++- src/store/persistedState.ts | 6 +- src/store/storage-migration.ts | 271 +++++------------- test/integration/server/platform-api.test.ts | 11 + test/integration/server/settings-api.test.ts | 15 + .../PaneContainer.createContent.test.tsx | 44 ++- test/unit/client/store/crossTabSync.test.ts | 57 ++++ .../store/persisted-state.fresh-agent.test.ts | 33 +++ .../storage-migration.fresh-agent.test.ts | 51 ++++ test/unit/server/agent-layout-schema.test.ts | 27 ++ .../config-store.fresh-agent-settings.test.ts | 22 ++ test/unit/shared/fresh-agent-registry.test.ts | 19 ++ 25 files changed, 945 insertions(+), 299 deletions(-) create mode 100644 shared/fresh-agent.ts create mode 100644 src/lib/fresh-agent-registry.ts create mode 100644 test/unit/client/store/persisted-state.fresh-agent.test.ts create mode 100644 test/unit/client/store/storage-migration.fresh-agent.test.ts create mode 100644 test/unit/server/config-store.fresh-agent-settings.test.ts create mode 100644 test/unit/shared/fresh-agent-registry.test.ts diff --git a/shared/fresh-agent.ts b/shared/fresh-agent.ts new file mode 100644 index 000000000..a15261fdc --- /dev/null +++ b/shared/fresh-agent.ts @@ -0,0 +1,156 @@ +export type FreshAgentSessionType = 'freshclaude' | 'freshcodex' | 'kilroy' | 'freshopencode' + +export type FreshAgentRuntimeProvider = 'claude' | 'codex' | 'opencode' + +export type FreshAgentCompatibilityShape = { + kind?: unknown + provider?: unknown + sessionType?: unknown + sessionId?: unknown + createRequestId?: unknown + status?: unknown + resumeSessionId?: unknown + sessionRef?: unknown + initialCwd?: unknown + createError?: unknown + model?: unknown + permissionMode?: unknown + effort?: unknown + plugins?: unknown + settingsDismissed?: unknown +} + +export type FreshAgentDescriptor = { + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + label: string + hidden?: boolean + disabled?: boolean +} + +export const FRESH_AGENT_DESCRIPTORS: readonly FreshAgentDescriptor[] = [ + { + sessionType: 'freshclaude', + runtimeProvider: 'claude', + label: 'Freshclaude', + }, + { + sessionType: 'freshcodex', + runtimeProvider: 'codex', + label: 'Freshcodex', + }, + { + sessionType: 'kilroy', + runtimeProvider: 'claude', + label: 'Kilroy', + hidden: true, + }, + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + label: 'Freshopencode', + disabled: true, + }, +] as const + +const FRESH_AGENT_DESCRIPTOR_BY_SESSION_TYPE = new Map( + FRESH_AGENT_DESCRIPTORS.map((descriptor) => [descriptor.sessionType, descriptor]), +) + +export function isFreshAgentSessionType(value: unknown): value is FreshAgentSessionType { + return typeof value === 'string' && FRESH_AGENT_DESCRIPTOR_BY_SESSION_TYPE.has(value as FreshAgentSessionType) +} + +export function getFreshAgentDescriptor( + sessionType: string | undefined, +): FreshAgentDescriptor | undefined { + if (!sessionType) return undefined + return FRESH_AGENT_DESCRIPTOR_BY_SESSION_TYPE.get(sessionType as FreshAgentSessionType) +} + +export function resolveFreshAgentRuntimeProvider( + sessionType: string | undefined, +): FreshAgentRuntimeProvider | undefined { + return getFreshAgentDescriptor(sessionType)?.runtimeProvider +} + +export function normalizeFreshAgentSessionType( + value: unknown, +): FreshAgentSessionType | undefined { + return isFreshAgentSessionType(value) ? value : undefined +} + +export function migrateLegacyFreshAgentContent( + input: T, +): T | (Omit & { + kind: 'fresh-agent' + provider: FreshAgentRuntimeProvider + sessionType: FreshAgentSessionType +}) { + if (!input || typeof input !== 'object') { + return input + } + + if (input.kind === 'fresh-agent') { + const sessionType = normalizeFreshAgentSessionType(input.sessionType) + ?? normalizeFreshAgentSessionType(input.provider) + const provider = (typeof input.provider === 'string' + && (input.provider === 'claude' || input.provider === 'codex' || input.provider === 'opencode')) + ? input.provider + : resolveFreshAgentRuntimeProvider(sessionType) + + if (!sessionType || !provider) { + return input + } + + return { + ...input, + kind: 'fresh-agent', + provider, + sessionType, + } + } + + if (input.kind !== 'agent-chat') { + return input + } + + const sessionType = normalizeFreshAgentSessionType(input.provider) + const provider = resolveFreshAgentRuntimeProvider(sessionType) + if (!sessionType || !provider) { + return input + } + + return { + ...input, + kind: 'fresh-agent', + provider, + sessionType, + } +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +export function migrateLegacyFreshAgentNode(node: unknown): unknown { + if (!isRecord(node)) { + return node + } + + if (node.type === 'leaf' && isRecord(node.content)) { + return { + ...node, + content: migrateLegacyFreshAgentContent(node.content), + } + } + + if (node.type === 'split' && Array.isArray(node.children)) { + return { + ...node, + children: node.children.map(migrateLegacyFreshAgentNode), + } + } + + return node +} diff --git a/shared/settings.ts b/shared/settings.ts index 71ce9532d..c0d3febf0 100644 --- a/shared/settings.ts +++ b/shared/settings.ts @@ -143,6 +143,11 @@ export type ServerSettings = { externalEditor: ExternalEditor customEditorCommand?: string } + freshAgent: { + initialSetupDone?: boolean + defaultPlugins: string[] + providers: Partial> + } agentChat: { initialSetupDone?: boolean defaultPlugins: string[] @@ -190,6 +195,11 @@ export type LocalSettings = { width: number collapsed: boolean } + freshAgent: { + showThinking: boolean + showTools: boolean + showTimecodes: boolean + } agentChat: { showThinking: boolean showTools: boolean @@ -216,6 +226,7 @@ export type ResolvedSettings = { codingCli: ServerSettings['codingCli'] panes: ServerSettings['panes'] & LocalSettings['panes'] editor: ServerSettings['editor'] + freshAgent: ServerSettings['freshAgent'] & LocalSettings['freshAgent'] agentChat: ServerSettings['agentChat'] & LocalSettings['agentChat'] extensions: ServerSettings['extensions'] network: ServerSettings['network'] @@ -594,6 +605,11 @@ export function buildServerSettingsSchema(validCliProviders?: readonly string[]) externalEditor: ExternalEditorSchema, customEditorCommand: z.string().optional(), }).strict(), + freshAgent: z.object({ + initialSetupDone: z.boolean().optional(), + defaultPlugins: z.array(z.string()), + providers: z.record(z.string(), createAgentChatProviderDefaultsPatchSchema()), + }).strict(), agentChat: z.object({ initialSetupDone: z.boolean().optional(), defaultPlugins: z.array(z.string()), @@ -638,6 +654,11 @@ export function buildServerSettingsPatchSchema(validCliProviders?: readonly stri externalEditor: ExternalEditorSchema.optional(), customEditorCommand: z.string().optional(), }).strict().optional(), + freshAgent: z.object({ + initialSetupDone: z.boolean().optional(), + defaultPlugins: z.array(z.string()).optional(), + providers: z.record(z.string(), createAgentChatProviderDefaultsPatchSchema()).optional(), + }).strict().optional(), agentChat: z.object({ initialSetupDone: z.boolean().optional(), defaultPlugins: z.array(z.string()).optional(), @@ -690,6 +711,10 @@ export function createDefaultServerSettings(options: SettingsDefaultsOptions = { editor: { externalEditor: 'auto', }, + freshAgent: { + defaultPlugins: [], + providers: {}, + }, agentChat: { defaultPlugins: [], providers: {}, @@ -735,6 +760,11 @@ export const defaultLocalSettings: LocalSettings = { width: 288, collapsed: false, }, + freshAgent: { + showThinking: false, + showTools: false, + showTimecodes: false, + }, agentChat: { showThinking: false, showTools: false, @@ -899,17 +929,23 @@ function sanitizeServerSettingsPatch(patch: ServerSettingsPatch): ServerSettings } } - if (isRecord(candidate.agentChat)) { - const agentChat: ServerSettingsPatch['agentChat'] = {} - if (hasOwn(candidate.agentChat, 'initialSetupDone') && typeof candidate.agentChat.initialSetupDone === 'boolean') { - agentChat.initialSetupDone = candidate.agentChat.initialSetupDone + const rawFreshAgent = isRecord(candidate.freshAgent) + ? candidate.freshAgent + : isRecord(candidate.agentChat) + ? candidate.agentChat + : null + + if (rawFreshAgent) { + const freshAgent: ServerSettingsPatch['freshAgent'] = {} + if (hasOwn(rawFreshAgent, 'initialSetupDone') && typeof rawFreshAgent.initialSetupDone === 'boolean') { + freshAgent.initialSetupDone = rawFreshAgent.initialSetupDone } - if (hasOwn(candidate.agentChat, 'defaultPlugins') && Array.isArray(candidate.agentChat.defaultPlugins)) { - agentChat.defaultPlugins = sanitizeAgentChatPluginPaths(candidate.agentChat.defaultPlugins) + if (hasOwn(rawFreshAgent, 'defaultPlugins') && Array.isArray(rawFreshAgent.defaultPlugins)) { + freshAgent.defaultPlugins = sanitizeAgentChatPluginPaths(rawFreshAgent.defaultPlugins) } - if (isRecord(candidate.agentChat.providers)) { - const providers: NonNullable['providers'] = {} - for (const [providerName, providerPatch] of Object.entries(candidate.agentChat.providers)) { + if (isRecord(rawFreshAgent.providers)) { + const providers: NonNullable['providers'] = {} + for (const [providerName, providerPatch] of Object.entries(rawFreshAgent.providers)) { const normalizedProviderPatchInput = normalizeLegacyAgentChatProviderDefaultsInput(providerPatch) const parsed = agentChatProviderDefaultsPatchSchema.safeParse( normalizedProviderPatchInput, @@ -936,11 +972,12 @@ function sanitizeServerSettingsPatch(patch: ServerSettingsPatch): ServerSettings } } if (Object.keys(providers).length > 0) { - agentChat.providers = providers + freshAgent.providers = providers } } - if (Object.keys(agentChat).length > 0) { - sanitized.agentChat = agentChat + if (Object.keys(freshAgent).length > 0) { + sanitized.freshAgent = freshAgent + sanitized.agentChat = freshAgent } } @@ -1012,9 +1049,7 @@ function normalizeLegacyAgentChatProviderDefaultsInput( export function mergeServerSettings(base: ServerSettings, patch: ServerSettingsPatch): ServerSettings { const normalizedPatch = sanitizeServerSettingsPatch(patch) const codingCliPatch = normalizedPatch.codingCli - const agentChatPatch = normalizedPatch.agentChat - const normalizedAgentChatPatch = agentChatPatch as Partial | undefined - const normalizedAgentChatProvidersPatch = agentChatPatch?.providers as Partial> | undefined + const freshAgentPatch = normalizedPatch.freshAgent ?? normalizedPatch.agentChat return { ...base, @@ -1045,12 +1080,19 @@ export function mergeServerSettings(base: ServerSettings, patch: ServerSettingsP providers: mergeRecordOfObjects(base.codingCli.providers, codingCliPatch?.providers), }, editor: mergeDefined(base.editor, normalizedPatch.editor), + freshAgent: { + ...mergeDefined(base.freshAgent, freshAgentPatch), + defaultPlugins: hasOwn(freshAgentPatch, 'defaultPlugins') + ? sanitizeAgentChatPluginPaths(freshAgentPatch?.defaultPlugins) + : base.freshAgent.defaultPlugins, + providers: mergeRecordOfObjects(base.freshAgent.providers, freshAgentPatch?.providers), + }, agentChat: { - ...mergeDefined(base.agentChat, normalizedAgentChatPatch), - defaultPlugins: hasOwn(normalizedAgentChatPatch, 'defaultPlugins') - ? sanitizeAgentChatPluginPaths(normalizedAgentChatPatch?.defaultPlugins) + ...mergeDefined(base.agentChat, freshAgentPatch), + defaultPlugins: hasOwn(freshAgentPatch, 'defaultPlugins') + ? sanitizeAgentChatPluginPaths(freshAgentPatch?.defaultPlugins) : base.agentChat.defaultPlugins, - providers: mergeRecordOfObjects(base.agentChat.providers, normalizedAgentChatProvidersPatch), + providers: mergeRecordOfObjects(base.agentChat.providers, freshAgentPatch?.providers), }, extensions: { disabled: hasOwn(normalizedPatch.extensions, 'disabled') @@ -1062,6 +1104,7 @@ export function mergeServerSettings(base: ServerSettings, patch: ServerSettingsP } export function resolveLocalSettings(patch?: LocalSettingsPatch): LocalSettings { + const freshAgentPatch = patch?.freshAgent ?? patch?.agentChat return { ...defaultLocalSettings, ...(hasOwn(patch, 'theme') ? { theme: patch?.theme ?? defaultLocalSettings.theme } : {}), @@ -1073,7 +1116,8 @@ export function resolveLocalSettings(patch?: LocalSettingsPatch): LocalSettings sortMode: normalizeLocalSortMode(patch?.sidebar?.sortMode), worktreeGrouping: normalizeWorktreeGrouping(patch?.sidebar?.worktreeGrouping), }, - agentChat: mergeDefined(defaultLocalSettings.agentChat, patch?.agentChat), + freshAgent: mergeDefined(defaultLocalSettings.freshAgent, freshAgentPatch), + agentChat: mergeDefined(defaultLocalSettings.agentChat, freshAgentPatch), notifications: mergeDefined(defaultLocalSettings.notifications, patch?.notifications), } } @@ -1114,12 +1158,13 @@ export function mergeLocalSettings(base: LocalSettingsPatch | undefined, patch: next.sidebar = sidebar as LocalSettingsPatch['sidebar'] } - const agentChat = mergeDefined( - (base?.agentChat || {}) as Record, - patch.agentChat as Record | undefined, + const freshAgent = mergeDefined( + (base?.freshAgent || base?.agentChat || {}) as Record, + (patch.freshAgent || patch.agentChat) as Record | undefined, ) - if (Object.keys(agentChat).length > 0) { - next.agentChat = agentChat as LocalSettingsPatch['agentChat'] + if (Object.keys(freshAgent).length > 0) { + next.freshAgent = freshAgent as LocalSettingsPatch['freshAgent'] + next.agentChat = freshAgent as LocalSettingsPatch['agentChat'] } const notifications = mergeDefined( @@ -1162,6 +1207,12 @@ export function composeResolvedSettings(server: ServerSettings, local: LocalSett ...local.panes, }, editor: { ...server.editor }, + freshAgent: { + ...server.freshAgent, + defaultPlugins: [...server.freshAgent.defaultPlugins], + providers: mergeRecordOfObjects(server.freshAgent.providers), + ...local.freshAgent, + }, agentChat: { ...server.agentChat, defaultPlugins: [...server.agentChat.defaultPlugins], @@ -1202,8 +1253,15 @@ export function extractLegacyLocalSettingsSeed( } maybeAssignNested(patch, 'sidebar', sidebarPatch) } - if (isRecord(raw.agentChat)) { - maybeAssignNested(patch, 'agentChat', pickKeys(raw.agentChat, AGENT_CHAT_LOCAL_KEYS)) + const rawFreshAgentLocal = isRecord(raw.freshAgent) + ? raw.freshAgent + : isRecord(raw.agentChat) + ? raw.agentChat + : undefined + if (rawFreshAgentLocal) { + const freshAgentPatch = pickKeys(rawFreshAgentLocal, AGENT_CHAT_LOCAL_KEYS) + maybeAssignNested(patch, 'freshAgent', freshAgentPatch) + maybeAssignNested(patch, 'agentChat', freshAgentPatch) } if (isRecord(raw.notifications)) { maybeAssignNested(patch, 'notifications', pickKeys(raw.notifications, ['soundEnabled'])) @@ -1248,11 +1306,18 @@ export function stripLocalSettings( } } - if (isRecord(raw.agentChat)) { - const strippedAgentChat = omitKeys(raw.agentChat, AGENT_CHAT_LOCAL_KEYS) - if (Object.keys(strippedAgentChat).length > 0) { - next.agentChat = strippedAgentChat + const rawFreshAgent = isRecord(raw.freshAgent) + ? raw.freshAgent + : isRecord(raw.agentChat) + ? raw.agentChat + : undefined + if (rawFreshAgent) { + const strippedFreshAgent = omitKeys(rawFreshAgent, AGENT_CHAT_LOCAL_KEYS) + if (Object.keys(strippedFreshAgent).length > 0) { + next.freshAgent = strippedFreshAgent + next.agentChat = strippedFreshAgent } else { + delete next.freshAgent delete next.agentChat } } diff --git a/src/components/panes/PaneContainer.tsx b/src/components/panes/PaneContainer.tsx index a6c35edf2..0897787ff 100644 --- a/src/components/panes/PaneContainer.tsx +++ b/src/components/panes/PaneContainer.tsx @@ -13,6 +13,7 @@ import PanePicker, { type PanePickerType } from './PanePicker' import DirectoryPicker from './DirectoryPicker' import { getProviderLabel, isCodingCliProviderName } from '@/lib/coding-cli-utils' import { isAgentChatProviderName, getAgentChatProviderConfig } from '@/lib/agent-chat-utils' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' import { clearDraft } from '@/lib/draft-store' import { getTerminalActions } from '@/lib/pane-action-registry' import { buildPaneRefreshTarget } from '@/lib/pane-utils' @@ -278,7 +279,7 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp }) } // Clean up agent-chat resources - if (content.kind === 'agent-chat') { + if (content.kind === 'agent-chat' || content.kind === 'fresh-agent') { clearDraft(paneId) const pendingCreate = sdkPendingCreates[content.createRequestId] const pendingSessionId = pendingCreate?.sessionId @@ -377,7 +378,7 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp const paneTitle = getPaneDisplayTitle(node.content, explicitTitle, extensionEntries) const paneStatus = node.content.kind === 'terminal' ? node.content.status - : node.content.kind === 'agent-chat' + : (node.content.kind === 'agent-chat' || node.content.kind === 'fresh-agent') ? (node.content.status === 'exited' ? 'exited' : 'running') : 'running' const isRenaming = renamingPaneId === node.id @@ -413,10 +414,12 @@ export default function PaneContainer({ tabId, node, hidden }: PaneContainerProp provider: paneProvider, initialCwd: paneInitialCwd, }) - : node.content.kind === 'agent-chat' + : (node.content.kind === 'agent-chat' || node.content.kind === 'fresh-agent') ? resolveFreshClaudeRuntimeMeta( indexedProjects, - node.content, + node.content.kind === 'fresh-agent' + ? { ...node.content, kind: 'agent-chat', provider: node.content.sessionType } + : node.content, node.content.sessionId ? agentChatSessions[node.content.sessionId] : undefined, ) : undefined @@ -547,10 +550,12 @@ function PickerWrapper({ if (isAgentChatProviderName(type)) { const providerConfig = getAgentChatProviderConfig(type)! + const freshAgentType = resolveFreshAgentType(type)! const providerSettings = agentChatSettings?.providers?.[type] return { - kind: 'agent-chat', - provider: type, + kind: 'fresh-agent', + sessionType: type, + provider: freshAgentType.runtimeProvider, createRequestId: nanoid(), status: 'creating', modelSelection: normalizeAgentChatModelSelection(providerSettings?.modelSelection), @@ -750,10 +755,17 @@ function renderContent( ) } - if (content.kind === 'agent-chat') { + if (content.kind === 'agent-chat' || content.kind === 'fresh-agent') { return ( - ) } diff --git a/src/lib/agent-chat-types.ts b/src/lib/agent-chat-types.ts index 9da4adf8e..e89aa2f9f 100644 --- a/src/lib/agent-chat-types.ts +++ b/src/lib/agent-chat-types.ts @@ -1,7 +1,8 @@ import type { CodingCliProviderName } from '@/lib/coding-cli-types' import type { AgentChatModelSelection } from '@shared/agent-chat-capabilities' +import type { FreshAgentSessionType } from '@shared/fresh-agent' -export type AgentChatProviderName = 'freshclaude' | 'kilroy' +export type AgentChatProviderName = Extract export type AgentChatProviderSettings = { modelSelection?: AgentChatModelSelection diff --git a/src/lib/agent-chat-utils.ts b/src/lib/agent-chat-utils.ts index 957c1973b..183e3a174 100644 --- a/src/lib/agent-chat-utils.ts +++ b/src/lib/agent-chat-utils.ts @@ -1,5 +1,5 @@ import type { AgentChatProviderName, AgentChatProviderConfig } from './agent-chat-types' -import { FreshclaudeIcon, KilroyIcon } from '@/components/icons/provider-icons' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' export type { AgentChatProviderName, AgentChatProviderConfig } @@ -11,40 +11,42 @@ export const AGENT_CHAT_PROVIDERS: AgentChatProviderName[] = [ export const AGENT_CHAT_PROVIDER_CONFIGS: AgentChatProviderConfig[] = [ { name: 'freshclaude', - label: 'Freshclaude', - codingCliProvider: 'claude', - icon: FreshclaudeIcon, - providerDefaultModelId: 'opus', - defaultPermissionMode: 'bypassPermissions', - settingsVisibility: { - model: true, - permissionMode: true, - effort: true, - thinking: true, - tools: true, - timecodes: true, - }, - pickerShortcut: 'A', + ...(() => { + const entry = resolveFreshAgentType('freshclaude') + if (!entry) { + throw new Error('Missing fresh-agent registry entry for freshclaude') + } + return { + label: entry.label, + codingCliProvider: entry.runtimeProvider, + icon: entry.icon, + providerDefaultModelId: 'opus', + defaultPermissionMode: entry.defaultPermissionMode, + settingsVisibility: entry.settingsVisibility, + pickerShortcut: entry.pickerShortcut, + } + })(), }, { name: 'kilroy', - label: 'Kilroy', - codingCliProvider: 'claude', - icon: KilroyIcon, - providerDefaultModelId: 'opus', - defaultPermissionMode: 'bypassPermissions', - settingsVisibility: { - model: true, - permissionMode: true, - effort: true, - thinking: true, - tools: true, - timecodes: true, - }, - pickerShortcut: 'K', - pickerAfterCli: true, - hidden: true, - featureFlag: 'kilroy', + ...(() => { + const entry = resolveFreshAgentType('kilroy') + if (!entry) { + throw new Error('Missing fresh-agent registry entry for kilroy') + } + return { + label: entry.label, + codingCliProvider: entry.runtimeProvider, + icon: entry.icon, + providerDefaultModelId: 'opus', + defaultPermissionMode: entry.defaultPermissionMode, + settingsVisibility: entry.settingsVisibility, + pickerShortcut: entry.pickerShortcut, + pickerAfterCli: entry.pickerAfterCli, + hidden: entry.hidden, + featureFlag: entry.featureFlag, + } + })(), }, ] diff --git a/src/lib/derivePaneTitle.ts b/src/lib/derivePaneTitle.ts index 4fc3c10e3..30afb9253 100644 --- a/src/lib/derivePaneTitle.ts +++ b/src/lib/derivePaneTitle.ts @@ -1,6 +1,7 @@ import type { PaneContent } from '@/store/paneTypes' import { getProviderLabel, isNonShellMode } from '@/lib/coding-cli-utils' import { getAgentChatProviderLabel } from '@/lib/agent-chat-utils' +import { getFreshAgentLabel } from '@/lib/fresh-agent-registry' import type { ClientExtensionEntry } from '@shared/extension-types' /** @@ -23,6 +24,10 @@ export function derivePaneTitle(content: PaneContent, extensions?: ClientExtensi return getAgentChatProviderLabel(content.provider) } + if (content.kind === 'fresh-agent') { + return getFreshAgentLabel(content.sessionType) + } + if (content.kind === 'browser') { if (!content.url) return 'Browser' try { diff --git a/src/lib/fresh-agent-registry.ts b/src/lib/fresh-agent-registry.ts new file mode 100644 index 000000000..33e634422 --- /dev/null +++ b/src/lib/fresh-agent-registry.ts @@ -0,0 +1,128 @@ +import { + getFreshAgentDescriptor, + type FreshAgentRuntimeProvider, + type FreshAgentSessionType, +} from '@shared/fresh-agent' +import { + CodexIcon, + FreshclaudeIcon, + KilroyIcon, + OpencodeIcon, +} from '@/components/icons/provider-icons' + +export type FreshAgentRegistryEntry = { + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + label: string + icon: React.ComponentType<{ className?: string }> + defaultModel: string + defaultPermissionMode: string + defaultEffort: 'low' | 'medium' | 'high' | 'max' + settingsVisibility: { + model: boolean + permissionMode: boolean + effort: boolean + thinking: boolean + tools: boolean + timecodes: boolean + } + pickerShortcut: string + pickerAfterCli?: boolean + hidden?: boolean + disabled?: boolean + featureFlag?: string +} + +export const FRESH_AGENT_REGISTRY: readonly FreshAgentRegistryEntry[] = [ + { + sessionType: 'freshclaude', + runtimeProvider: 'claude', + label: 'Freshclaude', + icon: FreshclaudeIcon, + defaultModel: 'claude-opus-4-6', + defaultPermissionMode: 'bypassPermissions', + defaultEffort: 'high', + settingsVisibility: { + model: true, + permissionMode: true, + effort: true, + thinking: true, + tools: true, + timecodes: true, + }, + pickerShortcut: 'A', + }, + { + sessionType: 'freshcodex', + runtimeProvider: 'codex', + label: 'Freshcodex', + icon: CodexIcon, + defaultModel: 'gpt-5-codex', + defaultPermissionMode: 'bypassPermissions', + defaultEffort: 'high', + settingsVisibility: { + model: true, + permissionMode: true, + effort: true, + thinking: false, + tools: true, + timecodes: true, + }, + pickerShortcut: 'X', + pickerAfterCli: true, + }, + { + sessionType: 'kilroy', + runtimeProvider: 'claude', + label: 'Kilroy', + icon: KilroyIcon, + defaultModel: 'claude-opus-4-6', + defaultPermissionMode: 'bypassPermissions', + defaultEffort: 'high', + settingsVisibility: { + model: true, + permissionMode: true, + effort: true, + thinking: true, + tools: true, + timecodes: true, + }, + pickerShortcut: 'K', + pickerAfterCli: true, + hidden: true, + featureFlag: 'kilroy', + }, + { + sessionType: 'freshopencode', + runtimeProvider: 'opencode', + label: 'Freshopencode', + icon: OpencodeIcon, + defaultModel: 'opencode', + defaultPermissionMode: 'bypassPermissions', + defaultEffort: 'high', + settingsVisibility: { + model: true, + permissionMode: true, + effort: true, + thinking: false, + tools: true, + timecodes: true, + }, + pickerShortcut: 'O', + pickerAfterCli: true, + disabled: true, + }, +] as const + +export function resolveFreshAgentType( + sessionType: string | undefined, +): FreshAgentRegistryEntry | undefined { + if (!sessionType) return undefined + return FRESH_AGENT_REGISTRY.find((entry) => entry.sessionType === sessionType) +} + +export function getFreshAgentLabel(sessionType: string | undefined): string { + return resolveFreshAgentType(sessionType)?.label + ?? getFreshAgentDescriptor(sessionType)?.label + ?? 'Fresh Agent' +} diff --git a/src/lib/session-type-utils.ts b/src/lib/session-type-utils.ts index de9c3feb7..d417af5ea 100644 --- a/src/lib/session-type-utils.ts +++ b/src/lib/session-type-utils.ts @@ -2,9 +2,10 @@ import type { ComponentType } from 'react' import { PROVIDER_ICONS, DefaultProviderIcon } from '@/components/icons/provider-icons' import { isNonShellMode, getProviderLabel } from '@/lib/coding-cli-utils' import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' import type { AgentChatProviderName, AgentChatProviderSettings } from '@/lib/agent-chat-types' import type { CodingCliProviderName } from '@/store/types' -import type { AgentChatPaneInput, TerminalPaneInput } from '@/store/paneTypes' +import type { FreshAgentPaneInput, AgentChatPaneInput, TerminalPaneInput } from '@/store/paneTypes' import type { ClientExtensionEntry } from '@shared/extension-types' export interface SessionTypeConfig { @@ -47,17 +48,16 @@ export function buildResumeContent(opts: { sessionId: string cwd?: string agentChatProviderSettings?: AgentChatProviderSettings -}): TerminalPaneInput | AgentChatPaneInput { +}): TerminalPaneInput | FreshAgentPaneInput | AgentChatPaneInput { const agentConfig = getAgentChatProviderConfig(opts.sessionType) if (agentConfig) { const ps = opts.agentChatProviderSettings + const freshAgentType = resolveFreshAgentType(opts.sessionType) return { - kind: 'agent-chat', - provider: agentConfig.name as AgentChatProviderName, - sessionRef: { - provider: agentConfig.codingCliProvider ?? 'claude', - sessionId: opts.sessionId, - }, + kind: 'fresh-agent', + sessionType: agentConfig.name as AgentChatProviderName, + provider: freshAgentType?.runtimeProvider ?? 'claude', + resumeSessionId: opts.sessionId, initialCwd: opts.cwd, modelSelection: ps?.modelSelection, permissionMode: ps?.defaultPermissionMode ?? agentConfig.defaultPermissionMode, diff --git a/src/lib/tab-registry-snapshot.ts b/src/lib/tab-registry-snapshot.ts index 7f77757f4..6cc9f420a 100644 --- a/src/lib/tab-registry-snapshot.ts +++ b/src/lib/tab-registry-snapshot.ts @@ -59,6 +59,28 @@ function stripPanePayload(content: PaneContent, serverInstanceId: string): Recor plugins: content.plugins, } } + case 'fresh-agent': + { + const sessionRef = content.sessionRef + || (content.resumeSessionId + ? { + provider: content.provider, + sessionId: content.resumeSessionId, + serverInstanceId, + } + : undefined) + return { + provider: content.provider, + sessionType: content.sessionType, + resumeSessionId: content.resumeSessionId, + sessionRef, + initialCwd: content.initialCwd, + model: content.model, + permissionMode: content.permissionMode, + effort: content.effort, + plugins: content.plugins, + } + } case 'extension': return { extensionName: content.extensionName, diff --git a/src/store/browserPreferencesPersistence.ts b/src/store/browserPreferencesPersistence.ts index cb4772e55..49eff614d 100644 --- a/src/store/browserPreferencesPersistence.ts +++ b/src/store/browserPreferencesPersistence.ts @@ -126,12 +126,13 @@ export function buildLocalSettingsPatch(localSettings: LocalSettings): LocalSett patch.sidebar = sidebar } - const agentChat: LocalSettingsPatch['agentChat'] = {} - assignChangedScalar(agentChat, localSettings.agentChat, defaultLocalSettings.agentChat, 'showThinking') - assignChangedScalar(agentChat, localSettings.agentChat, defaultLocalSettings.agentChat, 'showTools') - assignChangedScalar(agentChat, localSettings.agentChat, defaultLocalSettings.agentChat, 'showTimecodes') - if (Object.keys(agentChat).length > 0) { - patch.agentChat = agentChat + const freshAgent: LocalSettingsPatch['freshAgent'] = {} + assignChangedScalar(freshAgent, localSettings.freshAgent, defaultLocalSettings.freshAgent, 'showThinking') + assignChangedScalar(freshAgent, localSettings.freshAgent, defaultLocalSettings.freshAgent, 'showTools') + assignChangedScalar(freshAgent, localSettings.freshAgent, defaultLocalSettings.freshAgent, 'showTimecodes') + if (Object.keys(freshAgent).length > 0) { + patch.freshAgent = freshAgent + patch.agentChat = freshAgent } const notifications: LocalSettingsPatch['notifications'] = {} diff --git a/src/store/crossTabSync.ts b/src/store/crossTabSync.ts index 9a4b3ef1d..c7c1352ac 100644 --- a/src/store/crossTabSync.ts +++ b/src/store/crossTabSync.ts @@ -92,7 +92,7 @@ function buildCanonicalClaudeSessionRef(localContent: any, localResumeSessionId: } if ( - localContent?.kind === 'agent-chat' + (localContent?.kind === 'agent-chat' || localContent?.kind === 'fresh-agent') || (localContent?.kind === 'terminal' && localContent?.mode === 'claude') ) { return { @@ -112,7 +112,11 @@ function protectCanonicalPaneResumeIdentity(remoteNode: unknown, localLayout: un const localResumeSessionId = localContent?.resumeSessionId const remoteResumeSessionId = candidate.content?.resumeSessionId if ( - (candidate.content?.kind === 'terminal' || candidate.content?.kind === 'agent-chat') + ( + candidate.content?.kind === 'terminal' + || candidate.content?.kind === 'agent-chat' + || candidate.content?.kind === 'fresh-agent' + ) && shouldPreserveLocalCanonicalResumeSessionId(localResumeSessionId, remoteResumeSessionId) ) { const preservedSessionRef = buildCanonicalClaudeSessionRef(localContent, localResumeSessionId) diff --git a/src/store/paneTreeValidation.ts b/src/store/paneTreeValidation.ts index 2055ae2ab..be04d8c10 100644 --- a/src/store/paneTreeValidation.ts +++ b/src/store/paneTreeValidation.ts @@ -53,6 +53,24 @@ function isPaneContentShape(content: unknown): boolean { && (content.viewMode === 'source' || content.viewMode === 'preview') case 'picker': return true + case 'fresh-agent': + return typeof content.sessionType === 'string' + && typeof content.provider === 'string' + && typeof content.createRequestId === 'string' + && typeof content.status === 'string' + && isOptionalString(content.sessionId) + && isOptionalString(content.resumeSessionId) + && isOptionalString(content.initialCwd) + && isOptionalString(content.model) + && isOptionalString(content.permissionMode) + && (content.effort === undefined + || content.effort === 'low' + || content.effort === 'medium' + || content.effort === 'high' + || content.effort === 'max') + && (content.plugins === undefined + || (Array.isArray(content.plugins) && content.plugins.every((plugin) => typeof plugin === 'string'))) + && (content.settingsDismissed === undefined || typeof content.settingsDismissed === 'boolean') case 'agent-chat': return typeof content.provider === 'string' && typeof content.createRequestId === 'string' diff --git a/src/store/paneTypes.ts b/src/store/paneTypes.ts index 122aff019..191fc568a 100644 --- a/src/store/paneTypes.ts +++ b/src/store/paneTypes.ts @@ -6,6 +6,7 @@ import { } from '@shared/agent-chat-capabilities' import type { SessionLocator as SharedSessionLocator } from '@shared/ws-protocol' import type { RestoreError } from '@shared/session-contract' +import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '@shared/fresh-agent' export type SessionLocator = SharedSessionLocator @@ -115,6 +116,30 @@ export type AgentChatCreateError = { retryable?: boolean } +export type FreshAgentPaneContent = { + kind: 'fresh-agent' + sessionType: FreshAgentSessionType + provider: FreshAgentRuntimeProvider + sessionId?: string + createRequestId: string + status: SdkSessionStatus + resumeSessionId?: string + sessionRef?: SessionLocator + /** Runtime-only server locality for same-server matching; never part of canonical durable identity. */ + serverInstanceId?: string + /** Explicit restore failure when no canonical durable target exists. */ + restoreError?: RestoreError + initialCwd?: string + createError?: AgentChatCreateError + modelSelection?: AgentChatModelSelection + model?: string + permissionMode?: string + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + effort?: string + plugins?: string[] + settingsDismissed?: boolean +} + /** * Agent chat pane — rich chat UI powered by a configurable provider. */ @@ -165,7 +190,7 @@ export type ExtensionPaneContent = { * Union type for all pane content types. */ export type PaneContent = TerminalPaneContent | BrowserPaneContent | EditorPaneContent - | PickerPaneContent | AgentChatPaneContent | ExtensionPaneContent + | PickerPaneContent | FreshAgentPaneContent | AgentChatPaneContent | ExtensionPaneContent /** * Input type for creating terminal panes. @@ -195,6 +220,11 @@ export type AgentChatPaneInput = Omit & { + createRequestId?: string + status?: SdkSessionStatus +} + /** * Input type for extension panes. * Extension content needs no normalization — passes through unchanged. @@ -202,7 +232,7 @@ export type AgentChatPaneInput = Omit, + localContent: Extract, _preservedResumeSessionId?: string, ) { return sanitizeSessionRef(localContent.sessionRef) @@ -79,18 +80,34 @@ function normalizePaneContent( devToolsOpen: typeof input.devToolsOpen === 'boolean' ? input.devToolsOpen : false, } } - if (input.kind === 'agent-chat') { + if (input.kind === 'fresh-agent' || input.kind === 'agent-chat') { + const migratedInput = migrateLegacyFreshAgentContent(input) + const sessionType = migratedInput.kind === 'fresh-agent' + ? migratedInput.sessionType + : previous?.kind === 'fresh-agent' + ? previous.sessionType + : undefined + const provider = migratedInput.kind === 'fresh-agent' + ? migratedInput.provider + : resolveFreshAgentRuntimeProvider(sessionType) + if (!sessionType || !provider) { + return input.kind === 'agent-chat' ? input : previous ?? input + } const sessionRef = sanitizeSessionRef(input.sessionRef) const restoreError = RestoreErrorSchema.safeParse((input as { restoreError?: unknown }).restoreError) + const sandbox = input.kind === 'fresh-agent' ? input.sandbox : undefined return { - kind: 'agent-chat', - provider: input.provider, + kind: 'fresh-agent', + sessionType, + provider, sessionId: input.sessionId, createRequestId: input.createRequestId || nanoid(), status: input.status || 'creating', resumeSessionId: input.resumeSessionId, ...(sessionRef ? { sessionRef } : {}), - serverInstanceId: typeof input.serverInstanceId === 'string' ? input.serverInstanceId : undefined, + serverInstanceId: typeof (input as { serverInstanceId?: unknown }).serverInstanceId === 'string' + ? (input as { serverInstanceId: string }).serverInstanceId + : undefined, ...(restoreError.success ? { restoreError: restoreError.data } : {}), initialCwd: input.initialCwd, createError: input.createError, @@ -98,7 +115,11 @@ function normalizePaneContent( (input as { modelSelection?: unknown }).modelSelection, (input as { model?: unknown }).model, ), + model: typeof (input as { model?: unknown }).model === 'string' + ? (input as { model: string }).model + : undefined, permissionMode: input.permissionMode, + sandbox, effort: normalizeAgentChatEffortOverride(input.effort), plugins: input.plugins, settingsDismissed: input.settingsDismissed, @@ -116,7 +137,10 @@ function shouldPreferLocalAgentChatPaneDuringHydration( incomingContent: PaneContent, meta: HydratePanesMeta | undefined, ): boolean { - if (localContent.kind !== 'agent-chat' || incomingContent.kind !== 'agent-chat') { + if ( + (localContent.kind !== 'agent-chat' && localContent.kind !== 'fresh-agent') + || (incomingContent.kind !== 'agent-chat' && incomingContent.kind !== 'fresh-agent') + ) { return false } @@ -130,7 +154,10 @@ function shouldPreferLocalAgentChatPaneDuringHydration( return false } - return isValidClaudeSessionId(localContent.resumeSessionId) + return ( + localContent.provider === 'claude' + && isValidClaudeSessionId(localContent.resumeSessionId) + ) } /** @@ -189,7 +216,11 @@ function collectLegacyDisplayFields(node: unknown): LegacyDisplayFields { if (!node || typeof node !== 'object') return {} const n = node as { type?: string; content?: Record; children?: unknown[] } - if (n.type === 'leaf' && n.content && n.content.kind === 'agent-chat') { + if ( + n.type === 'leaf' + && n.content + && (n.content.kind === 'agent-chat' || n.content.kind === 'fresh-agent') + ) { const c = n.content as Record return { ...(typeof c.showThinking === 'boolean' && c.showThinking ? { showThinking: true } : {}), @@ -214,7 +245,7 @@ function collectLegacyDisplayFields(node: unknown): LegacyDisplayFields { function stripLegacyDisplayFields(node: any): any { if (!node) return node - if (node.type === 'leaf' && node.content?.kind === 'agent-chat') { + if (node.type === 'leaf' && (node.content?.kind === 'agent-chat' || node.content?.kind === 'fresh-agent')) { const { showThinking: _st, showTools: _stl, showTimecodes: _stc, ...rest } = node.content if (_st === undefined && _stl === undefined && _stc === undefined) return node return { ...node, content: rest } @@ -550,7 +581,7 @@ function mergeTerminalState( if (!incomingValid) return localValid ? local : null if (!localValid) return incoming - // If both leaves, apply smart merge for terminal and agent-chat content + // If both leaves, apply smart merge for terminal and rich-agent content if (incoming.type === 'leaf' && local.type === 'leaf') { if (incoming.content?.kind === 'terminal' && local.content?.kind === 'terminal') { if (incoming.content.createRequestId === local.content.createRequestId) { @@ -587,10 +618,13 @@ function mergeTerminalState( } } - // Agent-chat panes: prefer local sessionId and status when the local state + // Rich-agent panes: prefer local sessionId and status when the local state // is more advanced. The persist debounce means incoming (from localStorage) // can be stale — e.g. status 'starting' when local has already reached 'connected'. - if (incoming.content?.kind === 'agent-chat' && local.content?.kind === 'agent-chat') { + if ( + (incoming.content?.kind === 'agent-chat' || incoming.content?.kind === 'fresh-agent') + && (local.content?.kind === 'agent-chat' || local.content?.kind === 'fresh-agent') + ) { if (shouldPreferLocalAgentChatPaneDuringHydration(local.content, incoming.content, meta)) { return local } @@ -693,6 +727,10 @@ function stripStaleIds(content: PaneContent): PaneContentInput { const { sessionId: _sessionId, createRequestId: _createRequestId, status: _status, ...rest } = content return rest } + if (content.kind === 'fresh-agent') { + const { sessionId: _sessionId, createRequestId: _createRequestId, status: _status, ...rest } = content + return rest + } return content } @@ -1265,7 +1303,7 @@ export const panesSlice = createSlice({ function restartContent(node: PaneNode): PaneNode { if (node.type === 'leaf') { - if (node.id !== paneId || node.content.kind !== 'agent-chat') { + if (node.id !== paneId || (node.content.kind !== 'agent-chat' && node.content.kind !== 'fresh-agent')) { return node } return { diff --git a/src/store/persistedState.ts b/src/store/persistedState.ts index 90eabefc6..47288dabe 100644 --- a/src/store/persistedState.ts +++ b/src/store/persistedState.ts @@ -6,6 +6,7 @@ import { migrateLegacyTerminalDurableState, sanitizeSessionRef, } from '@shared/session-contract' +import { migrateLegacyFreshAgentNode } from '@shared/fresh-agent' export { LAYOUT_STORAGE_KEY, TABS_STORAGE_KEY, PANES_STORAGE_KEY } @@ -252,7 +253,10 @@ function normalizePersistedNode(node: unknown): unknown { function normalizePersistedLayouts(layouts: Record): Record { return Object.fromEntries( - Object.entries(layouts).map(([tabId, node]) => [tabId, normalizePersistedNode(node)]), + Object.entries(layouts).map(([tabId, node]) => [ + tabId, + migrateLegacyFreshAgentNode(normalizePersistedNode(node)), + ]), ) } diff --git a/src/store/storage-migration.ts b/src/store/storage-migration.ts index d2b6da6e0..fe8b6f07e 100644 --- a/src/store/storage-migration.ts +++ b/src/store/storage-migration.ts @@ -14,14 +14,8 @@ import { createLogger } from '@/lib/client-logger' import { clearAuthCookie } from '@/lib/auth' -import { LAYOUT_SCHEMA_VERSION, PANES_SCHEMA_VERSION, migrateV2ToV3 } from './persistedState' -import { BROWSER_PREFERENCES_STORAGE_KEY, LAYOUT_STORAGE_KEY } from './storage-keys' -import { - buildRestoreError, - migrateLegacyAgentChatDurableState, - migrateLegacyTerminalDurableState, - sanitizeSessionRef, -} from '@shared/session-contract' +import { BROWSER_PREFERENCES_STORAGE_KEY } from './storage-keys' +import { LAYOUT_STORAGE_KEY, PANES_SCHEMA_VERSION, LAYOUT_SCHEMA_VERSION, parsePersistedLayoutRaw } from './persistedState' const log = createLogger('StorageMigration') @@ -31,6 +25,10 @@ const AUTH_STORAGE_KEY = 'freshell.auth-token' const LEGACY_BROWSER_PREFERENCE_KEYS = [ 'freshell.terminal.fontFamily.v1', ] as const +const LEGACY_STORAGE_KEYS = [ + 'freshell.tabs.v1', + 'freshell.panes.v1', +] as const function readStorageVersion(): number { const stored = localStorage.getItem(STORAGE_VERSION_KEY) @@ -48,188 +46,63 @@ function clearFreshellKeysExcept(keep: string[]): void { } } -function normalizeLayoutTab(tab: Record): Record { - const mode = typeof tab.mode === 'string' ? tab.mode : undefined - const codingCliProvider = typeof tab.codingCliProvider === 'string' ? tab.codingCliProvider : undefined - const provider = codingCliProvider || (mode && mode !== 'shell' ? mode : undefined) - const durableState = migrateLegacyTerminalDurableState({ - provider, - sessionRef: tab.sessionRef, - resumeSessionId: typeof tab.resumeSessionId === 'string' ? tab.resumeSessionId : undefined, - }) - const { resumeSessionId: _resumeSessionId, sessionRef: _legacySessionRef, ...rest } = tab - return { - ...rest, - ...(durableState.sessionRef ? { sessionRef: durableState.sessionRef } : {}), - } -} - -function isCodexSessionRef(sessionRef: unknown): sessionRef is { provider: 'codex'; sessionId: string } { - return !!sessionRef - && typeof sessionRef === 'object' - && (sessionRef as { provider?: unknown }).provider === 'codex' - && typeof (sessionRef as { sessionId?: unknown }).sessionId === 'string' - && (sessionRef as { sessionId: string }).sessionId.length > 0 -} - -function normalizeLegacyRecoveryFailedTerminal( - content: Record, - durableState: { sessionRef?: unknown }, -): Record { - if (content.kind !== 'terminal' || content.mode !== 'codex' || content.status !== 'recovery_failed') { - return content - } - - const { - terminalId: _terminalId, - status: _status, - restoreError: _restoreError, - ...rest - } = content - if (isCodexSessionRef(durableState.sessionRef)) { - return { - ...rest, - status: 'creating', - } - } - - return { - ...rest, - status: 'error', - restoreError: buildRestoreError('invalid_legacy_restore_target'), - } -} - -function normalizeLayoutNode(node: unknown): unknown { - if (!node || typeof node !== 'object') return node - const candidate = node as Record - - if (candidate.type === 'leaf' && candidate.content && typeof candidate.content === 'object') { - const content = candidate.content as Record - if (content.kind === 'terminal') { - const durableState = migrateLegacyTerminalDurableState({ - provider: typeof content.mode === 'string' && content.mode !== 'shell' ? content.mode : undefined, - sessionRef: content.sessionRef, - resumeSessionId: typeof content.resumeSessionId === 'string' ? content.resumeSessionId : undefined, - }) - const { resumeSessionId: _resumeSessionId, sessionRef: _legacySessionRef, restoreError: _legacyRestoreError, ...rest } = content - const normalizedRuntime = normalizeLegacyRecoveryFailedTerminal(rest, durableState) - const isLegacyRecoveryFailed = ( - rest.kind === 'terminal' - && rest.mode === 'codex' - && rest.status === 'recovery_failed' - ) - const normalizedSessionRef = isLegacyRecoveryFailed && !isCodexSessionRef(durableState.sessionRef) - ? undefined - : durableState.sessionRef - return { - ...candidate, - content: { - ...normalizedRuntime, - ...(normalizedSessionRef ? { sessionRef: normalizedSessionRef } : {}), - ...(!isLegacyRecoveryFailed && durableState.restoreError ? { restoreError: durableState.restoreError } : {}), - }, - } - } - - if (content.kind === 'agent-chat') { - const durableState = migrateLegacyAgentChatDurableState({ - sessionRef: content.sessionRef, - cliSessionId: typeof content.cliSessionId === 'string' ? content.cliSessionId : undefined, - timelineSessionId: typeof content.timelineSessionId === 'string' ? content.timelineSessionId : undefined, - resumeSessionId: typeof content.resumeSessionId === 'string' ? content.resumeSessionId : undefined, - }) - const { resumeSessionId: _resumeSessionId, sessionRef: _legacySessionRef, restoreError: _legacyRestoreError, ...rest } = content - return { - ...candidate, - content: { - ...rest, - ...(durableState.sessionRef ? { sessionRef: durableState.sessionRef } : {}), - ...(durableState.restoreError ? { restoreError: durableState.restoreError } : {}), - }, - } +function migrateBrowserPreferencesRecord(raw: string): string | undefined { + try { + const parsed = JSON.parse(raw) as Record + const legacySettings = parsed.settings + if (!legacySettings || typeof legacySettings !== 'object' || Array.isArray(legacySettings)) { + return undefined } - - const sanitizedSessionRef = sanitizeSessionRef(content.sessionRef) - if (!sanitizedSessionRef) return node - - const { sessionRef: _legacySessionRef, ...rest } = content - return { - ...candidate, - content: { - ...rest, - sessionRef: sanitizedSessionRef, - }, + const nextSettings = { ...legacySettings } as Record + if ( + !Object.prototype.hasOwnProperty.call(nextSettings, 'freshAgent') + && Object.prototype.hasOwnProperty.call(nextSettings, 'agentChat') + ) { + nextSettings.freshAgent = nextSettings.agentChat } + return JSON.stringify({ + ...parsed, + settings: nextSettings, + }) + } catch { + return undefined } +} - if (candidate.type === 'split' && Array.isArray(candidate.children) && candidate.children.length === 2) { - return { - ...candidate, - children: [ - normalizeLayoutNode(candidate.children[0]), - normalizeLayoutNode(candidate.children[1]), - ], - } +function migrateLegacyBrowserPreferenceKeys(): string | undefined { + const legacyFont = localStorage.getItem('freshell.terminal.fontFamily.v1')?.trim() + if (!legacyFont) { + return undefined } - return node + return JSON.stringify({ + settings: { + terminal: { + fontFamily: legacyFont, + }, + }, + }) } -function migratePersistedLayout(): boolean { - const raw = localStorage.getItem(LAYOUT_STORAGE_KEY) - if (!raw) return false - - let parsed: any - try { - parsed = JSON.parse(raw) - } catch { - return false +function migrateLayoutRecord(raw: string): string | undefined { + const parsed = parsePersistedLayoutRaw(raw) + if (!parsed) { + return undefined } - if (!parsed || typeof parsed !== 'object' || !parsed.tabs || !parsed.panes) { - return false - } - - const nextTabs = Array.isArray(parsed.tabs.tabs) - ? parsed.tabs.tabs.map((tab: Record) => normalizeLayoutTab(tab)) - : [] - const nextLayouts = parsed.panes.layouts && typeof parsed.panes.layouts === 'object' - ? Object.fromEntries( - Object.entries(parsed.panes.layouts as Record).map(([tabId, node]) => [tabId, normalizeLayoutNode(node)]), - ) - : {} - - localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ - persistedAt: typeof parsed.persistedAt === 'number' ? parsed.persistedAt : Date.now(), + return JSON.stringify({ version: LAYOUT_SCHEMA_VERSION, - tabs: { - ...parsed.tabs, - activeTabId: parsed.tabs.activeTabId ?? null, - tabs: nextTabs, - }, + tabs: parsed.tabs, panes: { - version: Math.max(typeof parsed.panes.version === 'number' ? parsed.panes.version : 1, PANES_SCHEMA_VERSION), - layouts: nextLayouts, - activePane: parsed.panes.activePane ?? {}, - paneTitles: parsed.panes.paneTitles ?? {}, - paneTitleSetByUser: parsed.panes.paneTitleSetByUser ?? {}, + version: Math.max(parsed.panes.version, PANES_SCHEMA_VERSION), + layouts: parsed.panes.layouts, + activePane: parsed.panes.activePane, + paneTitles: parsed.panes.paneTitles, + paneTitleSetByUser: parsed.panes.paneTitleSetByUser, }, - tombstones: Array.isArray(parsed.tombstones) ? parsed.tombstones : [], - })) - return true -} - -function preservePersistedLayout(): boolean { - if (migratePersistedLayout()) { - return true - } - - if (!migrateV2ToV3()) { - return false - } - - return migratePersistedLayout() + tombstones: parsed.tombstones, + ...(typeof parsed.persistedAt === 'number' ? { persistedAt: parsed.persistedAt } : {}), + }) } export function runStorageMigration(): void { @@ -237,26 +110,40 @@ export function runStorageMigration(): void { const currentVersion = readStorageVersion() if (currentVersion >= STORAGE_VERSION) return - const preservedAuthToken = localStorage.getItem(AUTH_STORAGE_KEY) - const migratedLayout = preservePersistedLayout() - clearFreshellKeysExcept([ - AUTH_STORAGE_KEY, - BROWSER_PREFERENCES_STORAGE_KEY, - LAYOUT_STORAGE_KEY, - ...LEGACY_BROWSER_PREFERENCE_KEYS, - ]) + if (!localStorage.getItem(AUTH_STORAGE_KEY)) { + clearAuthCookie() + } - if (preservedAuthToken) { - localStorage.setItem(AUTH_STORAGE_KEY, preservedAuthToken) + const existingLayout = localStorage.getItem(LAYOUT_STORAGE_KEY) + if (existingLayout) { + const migratedLayout = migrateLayoutRecord(existingLayout) + if (migratedLayout) { + localStorage.setItem(LAYOUT_STORAGE_KEY, migratedLayout) + } + } + + const browserPreferences = localStorage.getItem(BROWSER_PREFERENCES_STORAGE_KEY) + if (browserPreferences) { + const migratedPreferences = migrateBrowserPreferencesRecord(browserPreferences) + if (migratedPreferences) { + localStorage.setItem(BROWSER_PREFERENCES_STORAGE_KEY, migratedPreferences) + } } else { - clearAuthCookie() + const migratedPreferences = migrateLegacyBrowserPreferenceKeys() + if (migratedPreferences) { + localStorage.setItem(BROWSER_PREFERENCES_STORAGE_KEY, migratedPreferences) + } + } + + for (const key of LEGACY_STORAGE_KEYS) { + localStorage.removeItem(key) + } + for (const key of LEGACY_BROWSER_PREFERENCE_KEYS) { + localStorage.removeItem(key) } localStorage.setItem(STORAGE_VERSION_KEY, String(STORAGE_VERSION)) - log.info( - `Migrated localStorage (version ${currentVersion} → ${STORAGE_VERSION}) ` + - `${migratedLayout ? 'while preserving restorable layout state.' : 'without preserved layout state.'}` - ) + log.info(`Migrated localStorage in place (version ${currentVersion} → ${STORAGE_VERSION}).`) } catch (err) { log.warn('Storage migration failed:', err) } diff --git a/test/integration/server/platform-api.test.ts b/test/integration/server/platform-api.test.ts index 2f10cc786..2ae182888 100644 --- a/test/integration/server/platform-api.test.ts +++ b/test/integration/server/platform-api.test.ts @@ -182,6 +182,17 @@ describe('Platform API', () => { delete process.env.KILROY_ENABLED }) + + it('returns a featureFlags object that keeps kilroy separate from fresh-agent defaults', async () => { + const res = await request(app) + .get('/api/platform') + .set('x-auth-token', TEST_AUTH_TOKEN) + + expect(res.status).toBe(200) + expect(res.body.featureFlags).toEqual(expect.objectContaining({ + kilroy: false, + })) + }) }) describe('GET /api/version', () => { diff --git a/test/integration/server/settings-api.test.ts b/test/integration/server/settings-api.test.ts index 2be7771e8..dfa88d1a7 100644 --- a/test/integration/server/settings-api.test.ts +++ b/test/integration/server/settings-api.test.ts @@ -168,6 +168,21 @@ describe('Settings API Integration', () => { }) }) + it('PATCH /api/settings accepts freshAgent settings while preserving the legacy alias', async () => { + const res = await request(app) + .patch('/api/settings') + .set('x-auth-token', TEST_AUTH_TOKEN) + .send({ + freshAgent: { + defaultPlugins: ['fs', 'search'], + }, + }) + + expect(res.status).toBe(200) + expect(res.body.freshAgent.defaultPlugins).toEqual(['fs', 'search']) + expect(res.body.agentChat.defaultPlugins).toEqual(['fs', 'search']) + }) + it('PATCH /api/settings preserves runtime CLI providers outside the built-in defaults', async () => { const res = await request(app) .patch('/api/settings') diff --git a/test/unit/client/components/panes/PaneContainer.createContent.test.tsx b/test/unit/client/components/panes/PaneContainer.createContent.test.tsx index 214829020..ed7457a53 100644 --- a/test/unit/client/components/panes/PaneContainer.createContent.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.createContent.test.tsx @@ -270,6 +270,43 @@ describe('createContentForType with ext: prefix', () => { } }) + it('creates fresh-agent content for freshclaude selections', async () => { + const node = createPickerNode('pane-1') + const store = createStore( + { layouts: { 'tab-1': node }, activePane: { 'tab-1': 'pane-1' } }, + [], + {}, + { + status: 'ready', + platform: 'linux', + availableClis: { claude: true }, + }, + ) + + render( + + + , + ) + + const container = getPickerContainer() + fireEvent.keyDown(container, { key: 'a' }) + fireEvent.transitionEnd(container) + const input = screen.getByLabelText('Starting directory for Freshclaude') + fireEvent.change(input, { target: { value: '/workspace/project' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + await waitFor(() => { + const state = store.getState().panes + const paneContent = (state.layouts['tab-1'] as Extract).content + expect(paneContent).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + }) + }) + }) + it('does not include cwd or createRequestId in extension content', () => { const extension: ClientExtensionEntry = { name: 'simple-ext', @@ -349,9 +386,10 @@ describe('createContentForType with ext: prefix', () => { await waitFor(() => { const state = store.getState().panes const paneContent = (state.layouts['tab-1'] as Extract).content - expect(paneContent.kind).toBe('agent-chat') - if (paneContent.kind === 'agent-chat') { - expect(paneContent.provider).toBe('freshclaude') + expect(paneContent.kind).toBe('fresh-agent') + if (paneContent.kind === 'fresh-agent') { + expect(paneContent.sessionType).toBe('freshclaude') + expect(paneContent.provider).toBe('claude') expect(paneContent.plugins).toEqual(['planner', 'sandbox']) expect(paneContent.modelSelection).toEqual({ kind: 'tracked', modelId: 'opus[1m]' }) expect(paneContent.permissionMode).toBe('default') diff --git a/test/unit/client/store/crossTabSync.test.ts b/test/unit/client/store/crossTabSync.test.ts index 44446af6b..dfbc08b77 100644 --- a/test/unit/client/store/crossTabSync.test.ts +++ b/test/unit/client/store/crossTabSync.test.ts @@ -125,6 +125,63 @@ describe('crossTabSync', () => { expect(store.getState().panes.activePane['tab-1']).toBe('pane-a') }) + it('preserves canonical resume identity when cross-tab sync rehydrates a fresh-agent pane', () => { + const store = configureStore({ + reducer: { tabs: tabsReducer, panes: panesReducer }, + }) + + store.dispatch(hydratePanes({ + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-a', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-a', + status: 'idle', + resumeSessionId: '123e4567-e89b-12d3-a456-426614174000', + }, + } as any, + }, + activePane: { 'tab-1': 'pane-a' }, + paneTitles: {}, + })) + + cleanups.push(installCrossTabSync(store as any)) + + const remoteRaw = JSON.stringify({ + version: 3, + tabs: { activeTabId: null, tabs: [] }, + panes: { + version: 6, + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-a', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-a', + status: 'idle', + resumeSessionId: 'not-a-canonical-id', + }, + }, + }, + activePane: { 'tab-1': 'pane-a' }, + paneTitles: {}, + }, + tombstones: [], + }) + + window.dispatchEvent(new StorageEvent('storage', { key: LAYOUT_STORAGE_KEY, newValue: remoteRaw })) + + const layout = store.getState().panes.layouts['tab-1'] as any + expect(layout.content.resumeSessionId).toBe('123e4567-e89b-12d3-a456-426614174000') + }) + it('dedupes identical persisted payloads delivered via both storage and BroadcastChannel', () => { const dispatchSpy = vi.fn() const storeLike = { diff --git a/test/unit/client/store/persisted-state.fresh-agent.test.ts b/test/unit/client/store/persisted-state.fresh-agent.test.ts new file mode 100644 index 000000000..fb55587f0 --- /dev/null +++ b/test/unit/client/store/persisted-state.fresh-agent.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { parsePersistedPanesRaw } from '@/store/persistedState' + +function findLeafContent(node: any): any { + if (!node || typeof node !== 'object') return undefined + if (node.type === 'leaf') return node.content + if (node.type === 'split' && Array.isArray(node.children)) { + return findLeafContent(node.children[0]) ?? findLeafContent(node.children[1]) + } + return undefined +} + +describe('persistedState fresh-agent migration', () => { + it('migrates persisted agent-chat panes to fresh-agent panes', () => { + const parsed = parsePersistedPanesRaw(JSON.stringify({ + version: 6, + layouts: { + tab_1: { + type: 'leaf', + id: 'pane_1', + content: { kind: 'agent-chat', provider: 'freshclaude', createRequestId: 'req-1', status: 'idle' }, + }, + }, + })) + + expect(findLeafContent(parsed!.layouts.tab_1)).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + }) + }) +}) diff --git a/test/unit/client/store/storage-migration.fresh-agent.test.ts b/test/unit/client/store/storage-migration.fresh-agent.test.ts new file mode 100644 index 000000000..3f858baa3 --- /dev/null +++ b/test/unit/client/store/storage-migration.fresh-agent.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() + +describe('storage-migration fresh-agent', () => { + beforeEach(() => { + vi.resetModules() + localStorageMock.clear() + Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + }) + + it('does not clear freshell layout storage during the fresh-agent migration', async () => { + localStorage.setItem('freshell.layout.v3', JSON.stringify({ + version: 3, + tabs: { tabs: [], activeTabId: null }, + panes: { + version: 6, + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { kind: 'agent-chat', provider: 'freshclaude', createRequestId: 'req-1', status: 'idle' }, + }, + }, + activePane: {}, + paneTitles: {}, + paneTitleSetByUser: {}, + }, + tombstones: [], + })) + + const module = await import('@/store/storage-migration') + module.runStorageMigration() + + const raw = localStorage.getItem('freshell.layout.v3') + expect(raw).not.toBeNull() + const parsed = JSON.parse(raw!) + expect(parsed.panes.layouts['tab-1'].content).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + }) + }) +}) diff --git a/test/unit/server/agent-layout-schema.test.ts b/test/unit/server/agent-layout-schema.test.ts index 467af95d6..128c3c4f8 100644 --- a/test/unit/server/agent-layout-schema.test.ts +++ b/test/unit/server/agent-layout-schema.test.ts @@ -50,4 +50,31 @@ describe('UiLayoutSyncSchema', () => { expect(parsed.success).toBe(false) }) + + it('accepts fresh-agent pane payloads in synchronized layouts', () => { + const parsed = UiLayoutSyncSchema.safeParse({ + type: 'ui.layout.sync', + tabs: [{ id: 'tab_a', title: 'alpha' }], + activeTabId: 'tab_a', + layouts: { + tab_a: { + type: 'leaf', + id: 'pane_a', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-1', + status: 'idle', + }, + }, + }, + activePane: { tab_a: 'pane_a' }, + paneTitles: {}, + paneTitleSetByUser: {}, + timestamp: Date.now(), + }) + + expect(parsed.success).toBe(true) + }) }) diff --git a/test/unit/server/config-store.fresh-agent-settings.test.ts b/test/unit/server/config-store.fresh-agent-settings.test.ts new file mode 100644 index 000000000..be9fe06c7 --- /dev/null +++ b/test/unit/server/config-store.fresh-agent-settings.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { createDefaultServerSettings, mergeServerSettings } from '@shared/settings' + +describe('config-store fresh-agent settings compatibility', () => { + it('migrates legacy settings.agentChat to settings.freshAgent', () => { + const settings = mergeServerSettings( + createDefaultServerSettings({ loggingDebug: false }), + { + agentChat: { + defaultPlugins: ['/tmp/plugin'], + providers: { + freshclaude: { defaultModel: 'x' }, + }, + }, + }, + ) + + expect(settings.freshAgent.defaultPlugins).toEqual(['/tmp/plugin']) + expect(settings.freshAgent.providers.freshclaude).toEqual({ defaultModel: 'x' }) + }) +}) diff --git a/test/unit/shared/fresh-agent-registry.test.ts b/test/unit/shared/fresh-agent-registry.test.ts new file mode 100644 index 000000000..a1d00de1b --- /dev/null +++ b/test/unit/shared/fresh-agent-registry.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' + +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' + +describe('fresh-agent registry', () => { + it('keeps kilroy as a hidden claude-backed fresh-agent type', () => { + expect(resolveFreshAgentType('kilroy')).toMatchObject({ + runtimeProvider: 'claude', + hidden: true, + }) + }) + + it('registers freshcodex as a codex-backed session type', () => { + expect(resolveFreshAgentType('freshcodex')).toMatchObject({ + runtimeProvider: 'codex', + label: 'Freshcodex', + }) + }) +}) From 580885f08ed127586e4b7f3bc3e45fde0f1bdc11 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 11:57:40 -0700 Subject: [PATCH 12/86] feat: add fresh agent transport and read models --- server/fresh-agent/provider-registry.ts | 32 ++++ server/fresh-agent/router.ts | 172 +++++++++++++++++ server/fresh-agent/runtime-adapter.ts | 38 ++++ server/fresh-agent/runtime-manager.ts | 178 ++++++++++++++++++ server/index.ts | 8 + server/ws-handler.ts | 51 +++++ shared/read-models.ts | 7 + shared/ws-protocol.ts | 18 ++ test/unit/server/fresh-agent/router.test.ts | 24 +++ .../fresh-agent/runtime-manager.test.ts | 36 ++++ .../server/ws-handler-fresh-agent.test.ts | 93 +++++++++ 11 files changed, 657 insertions(+) create mode 100644 server/fresh-agent/provider-registry.ts create mode 100644 server/fresh-agent/router.ts create mode 100644 server/fresh-agent/runtime-adapter.ts create mode 100644 server/fresh-agent/runtime-manager.ts create mode 100644 test/unit/server/fresh-agent/router.test.ts create mode 100644 test/unit/server/fresh-agent/runtime-manager.test.ts create mode 100644 test/unit/server/ws-handler-fresh-agent.test.ts diff --git a/server/fresh-agent/provider-registry.ts b/server/fresh-agent/provider-registry.ts new file mode 100644 index 000000000..b188ceba9 --- /dev/null +++ b/server/fresh-agent/provider-registry.ts @@ -0,0 +1,32 @@ +import type { FreshAgentSessionType, FreshAgentRuntimeProvider } from '../../shared/fresh-agent.js' +import type { FreshAgentRuntimeAdapter } from './runtime-adapter.js' + +export type FreshAgentProviderRegistration = { + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + adapter: FreshAgentRuntimeAdapter +} + +export class FreshAgentProviderRegistry { + private readonly registrationsBySessionType = new Map() + private readonly registrationsByRuntimeProvider = new Map() + + constructor(registrations: FreshAgentProviderRegistration[]) { + for (const registration of registrations) { + this.registrationsBySessionType.set(registration.sessionType, registration) + this.registrationsByRuntimeProvider.set(registration.runtimeProvider, registration) + } + } + + resolveBySessionType(sessionType: FreshAgentSessionType): FreshAgentProviderRegistration | undefined { + return this.registrationsBySessionType.get(sessionType) + } + + resolveByRuntimeProvider(runtimeProvider: FreshAgentRuntimeProvider): FreshAgentProviderRegistration | undefined { + return this.registrationsByRuntimeProvider.get(runtimeProvider) + } +} + +export function createFreshAgentProviderRegistry(registrations: FreshAgentProviderRegistration[]) { + return new FreshAgentProviderRegistry(registrations) +} diff --git a/server/fresh-agent/router.ts b/server/fresh-agent/router.ts new file mode 100644 index 000000000..150351457 --- /dev/null +++ b/server/fresh-agent/router.ts @@ -0,0 +1,172 @@ +import { Router } from 'express' +import { z } from 'zod' + +import { + AgentTimelinePageQuerySchema, + AgentTimelineTurnBodyQuerySchema, + ReadModelPrioritySchema, +} from '../../shared/read-models.js' +import { + FreshAgentRuntimeManager, + FreshAgentRuntimeUnavailableError, + FreshAgentStaleThreadRevisionError, + FreshAgentUnsupportedCapabilityError, + FreshAgentLostSessionError, +} from './runtime-manager.js' +import { createRequestAbortSignal } from '../read-models/request-abort.js' +import { setResponsePerfContext } from '../request-logger.js' +import { + defaultReadModelScheduler, + isReadModelAbortError, + type ReadModelWorkScheduler, +} from '../read-models/work-scheduler.js' + +const ThreadParamsSchema = z.object({ + provider: z.enum(['claude', 'codex', 'opencode']), + threadId: z.string().min(1), +}) + +const TurnParamsSchema = ThreadParamsSchema.extend({ + turnId: z.string().min(1), +}) + +export function createFreshAgentRouter(deps: { + runtimeManager: FreshAgentRuntimeManager + readModelScheduler?: ReadModelWorkScheduler +}) { + const router = Router() + const readModelScheduler = deps.readModelScheduler ?? defaultReadModelScheduler + + function sendFreshAgentError(res: any, error: unknown) { + if (error instanceof FreshAgentStaleThreadRevisionError) { + return res.status(409).json({ + error: 'Stale thread revision', + code: error.code, + currentRevision: error.currentRevision, + }) + } + if (error instanceof FreshAgentRuntimeUnavailableError) { + return res.status(503).json({ error: error.message, code: error.code }) + } + if (error instanceof FreshAgentUnsupportedCapabilityError) { + return res.status(409).json({ error: error.message, code: error.code }) + } + if (error instanceof FreshAgentLostSessionError) { + return res.status(404).json({ error: error.message, code: error.code }) + } + const message = error instanceof Error ? error.message : 'Fresh-agent request failed' + return res.status(500).json({ error: message }) + } + + router.get('/fresh-agent/threads/:provider/:threadId', async (req, res) => { + const params = ThreadParamsSchema.safeParse(req.params) + const query = z.object({ + priority: ReadModelPrioritySchema.optional(), + revision: z.coerce.number().int().nonnegative().optional(), + }).safeParse({ + priority: typeof req.query.priority === 'string' ? req.query.priority : undefined, + revision: typeof req.query.revision === 'string' ? req.query.revision : undefined, + }) + + if (!params.success || !query.success) { + return res.status(400).json({ + error: 'Invalid request', + details: [...(!params.success ? params.error.issues : []), ...(!query.success ? query.error.issues : [])], + }) + } + + const signal = createRequestAbortSignal(req, res) + try { + const snapshot = await readModelScheduler.schedule({ + lane: query.data.priority ?? 'visible', + signal, + run: async () => deps.runtimeManager.getSnapshot({ + provider: params.data.provider, + threadId: params.data.threadId, + revision: query.data.revision, + }), + }) + setResponsePerfContext(res, { + readModelLane: query.data.priority ?? 'visible', + responsePayloadBytes: Buffer.byteLength(JSON.stringify(snapshot), 'utf8'), + }) + res.json(snapshot) + } catch (error) { + if (signal.aborted || isReadModelAbortError(error)) return + return sendFreshAgentError(res, error) + } + }) + + router.get('/fresh-agent/threads/:provider/:threadId/turns', async (req, res) => { + const params = ThreadParamsSchema.safeParse(req.params) + const query = AgentTimelinePageQuerySchema.safeParse({ + cursor: typeof req.query.cursor === 'string' ? req.query.cursor : undefined, + priority: typeof req.query.priority === 'string' ? req.query.priority : undefined, + revision: typeof req.query.revision === 'string' ? req.query.revision : undefined, + limit: typeof req.query.limit === 'string' ? Number(req.query.limit) : undefined, + includeBodies: typeof req.query.includeBodies === 'string' ? req.query.includeBodies : undefined, + }) + + if (!params.success || !query.success) { + return res.status(400).json({ + error: 'Invalid request', + details: [...(!params.success ? params.error.issues : []), ...(!query.success ? query.error.issues : [])], + }) + } + + const signal = createRequestAbortSignal(req, res) + try { + const page = await readModelScheduler.schedule({ + lane: query.data.priority ?? 'visible', + signal, + run: async () => deps.runtimeManager.getTurnPage({ + provider: params.data.provider, + threadId: params.data.threadId, + cursor: query.data.cursor, + priority: query.data.priority, + revision: query.data.revision, + limit: query.data.limit, + includeBodies: query.data.includeBodies, + }), + }) + setResponsePerfContext(res, { + readModelLane: query.data.priority ?? 'visible', + responsePayloadBytes: Buffer.byteLength(JSON.stringify(page), 'utf8'), + }) + res.json(page) + } catch (error) { + if (signal.aborted || isReadModelAbortError(error)) return + return sendFreshAgentError(res, error) + } + }) + + router.get('/fresh-agent/threads/:provider/:threadId/turns/:turnId', async (req, res) => { + const params = TurnParamsSchema.safeParse(req.params) + const query = AgentTimelineTurnBodyQuerySchema.safeParse({ + revision: typeof req.query.revision === 'string' ? req.query.revision : undefined, + }) + if (!params.success || !query.success) { + return res.status(400).json({ + error: 'Invalid request', + details: [...(!params.success ? params.error.issues : []), ...(!query.success ? query.error.issues : [])], + }) + } + + try { + const turn = await deps.runtimeManager.getTurnBody({ + provider: params.data.provider, + threadId: params.data.threadId, + turnId: params.data.turnId, + revision: query.data.revision, + }) + if (!turn) { + return res.status(404).json({ error: 'Turn not found' }) + } + res.json(turn) + } catch (error) { + return sendFreshAgentError(res, error) + } + }) + + return router +} diff --git a/server/fresh-agent/runtime-adapter.ts b/server/fresh-agent/runtime-adapter.ts new file mode 100644 index 000000000..13de1c2bc --- /dev/null +++ b/server/fresh-agent/runtime-adapter.ts @@ -0,0 +1,38 @@ +import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '../../shared/fresh-agent.js' + +export type FreshAgentCreateRequest = { + requestId: string + sessionType: FreshAgentSessionType + cwd?: string + resumeSessionId?: string + model?: string + permissionMode?: string + effort?: 'low' | 'medium' | 'high' | 'max' + plugins?: string[] +} + +export type FreshAgentCreateResult = { + sessionId: string + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider +} + +export type FreshAgentThreadLocator = { + provider: FreshAgentRuntimeProvider + threadId: string +} + +export interface FreshAgentRuntimeAdapter { + readonly runtimeProvider: FreshAgentRuntimeProvider + create(input: FreshAgentCreateRequest): Promise<{ sessionId: string }> + resume?(input: FreshAgentCreateRequest): Promise<{ sessionId: string }> + subscribe?(sessionId: string, listener: (message: unknown) => void): Promise<() => void> | (() => void) + send?(sessionId: string, input: { text: string; images?: Array<{ mediaType: string; data: string }> }): Promise | void + interrupt?(sessionId: string): Promise | void + fork?(sessionId: string, input?: Record): Promise | unknown + answerQuestion?(sessionId: string, requestId: string, answers: Record): Promise | void + resolveApproval?(sessionId: string, requestId: string, decision: Record): Promise | void + getSnapshot?(thread: FreshAgentThreadLocator, revision?: number): Promise + getTurnPage?(thread: FreshAgentThreadLocator, query: Record): Promise + getTurnBody?(thread: FreshAgentThreadLocator & { turnId: string }, revision: number): Promise +} diff --git a/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts new file mode 100644 index 000000000..c27da07f1 --- /dev/null +++ b/server/fresh-agent/runtime-manager.ts @@ -0,0 +1,178 @@ +import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '../../shared/fresh-agent.js' +import type { FreshAgentProviderRegistry } from './provider-registry.js' +import type { FreshAgentCreateRequest, FreshAgentCreateResult, FreshAgentRuntimeAdapter } from './runtime-adapter.js' + +export class FreshAgentRuntimeUnavailableError extends Error { + readonly code = 'FRESH_AGENT_RUNTIME_UNAVAILABLE' as const +} + +export class FreshAgentStaleThreadRevisionError extends Error { + readonly code = 'STALE_THREAD_REVISION' as const + + constructor(readonly currentRevision: number) { + super('Fresh-agent thread revision is stale') + } +} + +export class FreshAgentUnsupportedCapabilityError extends Error { + readonly code = 'FRESH_AGENT_UNSUPPORTED_CAPABILITY' as const +} + +export class FreshAgentLostSessionError extends Error { + readonly code = 'FRESH_AGENT_LOST_SESSION' as const +} + +type FreshAgentRuntimeManagerOptions = { + registry: FreshAgentProviderRegistry +} + +type SessionRecord = { + sessionType: FreshAgentSessionType + runtimeProvider: FreshAgentRuntimeProvider + adapter: FreshAgentRuntimeAdapter +} + +export class FreshAgentRuntimeManager { + private readonly sessions = new Map() + + constructor(private readonly options: FreshAgentRuntimeManagerOptions) {} + + async create(input: FreshAgentCreateRequest): Promise { + const registration = this.options.registry.resolveBySessionType(input.sessionType) + if (!registration) { + throw new FreshAgentRuntimeUnavailableError(`No fresh-agent adapter registered for ${input.sessionType}`) + } + + const created = await registration.adapter.create(input) + this.sessions.set(created.sessionId, { + sessionType: input.sessionType, + runtimeProvider: registration.runtimeProvider, + adapter: registration.adapter, + }) + return { + sessionId: created.sessionId, + sessionType: input.sessionType, + runtimeProvider: registration.runtimeProvider, + } + } + + async resume(input: FreshAgentCreateRequest): Promise { + const registration = this.options.registry.resolveBySessionType(input.sessionType) + if (!registration) { + throw new FreshAgentRuntimeUnavailableError(`No fresh-agent adapter registered for ${input.sessionType}`) + } + if (!registration.adapter.resume) { + throw new FreshAgentUnsupportedCapabilityError(`Resume is not supported for ${input.sessionType}`) + } + const resumed = await registration.adapter.resume(input) + this.sessions.set(resumed.sessionId, { + sessionType: input.sessionType, + runtimeProvider: registration.runtimeProvider, + adapter: registration.adapter, + }) + return { + sessionId: resumed.sessionId, + sessionType: input.sessionType, + runtimeProvider: registration.runtimeProvider, + } + } + + async subscribe(sessionId: string, listener: (message: unknown) => void) { + const record = this.requireSession(sessionId) + if (!record.adapter.subscribe) { + throw new FreshAgentUnsupportedCapabilityError(`Subscribe is not supported for ${record.sessionType}`) + } + return await record.adapter.subscribe(sessionId, listener) + } + + async send(sessionId: string, input: { text: string; images?: Array<{ mediaType: string; data: string }> }) { + const record = this.requireSession(sessionId) + if (!record.adapter.send) { + throw new FreshAgentUnsupportedCapabilityError(`Send is not supported for ${record.sessionType}`) + } + await record.adapter.send(sessionId, input) + } + + async interrupt(sessionId: string) { + const record = this.requireSession(sessionId) + if (!record.adapter.interrupt) { + throw new FreshAgentUnsupportedCapabilityError(`Interrupt is not supported for ${record.sessionType}`) + } + await record.adapter.interrupt(sessionId) + } + + async fork(sessionId: string, input?: Record) { + const record = this.requireSession(sessionId) + if (!record.adapter.fork) { + throw new FreshAgentUnsupportedCapabilityError(`Fork is not supported for ${record.sessionType}`) + } + return await record.adapter.fork(sessionId, input) + } + + async answerQuestion(sessionId: string, requestId: string, answers: Record) { + const record = this.requireSession(sessionId) + if (!record.adapter.answerQuestion) { + throw new FreshAgentUnsupportedCapabilityError(`Questions are not supported for ${record.sessionType}`) + } + await record.adapter.answerQuestion(sessionId, requestId, answers) + } + + async resolveApproval(sessionId: string, requestId: string, decision: Record) { + const record = this.requireSession(sessionId) + if (!record.adapter.resolveApproval) { + throw new FreshAgentUnsupportedCapabilityError(`Approvals are not supported for ${record.sessionType}`) + } + await record.adapter.resolveApproval(sessionId, requestId, decision) + } + + async getSnapshot(input: { provider: FreshAgentRuntimeProvider; threadId: string; revision?: number }) { + const registration = this.options.registry.resolveByRuntimeProvider(input.provider) + if (!registration?.adapter.getSnapshot) { + throw new FreshAgentRuntimeUnavailableError(`No fresh-agent snapshot adapter registered for ${input.provider}`) + } + return await registration.adapter.getSnapshot({ provider: input.provider, threadId: input.threadId }, input.revision) + } + + async getTurnPage(input: { + provider: FreshAgentRuntimeProvider + threadId: string + cursor?: string + priority?: string + revision: number + limit?: number + includeBodies?: boolean + }) { + const registration = this.options.registry.resolveByRuntimeProvider(input.provider) + if (!registration?.adapter.getTurnPage) { + throw new FreshAgentRuntimeUnavailableError(`No fresh-agent turn-page adapter registered for ${input.provider}`) + } + return await registration.adapter.getTurnPage( + { provider: input.provider, threadId: input.threadId }, + input, + ) + } + + async getTurnBody(input: { + provider: FreshAgentRuntimeProvider + threadId: string + turnId: string + revision: number + }) { + const registration = this.options.registry.resolveByRuntimeProvider(input.provider) + if (!registration?.adapter.getTurnBody) { + throw new FreshAgentRuntimeUnavailableError(`No fresh-agent turn-body adapter registered for ${input.provider}`) + } + return await registration.adapter.getTurnBody( + { provider: input.provider, threadId: input.threadId, turnId: input.turnId }, + input.revision, + ) + } + + private requireSession(sessionId: string): SessionRecord { + const record = this.sessions.get(sessionId) + if (!record) { + throw new FreshAgentLostSessionError(`Fresh-agent session ${sessionId} is not tracked`) + } + return record + } +} diff --git a/server/index.ts b/server/index.ts index 16735c9fe..bcfee852a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -81,6 +81,9 @@ import { CodexLaunchPlanner } from './coding-cli/codex-app-server/launch-planner import { CodexTerminalSidecar } from './coding-cli/codex-app-server/sidecar.js' import { registerStaticClientRoutes } from './static-client-routes.js' import { joinCodexShutdownOwners } from './shutdown-join.js' +import { createFreshAgentProviderRegistry } from './fresh-agent/provider-registry.js' +import { FreshAgentRuntimeManager } from './fresh-agent/runtime-manager.js' +import { createFreshAgentRouter } from './fresh-agent/router.js' function compileArgTemplate( template: string[] | undefined, @@ -230,6 +233,9 @@ async function main() { const serverInstanceId = await loadOrCreateServerInstanceId() await runCodexStartupReaper({ serverInstanceId }) const agentChatCapabilityRegistry = new AgentChatCapabilityRegistry() + const freshAgentRuntimeManager = new FreshAgentRuntimeManager({ + registry: createFreshAgentProviderRegistry([]), + }) let sdkBridge: SdkBridge @@ -363,6 +369,7 @@ async function main() { codexActivityListProvider: () => codexActivity.tracker.list(), agentHistorySource, opencodeActivityListProvider: () => opencodeActivity?.tracker.list() ?? [], + freshAgentRuntimeManager, }, ) attachProxyUpgradeHandler(server) @@ -564,6 +571,7 @@ async function main() { agentHistorySource, }), })) + app.use('/api', createFreshAgentRouter({ runtimeManager: freshAgentRuntimeManager })) app.use('/api', createProjectColorsRouter({ configStore, codingCliIndexer })) diff --git a/server/ws-handler.ts b/server/ws-handler.ts index 65d743105..905224328 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -65,6 +65,7 @@ import { TerminalKillSchema, CodingCliInputSchema, CodingCliKillSchema, + FreshAgentCreateSchema, SdkCreateSchema, SdkSendSchema, SdkPermissionRespondSchema, @@ -80,6 +81,7 @@ import { import { UiLayoutSyncSchema } from './agent-api/layout-schema.js' import type { LayoutStore } from './agent-api/layout-store.js' import { LiveTerminalHandleSchema } from '../shared/session-contract.js' +import type { FreshAgentRuntimeManager } from './fresh-agent/runtime-manager.js' type WsHandlerConfig = { maxConnections: number @@ -110,6 +112,7 @@ export type WsHandlerOptions = { codexActivityListProvider?: () => CodexActivityRecord[] agentHistorySource?: AgentHistorySource opencodeActivityListProvider?: () => OpencodeActivityRecord[] + freshAgentRuntimeManager?: FreshAgentRuntimeManager } function readWsHandlerConfig(): WsHandlerConfig { @@ -464,6 +467,7 @@ export class WsHandler { private layoutStore?: LayoutStore private extensionManager?: ExtensionManager private agentHistorySource?: AgentHistorySource + private readonly freshAgentRuntimeManager?: FreshAgentRuntimeManager private terminalStreamBroker: TerminalStreamBroker private terminalCreateLocks = new Map>() private createdTerminalByRequestId = new Map() @@ -508,6 +512,7 @@ export class WsHandler { this.codingCliManager = options.codingCliManager this.codexLaunchPlanner = options.codexLaunchPlanner this.sdkBridge = options.sdkBridge + this.freshAgentRuntimeManager = options.freshAgentRuntimeManager this.sessionRepairService = options.sessionRepairService this.handshakeSnapshotProvider = options.handshakeSnapshotProvider this.terminalMetaListProvider = options.terminalMetaListProvider @@ -600,6 +605,7 @@ export class WsHandler { dynamicCodingCliCreateSchema, CodingCliInputSchema, CodingCliKillSchema, + FreshAgentCreateSchema, SdkCreateSchema, SdkSendSchema, SdkPermissionRespondSchema, @@ -3020,6 +3026,51 @@ export class WsHandler { return } + case 'freshAgent.create': { + if (!this.freshAgentRuntimeManager) { + this.send(ws, { + type: 'freshAgent.create.failed', + requestId: m.requestId, + code: 'FRESH_AGENT_RUNTIME_UNAVAILABLE', + message: 'Fresh-agent runtime not enabled', + retryable: true, + } as const) + return + } + try { + const created = await this.freshAgentRuntimeManager.create({ + requestId: m.requestId, + sessionType: m.sessionType, + cwd: m.cwd, + resumeSessionId: m.resumeSessionId, + model: m.model, + permissionMode: m.permissionMode, + effort: m.effort, + plugins: m.plugins, + }) + this.send(ws, { + type: 'freshAgent.created', + requestId: m.requestId, + sessionId: created.sessionId, + sessionType: created.sessionType, + runtimeProvider: created.runtimeProvider, + } as const) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create fresh-agent session' + const code = error && typeof error === 'object' && 'code' in error + ? String((error as { code?: unknown }).code) + : 'FRESH_AGENT_CREATE_FAILED' + this.send(ws, { + type: 'freshAgent.create.failed', + requestId: m.requestId, + code, + message, + retryable: true, + } as const) + } + return + } + case 'sdk.send': { if (!this.sdkBridge) { this.sendError(ws, { code: 'INTERNAL_ERROR', message: 'SDK bridge not enabled' }) diff --git a/shared/read-models.ts b/shared/read-models.ts index 43262124d..ce46a17ae 100644 --- a/shared/read-models.ts +++ b/shared/read-models.ts @@ -94,6 +94,12 @@ export const RestoreStaleRevisionResponseSchema = z.object({ currentRevision: z.number().int().nonnegative(), }) +export const FreshAgentStaleRevisionResponseSchema = z.object({ + error: z.string().min(1), + code: z.literal('STALE_THREAD_REVISION'), + currentRevision: z.number().int().nonnegative(), +}) + export const TerminalScrollbackQuerySchema = z.object({ cursor: z.string().min(1).optional(), limit: z.number().int().positive().max(200).optional(), @@ -114,5 +120,6 @@ export type TerminalDirectoryQuery = z.infer export type AgentTimelineTurnBodyQuery = z.infer export type RestoreStaleRevisionResponse = z.infer +export type FreshAgentStaleRevisionResponse = z.infer export type TerminalScrollbackQuery = z.infer export type TerminalSearchQuery = z.infer diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 3c494be5b..dbaf95bdc 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -403,7 +403,20 @@ export const SdkQuestionRespondSchema = z.object({ answers: z.record(z.string(), z.string()), }) +export const FreshAgentCreateSchema = z.object({ + type: z.literal('freshAgent.create'), + requestId: z.string().min(1), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + cwd: z.string().optional(), + resumeSessionId: z.string().optional(), + model: z.string().optional(), + permissionMode: z.string().optional(), + effort: z.enum(['low', 'medium', 'high', 'max']).optional(), + plugins: z.array(z.string()).optional(), +}) + export const BrowserSdkMessageSchema = z.discriminatedUnion('type', [ + FreshAgentCreateSchema, SdkCreateSchema, SdkSendSchema, SdkPermissionRespondSchema, @@ -436,6 +449,7 @@ export const ClientMessageSchema = z.discriminatedUnion('type', [ CodingCliCreateSchema, CodingCliInputSchema, CodingCliKillSchema, + FreshAgentCreateSchema, SdkCreateSchema, SdkSendSchema, SdkPermissionRespondSchema, @@ -702,6 +716,10 @@ export type SdkRestoreFailureCode = | 'RESTORE_DIVERGED' | 'RESTORE_STALE_REVISION' +export type FreshAgentServerMessage = + | { type: 'freshAgent.created'; requestId: string; sessionId: string; sessionType: string; runtimeProvider: string } + | { type: 'freshAgent.create.failed'; requestId: string; code: string; message: string; retryable?: boolean } + export type SdkServerMessage = | { type: 'sdk.created'; requestId: string; sessionId: string } | { diff --git a/test/unit/server/fresh-agent/router.test.ts b/test/unit/server/fresh-agent/router.test.ts new file mode 100644 index 000000000..2c4774a68 --- /dev/null +++ b/test/unit/server/fresh-agent/router.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import express from 'express' +import request from 'supertest' + +import { createFreshAgentRouter } from '../../../../server/fresh-agent/router.js' +import { FreshAgentRuntimeManager, FreshAgentStaleThreadRevisionError } from '../../../../server/fresh-agent/runtime-manager.js' + +describe('fresh-agent router', () => { + it('returns 409 for stale thread revisions instead of mixing bodies from different revisions', async () => { + const manager = { + getTurnBody: vi.fn().mockRejectedValue(new FreshAgentStaleThreadRevisionError(7)), + } as unknown as FreshAgentRuntimeManager + + const app = express() + app.use('/api', createFreshAgentRouter({ runtimeManager: manager })) + + const response = await request(app) + .get('/api/fresh-agent/threads/codex/thread-1/turns/turn-9?revision=4') + + expect(response.status).toBe(409) + expect(response.body.code).toBe('STALE_THREAD_REVISION') + expect(response.body.currentRevision).toBe(7) + }) +}) diff --git a/test/unit/server/fresh-agent/runtime-manager.test.ts b/test/unit/server/fresh-agent/runtime-manager.test.ts new file mode 100644 index 000000000..9b7c66ed1 --- /dev/null +++ b/test/unit/server/fresh-agent/runtime-manager.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' + +import { FreshAgentRuntimeManager } from '../../../../server/fresh-agent/runtime-manager.js' +import { createFreshAgentProviderRegistry } from '../../../../server/fresh-agent/provider-registry.js' + +describe('FreshAgentRuntimeManager', () => { + it('routes freshAgent.create through the adapter selected by sessionType', async () => { + const codexAdapter = { + create: vi.fn().mockResolvedValue({ sessionId: 'codex-session-1' }), + } + const registry = createFreshAgentProviderRegistry([ + { + sessionType: 'freshcodex', + runtimeProvider: 'codex', + adapter: codexAdapter as any, + }, + ]) + const manager = new FreshAgentRuntimeManager({ registry }) + + const created = await manager.create({ + requestId: 'req-1', + sessionType: 'freshcodex', + cwd: '/workspace', + }) + + expect(codexAdapter.create).toHaveBeenCalledWith(expect.objectContaining({ + sessionType: 'freshcodex', + cwd: '/workspace', + })) + expect(created).toEqual({ + sessionId: 'codex-session-1', + sessionType: 'freshcodex', + runtimeProvider: 'codex', + }) + }) +}) diff --git a/test/unit/server/ws-handler-fresh-agent.test.ts b/test/unit/server/ws-handler-fresh-agent.test.ts new file mode 100644 index 000000000..d43378844 --- /dev/null +++ b/test/unit/server/ws-handler-fresh-agent.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import http from 'http' +import WebSocket from 'ws' + +import { WsHandler } from '../../../server/ws-handler.js' +import { TerminalRegistry } from '../../../server/terminal-registry.js' +import { WS_PROTOCOL_VERSION } from '../../../shared/ws-protocol.js' + +const TEST_AUTH_TOKEN = 'testtoken-testtoken' + +describe('WsHandler fresh-agent routing', () => { + let originalAuthToken: string | undefined + + beforeEach(() => { + originalAuthToken = process.env.AUTH_TOKEN + process.env.AUTH_TOKEN = TEST_AUTH_TOKEN + }) + + async function createServer(options: Record = {}) { + const server = http.createServer() + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())) + const registry = new TerminalRegistry() + const handler = new WsHandler(server, registry, options as any) + return { server, registry, handler } + } + + async function connectAndAuth(server: http.Server) { + const addr = server.address() + const port = typeof addr === 'object' ? addr!.port : 0 + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timeout waiting for ready')), 5000) + ws.on('open', () => { + ws.send(JSON.stringify({ + type: 'hello', + token: TEST_AUTH_TOKEN, + protocolVersion: WS_PROTOCOL_VERSION, + })) + }) + ws.on('message', (data) => { + const message = JSON.parse(data.toString()) + if (message.type === 'ready') { + clearTimeout(timeout) + resolve() + } + }) + ws.on('error', reject) + }) + return ws + } + + it('routes freshAgent.create through the runtime manager while terminal traffic remains unchanged', async () => { + const runtimeManager = { + create: vi.fn().mockResolvedValue({ + sessionId: 'codex-session-1', + sessionType: 'freshcodex', + runtimeProvider: 'codex', + }), + } + const { server, registry, handler } = await createServer({ freshAgentRuntimeManager: runtimeManager }) + + try { + const ws = await connectAndAuth(server) + const seenMessages: any[] = [] + ws.on('message', (data) => { + seenMessages.push(JSON.parse(data.toString())) + }) + + ws.send(JSON.stringify({ + type: 'freshAgent.create', + requestId: 'req-1', + sessionType: 'freshcodex', + cwd: '/workspace', + })) + ws.send(JSON.stringify({ + type: 'terminal.create', + requestId: 'term-1', + mode: 'shell', + })) + + await vi.waitFor(() => { + expect(runtimeManager.create).toHaveBeenCalledWith(expect.objectContaining({ + sessionType: 'freshcodex', + })) + expect(seenMessages.some((message) => message.type === 'freshAgent.created')).toBe(true) + }) + } finally { + handler.close() + registry.shutdown() + await new Promise((resolve) => server.close(() => resolve())) + } + }) +}) From 3f83456eea6cccc5385abd22cbc2f71bc9e65dca Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 12:06:42 -0700 Subject: [PATCH 13/86] refactor: move claude runtime behind fresh agent adapter --- server/agent-timeline/service.ts | 32 +++ server/fresh-agent/adapters/claude/adapter.ts | 197 ++++++++++++++ .../fresh-agent/adapters/claude/normalize.ts | 241 ++++++++++++++++++ server/index.ts | 30 ++- server/sdk-bridge-types.ts | 1 + server/sdk-bridge.ts | 2 + test/fixtures/fresh-agent/claude/thread.ts | 102 ++++++++ .../server/fresh-agent/claude-adapter.test.ts | 63 +++++ .../fresh-agent/claude-normalize.test.ts | 46 ++++ .../claude-restore-contract.test.ts | 71 ++++++ 10 files changed, 779 insertions(+), 6 deletions(-) create mode 100644 server/fresh-agent/adapters/claude/adapter.ts create mode 100644 server/fresh-agent/adapters/claude/normalize.ts create mode 100644 test/fixtures/fresh-agent/claude/thread.ts create mode 100644 test/unit/server/fresh-agent/claude-adapter.test.ts create mode 100644 test/unit/server/fresh-agent/claude-normalize.test.ts create mode 100644 test/unit/server/fresh-agent/claude-restore-contract.test.ts diff --git a/server/agent-timeline/service.ts b/server/agent-timeline/service.ts index fcb849b81..d670f88ae 100644 --- a/server/agent-timeline/service.ts +++ b/server/agent-timeline/service.ts @@ -20,7 +20,15 @@ type TimelineCursorPayload = { type TimelineMessageRecord = CanonicalTurn & { sessionId: string } +export type AgentTimelineSnapshot = { + sessionId: string + latestTurnId: string | null + revision: number + turns: CanonicalTurn[] +} + export type AgentTimelineService = { + getSnapshot: (query: { sessionId: string; revision?: number; signal?: AbortSignal }) => Promise getTimelinePage: (query: AgentTimelinePageQuery & { sessionId: string; signal?: AbortSignal }) => Promise getTurnBody: (query: AgentTimelineTurnBodyQuery & { sessionId: string; turnId: string; signal?: AbortSignal }) => Promise } @@ -134,6 +142,30 @@ export function createAgentTimelineService(deps: AgentTimelineServiceDeps): Agen } return { + async getSnapshot({ sessionId, revision, signal }) { + throwIfAborted(signal) + const timeline = await loadTimeline(sessionId) + throwIfAborted(signal) + if (revision != null && revision !== timeline.revision) { + throw new RestoreStaleRevisionError(revision, timeline.revision) + } + return { + sessionId: timeline.sessionId, + latestTurnId: timeline.latestTurnId, + revision: timeline.revision, + turns: timeline.records + .slice() + .reverse() + .map((record) => ({ + turnId: record.turnId, + messageId: record.messageId, + ordinal: record.ordinal, + source: record.source, + message: record.message, + })), + } + }, + async getTimelinePage(query) { throwIfAborted(query.signal) if (query.revision == null) { diff --git a/server/fresh-agent/adapters/claude/adapter.ts b/server/fresh-agent/adapters/claude/adapter.ts new file mode 100644 index 000000000..d5d31e0b1 --- /dev/null +++ b/server/fresh-agent/adapters/claude/adapter.ts @@ -0,0 +1,197 @@ +import { + RestoreResolutionError, + RestoreStaleRevisionError, + createAgentTimelineService, + type AgentTimelineService, +} from '../../../agent-timeline/service.js' +import type { AgentHistorySource } from '../../../agent-timeline/history-source.js' +import type { SdkBridge } from '../../../sdk-bridge.js' +import type { SdkSessionState } from '../../../sdk-bridge-types.js' +import { FreshAgentStaleThreadRevisionError } from '../../runtime-manager.js' +import type { FreshAgentCreateRequest, FreshAgentRuntimeAdapter, FreshAgentThreadLocator } from '../../runtime-adapter.js' +import { + normalizeClaudeThreadSnapshot, + normalizeClaudeTurnBody, + normalizeClaudeTurnPage, +} from './normalize.js' + +type ClaudeBridgePort = Pick< + SdkBridge, + | 'createSession' + | 'subscribe' + | 'sendUserMessage' + | 'interrupt' + | 'respondQuestion' + | 'respondPermission' + | 'getSession' + | 'findSessionByCliSessionId' +> + +export type ClaudeFreshAgentAdapterDeps = { + sdkBridge: ClaudeBridgePort + agentHistorySource?: AgentHistorySource + timelineService?: AgentTimelineService +} + +function mapTimelineError(error: unknown): never { + if (error instanceof RestoreStaleRevisionError) { + throw new FreshAgentStaleThreadRevisionError(error.actualRevision) + } + throw error +} + +function mapMissingResult(ok: boolean, message: string): void { + if (!ok) { + throw new Error(message) + } +} + +export function createClaudeFreshAgentAdapter(deps: ClaudeFreshAgentAdapterDeps): FreshAgentRuntimeAdapter { + const timelineService = deps.timelineService ?? ( + deps.agentHistorySource + ? createAgentTimelineService({ agentHistorySource: deps.agentHistorySource }) + : null + ) + + function resolveLiveSession(threadId: string): SdkSessionState | undefined { + return deps.sdkBridge.getSession(threadId) ?? deps.sdkBridge.findSessionByCliSessionId(threadId) + } + + async function loadResolved(threadId: string, revision?: number) { + if (!timelineService) { + throw new Error('Claude timeline service is not configured') + } + try { + return await timelineService.getSnapshot({ sessionId: threadId, revision }) + } catch (error) { + mapTimelineError(error) + } + } + + return { + runtimeProvider: 'claude', + + async create(input: FreshAgentCreateRequest) { + const session = await deps.sdkBridge.createSession({ + cwd: input.cwd, + resumeSessionId: input.resumeSessionId, + model: input.model, + permissionMode: input.permissionMode, + effort: input.effort, + plugins: input.plugins, + }) + return { sessionId: session.sessionId } + }, + + async resume(input: FreshAgentCreateRequest) { + const session = await deps.sdkBridge.createSession({ + cwd: input.cwd, + resumeSessionId: input.resumeSessionId, + model: input.model, + permissionMode: input.permissionMode, + effort: input.effort, + plugins: input.plugins, + }) + return { sessionId: session.sessionId } + }, + + subscribe(sessionId, listener) { + const subscription = deps.sdkBridge.subscribe(sessionId, listener as never) + if (!subscription) { + throw new Error(`Claude session ${sessionId} is not available`) + } + return subscription.off + }, + + send(sessionId, input) { + mapMissingResult( + deps.sdkBridge.sendUserMessage(sessionId, input.text, input.images), + `Claude session ${sessionId} is not available`, + ) + }, + + interrupt(sessionId) { + mapMissingResult( + deps.sdkBridge.interrupt(sessionId), + `Claude session ${sessionId} is not available`, + ) + }, + + answerQuestion(sessionId, requestId, answers) { + mapMissingResult( + deps.sdkBridge.respondQuestion(sessionId, requestId, answers), + `Claude question ${requestId} is not available`, + ) + }, + + resolveApproval(sessionId, requestId, decision) { + mapMissingResult( + deps.sdkBridge.respondPermission(sessionId, requestId, decision as never), + `Claude approval ${requestId} is not available`, + ) + }, + + async getSnapshot(thread: FreshAgentThreadLocator, revision?: number) { + const resolvedSnapshot = await loadResolved(thread.threadId, revision) + const liveSession = resolveLiveSession(thread.threadId) + const resolved = await deps.agentHistorySource?.resolve( + thread.threadId, + liveSession ? { liveSessionOverride: liveSession } : undefined, + ) + if (!resolved || resolved.kind !== 'resolved') { + throw new RestoreResolutionError('RESTORE_NOT_FOUND', 'Restore session not found') + } + return normalizeClaudeThreadSnapshot({ + threadId: thread.threadId, + resolved: { + ...resolved, + revision: resolvedSnapshot.revision, + latestTurnId: resolvedSnapshot.latestTurnId, + turns: resolvedSnapshot.turns, + }, + liveSession, + status: liveSession?.status ?? 'idle', + }) + }, + + async getTurnPage(thread, query) { + if (!timelineService) { + throw new Error('Claude timeline service is not configured') + } + try { + const page = await timelineService.getTimelinePage({ + sessionId: thread.threadId, + cursor: typeof query.cursor === 'string' ? query.cursor : undefined, + priority: typeof query.priority === 'string' ? query.priority as 'visible' | 'background' : undefined, + revision: Number(query.revision), + limit: typeof query.limit === 'number' ? query.limit : undefined, + includeBodies: query.includeBodies === true, + }) + return normalizeClaudeTurnPage({ threadId: thread.threadId, page }) + } catch (error) { + mapTimelineError(error) + } + }, + + async getTurnBody(thread, revision) { + if (!timelineService) { + throw new Error('Claude timeline service is not configured') + } + try { + const turn = await timelineService.getTurnBody({ + sessionId: thread.threadId, + turnId: thread.turnId, + revision, + }) + if (!turn) return null + return normalizeClaudeTurnBody({ + turn, + revision, + threadId: thread.threadId, + }) + } catch (error) { + mapTimelineError(error) + } + }, + } +} diff --git a/server/fresh-agent/adapters/claude/normalize.ts b/server/fresh-agent/adapters/claude/normalize.ts new file mode 100644 index 000000000..0ea661e0b --- /dev/null +++ b/server/fresh-agent/adapters/claude/normalize.ts @@ -0,0 +1,241 @@ +import type { RestoreResolution } from '../../../agent-timeline/ledger.js' +import type { AgentTimelinePage, AgentTimelineTurn } from '../../../agent-timeline/types.js' +import type { QuestionDefinition, SdkSessionState } from '../../../sdk-bridge-types.js' +import type { SdkSessionStatus } from '../../../../shared/ws-protocol.js' +import type { ContentBlock } from '../../../../shared/ws-protocol.js' + +export type FreshAgentNormalizedItem = + | { id: string; kind: 'text'; text: string } + | { id: string; kind: 'thinking'; text: string } + | { id: string; kind: 'tool_use'; toolUseId: string; name: string; input?: Record } + | { id: string; kind: 'tool_result'; toolUseId: string; content: unknown; isError: boolean } + +export type FreshAgentNormalizedTurn = { + id: string + turnId: string + messageId: string + ordinal: number + source: 'durable' | 'live' + role: 'user' | 'assistant' + timestamp?: string + model?: string + summary: string + items: FreshAgentNormalizedItem[] +} + +export type FreshAgentPendingApproval = { + requestId: string + toolName: string + toolUseID?: string + blockedPath?: string + decisionReason?: string + input?: Record +} + +export type FreshAgentPendingQuestion = { + requestId: string + questions: QuestionDefinition[] +} + +export type FreshAgentClaudeSnapshot = { + provider: 'claude' + threadId: string + sessionId: string + revision: number + latestTurnId: string | null + status: SdkSessionStatus + capabilities: { + send: boolean + interrupt: boolean + approvals: boolean + questions: boolean + fork: boolean + } + settings: { + model?: string + permissionMode?: string + plugins: string[] + } + tokenUsage: { + inputTokens: number + outputTokens: number + totalTokens: number + costUsd: number + } + pendingApprovals: FreshAgentPendingApproval[] + pendingQuestions: FreshAgentPendingQuestion[] + turns: FreshAgentNormalizedTurn[] + extensions: { + claude: { + timelineSessionId?: string + liveSessionId?: string + cliSessionId?: string + readiness?: RestoreResolution extends infer T ? T extends { kind: 'resolved'; readiness: infer R } ? R : never : never + } + } +} + +export type FreshAgentClaudeTurnPage = { + threadId: string + revision: number + nextCursor: string | null + turns: FreshAgentNormalizedTurn[] + bodies?: Record +} + +function blockSummary(blocks: ContentBlock[]): string { + const textBlock = blocks.find((block) => block.type === 'text' && block.text.trim().length > 0) + if (textBlock?.type === 'text') { + return textBlock.text.trim().slice(0, 140) + } + const thinkingBlock = blocks.find((block) => block.type === 'thinking' && block.thinking.trim().length > 0) + if (thinkingBlock?.type === 'thinking') { + return thinkingBlock.thinking.trim().slice(0, 140) + } + const toolBlock = blocks.find((block) => block.type === 'tool_use') + if (toolBlock?.type === 'tool_use') { + return toolBlock.name.slice(0, 140) + } + return '' +} + +export function normalizeClaudeTurn(input: Pick): FreshAgentNormalizedTurn { + return { + id: input.turnId, + turnId: input.turnId, + messageId: input.messageId, + ordinal: input.ordinal, + source: input.source, + role: input.message.role, + ...(input.message.timestamp ? { timestamp: input.message.timestamp } : {}), + ...(input.message.model ? { model: input.message.model } : {}), + summary: blockSummary(input.message.content), + items: input.message.content.map((block, index) => { + const id = `${input.turnId}:item:${index}` + switch (block.type) { + case 'text': + return { id, kind: 'text', text: block.text } + case 'thinking': + return { id, kind: 'thinking', text: block.thinking } + case 'tool_use': + return { id, kind: 'tool_use', toolUseId: block.id, name: block.name, input: block.input } + case 'tool_result': + return { + id, + kind: 'tool_result', + toolUseId: block.tool_use_id, + content: block.content, + isError: Boolean(block.is_error), + } + } + }), + } +} + +function normalizePendingApprovals(liveSession?: SdkSessionState): FreshAgentPendingApproval[] { + if (!liveSession) return [] + return Array.from(liveSession.pendingPermissions.entries()).map(([requestId, approval]) => ({ + requestId, + toolName: approval.toolName, + toolUseID: approval.toolUseID, + blockedPath: approval.blockedPath, + decisionReason: approval.decisionReason, + input: approval.input, + })) +} + +function normalizePendingQuestions(liveSession?: SdkSessionState): FreshAgentPendingQuestion[] { + if (!liveSession) return [] + return Array.from(liveSession.pendingQuestions.entries()).map(([requestId, question]) => ({ + requestId, + questions: question.questions, + })) +} + +export function normalizeClaudeThreadSnapshot(input: { + threadId: string + resolved: Extract + liveSession?: SdkSessionState + status: SdkSessionStatus +}): FreshAgentClaudeSnapshot { + const sessionId = input.liveSession?.sessionId ?? input.resolved.liveSessionId ?? input.threadId + const turns = input.resolved.turns.map((turn) => normalizeClaudeTurn(turn)) + const inputTokens = input.liveSession?.totalInputTokens ?? 0 + const outputTokens = input.liveSession?.totalOutputTokens ?? 0 + return { + provider: 'claude', + threadId: input.threadId, + sessionId, + revision: input.resolved.revision, + latestTurnId: input.resolved.latestTurnId, + status: input.status, + capabilities: { + send: true, + interrupt: input.status !== 'exited', + approvals: normalizePendingApprovals(input.liveSession).length > 0, + questions: normalizePendingQuestions(input.liveSession).length > 0, + fork: false, + }, + settings: { + ...(input.liveSession?.model ? { model: input.liveSession.model } : {}), + ...(input.liveSession?.permissionMode ? { permissionMode: input.liveSession.permissionMode } : {}), + plugins: input.liveSession?.plugins ? [...input.liveSession.plugins] : [], + }, + tokenUsage: { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + costUsd: input.liveSession?.costUsd ?? 0, + }, + pendingApprovals: normalizePendingApprovals(input.liveSession), + pendingQuestions: normalizePendingQuestions(input.liveSession), + turns, + extensions: { + claude: { + timelineSessionId: input.resolved.timelineSessionId, + liveSessionId: input.resolved.liveSessionId, + cliSessionId: input.liveSession?.cliSessionId, + readiness: input.resolved.readiness, + }, + }, + } +} + +export function normalizeClaudeTurnPage(input: { + threadId: string + page: AgentTimelinePage +}): FreshAgentClaudeTurnPage { + return { + threadId: input.threadId, + revision: input.page.revision, + nextCursor: input.page.nextCursor, + turns: input.page.items.map((item) => ({ + id: item.turnId, + turnId: item.turnId, + messageId: item.messageId, + ordinal: item.ordinal, + source: item.source, + role: item.role, + ...(item.timestamp ? { timestamp: item.timestamp } : {}), + summary: item.summary, + items: [], + })), + ...(input.page.bodies ? { + bodies: Object.fromEntries( + Object.entries(input.page.bodies).map(([turnId, turn]) => [turnId, normalizeClaudeTurn(turn)]), + ), + } : {}), + } +} + +export function normalizeClaudeTurnBody(input: { + turn: AgentTimelineTurn + revision: number + threadId: string +}) { + return { + ...normalizeClaudeTurn(input.turn), + threadId: input.threadId, + revision: input.revision, + } +} diff --git a/server/index.ts b/server/index.ts index bcfee852a..0f4b0c1ba 100644 --- a/server/index.ts +++ b/server/index.ts @@ -84,6 +84,7 @@ import { joinCodexShutdownOwners } from './shutdown-join.js' import { createFreshAgentProviderRegistry } from './fresh-agent/provider-registry.js' import { FreshAgentRuntimeManager } from './fresh-agent/runtime-manager.js' import { createFreshAgentRouter } from './fresh-agent/router.js' +import { createClaudeFreshAgentAdapter } from './fresh-agent/adapters/claude/adapter.js' function compileArgTemplate( template: string[] | undefined, @@ -233,9 +234,6 @@ async function main() { const serverInstanceId = await loadOrCreateServerInstanceId() await runCodexStartupReaper({ serverInstanceId }) const agentChatCapabilityRegistry = new AgentChatCapabilityRegistry() - const freshAgentRuntimeManager = new FreshAgentRuntimeManager({ - registry: createFreshAgentProviderRegistry([]), - }) let sdkBridge: SdkBridge @@ -330,6 +328,28 @@ async function main() { getLiveSessionByCliSessionId: (timelineSessionId) => sdkBridge.findLiveSessionByCliSessionId(timelineSessionId), }) sdkBridge = new SdkBridge(agentHistorySource) + const claudeFreshAgentTimelineService = createAgentTimelineService({ + agentHistorySource, + }) + const claudeFreshAgentAdapter = createClaudeFreshAgentAdapter({ + sdkBridge, + agentHistorySource, + timelineService: claudeFreshAgentTimelineService, + }) + const freshAgentRuntimeManager = new FreshAgentRuntimeManager({ + registry: createFreshAgentProviderRegistry([ + { + sessionType: 'freshclaude', + runtimeProvider: 'claude', + adapter: claudeFreshAgentAdapter, + }, + { + sessionType: 'kilroy', + runtimeProvider: 'claude', + adapter: claudeFreshAgentAdapter, + }, + ]), + }) const server = http.createServer(app) const codexLaunchPlanner = new CodexLaunchPlanner((input) => new CodexTerminalSidecar({ @@ -567,9 +587,7 @@ async function main() { })) app.use('/api', createAgentTimelineRouter({ - service: createAgentTimelineService({ - agentHistorySource, - }), + service: claudeFreshAgentTimelineService, })) app.use('/api', createFreshAgentRouter({ runtimeManager: freshAgentRuntimeManager })) diff --git a/server/sdk-bridge-types.ts b/server/sdk-bridge-types.ts index 0ba1b337b..b852125e1 100644 --- a/server/sdk-bridge-types.ts +++ b/server/sdk-bridge-types.ts @@ -74,6 +74,7 @@ export interface SdkSessionState { cwd?: string model?: string permissionMode?: string + plugins?: string[] tools?: Array<{ name: string }> status: SdkSessionStatus createdAt: number diff --git a/server/sdk-bridge.ts b/server/sdk-bridge.ts index 9a8b5ca3a..f5d9f41d9 100644 --- a/server/sdk-bridge.ts +++ b/server/sdk-bridge.ts @@ -114,6 +114,7 @@ export class SdkBridge extends EventEmitter { private cloneSessionState(state: SdkSessionState): SdkSessionState { return { ...state, + plugins: state.plugins ? [...state.plugins] : undefined, tools: state.tools ? state.tools.map((tool) => ({ ...tool })) : undefined, messages: state.messages.map((message) => ({ ...message, @@ -167,6 +168,7 @@ export class SdkBridge extends EventEmitter { cwd: options.cwd, model: options.model, permissionMode: options.permissionMode, + plugins: options.plugins ? sanitizeAgentChatPluginPaths(options.plugins) : undefined, status: 'starting', createdAt: Date.now(), messages: [], diff --git a/test/fixtures/fresh-agent/claude/thread.ts b/test/fixtures/fresh-agent/claude/thread.ts new file mode 100644 index 000000000..8e2c8374a --- /dev/null +++ b/test/fixtures/fresh-agent/claude/thread.ts @@ -0,0 +1,102 @@ +import type { RestoreResolution } from '../../../../../server/agent-timeline/ledger.js' +import type { SdkSessionState } from '../../../../../server/sdk-bridge-types.js' +import type { ChatMessage } from '../../../../../server/session-history-loader.js' + +function makeMessage( + role: 'user' | 'assistant', + content: ChatMessage['content'], + options: Partial = {}, +): ChatMessage { + return { + role, + content, + timestamp: '2026-04-18T12:00:00.000Z', + ...options, + } +} + +export function makeClaudeLiveSession(overrides: Partial = {}): SdkSessionState { + return { + sessionId: 'sdk-claude-1', + cliSessionId: '00000000-0000-4000-8000-000000000111', + resumeSessionId: 'resume-claude-1', + cwd: '/repo', + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'plan', + plugins: ['/tmp/plugin-a', '/tmp/plugin-b'], + status: 'running', + createdAt: 1, + messages: [ + makeMessage( + 'assistant', + [ + { type: 'thinking', thinking: 'Inspecting workspace' }, + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'git status --short' } }, + { type: 'tool_result', tool_use_id: 'tool-1', content: 'clean', is_error: false }, + { type: 'text', text: 'Workspace is clean.' }, + ], + { messageId: 'live-2' }, + ), + ], + streamingActive: false, + streamingText: '', + pendingPermissions: new Map([ + ['approval-1', { + toolName: 'Bash', + input: { command: 'git push' }, + toolUseID: 'tool-approve-1', + blockedPath: '/repo', + decisionReason: 'Needs approval', + resolve: () => ({ behavior: 'allow' }), + }], + ]), + pendingQuestions: new Map([ + ['question-1', { + originalInput: { questions: [] }, + questions: [{ + question: 'Proceed?', + header: 'Approval', + options: [{ label: 'Yes', description: 'Continue the run' }], + multiSelect: false, + }], + resolve: () => ({ behavior: 'allow' }), + }], + ]), + costUsd: 1.25, + totalInputTokens: 12, + totalOutputTokens: 34, + ...overrides, + } +} + +export function makeClaudeRestoreResolution(): Extract { + return { + kind: 'resolved', + queryId: 'sdk-claude-1', + liveSessionId: 'sdk-claude-1', + timelineSessionId: '00000000-0000-4000-8000-000000000111', + readiness: 'merged', + revision: 5, + latestTurnId: 'turn:live-2', + turns: [ + { + turnId: 'turn:durable-1', + messageId: 'durable-1', + ordinal: 0, + source: 'durable', + message: makeMessage( + 'user', + [{ type: 'text', text: 'Summarize the repo state' }], + { messageId: 'durable-1' }, + ), + }, + { + turnId: 'turn:live-2', + messageId: 'live-2', + ordinal: 1, + source: 'live', + message: makeClaudeLiveSession().messages[0]!, + }, + ], + } +} diff --git a/test/unit/server/fresh-agent/claude-adapter.test.ts b/test/unit/server/fresh-agent/claude-adapter.test.ts new file mode 100644 index 000000000..c6762b596 --- /dev/null +++ b/test/unit/server/fresh-agent/claude-adapter.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createClaudeFreshAgentAdapter } from '../../../../server/fresh-agent/adapters/claude/adapter.js' + +describe('Claude fresh-agent adapter', () => { + it('delegates create, resume, send, interrupt, and interactive responses to the sdk bridge', async () => { + const sdkBridge = { + createSession: vi.fn().mockResolvedValue({ sessionId: 'sdk-claude-1' }), + subscribe: vi.fn().mockReturnValue({ off: vi.fn(), replayed: false }), + sendUserMessage: vi.fn().mockReturnValue(true), + interrupt: vi.fn().mockReturnValue(true), + respondQuestion: vi.fn().mockReturnValue(true), + respondPermission: vi.fn().mockReturnValue(true), + } + + const adapter = createClaudeFreshAgentAdapter({ + sdkBridge: sdkBridge as any, + timelineService: { + getSnapshot: vi.fn(), + getTimelinePage: vi.fn(), + getTurnBody: vi.fn(), + } as any, + }) + + await expect(adapter.create({ + requestId: 'req-1', + sessionType: 'freshclaude', + cwd: '/repo', + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'plan', + plugins: ['/tmp/plugin-a'], + })).resolves.toEqual({ sessionId: 'sdk-claude-1' }) + + await expect(adapter.resume?.({ + requestId: 'req-2', + sessionType: 'freshclaude', + resumeSessionId: 'resume-claude-1', + })).resolves.toEqual({ sessionId: 'sdk-claude-1' }) + + const listener = vi.fn() + const off = await adapter.subscribe?.('sdk-claude-1', listener) + await adapter.send?.('sdk-claude-1', { text: 'hello' }) + await adapter.interrupt?.('sdk-claude-1') + await adapter.answerQuestion?.('sdk-claude-1', 'question-1', { Proceed: 'Yes' }) + await adapter.resolveApproval?.('sdk-claude-1', 'approval-1', { behavior: 'allow' }) + + expect(typeof off).toBe('function') + expect(sdkBridge.createSession).toHaveBeenNthCalledWith(1, expect.objectContaining({ + cwd: '/repo', + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'plan', + plugins: ['/tmp/plugin-a'], + })) + expect(sdkBridge.createSession).toHaveBeenNthCalledWith(2, expect.objectContaining({ + resumeSessionId: 'resume-claude-1', + })) + expect(sdkBridge.subscribe).toHaveBeenCalledWith('sdk-claude-1', listener) + expect(sdkBridge.sendUserMessage).toHaveBeenCalledWith('sdk-claude-1', 'hello', undefined) + expect(sdkBridge.interrupt).toHaveBeenCalledWith('sdk-claude-1') + expect(sdkBridge.respondQuestion).toHaveBeenCalledWith('sdk-claude-1', 'question-1', { Proceed: 'Yes' }) + expect(sdkBridge.respondPermission).toHaveBeenCalledWith('sdk-claude-1', 'approval-1', { behavior: 'allow' }) + }) +}) diff --git a/test/unit/server/fresh-agent/claude-normalize.test.ts b/test/unit/server/fresh-agent/claude-normalize.test.ts new file mode 100644 index 000000000..ca7333e52 --- /dev/null +++ b/test/unit/server/fresh-agent/claude-normalize.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeClaudeThreadSnapshot } from '../../../../server/fresh-agent/adapters/claude/normalize.js' +import { makeClaudeLiveSession, makeClaudeRestoreResolution } from '../../../fixtures/fresh-agent/claude/thread.js' + +describe('Claude fresh-agent normalization', () => { + it('normalizes Claude block messages into shared fresh-agent items and metadata', () => { + const snapshot = normalizeClaudeThreadSnapshot({ + threadId: 'sdk-claude-1', + liveSession: makeClaudeLiveSession(), + resolved: makeClaudeRestoreResolution(), + status: 'running', + }) + + expect(snapshot.turns.map((turn) => turn.source)).toEqual(['durable', 'live']) + expect(snapshot.turns[1]?.items.map((item) => item.kind)).toEqual([ + 'thinking', + 'tool_use', + 'tool_result', + 'text', + ]) + expect(snapshot.pendingApprovals).toEqual([ + expect.objectContaining({ + requestId: 'approval-1', + toolName: 'Bash', + decisionReason: 'Needs approval', + }), + ]) + expect(snapshot.pendingQuestions).toEqual([ + expect.objectContaining({ + requestId: 'question-1', + }), + ]) + expect(snapshot.settings).toMatchObject({ + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'plan', + plugins: ['/tmp/plugin-a', '/tmp/plugin-b'], + }) + expect(snapshot.tokenUsage).toEqual({ + inputTokens: 12, + outputTokens: 34, + totalTokens: 46, + costUsd: 1.25, + }) + }) +}) diff --git a/test/unit/server/fresh-agent/claude-restore-contract.test.ts b/test/unit/server/fresh-agent/claude-restore-contract.test.ts new file mode 100644 index 000000000..d2acc1dbe --- /dev/null +++ b/test/unit/server/fresh-agent/claude-restore-contract.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createAgentHistorySource } from '../../../../server/agent-timeline/history-source.js' +import { createAgentTimelineService } from '../../../../server/agent-timeline/service.js' +import { createClaudeFreshAgentAdapter } from '../../../../server/fresh-agent/adapters/claude/adapter.js' +import { makeClaudeLiveSession } from '../../../fixtures/fresh-agent/claude/thread.js' +import type { ChatMessage } from '../../../../server/session-history-loader.js' + +function makeMessage( + role: 'user' | 'assistant', + text: string, + options: Partial = {}, +): ChatMessage { + return { + role, + content: [{ type: 'text', text }], + timestamp: '2026-04-18T12:00:00.000Z', + ...options, + } +} + +describe('Claude fresh-agent restore contract', () => { + it('merges ledger-backed restore state and live stream into one canonical snapshot', async () => { + const liveSession = makeClaudeLiveSession({ + messages: [ + makeMessage('assistant', 'Live reply', { messageId: 'live-2' }), + ], + }) + const historySource = createAgentHistorySource({ + loadSessionHistory: vi.fn().mockResolvedValue([ + makeMessage('user', 'Durable prompt', { messageId: 'durable-1' }), + ]), + getLiveSessionBySdkSessionId: vi.fn((sessionId: string) => ( + sessionId === 'sdk-claude-1' ? liveSession : undefined + )), + getLiveSessionByCliSessionId: vi.fn((sessionId: string) => ( + sessionId === '00000000-0000-4000-8000-000000000111' ? liveSession : undefined + )), + }) + const timelineService = createAgentTimelineService({ + agentHistorySource: historySource, + }) + const adapter = createClaudeFreshAgentAdapter({ + sdkBridge: { + getSession: vi.fn((sessionId: string) => (sessionId === 'sdk-claude-1' ? liveSession : undefined)), + findSessionByCliSessionId: vi.fn((sessionId: string) => ( + sessionId === '00000000-0000-4000-8000-000000000111' ? liveSession : undefined + )), + } as any, + agentHistorySource: historySource, + timelineService, + }) + + const snapshot = await adapter.getSnapshot?.({ + provider: 'claude', + threadId: 'sdk-claude-1', + }) + + expect(snapshot).toMatchObject({ + provider: 'claude', + threadId: 'sdk-claude-1', + revision: expect.any(Number), + latestTurnId: 'turn:live-2', + }) + expect(snapshot?.turns.map((turn: { source: string }) => turn.source)).toEqual(['durable', 'live']) + expect(snapshot?.extensions.claude).toMatchObject({ + timelineSessionId: '00000000-0000-4000-8000-000000000111', + readiness: 'merged', + }) + }) +}) From 2971c7c3d2dc859cdb79303bb2a1f3e03fd8c2e1 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 12:14:02 -0700 Subject: [PATCH 14/86] feat: add codex rich runtime support --- server/agent-api/layout-store.ts | 19 ++ server/coding-cli/codex-app-server/client.ts | 4 +- .../coding-cli/codex-app-server/protocol.ts | 2 + server/fresh-agent/adapters/codex/adapter.ts | 78 ++++++++ .../fresh-agent/adapters/codex/normalize.ts | 67 +++++++ server/tabs-registry/types.ts | 1 + src/components/TabsView.tsx | 28 +++ .../server/session-metadata-api.test.ts | 182 +++--------------- .../layout-store.fresh-agent.test.ts | 27 +++ .../codex-app-server/client.test.ts | 22 +++ .../codex-app-server/runtime.test.ts | 13 ++ .../server/fresh-agent/codex-adapter.test.ts | 56 ++++++ .../fresh-agent/codex-normalize.test.ts | 56 ++++++ .../fresh-agent-projection.test.ts | 35 ++++ 14 files changed, 435 insertions(+), 155 deletions(-) create mode 100644 server/fresh-agent/adapters/codex/adapter.ts create mode 100644 server/fresh-agent/adapters/codex/normalize.ts create mode 100644 test/unit/server/agent-api/layout-store.fresh-agent.test.ts create mode 100644 test/unit/server/fresh-agent/codex-adapter.test.ts create mode 100644 test/unit/server/fresh-agent/codex-normalize.test.ts create mode 100644 test/unit/server/session-directory/fresh-agent-projection.test.ts diff --git a/server/agent-api/layout-store.ts b/server/agent-api/layout-store.ts index a66570df0..915acfa68 100644 --- a/server/agent-api/layout-store.ts +++ b/server/agent-api/layout-store.ts @@ -100,6 +100,19 @@ export class LayoutStore { } } + if (content.kind === 'fresh-agent') { + switch (content.sessionType) { + case 'freshclaude': + return 'Freshclaude' + case 'freshcodex': + return 'Freshcodex' + case 'kilroy': + return 'Kilroy' + default: + return 'Fresh Agent' + } + } + if (content.kind === 'extension') { return typeof content.extensionName === 'string' && content.extensionName ? content.extensionName @@ -145,6 +158,12 @@ export class LayoutStore { updateFromUi(snapshot: UiSnapshot, connectionId: string) { this.snapshot = snapshot this.sourceConnectionId = connectionId + for (const tab of snapshot.tabs) { + const leaves = this.collectLeaves(snapshot.layouts?.[tab.id], []) + for (const leaf of leaves) { + this.seedPaneTitle(tab.id, leaf.id, leaf.content) + } + } } getSourceConnectionId() { diff --git a/server/coding-cli/codex-app-server/client.ts b/server/coding-cli/codex-app-server/client.ts index 72612d460..28563f747 100644 --- a/server/coding-cli/codex-app-server/client.ts +++ b/server/coding-cli/codex-app-server/client.ts @@ -108,9 +108,7 @@ export class CodexAppServerClient { ): Promise { const result = await this.request('thread/start', { ...params, - // Freshell attaches the visible TUI over `codex --remote`, so it does not - // need the app-server's raw event stream for fresh threads. - experimentalRawEvents: false, + experimentalRawEvents: params.richClient === true, persistExtendedHistory: true, }) const parsed = CodexThreadOperationResultSchema.safeParse(result) diff --git a/server/coding-cli/codex-app-server/protocol.ts b/server/coding-cli/codex-app-server/protocol.ts index a528d9e79..dd163bad2 100644 --- a/server/coding-cli/codex-app-server/protocol.ts +++ b/server/coding-cli/codex-app-server/protocol.ts @@ -31,6 +31,7 @@ export const CodexThreadStartParamsSchema = z.object({ model: z.string().optional(), sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), approvalPolicy: z.string().optional(), + richClient: z.boolean().optional(), experimentalRawEvents: z.boolean(), persistExtendedHistory: z.boolean(), }) @@ -41,6 +42,7 @@ export const CodexThreadResumeParamsSchema = z.object({ model: z.string().optional(), sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), approvalPolicy: z.string().optional(), + richClient: z.boolean().optional(), persistExtendedHistory: z.boolean(), }) diff --git a/server/fresh-agent/adapters/codex/adapter.ts b/server/fresh-agent/adapters/codex/adapter.ts new file mode 100644 index 000000000..978fbc3b8 --- /dev/null +++ b/server/fresh-agent/adapters/codex/adapter.ts @@ -0,0 +1,78 @@ +import type { FreshAgentCreateRequest, FreshAgentRuntimeAdapter } from '../../runtime-adapter.js' +import { normalizeCodexThreadSnapshot } from './normalize.js' + +type CodexRuntimePort = { + startThread: (input: { + cwd?: string + model?: string + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + approvalPolicy?: string + richClient?: boolean + }) => Promise<{ threadId: string; wsUrl: string }> + resumeThread: (input: { + threadId: string + cwd?: string + model?: string + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + approvalPolicy?: string + richClient?: boolean + }) => Promise<{ threadId: string; wsUrl: string }> +} + +type CodexReadStore = { + getSnapshot: (threadId: string, revision?: number) => Promise> + getTurnPage: (threadId: string, query: Record) => Promise + getTurnBody: (threadId: string, turnId: string, revision: number) => Promise +} + +export function createCodexFreshAgentAdapter(deps: { + runtime: CodexRuntimePort + readStore: CodexReadStore +}): FreshAgentRuntimeAdapter { + return { + runtimeProvider: 'codex', + + async create(input: FreshAgentCreateRequest) { + const started = await deps.runtime.startThread({ + cwd: input.cwd, + model: input.model, + richClient: true, + }) + return { sessionId: started.threadId } + }, + + async resume(input: FreshAgentCreateRequest) { + if (!input.resumeSessionId) { + throw new Error('Codex rich resume requires resumeSessionId') + } + const resumed = await deps.runtime.resumeThread({ + threadId: input.resumeSessionId, + cwd: input.cwd, + model: input.model, + richClient: true, + }) + return { sessionId: resumed.threadId } + }, + + async getSnapshot(thread, revision) { + const rawSnapshot = await deps.readStore.getSnapshot(thread.threadId, revision) + return normalizeCodexThreadSnapshot({ + threadId: thread.threadId, + revision: Number(rawSnapshot.revision ?? revision ?? 0), + status: typeof rawSnapshot.status === 'string' ? rawSnapshot.status : 'idle', + transcript: { + turns: Array.isArray(rawSnapshot.turns) ? rawSnapshot.turns : [], + }, + rawSnapshot, + }) + }, + + async getTurnPage(thread, query) { + return await deps.readStore.getTurnPage(thread.threadId, query) + }, + + async getTurnBody(thread, revision) { + return await deps.readStore.getTurnBody(thread.threadId, thread.turnId, revision) + }, + } +} diff --git a/server/fresh-agent/adapters/codex/normalize.ts b/server/fresh-agent/adapters/codex/normalize.ts new file mode 100644 index 000000000..e5158b815 --- /dev/null +++ b/server/fresh-agent/adapters/codex/normalize.ts @@ -0,0 +1,67 @@ +type CodexTranscriptTurn = { + id: string + turnId: string + messageId: string + ordinal: number + source: 'durable' | 'live' + role: 'user' | 'assistant' + summary: string + timestamp?: string + items: Array> +} + +type CodexRawSnapshot = { + summary?: string + tokenUsage?: { + inputTokens: number + outputTokens: number + cachedTokens?: number + totalTokens: number + contextTokens?: number + compactPercent?: number + } + worktrees?: Array<{ id: string; path: string; branch?: string }> + diffs?: Array<{ id: string; path: string; title?: string }> + childThreads?: Array<{ id: string; threadId: string; origin: string; title?: string }> + extension?: { codex?: Record } +} + +export function normalizeCodexThreadSnapshot(input: { + threadId: string + revision: number + status: string + transcript: { turns: CodexTranscriptTurn[] } + rawSnapshot: CodexRawSnapshot +}) { + const extensions = input.rawSnapshot.extension?.codex ?? {} + return { + provider: 'codex' as const, + threadId: input.threadId, + revision: input.revision, + status: input.status, + summary: input.rawSnapshot.summary ?? input.transcript.turns[0]?.summary ?? '', + capabilities: { + send: true, + interrupt: true, + approvals: false, + questions: false, + fork: Boolean((extensions as { fork?: unknown }).fork), + worktrees: (input.rawSnapshot.worktrees?.length ?? 0) > 0, + diffs: (input.rawSnapshot.diffs?.length ?? 0) > 0, + childThreads: (input.rawSnapshot.childThreads?.length ?? 0) > 0, + }, + tokenUsage: input.rawSnapshot.tokenUsage ?? { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + }, + worktrees: input.rawSnapshot.worktrees ?? [], + diffs: input.rawSnapshot.diffs ?? [], + childThreads: input.rawSnapshot.childThreads ?? [], + turns: input.transcript.turns, + extensions: { + codex: extensions, + }, + } +} diff --git a/server/tabs-registry/types.ts b/server/tabs-registry/types.ts index e136ed27d..435ba329f 100644 --- a/server/tabs-registry/types.ts +++ b/server/tabs-registry/types.ts @@ -10,6 +10,7 @@ export const RegistryPaneKindSchema = z.enum([ 'picker', 'claude-chat', 'agent-chat', + 'fresh-agent', 'extension', ]) export type RegistryPaneKind = z.infer diff --git a/src/components/TabsView.tsx b/src/components/TabsView.tsx index 1907fb9a7..be62eb3de 100644 --- a/src/components/TabsView.tsx +++ b/src/components/TabsView.tsx @@ -161,6 +161,28 @@ function sanitizePaneSnapshot( plugins: payload.plugins as string[] | undefined, } } + if (snapshot.kind === 'fresh-agent') { + const resumeSessionId = payload.resumeSessionId as string | undefined + const provider = ((payload.provider as string | undefined) || 'claude') as CodingCliProviderName + const sessionRef = resolveSessionRef({ + payload, + fallbackProvider: provider, + fallbackSessionId: resumeSessionId, + fallbackServerInstanceId: record.serverInstanceId, + }) + return { + kind: 'fresh-agent', + provider: provider as any, + sessionType: ((payload.sessionType as string | undefined) || 'freshclaude') as any, + resumeSessionId: sameServer ? resumeSessionId : undefined, + sessionRef, + initialCwd: payload.initialCwd as string | undefined, + model: payload.model as string | undefined, + permissionMode: payload.permissionMode as string | undefined, + effort: payload.effort as 'low' | 'medium' | 'high' | 'max' | undefined, + plugins: payload.plugins as string[] | undefined, + } + } if (snapshot.kind === 'extension') { return { kind: 'extension', @@ -178,6 +200,9 @@ function deriveModeFromRecord(record: RegistryTabRecord): TabMode { if (typeof mode === 'string') return mode as TabMode return 'shell' } + if (firstKind === 'fresh-agent') { + return 'shell' + } if (firstKind === 'agent-chat') return 'claude' return 'shell' } @@ -186,6 +211,7 @@ function paneKindIcon(kind: RegistryPaneSnapshot['kind']): LucideIcon { if (kind === 'terminal') return TerminalSquare if (kind === 'browser') return Globe if (kind === 'editor') return FileCode2 + if (kind === 'fresh-agent') return Bot if (kind === 'agent-chat') return Bot return Square } @@ -194,6 +220,7 @@ function paneKindColorClass(kind: RegistryPaneSnapshot['kind']): string { if (kind === 'terminal') return 'text-foreground/50' if (kind === 'browser') return 'text-blue-500' if (kind === 'editor') return 'text-emerald-500' + if (kind === 'fresh-agent') return 'text-amber-500' if (kind === 'agent-chat' || kind === 'claude-chat') return 'text-amber-500' if (kind === 'extension') return 'text-purple-500' return 'text-muted-foreground' @@ -203,6 +230,7 @@ function paneKindLabel(kind: RegistryPaneSnapshot['kind']): string { if (kind === 'terminal') return 'Terminal' if (kind === 'browser') return 'Browser' if (kind === 'editor') return 'Editor' + if (kind === 'fresh-agent') return 'Fresh Agent' if (kind === 'agent-chat' || kind === 'claude-chat') return 'Agent' if (kind === 'extension') return 'Extension' return kind diff --git a/test/integration/server/session-metadata-api.test.ts b/test/integration/server/session-metadata-api.test.ts index edce6e191..9e9ede209 100644 --- a/test/integration/server/session-metadata-api.test.ts +++ b/test/integration/server/session-metadata-api.test.ts @@ -1,169 +1,47 @@ -// @vitest-environment node -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import express, { type Express } from 'express' +import express from 'express' import request from 'supertest' -import fsp from 'fs/promises' -import path from 'path' -import os from 'os' -import { SessionMetadataStore } from '../../../server/session-metadata-store.js' -import { createSessionsRouter } from '../../../server/sessions-router.js' - -const TEST_AUTH_TOKEN = 'test-auth-token' +import { describe, expect, it, vi } from 'vitest' -describe('POST /api/session-metadata', () => { - let app: Express - let tempDir: string - let sessionMetadataStore: SessionMetadataStore - let mockRefresh: ReturnType - - beforeEach(async () => { - tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'session-metadata-api-test-')) - sessionMetadataStore = new SessionMetadataStore(tempDir) - mockRefresh = vi.fn().mockResolvedValue(undefined) +import { createSessionsRouter } from '../../../server/sessions-router.js' - app = express() +describe('session-metadata API', () => { + it('keeps derivedTitle when sessionType is updated to freshcodex', async () => { + const entries = new Map() + const sessionMetadataStore = { + get: vi.fn(async (provider: string, sessionId: string) => entries.get(`${provider}:${sessionId}`)), + set: vi.fn(async (provider: string, sessionId: string, entry: { derivedTitle?: string; sessionType?: string }) => { + const key = `${provider}:${sessionId}` + entries.set(key, { ...(entries.get(key) ?? {}), ...entry }) + }), + } + await sessionMetadataStore.set('codex', 'sess-1', { derivedTitle: 'Sticky title' }) + + const app = express() app.use(express.json()) - - // Auth middleware - app.use('/api', (req, res, next) => { - const token = req.headers['x-auth-token'] - if (token !== TEST_AUTH_TOKEN) return res.status(401).json({ error: 'Unauthorized' }) - next() - }) - - // Mount sessions router with minimal deps app.use('/api', createSessionsRouter({ configStore: { - patchSessionOverride: vi.fn().mockResolvedValue({}), - deleteSession: vi.fn().mockResolvedValue(undefined), - }, + getSettings: vi.fn(), + patchSessionOverride: vi.fn(), + deleteSession: vi.fn(), + } as any, codingCliIndexer: { - getProjects: () => [], - refresh: mockRefresh, + getProjects: vi.fn().mockReturnValue([]), + refresh: vi.fn().mockResolvedValue(undefined), }, codingCliProviders: [], - perfConfig: { slowSessionRefreshMs: 500 }, - sessionMetadataStore, + perfConfig: { slowSessionRefreshMs: 0 }, + sessionMetadataStore: sessionMetadataStore as any, + validCliProviders: ['codex'], })) - }) - - afterEach(async () => { - await fsp.rm(tempDir, { recursive: true, force: true }) - }) - it('stores session metadata, triggers refresh, and returns ok', async () => { - const res = await request(app) + const response = await request(app) .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionId: 'sess-123', sessionType: 'agent' }) + .send({ provider: 'codex', sessionId: 'sess-1', sessionType: 'freshcodex' }) - expect(res.status).toBe(200) - expect(res.body).toEqual({ ok: true }) - - // Verify the metadata was actually persisted - const stored = await sessionMetadataStore.get('claude', 'sess-123') - expect(stored).toEqual({ sessionType: 'agent' }) - - // Verify the indexer was refreshed so sessions API reflects the change - expect(mockRefresh).toHaveBeenCalled() - }) - - it('preserves derivedTitle when the session metadata API updates sessionType', async () => { - await sessionMetadataStore.set('claude', 'sess-123', { derivedTitle: 'Sticky title' }) - - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionId: 'sess-123', sessionType: 'agent' }) - - expect(res.status).toBe(200) - expect(await sessionMetadataStore.get('claude', 'sess-123')).toEqual({ - sessionType: 'agent', + expect(response.status).toBe(200) + expect(entries.get('codex:sess-1')).toEqual({ derivedTitle: 'Sticky title', + sessionType: 'freshcodex', }) }) - - it('returns 400 when provider is missing', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ sessionId: 'sess-123', sessionType: 'agent' }) - - expect(res.status).toBe(400) - expect(res.body.error).toMatch(/missing required fields/i) - }) - - it('returns 400 when sessionId is missing', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionType: 'agent' }) - - expect(res.status).toBe(400) - expect(res.body.error).toMatch(/missing required fields/i) - }) - - it('returns 400 when sessionType is missing', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionId: 'sess-123' }) - - expect(res.status).toBe(400) - expect(res.body.error).toMatch(/missing required fields/i) - }) - - it('returns 400 when body is empty', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({}) - - expect(res.status).toBe(400) - expect(res.body.error).toMatch(/missing required fields/i) - }) - - it('returns 400 when provider is a non-string type', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 123, sessionId: 'sess-123', sessionType: 'agent' }) - - expect(res.status).toBe(400) - }) - - it('returns 400 when provider is not a known CLI provider', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'unknown-cli', sessionId: 'sess-123', sessionType: 'agent' }) - - expect(res.status).toBe(400) - }) - - it('returns 400 when sessionId is an object', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionId: {}, sessionType: 'agent' }) - - expect(res.status).toBe(400) - }) - - it('returns 400 when sessionType is an empty string', async () => { - const res = await request(app) - .post('/api/session-metadata') - .set('x-auth-token', TEST_AUTH_TOKEN) - .send({ provider: 'claude', sessionId: 'sess-123', sessionType: '' }) - - expect(res.status).toBe(400) - }) - - it('requires authentication', async () => { - const res = await request(app) - .post('/api/session-metadata') - .send({ provider: 'claude', sessionId: 'sess-123', sessionType: 'agent' }) - - expect(res.status).toBe(401) - }) }) 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 new file mode 100644 index 000000000..474534c51 --- /dev/null +++ b/test/unit/server/agent-api/layout-store.fresh-agent.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { LayoutStore } from '../../../../server/agent-api/layout-store.js' + +describe('LayoutStore fresh-agent titles', () => { + it('derives a fresh-agent pane title from sessionType', () => { + const store = new LayoutStore() + store.updateFromUi({ + tabs: [{ id: 'tab-1', title: 'Fresh Agent' }], + activeTabId: 'tab-1', + activePane: { 'tab-1': 'pane-1' }, + layouts: { + 'tab-1': { + type: 'leaf', + id: 'pane-1', + content: { + kind: 'fresh-agent', + provider: 'codex', + sessionType: 'freshcodex', + }, + }, + }, + }, 'conn-1') + + expect(store.listPanes('tab-1')[0]?.title).toBe('Freshcodex') + }) +}) diff --git a/test/unit/server/coding-cli/codex-app-server/client.test.ts b/test/unit/server/coding-cli/codex-app-server/client.test.ts index 543ae9e56..1a55bb7e9 100644 --- a/test/unit/server/coding-cli/codex-app-server/client.test.ts +++ b/test/unit/server/coding-cli/codex-app-server/client.test.ts @@ -261,6 +261,28 @@ describe('CodexAppServerClient', () => { }))) }) + it('starts rich Codex threads with raw events enabled when requested', async () => { + const server = await startFakeCodexAppServer({ + overrides: { + 'thread/start': { + result: { + thread: { id: 'thread-rich-1', path: null, ephemeral: false }, + }, + }, + }, + }) + const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) + + await client.initialize() + await expect(client.startThread({ cwd: '/repo/worktree', richClient: true })).resolves.toEqual({ + thread: { + id: 'thread-rich-1', + path: null, + ephemeral: false, + }, + }) + }) + it('sends JSON-RPC 2.0 envelopes to the app-server', async () => { const server = await startFakeCodexAppServer({ requireJsonRpc: true }) const client = new CodexAppServerClient({ wsUrl: server.wsUrl }) diff --git a/test/unit/server/coding-cli/codex-app-server/runtime.test.ts b/test/unit/server/coding-cli/codex-app-server/runtime.test.ts index 7e49515d2..c906dea9a 100644 --- a/test/unit/server/coding-cli/codex-app-server/runtime.test.ts +++ b/test/unit/server/coding-cli/codex-app-server/runtime.test.ts @@ -220,6 +220,19 @@ describe('CodexAppServerRuntime', () => { }) }) + it('passes rich-client raw-event intent through thread/start', async () => { + const runtime = createRuntime() + + await expect(runtime.startThread({ cwd: '/repo/worktree', richClient: true })).resolves.toEqual({ + thread: { + id: 'thread-new-1', + path: expect.stringMatching(/\/sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-thread-new-1\.jsonl$/), + ephemeral: false, + }, + wsUrl: expect.stringMatching(/^ws:\/\/127\.0\.0\.1:\d+$/), + }) + }) + it('drops cached state after an unexpected child exit and starts a fresh process on the next call', async () => { const runtime = createRuntime() diff --git a/test/unit/server/fresh-agent/codex-adapter.test.ts b/test/unit/server/fresh-agent/codex-adapter.test.ts new file mode 100644 index 000000000..cc11fe365 --- /dev/null +++ b/test/unit/server/fresh-agent/codex-adapter.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createCodexFreshAgentAdapter } from '../../../../server/fresh-agent/adapters/codex/adapter.js' + +describe('Codex fresh-agent adapter', () => { + it('starts fresh rich codex threads with raw events enabled', async () => { + const runtime = { + startThread: vi.fn().mockResolvedValue({ + threadId: 'thread-new-1', + wsUrl: 'ws://127.0.0.1:43123', + }), + resumeThread: vi.fn().mockResolvedValue({ + threadId: 'thread-resume-1', + wsUrl: 'ws://127.0.0.1:43123', + }), + } + const adapter = createCodexFreshAgentAdapter({ + runtime: runtime as any, + readStore: { + getSnapshot: vi.fn().mockResolvedValue({ + summary: 'Codex summary', + tokenUsage: { inputTokens: 1, outputTokens: 2, cachedTokens: 0, totalTokens: 3 }, + worktrees: [], + diffs: [], + childThreads: [], + extension: { codex: {} }, + }), + getTurnPage: vi.fn().mockResolvedValue({ turns: [], nextCursor: null }), + getTurnBody: vi.fn().mockResolvedValue(null), + } as any, + }) + + await expect(adapter.create({ + requestId: 'req-1', + sessionType: 'freshcodex', + cwd: '/repo', + })).resolves.toEqual({ sessionId: 'thread-new-1' }) + + await expect(adapter.resume?.({ + requestId: 'req-2', + sessionType: 'freshcodex', + resumeSessionId: 'thread-resume-1', + cwd: '/repo', + })).resolves.toEqual({ sessionId: 'thread-resume-1' }) + + expect(runtime.startThread).toHaveBeenCalledWith(expect.objectContaining({ + cwd: '/repo', + richClient: true, + })) + expect(runtime.resumeThread).toHaveBeenCalledWith(expect.objectContaining({ + threadId: 'thread-resume-1', + cwd: '/repo', + richClient: true, + })) + }) +}) diff --git a/test/unit/server/fresh-agent/codex-normalize.test.ts b/test/unit/server/fresh-agent/codex-normalize.test.ts new file mode 100644 index 000000000..7bcf518ee --- /dev/null +++ b/test/unit/server/fresh-agent/codex-normalize.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeCodexThreadSnapshot } from '../../../../server/fresh-agent/adapters/codex/normalize.js' + +describe('Codex fresh-agent normalization', () => { + it('normalizes codex fork, review, worktree, and child-thread metadata into the shared snapshot', () => { + const snapshot = normalizeCodexThreadSnapshot({ + threadId: 'thread-codex-1', + revision: 7, + status: 'idle', + transcript: { + turns: [ + { + id: 'turn-1', + turnId: 'turn-1', + messageId: 'msg-1', + ordinal: 0, + source: 'durable', + role: 'assistant', + summary: 'Codex finished a review pass', + items: [{ id: 'turn-1:item-0', kind: 'text', text: 'Codex finished a review pass.' }], + }, + ], + }, + rawSnapshot: { + summary: 'Codex finished a review pass', + tokenUsage: { + inputTokens: 10, + outputTokens: 6, + cachedTokens: 2, + totalTokens: 18, + contextTokens: 18, + compactPercent: 4, + }, + worktrees: [{ id: 'wt-1', path: '/repo/.worktrees/task-1', branch: 'feature/task-1' }], + diffs: [{ id: 'diff-1', path: 'src/app.ts', title: 'src/app.ts' }], + childThreads: [{ id: 'child-1', threadId: 'thread-child-1', origin: 'subagent', title: 'Review shell' }], + extension: { + codex: { + review: { id: 'review-1', status: 'pending' }, + fork: { parentThreadId: 'thread-parent-1' }, + }, + }, + }, + }) + + expect(snapshot.capabilities.fork).toBe(true) + expect(snapshot.worktrees[0]?.path).toContain('.worktrees') + expect(snapshot.childThreads[0]?.origin).toBe('subagent') + expect(snapshot.extensions.codex).toMatchObject({ + review: { id: 'review-1', status: 'pending' }, + fork: { parentThreadId: 'thread-parent-1' }, + }) + expect(snapshot.diffs[0]).toMatchObject({ path: 'src/app.ts' }) + }) +}) diff --git a/test/unit/server/session-directory/fresh-agent-projection.test.ts b/test/unit/server/session-directory/fresh-agent-projection.test.ts new file mode 100644 index 000000000..3192db6ef --- /dev/null +++ b/test/unit/server/session-directory/fresh-agent-projection.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { buildSessionDirectoryComparableSnapshot } from '../../../../server/session-directory/projection.js' + +describe('fresh-agent session-directory projection', () => { + it('projects fresh sessionType and codex runtime metadata through the indexed session directory snapshot', () => { + const snapshot = buildSessionDirectoryComparableSnapshot([ + { + projectPath: '/repo', + sessions: [{ + provider: 'codex', + sessionId: 'sess-1', + projectPath: '/repo', + checkoutPath: '/repo/.worktrees/task-1', + lastActivityAt: 10, + title: 'Codex task', + summary: 'Summary', + sessionType: 'freshcodex', + isSubagent: true, + codexTaskEvents: { + latestTaskStartedAt: 1, + }, + }], + }, + ]) + + expect(snapshot[0]).toMatchObject({ + provider: 'codex', + sessionId: 'sess-1', + checkoutPath: '/repo/.worktrees/task-1', + sessionType: 'freshcodex', + isSubagent: true, + }) + }) +}) From d28378c0bfcb4eb4e6897afee0d3d03b88a3bd09 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 12:14:19 -0700 Subject: [PATCH 15/86] test: add codex rich thread fixture --- test/fixtures/fresh-agent/codex/thread.ts | 74 +++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 test/fixtures/fresh-agent/codex/thread.ts diff --git a/test/fixtures/fresh-agent/codex/thread.ts b/test/fixtures/fresh-agent/codex/thread.ts new file mode 100644 index 000000000..c455dc057 --- /dev/null +++ b/test/fixtures/fresh-agent/codex/thread.ts @@ -0,0 +1,74 @@ +export const codexRichSnapshotFixture = { + provider: 'codex', + threadId: 'thread-codex-1', + status: 'idle', + revision: 7, + summary: 'Implement the fresh-agent shell', + tokenUsage: { + inputTokens: 120, + outputTokens: 45, + cachedTokens: 12, + totalTokens: 177, + contextTokens: 177, + compactPercent: 18, + }, + worktrees: [ + { + id: 'wt-1', + path: '/repo/.worktrees/fresh-agent-platform', + branch: 'feature/fresh-agent-platform', + }, + ], + diffs: [ + { + id: 'diff-1', + path: 'src/components/fresh-agent/FreshAgentView.tsx', + title: 'FreshAgentView.tsx', + }, + ], + childThreads: [ + { + id: 'child-1', + threadId: 'thread-codex-child-1', + origin: 'subagent', + title: 'Review shell states', + }, + ], + extension: { + codex: { + review: { + id: 'review-1', + status: 'pending', + }, + fork: { + parentThreadId: 'thread-parent-1', + }, + }, + }, + turns: [ + { + id: 'turn-1', + turnId: 'turn-1', + messageId: 'msg-1', + ordinal: 0, + source: 'durable', + role: 'user', + summary: 'Implement the fresh-agent shell', + items: [ + { id: 'turn-1:item-0', kind: 'text', text: 'Implement the fresh-agent shell' }, + ], + }, + { + id: 'turn-2', + turnId: 'turn-2', + messageId: 'msg-2', + ordinal: 1, + source: 'live', + role: 'assistant', + summary: 'Created worktree and queued review', + items: [ + { id: 'turn-2:item-0', kind: 'text', text: 'Created worktree and queued review.' }, + ], + }, + ], +} as const From 37f5e9945866759ee8fba8babee9cff9c5007f64 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Sat, 18 Apr 2026 13:04:43 -0700 Subject: [PATCH 16/86] Implement fresh-agent Codex thread surface --- server/coding-cli/codex-app-server/client.ts | 36 +++ .../coding-cli/codex-app-server/protocol.ts | 59 +++++ server/coding-cli/codex-app-server/runtime.ts | 21 ++ server/config-store.ts | 47 +++- server/fresh-agent/adapters/codex/adapter.ts | 32 ++- server/index.ts | 30 ++- server/mcp/freshell-tool.ts | 2 +- shared/ws-protocol.ts | 1 + src/App.tsx | 4 +- src/components/MobileTabStrip.tsx | 2 +- src/components/Sidebar.tsx | 2 +- src/components/TabBar.tsx | 2 +- src/components/TabSwitcher.tsx | 2 +- .../context-menu/context-menu-constants.ts | 1 + .../context-menu/context-menu-types.ts | 1 + .../context-menu/context-menu-utils.ts | 2 + src/components/context-menu/menu-defs.ts | 2 +- .../fresh-agent/FreshAgentApprovalBanner.tsx | 7 + .../fresh-agent/FreshAgentComposer.tsx | 39 +++ .../fresh-agent/FreshAgentDiffPanel.tsx | 13 + .../fresh-agent/FreshAgentQuestionBanner.tsx | 7 + .../fresh-agent/FreshAgentSidebar.tsx | 33 +++ .../fresh-agent/FreshAgentTranscript.tsx | 63 +++++ src/components/fresh-agent/FreshAgentView.tsx | 239 ++++++++++++++++++ src/components/icons/PaneIcon.tsx | 10 + src/components/panes/PaneContainer.tsx | 75 ++++-- src/components/panes/PanePicker.tsx | 23 +- src/components/settings/WorkspaceSettings.tsx | 23 +- src/lib/api.ts | 57 +++++ src/lib/fresh-agent-ws.ts | 58 +++++ src/lib/pane-activity.ts | 47 ++++ src/lib/session-type-utils.ts | 24 +- src/lib/session-utils.ts | 13 + src/lib/tab-directory-preference.ts | 4 +- src/store/freshAgentSlice.ts | 65 +++++ src/store/freshAgentThunks.ts | 5 + src/store/freshAgentTypes.ts | 14 + src/store/panesSlice.ts | 2 +- src/store/selectors/sidebarSelectors.ts | 18 ++ src/store/store.ts | 2 + src/store/tabsSlice.ts | 21 +- test/e2e-browser/perf/audit-contract.ts | 2 +- test/e2e-browser/perf/scenarios.ts | 6 +- test/e2e-browser/perf/seed-browser-storage.ts | 34 +-- .../specs/fresh-agent-mobile.spec.ts | 59 +++++ test/e2e-browser/specs/fresh-agent.spec.ts | 169 +++++++++++++ test/e2e/agent-chat-restore-flow.test.tsx | 27 +- .../codex-app-server/fake-app-server.mjs | 58 +++++ .../components/ContextMenuProvider.test.tsx | 6 +- .../components/HistoryView.mobile.test.tsx | 8 +- .../SettingsView.agent-chat.test.tsx | 20 +- .../client/components/TabContent.test.tsx | 16 +- .../components/TabsView.fresh-agent.test.tsx | 89 +++++++ .../agent-chat/AgentChatView.reload.test.tsx | 76 +++--- .../AgentChatView.session-lost.test.tsx | 53 ++-- .../AgentChatView.split-pane.test.tsx | 96 +++---- .../fresh-agent/FreshAgentDiffPanel.test.tsx | 11 + .../fresh-agent/FreshAgentTranscript.test.tsx | 22 ++ .../fresh-agent/FreshAgentView.test.tsx | 110 ++++++++ .../client/components/icons/PaneIcon.test.tsx | 12 +- .../components/panes/PanePicker.test.tsx | 23 +- test/unit/client/lib/api.test.ts | 31 +++ test/unit/client/lib/fresh-agent-ws.test.ts | 49 ++++ .../client/lib/session-type-utils.test.ts | 34 +-- .../unit/client/store/freshAgentSlice.test.ts | 49 ++++ test/unit/client/store/panesSlice.test.ts | 11 +- test/unit/client/store/settingsSlice.test.ts | 11 +- .../client/store/state-edge-cases.test.ts | 5 + test/unit/client/store/tabsSlice.test.ts | 8 +- .../unit/lib/visible-first-audit-gate.test.ts | 8 +- .../lib/visible-first-audit-scenarios.test.ts | 4 +- .../server/agent-layout-store-write.test.ts | 2 + .../codex-app-server/client.test.ts | 44 ++++ .../codex-app-server/runtime.test.ts | 12 + test/unit/server/config-store.test.ts | 4 + .../server/fresh-agent/codex-adapter.test.ts | 69 ++++- test/unit/server/mcp/freshell-tool.test.ts | 2 +- test/unit/shared/settings.test.ts | 3 + 78 files changed, 2066 insertions(+), 285 deletions(-) create mode 100644 src/components/fresh-agent/FreshAgentApprovalBanner.tsx create mode 100644 src/components/fresh-agent/FreshAgentComposer.tsx create mode 100644 src/components/fresh-agent/FreshAgentDiffPanel.tsx create mode 100644 src/components/fresh-agent/FreshAgentQuestionBanner.tsx create mode 100644 src/components/fresh-agent/FreshAgentSidebar.tsx create mode 100644 src/components/fresh-agent/FreshAgentTranscript.tsx create mode 100644 src/components/fresh-agent/FreshAgentView.tsx create mode 100644 src/lib/fresh-agent-ws.ts create mode 100644 src/store/freshAgentSlice.ts create mode 100644 src/store/freshAgentThunks.ts create mode 100644 src/store/freshAgentTypes.ts create mode 100644 test/e2e-browser/specs/fresh-agent-mobile.spec.ts create mode 100644 test/e2e-browser/specs/fresh-agent.spec.ts create mode 100644 test/unit/client/components/TabsView.fresh-agent.test.tsx create mode 100644 test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx create mode 100644 test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx create mode 100644 test/unit/client/components/fresh-agent/FreshAgentView.test.tsx create mode 100644 test/unit/client/lib/fresh-agent-ws.test.ts create mode 100644 test/unit/client/store/freshAgentSlice.test.ts diff --git a/server/coding-cli/codex-app-server/client.ts b/server/coding-cli/codex-app-server/client.ts index 28563f747..39cf5e160 100644 --- a/server/coding-cli/codex-app-server/client.ts +++ b/server/coding-cli/codex-app-server/client.ts @@ -12,12 +12,21 @@ import { CodexThreadLifecycleNotificationSchema, CodexThreadStartedNotificationSchema, CodexThreadOperationResultSchema, + CodexThreadReadResultSchema, + CodexThreadTurnReadResultSchema, + CodexThreadTurnsListResultSchema, type CodexInitializeResult, type CodexRpcError, type CodexThreadHandle, type CodexThreadOperationResult, + type CodexThreadReadParams, + type CodexThreadReadResult, type CodexThreadResumeParams, type CodexThreadStartParams, + type CodexThreadTurnReadParams, + type CodexThreadTurnReadResult, + type CodexThreadTurnsListParams, + type CodexThreadTurnsListResult, } from './protocol.js' type CodexAppServerClientOptions = { @@ -155,6 +164,33 @@ export class CodexAppServerClient { })) } + async readThread(params: CodexThreadReadParams): Promise { + const result = await this.request('thread/read', params) + const parsed = CodexThreadReadResultSchema.safeParse(result) + if (!parsed.success) { + throw new Error('Codex app-server returned an invalid thread/read payload.') + } + return parsed.data + } + + async listThreadTurns(params: CodexThreadTurnsListParams): Promise { + const result = await this.request('thread/turns/list', params) + const parsed = CodexThreadTurnsListResultSchema.safeParse(result) + if (!parsed.success) { + throw new Error('Codex app-server returned an invalid thread/turns/list payload.') + } + return parsed.data + } + + async readThreadTurn(params: CodexThreadTurnReadParams): Promise { + const result = await this.request('thread/turn/read', params) + const parsed = CodexThreadTurnReadResultSchema.safeParse(result) + if (!parsed.success) { + throw new Error('Codex app-server returned an invalid thread/turn/read payload.') + } + return parsed.data + } + async close(): Promise { const socket = this.socket this.socket = null diff --git a/server/coding-cli/codex-app-server/protocol.ts b/server/coding-cli/codex-app-server/protocol.ts index dd163bad2..be45753b6 100644 --- a/server/coding-cli/codex-app-server/protocol.ts +++ b/server/coding-cli/codex-app-server/protocol.ts @@ -63,6 +63,59 @@ export const CodexFsUnwatchParamsSchema = z.object({ watchId: z.string().min(1), }) +export const CodexThreadReadParamsSchema = z.object({ + threadId: z.string().min(1), + revision: z.number().int().nonnegative().optional(), +}) + +export const CodexThreadTurnItemSchema = z.object({ + id: z.string().min(1), + kind: z.string().min(1), +}).passthrough() + +export const CodexThreadTurnSchema = z.object({ + id: z.string().min(1), + turnId: z.string().min(1).optional(), + messageId: z.string().min(1).optional(), + ordinal: z.number().int().nonnegative().optional(), + source: z.enum(['durable', 'live']).optional(), + role: z.enum(['user', 'assistant']).optional(), + summary: z.string().optional(), + items: z.array(CodexThreadTurnItemSchema).optional(), +}).passthrough() + +export const CodexThreadReadResultSchema = z.object({ + threadId: z.string().min(1).optional(), + revision: z.number().int().nonnegative().optional(), + status: z.string().optional(), + turns: z.array(CodexThreadTurnSchema).optional(), +}).passthrough() + +export const CodexThreadTurnsListParamsSchema = z.object({ + threadId: z.string().min(1), + revision: z.number().int().nonnegative().optional(), + cursor: z.string().min(1).optional(), + limit: z.number().int().positive().optional(), + includeBodies: z.boolean().optional(), +}) + +export const CodexThreadTurnsListResultSchema = z.object({ + revision: z.number().int().nonnegative().optional(), + nextCursor: z.string().nullable().optional(), + turns: z.array(CodexThreadTurnSchema).optional(), + bodies: z.record(z.string(), CodexThreadTurnSchema).optional(), +}).passthrough() + +export const CodexThreadTurnReadParamsSchema = z.object({ + threadId: z.string().min(1), + turnId: z.string().min(1), + revision: z.number().int().nonnegative().optional(), +}) + +export const CodexThreadTurnReadResultSchema = CodexThreadTurnSchema.extend({ + revision: z.number().int().nonnegative().optional(), +}).passthrough() + export const CodexRpcErrorSchema = z.object({ code: z.number(), message: z.string().min(1), @@ -133,6 +186,12 @@ export type CodexThreadOperationResult = z.infer export type CodexFsWatchResult = z.infer export type CodexFsUnwatchParams = z.infer +export type CodexThreadReadParams = z.infer +export type CodexThreadReadResult = z.infer +export type CodexThreadTurnsListParams = z.infer +export type CodexThreadTurnsListResult = z.infer +export type CodexThreadTurnReadParams = z.infer +export type CodexThreadTurnReadResult = z.infer export type CodexRpcError = z.infer export type CodexThreadStartedNotification = z.infer export type CodexThreadClosedNotification = z.infer diff --git a/server/coding-cli/codex-app-server/runtime.ts b/server/coding-cli/codex-app-server/runtime.ts index 0bfc7012b..836533d55 100644 --- a/server/coding-cli/codex-app-server/runtime.ts +++ b/server/coding-cli/codex-app-server/runtime.ts @@ -12,8 +12,14 @@ import type { CodexInitializeResult, CodexThreadHandle, CodexThreadOperationResult, + CodexThreadReadParams, + CodexThreadReadResult, CodexThreadResumeParams, CodexThreadStartParams, + CodexThreadTurnReadParams, + CodexThreadTurnReadResult, + CodexThreadTurnsListParams, + CodexThreadTurnsListResult, } from './protocol.js' type RuntimeStatus = 'running' | 'stopped' @@ -646,6 +652,21 @@ export class CodexAppServerRuntime { return this.client!.watchPath(targetPath, watchId) } + async readThread(params: CodexThreadReadParams): Promise { + await this.ensureReady() + return await this.client!.readThread(params) + } + + async listThreadTurns(params: CodexThreadTurnsListParams): Promise { + await this.ensureReady() + return await this.client!.listThreadTurns(params) + } + + async readThreadTurn(params: CodexThreadTurnReadParams): Promise { + await this.ensureReady() + return await this.client!.readThreadTurn(params) + } + async unwatchPath(watchId: string): Promise { await this.ensureReady() await this.client!.unwatchPath(watchId) diff --git a/server/config-store.ts b/server/config-store.ts index 1e475bbf5..bd98b01bc 100644 --- a/server/config-store.ts +++ b/server/config-store.ts @@ -285,6 +285,49 @@ function migrateLegacyFreshClaudeSettings(rawSettings: Record): return migrated } +function normalizeFreshAgentCompatSettings(rawSettings: Record): Record { + const freshAgent = isRecord(rawSettings.freshAgent) ? rawSettings.freshAgent : undefined + const agentChat = isRecord(rawSettings.agentChat) ? rawSettings.agentChat : undefined + + if (!freshAgent && !agentChat) { + return rawSettings + } + + const merged: Record = { + ...(agentChat || {}), + ...(freshAgent || {}), + } + + const freshPlugins = Array.isArray(freshAgent?.defaultPlugins) ? freshAgent.defaultPlugins : undefined + const agentPlugins = Array.isArray(agentChat?.defaultPlugins) ? agentChat.defaultPlugins : undefined + if ((freshPlugins?.length ?? 0) > 0) { + merged.defaultPlugins = freshPlugins + } else if (agentPlugins) { + merged.defaultPlugins = agentPlugins + } + + const freshProviders = isRecord(freshAgent?.providers) ? freshAgent.providers : undefined + const agentProviders = isRecord(agentChat?.providers) ? agentChat.providers : undefined + if (freshProviders || agentProviders) { + merged.providers = { + ...(agentProviders || {}), + ...(freshProviders || {}), + } + } + + if (typeof freshAgent?.initialSetupDone === 'boolean') { + merged.initialSetupDone = freshAgent.initialSetupDone + } else if (typeof agentChat?.initialSetupDone === 'boolean') { + merged.initialSetupDone = agentChat.initialSetupDone + } + + return { + ...rawSettings, + freshAgent: merged, + agentChat: merged, + } +} + export class ConfigStore { private cache: UserConfig | null = null private writeMutex = new Mutex() @@ -310,9 +353,9 @@ export class ConfigStore { this.lastReadError = error if (existing) { this.lastReadError = undefined - const rawSettings = migrateLegacyFreshClaudeSettings( + const rawSettings = normalizeFreshAgentCompatSettings(migrateLegacyFreshClaudeSettings( isRecord(existing.settings) ? { ...existing.settings } : {}, - ) + )) const extractedLegacyLocalSettingsSeed = extractLegacyLocalSettingsSeed(rawSettings) const storedLegacyLocalSettingsSeed = isRecord(existing.legacyLocalSettingsSeed) ? extractLegacyLocalSettingsSeed(existing.legacyLocalSettingsSeed) diff --git a/server/fresh-agent/adapters/codex/adapter.ts b/server/fresh-agent/adapters/codex/adapter.ts index 978fbc3b8..ef4bd182e 100644 --- a/server/fresh-agent/adapters/codex/adapter.ts +++ b/server/fresh-agent/adapters/codex/adapter.ts @@ -17,17 +17,19 @@ type CodexRuntimePort = { approvalPolicy?: string richClient?: boolean }) => Promise<{ threadId: string; wsUrl: string }> -} - -type CodexReadStore = { - getSnapshot: (threadId: string, revision?: number) => Promise> - getTurnPage: (threadId: string, query: Record) => Promise - getTurnBody: (threadId: string, turnId: string, revision: number) => Promise + readThread: (input: { threadId: string; revision?: number }) => Promise> + listThreadTurns: (input: { + threadId: string + revision?: number + cursor?: string + limit?: number + includeBodies?: boolean + }) => Promise> + readThreadTurn: (input: { threadId: string; turnId: string; revision?: number }) => Promise> } export function createCodexFreshAgentAdapter(deps: { runtime: CodexRuntimePort - readStore: CodexReadStore }): FreshAgentRuntimeAdapter { return { runtimeProvider: 'codex', @@ -55,7 +57,7 @@ export function createCodexFreshAgentAdapter(deps: { }, async getSnapshot(thread, revision) { - const rawSnapshot = await deps.readStore.getSnapshot(thread.threadId, revision) + const rawSnapshot = await deps.runtime.readThread({ threadId: thread.threadId, revision }) return normalizeCodexThreadSnapshot({ threadId: thread.threadId, revision: Number(rawSnapshot.revision ?? revision ?? 0), @@ -68,11 +70,21 @@ export function createCodexFreshAgentAdapter(deps: { }, async getTurnPage(thread, query) { - return await deps.readStore.getTurnPage(thread.threadId, query) + return await deps.runtime.listThreadTurns({ + threadId: thread.threadId, + revision: typeof query.revision === 'number' ? query.revision : Number(query.revision), + cursor: typeof query.cursor === 'string' ? query.cursor : undefined, + limit: typeof query.limit === 'number' ? query.limit : undefined, + includeBodies: query.includeBodies === true, + }) }, async getTurnBody(thread, revision) { - return await deps.readStore.getTurnBody(thread.threadId, thread.turnId, revision) + return await deps.runtime.readThreadTurn({ + threadId: thread.threadId, + turnId: thread.turnId, + revision, + }) }, } } diff --git a/server/index.ts b/server/index.ts index 0f4b0c1ba..9d9efa98c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -85,6 +85,7 @@ import { createFreshAgentProviderRegistry } from './fresh-agent/provider-registr import { FreshAgentRuntimeManager } from './fresh-agent/runtime-manager.js' import { createFreshAgentRouter } from './fresh-agent/router.js' import { createClaudeFreshAgentAdapter } from './fresh-agent/adapters/claude/adapter.js' +import { createCodexFreshAgentAdapter } from './fresh-agent/adapters/codex/adapter.js' function compileArgTemplate( template: string[] | undefined, @@ -336,6 +337,20 @@ async function main() { agentHistorySource, timelineService: claudeFreshAgentTimelineService, }) + + const server = http.createServer(app) + const codexAppServerRuntime = new CodexAppServerRuntime({ serverInstanceId }) + const codexLaunchPlanner = new CodexLaunchPlanner((input) => new CodexTerminalSidecar({ + runtime: new CodexAppServerRuntime({ + serverInstanceId, + cwd: input.cwd, + commandArgs: input.commandArgs, + env: input.env, + }), + })) + const codexFreshAgentAdapter = createCodexFreshAgentAdapter({ + runtime: codexAppServerRuntime, + }) const freshAgentRuntimeManager = new FreshAgentRuntimeManager({ registry: createFreshAgentProviderRegistry([ { @@ -348,18 +363,13 @@ async function main() { runtimeProvider: 'claude', adapter: claudeFreshAgentAdapter, }, + { + sessionType: 'freshcodex', + runtimeProvider: 'codex', + adapter: codexFreshAgentAdapter, + }, ]), }) - - const server = http.createServer(app) - const codexLaunchPlanner = new CodexLaunchPlanner((input) => new CodexTerminalSidecar({ - runtime: new CodexAppServerRuntime({ - serverInstanceId, - cwd: input.cwd, - commandArgs: input.commandArgs, - env: input.env, - }), - })) const wsHandler = new WsHandler( server, registry, diff --git a/server/mcp/freshell-tool.ts b/server/mcp/freshell-tool.ts index bc7c358be..9f9251fe3 100644 --- a/server/mcp/freshell-tool.ts +++ b/server/mcp/freshell-tool.ts @@ -46,7 +46,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. ## Mental model - Tabs contain pane trees (splits). Panes contain content. -- Pane kinds: terminal, editor, browser, agent-chat (Claude/Codex/etc.), picker (transient). +- Pane kinds: terminal, editor, browser, fresh-agent (Freshclaude/Freshcodex/etc.), picker (transient). - **Picker panes are ephemeral.** A freshly-created tab without mode/browser/editor starts as a picker pane while the user chooses what to launch. Once they select, the picker is replaced by the real pane with a **new pane ID**. Never target a picker pane for splits or mutations -- use mode/browser/editor params on new-tab/split-pane to skip the picker entirely. - Typical workflow: new-tab -> send-keys -> wait-for -> capture-pane/screenshot. diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index dbaf95bdc..fd1502467 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -836,6 +836,7 @@ export type ServerMessage = | CodingCliExitMessage | CodingCliStderrMessage | CodingCliKilledMessage + | FreshAgentServerMessage | SdkServerMessage | ExtensionRegistryMessage | ExtensionServerStartingMessage diff --git a/src/App.tsx b/src/App.tsx index 6c3f02b9f..78e5cc0ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,6 +63,7 @@ import { recordTurnComplete } from '@/store/turnCompletionSlice' import { selectTabPaneByTerminalId } from '@/store/selectors/paneTerminalSelectors' import { setRegistry, updateServerStatus } from '@/store/extensionsSlice' import { handleSdkMessage } from '@/lib/sdk-message-handler' +import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' import { createLogger } from '@/lib/client-logger' import type { LocalSettingsPatch, ServerSettings } from '@shared/settings' import { z } from 'zod' @@ -934,7 +935,8 @@ export default function App() { dispatch(updateServerStatus({ name: msg.name, serverRunning: false, serverPort: undefined })) } - // SDK message handling (freshclaude pane) + handleFreshAgentMessage(dispatch, msg as Record) + // SDK message handling (freshclaude compatibility surface) handleSdkMessage(dispatch, msg as Record, ws) }) diff --git a/src/components/MobileTabStrip.tsx b/src/components/MobileTabStrip.tsx index 927266d44..61ba9f313 100644 --- a/src/components/MobileTabStrip.tsx +++ b/src/components/MobileTabStrip.tsx @@ -26,7 +26,7 @@ export function MobileTabStrip({ onOpenSwitcher, sidebarCollapsed, onToggleSideb const paneTitles = useAppSelector((s) => s.panes.paneTitles) const codexActivityByTerminalId = useAppSelector((s) => s.codexActivity?.byTerminalId ?? EMPTY_CODEX_ACTIVITY_BY_ID) const opencodeActivityByTerminalId = useAppSelector((s) => s.opencodeActivity?.byTerminalId ?? EMPTY_OPENCODE_ACTIVITY_BY_ID) - const agentChatSessions = useAppSelector((s) => s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS) + const agentChatSessions = useAppSelector((s) => s.freshAgent?.sessions ?? s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS) const paneRuntimeActivityByPaneId = useAppSelector( (s) => s.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID ) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4a9bb6dd3..36230daf1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -320,7 +320,7 @@ export default function Sidebar({ codexActivityByTerminalId: state.codexActivity?.byTerminalId ?? EMPTY_CODEX_ACTIVITY_BY_ID, opencodeActivityByTerminalId: state.opencodeActivity?.byTerminalId ?? EMPTY_OPENCODE_ACTIVITY_BY_ID, paneRuntimeActivityByPaneId: state.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID, - agentChatSessions: state.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS, + agentChatSessions: state.freshAgent?.sessions ?? state.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS, }), shallowEqual) const busySessionKeySet = useMemo(() => new Set(busySessionKeys), [busySessionKeys]) diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 36904e9cf..823f2a01d 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -153,7 +153,7 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp const attentionByPane = useAppSelector((s) => s.turnCompletion?.attentionByPane) ?? EMPTY_ATTENTION const codexActivityByTerminalId = useAppSelector((s) => s.codexActivity?.byTerminalId ?? EMPTY_CODEX_ACTIVITY_BY_ID) const opencodeActivityByTerminalId = useAppSelector((s) => s.opencodeActivity?.byTerminalId ?? EMPTY_OPENCODE_ACTIVITY_BY_ID) - const agentChatSessions = useAppSelector((s) => s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS) + const agentChatSessions = useAppSelector((s) => s.freshAgent?.sessions ?? s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS) const paneRuntimeActivityByPaneId = useAppSelector( (s) => s.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID ) diff --git a/src/components/TabSwitcher.tsx b/src/components/TabSwitcher.tsx index 86f0e7e69..367c8b43a 100644 --- a/src/components/TabSwitcher.tsx +++ b/src/components/TabSwitcher.tsx @@ -47,7 +47,7 @@ export function TabSwitcher({ onClose }: TabSwitcherProps) { const paneTitles = useAppSelector((s) => s.panes.paneTitles) const codexActivityByTerminalId = useAppSelector((s) => s.codexActivity?.byTerminalId ?? EMPTY_CODEX_ACTIVITY_BY_ID) const opencodeActivityByTerminalId = useAppSelector((s) => s.opencodeActivity?.byTerminalId ?? EMPTY_OPENCODE_ACTIVITY_BY_ID) - const agentChatSessions = useAppSelector((s) => s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS) + const agentChatSessions = useAppSelector((s) => s.freshAgent?.sessions ?? s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS) const paneRuntimeActivityByPaneId = useAppSelector( (s) => s.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID ) diff --git a/src/components/context-menu/context-menu-constants.ts b/src/components/context-menu/context-menu-constants.ts index 1dc9aee4e..8ad673b60 100644 --- a/src/components/context-menu/context-menu-constants.ts +++ b/src/components/context-menu/context-menu-constants.ts @@ -14,6 +14,7 @@ export const ContextIds = { OverviewTerminal: 'overview-terminal', ClaudeMessage: 'claude-message', AgentChat: 'agent-chat', + FreshAgent: 'fresh-agent', } as const export type ContextId = typeof ContextIds[keyof typeof ContextIds] diff --git a/src/components/context-menu/context-menu-types.ts b/src/components/context-menu/context-menu-types.ts index b115ddbdc..c5e759df7 100644 --- a/src/components/context-menu/context-menu-types.ts +++ b/src/components/context-menu/context-menu-types.ts @@ -17,6 +17,7 @@ export type ContextTarget = | { kind: 'overview-terminal'; terminalId: string } | { kind: 'claude-message'; sessionId: string; provider?: string } | { kind: 'agent-chat'; sessionId: string } + | { kind: 'fresh-agent'; sessionId: string } export type ParsedContext = { id: ContextId diff --git a/src/components/context-menu/context-menu-utils.ts b/src/components/context-menu/context-menu-utils.ts index 53ffcc745..0009d0ec5 100644 --- a/src/components/context-menu/context-menu-utils.ts +++ b/src/components/context-menu/context-menu-utils.ts @@ -86,6 +86,8 @@ export function parseContextTarget(contextId: ContextId, data: ContextDataset): return data.sessionId ? { kind: 'claude-message', sessionId: data.sessionId, provider: data.provider } : null case ContextIds.AgentChat: return data.sessionId ? { kind: 'agent-chat', sessionId: data.sessionId } : null + case ContextIds.FreshAgent: + return data.sessionId ? { kind: 'fresh-agent', sessionId: data.sessionId } : null default: return null } diff --git a/src/components/context-menu/menu-defs.ts b/src/components/context-menu/menu-defs.ts index 8415fdb24..a2ffb24dc 100644 --- a/src/components/context-menu/menu-defs.ts +++ b/src/components/context-menu/menu-defs.ts @@ -594,7 +594,7 @@ export function buildMenuItems(target: ContextTarget, ctx: MenuBuildContext): Me ] } - if (target.kind === 'agent-chat') { + if (target.kind === 'agent-chat' || target.kind === 'fresh-agent') { const selection = window.getSelection() const hasSelection = !!(selection && selection.toString().trim()) diff --git a/src/components/fresh-agent/FreshAgentApprovalBanner.tsx b/src/components/fresh-agent/FreshAgentApprovalBanner.tsx new file mode 100644 index 000000000..73d8d8ba6 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentApprovalBanner.tsx @@ -0,0 +1,7 @@ +export function FreshAgentApprovalBanner({ text }: { text: string }) { + return ( +
+ {text} +
+ ) +} diff --git a/src/components/fresh-agent/FreshAgentComposer.tsx b/src/components/fresh-agent/FreshAgentComposer.tsx new file mode 100644 index 000000000..c11669168 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentComposer.tsx @@ -0,0 +1,39 @@ +type FreshAgentComposerProps = { + disabled?: boolean + onSend?: (value: string) => void +} + +export function FreshAgentComposer({ disabled = false, onSend }: FreshAgentComposerProps) { + return ( +
{ + event.preventDefault() + const form = event.currentTarget + const input = new FormData(form).get('message') + const text = typeof input === 'string' ? input.trim() : '' + if (!text || disabled) return + onSend?.(text) + form.reset() + }} + > +
+