Przeglądaj źródła

fix(tui): guard messages.data in session.sync against undefined (#26566)

Co-authored-by: Developer <temp@example.com>
Kit Langton 3 miesięcy temu
rodzic
commit
6c2dfd2f52

+ 4 - 2
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 ?? []
             }),
           )

+ 120 - 0
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<Response> | 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<typeof useKV>; sync: ReturnType<typeof useSync> }
+
+export async function mount(override?: FetchHandler) {
+  const calls = createFetch(override)
+  let sync!: ReturnType<typeof useSync>
+  let kv!: ReturnType<typeof useKV>
+  let done!: () => void
+  const ready = new Promise<void>((resolve) => {
+    done = resolve
+  })
+
+  function Probe() {
+    const ctx: Ctx = { kv: useKV(), sync: useSync() }
+    onMount(() => {
+      sync = ctx.sync
+      kv = ctx.kv
+      done()
+    })
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <ArgsProvider>
+      <ExitProvider>
+        <KVProvider>
+          <SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={eventSource()}>
+            <ProjectProvider>
+              <SyncProvider>
+                <Probe />
+              </SyncProvider>
+            </ProjectProvider>
+          </SDKProvider>
+        </KVProvider>
+      </ExitProvider>
+    </ArgsProvider>
+  ))
+
+  await ready
+  await wait(() => sync.status === "complete")
+  return { app, kv, sync, session: calls.session }
+}

+ 47 - 0
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
+    }
+  })
+})

+ 1 - 120
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<typeof useSync>
-  let kv!: ReturnType<typeof useKV>
-  let done!: () => void
-  const ready = new Promise<void>((resolve) => {
-    done = resolve
-  })
-
-  const app = await testRender(() => (
-    <ArgsProvider>
-      <ExitProvider>
-        <KVProvider>
-          <SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={eventSource()}>
-            <ProjectProvider>
-              <SyncProvider>
-                <Probe
-                  onReady={(ctx) => {
-                    sync = ctx.sync
-                    kv = ctx.kv
-                    done()
-                  }}
-                />
-              </SyncProvider>
-            </ProjectProvider>
-          </SDKProvider>
-        </KVProvider>
-      </ExitProvider>
-    </ArgsProvider>
-  ))
-
-  await ready
-  await wait(() => sync.status === "complete")
-  return { app, kv, sync, session: calls.session }
-}
-
-function Probe(props: { onReady: (ctx: { kv: ReturnType<typeof useKV>; sync: ReturnType<typeof useSync> }) => void }) {
-  const kv = useKV()
-  const sync = useSync()
-
-  onMount(() => {
-    props.onReady({ kv, sync })
-  })
-
-  return <box />
-}
+import { mount } from "./sync-fixture"
 
 describe("tui sync", () => {
   test("refresh scopes sessions by default and lists project sessions when disabled", async () => {