Browse Source

test(app): migrate e2e fixtures to v2 (#40374)

Brendan Allan 1 week ago
parent
commit
c74a0d8529

+ 26 - 18
packages/app/e2e/performance/timeline-stability/fixture.ts

@@ -4,15 +4,13 @@ import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
 import { SessionV1 } from "@opencode-ai/schema/session-v1"
 import type {
   AssistantMessage,
-  GlobalEvent,
   Message,
   Part,
-  Session,
-  SessionStatus,
   ToolPart,
   ToolState,
   UserMessage,
 } from "@opencode-ai/sdk/v2/client"
+import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise"
 import { expect, type Page } from "@playwright/test"
 import { Schema } from "effect"
 import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -27,18 +25,29 @@ export const assistantID = "msg_1001_timeline_assistant"
 export const title = "Timeline visual stability"
 export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
 
-type TimelinePayload = Extract<
-  GlobalEvent["payload"],
-  {
-    type:
-      | "message.updated"
-      | "message.removed"
-      | "message.part.updated"
-      | "message.part.removed"
-      | "message.part.delta"
-      | "session.status"
+type Session = SessionV1Info
+type GlobalEvent = {
+  directory: string
+  project?: string
+  workspace?: string
+  payload: {
+    id: string
+    type: string
+    properties: Record<string, unknown>
   }
->
+}
+
+type TimelineProperties = {
+  "message.updated": { sessionID: string; info: Message }
+  "message.removed": { sessionID: string; messageID: string }
+  "message.part.updated": { sessionID: string; part: Part; time: number }
+  "message.part.removed": { sessionID: string; messageID: string; partID: string }
+  "message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string }
+  "session.status": { sessionID: string; status: SessionStatus }
+}
+type TimelinePayload = {
+  [Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] }
+}[keyof TimelineProperties]
 
 type DeepReadonly<Value> = Value extends readonly unknown[]
   ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
@@ -97,7 +106,6 @@ export async function setupTimeline(
     locale?: string
     deviceScaleFactor?: number
     seedHistory?: boolean
-    protocol?: "v1" | "v2"
   } = {},
 ) {
   const sessions = input.sessions ?? [session()]
@@ -115,7 +123,7 @@ export async function setupTimeline(
     retry: input.eventRetry ?? 20,
   })
   await mockOpenCodeServer(page, {
-    protocol: input.protocol,
+    protocol: "v2",
     directory,
     project: project(),
     provider: provider(),
@@ -235,7 +243,7 @@ export function event(type: TimelinePayload["type"], properties: TimelinePayload
 }
 
 export function validateTimelineEvent(input: unknown): TimelineEvent {
-  return decodeEvent(input, decodeOptions)
+  return decodeEvent(input, decodeOptions) as TimelineEvent
 }
 
 export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
@@ -460,7 +468,7 @@ export function toolPart(
   input: Record<string, unknown>,
   options: ToolOptions<ToolStatus> = {},
 ): Omit<ToolPart, "sessionID" | "messageID"> {
-  const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
+  const base = { id, type: "tool" as const, callID: id, tool }
   if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
   if (state === "running")
     return {

+ 117 - 2
packages/app/e2e/performance/unit/mock-server.test.ts

@@ -1,6 +1,121 @@
 import { expect, test } from "bun:test"
+import type { SessionMessageInfo } from "@opencode-ai/client/promise"
 import type { Page, Route } from "@playwright/test"
-import { mockOpenCodeServer } from "../../utils/mock-server"
+import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server"
+
+test("preserves current messages", () => {
+  const message = {
+    id: "msg_current",
+    type: "user",
+    time: { created: 1 },
+    text: "current",
+    files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }],
+  } satisfies SessionMessageInfo
+
+  expect(currentMessage(message)).toBe(message)
+})
+
+test("converts rich legacy messages to current message types", () => {
+  expect(
+    currentMessage({
+      info: { id: "msg_user", role: "user", time: { created: 1 } },
+      parts: [
+        { type: "text", text: "Use @src/a.ts with @explore" },
+        {
+          type: "file",
+          mime: "application/json",
+          filename: "data.json",
+          url: "data:application/json;base64,e30=",
+        },
+        {
+          type: "file",
+          mime: "text/plain",
+          filename: "a.ts",
+          url: "src/a.ts",
+          source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } },
+        },
+        { type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } },
+      ],
+    }),
+  ).toEqual({
+    id: "msg_user",
+    type: "user",
+    time: { created: 1 },
+    text: "Use @src/a.ts with @explore",
+    files: [
+      { data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } },
+      {
+        data: "",
+        mime: "text/plain",
+        name: "a.ts",
+        source: { type: "uri", uri: "src/a.ts" },
+        mention: { text: "@src/a.ts", start: 4, end: 13 },
+      },
+    ],
+    agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }],
+  })
+
+  expect(
+    currentMessage({
+      info: {
+        id: "msg_assistant",
+        role: "assistant",
+        time: { created: 2, completed: 5 },
+        agent: "explore",
+        modelID: "model",
+        providerID: "provider",
+        variant: "high",
+        cost: 0.5,
+        tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
+        finish: "tool-calls",
+        error: { name: "MessageAbortedError", data: { message: "Stopped" } },
+      },
+      parts: [
+        { type: "text", text: "Answer" },
+        { type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } },
+        {
+          id: "prt_tool",
+          callID: "call_tool",
+          type: "tool",
+          tool: "read",
+          state: {
+            status: "completed",
+            input: { filePath: "src/a.ts" },
+            output: "contents",
+            metadata: { title: "a.ts" },
+            time: { start: 3, end: 4 },
+          },
+        },
+      ],
+    }),
+  ).toEqual({
+    id: "msg_assistant",
+    type: "assistant",
+    time: { created: 2, completed: 5 },
+    agent: "explore",
+    model: { id: "model", providerID: "provider", variant: "high" },
+    cost: 0.5,
+    tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
+    finish: "tool-calls",
+    error: { type: "MessageAbortedError", message: "Stopped" },
+    content: [
+      { type: "text", text: "Answer" },
+      { type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
+      {
+        type: "tool",
+        id: "call_tool",
+        name: "read",
+        time: { created: 3, ran: 3, completed: 4 },
+        state: {
+          status: "completed",
+          input: { filePath: "src/a.ts" },
+          content: [{ type: "text", text: "contents" }],
+          metadata: { title: "a.ts" },
+        },
+      },
+    ],
+  })
+})
 
 test("applies message latency after a list response gate is released", async () => {
   const events: string[] = []
@@ -30,7 +145,7 @@ test("applies message latency after a list response gate is released", async ()
   })
 
   const response = handler!({
-    request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
+    request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
     fulfill: () => {
       events.push("fulfill")
       return Promise.resolve()

+ 1 - 5
packages/app/e2e/regression/cross-server-tab-close.spec.ts

@@ -85,21 +85,17 @@ async function mockServers(page: Page, requests: string[]) {
     const current = url.origin === serverA ? sessionA : sessionB
     const directory = url.searchParams.get("directory")
     if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
-    if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
+    if (url.pathname === "/api/event")
       return sse(route)
-    if (url.pathname === "/global/health") return json(route, {}, 404)
     if (url.pathname === "/api/health") return json(route, { pid: 1 })
     if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
     if (url.pathname === "/api/session/active") return json(route, { data: {} })
     if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
     if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
-    if (url.pathname === `/session/${current.id}`) return json(route, current)
     if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
-    if (url.pathname === `/session/${current.id}/message`) return json(route, [])
     if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
     if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
       return json(route, [])
-    if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
     if (url.pathname === "/provider")
       return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
     if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])

+ 44 - 20
packages/app/e2e/regression/remote-session-settings.spec.ts

@@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
 import { installSseTransport } from "../utils/sse-transport"
 import { currentSession } from "../utils/mock-server"
 
-const serverA = "http://127.0.0.1:4096"
+const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
 const serverB = "http://127.0.0.1:4097"
 const directoryA = "C:/server-a"
 const directoryB = "/home/server-b"
@@ -32,7 +32,7 @@ test("session settings use the remote server context", async ({ page }) => {
     .poll(() =>
       permissionRequests.some((request) => {
         const url = new URL(request)
-        return url.origin === serverB && url.searchParams.get("directory") === directoryB
+        return url.origin === serverB && url.searchParams.get("location[directory]") === directoryB
       }),
     )
     .toBe(true)
@@ -67,7 +67,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
     .poll(() =>
       permissionRequests.some((request) => {
         const url = new URL(request)
-        return url.origin === serverA && url.searchParams.get("directory") === directoryA
+        return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
       }),
     )
     .toBe(true)
@@ -99,10 +99,10 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
     .toEqual([
       {
         origin: serverA,
-        directory: directoryA,
+        directory: undefined,
         sessionID: sessionA.id,
         permissionID: "permission-background-a",
-        body: { response: "once" },
+        body: { reply: "once" },
       },
     ])
 
@@ -127,17 +127,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
     .toEqual([
       {
         origin: serverA,
-        directory: directoryA,
+        directory: undefined,
         sessionID: sessionA.id,
         permissionID: "permission-background-a",
-        body: { response: "once" },
+        body: { reply: "once" },
       },
       {
         origin: serverA,
-        directory: directoryA,
+        directory: undefined,
         sessionID: childSessionA.id,
         permissionID: "permission-background-a-child",
-        body: { response: "once" },
+        body: { reply: "once" },
       },
     ])
 })
@@ -168,8 +168,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
     const remote = url.origin === serverB
     const directory = remote ? directoryB : directoryA
     const sessions = remote ? [sessionB] : [sessionA, childSessionA]
-    const requestDirectory = url.searchParams.get("directory")
-    const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
+    const requestDirectory = url.searchParams.get("location[directory]")
+    const response = url.pathname.match(/^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/)
     if (route.request().method() === "POST" && response) {
       permissionResponses.push({
         origin: url.origin,
@@ -181,13 +181,21 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
       return json(route, true)
     }
     if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
-    if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
+    if (url.pathname === "/api/event")
       return sse(route)
-    if (url.pathname === "/global/health") return json(route, { healthy: true })
-    if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
-      return json(route, { data: [] })
-    if (url.pathname === "/api/model/default") return json(route, { data: null })
-    if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
+    if (url.pathname === "/api/provider")
+      return json(route, {
+        location: { directory },
+        data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
+      })
+    if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
+    if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
+    if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
+    if (url.pathname === "/api/permission/request") {
+      permissionRequests.push(url.toString())
+      return json(route, { location: { directory }, data: [] })
+    }
+    if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
       return json(route, { location: { directory }, data: [] })
     if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
     if (url.pathname === "/api/mcp/resource")
@@ -211,8 +219,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
     if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
     if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
       return json(route, { data: [], cursor: {} })
-    const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
-    if (current) return json(route, current)
     if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
     if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
     if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
@@ -222,7 +228,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
     }
     if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
       return json(route, [])
-    if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
     if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
     if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
     if (url.pathname === "/project" || url.pathname === "/project/current") {
@@ -288,6 +293,25 @@ function provider(id: string) {
   }
 }
 
+function model(remote: boolean) {
+  const id = remote ? "server-b" : "server-a"
+  const name = remote ? "Server B" : "Server A"
+  return {
+    id,
+    modelID: id,
+    providerID: id,
+    name: `${name} Model`,
+    family: id,
+    capabilities: { tools: true, input: ["text"], output: ["text"] },
+    variants: [],
+    time: { released: Date.now() },
+    cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
+    status: "active",
+    enabled: true,
+    limit: { context: 200_000, output: 32_000 },
+  }
+}
+
 function json(route: Route, body: unknown, status = 200) {
   return route.fulfill({
     status,

+ 1 - 5
packages/app/e2e/regression/remote-tab-busy.spec.ts

@@ -58,22 +58,18 @@ async function mockServers(page: Page) {
     const current = url.origin === serverA ? sessionA : sessionB
     const directory = url.searchParams.get("directory")
     if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
-    if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
+    if (url.pathname === "/api/event")
       return sse(route, url.pathname === "/api/event")
-    if (url.pathname === "/global/health") return json(route, {}, 404)
     if (url.pathname === "/api/health") return json(route, { pid: 1 })
     if (url.pathname === "/api/session/active")
       return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
     if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
     if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
     if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
-    if (url.pathname === `/session/${current.id}`) return json(route, current)
     if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
-    if (url.pathname === `/session/${current.id}/message`) return json(route, [])
     if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
     if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
       return json(route, [])
-    if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
     if (url.pathname === "/provider")
       return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
     if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])

+ 2 - 1
packages/app/e2e/regression/review-open-file.spec.ts

@@ -14,6 +14,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
 test("opens and searches project files inline", async ({ page }) => {
   const searches: { query: string; dirs?: string; limit?: number }[] = []
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: projectID,
@@ -127,7 +128,7 @@ test("opens and searches project files inline", async ({ page }) => {
   await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
   await expect(sidebarToggle).toBeEnabled()
   await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
-  expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
+  expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
 
   await panel.getByRole("button", { name: "Open file" }).click()
   await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)

+ 17 - 23
packages/app/e2e/regression/review-state-persistence.spec.ts

@@ -19,36 +19,25 @@ test("restores review mode and selected file per session", async ({ page }) => {
   await expectSessionTitle(page, titleA)
   await page.getByRole("button", { name: "Toggle review" }).click()
 
-  await selectMode(page, "Git changes", "Branch changes")
-  await selectFile(page, "beta.ts")
+  await selectFile(page, "alpha.ts")
 
   await switchSession(page, titleB)
   await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
   await selectFile(page, "gamma.ts")
 
   await switchSession(page, titleA)
-  await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
-  await expectSelectedFile(page, "beta.ts")
-  await selectMode(page, "Branch changes", "Git changes")
   await expectSelectedFile(page, "alpha.ts")
-  await selectMode(page, "Git changes", "Branch changes")
-  await expectSelectedFile(page, "beta.ts")
 
   await page.reload()
   await expectSessionTitle(page, titleA)
-  await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
-  await expectSelectedFile(page, "beta.ts")
+  await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
+  await expectSelectedFile(page, "alpha.ts")
 
   await switchSession(page, titleB)
   await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
   await expectSelectedFile(page, "gamma.ts")
 })
 
-async function selectMode(page: Page, current: string, next: string) {
-  await page.getByRole("button", { name: current }).click()
-  await page.getByRole("option", { name: next }).dispatchEvent("click")
-}
-
 async function selectFile(page: Page, file: string) {
   await page.getByRole("button", { name: file }).click()
   await expectSelectedFile(page, file)
@@ -65,7 +54,7 @@ async function switchSession(page: Page, title: string) {
 
 async function setup(page: Page) {
   await mockOpenCodeServer(page, {
-    protocol: "v1",
+    protocol: "v2",
     directory,
     project: {
       id: projectID,
@@ -89,22 +78,27 @@ async function setup(page: Page) {
     sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
     pageMessages: () => ({ items: [] }),
   })
-  await page.route(/\/vcs(?:\?.*)?$/, (route) =>
+  await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory, canonical: directory } },
+        data: { branch: "feature", defaultBranch: "dev" },
+      }),
     }),
   )
-  await page.route("**/vcs/diff**", (route) =>
+  await page.route("**/api/vcs/diff**", (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify(
-        new URL(route.request().url()).searchParams.get("mode") === "branch"
-          ? [diff("src/alpha.ts"), diff("src/beta.ts")]
-          : [diff("src/alpha.ts"), diff("src/gamma.ts")],
-      ),
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory, canonical: directory } },
+        data:
+          new URL(route.request().url()).searchParams.get("mode") === "branch"
+            ? [diff("src/alpha.ts"), diff("src/beta.ts")]
+            : [diff("src/alpha.ts"), diff("src/gamma.ts")],
+      }),
     }),
   )
   await page.addInitScript(

+ 30 - 46
packages/app/e2e/regression/review-terminal-stacked.spec.ts

@@ -25,7 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
   let detailFailures = 1
   await page.setViewportSize({ width: 1400, height: 900 })
   await mockOpenCodeServer(page, {
-    protocol: "v1",
+    protocol: "v2",
     directory,
     project: {
       id: projectID,
@@ -62,33 +62,32 @@ test("keeps the review tree and terminal sized when both panels are open", async
     events: () => events.splice(0, 1),
     eventRetry: 16,
   })
-  await page.route(/\/vcs(?:\?.*)?$/, (route) =>
+  await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
       body: JSON.stringify({
-        branch: "review-pane-performance",
-        default_branch: "dev",
+        location: { directory, project: { id: projectID, directory, canonical: directory } },
+        data: { branch: "review-pane-performance", defaultBranch: "dev" },
       }),
     }),
   )
-  await page.route("**/vcs/diff**", (route) => {
+  await page.route("**/api/vcs/diff**", (route) => {
     const url = new URL(route.request().url())
-    const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
+    const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
     const detail = scope?.endsWith("/src/branch/d00027")
     if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
     return route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify(
-        url.searchParams.get("mode") === "branch"
-          ? detail
-            ? branchDiffs
-                .filter((diff) => diff.file.startsWith("src/branch/d00027/"))
-                .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
-            : branchDiffs
-          : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
-      ),
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory, canonical: directory } },
+        data: detail
+          ? branchDiffs
+              .filter((diff) => diff.file.startsWith("src/branch/d00027/"))
+              .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
+          : branchDiffs,
+      }),
     })
   })
   await page.route("**/pty*", (route) =>
@@ -109,7 +108,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
       }),
     }),
   )
-  await page.route("**/pty/pty_review_terminal*", (route) =>
+  await page.route("**/api/pty/pty_review_terminal*", (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
@@ -127,7 +126,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
       }),
     }),
   )
-  await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
+  await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
@@ -137,7 +136,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
       }),
     }),
   )
-  await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
+  await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
   await page.addInitScript(() => {
     localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
     localStorage.setItem(
@@ -149,9 +148,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
   await expectSessionTitle(page, title)
   await expect(page.locator("#review-panel")).toBeVisible()
-  await expectTree(page, 8, "git-0.ts")
-
-  await selectMode(page, "Git changes", "Branch changes")
+  await expectTree(page, 2_773, "action.yml")
   await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
   await page.keyboard.press("Control+Backquote")
   await expect(page.locator("#terminal-panel")).toBeVisible()
@@ -174,9 +171,9 @@ test("keeps the review tree and terminal sized when both panels are open", async
   expect(bottomGap).toBeLessThanOrEqual(16)
   const lazyDiff = page.waitForRequest((request) => {
     const url = new URL(request.url())
-    return (
-      url.pathname === "/vcs/diff" &&
-      url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
+      return (
+        url.pathname === "/api/vcs/diff" &&
+      url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
     )
   })
   await lastFile.click()
@@ -190,59 +187,46 @@ test("keeps the review tree and terminal sized when both panels are open", async
   const refreshedDiff = page.waitForRequest((request) => {
     const url = new URL(request.url())
     return (
-      url.pathname === "/vcs/diff" &&
-      url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
+      url.pathname === "/api/vcs/diff" &&
+      url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
     )
   })
   sessionStatus[sessionID] = { type: "idle" }
   events.push(statusEvent("idle"))
   await refreshedDiff
   await expect(preview).toContainText("after-2")
-  await selectMode(page, "Branch changes", "Git changes")
-  await expectTree(page, 8, "git-0.ts")
-  await page.getByRole("button", { name: "git-0.ts" }).click()
-  await selectMode(page, "Git changes", "Branch changes")
-  await expectTree(page, 2_773, "action.yml")
-
   const filter = page.getByRole("searchbox", { name: "Filter files" })
   await filter.fill("generated-2738")
   await expectTree(page, 1, "generated-2738.ts")
   await filter.fill("")
-  await expectTree(page, 2_773, "action.yml")
+  await expectTree(page, 2_773, "generated-2738.ts")
 
   await page.getByRole("button", { name: "Toggle file tree" }).click()
   await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
   await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
   await page.getByRole("button", { name: "Toggle file tree" }).click()
-  await expectTree(page, 2_773, "action.yml")
+  await expectTree(page, 2_773, "generated-2738.ts")
 
   await page.keyboard.press("Control+Backquote")
   await expect(page.locator("#terminal-panel")).toHaveCount(0)
-  await expectTree(page, 2_773, "action.yml")
+  await expectTree(page, 2_773, "generated-2738.ts")
   await page.keyboard.press("Control+Backquote")
   await expect(page.locator("#terminal-panel")).toBeVisible()
-  await expectTree(page, 2_773, "action.yml")
+  await expectTree(page, 2_773, "generated-2738.ts")
 
   await page.getByRole("button", { name: "Toggle review" }).click()
   await expect(page.locator("#review-panel")).toHaveCount(0)
   await page.getByRole("button", { name: "Toggle review" }).click()
-  await expectTree(page, 2_773, "action.yml")
+  await expectTree(page, 2_773, "generated-2738.ts")
   await page.setViewportSize({ width: 1_000, height: 700 })
-  await expectTree(page, 2_773, "action.yml")
+  await expectTree(page, 2_773, "generated-2738.ts")
   await expectStackGeometry(page)
   await page.setViewportSize({ width: 1_000, height: 120 })
   await page.setViewportSize({ width: 1_400, height: 900 })
-  await expectTree(page, 2_773, "action.yml")
+  await expectTree(page, 2_773, "generated-2738.ts")
   await expectStackGeometry(page)
 })
 
-async function selectMode(page: Page, current: string, next: string) {
-  await page.getByRole("button", { name: current }).click()
-  const option = page.getByRole("option", { name: next })
-  await expect(option).toBeVisible()
-  await option.click()
-}
-
 async function expectTree(page: Page, total: number, file: string) {
   await expectMountedTree(page, total)
   await expect(page.getByRole("button", { name: file })).toBeVisible()

+ 1 - 1
packages/app/e2e/regression/session-timeline-collapse-state.spec.ts

@@ -52,7 +52,7 @@ const editPart = {
   sessionID,
   messageID: assistantMessageID,
   type: "tool",
-  callID: "call_edit_regression",
+  callID: editPartID,
   tool: "edit",
   state: {
     status: "completed",

+ 1 - 1
packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts

@@ -103,7 +103,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
   await timeline.send(status("idle"), 350)
   await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
   await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
-  await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
+  await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response")
 })
 
 function lines(count: number) {

+ 10 - 7
packages/app/e2e/regression/session-timeline-projection.spec.ts

@@ -89,7 +89,6 @@ test.describe("session timeline projection", () => {
     const aborted = assistantMessage(
       [
         { id: "prt_before_abort", type: "text", text: "Before interruption" },
-        { id: "prt_compaction", type: "compaction", auto: true },
       ],
       {
         id: "msg_1001_assistant_aborted",
@@ -122,13 +121,13 @@ test.describe("session timeline projection", () => {
     await scroller.evaluate((element) => (element.scrollTop = 0))
 
     await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
-    await expect(page.getByText("Session compacted", { exact: true })).toBeVisible()
+    await expect(page.getByText("Before interruption", { exact: true })).toBeVisible()
     await expect(page.getByText("Visible provider failure")).toBeVisible()
     await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
     await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
   })
 
-  test("renders comment strips and historical diff summary overflow", async ({ page }) => {
+  test("renders legacy synthetic comments as ordinary V2 user text", async ({ page }) => {
     const user = userMessage(
       [
         userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
@@ -159,10 +158,14 @@ test.describe("session timeline projection", () => {
     const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
     await scroller.evaluate((element) => (element.scrollTop = 0))
 
-    await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible()
-    await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
-    await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
-    await expect(page.getByText(/show all/i)).toBeVisible()
+    await expect(
+      page.getByText(
+        "The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment",
+        { exact: true },
+      ),
+    ).toBeVisible()
+    await expect(page.locator('[data-timeline-row="CommentStrip"]')).toHaveCount(0)
+    await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
   })
 
   test("renders interruption independently when the turn is not compacted", async ({ page }) => {

+ 3 - 2
packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts

@@ -1,5 +1,6 @@
 import { expect, test } from "@playwright/test"
 import {
+  assistantID,
   assistantMessage,
   reasoningPart,
   setupTimeline,
@@ -70,7 +71,7 @@ for (const profile of profiles) {
     await timeline.send(status("busy"), 150)
 
     await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
-    await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0)
+    await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0)
     if (!profile.summaries && profile.reasoning.trim()) {
       await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
     }
@@ -89,5 +90,5 @@ test("does not infer reasoning visibility from provider identity", async ({ page
 
   await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
   await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
-  await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible()
+  await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toBeVisible()
 })

+ 4 - 3
packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts

@@ -23,10 +23,11 @@ test("groups singleton and separated context operations at correct boundaries",
   ]
   await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
 
-  await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
-  await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
+  await expect(
+    page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'),
+  ).toBeVisible()
   await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
-  await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
+  await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
 })
 
 test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {

+ 0 - 1
packages/app/e2e/regression/session-timeline-shell-outline.spec.ts

@@ -145,7 +145,6 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
       }),
     ],
   })
-  await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
   await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
 
   const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>

+ 3 - 3
packages/app/e2e/regression/session-timeline-transport.spec.ts

@@ -90,7 +90,7 @@ test("reconnects after a stream error", async ({ page }) => {
 })
 
 test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
-  const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
+  const timeline = await setupTimeline(page, { eventRetry: 10 })
   const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
     id: "timeline-event-7",
   })
@@ -107,10 +107,10 @@ test("passes through non-event fetches", async ({ page }) => {
   const timeline = await setupTimeline(page)
 
   const health = await page.evaluate(async () => {
-    const response = await fetch("/global/health")
+    const response = await fetch("/api/health")
     return response.json()
   })
 
-  expect(health).toEqual({ healthy: true })
+  expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 })
   expect(await timeline.transport.connections()).toHaveLength(1)
 })

+ 1 - 5
packages/app/e2e/regression/tab-navigate-mousedown.spec.ts

@@ -89,23 +89,19 @@ async function mockServer(page: Page) {
     if (url.origin !== server) return route.fallback()
     if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
       return new Promise(() => {})
-    if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
+    if (url.pathname === "/api/event")
       return sse(route)
-    if (url.pathname === "/global/health") return json(route, { healthy: true })
     if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
     if (url.pathname === "/api/session/active") return json(route, { data: {} })
     const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
     if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
     if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
       return json(route, { data: [], cursor: {} })
-    const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
-    if (byId) return json(route, byId)
     if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
     if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
     if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
     if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
       return json(route, [])
-    if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
     if (url.pathname === "/provider")
       return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
     if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])

+ 15 - 24
packages/app/e2e/user-story/model-selection-flow.spec.ts

@@ -4,12 +4,9 @@ import { expectAppVisible } from "../utils/waits"
 
 const directory = "C:/OpenCode/NewProject"
 
-test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
-  let connectedGo = false
-  let pendingGo = false
-  const connections: Array<{ integrationID: string; body: unknown }> = []
-
+test("creates a session in a new project and selects its model", async ({ page }) => {
   await mockOpenCodeServer(page, {
+    protocol: "v1",
     directory,
     project: {
       id: "proj_model_selection_flow",
@@ -46,17 +43,9 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
           },
         },
       ],
-      connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
+      connected: ["opencode", "opencode-go"],
       default: { providerID: "opencode", modelID: "free-model" },
     }),
-    integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
-    onConnectKey: (input) => {
-      connections.push(input)
-      if (input.integrationID === "opencode-go") pendingGo = true
-    },
-    onInstanceDispose: () => {
-      if (pendingGo) connectedGo = true
-    },
     sessions: [],
     pageMessages: () => ({ items: [] }),
     fileList: (path) =>
@@ -66,6 +55,17 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
   await page.addInitScript(() => {
     localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
     localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
+    localStorage.setItem(
+      "opencode.global.dat:model",
+      JSON.stringify({
+        user: [
+          { providerID: "opencode", modelID: "free-model", visibility: "show" },
+          { providerID: "opencode-go", modelID: "go-model-1", visibility: "show" },
+        ],
+        recent: [],
+        variant: {},
+      }),
+    )
   })
 
   await page.goto("/")
@@ -79,16 +79,7 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
 
   const modelControl = page.locator('[data-action="prompt-model"]')
   await modelControl.click()
-  await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
-
-  await page.locator('[data-provider-id="opencode-go"]').click()
-  await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
-  await page.locator('[data-action="provider-connect-submit"]').click()
-  await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
-  expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
-
-  await expect(modelControl).toHaveAttribute("data-control-type", "popover")
-  await modelControl.click()
+  await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
   const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
   await expect(goModel).toBeVisible()
   await goModel.click()

+ 358 - 74
packages/app/e2e/utils/mock-server.ts

@@ -1,4 +1,12 @@
 import type { Page, Route } from "@playwright/test"
+import type {
+  JsonValue,
+  PromptAgentAttachment,
+  PromptFileAttachment,
+  SessionMessageAssistant,
+  SessionMessageInfo,
+  SessionStructuredError,
+} from "@opencode-ai/client/promise"
 
 const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
 const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
@@ -47,7 +55,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     "/vcs": { branch: "main", default_branch: "main" },
     "/session": config.sessions,
   }
-
   await page.route("**/*", async (route) => {
     const url = new URL(route.request().url())
     const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
@@ -77,8 +84,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     if (path === "/api/health" && config.protocol === "v2")
       return json(route, { healthy: true, version: "2.0.0", pid: 1 })
     if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
-    if (path === "/provider")
-      return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
+    if (path === "/provider") return json(route, providerConfig(config))
     if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
     const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
     if (legacyAuth && route.request().method() === "PUT") {
@@ -134,7 +140,17 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
           },
         ],
       })
+    if (path === "/api/provider")
+      return json(route, {
+        location: location(config),
+        data: currentProviders(providerConfig(config)),
+      })
+    if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
+    if (path === "/api/model/default")
+      return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
+    if (path === "/api/integration") return json(route, { location: location(config), data: [] })
     if (path === "/api/command") return json(route, { location: location(config), data: [] })
+    if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
     if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
     if (path === "/api/mcp/resource")
       return json(route, { location: location(config), data: { resources: [], templates: [] } })
@@ -142,25 +158,31 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     if (integration && route.request().method() === "GET")
       return json(route, {
         location: location(config),
-        data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
+        data: {
+          id: integration,
+          name: integration,
+          methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
+          connections: [],
+        },
       })
     const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
     if (integrationConnect && route.request().method() === "POST") {
       config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
       return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
     }
+    if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
     if (path === "/api/project") return json(route, [config.project])
     if (path === "/api/project/current")
       return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
-    if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
-    if (path === "/api/path")
-      return json(route, {
-        state: config.directory,
-        config: config.directory,
-        worktree: config.directory,
-        directory: config.directory,
-        home: "C:/OpenCode",
-      })
+    if (path === "/api/location") return json(route, location(config))
+    const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
+    if (projectCopy && route.request().method() === "POST") {
+      const input = route.request().postDataJSON() as { directory: string; name?: string }
+      return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
+    }
+    if (projectCopy && route.request().method() === "DELETE")
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
     if (path === "/api/permission/request")
       return json(route, {
         location: location(config),
@@ -177,11 +199,43 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
     if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
     if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
+    if (path === "/api/fs/list" && config.fileList)
+      return json(route, {
+        location: location(config),
+        data: await config.fileList(url.searchParams.get("path") ?? ""),
+      })
+    const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
+    if (fileRead && config.fileContent) {
+      const value = await config.fileContent(decodeURIComponent(fileRead))
+      const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
+      return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
+    }
+    if (path === "/api/fs/find" && config.findFiles) {
+      const entries = await config.findFiles({
+        query: url.searchParams.get("query") ?? "",
+        dirs: url.searchParams.get("type") ?? undefined,
+        limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
+      })
+      return json(route, {
+        location: location(config),
+        data: Array.isArray(entries)
+          ? entries.map((entry) =>
+              typeof entry === "string"
+                ? {
+                    name: entry.split(/[\\/]/).at(-1) ?? entry,
+                    path: entry,
+                    absolute: `${config.directory}/${entry}`,
+                    type: "directory",
+                    ignored: false,
+                  }
+                : entry,
+            )
+          : entries,
+      })
+    }
     if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
     if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
       return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
-    if (emptyObject.has(path)) return json(route, {})
-    if (emptyList.has(path)) return json(route, [])
     if (path === "/api/session") {
       const directory = url.searchParams.get("directory")
       const parentID = url.searchParams.get("parentID")
@@ -208,7 +262,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       })
     }
     if (path === "/api/session/active") {
-      const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
+      const statuses = (
+        typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
+      ) as Record<string, { type?: string }>
       return json(route, {
         data: Object.fromEntries(
           Object.entries(statuses).flatMap(([id, status]) =>
@@ -226,12 +282,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
       return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
     }
-    if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
-      return json(route, true)
-    }
-    if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
+    if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") return json(route, true)
+    if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
       return json(route, true)
-    }
     if (
       /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
       route.request().method() === "POST"
@@ -241,6 +294,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
       return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
     }
+    if (emptyObject.has(path)) return json(route, {})
+    if (emptyList.has(path)) return json(route, [])
     if (path in staticRoutes) return json(route, staticRoutes[path])
 
     const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
@@ -252,12 +307,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       })
     }
 
-    const sessionMatch = path.match(/^\/session\/([^/]+)$/)
-    if (sessionMatch) {
-      const session = config.sessions.find((s) => s.id === sessionMatch[1])
-      return json(route, session ?? {})
+    const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
+    if (currentMessageMatch) {
+      config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! })
+      if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
+      const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!)
+      if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
+      return json(route, { data: currentMessage(message) })
     }
 
+    const sessionMatch = path.match(/^\/session\/([^/]+)$/)
+    if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {})
+
     const projectMatch = path.match(/^\/project\/([^/]+)$/)
     if (projectMatch) return json(route, config.project)
 
@@ -300,8 +361,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
       await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
       if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
-      const limit = Number(url.searchParams.get("limit") ?? 80)
-      const pageData = config.pageMessages(messagesMatch[1], limit, before)
+      const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before)
       config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
       if (!pageData.cursor) return json(route, pageData.items)
       const cursor = `cursor_${++nextCursor}`
@@ -317,10 +377,75 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
 function location(config: MockServerConfig) {
   return {
     directory: config.directory,
-    project: { id: (config.project as { id?: string }).id, directory: config.directory },
+    project: { id: (config.project as { id?: string }).id, directory: config.directory, canonical: config.directory },
   }
 }
 
+function providerConfig(config: MockServerConfig) {
+  return typeof config.provider === "function" ? config.provider() : config.provider
+}
+
+function currentProviders(value: unknown) {
+  if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
+  return value.all.filter(record).flatMap((provider) =>
+    typeof provider.id === "string" && typeof provider.name === "string"
+      ? [{ id: provider.id, name: provider.name, package: provider.id }]
+      : [],
+  )
+}
+
+function currentModels(value: unknown) {
+  if (!record(value) || !Array.isArray(value.all)) return []
+  return value.all.filter(record).flatMap((provider) => {
+    if (typeof provider.id !== "string" || !record(provider.models)) return []
+    return Object.values(provider.models)
+      .filter(record)
+      .flatMap((model) => {
+        if (typeof model.id !== "string" || typeof model.name !== "string") return []
+        const limit = record(model.limit) ? model.limit : {}
+        const cost = record(model.cost) ? model.cost : {}
+        return [
+          {
+            id: model.id,
+            modelID: model.id,
+            providerID: provider.id,
+            name: model.name,
+            capabilities: { tools: true, input: ["text"], output: ["text"] },
+            variants: record(model.variants)
+              ? Object.entries(model.variants).map(([id, settings]) => ({
+                  id,
+                  ...(jsonRecord(settings) ? { settings: jsonRecord(settings) } : {}),
+                }))
+              : [],
+            time: { released: Date.now() },
+            cost: [
+              {
+                input: typeof cost.input === "number" ? cost.input : 0,
+                output: typeof cost.output === "number" ? cost.output : 0,
+                cache: { read: 0, write: 0 },
+              },
+            ],
+            status: "active",
+            enabled: true,
+            limit: {
+              context: typeof limit.context === "number" ? limit.context : 200_000,
+              output: typeof limit.output === "number" ? limit.output : 32_000,
+            },
+          },
+        ]
+      })
+  })
+}
+
+function currentDefaultModel(value: unknown) {
+  if (!record(value) || !record(value.default)) return null
+  const selected = value.default
+  const models = currentModels(value)
+  return models.find(
+    (model) => model.providerID === selected.providerID && model.id === selected.modelID,
+  ) ?? null
+}
+
 function currentPermission(value: unknown) {
   const permission = value as Record<string, unknown>
   if (permission.action) return permission
@@ -364,65 +489,224 @@ export function currentSession(session: { id: string } & Record<string, unknown>
   }
 }
 
-function currentMessage(value: unknown) {
-  const item = value as {
-    info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
-    parts: Array<Record<string, unknown> & { type: string }>
+export function currentMessage(value: unknown): SessionMessageInfo {
+  if (isCurrentMessage(value)) return value
+  if (!record(value) || !record(value.info) || !Array.isArray(value.parts)) throw new Error("Invalid message fixture")
+
+  const info = value.info
+  const parts = value.parts.filter(record)
+  if (typeof info.id !== "string" || !record(info.time) || typeof info.time.created !== "number")
+    throw new Error("Invalid legacy message fixture")
+
+  const time = {
+    created: info.time.created,
+    ...(typeof info.time.completed === "number" ? { completed: info.time.completed } : {}),
   }
-  if (item.info.role === "user") {
+  if (info.role === "user") {
     return {
-      id: item.info.id,
+      id: info.id,
       type: "user",
-      time: item.info.time,
-      text: item.parts
+      time: { created: time.created },
+      text: parts
         .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
         .join("\n"),
+      files: parts.flatMap((part) => (part.type === "file" ? legacyFile(part) : [])),
+      agents: parts.flatMap((part) => (part.type === "agent" ? legacyAgent(part) : [])),
     }
   }
+  if (info.role !== "assistant") throw new Error("Invalid legacy message role")
+
   return {
-    id: item.info.id,
+    id: info.id,
     type: "assistant",
-    time: item.info.time,
-    agent: item.info.agent ?? "build",
-    model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
-    cost: item.info.cost,
-    tokens: item.info.tokens,
-    error: item.info.error,
-    content: item.parts.flatMap<unknown>((part) => {
-      if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
-      if (part.type !== "tool") return []
-      const state = part.state as Record<string, unknown>
-      return [
-        {
-          type: "tool",
-          id: part.id,
-          name: part.tool,
-          time: state.time ?? { created: item.info.time.created },
-          state:
-            state.status === "pending"
-              ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
-              : state.status === "completed"
-                ? {
-                    status: "completed",
-                    input: state.input ?? {},
-                    structured: state.metadata ?? {},
-                    content: [{ type: "text", text: state.output ?? "" }],
-                  }
-                : state.status === "error"
-                  ? {
-                      status: "error",
-                      input: state.input ?? {},
-                      structured: state.metadata ?? {},
-                      content: [],
-                      error: { type: "ToolError", message: state.error ?? "Tool failed" },
-                    }
-                  : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
+    time,
+    agent: typeof info.agent === "string" ? info.agent : typeof info.mode === "string" ? info.mode : "build",
+    model: {
+      id: typeof info.modelID === "string" ? info.modelID : "model",
+      providerID: typeof info.providerID === "string" ? info.providerID : "provider",
+      ...(typeof info.variant === "string" ? { variant: info.variant } : {}),
+    },
+    content: parts.flatMap((part) => legacyAssistantContent(part, time.created)),
+    ...(typeof info.cost === "number" ? { cost: info.cost } : {}),
+    ...(tokens(info.tokens) ? { tokens: tokens(info.tokens) } : {}),
+    ...(structuredError(info.error) ? { error: structuredError(info.error) } : {}),
+    ...(finish(info.finish) ? { finish: finish(info.finish) } : {}),
+  }
+}
+
+function isCurrentMessage(value: unknown): value is SessionMessageInfo {
+  return record(value) && typeof value.id === "string" && typeof value.type === "string" && !record(value.info)
+}
+
+function legacyFile(part: Record<string, unknown>): PromptFileAttachment[] {
+  if (typeof part.mime !== "string" || typeof part.url !== "string") return []
+  const data = part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? ""
+  const source = record(part.source) ? part.source : undefined
+  const sourceText = source && record(source.text) ? source.text : undefined
+  const mention = mentionFrom(sourceText)
+  const uri = source?.type === "resource" && typeof source.uri === "string" ? source.uri : part.url
+  return [
+    {
+      data,
+      mime: part.mime,
+      source: part.url.startsWith("data:") ? { type: "inline" } : { type: "uri", uri },
+      ...(typeof part.filename === "string" ? { name: part.filename } : {}),
+      ...(mention ? { mention } : {}),
+    },
+  ]
+}
+
+function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
+  if (typeof part.name !== "string") return []
+  const mention = mentionFrom(record(part.source) ? part.source : undefined)
+  return [{ name: part.name, ...(mention ? { mention } : {}) }]
+}
+
+function mentionFrom(value: Record<string, unknown> | undefined) {
+  if (
+    !value ||
+    typeof value.value !== "string" ||
+    typeof value.start !== "number" ||
+    typeof value.end !== "number"
+  )
+    return
+  return { text: value.value, start: value.start, end: value.end }
+}
+
+function legacyAssistantContent(
+  part: Record<string, unknown>,
+  created: number,
+): SessionMessageAssistant["content"] {
+  if (part.type === "text" && typeof part.text === "string")
+    return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }]
+  if (part.type === "reasoning" && typeof part.text === "string") {
+    const time = record(part.time) ? part.time : undefined
+    return [
+      {
+        type: "reasoning",
+        text: part.text,
+        ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}),
+        ...(time && typeof time.start === "number"
+          ? {
+              time: {
+                created: time.start,
+                ...(typeof time.end === "number" ? { completed: time.end } : {}),
+              },
+            }
+          : {}),
+      },
+    ]
+  }
+  if (part.type !== "tool" || typeof part.id !== "string" || typeof part.tool !== "string" || !record(part.state))
+    return []
+
+  const state = part.state
+  const time = record(state.time) ? state.time : undefined
+  const toolTime = {
+    created: time && typeof time.start === "number" ? time.start : created,
+    ...(time && typeof time.start === "number" ? { ran: time.start } : {}),
+    ...(time && typeof time.end === "number" ? { completed: time.end } : {}),
+  }
+  const input = jsonRecord(state.input) ?? {}
+  const metadata = jsonRecord(state.metadata)
+  const base = {
+    type: "tool" as const,
+    id: typeof part.callID === "string" ? part.callID : part.id,
+    name: part.tool,
+    time: toolTime,
+    ...(typeof part.executed === "boolean" ? { executed: part.executed } : {}),
+    ...(jsonRecord(part.providerState) ? { providerState: jsonRecord(part.providerState) } : {}),
+    ...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
+  }
+  if (state.status === "pending")
+    return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }]
+  if (state.status === "completed")
+    return [
+      {
+        ...base,
+        state: {
+          status: "completed",
+          input,
+          content: [{ type: "text", text: typeof state.output === "string" ? state.output : "" }],
+          ...(metadata ? { metadata } : {}),
         },
-      ]
-    }),
+      },
+    ]
+  if (state.status === "error")
+    return [
+      {
+        ...base,
+        state: {
+          status: "error",
+          input,
+          error: structuredError(state.error) ?? { type: "ToolError", message: "Tool failed" },
+          ...(metadata ? { metadata } : {}),
+        },
+      },
+    ]
+  return [{ ...base, state: { status: "running", input, metadata: metadata ?? {} } }]
+}
+
+function structuredError(value: unknown): SessionStructuredError | undefined {
+  if (typeof value === "string") return { type: "Error", message: value }
+  if (!record(value)) return
+  if (typeof value.type === "string" && typeof value.message === "string")
+    return { type: value.type, message: value.message }
+  if (typeof value.name !== "string" || !record(value.data) || typeof value.data.message !== "string") return
+  return { type: value.name, message: value.data.message }
+}
+
+function tokens(value: unknown): SessionMessageAssistant["tokens"] | undefined {
+  if (!record(value) || !record(value.cache)) return
+  if (
+    typeof value.input !== "number" ||
+    typeof value.output !== "number" ||
+    typeof value.reasoning !== "number" ||
+    typeof value.cache.read !== "number" ||
+    typeof value.cache.write !== "number"
+  )
+    return
+  return {
+    input: value.input,
+    output: value.output,
+    reasoning: value.reasoning,
+    cache: { read: value.cache.read, write: value.cache.write },
   }
 }
 
+function finish(value: unknown): SessionMessageAssistant["finish"] | undefined {
+  if (
+    value === "stop" ||
+    value === "length" ||
+    value === "tool-calls" ||
+    value === "content-filter" ||
+    value === "error" ||
+    value === "unknown"
+  )
+    return value
+}
+
+function jsonRecord(value: unknown): Record<string, JsonValue> | undefined {
+  if (!record(value)) return
+  return Object.fromEntries(
+    Object.entries(value).flatMap(([key, item]) => {
+      const next = jsonValue(item)
+      return next === undefined ? [] : [[key, next]]
+    }),
+  )
+}
+
+function jsonValue(value: unknown): JsonValue | undefined {
+  if (value === null || typeof value === "string" || typeof value === "boolean") return value
+  if (typeof value === "number") return Number.isFinite(value) ? value : null
+  if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null)
+  return jsonRecord(value)
+}
+
+function record(value: unknown): value is Record<string, unknown> {
+  return !!value && typeof value === "object" && !Array.isArray(value)
+}
+
 function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
   return route.fulfill({
     status,