Просмотр исходного кода

test(app): add a golden path for mocked e2e prompts (#20593)

Kit Langton 2 недель назад
Родитель
Сommit
c3ef69c866
35 измененных файлов с 2467 добавлено и 2138 удалено
  1. 5 0
      .github/workflows/test.yml
  2. 7 8
      packages/app/e2e/AGENTS.md
  3. 65 173
      packages/app/e2e/actions.ts
  4. 2 1
      packages/app/e2e/backend.ts
  5. 424 125
      packages/app/e2e/fixtures.ts
  6. 43 37
      packages/app/e2e/projects/project-edit.spec.ts
  7. 34 39
      packages/app/e2e/projects/projects-close.spec.ts
  8. 57 82
      packages/app/e2e/projects/projects-switch.spec.ts
  9. 16 35
      packages/app/e2e/projects/workspace-new-session.spec.ts
  10. 250 257
      packages/app/e2e/projects/workspaces.spec.ts
  11. 0 41
      packages/app/e2e/prompt/mock.ts
  12. 8 35
      packages/app/e2e/prompt/prompt-async.spec.ts
  13. 38 67
      packages/app/e2e/prompt/prompt-history.spec.ts
  14. 24 25
      packages/app/e2e/prompt/prompt-shell.spec.ts
  15. 33 34
      packages/app/e2e/prompt/prompt-slash-share.spec.ts
  16. 8 38
      packages/app/e2e/prompt/prompt.spec.ts
  17. 0 11
      packages/app/e2e/selectors.ts
  18. 25 26
      packages/app/e2e/session/session-child-navigation.spec.ts
  19. 324 346
      packages/app/e2e/session/session-composer-dock.spec.ts
  20. 66 112
      packages/app/e2e/session/session-model-persistence.spec.ts
  21. 168 163
      packages/app/e2e/session/session-review.spec.ts
  22. 141 144
      packages/app/e2e/session/session-undo-redo.spec.ts
  23. 122 126
      packages/app/e2e/session/session.spec.ts
  24. 13 3
      packages/app/e2e/settings/settings.spec.ts
  25. 38 47
      packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts
  26. 22 23
      packages/app/e2e/terminal/terminal-reconnect.spec.ts
  27. 122 125
      packages/app/e2e/terminal/terminal-tabs.spec.ts
  28. 3 0
      packages/app/src/components/prompt-input/submit.ts
  29. 27 0
      packages/app/src/testing/prompt.ts
  30. 1 0
      packages/app/src/testing/terminal.ts
  31. 17 0
      packages/opencode/src/provider/provider.ts
  32. 3 1
      packages/opencode/src/tool/registry.ts
  33. 11 1
      packages/opencode/test/effect/cross-spawn-spawner.test.ts
  34. 36 13
      packages/opencode/test/lib/llm-server.ts
  35. 314 0
      packages/opencode/test/session/e2e-url-repro.test.ts

+ 5 - 0
.github/workflows/test.yml

@@ -47,6 +47,11 @@ jobs:
 
       - name: Run unit tests
         run: bun turbo test
+        env:
+          # Bun 1.3.11 intermittently crashes on Windows during test teardown
+          # inside the native @parcel/watcher binding. Unit CI does not rely on
+          # the live watcher backend there, so disable it for that platform.
+          OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
 
   e2e:
     name: e2e (${{ matrix.settings.name }})

+ 7 - 8
packages/app/e2e/AGENTS.md

@@ -59,8 +59,10 @@ test("test description", async ({ page, sdk, gotoSession }) => {
 ### Using Fixtures
 
 - `page` - Playwright page
-- `sdk` - OpenCode SDK client for API calls
-- `gotoSession(sessionID?)` - Navigate to session
+- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
+- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`)
+- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
+- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
 
 ### Helper Functions
 
@@ -73,12 +75,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
 - `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
 - `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
 - `withSession(sdk, title, callback)` - Create temp session
-- `withProject(...)` - Create temp project/workspace
 - `sessionIDFromUrl(url)` - Read session ID from URL
 - `slugFromUrl(url)` - Read workspace slug from URL
 - `waitSlug(page, skip?)` - Wait for resolved workspace slug
-- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
-- `trackDirectory(directory)` - Register directory for fixture cleanup
 - `clickListItem(container, filter)` - Click list item by key/text
 
 **Selectors** (`selectors.ts`):
@@ -128,9 +127,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
 })
 ```
 
-- Prefer `withSession(...)` for temp sessions
-- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
-- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
+- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
+- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
+- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up
 - Avoid calling `sdk.session.delete(...)` directly
 
 ### Timeouts

+ 65 - 173
packages/app/e2e/actions.ts

@@ -1,5 +1,5 @@
 import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
-import { expect, type Locator, type Page, type Route } from "@playwright/test"
+import { expect, type Locator, type Page } from "@playwright/test"
 import fs from "node:fs/promises"
 import os from "node:os"
 import path from "node:path"
@@ -7,7 +7,6 @@ import { execSync } from "node:child_process"
 import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
 import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
 import {
-  dropdownMenuTriggerSelector,
   dropdownMenuContentSelector,
   projectSwitchSelector,
   projectMenuTriggerSelector,
@@ -43,27 +42,6 @@ export async function defocus(page: Page) {
     .catch(() => undefined)
 }
 
-export async function withNoReplyPrompt<T>(page: Page, fn: () => Promise<T>) {
-  const url = "**/session/*/prompt_async"
-  const route = async (input: Route) => {
-    const body = input.request().postDataJSON()
-    await input.continue({
-      postData: JSON.stringify({ ...body, noReply: true }),
-      headers: {
-        ...input.request().headers(),
-        "content-type": "application/json",
-      },
-    })
-  }
-
-  await page.route(url, route)
-  try {
-    return await fn()
-  } finally {
-    await page.unroute(url, route)
-  }
-}
-
 async function terminalID(term: Locator) {
   const id = await term.getAttribute(terminalAttr)
   if (id) return id
@@ -333,63 +311,6 @@ export async function openSettings(page: Page) {
   return dialog
 }
 
-export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
-  await page.addInitScript(
-    (args: { directory: string; serverUrl: string; extra: string[] }) => {
-      const key = "opencode.global.dat:server"
-      const defaultKey = "opencode.settings.dat:defaultServerUrl"
-      const raw = localStorage.getItem(key)
-      const parsed = (() => {
-        if (!raw) return undefined
-        try {
-          return JSON.parse(raw) as unknown
-        } catch {
-          return undefined
-        }
-      })()
-
-      const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
-      const list = Array.isArray(store.list) ? store.list : []
-      const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
-      const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
-      const nextProjects = { ...(projects as Record<string, unknown>) }
-      const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
-
-      const add = (origin: string, directory: string) => {
-        const current = nextProjects[origin]
-        const items = Array.isArray(current) ? current : []
-        const existing = items.filter(
-          (p): p is { worktree: string; expanded?: boolean } =>
-            !!p &&
-            typeof p === "object" &&
-            "worktree" in p &&
-            typeof (p as { worktree?: unknown }).worktree === "string",
-        )
-
-        if (existing.some((p) => p.worktree === directory)) return
-        nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
-      }
-
-      const directories = [args.directory, ...args.extra]
-      for (const directory of directories) {
-        add("local", directory)
-        add(args.serverUrl, directory)
-      }
-
-      localStorage.setItem(
-        key,
-        JSON.stringify({
-          list: nextList,
-          projects: nextProjects,
-          lastProject,
-        }),
-      )
-      localStorage.setItem(defaultKey, args.serverUrl)
-    },
-    { directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
-  )
-}
-
 export async function createTestProject(input?: { serverUrl?: string }) {
   const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
   const id = `e2e-${path.basename(root)}`
@@ -479,7 +400,15 @@ export async function waitDir(page: Page, directory: string, input?: { serverUrl
   return { directory: target, slug: base64Encode(target) }
 }
 
-export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
+export async function waitSession(
+  page: Page,
+  input: {
+    directory: string
+    sessionID?: string
+    serverUrl?: string
+    allowAnySession?: boolean
+  },
+) {
   const target = await resolveDirectory(input.directory, input.serverUrl)
   await expect
     .poll(
@@ -491,11 +420,11 @@ export async function waitSession(page: Page, input: { directory: string; sessio
         if (!resolved || resolved.directory !== target) return false
         const current = sessionIDFromUrl(page.url())
         if (input.sessionID && current !== input.sessionID) return false
-        if (!input.sessionID && current) return false
+        if (!input.sessionID && !input.allowAnySession && current) return false
 
         const state = await probeSession(page)
         if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
-        if (!input.sessionID && state?.sessionID) return false
+        if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
         if (state?.dir) {
           const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
           if (dir !== target) return false
@@ -602,12 +531,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
 }
 
 export async function openSharePopover(page: Page) {
-  const rightSection = page.locator(titlebarRightSelector)
-  const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
-  await expect(shareButton).toBeVisible()
+  const scroller = page.locator(".scroll-view__viewport").first()
+  await expect(scroller).toBeVisible()
+  await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
+
+  const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
+  await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
 
   const popoverBody = page
-    .locator(popoverBodySelector)
+    .locator('[data-component="popover-content"]')
     .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
     .first()
 
@@ -617,16 +549,13 @@ export async function openSharePopover(page: Page) {
     .catch(() => false)
 
   if (!opened) {
-    await shareButton.click()
-    await expect(popoverBody).toBeVisible()
+    const menu = page.locator(dropdownMenuContentSelector).first()
+    await menuTrigger.click()
+    await clickMenuItem(menu, /share/i)
+    await expect(menu).toHaveCount(0)
+    await expect(popoverBody).toBeVisible({ timeout: 30_000 })
   }
-  return { rightSection, popoverBody }
-}
-
-export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
-  const button = page.getByRole("button").filter({ hasText: buttonName }).first()
-  await expect(button).toBeVisible()
-  await button.click()
+  return { rightSection: scroller, popoverBody }
 }
 
 export async function clickListItem(
@@ -794,40 +723,6 @@ export async function seedSessionQuestion(
   return { id: result.id }
 }
 
-export async function seedSessionPermission(
-  sdk: ReturnType<typeof createSdk>,
-  input: {
-    sessionID: string
-    permission: string
-    patterns: string[]
-    description?: string
-  },
-) {
-  const text = [
-    "Your only valid response is one bash tool call.",
-    `Use this JSON input: ${JSON.stringify({
-      command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
-      workdir: "/",
-      description: input.description ?? `seed ${input.permission} permission request`,
-    })}`,
-    "Do not output plain text.",
-  ].join("\n")
-
-  const result = await seed({
-    sdk,
-    sessionID: input.sessionID,
-    prompt: text,
-    timeout: 30_000,
-    probe: async () => {
-      const list = await sdk.permission.list().then((x) => x.data ?? [])
-      return list.find((item) => item.sessionID === input.sessionID)
-    },
-  })
-
-  if (!result) throw new Error("Timed out seeding permission request")
-  return { id: result.id }
-}
-
 export async function seedSessionTask(
   sdk: ReturnType<typeof createSdk>,
   input: {
@@ -886,36 +781,6 @@ export async function seedSessionTask(
   return result
 }
 
-export async function seedSessionTodos(
-  sdk: ReturnType<typeof createSdk>,
-  input: {
-    sessionID: string
-    todos: Array<{ content: string; status: string; priority: string }>
-  },
-) {
-  const text = [
-    "Your only valid response is one todowrite tool call.",
-    `Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
-    "Do not output plain text.",
-  ].join("\n")
-  const target = JSON.stringify(input.todos)
-
-  const result = await seed({
-    sdk,
-    sessionID: input.sessionID,
-    prompt: text,
-    timeout: 30_000,
-    probe: async () => {
-      const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
-      if (JSON.stringify(todos) !== target) return
-      return true
-    },
-  })
-
-  if (!result) throw new Error("Timed out seeding todos")
-  return true
-}
-
 export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
   const [questions, permissions] = await Promise.all([
     sdk.question.list().then((x) => x.data ?? []),
@@ -1005,30 +870,57 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
 }
 
 export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
-  const current = await page
-    .getByRole("button", { name: "New workspace" })
-    .first()
-    .isVisible()
-    .then((x) => x)
-    .catch(() => false)
+  const current = () =>
+    page
+      .getByRole("button", { name: "New workspace" })
+      .first()
+      .isVisible()
+      .then((x) => x)
+      .catch(() => false)
+
+  if ((await current()) === enabled) return
 
-  if (current === enabled) return
+  if (enabled) {
+    await page.reload()
+    await openSidebar(page)
+    if ((await current()) === enabled) return
+  }
 
   const flip = async (timeout?: number) => {
     const menu = await openProjectMenu(page, projectSlug)
     const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
     await expect(toggle).toBeVisible()
-    return toggle.click({ force: true, timeout })
+    await expect(toggle).toBeEnabled({ timeout: 30_000 })
+    const clicked = await toggle
+      .click({ force: true, timeout })
+      .then(() => true)
+      .catch(() => false)
+    if (clicked) return
+    await toggle.focus()
+    await page.keyboard.press("Enter")
   }
 
-  const flipped = await flip(1500)
-    .then(() => true)
-    .catch(() => false)
+  for (const timeout of [1500, undefined, undefined]) {
+    if ((await current()) === enabled) break
+    await flip(timeout)
+      .then(() => undefined)
+      .catch(() => undefined)
+    const matched = await expect
+      .poll(current, { timeout: 5_000 })
+      .toBe(enabled)
+      .then(() => true)
+      .catch(() => false)
+    if (matched) break
+  }
 
-  if (!flipped) await flip()
+  if ((await current()) !== enabled) {
+    await page.reload()
+    await openSidebar(page)
+  }
 
   const expected = enabled ? "New workspace" : "New session"
-  await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
+  await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
+  await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
 }
 
 export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {

+ 2 - 1
packages/app/e2e/backend.ts

@@ -62,7 +62,7 @@ function tail(input: string[]) {
   return input.slice(-40).join("")
 }
 
-export async function startBackend(label: string): Promise<Handle> {
+export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
   const port = await freePort()
   const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
   const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
@@ -80,6 +80,7 @@ export async function startBackend(label: string): Promise<Handle> {
     XDG_STATE_HOME: path.join(sandbox, "state"),
     OPENCODE_CLIENT: "app",
     OPENCODE_STRICT_CONFIG_DEPS: "true",
+    OPENCODE_E2E_LLM_URL: input?.llmUrl,
   } satisfies Record<string, string | undefined>
   const out: string[] = []
   const err: string[] = []

+ 424 - 125
packages/app/e2e/fixtures.ts

@@ -10,13 +10,14 @@ import {
   cleanupTestProject,
   createTestProject,
   setHealthPhase,
-  seedProjects,
   sessionIDFromUrl,
-  waitSlug,
   waitSession,
+  waitSessionIdle,
+  waitSessionSaved,
+  waitSlug,
 } from "./actions"
-import { openaiModel, withMockOpenAI } from "./prompt/mock"
-import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
+import { promptSelector } from "./selectors"
+import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
 
 type LLMFixture = {
   url: string
@@ -51,6 +52,23 @@ type LLMFixture = {
   misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
 }
 
+type LLMWorker = LLMFixture & {
+  reset: () => Promise<void>
+}
+
+type AssistantFixture = {
+  reply: LLMFixture["text"]
+  tool: LLMFixture["tool"]
+  toolHang: LLMFixture["toolHang"]
+  reason: LLMFixture["reason"]
+  fail: LLMFixture["fail"]
+  error: LLMFixture["error"]
+  hang: LLMFixture["hang"]
+  hold: LLMFixture["hold"]
+  calls: LLMFixture["calls"]
+  pending: LLMFixture["pending"]
+}
+
 export const settingsKey = "settings.v3"
 
 const seedModel = (() => {
@@ -63,6 +81,40 @@ const seedModel = (() => {
   }
 })()
 
+function clean(value: string | null) {
+  return (value ?? "").replace(/\u200B/g, "").trim()
+}
+
+async function visit(page: Page, url: string) {
+  let err: unknown
+  for (const _ of [0, 1, 2]) {
+    try {
+      await page.goto(url)
+      return
+    } catch (cause) {
+      err = cause
+      if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause
+      await new Promise((resolve) => setTimeout(resolve, 300))
+    }
+  }
+  throw err
+}
+
+async function promptSend(page: Page) {
+  return page
+    .evaluate(() => {
+      const win = window as E2EWindow
+      const sent = win.__opencode_e2e?.prompt?.sent
+      return {
+        started: sent?.started ?? 0,
+        count: sent?.count ?? 0,
+        sessionID: sent?.sessionID,
+        directory: sent?.directory,
+      }
+    })
+    .catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined }))
+}
+
 type ProjectHandle = {
   directory: string
   slug: string
@@ -79,16 +131,23 @@ type ProjectOptions = {
   beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
 }
 
+type ProjectFixture = ProjectHandle & {
+  open: (options?: ProjectOptions) => Promise<void>
+  prompt: (text: string) => Promise<string>
+  user: (text: string) => Promise<string>
+  shell: (cmd: string) => Promise<string>
+}
+
 type TestFixtures = {
   llm: LLMFixture
+  assistant: AssistantFixture
+  project: ProjectFixture
   sdk: ReturnType<typeof createSdk>
   gotoSession: (sessionID?: string) => Promise<void>
-  withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
-  withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
-  withMockProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
 }
 
 type WorkerFixtures = {
+  _llm: LLMWorker
   backend: {
     url: string
     sdk: (directory?: string) => ReturnType<typeof createSdk>
@@ -98,9 +157,42 @@ type WorkerFixtures = {
 }
 
 export const test = base.extend<TestFixtures, WorkerFixtures>({
+  _llm: [
+    async ({}, use) => {
+      const rt = ManagedRuntime.make(TestLLMServer.layer)
+      try {
+        const svc = await rt.runPromise(TestLLMServer.asEffect())
+        await use({
+          url: svc.url,
+          push: (...input) => rt.runPromise(svc.push(...input)),
+          pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
+          textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
+          toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
+          text: (value, opts) => rt.runPromise(svc.text(value, opts)),
+          tool: (name, input) => rt.runPromise(svc.tool(name, input)),
+          toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
+          reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
+          fail: (message) => rt.runPromise(svc.fail(message)),
+          error: (status, body) => rt.runPromise(svc.error(status, body)),
+          hang: () => rt.runPromise(svc.hang),
+          hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
+          reset: () => rt.runPromise(svc.reset),
+          hits: () => rt.runPromise(svc.hits),
+          calls: () => rt.runPromise(svc.calls),
+          wait: (count) => rt.runPromise(svc.wait(count)),
+          inputs: () => rt.runPromise(svc.inputs),
+          pending: () => rt.runPromise(svc.pending),
+          misses: () => rt.runPromise(svc.misses),
+        })
+      } finally {
+        await rt.dispose()
+      }
+    },
+    { scope: "worker" },
+  ],
   backend: [
-    async ({}, use, workerInfo) => {
-      const handle = await startBackend(`w${workerInfo.workerIndex}`)
+    async ({ _llm }, use, workerInfo) => {
+      const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
       try {
         await use({
           url: handle.url,
@@ -112,35 +204,48 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
     },
     { scope: "worker" },
   ],
-  llm: async ({}, use) => {
-    const rt = ManagedRuntime.make(TestLLMServer.layer)
-    try {
-      const svc = await rt.runPromise(TestLLMServer.asEffect())
-      await use({
-        url: svc.url,
-        push: (...input) => rt.runPromise(svc.push(...input)),
-        pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
-        textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
-        toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
-        text: (value, opts) => rt.runPromise(svc.text(value, opts)),
-        tool: (name, input) => rt.runPromise(svc.tool(name, input)),
-        toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
-        reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
-        fail: (message) => rt.runPromise(svc.fail(message)),
-        error: (status, body) => rt.runPromise(svc.error(status, body)),
-        hang: () => rt.runPromise(svc.hang),
-        hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
-        hits: () => rt.runPromise(svc.hits),
-        calls: () => rt.runPromise(svc.calls),
-        wait: (count) => rt.runPromise(svc.wait(count)),
-        inputs: () => rt.runPromise(svc.inputs),
-        pending: () => rt.runPromise(svc.pending),
-        misses: () => rt.runPromise(svc.misses),
-      })
-    } finally {
-      await rt.dispose()
+  llm: async ({ _llm }, use) => {
+    await _llm.reset()
+    await use({
+      url: _llm.url,
+      push: _llm.push,
+      pushMatch: _llm.pushMatch,
+      textMatch: _llm.textMatch,
+      toolMatch: _llm.toolMatch,
+      text: _llm.text,
+      tool: _llm.tool,
+      toolHang: _llm.toolHang,
+      reason: _llm.reason,
+      fail: _llm.fail,
+      error: _llm.error,
+      hang: _llm.hang,
+      hold: _llm.hold,
+      hits: _llm.hits,
+      calls: _llm.calls,
+      wait: _llm.wait,
+      inputs: _llm.inputs,
+      pending: _llm.pending,
+      misses: _llm.misses,
+    })
+    const pending = await _llm.pending()
+    if (pending > 0) {
+      throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
     }
   },
+  assistant: async ({ llm }, use) => {
+    await use({
+      reply: llm.text,
+      tool: llm.tool,
+      toolHang: llm.toolHang,
+      reason: llm.reason,
+      fail: llm.fail,
+      error: llm.error,
+      hang: llm.hang,
+      hold: llm.hold,
+      calls: llm.calls,
+      pending: llm.pending,
+    })
+  },
   page: async ({ page }, use) => {
     let boundary: string | undefined
     setHealthPhase(page, "test")
@@ -165,9 +270,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
     if (boundary) throw new Error(boundary)
   },
   directory: [
-    async ({}, use) => {
-      const directory = await getWorktree()
-      await use(directory)
+    async ({ backend }, use) => {
+      await use(await getWorktree(backend.url))
     },
     { scope: "worker" },
   ],
@@ -177,93 +281,254 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
     },
     { scope: "worker" },
   ],
-  sdk: async ({ directory }, use) => {
-    await use(createSdk(directory))
+  sdk: async ({ directory, backend }, use) => {
+    await use(backend.sdk(directory))
   },
-  gotoSession: async ({ page, directory }, use) => {
-    await seedStorage(page, { directory })
+  gotoSession: async ({ page, directory, backend }, use) => {
+    await seedStorage(page, { directory, serverUrl: backend.url })
 
     const gotoSession = async (sessionID?: string) => {
-      await page.goto(sessionPath(directory, sessionID))
-      await waitSession(page, { directory, sessionID })
+      await visit(page, sessionPath(directory, sessionID))
+      await waitSession(page, {
+        directory,
+        sessionID,
+        serverUrl: backend.url,
+        allowAnySession: !sessionID,
+      })
     }
     await use(gotoSession)
   },
-  withProject: async ({ page }, use) => {
-    await use((callback, options) => runProject(page, callback, options))
-  },
-  withBackendProject: async ({ page, backend }, use) => {
-    await use((callback, options) =>
-      runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
-    )
-  },
-  withMockProject: async ({ page, llm, backend }, use) => {
-    await use((callback, options) =>
-      withMockOpenAI({
-        serverUrl: backend.url,
-        llmUrl: llm.url,
-        fn: () =>
-          runProject(page, callback, {
-            ...options,
-            model: options?.model ?? openaiModel,
-            serverUrl: backend.url,
-            sdk: backend.sdk,
-          }),
-      }),
-    )
+  project: async ({ page, llm, backend }, use) => {
+    const item = makeProject(page, llm, backend)
+    try {
+      await use(item.project)
+    } finally {
+      await item.cleanup()
+    }
   },
 })
 
-async function runProject<T>(
+function makeProject(
   page: Page,
-  callback: (project: ProjectHandle) => Promise<T>,
-  options?: ProjectOptions & {
-    serverUrl?: string
-    sdk?: (directory?: string) => ReturnType<typeof createSdk>
-  },
+  llm: LLMFixture,
+  backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
 ) {
-  const url = options?.serverUrl
-  const root = await createTestProject(url ? { serverUrl: url } : undefined)
-  const sdk = options?.sdk?.(root) ?? createSdk(root, url)
-  const sessions = new Map<string, string>()
-  const dirs = new Set<string>()
-  await options?.setup?.(root)
-  await seedStorage(page, {
-    directory: root,
-    extra: options?.extra,
-    model: options?.model,
-    serverUrl: url,
-  })
+  let state:
+    | {
+        directory: string
+        slug: string
+        sdk: ReturnType<typeof createSdk>
+        sessions: Map<string, string>
+        dirs: Set<string>
+      }
+    | undefined
 
-  const gotoSession = async (sessionID?: string) => {
-    await page.goto(sessionPath(root, sessionID))
-    await waitSession(page, { directory: root, sessionID, serverUrl: url })
-    const current = sessionIDFromUrl(page.url())
-    if (current) trackSession(current)
+  const need = () => {
+    if (state) return state
+    throw new Error("project.open() must be called first")
   }
 
   const trackSession = (sessionID: string, directory?: string) => {
-    sessions.set(sessionID, directory ?? root)
+    const cur = need()
+    cur.sessions.set(sessionID, directory ?? cur.directory)
   }
 
   const trackDirectory = (directory: string) => {
-    if (directory !== root) dirs.add(directory)
+    const cur = need()
+    if (directory !== cur.directory) cur.dirs.add(directory)
+  }
+
+  const gotoSession = async (sessionID?: string) => {
+    const cur = need()
+    await visit(page, sessionPath(cur.directory, sessionID))
+    await waitSession(page, {
+      directory: cur.directory,
+      sessionID,
+      serverUrl: backend.url,
+      allowAnySession: !sessionID,
+    })
+    const current = sessionIDFromUrl(page.url())
+    if (current) trackSession(current)
   }
 
-  try {
-    await options?.beforeGoto?.({ directory: root, sdk })
+  const open = async (options?: ProjectOptions) => {
+    if (state) return
+    const directory = await createTestProject({ serverUrl: backend.url })
+    const sdk = backend.sdk(directory)
+    await options?.setup?.(directory)
+    await seedStorage(page, {
+      directory,
+      extra: options?.extra,
+      model: options?.model,
+      serverUrl: backend.url,
+    })
+    state = {
+      directory,
+      slug: "",
+      sdk,
+      sessions: new Map(),
+      dirs: new Set(),
+    }
+    await options?.beforeGoto?.({ directory, sdk })
     await gotoSession()
-    const slug = await waitSlug(page)
-    return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
-  } finally {
+    need().slug = await waitSlug(page)
+  }
+
+  const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
+    if (input.noReply) {
+      const cur = need()
+      const state = await page.evaluate(() => {
+        const model = (window as E2EWindow).__opencode_e2e?.model?.current
+        if (!model) return null
+        return {
+          dir: model.dir,
+          sessionID: model.sessionID,
+          agent: model.agent,
+          model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
+          variant: model.variant ?? undefined,
+        }
+      })
+      const dir = state?.dir ?? cur.directory
+      const sdk = backend.sdk(dir)
+      const sessionID = state?.sessionID
+        ? state.sessionID
+        : await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
+            if (!res.data?.id) throw new Error("Failed to create no-reply session")
+            return res.data.id
+          })
+      await sdk.session.prompt({
+        sessionID,
+        agent: state?.agent,
+        model: state?.model,
+        variant: state?.variant,
+        noReply: true,
+        parts: [{ type: "text", text }],
+      })
+      await visit(page, sessionPath(dir, sessionID))
+      const active = await waitSession(page, {
+        directory: dir,
+        sessionID,
+        serverUrl: backend.url,
+      })
+      trackSession(sessionID, active.directory)
+      await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
+      return sessionID
+    }
+
+    const prev = await promptSend(page)
+    if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
+      await llm.text("ok")
+    }
+
+    const prompt = page.locator(promptSelector).first()
+    const submit = async () => {
+      await expect(prompt).toBeVisible()
+      await prompt.click()
+      if (input.shell) {
+        await page.keyboard.type("!")
+        await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
+      }
+      await page.keyboard.type(text)
+      await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
+      await page.keyboard.press("Enter")
+      const started = await expect
+        .poll(async () => (await promptSend(page)).started, { timeout: 5_000 })
+        .toBeGreaterThan(prev.started)
+        .then(() => true)
+        .catch(() => false)
+      if (started) return
+      const send = page.getByRole("button", { name: "Send" }).first()
+      const enabled = await send
+        .isEnabled()
+        .then((x) => x)
+        .catch(() => false)
+      if (enabled) {
+        await send.click()
+      } else {
+        await prompt.click()
+        await page.keyboard.press("Enter")
+      }
+      await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
+    }
+
+    await submit()
+
+    let next: { sessionID: string; directory: string } | undefined
+    await expect
+      .poll(
+        async () => {
+          const sent = await promptSend(page)
+          if (sent.count <= prev.count) return ""
+          if (!sent.sessionID || !sent.directory) return ""
+          next = { sessionID: sent.sessionID, directory: sent.directory }
+          return sent.sessionID
+        },
+        { timeout: 90_000 },
+      )
+      .not.toBe("")
+
+    if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe")
+    const active = await waitSession(page, {
+      directory: next.directory,
+      sessionID: next.sessionID,
+      serverUrl: backend.url,
+    })
+    trackSession(next.sessionID, active.directory)
+    if (!input.shell) {
+      await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url)
+    }
+    await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined)
+    return next.sessionID
+  }
+
+  const prompt = async (text: string) => {
+    return send(text, { noReply: false, shell: false })
+  }
+
+  const user = async (text: string) => {
+    return send(text, { noReply: true, shell: false })
+  }
+
+  const shell = async (cmd: string) => {
+    return send(cmd, { noReply: false, shell: true })
+  }
+
+  const cleanup = async () => {
+    const cur = state
+    if (!cur) return
     setHealthPhase(page, "cleanup")
     await Promise.allSettled(
-      Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
+      Array.from(cur.sessions, ([sessionID, directory]) =>
+        cleanupSession({ sessionID, directory, serverUrl: backend.url }),
+      ),
     )
-    await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
-    await cleanupTestProject(root)
+    await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
+    await cleanupTestProject(cur.directory)
+    state = undefined
     setHealthPhase(page, "test")
   }
+
+  return {
+    project: {
+      open,
+      prompt,
+      user,
+      shell,
+      gotoSession,
+      trackSession,
+      trackDirectory,
+      get directory() {
+        return need().directory
+      },
+      get slug() {
+        return need().slug
+      },
+      get sdk() {
+        return need().sdk
+      },
+    },
+    cleanup,
+  }
 }
 
 async function seedStorage(
@@ -275,31 +540,65 @@ async function seedStorage(
     serverUrl?: string
   },
 ) {
-  await seedProjects(page, input)
-  await page.addInitScript((model: { providerID: string; modelID: string }) => {
-    const win = window as E2EWindow
-    win.__opencode_e2e = {
-      ...win.__opencode_e2e,
-      model: {
-        enabled: true,
-      },
-      prompt: {
-        enabled: true,
-      },
-      terminal: {
-        enabled: true,
-        terminals: {},
-      },
-    }
-    localStorage.setItem(
-      "opencode.global.dat:model",
-      JSON.stringify({
-        recent: [model],
-        user: [],
-        variant: {},
-      }),
-    )
-  }, input.model ?? seedModel)
+  const origin = input.serverUrl ?? serverUrl
+  await page.addInitScript(
+    (args: {
+      directory: string
+      serverUrl: string
+      extra: string[]
+      model: { providerID: string; modelID: string }
+    }) => {
+      const key = "opencode.global.dat:server"
+      const raw = localStorage.getItem(key)
+      const parsed = (() => {
+        if (!raw) return undefined
+        try {
+          return JSON.parse(raw) as unknown
+        } catch {
+          return undefined
+        }
+      })()
+
+      const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
+      const list = Array.isArray(store.list) ? store.list : []
+      const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
+      const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
+      const next = { ...(projects as Record<string, unknown>) }
+      const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
+
+      const add = (origin: string, directory: string) => {
+        const current = next[origin]
+        const items = Array.isArray(current) ? current : []
+        const existing = items.filter(
+          (p): p is { worktree: string; expanded?: boolean } =>
+            !!p &&
+            typeof p === "object" &&
+            "worktree" in p &&
+            typeof (p as { worktree?: unknown }).worktree === "string",
+        )
+        if (existing.some((p) => p.worktree === directory)) return
+        next[origin] = [{ worktree: directory, expanded: true }, ...existing]
+      }
+
+      for (const directory of [args.directory, ...args.extra]) {
+        add("local", directory)
+        add(args.serverUrl, directory)
+      }
+
+      localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
+      localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
+
+      const win = window as E2EWindow
+      win.__opencode_e2e = {
+        ...win.__opencode_e2e,
+        model: { enabled: true },
+        prompt: { enabled: true },
+        terminal: { enabled: true, terminals: {} },
+      }
+      localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
+    },
+    { directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
+  )
 }
 
 export { expect }

+ 43 - 37
packages/app/e2e/projects/project-edit.spec.ts

@@ -1,43 +1,49 @@
 import { test, expect } from "../fixtures"
 import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
 
-test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
+test("dialog edit project updates name and startup script", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
-  await withProject(async ({ slug }) => {
-    await openSidebar(page)
-
-    const open = async () => {
-      const menu = await openProjectMenu(page, slug)
-      await clickMenuItem(menu, /^Edit$/i, { force: true })
-
-      const dialog = page.getByRole("dialog")
-      await expect(dialog).toBeVisible()
-      await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
-      return dialog
-    }
-
-    const name = `e2e project ${Date.now()}`
-    const startup = `echo e2e_${Date.now()}`
-
-    const dialog = await open()
-
-    const nameInput = dialog.getByLabel("Name")
-    await nameInput.fill(name)
-
-    const startupInput = dialog.getByLabel("Workspace startup script")
-    await startupInput.fill(startup)
-
-    await dialog.getByRole("button", { name: "Save" }).click()
-    await expect(dialog).toHaveCount(0)
-
-    const header = page.locator(".group\\/project").first()
-    await expect(header).toContainText(name)
-
-    const reopened = await open()
-    await expect(reopened.getByLabel("Name")).toHaveValue(name)
-    await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
-    await reopened.getByRole("button", { name: "Cancel" }).click()
-    await expect(reopened).toHaveCount(0)
-  })
+  await project.open()
+  await openSidebar(page)
+
+  const open = async () => {
+    const menu = await openProjectMenu(page, project.slug)
+    await clickMenuItem(menu, /^Edit$/i, { force: true })
+
+    const dialog = page.getByRole("dialog")
+    await expect(dialog).toBeVisible()
+    await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
+    return dialog
+  }
+
+  const name = `e2e project ${Date.now()}`
+  const startup = `echo e2e_${Date.now()}`
+
+  const dialog = await open()
+
+  const nameInput = dialog.getByLabel("Name")
+  await nameInput.fill(name)
+
+  const startupInput = dialog.getByLabel("Workspace startup script")
+  await startupInput.fill(startup)
+
+  await dialog.getByRole("button", { name: "Save" }).click()
+  await expect(dialog).toHaveCount(0)
+
+  await expect
+    .poll(
+      async () => {
+        await page.reload()
+        await openSidebar(page)
+        const reopened = await open()
+        const value = await reopened.getByLabel("Name").inputValue()
+        const next = await reopened.getByLabel("Workspace startup script").inputValue()
+        await reopened.getByRole("button", { name: "Cancel" }).click()
+        await expect(reopened).toHaveCount(0)
+        return `${value}\n${next}`
+      },
+      { timeout: 30_000 },
+    )
+    .toBe(`${name}\n${startup}`)
 })

+ 34 - 39
packages/app/e2e/projects/projects-close.spec.ts

@@ -3,51 +3,46 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open
 import { projectSwitchSelector } from "../selectors"
 import { dirSlug } from "../utils"
 
-test("closing active project navigates to another open project", async ({ page, withProject }) => {
+test("closing active project navigates to another open project", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
   const other = await createTestProject()
   const otherSlug = dirSlug(other)
 
   try {
-    await withProject(
-      async ({ slug }) => {
-        await openSidebar(page)
-
-        const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
-        await expect(otherButton).toBeVisible()
-        await otherButton.click()
-
-        await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
-
-        const menu = await openProjectMenu(page, otherSlug)
-
-        await clickMenuItem(menu, /^Close$/i, { force: true })
-
-        await expect
-          .poll(
-            () => {
-              const pathname = new URL(page.url()).pathname
-              if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
-              if (pathname === "/") return "home"
-              return ""
-            },
-            { timeout: 15_000 },
-          )
-          .toMatch(/^(project|home)$/)
-
-        await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
-        await expect
-          .poll(
-            async () => {
-              return await page.locator(projectSwitchSelector(otherSlug)).count()
-            },
-            { timeout: 15_000 },
-          )
-          .toBe(0)
-      },
-      { extra: [other] },
-    )
+    await project.open({ extra: [other] })
+    await openSidebar(page)
+
+    const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+    await expect(otherButton).toBeVisible()
+    await otherButton.click()
+
+    await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+
+    const menu = await openProjectMenu(page, otherSlug)
+    await clickMenuItem(menu, /^Close$/i, { force: true })
+
+    await expect
+      .poll(
+        () => {
+          const pathname = new URL(page.url()).pathname
+          if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
+          if (pathname === "/") return "home"
+          return ""
+        },
+        { timeout: 15_000 },
+      )
+      .toMatch(/^(project|home)$/)
+
+    await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
+    await expect
+      .poll(
+        async () => {
+          return await page.locator(projectSwitchSelector(otherSlug)).count()
+        },
+        { timeout: 15_000 },
+      )
+      .toBe(0)
   } finally {
     await cleanupTestProject(other)
   }

+ 57 - 82
packages/app/e2e/projects/projects-switch.spec.ts

@@ -5,114 +5,89 @@ import {
   createTestProject,
   cleanupTestProject,
   openSidebar,
-  sessionIDFromUrl,
   setWorkspacesEnabled,
   waitSession,
-  waitSessionSaved,
   waitSlug,
-  withNoReplyPrompt,
 } from "../actions"
-import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
+import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
 import { dirSlug, resolveDirectory } from "../utils"
 
-test("can switch between projects from sidebar", async ({ page, withProject }) => {
+test("can switch between projects from sidebar", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
   const other = await createTestProject()
   const otherSlug = dirSlug(other)
 
   try {
-    await withProject(
-      async ({ directory }) => {
-        await defocus(page)
+    await project.open({ extra: [other] })
+    await defocus(page)
 
-        const currentSlug = dirSlug(directory)
-        const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
-        await expect(otherButton).toBeVisible()
-        await otherButton.click()
+    const currentSlug = dirSlug(project.directory)
+    const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+    await expect(otherButton).toBeVisible()
+    await otherButton.click()
 
-        await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+    await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
 
-        const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
-        await expect(currentButton).toBeVisible()
-        await currentButton.click()
+    const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
+    await expect(currentButton).toBeVisible()
+    await currentButton.click()
 
-        await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
-      },
-      { extra: [other] },
-    )
+    await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
   } finally {
     await cleanupTestProject(other)
   }
 })
 
-test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
+test("switching back to a project opens the latest workspace session", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
   const other = await createTestProject()
   const otherSlug = dirSlug(other)
   try {
-    await withProject(
-      async ({ directory, slug, trackSession, trackDirectory }) => {
-        await defocus(page)
-        await setWorkspacesEnabled(page, slug, true)
-        await openSidebar(page)
-        await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
-
-        await page.getByRole("button", { name: "New workspace" }).first().click()
-
-        const raw = await waitSlug(page, [slug])
-        const dir = base64Decode(raw)
-        if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
-        const space = await resolveDirectory(dir)
-        const next = dirSlug(space)
-        trackDirectory(space)
-        await openSidebar(page)
-
-        const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
-        await expect(item).toBeVisible()
-        await item.hover()
-
-        const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
-        await expect(btn).toBeVisible()
-        await btn.click({ force: true })
-
-        await waitSession(page, { directory: space })
-
-        // Create a session by sending a prompt
-        const prompt = page.locator(promptSelector)
-        await expect(prompt).toBeVisible()
-        await withNoReplyPrompt(page, async () => {
-          await prompt.fill("test")
-          await page.keyboard.press("Enter")
-        })
-
-        // Wait for the URL to update with the new session ID
-        await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
-
-        const created = sessionIDFromUrl(page.url())
-        if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
-        trackSession(created, space)
-        await waitSessionSaved(space, created)
-
-        await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
-
-        await openSidebar(page)
-
-        const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
-        await expect(otherButton).toBeVisible()
-        await otherButton.click({ force: true })
-        await waitSession(page, { directory: other })
-
-        const rootButton = page.locator(projectSwitchSelector(slug)).first()
-        await expect(rootButton).toBeVisible()
-        await rootButton.click({ force: true })
-
-        await waitSession(page, { directory: space, sessionID: created })
-        await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
-      },
-      { extra: [other] },
-    )
+    await project.open({ extra: [other] })
+    await defocus(page)
+    await setWorkspacesEnabled(page, project.slug, true)
+    await openSidebar(page)
+    await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+
+    await page.getByRole("button", { name: "New workspace" }).first().click()
+
+    const raw = await waitSlug(page, [project.slug])
+    const dir = base64Decode(raw)
+    if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
+    const space = await resolveDirectory(dir)
+    const next = dirSlug(space)
+    project.trackDirectory(space)
+    await openSidebar(page)
+
+    const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
+    await expect(item).toBeVisible()
+    await item.hover()
+
+    const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
+    await expect(btn).toBeVisible()
+    await btn.click({ force: true })
+
+    await waitSession(page, { directory: space })
+
+    const created = await project.user("test")
+
+    await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
+
+    await openSidebar(page)
+
+    const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+    await expect(otherButton).toBeVisible()
+    await otherButton.click({ force: true })
+    await waitSession(page, { directory: other })
+
+    const rootButton = page.locator(projectSwitchSelector(project.slug)).first()
+    await expect(rootButton).toBeVisible()
+    await rootButton.click({ force: true })
+
+    await waitSession(page, { directory: space, sessionID: created })
+    await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
   } finally {
     await cleanupTestProject(other)
   }

+ 16 - 35
packages/app/e2e/projects/workspace-new-session.spec.ts

@@ -7,12 +7,9 @@ import {
   setWorkspacesEnabled,
   waitDir,
   waitSession,
-  waitSessionSaved,
   waitSlug,
-  withNoReplyPrompt,
 } from "../actions"
-import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
-import { createSdk } from "../utils"
+import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
 
 function item(space: { slug: string; raw: string }) {
   return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
@@ -51,47 +48,31 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
 }
 
 async function createSessionFromWorkspace(
+  project: Parameters<typeof test>[0]["project"],
   page: Page,
   space: { slug: string; raw: string; directory: string },
   text: string,
 ) {
   await openWorkspaceNewSession(page, space)
-
-  const prompt = page.locator(promptSelector)
-  await expect(prompt).toBeVisible()
-  await withNoReplyPrompt(page, async () => {
-    await prompt.fill(text)
-    await page.keyboard.press("Enter")
-  })
-
-  await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
-  const sessionID = sessionIDFromUrl(page.url())
-  if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
-
-  await waitSessionSaved(space.directory, sessionID)
-  await createSdk(space.directory)
-    .session.abort({ sessionID })
-    .catch(() => undefined)
-  return sessionID
+  return project.user(text)
 }
 
-test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
+test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
-  await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
-    await openSidebar(page)
-    await setWorkspacesEnabled(page, root, true)
+  await project.open()
+  await openSidebar(page)
+  await setWorkspacesEnabled(page, project.slug, true)
 
-    const first = await createWorkspace(page, root, [])
-    trackDirectory(first.directory)
-    await waitWorkspaceReady(page, first)
+  const first = await createWorkspace(page, project.slug, [])
+  project.trackDirectory(first.directory)
+  await waitWorkspaceReady(page, first)
 
-    const second = await createWorkspace(page, root, [first.slug])
-    trackDirectory(second.directory)
-    await waitWorkspaceReady(page, second)
+  const second = await createWorkspace(page, project.slug, [first.slug])
+  project.trackDirectory(second.directory)
+  await waitWorkspaceReady(page, second)
 
-    trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
-    trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
-    trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
-  })
+  await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`)
+  await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`)
+  await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`)
 })

+ 250 - 257
packages/app/e2e/projects/workspaces.spec.ts

@@ -19,10 +19,10 @@ import {
   waitDir,
   waitSlug,
 } from "../actions"
-import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
-import { createSdk, dirSlug } from "../utils"
+import { inlineInputSelector, workspaceItemSelector } from "../selectors"
+import { dirSlug } from "../utils"
 
-async function setupWorkspaceTest(page: Page, project: { slug: string }) {
+async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) {
   const rootSlug = project.slug
   await openSidebar(page)
 
@@ -31,6 +31,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
   await page.getByRole("button", { name: "New workspace" }).first().click()
   const next = await resolveSlug(await waitSlug(page, [rootSlug]))
   await waitDir(page, next.directory)
+  project.trackDirectory(next.directory)
 
   await openSidebar(page)
 
@@ -52,62 +53,59 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
   return { rootSlug, slug: next.slug, directory: next.directory }
 }
 
-test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
+test("can enable and disable workspaces from project menu", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
+  await project.open()
 
-  await withProject(async ({ slug }) => {
-    await openSidebar(page)
+  await openSidebar(page)
 
-    await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
-    await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
+  await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+  await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
 
-    await setWorkspacesEnabled(page, slug, true)
-    await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
-    await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
+  await setWorkspacesEnabled(page, project.slug, true)
+  await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+  await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible()
 
-    await setWorkspacesEnabled(page, slug, false)
-    await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
-    await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
-  })
+  await setWorkspacesEnabled(page, project.slug, false)
+  await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+  await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0)
 })
 
-test("can create a workspace", async ({ page, withProject }) => {
+test("can create a workspace", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
+  await project.open()
 
-  await withProject(async ({ slug }) => {
-    await openSidebar(page)
-    await setWorkspacesEnabled(page, slug, true)
-
-    await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+  await openSidebar(page)
+  await setWorkspacesEnabled(page, project.slug, true)
 
-    await page.getByRole("button", { name: "New workspace" }).first().click()
-    const next = await resolveSlug(await waitSlug(page, [slug]))
-    await waitDir(page, next.directory)
+  await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
 
-    await openSidebar(page)
+  await page.getByRole("button", { name: "New workspace" }).first().click()
+  const next = await resolveSlug(await waitSlug(page, [project.slug]))
+  await waitDir(page, next.directory)
+  project.trackDirectory(next.directory)
 
-    await expect
-      .poll(
-        async () => {
-          const item = page.locator(workspaceItemSelector(next.slug)).first()
-          try {
-            await item.hover({ timeout: 500 })
-            return true
-          } catch {
-            return false
-          }
-        },
-        { timeout: 60_000 },
-      )
-      .toBe(true)
+  await openSidebar(page)
 
-    await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
+  await expect
+    .poll(
+      async () => {
+        const item = page.locator(workspaceItemSelector(next.slug)).first()
+        try {
+          await item.hover({ timeout: 500 })
+          return true
+        } catch {
+          return false
+        }
+      },
+      { timeout: 60_000 },
+    )
+    .toBe(true)
 
-    await cleanupTestProject(next.directory)
-  })
+  await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
 })
 
-test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
+test("non-git projects keep workspace mode disabled", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
   const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
@@ -116,260 +114,255 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
   await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
 
   try {
-    await withProject(async () => {
-      await page.goto(`/${nonGitSlug}/session`)
-
-      await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
+    await project.open({ extra: [nonGit] })
+    await page.goto(`/${nonGitSlug}/session`)
 
-      const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
-      expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
+    await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
 
-      await openSidebar(page)
-      await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
-
-      const trigger = page.locator('[data-action="project-menu"]').first()
-      const hasMenu = await trigger
-        .isVisible()
-        .then((x) => x)
-        .catch(() => false)
-      if (!hasMenu) return
+    const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
+    expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
 
-      await trigger.click({ force: true })
-
-      const menu = page.locator(dropdownMenuContentSelector).first()
-      await expect(menu).toBeVisible()
-
-      const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
-
-      await expect(toggle).toBeVisible()
-      await expect(toggle).toBeDisabled()
-      await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
-    })
+    await openSidebar(page)
+    await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
+    await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible()
   } finally {
     await cleanupTestProject(nonGit)
   }
 })
 
-test("can rename a workspace", async ({ page, withProject }) => {
+test("can rename a workspace", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
-
-  await withProject(async (project) => {
-    const { slug } = await setupWorkspaceTest(page, project)
-
-    const rename = `e2e workspace ${Date.now()}`
-    const menu = await openWorkspaceMenu(page, slug)
-    await clickMenuItem(menu, /^Rename$/i, { force: true })
-
-    await expect(menu).toHaveCount(0)
-
-    const item = page.locator(workspaceItemSelector(slug)).first()
-    await expect(item).toBeVisible()
-    const input = item.locator(inlineInputSelector).first()
-    await expect(input).toBeVisible()
-    await input.fill(rename)
-    await input.press("Enter")
-    await expect(item).toContainText(rename)
-  })
+  await project.open()
+
+  const { slug } = await setupWorkspaceTest(page, project)
+
+  const rename = `e2e workspace ${Date.now()}`
+  const menu = await openWorkspaceMenu(page, slug)
+  await clickMenuItem(menu, /^Rename$/i, { force: true })
+
+  await expect(menu).toHaveCount(0)
+
+  const item = page.locator(workspaceItemSelector(slug)).first()
+  await expect(item).toBeVisible()
+  const input = item.locator(inlineInputSelector).first()
+  const shown = await input
+    .isVisible()
+    .then((x) => x)
+    .catch(() => false)
+  if (!shown) {
+    const retry = await openWorkspaceMenu(page, slug)
+    await clickMenuItem(retry, /^Rename$/i, { force: true })
+    await expect(retry).toHaveCount(0)
+  }
+  await expect(input).toBeVisible()
+  await input.fill(rename)
+  await input.press("Enter")
+  await expect(item).toContainText(rename)
 })
 
-test("can reset a workspace", async ({ page, sdk, withProject }) => {
+test("can reset a workspace", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
+  await project.open()
 
-  await withProject(async (project) => {
-    const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
+  const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
 
-    const readme = path.join(createdDir, "README.md")
-    const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
-    const original = await fs.readFile(readme, "utf8")
-    const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
-    await fs.writeFile(readme, dirty, "utf8")
-    await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
+  const readme = path.join(createdDir, "README.md")
+  const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
+  const original = await fs.readFile(readme, "utf8")
+  const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
+  await fs.writeFile(readme, dirty, "utf8")
+  await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
 
-    await expect
-      .poll(async () => {
-        return await fs
-          .stat(extra)
-          .then(() => true)
-          .catch(() => false)
-      })
-      .toBe(true)
+  await expect
+    .poll(async () => {
+      return await fs
+        .stat(extra)
+        .then(() => true)
+        .catch(() => false)
+    })
+    .toBe(true)
 
-    await expect
-      .poll(async () => {
-        const files = await sdk.file
+  await expect
+    .poll(async () => {
+      const files = await project.sdk.file
+        .status({ directory: createdDir })
+        .then((r) => r.data ?? [])
+        .catch(() => [])
+      return files.length
+    })
+    .toBeGreaterThan(0)
+
+  const menu = await openWorkspaceMenu(page, slug)
+  await clickMenuItem(menu, /^Reset$/i, { force: true })
+  await confirmDialog(page, /^Reset workspace$/i)
+
+  await expect
+    .poll(
+      async () => {
+        const files = await project.sdk.file
           .status({ directory: createdDir })
           .then((r) => r.data ?? [])
           .catch(() => [])
         return files.length
-      })
-      .toBeGreaterThan(0)
-
-    const menu = await openWorkspaceMenu(page, slug)
-    await clickMenuItem(menu, /^Reset$/i, { force: true })
-    await confirmDialog(page, /^Reset workspace$/i)
-
-    await expect
-      .poll(
-        async () => {
-          const files = await sdk.file
-            .status({ directory: createdDir })
-            .then((r) => r.data ?? [])
-            .catch(() => [])
-          return files.length
-        },
-        { timeout: 60_000 },
-      )
-      .toBe(0)
+      },
+      { timeout: 120_000 },
+    )
+    .toBe(0)
 
-    await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
+  await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original)
 
-    await expect
-      .poll(async () => {
-        return await fs
-          .stat(extra)
-          .then(() => true)
-          .catch(() => false)
-      })
-      .toBe(false)
-  })
+  await expect
+    .poll(async () => {
+      return await fs
+        .stat(extra)
+        .then(() => true)
+        .catch(() => false)
+    })
+    .toBe(false)
 })
 
-test("can delete a workspace", async ({ page, withProject }) => {
+test("can reorder workspaces by drag and drop", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
+  await project.open()
+  const rootSlug = project.slug
 
-  await withProject(async (project) => {
-    const sdk = createSdk(project.directory)
-    const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
+  const listSlugs = async () => {
+    const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
+    const slugs = await nodes.evaluateAll((els) => {
+      return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
+    })
+    return slugs
+  }
 
+  const waitReady = async (slug: string) => {
     await expect
       .poll(
         async () => {
-          const worktrees = await sdk.worktree
-            .list()
-            .then((r) => r.data ?? [])
-            .catch(() => [] as string[])
-          return worktrees.includes(directory)
+          const item = page.locator(workspaceItemSelector(slug)).first()
+          try {
+            await item.hover({ timeout: 500 })
+            return true
+          } catch {
+            return false
+          }
         },
-        { timeout: 30_000 },
+        { timeout: 60_000 },
       )
       .toBe(true)
+  }
 
-    const menu = await openWorkspaceMenu(page, slug)
-    await clickMenuItem(menu, /^Delete$/i, { force: true })
-    await confirmDialog(page, /^Delete workspace$/i)
+  const drag = async (from: string, to: string) => {
+    const src = page.locator(workspaceItemSelector(from)).first()
+    const dst = page.locator(workspaceItemSelector(to)).first()
 
-    await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
+    const a = await src.boundingBox()
+    const b = await dst.boundingBox()
+    if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
 
-    await expect
-      .poll(
-        async () => {
-          const worktrees = await sdk.worktree
-            .list()
-            .then((r) => r.data ?? [])
-            .catch(() => [] as string[])
-          return worktrees.includes(directory)
-        },
-        { timeout: 60_000 },
-      )
-      .toBe(false)
+    await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
+    await page.mouse.down()
+    await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
+    await page.mouse.up()
+  }
+
+  await openSidebar(page)
 
-    await project.gotoSession()
+  await setWorkspacesEnabled(page, rootSlug, true)
+
+  const workspaces = [] as { directory: string; slug: string }[]
+  for (const _ of [0, 1]) {
+    const prev = slugFromUrl(page.url())
+    await page.getByRole("button", { name: "New workspace" }).first().click()
+    const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
+    await waitDir(page, next.directory)
+    project.trackDirectory(next.directory)
+    workspaces.push(next)
 
     await openSidebar(page)
-    await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
-    await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
-  })
+  }
+
+  if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
+
+  const a = workspaces[0].slug
+  const b = workspaces[1].slug
+
+  await waitReady(a)
+  await waitReady(b)
+
+  const list = async () => {
+    const slugs = await listSlugs()
+    return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
+  }
+
+  await expect
+    .poll(async () => {
+      const slugs = await list()
+      return slugs.length === 2
+    })
+    .toBe(true)
+
+  const before = await list()
+  const from = before[1]
+  const to = before[0]
+  if (!from || !to) throw new Error("Failed to resolve initial workspace order")
+
+  await drag(from, to)
+
+  await expect.poll(async () => await list()).toEqual([from, to])
 })
 
-test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
+test("can delete a workspace", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
-  await withProject(async ({ slug: rootSlug }) => {
-    const workspaces = [] as { directory: string; slug: string }[]
-
-    const listSlugs = async () => {
-      const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
-      const slugs = await nodes.evaluateAll((els) => {
-        return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
-      })
-      return slugs
-    }
-
-    const waitReady = async (slug: string) => {
-      await expect
-        .poll(
-          async () => {
-            const item = page.locator(workspaceItemSelector(slug)).first()
-            try {
-              await item.hover({ timeout: 500 })
-              return true
-            } catch {
-              return false
-            }
-          },
-          { timeout: 60_000 },
-        )
-        .toBe(true)
-    }
-
-    const drag = async (from: string, to: string) => {
-      const src = page.locator(workspaceItemSelector(from)).first()
-      const dst = page.locator(workspaceItemSelector(to)).first()
-
-      const a = await src.boundingBox()
-      const b = await dst.boundingBox()
-      if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
-
-      await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
-      await page.mouse.down()
-      await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
-      await page.mouse.up()
-    }
-
-    try {
-      await openSidebar(page)
-
-      await setWorkspacesEnabled(page, rootSlug, true)
-
-      for (const _ of [0, 1]) {
-        const prev = slugFromUrl(page.url())
-        await page.getByRole("button", { name: "New workspace" }).first().click()
-        const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
-        await waitDir(page, next.directory)
-        workspaces.push(next)
-
-        await openSidebar(page)
-      }
-
-      if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
-
-      const a = workspaces[0].slug
-      const b = workspaces[1].slug
-
-      await waitReady(a)
-      await waitReady(b)
-
-      const list = async () => {
-        const slugs = await listSlugs()
-        return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
-      }
-
-      await expect
-        .poll(async () => {
-          const slugs = await list()
-          return slugs.length === 2
-        })
-        .toBe(true)
-
-      const before = await list()
-      const from = before[1]
-      const to = before[0]
-      if (!from || !to) throw new Error("Failed to resolve initial workspace order")
-
-      await drag(from, to)
-
-      await expect.poll(async () => await list()).toEqual([from, to])
-    } finally {
-      await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
-    }
-  })
+  await project.open()
+
+  const rootSlug = project.slug
+  await openSidebar(page)
+  await setWorkspacesEnabled(page, rootSlug, true)
+
+  const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
+  if (!created?.directory) throw new Error("Failed to create workspace for delete test")
+
+  const directory = created.directory
+  const slug = dirSlug(directory)
+  project.trackDirectory(directory)
+
+  await page.reload()
+  await openSidebar(page)
+  await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
+
+  await expect
+    .poll(
+      async () => {
+        const worktrees = await project.sdk.worktree
+          .list()
+          .then((r) => r.data ?? [])
+          .catch(() => [] as string[])
+        return worktrees.includes(directory)
+      },
+      { timeout: 30_000 },
+    )
+    .toBe(true)
+
+  const menu = await openWorkspaceMenu(page, slug)
+  await clickMenuItem(menu, /^Delete$/i, { force: true })
+  await confirmDialog(page, /^Delete workspace$/i)
+
+  await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
+
+  await expect
+    .poll(
+      async () => {
+        const worktrees = await project.sdk.worktree
+          .list()
+          .then((r) => r.data ?? [])
+          .catch(() => [] as string[])
+        return worktrees.includes(directory)
+      },
+      { timeout: 60_000 },
+    )
+    .toBe(false)
+
+  await openSidebar(page)
+  await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
+  await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
 })

+ 0 - 41
packages/app/e2e/prompt/mock.ts

@@ -1,21 +1,9 @@
-import { createSdk } from "../utils"
-
-export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
-
 type Hit = { body: Record<string, unknown> }
 
 export function bodyText(hit: Hit) {
   return JSON.stringify(hit.body)
 }
 
-export function titleMatch(hit: Hit) {
-  return bodyText(hit).includes("Generate a title for this conversation")
-}
-
-export function promptMatch(token: string) {
-  return (hit: Hit) => bodyText(hit).includes(token)
-}
-
 /**
  * Match requests whose body contains the exact serialized tool input.
  * The seed prompts embed JSON.stringify(input) in the prompt text, which
@@ -25,32 +13,3 @@ export function inputMatch(input: unknown) {
   const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
   return (hit: Hit) => bodyText(hit).includes(escaped)
 }
-
-export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
-  const sdk = createSdk(undefined, input.serverUrl)
-  const prev = await sdk.global.config.get().then((res) => res.data ?? {})
-
-  try {
-    await sdk.global.config.update({
-      config: {
-        ...prev,
-        model: `${openaiModel.providerID}/${openaiModel.modelID}`,
-        enabled_providers: ["openai"],
-        provider: {
-          ...prev.provider,
-          openai: {
-            ...prev.provider?.openai,
-            options: {
-              ...prev.provider?.openai?.options,
-              apiKey: "test-key",
-              baseURL: input.llmUrl,
-            },
-          },
-        },
-      },
-    })
-    return await input.fn()
-  } finally {
-    await sdk.global.config.update({ config: prev })
-  }
-}

+ 8 - 35
packages/app/e2e/prompt/prompt-async.spec.ts

@@ -1,52 +1,25 @@
 import { test, expect } from "../fixtures"
 import { promptSelector } from "../selectors"
-import { assistantText, sessionIDFromUrl, withSession } from "../actions"
-import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
+import { assistantText, withSession } from "../actions"
 
 const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
 
 // Regression test for Issue #12453: the synchronous POST /message endpoint holds
 // the connection open while the agent works, causing "Failed to fetch" over
 // VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
-test("prompt succeeds when sync message endpoint is unreachable", async ({
-  page,
-  llm,
-  backend,
-  withBackendProject,
-}) => {
+test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => {
   test.setTimeout(120_000)
 
   // Simulate Tailscale/VPN killing the long-lived sync connection
   await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
 
-  await withMockOpenAI({
-    serverUrl: backend.url,
-    llmUrl: llm.url,
-    fn: async () => {
-      const token = `E2E_ASYNC_${Date.now()}`
-      await llm.textMatch(titleMatch, "E2E Title")
-      await llm.textMatch(promptMatch(token), token)
+  const token = `E2E_ASYNC_${Date.now()}`
+  await project.open()
+  await assistant.reply(token)
+  const sessionID = await project.prompt(`Reply with exactly: ${token}`)
 
-      await withBackendProject(
-        async (project) => {
-          await page.locator(promptSelector).click()
-          await page.keyboard.type(`Reply with exactly: ${token}`)
-          await page.keyboard.press("Enter")
-
-          await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
-          const sessionID = sessionIDFromUrl(page.url())!
-          project.trackSession(sessionID)
-
-          await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
-
-          await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
-        },
-        {
-          model: openaiModel,
-        },
-      )
-    },
-  })
+  await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
+  await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
 })
 
 test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {

+ 38 - 67
packages/app/e2e/prompt/prompt-history.spec.ts

@@ -1,10 +1,9 @@
 import type { ToolPart } from "@opencode-ai/sdk/v2/client"
 import type { Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
-import { assistantText, sessionIDFromUrl } from "../actions"
+import { assistantText } from "../actions"
 import { promptSelector } from "../selectors"
 import { createSdk } from "../utils"
-import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
 
 const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
 type Sdk = ReturnType<typeof createSdk>
@@ -43,73 +42,45 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
     .toContain(token)
 }
 
-test("prompt history restores unsent draft with arrow navigation", async ({
-  page,
-  llm,
-  backend,
-  withBackendProject,
-}) => {
+test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => {
   test.setTimeout(120_000)
 
-  await withMockOpenAI({
-    serverUrl: backend.url,
-    llmUrl: llm.url,
-    fn: async () => {
-      const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
-      const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
-      const first = `Reply with exactly: ${firstToken}`
-      const second = `Reply with exactly: ${secondToken}`
-      const draft = `draft ${Date.now()}`
-
-      await llm.textMatch(titleMatch, "E2E Title")
-      await llm.textMatch(promptMatch(firstToken), firstToken)
-      await llm.textMatch(promptMatch(secondToken), secondToken)
-
-      await withBackendProject(
-        async (project) => {
-          const prompt = page.locator(promptSelector)
-
-          await prompt.click()
-          await page.keyboard.type(first)
-          await page.keyboard.press("Enter")
-          await wait(page, "")
-
-          await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
-          const sessionID = sessionIDFromUrl(page.url())!
-          project.trackSession(sessionID)
-          await reply(project.sdk, sessionID, firstToken)
-
-          await prompt.click()
-          await page.keyboard.type(second)
-          await page.keyboard.press("Enter")
-          await wait(page, "")
-          await reply(project.sdk, sessionID, secondToken)
-
-          await prompt.click()
-          await page.keyboard.type(draft)
-          await wait(page, draft)
-
-          await prompt.fill("")
-          await wait(page, "")
-
-          await page.keyboard.press("ArrowUp")
-          await wait(page, second)
-
-          await page.keyboard.press("ArrowUp")
-          await wait(page, first)
-
-          await page.keyboard.press("ArrowDown")
-          await wait(page, second)
-
-          await page.keyboard.press("ArrowDown")
-          await wait(page, "")
-        },
-        {
-          model: openaiModel,
-        },
-      )
-    },
-  })
+  const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
+  const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
+  const first = `Reply with exactly: ${firstToken}`
+  const second = `Reply with exactly: ${secondToken}`
+  const draft = `draft ${Date.now()}`
+
+  await project.open()
+  await assistant.reply(firstToken)
+  const sessionID = await project.prompt(first)
+  await wait(page, "")
+  await reply(project.sdk, sessionID, firstToken)
+
+  await assistant.reply(secondToken)
+  await project.prompt(second)
+  await wait(page, "")
+  await reply(project.sdk, sessionID, secondToken)
+
+  const prompt = page.locator(promptSelector)
+  await prompt.click()
+  await page.keyboard.type(draft)
+  await wait(page, draft)
+
+  await prompt.fill("")
+  await wait(page, "")
+
+  await page.keyboard.press("ArrowUp")
+  await wait(page, second)
+
+  await page.keyboard.press("ArrowUp")
+  await wait(page, first)
+
+  await page.keyboard.press("ArrowDown")
+  await wait(page, second)
+
+  await page.keyboard.press("ArrowDown")
+  await wait(page, "")
 })
 
 test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {

+ 24 - 25
packages/app/e2e/prompt/prompt-shell.spec.ts

@@ -1,7 +1,6 @@
 import type { ToolPart } from "@opencode-ai/sdk/v2/client"
 import { test, expect } from "../fixtures"
-import { sessionIDFromUrl } from "../actions"
-import { promptSelector } from "../selectors"
+import { withSession } from "../actions"
 
 const isBash = (part: unknown): part is ToolPart => {
   if (!part || typeof part !== "object") return false
@@ -10,33 +9,35 @@ const isBash = (part: unknown): part is ToolPart => {
   return "state" in part
 }
 
-test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
-  test.setTimeout(120_000)
-
-  await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
-    const prompt = page.locator(promptSelector)
-    const cmd = process.platform === "win32" ? "dir" : "command ls"
-
-    await gotoSession()
-    await prompt.click()
-    await page.keyboard.type("!")
-    await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
+async function setAutoAccept(page: Parameters<typeof test>[0]["page"], enabled: boolean) {
+  const button = page.locator('[data-action="prompt-permissions"]').first()
+  await expect(button).toBeVisible()
+  const pressed = (await button.getAttribute("aria-pressed")) === "true"
+  if (pressed === enabled) return
+  await button.click()
+  await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
+}
 
-    await page.keyboard.type(cmd)
-    await page.keyboard.press("Enter")
+test("shell mode runs a command in the project directory", async ({ page, project }) => {
+  test.setTimeout(120_000)
 
-    await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
+  await project.open()
+  const cmd = process.platform === "win32" ? "dir" : "command ls"
 
-    const id = sessionIDFromUrl(page.url())
-    if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
-    trackSession(id, directory)
+  await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
+    project.trackSession(session.id)
+    await project.gotoSession(session.id)
+    await setAutoAccept(page, true)
+    await project.shell(cmd)
 
     await expect
       .poll(
         async () => {
-          const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
+          const list = await project.sdk.session
+            .messages({ sessionID: session.id, limit: 50 })
+            .then((x) => x.data ?? [])
           const msg = list.findLast(
-            (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
+            (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory,
           )
           if (!msg) return
 
@@ -49,12 +50,10 @@ test("shell mode runs a command in the project directory", async ({ page, withBa
             typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
           if (!output.includes("README.md")) return
 
-          return { cwd: directory, output }
+          return { cwd: project.directory, output }
         },
         { timeout: 90_000 },
       )
-      .toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
-
-    await expect(prompt).toHaveText("")
+      .toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") }))
   })
 })

+ 33 - 34
packages/app/e2e/prompt/prompt-slash-share.spec.ts

@@ -22,46 +22,45 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
     .toBeGreaterThan(0)
 }
 
-test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
+test("/share and /unshare update session share state", async ({ page, project }) => {
   test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
 
-  await withBackendProject(async (project) => {
-    await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
-      project.trackSession(session.id)
-      const prompt = page.locator(promptSelector)
+  await project.open()
+  await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
+    project.trackSession(session.id)
+    const prompt = page.locator(promptSelector)
 
-      await seed(project.sdk, session.id)
-      await project.gotoSession(session.id)
+    await seed(project.sdk, session.id)
+    await project.gotoSession(session.id)
 
-      await prompt.click()
-      await page.keyboard.type("/share")
-      await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
-      await page.keyboard.press("Enter")
+    await prompt.click()
+    await page.keyboard.type("/share")
+    await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
+    await page.keyboard.press("Enter")
 
-      await expect
-        .poll(
-          async () => {
-            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-            return data?.share?.url || undefined
-          },
-          { timeout: 30_000 },
-        )
-        .not.toBeUndefined()
+    await expect
+      .poll(
+        async () => {
+          const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.share?.url || undefined
+        },
+        { timeout: 30_000 },
+      )
+      .not.toBeUndefined()
 
-      await prompt.click()
-      await page.keyboard.type("/unshare")
-      await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
-      await page.keyboard.press("Enter")
+    await prompt.click()
+    await page.keyboard.type("/unshare")
+    await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
+    await page.keyboard.press("Enter")
 
-      await expect
-        .poll(
-          async () => {
-            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-            return data?.share?.url || undefined
-          },
-          { timeout: 30_000 },
-        )
-        .toBeUndefined()
-    })
+    await expect
+      .poll(
+        async () => {
+          const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.share?.url || undefined
+        },
+        { timeout: 30_000 },
+      )
+      .toBeUndefined()
   })
 })

+ 8 - 38
packages/app/e2e/prompt/prompt.spec.ts

@@ -1,9 +1,7 @@
 import { test, expect } from "../fixtures"
-import { promptSelector } from "../selectors"
-import { assistantText, sessionIDFromUrl } from "../actions"
-import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
+import { assistantText } from "../actions"
 
-test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
+test("can send a prompt and receive a reply", async ({ page, project, assistant }) => {
   test.setTimeout(120_000)
 
   const pageErrors: string[] = []
@@ -13,41 +11,13 @@ test("can send a prompt and receive a reply", async ({ page, llm, backend, withB
   page.on("pageerror", onPageError)
 
   try {
-    await withMockOpenAI({
-      serverUrl: backend.url,
-      llmUrl: llm.url,
-      fn: async () => {
-        const token = `E2E_OK_${Date.now()}`
+    const token = `E2E_OK_${Date.now()}`
+    await project.open()
+    await assistant.reply(token)
+    const sessionID = await project.prompt(`Reply with exactly: ${token}`)
 
-        await llm.textMatch(titleMatch, "E2E Title")
-        await llm.textMatch(promptMatch(token), token)
-
-        await withBackendProject(
-          async (project) => {
-            const prompt = page.locator(promptSelector)
-            await prompt.click()
-            await page.keyboard.type(`Reply with exactly: ${token}`)
-            await page.keyboard.press("Enter")
-
-            await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
-
-            const sessionID = (() => {
-              const id = sessionIDFromUrl(page.url())
-              if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
-              return id
-            })()
-            project.trackSession(sessionID)
-
-            await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
-
-            await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
-          },
-          {
-            model: openaiModel,
-          },
-        )
-      },
-    })
+    await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
+    await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
   } finally {
     page.off("pageerror", onPageError)
   }

+ 0 - 11
packages/app/e2e/selectors.ts

@@ -4,13 +4,7 @@ export const terminalSelector = `${terminalPanelSelector} [data-component="termi
 export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
 export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
 export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
-export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
-export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
-export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
-export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
-export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
 export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
-export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
 
 export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
 export const promptAgentSelector = '[data-component="prompt-agent-control"]'
@@ -40,9 +34,6 @@ export const projectMenuTriggerSelector = (slug: string) =>
 
 export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
 
-export const projectClearNotificationsSelector = (slug: string) =>
-  `[data-action="project-clear-notifications"][data-project="${slug}"]`
-
 export const projectWorkspacesToggleSelector = (slug: string) =>
   `[data-action="project-workspaces-toggle"][data-project="${slug}"]`
 
@@ -50,8 +41,6 @@ export const titlebarRightSelector = "#opencode-titlebar-right"
 
 export const popoverBodySelector = '[data-slot="popover-body"]'
 
-export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
-
 export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
 
 export const inlineInputSelector = '[data-component="inline-input"]'

+ 25 - 26
packages/app/e2e/session/session-child-navigation.spec.ts

@@ -3,7 +3,7 @@ import { test, expect } from "../fixtures"
 import { inputMatch } from "../prompt/mock"
 import { promptSelector } from "../selectors"
 
-test("task tool child-session link does not trigger stale show errors", async ({ page, llm, withMockProject }) => {
+test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
   test.setTimeout(120_000)
 
   const errs: string[] = []
@@ -13,34 +13,33 @@ test("task tool child-session link does not trigger stale show errors", async ({
   page.on("pageerror", onError)
 
   try {
-    await withMockProject(async ({ gotoSession, trackSession, sdk }) => {
-      await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
-        const taskInput = {
-          description: "Open child session",
-          prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
-          subagent_type: "general",
-        }
-        await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
-        const child = await seedSessionTask(sdk, {
-          sessionID: session.id,
-          description: taskInput.description,
-          prompt: taskInput.prompt,
-        })
-        trackSession(child.sessionID)
+    await project.open()
+    await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => {
+      const taskInput = {
+        description: "Open child session",
+        prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
+        subagent_type: "general",
+      }
+      await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
+      const child = await seedSessionTask(project.sdk, {
+        sessionID: session.id,
+        description: taskInput.description,
+        prompt: taskInput.prompt,
+      })
+      project.trackSession(child.sessionID)
 
-        await gotoSession(session.id)
+      await project.gotoSession(session.id)
 
-        const link = page
-          .locator("a.subagent-link")
-          .filter({ hasText: /open child session/i })
-          .first()
-        await expect(link).toBeVisible({ timeout: 30_000 })
-        await link.click()
+      const link = page
+        .locator("a.subagent-link")
+        .filter({ hasText: /open child session/i })
+        .first()
+      await expect(link).toBeVisible({ timeout: 30_000 })
+      await link.click()
 
-        await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
-        await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
-        await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
-      })
+      await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
+      await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
+      await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
     })
   } finally {
     page.off("pageerror", onError)

+ 324 - 346
packages/app/e2e/session/session-composer-dock.spec.ts

@@ -242,9 +242,7 @@ async function withMockPermission<T>(
         const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
         if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
         await route.fulfill({
-          status: res.status(),
-          headers: res.headers(),
-          contentType: "application/json",
+          response: res,
           body: JSON.stringify(json),
         })
       }
@@ -269,240 +267,227 @@ async function withMockPermission<T>(
   }
 }
 
-test("default dock shows prompt input", async ({ page, withBackendProject }) => {
-  await withBackendProject(async (project) => {
-    await withDockSession(
-      project.sdk,
-      "e2e composer dock default",
-      async (session) => {
-        await project.gotoSession(session.id)
-
-        await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
-        await expect(page.locator(promptSelector)).toBeVisible()
-        await expect(page.locator(questionDockSelector)).toHaveCount(0)
-        await expect(page.locator(permissionDockSelector)).toHaveCount(0)
-
-        await page.locator(promptSelector).click()
-        await expect(page.locator(promptSelector)).toBeFocused()
-      },
-      { trackSession: project.trackSession },
-    )
-  })
+test("default dock shows prompt input", async ({ page, project }) => {
+  await project.open()
+  await withDockSession(
+    project.sdk,
+    "e2e composer dock default",
+    async (session) => {
+      await project.gotoSession(session.id)
+
+      await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
+      await expect(page.locator(promptSelector)).toBeVisible()
+      await expect(page.locator(questionDockSelector)).toHaveCount(0)
+      await expect(page.locator(permissionDockSelector)).toHaveCount(0)
+
+      await page.locator(promptSelector).click()
+      await expect(page.locator(promptSelector)).toBeFocused()
+    },
+    { trackSession: project.trackSession },
+  )
 })
 
-test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
-  await withBackendProject(async ({ gotoSession }) => {
-    await gotoSession()
+test("auto-accept toggle works before first submit", async ({ page, project }) => {
+  await project.open()
 
-    const button = page.locator('[data-action="prompt-permissions"]').first()
-    await expect(button).toBeVisible()
-    await expect(button).toHaveAttribute("aria-pressed", "false")
+  const button = page.locator('[data-action="prompt-permissions"]').first()
+  await expect(button).toBeVisible()
+  await expect(button).toHaveAttribute("aria-pressed", "false")
 
-    await setAutoAccept(page, true)
-    await setAutoAccept(page, false)
-  })
+  await setAutoAccept(page, true)
+  await setAutoAccept(page, false)
 })
 
-test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => {
-  await withMockProject(async (project) => {
-    await withDockSession(
-      project.sdk,
-      "e2e composer dock question",
-      async (session) => {
-        await withDockSeed(project.sdk, session.id, async () => {
-          await project.gotoSession(session.id)
+test("blocked question flow unblocks after submit", async ({ page, llm, project }) => {
+  await project.open()
+  await withDockSession(
+    project.sdk,
+    "e2e composer dock question",
+    async (session) => {
+      await withDockSeed(project.sdk, session.id, async () => {
+        await project.gotoSession(session.id)
 
-          await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
-          await seedSessionQuestion(project.sdk, {
-            sessionID: session.id,
-            questions: defaultQuestions,
-          })
+        await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
+        await seedSessionQuestion(project.sdk, {
+          sessionID: session.id,
+          questions: defaultQuestions,
+        })
 
-          const dock = page.locator(questionDockSelector)
-          await expectQuestionBlocked(page)
+        const dock = page.locator(questionDockSelector)
+        await expectQuestionBlocked(page)
 
-          await dock.locator('[data-slot="question-option"]').first().click()
-          await dock.getByRole("button", { name: /submit/i }).click()
+        await dock.locator('[data-slot="question-option"]').first().click()
+        await dock.getByRole("button", { name: /submit/i }).click()
 
-          await expectQuestionOpen(page)
-        })
-      },
-      { trackSession: project.trackSession },
-    )
-  })
+        await expectQuestionOpen(page)
+      })
+    },
+    { trackSession: project.trackSession },
+  )
 })
 
-test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => {
-  await withMockProject(async (project) => {
-    await withDockSession(
-      project.sdk,
-      "e2e composer dock question keyboard",
-      async (session) => {
-        await withDockSeed(project.sdk, session.id, async () => {
-          await project.gotoSession(session.id)
+test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => {
+  await project.open()
+  await withDockSession(
+    project.sdk,
+    "e2e composer dock question keyboard",
+    async (session) => {
+      await withDockSeed(project.sdk, session.id, async () => {
+        await project.gotoSession(session.id)
 
-          await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
-          await seedSessionQuestion(project.sdk, {
-            sessionID: session.id,
-            questions: defaultQuestions,
-          })
+        await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
+        await seedSessionQuestion(project.sdk, {
+          sessionID: session.id,
+          questions: defaultQuestions,
+        })
 
-          const dock = page.locator(questionDockSelector)
-          const first = dock.locator('[data-slot="question-option"]').first()
-          const second = dock.locator('[data-slot="question-option"]').nth(1)
+        const dock = page.locator(questionDockSelector)
+        const first = dock.locator('[data-slot="question-option"]').first()
+        const second = dock.locator('[data-slot="question-option"]').nth(1)
 
-          await expectQuestionBlocked(page)
-          await expect(first).toBeFocused()
+        await expectQuestionBlocked(page)
+        await expect(first).toBeFocused()
 
-          await page.keyboard.press("ArrowDown")
-          await expect(second).toBeFocused()
+        await page.keyboard.press("ArrowDown")
+        await expect(second).toBeFocused()
 
-          await page.keyboard.press("Space")
-          await page.keyboard.press(`${modKey}+Enter`)
-          await expectQuestionOpen(page)
-        })
-      },
-      { trackSession: project.trackSession },
-    )
-  })
+        await page.keyboard.press("Space")
+        await page.keyboard.press(`${modKey}+Enter`)
+        await expectQuestionOpen(page)
+      })
+    },
+    { trackSession: project.trackSession },
+  )
 })
 
-test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => {
-  await withMockProject(async (project) => {
-    await withDockSession(
-      project.sdk,
-      "e2e composer dock question escape",
-      async (session) => {
-        await withDockSeed(project.sdk, session.id, async () => {
-          await project.gotoSession(session.id)
+test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => {
+  await project.open()
+  await withDockSession(
+    project.sdk,
+    "e2e composer dock question escape",
+    async (session) => {
+      await withDockSeed(project.sdk, session.id, async () => {
+        await project.gotoSession(session.id)
 
-          await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
-          await seedSessionQuestion(project.sdk, {
-            sessionID: session.id,
-            questions: defaultQuestions,
-          })
+        await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
+        await seedSessionQuestion(project.sdk, {
+          sessionID: session.id,
+          questions: defaultQuestions,
+        })
 
-          const dock = page.locator(questionDockSelector)
-          const first = dock.locator('[data-slot="question-option"]').first()
+        const dock = page.locator(questionDockSelector)
+        const first = dock.locator('[data-slot="question-option"]').first()
 
-          await expectQuestionBlocked(page)
-          await expect(first).toBeFocused()
+        await expectQuestionBlocked(page)
+        await expect(first).toBeFocused()
 
-          await page.keyboard.press("Escape")
-          await expectQuestionOpen(page)
-        })
-      },
-      { trackSession: project.trackSession },
-    )
-  })
+        await page.keyboard.press("Escape")
+        await expectQuestionOpen(page)
+      })
+    },
+    { trackSession: project.trackSession },
+  )
 })
 
-test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
-  await withBackendProject(async (project) => {
-    await withDockSession(
-      project.sdk,
-      "e2e composer dock permission once",
-      async (session) => {
-        await project.gotoSession(session.id)
-        await setAutoAccept(page, false)
-        await withMockPermission(
-          page,
-          {
-            id: "per_e2e_once",
-            sessionID: session.id,
-            permission: "bash",
-            patterns: ["/tmp/opencode-e2e-perm-once"],
-            metadata: { description: "Need permission for command" },
-          },
-          undefined,
-          async (state) => {
-            await page.goto(page.url())
-            await expectPermissionBlocked(page)
-
-            await clearPermissionDock(page, /allow once/i)
-            await state.resolved()
-            await page.goto(page.url())
-            await expectPermissionOpen(page)
-          },
-        )
-      },
-      { trackSession: project.trackSession },
-    )
-  })
+test("blocked permission flow supports allow once", async ({ page, project }) => {
+  await project.open()
+  await withDockSession(
+    project.sdk,
+    "e2e composer dock permission once",
+    async (session) => {
+      await project.gotoSession(session.id)
+      await setAutoAccept(page, false)
+      await withMockPermission(
+        page,
+        {
+          id: "per_e2e_once",
+          sessionID: session.id,
+          permission: "bash",
+          patterns: ["/tmp/opencode-e2e-perm-once"],
+          metadata: { description: "Need permission for command" },
+        },
+        undefined,
+        async (state) => {
+          await page.goto(page.url())
+          await expectPermissionBlocked(page)
+
+          await clearPermissionDock(page, /allow once/i)
+          await state.resolved()
+          await page.goto(page.url())
+          await expectPermissionOpen(page)
+        },
+      )
+    },
+    { trackSession: project.trackSession },
+  )
 })
 
-test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
-  await withBackendProject(async (project) => {
-    await withDockSession(
-      project.sdk,
-      "e2e composer dock permission reject",
-      async (session) => {
-        await project.gotoSession(session.id)
-        await setAutoAccept(page, false)
-        await withMockPermission(
-          page,
-          {
-            id: "per_e2e_reject",
-            sessionID: session.id,
-            permission: "bash",
-            patterns: ["/tmp/opencode-e2e-perm-reject"],
-          },
-          undefined,
-          async (state) => {
-            await page.goto(page.url())
-            await expectPermissionBlocked(page)
-
-            await clearPermissionDock(page, /deny/i)
-            await state.resolved()
-            await page.goto(page.url())
-            await expectPermissionOpen(page)
-          },
-        )
-      },
-      { trackSession: project.trackSession },
-    )
-  })
+test("blocked permission flow supports reject", async ({ page, project }) => {
+  await project.open()
+  await withDockSession(
+    project.sdk,
+    "e2e composer dock permission reject",
+    async (session) => {
+      await project.gotoSession(session.id)
+      await setAutoAccept(page, false)
+      await withMockPermission(
+        page,
+        {
+          id: "per_e2e_reject",
+          sessionID: session.id,
+          permission: "bash",
+          patterns: ["/tmp/opencode-e2e-perm-reject"],
+        },
+        undefined,
+        async (state) => {
+          await page.goto(page.url())
+          await expectPermissionBlocked(page)
+
+          await clearPermissionDock(page, /deny/i)
+          await state.resolved()
+          await page.goto(page.url())
+          await expectPermissionOpen(page)
+        },
+      )
+    },
+    { trackSession: project.trackSession },
+  )
 })
 
-test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
-  await withBackendProject(async (project) => {
-    await withDockSession(
-      project.sdk,
-      "e2e composer dock permission always",
-      async (session) => {
-        await project.gotoSession(session.id)
-        await setAutoAccept(page, false)
-        await withMockPermission(
-          page,
-          {
-            id: "per_e2e_always",
-            sessionID: session.id,
-            permission: "bash",
-            patterns: ["/tmp/opencode-e2e-perm-always"],
-            metadata: { description: "Need permission for command" },
-          },
-          undefined,
-          async (state) => {
-            await page.goto(page.url())
-            await expectPermissionBlocked(page)
-
-            await clearPermissionDock(page, /allow always/i)
-            await state.resolved()
-            await page.goto(page.url())
-            await expectPermissionOpen(page)
-          },
-        )
-      },
-      { trackSession: project.trackSession },
-    )
-  })
+test("blocked permission flow supports allow always", async ({ page, project }) => {
+  await project.open()
+  await withDockSession(
+    project.sdk,
+    "e2e composer dock permission always",
+    async (session) => {
+      await project.gotoSession(session.id)
+      await setAutoAccept(page, false)
+      await withMockPermission(
+        page,
+        {
+          id: "per_e2e_always",
+          sessionID: session.id,
+          permission: "bash",
+          patterns: ["/tmp/opencode-e2e-perm-always"],
+          metadata: { description: "Need permission for command" },
+        },
+        undefined,
+        async (state) => {
+          await page.goto(page.url())
+          await expectPermissionBlocked(page)
+
+          await clearPermissionDock(page, /allow always/i)
+          await state.resolved()
+          await page.goto(page.url())
+          await expectPermissionOpen(page)
+        },
+      )
+    },
+    { trackSession: project.trackSession },
+  )
 })
 
-test("child session question request blocks parent dock and unblocks after submit", async ({
-  page,
-  llm,
-  withMockProject,
-}) => {
+test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => {
   const questions = [
     {
       header: "Child input",
@@ -513,137 +498,131 @@ test("child session question request blocks parent dock and unblocks after submi
       ],
     },
   ]
-  await withMockProject(async (project) => {
-    await withDockSession(
-      project.sdk,
-      "e2e composer dock child question parent",
-      async (session) => {
-        await project.gotoSession(session.id)
+  await project.open()
+  await withDockSession(
+    project.sdk,
+    "e2e composer dock child question parent",
+    async (session) => {
+      await project.gotoSession(session.id)
+
+      const child = await project.sdk.session
+        .create({
+          title: "e2e composer dock child question",
+          parentID: session.id,
+        })
+        .then((r) => r.data)
+      if (!child?.id) throw new Error("Child session create did not return an id")
+      project.trackSession(child.id)
 
-        const child = await project.sdk.session
-          .create({
-            title: "e2e composer dock child question",
-            parentID: session.id,
+      try {
+        await withDockSeed(project.sdk, child.id, async () => {
+          await llm.toolMatch(inputMatch({ questions }), "question", { questions })
+          await seedSessionQuestion(project.sdk, {
+            sessionID: child.id,
+            questions,
           })
-          .then((r) => r.data)
-        if (!child?.id) throw new Error("Child session create did not return an id")
-        project.trackSession(child.id)
-
-        try {
-          await withDockSeed(project.sdk, child.id, async () => {
-            await llm.toolMatch(inputMatch({ questions }), "question", { questions })
-            await seedSessionQuestion(project.sdk, {
-              sessionID: child.id,
-              questions,
-            })
 
-            const dock = page.locator(questionDockSelector)
-            await expectQuestionBlocked(page)
+          const dock = page.locator(questionDockSelector)
+          await expectQuestionBlocked(page)
 
-            await dock.locator('[data-slot="question-option"]').first().click()
-            await dock.getByRole("button", { name: /submit/i }).click()
+          await dock.locator('[data-slot="question-option"]').first().click()
+          await dock.getByRole("button", { name: /submit/i }).click()
 
-            await expectQuestionOpen(page)
-          })
-        } finally {
-          await cleanupSession({ sdk: project.sdk, sessionID: child.id })
-        }
-      },
-      { trackSession: project.trackSession },
-    )
-  })
+          await expectQuestionOpen(page)
+        })
+      } finally {
+        await cleanupSession({ sdk: project.sdk, sessionID: child.id })
+      }
+    },
+    { trackSession: project.trackSession },
+  )
 })
 
-test("child session permission request blocks parent dock and supports allow once", async ({
-  page,
-  withBackendProject,
-}) => {
-  await withBackendProject(async (project) => {
-    await withDockSession(
-      project.sdk,
-      "e2e composer dock child permission parent",
-      async (session) => {
-        await project.gotoSession(session.id)
-        await setAutoAccept(page, false)
+test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => {
+  await project.open()
+  await withDockSession(
+    project.sdk,
+    "e2e composer dock child permission parent",
+    async (session) => {
+      await project.gotoSession(session.id)
+      await setAutoAccept(page, false)
+
+      const child = await project.sdk.session
+        .create({
+          title: "e2e composer dock child permission",
+          parentID: session.id,
+        })
+        .then((r) => r.data)
+      if (!child?.id) throw new Error("Child session create did not return an id")
+      project.trackSession(child.id)
 
-        const child = await project.sdk.session
-          .create({
-            title: "e2e composer dock child permission",
-            parentID: session.id,
-          })
-          .then((r) => r.data)
-        if (!child?.id) throw new Error("Child session create did not return an id")
-        project.trackSession(child.id)
-
-        try {
-          await withMockPermission(
-            page,
-            {
-              id: "per_e2e_child",
-              sessionID: child.id,
-              permission: "bash",
-              patterns: ["/tmp/opencode-e2e-perm-child"],
-              metadata: { description: "Need child permission" },
-            },
-            { child },
-            async (state) => {
-              await page.goto(page.url())
-              await expectPermissionBlocked(page)
-
-              await clearPermissionDock(page, /allow once/i)
-              await state.resolved()
-              await page.goto(page.url())
-
-              await expectPermissionOpen(page)
-            },
-          )
-        } finally {
-          await cleanupSession({ sdk: project.sdk, sessionID: child.id })
-        }
-      },
-      { trackSession: project.trackSession },
-    )
-  })
+      try {
+        await withMockPermission(
+          page,
+          {
+            id: "per_e2e_child",
+            sessionID: child.id,
+            permission: "bash",
+            patterns: ["/tmp/opencode-e2e-perm-child"],
+            metadata: { description: "Need child permission" },
+          },
+          { child },
+          async (state) => {
+            await page.goto(page.url())
+            await expectPermissionBlocked(page)
+
+            await clearPermissionDock(page, /allow once/i)
+            await state.resolved()
+            await page.goto(page.url())
+
+            await expectPermissionOpen(page)
+          },
+        )
+      } finally {
+        await cleanupSession({ sdk: project.sdk, sessionID: child.id })
+      }
+    },
+    { trackSession: project.trackSession },
+  )
 })
 
-test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
-  await withBackendProject(async (project) => {
-    await withDockSession(
-      project.sdk,
-      "e2e composer dock todo",
-      async (session) => {
-        const dock = await todoDock(page, session.id)
-        await project.gotoSession(session.id)
-        await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
-
-        try {
-          await dock.open([
-            { content: "first task", status: "pending", priority: "high" },
-            { content: "second task", status: "in_progress", priority: "medium" },
-          ])
-          await dock.expectOpen(["pending", "in_progress"])
-
-          await dock.collapse()
-          await dock.expectCollapsed(["pending", "in_progress"])
-
-          await dock.expand()
-          await dock.expectOpen(["pending", "in_progress"])
-
-          await dock.finish([
-            { content: "first task", status: "completed", priority: "high" },
-            { content: "second task", status: "cancelled", priority: "medium" },
-          ])
-          await dock.expectClosed()
-        } finally {
-          await dock.clear()
-        }
-      },
-      { trackSession: project.trackSession },
-    )
-  })
+test("todo dock transitions and collapse behavior", async ({ page, project }) => {
+  await project.open()
+  await withDockSession(
+    project.sdk,
+    "e2e composer dock todo",
+    async (session) => {
+      const dock = await todoDock(page, session.id)
+      await project.gotoSession(session.id)
+      await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
+
+      try {
+        await dock.open([
+          { content: "first task", status: "pending", priority: "high" },
+          { content: "second task", status: "in_progress", priority: "medium" },
+        ])
+        await dock.expectOpen(["pending", "in_progress"])
+
+        await dock.collapse()
+        await dock.expectCollapsed(["pending", "in_progress"])
+
+        await dock.expand()
+        await dock.expectOpen(["pending", "in_progress"])
+
+        await dock.finish([
+          { content: "first task", status: "completed", priority: "high" },
+          { content: "second task", status: "cancelled", priority: "medium" },
+        ])
+        await dock.expectClosed()
+      } finally {
+        await dock.clear()
+      }
+    },
+    { trackSession: project.trackSession },
+  )
 })
 
-test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMockProject }) => {
+test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => {
   const questions = [
     {
       header: "Need input",
@@ -651,28 +630,27 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMo
       options: [{ label: "Continue", description: "Continue now" }],
     },
   ]
-  await withMockProject(async (project) => {
-    await withDockSession(
-      project.sdk,
-      "e2e composer dock keyboard",
-      async (session) => {
-        await withDockSeed(project.sdk, session.id, async () => {
-          await project.gotoSession(session.id)
+  await project.open()
+  await withDockSession(
+    project.sdk,
+    "e2e composer dock keyboard",
+    async (session) => {
+      await withDockSeed(project.sdk, session.id, async () => {
+        await project.gotoSession(session.id)
 
-          await llm.toolMatch(inputMatch({ questions }), "question", { questions })
-          await seedSessionQuestion(project.sdk, {
-            sessionID: session.id,
-            questions,
-          })
+        await llm.toolMatch(inputMatch({ questions }), "question", { questions })
+        await seedSessionQuestion(project.sdk, {
+          sessionID: session.id,
+          questions,
+        })
 
-          await expectQuestionBlocked(page)
+        await expectQuestionBlocked(page)
 
-          await page.locator("main").click({ position: { x: 5, y: 5 } })
-          await page.keyboard.type("abc")
-          await expect(page.locator(promptSelector)).toHaveCount(0)
-        })
-      },
-      { trackSession: project.trackSession },
-    )
-  })
+        await page.locator("main").click({ position: { x: 5, y: 5 } })
+        await page.keyboard.type("abc")
+        await expect(page.locator(promptSelector)).toHaveCount(0)
+      })
+    },
+    { trackSession: project.trackSession },
+  )
 })

+ 66 - 112
packages/app/e2e/session/session-model-persistence.spec.ts

@@ -1,15 +1,6 @@
 import type { Locator, Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
-import {
-  openSidebar,
-  resolveSlug,
-  sessionIDFromUrl,
-  setWorkspacesEnabled,
-  waitSession,
-  waitSessionIdle,
-  waitSlug,
-  withNoReplyPrompt,
-} from "../actions"
+import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions"
 import {
   promptAgentSelector,
   promptModelSelector,
@@ -230,35 +221,8 @@ async function goto(page: Page, directory: string, sessionID?: string) {
   await waitSession(page, { directory, sessionID })
 }
 
-async function submit(page: Page, value: string) {
-  const prompt = page.locator('[data-component="prompt-input"]')
-  await expect(prompt).toBeVisible()
-
-  await withNoReplyPrompt(page, async () => {
-    await prompt.click()
-    await prompt.fill(value)
-    await prompt.press("Enter")
-  })
-
-  await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
-  const id = sessionIDFromUrl(page.url())
-  if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
-  return id
-}
-
-async function waitUser(directory: string, sessionID: string) {
-  const sdk = createSdk(directory)
-  await expect
-    .poll(
-      async () => {
-        const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
-        return items.some((item) => item.info.role === "user")
-      },
-      { timeout: 30_000 },
-    )
-    .toBe(true)
-  await sdk.session.abort({ sessionID }).catch(() => undefined)
-  await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
+async function submit(project: Parameters<typeof test>[0]["project"], value: string) {
+  return project.prompt(value)
 }
 
 async function createWorkspace(page: Page, root: string, seen: string[]) {
@@ -301,108 +265,98 @@ async function newWorkspaceSession(page: Page, slug: string) {
   return waitSession(page, { directory: next.directory }).then((item) => item.directory)
 }
 
-test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
+test("session model restore per session without leaking into new sessions", async ({ page, project }) => {
   await page.setViewportSize({ width: 1440, height: 900 })
 
-  await withProject(async ({ directory, gotoSession, trackSession }) => {
-    await gotoSession()
+  await project.open()
+  await project.gotoSession()
 
-    const firstState = await chooseOtherModel(page)
-    const firstKey = await currentModel(page)
-    const first = await submit(page, `session variant ${Date.now()}`)
-    trackSession(first)
-    await waitUser(directory, first)
+  const firstState = await chooseOtherModel(page)
+  const firstKey = await currentModel(page)
+  const first = await submit(project, `session variant ${Date.now()}`)
 
-    await page.reload()
-    await waitSession(page, { directory, sessionID: first })
-    await waitFooter(page, firstState)
+  await page.reload()
+  await waitSession(page, { directory: project.directory, sessionID: first })
+  await waitFooter(page, firstState)
 
-    await gotoSession()
-    const fresh = await read(page)
-    expect(fresh.model).not.toBe(firstState.model)
+  await project.gotoSession()
+  const fresh = await read(page)
+  expect(fresh.model).not.toBe(firstState.model)
 
-    const secondState = await chooseOtherModel(page, [firstKey])
-    const second = await submit(page, `session model ${Date.now()}`)
-    trackSession(second)
-    await waitUser(directory, second)
+  const secondState = await chooseOtherModel(page, [firstKey])
+  const second = await submit(project, `session model ${Date.now()}`)
 
-    await goto(page, directory, first)
-    await waitFooter(page, firstState)
+  await goto(page, project.directory, first)
+  await waitFooter(page, firstState)
 
-    await goto(page, directory, second)
-    await waitFooter(page, secondState)
+  await goto(page, project.directory, second)
+  await waitFooter(page, secondState)
 
-    await gotoSession()
-    await waitFooter(page, fresh)
-  })
+  await project.gotoSession()
+  await page.reload()
+  await waitSession(page, { directory: project.directory })
+  await waitFooter(page, fresh)
 })
 
-test("session model restore across workspaces", async ({ page, withProject }) => {
+test("session model restore across workspaces", async ({ page, project }) => {
   await page.setViewportSize({ width: 1440, height: 900 })
 
-  await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
-    await gotoSession()
+  await project.open()
+  const root = project.directory
+  await project.gotoSession()
 
-    const firstState = await chooseOtherModel(page)
-    const firstKey = await currentModel(page)
-    const first = await submit(page, `root session ${Date.now()}`)
-    trackSession(first, root)
-    await waitUser(root, first)
+  const firstState = await chooseOtherModel(page)
+  const firstKey = await currentModel(page)
+  const first = await submit(project, `root session ${Date.now()}`)
 
-    await openSidebar(page)
-    await setWorkspacesEnabled(page, slug, true)
+  await openSidebar(page)
+  await setWorkspacesEnabled(page, project.slug, true)
 
-    const one = await createWorkspace(page, slug, [])
-    const oneDir = await newWorkspaceSession(page, one.slug)
-    trackDirectory(oneDir)
+  const one = await createWorkspace(page, project.slug, [])
+  const oneDir = await newWorkspaceSession(page, one.slug)
+  project.trackDirectory(oneDir)
 
-    const secondState = await chooseOtherModel(page, [firstKey])
-    const secondKey = await currentModel(page)
-    const second = await submit(page, `workspace one ${Date.now()}`)
-    trackSession(second, oneDir)
-    await waitUser(oneDir, second)
+  const secondState = await chooseOtherModel(page, [firstKey])
+  const secondKey = await currentModel(page)
+  const second = await submit(project, `workspace one ${Date.now()}`)
 
-    const two = await createWorkspace(page, slug, [one.slug])
-    const twoDir = await newWorkspaceSession(page, two.slug)
-    trackDirectory(twoDir)
+  const two = await createWorkspace(page, project.slug, [one.slug])
+  const twoDir = await newWorkspaceSession(page, two.slug)
+  project.trackDirectory(twoDir)
 
-    const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
-    const third = await submit(page, `workspace two ${Date.now()}`)
-    trackSession(third, twoDir)
-    await waitUser(twoDir, third)
+  const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
+  const third = await submit(project, `workspace two ${Date.now()}`)
 
-    await goto(page, root, first)
-    await waitFooter(page, firstState)
+  await goto(page, root, first)
+  await waitFooter(page, firstState)
 
-    await goto(page, oneDir, second)
-    await waitFooter(page, secondState)
+  await goto(page, oneDir, second)
+  await waitFooter(page, secondState)
 
-    await goto(page, twoDir, third)
-    await waitFooter(page, thirdState)
+  await goto(page, twoDir, third)
+  await waitFooter(page, thirdState)
 
-    await goto(page, root, first)
-    await waitFooter(page, firstState)
-  })
+  await goto(page, root, first)
+  await waitFooter(page, firstState)
 })
 
-test("variant preserved when switching agent modes", async ({ page, withProject }) => {
+test("variant preserved when switching agent modes", async ({ page, project }) => {
   await page.setViewportSize({ width: 1440, height: 900 })
 
-  await withProject(async ({ directory, gotoSession }) => {
-    await gotoSession()
+  await project.open()
+  await project.gotoSession()
 
-    await ensureVariant(page, directory)
-    const updated = await chooseDifferentVariant(page)
+  await ensureVariant(page, project.directory)
+  const updated = await chooseDifferentVariant(page)
 
-    const available = await agents(page)
-    const other = available.find((name) => name !== updated.agent)
-    test.skip(!other, "only one agent available")
-    if (!other) return
+  const available = await agents(page)
+  const other = available.find((name) => name !== updated.agent)
+  test.skip(!other, "only one agent available")
+  if (!other) return
 
-    await choose(page, promptAgentSelector, other)
-    await waitFooter(page, { agent: other, variant: updated.variant })
+  await choose(page, promptAgentSelector, other)
+  await waitFooter(page, { agent: other, variant: updated.variant })
 
-    await choose(page, promptAgentSelector, updated.agent)
-    await waitFooter(page, { agent: updated.agent, variant: updated.variant })
-  })
+  await choose(page, promptAgentSelector, updated.agent)
+  await waitFooter(page, { agent: updated.agent, variant: updated.variant })
 })

+ 168 - 163
packages/app/e2e/session/session-review.spec.ts

@@ -1,6 +1,6 @@
 import { waitSessionIdle, withSession } from "../actions"
 import { test, expect } from "../fixtures"
-import { inputMatch } from "../prompt/mock"
+import { bodyText } from "../prompt/mock"
 
 const count = 14
 
@@ -47,8 +47,12 @@ async function patchWithMock(
   patchText: string,
 ) {
   const callsBefore = await llm.calls()
-  await llm.toolMatch(inputMatch({ patchText }), "apply_patch", { patchText })
-  await sdk.session.promptAsync({
+  await llm.toolMatch(
+    (hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
+    "apply_patch",
+    { patchText },
+  )
+  await sdk.session.prompt({
     sessionID,
     agent: "build",
     system: [
@@ -61,12 +65,16 @@ async function patchWithMock(
     parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
   })
 
-  // Wait for the agent loop to actually start before checking idle.
-  // promptAsync is fire-and-forget — without this, waitSessionIdle can
-  // return immediately because the session status is still undefined.
   await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
-
-  await waitSessionIdle(sdk, sessionID, 120_000)
+  await expect
+    .poll(
+      async () => {
+        const diff = await sdk.session.diff({ sessionID }).then((res) => res.data ?? [])
+        return diff.length
+      },
+      { timeout: 120_000 },
+    )
+    .toBeGreaterThan(0)
 }
 
 async function show(page: Parameters<typeof test>[0]["page"]) {
@@ -245,7 +253,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
   }
 }
 
-test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, withMockProject }) => {
+test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => {
   test.setTimeout(180_000)
 
   const tag = `review-comment-${Date.now()}`
@@ -254,46 +262,45 @@ test("review applies inline comment clicks without horizontal overflow", async (
 
   await page.setViewportSize({ width: 1280, height: 900 })
 
-  await withMockProject(async (project) => {
-    await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
-      project.trackSession(session.id)
-      await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
-
-      await expect
-        .poll(
-          async () => {
-            const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
-            return diff.length
-          },
-          { timeout: 60_000 },
-        )
-        .toBe(1)
-
-      await project.gotoSession(session.id)
-      await show(page)
-
-      const tab = page.getByRole("tab", { name: /Review/i }).first()
-      await expect(tab).toBeVisible()
-      await tab.click()
-
-      await expand(page)
-      await waitMark(page, file, tag)
-      await comment(page, file, note)
-
-      await expect
-        .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
-        .toBeLessThanOrEqual(1)
-      await expect
-        .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
-        .toBeLessThanOrEqual(1)
-      await expect
-        .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
-        .toBeLessThanOrEqual(1)
-    })
+  await project.open()
+  await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
+    project.trackSession(session.id)
+    await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
+
+    await expect
+      .poll(
+        async () => {
+          const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
+          return diff.length
+        },
+        { timeout: 60_000 },
+      )
+      .toBe(1)
+
+    await project.gotoSession(session.id)
+    await show(page)
+
+    const tab = page.getByRole("tab", { name: /Review/i }).first()
+    await expect(tab).toBeVisible()
+    await tab.click()
+
+    await expand(page)
+    await waitMark(page, file, tag)
+    await comment(page, file, note)
+
+    await expect
+      .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+      .toBeLessThanOrEqual(1)
+    await expect
+      .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+      .toBeLessThanOrEqual(1)
+    await expect
+      .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+      .toBeLessThanOrEqual(1)
   })
 })
 
-test("review file comments submit on click without clipping actions", async ({ page, llm, withMockProject }) => {
+test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => {
   test.setTimeout(180_000)
 
   const tag = `review-file-comment-${Date.now()}`
@@ -302,47 +309,46 @@ test("review file comments submit on click without clipping actions", async ({ p
 
   await page.setViewportSize({ width: 1280, height: 900 })
 
-  await withMockProject(async (project) => {
-    await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
-      project.trackSession(session.id)
-      await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
-
-      await expect
-        .poll(
-          async () => {
-            const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
-            return diff.length
-          },
-          { timeout: 60_000 },
-        )
-        .toBe(1)
-
-      await project.gotoSession(session.id)
-      await show(page)
-
-      const tab = page.getByRole("tab", { name: /Review/i }).first()
-      await expect(tab).toBeVisible()
-      await tab.click()
-
-      await expand(page)
-      await waitMark(page, file, tag)
-      await openReviewFile(page, file)
-      await fileComment(page, note)
-
-      await expect
-        .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
-        .toBeLessThanOrEqual(1)
-      await expect
-        .poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
-        .toBeLessThanOrEqual(1)
-      await expect
-        .poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
-        .toBeLessThanOrEqual(1)
-    })
+  await project.open()
+  await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
+    project.trackSession(session.id)
+    await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
+
+    await expect
+      .poll(
+        async () => {
+          const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
+          return diff.length
+        },
+        { timeout: 60_000 },
+      )
+      .toBe(1)
+
+    await project.gotoSession(session.id)
+    await show(page)
+
+    const tab = page.getByRole("tab", { name: /Review/i }).first()
+    await expect(tab).toBeVisible()
+    await tab.click()
+
+    await expand(page)
+    await waitMark(page, file, tag)
+    await openReviewFile(page, file)
+    await fileComment(page, note)
+
+    await expect
+      .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+      .toBeLessThanOrEqual(1)
+    await expect
+      .poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+      .toBeLessThanOrEqual(1)
+    await expect
+      .poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+      .toBeLessThanOrEqual(1)
   })
 })
 
-test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, withMockProject }) => {
+test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => {
   test.setTimeout(180_000)
 
   const tag = `review-${Date.now()}`
@@ -352,84 +358,83 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
 
   await page.setViewportSize({ width: 1600, height: 1000 })
 
-  await withMockProject(async (project) => {
-    await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
-      project.trackSession(session.id)
-      await patchWithMock(llm, project.sdk, session.id, seed(list))
-
-      await expect
-        .poll(
-          async () => {
-            const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
-            return info?.summary?.files ?? 0
-          },
-          { timeout: 60_000 },
-        )
-        .toBe(list.length)
-
-      await expect
-        .poll(
-          async () => {
-            const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
-            return diff.length
-          },
-          { timeout: 60_000 },
-        )
-        .toBe(list.length)
-
-      await project.gotoSession(session.id)
-      await show(page)
-
-      const tab = page.getByRole("tab", { name: /Review/i }).first()
-      await expect(tab).toBeVisible()
-      await tab.click()
-
-      const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
-      await expect(view).toBeVisible()
-      const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
-      await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
-
-      await expand(page)
-      await waitMark(page, hit.file, hit.mark)
-
-      const row = page
-        .getByRole("heading", {
-          level: 3,
-          name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
-        })
-        .first()
-      await expect(row).toBeVisible()
-      await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
-
-      await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
-      const prev = await spot(page, hit.file)
-      if (!prev) throw new Error(`missing review row for ${hit.file}`)
-
-      await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
-
-      await expect
-        .poll(
-          async () => {
-            const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
-            const item = diff.find((item) => item.file === hit.file)
-            return typeof item?.after === "string" ? item.after : ""
-          },
-          { timeout: 60_000 },
-        )
-        .toContain(`mark ${next}`)
-
-      await waitMark(page, hit.file, next)
-
-      await expect
-        .poll(
-          async () => {
-            const next = await spot(page, hit.file)
-            if (!next) return Number.POSITIVE_INFINITY
-            return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
-          },
-          { timeout: 60_000 },
-        )
-        .toBeLessThanOrEqual(32)
-    })
+  await project.open()
+  await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
+    project.trackSession(session.id)
+    await patchWithMock(llm, project.sdk, session.id, seed(list))
+
+    await expect
+      .poll(
+        async () => {
+          const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
+          return info?.summary?.files ?? 0
+        },
+        { timeout: 60_000 },
+      )
+      .toBe(list.length)
+
+    await expect
+      .poll(
+        async () => {
+          const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
+          return diff.length
+        },
+        { timeout: 60_000 },
+      )
+      .toBe(list.length)
+
+    await project.gotoSession(session.id)
+    await show(page)
+
+    const tab = page.getByRole("tab", { name: /Review/i }).first()
+    await expect(tab).toBeVisible()
+    await tab.click()
+
+    const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
+    await expect(view).toBeVisible()
+    const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
+    await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
+
+    await expand(page)
+    await waitMark(page, hit.file, hit.mark)
+
+    const row = page
+      .getByRole("heading", {
+        level: 3,
+        name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
+      })
+      .first()
+    await expect(row).toBeVisible()
+    await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
+
+    await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
+    const prev = await spot(page, hit.file)
+    if (!prev) throw new Error(`missing review row for ${hit.file}`)
+
+    await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
+
+    await expect
+      .poll(
+        async () => {
+          const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
+          const item = diff.find((item) => item.file === hit.file)
+          return typeof item?.after === "string" ? item.after : ""
+        },
+        { timeout: 60_000 },
+      )
+      .toContain(`mark ${next}`)
+
+    await waitMark(page, hit.file, next)
+
+    await expect
+      .poll(
+        async () => {
+          const next = await spot(page, hit.file)
+          if (!next) return Number.POSITIVE_INFINITY
+          return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
+        },
+        { timeout: 60_000 },
+      )
+      .toBeLessThanOrEqual(32)
   })
 })

+ 141 - 144
packages/app/e2e/session/session-undo-redo.spec.ts

@@ -49,188 +49,185 @@ async function seedConversation(input: {
   return { prompt, userMessageID }
 }
 
-test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
+test("slash undo sets revert and restores prior prompt", async ({ page, project }) => {
   test.setTimeout(120_000)
 
   const token = `undo_${Date.now()}`
 
-  await withBackendProject(async (project) => {
-    const sdk = project.sdk
+  await project.open()
+  const sdk = project.sdk
 
-    await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
-      project.trackSession(session.id)
-      await project.gotoSession(session.id)
+  await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
+    project.trackSession(session.id)
+    await project.gotoSession(session.id)
 
-      const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
+    const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
 
-      await seeded.prompt.click()
-      await page.keyboard.type("/undo")
+    await seeded.prompt.click()
+    await page.keyboard.type("/undo")
 
-      const undo = page.locator('[data-slash-id="session.undo"]').first()
-      await expect(undo).toBeVisible()
-      await page.keyboard.press("Enter")
+    const undo = page.locator('[data-slash-id="session.undo"]').first()
+    await expect(undo).toBeVisible()
+    await page.keyboard.press("Enter")
 
-      await expect
-        .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
-          timeout: 30_000,
-        })
-        .toBe(seeded.userMessageID)
+    await expect
+      .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+        timeout: 30_000,
+      })
+      .toBe(seeded.userMessageID)
 
-      await expect(seeded.prompt).toContainText(token)
-      await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
-    })
+    await expect(seeded.prompt).toContainText(token)
+    await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
   })
 })
 
-test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
+test("slash redo clears revert and restores latest state", async ({ page, project }) => {
   test.setTimeout(120_000)
 
   const token = `redo_${Date.now()}`
 
-  await withBackendProject(async (project) => {
-    const sdk = project.sdk
+  await project.open()
+  const sdk = project.sdk
 
-    await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
-      project.trackSession(session.id)
-      await project.gotoSession(session.id)
+  await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
+    project.trackSession(session.id)
+    await project.gotoSession(session.id)
 
-      const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
+    const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
 
-      await seeded.prompt.click()
-      await page.keyboard.type("/undo")
+    await seeded.prompt.click()
+    await page.keyboard.type("/undo")
 
-      const undo = page.locator('[data-slash-id="session.undo"]').first()
-      await expect(undo).toBeVisible()
-      await page.keyboard.press("Enter")
+    const undo = page.locator('[data-slash-id="session.undo"]').first()
+    await expect(undo).toBeVisible()
+    await page.keyboard.press("Enter")
 
-      await expect
-        .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
-          timeout: 30_000,
-        })
-        .toBe(seeded.userMessageID)
+    await expect
+      .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+        timeout: 30_000,
+      })
+      .toBe(seeded.userMessageID)
 
-      await seeded.prompt.click()
-      await page.keyboard.press(`${modKey}+A`)
-      await page.keyboard.press("Backspace")
-      await page.keyboard.type("/redo")
+    await seeded.prompt.click()
+    await page.keyboard.press(`${modKey}+A`)
+    await page.keyboard.press("Backspace")
+    await page.keyboard.type("/redo")
 
-      const redo = page.locator('[data-slash-id="session.redo"]').first()
-      await expect(redo).toBeVisible()
-      await page.keyboard.press("Enter")
+    const redo = page.locator('[data-slash-id="session.redo"]').first()
+    await expect(redo).toBeVisible()
+    await page.keyboard.press("Enter")
 
-      await expect
-        .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
-          timeout: 30_000,
-        })
-        .toBeUndefined()
+    await expect
+      .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+        timeout: 30_000,
+      })
+      .toBeUndefined()
 
-      await expect(seeded.prompt).not.toContainText(token)
-      await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
-    })
+    await expect(seeded.prompt).not.toContainText(token)
+    await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
   })
 })
 
-test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
+test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => {
   test.setTimeout(120_000)
 
   const firstToken = `undo_redo_first_${Date.now()}`
   const secondToken = `undo_redo_second_${Date.now()}`
 
-  await withBackendProject(async (project) => {
-    const sdk = project.sdk
+  await project.open()
+  const sdk = project.sdk
+
+  await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
+    project.trackSession(session.id)
+    await project.gotoSession(session.id)
+
+    const first = await seedConversation({
+      page,
+      sdk,
+      sessionID: session.id,
+      token: firstToken,
+    })
+    const second = await seedConversation({
+      page,
+      sdk,
+      sessionID: session.id,
+      token: secondToken,
+    })
+
+    expect(first.userMessageID).not.toBe(second.userMessageID)
+
+    const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
+    const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
+
+    await expect(firstMessage).toHaveCount(1)
+    await expect(secondMessage).toHaveCount(1)
+
+    await second.prompt.click()
+    await page.keyboard.press(`${modKey}+A`)
+    await page.keyboard.press("Backspace")
+    await page.keyboard.type("/undo")
 
-    await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
-      project.trackSession(session.id)
-      await project.gotoSession(session.id)
+    const undo = page.locator('[data-slash-id="session.undo"]').first()
+    await expect(undo).toBeVisible()
+    await page.keyboard.press("Enter")
 
-      const first = await seedConversation({
-        page,
-        sdk,
-        sessionID: session.id,
-        token: firstToken,
+    await expect
+      .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+        timeout: 30_000,
       })
-      const second = await seedConversation({
-        page,
-        sdk,
-        sessionID: session.id,
-        token: secondToken,
+      .toBe(second.userMessageID)
+
+    await expect(firstMessage).toHaveCount(1)
+    await expect(secondMessage).toHaveCount(0)
+
+    await second.prompt.click()
+    await page.keyboard.press(`${modKey}+A`)
+    await page.keyboard.press("Backspace")
+    await page.keyboard.type("/undo")
+    await expect(undo).toBeVisible()
+    await page.keyboard.press("Enter")
+
+    await expect
+      .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+        timeout: 30_000,
       })
+      .toBe(first.userMessageID)
 
-      expect(first.userMessageID).not.toBe(second.userMessageID)
-
-      const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
-      const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
-
-      await expect(firstMessage).toHaveCount(1)
-      await expect(secondMessage).toHaveCount(1)
-
-      await second.prompt.click()
-      await page.keyboard.press(`${modKey}+A`)
-      await page.keyboard.press("Backspace")
-      await page.keyboard.type("/undo")
-
-      const undo = page.locator('[data-slash-id="session.undo"]').first()
-      await expect(undo).toBeVisible()
-      await page.keyboard.press("Enter")
-
-      await expect
-        .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
-          timeout: 30_000,
-        })
-        .toBe(second.userMessageID)
-
-      await expect(firstMessage).toHaveCount(1)
-      await expect(secondMessage).toHaveCount(0)
-
-      await second.prompt.click()
-      await page.keyboard.press(`${modKey}+A`)
-      await page.keyboard.press("Backspace")
-      await page.keyboard.type("/undo")
-      await expect(undo).toBeVisible()
-      await page.keyboard.press("Enter")
-
-      await expect
-        .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
-          timeout: 30_000,
-        })
-        .toBe(first.userMessageID)
-
-      await expect(firstMessage).toHaveCount(0)
-      await expect(secondMessage).toHaveCount(0)
-
-      await second.prompt.click()
-      await page.keyboard.press(`${modKey}+A`)
-      await page.keyboard.press("Backspace")
-      await page.keyboard.type("/redo")
-
-      const redo = page.locator('[data-slash-id="session.redo"]').first()
-      await expect(redo).toBeVisible()
-      await page.keyboard.press("Enter")
-
-      await expect
-        .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
-          timeout: 30_000,
-        })
-        .toBe(second.userMessageID)
-
-      await expect(firstMessage).toHaveCount(1)
-      await expect(secondMessage).toHaveCount(0)
-
-      await second.prompt.click()
-      await page.keyboard.press(`${modKey}+A`)
-      await page.keyboard.press("Backspace")
-      await page.keyboard.type("/redo")
-      await expect(redo).toBeVisible()
-      await page.keyboard.press("Enter")
-
-      await expect
-        .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
-          timeout: 30_000,
-        })
-        .toBeUndefined()
-
-      await expect(firstMessage).toHaveCount(1)
-      await expect(secondMessage).toHaveCount(1)
-    })
+    await expect(firstMessage).toHaveCount(0)
+    await expect(secondMessage).toHaveCount(0)
+
+    await second.prompt.click()
+    await page.keyboard.press(`${modKey}+A`)
+    await page.keyboard.press("Backspace")
+    await page.keyboard.type("/redo")
+
+    const redo = page.locator('[data-slash-id="session.redo"]').first()
+    await expect(redo).toBeVisible()
+    await page.keyboard.press("Enter")
+
+    await expect
+      .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+        timeout: 30_000,
+      })
+      .toBe(second.userMessageID)
+
+    await expect(firstMessage).toHaveCount(1)
+    await expect(secondMessage).toHaveCount(0)
+
+    await second.prompt.click()
+    await page.keyboard.press(`${modKey}+A`)
+    await page.keyboard.press("Backspace")
+    await page.keyboard.type("/redo")
+    await expect(redo).toBeVisible()
+    await page.keyboard.press("Enter")
+
+    await expect
+      .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+        timeout: 30_000,
+      })
+      .toBeUndefined()
+
+    await expect(firstMessage).toHaveCount(1)
+    await expect(secondMessage).toHaveCount(1)
   })
 })

+ 122 - 126
packages/app/e2e/session/session.spec.ts

@@ -31,156 +31,152 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
     .toBeGreaterThan(0)
 }
 
-test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
+test("session can be renamed via header menu", async ({ page, project }) => {
   const stamp = Date.now()
   const originalTitle = `e2e rename test ${stamp}`
   const renamedTitle = `e2e renamed ${stamp}`
 
-  await withBackendProject(async (project) => {
-    await withSession(project.sdk, originalTitle, async (session) => {
-      project.trackSession(session.id)
-      await seedMessage(project.sdk, session.id)
-      await project.gotoSession(session.id)
-      await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
-
-      const menu = await openSessionMoreMenu(page, session.id)
-      await clickMenuItem(menu, /rename/i)
-
-      const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
-      await expect(input).toBeVisible()
-      await expect(input).toBeFocused()
-      await input.fill(renamedTitle)
-      await expect(input).toHaveValue(renamedTitle)
-      await input.press("Enter")
-
-      await expect
-        .poll(
-          async () => {
-            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-            return data?.title
-          },
-          { timeout: 30_000 },
-        )
-        .toBe(renamedTitle)
-
-      await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
-    })
+  await project.open()
+  await withSession(project.sdk, originalTitle, async (session) => {
+    project.trackSession(session.id)
+    await seedMessage(project.sdk, session.id)
+    await project.gotoSession(session.id)
+    await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
+
+    const menu = await openSessionMoreMenu(page, session.id)
+    await clickMenuItem(menu, /rename/i)
+
+    const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
+    await expect(input).toBeVisible()
+    await expect(input).toBeFocused()
+    await input.fill(renamedTitle)
+    await expect(input).toHaveValue(renamedTitle)
+    await input.press("Enter")
+
+    await expect
+      .poll(
+        async () => {
+          const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.title
+        },
+        { timeout: 30_000 },
+      )
+      .toBe(renamedTitle)
+
+    await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
   })
 })
 
-test("session can be archived via header menu", async ({ page, withBackendProject }) => {
+test("session can be archived via header menu", async ({ page, project }) => {
   const stamp = Date.now()
   const title = `e2e archive test ${stamp}`
 
-  await withBackendProject(async (project) => {
-    await withSession(project.sdk, title, async (session) => {
-      project.trackSession(session.id)
-      await seedMessage(project.sdk, session.id)
-      await project.gotoSession(session.id)
-      const menu = await openSessionMoreMenu(page, session.id)
-      await clickMenuItem(menu, /archive/i)
-
-      await expect
-        .poll(
-          async () => {
-            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-            return data?.time?.archived
-          },
-          { timeout: 30_000 },
-        )
-        .not.toBeUndefined()
-
-      await openSidebar(page)
-      await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
-    })
+  await project.open()
+  await withSession(project.sdk, title, async (session) => {
+    project.trackSession(session.id)
+    await seedMessage(project.sdk, session.id)
+    await project.gotoSession(session.id)
+    const menu = await openSessionMoreMenu(page, session.id)
+    await clickMenuItem(menu, /archive/i)
+
+    await expect
+      .poll(
+        async () => {
+          const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.time?.archived
+        },
+        { timeout: 30_000 },
+      )
+      .not.toBeUndefined()
+
+    await openSidebar(page)
+    await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
   })
 })
 
-test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
+test("session can be deleted via header menu", async ({ page, project }) => {
   const stamp = Date.now()
   const title = `e2e delete test ${stamp}`
 
-  await withBackendProject(async (project) => {
-    await withSession(project.sdk, title, async (session) => {
-      project.trackSession(session.id)
-      await seedMessage(project.sdk, session.id)
-      await project.gotoSession(session.id)
-      const menu = await openSessionMoreMenu(page, session.id)
-      await clickMenuItem(menu, /delete/i)
-      await confirmDialog(page, /delete/i)
-
-      await expect
-        .poll(
-          async () => {
-            const data = await project.sdk.session
-              .get({ sessionID: session.id })
-              .then((r) => r.data)
-              .catch(() => undefined)
-            return data?.id
-          },
-          { timeout: 30_000 },
-        )
-        .toBeUndefined()
-
-      await openSidebar(page)
-      await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
-    })
+  await project.open()
+  await withSession(project.sdk, title, async (session) => {
+    project.trackSession(session.id)
+    await seedMessage(project.sdk, session.id)
+    await project.gotoSession(session.id)
+    const menu = await openSessionMoreMenu(page, session.id)
+    await clickMenuItem(menu, /delete/i)
+    await confirmDialog(page, /delete/i)
+
+    await expect
+      .poll(
+        async () => {
+          const data = await project.sdk.session
+            .get({ sessionID: session.id })
+            .then((r) => r.data)
+            .catch(() => undefined)
+          return data?.id
+        },
+        { timeout: 30_000 },
+      )
+      .toBeUndefined()
+
+    await openSidebar(page)
+    await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
   })
 })
 
-test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
+test("session can be shared and unshared via header button", async ({ page, project }) => {
   test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
 
   const stamp = Date.now()
   const title = `e2e share test ${stamp}`
 
-  await withBackendProject(async (project) => {
-    await withSession(project.sdk, title, async (session) => {
-      project.trackSession(session.id)
-      await seedMessage(project.sdk, session.id)
-      await project.gotoSession(session.id)
-
-      const shared = await openSharePopover(page)
-      const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
-      await expect(publish).toBeVisible({ timeout: 30_000 })
-      await publish.click()
-
-      await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
-        timeout: 30_000,
-      })
-
-      await expect
-        .poll(
-          async () => {
-            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-            return data?.share?.url || undefined
-          },
-          { timeout: 30_000 },
-        )
-        .not.toBeUndefined()
-
-      const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
-      await expect(unpublish).toBeVisible({ timeout: 30_000 })
-      await unpublish.click()
-
-      await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
-        timeout: 30_000,
-      })
-
-      await expect
-        .poll(
-          async () => {
-            const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
-            return data?.share?.url || undefined
-          },
-          { timeout: 30_000 },
-        )
-        .toBeUndefined()
-
-      const unshared = await openSharePopover(page)
-      await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
-        timeout: 30_000,
-      })
+  await project.open()
+  await withSession(project.sdk, title, async (session) => {
+    project.trackSession(session.id)
+    await project.gotoSession(session.id)
+    await project.prompt(`share seed ${stamp}`)
+
+    const shared = await openSharePopover(page)
+    const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
+    await expect(publish).toBeVisible({ timeout: 30_000 })
+    await publish.click()
+
+    await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
+      timeout: 30_000,
+    })
+
+    await expect
+      .poll(
+        async () => {
+          const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.share?.url || undefined
+        },
+        { timeout: 30_000 },
+      )
+      .not.toBeUndefined()
+
+    const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
+    await expect(unpublish).toBeVisible({ timeout: 30_000 })
+    await unpublish.click()
+
+    await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
+      timeout: 30_000,
+    })
+
+    await expect
+      .poll(
+        async () => {
+          const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.share?.url || undefined
+        },
+        { timeout: 30_000 },
+      )
+      .toBeUndefined()
+
+    const unshared = await openSharePopover(page)
+    await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
+      timeout: 30_000,
     })
   })
 })

+ 13 - 3
packages/app/e2e/settings/settings.spec.ts

@@ -88,10 +88,20 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
     return document.documentElement.getAttribute("data-theme")
   })
   const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
-
-  await select.locator('[data-slot="select-select-trigger"]').click()
-
+  const trigger = select.locator('[data-slot="select-select-trigger"]')
   const items = page.locator('[data-slot="select-select-item"]')
+
+  await trigger.click()
+  const open = await expect
+    .poll(async () => (await items.count()) > 0, { timeout: 5_000 })
+    .toBe(true)
+    .then(() => true)
+    .catch(() => false)
+  if (!open) {
+    await trigger.click()
+    await expect.poll(async () => (await items.count()) > 0, { timeout: 10_000 }).toBe(true)
+  }
+  await expect(items.first()).toBeVisible()
   const count = await items.count()
   expect(count).toBeGreaterThan(1)
 

+ 38 - 47
packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts

@@ -48,70 +48,61 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
   }
 })
 
-test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
+test("open sidebar project popover stays closed after clicking avatar", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
   const other = await createTestProject()
   const slug = dirSlug(other)
 
   try {
-    await withProject(
-      async () => {
-        await openSidebar(page)
-
-        const project = page.locator(projectSwitchSelector(slug)).first()
-        const card = page.locator('[data-component="hover-card-content"]')
-
-        await expect(project).toBeVisible()
-        await project.hover()
-        await expect(card.getByText(/recent sessions/i)).toBeVisible()
-
-        await page.mouse.down()
-        await expect(card).toHaveCount(0)
-        await page.mouse.up()
-
-        await waitSession(page, { directory: other })
-        await expect(card).toHaveCount(0)
-      },
-      { extra: [other] },
-    )
+    await project.open({ extra: [other] })
+    await openSidebar(page)
+
+    const projectButton = page.locator(projectSwitchSelector(slug)).first()
+    const card = page.locator('[data-component="hover-card-content"]')
+
+    await expect(projectButton).toBeVisible()
+    await projectButton.hover()
+    await expect(card.getByText(/recent sessions/i)).toBeVisible()
+
+    await projectButton.click()
+    await expect(card).toHaveCount(0)
+
+    await waitSession(page, { directory: other })
+    await expect(card).toHaveCount(0)
   } finally {
     await cleanupTestProject(other)
   }
 })
 
-test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
+test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
   const other = await createTestProject()
   const slug = dirSlug(other)
 
   try {
-    await withProject(
-      async () => {
-        await openSidebar(page)
-        await defocus(page)
-
-        const project = page.locator(projectSwitchSelector(slug)).first()
-
-        await expect(project).toBeVisible()
-
-        let hit = false
-        for (let i = 0; i < 20; i++) {
-          hit = await project.evaluate((el) => {
-            return el.matches(":focus") || !!el.parentElement?.matches(":focus")
-          })
-          if (hit) break
-          await page.keyboard.press("Tab")
-        }
-
-        expect(hit).toBe(true)
-
-        await page.keyboard.press("Enter")
-        await waitSession(page, { directory: other })
-      },
-      { extra: [other] },
-    )
+    await project.open({ extra: [other] })
+    await openSidebar(page)
+    await defocus(page)
+
+    const projectButton = page.locator(projectSwitchSelector(slug)).first()
+
+    await expect(projectButton).toBeVisible()
+
+    let hit = false
+    for (let i = 0; i < 20; i++) {
+      hit = await projectButton.evaluate((el) => {
+        return el.matches(":focus") || !!el.parentElement?.matches(":focus")
+      })
+      if (hit) break
+      await page.keyboard.press("Tab")
+    }
+
+    expect(hit).toBe(true)
+
+    await page.keyboard.press("Enter")
+    await waitSession(page, { directory: other })
   } finally {
     await cleanupTestProject(other)
   }

+ 22 - 23
packages/app/e2e/terminal/terminal-reconnect.spec.ts

@@ -12,35 +12,34 @@ async function open(page: Page) {
   return term
 }
 
-test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
-  await withProject(async ({ gotoSession }) => {
-    const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
-    const token = `E2E_RECONNECT_${Date.now()}`
+test("terminal reconnects without replacing the pty", async ({ page, project }) => {
+  await project.open()
+  const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
+  const token = `E2E_RECONNECT_${Date.now()}`
 
-    await gotoSession()
+  await project.gotoSession()
 
-    const term = await open(page)
-    const id = await term.getAttribute("data-pty-id")
-    if (!id) throw new Error("Active terminal missing data-pty-id")
+  const term = await open(page)
+  const id = await term.getAttribute("data-pty-id")
+  if (!id) throw new Error("Active terminal missing data-pty-id")
 
-    const prev = await terminalConnects(page, { term })
+  const prev = await terminalConnects(page, { term })
 
-    await runTerminal(page, {
-      term,
-      cmd: `export ${name}=${token}; echo ${token}`,
-      token,
-    })
+  await runTerminal(page, {
+    term,
+    cmd: `export ${name}=${token}; echo ${token}`,
+    token,
+  })
 
-    await disconnectTerminal(page, { term })
+  await disconnectTerminal(page, { term })
 
-    await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
-    await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
+  await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
+  await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
 
-    await runTerminal(page, {
-      term,
-      cmd: `echo $${name}`,
-      token,
-      timeout: 15_000,
-    })
+  await runTerminal(page, {
+    term,
+    cmd: `echo $${name}`,
+    token,
+    timeout: 15_000,
   })
 })

+ 122 - 125
packages/app/e2e/terminal/terminal-tabs.spec.ts

@@ -36,133 +36,130 @@ async function store(page: Page, key: string) {
   }, key)
 }
 
-test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
-  await withProject(async ({ directory, gotoSession }) => {
-    const key = workspacePersistKey(directory, "terminal")
-    const one = `E2E_TERM_ONE_${Date.now()}`
-    const two = `E2E_TERM_TWO_${Date.now()}`
-    const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
-    const first = tabs.filter({ hasText: /Terminal 1/ }).first()
-    const second = tabs.filter({ hasText: /Terminal 2/ }).first()
-
-    await gotoSession()
-    await open(page)
-
-    await runTerminal(page, { cmd: `echo ${one}`, token: one })
-
-    await page.getByRole("button", { name: /new terminal/i }).click()
-    await expect(tabs).toHaveCount(2)
-
-    await runTerminal(page, { cmd: `echo ${two}`, token: two })
-
-    await first.click()
-    await expect(first).toHaveAttribute("aria-selected", "true")
-
-    await expect
-      .poll(
-        async () => {
-          const state = await store(page, key)
-          const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
-          const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
-          return {
-            first: first.includes(one),
-            second: second.includes(two),
-          }
-        },
-        { timeout: 5_000 },
-      )
-      .toEqual({ first: false, second: true })
-
-    await second.click()
-    await expect(second).toHaveAttribute("aria-selected", "true")
-    await expect
-      .poll(
-        async () => {
-          const state = await store(page, key)
-          const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
-          const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
-          return {
-            first: first.includes(one),
-            second: second.includes(two),
-          }
-        },
-        { timeout: 5_000 },
-      )
-      .toEqual({ first: true, second: false })
-  })
+test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => {
+  await project.open()
+  const key = workspacePersistKey(project.directory, "terminal")
+  const one = `E2E_TERM_ONE_${Date.now()}`
+  const two = `E2E_TERM_TWO_${Date.now()}`
+  const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
+  const first = tabs.filter({ hasText: /Terminal 1/ }).first()
+  const second = tabs.filter({ hasText: /Terminal 2/ }).first()
+
+  await project.gotoSession()
+  await open(page)
+
+  await runTerminal(page, { cmd: `echo ${one}`, token: one })
+
+  await page.getByRole("button", { name: /new terminal/i }).click()
+  await expect(tabs).toHaveCount(2)
+
+  await runTerminal(page, { cmd: `echo ${two}`, token: two })
+
+  await first.click()
+  await expect(first).toHaveAttribute("aria-selected", "true")
+
+  await expect
+    .poll(
+      async () => {
+        const state = await store(page, key)
+        const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
+        const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
+        return {
+          first: first.includes(one),
+          second: second.includes(two),
+        }
+      },
+      { timeout: 5_000 },
+    )
+    .toEqual({ first: false, second: true })
+
+  await second.click()
+  await expect(second).toHaveAttribute("aria-selected", "true")
+  await expect
+    .poll(
+      async () => {
+        const state = await store(page, key)
+        const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
+        const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
+        return {
+          first: first.includes(one),
+          second: second.includes(two),
+        }
+      },
+      { timeout: 5_000 },
+    )
+    .toEqual({ first: true, second: false })
 })
 
-test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
-  await withProject(async ({ directory, gotoSession }) => {
-    const key = workspacePersistKey(directory, "terminal")
-    const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
-
-    await gotoSession()
-    await open(page)
-
-    await page.getByRole("button", { name: /new terminal/i }).click()
-    await expect(tabs).toHaveCount(2)
-
-    const second = tabs.filter({ hasText: /Terminal 2/ }).first()
-    await second.click()
-    await expect(second).toHaveAttribute("aria-selected", "true")
-
-    await second.hover()
-    await page
-      .getByRole("button", { name: /close terminal/i })
-      .nth(1)
-      .click({ force: true })
-
-    const first = tabs.filter({ hasText: /Terminal 1/ }).first()
-    await expect(tabs).toHaveCount(1)
-    await expect(first).toHaveAttribute("aria-selected", "true")
-    await expect
-      .poll(
-        async () => {
-          const state = await store(page, key)
-          return {
-            count: state?.all.length ?? 0,
-            first: state?.all.some((item) => item.titleNumber === 1) ?? false,
-          }
-        },
-        { timeout: 15_000 },
-      )
-      .toEqual({ count: 1, first: true })
-  })
+test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => {
+  await project.open()
+  const key = workspacePersistKey(project.directory, "terminal")
+  const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
+
+  await project.gotoSession()
+  await open(page)
+
+  await page.getByRole("button", { name: /new terminal/i }).click()
+  await expect(tabs).toHaveCount(2)
+
+  const second = tabs.filter({ hasText: /Terminal 2/ }).first()
+  await second.click()
+  await expect(second).toHaveAttribute("aria-selected", "true")
+
+  await second.hover()
+  await page
+    .getByRole("button", { name: /close terminal/i })
+    .nth(1)
+    .click({ force: true })
+
+  const first = tabs.filter({ hasText: /Terminal 1/ }).first()
+  await expect(tabs).toHaveCount(1)
+  await expect(first).toHaveAttribute("aria-selected", "true")
+  await expect
+    .poll(
+      async () => {
+        const state = await store(page, key)
+        return {
+          count: state?.all.length ?? 0,
+          first: state?.all.some((item) => item.titleNumber === 1) ?? false,
+        }
+      },
+      { timeout: 15_000 },
+    )
+    .toEqual({ count: 1, first: true })
 })
 
-test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
-  await withProject(async ({ directory, gotoSession }) => {
-    const key = workspacePersistKey(directory, "terminal")
-    const rename = `E2E term ${Date.now()}`
-    const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
-
-    await gotoSession()
-    await open(page)
-
-    await expect(tab).toContainText(/Terminal 1/)
-    await tab.click({ button: "right" })
-
-    const menu = page.locator(dropdownMenuContentSelector).first()
-    await expect(menu).toBeVisible()
-    await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
-    await expect(menu).toHaveCount(0)
-
-    const input = page.locator('#terminal-panel input[type="text"]').first()
-    await expect(input).toBeVisible()
-    await input.fill(rename)
-    await input.press("Enter")
-
-    await expect(input).toHaveCount(0)
-    await expect(tab).toContainText(rename)
-    await expect
-      .poll(
-        async () => {
-          const state = await store(page, key)
-          return state?.all[0]?.title
-        },
-        { timeout: 5_000 },
-      )
-      .toBe(rename)
-  })
+test("terminal tab can be renamed from the context menu", async ({ page, project }) => {
+  await project.open()
+  const key = workspacePersistKey(project.directory, "terminal")
+  const rename = `E2E term ${Date.now()}`
+  const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
+
+  await project.gotoSession()
+  await open(page)
+
+  await expect(tab).toContainText(/Terminal 1/)
+  await tab.click({ button: "right" })
+
+  const menu = page.locator(dropdownMenuContentSelector).first()
+  await expect(menu).toBeVisible()
+  await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
+  await expect(menu).toHaveCount(0)
+
+  const input = page.locator('#terminal-panel input[type="text"]').first()
+  await expect(input).toBeVisible()
+  await input.fill(rename)
+  await input.press("Enter")
+
+  await expect(input).toHaveCount(0)
+  await expect(tab).toContainText(rename)
+  await expect
+    .poll(
+      async () => {
+        const state = await store(page, key)
+        return state?.all[0]?.title
+      },
+      { timeout: 5_000 },
+    )
+    .toBe(rename)
 })

+ 3 - 0
packages/app/src/components/prompt-input/submit.ts

@@ -13,6 +13,7 @@ import { usePermission } from "@/context/permission"
 import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
+import { promptProbe } from "@/testing/prompt"
 import { Identifier } from "@/utils/id"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { buildRequestParts } from "./build-request-parts"
@@ -307,6 +308,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     input.addToHistory(currentPrompt, mode)
     input.resetHistoryNavigation()
+    promptProbe.start()
 
     const projectDirectory = sdk.directory
     const isNewSession = !params.id
@@ -426,6 +428,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       return
     }
 
+    promptProbe.submit({ sessionID: session.id, directory: sessionDirectory })
     input.onSubmit?.()
 
     if (mode === "shell") {

+ 27 - 0
packages/app/src/testing/prompt.ts

@@ -10,6 +10,13 @@ export type PromptProbeState = {
   selects: number
 }
 
+export type PromptSendState = {
+  started: number
+  count: number
+  sessionID?: string
+  directory?: string
+}
+
 export const promptEnabled = () => {
   if (typeof window === "undefined") return false
   return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
@@ -53,4 +60,24 @@ export const promptProbe = {
     if (!state) return
     state.current = undefined
   },
+  start() {
+    const state = root()
+    if (!state) return
+    state.sent = {
+      started: (state.sent?.started ?? 0) + 1,
+      count: state.sent?.count ?? 0,
+      sessionID: state.sent?.sessionID,
+      directory: state.sent?.directory,
+    }
+  },
+  submit(input: { sessionID: string; directory: string }) {
+    const state = root()
+    if (!state) return
+    state.sent = {
+      started: state.sent?.started ?? 0,
+      count: (state.sent?.count ?? 0) + 1,
+      sessionID: input.sessionID,
+      directory: input.directory,
+    }
+  },
 }

+ 1 - 0
packages/app/src/testing/terminal.ts

@@ -23,6 +23,7 @@ export type E2EWindow = Window & {
     prompt?: {
       enabled?: boolean
       current?: import("./prompt").PromptProbeState
+      sent?: import("./prompt").PromptSendState
     }
     terminal?: {
       enabled?: boolean

+ 17 - 0
packages/opencode/src/provider/provider.ts

@@ -114,6 +114,12 @@ export namespace Provider {
     })
   }
 
+  function e2eURL() {
+    const url = Env.get("OPENCODE_E2E_LLM_URL")
+    if (typeof url !== "string" || url === "") return
+    return url
+  }
+
   type BundledSDK = {
     languageModel(modelId: string): LanguageModelV3
   }
@@ -1450,6 +1456,17 @@ export namespace Provider {
         if (s.models.has(key)) return s.models.get(key)!
 
         return yield* Effect.promise(async () => {
+          const url = e2eURL()
+          if (url) {
+            const language = createOpenAICompatible({
+              name: model.providerID,
+              apiKey: "test-key",
+              baseURL: url,
+            }).chatModel(model.api.id)
+            s.models.set(key, language)
+            return language
+          }
+
           const provider = s.providers[model.providerID]
           const sdk = await resolveSDK(model, s)
 

+ 3 - 1
packages/opencode/src/tool/registry.ts

@@ -32,6 +32,7 @@ import { pathToFileURL } from "url"
 import { Effect, Layer, ServiceMap } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
+import { Env } from "../env"
 
 export namespace ToolRegistry {
   const log = Log.create({ service: "tool.registry" })
@@ -166,7 +167,8 @@ export namespace ToolRegistry {
           }
 
           const usePatch =
-            model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
+            !!Env.get("OPENCODE_E2E_LLM_URL") ||
+            (model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
           if (tool.id === "apply_patch") return usePatch
           if (tool.id === "edit" || tool.id === "write") return !usePatch
 

+ 11 - 1
packages/opencode/test/effect/cross-spawn-spawner.test.ts

@@ -159,7 +159,17 @@ describe("cross-spawn spawner", () => {
     fx.effect(
       "captures both stdout and stderr",
       Effect.gen(function* () {
-        const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")')
+        const handle = yield* js(
+          [
+            "let pending = 2",
+            "const done = () => {",
+            "  pending -= 1",
+            "  if (pending === 0) setTimeout(() => process.exit(0), 0)",
+            "}",
+            'process.stdout.write("stdout\\n", done)',
+            'process.stderr.write("stderr\\n", done)',
+          ].join("\n"),
+        )
         const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
         expect(stdout).toBe("stdout")
         expect(stderr).toBe("stderr")

+ 36 - 13
packages/opencode/test/lib/llm-server.ts

@@ -254,6 +254,16 @@ function responseToolArgs(id: string, text: string, seq: number) {
   }
 }
 
+function responseToolArgsDone(id: string, args: string, seq: number) {
+  return {
+    type: "response.function_call_arguments.done",
+    sequence_number: seq,
+    output_index: 0,
+    item_id: id,
+    arguments: args,
+  }
+}
+
 function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
   return {
     type: "response.output_item.done",
@@ -390,6 +400,8 @@ function responses(item: Sse, model: string) {
     lines.push(responseReasonDone(reason, seq))
   }
   if (call && !item.hang && !item.error) {
+    seq += 1
+    lines.push(responseToolArgsDone(call.item, call.args, seq))
     seq += 1
     lines.push(responseToolDone(call, seq))
   }
@@ -599,6 +611,11 @@ function isToolResultFollowUp(body: unknown): boolean {
   return false
 }
 
+function isTitleRequest(body: unknown): boolean {
+  if (!body || typeof body !== "object") return false
+  return JSON.stringify(body).includes("Generate a title for this conversation")
+}
+
 function requestSummary(body: unknown): string {
   if (!body || typeof body !== "object") return "empty body"
   if ("messages" in body && Array.isArray(body.messages)) {
@@ -623,6 +640,7 @@ namespace TestLLMServer {
     readonly error: (status: number, body: unknown) => Effect.Effect<void>
     readonly hang: Effect.Effect<void>
     readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
+    readonly reset: Effect.Effect<void>
     readonly hits: Effect.Effect<Hit[]>
     readonly calls: Effect.Effect<number>
     readonly wait: (count: number) => Effect.Effect<void>
@@ -671,21 +689,20 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
         const req = yield* HttpServerRequest.HttpServerRequest
         const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
         const current = hit(req.originalUrl, body)
+        if (isTitleRequest(body)) {
+          hits = [...hits, current]
+          yield* notify()
+          const auto: Sse = { type: "sse", head: [role()], tail: [textLine("E2E Title"), finishLine("stop")] }
+          if (mode === "responses") return send(responses(auto, modelFrom(body)))
+          return send(auto)
+        }
         const next = pull(current)
         if (!next) {
-          // Auto-acknowledge tool-result follow-ups so tests only need to
-          // queue one response per tool call instead of two.
-          if (isToolResultFollowUp(body)) {
-            hits = [...hits, current]
-            yield* notify()
-            const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
-            if (mode === "responses") return send(responses(auto, modelFrom(body)))
-            return send(auto)
-          }
-          misses = [...misses, current]
-          const summary = requestSummary(body)
-          console.warn(`[TestLLMServer] unmatched request: ${req.originalUrl} (${summary}, pending=${list.length})`)
-          return HttpServerResponse.text(`unexpected request: ${summary}`, { status: 500 })
+          hits = [...hits, current]
+          yield* notify()
+          const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
+          if (mode === "responses") return send(responses(auto, modelFrom(body)))
+          return send(auto)
         }
         hits = [...hits, current]
         yield* notify()
@@ -755,6 +772,12 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
         hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
           queue(reply().wait(wait).text(value).stop().item())
         }),
+        reset: Effect.sync(() => {
+          hits = []
+          list = []
+          waits = []
+          misses = []
+        }),
         hits: Effect.sync(() => [...hits]),
         calls: Effect.sync(() => hits.length),
         wait: Effect.fn("TestLLMServer.wait")(function* (count: number) {

+ 314 - 0
packages/opencode/test/session/e2e-url-repro.test.ts

@@ -0,0 +1,314 @@
+/**
+ * Reproduction test for e2e LLM URL routing.
+ *
+ * Tests whether OPENCODE_E2E_LLM_URL correctly routes LLM calls
+ * to the mock server when no explicit provider config is set.
+ * This mimics the e2e `project` fixture path (vs. withMockOpenAI).
+ */
+import { expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import { Session } from "../../src/session"
+import { SessionPrompt } from "../../src/session/prompt"
+import { SessionSummary } from "../../src/session/summary"
+import { Log } from "../../src/util/log"
+import { provideTmpdirServer } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+import { TestLLMServer } from "../lib/llm-server"
+
+import { NodeFileSystem } from "@effect/platform-node"
+import { Agent as AgentSvc } from "../../src/agent/agent"
+import { Bus } from "../../src/bus"
+import { Command } from "../../src/command"
+import { Config } from "../../src/config/config"
+import { FileTime } from "../../src/file/time"
+import { LSP } from "../../src/lsp"
+import { MCP } from "../../src/mcp"
+import { Permission } from "../../src/permission"
+import { Plugin } from "../../src/plugin"
+import { Provider as ProviderSvc } from "../../src/provider/provider"
+import { ModelID, ProviderID } from "../../src/provider/schema"
+import { Server } from "../../src/server/server"
+import { SessionCompaction } from "../../src/session/compaction"
+import { Instruction } from "../../src/session/instruction"
+import { SessionProcessor } from "../../src/session/processor"
+import { SessionStatus } from "../../src/session/status"
+import { LLM } from "../../src/session/llm"
+import { Shell } from "../../src/shell/shell"
+import { Snapshot } from "../../src/snapshot"
+import { ToolRegistry } from "../../src/tool/registry"
+import { Truncate } from "../../src/tool/truncate"
+import { AppFileSystem } from "../../src/filesystem"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+
+Log.init({ print: false })
+
+const mcp = Layer.succeed(
+  MCP.Service,
+  MCP.Service.of({
+    status: () => Effect.succeed({}),
+    clients: () => Effect.succeed({}),
+    tools: () => Effect.succeed({}),
+    prompts: () => Effect.succeed({}),
+    resources: () => Effect.succeed({}),
+    add: () => Effect.succeed({ status: { status: "disabled" as const } }),
+    connect: () => Effect.void,
+    disconnect: () => Effect.void,
+    getPrompt: () => Effect.succeed(undefined),
+    readResource: () => Effect.succeed(undefined),
+    startAuth: () => Effect.die("unexpected MCP auth"),
+    authenticate: () => Effect.die("unexpected MCP auth"),
+    finishAuth: () => Effect.die("unexpected MCP auth"),
+    removeAuth: () => Effect.void,
+    supportsOAuth: () => Effect.succeed(false),
+    hasStoredTokens: () => Effect.succeed(false),
+    getAuthStatus: () => Effect.succeed("not_authenticated" as const),
+  }),
+)
+
+const lsp = Layer.succeed(
+  LSP.Service,
+  LSP.Service.of({
+    init: () => Effect.void,
+    status: () => Effect.succeed([]),
+    hasClients: () => Effect.succeed(false),
+    touchFile: () => Effect.void,
+    diagnostics: () => Effect.succeed({}),
+    hover: () => Effect.succeed(undefined),
+    definition: () => Effect.succeed([]),
+    references: () => Effect.succeed([]),
+    implementation: () => Effect.succeed([]),
+    documentSymbol: () => Effect.succeed([]),
+    workspaceSymbol: () => Effect.succeed([]),
+    prepareCallHierarchy: () => Effect.succeed([]),
+    incomingCalls: () => Effect.succeed([]),
+    outgoingCalls: () => Effect.succeed([]),
+  }),
+)
+
+const filetime = Layer.succeed(
+  FileTime.Service,
+  FileTime.Service.of({
+    read: () => Effect.void,
+    get: () => Effect.succeed(undefined),
+    assert: () => Effect.void,
+    withLock: (_filepath, fn) => Effect.promise(fn),
+  }),
+)
+
+const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
+const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
+const patchModel = { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-5.4") } as const
+
+function makeHttp() {
+  const deps = Layer.mergeAll(
+    Session.defaultLayer,
+    Snapshot.defaultLayer,
+    LLM.defaultLayer,
+    AgentSvc.defaultLayer,
+    Command.defaultLayer,
+    Permission.layer,
+    Plugin.defaultLayer,
+    Config.defaultLayer,
+    ProviderSvc.defaultLayer,
+    filetime,
+    lsp,
+    mcp,
+    AppFileSystem.defaultLayer,
+    status,
+  ).pipe(Layer.provideMerge(infra))
+  const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps))
+  const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
+  const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
+  const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
+  return Layer.mergeAll(
+    TestLLMServer.layer,
+    SessionPrompt.layer.pipe(
+      Layer.provideMerge(compact),
+      Layer.provideMerge(proc),
+      Layer.provideMerge(registry),
+      Layer.provideMerge(trunc),
+      Layer.provide(Instruction.defaultLayer),
+      Layer.provideMerge(deps),
+    ),
+  )
+}
+
+const it = testEffect(makeHttp())
+
+it.live("e2eURL routes apply_patch through mock server", () =>
+  provideTmpdirServer(
+    Effect.fnUntraced(function* ({ dir, llm }) {
+      // Set the env var to route all LLM calls through the mock
+      const prev = process.env.OPENCODE_E2E_LLM_URL
+      process.env.OPENCODE_E2E_LLM_URL = llm.url
+      yield* Effect.addFinalizer(() =>
+        Effect.sync(() => {
+          if (prev === undefined) delete process.env.OPENCODE_E2E_LLM_URL
+          else process.env.OPENCODE_E2E_LLM_URL = prev
+        }),
+      )
+
+      const prompt = yield* SessionPrompt.Service
+      const sessions = yield* Session.Service
+
+      const session = yield* sessions.create({
+        title: "e2e url test",
+        permission: [{ permission: "*", pattern: "*", action: "allow" }],
+      })
+
+      const patch = ["*** Begin Patch", "*** Add File: e2e-test.txt", "+line 1", "+line 2", "*** End Patch"].join("\n")
+
+      // Queue mock response: match on system prompt, return apply_patch tool call
+      yield* llm.toolMatch(
+        (hit) => JSON.stringify(hit.body).includes("Your only valid response is one apply_patch tool call"),
+        "apply_patch",
+        { patchText: patch },
+      )
+      // After tool execution, LLM gets called again with tool result — return "done"
+      yield* llm.text("done")
+
+      // Seed user message
+      yield* prompt.prompt({
+        sessionID: session.id,
+        agent: "build",
+        model: patchModel,
+        noReply: true,
+        system: [
+          "You are seeding deterministic e2e UI state.",
+          "Your only valid response is one apply_patch tool call.",
+          `Use this JSON input: ${JSON.stringify({ patchText: patch })}`,
+          "Do not call any other tools.",
+          "Do not output plain text.",
+        ].join("\n"),
+        parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
+      })
+
+      // Run the agent loop
+      const result = yield* prompt.loop({ sessionID: session.id })
+      expect(result.info.role).toBe("assistant")
+
+      const calls = yield* llm.calls
+      expect(calls).toBe(2)
+
+      const missed = yield* llm.misses
+      expect(missed.length).toBe(0)
+
+      const content = yield* Effect.promise(() =>
+        Bun.file(`${dir}/e2e-test.txt`)
+          .text()
+          .catch(() => "NOT FOUND"),
+      )
+      expect(content).toContain("line 1")
+
+      let diff: Awaited<ReturnType<typeof SessionSummary.diff>> = []
+      for (let i = 0; i < 20; i++) {
+        diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id }))
+        if (diff.length > 0) break
+        yield* Effect.sleep("100 millis")
+      }
+      expect(diff.length).toBeGreaterThan(0)
+    }),
+    {
+      git: true,
+      config: () => ({
+        model: "openai/gpt-5.4",
+        agent: {
+          build: {
+            model: "openai/gpt-5.4",
+          },
+        },
+        provider: {
+          openai: {
+            options: {
+              apiKey: "test-openai-key",
+            },
+          },
+        },
+      }),
+    },
+  ),
+)
+
+it.live("server message route produces diff through mock server", () =>
+  provideTmpdirServer(
+    Effect.fnUntraced(function* ({ dir, llm }) {
+      const prev = process.env.OPENCODE_E2E_LLM_URL
+      process.env.OPENCODE_E2E_LLM_URL = llm.url
+      yield* Effect.addFinalizer(() =>
+        Effect.sync(() => {
+          if (prev === undefined) delete process.env.OPENCODE_E2E_LLM_URL
+          else process.env.OPENCODE_E2E_LLM_URL = prev
+        }),
+      )
+
+      const sessions = yield* Session.Service
+      const session = yield* sessions.create({
+        title: "e2e route test",
+        permission: [{ permission: "*", pattern: "*", action: "allow" }],
+      })
+      const app = Server.Default()
+      const patch = ["*** Begin Patch", "*** Add File: route-test.txt", "+line 1", "+line 2", "*** End Patch"].join(
+        "\n",
+      )
+
+      yield* llm.toolMatch(
+        (hit) => JSON.stringify(hit.body).includes("Your only valid response is one apply_patch tool call"),
+        "apply_patch",
+        { patchText: patch },
+      )
+      yield* llm.text("done")
+
+      const res = yield* Effect.promise(() =>
+        Promise.resolve(
+          app.request(`/session/${session.id}/message`, {
+            method: "POST",
+            headers: {
+              "content-type": "application/json",
+              "x-opencode-directory": dir,
+            },
+            body: JSON.stringify({
+              agent: "build",
+              system: [
+                "You are seeding deterministic e2e UI state.",
+                "Your only valid response is one apply_patch tool call.",
+                `Use this JSON input: ${JSON.stringify({ patchText: patch })}`,
+                "Do not call any other tools.",
+                "Do not output plain text.",
+              ].join("\n"),
+              parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
+            }),
+          }),
+        ),
+      )
+      expect(res.status).toBe(200)
+      yield* Effect.promise(() => res.json())
+
+      const calls = yield* llm.calls
+      expect(calls).toBe(2)
+
+      const content = yield* Effect.promise(() =>
+        Bun.file(`${dir}/route-test.txt`)
+          .text()
+          .catch(() => "NOT FOUND"),
+      )
+      expect(content).toContain("line 1")
+
+      let diff: Awaited<ReturnType<typeof SessionSummary.diff>> = []
+      for (let i = 0; i < 30; i++) {
+        diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id }))
+        if (diff.length > 0) break
+        yield* Effect.sleep("100 millis")
+      }
+
+      expect(diff.length).toBeGreaterThan(0)
+    }),
+    {
+      git: true,
+      config: () => ({
+        model: "openai/gpt-5.4",
+        agent: { build: { model: "openai/gpt-5.4" } },
+        provider: { openai: { options: { apiKey: "test-openai-key" } } },
+      }),
+    },
+  ),
+)