Skip to content

Commit b300bbb

Browse files
authored
fix(app): seed session status before warming session info (anomalyco#34864)
1 parent bec4622 commit b300bbb

2 files changed

Lines changed: 121 additions & 52 deletions

File tree

‎packages/app/src/context/global-sync/bootstrap.test.ts‎

Lines changed: 99 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,55 @@
11
import { describe, expect, test } from "bun:test"
22
import { createStore } from "solid-js/store"
33
import { QueryClient } from "@tanstack/solid-query"
4-
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
4+
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
55
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
66
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
77
import type { State, VcsCache } from "./types"
8+
import { createServerSession } from "../server-session"
89
import { ServerScope } from "@/utils/server-scope"
910

1011
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
1112

13+
function directoryState() {
14+
return createStore<State>({
15+
status: "loading",
16+
agent: [],
17+
command: [],
18+
reference: [],
19+
project: "",
20+
projectMeta: undefined,
21+
icon: undefined,
22+
provider_ready: true,
23+
provider,
24+
config: {},
25+
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
26+
session: [],
27+
sessionTotal: 0,
28+
session_status: {},
29+
session_working(id: string) {
30+
return this.session_status[id]?.type !== "idle"
31+
},
32+
session_diff: {},
33+
todo: {},
34+
permission: {},
35+
question: {},
36+
mcp_ready: true,
37+
mcp: {},
38+
mcp_resource: {},
39+
lsp_ready: true,
40+
lsp: [],
41+
vcs: undefined,
42+
limit: 5,
43+
message: {},
44+
part: {},
45+
part_text_accum_delta: {},
46+
})
47+
}
48+
1249
describe("bootstrapDirectory", () => {
1350
test("marks a loading directory partial during bootstrap and complete after success", async () => {
1451
const mcpReads: string[] = []
15-
const [store, setStore] = createStore<State>({
16-
status: "loading",
17-
agent: [],
18-
command: [],
19-
reference: [],
20-
project: "",
21-
projectMeta: undefined,
22-
icon: undefined,
23-
provider_ready: true,
24-
provider,
25-
config: {},
26-
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
27-
session: [],
28-
sessionTotal: 0,
29-
session_status: {},
30-
session_working(id: string) {
31-
return this.session_status[id]?.type !== "idle"
32-
},
33-
session_diff: {},
34-
todo: {},
35-
permission: {},
36-
question: {},
37-
mcp_ready: true,
38-
mcp: {},
39-
mcp_resource: {},
40-
lsp_ready: true,
41-
lsp: [],
42-
vcs: undefined,
43-
limit: 5,
44-
message: {},
45-
part: {},
46-
part_text_accum_delta: {},
47-
})
52+
const [store, setStore] = directoryState()
4853

4954
await bootstrapDirectory({
5055
directory: "/project",
@@ -93,6 +98,66 @@ describe("bootstrapDirectory", () => {
9398
expect(store.status).toBe("complete")
9499
expect(mcpReads).toEqual([])
95100
})
101+
102+
test("seeds session status even while warming session info stalls", async () => {
103+
const [store, setStore] = directoryState()
104+
const stalled = Promise.withResolvers<never>()
105+
const client = {
106+
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
107+
config: { get: async () => ({ data: {} }) },
108+
session: {
109+
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
110+
get: () => stalled.promise,
111+
},
112+
vcs: { get: async () => ({ data: undefined }) },
113+
command: { list: async () => ({ data: [] }) },
114+
permission: { list: async () => ({ data: [] }) },
115+
question: { list: async () => ({ data: [] }) },
116+
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
117+
mcp: { status: async () => ({ data: {} }) },
118+
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
119+
} as unknown as OpencodeClient
120+
const session = createServerSession(client)
121+
const stale: Session = {
122+
id: "ses_stale",
123+
slug: "ses_stale",
124+
projectID: "project",
125+
directory: "/project",
126+
title: "stale",
127+
version: "1",
128+
time: { created: 1, updated: 1 },
129+
}
130+
session.remember(stale)
131+
session.set("session_status", stale.id, { type: "busy" })
132+
133+
await bootstrapDirectory({
134+
directory: "/project",
135+
scope: ServerScope.local,
136+
mcp: false,
137+
global: {
138+
config: {} satisfies Config,
139+
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
140+
project: [{ id: "project", worktree: "/project" } as Project],
141+
provider,
142+
},
143+
sdk: client,
144+
store,
145+
setStore,
146+
vcsCache: { setStore() {} } as unknown as VcsCache,
147+
loadSessions() {},
148+
translate: (key) => key,
149+
queryClient: new QueryClient(),
150+
session,
151+
})
152+
153+
const deadline = Date.now() + 500
154+
while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
155+
await new Promise((resolve) => setTimeout(resolve, 10))
156+
}
157+
158+
expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
159+
expect(session.data.session_status[stale.id]).toBeUndefined()
160+
})
96161
})
97162

98163
describe("query keys", () => {

‎packages/app/src/context/global-sync/bootstrap.ts‎

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -247,25 +247,29 @@ export async function bootstrapDirectory(input: {
247247
() =>
248248
retry(() =>
249249
input.sdk.session.status().then(async (x) => {
250-
if (input.session) {
251-
const statuses = x.data ?? {}
252-
await Promise.all(
253-
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
254-
)
255-
input.session.set(
256-
"session_status",
257-
produce((draft) => {
258-
for (const sessionID of Object.keys(draft)) {
259-
if (statuses[sessionID]) continue
260-
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
261-
}
262-
}),
263-
)
264-
for (const [sessionID, status] of Object.entries(statuses)) {
265-
input.session.set("session_status", sessionID, reconcile(status))
266-
}
250+
if (!input.session) {
251+
input.setStore("session_status", x.data!)
252+
return
267253
}
268-
if (!input.session) input.setStore("session_status", x.data!)
254+
const statuses = x.data ?? {}
255+
input.session.set(
256+
"session_status",
257+
produce((draft) => {
258+
for (const sessionID of Object.keys(draft)) {
259+
if (statuses[sessionID]) continue
260+
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
261+
}
262+
}),
263+
)
264+
for (const [sessionID, status] of Object.entries(statuses)) {
265+
input.session.set("session_status", sessionID, reconcile(status))
266+
}
267+
// Warm session info only after seeding statuses so a stalled session
268+
// fetch cannot park busy indicators behind it, mirroring how live
269+
// session.status events apply first and resolve info in the background.
270+
await Promise.all(
271+
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
272+
)
269273
}),
270274
),
271275
!seededProject &&

0 commit comments

Comments
 (0)