refactor(tui): use v2 client transport

This commit is contained in:
Dax Raad 2026-06-26 14:57:01 -04:00
commit 8b682c42b6
26 changed files with 428 additions and 1507 deletions

View file

@ -4,7 +4,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, json } from "./fixture/tui-sdk"
import { createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@ -17,8 +17,8 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
setTitle(title)
}
const listeners = new Set(process.listeners("SIGHUP"))
const events = createEventSource()
const calls = createFetch()
const events = createEventStream()
const calls = createFetch(undefined, events)
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
@ -29,11 +29,8 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
url: "http://test",
directory,
client: createClient(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: events.source,
args: {},
pluginHost: {
async start() {
@ -63,21 +60,27 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventSource()
const calls = createFetch((url) => {
if (url.pathname === "/session")
return json([
{
id: "dummy",
title: "Demo session",
slug: "dummy",
projectID: "project",
directory,
version: "0.0.0-test",
time: { created: 0, updated: 0 },
},
])
})
const events = createEventStream()
const calls = createFetch(
(url) => {
if (url.pathname === "/api/session")
return json({
data: [
{
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
],
cursor: {},
})
},
events,
)
const originalWrite = process.stdout.write.bind(process.stdout)
let stdout = ""
let api: TuiPluginApi | undefined
@ -95,11 +98,8 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
url: "http://test",
directory,
client: createClient(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: events.source,
args: { continue: true },
pluginHost: {
async start(input) {

View file

@ -83,6 +83,48 @@ function permission(id: string, sessionID = "session"): PermissionRequest {
}
}
function stepStarted(id: string, sessionID = "session"): Event {
return {
id,
type: "session.next.step.started",
properties: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
agent: "build",
model: { id: "model", providerID: "provider" },
},
}
}
function stepEnded(id: string, sessionID = "session", finish = "stop"): Event {
return {
id,
type: "session.next.step.ended",
properties: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
finish,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
}
}
function stepFailed(id: string, sessionID = "session"): Event {
return {
id,
type: "session.next.step.failed",
properties: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
error: { type: "unknown", message: "boom" },
},
}
}
const questionNotification: TuiAttentionNotifyInput = {
title: "Demo session",
message: "Question needs input",
@ -139,21 +181,9 @@ describe("internal notifications TUI plugin", () => {
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
const harness = await setup()
harness.emit({
id: "event-1",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
})
harness.emit({
id: "event-2",
type: "session.status",
properties: { sessionID: "session", status: { type: "busy" } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
})
harness.emit(stepEnded("event-1"))
harness.emit(stepStarted("event-2"))
harness.emit(stepEnded("event-3"))
expect(harness.notifications).toEqual([
{
@ -169,16 +199,8 @@ describe("internal notifications TUI plugin", () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
harness.emit({
id: "event-2",
type: "session.status",
properties: { sessionID: "subagent", status: { type: "busy" } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "subagent", status: { type: "idle" } },
})
harness.emit(stepStarted("event-2", "subagent"))
harness.emit(stepEnded("event-3", "subagent"))
expect(harness.notifications).toEqual([
{
@ -199,21 +221,9 @@ describe("internal notifications TUI plugin", () => {
test("notifies session errors once and suppresses the following idle done notification", async () => {
const harness = await setup()
harness.emit({
id: "event-1",
type: "session.status",
properties: { sessionID: "session", status: { type: "busy" } },
})
harness.emit({
id: "event-2",
type: "session.error",
properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
})
harness.emit(stepStarted("event-1"))
harness.emit(stepFailed("event-2"))
harness.emit(stepEnded("event-3"))
expect(harness.notifications).toEqual([
{
@ -228,21 +238,13 @@ describe("internal notifications TUI plugin", () => {
test("special-cases aborts and model response timeouts", async () => {
const harness = await setup()
harness.emit({
id: "event-1",
type: "session.status",
properties: { sessionID: "abort", status: { type: "busy" } },
})
harness.emit(stepStarted("event-1", "abort"))
harness.emit({
id: "event-2",
type: "session.error",
properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "timeout", status: { type: "busy" } },
})
harness.emit(stepStarted("event-3", "timeout"))
harness.emit({
id: "event-4",
type: "session.error",

View file

@ -7,9 +7,9 @@ import { ProjectProvider, useProject } from "../../../../src/context/project"
import { SDKProvider } from "../../../../src/context/sdk"
import { SyncProvider, useSync } from "../../../../src/context/sync"
import { ExitProvider } from "../../../../src/context/exit"
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
import { createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk"
import { TestTuiContexts } from "../../../fixture/tui-environment"
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk"
export async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
@ -22,8 +22,8 @@ export async function wait(fn: () => boolean, timeout = 2000) {
type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
export async function mount(override?: FetchHandler, state?: string) {
const calls = createFetch(override)
const events = createEventSource()
const events = createEventStream()
const calls = createFetch(override, events)
let sync!: ReturnType<typeof useSync>
let project!: ReturnType<typeof useProject>
let kv!: ReturnType<typeof useKV>
@ -47,7 +47,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 client={createClient(calls.fetch)}>
<ProjectProvider>
<ExitProvider exit={() => {}}>
<SyncProvider>

View file

@ -1,262 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { tmpdir } from "../../../fixture/fixture"
import { json, mount, wait } from "./sync-fixture"
const sessionID = "ses_hydration_race"
const messageID = "msg_hydration_race"
const partID = "prt_hydration_race"
const session = {
id: sessionID,
title: "race",
time: { created: 0, updated: 0 },
version: "1.15.13",
directory: "/tmp/opencode/packages/opencode",
}
const assistant = {
id: messageID,
sessionID,
role: "assistant" as const,
agent: "build",
modelID: "model",
providerID: "test",
mode: "build",
parentID: "msg_user",
path: { cwd: session.directory, root: session.directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
}
function global(payload: GlobalEvent["payload"]): GlobalEvent {
return { directory: "/tmp/other", project: "proj_test", payload }
}
test("stale session hydration does not overwrite live message parts", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
}, tmp.path)
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
emit(
global({
id: "evt_part",
type: "message.part.updated",
properties: {
sessionID,
time: 2,
part: { id: partID, sessionID, messageID, type: "text", text: "visible live content" },
},
}),
)
await wait(() => sync.data.part[messageID]?.[0]?.type === "text")
resolveMessages(
json([
{
info: assistant,
parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }],
},
]),
)
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible live content" })
} finally {
app.renderer.destroy()
}
})
test("orphan live deltas do not suppress hydrated parts", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
}, tmp.path)
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(
global({
id: "evt_delta",
type: "message.part.delta",
properties: { sessionID, messageID, partID, field: "text", delta: "ignored until part exists" },
}),
)
resolveMessages(
json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "hydrated" }] }]),
)
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "hydrated" })
} finally {
app.renderer.destroy()
}
})
test("hydration does not clear text streamed before it starts", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
}, tmp.path)
try {
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
emit(
global({
id: "evt_part",
type: "message.part.updated",
properties: {
sessionID,
time: 1,
part: { id: partID, sessionID, messageID, type: "text", text: "" },
},
}),
)
emit(
global({
id: "evt_delta",
type: "message.part.delta",
properties: { sessionID, messageID, partID, field: "text", delta: "visible streamed content" },
}),
)
await wait(() => sync.data.part[messageID]?.[0]?.type === "text" && sync.data.part[messageID][0].text !== "")
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
resolveMessages(json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }] }]))
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible streamed content" })
} finally {
app.renderer.destroy()
}
})
test("live messages merged during hydration retain the 100 message window", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
}, tmp.path)
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
const live = { ...assistant, id: "msg_z_live" }
emit(global({ id: "evt_live", type: "message.updated", properties: { sessionID, info: live } }))
await wait(() => sync.data.message[sessionID]?.some((message) => message.id === live.id) ?? false)
resolveMessages(
json(
Array.from({ length: 100 }, (_, index) => {
const id = `msg_${String(index).padStart(3, "0")}`
return {
info: { ...assistant, id },
parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }],
}
}),
),
)
await hydrate
expect(sync.data.message[sessionID]).toHaveLength(100)
expect(sync.data.message[sessionID].at(-1)?.id).toBe(live.id)
expect(sync.data.message[sessionID].some((message) => message.id === "msg_000")).toBe(false)
expect(sync.data.part.msg_000).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("a message removed during hydration does not regain stale parts", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
}, tmp.path)
try {
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
await wait(() => sync.data.message[sessionID]?.length === 1)
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(global({ id: "evt_removed", type: "message.removed", properties: { sessionID, messageID } }))
await wait(() => sync.data.message[sessionID]?.length === 0)
resolveMessages(
json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "stale" }] }]),
)
await hydrate
expect(sync.data.message[sessionID]).toEqual([])
expect(sync.data.part[messageID]).toBeUndefined()
} finally {
app.renderer.destroy()
}
})

View file

@ -1,43 +0,0 @@
/** @jsxImportSource @opentui/solid */
/**
* Reproducer for #26560 TUI crashes with
* `TypeError: undefined is not an object (evaluating 'f.data.map')`
* when entering a session whose messages endpoint returns a non-2xx.
* The failure path is `sync.tsx#sync.session.sync` reading
* `messages.data!` while the SDK leaves `data` undefined on error.
*/
import { describe, expect, test } from "bun:test"
import { tmpdir } from "../../../fixture/fixture"
import { directory, json, mount } from "./sync-fixture"
const sessionID = "ses_undef"
describe("tui sync (#26560)", () => {
test("entering a session whose messages endpoint errors does not crash sync", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
const sessionPayload = {
id: sessionID,
title: "broken",
time: { created: 0, updated: 0 },
version: "1.14.42",
directory,
project_id: "proj_test",
}
const { app, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(sessionPayload)
if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 })
if (url.pathname === `/session/${sessionID}/todo`) return json([])
if (url.pathname === `/session/${sessionID}/diff`) return json([])
if (url.pathname === "/session") return json([sessionPayload])
return undefined
}, tmp.path)
try {
await expect(sync.session.sync(sessionID)).resolves.toBeUndefined()
} finally {
app.renderer.destroy()
}
})
})

View file

@ -1,65 +1,24 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import { tmpdir } from "../../../fixture/fixture"
import { mount, wait } from "./sync-fixture"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { expect, test } from "bun:test"
import { mount } from "./sync-fixture"
function branchEvent(branch: string, workspace?: string): GlobalEvent {
return {
directory: "/tmp/other",
project: "proj_test",
workspace,
payload: {
id: `evt_vcs_${branch}`,
type: "vcs.branch.updated",
properties: { branch },
},
test("legacy sync is an inert compatibility context", async () => {
const { app, session, sync } = await mount()
try {
expect(sync.status).toBe("complete")
expect(sync.ready).toBe(true)
expect(sync.data.session).toEqual([])
expect(sync.data.message).toEqual({})
expect(sync.data.provider).toEqual([])
expect(sync.session.get("ses_test")).toBeUndefined()
await sync.bootstrap()
await sync.session.refresh()
await sync.session.sync("ses_test")
expect(session).toEqual([])
} finally {
app.renderer.destroy()
}
}
describe("tui sync", () => {
test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
const { app, kv, sync, session } = await mount(undefined, tmp.path)
try {
expect(kv.get("session_directory_filter_enabled", true)).toBe(true)
expect(session.at(-1)?.searchParams.get("roots")).toBeNull()
expect(session.at(-1)?.searchParams.get("scope")).toBeNull()
expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui")
kv.set("session_directory_filter_enabled", false)
await sync.session.refresh()
expect(session.at(-1)?.searchParams.get("scope")).toBe("project")
expect(session.at(-1)?.searchParams.get("path")).toBeNull()
expect(session.at(-1)?.searchParams.get("roots")).toBeNull()
} finally {
app.renderer.destroy()
}
})
test("vcs branch updates only apply for the active workspace", async () => {
await using tmp = await tmpdir()
await Bun.write(`${tmp.path}/kv.json`, "{}")
const { app, emit, project, sync } = await mount(undefined, tmp.path)
try {
expect(sync.data.vcs?.branch).toBe("main")
project.workspace.set("ws_a")
emit(branchEvent("other", "ws_b"))
await Bun.sleep(30)
expect(sync.data.vcs?.branch).toBe("main")
emit(branchEvent("feature", "ws_a"))
await wait(() => sync.data.vcs?.branch === "feature")
expect(sync.data.vcs?.branch).toBe("feature")
} finally {
app.renderer.destroy()
}
})
})

View file

@ -6,7 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
import { DataProvider, useData } from "../../../src/context/data"
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
import { createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
async function wait(fn: () => boolean, timeout = 2000) {
@ -21,12 +21,12 @@ function global(payload: Event): GlobalEvent {
return { directory, project: "proj_test", payload }
}
function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event) {
function emitEvent(events: ReturnType<typeof createEventStream>, payload: Event) {
events.emit(global(payload))
}
test("refreshes resources into reactive getters", async () => {
const events = createEventSource()
const events = createEventStream()
const location = {
directory,
project: { id: "proj_test", directory },
@ -73,7 +73,7 @@ test("refreshes resources into reactive getters", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@ -85,7 +85,7 @@ test("refreshes resources into reactive getters", async () => {
try {
await mounted
expect(data.location.default()).toEqual({ directory })
expect(data.location.default()).toEqual({ directory: process.cwd() })
expect(data.session.get("ses_test")).toBeUndefined()
expect(data.location.agent.list(location)).toBeUndefined()
@ -106,12 +106,12 @@ test("refreshes resources into reactive getters", async () => {
})
test("reconnects the event stream and bootstraps fresh data", async () => {
const events = createEventSource()
const events = createEventStream()
const requests = { event: 0, model: 0 }
const calls = createFetch((url) => {
if (url.pathname === "/api/event") {
requests.event++
return events.response()
return events.v2()
}
if (url.pathname !== "/api/model") return
requests.model++
@ -143,7 +143,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@ -173,8 +173,68 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
}
})
test("tracks session status from active sessions and execution events", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/active")
return json({ data: { "session-active": { type: "running" } } })
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.session.status("session-active") === "running")
expect(data.session.status("session-idle")).toBe("idle")
emitEvent(events, {
id: "evt_step_started",
type: "session.next.step.started",
properties: {
sessionID: "session-live",
assistantMessageID: "message-live",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
await wait(() => data.session.status("session-live") === "running")
emitEvent(events, {
id: "evt_step_ended",
type: "session.next.step.ended",
properties: {
sessionID: "session-live",
assistantMessageID: "message-live",
timestamp: 2,
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
await wait(() => data.session.status("session-live") === "idle")
} finally {
app.renderer.destroy()
}
})
test("refreshes integrations after integration updates", async () => {
const events = createEventSource()
const events = createEventStream()
const requests = { integration: 0, model: 0, provider: 0 }
const calls = createFetch((url) => {
if (url.pathname === "/api/model") {
@ -215,7 +275,7 @@ test("refreshes integrations after integration updates", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@ -241,7 +301,7 @@ test("refreshes integrations after integration updates", async () => {
})
test("refreshes effective catalog data after catalog updates", async () => {
const events = createEventSource()
const events = createEventStream()
const requests = { model: 0, provider: 0 }
const calls = createFetch((url) => {
if (url.pathname === "/api/model") {
@ -256,7 +316,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<box />
@ -277,7 +337,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
})
test("refreshes references after updates", async () => {
const events = createEventSource()
const events = createEventStream()
let requests = 0
const calls = createFetch((url) => {
if (url.pathname !== "/api/reference") return
@ -301,7 +361,7 @@ test("refreshes references after updates", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@ -323,7 +383,7 @@ test("refreshes references after updates", async () => {
})
test("adds and dismisses permission requests from live events", async () => {
const events = createEventSource()
const events = createEventStream()
const calls = createFetch(undefined, events)
let data!: ReturnType<typeof useData>
@ -334,7 +394,7 @@ test("adds and dismisses permission requests from live events", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@ -388,7 +448,7 @@ test("adds and dismisses permission requests from live events", async () => {
})
test("adds and dismisses question requests from live events", async () => {
const events = createEventSource()
const events = createEventStream()
const calls = createFetch(undefined, events)
let data!: ReturnType<typeof useData>
@ -399,7 +459,7 @@ test("adds and dismisses question requests from live events", async () => {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@ -451,7 +511,7 @@ test("adds and dismisses question requests from live events", async () => {
})
test("settles pending tools when a live failure arrives", async () => {
const events = createEventSource()
const events = createEventStream()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
@ -467,7 +527,7 @@ test("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 client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@ -580,7 +640,7 @@ test("settles pending tools when a live failure arrives", async () => {
})
test("renders admitted prompts only after they become model-visible", async () => {
const events = createEventSource()
const events = createEventStream()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
@ -596,7 +656,7 @@ test("renders admitted prompts only after they become model-visible", async () =
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<SDKProvider client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
@ -653,7 +713,7 @@ test("renders admitted prompts only after they become model-visible", async () =
})
test("projects live context updates with their message ID", async () => {
const events = createEventSource()
const events = createEventStream()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
@ -669,7 +729,7 @@ test("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 client={createClient(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />

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, createEventStream, createFetch, directory } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
const projectID = "proj_test"
@ -49,8 +49,8 @@ function update(version: string): Event {
}
async function mount() {
const events = createEventSource()
const calls = createFetch()
const events = createEventStream()
const calls = createFetch(undefined, events)
const seen: Event[] = []
const workspaces: Array<string | undefined> = []
let project!: ReturnType<typeof useProject>
@ -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 client={createClient(calls.fetch)}>
<ProjectProvider>
<Probe
onReady={async (ctx) => {

View file

@ -1,5 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import 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`
@ -11,71 +11,83 @@ export function json(data: unknown, init?: ResponseInit) {
})
}
export function eventSource(): EventSource {
return { subscribe: async () => () => {} }
}
export function createEventStream() {
const encoder = new TextEncoder()
const global = new Set<ReadableStreamDefaultController<Uint8Array>>()
const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
const pending = {
global: [] as Uint8Array[],
v2: [] as Uint8Array[],
}
const response = (
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
queued: Uint8Array[],
initial?: unknown,
) => {
let current: ReadableStreamDefaultController<Uint8Array> | undefined
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
current = controller
controllers.add(controller)
if (initial) controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`))
for (const chunk of queued.splice(0)) controller.enqueue(chunk)
},
cancel() {
if (current) controllers.delete(current)
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
const send = (
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
queued: Uint8Array[],
event: unknown,
) => {
const chunk = encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
if (controllers.size === 0) {
queued.push(chunk)
return
}
for (const controller of controllers) controller.enqueue(chunk)
}
export function createEventSource() {
let fn: ((event: GlobalEvent) => void) | undefined
let stream: ReadableStreamDefaultController<Uint8Array> | undefined
const pending: Uint8Array[] = []
return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
fn = handler
return () => {
if (fn === handler) fn = undefined
}
},
} satisfies EventSource,
emit(event: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(event)
send(global, pending.global, event)
if (!("properties" in event.payload)) return
const chunk = new TextEncoder().encode(
`data: ${JSON.stringify({
...event.payload,
location: { directory: event.directory, workspaceID: event.workspace },
data: event.payload.properties,
})}\n\n`,
)
if (stream) return stream.enqueue(chunk)
pending.push(chunk)
send(v2, pending.v2, {
...event.payload,
location: { directory: event.directory, workspaceID: event.workspace },
data: event.payload.properties,
})
},
response() {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
stream = controller
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`),
)
for (const chunk of pending.splice(0)) controller.enqueue(chunk)
},
cancel() {
stream = undefined
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
global() {
return response(global, pending.global)
},
v2() {
return response(v2, pending.v2, { id: "evt_connected", type: "server.connected", data: {} })
},
disconnect() {
stream?.close()
stream = undefined
for (const controller of [...global, ...v2]) controller.close()
global.clear()
v2.clear()
},
}
}
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventSource>) {
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
const session = [] as URL[]
const fetch = (async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input))
if (url.pathname === "/session") session.push(url)
const overridden = await override?.(url)
if (overridden) return overridden
if (url.pathname === "/api/event" && events) return events.response()
if (url.pathname === "/global/event" && events) return events.global()
if (url.pathname === "/api/event" && events) return events.v2()
if (
[
@ -96,6 +108,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(
url.pathname,
@ -115,3 +128,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
}) as typeof globalThis.fetch
return { fetch, session }
}
export function createClient(fetch: typeof globalThis.fetch) {
return createOpencodeClient({ baseUrl: "http://test", fetch })
}