Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions packages/app/e2e/regression/subagent-child-navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
51 changes: 39 additions & 12 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
createMemo,
createEffect,
createComputed,
createResource,
createSignal,
on,
onMount,
type ParentProps,
Expand Down Expand Up @@ -85,7 +85,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"

Expand Down Expand Up @@ -223,17 +223,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()!

Expand Down Expand Up @@ -264,6 +255,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()
Expand Down
15 changes: 1 addition & 14 deletions packages/app/src/utils/session-route.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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()
})
})
9 changes: 0 additions & 9 deletions packages/app/src/utils/session-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading