Parcourir la source

fix(app): seed session status before warming session info (#34864)

Luke Parker il y a 1 mois
Parent
commit
fbb95a6ee3

+ 99 - 34
packages/app/src/context/global-sync/bootstrap.test.ts

@@ -1,50 +1,55 @@
 import { describe, expect, test } from "bun:test"
 import { createStore } from "solid-js/store"
 import { QueryClient } from "@tanstack/solid-query"
-import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
+import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
 import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
 import type { State, VcsCache } from "./types"
+import { createServerSession } from "../server-session"
 import { ServerScope } from "@/utils/server-scope"
 
 const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
 
+function directoryState() {
+  return createStore<State>({
+    status: "loading",
+    agent: [],
+    command: [],
+    reference: [],
+    project: "",
+    projectMeta: undefined,
+    icon: undefined,
+    provider_ready: true,
+    provider,
+    config: {},
+    path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
+    session: [],
+    sessionTotal: 0,
+    session_status: {},
+    session_working(id: string) {
+      return this.session_status[id]?.type !== "idle"
+    },
+    session_diff: {},
+    todo: {},
+    permission: {},
+    question: {},
+    mcp_ready: true,
+    mcp: {},
+    mcp_resource: {},
+    lsp_ready: true,
+    lsp: [],
+    vcs: undefined,
+    limit: 5,
+    message: {},
+    part: {},
+    part_text_accum_delta: {},
+  })
+}
+
 describe("bootstrapDirectory", () => {
   test("marks a loading directory partial during bootstrap and complete after success", async () => {
     const mcpReads: string[] = []
-    const [store, setStore] = createStore<State>({
-      status: "loading",
-      agent: [],
-      command: [],
-      reference: [],
-      project: "",
-      projectMeta: undefined,
-      icon: undefined,
-      provider_ready: true,
-      provider,
-      config: {},
-      path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
-      session: [],
-      sessionTotal: 0,
-      session_status: {},
-      session_working(id: string) {
-        return this.session_status[id]?.type !== "idle"
-      },
-      session_diff: {},
-      todo: {},
-      permission: {},
-      question: {},
-      mcp_ready: true,
-      mcp: {},
-      mcp_resource: {},
-      lsp_ready: true,
-      lsp: [],
-      vcs: undefined,
-      limit: 5,
-      message: {},
-      part: {},
-      part_text_accum_delta: {},
-    })
+    const [store, setStore] = directoryState()
 
     await bootstrapDirectory({
       directory: "/project",
@@ -93,6 +98,66 @@ describe("bootstrapDirectory", () => {
     expect(store.status).toBe("complete")
     expect(mcpReads).toEqual([])
   })
+
+  test("seeds session status even while warming session info stalls", async () => {
+    const [store, setStore] = directoryState()
+    const stalled = Promise.withResolvers<never>()
+    const client = {
+      app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
+      config: { get: async () => ({ data: {} }) },
+      session: {
+        status: async () => ({ data: { ses_busy: { type: "busy" } } }),
+        get: () => stalled.promise,
+      },
+      vcs: { get: async () => ({ data: undefined }) },
+      command: { list: async () => ({ data: [] }) },
+      permission: { list: async () => ({ data: [] }) },
+      question: { list: async () => ({ data: [] }) },
+      v2: { reference: { list: async () => ({ data: { data: [] } }) } },
+      mcp: { status: async () => ({ data: {} }) },
+      provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
+    } as unknown as OpencodeClient
+    const session = createServerSession(client)
+    const stale: Session = {
+      id: "ses_stale",
+      slug: "ses_stale",
+      projectID: "project",
+      directory: "/project",
+      title: "stale",
+      version: "1",
+      time: { created: 1, updated: 1 },
+    }
+    session.remember(stale)
+    session.set("session_status", stale.id, { type: "busy" })
+
+    await bootstrapDirectory({
+      directory: "/project",
+      scope: ServerScope.local,
+      mcp: false,
+      global: {
+        config: {} satisfies Config,
+        path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
+        project: [{ id: "project", worktree: "/project" } as Project],
+        provider,
+      },
+      sdk: client,
+      store,
+      setStore,
+      vcsCache: { setStore() {} } as unknown as VcsCache,
+      loadSessions() {},
+      translate: (key) => key,
+      queryClient: new QueryClient(),
+      session,
+    })
+
+    const deadline = Date.now() + 500
+    while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
+      await new Promise((resolve) => setTimeout(resolve, 10))
+    }
+
+    expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
+    expect(session.data.session_status[stale.id]).toBeUndefined()
+  })
 })
 
 describe("query keys", () => {

+ 22 - 18
packages/app/src/context/global-sync/bootstrap.ts

@@ -247,25 +247,29 @@ export async function bootstrapDirectory(input: {
       () =>
         retry(() =>
           input.sdk.session.status().then(async (x) => {
-            if (input.session) {
-              const statuses = x.data ?? {}
-              await Promise.all(
-                Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
-              )
-              input.session.set(
-                "session_status",
-                produce((draft) => {
-                  for (const sessionID of Object.keys(draft)) {
-                    if (statuses[sessionID]) continue
-                    if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
-                  }
-                }),
-              )
-              for (const [sessionID, status] of Object.entries(statuses)) {
-                input.session.set("session_status", sessionID, reconcile(status))
-              }
+            if (!input.session) {
+              input.setStore("session_status", x.data!)
+              return
             }
-            if (!input.session) input.setStore("session_status", x.data!)
+            const statuses = x.data ?? {}
+            input.session.set(
+              "session_status",
+              produce((draft) => {
+                for (const sessionID of Object.keys(draft)) {
+                  if (statuses[sessionID]) continue
+                  if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
+                }
+              }),
+            )
+            for (const [sessionID, status] of Object.entries(statuses)) {
+              input.session.set("session_status", sessionID, reconcile(status))
+            }
+            // Warm session info only after seeding statuses so a stalled session
+            // fetch cannot park busy indicators behind it, mirroring how live
+            // session.status events apply first and resolve info in the background.
+            await Promise.all(
+              Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
+            )
           }),
         ),
       !seededProject &&