subagent-conversation.e2e.ts 24 KB

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