fix(tui): guard messages.data in session.sync against undefined (#26566)
Co-authored-by: Developer <temp@example.com>
This commit is contained in:
@@ -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[match.index] = session.data!
|
||||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||||
draft.todo[sessionID] = todo.data ?? []
|
draft.todo[sessionID] = todo.data ?? []
|
||||||
draft.message[sessionID] = messages.data!.map((x) => x.info)
|
const infos: typeof draft.message[string] = []
|
||||||
for (const message of messages.data!) {
|
for (const message of messages.data ?? []) {
|
||||||
|
infos.push(message.info)
|
||||||
draft.part[message.info.id] = message.parts
|
draft.part[message.info.id] = message.parts
|
||||||
}
|
}
|
||||||
|
draft.message[sessionID] = infos
|
||||||
draft.session_diff[sessionID] = diff.data ?? []
|
draft.session_diff[sessionID] = diff.data ?? []
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
120
packages/opencode/test/cli/cmd/tui/sync-fixture.tsx
Normal file
120
packages/opencode/test/cli/cmd/tui/sync-fixture.tsx
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,127 +1,8 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { describe, expect, test } from "bun:test"
|
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 { 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"
|
import { tmpdir } from "../../../fixture/fixture"
|
||||||
|
import { mount } from "./sync-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 />
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("tui sync", () => {
|
describe("tui sync", () => {
|
||||||
test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
|
test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user