subagent-conversation.e2e.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { fileURLToPath } from 'node:url'
  4. import { join } from 'node:path'
  5. import type { Browser, Page } from 'playwright'
  6. import { chromium } from 'playwright'
  7. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  8. import {
  9. SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionHeader, type SessionId,
  10. } from '@deepseek-ai/dsh-session'
  11. import type {} from '@deepseek-ai/dsh-agent'
  12. import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
  13. import {
  14. acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria,
  15. compareOrRefreshGolden,
  16. launchWebScaffold, watchConsole,
  17. webSnapshotMode, type WebScaffold,
  18. } from './scaffold.ts'
  19. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  20. const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.jsonl', import.meta.url))
  21. const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url))
  22. const AVAILABLE_CHILD_EXPANDED_EXPECTED = fileURLToPath(
  23. new URL('../../../snapshots/web/subagent-conversation/ui-expanded.expected.md', import.meta.url),
  24. )
  25. const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/tree.expected.md', import.meta.url))
  26. const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url))
  27. const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/stale-catalog.expected.md', import.meta.url))
  28. const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar.expected.md', import.meta.url))
  29. const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url))
  30. const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url))
  31. const MODE = webSnapshotMode()
  32. const LABEL = 'event-sourcing researcher'
  33. const ONE_SHOT_LABEL = 'event-sourcing reviewer'
  34. const NESTED_LABEL = 'example editor'
  35. const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
  36. const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
  37. /** The grandchild's own first message; its arrival is what says its history finished loading. */
  38. const NESTED_PROMPT = 'Give one concrete event sourcing example.'
  39. const FOLLOWUP = 'Now give the same explanation to a human reader.'
  40. const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
  41. function childFixture(source: string, fixtureId: string, withContinuation: boolean): string {
  42. const [header, ...eventLines] = source.trimEnd().split('\n')
  43. if (header === undefined) throw new Error('base replay fixture has no header')
  44. const childHeader = header
  45. .replace('"id":"{{sessionId}}"', `"id":"${fixtureId}"`)
  46. .replace(/"createdAt":\d+/, '"createdAt":1784998084442')
  47. if (!withContinuation) return [childHeader, ...eventLines, ''].join('\n')
  48. const continued = eventLines.map(line => line
  49. .replace(/"seq":(\d+)/g, (_match, seq: string) => `"seq":${String(Number(seq) + 100)}`)
  50. .replace(/"seq0":(\d+)/g, (_match, seq: string) => `"seq0":${String(Number(seq) + 100)}`)
  51. .replaceAll('"turn":1', '"turn":2'))
  52. return [childHeader, ...eventLines, ...continued, ''].join('\n')
  53. }
  54. async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promise<void> {
  55. const deadline = Date.now() + 30_000
  56. while (scaffold.ctx.agents.get(id) !== undefined) {
  57. if (Date.now() >= deadline) throw new Error(`subagent ${id} did not settle`)
  58. await new Promise<void>(resolve => setTimeout(resolve, 10))
  59. }
  60. }
  61. /** Poll until the cold-read write-back of {@link coldSnapshot} lands a visible row. */
  62. async function waitForCacheRow(
  63. scaffold: WebScaffold,
  64. header: SessionHeader,
  65. ): Promise<void> {
  66. const deadline = Date.now() + 10_000
  67. while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header) === undefined) {
  68. if (Date.now() >= deadline) throw new Error(`cache row for "${header.id}" did not land`)
  69. await new Promise<void>(resolve => setTimeout(resolve, 10))
  70. }
  71. }
  72. describe('web e2e: persisted subagent conversation and human continuation', () => {
  73. let scaffold: WebScaffold
  74. let browser: Browser
  75. let page: Page
  76. let sidecarRoot: string
  77. let childId: SessionId
  78. let oneShotId: SessionId
  79. let grandchildId: SessionId
  80. let tripwire: ReturnType<typeof watchConsole>
  81. const apiCalls: string[] = []
  82. beforeAll(async () => {
  83. if (MODE === 'record') throw new Error('subagent conversation is a keyless assembled snapshot')
  84. const baseFixture = await readFile(BASE_FIXTURE, 'utf8')
  85. sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-'))
  86. const childFixturePath = join(sidecarRoot, 'child.jsonl')
  87. await writeFile(childFixturePath, childFixture(baseFixture, 'recorded-subagent', true))
  88. scaffold = await launchWebScaffold({
  89. replayFixture: BASE_FIXTURE,
  90. compareReplaySession: false,
  91. replayChildFixtures: [childFixturePath],
  92. paceMs: 25,
  93. })
  94. browser = await chromium.launch()
  95. page = await newEnglishPage(browser)
  96. page.on('request', (request) => {
  97. const path = new URL(request.url()).pathname
  98. if (path.startsWith('/api/')) apiCalls.push(path)
  99. })
  100. tripwire = watchConsole(page)
  101. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  102. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  103. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  104. const parent = scaffold.ctx.agents.roots()[0]
  105. if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent')
  106. const parentSettled = scaffold.whenTurnSettled()
  107. const parentInput = page.locator('[data-composer-input][contenteditable="true"]').first()
  108. await parentInput.fill(PARENT_PROMPT)
  109. await parentInput.press('Enter')
  110. expect(await parentSettled).toBe(parent.id)
  111. const started = await scaffold.ctx.subagents.startContinuable({
  112. provider: 'spawn',
  113. label: LABEL,
  114. signal: new AbortController().signal,
  115. request: {
  116. prompt: [{ type: 'text', text: INITIAL_PROMPT }],
  117. parent,
  118. },
  119. })
  120. childId = started.childId
  121. await waitForAgentToSettle(scaffold, childId)
  122. oneShotId = sessionId('recorded-one-shot')
  123. const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000
  124. const oneShotAt = Date.now() - oneShotDurationMs
  125. const oneShotHeader: SessionHeader = {
  126. version: SESSION_FORMAT_VERSION,
  127. id: oneShotId,
  128. createdAt: oneShotAt,
  129. cwd: scaffold.workspaceCwd,
  130. parentSession: parent.id,
  131. origin: 'subagent',
  132. delegationDepth: 1,
  133. }
  134. await scaffold.ctx.sessionPersistence.create(oneShotHeader)
  135. const oneShotEvents = [
  136. {
  137. type: 'turn/start',
  138. seq: 0,
  139. time: oneShotAt,
  140. data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  141. },
  142. {
  143. type: 'user/message',
  144. seq: 1,
  145. time: oneShotAt + 1,
  146. data: {
  147. content: [{ type: 'text', text: 'Review the event sourcing explanation.' }],
  148. source: { kind: 'user' },
  149. },
  150. surfaceOp: 'append',
  151. },
  152. {
  153. type: 'subagent/descriptor',
  154. seq: 2,
  155. time: oneShotAt + 2,
  156. data: snapshotSubagentDescriptor({
  157. mode: 'one-shot', provider: 'spawn', label: ONE_SHOT_LABEL,
  158. }),
  159. },
  160. {
  161. type: 'turn/end',
  162. seq: 3,
  163. time: oneShotAt + oneShotDurationMs,
  164. data: { turn: 1, reason: { kind: 'completed' } },
  165. },
  166. ] as SessionEvent[]
  167. await scaffold.ctx.sessionPersistence.append(oneShotId, oneShotEvents)
  168. scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotHeader, oneShotEvents)
  169. await waitForCacheRow(scaffold, oneShotHeader)
  170. grandchildId = sessionId('recorded-grandchild')
  171. const authoredAt = Date.now()
  172. const grandchildHeader: SessionHeader = {
  173. version: SESSION_FORMAT_VERSION,
  174. id: grandchildId,
  175. createdAt: authoredAt,
  176. cwd: scaffold.workspaceCwd,
  177. parentSession: childId,
  178. origin: 'subagent',
  179. delegationDepth: 2,
  180. }
  181. await scaffold.ctx.sessionPersistence.create(grandchildHeader)
  182. const grandchildEvents = [
  183. {
  184. type: 'turn/start',
  185. seq: 0,
  186. time: authoredAt,
  187. data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  188. },
  189. {
  190. type: 'user/message',
  191. seq: 1,
  192. time: authoredAt + 1,
  193. data: {
  194. content: [{ type: 'text', text: NESTED_PROMPT }],
  195. source: { kind: 'user' },
  196. },
  197. surfaceOp: 'append',
  198. },
  199. {
  200. type: 'subagent/descriptor',
  201. seq: 2,
  202. time: authoredAt + 2,
  203. data: snapshotSubagentDescriptor({
  204. mode: 'continuable', provider: 'spawn', label: NESTED_LABEL,
  205. }),
  206. },
  207. {
  208. type: 'turn/end',
  209. seq: 3,
  210. time: authoredAt + 3,
  211. data: { turn: 1, reason: { kind: 'completed' } },
  212. },
  213. ] as SessionEvent[]
  214. await scaffold.ctx.sessionPersistence.append(grandchildId, grandchildEvents)
  215. scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildHeader, grandchildEvents)
  216. await waitForCacheRow(scaffold, grandchildHeader)
  217. expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
  218. expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
  219. expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
  220. await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([
  221. {
  222. kind: 'child', id: oneShotId, mode: 'one-shot',
  223. label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false,
  224. },
  225. {
  226. kind: 'child', id: childId, mode: 'continuable', label: LABEL,
  227. activity: 'inactive', hasChildren: true,
  228. },
  229. ])
  230. await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([
  231. {
  232. kind: 'child', id: grandchildId, mode: 'continuable',
  233. label: NESTED_LABEL, activity: 'inactive', hasChildren: false,
  234. },
  235. ])
  236. // These two cold fixtures were authored after the page's initial
  237. // session.list and intentionally emitted no api-session/added event. Reload
  238. // to exercise the restart baseline that discovers their full lineage.
  239. const warningStart = tripwire.warnings.length
  240. await page.reload({ waitUntil: 'load' })
  241. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  242. const catalogButton = page.getByRole('button', { name: '3 subagents', exact: true })
  243. await catalogButton.waitFor({ timeout: 15_000 })
  244. await catalogButton.hover()
  245. const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
  246. await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 })
  247. await catalogTree.press('Escape')
  248. await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
  249. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  250. }, 120_000)
  251. afterAll(async () => {
  252. const failures: unknown[] = []
  253. await browser?.close().catch((error: unknown) => failures.push(error))
  254. await scaffold?.close().catch((error: unknown) => failures.push(error))
  255. if (sidecarRoot !== undefined) {
  256. await rm(sidecarRoot, { recursive: true, force: true })
  257. .catch((error: unknown) => failures.push(error))
  258. }
  259. if (failures.length === 1) throw failures[0]
  260. if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
  261. })
  262. it('keeps known descendants reachable across a stale empty catalog response', async () => {
  263. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
  264. const pattern = '**/api/subagents/list'
  265. let firstClaimed = false
  266. let emptyDelivered = false
  267. let trailingRequested = false
  268. let releaseCatalog = (): void => {}
  269. const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
  270. await page.route(pattern, async (route) => {
  271. if (firstClaimed) {
  272. const response = await route.fetch()
  273. trailingRequested = true
  274. await catalogHeld
  275. await route.fulfill({ response })
  276. return
  277. }
  278. firstClaimed = true
  279. const response = await route.fetch()
  280. const body = await response.json() as {
  281. result: { ok: true; value: { entries: unknown[] } } | { ok: false }
  282. }
  283. if (body.result.ok) body.result.value.entries = []
  284. await route.fulfill({ response, json: body })
  285. emptyDelivered = true
  286. })
  287. const warningStart = tripwire.warnings.length
  288. try {
  289. await page.reload({ waitUntil: 'load' })
  290. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  291. await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true)
  292. await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
  293. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  294. await page.getByRole('button', { name: '3 subagents' }).hover()
  295. await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true)
  296. const tree = page.getByRole('tree', { name: 'Subagent sessions' })
  297. await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor()
  298. expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2)
  299. await compareOrRefreshGolden(
  300. STALE_CATALOG_EXPECTED,
  301. await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
  302. MODE,
  303. )
  304. releaseCatalog()
  305. await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 })
  306. await tree.press('Escape')
  307. } finally {
  308. releaseCatalog()
  309. await page.unroute(pattern)
  310. }
  311. })
  312. it('expands a persisted grandchild progressively without activating either level', async () => {
  313. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
  314. await page.getByRole('button', { name: '3 subagents' }).hover()
  315. const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
  316. expect(await catalogTree.evaluate((element) => {
  317. const rect = element.getBoundingClientRect()
  318. const hit = document.elementFromPoint(rect.left + 8, rect.top + 8)
  319. return hit !== null && element.contains(hit)
  320. })).toBe(true)
  321. expect(await page.getByRole('button', {
  322. name: `Expand ${ONE_SHOT_LABEL} descendants`,
  323. }).count()).toBe(0)
  324. const oneShotRow = page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) })
  325. expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1)
  326. expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s')
  327. await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
  328. const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) })
  329. const childLabel = await childRow.getAttribute('aria-label')
  330. await page.waitForTimeout(1_100)
  331. expect(await childRow.getAttribute('aria-label')).toBe(childLabel)
  332. await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
  333. expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
  334. expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
  335. const snapshot = await captureStableAria(
  336. page,
  337. '[role="tree"][aria-label="Subagent sessions"]',
  338. scaffold.workspaceCwd,
  339. )
  340. await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE)
  341. await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape')
  342. })
  343. it('opens the completed child from persistence without activating it', async () => {
  344. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
  345. await page.getByRole('button', { name: '3 subagents' }).hover()
  346. await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
  347. await expect.poll(
  348. () => page.getByText(INITIAL_PROMPT, { exact: true }).count(),
  349. { timeout: 15_000 },
  350. ).toBe(1)
  351. if (scaffold.ctx.agents.get(childId) !== undefined) {
  352. throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`)
  353. }
  354. const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
  355. await hierarchy.getByRole('button', { name: `Switch subagent: ${LABEL}` }).waitFor()
  356. const sidebar = await captureStableAria(
  357. page,
  358. '[role="tree"][aria-label="Sessions"]',
  359. scaffold.workspaceCwd,
  360. )
  361. await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
  362. })
  363. it('keeps a restored child neutral until its parent availability arrives', async () => {
  364. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-restore'))
  365. const pattern = '**/api/subagents/list'
  366. let requested = false
  367. let releaseCatalog = (): void => {}
  368. const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
  369. await page.route(pattern, async (route) => {
  370. const response = await route.fetch()
  371. requested = true
  372. await catalogHeld
  373. await route.fulfill({ response })
  374. })
  375. const warningStart = tripwire.warnings.length
  376. try {
  377. await page.reload({ waitUntil: 'load' })
  378. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  379. await expect.poll(() => requested, { timeout: 15_000 }).toBe(true)
  380. expect(await page.getByText('This subagent is read-only for now', { exact: true }).count()).toBe(0)
  381. expect(await page.locator('[data-composer-seat]').evaluate(element =>
  382. getComputedStyle(element).visibility)).toBe('hidden')
  383. releaseCatalog()
  384. const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
  385. await input.waitFor({ timeout: 15_000 })
  386. await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
  387. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  388. } finally {
  389. releaseCatalog()
  390. await page.unroute(pattern)
  391. }
  392. })
  393. it('continues through FIFO follow-up admission and receives the child follow events', async () => {
  394. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup'))
  395. const ended = new Promise<void>((resolveEnded, reject) => {
  396. const timer = setTimeout(() => {
  397. off()
  398. reject(new Error('subagent follow-up did not reach turn/end'))
  399. }, 30_000)
  400. const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
  401. if (session.id !== childId || event.type !== 'turn/end') return
  402. clearTimeout(timer)
  403. off()
  404. resolveEnded()
  405. })
  406. })
  407. const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
  408. await input.fill(FOLLOWUP)
  409. await input.press('Enter')
  410. await expect.poll(
  411. () => scaffold.ctx.agents.get(childId)?.status,
  412. { timeout: 10_000 },
  413. ).toBe('running')
  414. await ended
  415. await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  416. await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
  417. expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
  418. })
  419. it('matches the settled addressed-conversation aria golden and stays clean', async () => {
  420. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria'))
  421. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  422. await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE)
  423. const expanded = await captureExpandedTurnProcessAria(
  424. page,
  425. '[class*="centerCol"]',
  426. scaffold.workspaceCwd,
  427. )
  428. await compareOrRefreshGolden(AVAILABLE_CHILD_EXPANDED_EXPECTED, expanded, MODE)
  429. expect(tripwire.pageErrors).toEqual([])
  430. expect(tripwire.warnings).toEqual([])
  431. })
  432. it('opens an unavailable persisted grandchild after recording the available child', async () => {
  433. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild'))
  434. await page.getByRole('button', { name: '1 subagent' }).hover()
  435. const tree = page.getByRole('tree', { name: 'Subagent sessions' })
  436. const nestedRow = tree.locator('[role="treeitem"]').filter({ hasText: NESTED_LABEL })
  437. await nestedRow.waitFor({ timeout: 15_000 })
  438. const clickArea = nestedRow.locator(':scope > div')
  439. expect(await clickArea.count()).toBe(1)
  440. const [treeBox, clickAreaBox] = await Promise.all([
  441. tree.boundingBox(),
  442. clickArea.boundingBox(),
  443. ])
  444. expect(treeBox).not.toBeNull()
  445. expect(clickAreaBox).not.toBeNull()
  446. expect([
  447. Math.round(clickAreaBox!.x - treeBox!.x),
  448. Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
  449. // Menu padding alone insets the rows now that the border is gone.
  450. ]).toEqual([4, 4])
  451. await compareOrRefreshGolden(
  452. BRANCHLESS_EXPECTED,
  453. await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
  454. MODE,
  455. )
  456. await nestedRow.click()
  457. await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
  458. // The offline banner renders from the descriptor alone, so it says nothing
  459. // about the transcript below it. The golden pins that transcript, and
  460. // `captureStableAria` calls two identical polls stable — including two of
  461. // "Loading history…". Wait for the message the golden asserts.
  462. await page.getByText(NESTED_PROMPT).waitFor()
  463. const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
  464. const crumbs = await hierarchy.getByRole('button').allTextContents()
  465. expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])
  466. expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
  467. expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
  468. await compareOrRefreshGolden(
  469. UNAVAILABLE_GRANDCHILD_EXPECTED,
  470. await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
  471. MODE,
  472. )
  473. })
  474. it('opens a one-shot child as permanently read-only history', async () => {
  475. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-one-shot'))
  476. const parentSession = page.getByRole('tree', { name: 'Sessions' })
  477. .getByRole('treeitem')
  478. .last()
  479. await parentSession.click()
  480. await page.getByRole('button', { name: '3 subagents' }).hover()
  481. await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click()
  482. await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor()
  483. expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
  484. })
  485. it('places an ordinary fork from a subagent beside its workspace-owning ancestor', async () => {
  486. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-fork'))
  487. await page.getByRole('tree', { name: 'Sessions' })
  488. .getByRole('treeitem', { name: /Ask a research subagent to/ })
  489. .click()
  490. await page.getByRole('button', { name: '3 subagents' }).hover()
  491. await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
  492. await page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }).waitFor()
  493. const forkResponse = page.waitForResponse(response =>
  494. new URL(response.url()).pathname === '/api/session/fork')
  495. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
  496. const forkReceipt = await (await forkResponse).json() as { result: { ok: boolean } }
  497. expect(forkReceipt.result).toMatchObject({ ok: true })
  498. await expect.poll(
  499. () => page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').count(),
  500. { timeout: 15_000 },
  501. ).toBe(3)
  502. expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
  503. const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
  504. await expect.poll(() => hierarchy.getByRole('button').count()).toBe(1)
  505. await compareOrRefreshGolden(
  506. FORK_EXPECTED,
  507. await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
  508. MODE,
  509. )
  510. })
  511. it('cold-resumes the original subagent while its ordinary fork stays active', async () => {
  512. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup'))
  513. const sessions = page.getByRole('tree', { name: 'Sessions' })
  514. await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
  515. await page.getByRole('button', { name: '3 subagents' }).hover()
  516. await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
  517. await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor()
  518. expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
  519. const forkResponse = page.waitForResponse(response =>
  520. new URL(response.url()).pathname === '/api/session/fork')
  521. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
  522. const forkReceipt = await (await forkResponse).json() as {
  523. result: { ok: true; value: { sessionId: string } } | { ok: false }
  524. }
  525. expect(forkReceipt.result).toMatchObject({ ok: true })
  526. if (!forkReceipt.result.ok) return
  527. const forkId = sessionId(forkReceipt.result.value.sessionId)
  528. await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
  529. await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
  530. await page.getByRole('button', { name: '3 subagents' }).press('ArrowDown')
  531. await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
  532. const input = page.locator('[data-composer-input][contenteditable="true"]').first()
  533. await input.waitFor()
  534. const promptResponse = page.waitForResponse(response =>
  535. new URL(response.url()).pathname === '/api/subagents/prompt')
  536. await input.fill(POST_FORK_FOLLOWUP)
  537. await input.press('Enter')
  538. const promptReceipt = await (await promptResponse).json() as {
  539. result: { ok: true } | { ok: false; error: { code: string; message: string } }
  540. }
  541. if (!promptReceipt.result.ok) {
  542. throw new Error(`post-fork follow-up rejected: ${JSON.stringify(promptReceipt.result.error)}`)
  543. }
  544. await expect.poll(async () => {
  545. const loaded = await scaffold.ctx.sessionPersistence.load(childId)
  546. const messageIndex = loaded.events.findIndex(event => event.type === 'user/message'
  547. && event.data.content.some(block => block.type === 'text' && block.text === POST_FORK_FOLLOWUP))
  548. return messageIndex >= 0 && loaded.events.slice(messageIndex + 1).some(event => event.type === 'turn/end')
  549. }, { timeout: 30_000 }).toBe(true)
  550. expect(scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
  551. await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
  552. })
  553. })