sidebar-subagent-activity.e2e.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. import { mkdir } from 'node:fs/promises'
  2. import { join } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import type { Browser, Page } from 'playwright'
  5. import { chromium } from 'playwright'
  6. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  7. import type { AgentHandle } from '@deepseek-ai/dsh-agent'
  8. import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
  9. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  10. import { SessionId, type SessionId as SessionIdValue } from '@deepseek-ai/dsh-session'
  11. import type {} from '@deepseek-ai/dsh-subagent'
  12. import type {} from '@deepseek-ai/dsh-workspace'
  13. import {
  14. assertFixtureInventory,
  15. captureStableAria,
  16. compareOrRefreshGolden,
  17. launchWebScaffold,
  18. watchConsole,
  19. webSnapshotMode,
  20. type WebScaffold,
  21. } from './scaffold.ts'
  22. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  23. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/sidebar-subagent-activity', import.meta.url))
  24. const RUNNING_OWNER_EXPECTED = join(SNAPSHOT_DIR, 'owner-running.expected.md')
  25. const MODE = webSnapshotMode()
  26. const HOLD_PROVIDER = 'web-test-hold'
  27. const HOLD_MODEL = 'hold'
  28. /** Model stub that completes the owner turn, then holds its delegated child open. */
  29. class StagedAdapter extends LlmAdapter {
  30. activeCalls = 0
  31. private calls = 0
  32. override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  33. if (this.calls === 0) {
  34. this.calls += 1
  35. yield { type: 'finish', reason: { kind: 'stop' } }
  36. return
  37. }
  38. this.calls += 1
  39. const signal = options.signal
  40. if (signal === undefined) throw new Error('staged Web adapter requires a turn signal')
  41. this.activeCalls += 1
  42. try {
  43. await new Promise<never>((_resolve, reject) => {
  44. const abort = (): void => {
  45. reject(signal.reason instanceof Error ? signal.reason : new Error('holding Web adapter aborted'))
  46. }
  47. if (signal.aborted) abort()
  48. else signal.addEventListener('abort', abort, { once: true })
  49. })
  50. } finally {
  51. this.activeCalls -= 1
  52. }
  53. }
  54. }
  55. async function waitForRunningChild(
  56. scaffold: WebScaffold,
  57. adapter: StagedAdapter,
  58. childId: SessionIdValue,
  59. ): Promise<void> {
  60. const deadline = Date.now() + 10_000
  61. while (adapter.activeCalls !== 1 || scaffold.ctx.agents.get(childId)?.status !== 'running') {
  62. if (Date.now() >= deadline) throw new Error('held child did not enter its running model call')
  63. await new Promise<void>(resolve => setTimeout(resolve, 10))
  64. }
  65. }
  66. describe('web e2e: sidebar subagent activity', () => {
  67. let scaffold: WebScaffold
  68. let browser: Browser
  69. let page: Page
  70. let parentHandle: AgentHandle
  71. let childId: SessionIdValue
  72. let adapter: StagedAdapter
  73. let tripwire: ReturnType<typeof watchConsole>
  74. beforeAll(async () => {
  75. scaffold = await launchWebScaffold()
  76. adapter = new StagedAdapter()
  77. scaffold.ctx.effect(
  78. () => scaffold.ctx.llm.registerAdapter([HOLD_PROVIDER], adapter),
  79. 'sidebar subagent activity staged adapter',
  80. )
  81. const cwd = join(scaffold.workspaceCwd, 'workspace')
  82. await mkdir(cwd)
  83. parentHandle = await scaffold.ctx.agents.create({
  84. sessionId: SessionId('sidebar-activity-owner'),
  85. meta: { cwd },
  86. agentOptions: { provider: HOLD_PROVIDER, model: HOLD_MODEL },
  87. })
  88. parentHandle.agent.followup(createUserMessage({
  89. content: [{ type: 'text', text: 'Delegate a background job.' }],
  90. source: { kind: 'user' },
  91. }))
  92. await parentHandle.agent.whenIdle()
  93. const started = await scaffold.ctx.subagents.startContinuable({
  94. provider: 'spawn',
  95. label: 'sidebar activity child',
  96. signal: new AbortController().signal,
  97. request: {
  98. prompt: [{ type: 'text', text: 'Hold this delegated task open.' }],
  99. parent: parentHandle.agent,
  100. },
  101. })
  102. childId = started.childId
  103. await waitForRunningChild(scaffold, adapter, childId)
  104. browser = await chromium.launch()
  105. page = await newEnglishPage(browser)
  106. tripwire = watchConsole(page)
  107. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  108. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  109. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  110. const workspace = await scaffold.ctx.workspaceRegistry.resolveByPath(cwd)
  111. if (workspace === undefined) throw new Error('connected Web workspace was not registered')
  112. await workspace.attachSession(parentHandle.agent.session.id)
  113. }, 60_000)
  114. afterAll(async () => {
  115. const failures: unknown[] = []
  116. const child = childId === undefined ? undefined : scaffold?.ctx.agents.get(childId)
  117. if (child !== undefined) {
  118. child.cancel({ kind: 'user' })
  119. await child.whenIdle().catch((error: unknown) => failures.push(error))
  120. }
  121. await browser?.close().catch((error: unknown) => failures.push(error))
  122. await parentHandle?.dispose().catch((error: unknown) => failures.push(error))
  123. await scaffold?.close().catch((error: unknown) => failures.push(error))
  124. if (failures.length === 1) throw failures[0]
  125. if (failures.length > 1) throw new AggregateError(failures, 'sidebar subagent activity teardown failed')
  126. })
  127. it('pins a running descendant on its visible idle owner row', async () => {
  128. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-subagent-activity'))
  129. const sidebar = page.getByRole('tree', { name: 'Sessions' })
  130. const ownerRow = sidebar.getByRole('treeitem', { name: /1 subagent running Delegate a background job/ })
  131. await ownerRow.waitFor({ timeout: 10_000 })
  132. expect(parentHandle.agent.status).toBe('idle')
  133. await compareOrRefreshGolden(
  134. RUNNING_OWNER_EXPECTED,
  135. await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
  136. MODE,
  137. )
  138. expect(await ownerRow.locator('[data-state="ongoing"]').count()).toBe(1)
  139. await ownerRow.click()
  140. const runningTrigger = page.getByRole('button', { name: '1 subagent running' })
  141. await runningTrigger.waitFor({ timeout: 10_000 })
  142. expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
  143. await assertFixtureInventory(SNAPSHOT_DIR, ['owner-running.expected.md'])
  144. expect(tripwire.pageErrors).toEqual([])
  145. expect(tripwire.warnings).toEqual([])
  146. })
  147. })