subagent-interrupt-ui.e2e.ts 15 KB

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