Przeglądaj źródła

feat(app): add v2 backend adapter

Brendan Allan 1 miesiąc temu
rodzic
commit
4c0feeebb4

+ 256 - 0
packages/app/src/context/backend-v2.test.ts

@@ -0,0 +1,256 @@
+import { describe, expect, test } from "bun:test"
+import { OpenCode } from "@opencode-ai/client"
+import { createV2Backend } from "./backend-v2"
+
+function setup(respond: (request: Request) => Response | Promise<Response>) {
+  const requests: Request[] = []
+  const fetch = Object.assign(
+    async (input: RequestInfo | URL, init?: RequestInit) => {
+      const request = input instanceof Request ? input : new Request(input, init)
+      requests.push(request)
+      return respond(request)
+    },
+    { preconnect: globalThis.fetch.preconnect },
+  ) satisfies typeof globalThis.fetch
+  return {
+    requests,
+    backend: createV2Backend(OpenCode.make({ baseUrl: "http://localhost", fetch }), {
+      directory: "/default",
+      workspaceID: "default-workspace",
+    }),
+  }
+}
+
+function json(data: unknown) {
+  return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } })
+}
+
+const session = {
+  id: "ses_1",
+  projectID: "project",
+  cost: 1.5,
+  tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
+  time: { created: 10, updated: 20 },
+  title: "Session",
+  location: { directory: "/repo", workspaceID: "workspace" },
+}
+
+describe("createV2Backend", () => {
+  test("normalizes session pages and preserves location precedence", async () => {
+    const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } }))
+
+    const result = await setupResult.backend.common.sessions.list({
+      location: { directory: "/explicit", workspaceID: "explicit-workspace" },
+      roots: true,
+      limit: 10,
+      cursor: "cursor",
+    })
+
+    expect(result).toEqual({
+      items: [
+        {
+          id: "ses_1",
+          parentID: undefined,
+          projectID: "project",
+          location: { directory: "/repo", workspaceID: "workspace" },
+          title: "Session",
+          cost: 1.5,
+          tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
+          time: { created: 10, updated: 20 },
+          revert: undefined,
+        },
+      ],
+      previous: "before",
+      next: "after",
+    })
+    const url = new URL(setupResult.requests[0].url)
+    expect(url.pathname).toBe("/api/session")
+    expect(url.searchParams.get("directory")).toBe("/explicit")
+    expect(url.searchParams.get("workspace")).toBe("explicit-workspace")
+    expect(url.searchParams.get("parentID")).toBe("null")
+    expect(url.searchParams.get("cursor")).toBe("cursor")
+  })
+
+  test("uses the default location and maps binary file responses", async () => {
+    const setupResult = setup(() => new Response(Uint8Array.from([0, 1, 2])))
+
+    const result = await setupResult.backend.common.files.read({ path: "dir/a.bin" })
+
+    expect([...result.bytes]).toEqual([0, 1, 2])
+    const url = new URL(setupResult.requests[0].url)
+    expect(url.pathname).toBe("/api/fs/read/dir/a.bin")
+    expect(url.searchParams.get("location[directory]")).toBe("/default")
+    expect(url.searchParams.get("location[workspace]")).toBe("default-workspace")
+  })
+
+  test("switches prompt selection before admission", async () => {
+    const setupResult = setup((request) =>
+      request.url.endsWith("/prompt")
+        ? json({
+            data: {
+              admittedSeq: 1,
+              id: "msg_1",
+              sessionID: "ses_1",
+              timeCreated: 1,
+              type: "user",
+              data: { text: "hello" },
+              delivery: "steer",
+            },
+          })
+        : new Response(null, { status: 204 }),
+    )
+
+    await setupResult.backend.common.sessions.prompt({
+      sessionID: "ses_1",
+      id: "msg_1",
+      text: "hello",
+      selection: { agent: "build", model: { id: "model", providerID: "provider", variant: "high" } },
+      files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", source: { text: "hi", start: 0, end: 2 } }],
+    })
+
+    expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
+      "/api/session/ses_1/agent",
+      "/api/session/ses_1/model",
+      "/api/session/ses_1/prompt",
+    ])
+    expect(await setupResult.requests[2].json()).toEqual({
+      id: "msg_1",
+      text: "hello",
+      files: [
+        {
+          uri: "data:text/plain;base64,aGk=",
+          name: "hi.txt",
+          mention: { start: 0, end: 2, text: "hi" },
+        },
+      ],
+    })
+  })
+
+  test("requests file staging when selected revert files are present", async () => {
+    const setupResult = setup(() => json({ data: { messageID: "msg_1", files: [] } }))
+
+    await setupResult.backend.capabilities.sessionExtrasV2?.stageRevert({
+      sessionID: "ses_1",
+      messageID: "msg_1",
+      files: ["a.txt"],
+    })
+
+    expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true })
+  })
+
+  test("normalizes retry events before message projection refresh", async () => {
+    const setupResult = setup(() =>
+      new Response(
+        `data: ${JSON.stringify({
+          id: "evt_retry",
+          created: 1,
+          type: "session.retry.scheduled",
+          durable: { aggregateID: "ses_1", seq: 3, version: 1 },
+          location: { directory: "/repo" },
+          data: {
+            sessionID: "ses_1",
+            assistantMessageID: "msg_1",
+            attempt: 2,
+            at: 1234,
+            error: { type: "rate_limit", message: "try again" },
+          },
+        })}\n\n`,
+        { headers: { "content-type": "text/event-stream" } },
+      ),
+    )
+
+    const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
+
+    expect(result.value).toEqual({
+      location: { directory: "/repo" },
+      event: {
+        type: "session.activity",
+        sessionID: "ses_1",
+        activity: { type: "retry", attempt: 2, message: "try again", next: 1234 },
+      },
+    })
+    expect(setupResult.requests).toHaveLength(1)
+  })
+
+  test("normalizes durable session log events through the event mapper", async () => {
+    const setupResult = setup(() =>
+      new Response(
+        `data: ${JSON.stringify({
+          id: "evt_started",
+          created: 1,
+          type: "session.execution.started",
+          durable: { aggregateID: "ses_1", seq: 7, version: 1 },
+          data: { sessionID: "ses_1" },
+        })}\n\n`,
+        { headers: { "content-type": "text/event-stream" } },
+      ),
+    )
+    const capability = setupResult.backend.capabilities.sessionExtrasV2
+    if (!capability) throw new Error("Missing V2 session capability")
+
+    const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next()
+
+    expect(result.value).toEqual({
+      sequence: 7,
+      event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } },
+    })
+    expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/experimental/session/ses_1/log")
+  })
+
+  test("refreshes message projections for streamed V2 fragments", async () => {
+    const setupResult = setup((request) => {
+      if (new URL(request.url).pathname === "/api/event") {
+        return new Response(
+          `data: ${JSON.stringify({
+            id: "evt_1",
+            created: 1,
+            type: "session.text.delta",
+            location: { directory: "/repo" },
+            data: { sessionID: "ses_1", assistantMessageID: "msg_1", ordinal: 0, delta: "hi" },
+          })}\n\n`,
+          { headers: { "content-type": "text/event-stream" } },
+        )
+      }
+      return json({
+        data: {
+          id: "msg_1",
+          type: "assistant",
+          time: { created: 1 },
+          agent: "build",
+          model: { id: "model", providerID: "provider" },
+          content: [{ type: "text", text: "hello" }],
+        },
+      })
+    })
+
+    const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
+
+    expect(result.value).toEqual({
+      location: { directory: "/repo" },
+      event: {
+        type: "timeline.updated",
+        item: {
+          type: "assistant",
+          id: "msg_1",
+          sessionID: "ses_1",
+          created: 1,
+          completed: undefined,
+          content: [{ type: "text", id: "msg_1:text:0", text: "hello" }],
+          agent: "build",
+          model: { id: "model", providerID: "provider" },
+          tokens: undefined,
+          error: undefined,
+          raw: {
+            id: "msg_1",
+            type: "assistant",
+            time: { created: 1 },
+            agent: "build",
+            model: { id: "model", providerID: "provider" },
+            content: [{ type: "text", text: "hello" }],
+          },
+        },
+      },
+    })
+    expect(new URL(setupResult.requests[1].url).pathname).toBe("/api/session/ses_1/message/msg_1")
+  })
+})

+ 970 - 0
packages/app/src/context/backend-v2.ts

@@ -0,0 +1,970 @@
+import type {
+  AgentInfo,
+  FormCreateInput,
+  FormInfo,
+  IntegrationInfo,
+  McpServer,
+  ModelInfo,
+  OpenCodeClient,
+  OpenCodeEvent,
+  PermissionV2Request,
+  Project,
+  QuestionV2Request,
+  SessionInfo,
+  SessionMessageAssistant,
+  SessionMessageInfo,
+  SessionPendingInfo,
+  ShellInfo1,
+} from "@opencode-ai/client"
+import type {
+  AppAgent,
+  AppClient,
+  AppEvent,
+  AppFileDiff,
+  AppMcpServer,
+  AppModel,
+  AppPermissionRequest,
+  AppProject,
+  AppQuestionRequest,
+  AppSession,
+  FormField,
+  IntegrationAttempt,
+  IntegrationAttemptStatus,
+  IntegrationConnection,
+  IntegrationMethod,
+  LocationInput,
+  LocationRef,
+  PendingSessionInput,
+  PromptFile,
+  RequestOptions,
+  SessionActivity,
+  SessionLogItem,
+  ShellProcess,
+  TimelineContent,
+  TimelineItem,
+} from "./backend"
+
+export function createV2Backend(client: OpenCodeClient, defaultLocation?: LocationRef): AppClient {
+  const request = (input?: RequestOptions) => ({ signal: input?.signal })
+  const location = (input?: LocationInput) => toLocation(input?.location ?? defaultLocation)
+
+  const loadSession = async (sessionID: string, options?: RequestOptions) =>
+    toSession(await client.session.get({ sessionID }, request(options)))
+  const loadMessage = async (sessionID: string, messageID: string, options?: RequestOptions) =>
+    toTimelineItem(await client.session.message({ sessionID, messageID }, request(options)), sessionID)
+
+  return {
+    version: "v2",
+    common: {
+      health: { get: (options) => client.health.get(request(options)) },
+      projects: {
+        list: async (options) => (await client.project.list(request(options))).map(toProject),
+        current: (input, options) => client.project.current({ location: location(input) }, request(options)),
+      },
+      catalog: {
+        providers: async (input, options) => {
+          const params = { location: location(input) }
+          const [providers, models, selected] = await Promise.all([
+            client.provider.list(params, request(options)),
+            client.model.list(params, request(options)),
+            client.model.default(params, request(options)),
+          ])
+          return {
+            providers: new Map(
+              providers.data.map((provider) => [
+                provider.id,
+                {
+                  id: provider.id,
+                  name: provider.name,
+                  models: Object.fromEntries(
+                    models.data
+                      .filter((model) => model.providerID === provider.id && model.status !== "deprecated")
+                      .map((model) => [model.id, toModel(model)]),
+                  ),
+                },
+              ]),
+            ),
+            connected: providers.data.filter((provider) => !provider.disabled).map((provider) => provider.id),
+            defaults: selected.data ? { [selected.data.providerID]: selected.data.id } : {},
+          }
+        },
+        agents: async (input, options) =>
+          (await client.agent.list({ location: location(input) }, request(options))).data.map(toAgent),
+      },
+      commands: {
+        list: async (input, options) =>
+          (await client.command.list({ location: location(input) }, request(options))).data.map((item) => ({
+            name: item.name,
+            description: item.description,
+          })),
+      },
+      references: {
+        list: async (input, options) =>
+          (await client.reference.list({ location: location(input) }, request(options))).data.map((item) => ({
+            name: item.name,
+            path: item.path,
+            description: item.description,
+            hidden: item.hidden,
+            source: item.source,
+          })),
+      },
+      sessions: {
+        list: async (input, options) => {
+          const result = await client.session.list(
+            {
+              workspace: input?.location?.workspaceID ?? defaultLocation?.workspaceID,
+              directory: input?.location?.directory ?? defaultLocation?.directory,
+              parentID: input?.roots ? null : undefined,
+              limit: input?.limit,
+              search: input?.search,
+              cursor: input?.cursor,
+            },
+            request(options),
+          )
+          return {
+            items: result.data.map(toSession),
+            previous: result.cursor.previous ?? undefined,
+            next: result.cursor.next ?? undefined,
+          }
+        },
+        create: async (input, options) =>
+          toSession(
+            await client.session.create(
+              { location: input?.location ?? defaultLocation, agent: input?.agent, model: input?.model },
+              request(options),
+            ),
+          ),
+        get: (input, options) => loadSession(input.sessionID, options),
+        remove: async (input, options) => {
+          await client.session.remove({ sessionID: input.sessionID }, request(options))
+        },
+        fork: async (input, options) =>
+          toSession(await client.session.fork({ sessionID: input.sessionID, messageID: input.messageID }, request(options))),
+        rename: async (input, options) => {
+          await client.session.rename({ sessionID: input.sessionID, title: input.title }, request(options))
+        },
+        interrupt: async (input, options) => {
+          await client.session.interrupt({ sessionID: input.sessionID }, request(options))
+        },
+        activity: async (_input, options) => {
+          const result = await client.session.active(request(options))
+          return Object.fromEntries(Object.keys(result).map((sessionID) => [sessionID, { type: "running" } as const]))
+        },
+        history: async (input, options) => {
+          const result = await client.message.list(
+            { sessionID: input.sessionID, limit: input.limit, cursor: input.cursor },
+            request(options),
+          )
+          return {
+            items: result.data.map((item) => toTimelineItem(item, input.sessionID)),
+            previous: result.cursor.previous ?? undefined,
+            next: result.cursor.next ?? undefined,
+          }
+        },
+        message: (input, options) => loadMessage(input.sessionID, input.messageID, options),
+        prompt: async (input, options) => {
+          if (input.selection?.agent)
+            await client.session.switchAgent(
+              { sessionID: input.sessionID, agent: input.selection.agent },
+              request(options),
+            )
+          if (input.selection?.model)
+            await client.session.switchModel(
+              { sessionID: input.sessionID, model: input.selection.model },
+              request(options),
+            )
+          await client.session.prompt(
+            {
+              sessionID: input.sessionID,
+              id: input.id,
+              text: input.text,
+              files: input.files?.map(toPromptFile),
+              agents: input.agents?.map((agent) => ({
+                name: agent.name,
+                mention:
+                  agent.start === undefined || agent.end === undefined || agent.text === undefined
+                    ? undefined
+                    : { start: agent.start, end: agent.end, text: agent.text },
+              })),
+              delivery: input.delivery,
+            },
+            request(options),
+          )
+        },
+        command: async (input, options) => {
+          await client.session.command(
+            {
+              sessionID: input.sessionID,
+              id: input.id,
+              command: input.command,
+              arguments: input.arguments,
+              agent: input.agent,
+              model: input.model,
+              files: input.files?.map(toPromptFile),
+              delivery: input.delivery,
+            },
+            request(options),
+          )
+        },
+      },
+      files: {
+        list: async (input, options) =>
+          (await client.file.list({ location: location(input), path: input.path }, request(options))).data,
+        find: async (input, options) =>
+          (
+            await client.file.find(
+              { location: location(input), query: input.query, type: input.type, limit: input.limit },
+              request(options),
+            )
+          ).data,
+        read: async (input, options) => ({
+          bytes: await client.file.read({ location: location(input), path: input.path }, request(options)),
+        }),
+      },
+      permissions: {
+        pending: async (input, options) =>
+          (await client.permission.request.list({ location: location(input) }, request(options))).data.map(toPermission),
+        reply: async (input, options) => {
+          await client.permission.reply(input, request(options))
+        },
+      },
+      questions: {
+        pending: async (input, options) =>
+          (await client.question.request.list({ location: location(input) }, request(options))).data.map(toQuestion),
+        reply: async (input, options) => {
+          await client.question.reply({ ...input, answers: input.answers.map((answer) => [...answer]) }, request(options))
+        },
+        reject: async (input, options) => {
+          await client.question.reject(input, request(options))
+        },
+      },
+      vcs: {
+        status: async (input, options) =>
+          (await client.vcs.status({ location: location(input) }, request(options))).data,
+        diff: async (input, options) =>
+          (await client.vcs.diff({ ...input, location: location(input) }, request(options))).data.map(toFileDiff),
+      },
+      mcp: {
+        list: async (input, options) =>
+          (await client.mcp.list({ location: location(input) }, request(options))).data.map(toMcpServer),
+        resources: async (input, options) =>
+          (await client.mcp.resource.catalog({ location: location(input) }, request(options))).data,
+      },
+      pty: {
+        list: async (input, options) =>
+          (await client.pty.list({ location: location(input) }, request(options))).data.map(toPty),
+        create: async (input, options) =>
+          toPty(
+            (
+              await client.pty.create(
+                {
+                  location: location(input),
+                  title: input.title,
+                  command: input.command,
+                  args: input.args,
+                  cwd: input.cwd,
+                  env: input.env,
+                },
+                request(options),
+              )
+            ).data,
+          ),
+        get: async (input, options) =>
+          toPty((await client.pty.get({ ptyID: input.ptyID, location: location(input) }, request(options))).data),
+        update: async (input, options) =>
+          toPty(
+            (
+              await client.pty.update(
+                { ptyID: input.ptyID, location: location(input), title: input.title, size: input.size },
+                request(options),
+              )
+            ).data,
+          ),
+        remove: async (input, options) => {
+          await client.pty.remove({ ptyID: input.ptyID, location: location(input) }, request(options))
+        },
+      },
+      events: {
+        subscribe: (options) => ({
+          async *[Symbol.asyncIterator]() {
+            for await (const input of client.event.subscribe(request(options))) {
+              yield {
+                location: input.location,
+                event: await toEvent(input, loadSession, loadMessage, options),
+              }
+            }
+          },
+        }),
+      },
+      disposeLocation: async (input, options) => {
+        await client.debug.location.evict({ location: location(input) }, request(options))
+      },
+    },
+    capabilities: {
+      integrationsV2: {
+        list: async (input, options) =>
+          (await client.integration.list({ location: location(input) }, request(options))).data.map(toIntegration),
+        get: async (input, options) => {
+          const result = await client.integration.get(
+            { integrationID: input.integrationID, location: location(input) },
+            request(options),
+          )
+          return result.data ? toIntegration(result.data) : null
+        },
+        connectKey: async (input, options) => {
+          await client.integration.connect.key({ ...input, location: location(input) }, request(options))
+        },
+        connectOauth: async (input, options) =>
+          toIntegrationAttempt(
+            (
+              await client.integration.connect.oauth(
+                {
+                  integrationID: input.integrationID,
+                  methodID: input.methodID,
+                  inputs: input.values,
+                  label: input.label,
+                  location: location(input),
+                },
+                request(options),
+              )
+            ).data,
+          ),
+        attemptStatus: async (input, options) =>
+          toAttemptStatus(
+            (
+              await client.integration.attempt.status(
+                { attemptID: input.attemptID, location: location(input) },
+                request(options),
+              )
+            ).data,
+          ),
+        completeAttempt: async (input, options) => {
+          await client.integration.attempt.complete({ ...input, location: location(input) }, request(options))
+        },
+        cancelAttempt: async (input, options) => {
+          await client.integration.attempt.cancel({ ...input, location: location(input) }, request(options))
+        },
+        renameCredential: async (input, options) => {
+          await client.credential.update({ ...input, location: location(input) }, request(options))
+        },
+        removeCredential: async (input, options) => {
+          await client.credential.remove({ ...input, location: location(input) }, request(options))
+        },
+      },
+      sessionExtrasV2: {
+        switchAgent: async (input, options) => {
+          await client.session.switchAgent(input, request(options))
+        },
+        switchModel: async (input, options) => {
+          await client.session.switchModel(input, request(options))
+        },
+        move: async (input, options) => {
+          await client.session.move(
+            { sessionID: input.sessionID, destination: { directory: input.directory }, moveChanges: input.moveChanges },
+            request(options),
+          )
+        },
+        skill: async (input, options) => {
+          await client.session.skill(input, request(options))
+        },
+        synthetic: async (input, options) =>
+          toPending(
+            await client.session.synthetic(
+              { ...input, metadata: input.metadata && toClientRecord(input.metadata) },
+              request(options),
+            ),
+          ),
+        shell: async (input, options) => {
+          await client.session.shell(input, request(options))
+        },
+        compact: async (input, options) => toPending(await client.session.compact(input, request(options))),
+        wait: async (input, options) => {
+          await client.session.wait(input, request(options))
+        },
+        context: async (input, options) =>
+          (await client.session.context(input, request(options))).map((item) => toTimelineItem(item, input.sessionID)),
+        pending: async (input, options) =>
+          (await client.session.pending.list(input, request(options))).map(toPending),
+        instructionEntries: (input, options) => client.session.instructions.entry.list(input, request(options)),
+        putInstructionEntry: async (input, options) => {
+          await client.session.instructions.entry.put({ ...input, value: toClientJson(input.value) }, request(options))
+        },
+        removeInstructionEntry: async (input, options) => {
+          await client.session.instructions.entry.remove(input, request(options))
+        },
+        log: (input, options) => ({
+          async *[Symbol.asyncIterator]() {
+            for await (const item of client.session.log(input, request(options))) {
+              yield {
+                sequence: "durable" in item ? item.durable.seq : (item.seq ?? 0),
+                event: await toLogEvent(item, loadSession, loadMessage, options),
+              } satisfies SessionLogItem
+            }
+          },
+        }),
+        background: async (input, options) => {
+          await client.session.background(input, request(options))
+        },
+        stageRevert: async (input, options) => {
+          const result = await client.session.revert.stage(
+            { sessionID: input.sessionID, messageID: input.messageID, files: input.files?.length ? true : undefined },
+            request(options),
+          )
+          return { messageID: result.messageID }
+        },
+        clearRevert: async (input, options) => {
+          await client.session.revert.clear(input, request(options))
+        },
+        commitRevert: async (input, options) => {
+          await client.session.revert.commit(input, request(options))
+        },
+      },
+      projectCopiesV2: {
+        directories: (input, options) =>
+          client.project.directories({ projectID: input.projectID, location: location(input) }, request(options)),
+        create: (input, options) => client.projectCopy.create({ ...input, location: location(input) }, request(options)),
+        remove: async (input, options) => {
+          await client.projectCopy.remove({ ...input, location: location(input) }, request(options))
+        },
+        refresh: async (input, options) => {
+          await client.projectCopy.refresh({ ...input, location: location(input) }, request(options))
+        },
+      },
+      formsV2: {
+        pending: async (input, options) =>
+          (await client.form.request.list({ location: location(input) }, request(options))).data.map(toForm),
+        list: async (input, options) => (await client.form.list(input, request(options))).map(toForm),
+        create: async (input, options) => {
+          if (input.fields.length === 0) throw new Error("A form requires at least one field")
+          const fields: FormCreateInput["fields"] = [
+            fromFormField(input.fields[0]),
+            ...input.fields.slice(1).map(fromFormField),
+          ]
+          return toForm(
+            await client.form.create(
+              { ...input, metadata: input.metadata && toClientRecord(input.metadata), fields },
+              request(options),
+            ),
+          )
+        },
+        get: async (input, options) => toForm(await client.form.get(input, request(options))),
+        state: (input, options) => client.form.state(input, request(options)),
+        reply: async (input, options) => {
+          await client.form.reply({ ...input, answer: { ...input.answer } }, request(options))
+        },
+        cancel: async (input, options) => {
+          await client.form.cancel(input, request(options))
+        },
+      },
+      savedPermissionsV2: {
+        list: (input, options) => client.permission.saved.list(input, request(options)),
+        remove: async (input, options) => {
+          await client.permission.saved.remove(input, request(options))
+        },
+      },
+      shellsV2: {
+        list: async (input, options) =>
+          (await client.shell.list({ location: location(input) }, request(options))).data.map(toShell),
+        create: async (input, options) =>
+          toShell(
+            (
+              await client.shell.create(
+                {
+                  ...input,
+                  metadata: input.metadata && toClientRecord(input.metadata),
+                  location: location(input),
+                },
+                request(options),
+              )
+            ).data,
+          ),
+        get: async (input, options) =>
+          toShell((await client.shell.get({ id: input.id, location: location(input) }, request(options))).data),
+        setTimeout: async (input, options) =>
+          toShell(
+            (
+              await client.shell.timeout(
+                { id: input.id, timeout: input.timeout, location: location(input) },
+                request(options),
+              )
+            ).data,
+          ),
+        output: async (input, options) =>
+          (
+            await client.shell.output(
+              { id: input.id, cursor: input.cursor, limit: input.limit, location: location(input) },
+              request(options),
+            )
+          ).data,
+        remove: async (input, options) => {
+          await client.shell.remove({ id: input.id, location: location(input) }, request(options))
+        },
+      },
+      discoveryV2: {
+        server: (options) => client.server.get(request(options)),
+        location: (input, options) => client.location.get({ location: location(input) }, request(options)),
+        plugins: async (input, options) =>
+          (await client.plugin.list({ location: location(input) }, request(options))).data,
+        skills: async (input, options) => {
+          const result = await client.skill.list({ location: location(input) }, request(options))
+          return result.data.map((item) => ({
+            ...item,
+            location: { directory: item.location },
+          }))
+        },
+        models: async (input, options) =>
+          (await client.model.list({ location: location(input) }, request(options))).data.map(toModel),
+        defaultModel: async (input, options) => {
+          const result = await client.model.default({ location: location(input) }, request(options))
+          return result.data ? toModel(result.data) : null
+        },
+        generateText: async (input, options) =>
+          (await client.generate.text({ ...input, location: location(input) }, request(options))).text,
+        loadedLocations: (options) => client.debug.location.list(request(options)),
+      },
+    },
+  }
+}
+
+function toLocation(input?: LocationRef) {
+  if (!input) return
+  return { directory: input.directory, workspace: input.workspaceID }
+}
+
+function toProject(input: Project): AppProject {
+  return {
+    id: input.id,
+    worktree: input.worktree,
+    name: input.name,
+    icon: input.icon,
+    commands: input.commands,
+    sandboxes: input.sandboxes,
+  }
+}
+
+function toSession(input: SessionInfo): AppSession {
+  return {
+    id: input.id,
+    parentID: input.parentID,
+    projectID: input.projectID,
+    location: input.location,
+    title: input.title,
+    cost: input.cost,
+    tokens: input.tokens,
+    time: input.time,
+    revert: input.revert && { messageID: input.revert.messageID },
+  }
+}
+
+function toModel(input: ModelInfo): AppModel {
+  const cost = input.cost[0]
+  return {
+    id: input.id,
+    providerID: input.providerID,
+    name: input.name,
+    family: input.family,
+    releaseDate: new Date(input.time.released).toISOString().slice(0, 10),
+    cost: cost && { input: cost.input, output: cost.output, cacheRead: cost.cache.read, cacheWrite: cost.cache.write },
+    capabilities: {
+      reasoning: input.capabilities.output.includes("reasoning"),
+      input: {
+        text: input.capabilities.input.includes("text"),
+        image: input.capabilities.input.includes("image"),
+        audio: input.capabilities.input.includes("audio"),
+        video: input.capabilities.input.includes("video"),
+        pdf: input.capabilities.input.includes("pdf"),
+      },
+    },
+    limit: { context: input.limit.context, output: input.limit.output },
+    variants: Object.fromEntries(input.variants.map((variant) => [variant.id, variant])),
+  }
+}
+
+function toAgent(input: AgentInfo): AppAgent {
+  return {
+    id: input.id,
+    name: input.name,
+    description: input.description,
+    mode: input.mode,
+    hidden: input.hidden,
+    color: input.color,
+    model: input.model,
+  }
+}
+
+function toTimelineItem(input: SessionMessageInfo, sessionID: string): TimelineItem {
+  if (input.type === "user") {
+    return {
+      type: "user",
+      id: input.id,
+      sessionID,
+      created: input.time.created,
+      content: [
+        { type: "text", id: `${input.id}:text`, text: input.text },
+        ...(input.files?.map((file, index) => ({
+          type: "file" as const,
+          id: `${input.id}:file:${index}`,
+          uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
+          name: file.name,
+        })) ?? []),
+        ...(input.agents?.map((agent, index) => ({
+          type: "agent" as const,
+          id: `${input.id}:agent:${index}`,
+          name: agent.name,
+          source: agent.mention,
+        })) ?? []),
+      ],
+      raw: input,
+    }
+  }
+  if (input.type === "assistant") return toAssistant(input, sessionID)
+  const type =
+    input.type === "agent-switched"
+      ? "agent-switch"
+      : input.type === "model-switched"
+        ? "model-switch"
+        : input.type
+  return { type, id: input.id, sessionID, created: input.time.created, raw: input }
+}
+
+function toAssistant(input: SessionMessageAssistant, sessionID: string): TimelineItem {
+  return {
+    type: "assistant",
+    id: input.id,
+    sessionID,
+    created: input.time.created,
+    completed: input.time.completed,
+    content: input.content.flatMap((item, index): TimelineContent[] => {
+      if (item.type === "text") return [{ type: "text", id: `${input.id}:text:${index}`, text: item.text }]
+      if (item.type === "reasoning")
+        return [{ type: "reasoning", id: `${input.id}:reasoning:${index}`, text: item.text }]
+      if (item.state.status === "streaming")
+        return [
+          {
+            type: "tool",
+            id: item.id,
+            tool: item.name,
+            state: { status: "pending", input: {}, raw: item.state.input },
+          },
+        ]
+      if (item.state.status === "running")
+        return [
+          {
+            type: "tool",
+            id: item.id,
+            tool: item.name,
+            state: { status: "running", input: item.state.input, metadata: item.state.structured },
+          },
+        ]
+      if (item.state.status === "error")
+        return [
+          {
+            type: "tool",
+            id: item.id,
+            tool: item.name,
+            state: {
+              status: "error",
+              input: item.state.input,
+              error: item.state.error.message,
+              metadata: item.state.structured,
+            },
+          },
+        ]
+      return [
+        {
+          type: "tool",
+          id: item.id,
+          tool: item.name,
+          state: {
+            status: "completed",
+            input: item.state.input,
+            output: item.state.content
+              .map((content) => (content.type === "text" ? content.text : content.uri))
+              .join("\n"),
+            metadata: item.state.structured,
+          },
+        },
+      ]
+    }),
+    agent: input.agent,
+    model: input.model,
+    tokens: input.tokens,
+    error: input.error,
+    raw: input,
+  }
+}
+
+function toPromptFile(input: PromptFile) {
+  return {
+    uri: input.uri,
+    name: input.name,
+    mention: input.source && { start: input.source.start, end: input.source.end, text: input.source.text },
+  }
+}
+
+function toPermission(input: PermissionV2Request): AppPermissionRequest {
+  return {
+    id: input.id,
+    sessionID: input.sessionID,
+    action: input.action,
+    resources: input.resources,
+    metadata: input.metadata,
+  }
+}
+
+function toQuestion(input: QuestionV2Request): AppQuestionRequest {
+  return { id: input.id, sessionID: input.sessionID, questions: input.questions }
+}
+
+function toFileDiff(input: { file: string; patch: string; additions: number; deletions: number; status: "added" | "deleted" | "modified" }): AppFileDiff {
+  return input
+}
+
+function toMcpServer(input: McpServer): AppMcpServer {
+  return { name: input.name, status: input.status, integrationID: input.integrationID }
+}
+
+function toPty(input: { id: string; title: string }) {
+  return { id: input.id, title: input.title }
+}
+
+function toIntegration(input: IntegrationInfo) {
+  return {
+    id: input.id,
+    name: input.name,
+    methods: input.methods.map((method): IntegrationMethod => {
+      if (method.type === "oauth") return method
+      if (method.type === "key") return { type: "key", label: method.label ?? "API key" }
+      return { type: "environment", label: method.names.join(", ") }
+    }),
+    connections: input.connections.map((connection): IntegrationConnection =>
+      connection.type === "credential"
+        ? { id: connection.id, label: connection.label }
+        : { id: connection.name, label: connection.name },
+    ),
+  }
+}
+
+function toIntegrationAttempt(input: {
+  attemptID: string
+  url: string
+  instructions: string
+  mode: "auto" | "code"
+  time: { created: number | string; expires: number | string }
+}): IntegrationAttempt {
+  return {
+    ...input,
+    time: { created: Number(input.time.created), expires: Number(input.time.expires) },
+  }
+}
+
+function toAttemptStatus(input: import("@opencode-ai/client").IntegrationAttemptStatus): IntegrationAttemptStatus {
+  if (input.status === "failed") return { status: input.status, error: input.message }
+  return { status: input.status }
+}
+
+function toPending(input: SessionPendingInfo): PendingSessionInput {
+  return {
+    id: input.id,
+    sessionID: input.sessionID,
+    sequence: input.admittedSeq,
+    created: input.timeCreated,
+    delivery: "delivery" in input ? input.delivery : undefined,
+    raw: input,
+  }
+}
+
+function toForm(input: FormInfo) {
+  return {
+    id: input.id,
+    sessionID: input.sessionID,
+    title: input.title,
+    metadata: input.metadata,
+    fields: input.fields.map((field): FormField => {
+      if (field.type === "external")
+        return {
+          type: field.type,
+          key: field.key,
+          label: field.title ?? field.key,
+          url: field.url,
+          description: field.description,
+        }
+      if (field.type === "multiselect")
+        return {
+          type: field.type,
+          key: field.key,
+          label: field.title ?? field.key,
+          options: field.options.map((option) => option.value),
+          required: field.required,
+          description: field.description,
+        }
+      return {
+        type: field.type,
+        key: field.key,
+        label: field.title ?? field.key,
+        required: field.required,
+        description: field.description,
+      }
+    }),
+  }
+}
+
+function fromFormField(input: FormField): FormCreateInput["fields"][number] {
+  if (input.type === "external")
+    return { type: input.type, key: input.key, title: input.label, url: input.url, description: input.description }
+  if (input.type === "multiselect")
+    return {
+      type: input.type,
+      key: input.key,
+      title: input.label,
+      options: input.options.map((option) => ({ value: option, label: option })),
+      required: input.required,
+      description: input.description,
+    }
+  return {
+    type: input.type,
+    key: input.key,
+    title: input.label,
+    required: input.type === "boolean" ? undefined : input.required,
+    description: input.description,
+  }
+}
+
+function toClientRecord(input: Readonly<Record<string, import("./backend").JsonValue>>) {
+  return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, toClientJson(value)]))
+}
+
+function toClientJson(input: import("./backend").JsonValue): import("@opencode-ai/client").JsonValue {
+  if (isJsonArray(input)) return input.map(toClientJson)
+  if (input && typeof input === "object") return toClientRecord(input)
+  return input
+}
+
+function isJsonArray(input: import("./backend").JsonValue): input is readonly import("./backend").JsonValue[] {
+  return Array.isArray(input)
+}
+
+function toShell(input: ShellInfo1): ShellProcess {
+  return {
+    id: input.id,
+    command: input.command,
+    cwd: input.cwd,
+    status: input.status === "running" ? "running" : "exited",
+    created: input.time.started,
+    exitCode: input.exit,
+    metadata: input.metadata,
+  }
+}
+
+async function toEvent(
+  input: OpenCodeEvent,
+  loadSession: (sessionID: string, options?: RequestOptions) => Promise<AppSession>,
+  loadMessage: (sessionID: string, messageID: string, options?: RequestOptions) => Promise<TimelineItem>,
+  options?: RequestOptions,
+): Promise<AppEvent> {
+  if (input.type === "server.connected") return { type: input.type }
+  if (input.type === "session.deleted") return { type: input.type, sessionID: input.data.sessionID }
+  if (input.type === "session.error") return { type: input.type, sessionID: input.data.sessionID, error: input.data.error }
+  if (input.type === "session.status") {
+    const activity = toActivity(input.data.status)
+    return activity
+      ? { type: "session.activity", sessionID: input.data.sessionID, activity }
+      : { type: "unknown", raw: input }
+  }
+  if (input.type === "session.execution.started")
+    return { type: "session.activity", sessionID: input.data.sessionID, activity: { type: "running" } }
+  if (input.type === "session.retry.scheduled")
+    return {
+      type: "session.activity",
+      sessionID: input.data.sessionID,
+      activity: {
+        type: "retry",
+        attempt: input.data.attempt,
+        message: input.data.error.message,
+        next: input.data.at,
+      },
+    }
+  if (input.type === "session.execution.failed")
+    return { type: "session.error", sessionID: input.data.sessionID, error: input.data.error }
+  if (input.type === "session.idle") return { type: "unknown", raw: input }
+  if (input.type === "permission.v2.asked") return { type: "permission.requested", request: toPermission(input.data) }
+  if (input.type === "permission.v2.replied")
+    return { type: "permission.replied", sessionID: input.data.sessionID, requestID: input.data.requestID }
+  if (input.type === "question.v2.asked") return { type: "question.requested", request: toQuestion(input.data) }
+  if (input.type === "question.v2.replied" || input.type === "question.v2.rejected")
+    return {
+      type: input.type === "question.v2.replied" ? "question.replied" : "question.rejected",
+      sessionID: input.data.sessionID,
+      requestID: input.data.requestID,
+    }
+  if (input.type === "filesystem.changed")
+    return { type: "file.changed", path: input.data.file, change: input.data.event }
+  if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.data.branch }
+  if (input.type === "pty.exited") return { type: input.type, ptyID: input.data.id }
+  if (input.type === "message.removed")
+    return { type: "timeline.removed", sessionID: input.data.sessionID, itemID: input.data.messageID }
+  if (input.type === "message.updated")
+    return {
+      type: "timeline.updated",
+      item: await loadMessage(input.data.sessionID, input.data.info.id, options),
+    }
+  if (input.type === "message.part.updated")
+    return {
+      type: "timeline.updated",
+      item: await loadMessage(input.data.sessionID, input.data.part.messageID, options),
+    }
+
+  const sessionID = eventSessionID(input)
+  const messageID = eventMessageID(input)
+  if (sessionID && messageID)
+    return { type: "timeline.updated", item: await loadMessage(sessionID, messageID, options) }
+  if (sessionID && isSessionProjectionEvent(input.type)) {
+    const session = await loadSession(sessionID, options)
+    return { type: input.type === "session.created" ? "session.created" : "session.updated", session }
+  }
+  return { type: "unknown", raw: input }
+}
+
+function toActivity(input: import("@opencode-ai/client").SessionStatus): SessionActivity | undefined {
+  if (input.type === "idle") return
+  if (input.type === "busy") return { type: "running" }
+  return input
+}
+
+function eventSessionID(input: OpenCodeEvent) {
+  if (!("data" in input) || typeof input.data !== "object" || input.data === null || !("sessionID" in input.data)) return
+  return typeof input.data.sessionID === "string" ? input.data.sessionID : undefined
+}
+
+function eventMessageID(input: OpenCodeEvent) {
+  if (!("data" in input) || typeof input.data !== "object" || input.data === null) return
+  if ("assistantMessageID" in input.data && typeof input.data.assistantMessageID === "string")
+    return input.data.assistantMessageID
+  if ("messageID" in input.data && typeof input.data.messageID === "string") return input.data.messageID
+  if (input.type === "session.input.promoted") return input.data.inputID
+}
+
+function isSessionProjectionEvent(type: string) {
+  return [
+    "session.created",
+    "session.updated",
+    "session.agent.selected",
+    "session.model.selected",
+    "session.moved",
+    "session.renamed",
+    "session.usage.updated",
+    "session.revert.staged",
+    "session.revert.cleared",
+    "session.revert.committed",
+  ].includes(type)
+}
+
+async function toLogEvent(
+  input: import("@opencode-ai/client").SessionLogOutput,
+  loadSession: (sessionID: string, options?: RequestOptions) => Promise<AppSession>,
+  loadMessage: (sessionID: string, messageID: string, options?: RequestOptions) => Promise<TimelineItem>,
+  options?: RequestOptions,
+): Promise<AppEvent> {
+  if (input.type === "log.synced") return { type: "unknown", raw: input }
+  return toEvent(input, loadSession, loadMessage, options)
+}