Просмотр исходного кода

fix(app): resolve target session lineage outside router transition (#34838)

Luke Parker 2 месяцев назад
Родитель
Сommit
39dfbb53d0

+ 203 - 0
packages/app/e2e/regression/subagent-child-navigation.spec.ts

@@ -0,0 +1,203 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { expect, test, type Page } from "@playwright/test"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectSessionTitle } from "../utils/waits"
+
+const directory = "C:/OpenCode/SubagentNavigation"
+const projectID = "proj_subagent_navigation"
+const parentID = "ses_subagent_parent"
+const childID = "ses_subagent_child"
+const parentTitle = "Parent session"
+const childTitle = "Subagent child session"
+// Child session pages derive their heading from the task part that spawned them.
+const taskDescription = "Inspect child navigation"
+
+type EventPayload = { directory: string; payload: Record<string, unknown> }
+
+test.use({ viewport: { width: 1440, height: 900 } })
+
+test("navigates to a subagent child session missing from the session list", async ({ page }) => {
+  await setup(page)
+  await openChildFromParent(page)
+
+  await expectSessionTitle(page, taskDescription)
+  await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0)
+
+  const titlebarRight = page.locator("#opencode-titlebar-right")
+  await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
+})
+
+test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
+  const events: EventPayload[] = []
+  await setup(page, () => events.splice(0, 1))
+  await openChildFromParent(page)
+  await expectSessionTitle(page, taskDescription)
+
+  events.push({
+    directory,
+    payload: { type: "session.deleted", properties: { info: childSession() } },
+  })
+
+  await expect(page.getByText("This session cannot be found")).toBeVisible()
+  await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
+  await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
+})
+
+async function setup(page: Page, events?: () => EventPayload[]) {
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: projectID,
+      worktree: directory,
+      vcs: "git",
+      name: "subagent-navigation",
+      time: { created: 1700000000000, updated: 1700000000000 },
+      sandboxes: [],
+    },
+    provider: {
+      all: [
+        {
+          id: "opencode",
+          name: "OpenCode",
+          models: {
+            "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } },
+          },
+        },
+      ],
+      connected: ["opencode"],
+      default: { providerID: "opencode", modelID: "claude-opus-4-6" },
+    },
+    sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
+    pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
+    events,
+    eventRetry: events ? 16 : undefined,
+  })
+  // The child session resolves via /session/:id but is absent from the /session list,
+  // matching a subagent session that has not been loaded into the list cache yet.
+  await page.route(
+    (url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
+    (route) =>
+      route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        headers: { "access-control-allow-origin": "*" },
+        body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
+      }),
+  )
+  await configurePage(page)
+}
+
+async function openChildFromParent(page: Page) {
+  await page.goto(sessionHref(parentID))
+  await expectSessionTitle(page, parentTitle)
+
+  const card = page.locator(`a[href="${sessionHref(childID)}"]`)
+  await expect(card).toBeVisible()
+  await card.click()
+
+  await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 })
+}
+
+function session(id: string, title: string, created: number, extra?: Record<string, unknown>) {
+  return {
+    id,
+    slug: id,
+    projectID,
+    directory,
+    title,
+    version: "dev",
+    time: { created, updated: created },
+    ...extra,
+  }
+}
+
+function childSession() {
+  return session(childID, childTitle, 1700000001000, { parentID })
+}
+
+function parentMessages() {
+  const userID = "msg_user_0001"
+  const assistantID = "msg_assistant_0001"
+  return [
+    {
+      info: {
+        id: userID,
+        sessionID: parentID,
+        role: "user",
+        time: { created: 1700000000000 },
+        agent: "build",
+        model: { providerID: "opencode", modelID: "claude-opus-4-6" },
+      },
+      parts: [
+        {
+          id: "prt_user_text_0001",
+          sessionID: parentID,
+          messageID: userID,
+          type: "text",
+          text: "Delegate work to a subagent",
+        },
+      ],
+    },
+    {
+      info: {
+        id: assistantID,
+        sessionID: parentID,
+        role: "assistant",
+        time: { created: 1700000001000, completed: 1700000002000 },
+        parentID: userID,
+        modelID: "claude-opus-4-6",
+        providerID: "opencode",
+        mode: "build",
+        agent: "build",
+        path: { cwd: directory, root: directory },
+        cost: 0.01,
+        tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
+        finish: "stop",
+      },
+      parts: [
+        {
+          id: "prt_tool_task_0001",
+          sessionID: parentID,
+          messageID: assistantID,
+          type: "tool",
+          callID: "call_task_0001",
+          tool: "task",
+          state: {
+            status: "completed",
+            input: { description: taskDescription, subagent_type: "explore" },
+            output: "Subagent finished",
+            title: taskDescription,
+            metadata: { sessionId: childID },
+            time: { start: 1700000001000, end: 1700000002000 },
+          },
+        },
+      ],
+    },
+  ]
+}
+
+async function configurePage(page: Page) {
+  const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+  await page.addInitScript(
+    ({ directory, server, sessionId }) => {
+      localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+      localStorage.setItem(
+        "opencode.global.dat:server",
+        JSON.stringify({
+          projects: { local: [{ worktree: directory, expanded: true }] },
+          lastProject: { local: directory },
+        }),
+      )
+      localStorage.setItem(
+        "opencode.window.browser.dat:tabs",
+        JSON.stringify([{ type: "session", server, sessionId }]),
+      )
+    },
+    { directory, server, sessionId: parentID },
+  )
+}
+
+function sessionHref(sessionID: string) {
+  const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+  return `/server/${base64Encode(server)}/session/${sessionID}`
+}

+ 39 - 12
packages/app/src/pages/session.tsx

@@ -11,7 +11,7 @@ import {
   createMemo,
   createEffect,
   createComputed,
-  createResource,
+  createSignal,
   on,
   onMount,
   type ParentProps,
@@ -86,7 +86,7 @@ import { diffs as list } from "@/utils/diffs"
 import { Persist, persisted } from "@/utils/persist"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { formatServerError, isSessionNotFoundError } from "@/utils/server-errors"
-import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "@/utils/session-route"
+import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
 import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
 import { createSessionOwnership } from "./session/session-ownership"
 
@@ -224,17 +224,8 @@ function ResolvedTargetSessionRoute() {
   const params = useParams<{ serverKey: string; id: string }>()
   const settings = useSettings()
   const tabs = useTabs()
-  const sync = useServerSync()
   const serverKey = createMemo(() => requireServerKey(params.serverKey))
-  const cached = createMemo(() => sync().session.lineage.peek(params.id))
-  const [resolved] = createResource(
-    () => {
-      if (cached()) return
-      return { id: params.id, sync: sync() }
-    },
-    ({ id, sync }) => sync.session.lineage.resolve(id),
-  )
-  const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
+  const current = createSessionLineage(() => params.id)
   const directory = createMemo(() => current()?.session.directory)
   const targetDirectory = () => directory()!
 
@@ -265,6 +256,42 @@ function ResolvedTargetSessionRoute() {
   )
 }
 
+// Reactive session lineage for the target session route, read from the sync store.
+// The route keys its consumer to the session ID, so resolution runs once per target.
+// Resolution is imperative rather than a resource on purpose: a resource created here
+// would be created inside the router's navigation transition, and suspending that
+// transition deadlocks the URL commit and double-mounts the session header portals
+// from the transition's shadow render. `lineage.resolve` fills the sync store, which
+// the returned accessor observes; resolve failures rethrow on read so the enclosing
+// SessionRouteErrorBoundary renders the scoped session error.
+function createSessionLineage(sessionID: () => string) {
+  const sync = useServerSync()
+  const cached = createMemo(() => sync().session.lineage.peek(sessionID()))
+  const [failure, setFailure] = createSignal<unknown>()
+  const [settled, setSettled] = createSignal(false)
+  onMount(() => {
+    if (cached()) {
+      setSettled(true)
+      return
+    }
+    sync()
+      .session.lineage.resolve(sessionID())
+      .then(() => setSettled(true))
+      .catch((error) => setFailure(() => error))
+  })
+  return createMemo(() => {
+    const error = failure()
+    if (error) throw error
+    const lineage = cached()
+    // The viewed session is pinned and pinned lineages are exempt from cache pruning,
+    // so a lineage missing after settlement means the session (or an ancestor) was
+    // deleted, possibly by another client. Match the resolve error so the boundary
+    // shows the session not found fallback.
+    if (!lineage && settled()) throw new Error(`Session not found: ${sessionID()}`)
+    return lineage
+  })
+}
+
 function TargetSessionPage() {
   const sdk = useSDK()
   const serverSDK = useServerSDK()

+ 1 - 14
packages/app/src/utils/session-route.test.ts

@@ -1,13 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { ServerConnection } from "@/context/server"
-import {
-  legacySessionHref,
-  legacySessionServer,
-  requireServerKey,
-  rootSession,
-  selectSessionLineage,
-  sessionHref,
-} from "./session-route"
+import { legacySessionHref, legacySessionServer, requireServerKey, rootSession, sessionHref } from "./session-route"
 
 describe("session routes", () => {
   test("uses the unique persisted server for a legacy session route", () => {
@@ -75,10 +68,4 @@ describe("session routes", () => {
 
     expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child")
   })
-
-  test("ignores a resolved lineage retained from the previous route", () => {
-    const previous = { session: { id: "A" }, root: { id: "A" } }
-
-    expect(selectSessionLineage("B", undefined, previous)).toBeUndefined()
-  })
 })

+ 0 - 9
packages/app/src/utils/session-route.ts

@@ -27,15 +27,6 @@ export function legacySessionServer(
 
 type SessionParent = { id: string; parentID?: string }
 
-export function selectSessionLineage<T extends { session: { id: string } }>(
-  sessionID: string,
-  cached: T | undefined,
-  resolved: T | undefined,
-) {
-  if (cached?.session.id === sessionID) return cached
-  if (resolved?.session.id === sessionID) return resolved
-}
-
 export async function rootSession<T extends SessionParent>(session: T, get: (sessionID: string) => Promise<T>) {
   const seen = new Set([session.id])
   let current = session