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

refactor(tui): use authenticated HTTP SDK transport

Dax Raad 2 месяцев назад
Родитель
Сommit
a7f335aec0

+ 20 - 11
packages/opencode/src/cli/cmd/attach.ts

@@ -1,8 +1,10 @@
 import { cmd } from "./cmd"
 import { UI } from "@/cli/ui"
 import { errorMessage } from "@opencode-ai/tui/util/error"
-import { validateSession } from "../tui/validate-session"
 import { ServerAuth } from "@/server/auth"
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { SessionID } from "@/session/schema"
+import { Schema } from "effect"
 
 export const AttachCommand = cmd({
   command: "attach <url>",
@@ -61,15 +63,22 @@ export const AttachCommand = cmd({
       }
     })()
     const headers = ServerAuth.headers({ password: args.password, username: args.username })
+    const sdk = createOpencodeClient({ baseUrl: args.url, directory, headers })
     const config = await TuiConfig.get()
 
     try {
-      await validateSession({
-        url: args.url,
-        sessionID: args.session,
-        directory,
-        headers,
-      })
+      if (args.session) {
+        const sessionID = (() => {
+          try {
+            return Schema.decodeUnknownSync(SessionID)(args.session)
+          } catch (error) {
+            throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, {
+              cause: error,
+            })
+          }
+        })()
+        await sdk.session.get({ sessionID }, { throwOnError: true })
+      }
     } catch (error) {
       UI.error(errorMessage(error))
       process.exitCode = 1
@@ -77,11 +86,12 @@ export const AttachCommand = cmd({
     }
 
     const { Effect } = await import("effect")
-    const { run } = await import("../tui/layer")
+    const { Global } = await import("@opencode-ai/core/global")
+    const { run } = await import("@opencode-ai/tui")
     const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
     await Effect.runPromise(
       run({
-        url: args.url,
+        sdk,
         config,
         pluginHost: createLegacyTuiPluginHost(),
         args: {
@@ -90,8 +100,7 @@ export const AttachCommand = cmd({
           fork: args.fork,
         },
         directory,
-        headers,
-      }),
+      }).pipe(Effect.provide(Global.defaultLayer)),
     )
   },
 })

+ 46 - 60
packages/opencode/src/cli/cmd/tui.ts

@@ -1,7 +1,12 @@
 import { cmd } from "@/cli/cmd/cmd"
 import { Rpc } from "@/util/rpc"
 import { type rpc } from "../tui/worker"
+import { ServerAuth } from "@/server/auth"
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { SessionID } from "@/session/schema"
+import { Schema } from "effect"
 import path from "path"
+import { randomBytes } from "node:crypto"
 import { fileURLToPath } from "url"
 import { UI } from "@/cli/ui"
 import * as Log from "@opencode-ai/core/util/log"
@@ -9,8 +14,6 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
 import { withTimeout } from "@/util/timeout"
 import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
 import { Filesystem } from "@/util/filesystem"
-import type { GlobalEvent } from "@opencode-ai/sdk/v2"
-import type { EventSource } from "@opencode-ai/tui/context/sdk"
 import { writeHeapSnapshot } from "v8"
 import {
   OPENCODE_PROCESS_ROLE,
@@ -18,43 +21,12 @@ import {
   ensureRunID,
   sanitizedProcessEnv,
 } from "@opencode-ai/core/util/opencode-process"
-import { validateSession } from "../tui/validate-session"
 import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
 
 declare global {
   const OPENCODE_WORKER_PATH: string
 }
 
-type RpcClient = ReturnType<typeof Rpc.client<typeof rpc>>
-
-function createWorkerFetch(client: RpcClient): typeof fetch {
-  const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
-    const request = new Request(input, init)
-    const body = request.body ? await request.text() : undefined
-    const result = await client.call("fetch", {
-      url: request.url,
-      method: request.method,
-      headers: Object.fromEntries(request.headers.entries()),
-      body,
-    })
-    return new Response(result.body, {
-      status: result.status,
-      headers: result.headers,
-    })
-  }
-  return fn as typeof fetch
-}
-
-function createEventSource(client: RpcClient): EventSource {
-  return {
-    subscribe: async (handler) => {
-      return client.on<GlobalEvent>("global.event", (e) => {
-        handler(e)
-      })
-    },
-  }
-}
-
 async function target() {
   if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
   const dist = new URL("./cli/tui/worker.js", import.meta.url)
@@ -132,6 +104,15 @@ export const TuiThreadCommand = cmd({
         return
       }
       const cwd = Filesystem.resolve(process.cwd())
+      const network = resolveNetworkOptionsNoConfig(args)
+      const external =
+        process.argv.includes("--port") ||
+        process.argv.includes("--hostname") ||
+        process.argv.includes("--mdns") ||
+        network.mdns ||
+        network.port !== 0 ||
+        network.hostname !== "127.0.0.1"
+      const password = external ? undefined : randomBytes(32).toString("base64url")
       const env = sanitizedProcessEnv({
         [OPENCODE_PROCESS_ROLE]: "worker",
         [OPENCODE_RUN_ID]: ensureRunID(),
@@ -183,34 +164,41 @@ export const TuiThreadCommand = cmd({
       const prompt = await input(args.prompt)
       const config = await TuiConfig.get()
 
-      const network = resolveNetworkOptionsNoConfig(args)
-      const external =
-        process.argv.includes("--port") ||
-        process.argv.includes("--hostname") ||
-        process.argv.includes("--mdns") ||
-        network.mdns ||
-        network.port !== 0 ||
-        network.hostname !== "127.0.0.1"
-
       const transport = external
         ? {
             url: (await client.call("server", network)).url,
-            fetch: undefined,
-            events: undefined,
+            headers: ServerAuth.headers(),
           }
         : {
-            url: "http://opencode.internal",
-            fetch: createWorkerFetch(client),
-            events: createEventSource(client),
+            url: (
+              await client.call("server", {
+                hostname: "127.0.0.1",
+                port: 0,
+                preferredPort: 0,
+                auth: { password },
+              })
+            ).url,
+            headers: ServerAuth.headers({ password }),
           }
+      const sdk = createOpencodeClient({
+        baseUrl: transport.url,
+        directory: cwd,
+        headers: transport.headers,
+      })
 
       try {
-        await validateSession({
-          url: transport.url,
-          sessionID: args.session,
-          directory: cwd,
-          fetch: transport.fetch,
-        })
+        if (args.session) {
+          const sessionID = (() => {
+            try {
+              return Schema.decodeUnknownSync(SessionID)(args.session)
+            } catch (error) {
+              throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, {
+                cause: error,
+              })
+            }
+          })()
+          await sdk.session.get({ sessionID }, { throwOnError: true })
+        }
       } catch (error) {
         UI.error(errorMessage(error))
         process.exitCode = 1
@@ -223,11 +211,12 @@ export const TuiThreadCommand = cmd({
 
       try {
         const { Effect } = await import("effect")
-        const { run } = await import("../tui/layer")
+        const { Global } = await import("@opencode-ai/core/global")
+        const { run } = await import("@opencode-ai/tui")
         const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
         await Effect.runPromise(
           run({
-            url: transport.url,
+            sdk,
             async onSnapshot() {
               const tui = writeHeapSnapshot("tui.heapsnapshot")
               const server = await client.call("snapshot", undefined)
@@ -236,8 +225,6 @@ export const TuiThreadCommand = cmd({
             config,
             pluginHost: createLegacyTuiPluginHost(),
             directory: cwd,
-            fetch: transport.fetch,
-            events: transport.events,
             args: {
               continue: args.continue,
               sessionID: args.session,
@@ -246,12 +233,11 @@ export const TuiThreadCommand = cmd({
               prompt,
               fork: args.fork,
             },
-          }),
+          }).pipe(Effect.provide(Global.defaultLayer)),
         )
       } finally {
         await stop()
       }
-      process.exit(0)
     } finally {
       try {
         unguard?.()
@@ -259,6 +245,6 @@ export const TuiThreadCommand = cmd({
         Log.Default.warn("failed to restore terminal guard", { error: errorMessage(error) })
       }
     }
+    process.exit(0)
   },
 })
-// scratch

+ 0 - 7
packages/opencode/src/cli/tui/layer.ts

@@ -1,7 +0,0 @@
-import { run as runTui, type TuiInput } from "@opencode-ai/tui"
-import { Global } from "@opencode-ai/core/global"
-import { Effect } from "effect"
-
-export function run(input: TuiInput) {
-  return runTui(input).pipe(Effect.provide(Global.defaultLayer))
-}

+ 0 - 29
packages/opencode/src/cli/tui/validate-session.ts

@@ -1,29 +0,0 @@
-import { createOpencodeClient } from "@opencode-ai/sdk/v2"
-import { SessionID } from "@/session/schema"
-import { Schema } from "effect"
-
-const decodeSessionID = Schema.decodeUnknownSync(SessionID)
-
-export async function validateSession(input: {
-  url: string
-  sessionID?: string
-  directory?: string
-  fetch?: typeof fetch
-  headers?: RequestInit["headers"]
-}) {
-  if (!input.sessionID) return
-
-  let sessionID: SessionID
-  try {
-    sessionID = decodeSessionID(input.sessionID)
-  } catch (error) {
-    throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error })
-  }
-
-  await createOpencodeClient({
-    baseUrl: input.url,
-    directory: input.directory,
-    fetch: input.fetch,
-    headers: input.headers,
-  }).session.get({ sessionID }, { throwOnError: true })
-}

+ 9 - 27
packages/opencode/src/cli/tui/worker.ts

@@ -5,8 +5,6 @@ import { InstanceRuntime } from "@/project/instance-runtime"
 import { Rpc } from "@/util/rpc"
 import { upgrade } from "@/cli/upgrade"
 import { Config } from "@/config/config"
-import { GlobalBus } from "@/bus/global"
-import { ServerAuth } from "@/server/auth"
 import { writeHeapSnapshot } from "node:v8"
 import { Heap } from "@/cli/heap"
 import { AppRuntime } from "@/effect/app-runtime"
@@ -39,38 +37,22 @@ process.on("uncaughtException", (e) => {
   })
 })
 
-// Subscribe to global events and forward them via RPC
-GlobalBus.on("event", (event) => {
-  Rpc.emit("global.event", event)
-})
-
 let server: Awaited<ReturnType<typeof Server.listen>> | undefined
 
 export const rpc = {
-  async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
-    const headers = { ...input.headers }
-    const auth = ServerAuth.header()
-    if (auth && !headers["authorization"] && !headers["Authorization"]) {
-      headers["Authorization"] = auth
-    }
-    const request = new Request(input.url, {
-      method: input.method,
-      headers,
-      body: input.body,
-    })
-    const response = await Server.Default().app.fetch(request)
-    const body = await response.text()
-    return {
-      status: response.status,
-      headers: Object.fromEntries(response.headers.entries()),
-      body,
-    }
-  },
   snapshot() {
     const result = writeHeapSnapshot("server.heapsnapshot")
     return result
   },
-  async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
+  async server(input: {
+    port: number
+    hostname: string
+    mdns?: boolean
+    mdnsDomain?: string
+    cors?: string[]
+    preferredPort?: number
+    auth?: { username?: string; password?: string }
+  }) {
     if (server) await server.stop(true)
     server = await Server.listen(input)
     return { url: server.url.toString() }

+ 17 - 3
packages/opencode/src/server/server.ts

@@ -13,6 +13,7 @@ import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
 import { PublicApi } from "./routes/instance/httpapi/public"
 import type { CorsOptions } from "./cors"
 import { lazy } from "@/util/lazy"
+import { ServerAuth } from "./auth"
 
 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -36,6 +37,8 @@ type ListenOptions = CorsOptions & {
   hostname: string
   mdns?: boolean
   mdnsDomain?: string
+  preferredPort?: number
+  auth?: ServerAuth.Credentials
 }
 type ListenerState = {
   scope: Scope.Scope
@@ -109,19 +112,30 @@ function listenerLayer(opts: ListenOptions, port: number) {
     Layer.provideMerge(WebSocketTracker.layer),
     Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })),
     // Install a fresh `ConfigProvider` per listener so `Config.string(...)`
-    // reads reflect the current `process.env`. Effect's default
+    // reads reflect the current `process.env` and listener-local credentials. Effect's default
     // `ConfigProvider` snapshots `process.env` on first read and caches the
     // result on a module-singleton Reference; without overriding it here,
     // every later `Server.listen()` keeps observing that initial snapshot.
-    Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv())),
+    Layer.provide(
+      ConfigProvider.layer(
+        opts.auth
+          ? ConfigProvider.fromUnknown({
+              ...process.env,
+              OPENCODE_SERVER_PASSWORD: opts.auth.password,
+              OPENCODE_SERVER_USERNAME: opts.auth.username ?? "opencode",
+            })
+          : ConfigProvider.fromEnv(),
+      ),
+    ),
   )
 }
 
 function startWithPortFallback(opts: ListenOptions) {
   if (opts.port !== 0) return startListener(opts, opts.port)
+  if (opts.preferredPort === 0) return startListener(opts, 0)
   // Match the legacy listener port-resolution behavior: explicit `0` prefers
   // 4096 first, then any free port.
-  return startListener(opts, 4096).pipe(Effect.catch(() => startListener(opts, 0)))
+  return startListener(opts, opts.preferredPort ?? 4096).pipe(Effect.catch(() => startListener(opts, 0)))
 }
 
 function startListener(opts: ListenOptions, port: number) {

+ 1 - 1
packages/opencode/test/cli/tui/attach.test.ts

@@ -4,7 +4,7 @@ describe("tui attach", () => {
   test("loads the TUI integration lazily", async () => {
     const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text()
 
-    expect(source).toContain('await import("../tui/layer")')
+    expect(source).toContain('await import("@opencode-ai/tui")')
     expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
     expect(source).not.toContain('import("./app")')
   })

+ 1 - 1
packages/opencode/test/cli/tui/thread.test.ts

@@ -8,7 +8,7 @@ describe("tui thread", () => {
   test("loads the TUI integration lazily", async () => {
     const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
 
-    expect(source).toContain('await import("../tui/layer")')
+    expect(source).toContain('await import("@opencode-ai/tui")')
     expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
     expect(source).not.toContain('import("./app")')
   })

+ 33 - 0
packages/opencode/test/server/httpapi-listen.test.ts

@@ -3,6 +3,7 @@ import net from "node:net"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import * as Log from "@opencode-ai/core/util/log"
 import { Server } from "../../src/server/server"
+import { ServerAuth } from "../../src/server/auth"
 import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
 import { withTimeout } from "../../src/util/timeout"
 import { resetDatabase } from "../fixture/db"
@@ -313,6 +314,38 @@ describe("HttpApi Server.listen", () => {
     }
   })
 
+  test("preferred port 0 uses an OS-assigned port", async () => {
+    const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, preferredPort: 0 })
+    try {
+      expect(listener.port).toBeGreaterThan(0)
+      expect(listener.port).not.toBe(4096)
+    } finally {
+      await stop(listener, "timed out cleaning up OS-assigned listener")
+    }
+  })
+
+  test("accepts listener-local credentials without changing process auth", async () => {
+    const listener = await Server.listen({
+      hostname: "127.0.0.1",
+      port: 0,
+      preferredPort: 0,
+      auth: { password: "local-secret" },
+    })
+    try {
+      expect((await fetch(new URL("/doc", listener.url))).status).toBe(401)
+      expect(
+        (
+          await fetch(new URL("/doc", listener.url), {
+            headers: { authorization: ServerAuth.header({ password: "local-secret" }) ?? "" },
+          })
+        ).status,
+      ).toBe(200)
+      expect(process.env.OPENCODE_SERVER_PASSWORD).toBe(original.envPassword)
+    } finally {
+      await stop(listener, "timed out cleaning up listener-local auth listener")
+    }
+  })
+
   test("port 0 falls back when 4096 is taken", async () => {
     const blocker = await occupyPort(4096)
     if (!blocker) return

+ 2 - 9
packages/opencode/test/server/httpapi-sdk.test.ts

@@ -9,7 +9,6 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
-import { validateSession } from "../../src/cli/tui/validate-session"
 import { InstanceBootstrap } from "../../src/project/bootstrap-service"
 import { InstanceStore } from "../../src/project/instance-store"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -470,14 +469,8 @@ describe("HttpApi SDK", () => {
       Effect.gen(function* () {
         const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
         const fetch = yield* serverFetch(serverPath)
-        const thrown = yield* captureThrown(() =>
-          validateSession({
-            url: "http://localhost",
-            directory,
-            sessionID,
-            fetch,
-          }),
-        )
+        const sdk = createOpencodeClient({ baseUrl: "http://localhost", directory, fetch })
+        const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
         expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
         return errorMessage(thrown)
       }),

+ 3 - 6
packages/tui/src/app.tsx

@@ -31,6 +31,7 @@ import { ProjectProvider, useProject } from "./context/project"
 import { EditorContextProvider } from "./context/editor"
 import { useEvent } from "./context/event"
 import { SDKProvider, useSDK } from "./context/sdk"
+import type { OpencodeClient } from "@opencode-ai/sdk/v2"
 import { StartupLoading } from "./component/startup-loading"
 import { SyncProvider, useSync } from "./context/sync"
 import { SyncProviderV2 } from "./context/sync-v2"
@@ -130,13 +131,11 @@ const appBindingCommands = [
 ] as const
 
 export type TuiInput = {
-  url: string
+  sdk: OpencodeClient
   args: Args
   config: TuiConfig.Resolved
   onSnapshot?: () => Promise<string[]>
   directory?: string
-  fetch?: typeof fetch
-  headers?: RequestInit["headers"]
   events?: EventSource
   pluginHost: TuiPluginHost
 }
@@ -270,10 +269,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                                   <TuiConfigProvider config={input.config}>
                                     <PluginRuntimeProvider value={pluginRuntime}>
                                       <SDKProvider
-                                        url={input.url}
+                                        sdk={input.sdk}
                                         directory={input.directory}
-                                        fetch={input.fetch}
-                                        headers={input.headers}
                                         events={input.events}
                                       >
                                         <ProjectProvider>

+ 3 - 18
packages/tui/src/context/sdk.tsx

@@ -1,5 +1,4 @@
-import { createOpencodeClient } from "@opencode-ai/sdk/v2"
-import type { GlobalEvent } from "@opencode-ai/sdk/v2"
+import type { GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { createSimpleContext } from "./helper"
 import { batch, onCleanup, onMount } from "solid-js"
@@ -11,26 +10,14 @@ export type EventSource = {
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
   name: "SDK",
   init: (props: {
-    url: string
+    sdk: OpencodeClient
     directory?: string
-    fetch?: typeof fetch
-    headers?: RequestInit["headers"]
     events?: EventSource
   }) => {
     const abort = new AbortController()
     let sse: AbortController | undefined
 
-    function createSDK() {
-      return createOpencodeClient({
-        baseUrl: props.url,
-        signal: abort.signal,
-        directory: props.directory,
-        fetch: props.fetch,
-        headers: props.headers,
-      })
-    }
-
-    let sdk = createSDK()
+    const sdk = props.sdk
 
     const handlers = new Set<(event: GlobalEvent) => void>()
     const emitter = {
@@ -144,8 +131,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       },
       directory: props.directory,
       event: emitter,
-      fetch: props.fetch ?? fetch,
-      url: props.url,
     }
   },
 })

+ 2 - 3
packages/tui/test/app-lifecycle.test.tsx

@@ -3,7 +3,7 @@ import { createTestRenderer } from "@opentui/core/testing"
 import { Effect } from "effect"
 import { Global } from "@opencode-ai/core/global"
 import { createTuiResolvedConfig } from "./fixture/tui-runtime"
-import { createEventSource, createFetch, directory } from "./fixture/tui-sdk"
+import { createClient, createEventSource, createFetch, directory } from "./fixture/tui-sdk"
 
 test("SIGHUP clears title and disposes scoped resources once", async () => {
   const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -28,10 +28,9 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
     const { run } = await import("../src/app")
     const task = Effect.runPromise(
       run({
-        url: "http://test",
+        sdk: createClient(calls.fetch),
         directory,
         config: createTuiResolvedConfig({ plugin_enabled: {} }),
-        fetch: calls.fetch,
         events: events.source,
         args: {},
         pluginHost: {

+ 2 - 2
packages/tui/test/cli/cmd/tui/sync-fixture.tsx

@@ -6,7 +6,7 @@ import { KVProvider, useKV } from "../../../../src/context/kv"
 import { ProjectProvider, useProject } from "../../../../src/context/project"
 import { SDKProvider } from "../../../../src/context/sdk"
 import { SyncProvider, useSync } from "../../../../src/context/sync"
-import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
+import { createClient, createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
 import { TestTuiContexts } from "../../../fixture/tui-environment"
 export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
 
@@ -46,7 +46,7 @@ export async function mount(override?: FetchHandler, state?: string) {
     <TestTuiContexts paths={state ? { state } : undefined}>
       <ArgsProvider>
         <KVProvider>
-          <SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
+          <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
             <ProjectProvider>
               <SyncProvider>
                 <Probe />

+ 8 - 8
packages/tui/test/cli/tui/sync-v2.test.tsx

@@ -6,7 +6,7 @@ import { onMount } from "solid-js"
 import { ProjectProvider } from "../../../src/context/project"
 import { SDKProvider } from "../../../src/context/sdk"
 import { SyncProviderV2, useSyncV2 } from "../../../src/context/sync-v2"
-import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
+import { createClient, createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 
 async function wait(fn: () => boolean, timeout = 2000) {
@@ -44,7 +44,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
         <ProjectProvider>
           <SyncProviderV2>
             <Probe />
@@ -173,7 +173,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
         <ProjectProvider>
           <SyncProviderV2>
             <Probe />
@@ -237,7 +237,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
         <ProjectProvider>
           <SyncProviderV2>
             <Probe />
@@ -285,7 +285,7 @@ test("sync v2 projects live context updates with their message ID", async () =>
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
         <ProjectProvider>
           <SyncProviderV2>
             <Probe />
@@ -340,7 +340,7 @@ test("sync v2 preserves live events while snapshot hydration is in flight", asyn
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
         <ProjectProvider>
           <SyncProviderV2>
             <Probe />
@@ -390,7 +390,7 @@ test("sync v2 replaces stale cached rows while preserving in-flight live rows",
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
         <ProjectProvider>
           <SyncProviderV2>
             <Probe />
@@ -459,7 +459,7 @@ test("sync v2 preserves snapshot order and metadata for in-flight updates", asyn
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
         <ProjectProvider>
           <SyncProviderV2>
             <Probe />

+ 2 - 2
packages/tui/test/cli/tui/use-event.test.tsx

@@ -6,7 +6,7 @@ import { onMount } from "solid-js"
 import { ProjectProvider, useProject } from "../../../src/context/project"
 import { SDKProvider } from "../../../src/context/sdk"
 import { useEvent } from "../../../src/context/event"
-import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
+import { createClient, createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 
 const projectID = "proj_test"
@@ -61,7 +61,7 @@ async function mount() {
 
   const app = await testRender(() => (
     <TestTuiContexts>
-      <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
+      <SDKProvider sdk={createClient(calls.fetch)} directory={directory} events={events.source}>
         <ProjectProvider>
           <Probe
             onReady={async (ctx) => {

+ 5 - 1
packages/tui/test/fixture/tui-sdk.ts

@@ -1,9 +1,13 @@
-import type { GlobalEvent } from "@opencode-ai/sdk/v2"
+import { createOpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2"
 import type { EventSource } from "../../src/context/sdk"
 
 export const worktree = "/tmp/opencode"
 export const directory = `${worktree}/packages/tui`
 
+export function createClient(fetch: typeof globalThis.fetch) {
+  return createOpencodeClient({ baseUrl: "http://test", directory, fetch })
+}
+
 export function json(data: unknown, init?: ResponseInit) {
   return new Response(JSON.stringify(data), {
     ...init,