From d036479effe4858eee9e5d98bb4672068afa6203 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 2 Sep 2026 22:01:34 -0400 Subject: [PATCH 1/2] fix(tui): distinguish location loading from data sync failures --- packages/tui/src/context/location.tsx | 65 ++++++-- .../storybook/session-location-missing.tsx | 13 +- packages/tui/src/routes/session/index.tsx | 8 +- .../src/routes/session/location-missing.tsx | 41 +++-- packages/tui/test/context/location.test.tsx | 152 ++++++++++++++++++ packages/tui/test/fixture/tui-environment.tsx | 5 +- .../tui/test/session-location-sync.test.tsx | 138 ++++++++++++++++ 7 files changed, 389 insertions(+), 33 deletions(-) create mode 100644 packages/tui/test/context/location.test.tsx create mode 100644 packages/tui/test/session-location-sync.test.tsx diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx index 54ae4b340576..9becc4142ba2 100644 --- a/packages/tui/src/context/location.tsx +++ b/packages/tui/src/context/location.tsx @@ -1,7 +1,18 @@ import type { LocationGetOutput, LocationRef } from "@opencode-ai/client" -import { createContext, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js" +import { + createContext, + createEffect, + createMemo, + createSignal, + onCleanup, + useContext, + type ParentProps, +} from "solid-js" import { useClient } from "./client" import { useData } from "./data" +import { useLog } from "./log" +import { useToast } from "../ui/toast" +import { errorMessage } from "../util/error" const context = createContext<{ readonly current: LocationGetOutput | undefined @@ -9,16 +20,25 @@ const context = createContext<{ readonly ref: LocationRef | undefined readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined set: (location?: LocationRef) => void + retry: () => void }>() export function LocationProvider(props: ParentProps) { const client = useClient() const data = useData() + const toast = useToast() + const log = useLog() const [ref, setRef] = createSignal() const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>() let generation = 0 const current = createMemo(() => data.location.info(ref())) + // A reconnect marks the connection ready before its buffered server.connected event. + // Invalidate old HTTP attempts at disconnect, not only when the next sync starts. + createEffect(() => { + if (client.connection.status() !== "connected") generation++ + }) + function sync(location?: LocationRef) { if (!location) return const attempt = ++generation @@ -28,16 +48,38 @@ export function LocationProvider(props: ParentProps) { ? undefined : location setError(undefined) - void data.location.sync(target).catch((cause) => { - const current = ref() - if ( - generation !== attempt || - current?.directory !== location.directory || - current.workspaceID !== location.workspaceID - ) - return - setError({ location, cause }) - }) + const active = () => + generation === attempt && + ref()?.directory === location.directory && + ref()?.workspaceID === location.workspaceID && + client.connection.status() === "connected" + let resolved = false + void data.location + .syncInfo(target) + .then(() => { + // syncInfo is cached: the remaining sync loads catalogs for the resolved location. + resolved = true + return data.location.sync(target) + }) + .catch((cause) => { + if (!active()) return + if (!resolved) { + setError({ location, cause }) + return + } + log.error("Session data sync failed", { cause }) + toast.show({ + variant: "error", + title: "Session data sync failed", + message: `Some session data could not be loaded (${errorMessage(cause)}).`, + action: { + label: "Retry", + run: () => { + if (active()) sync(location) + }, + }, + }) + }) } function set(location?: LocationRef) { @@ -60,6 +102,7 @@ export function LocationProvider(props: ParentProps) { return error() }, set, + retry: () => sync(ref()), }} > {props.children} diff --git a/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx b/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx index 6b6e9908c573..03166ea0b446 100644 --- a/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx @@ -12,7 +12,7 @@ const directory = "/Users/kit/code/open-source/opencode-workerd-profile" function SessionLocationMissingStory(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() const theme = props.context.theme.contextual.elevated - const [message, setMessage] = createSignal("Choose another directory to continue") + const [message, setMessage] = createSignal("Retry or choose another directory") const open = () => props.context.ui.dialog.show(() => ( Workerd Modal workspace driver - build · GPT-5.6 Sol (high) + build · Demo Model You Test the mounted workspace and verify the deployment. - Build · GPT-5.6 Sol (high) + Build · Demo Model The deployment is verified and the worktree is clean. - + setMessage("Retried location sync")} + onMove={open} + /> - + props.projectID, sessionID: () => props.sessionID }) - return + return ( + + {(error) => ( + + )} + + ) } -export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) { +export function SessionLocationUnavailable(props: { + directory: string + message: string + onRetry: () => void + onMove: () => void +}) { const paths = useTuiPaths() const theme = useTheme("elevated") const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72)) return ( {directory()} - Choose another directory to continue this session. + {props.message} } - options={{ move: "Choose directory" }} - onSelect={props.onMove} + options={{ retry: "Retry", move: "Choose directory" }} + onSelect={(option) => (option === "retry" ? props.onRetry() : props.onMove())} /> ) } diff --git a/packages/tui/test/context/location.test.tsx b/packages/tui/test/context/location.test.tsx new file mode 100644 index 000000000000..934de86773e8 --- /dev/null +++ b/packages/tui/test/context/location.test.tsx @@ -0,0 +1,152 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { createEffect } from "solid-js" +import { ConfigProvider } from "../../src/config" +import { ClientProvider, useClient } from "../../src/context/client" +import { DataProvider, useData } from "../../src/context/data" +import { LocationProvider, useLocation } from "../../src/context/location" +import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" +import { useToast } from "../../src/ui/toast" + +test.each([ + { endpoint: "location", reconnect: false }, + { endpoint: "agent", reconnect: false }, + { endpoint: "agent", reconnect: true }, +])("a late failure cannot replace the new sync's state (%o)", async ({ endpoint, reconnect }) => { + const requested = Promise.withResolvers() + const response = Promise.withResolvers() + const events = createEventStream() + let requests = 0 + let connections = 0 + const calls = createFetch((url) => { + if (url.pathname === "/api/event") connections++ + const target = url.searchParams.get("location[directory]") ?? directory + if (target === directory && url.pathname === `/api/${endpoint}` && ++requests === 1) { + requested.resolve() + return response.promise + } + const location = { directory: target, project: { id: "project", directory: target, canonical: target } } + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/agent") return json({ location, data: [] }) + return undefined + }, events) + let location!: ReturnType + let data!: ReturnType + let toast!: ReturnType + function Probe() { + const client = useClient() + location = useLocation() + data = useData() + toast = useToast() + location.set({ directory }) + createEffect(() => { + // Connection status changes before the buffered server.connected event is published. + // Deliver the old HTTP failure in that gap, not after an arbitrary timer. + if (client.connection.status() === "connected" && reconnect && connections > 1) + response.resolve(json({ message: "Old location sync failed" }, { status: 500 })) + }) + return + } + const app = await testRender(() => ( + + + + + + + + + + + + )) + app.renderer.start() + try { + await requested.promise + const target = reconnect ? directory : "/other" + if (reconnect) { + events.disconnect() + await app.waitFor(() => connections > 1, { maxPasses: 120 }) + } + if (!reconnect) location.set({ directory: target }) + if (!reconnect) response.resolve(json({ message: "Old location sync failed" }, { status: 500 })) + await app.waitFor(() => data.location.agent.list({ directory: target }) !== undefined) + await app.waitForVisualIdle() + expect(location.ref).toEqual({ directory: target }) + expect(location.current?.directory).toBe(target) + expect(location.error).toBeUndefined() + expect(toast.currentToast).toBeNull() + } finally { + response.resolve(json({ message: "Old location sync failed" }, { status: 500 })) + app.renderer.destroy() + } +}) + +test("catalog failures preserve resolved info and an old Retry cannot sync a different location", async () => { + const requests: string[] = [] + const causes: unknown[] = [] + const failure = Promise.withResolvers() + const calls = createFetch((url) => { + const target = url.searchParams.get("location[directory]") ?? directory + requests.push(`${target}:${url.pathname}`) + const location = { directory: target, project: { id: "project", directory: target, canonical: target } } + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/agent" && target === directory) return failure.promise + if (url.pathname === "/api/agent") return json({ location, data: [] }) + return undefined + }, createEventStream()) + let location!: ReturnType + let data!: ReturnType + let toast!: ReturnType + function Probe() { + location = useLocation() + data = useData() + toast = useToast() + location.set({ directory }) + return + } + const app = await testRender(() => ( + { + if (message === "Session data sync failed") causes.push(tags.cause) + }} + > + + + + + + + + + + + )) + app.renderer.start() + try { + await app.waitFor(() => requests.includes(`${directory}:/api/agent`)) + const original = data.location.agent.sync({ directory }).catch((cause: unknown) => cause) + failure.resolve(json({ message: "Agent catalog temporarily unavailable" }, { status: 500 })) + await app.waitFor(() => toast.currentToast !== null) + expect(causes).toEqual([await original]) + expect(causes[0]).toBe(await original) + expect(location.current?.directory).toBe(directory) + expect(location.error).toBeUndefined() + const retry = toast.currentToast?.action?.run + expect(retry).toBeDefined() + location.set({ directory: "/other" }) + await app.waitFor(() => data.location.agent.list({ directory: "/other" }) !== undefined) + const before = requests.length + retry?.() + await app.waitForVisualIdle() + expect(requests).toHaveLength(before) + expect(location.ref).toEqual({ directory: "/other" }) + expect(location.error).toBeUndefined() + } finally { + failure.resolve(json({ message: "Agent catalog temporarily unavailable" }, { status: 500 })) + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/fixture/tui-environment.tsx b/packages/tui/test/fixture/tui-environment.tsx index 5a2557217425..25f30accce86 100644 --- a/packages/tui/test/fixture/tui-environment.tsx +++ b/packages/tui/test/fixture/tui-environment.tsx @@ -8,6 +8,7 @@ import { import type { ParentProps } from "solid-js" import { LogProvider, type LogSink } from "../../src/context/log" import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard" +import { ToastProvider } from "../../src/ui/toast" const clipboard: ClipboardService = { async read() { @@ -38,7 +39,9 @@ export function TestTuiContexts( > - {props.children} + + {props.children} + diff --git a/packages/tui/test/session-location-sync.test.tsx b/packages/tui/test/session-location-sync.test.tsx new file mode 100644 index 000000000000..59af87077fcd --- /dev/null +++ b/packages/tui/test/session-location-sync.test.tsx @@ -0,0 +1,138 @@ +import { expect, test } from "bun:test" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect, FileSystem } from "effect" +import { Global } from "@opencode-ai/util/global" +import { createEventStream, createFetch, directory, json } from "./fixture/tui-client" +import { tmpdir } from "./fixture/fixture" + +test.each([ + { width: 70, endpoint: "agent", initial: true }, + { width: 120, endpoint: "agent", initial: false }, + { width: 100, endpoint: "model", initial: false }, + { width: 100, endpoint: "mcp", initial: false }, + { width: 100, endpoint: "location", initial: true }, + { width: 100, endpoint: "location", initial: false }, +])("session sync offers truthful recovery (%o)", async ({ width, endpoint, initial }) => { + await using state = await tmpdir() + const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const sessionID = `ses_location_sync_${endpoint}_${width}` + const location = { directory, project: { id: "project", directory, canonical: directory } } + let agents = 0 + let failures = 0 + let healthy = !initial + let recovered = 0 + const events = createEventStream() + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}`) + return json({ + data: { + id: sessionID, + projectID: "project", + title: "Location sync fixture", + model: { providerID: "demo", id: "model" }, + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + }, + }) + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + if (url.pathname === `/api/session/${sessionID}/inbox` || url.pathname === `/api/session/${sessionID}/permission`) + return json({ data: [] }) + if (url.pathname === "/api/worktree/project") return json([{ directory: "/other" }]) + if (url.pathname === "/api/worktree/project/refresh") return new Response(null, { status: 204 }) + if (url.pathname === `/api/${endpoint}` && !healthy) { + if (endpoint === "agent") agents++ + failures++ + return json({ message: "Service temporarily unavailable" }, { status: 500 }) + } + if (url.pathname === `/api/${endpoint}` && failures > 0) recovered++ + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/agent") { + agents++ + return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] }) + } + if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] }) + if (url.pathname === "/api/model") + return json({ location, data: [{ id: "model", providerID: "demo", name: "Demo Model", variants: [] }] }) + return undefined + }, events) + const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) }) + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({ animations: false }), update: async () => ({}) }, + packages: { prepare: async () => ({ directory: "" }) }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), + args: { sessionID }, + log: () => {}, + }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), + ) + try { + const title = endpoint === "location" ? "Could not load session location" : "Session data sync failed" + if (!initial) { + await setup.waitForFrame((frame) => frame.includes("Demo Model")) + await setup.mockInput.typeText("Keep this draft") + await setup.waitForFrame((frame) => frame.includes("Keep this draft")) + healthy = false + events.disconnect() + } + const editor = setup.renderer.currentFocusedEditor + await setup.waitFor(() => failures > 0, { maxPasses: 120 }) + // Also settle on the old panel so this remains an assertion failure on the base revision. + await setup.waitForFrame((frame) => frame.includes("Session location unavailable") || frame.includes(title)) + await setup.waitForVisualIdle() + const frame = setup.captureCharFrame() + expect(frame).not.toContain("Choose another directory to continue this session.") + expect(frame).toContain(title) + // Undeclared HTTP 500 bodies are deliberately not decoded by the generated client. + expect(frame).toContain("UnexpectedStatus") + expect(frame).toContain("Retry") + if (endpoint !== "location") { + expect(agents).toBeGreaterThan(0) + expect(frame).not.toContain("Choose directory") + if (!initial) { + expect(setup.renderer.currentFocusedEditor).toBe(editor) + expect(editor?.plainText).toBe("Keep this draft") + } + } + if (endpoint === "location") { + expect(frame).toContain("Choose directory") + const lines = frame.split("\n") + const row = lines.findIndex((line) => line.includes("Choose directory")) + await setup.mockMouse.click(lines[row]!.indexOf("Choose directory"), row) + await setup.waitForFrame((frame) => frame.includes("Move session") && frame.includes("/other")) + setup.mockInput.pressEscape() + await setup.waitForFrame((frame) => !frame.includes("Move session") && frame.includes(title)) + } + + const retry = async () => { + const lines = setup.captureCharFrame().split("\n") + const row = lines.findIndex((line) => line.includes("Retry")) + expect(row).toBeGreaterThanOrEqual(0) + await setup.mockMouse.click(lines[row]!.indexOf("Retry"), row) + } + const before = failures + await retry() + await setup.waitFor(() => failures > before) + await setup.waitForFrame((frame) => frame.includes(title)) + healthy = true + await retry() + await setup.waitFor(() => recovered > 0) + await setup.waitForFrame((frame) => frame.includes("Demo Model") && !frame.includes(title)) + expect(agents).toBeGreaterThan(0) + expect(setup.captureCharFrame()).not.toContain("Choose directory") + if (!initial && endpoint !== "location") { + expect(setup.renderer.currentFocusedEditor).toBe(editor) + expect(editor?.plainText).toBe("Keep this draft") + await setup.mockInput.typeText(" after retry") + await setup.waitForFrame((frame) => frame.includes("Keep this draft after retry")) + } + } finally { + setup.renderer.destroy() + await task.finally(() => server.stop(true)) + } +}) From 1129adf5958060a4062c64897d782fb136d7bb52 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 2 Sep 2026 22:04:25 -0400 Subject: [PATCH 2/2] test(tui): isolate stale location lookups from preload --- packages/tui/test/context/location.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/tui/test/context/location.test.tsx b/packages/tui/test/context/location.test.tsx index 934de86773e8..209aca6eac37 100644 --- a/packages/tui/test/context/location.test.tsx +++ b/packages/tui/test/context/location.test.tsx @@ -16,6 +16,8 @@ test.each([ { endpoint: "agent", reconnect: false }, { endpoint: "agent", reconnect: true }, ])("a late failure cannot replace the new sync's state (%o)", async ({ endpoint, reconnect }) => { + // Keep the held lookup separate from the client's launch-directory preload. + const source = `${directory}/old` const requested = Promise.withResolvers() const response = Promise.withResolvers() const events = createEventStream() @@ -24,7 +26,7 @@ test.each([ const calls = createFetch((url) => { if (url.pathname === "/api/event") connections++ const target = url.searchParams.get("location[directory]") ?? directory - if (target === directory && url.pathname === `/api/${endpoint}` && ++requests === 1) { + if (target === source && url.pathname === `/api/${endpoint}` && ++requests === 1) { requested.resolve() return response.promise } @@ -41,7 +43,7 @@ test.each([ location = useLocation() data = useData() toast = useToast() - location.set({ directory }) + location.set({ directory: source }) createEffect(() => { // Connection status changes before the buffered server.connected event is published. // Deliver the old HTTP failure in that gap, not after an arbitrary timer. @@ -66,7 +68,7 @@ test.each([ app.renderer.start() try { await requested.promise - const target = reconnect ? directory : "/other" + const target = reconnect ? source : "/other" if (reconnect) { events.disconnect() await app.waitFor(() => connections > 1, { maxPasses: 120 })