real-product.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. import { execFile } from 'node:child_process'
  2. import {
  3. existsSync,
  4. mkdirSync,
  5. mkdtempSync,
  6. readFileSync,
  7. realpathSync,
  8. writeFileSync,
  9. } from 'node:fs'
  10. import { rm } from 'node:fs/promises'
  11. import { tmpdir } from 'node:os'
  12. import { dirname, join, resolve } from 'node:path'
  13. import { fileURLToPath } from 'node:url'
  14. import { promisify } from 'node:util'
  15. import type {
  16. Query,
  17. SDKMessage,
  18. SDKSystemMessage,
  19. } from '@anthropic-ai/claude-agent-sdk'
  20. import { Context } from '@deepseek-ai/cordis'
  21. import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
  22. import type { Agent } from '@deepseek-ai/dsh-agent'
  23. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  24. import type {
  25. SubprocessHandle,
  26. SubprocessOutcome,
  27. SubprocessSpawnSpec,
  28. } from '@deepseek-ai/dsh-subprocess'
  29. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  30. import * as claudeCode from '../src/index.ts'
  31. import type { ClaudeCodePermissionMode } from '../src/run.ts'
  32. import {
  33. startMessagesFixture,
  34. type MessagesBehavior,
  35. type MessagesFixture,
  36. } from './messages-fixture.ts'
  37. const observedSdkMessages = vi.hoisted((): SDKMessage[] => [])
  38. const sdkTestOverrides = vi.hoisted((): { maxTurns?: number } => ({}))
  39. vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => {
  40. const actual = await importOriginal<
  41. typeof import('@anthropic-ai/claude-agent-sdk')
  42. >()
  43. return {
  44. ...actual,
  45. query(params: Parameters<typeof actual.query>[0]): Query {
  46. const query = actual.query(sdkTestOverrides.maxTurns === undefined
  47. ? params
  48. : {
  49. ...params,
  50. options: { ...params.options, maxTurns: sdkTestOverrides.maxTurns },
  51. })
  52. // Observe the real SDK stream without replacing its protocol or CLI.
  53. return new Proxy(query, {
  54. get(target, property) {
  55. if (property === Symbol.asyncIterator) {
  56. return async function* (): AsyncGenerator<SDKMessage, void> {
  57. for await (const message of target) {
  58. observedSdkMessages.push(message)
  59. yield message
  60. }
  61. }
  62. }
  63. const value: unknown = Reflect.get(target, property, target)
  64. if (typeof value === 'function') {
  65. const method = value as (...args: unknown[]) => unknown
  66. return method.bind(target)
  67. }
  68. return value
  69. },
  70. })
  71. },
  72. }
  73. })
  74. const execFileAsync = promisify(execFile)
  75. const sdkRoot = dirname(fileURLToPath(
  76. import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
  77. ))
  78. const sdkPackage = JSON.parse(readFileSync(
  79. join(sdkRoot, 'package.json'),
  80. 'utf8',
  81. )) as {
  82. version: string
  83. claudeCodeVersion: string
  84. optionalDependencies: Record<string, string>
  85. }
  86. const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
  87. const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!)
  88. const claudeBin = join(
  89. platformRoot,
  90. process.platform === 'win32' ? 'claude.exe' : 'claude',
  91. )
  92. const settingsModel = 'dsh-settings-inheritance-marker'
  93. const fakeKey = 'dsh-fake-anthropic-key'
  94. const roots: string[] = []
  95. const fixtures: MessagesFixture[] = []
  96. const contexts: Context[] = []
  97. // Ambient Anthropic model env leaks into the real CLI and overrides the
  98. // fixture settings.json on developer machines; delete it for this file and
  99. // restore it after, like the workspace-context USERPROFILE isolation.
  100. const ambientAnthropicModel = process.env.ANTHROPIC_MODEL
  101. const ambientAnthropicSmallFastModel = process.env.ANTHROPIC_SMALL_FAST_MODEL
  102. beforeAll(() => {
  103. delete process.env.ANTHROPIC_MODEL
  104. delete process.env.ANTHROPIC_SMALL_FAST_MODEL
  105. })
  106. afterAll(() => {
  107. if (ambientAnthropicModel !== undefined) process.env.ANTHROPIC_MODEL = ambientAnthropicModel
  108. if (ambientAnthropicSmallFastModel !== undefined) process.env.ANTHROPIC_SMALL_FAST_MODEL = ambientAnthropicSmallFastModel
  109. })
  110. afterEach(async () => {
  111. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  112. await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
  113. for (const root of roots.splice(0)) {
  114. await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
  115. }
  116. observedSdkMessages.length = 0
  117. delete sdkTestOverrides.maxTurns
  118. })
  119. interface RealHarness {
  120. readonly ctx: Context
  121. readonly handles: SubprocessHandle[]
  122. readonly spawnSpecs: SubprocessSpawnSpec[]
  123. readonly parent: Agent
  124. readonly workspace: string
  125. readonly env: Record<string, string>
  126. }
  127. interface RealInstanceFixture {
  128. readonly fixture: MessagesFixture
  129. readonly workspace: string
  130. readonly env: Record<string, string>
  131. }
  132. async function realInstanceFixture(
  133. behavior: MessagesBehavior,
  134. nativeAllow: readonly string[] = [],
  135. ): Promise<RealInstanceFixture> {
  136. const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-real-'))
  137. roots.push(root)
  138. const workspace = join(root, 'workspace')
  139. const claudeConfig = join(root, 'claude-config')
  140. const xdgConfig = join(root, 'xdg')
  141. mkdirSync(workspace)
  142. mkdirSync(claudeConfig)
  143. mkdirSync(xdgConfig)
  144. writeFileSync(
  145. join(claudeConfig, 'settings.json'),
  146. `${JSON.stringify({
  147. model: settingsModel,
  148. permissions: {
  149. defaultMode: 'default',
  150. ...nativeAllow.length === 0 ? {} : { allow: nativeAllow },
  151. },
  152. }, null, 2)}\n`,
  153. )
  154. const fixture = await startMessagesFixture(behavior)
  155. fixtures.push(fixture)
  156. const env = {
  157. ANTHROPIC_API_KEY: fakeKey,
  158. ANTHROPIC_BASE_URL: fixture.baseUrl,
  159. CLAUDE_CONFIG_DIR: claudeConfig,
  160. HOME: root,
  161. XDG_CONFIG_HOME: xdgConfig,
  162. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
  163. CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1',
  164. DISABLE_TELEMETRY: '1',
  165. DISABLE_ERROR_REPORTING: '1',
  166. HTTP_PROXY: '',
  167. HTTPS_PROXY: '',
  168. ALL_PROXY: '',
  169. NO_PROXY: '127.0.0.1,localhost',
  170. }
  171. return { fixture, workspace, env }
  172. }
  173. interface RealRuntime {
  174. readonly ctx: Context
  175. readonly handles: SubprocessHandle[]
  176. readonly spawnSpecs: SubprocessSpawnSpec[]
  177. }
  178. async function realRuntime(): Promise<RealRuntime> {
  179. const ctx = new Context()
  180. contexts.push(ctx)
  181. await ctx.plugin(SubagentRuntime)
  182. await ctx.plugin(LocalSubprocessRuntime)
  183. const handles: SubprocessHandle[] = []
  184. const spawnSpecs: SubprocessSpawnSpec[] = []
  185. const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
  186. vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
  187. spawnSpecs.push(spec)
  188. const handle = spawn(spec)
  189. handles.push(handle)
  190. return handle
  191. })
  192. return { ctx, handles, spawnSpecs }
  193. }
  194. async function realHarness(
  195. behavior: MessagesBehavior,
  196. permissionMode?: ClaudeCodePermissionMode,
  197. nativeAllow: readonly string[] = [],
  198. ): Promise<{
  199. readonly harness: RealHarness
  200. readonly fixture: MessagesFixture
  201. }> {
  202. const instance = await realInstanceFixture(behavior, nativeAllow)
  203. const { ctx, handles, spawnSpecs } = await realRuntime()
  204. await ctx.plugin(claudeCode, {
  205. env: instance.env,
  206. ...permissionMode === undefined ? {} : { permissionMode },
  207. disposeGraceMs: 3_000,
  208. })
  209. const parent = {
  210. id: 'real-parent',
  211. session: { header: { cwd: instance.workspace } },
  212. } as unknown as Agent
  213. return {
  214. harness: {
  215. ctx,
  216. handles,
  217. spawnSpecs,
  218. parent,
  219. workspace: instance.workspace,
  220. env: instance.env,
  221. },
  222. fixture: instance.fixture,
  223. }
  224. }
  225. async function expectQuiescent(
  226. handles: readonly SubprocessHandle[],
  227. ): Promise<void> {
  228. expect(handles.length).toBeGreaterThan(0)
  229. for (const handle of handles) {
  230. await expect(handle.waitForExit()).resolves.toBe(true)
  231. const outcome = await handle.done
  232. expect(outcome).toHaveProperty('exitCode')
  233. expect(outcome).toHaveProperty('signal')
  234. }
  235. }
  236. function expectedFailure(
  237. stage: 'query-run' | 'process',
  238. category: 'error_during_execution' | 'process-exit',
  239. outcome: SubprocessOutcome,
  240. ): string {
  241. const fields = [
  242. 'product: Claude Code',
  243. `stage: ${stage}`,
  244. `category: ${category}`,
  245. ]
  246. if (outcome.exitCode !== null) fields.push(`exit code: ${outcome.exitCode}`)
  247. if (outcome.signal !== null) fields.push(`signal: ${outcome.signal}`)
  248. return `Product subagent failure (${fields.join('; ')})`
  249. }
  250. function expectedObservedFailure(outcome: SubprocessOutcome): string {
  251. return observedSdkMessages.some(message =>
  252. message.type === 'result'
  253. && message.subtype === 'error_during_execution')
  254. ? expectedFailure('query-run', 'error_during_execution', outcome)
  255. : expectedFailure('process', 'process-exit', outcome)
  256. }
  257. function startRequest(
  258. harness: RealHarness,
  259. prompt: string,
  260. signal = new AbortController().signal,
  261. ) {
  262. return harness.ctx.subagents.start('claude-code', {
  263. prompt: [{ type: 'text', text: prompt }],
  264. parent: harness.parent,
  265. signal,
  266. })
  267. }
  268. describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 fixture', {
  269. timeout: 60_000,
  270. }, () => {
  271. it('inherits host settings and sends the exact task and fake key to local Messages', async () => {
  272. const sentinel = 'REAL_CLAUDE_CODE_SENTINEL_2_1_220'
  273. const task = 'Return the fixture sentinel exactly.'
  274. const { harness, fixture } = await realHarness({
  275. kind: 'complete',
  276. text: sentinel,
  277. })
  278. expect(sdkPackage.version).toBe('0.3.220')
  279. expect(sdkPackage.claudeCodeVersion).toBe('2.1.220')
  280. expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220')
  281. const version = await execFileAsync(claudeBin, ['--version'], {
  282. env: { ...process.env, ...harness.env },
  283. })
  284. expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)')
  285. const run = await startRequest(harness, task)
  286. await expect(run.result).resolves.toEqual({
  287. output: [{ type: 'text', text: sentinel }],
  288. stopReason: 'completed',
  289. })
  290. await run.dispose()
  291. const initMessage = observedSdkMessages.find(
  292. (message): message is SDKSystemMessage =>
  293. message.type === 'system' && message.subtype === 'init',
  294. )
  295. expect(initMessage?.claude_code_version).toBe('2.1.220')
  296. const spawnedExecutable = harness.spawnSpecs[0]?.argv[0]
  297. expect(spawnedExecutable).toBeDefined()
  298. expect(process.platform === 'win32'
  299. ? realpathSync(spawnedExecutable!).toLowerCase()
  300. : realpathSync(spawnedExecutable!))
  301. .toBe(process.platform === 'win32'
  302. ? realpathSync(claudeBin).toLowerCase()
  303. : realpathSync(claudeBin))
  304. expect(fixture.requests).toHaveLength(1)
  305. const recorded = fixture.requests[0]!
  306. expect(recorded.method).toBe('POST')
  307. expect(recorded.path).toMatch(/^\/v1\/messages(?:\?.*)?$/)
  308. expect(recorded.headers['x-api-key']).toBe(fakeKey)
  309. expect(recorded.body.model).toBe(settingsModel)
  310. expect(Array.isArray(recorded.body.messages)).toBe(true)
  311. const messageTexts = (
  312. recorded.body.messages as Array<{ content?: unknown }>
  313. ).flatMap((message): unknown[] =>
  314. Array.isArray(message.content) ? message.content as unknown[] : [])
  315. .filter((block): block is { type: string; text: string } =>
  316. typeof block === 'object'
  317. && block !== null
  318. && 'type' in block
  319. && block.type === 'text'
  320. && 'text' in block
  321. && typeof block.text === 'string')
  322. .map(block => block.text)
  323. expect(messageTexts.filter(text => text.includes(task))).toEqual([task])
  324. await expectQuiescent(harness.handles)
  325. })
  326. it('maps a real SDK max-turns result to safe query-run facts', async () => {
  327. const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-max-turns-'))
  328. roots.push(root)
  329. const target = join(root, 'max-turns.txt')
  330. sdkTestOverrides.maxTurns = 1
  331. const { harness, fixture } = await realHarness({
  332. kind: 'tool-use',
  333. toolName: 'Write',
  334. input: {
  335. file_path: target,
  336. content: 'real-sdk-max-turns',
  337. },
  338. }, 'bypassPermissions')
  339. const run = await startRequest(harness, 'Exercise the SDK max-turns result.')
  340. const result = await run.result
  341. expect(observedSdkMessages
  342. .filter(message => message.type === 'result')
  343. .map(message => message.subtype)).toEqual(['error_max_turns'])
  344. expect(result).toMatchObject({
  345. output: [],
  346. stopReason: 'error',
  347. })
  348. expect(result.diagnostic).toContain(
  349. 'product: Claude Code; stage: query-run; category: error_max_turns',
  350. )
  351. expect(readFileSync(target, 'utf8')).toBe('real-sdk-max-turns')
  352. expect(result.diagnostic).not.toContain(target)
  353. expect(result.diagnostic).not.toContain('real-sdk-max-turns')
  354. await run.dispose()
  355. expect(fixture.requests).toHaveLength(1)
  356. await expectQuiescent(harness.handles)
  357. })
  358. it('runs two named instances concurrently and unloads one without revoking its run', async () => {
  359. const safeInstance = await realInstanceFixture({ kind: 'hold' })
  360. const bypassInstance = await realInstanceFixture({
  361. kind: 'complete',
  362. text: 'NAMED_BYPASS_RESULT',
  363. })
  364. const { ctx, handles, spawnSpecs } = await realRuntime()
  365. const safeFiber = await ctx.plugin(claudeCode, {
  366. providerName: 'claude-safe',
  367. env: safeInstance.env,
  368. permissionMode: 'dontAsk',
  369. disposeGraceMs: 3_000,
  370. })
  371. const bypassFiber = await ctx.plugin(claudeCode, {
  372. providerName: 'claude-bypass',
  373. env: bypassInstance.env,
  374. permissionMode: 'bypassPermissions',
  375. disposeGraceMs: 3_000,
  376. })
  377. const safeParent = {
  378. id: 'safe-parent',
  379. session: { header: { cwd: safeInstance.workspace } },
  380. } as unknown as Agent
  381. const bypassParent = {
  382. id: 'bypass-parent',
  383. session: { header: { cwd: bypassInstance.workspace } },
  384. } as unknown as Agent
  385. const safeController = new AbortController()
  386. const [safeRun, bypassRun] = await Promise.all([
  387. ctx.subagents.start('claude-safe', {
  388. prompt: [{ type: 'text', text: 'Hold the safe instance.' }],
  389. parent: safeParent,
  390. signal: safeController.signal,
  391. }),
  392. ctx.subagents.start('claude-bypass', {
  393. prompt: [{ type: 'text', text: 'Complete the bypass instance.' }],
  394. parent: bypassParent,
  395. signal: new AbortController().signal,
  396. }),
  397. ])
  398. await safeInstance.fixture.requestStarted
  399. await safeFiber.dispose()
  400. expect(ctx.subagents.list()).toEqual(['claude-bypass'])
  401. await expect(ctx.subagents.start('claude-safe', {
  402. prompt: [{ type: 'text', text: 'This start must fail.' }],
  403. parent: safeParent,
  404. signal: new AbortController().signal,
  405. })).rejects.toMatchObject({ code: 'NO_PROVIDER' })
  406. await expect(bypassRun.result).resolves.toEqual({
  407. output: [{ type: 'text', text: 'NAMED_BYPASS_RESULT' }],
  408. stopReason: 'completed',
  409. })
  410. safeController.abort(new Error('cancel only the published safe run'))
  411. await expect(safeRun.result).resolves.toEqual({
  412. output: [],
  413. stopReason: 'aborted',
  414. })
  415. await Promise.all([safeRun.dispose(), bypassRun.dispose()])
  416. expect(safeInstance.fixture.requests).toHaveLength(1)
  417. expect(bypassInstance.fixture.requests).toHaveLength(1)
  418. expect(safeInstance.fixture.requests[0]?.body.messages)
  419. .not.toEqual(bypassInstance.fixture.requests[0]?.body.messages)
  420. expect(spawnSpecs.map(spec => spec.env?.CLAUDE_CONFIG_DIR).sort())
  421. .toEqual([
  422. safeInstance.env.CLAUDE_CONFIG_DIR,
  423. bypassInstance.env.CLAUDE_CONFIG_DIR,
  424. ].sort())
  425. await expectQuiescent(handles)
  426. await bypassFiber.dispose()
  427. expect(ctx.subagents.list()).toEqual([])
  428. })
  429. it('maps a real CLI process failure to its exit outcome', async () => {
  430. const { harness, fixture } = await realHarness({ kind: 'hold' })
  431. const run = await startRequest(harness, 'Exercise the failure path.')
  432. await fixture.requestStarted
  433. expect(harness.handles).toHaveLength(1)
  434. harness.handles[0]!.terminate()
  435. const outcome = await harness.handles[0]!.done
  436. const result = await run.result
  437. expect(result.output).toEqual([])
  438. expect(result.stopReason).toBe('error')
  439. expect(result.diagnostic).toBe(expectedObservedFailure(outcome))
  440. await run.dispose()
  441. expect(fixture.requests).toHaveLength(1)
  442. expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey)
  443. await expectQuiescent(harness.handles)
  444. })
  445. it('overrides interactive settings, denies a write, and returns a safe diagnostic', async () => {
  446. const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-denied-target-'))
  447. roots.push(root)
  448. const target = join(root, 'denied.txt')
  449. const { harness } = await realHarness({
  450. kind: 'tool-use',
  451. toolName: 'Write',
  452. input: {
  453. file_path: target,
  454. content: 'SECRET_TOKEN must not reach the diagnostic',
  455. },
  456. })
  457. const run = await startRequest(harness, 'Write the requested fixture file.')
  458. await vi.waitFor(() => {
  459. expect(observedSdkMessages.some(message =>
  460. message.type === 'system'
  461. && message.subtype === 'permission_denied')).toBe(true)
  462. }, { timeout: 30_000 })
  463. expect(existsSync(target)).toBe(false)
  464. harness.handles[0]!.terminate()
  465. const outcome = await harness.handles[0]!.done
  466. const result = await run.result
  467. expect(result.output).toEqual([])
  468. expect(result.stopReason).toBe('error')
  469. const diagnosticLines = result.diagnostic?.split('\n') ?? []
  470. expect(diagnosticLines[0]).toBe(expectedObservedFailure(outcome))
  471. expect(diagnosticLines[1]).toBe(
  472. 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt',
  473. )
  474. expect(result.diagnostic).not.toContain(target)
  475. expect(result.diagnostic).not.toContain('SECRET_TOKEN')
  476. await run.dispose()
  477. await expectQuiescent(harness.handles)
  478. })
  479. it('runs an explicitly selected bypass write in the isolated workspace', async () => {
  480. const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-bypass-target-'))
  481. roots.push(root)
  482. const target = join(root, 'bypass.txt')
  483. const { harness } = await realHarness({
  484. kind: 'tool-use',
  485. toolName: 'Write',
  486. input: {
  487. file_path: target,
  488. content: 'bypass write completed',
  489. },
  490. finalText: 'write complete',
  491. }, 'bypassPermissions')
  492. const run = await startRequest(harness, 'Write the requested fixture file.')
  493. await expect(run.result).resolves.toEqual({
  494. output: [{ type: 'text', text: 'write complete' }],
  495. stopReason: 'completed',
  496. })
  497. expect(readFileSync(target, 'utf8')).toBe('bypass write completed')
  498. await run.dispose()
  499. await expectQuiescent(harness.handles)
  500. })
  501. it('returns the completed plan without approving execution', async () => {
  502. const { harness, fixture } = await realHarness({
  503. kind: 'tool-use',
  504. toolName: 'ExitPlanMode',
  505. input: {},
  506. finalText: 'PLAN_ONLY_RESULT',
  507. }, 'plan', ['ExitPlanMode'])
  508. const run = await startRequest(harness, 'Design the fixture change without implementing it.')
  509. await expect(run.result).resolves.toEqual({
  510. output: [{ type: 'text', text: 'PLAN_ONLY_RESULT' }],
  511. stopReason: 'completed',
  512. })
  513. expect(fixture.requests).toHaveLength(2)
  514. expect(JSON.stringify(fixture.requests[1]?.body.messages))
  515. .toContain('ExitPlanMode exists but is not enabled in this context')
  516. await run.dispose()
  517. await expectQuiescent(harness.handles)
  518. })
  519. it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => {
  520. const { harness, fixture } = await realHarness({ kind: 'hold' })
  521. const controller = new AbortController()
  522. const run = await startRequest(
  523. harness,
  524. 'Wait for cancellation.',
  525. controller.signal,
  526. )
  527. await fixture.requestStarted
  528. controller.abort(new Error('real product cancellation'))
  529. await expect(run.result).resolves.toEqual({
  530. output: [],
  531. stopReason: 'aborted',
  532. })
  533. await run.dispose()
  534. await expectQuiescent(harness.handles)
  535. })
  536. })