subagent-conversation.e2e.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  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, 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 SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url))
  23. const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url))
  24. const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url))
  25. const MODE = webSnapshotMode()
  26. const LABEL = 'event-sourcing researcher'
  27. const ONE_SHOT_LABEL = 'event-sourcing reviewer'
  28. const NESTED_LABEL = 'example editor'
  29. const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
  30. const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
  31. const FOLLOWUP = 'Now give the same explanation to a human reader.'
  32. const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
  33. function childFixture(source: string, fixtureId: string, withContinuation: boolean): string {
  34. const [header, ...eventLines] = source.trimEnd().split('\n')
  35. if (header === undefined) throw new Error('base replay fixture has no header')
  36. const childHeader = header
  37. .replace('"id":"{{sessionId}}"', `"id":"${fixtureId}"`)
  38. .replace(/"createdAt":\d+/, '"createdAt":1784998084442')
  39. if (!withContinuation) return [childHeader, ...eventLines, ''].join('\n')
  40. const continued = eventLines.map(line => line
  41. .replace(/"seq":(\d+)/g, (_match, seq: string) => `"seq":${String(Number(seq) + 100)}`)
  42. .replace(/"seq0":(\d+)/g, (_match, seq: string) => `"seq0":${String(Number(seq) + 100)}`)
  43. .replaceAll('"turn":1', '"turn":2'))
  44. return [childHeader, ...eventLines, ...continued, ''].join('\n')
  45. }
  46. async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promise<void> {
  47. const deadline = Date.now() + 30_000
  48. while (scaffold.ctx.agents.get(id) !== undefined) {
  49. if (Date.now() >= deadline) throw new Error(`subagent ${id} did not settle`)
  50. await new Promise<void>(resolve => setTimeout(resolve, 10))
  51. }
  52. }
  53. describe('web e2e: persisted subagent conversation and human continuation', () => {
  54. let scaffold: WebScaffold
  55. let browser: Browser
  56. let page: Page
  57. let sidecarRoot: string
  58. let childId: SessionId
  59. let oneShotId: SessionId
  60. let grandchildId: SessionId
  61. let tripwire: ReturnType<typeof watchConsole>
  62. const apiCalls: string[] = []
  63. beforeAll(async () => {
  64. if (MODE === 'record') throw new Error('subagent conversation is a keyless assembled snapshot')
  65. const baseFixture = await readFile(BASE_FIXTURE, 'utf8')
  66. sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-'))
  67. const childFixturePath = join(sidecarRoot, 'child.jsonl')
  68. await writeFile(childFixturePath, childFixture(baseFixture, 'recorded-subagent', true))
  69. scaffold = await launchWebScaffold({
  70. replayFixture: BASE_FIXTURE,
  71. replayChildFixtures: [childFixturePath],
  72. paceMs: 25,
  73. })
  74. browser = await chromium.launch()
  75. page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
  76. page.on('request', (request) => {
  77. const path = new URL(request.url()).pathname
  78. if (path.startsWith('/api/')) apiCalls.push(path)
  79. })
  80. tripwire = watchConsole(page)
  81. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  82. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  83. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  84. const parent = scaffold.ctx.agents.roots()[0]
  85. if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent')
  86. const parentSettled = scaffold.whenTurnSettled()
  87. const parentInput = page.locator('textarea:enabled').first()
  88. await parentInput.fill(PARENT_PROMPT)
  89. await parentInput.press('Enter')
  90. expect(await parentSettled).toBe(parent.id)
  91. const started = await scaffold.ctx.subagents.startContinuable({
  92. provider: 'spawn',
  93. label: LABEL,
  94. signal: new AbortController().signal,
  95. request: {
  96. prompt: [{ type: 'text', text: INITIAL_PROMPT }],
  97. parent,
  98. },
  99. })
  100. childId = started.childId
  101. await waitForAgentToSettle(scaffold, childId)
  102. oneShotId = sessionId('recorded-one-shot')
  103. const oneShotAt = Date.now()
  104. await scaffold.ctx.sessionPersistence.create({
  105. version: SESSION_FORMAT_VERSION,
  106. id: oneShotId,
  107. createdAt: oneShotAt,
  108. cwd: scaffold.workspaceCwd,
  109. parentSession: parent.id,
  110. origin: 'subagent',
  111. delegationDepth: 1,
  112. })
  113. await scaffold.ctx.sessionPersistence.append(oneShotId, [
  114. {
  115. type: 'turn/start',
  116. seq: 0,
  117. time: oneShotAt,
  118. data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  119. },
  120. {
  121. type: 'user/message',
  122. seq: 1,
  123. time: oneShotAt + 1,
  124. data: {
  125. content: [{ type: 'text', text: 'Review the event sourcing explanation.' }],
  126. source: { kind: 'user' },
  127. },
  128. surfaceOp: 'append',
  129. },
  130. {
  131. type: 'subagent/descriptor',
  132. seq: 2,
  133. time: oneShotAt + 2,
  134. data: snapshotSubagentDescriptor({
  135. mode: 'one-shot', provider: 'spawn', label: ONE_SHOT_LABEL,
  136. }),
  137. },
  138. {
  139. type: 'turn/end',
  140. seq: 3,
  141. time: oneShotAt + 3,
  142. data: { turn: 1, reason: { kind: 'completed' } },
  143. },
  144. ] as SessionEvent[])
  145. grandchildId = sessionId('recorded-grandchild')
  146. const authoredAt = Date.now()
  147. await scaffold.ctx.sessionPersistence.create({
  148. version: SESSION_FORMAT_VERSION,
  149. id: grandchildId,
  150. createdAt: authoredAt,
  151. cwd: scaffold.workspaceCwd,
  152. parentSession: childId,
  153. origin: 'subagent',
  154. delegationDepth: 2,
  155. })
  156. await scaffold.ctx.sessionPersistence.append(grandchildId, [
  157. {
  158. type: 'turn/start',
  159. seq: 0,
  160. time: authoredAt,
  161. data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  162. },
  163. {
  164. type: 'user/message',
  165. seq: 1,
  166. time: authoredAt + 1,
  167. data: {
  168. content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
  169. source: { kind: 'user' },
  170. },
  171. surfaceOp: 'append',
  172. },
  173. {
  174. type: 'subagent/descriptor',
  175. seq: 2,
  176. time: authoredAt + 2,
  177. data: snapshotSubagentDescriptor({
  178. mode: 'continuable', provider: 'spawn', label: NESTED_LABEL,
  179. }),
  180. },
  181. {
  182. type: 'turn/end',
  183. seq: 3,
  184. time: authoredAt + 3,
  185. data: { turn: 1, reason: { kind: 'completed' } },
  186. },
  187. ] as SessionEvent[])
  188. expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
  189. expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
  190. expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
  191. await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([
  192. {
  193. kind: 'child', id: childId, mode: 'continuable', label: LABEL,
  194. activity: 'inactive', hasChildren: true,
  195. },
  196. {
  197. kind: 'child', id: oneShotId, mode: 'one-shot',
  198. label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false,
  199. },
  200. ])
  201. await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([
  202. {
  203. kind: 'child', id: grandchildId, mode: 'continuable',
  204. label: NESTED_LABEL, activity: 'inactive', hasChildren: false,
  205. },
  206. ])
  207. // These two cold fixtures were authored after the page's initial
  208. // session.list and intentionally emitted no session-added frame. Reload
  209. // to exercise the restart baseline that discovers their full lineage.
  210. const warningStart = tripwire.warnings.length
  211. await page.reload({ waitUntil: 'load' })
  212. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  213. const catalogButton = page.getByRole('button', { name: /个子代理/ })
  214. await catalogButton.waitFor({ timeout: 15_000 })
  215. await catalogButton.click()
  216. const catalogTree = page.getByRole('tree', { name: '子代理会话' })
  217. await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 })
  218. await catalogTree.press('Escape')
  219. await page.getByRole('button', { name: '3 个子代理' }).waitFor({ timeout: 15_000 })
  220. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  221. }, 120_000)
  222. afterAll(async () => {
  223. const failures: unknown[] = []
  224. await browser?.close().catch((error: unknown) => failures.push(error))
  225. await scaffold?.close().catch((error: unknown) => failures.push(error))
  226. if (sidecarRoot !== undefined) {
  227. await rm(sidecarRoot, { recursive: true, force: true })
  228. .catch((error: unknown) => failures.push(error))
  229. }
  230. if (failures.length === 1) throw failures[0]
  231. if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
  232. })
  233. it('expands a persisted grandchild progressively without activating either level', async () => {
  234. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
  235. await page.getByRole('button', { name: '3 个子代理' }).click()
  236. expect(await page.getByRole('button', {
  237. name: `展开 ${ONE_SHOT_LABEL} 的下级子代理`,
  238. }).count()).toBe(0)
  239. await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click()
  240. await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
  241. expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
  242. expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
  243. const snapshot = await captureStableAria(
  244. page,
  245. '[role="tree"][aria-label="子代理会话"]',
  246. scaffold.workspaceCwd,
  247. )
  248. await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE)
  249. await page.getByRole('tree', { name: '子代理会话' }).press('Escape')
  250. })
  251. it('opens the completed child from persistence without activating it', async () => {
  252. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
  253. await page.getByRole('button', { name: '3 个子代理' }).click()
  254. await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
  255. await expect.poll(
  256. () => page.getByText(INITIAL_PROMPT, { exact: true }).count(),
  257. { timeout: 15_000 },
  258. ).toBe(1)
  259. if (scaffold.ctx.agents.get(childId) !== undefined) {
  260. throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`)
  261. }
  262. const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
  263. await hierarchy.getByRole('button', { name: LABEL, disabled: true }).waitFor()
  264. const sidebar = await captureStableAria(
  265. page,
  266. '[role="tree"][aria-label="Sessions"]',
  267. scaffold.workspaceCwd,
  268. )
  269. await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
  270. })
  271. it('continues through FIFO follow-up admission and receives the child mux events', async () => {
  272. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup'))
  273. const ended = new Promise<void>((resolveEnded, reject) => {
  274. const timer = setTimeout(() => {
  275. off()
  276. reject(new Error('subagent follow-up did not reach turn/end'))
  277. }, 30_000)
  278. const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
  279. if (session.id !== childId || event.type !== 'turn/end') return
  280. clearTimeout(timer)
  281. off()
  282. resolveEnded()
  283. })
  284. })
  285. const input = page.getByRole('textbox', { name: 'Message the agent' })
  286. await input.fill(FOLLOWUP)
  287. await input.press('Enter')
  288. await expect.poll(
  289. () => scaffold.ctx.agents.get(childId)?.status,
  290. { timeout: 10_000 },
  291. ).toBe('running')
  292. const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
  293. await hierarchy.getByRole('button').first().click()
  294. const runningTrigger = page.getByRole('button', { name: '3 个子代理,正在运行' })
  295. await runningTrigger.waitFor({ timeout: 10_000 })
  296. expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
  297. await runningTrigger.click()
  298. await page.getByRole('treeitem', {
  299. name: new RegExp(`${LABEL}.*正在运行`),
  300. }).waitFor({ timeout: 10_000 })
  301. await ended
  302. await page.getByRole('treeitem', {
  303. name: new RegExp(`${LABEL}.*当前未运行`),
  304. }).waitFor({ timeout: 10_000 })
  305. expect(await page.getByRole('button', { name: '3 个子代理' })
  306. .locator('[data-state="ongoing"]').count()).toBe(0)
  307. await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
  308. await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  309. await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
  310. expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
  311. })
  312. it('matches the settled addressed-conversation aria golden and stays clean', async () => {
  313. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria'))
  314. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  315. await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE)
  316. expect(tripwire.pageErrors).toEqual([])
  317. expect(tripwire.warnings).toEqual([])
  318. })
  319. it('opens an unavailable persisted grandchild after recording the available child', async () => {
  320. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild'))
  321. await page.getByRole('button', { name: '1 个子代理' }).click()
  322. await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click()
  323. await page.getByText('父会话当前不在线,重新打开父会话后即可继续发送消息。').waitFor()
  324. const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
  325. const crumbs = await hierarchy.getByRole('button').allTextContents()
  326. expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])
  327. expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
  328. expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
  329. await compareOrRefreshGolden(
  330. UNAVAILABLE_GRANDCHILD_EXPECTED,
  331. await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
  332. MODE,
  333. )
  334. })
  335. it('opens a one-shot child as permanently read-only history', async () => {
  336. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-one-shot'))
  337. const parentSession = page.getByRole('tree', { name: 'Sessions' })
  338. .getByRole('treeitem')
  339. .last()
  340. await parentSession.click()
  341. await page.getByRole('button', { name: '3 个子代理' }).click()
  342. await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click()
  343. await page.getByText('一次性任务不支持后续消息,可在这里查看完整执行记录。').waitFor()
  344. expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
  345. })
  346. it('places an ordinary fork from a subagent beside its workspace-owning ancestor', async () => {
  347. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-fork'))
  348. await page.getByRole('tree', { name: 'Sessions' })
  349. .getByRole('treeitem', { name: /Ask a research subagent to/ })
  350. .click()
  351. await page.getByRole('button', { name: '3 个子代理' }).click()
  352. await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
  353. await page.getByRole('textbox', { name: 'Message the agent' }).waitFor()
  354. const forkResponse = page.waitForResponse(response =>
  355. new URL(response.url()).pathname === '/api/session.fork')
  356. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
  357. const forkReceipt = await (await forkResponse).json() as { result: { ok: boolean } }
  358. expect(forkReceipt.result).toMatchObject({ ok: true })
  359. await expect.poll(
  360. () => page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').count(),
  361. { timeout: 15_000 },
  362. ).toBe(3)
  363. expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
  364. const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
  365. expect(await hierarchy.getByRole('button').count()).toBe(1)
  366. await compareOrRefreshGolden(
  367. FORK_EXPECTED,
  368. await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
  369. MODE,
  370. )
  371. })
  372. it('cold-resumes the original subagent while its ordinary fork stays active', async () => {
  373. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup'))
  374. const sessions = page.getByRole('tree', { name: 'Sessions' })
  375. await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
  376. await page.getByRole('button', { name: '3 个子代理' }).click()
  377. await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
  378. await page.locator('textarea:enabled').first().waitFor()
  379. expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
  380. const forkResponse = page.waitForResponse(response =>
  381. new URL(response.url()).pathname === '/api/session.fork')
  382. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
  383. const forkReceipt = await (await forkResponse).json() as {
  384. result: { ok: true; value: { sessionId: string } } | { ok: false }
  385. }
  386. expect(forkReceipt.result).toMatchObject({ ok: true })
  387. if (!forkReceipt.result.ok) return
  388. const forkId = sessionId(forkReceipt.result.value.sessionId)
  389. await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
  390. await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
  391. await page.getByRole('button', { name: '3 个子代理' }).click()
  392. await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
  393. const input = page.locator('textarea:enabled').first()
  394. await input.waitFor()
  395. const promptResponse = page.waitForResponse(response =>
  396. new URL(response.url()).pathname === '/api/subagent.prompt')
  397. await input.fill(POST_FORK_FOLLOWUP)
  398. await input.press('Enter')
  399. const promptReceipt = await (await promptResponse).json() as {
  400. result: { ok: true } | { ok: false; error: { code: string; message: string } }
  401. }
  402. if (!promptReceipt.result.ok) {
  403. throw new Error(`post-fork follow-up rejected: ${JSON.stringify(promptReceipt.result.error)}`)
  404. }
  405. await expect.poll(async () => {
  406. const loaded = await scaffold.ctx.sessionPersistence.load(childId)
  407. const messageIndex = loaded.events.findIndex(event => event.type === 'user/message'
  408. && event.data.content.some(block => block.type === 'text' && block.text === POST_FORK_FOLLOWUP))
  409. return messageIndex >= 0 && loaded.events.slice(messageIndex + 1).some(event => event.type === 'turn/end')
  410. }, { timeout: 30_000 }).toBe(true)
  411. expect(scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
  412. await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
  413. })
  414. })