Compare commits

...

2 Commits

Author SHA1 Message Date
Dax Raad 20f6635ca0 fix(cli): pass initialized SDK to TUI 2026-06-07 13:15:12 -04:00
Dax Raad a7f335aec0 refactor(tui): use authenticated HTTP SDK transport 2026-06-07 13:14:25 -04:00
18 changed files with 156 additions and 190 deletions
+2 -2
View File
@@ -2,14 +2,14 @@ import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
return run({
...transport,
sdk: createOpencodeClient({ baseUrl: transport.url, headers: transport.headers, fetch: gracefulFetch }),
args: {},
config,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
+20 -11
View File
@@ -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
View File
@@ -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
-7
View File
@@ -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))
}
@@ -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
View File
@@ -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
View File
@@ -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) {
@@ -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")')
})
@@ -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")')
})
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: {
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,