subagent-interrupt.e2e.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. // Web e2e scenario (browserless): the subagent.interrupt RPC against the real
  2. // composition. A live continuable child holds its model turn open through a
  3. // replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and
  4. // proves from the real session state that the turn aborted, the follow-up
  5. // parked without auto-starting a new turn, and a later waking send resumed the
  6. // preserved FIFO order. No browser: the RPC surface is the product surface
  7. // under test, and subagent-interrupt-ui.e2e.ts owns the composer interaction.
  8. import { randomUUID } from 'node:crypto'
  9. import { existsSync } from 'node:fs'
  10. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  11. import { tmpdir } from 'node:os'
  12. import { join } from 'node:path'
  13. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  14. import { SessionId as sessionId, type SessionId } from '@deepseek-ai/dsh-session'
  15. import type {} from '@deepseek-ai/dsh-agent'
  16. import { launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts'
  17. const MODE = webSnapshotMode()
  18. const INITIAL = 'Explain event sourcing in one sentence.'
  19. const FOLLOWUP = 'Now give the same explanation to a human reader.'
  20. const WAKING = 'And add one concrete example.'
  21. type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
  22. /** POST one API Proxy unary RPC through the real HTTP carrier and unwrap its result. */
  23. async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<RpcResult<T>> {
  24. const response = await fetch(`${baseUrl}/api/${method}`, {
  25. method: 'POST',
  26. headers: { 'content-type': 'application/json' },
  27. body: JSON.stringify({
  28. type: 'client-request',
  29. rpcId: `interrupt-e2e-${method}-${randomUUID()}`,
  30. method,
  31. payload,
  32. }),
  33. })
  34. if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
  35. return (await response.json() as { result: RpcResult<T> }).result
  36. }
  37. /** POST one generated Session Remote unary through the API Gateway carrier. */
  38. async function sessionRemote<T>(baseUrl: string, method: string, request: unknown): Promise<RpcResult<T>> {
  39. const endpoint = `session/${method}`
  40. const response = await fetch(`${baseUrl}/api/${endpoint}`, {
  41. method: 'POST',
  42. headers: { 'content-type': 'application/json' },
  43. body: JSON.stringify({
  44. type: 'client-request',
  45. rpcId: `interrupt-e2e-${endpoint}-${randomUUID()}`,
  46. method: endpoint,
  47. payload: { args: { request } },
  48. }),
  49. })
  50. if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`)
  51. return (await response.json() as { result: RpcResult<T> }).result
  52. }
  53. /** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
  54. async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
  55. const deadline = Date.now() + timeoutMs
  56. while (!predicate()) {
  57. if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`)
  58. await new Promise<void>(resolve => setTimeout(resolve, 10))
  59. }
  60. }
  61. /** One text-only scripted model completion (no tool calls: real tools are mounted). */
  62. function textCompletion(text: string): object {
  63. return {
  64. kind: 'chunks',
  65. chunks: [
  66. { type: 'block-start', index: 0, blockType: 'text' },
  67. { type: 'text-delta', index: 0, text },
  68. { type: 'block-end', index: 0, block: { type: 'text', text } },
  69. { type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } },
  70. { type: 'finish', reason: { kind: 'stop' } },
  71. ],
  72. }
  73. }
  74. describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => {
  75. let scaffold: WebScaffold
  76. let sidecarRoot: string
  77. let readyFile: string
  78. let parentId: SessionId
  79. let childId: SessionId
  80. beforeAll(async () => {
  81. sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-'))
  82. readyFile = join(sidecarRoot, 'hang-ready')
  83. // Whole-script replacement: the child's three model calls are the hang
  84. // (turn 1, interrupted), the parked follow-up's turn, and the waking turn.
  85. // The parent never runs a turn, so the child claims this primary script.
  86. await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
  87. { kind: 'hang', readyFile },
  88. textCompletion('resumed response one'),
  89. textCompletion('resumed response two'),
  90. ]))
  91. // Header-only primary fixture: the bare-array override replaces the
  92. // derived script entirely; the path only anchors replay installation.
  93. await writeFile(
  94. join(sidecarRoot, 'session.jsonl'),
  95. '{"type":"session","version":0,"id":"primary","createdAt":0}\n',
  96. )
  97. scaffold = await launchWebScaffold({
  98. replayFixture: join(sidecarRoot, 'session.jsonl'),
  99. replayOverride: join(sidecarRoot, 'replay.override.json'),
  100. })
  101. // A live parent Agent through the real API; no workspace or browser.
  102. const created = await sessionRemote<{ sessionId: string }>(scaffold.baseUrl, 'create', {
  103. cwd: scaffold.workspaceCwd,
  104. })
  105. if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`)
  106. parentId = sessionId(created.value.sessionId)
  107. const parent = scaffold.ctx.agents.get(parentId)
  108. if (parent === undefined) throw new Error('created parent session did not publish a live Agent')
  109. const started = await scaffold.ctx.subagents.startContinuable({
  110. provider: 'spawn',
  111. label: 'event-sourcing researcher',
  112. signal: new AbortController().signal,
  113. request: { prompt: [{ type: 'text', text: INITIAL }], parent },
  114. })
  115. childId = started.childId
  116. // The hang entry writes readyFile after its prefix chunks, immediately
  117. // before waiting for cancellation: the deterministic "turn is open" gate.
  118. await waitFor(() => existsSync(readyFile), 'the held child turn to open')
  119. }, 120_000)
  120. afterAll(async () => {
  121. const failures: unknown[] = []
  122. await scaffold?.close().catch((error: unknown) => failures.push(error))
  123. await rm(sidecarRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  124. if (failures.length === 1) throw failures[0]
  125. if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt teardown failed')
  126. })
  127. it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => {
  128. // Queue the follow-up while the turn is still open, then interrupt.
  129. const queued = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
  130. parentSessionId: parentId,
  131. childSessionId: childId,
  132. mode: 'continuable',
  133. content: [{ type: 'text', text: FOLLOWUP }],
  134. })
  135. expect(queued).toMatchObject({ ok: true })
  136. const settled = scaffold.whenTurnSettled()
  137. const interrupted = await rpc<{ accepted: true }>(scaffold.baseUrl, 'subagent.interrupt', {
  138. parentSessionId: parentId,
  139. childSessionId: childId,
  140. mode: 'continuable',
  141. })
  142. expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } })
  143. // accepted acknowledges the admitted cancel, not quiescence: wait for the
  144. // aborted turn/end (the composition's first turn/end) before asserting.
  145. expect(await settled).toBe(childId)
  146. // Parked, not resumed: the Activation stays resident with an idle driver,
  147. // the follow-up is retained, and no second turn opened.
  148. const child = scaffold.ctx.agents.get(childId)
  149. expect(child).toBeDefined()
  150. expect(child!.status).toBe('idle')
  151. expect(child!.inbox.nextTurn).toHaveLength(1)
  152. expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
  153. const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1)
  154. expect((lastEnd)?.data.reason.kind).toBe('aborted')
  155. // Only an explicit waking send resumes the parked queue, FIFO, then the
  156. // child runs both turns to completion and settles.
  157. const waking = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', {
  158. parentSessionId: parentId,
  159. childSessionId: childId,
  160. mode: 'continuable',
  161. content: [{ type: 'text', text: WAKING }],
  162. })
  163. expect(waking).toMatchObject({ ok: true })
  164. await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
  165. const loaded = await scaffold.ctx.sessionPersistence.load(childId)
  166. // Human-origin messages only: the real composition also injects
  167. // runtime-context snapshots as non-user-source messages.
  168. const userTexts = loaded.events.flatMap(event => event.type === 'user/message'
  169. && event.data.source.kind === 'user'
  170. ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
  171. : [])
  172. expect(userTexts).toEqual([INITIAL, FOLLOWUP, WAKING])
  173. const turnEndKinds = loaded.events
  174. .filter(event => event.type === 'turn/end')
  175. .map(event => (event).data.reason.kind)
  176. expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed'])
  177. }, 120_000)
  178. })