subagent-interrupt-ui.e2e.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. // Web e2e scenario: the composer's independent Stop interrupts a running
  2. // continuable child. The child holds its model turn open through a replay
  3. // hang entry; the browser proves Send and Stop coexist, the parent-offline
  4. // disabled-Send-with-Stop composer, the subagents/interruptByParent
  5. // (never session.cancel) transport, the parked follow-up, and the FIFO resume
  6. // on a waking send.
  7. //
  8. // Replay-binding note: only the PRIMARY script can hang, and scripts bind by
  9. // first-call order, so the child issues the composition's first model call
  10. // (claiming the overridden primary) and the parent's one UI prompt — needed
  11. // so the non-blank parent renders its header catalog — binds to a derived
  12. // child fixture afterwards.
  13. import { existsSync } from 'node:fs'
  14. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  15. import { tmpdir } from 'node:os'
  16. import { join } from 'node:path'
  17. import { fileURLToPath } from 'node:url'
  18. import type { Browser, Page } from 'playwright'
  19. import { chromium } from 'playwright'
  20. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  21. import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
  22. import type { Agent } from '@deepseek-ai/dsh-agent'
  23. import type { SubagentPromptRequestId } from '@deepseek-ai/dsh-subagent'
  24. import {
  25. acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  26. launchWebScaffold, readPersistedEvents, watchConsole, webSnapshotMode, type WebScaffold,
  27. } from './scaffold.ts'
  28. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  29. const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v2.jsonl', import.meta.url))
  30. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/subagent-interrupt', import.meta.url))
  31. const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.md')
  32. const MODE = webSnapshotMode()
  33. const LABEL = 'event-sourcing researcher'
  34. const INITIAL = 'Explain event sourcing in one sentence.'
  35. const REARM = 'Keep working until I stop you again.'
  36. const REARM_WAKE = 'Start that queued work now.'
  37. const FOLLOWUP = 'Now give the same explanation to a human reader.'
  38. const WAKING = 'And add one concrete example.'
  39. const REARMED_ANSWER = 're-armed setup answer'
  40. const PARKED_ANSWER = 'parked follow-up answer'
  41. const WAKING_ANSWER = 'waking answer'
  42. /** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
  43. async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
  44. const deadline = Date.now() + timeoutMs
  45. while (!predicate()) {
  46. if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
  47. await new Promise<void>(resolve => setTimeout(resolve, 10))
  48. }
  49. }
  50. /** Resolve on one exact child's next aborted turn end. */
  51. function waitForAbortedTurn(scaffold: WebScaffold, childId: SessionId): Promise<void> {
  52. return new Promise<void>((resolve, reject) => {
  53. const timer = setTimeout(() => {
  54. off()
  55. reject(new Error('interrupt did not reach an aborted turn/end'))
  56. }, 30_000)
  57. const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
  58. if (session.id !== childId || event.type !== 'turn/end') return
  59. clearTimeout(timer)
  60. off()
  61. if (event.data.reason.kind === 'aborted') resolve()
  62. else reject(new Error(`expected an aborted turn/end, got ${event.data.reason.kind}`))
  63. })
  64. })
  65. }
  66. /** One text-only scripted model completion (no tool calls: real tools are mounted). */
  67. function textCompletion(text: string): object {
  68. return {
  69. kind: 'chunks',
  70. chunks: [
  71. { type: 'block-start', index: 0, blockType: 'text' },
  72. { type: 'text-delta', index: 0, text },
  73. { type: 'block-end', index: 0, block: { type: 'text', text } },
  74. { type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
  75. { type: 'finish', reason: { kind: 'stop' } },
  76. ],
  77. }
  78. }
  79. describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running continuable child', () => {
  80. let scaffold: WebScaffold
  81. let browser: Browser
  82. let page: Page
  83. let sidecarRoot: string
  84. let rearmedReadyFile: string
  85. let parent: Agent
  86. let childId: SessionId
  87. let tripwire: ReturnType<typeof watchConsole>
  88. const apiCalls: string[] = []
  89. beforeAll(async () => {
  90. sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-ui-'))
  91. const readyFile = join(sidecarRoot, 'hang-ready')
  92. rearmedReadyFile = join(sidecarRoot, 'hang-rearmed-ready')
  93. // The child claims this whole-script replacement: the offline and online
  94. // interrupt paths each hold one turn, then the parked and waking turns settle.
  95. await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
  96. { kind: 'hang', readyFile },
  97. { kind: 'hang', readyFile: rearmedReadyFile },
  98. textCompletion(REARMED_ANSWER),
  99. textCompletion(PARKED_ANSWER),
  100. textCompletion(WAKING_ANSWER),
  101. ]))
  102. await writeFile(
  103. join(sidecarRoot, 'session.jsonl'),
  104. '{"type":"session","version":0,"id":"primary","createdAt":0}\n',
  105. )
  106. // The parent's one prompted turn replays this recorded single text-only
  107. // call (binding is positional, not lineage-aware).
  108. const parentTurnPath = join(sidecarRoot, 'parent-turn.jsonl')
  109. const base = await readFile(BASE_FIXTURE, 'utf8')
  110. const [header, ...events] = base.trimEnd().split('\n')
  111. if (header === undefined) throw new Error('base replay fixture has no header')
  112. await writeFile(parentTurnPath, [
  113. header
  114. .replace('"id":"{{sessionId}}"', '"id":"recorded-parent-turn"')
  115. .replace(/"createdAt":\d+/, '"createdAt":1784998084442'),
  116. ...events,
  117. '',
  118. ].join('\n'))
  119. scaffold = await launchWebScaffold({
  120. replayFixture: join(sidecarRoot, 'session.jsonl'),
  121. replayOverride: join(sidecarRoot, 'replay.override.json'),
  122. replayChildFixtures: [parentTurnPath],
  123. })
  124. browser = await chromium.launch()
  125. page = await newEnglishPage(browser)
  126. page.on('request', (request) => {
  127. const path = new URL(request.url()).pathname
  128. if (path.startsWith('/api/')) apiCalls.push(path)
  129. })
  130. tripwire = watchConsole(page)
  131. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  132. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  133. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  134. const root = scaffold.ctx.agents.roots()[0]
  135. if (root === undefined) throw new Error('fresh workspace did not publish its parent Agent')
  136. parent = root
  137. // The child's first model call claims the primary override and holds.
  138. const started = await scaffold.ctx.subagents.startContinuable({
  139. provider: 'spawn',
  140. label: LABEL,
  141. signal: new AbortController().signal,
  142. request: { prompt: [{ type: 'text', text: INITIAL }], parent },
  143. })
  144. childId = started.childId
  145. await waitFor(() => existsSync(readyFile), 'the held child turn to open')
  146. // One prompted parent turn makes the parent non-blank so the session
  147. // header (and its subagent catalog action) renders.
  148. const parentSettled = scaffold.whenTurnSettled()
  149. const parentInput = page.locator('[data-composer-input][contenteditable="true"]').first()
  150. await parentInput.fill('Ask a research subagent to explain event sourcing.')
  151. await parentInput.press('Enter')
  152. expect(await parentSettled).toBe(parent.id)
  153. // Reload onto the restart baseline (the proven route to a freshly
  154. // discovered catalog), with the child still live and running host-side.
  155. const warningStart = tripwire.warnings.length
  156. await page.reload({ waitUntil: 'load' })
  157. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  158. await page.getByRole('button', { name: /1 subagent/ }).waitFor({ timeout: 15_000 })
  159. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  160. expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
  161. }, 120_000)
  162. afterAll(async () => {
  163. const failures: unknown[] = []
  164. await browser?.close().catch((error: unknown) => failures.push(error))
  165. await scaffold?.close().catch((error: unknown) => failures.push(error))
  166. if (sidecarRoot !== undefined) {
  167. await rm(sidecarRoot, { recursive: true, force: true })
  168. .catch((error: unknown) => failures.push(error))
  169. }
  170. if (failures.length === 1) throw failures[0]
  171. if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt UI teardown failed')
  172. })
  173. it('interrupts the live child through the parent-offline composer', async () => {
  174. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline'))
  175. // Simulate a parent that went offline: the catalog delivers
  176. // parentAvailable: false while the child Activation stays live (the
  177. // interrupt RPC itself needs no live parent — covered host-side by
  178. // subagent-interrupt.e2e.ts).
  179. const pattern = '**/api/subagents/list'
  180. await page.route(pattern, async (route) => {
  181. const response = await route.fetch()
  182. const body = await response.json() as {
  183. result: { ok: true; value: { parentAvailable: boolean } } | { ok: false }
  184. }
  185. if (body.result.ok) body.result.value.parentAvailable = false
  186. await route.fulfill({ response, json: body })
  187. })
  188. try {
  189. await page.getByRole('button', { name: /1 subagent/ }).click()
  190. await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
  191. const input = page.getByRole('textbox', {
  192. name: 'Parent session offline; sending is unavailable but you can still stop the run',
  193. })
  194. await input.waitFor({ timeout: 15_000 })
  195. await page.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /)
  196. .waitFor({ timeout: 15_000 })
  197. expect(await input.isDisabled()).toBe(true)
  198. const stop = page.getByRole('button', { name: 'Stop generating' })
  199. expect(await stop.count()).toBe(1)
  200. expect(await stop.isEnabled()).toBe(true)
  201. const send = page.getByRole('button', { name: 'Send message' })
  202. expect(await send.count()).toBe(1)
  203. expect(await send.isDisabled()).toBe(true)
  204. await compareOrRefreshGolden(
  205. OFFLINE_COMPOSER_EXPECTED,
  206. await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
  207. MODE,
  208. )
  209. // Keep the continuable Activation resident after this first abort. The
  210. // direct setup queue does not change the parent-offline UI contract: its
  211. // input and Send remain disabled throughout the exercised browser path.
  212. await scaffold.ctx.subagents.prompt({
  213. requestId: 'interrupt-ui-rearm' as SubagentPromptRequestId,
  214. parentSessionId: parent.id,
  215. childSessionId: childId,
  216. mode: 'continuable',
  217. content: [{ type: 'text', text: REARM }],
  218. }, new AbortController().signal)
  219. const aborted = waitForAbortedTurn(scaffold, childId)
  220. const interruptResponse = page.waitForResponse(response =>
  221. new URL(response.url()).pathname === '/api/subagents/interruptByParent')
  222. await stop.click()
  223. expect(((await (await interruptResponse).json()) as {
  224. result: { ok: boolean; value?: { accepted: boolean } }
  225. }).result).toMatchObject({ ok: true, value: { accepted: true } })
  226. expect(apiCalls.filter(path => path === '/api/session/cancel')).toEqual([])
  227. await aborted
  228. await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
  229. // Wake the parked setup message only after cancellation converges. A
  230. // second hang keeps the parent-available case independent from this stop.
  231. await scaffold.ctx.subagents.prompt({
  232. requestId: 'interrupt-ui-rearm-wake' as SubagentPromptRequestId,
  233. parentSessionId: parent.id,
  234. childSessionId: childId,
  235. mode: 'continuable',
  236. content: [{ type: 'text', text: REARM_WAKE }],
  237. }, new AbortController().signal)
  238. await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open')
  239. expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
  240. } finally {
  241. await page.unrouteAll({ behavior: 'wait' })
  242. }
  243. }, 60_000)
  244. it('interrupts through subagents/interruptByParent, parks the follow-up, and resumes it FIFO', async () => {
  245. onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow'))
  246. // Reselect the child with the truthful catalog: parent available again.
  247. await page.getByRole('navigation', { name: 'Session hierarchy' })
  248. .getByRole('button').first().click()
  249. await page.getByRole('button', { name: /1 subagent/ }).click()
  250. await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
  251. const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
  252. await input.waitFor({ timeout: 15_000 })
  253. expect(await input.isDisabled()).toBe(false)
  254. // Queue a follow-up through Send while independent Stop remains available.
  255. const promptResponse = page.waitForResponse(response =>
  256. new URL(response.url()).pathname === '/api/subagents/prompt')
  257. await input.fill(FOLLOWUP)
  258. await page.getByRole('button', { name: 'Send message' }).click()
  259. expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
  260. .toMatchObject({ ok: true })
  261. const aborted = waitForAbortedTurn(scaffold, childId)
  262. const stop = page.getByRole('button', { name: 'Stop generating' })
  263. expect(await stop.count()).toBe(1)
  264. const interruptResponse = page.waitForResponse(response =>
  265. new URL(response.url()).pathname === '/api/subagents/interruptByParent')
  266. await stop.click()
  267. expect(((await (await interruptResponse).json()) as {
  268. result: { ok: boolean; value?: { accepted: boolean } }
  269. }).result).toMatchObject({ ok: true, value: { accepted: true } })
  270. // The addressed child stops through its own RPC, never the generic one.
  271. expect(apiCalls.filter(path => path === '/api/session/cancel')).toEqual([])
  272. await aborted
  273. // Parked: the Activation stays resident and idle with the retained
  274. // follow-up; the primary returns to Send without a new turn starting.
  275. await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
  276. const child = scaffold.ctx.agents.get(childId)
  277. expect(child).toBeDefined()
  278. expect(child!.inbox.nextTurn).toHaveLength(2)
  279. expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2)
  280. await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 })
  281. // Only the waking send resumes the parked queue, FIFO, to settlement.
  282. await input.fill(WAKING)
  283. await input.press('Enter')
  284. await expect.poll(() => page.getByText(REARMED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
  285. await expect.poll(() => page.getByText(PARKED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
  286. await expect.poll(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
  287. await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
  288. // The settled child's loop appended every turn's closing events durably,
  289. // so the physical log carries the complete record asserted here.
  290. const events = await readPersistedEvents(scaffold, childId)
  291. const userTexts = events.flatMap(event => event.type === 'user/message'
  292. && event.data.source.kind === 'user'
  293. ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
  294. : [])
  295. expect(userTexts[0]).toBe(INITIAL)
  296. expect(userTexts[1]).toMatch(/^Your parent agent id is .+send_message\(\{ agent_id: /)
  297. expect(userTexts.slice(2)).toEqual([REARM, REARM_WAKE, FOLLOWUP, WAKING])
  298. const turnEndKinds = events
  299. .filter(event => event.type === 'turn/end')
  300. .map(event => event.data.reason.kind)
  301. expect(turnEndKinds).toEqual(['aborted', 'aborted', 'completed', 'completed', 'completed'])
  302. expect(tripwire.pageErrors).toEqual([])
  303. }, 120_000)
  304. it('keeps its snapshot inventory closed', async () => {
  305. await assertFixtureInventory(SNAPSHOT_DIR, ['offline-composer.expected.md'])
  306. })
  307. })