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

feat(app): add dual-server compatibility (#38462)

Brendan Allan 1 месяц назад
Родитель
Сommit
d03e0c5e54

+ 95 - 0
packages/app/src/utils/server-compat.test.ts

@@ -0,0 +1,95 @@
+import { describe, expect, test } from "bun:test"
+import { createApiForServer, createSdkForServer } from "./server"
+import { createCompatibleApi } from "./server-compat"
+
+function setup(protocol: "v1" | "v2") {
+  const requests: Request[] = []
+  const fetcher = Object.assign(
+    async (input: string | URL | Request, init?: RequestInit) => {
+      const request = new Request(input, init)
+      requests.push(request)
+      if (request.method === "PATCH") {
+        return Response.json({
+          id: "ses_1",
+          slug: "ses_1",
+          projectID: "project",
+          directory: "/repo",
+          title: "Session",
+          version: "1",
+          time: { created: 1, updated: 1 },
+        })
+      }
+      if (request.method === "POST" && request.url.endsWith("/prompt_async"))
+        return new Response(undefined, { status: 204 })
+    if (request.method === "POST" && request.url.endsWith("/prompt")) {
+        return Response.json({
+          admittedSeq: 1,
+          id: "msg_1",
+          sessionID: "ses_1",
+          timeCreated: 1,
+          type: "user",
+          data: { text: "hello" },
+          delivery: "steer",
+      })
+    }
+    if (request.method === "GET") return Response.json([])
+    return new Response(undefined, { status: 204 })
+    },
+    { preconnect: globalThis.fetch.preconnect },
+  )
+  const server = { url: "http://localhost:4096" }
+  const api = createCompatibleApi({
+    protocol: Promise.resolve(protocol),
+    current: createApiForServer({ server, fetch: fetcher }),
+    legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
+    directory: "/repo",
+  })
+  return { api, requests }
+}
+
+describe("createCompatibleApi", () => {
+  test("routes V1 archive through the legacy session update", async () => {
+    const { api, requests } = setup("v1")
+    await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
+
+    const url = new URL(requests[0]!.url)
+    expect(url.pathname).toBe("/session/ses_1")
+    expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
+    expect(requests[0]!.method).toBe("PATCH")
+    expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
+  })
+
+  test("converts current prompts to the V1 prompt contract", async () => {
+    const { api, requests } = setup("v1")
+    await api.session.prompt({
+      sessionID: "ses_1",
+      id: "msg_1",
+      text: "hello",
+      agent: "build",
+      model: { providerID: "provider", modelID: "model" },
+    })
+
+    expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
+    expect(await requests[0]!.json()).toMatchObject({
+      messageID: "msg_1",
+      agent: "build",
+      model: { providerID: "provider", modelID: "model" },
+      parts: [{ type: "text", text: "hello" }],
+    })
+  })
+
+  test("keeps V2 session actions on the current API", async () => {
+    const { api, requests } = setup("v2")
+    await api.session.archive({ sessionID: "ses_1" })
+
+    expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
+    expect(requests[0]!.method).toBe("POST")
+  })
+
+  test("uses the global V1 session search endpoint", async () => {
+    const { api, requests } = setup("v1")
+    await api.session.list({ parentID: null, search: "session", limit: 50 })
+
+    expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
+  })
+})

+ 496 - 0
packages/app/src/utils/server-compat.ts

@@ -0,0 +1,496 @@
+import type { ServerApi } from "./server"
+import type { ServerProtocol } from "./server-protocol"
+import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
+import type {
+  Project,
+  ProjectCurrent,
+  SessionApi,
+  SessionCommandInput,
+  SessionCommandOutput,
+  SessionCompactInput,
+  SessionCompactOutput,
+  SessionInfo,
+  SessionPromptInput,
+  SessionPromptOutput,
+  SessionShellInput,
+  SessionShellOutput,
+} from "@opencode-ai/client/promise"
+
+type LegacyClient = OpencodeClient
+type LegacyFor = (directory?: string) => LegacyClient
+type CompatibleSessionApi = Omit<
+  SessionApi,
+  "prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove"
+> & {
+  prompt: (input: SessionPromptInput & LegacyPrompt) => Promise<SessionPromptOutput>
+  command: (input: SessionCommandInput) => Promise<SessionCommandOutput>
+  shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
+  compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
+  rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
+  archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
+  remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
+}
+export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
+type LegacyPrompt = {
+  agent?: string
+  model?: { providerID: string; modelID: string }
+  variant?: string
+}
+type LegacyLocation = { directory?: string }
+
+function mime(uri: string) {
+  const match = /^data:([^;,]+)/.exec(uri)
+  return match?.[1] ?? "application/octet-stream"
+}
+
+function sessionInfo(session: Session): SessionInfo {
+  return {
+    id: session.id,
+    parentID: session.parentID,
+    projectID: session.projectID,
+    agent: session.agent,
+    model: session.model && {
+      id: session.model.id,
+      providerID: session.model.providerID,
+      variant: session.model.variant,
+    },
+    cost: session.cost ?? 0,
+    tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+    time: session.time,
+    title: session.title,
+    location: { directory: session.directory, workspaceID: session.workspaceID },
+    subpath: session.path,
+    revert: session.revert && {
+      messageID: session.revert.messageID,
+      partID: session.revert.partID,
+      snapshot: session.revert.snapshot,
+    },
+  }
+}
+
+export function createCompatibleApi(input: {
+  protocol: Promise<ServerProtocol>
+  current: ServerApi
+  legacy: LegacyFor
+  directory?: string
+}): 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) ?? "",
+      project: { id: "", directory: directory(value) ?? "" },
+    },
+    data,
+  })
+
+  return {
+    ...input.current,
+    session: {
+      ...input.current.session,
+      async list(
+        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(
+            {
+              roots: value.parentID === null ? true : undefined,
+              search: value.search,
+              limit: value.limit,
+            },
+            options,
+          )
+          return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
+        }
+        const result = await legacy({ directory: value?.directory }).session.list({
+          directory: value?.directory,
+          roots: value?.parentID === null ? true : undefined,
+          search: value?.search,
+          limit: value?.limit,
+        })
+        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),
+        })
+        if (!result.data) throw new Error("Failed to create session")
+        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]) =>
+            status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
+          ),
+        )
+      },
+      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,
+          agent: value.agent,
+          model: value.model,
+          variant: value.variant,
+          parts: [
+            { type: "text", text: value.text },
+            ...(value.files ?? []).map((file) => ({
+              type: "file" as const,
+              mime: mime(file.uri),
+              url: file.uri,
+              filename: file.name,
+            })),
+            ...(value.agents ?? []).map((agent) => ({
+              type: "agent" as const,
+              name: agent.name,
+              source: agent.mention
+                ? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end }
+                : undefined,
+            })),
+          ],
+        })
+        return {
+          admittedSeq: 0,
+          id: value.id ?? "",
+          sessionID: value.sessionID,
+          timeCreated: Date.now(),
+          type: "user",
+          data: { text: value.text },
+          delivery: value.delivery ?? "steer",
+        }
+      },
+      async command(value: SessionCommandInput) {
+        if (!(await isV1())) return input.current.session.command(value)
+        await legacy().session.command({
+          sessionID: value.sessionID,
+          messageID: value.id ?? undefined,
+          command: value.command,
+          arguments: value.arguments ?? "",
+          agent: value.agent ?? undefined,
+          model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined,
+          variant: value.model?.variant,
+          parts: value.files?.map((file) => ({
+            type: "file" as const,
+            mime: mime(file.uri),
+            url: file.uri,
+            filename: file.name,
+          })),
+        })
+        return {
+          admittedSeq: 0,
+          id: value.id ?? "",
+          sessionID: value.sessionID,
+          timeCreated: Date.now(),
+          type: "user",
+          data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() },
+          delivery: value.delivery ?? "steer",
+        }
+      },
+      async shell(value: SessionShellInput & LegacyPrompt) {
+        if (!(await isV1())) return input.current.session.shell(value)
+        await legacy().session.shell({
+          sessionID: value.sessionID,
+          command: value.command,
+          agent: value.agent,
+          model: value.model,
+        })
+      },
+      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,
+          providerID: value.model.providerID,
+          modelID: value.model.modelID,
+        })
+        return {
+          admittedSeq: 0,
+          id: value.id ?? "",
+          sessionID: value.sessionID,
+          timeCreated: Date.now(),
+          type: "compaction",
+        }
+      },
+      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,
+      },
+    },
+    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,
+          directory: project?.worktree,
+        })
+        if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
+        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 }))
+      },
+    },
+    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
+      },
+    },
+    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,
+        })
+        return located(
+          (result.data ?? []).map((file) => ({
+            file: file.file,
+            patch: file.patch ?? "",
+            additions: file.additions,
+            deletions: file.deletions,
+            status: file.status ?? "modified",
+          })),
+          value.location,
+        )
+      },
+    },
+    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,
+          limit: value.limit,
+        })
+        return located(
+          (result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })),
+          value.location,
+        )
+      },
+    },
+    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"
+              ? { type: "key" as const, label: method.label }
+              : { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts },
+        )
+        return located(
+          {
+            id: value.integrationID,
+            name: value.integrationID,
+            methods,
+            connections: [],
+          },
+          value.location,
+        )
+      },
+      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 },
+          })
+        },
+      },
+      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 },
+            { throwOnError: true },
+          )
+          if (!result.data) throw new Error("Failed to start OAuth authorization")
+          return located(
+            {
+              attemptID: `${value.integrationID}:${method}`,
+              url: result.data.url,
+              instructions: result.data.instructions,
+              mode: result.data.method,
+              time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 },
+            },
+            value.location,
+          )
+        },
+        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 },
+            { throwOnError: true },
+          )
+        },
+        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 },
+            { throwOnError: true },
+          )
+          return located(
+            { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
+            value.location,
+          )
+        },
+      },
+    },
+    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,
+          cwd: value?.cwd,
+          title: value?.title,
+          env: value?.env,
+        })
+        if (!result.data) throw new Error("Failed to create terminal")
+        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,
+          size: value.size,
+        })
+        if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
+        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)
+      },
+    },
+    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,
+          response: value.reply,
+        })
+      },
+    },
+    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 })
+      },
+    },
+  }
+}

+ 34 - 4
packages/app/src/utils/server-health.test.ts

@@ -14,15 +14,45 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
 
 describe("checkServerHealth", () => {
   test("returns healthy response with version", async () => {
-    const fetch = (async () =>
-      new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
+    let request: URL | undefined
+    const fetch = (async (input: RequestInfo | URL) => {
+      request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
+      return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
         status: 200,
         headers: { "content-type": "application/json" },
-      })) as unknown as typeof globalThis.fetch
+      })
+    }) as unknown as typeof globalThis.fetch
 
     const result = await checkServerHealth(server, fetch)
 
     expect(result).toEqual({ healthy: true, version: "1.2.3" })
+    expect(request?.pathname).toBe("/api/health")
+  })
+
+  test("falls back to the V1 health endpoint", async () => {
+    const paths: string[] = []
+    const fetch = (async (input: RequestInfo | URL) => {
+      const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
+      paths.push(url.pathname)
+      if (url.pathname === "/api/health") return new Response(undefined, { status: 404 })
+      return Response.json({ healthy: true, version: "1.18.4" })
+    }) as unknown as typeof globalThis.fetch
+
+    expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
+    expect(paths).toEqual(["/api/health", "/global/health"])
+  })
+
+  test("falls back when the current health response is malformed", async () => {
+    const paths: string[] = []
+    const fetch = (async (input: RequestInfo | URL) => {
+      const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
+      paths.push(url.pathname)
+      if (url.pathname === "/api/health") return Response.json({})
+      return Response.json({ healthy: true, version: "1.18.4" })
+    }) as unknown as typeof globalThis.fetch
+
+    expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
+    expect(paths).toEqual(["/api/health", "/global/health"])
   })
 
   test("allows slow servers thirty seconds by default", async () => {
@@ -142,7 +172,7 @@ describe("checkServerHealth", () => {
       retryDelayMs: 1,
     })
 
-    expect(count).toBe(3)
+    expect(count).toBe(6)
     expect(result).toEqual({ healthy: false })
   })
 })

+ 23 - 5
packages/app/src/utils/server-health.ts

@@ -1,6 +1,7 @@
 import { usePlatform } from "@/context/platform"
 import { ServerConnection } from "@/context/server"
-import { createSdkForServer } from "./server"
+import { authTokenFromCredentials, createSdkForServer } from "./server"
+import { ClientError, OpenCode } from "@opencode-ai/client"
 import { Accessor, createEffect, onCleanup } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
 
@@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) {
 
 function retryable(error: unknown, signal?: AbortSignal) {
   if (signal?.aborted) return false
+  if (error instanceof ClientError) return error.reason === "Transport"
   if (!(error instanceof Error)) return false
   if (error.name === "AbortError" || error.name === "TimeoutError") return false
   if (error instanceof TypeError) return true
@@ -82,15 +84,31 @@ export async function checkServerHealth(
       .then(() => attempt(count + 1))
       .catch(() => ({ healthy: false }))
   }
-  const attempt = (count: number): Promise<ServerHealth> =>
-    createSdkForServer({
-      server,
+  const attempt = async (count: number): Promise<ServerHealth> => {
+    const current = await OpenCode.make({
+      baseUrl: server.url,
       fetch,
-      signal,
+      headers: server.password
+        ? {
+            Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
+          }
+        : undefined,
     })
+      .health.get({ signal })
+      .then((x) =>
+        typeof x.healthy === "boolean"
+          ? { data: { healthy: x.healthy, version: x.version } }
+          : { error: new Error("Invalid health response") },
+      )
+      .catch((error) => ({ error }))
+    if ("data" in current && current.data) return current.data
+    if (signal?.aborted) return { healthy: false }
+
+    return createSdkForServer({ server, fetch, signal })
       .global.health()
       .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
       .catch((error) => next(count, error))
+  }
   return attempt(0).finally(() => timeout?.clear?.())
 }
 

+ 40 - 0
packages/app/src/utils/server-protocol.test.ts

@@ -0,0 +1,40 @@
+import { describe, expect, test } from "bun:test"
+import { detectServerProtocol } from "./server-protocol"
+
+const server = { url: "http://localhost:4096" }
+const json = (value: unknown, status = 200) =>
+  new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
+const mockFetch = (run: (input: string | URL | Request) => Promise<Response>) =>
+  Object.assign(run, { preconnect: globalThis.fetch.preconnect })
+
+describe("detectServerProtocol", () => {
+  test("prefers the legacy health endpoint when both API generations exist", async () => {
+    const fetcher = mockFetch((input) => {
+      const path = new URL(input instanceof Request ? input.url : input).pathname
+      if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" }))
+      return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
+    })
+
+    expect(await detectServerProtocol(server, fetcher)).toBe("v1")
+  })
+
+  test("recognizes V2 health by its process identifier", async () => {
+    const fetcher = mockFetch((input) => {
+      const path = new URL(input instanceof Request ? input.url : input).pathname
+      if (path === "/global/health") return Promise.resolve(json({}, 404))
+      return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
+    })
+
+    expect(await detectServerProtocol(server, fetcher)).toBe("v2")
+  })
+
+  test("recognizes the transitional V1 API health response", async () => {
+    const fetcher = mockFetch((input) => {
+      const path = new URL(input instanceof Request ? input.url : input).pathname
+      if (path === "/global/health") return Promise.resolve(json({}, 404))
+      return Promise.resolve(json({ healthy: true }))
+    })
+
+    expect(await detectServerProtocol(server, fetcher)).toBe("v1")
+  })
+})

+ 35 - 0
packages/app/src/utils/server-protocol.ts

@@ -0,0 +1,35 @@
+import type { ServerConnection } from "@/context/server"
+import { authTokenFromCredentials } from "./server"
+
+export type ServerProtocol = "v1" | "v2"
+
+function headers(server: ServerConnection.HttpBase) {
+  if (!server.password) return
+  return {
+    Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
+  }
+}
+
+async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) {
+  const response = await fetch(new URL(path, server.url), {
+    headers: headers(server),
+    signal: AbortSignal.timeout(5_000),
+  })
+  if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return
+  const value: unknown = await response.json()
+  if (!value || typeof value !== "object") return
+  return value
+}
+
+export async function detectServerProtocol(
+  server: ServerConnection.HttpBase,
+  fetch: typeof globalThis.fetch,
+): Promise<ServerProtocol> {
+  const legacy = await probe(server, fetch, "/global/health").catch(() => undefined)
+  if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1"
+
+  const current = await probe(server, fetch, "/api/health").catch(() => undefined)
+  if (current && "pid" in current && typeof current.pid === "number") return "v2"
+  if (current && "healthy" in current && current.healthy === true) return "v1"
+  return "v2"
+}

+ 21 - 0
packages/app/src/utils/server.ts

@@ -1,4 +1,5 @@
 import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
 import type { ServerConnection } from "@/context/server"
 import { decode64 } from "@/utils/base64"
 
@@ -39,3 +40,23 @@ export function createSdkForServer({
     baseUrl: server.url,
   })
 }
+
+export function createApiForServer(input: {
+  server: ServerConnection.HttpBase
+  fetch?: typeof globalThis.fetch
+}): OpenCodeClient {
+  return OpenCode.make({
+    baseUrl: input.server.url,
+    fetch: input.fetch,
+    headers: input.server.password
+      ? {
+          Authorization: `Basic ${authTokenFromCredentials({
+            username: input.server.username,
+            password: input.server.password,
+          })}`,
+        }
+      : undefined,
+  })
+}
+
+export type ServerApi = OpenCodeClient

+ 12 - 11
packages/desktop/src/main/server.ts

@@ -182,9 +182,9 @@ export async function spawnLocalServer(
 }
 
 export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
-  let healthUrl: URL
+  let healthUrls: URL[]
   try {
-    healthUrl = new URL("/global/health", url)
+    healthUrls = [new URL("/api/health", url), new URL("/global/health", url)]
   } catch {
     return false
   }
@@ -195,16 +195,17 @@ export async function checkHealth(url: string, password?: string | null): Promis
     headers.set("authorization", `Basic ${auth}`)
   }
 
-  try {
-    const res = await fetch(healthUrl, {
-      method: "GET",
-      headers,
-      signal: AbortSignal.timeout(3000),
-    })
-    return res.ok
-  } catch {
-    return false
+  for (const healthUrl of healthUrls) {
+    try {
+      const res = await fetch(healthUrl, {
+        method: "GET",
+        headers,
+        signal: AbortSignal.timeout(3000),
+      })
+      if (res.ok) return true
+    } catch {}
   }
+  return false
 }
 
 function createSidecarEnv(): Record<string, string> {