subagent-interrupt-ui.e2e.ts 18 KB

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