581 lines
26 KiB
TypeScript
581 lines
26 KiB
TypeScript
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||
import { tmpdir } from 'node:os'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { join } from 'node:path'
|
||
import type { Browser, Page } from 'playwright'
|
||
import { chromium } from 'playwright'
|
||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||
import {
|
||
SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
|
||
} from '@deepseek-ai/dsh-session'
|
||
import type {} from '@deepseek-ai/dsh-agent'
|
||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||
import {
|
||
acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden,
|
||
launchWebScaffold, watchConsole,
|
||
webSnapshotMode, type WebScaffold,
|
||
} from './scaffold.ts'
|
||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||
|
||
const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url))
|
||
const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url))
|
||
const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url))
|
||
const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url))
|
||
const BRANCHLESS_LAYOUT_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless-layout.expected.md', import.meta.url))
|
||
const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url))
|
||
const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url))
|
||
const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url))
|
||
const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url))
|
||
const MODE = webSnapshotMode()
|
||
const LABEL = 'event-sourcing researcher'
|
||
const ONE_SHOT_LABEL = 'event-sourcing reviewer'
|
||
const NESTED_LABEL = 'example editor'
|
||
const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
|
||
const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
|
||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||
const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
|
||
|
||
function childFixture(source: string, fixtureId: string, withContinuation: boolean): string {
|
||
const [header, ...eventLines] = source.trimEnd().split('\n')
|
||
if (header === undefined) throw new Error('base replay fixture has no header')
|
||
const childHeader = header
|
||
.replace('"id":"{{sessionId}}"', `"id":"${fixtureId}"`)
|
||
.replace(/"createdAt":\d+/, '"createdAt":1784998084442')
|
||
if (!withContinuation) return [childHeader, ...eventLines, ''].join('\n')
|
||
const continued = eventLines.map(line => line
|
||
.replace(/"seq":(\d+)/g, (_match, seq: string) => `"seq":${String(Number(seq) + 100)}`)
|
||
.replace(/"seq0":(\d+)/g, (_match, seq: string) => `"seq0":${String(Number(seq) + 100)}`)
|
||
.replaceAll('"turn":1', '"turn":2'))
|
||
return [childHeader, ...eventLines, ...continued, ''].join('\n')
|
||
}
|
||
|
||
async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promise<void> {
|
||
const deadline = Date.now() + 30_000
|
||
while (scaffold.ctx.agents.get(id) !== undefined) {
|
||
if (Date.now() >= deadline) throw new Error(`subagent ${id} did not settle`)
|
||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||
}
|
||
}
|
||
|
||
describe('web e2e: persisted subagent conversation and human continuation', () => {
|
||
let scaffold: WebScaffold
|
||
let browser: Browser
|
||
let page: Page
|
||
let sidecarRoot: string
|
||
let childId: SessionId
|
||
let oneShotId: SessionId
|
||
let grandchildId: SessionId
|
||
let tripwire: ReturnType<typeof watchConsole>
|
||
const apiCalls: string[] = []
|
||
|
||
beforeAll(async () => {
|
||
if (MODE === 'record') throw new Error('subagent conversation is a keyless assembled snapshot')
|
||
const baseFixture = await readFile(BASE_FIXTURE, 'utf8')
|
||
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-'))
|
||
const childFixturePath = join(sidecarRoot, 'child.jsonl')
|
||
await writeFile(childFixturePath, childFixture(baseFixture, 'recorded-subagent', true))
|
||
scaffold = await launchWebScaffold({
|
||
replayFixture: BASE_FIXTURE,
|
||
replayChildFixtures: [childFixturePath],
|
||
paceMs: 25,
|
||
})
|
||
browser = await chromium.launch()
|
||
page = await newEnglishPage(browser)
|
||
page.on('request', (request) => {
|
||
const path = new URL(request.url()).pathname
|
||
if (path.startsWith('/api/')) apiCalls.push(path)
|
||
})
|
||
tripwire = watchConsole(page)
|
||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||
|
||
const parent = scaffold.ctx.agents.roots()[0]
|
||
if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent')
|
||
const parentSettled = scaffold.whenTurnSettled()
|
||
const parentInput = page.locator('textarea:enabled').first()
|
||
await parentInput.fill(PARENT_PROMPT)
|
||
await parentInput.press('Enter')
|
||
expect(await parentSettled).toBe(parent.id)
|
||
|
||
const started = await scaffold.ctx.subagents.startContinuable({
|
||
provider: 'spawn',
|
||
label: LABEL,
|
||
signal: new AbortController().signal,
|
||
request: {
|
||
prompt: [{ type: 'text', text: INITIAL_PROMPT }],
|
||
parent,
|
||
},
|
||
})
|
||
childId = started.childId
|
||
await waitForAgentToSettle(scaffold, childId)
|
||
oneShotId = sessionId('recorded-one-shot')
|
||
const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000
|
||
const oneShotAt = Date.now() - oneShotDurationMs
|
||
await scaffold.ctx.sessionPersistence.create({
|
||
version: SESSION_FORMAT_VERSION,
|
||
id: oneShotId,
|
||
createdAt: oneShotAt,
|
||
cwd: scaffold.workspaceCwd,
|
||
parentSession: parent.id,
|
||
origin: 'subagent',
|
||
delegationDepth: 1,
|
||
})
|
||
await scaffold.ctx.sessionPersistence.append(oneShotId, [
|
||
{
|
||
type: 'turn/start',
|
||
seq: 0,
|
||
time: oneShotAt,
|
||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||
},
|
||
{
|
||
type: 'user/message',
|
||
seq: 1,
|
||
time: oneShotAt + 1,
|
||
data: {
|
||
content: [{ type: 'text', text: 'Review the event sourcing explanation.' }],
|
||
source: { kind: 'user' },
|
||
},
|
||
surfaceOp: 'append',
|
||
},
|
||
{
|
||
type: 'subagent/descriptor',
|
||
seq: 2,
|
||
time: oneShotAt + 2,
|
||
data: snapshotSubagentDescriptor({
|
||
mode: 'one-shot', provider: 'spawn', label: ONE_SHOT_LABEL,
|
||
}),
|
||
},
|
||
{
|
||
type: 'turn/end',
|
||
seq: 3,
|
||
time: oneShotAt + oneShotDurationMs,
|
||
data: { turn: 1, reason: { kind: 'completed' } },
|
||
},
|
||
] as SessionEvent[])
|
||
await scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotId)
|
||
grandchildId = sessionId('recorded-grandchild')
|
||
const authoredAt = Date.now()
|
||
await scaffold.ctx.sessionPersistence.create({
|
||
version: SESSION_FORMAT_VERSION,
|
||
id: grandchildId,
|
||
createdAt: authoredAt,
|
||
cwd: scaffold.workspaceCwd,
|
||
parentSession: childId,
|
||
origin: 'subagent',
|
||
delegationDepth: 2,
|
||
})
|
||
await scaffold.ctx.sessionPersistence.append(grandchildId, [
|
||
{
|
||
type: 'turn/start',
|
||
seq: 0,
|
||
time: authoredAt,
|
||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||
},
|
||
{
|
||
type: 'user/message',
|
||
seq: 1,
|
||
time: authoredAt + 1,
|
||
data: {
|
||
content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
|
||
source: { kind: 'user' },
|
||
},
|
||
surfaceOp: 'append',
|
||
},
|
||
{
|
||
type: 'subagent/descriptor',
|
||
seq: 2,
|
||
time: authoredAt + 2,
|
||
data: snapshotSubagentDescriptor({
|
||
mode: 'continuable', provider: 'spawn', label: NESTED_LABEL,
|
||
}),
|
||
},
|
||
{
|
||
type: 'turn/end',
|
||
seq: 3,
|
||
time: authoredAt + 3,
|
||
data: { turn: 1, reason: { kind: 'completed' } },
|
||
},
|
||
] as SessionEvent[])
|
||
await scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildId)
|
||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
|
||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||
await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([
|
||
{
|
||
kind: 'child', id: oneShotId, mode: 'one-shot',
|
||
label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false,
|
||
},
|
||
{
|
||
kind: 'child', id: childId, mode: 'continuable', label: LABEL,
|
||
activity: 'inactive', hasChildren: true,
|
||
},
|
||
])
|
||
await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([
|
||
{
|
||
kind: 'child', id: grandchildId, mode: 'continuable',
|
||
label: NESTED_LABEL, activity: 'inactive', hasChildren: false,
|
||
},
|
||
])
|
||
// These two cold fixtures were authored after the page's initial
|
||
// session.list and intentionally emitted no session-added frame. Reload
|
||
// to exercise the restart baseline that discovers their full lineage.
|
||
const warningStart = tripwire.warnings.length
|
||
await page.reload({ waitUntil: 'load' })
|
||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||
const catalogButton = page.getByRole('button', { name: /subagents/ })
|
||
await catalogButton.waitFor({ timeout: 15_000 })
|
||
await catalogButton.click()
|
||
const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
|
||
await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 })
|
||
await catalogTree.press('Escape')
|
||
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
|
||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||
}, 120_000)
|
||
|
||
afterAll(async () => {
|
||
const failures: unknown[] = []
|
||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||
if (sidecarRoot !== undefined) {
|
||
await rm(sidecarRoot, { recursive: true, force: true })
|
||
.catch((error: unknown) => failures.push(error))
|
||
}
|
||
if (failures.length === 1) throw failures[0]
|
||
if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
|
||
})
|
||
|
||
it('keeps known descendants reachable across a stale empty catalog response', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
|
||
const pattern = '**/api/subagent.list'
|
||
let firstClaimed = false
|
||
let emptyDelivered = false
|
||
let trailingRequested = false
|
||
let releaseCatalog = (): void => {}
|
||
const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
|
||
await page.route(pattern, async (route) => {
|
||
if (firstClaimed) {
|
||
const response = await route.fetch()
|
||
trailingRequested = true
|
||
await catalogHeld
|
||
await route.fulfill({ response })
|
||
return
|
||
}
|
||
firstClaimed = true
|
||
const response = await route.fetch()
|
||
const body = await response.json() as {
|
||
result: { ok: true; value: { entries: unknown[] } } | { ok: false }
|
||
}
|
||
if (body.result.ok) body.result.value.entries = []
|
||
await route.fulfill({ response, json: body })
|
||
emptyDelivered = true
|
||
})
|
||
|
||
const warningStart = tripwire.warnings.length
|
||
try {
|
||
await page.reload({ waitUntil: 'load' })
|
||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||
await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true)
|
||
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
|
||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||
|
||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||
await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true)
|
||
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
|
||
await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor()
|
||
expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2)
|
||
await compareOrRefreshGolden(
|
||
STALE_CATALOG_EXPECTED,
|
||
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
|
||
MODE,
|
||
)
|
||
releaseCatalog()
|
||
await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 })
|
||
await tree.press('Escape')
|
||
} finally {
|
||
releaseCatalog()
|
||
await page.unroute(pattern)
|
||
}
|
||
})
|
||
|
||
it('expands a persisted grandchild progressively without activating either level', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
|
||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||
expect(await page.getByRole('button', {
|
||
name: `Expand ${ONE_SHOT_LABEL} descendants`,
|
||
}).count()).toBe(0)
|
||
const oneShotRow = page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) })
|
||
expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1)
|
||
expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s')
|
||
await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
|
||
const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) })
|
||
const childLabel = await childRow.getAttribute('aria-label')
|
||
await page.waitForTimeout(1_100)
|
||
expect(await childRow.getAttribute('aria-label')).toBe(childLabel)
|
||
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
|
||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||
const snapshot = await captureStableAria(
|
||
page,
|
||
'[role="tree"][aria-label="Subagent sessions"]',
|
||
scaffold.workspaceCwd,
|
||
)
|
||
await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE)
|
||
await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape')
|
||
})
|
||
|
||
it('opens the completed child from persistence without activating it', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
|
||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||
await expect.poll(
|
||
() => page.getByText(INITIAL_PROMPT, { exact: true }).count(),
|
||
{ timeout: 15_000 },
|
||
).toBe(1)
|
||
if (scaffold.ctx.agents.get(childId) !== undefined) {
|
||
throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`)
|
||
}
|
||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||
await hierarchy.getByRole('button', { name: LABEL, disabled: true }).waitFor()
|
||
const sidebar = await captureStableAria(
|
||
page,
|
||
'[role="tree"][aria-label="Sessions"]',
|
||
scaffold.workspaceCwd,
|
||
)
|
||
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
|
||
})
|
||
|
||
it('continues through FIFO follow-up admission and receives the child mux events', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup'))
|
||
const ended = new Promise<void>((resolveEnded, reject) => {
|
||
const timer = setTimeout(() => {
|
||
off()
|
||
reject(new Error('subagent follow-up did not reach turn/end'))
|
||
}, 30_000)
|
||
const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
|
||
if (session.id !== childId || event.type !== 'turn/end') return
|
||
clearTimeout(timer)
|
||
off()
|
||
resolveEnded()
|
||
})
|
||
})
|
||
const input = page.getByRole('textbox', { name: 'Message the agent' })
|
||
await input.fill(FOLLOWUP)
|
||
await input.press('Enter')
|
||
await expect.poll(
|
||
() => scaffold.ctx.agents.get(childId)?.status,
|
||
{ timeout: 10_000 },
|
||
).toBe('running')
|
||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||
await hierarchy.getByRole('button').first().click()
|
||
const runningTrigger = page.getByRole('button', { name: '3 subagents running' })
|
||
await runningTrigger.waitFor({ timeout: 10_000 })
|
||
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
|
||
await runningTrigger.click()
|
||
await page.getByRole('treeitem', {
|
||
name: new RegExp(`${LABEL}.*running`),
|
||
}).waitFor({ timeout: 10_000 })
|
||
await ended
|
||
await page.getByRole('treeitem', {
|
||
name: new RegExp(`${LABEL}.*not running`),
|
||
}).waitFor({ timeout: 10_000 })
|
||
expect(await page.getByRole('button', { name: '3 subagents' })
|
||
.locator('[data-state="ongoing"]').count()).toBe(0)
|
||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||
await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||
expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
|
||
})
|
||
|
||
it('matches the settled addressed-conversation aria golden and stays clean', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria'))
|
||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||
await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE)
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
})
|
||
|
||
it('opens an unavailable persisted grandchild after recording the available child', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild'))
|
||
await page.getByRole('button', { name: '1 subagent' }).click()
|
||
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
|
||
const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) })
|
||
expect(await nestedRow.locator(':scope > *').count()).toBe(1)
|
||
const clickArea = nestedRow.locator(':scope > *')
|
||
const label = nestedRow.getByText(NESTED_LABEL, { exact: true })
|
||
const summary = nestedRow.getByText('continuable · not running', { exact: true })
|
||
const [treeBox, rowBox, clickAreaBox, treeStyle, rowStyle, labelStyle, summaryStyle] = await Promise.all([
|
||
tree.boundingBox(),
|
||
nestedRow.boundingBox(),
|
||
clickArea.boundingBox(),
|
||
tree.evaluate((element) => {
|
||
const style = getComputedStyle(element)
|
||
return { gap: style.gap, padding: style.padding, radius: style.borderRadius }
|
||
}),
|
||
nestedRow.evaluate((element) => {
|
||
const style = getComputedStyle(element)
|
||
return { padding: style.padding, radius: style.borderRadius }
|
||
}),
|
||
label.evaluate((element) => {
|
||
const style = getComputedStyle(element)
|
||
return { size: style.fontSize, lineHeight: style.lineHeight, weight: style.fontWeight }
|
||
}),
|
||
summary.evaluate((element) => {
|
||
const style = getComputedStyle(element)
|
||
return { size: style.fontSize, lineHeight: style.lineHeight, weight: style.fontWeight }
|
||
}),
|
||
])
|
||
expect(treeBox).not.toBeNull()
|
||
expect(rowBox).not.toBeNull()
|
||
expect(clickAreaBox).not.toBeNull()
|
||
const leftInset = Math.round(clickAreaBox!.x - treeBox!.x)
|
||
const rightInset = Math.round(
|
||
treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width,
|
||
)
|
||
const layout = {
|
||
menuWidth: Math.round(treeBox!.width),
|
||
menuPadding: treeStyle.padding,
|
||
menuGap: treeStyle.gap,
|
||
menuRadius: treeStyle.radius,
|
||
rowWidth: Math.round(rowBox!.width),
|
||
rowHeight: Math.round(rowBox!.height),
|
||
rowPadding: rowStyle.padding,
|
||
rowRadius: rowStyle.radius,
|
||
contentWidth: Math.round(clickAreaBox!.width),
|
||
contentHeight: Math.round(clickAreaBox!.height),
|
||
leftInset,
|
||
rightInset,
|
||
label: labelStyle,
|
||
summary: summaryStyle,
|
||
}
|
||
expect(layout).toEqual({
|
||
menuWidth: 360,
|
||
menuPadding: '4px',
|
||
menuGap: '4px',
|
||
menuRadius: '12px',
|
||
rowWidth: 352,
|
||
rowHeight: 54,
|
||
rowPadding: '7px 8px 7px 4px',
|
||
rowRadius: '8px',
|
||
contentWidth: 340,
|
||
contentHeight: 40,
|
||
leftInset: 8,
|
||
rightInset: 12,
|
||
label: { size: '14px', lineHeight: '20px', weight: '400' },
|
||
summary: { size: '12px', lineHeight: '18px', weight: '400' },
|
||
})
|
||
await compareOrRefreshGolden(
|
||
BRANCHLESS_LAYOUT_EXPECTED,
|
||
[
|
||
`menu: ${layout.menuWidth}px; padding ${layout.menuPadding}; gap ${layout.menuGap}; radius ${layout.menuRadius}`,
|
||
`row: ${layout.rowWidth}×${layout.rowHeight}px; padding ${layout.rowPadding}; radius ${layout.rowRadius}`,
|
||
`content: ${layout.contentWidth}×${layout.contentHeight}px; left inset ${layout.leftInset}px; right inset ${layout.rightInset}px`,
|
||
`label: ${layout.label.size}/${layout.label.lineHeight}/${layout.label.weight}`,
|
||
`summary: ${layout.summary.size}/${layout.summary.lineHeight}/${layout.summary.weight}`,
|
||
].join('\n'),
|
||
MODE,
|
||
)
|
||
await compareOrRefreshGolden(
|
||
BRANCHLESS_EXPECTED,
|
||
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
|
||
MODE,
|
||
)
|
||
await nestedRow.click()
|
||
await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
|
||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||
const crumbs = await hierarchy.getByRole('button').allTextContents()
|
||
expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])
|
||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||
await compareOrRefreshGolden(
|
||
UNAVAILABLE_GRANDCHILD_EXPECTED,
|
||
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
|
||
MODE,
|
||
)
|
||
})
|
||
|
||
it('opens a one-shot child as permanently read-only history', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-one-shot'))
|
||
const parentSession = page.getByRole('tree', { name: 'Sessions' })
|
||
.getByRole('treeitem')
|
||
.last()
|
||
await parentSession.click()
|
||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||
await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click()
|
||
await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor()
|
||
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
|
||
})
|
||
|
||
it('places an ordinary fork from a subagent beside its workspace-owning ancestor', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-fork'))
|
||
await page.getByRole('tree', { name: 'Sessions' })
|
||
.getByRole('treeitem', { name: /Ask a research subagent to/ })
|
||
.click()
|
||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||
await page.getByRole('textbox', { name: 'Message the agent' }).waitFor()
|
||
const forkResponse = page.waitForResponse(response =>
|
||
new URL(response.url()).pathname === '/api/session.fork')
|
||
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
|
||
const forkReceipt = await (await forkResponse).json() as { result: { ok: boolean } }
|
||
expect(forkReceipt.result).toMatchObject({ ok: true })
|
||
await expect.poll(
|
||
() => page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').count(),
|
||
{ timeout: 15_000 },
|
||
).toBe(3)
|
||
expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
|
||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||
expect(await hierarchy.getByRole('button').count()).toBe(1)
|
||
await compareOrRefreshGolden(
|
||
FORK_EXPECTED,
|
||
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
|
||
MODE,
|
||
)
|
||
})
|
||
|
||
it('cold-resumes the original subagent while its ordinary fork stays active', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup'))
|
||
const sessions = page.getByRole('tree', { name: 'Sessions' })
|
||
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
|
||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||
await page.locator('textarea:enabled').first().waitFor()
|
||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||
|
||
const forkResponse = page.waitForResponse(response =>
|
||
new URL(response.url()).pathname === '/api/session.fork')
|
||
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
|
||
const forkReceipt = await (await forkResponse).json() as {
|
||
result: { ok: true; value: { sessionId: string } } | { ok: false }
|
||
}
|
||
expect(forkReceipt.result).toMatchObject({ ok: true })
|
||
if (!forkReceipt.result.ok) return
|
||
const forkId = sessionId(forkReceipt.result.value.sessionId)
|
||
await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
|
||
|
||
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
|
||
await page.getByRole('button', { name: '3 subagents' }).click()
|
||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||
const input = page.locator('textarea:enabled').first()
|
||
await input.waitFor()
|
||
const promptResponse = page.waitForResponse(response =>
|
||
new URL(response.url()).pathname === '/api/subagent.prompt')
|
||
await input.fill(POST_FORK_FOLLOWUP)
|
||
await input.press('Enter')
|
||
const promptReceipt = await (await promptResponse).json() as {
|
||
result: { ok: true } | { ok: false; error: { code: string; message: string } }
|
||
}
|
||
if (!promptReceipt.result.ok) {
|
||
throw new Error(`post-fork follow-up rejected: ${JSON.stringify(promptReceipt.result.error)}`)
|
||
}
|
||
await expect.poll(async () => {
|
||
const loaded = await scaffold.ctx.sessionPersistence.load(childId)
|
||
const messageIndex = loaded.events.findIndex(event => event.type === 'user/message'
|
||
&& event.data.content.some(block => block.type === 'text' && block.text === POST_FORK_FOLLOWUP))
|
||
return messageIndex >= 0 && loaded.events.slice(messageIndex + 1).some(event => event.type === 'turn/end')
|
||
}, { timeout: 30_000 }).toBe(true)
|
||
expect(scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
|
||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||
})
|
||
})
|