Explorar el Código

feat(app): support current event transport (#38464)

Brendan Allan hace 3 semanas
padre
commit
e59ba24b80

+ 2 - 0
packages/app/e2e/performance/timeline-stability/fixture.ts

@@ -97,6 +97,7 @@ export async function setupTimeline(
     locale?: string
     deviceScaleFactor?: number
     seedHistory?: boolean
+    protocol?: "v1" | "v2"
   } = {},
 ) {
   const sessions = input.sessions ?? [session()]
@@ -114,6 +115,7 @@ export async function setupTimeline(
     retry: input.eventRetry ?? 20,
   })
   await mockOpenCodeServer(page, {
+    protocol: input.protocol,
     directory,
     project: project(),
     provider: provider(),

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

@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
   expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
 })
 
-test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
-  const timeline = await setupTimeline(page, { eventRetry: 10 })
+test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
+  const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
   const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
     id: "timeline-event-7",
   })
@@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) =
   const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
 
   expect(first.eventID).toBe("timeline-event-7")
-  expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
+  expect(connection.headers["last-event-id"]).toBeUndefined()
 })
 
 test("passes through non-event fetches", async ({ page }) => {

+ 247 - 10
packages/app/e2e/utils/mock-server.ts

@@ -4,6 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
 const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
 
 export interface MockServerConfig {
+  protocol?: "v1" | "v2"
   provider: unknown
   directory: string
   project: unknown
@@ -54,14 +55,21 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     if (url.port !== targetPort && url.port !== appPort) return route.fallback()
 
     const path = url.pathname
-    if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
-    if (path === "/global/health") return json(route, { healthy: true })
-    if (path === "/api/session")
-      return json(route, {
-        data: config.sessions.map((session) => v2Session(session, config.directory)),
-        cursor: {},
-      })
-    if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
+    if (path === "/global/event" || path === "/event" || path === "/api/event") {
+      const events = config.events?.()
+      return sse(
+        route,
+        path === "/api/event"
+          ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
+          : events,
+        config.eventRetry,
+      )
+    }
+    if (path === "/global/health")
+      return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
+    if (path === "/api/health" && config.protocol === "v2")
+      return json(route, { healthy: true, version: "2.0.0", pid: 1 })
+    if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
     if (path === "/permission")
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
     if (path === "/question")
@@ -89,10 +97,122 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
         },
         data: [],
       })
+    if (path === "/api/agent")
+      return json(route, {
+        location: location(config),
+        data: [
+          {
+            id: "build",
+            name: "Build",
+            mode: "primary",
+            hidden: false,
+            request: { settings: {}, headers: {}, body: {} },
+            permissions: [],
+          },
+        ],
+      })
+    if (path === "/api/command") return json(route, { location: location(config), data: [] })
+    if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
+    if (path === "/api/mcp/resource")
+      return json(route, { location: location(config), data: { resources: [], templates: [] } })
+    const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
+    if (integration && route.request().method() === "GET")
+      return json(route, {
+        location: location(config),
+        data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
+      })
+    if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    if (path === "/api/project") return json(route, [config.project])
+    if (path === "/api/project/current")
+      return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
+    if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
+    if (path === "/api/path")
+      return json(route, {
+        state: config.directory,
+        config: config.directory,
+        worktree: config.directory,
+        directory: config.directory,
+        home: "C:/OpenCode",
+      })
+    if (path === "/api/permission/request")
+      return json(route, {
+        location: location(config),
+        data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
+          currentPermission,
+        ),
+      })
+    if (path === "/api/question/request")
+      return json(route, {
+        location: location(config),
+        data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
+      })
+    if (path === "/api/vcs")
+      return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
+    if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
+    if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
+    if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
+    if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
+      return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
     if (emptyObject.has(path)) return json(route, {})
     if (emptyList.has(path)) return json(route, [])
+    if (path === "/api/session") {
+      const directory = url.searchParams.get("directory")
+      const parentID = url.searchParams.get("parentID")
+      const limit = Number(url.searchParams.get("limit") ?? 50)
+      const offset = Number(url.searchParams.get("cursor") ?? 0)
+      const sessions = config.sessions
+        .filter((session) => !directory || session.directory === directory)
+        .filter((session) => parentID !== "null" || session.parentID === undefined)
+        .filter((session) => {
+          const search = url.searchParams.get("search")?.toLowerCase()
+          return !search || String(session.title ?? "").toLowerCase().includes(search)
+        })
+      const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
+      const data = ordered.slice(offset, offset + limit)
+      const next = offset + limit < ordered.length ? String(offset + limit) : undefined
+      return json(route, {
+        data: data.map((session) => currentSession(session, config.directory)),
+        cursor: { next },
+      })
+    }
+    if (path === "/api/session/active") {
+      const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
+      return json(route, {
+        data: Object.fromEntries(
+          Object.entries(statuses).flatMap(([id, status]) => (status.type === "idle" ? [] : [[id, { type: "running" }]])),
+        ),
+      })
+    }
+    if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    }
+    if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    }
+    if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    }
+    if (
+      /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
+      route.request().method() === "POST"
+    ) {
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    }
+    if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
+      return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    }
     if (path in staticRoutes) return json(route, staticRoutes[path])
 
+    const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
+    if (currentSessionMatch) {
+      const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
+      if (!session) return json(route, { error: "Session not found" }, undefined, 404)
+      return json(route, {
+        data: currentSession(session, config.directory),
+      })
+    }
+
     const sessionMatch = path.match(/^\/session\/([^/]+)$/)
     if (sessionMatch) {
       const session = config.sessions.find((s) => s.id === sessionMatch[1])
@@ -115,6 +235,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
     if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
 
+    const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
+    if (currentMessagesMatch) {
+      const token = url.searchParams.get("cursor") ?? undefined
+      const before = token ? cursors.get(token) : undefined
+      if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
+      config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
+      await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
+      if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
+      const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
+      config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
+      const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
+      if (cursor) cursors.set(cursor, pageData.cursor!)
+      return json(route, {
+        data: pageData.items.map(currentMessage).reverse(),
+        cursor: { next: cursor },
+      })
+    }
+
     const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
     if (messagesMatch) {
       const token = url.searchParams.get("before") ?? undefined
@@ -137,12 +275,36 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
   })
 }
 
-function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
+function location(config: MockServerConfig) {
+  return {
+    directory: config.directory,
+    project: { id: (config.project as { id?: string }).id, directory: config.directory },
+  }
+}
+
+function currentPermission(value: unknown) {
+  const permission = value as Record<string, unknown>
+  if (permission.action) return permission
+  const tool = permission.tool as { messageID?: string; callID?: string } | undefined
+  return {
+    id: permission.id,
+    sessionID: permission.sessionID,
+    action: permission.permission,
+    resources: permission.patterns ?? [],
+    save: permission.always,
+    metadata: permission.metadata,
+    source: tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
+  }
+}
+
+export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
   const time = session.time && typeof session.time === "object" ? session.time : {}
   return {
     id: session.id,
     parentID: session.parentID,
     projectID: session.projectID ?? "project",
+    agent: session.agent ?? "build",
+    model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
     cost: session.cost ?? 0,
     tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
     time: {
@@ -157,7 +319,67 @@ function v2Session(session: { id: string } & Record<string, unknown>, fallbackDi
       directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
       ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
     },
-    ...(typeof session.path === "string" ? { subpath: session.path } : {}),
+    subpath: session.path,
+    revert: session.revert,
+  }
+}
+
+function currentMessage(value: unknown) {
+  const item = value as {
+    info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
+    parts: Array<Record<string, unknown> & { type: string }>
+  }
+  if (item.info.role === "user") {
+    return {
+      id: item.info.id,
+      type: "user",
+      time: item.info.time,
+      text: item.parts
+        .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
+        .join("\n"),
+    }
+  }
+  return {
+    id: item.info.id,
+    type: "assistant",
+    time: item.info.time,
+    agent: item.info.agent ?? "build",
+    model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
+    cost: item.info.cost,
+    tokens: item.info.tokens,
+    error: item.info.error,
+    content: item.parts.flatMap<unknown>((part) => {
+      if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
+      if (part.type !== "tool") return []
+      const state = part.state as Record<string, unknown>
+      return [
+        {
+          type: "tool",
+          id: part.id,
+          name: part.tool,
+          time: state.time ?? { created: item.info.time.created },
+          state:
+            state.status === "pending"
+              ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
+              : state.status === "completed"
+                ? {
+                    status: "completed",
+                    input: state.input ?? {},
+                    structured: state.metadata ?? {},
+                    content: [{ type: "text", text: state.output ?? "" }],
+                  }
+                : state.status === "error"
+                  ? {
+                      status: "error",
+                      input: state.input ?? {},
+                      structured: state.metadata ?? {},
+                      content: [],
+                      error: { type: "ToolError", message: state.error ?? "Tool failed" },
+                    }
+                  : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
+        },
+      ]
+    }),
   }
 }
 
@@ -181,3 +403,18 @@ function sse(route: Route, events?: unknown[], retry?: number) {
     body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
   })
 }
+
+function currentEvent(input: unknown) {
+  if (!input || typeof input !== "object" || !("payload" in input)) return input
+  const envelope = input as { directory?: string; payload?: unknown }
+  if (!envelope.payload || typeof envelope.payload !== "object") return input
+  const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
+  if (!payload.type) return input
+  return {
+    id: payload.id ?? `evt_mock_${Date.now()}`,
+    created: Date.now(),
+    type: payload.type,
+    data: payload.properties ?? {},
+    location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
+  }
+}

+ 30 - 7
packages/app/e2e/utils/sse-transport.ts

@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
 export type SseConnectionRecord = {
   id: number
   url: string
-  path: "/global/event" | "/event"
+  path: "/global/event" | "/event" | "/api/event"
   headers: Record<string, string>
   openedAt: number
   endedAt?: number
@@ -93,6 +93,20 @@ export async function installSseTransport<T>(
           eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
           `data: ${JSON.stringify(payload)}\n\n`,
         ].join("")
+      const currentEvent = (input: unknown) => {
+        if (!input || typeof input !== "object" || !("payload" in input)) return input
+        const envelope = input as { directory?: string; payload?: unknown }
+        if (!envelope.payload || typeof envelope.payload !== "object") return input
+        const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
+        if (!payload.type) return input
+        return {
+          id: payload.id ?? `evt_mock_${Date.now()}`,
+          created: Date.now(),
+          type: payload.type,
+          data: payload.properties ?? {},
+          location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
+        }
+      }
       const acknowledge = (
         connection: Connection,
         bytes: number,
@@ -140,14 +154,14 @@ export async function installSseTransport<T>(
           output.forEach((chunk) => connection.controller.enqueue(chunk))
           return acknowledge(connection, input.bytes.length, output.length)
         }
-        const encoded = input.deliveries.map((delivery) => ({
-          delivery,
-          bytes: encoder.encode(frame(delivery.payload, delivery.options)),
-        }))
+        const encoded = input.deliveries.map((delivery) => {
+          const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
+          return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
+        })
         encoded.forEach((item) => marker(item.delivery.options?.marker))
         if (input.burst) {
           const bytes = encoder.encode(
-            encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
+            encoded.map((item) => frame(item.payload, item.delivery.options)).join(""),
           )
           connection.controller.enqueue(bytes)
           return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
@@ -161,7 +175,10 @@ export async function installSseTransport<T>(
       const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
         const request = new Request(input, init)
         const url = new URL(request.url)
-        if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
+        if (
+          url.origin !== server ||
+          (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
+        )
           return originalFetch(request)
 
         const id = ++nextConnectionID
@@ -177,6 +194,12 @@ export async function installSseTransport<T>(
             record.controller = controller
             connections.push(record)
             if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
+            if (url.pathname === "/api/event")
+              controller.enqueue(
+                encoder.encode(
+                  frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} }),
+                ),
+              )
             request.signal.addEventListener(
               "abort",
               () => {

+ 36 - 1
packages/app/src/context/server-sdk.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
+import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
+import type { OpenCodeEvent } from "@opencode-ai/client/promise"
 import type { Event } from "@opencode-ai/sdk/v2/client"
 
 describe("resumeStreamAfterPageShow", () => {
@@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
   })
 })
 
+describe("adaptServerEvent", () => {
+  test("preserves V2 events while adapting permission requests for existing consumers", () => {
+    const current = {
+      id: "evt_1",
+      created: 1,
+      type: "permission.v2.asked",
+      data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
+    } as OpenCodeEvent
+
+    expect(adaptServerEvent(current)).toMatchObject({
+      type: "permission.asked",
+      properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
+      current,
+    })
+  })
+})
+
 describe("coalesceServerEvents", () => {
   const delta = (value: string, field = "text", partID = "part") => ({
     directory: "/repo",
@@ -34,6 +52,23 @@ describe("coalesceServerEvents", () => {
     expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
   })
 
+  test("merges adjacent current text deltas", () => {
+    const current = (id: string, value: string) => adaptServerEvent({
+      id,
+      created: 1,
+      type: "session.text.delta",
+      location: { directory: "/repo" },
+      data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
+    } as OpenCodeEvent)
+    const result = coalesceServerEvents([
+      { directory: "/repo", payload: current("evt_1", "hello ") },
+      { directory: "/repo", payload: current("evt_2", "world") },
+    ])
+
+    expect(result).toHaveLength(1)
+    expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
+  })
+
   test("preserves event boundaries and distinct fields", () => {
     const status = {
       directory: "/repo",

+ 143 - 51
packages/app/src/context/server-sdk.tsx

@@ -1,21 +1,60 @@
+import type { OpenCodeEvent } from "@opencode-ai/client/promise"
 import type { Event } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
-import { createSdkForServer } from "@/utils/server"
+import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
 import { useLanguage } from "./language"
 import { usePlatform } from "./platform"
 import { ServerConnection, useServer } from "./server"
 import { createRefCountMap } from "@/utils/refcount"
 import { useGlobal } from "./global"
 import { ServerScope } from "@/utils/server-scope"
+import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
+import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
 
 const isAbortError = (error: unknown) =>
   error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
 
 const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
-type QueuedServerEvent = { directory: string; payload: Event }
+export type ServerEvent = Event & { current?: OpenCodeEvent }
+type QueuedServerEvent = { directory: string; payload: ServerEvent }
+type CurrentDelta = Extract<
+  OpenCodeEvent,
+  { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
+>
+
+export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
+  if (event.type === "permission.v2.asked") {
+    return {
+      id: event.id,
+      type: "permission.asked",
+      properties: {
+        id: event.data.id,
+        sessionID: event.data.sessionID,
+        permission: event.data.action,
+        patterns: event.data.resources,
+        always: event.data.save ?? [],
+        metadata: event.data.metadata ?? {},
+        tool:
+          event.data.source?.type === "tool"
+            ? { messageID: event.data.source.messageID, callID: event.data.source.callID }
+            : undefined,
+      },
+      current: event,
+    } as ServerEvent
+  }
+  if (event.type === "permission.v2.replied")
+    return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
+  if (event.type === "question.v2.asked")
+    return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
+  if (event.type === "question.v2.replied")
+    return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
+  if (event.type === "question.v2.rejected")
+    return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
+  return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
+}
 
 const coalescedKey = (event: QueuedServerEvent) => {
   if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
@@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
 export function coalesceServerEvents(events: QueuedServerEvent[]) {
   const output: QueuedServerEvent[] = []
   events.forEach((event) => {
+    const current = currentDelta(event.payload.current)
+    if (current) {
+      const previous = output[output.length - 1]
+      const prior = currentDelta(previous?.payload.current)
+      if (
+        previous &&
+        prior &&
+        previous.directory === event.directory &&
+        currentDeltaKey(prior) === currentDeltaKey(current)
+      ) {
+        const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
+        const data =
+          current.type === "session.compaction.delta"
+            ? { ...current.data, text: fragment }
+            : { ...current.data, delta: fragment }
+        output[output.length - 1] = {
+          directory: event.directory,
+          payload: {
+            ...event.payload,
+            properties: data,
+            current: { ...current, data } as CurrentDelta,
+          } as ServerEvent,
+        }
+        return
+      }
+      output.push(event)
+      return
+    }
     if (event.payload.type !== "message.part.delta") {
       output.push(event)
       return
@@ -71,12 +138,52 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
   return output
 }
 
+function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
+  if (
+    event?.type === "session.text.delta" ||
+    event?.type === "session.reasoning.delta" ||
+    event?.type === "session.tool.input.delta" ||
+    event?.type === "session.compaction.delta"
+  )
+    return event
+}
+
+function currentDeltaKey(event: CurrentDelta) {
+  if (event.type === "session.tool.input.delta")
+    return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
+  if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
+  return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
+}
+
+function currentDeltaFragment(event: CurrentDelta) {
+  return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
+}
+
 export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
   if (!event.persisted) return
   start()
 }
 
-function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
+type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
+type ServerSDKBase = {
+  server: ServerConnection.Any
+  scope: ServerScope
+  protocol: Promise<ServerProtocol>
+  url: string
+  client: ReturnType<typeof createSdkForServer>
+  api: CompatibleApi
+  currentApi: ServerApi
+  event: {
+    on: ServerEventEmitter["on"]
+    listen: ServerEventEmitter["listen"]
+    start: () => Promise<void> | undefined
+  }
+  createClient: (
+    opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
+  ) => ReturnType<typeof createSdkForServer>
+}
+
+function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
   const platform = usePlatform()
   const abort = new AbortController()
 
@@ -91,13 +198,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
     }
   })()
 
+  const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
   const eventSdk = createSdkForServer({
     signal: abort.signal,
     fetch: eventFetch,
     server: server.http,
   })
+  const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
   const emitter = createGlobalEmitter<{
-    [key: string]: Event
+    [key: string]: ServerEvent
   }>()
 
   type Queued = QueuedServerEvent
@@ -142,21 +251,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
   let run: Promise<void> | undefined
   let started = false
   let generation = 0
-  const HEARTBEAT_TIMEOUT_MS = 15_000
-  let lastEventAt = Date.now()
-  let heartbeat: ReturnType<typeof setTimeout> | undefined
-  const resetHeartbeat = () => {
-    lastEventAt = Date.now()
-    if (heartbeat) clearTimeout(heartbeat)
-    heartbeat = setTimeout(() => {
-      attempt?.abort()
-    }, HEARTBEAT_TIMEOUT_MS)
-  }
-  const clearHeartbeat = () => {
-    if (!heartbeat) return
-    clearTimeout(heartbeat)
-    heartbeat = undefined
-  }
 
   const start = () => {
     if (started) return run
@@ -168,35 +262,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
       // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
       while (!abort.signal.aborted && started && generation === active) {
         attempt = new AbortController()
-        lastEventAt = Date.now()
         const onAbort = () => {
           attempt?.abort()
         }
         abort.signal.addEventListener("abort", onAbort)
         try {
-          const events = await eventSdk.global.event({
-            signal: attempt.signal,
-            onSseError: (error) => {
-              if (isStreamClosed(error, attempt?.signal)) return
-              if (streamErrorLogged) return
-              streamErrorLogged = true
-              console.error("[global-sdk] event stream error", {
-                url: server.http.url,
-                fetch: eventFetch ? "platform" : "webview",
-                error,
-              })
-            },
-          })
+          const kind = await protocol
+          const events =
+            kind === "v1"
+              ? (await eventSdk.global.event({ signal: attempt.signal })).stream
+              : eventApi.event.subscribe({ signal: attempt.signal })
           let yielded = Date.now()
-          resetHeartbeat()
-          for await (const event of events.stream) {
-            resetHeartbeat()
+          for await (const event of events) {
             streamErrorLogged = false
-            if (event.payload.type !== "sync") {
-              const directory = event.directory ?? "global"
-              const payload = event.payload as Event
-              if (enqueueServerEvent(queue, { directory, payload })) schedule()
-            }
+            const legacy = "payload" in event
+            if (legacy && event.payload.type === "sync") continue
+            const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
+            const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
+            if (enqueueServerEvent(queue, { directory, payload })) schedule()
 
             if (Date.now() - yielded < STREAM_YIELD_MS) continue
             yielded = Date.now()
@@ -214,7 +297,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
         } finally {
           abort.signal.removeEventListener("abort", onAbort)
           attempt = undefined
-          clearHeartbeat()
         }
 
         if (abort.signal.aborted || !started || generation !== active) return
@@ -233,18 +315,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
     started = false
     generation++
     attempt?.abort()
-    clearHeartbeat()
   }
 
   onMount(() => {
     makeEventListener(window, "pagehide", stop)
     makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
-    makeEventListener(document, "visibilitychange", () => {
-      if (document.visibilityState !== "visible") return
-      if (!started) return
-      if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
-      attempt?.abort()
-    })
   })
 
   onCleanup(() => {
@@ -258,12 +333,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
     fetch: platform.fetch,
     throwOnError: true,
   })
+  const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
+  const legacy = (directory?: string) =>
+    createSdkForServer({
+      server: server.http,
+      fetch: platform.fetch,
+      throwOnError: true,
+      directory,
+    })
+  const api = createCompatibleApi({ protocol, current: currentApi, legacy })
 
   return {
     server,
     scope,
+    protocol,
     url: server.http.url,
     client: sdk,
+    api,
+    currentApi,
     event: {
       on: emitter.on.bind(emitter),
       listen: emitter.listen.bind(emitter),
@@ -279,7 +366,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
   }
 }
 
-type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
 export type ServerSDK = ServerSDKBase & {
   ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
 }
@@ -309,7 +395,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
 })
 
 type SDKEventMap = {
-  [key in Event["type"]]: Extract<Event, { type: key }>
+  [key in Event["type"]]: Extract<ServerEvent, { type: key }>
 }
 
 function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
@@ -329,6 +415,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
     scope: serverSDK.scope,
     directory,
     client,
+    api: createCompatibleApi({
+      protocol: serverSDK.protocol,
+      current: serverSDK.currentApi,
+      legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
+      directory,
+    }),
     event: emitter,
     get url() {
       return serverSDK.url

+ 20 - 2
packages/app/src/utils/server-compat.test.ts

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
 import { createApiForServer, createSdkForServer } from "./server"
 import { createCompatibleApi } from "./server-compat"
 
-function setup(protocol: "v1" | "v2") {
+function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
   const requests: Request[] = []
   const fetcher = Object.assign(
     async (input: string | URL | Request, init?: RequestInit) => {
@@ -39,7 +39,7 @@ function setup(protocol: "v1" | "v2") {
   )
   const server = { url: "http://localhost:4096" }
   const api = createCompatibleApi({
-    protocol: Promise.resolve(protocol),
+    protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol,
     current: createApiForServer({ server, fetch: fetcher }),
     legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
     directory: "/repo",
@@ -86,6 +86,24 @@ describe("createCompatibleApi", () => {
     expect(requests[0]!.method).toBe("POST")
   })
 
+  test("resolves protocol detection once across implementation methods", async () => {
+    let detections = 0
+    const resolved = Promise.resolve<"v1" | "v2">("v2")
+    const protocol = new Proxy(resolved, {
+      get(target, property) {
+        if (property !== "then") return Reflect.get(target, property, target)
+        detections++
+        return target.then.bind(target)
+      },
+    })
+    const { api } = setup(protocol)
+
+    await api.session.archive({ sessionID: "ses_1" })
+    await api.session.list()
+
+    expect(detections).toBe(1)
+  })
+
   test("uses the global V1 session search endpoint", async () => {
     const { api, requests } = setup("v1")
     await api.session.list({ parentID: null, search: "session", limit: 50 })

+ 46 - 47
packages/app/src/utils/server-compat.ts

@@ -37,6 +37,12 @@ type LegacyPrompt = {
   variant?: string
 }
 type LegacyLocation = { directory?: string }
+type CompatibleInput = {
+  protocol: Promise<ServerProtocol>
+  current: ServerApi
+  legacy: LegacyFor
+  directory?: string
+}
 
 function mime(uri: string) {
   const match = /^data:([^;,]+)/.exec(uri)
@@ -68,15 +74,48 @@ function sessionInfo(session: Session): SessionInfo {
   }
 }
 
-export function createCompatibleApi(input: {
-  protocol: Promise<ServerProtocol>
-  current: ServerApi
-  legacy: LegacyFor
-  directory?: string
-}): CompatibleApi {
+export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
+  const v1 = createV1Api(input)
+  return lazyApi(
+    input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
+    input.current,
+  )
+}
+
+function lazyApi<T extends object>(implementation: Promise<T>, shape: T): T {
+  const cache = new Map<PropertyKey, unknown>()
+  return new Proxy(shape, {
+    get(target, property, receiver) {
+      const sample = Reflect.get(target, property, receiver)
+      if (typeof sample === "function") {
+        return (...args: unknown[]) =>
+          implementation.then((value) => {
+            const method = Reflect.get(value, property)
+            if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`)
+            return Reflect.apply(method, value, args)
+          })
+      }
+      if (sample === null || typeof sample !== "object") return sample
+      if (cache.has(property)) return cache.get(property)
+      const nested = lazyApi(
+        implementation.then((value) => {
+          const result = Reflect.get(value, property)
+          if (result === null || typeof result !== "object") {
+            throw new Error(`API namespace unavailable: ${String(property)}`)
+          }
+          return result
+        }),
+        sample,
+      )
+      cache.set(property, nested)
+      return nested
+    },
+  })
+}
+
+function createV1Api(input: CompatibleInput): CompatibleApi {
   const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
   const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
-  const isV1 = () => input.protocol.then((protocol) => protocol === "v1")
   const located = <T>(data: T, value?: { directory?: string }) => ({
     location: {
       directory: directory(value) ?? "",
@@ -93,7 +132,6 @@ export function createCompatibleApi(input: {
         value?: Parameters<ServerApi["session"]["list"]>[0],
         options?: Parameters<ServerApi["session"]["list"]>[1],
       ) {
-        if (!(await isV1())) return input.current.session.list(value, options)
         if (!value?.directory && value?.search !== undefined) {
           const result = await legacy().experimental.session.list(
             {
@@ -114,7 +152,6 @@ export function createCompatibleApi(input: {
         return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
       },
       async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
-        if (!(await isV1())) return input.current.session.create(value)
         const result = await legacy(value?.location ?? undefined).session.create({
           directory: directory(value?.location ?? undefined),
         })
@@ -122,13 +159,11 @@ export function createCompatibleApi(input: {
         return sessionInfo(result.data)
       },
       async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
-        if (!(await isV1())) return input.current.session.get(value)
         const result = await legacy().session.get(value)
         if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
         return sessionInfo(result.data)
       },
       async active() {
-        if (!(await isV1())) return input.current.session.active()
         const result = await legacy().session.status()
         return Object.fromEntries(
           Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
@@ -137,29 +172,23 @@ export function createCompatibleApi(input: {
         )
       },
       async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
-        if (!(await isV1())) return input.current.session.rename(value)
         await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
       },
       async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
-        if (!(await isV1())) return input.current.session.archive(value)
         await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
       },
       async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
-        if (!(await isV1())) return input.current.session.remove(value)
         await legacy(value).session.delete(value)
       },
       async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
-        if (!(await isV1())) return input.current.session.fork(value)
         const result = await legacy().session.fork(value)
         if (!result.data) throw new Error("Failed to fork session")
         return sessionInfo(result.data)
       },
       async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
-        if (!(await isV1())) return input.current.session.interrupt(value)
         await legacy().session.abort(value)
       },
       async prompt(value: SessionPromptInput & LegacyPrompt) {
-        if (!(await isV1())) return input.current.session.prompt(value)
         await legacy().session.promptAsync({
           sessionID: value.sessionID,
           messageID: value.id ?? undefined,
@@ -194,7 +223,6 @@ export function createCompatibleApi(input: {
         }
       },
       async command(value: SessionCommandInput) {
-        if (!(await isV1())) return input.current.session.command(value)
         await legacy().session.command({
           sessionID: value.sessionID,
           messageID: value.id ?? undefined,
@@ -221,7 +249,6 @@ export function createCompatibleApi(input: {
         }
       },
       async shell(value: SessionShellInput & LegacyPrompt) {
-        if (!(await isV1())) return input.current.session.shell(value)
         await legacy().session.shell({
           sessionID: value.sessionID,
           command: value.command,
@@ -230,7 +257,6 @@ export function createCompatibleApi(input: {
         })
       },
       compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
-        if (!(await isV1())) return input.current.session.compact(value)
         if (!value.model) throw new Error("A model is required to compact a V1 session")
         await legacy().session.summarize({
           sessionID: value.sessionID,
@@ -247,12 +273,10 @@ export function createCompatibleApi(input: {
       },
       revert: {
         stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
-          if (!(await isV1())) return input.current.session.revert.stage(value)
           await legacy().session.revert(value)
           return { messageID: value.messageID }
         },
         clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
-          if (!(await isV1())) return input.current.session.revert.clear(value)
           await legacy().session.unrevert(value)
         },
         commit: input.current.session.revert.commit,
@@ -261,17 +285,14 @@ export function createCompatibleApi(input: {
     project: {
       ...input.current.project,
       async list() {
-        if (!(await isV1())) return input.current.project.list()
         return ((await legacy().project.list()).data ?? []) as Project[]
       },
       async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
-        if (!(await isV1())) return input.current.project.current(value)
         const result = await legacy(value?.location).project.current()
         if (!result.data) throw new Error("Project not found")
         return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
       },
       async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
-        if (!(await isV1())) return input.current.project.update(value)
         const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
         const result = await legacy({ directory: project?.worktree }).project.update({
           ...value,
@@ -281,7 +302,6 @@ export function createCompatibleApi(input: {
         return result.data as Project
       },
       async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
-        if (!(await isV1())) return input.current.project.directories(value)
         const result = await legacy(value.location).worktree.list()
         return (result.data ?? []).map((item) => ({ directory: item }))
       },
@@ -289,7 +309,6 @@ export function createCompatibleApi(input: {
     path: {
       ...input.current.path,
       async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
-        if (!(await isV1())) return input.current.path.get(value)
         const result = await legacy(value?.location).path.get()
         if (!result.data) throw new Error("Path unavailable")
         return result.data
@@ -298,17 +317,14 @@ export function createCompatibleApi(input: {
     vcs: {
       ...input.current.vcs,
       async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
-        if (!(await isV1())) return input.current.vcs.get(value)
         const result = await legacy(value?.location).vcs.get()
         return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
       },
       async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
-        if (!(await isV1())) return input.current.vcs.status(value)
         const result = await legacy(value?.location).vcs.status()
         return located(result.data ?? [], value?.location)
       },
       async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
-        if (!(await isV1())) return input.current.vcs.diff(value)
         const result = await legacy(value.location).vcs.diff({
           mode: value.mode === "working" ? "git" : value.mode,
           context: value.context,
@@ -328,12 +344,10 @@ export function createCompatibleApi(input: {
     file: {
       ...input.current.file,
       async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
-        if (!(await isV1())) return input.current.file.list(value)
         const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
         return located(result.data ?? [], value?.location)
       },
       async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
-        if (!(await isV1())) return input.current.file.find(value)
         const result = await legacy(value.location).find.files({
           query: value.query,
           type: value.type,
@@ -348,7 +362,6 @@ export function createCompatibleApi(input: {
     integration: {
       ...input.current.integration,
       async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
-        if (!(await isV1())) return input.current.integration.get(value)
         const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
           (method, index) =>
             method.type === "api"
@@ -368,7 +381,6 @@ export function createCompatibleApi(input: {
       connect: {
         ...input.current.integration.connect,
         key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
-          if (!(await isV1())) return input.current.integration.connect.key(value)
           await legacy(value.location).auth.set({
             providerID: value.integrationID,
             auth: { type: "api", key: value.key },
@@ -378,7 +390,6 @@ export function createCompatibleApi(input: {
       oauth: {
         ...input.current.integration.oauth,
         connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
-          if (!(await isV1())) return input.current.integration.oauth.connect(value)
           const method = Number(value.methodID)
           const result = await legacy(value.location).provider.oauth.authorize(
             { providerID: value.integrationID, method, inputs: value.inputs },
@@ -397,7 +408,6 @@ export function createCompatibleApi(input: {
           )
         },
         complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
-          if (!(await isV1())) return input.current.integration.oauth.complete(value)
           const method = Number(value.attemptID.split(":").at(-1))
           await legacy(value.location).provider.oauth.callback(
             { providerID: value.integrationID, method, code: value.code },
@@ -405,7 +415,6 @@ export function createCompatibleApi(input: {
           )
         },
         status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
-          if (!(await isV1())) return input.current.integration.oauth.status(value)
           const method = Number(value.attemptID.split(":").at(-1))
           await legacy(value.location).provider.oauth.callback(
             { providerID: value.integrationID, method },
@@ -421,15 +430,12 @@ export function createCompatibleApi(input: {
     pty: {
       ...input.current.pty,
       async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
-        if (!(await isV1())) return input.current.pty.shells(value)
         return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
       },
       async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
-        if (!(await isV1())) return input.current.pty.list(value)
         return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
       },
       async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
-        if (!(await isV1())) return input.current.pty.create(value)
         const result = await legacy(value?.location).pty.create({
           command: value?.command,
           args: value?.args ? [...value.args] : undefined,
@@ -441,13 +447,11 @@ export function createCompatibleApi(input: {
         return located(result.data, value?.location)
       },
       async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
-        if (!(await isV1())) return input.current.pty.get(value)
         const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
         if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
         return located(result.data, value.location)
       },
       async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
-        if (!(await isV1())) return input.current.pty.update(value)
         const result = await legacy(value.location).pty.update({
           ptyID: value.ptyID,
           title: value.title,
@@ -457,11 +461,9 @@ export function createCompatibleApi(input: {
         return located(result.data, value.location)
       },
       async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
-        if (!(await isV1())) return input.current.pty.remove(value)
         await legacy(value.location).pty.remove({ ptyID: value.ptyID })
       },
       async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
-        if (!(await isV1())) return input.current.pty.connectToken(value)
         const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
         if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
         return located(result.data, value.location)
@@ -470,7 +472,6 @@ export function createCompatibleApi(input: {
     permission: {
       ...input.current.permission,
       async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
-        if (!(await isV1())) return input.current.permission.reply(value)
         await legacy().permission.respond({
           sessionID: value.sessionID,
           permissionID: value.requestID,
@@ -481,14 +482,12 @@ export function createCompatibleApi(input: {
     question: {
       ...input.current.question,
       async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
-        if (!(await isV1())) return input.current.question.reply(value)
         await legacy().question.reply({
           requestID: value.requestID,
           answers: value.answers.map((answer) => [...answer]),
         })
       },
       async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
-        if (!(await isV1())) return input.current.question.reject(value)
         await legacy().question.reject({ requestID: value.requestID })
       },
     },