Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 9609670544 fix(tui): prevent stale shell counts 2026-07-07 20:22:42 -04:00
2 changed files with 70 additions and 5 deletions
+9 -4
View File
@@ -114,6 +114,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const sessionRefreshApplied = new Map<string, number>()
const sessionUsage = new Map<string, { generation: number; cost: number; tokens: SessionV2Info["tokens"] }>()
let connectionGeneration = 0
let shellGeneration = 0
let statusChanges: Set<string> | undefined
let bootstrapping: Promise<void> | 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<void> {
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])),
})
},
},
+61 -1
View File
@@ -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<Response>((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<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
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)