Преглед изворни кода

feat(app): project current server state (#38459)

Brendan Allan пре 1 месец
родитељ
комит
37c263e153
38 измењених фајлова са 2922 додато и 477 уклоњено
  1. 5 1
      packages/app/e2e/regression/remote-tab-busy.spec.ts
  2. 1 1
      packages/app/e2e/regression/review-open-file.spec.ts
  3. 1 1
      packages/app/e2e/regression/review-state-persistence.spec.ts
  4. 6 1
      packages/app/e2e/utils/mock-server.ts
  5. 8 0
      packages/app/e2e/utils/sse-transport.ts
  6. 2 2
      packages/app/src/components/prompt-input-v2.tsx
  7. 1 1
      packages/app/src/components/prompt-input.tsx
  8. 24 0
      packages/app/src/components/prompt-input/submit.test.ts
  9. 65 33
      packages/app/src/components/prompt-input/submit.ts
  10. 1 1
      packages/app/src/components/status-popover-indicator.test.ts
  11. 4 3
      packages/app/src/components/status-popover-indicator.ts
  12. 6 5
      packages/app/src/context/directory-sync.ts
  13. 124 68
      packages/app/src/context/global-sync/bootstrap.test.ts
  14. 205 77
      packages/app/src/context/global-sync/bootstrap.ts
  15. 1 0
      packages/app/src/context/global-sync/child-store.ts
  16. 7 3
      packages/app/src/context/global-sync/event-reducer.test.ts
  17. 73 7
      packages/app/src/context/global-sync/event-reducer.ts
  18. 20 0
      packages/app/src/context/global-sync/mcp.test.ts
  19. 3 2
      packages/app/src/context/global-sync/mcp.ts
  20. 8 11
      packages/app/src/context/global-sync/session-cache.test.ts
  21. 6 10
      packages/app/src/context/global-sync/session-cache.ts
  22. 22 14
      packages/app/src/context/global-sync/session-load.ts
  23. 8 19
      packages/app/src/context/global-sync/types.ts
  24. 99 23
      packages/app/src/context/global-sync/utils.test.ts
  25. 146 26
      packages/app/src/context/global-sync/utils.ts
  26. 148 0
      packages/app/src/context/server-session-v2-reducer.test.ts
  27. 434 0
      packages/app/src/context/server-session-v2-reducer.ts
  28. 157 0
      packages/app/src/context/server-session.test.ts
  29. 247 38
      packages/app/src/context/server-session.ts
  30. 143 30
      packages/app/src/context/server-sync.test.ts
  31. 232 39
      packages/app/src/context/server-sync.tsx
  32. 21 59
      packages/app/src/pages/session.tsx
  33. 14 1
      packages/app/src/utils/server-compat.test.ts
  34. 1 1
      packages/app/src/utils/server-compat.ts
  35. 200 0
      packages/app/src/utils/session-message.test.ts
  36. 348 0
      packages/app/src/utils/session-message.ts
  37. 94 0
      packages/app/src/utils/session.test.ts
  38. 37 0
      packages/app/src/utils/session.ts

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

@@ -105,5 +105,9 @@ function json(route: Route, body: unknown, status = 200) {
 }
 
 function sse(route: Route) {
-  return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
+  return route.fulfill({
+    status: 200,
+    contentType: "text/event-stream",
+    body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`,
+  })
 }

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

@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
   await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
   await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
   await expect(sidebarToggle).toBeDisabled()
-  await panel.getByRole("tab", { name: /Review/ }).click()
+  await panel.locator("#session-side-panel-review-tab").click()
   await expect(sidebarToggle).toBeEnabled()
   await panel.getByRole("tab", { name: "Open file" }).click()
   await page.keyboard.press("Control+w")

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

@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
 
 async function selectMode(page: Page, current: string, next: string) {
   await page.getByRole("button", { name: current }).click()
-  await page.getByRole("option", { name: next }).click()
+  await page.getByRole("option", { name: next }).dispatchEvent("click")
 }
 
 async function selectFile(page: Page, file: string) {

+ 6 - 1
packages/app/e2e/utils/mock-server.ts

@@ -61,7 +61,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
         route,
         path === "/api/event"
           ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
-          : events,
+          : [
+              ...(path === "/global/event"
+                ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
+                : []),
+              ...(events ?? []),
+            ],
         config.eventRetry,
       )
     }

+ 8 - 0
packages/app/e2e/utils/sse-transport.ts

@@ -197,6 +197,14 @@ export async function installSseTransport<T>(
               controller.enqueue(
                 encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
               )
+            if (url.pathname === "/global/event")
+              controller.enqueue(
+                encoder.encode(
+                  frame({
+                    payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
+                  }),
+                ),
+              )
             request.signal.addEventListener(
               "abort",
               () => {

+ 2 - 2
packages/app/src/components/prompt-input-v2.tsx

@@ -310,7 +310,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
   )
   const resources = createMemo(() =>
     Object.values(sync().data.mcp_resource).map((resource) => ({
-      id: `resource:${resource.client}:${resource.uri}`,
+      id: `resource:${resource.server}:${resource.uri}`,
       kind: "resource" as const,
       label: `@${resource.name}`,
       path: resource.uri,
@@ -327,7 +327,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
         source: {
           type: "resource" as const,
           text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
-          clientName: resource.client,
+          clientName: resource.server,
           uri: resource.uri,
         },
       },

+ 1 - 1
packages/app/src/components/prompt-input.tsx

@@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         type: "resource",
         name: resource.name,
         uri: resource.uri,
-        client: resource.client,
+        client: resource.server,
         display: resource.name,
         description: resource.description,
         mime: resource.mimeType,

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

@@ -19,6 +19,7 @@ const optimistic: Array<{
 }> = []
 const optimisticSeeded: boolean[] = []
 const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
+const sessionDirectories: Record<string, string> = {}
 const promoted: Array<{ directory: string; sessionID: string }> = []
 const sentShell: string[] = []
 const syncedDirectories: string[] = []
@@ -89,6 +90,27 @@ const clientFor = (directory: string) => {
   }
 }
 
+const api = {
+  session: {
+    async create(input: { location: { directory: string } }) {
+      await createSessionGate
+      createdSessions.push(input.location.directory)
+      const session = {
+        id: `session-${createdSessions.length}`,
+        title: `New session ${createdSessions.length}`,
+      }
+      sessionDirectories[session.id] = input.location.directory
+      return session
+    },
+    async shell(input: { sessionID: string }) {
+      sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main")
+    },
+    async prompt() {},
+    async command() {},
+    async interrupt() {},
+  },
+}
+
 beforeAll(async () => {
   const rootClient = clientFor("/repo/main")
 
@@ -171,6 +193,7 @@ beforeAll(async () => {
       const sdk = {
         scope: "local",
         directory: "/repo/main",
+        api,
         client: rootClient,
         url: "http://localhost:4096",
         createClient(opts: any) {
@@ -265,6 +288,7 @@ beforeEach(() => {
   permissionServer = "server-a"
   createSessionGate = undefined
   for (const key of Object.keys(storedSessions)) delete storedSessions[key]
+  for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
 })
 
 describe("prompt submit worktree selection", () => {

+ 65 - 33
packages/app/src/components/prompt-input/submit.ts

@@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom"
 import { formatServerError } from "@/utils/server-errors"
 import { ScopedKey } from "@/utils/server-scope"
 import { createPromptSubmissionState } from "./submission-state"
+import { normalizeSessionInfo } from "@/utils/session"
+import { Event } from "@opencode-ai/schema/event"
 
 type PendingPrompt = {
   abort: AbortController
@@ -39,7 +41,7 @@ export type FollowupDraft = {
 }
 
 type FollowupSendInput = {
-  client: DirectorySDK["client"]
+  api: DirectorySDK["api"]["session"]
   serverSync: ServerSync
   sync: DirectorySync
   draft: FollowupDraft
@@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
         return false
       }
 
-      await input.client.session.command({
+      const messageID = Identifier.ascending("message")
+      await input.api.command({
         sessionID: input.draft.sessionID,
+        id: messageID,
         command: cmd,
         arguments: tail.join(" "),
         agent: input.draft.agent,
-        model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
-        variant: input.draft.variant,
-        parts: images.map((attachment) => ({
-          id: Identifier.ascending("part"),
-          type: "file" as const,
-          mime: attachment.mime,
-          url: attachment.dataUrl,
-          filename: attachment.filename,
+        model: {
+          id: input.draft.model.modelID,
+          providerID: input.draft.model.providerID,
+          variant: input.draft.variant,
+        },
+        files: images.map((attachment) => ({
+          uri: attachment.dataUrl,
+          name: attachment.filename,
         })),
       })
       return true
@@ -152,13 +156,36 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
       return false
     }
 
-    await input.client.session.promptAsync({
+    await input.api.prompt({
       sessionID: input.draft.sessionID,
+      id: messageID,
       agent: input.draft.agent,
       model: input.draft.model,
-      messageID,
-      parts: requestParts,
       variant: input.draft.variant,
+      text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
+      files: requestParts.flatMap((part) => {
+        if (part.type !== "file") return []
+        const text = part.source?.text
+        return [
+          {
+            uri: part.url,
+            name: part.filename,
+            mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
+          },
+        ]
+      }),
+      agents: requestParts.flatMap((part) =>
+        part.type === "agent"
+          ? [
+              {
+                name: part.name,
+                mention: part.source
+                  ? { start: part.source.start, end: part.source.end, text: part.source.value }
+                  : undefined,
+              },
+            ]
+          : [],
+      ),
     })
     return true
   } catch (err) {
@@ -210,6 +237,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
   const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
 
   const errorMessage = (err: unknown) => {
+    if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
     if (err && typeof err === "object" && "data" in err) {
       const data = (err as { data?: { message?: string } }).data
       if (data?.message) return data.message
@@ -235,9 +263,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       return Promise.resolve()
     }
     return sdk()
-      .client.session.abort({
-        sessionID,
-      })
+      .api.session.interrupt({ sessionID })
       .catch(() => {})
   }
 
@@ -364,9 +390,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     let session = input.info()
     if (!session && isNewSession) {
-      const created = await client.session
-        .create()
-        .then((x) => x.data ?? undefined)
+      const created = await sdk()
+        .api.session.create({
+          agent: currentAgent.name,
+          model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
+          location: { directory: sessionDirectory },
+        })
+        .then(normalizeSessionInfo)
         .catch((err) => {
           showToast({
             title: language.t("prompt.toast.sessionCreateFailed.title"),
@@ -450,12 +480,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     if (mode === "shell") {
       clearInput()
-      client.session
-        .shell({
+      const eventID = Event.ID.create()
+      sdk()
+        .api.session.shell({
           sessionID: session.id,
+          id: eventID,
+          command: text,
           agent,
           model,
-          command: text,
         })
         .catch((err) => {
           showToast({
@@ -473,23 +505,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       const customCommand = sync().data.command.find((c) => c.name === commandName)
       if (customCommand) {
         clearInput()
-        client.session
-          .command({
+        const messageID = Identifier.ascending("message")
+        serverSync().session.set("session_status", session.id, { type: "busy" })
+        sdk()
+          .api.session.command({
             sessionID: session.id,
+            id: messageID,
             command: commandName,
             arguments: args.join(" "),
             agent,
-            model: `${model.providerID}/${model.modelID}`,
-            variant,
-            parts: images.map((attachment) => ({
-              id: Identifier.ascending("part"),
-              type: "file" as const,
-              mime: attachment.mime,
-              url: attachment.dataUrl,
-              filename: attachment.filename,
+            model: { id: model.modelID, providerID: model.providerID, variant },
+            files: images.map((attachment) => ({
+              uri: attachment.dataUrl,
+              name: attachment.filename,
             })),
           })
           .catch((err) => {
+            serverSync().session.set("session_status", session.id, { type: "idle" })
             showToast({
               title: language.t("prompt.toast.commandSendFailed.title"),
               description: formatServerError(err, language.t, language.t("common.requestFailed")),
@@ -573,7 +605,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     }
 
     void sendFollowupDraft({
-      client,
+      api: sdk().api.session,
       sync: sync(),
       serverSync: serverSync(),
       draft,

+ 1 - 1
packages/app/src/components/status-popover-indicator.test.ts

@@ -26,7 +26,7 @@ describe("hasNonBlockingServiceIssue", () => {
     expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
     expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
     expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
-    expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
+    expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
   })
 
   test("detects LSP failures that do not block chatting", () => {

+ 4 - 3
packages/app/src/components/status-popover-indicator.ts

@@ -1,11 +1,12 @@
-import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
+import type { LspStatus } from "@opencode-ai/sdk/v2/client"
+import type { McpServer } from "@opencode-ai/client/promise"
 
 export function hasNonBlockingServiceIssue(input: {
-  mcp: Array<McpStatus["status"]>
+  mcp: Array<McpServer["status"]["status"]>
   lsp: Array<LspStatus["status"]>
 }) {
   return (
-    input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
+    input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
     input.lsp.some((status) => status === "error")
   )
 }

+ 6 - 5
packages/app/src/context/directory-sync.ts

@@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
 import type { createServerSdkContext } from "./server-sdk"
 import type { createServerSyncContextInner } from "./server-sync"
 import type { State } from "./global-sync/types"
+import { normalizeSessionInfo } from "@/utils/session"
 
 const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 const sessionFields = new Set([
@@ -15,6 +16,7 @@ const sessionFields = new Set([
   "permission",
   "question",
   "message",
+  "session_message",
   "part",
   "part_text_accum_delta",
 ])
@@ -114,7 +116,6 @@ export const createDirSyncContext = (
         await serverSync.session.sync(sessionID, options)
         index(sessionID)
       },
-      diff: serverSync.session.diff,
       todo: serverSync.session.todo,
       history: serverSync.session.history,
       evict(sessionID: string) {
@@ -123,9 +124,9 @@ export const createDirSyncContext = (
       fetch: async (count = 10) => {
         const [store, setStore] = current()
         setStore("limit", (value) => value + count)
-        const response = await client.session.list()
-        const sessions = (response.data ?? [])
-          .filter((session) => !!session?.id)
+        const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
+        const sessions = response.data
+          .map(normalizeSessionInfo)
           .sort((a, b) => cmp(a.id, b.id))
           .slice(0, store.limit)
         sessions.forEach(serverSync.session.remember)
@@ -133,7 +134,7 @@ export const createDirSyncContext = (
       },
       more: createMemo(() => current()[0].session.length >= current()[0].limit),
       archive: async (sessionID: string) => {
-        await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
+        await serverSDK.api.session.archive({ sessionID, directory })
         current()[1](
           "session",
           produce((draft) => {

+ 124 - 68
packages/app/src/context/global-sync/bootstrap.test.ts

@@ -1,14 +1,39 @@
 import { describe, expect, test } from "bun:test"
 import { createStore } from "solid-js/store"
 import { QueryClient } from "@tanstack/solid-query"
-import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
+import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
+import type { AgentApi, CatalogApi, CommandApi, ProjectApi, ReferenceApi } from "@opencode-ai/client/promise"
 import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
-import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
+import {
+  bootstrapDirectory,
+  loadAgentsQuery,
+  loadCommands,
+  loadPathQuery,
+  loadProjectsQuery,
+  loadProvidersQuery,
+  loadReferencesQuery,
+} from "./bootstrap"
 import type { State, VcsCache } from "./types"
-import { createServerSession } from "../server-session"
 import { ServerScope } from "@/utils/server-scope"
+import type { ServerApi } from "@/utils/server"
 
 const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
+const api = {
+  agent: { list: async () => ({ location: {}, data: [] }) },
+  provider: { list: async () => ({ location: {}, data: [] }) },
+  model: {
+    list: async () => ({ location: {}, data: [] }),
+    default: async () => ({ location: {}, data: null }),
+  },
+  permission: { request: { list: async () => ({ location: {}, data: [] }) } },
+  project: {
+    list: async () => [],
+    current: async () => ({ id: "project", directory: "/project" }),
+  },
+  question: { request: { list: async () => ({ location: {}, data: [] }) } },
+  reference: { list: async () => ({ location: {}, data: [] }) },
+  vcs: { get: async () => ({ location: {}, data: {} }) },
+} as unknown as ServerApi
 
 function directoryState() {
   return createStore<State>({
@@ -41,6 +66,7 @@ function directoryState() {
     vcs: undefined,
     limit: 5,
     message: {},
+    session_message: {},
     part: {},
     part_text_accum_delta: {},
   })
@@ -64,7 +90,6 @@ describe("bootstrapDirectory", () => {
       sdk: {
         app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
         config: { get: async () => ({ data: {} }) },
-        session: { status: async () => ({ data: {} }) },
         vcs: { get: async () => ({ data: undefined }) },
         command: {
           list: async () => {
@@ -83,6 +108,7 @@ describe("bootstrapDirectory", () => {
         },
         provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
       } as unknown as OpencodeClient,
+      api,
       store,
       setStore,
       vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -99,78 +125,108 @@ describe("bootstrapDirectory", () => {
     expect(mcpReads).toEqual([])
   })
 
-  test("seeds session status even while warming session info stalls", async () => {
-    const [store, setStore] = directoryState()
-    const stalled = Promise.withResolvers<never>()
-    const client = {
-      app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
-      config: { get: async () => ({ data: {} }) },
-      session: {
-        status: async () => ({ data: { ses_busy: { type: "busy" } } }),
-        get: () => stalled.promise,
-      },
-      vcs: { get: async () => ({ data: undefined }) },
-      command: { list: async () => ({ data: [] }) },
-      permission: { list: async () => ({ data: [] }) },
-      question: { list: async () => ({ data: [] }) },
-      v2: { reference: { list: async () => ({ data: { data: [] } }) } },
-      mcp: { status: async () => ({ data: {} }) },
-      provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
-    } as unknown as OpencodeClient
-    const session = createServerSession(client)
-    const stale: Session = {
-      id: "ses_stale",
-      slug: "ses_stale",
-      projectID: "project",
-      directory: "/project",
-      title: "stale",
-      version: "1",
-      time: { created: 1, updated: 1 },
-    }
-    session.remember(stale)
-    session.set("session_status", stale.id, { type: "busy" })
-
-    await bootstrapDirectory({
-      directory: "/project",
-      scope: ServerScope.local,
-      mcp: false,
-      global: {
-        config: {} satisfies Config,
-        path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
-        project: [{ id: "project", worktree: "/project" } as Project],
-        provider,
-      },
-      sdk: client,
-      store,
-      setStore,
-      vcsCache: { setStore() {} } as unknown as VcsCache,
-      loadSessions() {},
-      translate: (key) => key,
-      queryClient: new QueryClient(),
-      session,
-    })
-
-    const deadline = Date.now() + 500
-    while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
-      await new Promise((resolve) => setTimeout(resolve, 10))
-    }
-
-    expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
-    expect(session.data.session_status[stale.id]).toBeUndefined()
-  })
 })
 
 describe("query keys", () => {
   test("partitions identical directories by server scope", () => {
-    const client = {} as OpencodeClient
+    const client = {} as Parameters<typeof loadPathQuery>[2]
+    const api = {} as CatalogApi
     const remote = "https://debian.example" as typeof ServerScope.local
 
     expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
     expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
-    expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
-      "https://debian.example",
-      null,
-      "providers",
+    expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
+  })
+
+  test("loads the current provider and model catalog", async () => {
+    const calls: unknown[] = []
+    const api = {
+      provider: {
+        list: async (input: unknown) => {
+          calls.push(["provider", input])
+          return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
+        },
+      },
+      model: {
+        list: async (input: unknown) => {
+          calls.push(["model", input])
+          return { location: {}, data: [] }
+        },
+        default: async (input: unknown) => {
+          calls.push(["default", input])
+          return { location: {}, data: null }
+        },
+      },
+    } as unknown as CatalogApi
+
+    const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
+
+    expect(calls).toEqual([
+      ["provider", { location: { directory: "/repo" } }],
+      ["model", { location: { directory: "/repo" } }],
+      ["default", { location: { directory: "/repo" } }],
     ])
+    expect(result.connected).toEqual(["openai"])
+  })
+
+  test("loads agents from the current location-scoped endpoint", async () => {
+    const calls: unknown[] = []
+    const api = {
+      list: async (input: unknown) => {
+        calls.push(input)
+        return { location: {}, data: [] }
+      },
+    } as unknown as AgentApi
+
+    const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
+
+    expect(calls).toEqual([{ location: { directory: "/repo" } }])
+    expect(result).toEqual([])
+  })
+
+  test("loads commands from the current location-scoped endpoint", async () => {
+    const calls: unknown[] = []
+    const api = {
+      list: async (input: unknown) => {
+        calls.push(input)
+        return {
+          location: {},
+          data: [{ name: "review", template: "Review files", source: "command" as const }],
+        }
+      },
+    } as unknown as CommandApi
+
+    const result = await loadCommands("/repo", api)
+
+    expect(calls).toEqual([{ location: { directory: "/repo" } }])
+    expect(result).toEqual([{ name: "review", template: "Review files", source: "command" }])
+  })
+
+  test("loads projects from the current endpoint", async () => {
+    const api = {
+      list: async () => [
+        { id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
+        { id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
+      ],
+    } as unknown as ProjectApi
+
+    const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
+
+    expect(result.map((project) => project.id)).toEqual(["a", "b"])
+  })
+
+  test("loads references from the current location-scoped endpoint", async () => {
+    const calls: unknown[] = []
+    const api = {
+      list: async (input: unknown) => {
+        calls.push(input)
+        return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
+      },
+    } as unknown as ReferenceApi
+
+    const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
+
+    expect(calls).toEqual([{ location: { directory: "/repo" } }])
+    expect(result).toHaveLength(1)
   })
 })

+ 205 - 77
packages/app/src/context/global-sync/bootstrap.ts

@@ -9,6 +9,26 @@ import type {
   ReferenceInfo,
   Session,
 } from "@opencode-ai/sdk/v2/client"
+import type {
+  AgentListInput,
+  AgentListOutput,
+  CatalogApi,
+  CommandInfo,
+  CommandListInput,
+  CommandListOutput,
+  McpApi,
+  PathGetInput,
+  PathGetOutput,
+  PermissionApi,
+  ProjectCurrentInput,
+  ProjectCurrentOutput,
+  ProjectListOutput,
+  QuestionApi,
+  ReferenceListInput,
+  ReferenceListOutput,
+  SessionApi,
+  VcsApi,
+} from "@opencode-ai/client/promise"
 import { showToast } from "@/utils/toast"
 import { getFilename } from "@opencode-ai/core/util/path"
 import { retry } from "@opencode-ai/core/util/retry"
@@ -16,12 +36,20 @@ import { batch } from "solid-js"
 import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
 import type { State, VcsCache } from "./types"
 import type { ServerSession } from "../server-session"
-import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
+import {
+  cmp,
+  normalizeAgentList,
+  normalizePermissionRequest,
+  normalizeProjectInfo,
+  normalizeProviderList,
+} from "./utils"
 import { formatServerError } from "@/utils/server-errors"
 import { QueryClient, queryOptions } from "@tanstack/solid-query"
 import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
 import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 import { ScopedKey, type ServerScope } from "@/utils/server-scope"
+import { normalizeSessionInfo } from "@/utils/session"
+import type { ServerProtocol } from "@/utils/server-protocol"
 
 type GlobalStore = {
   ready: boolean
@@ -88,15 +116,25 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =
     queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
   })
 
-export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
+type ProjectApi = {
+  readonly list: () => Promise<ProjectListOutput>
+  readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
+}
+
+type PathApi = {
+  readonly get: (input?: PathGetInput) => Promise<PathGetOutput>
+}
+
+export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
   queryOptions({
     queryKey: [scope, "project"],
     queryFn: () =>
       retry(() =>
-        sdk.project.list().then((x) => {
-          return (x.data ?? [])
+        api.list().then((projects) => {
+          return projects
             .filter((p) => !!p?.id)
             .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+            .map(normalizeProjectInfo)
             .slice()
             .sort((a, b) => cmp(a.id, b.id))
         }),
@@ -105,6 +143,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
 
 export async function bootstrapGlobal(input: {
   serverSDK: OpencodeClient
+  serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi }
+  protocol?: Promise<ServerProtocol>
   scope: ServerScope
   requestFailedTitle: string
   translate: (key: string, vars?: Record<string, string | number>) => string
@@ -114,11 +154,14 @@ export async function bootstrapGlobal(input: {
 }) {
   const slow = [
     () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
-    () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
-    () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
+    () =>
+      input.queryClient.fetchQuery(
+        loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
+      ),
+    () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.path)),
     () =>
       input.queryClient
-        .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
+        .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
         .then((data) => input.setGlobalStore("project", data)),
   ]
   await runAll(slow)
@@ -162,44 +205,117 @@ function warmSessions(input: {
   ids: string[]
   store: Store<State>
   setStore: SetStoreFunction<State>
-  sdk: OpencodeClient
+  api: SessionApi
 }) {
   const known = new Set(input.store.session.map((item) => item.id))
   const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
   if (ids.length === 0) return Promise.resolve()
   return Promise.all(
     ids.map((sessionID) =>
-      retry(() => input.sdk.session.get({ sessionID })).then((x) => {
-        const session = x.data
-        if (!session?.id) return
-        mergeSession(input.setStore, session)
-      }),
+      retry(() => input.api.get({ sessionID })).then((session) =>
+        mergeSession(input.setStore, normalizeSessionInfo(session)),
+      ),
     ),
   ).then(() => undefined)
 }
 
-export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
+export const loadProvidersQuery = (
+  scope: ServerScope,
+  directory: string | null,
+  sdk: CatalogApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<ServerProtocol>,
+) =>
   queryOptions({
     queryKey: [scope, directory, "providers"],
-    queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
+    queryFn: () =>
+      retry(async () => {
+        if ((await protocol) === "v1" && legacy) {
+          const result = await legacy.provider.list()
+          return normalizeProviderList(result.data!)
+        }
+        const location = directory ? { location: { directory } } : undefined
+        const [providers, models, defaultModel] = await Promise.all([
+          sdk.provider.list(location),
+          sdk.model.list(location),
+          sdk.model.default(location),
+        ])
+        return normalizeProviderList(providers.data, models.data, defaultModel.data)
+      }),
   })
 
-export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
+type AgentListApi = {
+  readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
+}
+
+type CommandListApi = {
+  readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
+}
+
+type ReferenceListApi = {
+  readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
+}
+
+export const loadAgentsQuery = (
+  scope: ServerScope,
+  directory: string,
+  sdk: AgentListApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<ServerProtocol>,
+) =>
   queryOptions({
     queryKey: [scope, directory, "agents"],
-    queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
+    queryFn: () =>
+      retry(async () => {
+        if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
+        return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
+      }),
   })
 
-export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
+export const loadCommands = (
+  directory: string,
+  api: CommandListApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<ServerProtocol>,
+): Promise<CommandInfo[]> =>
+  retry(async () => {
+    if ((await protocol) === "v1" && legacy) {
+      return ((await legacy.command.list()).data ?? []).map((command) => {
+        const [providerID, id] = command.model?.split("/") ?? []
+        return {
+          name: command.name,
+          template: command.template,
+          description: command.description,
+          agent: command.agent,
+          model: providerID && id ? { providerID, id } : undefined,
+          subtask: command.subtask,
+          source: command.source === "skill" ? undefined : command.source,
+        }
+      })
+    }
+    return api.list({ location: { directory } }).then((result) => result.data)
+  })
+
+export const loadPathQuery = (scope: ServerScope, directory: string | null, api: PathApi) =>
   queryOptions<Path>({
     queryKey: [scope, directory, "path"],
-    queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
+    queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)),
   })
 
-export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
+export const loadReferencesQuery = (
+  scope: ServerScope,
+  directory: string,
+  api: ReferenceListApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<ServerProtocol>,
+) =>
   queryOptions<ReferenceInfo[]>({
     queryKey: [scope, directory, "references"] as const,
-    queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
+    queryFn: () =>
+      retry(async () => {
+        if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
+        return api.list({ location: { directory } }).then((result) => result.data)
+      }).catch(() => []),
     placeholderData: [],
   })
 
@@ -208,6 +324,18 @@ export async function bootstrapDirectory(input: {
   scope: ServerScope
   mcp: boolean
   sdk: OpencodeClient
+  api: CatalogApi & {
+    readonly agent: AgentListApi
+    readonly command: CommandListApi
+    readonly mcp: McpApi
+    readonly path: PathApi
+    readonly permission: PermissionApi
+    readonly project: ProjectApi
+    readonly question: QuestionApi
+    readonly reference: ReferenceListApi
+    readonly session: SessionApi
+    readonly vcs: VcsApi
+  }
   store: Store<State>
   setStore: SetStoreFunction<State>
   vcsCache: VcsCache
@@ -221,6 +349,7 @@ export async function bootstrapDirectory(input: {
   }
   queryClient: QueryClient
   session?: ServerSession
+  protocol?: Promise<ServerProtocol>
 }) {
   const loading = input.store.status !== "complete"
   const seededProject = projectID(input.directory, input.global.project)
@@ -240,66 +369,55 @@ export async function bootstrapDirectory(input: {
       () => Promise.resolve(input.loadSessions(input.directory)),
       () =>
         input.queryClient
-          .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
+          .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
           .then((data) => input.setStore("agent", data)),
       () =>
         retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
-      () =>
-        retry(() =>
-          input.sdk.session.status().then(async (x) => {
-            if (!input.session) {
-              input.setStore("session_status", x.data!)
-              return
-            }
-            const statuses = x.data ?? {}
-            input.session.set(
-              "session_status",
-              produce((draft) => {
-                for (const sessionID of Object.keys(draft)) {
-                  if (statuses[sessionID]) continue
-                  if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
-                }
-              }),
-            )
-            for (const [sessionID, status] of Object.entries(statuses)) {
-              input.session.set("session_status", sessionID, reconcile(status))
-            }
-            // Warm session info only after seeding statuses so a stalled session
-            // fetch cannot park busy indicators behind it, mirroring how live
-            // session.status events apply first and resolve info in the background.
-            await Promise.all(
-              Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
-            )
-          }),
-        ),
       !seededProject &&
-        (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
+        (() =>
+          retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
+            input.setStore("project", project.id),
+          )),
       !seededPath &&
         (() =>
-          input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
-            const next = projectID(data.directory ?? input.directory, input.global.project)
-            if (next) input.setStore("project", next)
-          })),
+          input.queryClient
+            .ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path))
+            .then((data) => {
+              const next = projectID(data.directory ?? input.directory, input.global.project)
+              if (next) input.setStore("project", next)
+            })),
       () =>
         retry(() =>
-          input.sdk.vcs.get().then((x) => {
-            const next = x.data ?? input.store.vcs
+          input.api.vcs.get({ location: { directory: input.directory } }).then((result) => {
+            const next = { branch: result.data.branch, default_branch: result.data.defaultBranch }
             input.setStore("vcs", next)
             if (next) input.vcsCache.setStore("value", next)
           }),
         ),
-      input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
-      () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
+      input.mcp &&
+        (() =>
+          loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
+            input.setStore("command", commands),
+          )),
+      () =>
+        input.queryClient.fetchQuery(
+          loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
+        ),
       () =>
         retry(() =>
-          input.sdk.permission.list().then((x) => {
-            const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
+          (async () => {
+            if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
+            return input.api.permission.request
+              .list({ location: { directory: input.directory } })
+              .then((result) => result.data.map(normalizePermissionRequest))
+          })().then((permissions) => {
+            const ids = permissions.map((permission) => permission.sessionID)
             const grouped = groupBySession(
-              (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+              permissions.filter((permission) => !!permission.id && !!permission.sessionID),
             )
             const warm = input.session
               ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
-              : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
+              : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
             return warm.then(() =>
               batch(() => {
                 const current = input.session?.data.permission ?? input.store.permission
@@ -323,12 +441,19 @@ export async function bootstrapDirectory(input: {
         ),
       () =>
         retry(() =>
-          input.sdk.question.list().then((x) => {
-            const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
-            const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+          (async () => {
+            if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
+            return input.api.question.request
+              .list({ location: { directory: input.directory } })
+              .then((result) => result.data)
+          })().then((questions) => {
+            const ids = questions.map((question) => question.sessionID)
+            const grouped = groupBySession(
+              questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
+            )
             const warm = input.session
               ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
-              : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
+              : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
             return warm.then(() =>
               batch(() => {
                 const current = input.session?.data.question ?? input.store.question
@@ -351,17 +476,20 @@ export async function bootstrapDirectory(input: {
           }),
         ),
       () => Promise.resolve(input.loadSessions(input.directory)),
-      input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
-      input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
+      input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
+      input.mcp &&
+        (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
       () =>
-        input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
-          const project = getFilename(input.directory)
-          showToast({
-            variant: "error",
-            title: input.translate("toast.project.reloadFailed.title", { project }),
-            description: formatServerError(err, input.translate),
-          })
-        }),
+        input.queryClient
+          .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
+          .catch((err) => {
+            const project = getFilename(input.directory)
+            showToast({
+              variant: "error",
+              title: input.translate("toast.project.reloadFailed.title", { project }),
+              description: formatServerError(err, input.translate),
+            })
+          }),
     ].filter(Boolean) as (() => Promise<any>)[]
 
     await waitForPaint()

+ 1 - 0
packages/app/src/context/global-sync/child-store.ts

@@ -255,6 +255,7 @@ export function createChildStoreManager(input: {
             vcs: vcsStore.value,
             limit: 5,
             message: {},
+            session_message: {},
             part: {},
             part_text_accum_delta: {},
           })

+ 7 - 3
packages/app/src/context/global-sync/event-reducer.test.ts

@@ -80,6 +80,7 @@ const baseState = (input: Partial<State> = {}) =>
     vcs: undefined,
     limit: 10,
     message: {},
+    session_message: {},
     part: {},
     part_text_accum_delta: {},
     ...input,
@@ -261,8 +262,8 @@ describe("applyDirectoryEvent", () => {
 
   test("cleans session caches when deleted and decrements only root totals", () => {
     const cases = [
-      { info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
-      { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 },
+      { info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false },
+      { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true },
     ]
 
     for (const item of cases) {
@@ -286,7 +287,10 @@ describe("applyDirectoryEvent", () => {
       )
 
       applyDirectoryEvent({
-        event: { type: "session.deleted", properties: { info: item.info } },
+        event: {
+          type: "session.deleted",
+          properties: item.current ? { sessionID: item.info.id } : { info: item.info },
+        },
         store,
         setStore,
         push() {},

+ 73 - 7
packages/app/src/context/global-sync/event-reducer.ts

@@ -8,9 +8,9 @@ import type {
   QuestionRequest,
   Session,
   SessionStatus,
-  SnapshotFileDiff,
   Todo,
 } from "@opencode-ai/sdk/v2/client"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import type { State, VcsCache } from "./types"
 import { trimSessions } from "./session-trim"
 import { dropSessionCaches } from "./session-cache"
@@ -171,8 +171,11 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "session.deleted": {
-      const info = (event.properties as { info: Session }).info
-      const result = Binary.search(input.store.session, info.id, (s) => s.id)
+      const properties = event.properties as { sessionID?: string; info?: Session }
+      const sessionID = properties.info?.id ?? properties.sessionID
+      if (!sessionID) break
+      const result = Binary.search(input.store.session, sessionID, (s) => s.id)
+      const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined)
       if (result.found) {
         input.setStore(
           "session",
@@ -181,14 +184,77 @@ export function applyDirectoryEvent(input: {
           }),
         )
       }
-      cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
-      if (info.parentID) break
+      cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
+      if (info?.parentID) break
       input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
       break
     }
+    case "session.renamed": {
+      const properties = event.properties as { sessionID: string; title: string }
+      const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
+      if (!result.found) break
+      input.setStore("session", result.index, (session) => ({
+        ...session,
+        title: properties.title,
+        time: { ...session.time, updated: Date.now() },
+      }))
+      break
+    }
+    case "session.usage.updated": {
+      const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
+      const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
+      if (!result.found) break
+      input.setStore("session", result.index, (session) => ({
+        ...session,
+        cost: properties.cost,
+        tokens: properties.tokens,
+      }))
+      break
+    }
+    case "session.archived": {
+      const properties = event.properties as { sessionID: string }
+      const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
+      if (!result.found) break
+      const info = input.store.session[result.index]
+      input.setStore(
+        "session",
+        produce((draft) => void draft.splice(result.index, 1)),
+      )
+      cleanupSessionCaches(input.setStore, properties.sessionID)
+      if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+      break
+    }
+    case "session.moved": {
+      const properties = event.properties as {
+        sessionID: string
+        location: { directory: string; workspaceID?: string }
+        projectID?: string
+        subpath?: string
+      }
+      const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
+      if (!result.found) break
+      if (properties.location.directory === input.directory) {
+        input.setStore("session", result.index, (session) => ({
+          ...session,
+          projectID: properties.projectID ?? session.projectID,
+          workspaceID: properties.location.workspaceID,
+          directory: properties.location.directory,
+          path: properties.subpath,
+          time: { ...session.time, updated: Date.now() },
+        }))
+        break
+      }
+      const info = input.store.session[result.index]
+      input.setStore(
+        "session",
+        produce((draft) => void draft.splice(result.index, 1)),
+      )
+      if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+      break
+    }
     case "session.diff": {
-      const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
-      input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
+      const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
+      input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" }))
       break
     }
     case "todo.updated": {

+ 20 - 0
packages/app/src/context/global-sync/mcp.test.ts

@@ -31,4 +31,24 @@ describe("toggleMcp", () => {
     await toggleMcp(input("disabled"))
     expect(calls).toEqual(["connect", "refresh"])
   })
+
+  test("does not toggle a server while its connection is pending", async () => {
+    const calls: string[] = []
+    await toggleMcp({
+      status: "pending",
+      connect: async () => {
+        calls.push("connect")
+      },
+      disconnect: async () => {
+        calls.push("disconnect")
+      },
+      authenticate: async () => {
+        calls.push("authenticate")
+      },
+      refresh: async () => {
+        calls.push("refresh")
+      },
+    })
+    expect(calls).toEqual([])
+  })
 })

+ 3 - 2
packages/app/src/context/global-sync/mcp.ts

@@ -1,12 +1,13 @@
-import type { McpStatus } from "@opencode-ai/sdk/v2/client"
+import type { McpServer } from "@opencode-ai/client/promise"
 
 export async function toggleMcp(input: {
-  status: McpStatus["status"]
+  status: McpServer["status"]["status"]
   connect: () => Promise<void>
   disconnect: () => Promise<void>
   authenticate: () => Promise<void>
   refresh: () => Promise<void>
 }) {
+  if (input.status === "pending") return
   await {
     connected: input.disconnect,
     needs_auth: input.authenticate,

+ 8 - 11
packages/app/src/context/global-sync/session-cache.test.ts

@@ -1,13 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import type {
-  Message,
-  Part,
-  PermissionRequest,
-  QuestionRequest,
-  SessionStatus,
-  SnapshotFileDiff,
-  Todo,
-} from "@opencode-ai/sdk/v2/client"
+import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
 
 const msg = (id: string, sessionID: string) =>
@@ -33,9 +26,10 @@ describe("app session cache", () => {
   test("dropSessionCaches clears orphaned parts without message rows", () => {
     const store: {
       session_status: Record<string, SessionStatus | undefined>
-      session_diff: Record<string, SnapshotFileDiff[] | undefined>
+      session_diff: Record<string, FileDiffInfo[] | undefined>
       todo: Record<string, Todo[] | undefined>
       message: Record<string, Message[] | undefined>
+      session_message: Record<string, never[] | undefined>
       part: Record<string, Part[] | undefined>
       permission: Record<string, PermissionRequest[] | undefined>
       question: Record<string, QuestionRequest[] | undefined>
@@ -45,6 +39,7 @@ describe("app session cache", () => {
       session_diff: { ses_1: [] },
       todo: { ses_1: [] as Todo[] },
       message: {},
+      session_message: {},
       part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
       permission: { ses_1: [] as PermissionRequest[] },
       question: { ses_1: [] as QuestionRequest[] },
@@ -67,9 +62,10 @@ describe("app session cache", () => {
     const m = msg("msg_1", "ses_1")
     const store: {
       session_status: Record<string, SessionStatus | undefined>
-      session_diff: Record<string, SnapshotFileDiff[] | undefined>
+      session_diff: Record<string, FileDiffInfo[] | undefined>
       todo: Record<string, Todo[] | undefined>
       message: Record<string, Message[] | undefined>
+      session_message: Record<string, never[] | undefined>
       part: Record<string, Part[] | undefined>
       permission: Record<string, PermissionRequest[] | undefined>
       question: Record<string, QuestionRequest[] | undefined>
@@ -79,6 +75,7 @@ describe("app session cache", () => {
       session_diff: {},
       todo: {},
       message: { ses_1: [m] },
+      session_message: {},
       part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
       permission: {},
       question: {},

+ 6 - 10
packages/app/src/context/global-sync/session-cache.ts

@@ -1,20 +1,15 @@
-import type {
-  Message,
-  Part,
-  PermissionRequest,
-  QuestionRequest,
-  SessionStatus,
-  SnapshotFileDiff,
-  Todo,
-} from "@opencode-ai/sdk/v2/client"
+import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
+import type { SessionMessageInfo } from "@opencode-ai/client/promise"
 
 export const SESSION_CACHE_LIMIT = 40
 
 type SessionCache = {
   session_status: Record<string, SessionStatus | undefined>
-  session_diff: Record<string, SnapshotFileDiff[] | undefined>
+  session_diff: Record<string, FileDiffInfo[] | undefined>
   todo: Record<string, Todo[] | undefined>
   message: Record<string, Message[] | undefined>
+  session_message: Record<string, SessionMessageInfo[] | undefined>
   part: Record<string, Part[] | undefined>
   permission: Record<string, PermissionRequest[] | undefined>
   question: Record<string, QuestionRequest[] | undefined>
@@ -37,6 +32,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
   for (const sessionID of stale) {
     delete store.message[sessionID]
     delete store.todo[sessionID]
+    delete store.session_message[sessionID]
     delete store.session_diff[sessionID]
     delete store.session_status[sessionID]
     delete store.permission[sessionID]

+ 22 - 14
packages/app/src/context/global-sync/session-load.ts

@@ -1,20 +1,28 @@
-import type { RootLoadArgs } from "./types"
+import type { SessionApi } from "@opencode-ai/client/promise"
+import { normalizeSessionInfo } from "@/utils/session"
+import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
 
-export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
+export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
+  const result = await input.api.list({
+    directory: input.directory,
+    parentID: null,
+    limit: input.limit,
+    order: "desc",
+  })
+  return {
+    data: result.data.map(normalizeSessionInfo),
+    limit: input.limit,
+    limited: true,
+  } as const
+}
+
+export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
   try {
-    const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
-    return {
-      data: result.data,
-      limit: input.limit,
-      limited: true,
-    } as const
+    const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
+    return { data: result.data, limit: input.limit, limited: true } as const
   } catch {
-    const result = await input.list({ directory: input.directory, roots: true })
-    return {
-      data: result.data,
-      limit: input.limit,
-      limited: false,
-    } as const
+    const result = await input.client.session.list({ directory: input.directory, roots: true })
+    return { data: result.data, limit: input.limit, limited: false } as const
   }
 }
 

+ 8 - 19
packages/app/src/context/global-sync/types.ts

@@ -1,10 +1,7 @@
 import type {
   Agent,
-  Command,
   Config,
   LspStatus,
-  McpResource,
-  McpStatus,
   Message,
   Part,
   Path,
@@ -13,11 +10,12 @@ import type {
   ReferenceInfo,
   Session,
   SessionStatus,
-  SnapshotFileDiff,
   Todo,
   VcsInfo,
 } from "@opencode-ai/sdk/v2/client"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
+import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
 import type { Accessor } from "solid-js"
 import type { SetStoreFunction, Store } from "solid-js/store"
 
@@ -35,7 +33,7 @@ export type ProjectMeta = {
 export type State = {
   status: "loading" | "partial" | "complete"
   agent: Agent[]
-  command: Command[]
+  command: CommandInfo[]
   reference: ReferenceInfo[]
   project: string
   projectMeta: ProjectMeta | undefined
@@ -51,7 +49,7 @@ export type State = {
   }
   session_working(id: string): boolean
   session_diff: {
-    [sessionID: string]: SnapshotFileDiff[]
+    [sessionID: string]: FileDiffInfo[]
   }
   todo: {
     [sessionID: string]: Todo[]
@@ -64,7 +62,7 @@ export type State = {
   }
   mcp_ready: boolean
   mcp: {
-    [name: string]: McpStatus
+    [name: string]: McpServer["status"]
   }
   mcp_resource: {
     [key: string]: McpResource
@@ -76,6 +74,9 @@ export type State = {
   message: {
     [sessionID: string]: Message[]
   }
+  session_message: {
+    [sessionID: string]: SessionMessageInfo[]
+  }
   part: {
     [messageID: string]: Part[]
   }
@@ -128,18 +129,6 @@ export type DisposeCheck = {
   loadingSessions: boolean
 }
 
-export type RootLoadArgs = {
-  directory: string
-  limit: number
-  list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
-}
-
-export type RootLoadResult = {
-  data?: Session[]
-  limit: number
-  limited: boolean
-}
-
 export const MAX_DIR_STORES = 30
 export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
 export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000

+ 99 - 23
packages/app/src/context/global-sync/utils.test.ts

@@ -1,36 +1,112 @@
 import { describe, expect, test } from "bun:test"
-import type { Agent } from "@opencode-ai/sdk/v2/client"
-import { directoryKey, normalizeAgentList } from "./utils"
-
-const agent = (name = "build") =>
-  ({
-    name,
-    mode: "primary",
-    permission: {},
-    options: {},
-  }) as Agent
+import type { AgentListOutput, ModelDefaultOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
+import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
 
 describe("normalizeAgentList", () => {
-  test("keeps array payloads", () => {
-    expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
-  })
+  test("adapts current agents to the app agent shape", () => {
+    const result = normalizeAgentList([
+      {
+        id: "build",
+        name: "Build",
+        mode: "primary",
+        hidden: false,
+        color: "primary",
+        model: { id: "gpt-5", providerID: "openai", variant: "high" },
+        request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
+        system: "Build software",
+        permissions: [{ action: "read", resource: "*", effect: "allow" }],
+      },
+    ] as AgentListOutput["data"])
 
-  test("wraps a single agent payload", () => {
-    expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
+    expect(result).toEqual([
+      {
+        name: "build",
+        description: undefined,
+        mode: "primary",
+        hidden: false,
+        temperature: 0.2,
+        topP: 0.9,
+        color: "primary",
+        permission: [{ permission: "read", pattern: "*", action: "allow" }],
+        model: { providerID: "openai", modelID: "gpt-5" },
+        variant: "high",
+        prompt: "Build software",
+        options: { temperature: 0.2, topP: 0.9 },
+        steps: undefined,
+      },
+    ])
   })
+})
 
-  test("extracts agents from keyed objects", () => {
+describe("normalizePermissionRequest", () => {
+  test("adapts the current permission request to app state", () => {
     expect(
-      normalizeAgentList({
-        build: agent("build"),
-        docs: agent("docs"),
+      normalizePermissionRequest({
+        id: "permission-1",
+        sessionID: "session-1",
+        action: "read",
+        resources: ["README.md"],
+        save: ["*.md"],
+        metadata: { path: "README.md" },
+        source: { type: "tool", messageID: "message-1", callID: "call-1" },
       }),
-    ).toEqual([agent("build"), agent("docs")])
+    ).toEqual({
+      id: "permission-1",
+      sessionID: "session-1",
+      permission: "read",
+      patterns: ["README.md"],
+      always: ["*.md"],
+      metadata: { path: "README.md" },
+      tool: { messageID: "message-1", callID: "call-1" },
+    })
   })
+})
+
+describe("normalizeProviderList", () => {
+  test("groups current models into the app provider catalog", () => {
+    const result = normalizeProviderList(
+      [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"],
+      [
+        {
+          id: "gpt-5",
+          modelID: "gpt-5",
+          providerID: "openai",
+          name: "GPT-5",
+          capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
+          variants: [{ id: "high" }],
+          time: { released: 1 },
+          cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
+          status: "active",
+          enabled: true,
+          limit: { context: 128_000, output: 8_192 },
+        },
+        {
+          id: "gpt-old",
+          modelID: "gpt-old",
+          providerID: "openai",
+          name: "GPT Old",
+          capabilities: { tools: false, input: ["text"], output: ["text"] },
+          variants: [],
+          time: { released: 0 },
+          cost: [],
+          status: "deprecated",
+          enabled: true,
+          limit: { context: 1, output: 1 },
+        },
+      ] as ModelListOutput["data"],
+      { id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"],
+    )
 
-  test("drops invalid payloads", () => {
-    expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
-    expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
+    expect(result.connected).toEqual(["openai"])
+    expect(result.default).toEqual({ openai: "gpt-5" })
+    expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
+    expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
+      id: "gpt-5",
+      providerID: "openai",
+      capabilities: { toolcall: true, attachment: true },
+      cost: { input: 1, output: 2 },
+      variants: { high: {} },
+    })
   })
 })
 

+ 146 - 26
packages/app/src/context/global-sync/utils.ts

@@ -1,39 +1,152 @@
-import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+import type {
+  AgentListOutput,
+  ModelDefaultOutput,
+  ModelListOutput,
+  PermissionV2Request,
+  ProviderListOutput,
+} from "@opencode-ai/client/promise"
+import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+import type { Project as CurrentProject } from "@opencode-ai/client/promise"
 import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
 
 export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 
-function isAgent(input: unknown): input is Agent {
-  if (!input || typeof input !== "object") return false
-  const item = input as { name?: unknown; mode?: unknown }
-  if (typeof item.name !== "string") return false
-  return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
+export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
+  if (input.every((agent) => !("request" in agent))) return input as Agent[]
+  return (input as AgentListOutput["data"]).map((agent) => ({
+    name: agent.id,
+    description: agent.description,
+    mode: agent.mode,
+    hidden: agent.hidden,
+    temperature:
+      typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined,
+    topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined,
+    color: agent.color,
+    permission: agent.permissions.map((rule) => ({
+      permission: rule.action,
+      pattern: rule.resource,
+      action: rule.effect,
+    })),
+    model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id },
+    variant: agent.model?.variant,
+    prompt: agent.system,
+    options: agent.request.settings,
+    steps: agent.steps,
+  }))
 }
 
-export function normalizeAgentList(input: unknown): Agent[] {
-  if (Array.isArray(input)) return input.filter(isAgent)
-  if (isAgent(input)) return [input]
-  if (!input || typeof input !== "object") return []
-  return Object.values(input).filter(isAgent)
+export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest {
+  if ("permission" in input) return input
+  return {
+    id: input.id,
+    sessionID: input.sessionID,
+    permission: input.action,
+    patterns: input.resources,
+    always: input.save ?? [],
+    metadata: input.metadata ?? {},
+    tool:
+      input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
+  }
 }
 
-export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
-  return {
-    ...input,
-    all: new Map(
-      input.all.map(
-        (provider) =>
-          [
-            provider.id,
-            {
-              ...provider,
-              models: Object.fromEntries(
-                Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
-              ),
-            },
-          ] as const,
+export function normalizeProviderList(
+  providers: ProviderListOutput["data"] | ProviderListResponse,
+  models?: ModelListOutput["data"],
+  defaultModel?: ModelDefaultOutput["data"],
+): NormalizedProviderListResponse {
+  if (!Array.isArray(providers)) {
+    return {
+      ...providers,
+      all: new Map(
+        providers.all.map((provider) => [
+          provider.id,
+          {
+            ...provider,
+            models: Object.fromEntries(
+              Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"),
+            ),
+          },
+        ]),
       ),
+    }
+  }
+  const all = new Map<string, Provider>()
+
+  for (const provider of providers) {
+    all.set(provider.id, {
+      id: provider.id,
+      name: provider.name,
+      source: "custom",
+      env: [],
+      options: provider.settings ?? {},
+      models: {},
+    })
+  }
+
+  for (const model of models ?? []) {
+    const provider = all.get(model.providerID)
+    if (!provider || model.status === "deprecated") continue
+    const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0]
+    provider.models[model.id] = {
+      id: model.id,
+      providerID: model.providerID,
+      api: {
+        id: model.modelID,
+        url: "",
+        npm: model.package ?? provider.id,
+      },
+      name: model.name,
+      family: model.family,
+      capabilities: {
+        temperature: false,
+        reasoning: false,
+        attachment: model.capabilities.input.some((item) => item !== "text"),
+        toolcall: model.capabilities.tools,
+        input: {
+          text: model.capabilities.input.includes("text"),
+          audio: model.capabilities.input.includes("audio"),
+          image: model.capabilities.input.includes("image"),
+          video: model.capabilities.input.includes("video"),
+          pdf: model.capabilities.input.includes("pdf"),
+        },
+        output: {
+          text: model.capabilities.output.includes("text"),
+          audio: model.capabilities.output.includes("audio"),
+          image: model.capabilities.output.includes("image"),
+          video: model.capabilities.output.includes("video"),
+          pdf: model.capabilities.output.includes("pdf"),
+        },
+        interleaved: false,
+      },
+      cost: {
+        input: cost?.input ?? 0,
+        output: cost?.output ?? 0,
+        cache: {
+          read: cost?.cache.read ?? 0,
+          write: cost?.cache.write ?? 0,
+        },
+      },
+      limit: model.limit,
+      status: model.status,
+      options: model.settings ?? {},
+      headers: model.headers ?? {},
+      release_date: new Date(model.time.released).toISOString().slice(0, 10),
+      variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])),
+    }
+  }
+
+  return {
+    all,
+    connected: providers.map((provider) => provider.id),
+    default: Object.fromEntries(
+      providers.flatMap((provider) => {
+        const model =
+          defaultModel?.providerID === provider.id
+            ? defaultModel
+            : models?.find((item) => item.providerID === provider.id && item.status !== "deprecated")
+        return model ? [[provider.id, model.id]] : []
+      }),
     ),
   }
 }
@@ -49,3 +162,10 @@ export function sanitizeProject(project: Project) {
     },
   }
 }
+
+export function normalizeProjectInfo(project: Project | CurrentProject): Project {
+  return {
+    ...project,
+    vcs: project.vcs === "git" ? "git" : undefined,
+  }
+}

+ 148 - 0
packages/app/src/context/server-session-v2-reducer.test.ts

@@ -0,0 +1,148 @@
+import { describe, expect, test } from "bun:test"
+import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
+import { createV2SessionReducer } from "./server-session-v2-reducer"
+
+const event = (input: object) => input as OpenCodeEvent
+const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
+
+describe("v2 session reducer", () => {
+  test("projects promoted input and streaming assistant content", () => {
+    const reducer = createV2SessionReducer()
+    let messages: SessionMessageInfo[] = []
+    const apply = (input: object) => {
+      const result = reducer.reduce(messages, event(input))
+      if (result) messages = result.messages
+      return result
+    }
+
+    apply({
+      ...base,
+      id: "evt_admitted",
+      type: "session.input.admitted",
+      data: {
+        sessionID: "ses_1",
+        inputID: "msg_user",
+        input: { type: "user", delivery: "steer", data: { text: "hello" } },
+      },
+    })
+    apply({ ...base, id: "evt_promoted", type: "session.input.promoted", data: { sessionID: "ses_1", inputID: "msg_user" } })
+    apply({
+      ...base,
+      id: "evt_step",
+      type: "session.step.started",
+      data: {
+        sessionID: "ses_1",
+        assistantMessageID: "msg_assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+      },
+    })
+    apply({
+      ...base,
+      id: "evt_text_start",
+      type: "session.text.started",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 },
+    })
+    apply({
+      ...base,
+      id: "evt_text_delta",
+      type: "session.text.delta",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" },
+    })
+    apply({
+      ...base,
+      id: "evt_text_end",
+      type: "session.text.ended",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" },
+    })
+
+    expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" })
+    expect(messages[1]).toMatchObject({
+      id: "msg_assistant",
+      type: "assistant",
+      content: [{ type: "text", text: "hello" }],
+    })
+  })
+
+  test("folds tool, retry, and completion events", () => {
+    const reducer = createV2SessionReducer()
+    let messages: SessionMessageInfo[] = []
+    const apply = (input: object) => {
+      const result = reducer.reduce(messages, event(input))
+      if (result) messages = result.messages
+    }
+
+    apply({
+      ...base,
+      id: "evt_step",
+      type: "session.step.started",
+      data: {
+        sessionID: "ses_1",
+        assistantMessageID: "msg_assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+      },
+    })
+    apply({
+      ...base,
+      id: "evt_tool_start",
+      type: "session.tool.input.started",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
+    })
+    apply({
+      ...base,
+      id: "evt_tool_delta",
+      type: "session.tool.input.delta",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
+    })
+    apply({
+      ...base,
+      id: "evt_tool_called",
+      type: "session.tool.called",
+      data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
+    })
+    apply({
+      ...base,
+      id: "evt_tool_success",
+      type: "session.tool.success",
+      data: {
+        sessionID: "ses_1",
+        assistantMessageID: "msg_assistant",
+        callID: "call_1",
+        structured: {},
+        content: [{ type: "text", text: "done" }],
+        executed: true,
+      },
+    })
+    apply({
+      ...base,
+      id: "evt_retry",
+      type: "session.retry.scheduled",
+      data: {
+        sessionID: "ses_1",
+        assistantMessageID: "msg_assistant",
+        attempt: 2,
+        at: 10,
+        error: { type: "ProviderError", message: "retry" },
+      },
+    })
+    apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } })
+
+    expect(messages[0]).toMatchObject({
+      type: "assistant",
+      retry: undefined,
+      content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }],
+    })
+  })
+
+  test("requests hydration when promotion admission was missed", () => {
+    const result = createV2SessionReducer().reduce([], event({
+      ...base,
+      id: "evt_promoted",
+      type: "session.input.promoted",
+      data: { sessionID: "ses_1", inputID: "msg_user" },
+    }))
+
+    expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
+  })
+})

+ 434 - 0
packages/app/src/context/server-session-v2-reducer.ts

@@ -0,0 +1,434 @@
+import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
+
+type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
+type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
+type Shell = Extract<SessionMessageInfo, { type: "shell" }>
+
+export type V2SessionReduction = {
+  sessionID: string
+  messages: SessionMessageInfo[]
+  touched: string[]
+  missing?: string
+}
+
+export function createV2SessionReducer() {
+  const pending = new Map<string, SessionPendingMessage>()
+
+  const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
+    if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
+    const sessionID = event.data.sessionID
+    const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
+      sessionID,
+      messages,
+      touched,
+    })
+    const append = (message: SessionMessageInfo) =>
+      result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
+
+    switch (event.type) {
+      case "session.input.admitted":
+        pending.set(key(sessionID, event.data.inputID), event.data.input)
+        return result([...source])
+      case "session.input.promoted": {
+        const input = pending.get(key(sessionID, event.data.inputID))
+        pending.delete(key(sessionID, event.data.inputID))
+        if (!input) return { ...result([...source]), missing: event.data.inputID }
+        if (input.type === "user")
+          return append({
+            id: event.data.inputID,
+            type: "user",
+            metadata: input.data.metadata,
+            text: input.data.text,
+            files: input.data.files,
+            agents: input.data.agents,
+            time: { created: event.created },
+          })
+        return append({
+          id: event.data.inputID,
+          type: "synthetic",
+          metadata: input.data.metadata,
+          text: input.data.text,
+          description: input.data.description,
+          time: { created: event.created },
+        })
+      }
+      case "session.agent.selected":
+        return append({
+          id: messageID(event.id),
+          type: "agent-switched",
+          metadata: event.metadata,
+          agent: event.data.agent,
+          time: { created: event.created },
+        })
+      case "session.model.selected":
+        return append({
+          id: messageID(event.id),
+          type: "model-switched",
+          metadata: event.metadata,
+          model: event.data.model,
+          previous: source.findLast(
+            (item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
+              item.type === "model-switched" || item.type === "assistant",
+          )?.model,
+          time: { created: event.created },
+        })
+      case "session.synthetic":
+        return append({
+          id: messageID(event.id),
+          type: "synthetic",
+          metadata: event.data.metadata,
+          text: event.data.text,
+          description: event.data.description,
+          time: { created: event.created },
+        })
+      case "session.skill.activated":
+        return append({
+          id: messageID(event.id),
+          type: "skill",
+          metadata: event.metadata,
+          skill: event.data.id,
+          name: event.data.name,
+          text: event.data.text,
+          time: { created: event.created },
+        })
+      case "session.shell.started":
+        return append({
+          id: messageID(event.id),
+          type: "shell",
+          metadata: event.metadata,
+          shellID: event.data.shell.id,
+          command: event.data.shell.command,
+          status: event.data.shell.status,
+          exit: event.data.shell.exit,
+          time: { created: event.created },
+        })
+      case "session.shell.ended":
+        return updateMessage<Shell>(source, (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, (item) => ({
+          ...item,
+          status: event.data.shell.status,
+          exit: event.data.shell.exit,
+          output: event.data.output,
+          time: { ...item.time, completed: event.created },
+        }), sessionID)
+      case "session.step.started": {
+        const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
+        const completed = current && current.id !== event.data.assistantMessageID
+          ? update(source, current.id, (item) => item.type === "assistant" ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } : item)
+          : [...source]
+        const existing = completed.find((item) => item.id === event.data.assistantMessageID)
+        if (existing?.type === "assistant")
+          return result(update(completed, existing.id, (item) => item.type === "assistant" ? {
+            ...item,
+            agent: event.data.agent,
+            model: event.data.model,
+            retry: undefined,
+            error: undefined,
+            finish: undefined,
+            snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
+            time: { ...item.time, completed: undefined },
+          } : item), current && current.id !== existing.id ? [current.id, existing.id] : [existing.id])
+        return result([...completed, {
+          id: event.data.assistantMessageID,
+          type: "assistant",
+          metadata: event.metadata,
+          agent: event.data.agent,
+          model: event.data.model,
+          content: [],
+          snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
+          time: { created: event.created },
+        }], current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID])
+      }
+      case "session.step.ended":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          finish: event.data.finish,
+          cost: event.data.cost,
+          tokens: event.data.tokens,
+          snapshot: event.data.snapshot || event.data.files
+            ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
+            : item.snapshot,
+          time: { ...item.time, completed: event.created },
+        }))
+      case "session.step.failed":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          finish: "error",
+          error: event.data.error,
+          retry: undefined,
+          cost: event.data.cost ?? item.cost,
+          tokens: event.data.tokens ?? item.tokens,
+          snapshot: event.data.snapshot || event.data.files
+            ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
+            : item.snapshot,
+          time: { ...item.time, completed: event.created },
+        }))
+      case "session.text.started":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }),
+        }))
+      case "session.text.delta":
+        return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
+          ...item,
+          text: item.text + event.data.delta,
+        }))
+      case "session.text.ended":
+        return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
+          ...item,
+          text: event.data.text,
+        }))
+      case "session.reasoning.started":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          content: insertOrdinal(item.content, "reasoning", event.data.ordinal, {
+            type: "reasoning",
+            text: "",
+            state: event.data.state,
+            time: { created: event.created },
+          }),
+        }))
+      case "session.reasoning.delta":
+        return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({
+          ...item,
+          text: item.text + event.data.delta,
+        }))
+      case "session.reasoning.ended":
+        return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({
+          ...item,
+          text: event.data.text,
+          state: event.data.state ?? item.state,
+          time: { created: item.time?.created ?? event.created, completed: event.created },
+        }))
+      case "session.tool.input.started":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
+            ? item.content
+            : [...item.content, {
+                type: "tool",
+                id: event.data.callID,
+                name: event.data.name,
+                state: { status: "streaming", input: "" },
+                time: { created: event.created },
+              }],
+        }))
+      case "session.tool.input.delta":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
+          tool.state.status === "streaming"
+            ? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
+            : tool,
+        )
+      case "session.tool.input.ended":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
+          tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
+        )
+      case "session.tool.called":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
+          ...tool,
+          executed: event.data.executed,
+          providerState: event.data.state,
+          state: { status: "running", input: event.data.input, structured: {}, content: [] },
+          time: { ...tool.time, ran: event.created },
+        }))
+      case "session.tool.progress":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
+          tool.state.status === "running"
+            ? { ...tool, state: { ...tool.state, structured: event.data.structured, content: event.data.content } }
+            : tool,
+        )
+      case "session.tool.success":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
+          if (tool.state.status !== "running") return tool
+          return {
+            ...tool,
+            executed: event.data.executed || tool.executed === true,
+            providerResultState: event.data.resultState,
+            state: {
+              status: "completed",
+              input: tool.state.input,
+              structured: event.data.structured,
+              content: event.data.content,
+              result: event.data.result,
+            },
+            time: { ...tool.time, completed: event.created },
+          }
+        })
+      case "session.tool.failed":
+        return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
+          if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
+          return {
+            ...tool,
+            executed: event.data.executed || tool.executed === true,
+            providerResultState: event.data.resultState,
+            state: {
+              status: "error",
+              input: typeof tool.state.input === "string" ? {} : tool.state.input,
+              structured: tool.state.status === "running" ? tool.state.structured : {},
+              content: tool.state.status === "running" ? tool.state.content : [],
+              error: event.data.error,
+              result: event.data.result,
+            },
+            time: { ...tool.time, completed: event.created },
+          }
+        })
+      case "session.retry.scheduled":
+        return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
+          ...item,
+          retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
+        }))
+      case "session.execution.succeeded":
+      case "session.execution.failed":
+      case "session.execution.interrupted": {
+        const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
+        if (!current?.retry) return result([...source])
+        return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined }))
+      }
+      case "session.compaction.started":
+        return append({
+          id: event.data.inputID ?? messageID(event.id),
+          type: "compaction",
+          status: "running",
+          metadata: event.metadata,
+          reason: event.data.reason,
+          summary: "",
+          recent: event.data.recent,
+          time: { created: event.created },
+        })
+      case "session.compaction.delta":
+        return updateMessage<Extract<Compaction, { status: "running" }>>(source, (item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running", (item) => ({
+          ...item,
+          summary: item.summary + event.data.text,
+        }), sessionID)
+      case "session.compaction.ended": {
+        const current = source.findLast((item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running")
+        if (!current)
+          return append({
+            id: messageID(event.id),
+            type: "compaction",
+            status: "completed",
+            metadata: event.metadata,
+            reason: event.data.reason,
+            summary: event.data.text,
+            recent: event.data.recent,
+            time: { created: event.created },
+          })
+        return result(update(source, current.id, () => ({
+          ...current,
+          status: "completed",
+          reason: event.data.reason,
+          summary: event.data.text,
+          recent: event.data.recent,
+        })), [current.id])
+      }
+      case "session.compaction.failed": {
+        const current = source.findLast((item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running")
+        const failed: Extract<Compaction, { status: "failed" }> = {
+          id: current?.id ?? event.data.inputID ?? messageID(event.id),
+          type: "compaction",
+          status: "failed",
+          metadata: current?.metadata ?? event.metadata,
+          reason: event.data.reason,
+          error: event.data.error,
+          time: current?.time ?? { created: event.created },
+        }
+        if (!current) return append(failed)
+        return result(update(source, current.id, () => failed), [failed.id])
+      }
+      default:
+        return
+    }
+  }
+
+  return {
+    reduce,
+    clear(sessionID: string) {
+      for (const id of pending.keys()) {
+        if (id.startsWith(`${sessionID}:`)) pending.delete(id)
+      }
+    },
+  }
+}
+
+function key(sessionID: string, inputID: string) {
+  return `${sessionID}:${inputID}`
+}
+
+function messageID(eventID: string) {
+  return eventID.replace(/^evt_/, "msg_")
+}
+
+function update(
+  source: readonly SessionMessageInfo[],
+  id: string,
+  apply: (item: SessionMessageInfo) => SessionMessageInfo,
+) {
+  return source.map((item) => item.id === id ? apply(item) : item)
+}
+
+function updateMessage<T extends SessionMessageInfo>(
+  source: readonly SessionMessageInfo[],
+  matches: (item: SessionMessageInfo) => item is T,
+  apply: (item: T) => T,
+  sessionID: string,
+): V2SessionReduction {
+  const current = source.findLast(matches)
+  if (!current) return { sessionID, messages: [...source], touched: [] }
+  return { sessionID, messages: update(source, current.id, (item) => matches(item) ? apply(item) : item), touched: [current.id] }
+}
+
+function updateAssistant(
+  source: readonly SessionMessageInfo[],
+  id: string,
+  sessionID: string,
+  apply: (item: Assistant) => Assistant,
+): V2SessionReduction {
+  return {
+    sessionID,
+    messages: update(source, id, (item) => item.type === "assistant" ? apply(item) : item),
+    touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [],
+  }
+}
+
+function updateContent<T extends "text" | "reasoning">(
+  source: readonly SessionMessageInfo[],
+  messageID: string,
+  sessionID: string,
+  type: T,
+  ordinal: number,
+  apply: (item: Extract<Assistant["content"][number], { type: T }>) => Extract<Assistant["content"][number], { type: T }>,
+) {
+  return updateAssistant(source, messageID, sessionID, (assistant) => {
+    let index = -1
+    return {
+      ...assistant,
+      content: assistant.content.map((item) => {
+        if (item.type !== type || ++index !== ordinal) return item
+        return apply(item as Extract<Assistant["content"][number], { type: T }>)
+      }),
+    }
+  })
+}
+
+function updateTool(
+  source: readonly SessionMessageInfo[],
+  messageID: string,
+  callID: string,
+  sessionID: string,
+  apply: (item: Extract<Assistant["content"][number], { type: "tool" }>) => Extract<Assistant["content"][number], { type: "tool" }>,
+) {
+  return updateAssistant(source, messageID, sessionID, (assistant) => ({
+    ...assistant,
+    content: assistant.content.map((item) => item.type === "tool" && item.id === callID ? apply(item) : item),
+  }))
+}
+
+function insertOrdinal<T extends Assistant["content"][number]["type"]>(
+  source: Assistant["content"],
+  type: T,
+  ordinal: number,
+  item: Extract<Assistant["content"][number], { type: T }>,
+) {
+  const matches = source.filter((content) => content.type === type)
+  if (matches[ordinal]) return source
+  return [...source, item]
+}

+ 157 - 0
packages/app/src/context/server-session.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import type { retry } from "@opencode-ai/core/util/retry"
+import type { MessageApi, OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
 import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
 import { createServerSession } from "./server-session"
 
@@ -158,6 +159,57 @@ function setup(sessions: Record<string, Session>) {
 }
 
 describe("server session", () => {
+  test("projects V2 session events into current and legacy message state", () => {
+    const ctx = setup({ child: session("child") })
+    ctx.store.remember(session("child"))
+    ctx.store.set("session_message", "child", [
+      {
+        id: "msg_1_user",
+        type: "user",
+        text: "hello",
+        time: { created: 1 },
+      },
+    ])
+    const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
+
+    apply({
+      id: "evt_step",
+      created: 2,
+      type: "session.step.started",
+      durable: { aggregateID: "child", seq: 1, version: 1 },
+      location: { directory: "/repo" },
+      data: {
+        sessionID: "child",
+        assistantMessageID: "msg_2_assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+      },
+    })
+    apply({
+      id: "evt_text_start",
+      created: 3,
+      type: "session.text.started",
+      durable: { aggregateID: "child", seq: 2, version: 1 },
+      location: { directory: "/repo" },
+      data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 },
+    })
+    apply({
+      id: "evt_text_delta",
+      created: 4,
+      type: "session.text.delta",
+      location: { directory: "/repo" },
+      data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
+    })
+
+    expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
+      id: "msg_2_assistant",
+      type: "assistant",
+      content: [{ type: "text", text: "world" }],
+    })
+    expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
+    expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
+  })
+
   test("resolves lineage by session ID without directory", async () => {
     const ctx = setup({ child: session("child", "root"), root: session("root") })
 
@@ -178,6 +230,111 @@ describe("server session", () => {
     expect(ctx.store.data.message.root).toEqual([])
   })
 
+  test("loads current session content through the current message API", async () => {
+    const requests: unknown[] = []
+    const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
+    const assistant = {
+      id: "msg_a_assistant",
+      type: "assistant",
+      agent: "build",
+      model: { id: "model", providerID: "provider" },
+      content: [{ type: "text", text: "hi" }],
+      time: { created: 2, completed: 3 },
+    }
+    const client = {
+      session: {
+        messages: () => {
+          throw new Error("legacy message endpoint called")
+        },
+      },
+    } as unknown as OpencodeClient
+    const messageApi = {
+      list: async (input: unknown) => {
+        requests.push(input)
+        return { data: [assistant, user], cursor: { previous: null, next: null } }
+      },
+    } as unknown as MessageApi
+    const store = createServerSession(client, {} as SessionApi, messageApi)
+    store.remember(session("root"))
+
+    await store.sync("root")
+
+    expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
+    expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
+  })
+
+  test("reprojects current assistants when an older page supplies their user", async () => {
+    const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
+    const assistant = (id: string, created: number) => ({
+      id,
+      type: "assistant" as const,
+      agent: "build",
+      model: { id: "model", providerID: "provider" },
+      content: [{ type: "text" as const, text: id }],
+      time: { created, completed: created },
+    })
+    const assistants = [
+      assistant("msg_2_assistant", 2),
+      assistant("msg_3_assistant", 3),
+      assistant("msg_4_assistant", 4),
+    ]
+    const pages = [
+      { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } },
+      { data: [assistants[0], user], cursor: { previous: null, next: null } },
+    ]
+    const messageApi = {
+      list: async () => pages.shift()!,
+    } as unknown as MessageApi
+    const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
+    store.remember(session("root"))
+
+    await store.sync("root")
+    expect(store.data.message.root).toEqual([])
+
+    await store.history.loadMore("root")
+
+    expect(store.data.message.root.map((message) => message.id)).toEqual([
+      user.id,
+      ...assistants.map((item) => item.id),
+    ])
+    expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
+  })
+
+  test("indexes V1 messages for the current timeline projection", async () => {
+    const user = userMessage("message-1", { sessionID: "root" })
+    const assistant = assistantMessage("message-2", user.id, { sessionID: "root" })
+    const client = messageClient(
+      response([
+        { info: user, parts: [textPart(user.id, { sessionID: "root" })] },
+        { info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] },
+      ]),
+    )
+    const messageApi = {
+      list: () => {
+        throw new Error("current message endpoint called")
+      },
+    } as unknown as MessageApi
+    const store = createServerSession(client, {} as SessionApi, messageApi, {
+      protocol: Promise.resolve("v1"),
+    })
+    store.remember(session("root"))
+
+    await store.sync("root")
+
+    expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
+    expect(store.data.session_message.root).toMatchObject([
+      { id: user.id, type: "user", text: "text" },
+      { id: assistant.id, type: "assistant" },
+    ])
+
+    const next = userMessage("message-3", { sessionID: "root" })
+    store.apply({ type: "message.updated", properties: { info: next } })
+    expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id])
+
+    store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } })
+    expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
+  })
+
   test("backfills an assistant-only initial page through its user root", async () => {
     const user = userMessage("message-1")
     const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]

+ 247 - 38
packages/app/src/context/server-session.ts

@@ -1,5 +1,6 @@
 import { Binary } from "@opencode-ai/core/util/binary"
 import { retry } from "@opencode-ai/core/util/retry"
+import type { MessageApi, OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
 import type {
   Message,
   OpencodeClient,
@@ -8,15 +9,18 @@ import type {
   QuestionRequest,
   Session,
   SessionStatus,
-  SnapshotFileDiff,
   Todo,
 } from "@opencode-ai/sdk/v2/client"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import { batch } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
-import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
+import { message as cleanMessage } from "@/utils/diffs"
 import { sessionNotFoundError } from "@/utils/server-errors"
 import { rootSession } from "@/utils/session-route"
+import { normalizeSessionInfo } from "@/utils/session"
+import { normalizeSessionMessages } from "@/utils/session-message"
 import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
+import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
 
 const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
@@ -36,10 +40,37 @@ type OptimisticItem = {
 type MessagePage = {
   session: Message[]
   part: { id: string; part: Part[] }[]
+  source?: SessionMessageInfo[]
+  sourceMode?: "latest" | "older"
+  projectSource?: boolean
   cursor?: string
   complete: boolean
 }
 
+function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
+  return items
+    .slice()
+    .sort((a, b) => cmp(a.info.id, b.info.id))
+    .map((item) => {
+      if (item.info.role === "user") {
+        return {
+          id: item.info.id,
+          type: "user" as const,
+          text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
+          time: item.info.time,
+        }
+      }
+      return {
+        id: item.info.id,
+        type: "assistant" as const,
+        agent: item.info.agent ?? item.info.mode,
+        model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
+        content: [],
+        time: item.info.time,
+      }
+    })
+}
+
 // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
 type MessageLoadState = {
   touchedMessages: Set<string>
@@ -52,6 +83,7 @@ type MessageLoadState = {
   optimisticParts: Map<string, Set<string>>
   orphanParents: Set<string>
   clearedMessageParts: Set<string>
+  touchedSource: Set<string>
 }
 
 type MessageLoadBaseline = Pick<
@@ -137,15 +169,25 @@ function reconcileFetched<T extends { id: string }>(
   return [...result.values()].sort((a, b) => cmp(a.id, b.id))
 }
 
-export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
+type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
+
+export function createServerSession(
+  client: OpencodeClient,
+  sessionApiOrOptions?: SessionApi | ServerSessionOptions,
+  messageApi?: MessageApi,
+  currentOptions?: ServerSessionOptions,
+) {
+  const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined
+  const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined)
   const [data, setData] = createStore({
     info: {} as Record<string, Session | undefined>,
     session_status: {} as Record<string, SessionStatus>,
-    session_diff: {} as Record<string, SnapshotFileDiff[]>,
+    session_diff: {} as Record<string, FileDiffInfo[]>,
     todo: {} as Record<string, Todo[]>,
     permission: {} as Record<string, PermissionRequest[]>,
     question: {} as Record<string, QuestionRequest[]>,
     message: {} as Record<string, Message[]>,
+    session_message: {} as Record<string, SessionMessageInfo[]>,
     part: {} as Record<string, Part[]>,
     part_text_accum_delta: {} as Record<string, string>,
     session_working(id: string) {
@@ -154,9 +196,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
   })
   const requests = new Map<string, Promise<Session>>()
   const inflight = new Map<string, Promise<void>>()
-  const inflightDiff = new Map<string, Promise<void>>()
   const inflightTodo = new Map<string, Promise<void>>()
   const optimistic = new Map<string, Map<string, OptimisticItem>>()
+  const v2 = createV2SessionReducer()
   const messageLoads = new Map<string, MessageLoadState>()
   const pendingParts = new Map<string, Map<string, Set<string>>>()
   const orphanParts = new Map<string, Set<string>>()
@@ -191,6 +233,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     at: {} as Record<string, number | undefined>,
   })
 
+  const indexLegacyMessage = (message: Message) => {
+    const current = data.session_message[message.sessionID] ?? []
+    if (current.some((item) => item.id === message.id)) return
+    setData(
+      "session_message",
+      message.sessionID,
+      reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]),
+    )
+  }
+
   const remember = (session: Session) => {
     setData("info", session.id, reconcile(session))
     infoSeen.delete(session.id)
@@ -200,7 +252,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         ...pinned.keys(),
         ...requests.keys(),
         ...inflight.keys(),
-        ...inflightDiff.keys(),
         ...inflightTodo.keys(),
         ...messageLoads.keys(),
         ...optimistic.keys(),
@@ -242,27 +293,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     const pending = requests.get(sessionID)
     if (pending) return pending
     const active = generation(sessionID)
-    const request = client.session.get({ sessionID }).then((result) => {
-      if (!result.data) throw sessionNotFoundError(sessionID)
-      if (generations.get(sessionID) !== active) return result.data
-      return remember(result.data)
+    const request = sessionApi
+      ? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
+      : client.session.get({ sessionID }).then((result) => {
+          if (!result.data) throw sessionNotFoundError(sessionID)
+          return result.data
+        })
+    const resolved = request.then((result) => {
+      if (generations.get(sessionID) !== active) return result
+      return remember(result)
     })
-    requests.set(sessionID, request)
+    requests.set(sessionID, resolved)
     const cleanup = () => {
-      if (requests.get(sessionID) === request) requests.delete(sessionID)
+      if (requests.get(sessionID) === resolved) requests.delete(sessionID)
       if (
         generations.get(sessionID) === active &&
         !data.info[sessionID] &&
         !requests.has(sessionID) &&
         !messageLoads.has(sessionID) &&
         !inflight.has(sessionID) &&
-        !inflightDiff.has(sessionID) &&
         !inflightTodo.has(sessionID)
       )
         generations.delete(sessionID)
     }
-    void request.then(cleanup, cleanup)
-    return request
+    void resolved.then(cleanup, cleanup)
+    return resolved
   }
 
   const peekLineage = (sessionID: string) => {
@@ -419,9 +474,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       clearOptimistic(sessionID)
       requests.delete(sessionID)
       inflight.delete(sessionID)
-      inflightDiff.delete(sessionID)
       inflightTodo.delete(sessionID)
       messageLoads.delete(sessionID)
+      v2.clear(sessionID)
       pendingParts.delete(sessionID)
       orphanParts.delete(sessionID)
       removedMessages.delete(sessionID)
@@ -449,7 +504,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       ...pinned.keys(),
       ...requests.keys(),
       ...inflight.keys(),
-      ...inflightDiff.keys(),
       ...inflightTodo.keys(),
       ...messageLoads.keys(),
       ...optimistic.keys(),
@@ -470,6 +524,25 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     )
 
   const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
+    if (messageApi && (await options?.protocol) !== "v1") {
+      const response = await (options?.retry ?? retry)(() => {
+        onAttempt?.()
+        return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" })
+      })
+      const source = [...response.data].reverse()
+      const normalized = normalizeSessionMessages(sessionID, source)
+      return {
+        session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
+        part: [...normalized.parts.entries()]
+          .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
+          .sort((a, b) => cmp(a.id, b.id)),
+        source,
+        sourceMode: before ? ("older" as const) : ("latest" as const),
+        projectSource: true,
+        cursor: response.cursor.next ?? undefined,
+        complete: response.data.length === 0,
+      }
+    }
     const response = await (options?.retry ?? retry)(() => {
       onAttempt?.()
       return client.session.messages({ sessionID, limit, before })
@@ -481,12 +554,24 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         id: item.info.id,
         part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
       })),
+      source: legacyMessageSource(items),
+      sourceMode: before ? ("older" as const) : ("latest" as const),
       cursor: response.response.headers.get("x-next-cursor") ?? undefined,
       complete: !response.response.headers.get("x-next-cursor"),
     }
   }
 
   const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
+    if (sessionApi && (await options?.protocol) !== "v1") {
+      const response = await (options?.retry ?? retry)(() => {
+        onAttempt?.()
+        return sessionApi.message({ sessionID, messageID })
+      })
+      const normalized = normalizeSessionMessages(sessionID, [response])
+      const message = normalized.messages[0]
+      if (!message) throw new Error(`Message not found: ${messageID}`)
+      return { message, parts: normalized.parts.get(messageID) ?? [] }
+    }
     const response = await (options?.retry ?? retry)(() => {
       onAttempt?.()
       return client.session.message({ sessionID, messageID })
@@ -571,7 +656,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     preserveUnfetched: boolean | ((message: Message) => boolean),
     cleanupOrphans: boolean,
   ) => {
-    const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
+    const source = page.source
+      ? (() => {
+          const incoming = new Map(page.source.map((message) => [message.id, message]))
+          const existing = data.session_message[sessionID] ?? []
+          const current = existing.filter((message) => !incoming.has(message.id))
+          const live = new Map(existing.map((message) => [message.id, message]))
+          return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
+            (message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
+          )
+        })()
+      : undefined
+    const projected =
+      page.projectSource && source
+        ? (() => {
+            const normalized = normalizeSessionMessages(sessionID, source)
+            return {
+              ...page,
+              session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
+              part: [...normalized.parts.entries()]
+                .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
+                .sort((a, b) => cmp(a.id, b.id)),
+            }
+          })()
+        : page
+    const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
     merged.observed.forEach((item) => {
       if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
     })
@@ -583,6 +692,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       preserveUnfetched,
     })
     batch(() => {
+      if (source) setData("session_message", sessionID, reconcile(source))
       const messageIDs = replaceMessages(sessionID, messages)
       replaceParts(sessionID, merged.part, messageIDs, load)
       const orphans = orphanParts.get(sessionID)
@@ -613,6 +723,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       optimisticParts: new Map(),
       orphanParents: new Set(),
       clearedMessageParts: new Set(),
+      touchedSource: new Set(),
     }
     messageLoads.set(sessionID, load)
     setMeta("loading", sessionID, true)
@@ -747,6 +858,109 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       return properties.part.sessionID
   }
 
+  const projectV2 = (reduction: V2SessionReduction) => {
+    reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
+    setData("session_message", reduction.sessionID, reconcile(reduction.messages))
+    if (reduction.touched.length === 0) return
+
+    const touched = new Set(reduction.touched)
+    let parentID: string | undefined
+    for (const message of reduction.messages) {
+      if (message.type === "user" || (message.type === "synthetic" && message.description?.trim()))
+        parentID = message.id
+      if (message.type === "shell") {
+        if (touched.has(message.id)) touched.add(`${message.id}:assistant`)
+        parentID = undefined
+      }
+      if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID)
+      if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID)
+    }
+
+    const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
+    batch(() => {
+      for (const message of normalized.messages) {
+        if (!touched.has(message.id)) continue
+        apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
+      }
+      for (const messageID of touched) {
+        const next = normalized.parts.get(messageID) ?? []
+        const nextIDs = new Set(next.map((part) => part.id))
+        for (const part of next) {
+          apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
+        }
+        for (const part of data.part[messageID] ?? []) {
+          if (nextIDs.has(part.id)) continue
+          apply({
+            type: "message.part.removed",
+            properties: { sessionID: reduction.sessionID, messageID, partID: part.id },
+          })
+        }
+      }
+    })
+  }
+
+  const hydrateV2Message = (sessionID: string, messageID: string) => {
+    if (!sessionApi) return
+    void sessionApi
+      .message({ sessionID, messageID })
+      .then((message) => {
+        const current = data.session_message[sessionID] ?? []
+        const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id))
+        projectV2({ sessionID, messages, touched: [message.id] })
+      })
+      .catch(() => {})
+  }
+
+  const applyV2 = (event: OpenCodeEvent) => {
+    if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
+    const sessionID = event.data.sessionID
+    const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
+    if (reduction) {
+      projectV2(reduction)
+      if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
+    }
+
+    const info = data.info[sessionID]
+    if (event.type === "session.renamed" && info)
+      remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
+    if (event.type === "session.moved" && info)
+      remember({
+        ...info,
+        projectID: event.data.projectID ?? info.projectID,
+        workspaceID: event.data.location.workspaceID,
+        directory: event.data.location.directory,
+        path: event.data.subpath,
+        time: { ...info.time, updated: event.created },
+      })
+    if (event.type === "session.usage.updated" && info)
+      remember({ ...info, cost: event.data.cost, tokens: event.data.tokens })
+    if (event.type === "session.archived") {
+      if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } })
+      evict([sessionID])
+    }
+    if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" })
+    if (
+      event.type === "session.execution.succeeded" ||
+      event.type === "session.execution.failed" ||
+      event.type === "session.execution.interrupted"
+    )
+      setData("session_status", sessionID, { type: "idle" })
+    if (event.type === "session.retry.scheduled")
+      setData("session_status", sessionID, {
+        type: "retry",
+        attempt: event.data.attempt,
+        message: event.data.error.message,
+        next: event.data.at,
+      })
+    if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {})
+    if (
+      event.type === "session.revert.staged" ||
+      event.type === "session.revert.cleared" ||
+      event.type === "session.revert.committed"
+    )
+      void resolve(sessionID, { force: true }).catch(() => {})
+  }
+
   const apply = (event: { type: string; properties?: unknown }) => {
     const eventID = eventSessionID(event)
     if (eventID) {
@@ -770,7 +984,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         return
       }
       case "session.deleted": {
-        const sessionID = (event.properties as { info: Session }).info.id
+        const properties = event.properties as { sessionID?: string; info?: Session }
+        const sessionID = properties.info?.id ?? properties.sessionID
+        if (!sessionID) return
         infoSeen.delete(sessionID)
         setData(
           "info",
@@ -779,11 +995,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         evict([sessionID])
         return
       }
-      case "session.diff": {
-        const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
-        setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
-        return
-      }
       case "todo.updated": {
         const props = event.properties as { sessionID: string; todos: Todo[] }
         setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
@@ -796,6 +1007,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       }
       case "message.updated": {
         const info = cleanMessage((event.properties as { info: Message }).info)
+        indexLegacyMessage(info)
         const load = messageLoads.get(info.sessionID)
         load?.touchedMessages.add(info.id)
         load?.removedMessages.delete(info.id)
@@ -828,6 +1040,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       }
       case "message.removed": {
         const props = event.properties as { sessionID: string; messageID: string }
+        setData("session_message", props.sessionID, (messages) =>
+          messages?.filter((message) => message.id !== props.messageID),
+        )
         const load = messageLoads.get(props.sessionID)
         load?.touchedMessages.add(props.messageID)
         load?.removedMessages.add(props.messageID)
@@ -1140,23 +1355,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
       },
     },
-    diff(sessionID: string, options?: { force?: boolean }) {
+    async todo(sessionID: string, request?: { force?: boolean }) {
       touch(sessionID)
-      if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
-      return runInflight(inflightDiff, sessionID, () => {
-        const active = generation(sessionID)
-        return retry(() => client.session.diff({ sessionID })).then((result) => {
-          if (generations.get(sessionID) !== active) return
-          setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
-        })
-      })
-    },
-    todo(sessionID: string, options?: { force?: boolean }) {
-      touch(sessionID)
-      if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
+      if (data.todo[sessionID] !== undefined && !request?.force) return
+      if ((await options?.protocol) === "v2") {
+        setData("todo", sessionID, [])
+        return
+      }
       return runInflight(inflightTodo, sessionID, () => {
         const active = generation(sessionID)
-        return retry(() => client.session.todo({ sessionID })).then((result) => {
+        return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
           if (generations.get(sessionID) !== active) return
           setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
         })
@@ -1190,6 +1398,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       if (count && count > 1) pinned.set(sessionID, count - 1)
     },
     apply,
+    applyV2,
   }
 }
 

+ 143 - 30
packages/app/src/context/server-sync.test.ts

@@ -1,6 +1,108 @@
 import { describe, expect, test } from "bun:test"
+import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
+import type {
+  McpApi,
+  McpListInput,
+  McpResourceCatalogInput,
+  SessionApi,
+  SessionInfo,
+  SessionListInput,
+} from "@opencode-ai/client/promise"
+import { QueryClient } from "@tanstack/solid-query"
 import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
-import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
+import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
+import {
+  loadActiveSessionsQuery,
+  loadMcpQuery,
+  loadMcpResourcesQuery,
+  seedActiveSessionStatuses,
+} from "./server-sync"
+import { ServerScope } from "@/utils/server-scope"
+import { createServerSession } from "./server-session"
+
+describe("MCP queries", () => {
+  test("loads current servers for the requested location", async () => {
+    const calls: unknown[] = []
+    const queryClient = new QueryClient()
+    const result = await queryClient.fetchQuery(
+      loadMcpQuery(ServerScope.local, "/project", {
+        list: async (input: McpListInput = {}) => {
+          calls.push(input)
+          return {
+            location: { directory: "/project", project: { id: "project", directory: "/project" } },
+            data: [
+              { name: "docs", status: { status: "connected" } },
+              { name: "search", status: { status: "pending" } },
+            ],
+          }
+        },
+      } as unknown as McpApi),
+    )
+
+    expect(calls).toEqual([{ location: { directory: "/project" } }])
+    expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } })
+  })
+
+  test("loads and keys the current resource catalog", async () => {
+    const calls: unknown[] = []
+    const queryClient = new QueryClient()
+    const result = await queryClient.fetchQuery(
+      loadMcpResourcesQuery(ServerScope.local, "/project", {
+        resource: {
+          catalog: async (input: McpResourceCatalogInput = {}) => {
+            calls.push(input)
+            return {
+              location: { directory: "/project", project: { id: "project", directory: "/project" } },
+              data: {
+                resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }],
+                templates: [],
+              },
+            }
+          },
+        },
+      } as unknown as McpApi),
+    )
+
+    expect(calls).toEqual([{ location: { directory: "/project" } }])
+    expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } })
+  })
+})
+
+describe("active session query", () => {
+  test("loads active sessions once per server cache", async () => {
+    let calls = 0
+    const queryClient = new QueryClient()
+    const options = loadActiveSessionsQuery(ServerScope.local, {
+      active: async () => {
+        calls++
+        return { ses_running: { type: "running" } }
+      },
+    })
+
+    expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
+    expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
+    expect(calls).toBe(1)
+    expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"])
+  })
+
+  test("does not overwrite statuses already written by events", () => {
+    const session = createServerSession({} as OpencodeClient)
+    session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
+
+    seedActiveSessionStatuses(session, {
+      ses_running: { type: "running" },
+      ses_retry: { type: "running" },
+    })
+
+    expect(session.data.session_status.ses_running).toEqual({ type: "busy" })
+    expect(session.data.session_status.ses_retry).toEqual({
+      type: "retry",
+      attempt: 2,
+      message: "retrying",
+      next: 10,
+    })
+  })
+})
 
 describe("pickDirectoriesToEvict", () => {
   test("keeps pinned stores and evicts idle stores", () => {
@@ -23,46 +125,57 @@ describe("pickDirectoriesToEvict", () => {
   })
 })
 
-describe("loadRootSessionsWithFallback", () => {
-  test("uses limited roots query when supported", async () => {
-    const calls: Array<{ directory: string; roots: true; limit?: number }> = []
+describe("loadRootSessions", () => {
+  test("loads and normalizes a limited page of root sessions", async () => {
+    const calls: SessionListInput[] = []
 
-    const result = await loadRootSessionsWithFallback({
+    const result = await loadRootSessions({
+      api: {
+        list: async (query = {}) => {
+          calls.push(query)
+          return { data: [sessionInfo("session-1")], cursor: {} }
+        },
+      } satisfies Pick<SessionApi, "list">,
       directory: "dir",
       limit: 10,
-      list: async (query) => {
-        calls.push(query)
-        return { data: [] }
-      },
     })
 
-    expect(result.data).toEqual([])
+    expect(result.data).toEqual([
+      expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
+    ])
     expect(result.limited).toBe(true)
-    expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
+    expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
   })
 
-  test("falls back to full roots query on limited-query failure", async () => {
-    const calls: Array<{ directory: string; roots: true; limit?: number }> = []
-
-    const result = await loadRootSessionsWithFallback({
-      directory: "dir",
-      limit: 25,
-      list: async (query) => {
-        calls.push(query)
-        if (query.limit) throw new Error("unsupported")
-        return { data: [] }
-      },
-    })
-
-    expect(result.data).toEqual([])
-    expect(result.limited).toBe(false)
-    expect(calls).toEqual([
-      { directory: "dir", roots: true, limit: 25 },
-      { directory: "dir", roots: true },
-    ])
+  test("propagates list failures", () => {
+    expect(
+      loadRootSessions({
+        api: {
+          list: async () => {
+            throw new Error("failed")
+          },
+        } satisfies Pick<SessionApi, "list">,
+        directory: "dir",
+        limit: 25,
+      }),
+    ).rejects.toThrow("failed")
   })
 })
 
+function sessionInfo(id: string) {
+  return {
+    id,
+    projectID: "project-1",
+    agent: "build",
+    model: { id: "model-1", providerID: "provider-1" },
+    cost: 0,
+    tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+    time: { created: 1, updated: 1 },
+    title: id,
+    location: { directory: "dir" },
+  } as SessionInfo
+}
+
 describe("estimateRootSessionTotal", () => {
   test("keeps exact total for full fetches", () => {
     expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)

+ 232 - 39
packages/app/src/context/server-sync.tsx

@@ -1,10 +1,10 @@
 import type {
   Config,
-  McpResource,
   OpencodeClient,
   Path,
   Project,
   ProviderAuthResponse,
+  SessionStatus,
 } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@/utils/toast"
 import { getFilename } from "@opencode-ai/core/util/path"
@@ -18,6 +18,7 @@ import {
   bootstrapGlobal,
   clearProviderRev,
   loadAgentsQuery,
+  loadCommands,
   loadGlobalConfigQuery,
   loadPathQuery,
   loadProjectsQuery,
@@ -26,12 +27,13 @@ import {
 } from "./global-sync/bootstrap"
 import { createChildStoreManager } from "./global-sync/child-store"
 import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
-import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
+import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load"
 import { trimSessions } from "./global-sync/session-trim"
 import type { ProjectMeta } from "./global-sync/types"
 import { SESSION_RECENT_LIMIT } from "./global-sync/types"
 import { formatServerError } from "@/utils/server-errors"
 import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
+import type { SolidQueryOptions } from "@tanstack/solid-query"
 import { createRefreshQueue } from "./global-sync/queue"
 import { directoryKey } from "./global-sync/utils"
 import { PathKey } from "@/utils/path-key"
@@ -45,8 +47,18 @@ import { retry } from "@opencode-ai/core/util/retry"
 import type { ServerScope } from "@/utils/server-scope"
 import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
 import { persisted } from "@/utils/persist"
+import type { ServerApi } from "@/utils/server"
+import type {
+  McpListInput,
+  McpListOutput,
+  McpResource,
+  McpResourceCatalogInput,
+  McpResourceCatalogOutput,
+  McpServer,
+  SessionActiveOutput,
+} from "@opencode-ai/client/promise"
 import { toggleMcp } from "./global-sync/mcp"
-import { createServerSession } from "./server-session"
+import { createServerSession, type ServerSession } from "./server-session"
 
 type GlobalStore = {
   ready: boolean
@@ -59,16 +71,76 @@ type GlobalStore = {
   reload: undefined | "pending" | "complete"
 }
 
-export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
-  queryOptions({
+type McpListApi = {
+  readonly list: (input?: McpListInput) => Promise<McpListOutput>
+}
+
+type McpResourceApi = {
+  readonly resource: {
+    readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
+  }
+}
+
+type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
+  initialData?: undefined
+  queryKey: K
+}
+
+type SessionActiveApi = {
+  readonly active: () => Promise<SessionActiveOutput>
+}
+
+export const loadMcpQuery = (
+  scope: ServerScope,
+  directory: string,
+  api: McpListApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<"v1" | "v2">,
+): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
+  queryOptions<
+    Record<string, McpServer["status"]>,
+    Error,
+    Record<string, McpServer["status"]>,
+    readonly [ServerScope, string, "mcp"]
+  >({
     queryKey: [scope, directory, "mcp"] as const,
-    queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
+    queryFn: async () => {
+      if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {}
+      return api
+        .list({ location: { directory } })
+        .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
+    },
   })
 
-export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
-  queryOptions<Record<string, McpResource>>({
+export const loadMcpResourcesQuery = (
+  scope: ServerScope,
+  directory: string,
+  api: McpResourceApi,
+  legacy?: OpencodeClient,
+  protocol?: Promise<"v1" | "v2">,
+): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
+  queryOptions<
+    Record<string, McpResource>,
+    Error,
+    Record<string, McpResource>,
+    readonly [ServerScope, string, "mcpResources"]
+  >({
     queryKey: [scope, directory, "mcpResources"] as const,
-    queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
+    queryFn: async () => {
+      if ((await protocol) === "v1" && legacy) {
+        return Object.fromEntries(
+          Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [
+            key,
+            { ...resource, server: resource.client },
+          ]),
+        )
+      }
+      return api.resource
+        .catalog({ location: { directory } })
+        .then((result) =>
+          Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
+        )
+    },
     placeholderData: {},
   })
 
@@ -78,22 +150,51 @@ export const loadLspQuery = (scope: ServerScope, directory: string, sdk: Opencod
     queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
   })
 
+export const loadActiveSessionsQuery = (
+  scope: ServerScope,
+  api: SessionActiveApi,
+): ApiQueryOptions<SessionActiveOutput, readonly [ServerScope, "activeSessions"]> =>
+  queryOptions<SessionActiveOutput, Error, SessionActiveOutput, readonly [ServerScope, "activeSessions"]>({
+    queryKey: [scope, "activeSessions"] as const,
+    queryFn: () => api.active(),
+    enabled: false,
+    staleTime: Number.POSITIVE_INFINITY,
+    gcTime: Number.POSITIVE_INFINITY,
+    refetchOnMount: false,
+    refetchOnReconnect: false,
+    refetchOnWindowFocus: false,
+  })
+
+export function seedActiveSessionStatuses(
+  session: Pick<ServerSession, "data" | "set">,
+  active: SessionActiveOutput | Record<string, SessionStatus>,
+) {
+  for (const sessionID of Object.keys(active)) {
+    if (session.data.session_status[sessionID] !== undefined) continue
+    const status = active[sessionID]
+    session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status)
+  }
+}
+
 function makeQueryOptionsApi(
   scope: ServerScope,
   serverSDK: () => OpencodeClient,
+  serverAPI: ServerApi,
   sdkFor: (dir: PathKey) => OpencodeClient,
+  protocol: Promise<"v1" | "v2">,
 ) {
   return {
     globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
-    projects: () => loadProjectsQuery(scope, serverSDK()),
+    projects: () => loadProjectsQuery(scope, serverAPI.project),
     providers: (directory: PathKey | null) =>
-      loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
-    path: (directory: PathKey | null) =>
-      loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
-    agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
-    references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
-    mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
-    mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
+      loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
+    path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path),
+    agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol),
+    references: (directory: PathKey) =>
+      loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol),
+    mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
+    mcpResources: (directory: PathKey) =>
+      loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
     lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
     sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
   }
@@ -122,11 +223,44 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     return sdk
   }
 
-  const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
+  const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
+    protocol: serverSDK.protocol,
+  })
+  const queryOptionsApi = makeQueryOptionsApi(
+    serverSDK.scope,
+    () => serverSDK.client,
+    serverSDK.api,
+    sdkFor,
+    serverSDK.protocol,
+  )
 
   const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
     queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
   }))
+  const activeSessionsQuery = useQuery(() =>
+    loadActiveSessionsQuery(serverSDK.scope, {
+      active: async () => {
+        if ((await serverSDK.protocol) === "v1") {
+          const statuses = (await serverSDK.client.session.status()).data ?? {}
+          for (const [sessionID, status] of Object.entries(statuses)) {
+            session.set("session_status", sessionID, reconcile(status))
+            void session.resolve(sessionID).catch(() => undefined)
+          }
+          return Object.fromEntries(
+            Object.entries(statuses).flatMap(([sessionID, status]) =>
+              status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
+            ),
+          )
+        }
+        const active = await serverSDK.api.session.active()
+        seedActiveSessionStatuses(session, active)
+        for (const sessionID of Object.keys(active)) {
+          void session.resolve(sessionID).catch(() => undefined)
+        }
+        return active
+      },
+    }),
+  )
 
   const [globalStore, setGlobalStore] = createStore<GlobalStore>({
     get ready() {
@@ -183,6 +317,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     queryFn: async () => {
       await bootstrapGlobal({
         serverSDK: serverSDK.client,
+        serverAPI: serverSDK.api,
+        protocol: serverSDK.protocol,
         scope: serverSDK.scope,
         requestFailedTitle: language.t("common.requestFailed"),
         translate: language.t,
@@ -212,8 +348,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     bootstrapInstance,
   })
 
-  const session = createServerSession(serverSDK.client)
-
   const children = createChildStoreManager({
     owner,
     scope: serverSDK.scope,
@@ -224,17 +358,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       void bootstrapInstance(directory)
     },
     onMcp: (directory, setStore) => {
-      void retry(() =>
-        sdkFor(directory)
-          .command.list()
-          .then((x) => setStore("command", x.data ?? [])),
-      ).catch((err) => {
-        showToast({
-          variant: "error",
-          title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
-          description: formatServerError(err, language.t),
+      void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol)
+        .then((commands) => setStore("command", commands))
+        .catch((err) => {
+          showToast({
+            variant: "error",
+            title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
+            description: formatServerError(err, language.t),
+          })
         })
-      })
     },
     onDispose: (directory) => {
       const key = directoryKey(directory)
@@ -279,11 +411,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       .fetchQuery({
         ...queryOptionsApi.sessions(key),
         queryFn: () =>
-          loadRootSessionsWithFallback({
-            directory,
-            limit,
-            list: (query) => serverSDK.client.session.list(query),
-          })
+          serverSDK.protocol
+            .then((protocol) =>
+              protocol === "v1"
+                ? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
+                : loadRootSessions({ api: serverSDK.api.session, directory, limit }),
+            )
             .then((x) => {
               const nonArchived = (x.data ?? [])
                 .filter((s) => !!s?.id)
@@ -353,6 +486,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
           provider: globalStore.provider,
         },
         sdk,
+        api: serverSDK.api,
         store: child[0],
         setStore: child[1],
         vcsCache: cache,
@@ -360,6 +494,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
         translate: language.t,
         queryClient,
         session,
+        protocol: serverSDK.protocol,
       })
     })
 
@@ -371,12 +506,31 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     return promise
   }
 
+  const indexSession = (info: Parameters<typeof session.remember>[0]) => {
+    const key = directoryKey(info.directory)
+    const existing = children.children[key]
+    if (!existing) return
+    applyDirectoryEvent({
+      event: { type: "session.created", properties: { info } },
+      directory: key,
+      store: existing[0],
+      setStore: existing[1],
+      push: queue.push,
+      retainedLimit: sessionMeta.get(key)?.limit,
+      sessionContent: false,
+      permission: session.data.permission,
+      loadLsp() {},
+    })
+  }
+
   const unsub = serverSDK.event.listen((e) => {
     const directory = e.name
     const key = directoryKey(directory)
     const event = e.details
+    const eventType: string = event.type
     const recent = bootingRoot || Date.now() - bootedAt < 1500
 
+    if (event.current) session.applyV2(event.current)
     session.apply(event)
     if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
       homeSessions.apply(event)
@@ -384,6 +538,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     homeSessions.refresh(event.type)
 
     if (directory === "global") {
+      if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
+        void activeSessionsQuery.refetch()
       applyGlobalEvent({
         event,
         project: globalStore.project,
@@ -393,7 +549,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
         },
         setGlobalProject: setProjects,
       })
-      if (event.type === "server.connected" || event.type === "global.disposed") {
+      if (
+        eventType === "config.updated" ||
+        eventType === "catalog.updated" ||
+        eventType === "agent.updated" ||
+        eventType === "project.directories.updated"
+      )
+        bootstrap.refetch()
+      if (eventType === "server.connected" || eventType === "global.disposed") {
         if (recent) return
         for (const directory of Object.keys(children.children)) {
           if (!children.active(directory)) continue
@@ -403,9 +566,30 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       return
     }
 
+    if (event.current?.type === "session.moved") {
+      const info = session.get(event.current.data.sessionID)
+      if (info) indexSession(info)
+    }
+    if (event.current?.type === "session.forked")
+      void session
+        .resolve(event.current.data.sessionID, { force: true })
+        .then(indexSession)
+        .catch(() => {})
+
     const existing = children.children[key]
     if (!existing) return
     children.mark(key)
+    if (
+      event.current?.type === "session.moved" ||
+      event.current?.type === "session.archived" ||
+      event.current?.type === "session.forked" ||
+      eventType === "command.updated" ||
+      eventType === "config.updated" ||
+      eventType === "agent.updated"
+    )
+      queue.push(key)
+    if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
+    if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
     const [store, setStore] = existing
     applyDirectoryEvent({
       event,
@@ -502,14 +686,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       toggle: async (directory: string, name: string) => {
         const key = directoryKey(directory)
         const sdk = sdkFor(key)
-        const status = children.child(key, { bootstrap: false })[0].mcp[name].status
+        const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status
+        if (!status) return
         await toggleMcp({
           status,
           connect: async () => {
-            await sdk.mcp.connect({ name })
+            if ((await serverSDK.protocol) === "v1") {
+              await sdk.mcp.connect({ name })
+              return
+            }
+            await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
           },
           disconnect: async () => {
-            await sdk.mcp.disconnect({ name })
+            if ((await serverSDK.protocol) === "v1") {
+              await sdk.mcp.disconnect({ name })
+              return
+            }
+            await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
           },
           authenticate: async () => {
             await sdk.mcp.auth.authenticate({ name })

+ 21 - 59
packages/app/src/pages/session.tsx

@@ -532,7 +532,6 @@ export default function Page() {
 
   const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
   const isChildSession = createMemo(() => !!info()?.parentID)
-  const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
   const canReview = createMemo(() => !!sync().project)
   const reviewTab = createMemo(() => isDesktop())
   const tabState = createSessionTabs({
@@ -690,8 +689,8 @@ export default function Page() {
       queryFn: mode
         ? () =>
             sdk()
-              .client.vcs.diff({ mode })
-              .then((result) => list(result.data))
+              .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
+              .then((result) => result.data)
               .catch((error) => {
                 console.debug("[session-review] failed to load vcs diff", { mode, error })
                 return []
@@ -738,8 +737,12 @@ export default function Page() {
           retry: 2,
           queryFn: () =>
             sdk()
-              .client.vcs.diff({ mode, directory: scope, context })
-              .then((result) => result.data ?? []),
+              .api.vcs.diff({
+                location: { directory: scope },
+                mode: mode === "git" ? "working" : mode,
+                context,
+              })
+              .then((result) => result.data),
         })
         .then((diffs) => diffs.find((diff) => diff.file === file))
 
@@ -946,10 +949,11 @@ export default function Page() {
   )
 
   const stopVcs = sdk().event.listen((evt) => {
-    if (evt.details.type !== "file.watcher.updated") return
+    const details = evt.details as { type: string; properties?: unknown }
+    if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
     const props =
-      typeof evt.details.properties === "object" && evt.details.properties
-        ? (evt.details.properties as Record<string, unknown>)
+      typeof details.properties === "object" && details.properties
+        ? (details.properties as Record<string, unknown>)
         : undefined
     const file = typeof props?.file === "string" ? props.file : undefined
     if (!file || file.startsWith(".git/")) return
@@ -1464,44 +1468,6 @@ export default function Page() {
     requestAnimationFrame(() => attempt(0))
   })
 
-  createEffect(() => {
-    const id = params.id
-    if (!id) return
-
-    if (!wantsReview()) return
-    if (sync().data.session_diff[id] !== undefined) return
-    if (sync().status === "loading") return
-
-    void sync().session.diff(id)
-  })
-
-  createEffect(
-    on(
-      () => [sessionKey(), wantsReview()] as const,
-      ([key, wants]) => {
-        if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
-        if (diffTimer !== undefined) window.clearTimeout(diffTimer)
-        diffFrame = undefined
-        diffTimer = undefined
-        if (!wants) return
-
-        const id = params.id
-        if (!id) return
-        if (!untrack(() => sync().data.session_diff[id] !== undefined)) return
-
-        diffFrame = requestAnimationFrame(() => {
-          diffFrame = undefined
-          diffTimer = window.setTimeout(() => {
-            diffTimer = undefined
-            if (sessionKey() !== key) return
-            void sync().session.diff(id, { force: true })
-          }, 0)
-        })
-      },
-      { defer: true },
-    ),
-  )
-
   let treeDir: string | undefined
   createEffect(() => {
     const dir = sdk().directory
@@ -1757,7 +1723,7 @@ export default function Page() {
       setFollowup("failed", input.sessionID, undefined)
 
       const ok = await sendFollowupDraft({
-        client: sdk().client,
+        api: sdk().api.session,
         sync: sync(),
         serverSync: serverSync(),
         draft: item,
@@ -1853,13 +1819,13 @@ export default function Page() {
   const halt = (sessionID: string) =>
     busy(sessionID)
       ? sdk()
-          .client.session.abort({ sessionID })
+          .api.session.interrupt({ sessionID })
           .catch(() => {})
       : Promise.resolve()
 
   const revertMutation = useMutation(() => ({
     mutationFn: async (input: { sessionID: string; messageID: string }) => {
-      const client = sdk().client
+      const session = sdk().api.session
       const target = sync()
       const last = target.session.get(input.sessionID)?.revert
       const value = draft(input.messageID)
@@ -1869,10 +1835,8 @@ export default function Page() {
           roll(input.sessionID, { messageID: input.messageID }, target)
           prompt.set(value)
         },
-        request: () => halt(input.sessionID).then(() => client.session.revert(input)),
-        complete: (result) => {
-          if (result.data) merge(result.data, target)
-        },
+        request: () => halt(input.sessionID).then(() => session.revert.stage(input)),
+        complete: () => undefined,
         rollback: () => roll(input.sessionID, last, target),
         fail,
       })
@@ -1884,7 +1848,7 @@ export default function Page() {
       const sessionID = params.id
       if (!sessionID) return
 
-      const client = sdk().client
+      const session = sdk().api.session
       const target = sync()
       const next = userMessages().find((item) => item.id > id)
       const last = target.session.get(sessionID)?.revert
@@ -1901,11 +1865,9 @@ export default function Page() {
         },
         request: () =>
           !next
-            ? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
-            : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
-        complete: (result) => {
-          if (result.data) merge(result.data, target)
-        },
+            ? halt(sessionID).then(() => session.revert.clear({ sessionID }))
+            : halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
+        complete: () => undefined,
         rollback: () => roll(sessionID, last, target),
         fail,
       })

+ 14 - 1
packages/app/src/utils/server-compat.test.ts

@@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"
 import { createApiForServer, createSdkForServer } from "./server"
 import { createCompatibleApi } from "./server-compat"
 
-function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
+function setup(
+  protocol: "v1" | "v2" | Promise<"v1" | "v2">,
+  responses?: { vcs?: { branch: string; default_branch: string } },
+) {
   const requests: Request[] = []
   const fetcher = Object.assign(
     async (input: string | URL | Request, init?: RequestInit) => {
@@ -32,6 +35,8 @@ function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
           delivery: "steer",
         })
       }
+      if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
+        return Response.json(responses?.vcs ?? {})
       if (request.method === "GET") return Response.json([])
       return new Response(undefined, { status: 204 })
     },
@@ -110,4 +115,12 @@ describe("createCompatibleApi", () => {
 
     expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
   })
+
+  test("projects the V1 default branch", async () => {
+    const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
+
+    expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({
+      data: { branch: "feature", defaultBranch: "dev" },
+    })
+  })
 })

+ 1 - 1
packages/app/src/utils/server-compat.ts

@@ -318,7 +318,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
       ...input.current.vcs,
       async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
         const result = await legacy(value?.location).vcs.get()
-        return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
+        return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
       },
       async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
         const result = await legacy(value?.location).vcs.status()

+ 200 - 0
packages/app/src/utils/session-message.test.ts

@@ -0,0 +1,200 @@
+import { describe, expect, test } from "bun:test"
+import type { SessionMessageInfo } from "@opencode-ai/client/promise"
+import { normalizeSessionMessages } from "./session-message"
+
+describe("normalizeSessionMessages", () => {
+  test("projects current turns into stable legacy rendering records", () => {
+    const source = [
+      { id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },
+      {
+        id: "msg_2",
+        type: "model-switched",
+        model: { id: "claude", providerID: "anthropic", variant: "high" },
+        time: { created: 2 },
+      },
+      {
+        id: "msg_3",
+        type: "user",
+        text: "inspect this",
+        files: [
+          {
+            data: "aGVsbG8=",
+            mime: "text/plain",
+            name: "note.txt",
+            source: { type: "inline" },
+          },
+        ],
+        agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
+        time: { created: 3 },
+      },
+      {
+        id: "msg_4",
+        type: "assistant",
+        agent: "build",
+        model: { id: "claude", providerID: "anthropic", variant: "high" },
+        content: [
+          { type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } },
+          { type: "text", text: "Result" },
+          {
+            type: "tool",
+            id: "call_1",
+            name: "read",
+            state: {
+              status: "completed",
+              input: { filePath: "note.txt" },
+              structured: { title: "note.txt" },
+              content: [{ type: "text", text: "hello" }],
+            },
+            time: { created: 5, ran: 6, completed: 7 },
+          },
+        ],
+        cost: 0.1,
+        tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
+        time: { created: 4, completed: 7 },
+      },
+      {
+        id: "msg_5",
+        type: "compaction",
+        status: "completed",
+        reason: "auto",
+        summary: "summary",
+        recent: "recent",
+        time: { created: 8 },
+      },
+    ] satisfies SessionMessageInfo[]
+
+    const result = normalizeSessionMessages("ses_1", source)
+
+    expect(result.messages).toHaveLength(2)
+    expect(result.messages[0]).toMatchObject({
+      id: "msg_3",
+      role: "user",
+      agent: "build",
+      model: { providerID: "anthropic", modelID: "claude", variant: "high" },
+    })
+    expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 })
+    expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
+      "msg_3:text:0",
+      "msg_3:file:0",
+      "msg_3:agent:0",
+      "msg_5:compaction",
+    ])
+    expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"])
+    expect(result.parts.get("msg_4")?.[2]).toMatchObject({
+      type: "tool",
+      tool: "read",
+      state: { status: "completed", output: "hello" },
+    })
+  })
+
+  test("does not invent a parent for an assistant-only page", () => {
+    const source = [
+      {
+        id: "msg_2",
+        type: "assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+        content: [{ type: "text", text: "orphan" }],
+        time: { created: 2 },
+      },
+    ] satisfies SessionMessageInfo[]
+
+    expect(normalizeSessionMessages("ses_1", source).messages).toEqual([])
+  })
+
+  test("projects a current shell message into a renderable standalone turn", () => {
+    const source = [
+      {
+        id: "msg_shell",
+        type: "shell",
+        shellID: "shell_1",
+        command: "printf hello",
+        status: "exited",
+        exit: 0,
+        output: { output: "hello", cursor: 5, size: 5, truncated: false },
+        time: { created: 1, completed: 2 },
+      },
+    ] satisfies SessionMessageInfo[]
+
+    const result = normalizeSessionMessages("ses_1", source)
+
+    expect(result.messages).toEqual([
+      expect.objectContaining({ id: "msg_shell", role: "user" }),
+      expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }),
+    ])
+    expect(result.parts.get("msg_shell")).toEqual([
+      expect.objectContaining({ type: "text", text: "printf hello" }),
+    ])
+    expect(result.parts.get("msg_shell:assistant")).toEqual([
+      expect.objectContaining({
+        type: "tool",
+        tool: "bash",
+        state: expect.objectContaining({
+          status: "completed",
+          input: { command: "printf hello" },
+          output: "hello",
+          title: "Shell",
+        }),
+      }),
+    ])
+  })
+
+  test("adapts current edit fields for the legacy edit renderer", () => {
+    const source = [
+      { id: "msg_user", type: "user", text: "edit it", time: { created: 1 } },
+      {
+        id: "msg_assistant",
+        type: "assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+        content: [
+          {
+            type: "tool",
+            id: "call_edit",
+            name: "edit",
+            state: {
+              status: "completed",
+              input: { path: "/repo/README.md", oldString: "old", newString: "new" },
+              content: [{ type: "text", text: "Edited file successfully" }],
+              structured: {
+                files: [
+                  {
+                    file: "README.md",
+                    patch: "@@ -1 +1 @@\n-old\n+new",
+                    additions: 1,
+                    deletions: 1,
+                    status: "modified",
+                  },
+                ],
+                replacements: 1,
+              },
+            },
+            time: { created: 2, ran: 3, completed: 4 },
+          },
+        ],
+        time: { created: 2, completed: 4 },
+      },
+    ] satisfies SessionMessageInfo[]
+
+    const result = normalizeSessionMessages("ses_1", source)
+
+    expect(result.parts.get("msg_assistant")).toEqual([
+      expect.objectContaining({
+        type: "tool",
+        tool: "edit",
+        state: expect.objectContaining({
+          status: "completed",
+          input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }),
+          metadata: expect.objectContaining({
+            filediff: {
+              file: "README.md",
+              patch: "@@ -1 +1 @@\n-old\n+new",
+              additions: 1,
+              deletions: 1,
+            },
+          }),
+        }),
+      }),
+    ])
+  })
+})

+ 348 - 0
packages/app/src/utils/session-message.ts

@@ -0,0 +1,348 @@
+import type {
+  SessionMessageAssistant,
+  SessionMessageAssistantTool,
+  SessionMessageInfo,
+  SessionMessageShell,
+  SessionMessageUser,
+} from "@opencode-ai/client/promise"
+import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
+import { Option, Schema } from "effect"
+
+const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
+const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" }
+const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
+
+function record(value: unknown): value is Record<string, unknown> {
+  return !!value && typeof value === "object" && !Array.isArray(value)
+}
+
+function normalizeToolInput(name: string, input: Record<string, unknown>) {
+  if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string")
+    return input
+  return { ...input, filePath: input.path }
+}
+
+function normalizeToolMetadata(name: string, metadata: Record<string, unknown>) {
+  if (name !== "edit" || !Array.isArray(metadata.files)) return metadata
+  const file = metadata.files.find(record)
+  if (!file || typeof file.file !== "string") return metadata
+  return {
+    ...metadata,
+    filediff: {
+      file: file.file,
+      patch: typeof file.patch === "string" ? file.patch : undefined,
+      additions: typeof file.additions === "number" ? file.additions : 0,
+      deletions: typeof file.deletions === "number" ? file.deletions : 0,
+    },
+  }
+}
+
+export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) {
+  const messages: Message[] = []
+  const parts = new Map<string, Part[]>()
+  let agent = ""
+  let model = emptyModel
+  let parentID: string | undefined
+
+  source.forEach((message) => {
+    if (message.type === "agent-switched") {
+      agent = message.agent
+      return
+    }
+    if (message.type === "model-switched") {
+      model = message.model
+      return
+    }
+    if (message.type === "user") {
+      parentID = message.id
+      messages.push(userMessage(sessionID, message, agent, model))
+      parts.set(message.id, userParts(sessionID, message))
+      return
+    }
+    if (message.type === "synthetic" && message.description?.trim()) {
+      parentID = message.id
+      messages.push({
+        id: message.id,
+        sessionID,
+        role: "user",
+        time: message.time,
+        agent,
+        model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
+      })
+      parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)])
+      return
+    }
+    if (message.type === "shell") {
+      messages.push(...shellMessages(sessionID, message, agent, model))
+      parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)])
+      parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)])
+      parentID = undefined
+      return
+    }
+    if (message.type === "assistant") {
+      agent = message.agent
+      model = message.model
+      if (!parentID) return
+      const parent = messages.findLast((item) => item.id === parentID)
+      if (parent?.role === "user") {
+        parent.agent = message.agent
+        parent.model = {
+          providerID: message.model.providerID,
+          modelID: message.model.id,
+          variant: message.model.variant,
+        }
+      }
+      messages.push(assistantMessage(sessionID, parentID, message))
+      parts.set(message.id, assistantParts(sessionID, message))
+      return
+    }
+    if (message.type !== "compaction" || !parentID) return
+    parts.set(parentID, [
+      ...(parts.get(parentID) ?? []),
+      {
+        id: `${message.id}:compaction`,
+        sessionID,
+        messageID: parentID,
+        type: "compaction",
+        auto: message.reason === "auto",
+      },
+    ])
+  })
+
+  return { messages, parts }
+}
+
+function shellMessages(
+  sessionID: string,
+  message: SessionMessageShell,
+  agent: string,
+  model: { id: string; providerID: string; variant?: string },
+): [UserMessage, AssistantMessage] {
+  return [
+    {
+      id: message.id,
+      sessionID,
+      role: "user",
+      time: { created: message.time.created },
+      agent,
+      model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
+    },
+    {
+      id: `${message.id}:assistant`,
+      sessionID,
+      role: "assistant",
+      time: message.time,
+      parentID: message.id,
+      modelID: model.id,
+      providerID: model.providerID,
+      variant: model.variant,
+      mode: agent,
+      agent,
+      path: { cwd: "", root: "" },
+      cost: 0,
+      tokens: emptyTokens,
+    },
+  ]
+}
+
+function shellPart(sessionID: string, message: SessionMessageShell): ToolPart {
+  const input = { command: message.command }
+  const start = message.time.created
+  const state: ToolPart["state"] =
+    message.status === "running"
+      ? { status: "running", input, time: { start } }
+      : {
+          status: "completed",
+          input,
+          output: message.output?.output ?? "",
+          title: "Shell",
+          metadata: {
+            status: message.status,
+            exit: message.exit,
+            truncated: message.output?.truncated,
+          },
+          time: { start, end: message.time.completed ?? start },
+        }
+  return {
+    id: `${message.id}:tool`,
+    sessionID,
+    messageID: `${message.id}:assistant`,
+    type: "tool",
+    callID: message.shellID,
+    tool: "bash",
+    state,
+  }
+}
+
+export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) {
+  return `${messageID}:${type}:${ordinal}`
+}
+
+function userMessage(
+  sessionID: string,
+  message: SessionMessageUser,
+  agent: string,
+  model: { id: string; providerID: string; variant?: string },
+): UserMessage {
+  return {
+    id: message.id,
+    sessionID,
+    role: "user",
+    time: message.time,
+    agent,
+    model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
+  }
+}
+
+function userParts(sessionID: string, message: SessionMessageUser): Part[] {
+  return [
+    textPart(sessionID, message.id, 0, message.text),
+    ...(message.files ?? []).map(
+      (file, index): FilePart => ({
+        id: `${message.id}:file:${index}`,
+        sessionID,
+        messageID: message.id,
+        type: "file",
+        mime: file.mime,
+        filename: file.name,
+        url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
+      }),
+    ),
+    ...(message.agents ?? []).map(
+      (item, index): Part => ({
+        id: `${message.id}:agent:${index}`,
+        sessionID,
+        messageID: message.id,
+        type: "agent",
+        name: item.name,
+        source: item.mention
+          ? { value: item.mention.text, start: item.mention.start, end: item.mention.end }
+          : undefined,
+      }),
+    ),
+  ]
+}
+
+function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage {
+  const error = message.error
+    ? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt")
+      ? { name: "MessageAbortedError" as const, data: { message: message.error.message } }
+      : { name: "UnknownError" as const, data: { message: message.error.message } }
+    : undefined
+  return {
+    id: message.id,
+    sessionID,
+    role: "assistant",
+    time: message.time,
+    error,
+    parentID,
+    modelID: message.model.id,
+    providerID: message.model.providerID,
+    variant: message.model.variant,
+    mode: message.agent,
+    agent: message.agent,
+    path: { cwd: "", root: "" },
+    cost: message.cost ?? 0,
+    tokens: message.tokens ?? emptyTokens,
+    finish: message.finish,
+  }
+}
+
+function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
+  const ordinals = { text: 0, reasoning: 0 }
+  return message.content.flatMap((content): Part[] => {
+    if (content.type === "text") {
+      const part = textPart(sessionID, message.id, ordinals.text++, content.text)
+      return content.text.trim() ? [part] : []
+    }
+    if (content.type === "reasoning") {
+      const part: Part = {
+        id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++),
+        sessionID,
+        messageID: message.id,
+        type: "reasoning",
+        text: content.text,
+        metadata: content.state,
+        time: {
+          start: content.time?.created ?? message.time.created,
+          end: content.time?.completed,
+        },
+      }
+      return content.text.trim() ? [part] : []
+    }
+    return [toolPart(sessionID, message.id, content)]
+  })
+}
+
+function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part {
+  return {
+    id: sessionMessagePartID(messageID, "text", ordinal),
+    sessionID,
+    messageID,
+    type: "text",
+    text,
+    synthetic,
+  }
+}
+
+function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart {
+  const start = tool.time.ran ?? tool.time.created
+  const state = (() => {
+    if (tool.state.status === "streaming") {
+      const value = Option.getOrUndefined(decodeToolInput(tool.state.input))
+      const input = normalizeToolInput(tool.name, record(value) ? value : {})
+      return { status: "pending" as const, input, raw: tool.state.input }
+    }
+    if (tool.state.status === "running") {
+      return {
+        status: "running" as const,
+        input: normalizeToolInput(tool.name, tool.state.input),
+        metadata: normalizeToolMetadata(tool.name, tool.state.structured),
+        time: { start },
+      }
+    }
+    if (tool.state.status === "error") {
+      return {
+        status: "error" as const,
+        input: normalizeToolInput(tool.name, tool.state.input),
+        error: tool.state.error.message,
+        metadata: normalizeToolMetadata(tool.name, tool.state.structured),
+        time: { start, end: tool.time.completed ?? start },
+      }
+    }
+    const attachments = tool.state.content.flatMap((item, index): FilePart[] =>
+      item.type === "file"
+        ? [
+            {
+              id: `${tool.id}:file:${index}`,
+              sessionID,
+              messageID,
+              type: "file",
+              mime: item.mime,
+              filename: item.name,
+              url: item.uri,
+            },
+          ]
+        : [],
+    )
+    return {
+      status: "completed" as const,
+      input: normalizeToolInput(tool.name, tool.state.input),
+      output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
+      title: tool.name,
+      metadata: normalizeToolMetadata(tool.name, tool.state.structured),
+      time: { start, end: tool.time.completed ?? start },
+      attachments: attachments.length ? attachments : undefined,
+    }
+  })()
+  return {
+    id: tool.id,
+    sessionID,
+    messageID,
+    type: "tool",
+    callID: tool.id,
+    tool: tool.name,
+    state,
+    metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState },
+  }
+}

+ 94 - 0
packages/app/src/utils/session.test.ts

@@ -0,0 +1,94 @@
+import { describe, expect, test } from "bun:test"
+import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
+import { listAllSessions, normalizeSessionInfo } from "./session"
+
+describe("normalizeSessionInfo", () => {
+  test("adapts a current session to the app session shape", () => {
+    const result = normalizeSessionInfo({
+      id: "session-1",
+      projectID: "project-1",
+      agent: "build",
+      model: { id: "gpt-5", providerID: "openai", variant: "high" },
+      cost: 0,
+      tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+      time: { created: 1, updated: 1 },
+      title: "New session",
+      location: { directory: "/repo/worktree", workspaceID: "workspace-1" },
+      subpath: "worktree",
+      revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] },
+    } as SessionInfo)
+
+    expect(result).toEqual({
+      id: "session-1",
+      slug: "session-1",
+      projectID: "project-1",
+      workspaceID: "workspace-1",
+      directory: "/repo/worktree",
+      path: "worktree",
+      parentID: undefined,
+      cost: 0,
+      tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+      title: "New session",
+      agent: "build",
+      model: { id: "gpt-5", providerID: "openai", variant: "high" },
+      version: "",
+      time: { created: 1, updated: 1 },
+      revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" },
+    })
+  })
+})
+
+describe("listAllSessions", () => {
+  test("loads every page in server order and retains the query", async () => {
+    const calls: SessionListInput[] = []
+    const pages = new Map<string | undefined, { data: SessionInfo[]; cursor: { next?: string } }>([
+      [undefined, { data: [sessionInfo("session-3"), sessionInfo("session-2")], cursor: { next: "next" } }],
+      ["next", { data: [sessionInfo("session-1", true)], cursor: {} }],
+    ])
+    const api = {
+      list: async (query = {}) => {
+        calls.push(query)
+        return pages.get(query.cursor) ?? { data: [], cursor: {} }
+      },
+    } satisfies Pick<SessionApi, "list">
+
+    const result = await listAllSessions(api, { directory: "/repo", order: "desc" })
+
+    expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"])
+    expect(result[2]?.time.archived).toBe(2)
+    expect(calls).toEqual([
+      { directory: "/repo", order: "desc", limit: 100, cursor: undefined },
+      { directory: "/repo", order: "desc", limit: 100, cursor: "next" },
+    ])
+  })
+
+  test("requests the terminal empty page when the server returns a next cursor", async () => {
+    const cursors: Array<string | undefined> = []
+    const api = {
+      list: async (query = {}) => {
+        cursors.push(query.cursor)
+        if (query.cursor) return { data: [], cursor: { next: "unused" } }
+        return { data: [sessionInfo("session-1")], cursor: { next: "terminal" } }
+      },
+    } satisfies Pick<SessionApi, "list">
+
+    const result = await listAllSessions(api, { directory: "/repo", limit: 25 })
+
+    expect(result.map((session) => session.id)).toEqual(["session-1"])
+    expect(cursors).toEqual([undefined, "terminal"])
+  })
+})
+
+function sessionInfo(id: string, archived = false) {
+  return {
+    id,
+    projectID: "project-1",
+    agent: "build",
+    model: { id: "model-1", providerID: "provider-1" },
+    cost: 0,
+    tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+    time: { created: 1, updated: 1, archived: archived ? 2 : undefined },
+    title: id,
+    location: { directory: "/repo" },
+  } as SessionInfo
+}

+ 37 - 0
packages/app/src/utils/session.ts

@@ -0,0 +1,37 @@
+import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
+import type { Session } from "@opencode-ai/sdk/v2/client"
+
+export function normalizeSessionInfo(input: SessionInfo | Session): Session {
+  if (!("location" in input)) return input
+  return {
+    id: input.id,
+    slug: input.id,
+    projectID: input.projectID,
+    workspaceID: input.location.workspaceID,
+    directory: input.location.directory,
+    path: input.subpath,
+    parentID: input.parentID,
+    cost: input.cost,
+    tokens: input.tokens,
+    title: input.title,
+    agent: input.agent,
+    model: input.model,
+    version: "",
+    time: input.time,
+    revert: input.revert && {
+      messageID: input.revert.messageID,
+      partID: input.revert.partID,
+      snapshot: input.revert.snapshot,
+    },
+  }
+}
+
+export async function listAllSessions(api: Pick<SessionApi, "list">, input: Omit<SessionListInput, "cursor">) {
+  const load = async (cursor?: string): Promise<Session[]> => {
+    const result = await api.list({ ...input, limit: input.limit ?? 100, cursor })
+    const sessions = result.data.map(normalizeSessionInfo)
+    if (result.data.length === 0 || !result.cursor.next) return sessions
+    return [...sessions, ...(await load(result.cursor.next))]
+  }
+  return load()
+}