subagent-conversation.e2e.ts 28 KB

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