diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 22f505682d..8bd0893a06 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -114,6 +114,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const sessionRefreshApplied = new Map() const sessionUsage = new Map() let connectionGeneration = 0 + let shellGeneration = 0 let statusChanges: Set | undefined let bootstrapping: Promise | undefined @@ -758,6 +759,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) break case "shell.created": + shellGeneration++ setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({ ...data, shell: { ...data?.shell, [event.data.info.id]: event.data.info }, @@ -765,6 +767,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "shell.exited": case "shell.deleted": + shellGeneration++ if (event.location) { setStore("location", locationKey(event.location), (data) => ({ ...data, @@ -932,12 +935,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ .map((data) => data.shell?.[id]) .find((shell) => shell !== undefined) }, - async refresh(ref?: LocationRef) { - const result = await sdk.api.shell.list({ location: locationQuery(ref) }) - const key = locationKey(result.location) + async refresh(ref?: LocationRef): Promise { + const generation = shellGeneration + const response = await sdk.api.shell.list({ location: locationQuery(ref) }) + if (generation !== shellGeneration) return result.shell.refresh(ref) + const key = locationKey(response.location) setStore("location", key, { ...store.location[key], - shell: Object.fromEntries(mutable(result.data).map((info) => [info.id, info])), + shell: Object.fromEntries(mutable(response.data).map((info) => [info.id, info])), }) }, }, diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index fdd5980fb5..bbe81cecb3 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -9,7 +9,7 @@ import { ProjectProvider } from "../../../src/context/project" import { SDKProvider } from "../../../src/context/sdk" import { DataProvider, useData } from "../../../src/context/data" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" -import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk" +import { createApi, createClient, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-sdk" import { TestTuiContexts } from "../../fixture/tui-environment" async function wait(fn: () => boolean, timeout = 2000) { @@ -1452,6 +1452,66 @@ test("keeps shell state scoped to location", async () => { } }) +test("retries a shell refresh when a lifecycle event races its response", async () => { + const events = createEventStream() + let requests = 0 + let resolveFirst!: (response: Response) => void + const first = new Promise((resolve) => { + resolveFirst = resolve + }) + const location = { directory, project: { id: "proj_test", directory: worktree } } + const shell = { + id: "sh_stale", + status: "running" as const, + command: "bun test", + cwd: directory, + shell: "/bin/sh", + file: "/tmp/opencode-shell", + metadata: { sessionID: "ses_default" }, + time: { started: 1 }, + } + const calls = createFetch((url) => { + if (url.pathname !== "/api/shell") return + requests++ + if (requests === 1) return first + return json({ location, data: [] }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => requests === 1) + emitEvent(events, { + id: "evt_shell_exited", + created: 0, + type: "shell.exited", + data: { id: "sh_stale", exit: 0, status: "exited" }, + }) + resolveFirst(json({ location, data: [shell] })) + + await wait(() => requests === 2) + expect(data.shell.list()).toEqual([]) + } finally { + app.renderer.destroy() + } +}) + test("adds and dismisses permission requests from live events", async () => { const events = createEventStream() const calls = createFetch(undefined, events)