From 6c2dfd2f52099d952cfe78613d55370b9fb9fefd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 15:58:03 -0400 Subject: [PATCH] fix(tui): guard messages.data in session.sync against undefined (#26566) Co-authored-by: Developer --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 6 +- .../test/cli/cmd/tui/sync-fixture.tsx | 120 +++++++++++++++++ .../cmd/tui/sync-undefined-messages.test.tsx | 47 +++++++ .../opencode/test/cli/cmd/tui/sync.test.tsx | 121 +----------------- 4 files changed, 172 insertions(+), 122 deletions(-) create mode 100644 packages/opencode/test/cli/cmd/tui/sync-fixture.tsx create mode 100644 packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 24609dd81e..e4c2824d41 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -526,10 +526,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => x.info) - for (const message of messages.data!) { + const infos: typeof draft.message[string] = [] + for (const message of messages.data ?? []) { + infos.push(message.info) draft.part[message.info.id] = message.parts } + draft.message[sessionID] = infos draft.session_diff[sessionID] = diff.data ?? [] }), ) diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx new file mode 100644 index 0000000000..d9ecdbe9d5 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx @@ -0,0 +1,120 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" +import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" +import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" +import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" + +export const worktree = "/tmp/opencode" +export const directory = `${worktree}/packages/opencode` + +export async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +export function json(data: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(data), { + ...init, + headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, + }) +} + +export function eventSource(): EventSource { + return { subscribe: async () => () => {} } +} + +type FetchHandler = (url: URL) => Response | Promise | undefined + +export function createFetch(override?: FetchHandler) { + 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 + + switch (url.pathname) { + case "/agent": + case "/command": + case "/experimental/workspace": + case "/experimental/workspace/status": + case "/formatter": + case "/lsp": + return json([]) + case "/config": + case "/experimental/resource": + case "/mcp": + case "/provider/auth": + case "/session/status": + return json({}) + case "/config/providers": + return json({ providers: {}, default: {} }) + case "/experimental/console": + return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + case "/path": + return json({ home: "", state: "", config: "", worktree, directory }) + case "/project/current": + return json({ id: "proj_test" }) + case "/provider": + return json({ all: [], default: {}, connected: [] }) + case "/session": + return json([]) + case "/vcs": + return json({ branch: "main" }) + } + + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch + + return { fetch, session } +} + +type Ctx = { kv: ReturnType; sync: ReturnType } + +export async function mount(override?: FetchHandler) { + const calls = createFetch(override) + let sync!: ReturnType + let kv!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + function Probe() { + const ctx: Ctx = { kv: useKV(), sync: useSync() } + onMount(() => { + sync = ctx.sync + kv = ctx.kv + done() + }) + return + } + + const app = await testRender(() => ( + + + + + + + + + + + + + + )) + + await ready + await wait(() => sync.status === "complete") + return { app, kv, sync, session: calls.session } +} diff --git a/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx new file mode 100644 index 0000000000..5fb7ece94d --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx @@ -0,0 +1,47 @@ +/** @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 { Global } from "@opencode-ai/core/global" +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 () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + 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 + }) + + try { + await expect(sync.session.sync(sessionID)).resolves.toBeUndefined() + } finally { + app.renderer.destroy() + Global.Path.state = previous + } + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index 993484d3ca..f67257f6ce 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -1,127 +1,8 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" -import { testRender } from "@opentui/solid" -import { onMount } from "solid-js" import { Global } from "@opencode-ai/core/global" -import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" -import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" -import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" -import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" -import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" -import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" import { tmpdir } from "../../../fixture/fixture" - -const worktree = "/tmp/opencode" -const directory = `${worktree}/packages/opencode` - -async function wait(fn: () => boolean, timeout = 2000) { - const start = Date.now() - while (!fn()) { - if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(10) - } -} - -function json(data: unknown) { - return new Response(JSON.stringify(data), { - headers: { "content-type": "application/json" }, - }) -} - -function eventSource(): EventSource { - return { - subscribe: async () => () => {}, - } -} - -function createFetch() { - 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) - - switch (url.pathname) { - case "/agent": - case "/command": - case "/experimental/workspace": - case "/experimental/workspace/status": - case "/formatter": - case "/lsp": - return json([]) - case "/config": - case "/experimental/resource": - case "/mcp": - case "/provider/auth": - case "/session/status": - return json({}) - case "/config/providers": - return json({ providers: {}, default: {} }) - case "/experimental/console": - return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) - case "/path": - return json({ home: "", state: "", config: "", worktree, directory }) - case "/project/current": - return json({ id: "proj_test" }) - case "/provider": - return json({ all: [], default: {}, connected: [] }) - case "/session": - return json([]) - case "/vcs": - return json({ branch: "main" }) - } - - throw new Error(`unexpected request: ${url.pathname}`) - }) as typeof globalThis.fetch - - return { fetch, session } -} - -async function mount() { - const calls = createFetch() - let sync!: ReturnType - let kv!: ReturnType - let done!: () => void - const ready = new Promise((resolve) => { - done = resolve - }) - - const app = await testRender(() => ( - - - - - - - { - sync = ctx.sync - kv = ctx.kv - done() - }} - /> - - - - - - - )) - - await ready - await wait(() => sync.status === "complete") - return { app, kv, sync, session: calls.session } -} - -function Probe(props: { onReady: (ctx: { kv: ReturnType; sync: ReturnType }) => void }) { - const kv = useKV() - const sync = useSync() - - onMount(() => { - props.onReady({ kv, sync }) - }) - - return -} +import { mount } from "./sync-fixture" describe("tui sync", () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => {