Files
deepseek-harness/packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts
Coder 8719fa84f0
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
feat: add economy/maximum presets, tool-lab and subagent-cursor extensions
- new economy and maximum agent presets with three-role pipeline skill
- new packages/extensions/tool-lab (home-lab ComfyUI/Docling/Whishper tools)
- new packages/subagent/subagent-cursor provider
- openrouter balance UI with on-demand refresh
- session projection context-seed boundary fold
- regenerate docs catalogs; keep local searxng benchmark scripts
2026-08-23 22:12:30 +07:00

705 lines
25 KiB
TypeScript

import { PassThrough } from 'node:stream'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SubagentRuntime, { type SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type {
SubprocessHandle,
SubprocessOutcome,
} from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as cursor from '../src/index.ts'
import * as invariant from '../src/invariant.ts'
import {
assertDirectlySpawnable,
cursorAgentArgv,
DEFAULT_DISPOSE_GRACE_MS,
disposeCursorChild,
startCursorRun,
textTask,
type CursorRunSpec,
} from '../src/run.ts'
import { CursorStreamWire } from '../src/wire.ts'
type JsonObject = Record<string, unknown>
const EXECUTABLE = '/opt/cursor/cursor-agent'
const fakeParent = {
id: 'parent',
session: { header: { cwd: process.cwd() } },
} as unknown as Agent
function request(
prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
signal = new AbortController().signal,
) {
return { prompt, parent: fakeParent, signal }
}
async function nextTask(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
function initEvent(overrides: JsonObject = {}): JsonObject {
return {
type: 'system',
subtype: 'init',
apiKeySource: 'env',
cwd: process.cwd(),
session_id: 'chat-1',
model: 'cursor-model',
permissionMode: 'default',
...overrides,
}
}
function assistantEvent(content: unknown): JsonObject {
return {
type: 'assistant',
message: { role: 'assistant', content },
session_id: 'chat-1',
}
}
function resultEvent(overrides: JsonObject = {}): JsonObject {
return {
type: 'result',
subtype: 'success',
duration_ms: 12,
duration_api_ms: 10,
is_error: false,
result: 'the final answer',
session_id: 'chat-1',
...overrides,
}
}
/** Writes stream-json events the way the real CLI writes its stdout. */
class StreamPeer {
constructor(private readonly output: PassThrough) {}
send(...events: readonly JsonObject[]): void {
this.output.write(`${events.map(event => JSON.stringify(event)).join('\n')}\n`)
}
raw(text: string): void {
this.output.write(text)
}
}
interface FakeChildOptions {
readonly pid?: number
readonly exitOnTerminate?: boolean
readonly doneError?: Error
}
interface FakeChild {
readonly handle: SubprocessHandle
readonly peer: StreamPeer
readonly fromChild: PassThrough
readonly toChild: PassThrough
readonly settle: (outcome?: SubprocessOutcome) => void
readonly fail: (error: Error) => void
readonly terminate: () => void
readonly waitForExit: (signal?: AbortSignal) => Promise<boolean>
}
function fakeChild(options: FakeChildOptions = {}): FakeChild {
const fromChild = new PassThrough()
const toChild = new PassThrough()
const peer = new StreamPeer(fromChild)
let exited = false
let resolveDone!: (outcome: SubprocessOutcome) => void
let rejectDone!: (error: Error) => void
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
resolveDone = resolve
rejectDone = reject
})
const settle = (
outcome: SubprocessOutcome = { exitCode: 0, signal: null },
): void => {
if (exited) return
exited = true
resolveDone(outcome)
}
const fail = (error: Error): void => {
if (exited) return
exited = true
rejectDone(error)
}
if (options.doneError !== undefined) fail(options.doneError)
const terminate = vi.fn(() => {
if (options.exitOnTerminate !== false) settle()
})
const waitForExit = vi.fn(async (signal?: AbortSignal) => {
if (exited) return true
if (signal === undefined) {
await done.catch(() => {})
return true
}
return await new Promise<boolean>((resolve) => {
const onAbort = (): void => { resolve(false) }
signal.addEventListener('abort', onAbort, { once: true })
void done.then(
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
)
})
})
const handle: SubprocessHandle = {
pid: options.pid ?? 4321,
stdin: toChild,
stdout: fromChild,
stderr: undefined,
collected: {},
done,
terminate,
waitForExit,
}
return {
handle,
peer,
fromChild,
toChild,
settle,
fail,
terminate,
waitForExit,
}
}
function runSpec(
child: FakeChild,
overrides: Partial<CursorRunSpec> = {},
): CursorRunSpec {
return {
cwd: process.cwd(),
executable: EXECUTABLE,
env: {},
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
force: false,
trust: false,
spawn: () => child.handle,
...overrides,
}
}
async function publishRun(
child = fakeChild(),
signal = new AbortController().signal,
specOverrides: Partial<CursorRunSpec> = {},
) {
const starting = startCursorRun(request(undefined, signal), runSpec(child, specOverrides))
await nextTask()
child.peer.send(initEvent())
return { child, run: await starting }
}
function startedWire(): { readonly child: FakeChild; readonly wire: CursorStreamWire } {
const child = fakeChild()
const wire = new CursorStreamWire(child.handle.stdout!)
wire.start()
return { child, wire }
}
describe('task admission and command construction', () => {
it('accepts one or more text blocks and rejects empty, non-text, or option-shaped tasks', () => {
expect(textTask([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab')
expect(() => textTask([])).toThrow('must contain only text blocks')
expect(() => textTask([{ type: 'reasoning', text: 'x' }]))
.toThrow('must contain only text blocks')
expect(() => textTask([{ type: 'text', text: ' ' }])).toThrow('must not be empty')
expect(() => textTask([{ type: 'text', text: '--force me' }]))
.toThrow('must not begin with "-"')
})
it('refuses a Windows batch shim so task text never reaches a shell', () => {
expect(assertDirectlySpawnable('C:\\bin\\cursor-agent.exe', 'win32'))
.toBe('C:\\bin\\cursor-agent.exe')
expect(assertDirectlySpawnable('/usr/bin/cursor-agent', 'linux'))
.toBe('/usr/bin/cursor-agent')
// A shim is only unusable where it needs cmd.exe.
expect(assertDirectlySpawnable('/usr/bin/cursor-agent.cmd', 'linux'))
.toBe('/usr/bin/cursor-agent.cmd')
for (const shim of ['C:\\bin\\cursor-agent.cmd', 'C:\\bin\\cursor-agent.BAT']) {
expect(() => assertDirectlySpawnable(shim, 'win32')).toThrow('is a batch shim')
}
// The omitted platform reads the host, so the expectation follows it.
const underHostPlatform = (): string => assertDirectlySpawnable('C:\\bin\\cursor-agent.cmd')
if (process.platform === 'win32') {
expect(underHostPlatform).toThrow('is a batch shim')
} else {
expect(underHostPlatform()).toBe('C:\\bin\\cursor-agent.cmd')
}
})
it('builds the fixed print-mode argv and adds only selected permissions', () => {
const base = { executable: EXECUTABLE, cwd: '/work', force: false, trust: false }
expect(cursorAgentArgv(base, 'ship it')).toEqual([
EXECUTABLE,
'--print',
'--output-format',
'stream-json',
'--workspace',
'/work',
'ship it',
])
expect(cursorAgentArgv({ ...base, force: true, trust: true }, 'ship it')).toEqual([
EXECUTABLE,
'--print',
'--output-format',
'stream-json',
'--workspace',
'/work',
'--force',
'--trust',
'ship it',
])
})
})
describe('package contracts', () => {
it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const fiber = await ctx.plugin(cursor, {})
expect(ctx.subagents.getProvider('cursor')).toMatchObject({
name: 'cursor',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
})
expect(ctx.subagents.list()).toEqual(['cursor'])
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
await expect(ctx.plugin(cursor, { disposeGraceMs }))
.rejects.toThrow('disposeGraceMs must be a positive finite number')
}
await expect(ctx.plugin(cursor, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
await ctx.fiber.dispose()
})
it('requires a parent session cwd before resolving or spawning anything', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
await ctx.plugin(cursor, {})
await expect(ctx.subagents.start('cursor', {
prompt: [{ type: 'text', text: 'task' }],
parent: {
id: 'parent-without-cwd',
session: { header: {} },
} as unknown as Agent,
signal: new AbortController().signal,
})).rejects.toThrow(
'subagent-cursor: no working directory for the child — delegate from a parent session that has one',
)
expect(resolveExecutable).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('keeps the namespace export shape and package-owned empty invariant', async () => {
expect('default' in cursor).toBe(false)
expect(cursor.name).toBe('subagent-cursor')
expect(cursor.inject).toEqual(['subagents', 'subprocess'])
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(cursor)).toBe(cursor)
const dispose = vi.fn()
const register = vi.fn((
_packageName: string,
_installer: InvariantInstaller,
) => dispose)
const ctx = { invariants: { register } } as unknown as Context
await expect(invariant.apply(ctx)).resolves.toBe(dispose)
expect(register).toHaveBeenCalledWith(
'@deepseek-ai/dsh-subagent-cursor',
expect.any(Function),
)
const install = register.mock.calls[0]![1]
await install(new Context(), (message) => { throw new Error(message) })
expect(invariant.name).toBe('subagent-cursor-invariant')
expect(invariant.inject).toEqual(['invariants'])
})
})
describe('CursorStreamWire', () => {
it('gates on init, keeps the terminal answer, and ignores unrelated events', async () => {
const { child, wire } = startedWire()
wire.start()
child.peer.send(initEvent())
await expect(wire.ready()).resolves.toEqual({
sessionId: 'chat-1',
model: 'cursor-model',
})
child.peer.send(
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'task' }] } },
{ type: 'tool_call', subtype: 'started', call_id: 'c1', tool_call: {} },
{ type: 'system', subtype: 'usage', tokens: 12 },
assistantEvent([{ type: 'text', text: 'thinking out loud' }]),
{ type: 'newer_cli_event', payload: 1 },
)
await nextTask()
expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'thinking out loud' }])
child.peer.send(resultEvent())
await expect(wire.awaitResult()).resolves.toEqual({
output: [{ type: 'text', text: 'the final answer' }],
stopReason: 'completed',
})
})
it('reads events split across chunks and skips blank lines', async () => {
const { child, wire } = startedWire()
const frame = JSON.stringify(initEvent())
child.peer.raw(`\n \n${frame.slice(0, 10)}`)
await nextTask()
child.peer.raw(`${frame.slice(10)}\n`)
await expect(wire.ready()).resolves.toMatchObject({ sessionId: 'chat-1' })
expect(wire.collectOutput()).toEqual([])
})
it('reports an absent model rather than inventing one', async () => {
const { child, wire } = startedWire()
child.peer.send(initEvent({ model: 42 }))
await expect(wire.ready()).resolves.toEqual({
sessionId: 'chat-1',
model: undefined,
})
})
it('keeps the last non-empty assistant message and drops non-text blocks', async () => {
const { child, wire } = startedWire()
child.peer.send(
initEvent(),
assistantEvent([{ type: 'text', text: 'first' }]),
assistantEvent([{ type: 'image', source: {} }]),
assistantEvent([]),
assistantEvent([{ type: 'text', text: 'second' }, { type: 'image', source: {} }]),
)
await nextTask()
expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'second' }])
})
it('fails closed on every unusable terminal result', async () => {
for (const [overrides, detail] of [
[{ subtype: 'error' }, 'terminal result subtype error'],
[{ subtype: 7 }, 'terminal result subtype unknown'],
[{ is_error: true }, 'success result was marked as an error'],
[{ result: 12 }, 'success result was marked as an error'],
[{ result: ' ' }, 'success result was marked as an error'],
] as const) {
const { child, wire } = startedWire()
child.peer.send(initEvent(), resultEvent(overrides))
await expect(wire.awaitResult()).rejects.toThrow(detail)
}
})
it('refuses a result that arrives without an announced session', async () => {
const { child, wire } = startedWire()
child.peer.send(resultEvent())
await expect(wire.ready()).rejects.toThrow('without announcing a session')
await expect(wire.awaitResult()).rejects.toThrow('without announcing a session')
})
it('treats malformed stdout as a protocol failure', async () => {
for (const [line, detail] of [
['not json at all', 'a stdout line that is not JSON'],
['[1,2]', 'an invalid event'],
['null', 'an invalid event'],
[JSON.stringify({ type: 'system', subtype: 'init', session_id: '' }), 'an invalid init session id'],
[JSON.stringify({ type: 'assistant', message: 'text' }), 'an invalid assistant message'],
[JSON.stringify(assistantEvent('not an array')), 'an invalid assistant message content'],
] as const) {
const { child, wire } = startedWire()
child.peer.raw(`${line}\n`)
await expect(wire.awaitResult()).rejects.toThrow(detail)
}
})
it('fails pending gates on stream error, end of stream, and close', async () => {
const broken = startedWire()
broken.child.fromChild.emit('error', new Error('stdout broke'))
await expect(broken.wire.awaitResult()).rejects.toThrow('stdout broke')
const ended = startedWire()
ended.child.fromChild.end()
await expect(ended.wire.awaitResult()).rejects.toThrow('ended without a terminal result')
const closed = startedWire()
closed.wire.close()
closed.wire.close()
await expect(closed.wire.ready()).rejects.toThrow('event stream closed')
// A completed run keeps its result across teardown and end of stream.
const done = startedWire()
done.child.peer.send(initEvent(), resultEvent())
await expect(done.wire.awaitResult()).resolves.toMatchObject({ stopReason: 'completed' })
done.wire.close()
done.child.fromChild.end()
await expect(done.wire.awaitResult()).resolves.toMatchObject({ stopReason: 'completed' })
})
it('is safe to close before it is started', async () => {
const child = fakeChild()
const wire = new CursorStreamWire(child.handle.stdout!)
wire.close()
await expect(wire.ready()).rejects.toThrow('event stream closed')
})
})
describe('run lifecycle and quiescence', () => {
it('spawns the fixed command, publishes after init, and disposes once', async () => {
const child = fakeChild()
const spawn = vi.fn(() => child.handle)
const starting = startCursorRun(
request(),
runSpec(child, { spawn, force: true, trust: true, disposeGraceMs: 40, env: { CURSOR_API_KEY: 'fake' } }),
)
await nextTask()
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: [
EXECUTABLE,
'--print',
'--output-format',
'stream-json',
'--workspace',
process.cwd(),
'--force',
'--trust',
'do the task',
],
cwd: process.cwd(),
graceMs: 40,
env: { CURSOR_API_KEY: 'fake' },
}))
child.peer.send(initEvent())
const run = await starting
expect(run.id).toMatch(/^[0-9a-f-]{36}$/)
expect(run.localAgent).toBeUndefined()
child.peer.send(resultEvent())
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'the final answer' }],
stopReason: 'completed',
})
const first = run.dispose()
expect(run.dispose()).toBe(first)
await first
expect(child.terminate).toHaveBeenCalledTimes(1)
})
it('closes stdin so an unattended prompt cannot stall the child', async () => {
const { child, run } = await publishRun()
expect(child.toChild.writableEnded).toBe(true)
child.peer.send(resultEvent())
await run.result
await run.dispose()
})
it('settles a cancelled run as aborted with the output collected so far', async () => {
const controller = new AbortController()
const child = fakeChild({ exitOnTerminate: false })
const { run } = await publishRun(child, controller.signal)
child.peer.send(assistantEvent([{ type: 'text', text: 'partial work' }]))
await nextTask()
controller.abort(new Error('parent stopped waiting'))
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'partial work' }],
stopReason: 'aborted',
})
child.settle({ exitCode: null, signal: 'SIGTERM' })
await run.dispose()
})
it('flattens a child exit and a protocol failure after publication', async () => {
const exited = await publishRun()
exited.child.settle({ exitCode: 2, signal: null })
await expect(exited.run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
await exited.run.dispose()
const onError = vi.fn<(error: Error, stopReason: SubagentStopReason) => void>()
const malformed = await publishRun(fakeChild(), new AbortController().signal, { onError })
malformed.child.peer.raw('garbage\n')
await expect(malformed.run.result).resolves.toMatchObject({ stopReason: 'error' })
expect(onError).toHaveBeenCalledTimes(1)
const [reported, reportedReason] = onError.mock.calls[0]!
expect(reported.message).toContain('not JSON')
expect(reportedReason).toBe('error')
await malformed.run.dispose()
})
it('rejects before spawn when the request is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const child = fakeChild()
const spawn = vi.fn(() => child.handle)
await expect(startCursorRun(request(undefined, controller.signal), runSpec(child, { spawn })))
.rejects.toThrow('aborted before cursor-agent startup')
expect(spawn).not.toHaveBeenCalled()
})
it('rolls the child back when startup fails or is aborted before publication', async () => {
const failed = fakeChild()
const failing = startCursorRun(request(), runSpec(failed))
await nextTask()
failed.peer.raw('not json\n')
await expect(failing).rejects.toThrow('not JSON')
expect(failed.terminate).toHaveBeenCalledTimes(1)
const controller = new AbortController()
const aborted = fakeChild()
const aborting = startCursorRun(request(undefined, controller.signal), runSpec(aborted))
await nextTask()
controller.abort()
await expect(aborting).rejects.toThrow('aborted before run publication')
expect(aborted.terminate).toHaveBeenCalledTimes(1)
const exited = fakeChild()
const exiting = startCursorRun(request(), runSpec(exited))
await nextTask()
exited.settle({ exitCode: 3, signal: null })
await expect(exiting).rejects.toThrow('exited before the run settled')
// A failed spawn reports pid -1, so rollback has no tree to signal.
const broken = fakeChild({ pid: -1, doneError: new Error('spawn observer failed') })
await expect(startCursorRun(request(), runSpec(broken)))
.rejects.toThrow('spawn observer failed')
expect(broken.terminate).not.toHaveBeenCalled()
})
it('reports a startup failure whose cleanup also failed as an aggregate', async () => {
const child = fakeChild()
child.handle.waitForExit = vi.fn(() => Promise.reject(new Error('tree never exited')))
const starting = startCursorRun(request(), runSpec(child))
await nextTask()
child.peer.raw('not json\n')
const failure = await starting.catch((error: unknown) => error)
expect(failure).toBeInstanceOf(AggregateError)
expect((failure as AggregateError).message)
.toContain('startup failed and cursor-agent cleanup also failed')
})
it('keeps overlapping runs isolated', async () => {
const first = await publishRun()
const second = await publishRun()
first.child.peer.send(resultEvent({ result: 'first answer' }))
second.child.peer.send(resultEvent({ subtype: 'error' }))
await expect(first.run.result).resolves.toMatchObject({
output: [{ type: 'text', text: 'first answer' }],
stopReason: 'completed',
})
await expect(second.run.result).resolves.toMatchObject({ stopReason: 'error' })
await first.run.dispose()
await second.run.dispose()
})
it('uses the registered provider config, the resolved executable, and logs flattened errors', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const child = fakeChild()
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
.mockResolvedValue(EXECUTABLE)
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => {
warnings.push(String(message))
}) as typeof ctx.logger.warn
await ctx.plugin(cursor, {
env: { CURSOR_API_KEY: 'fake' },
disposeGraceMs: 25,
force: true,
})
const starting = ctx.subagents.start('cursor', {
prompt: [{ type: 'text', text: 'task' }],
parent: fakeParent,
signal: new AbortController().signal,
})
await nextTask()
child.peer.send(initEvent())
const run = await starting
child.settle({ exitCode: 1, signal: null })
await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
expect(resolveExecutable).toHaveBeenCalledWith(
'cursor-agent',
{ CURSOR_API_KEY: 'fake' },
expect.any(AbortSignal),
)
expect(spawn).toHaveBeenCalledTimes(1)
const spawned = spawn.mock.calls[0]![0]
expect(spawned.argv).toContain('--force')
expect(spawned.env).toEqual({ CURSOR_API_KEY: 'fake' })
expect(spawned.graceMs).toBe(25)
expect(spawned.cwd).toBe(process.cwd())
expect(warnings).toEqual([
expect.stringContaining('subagent-cursor: child run failed (error):'),
])
await run.dispose().catch(() => {})
await ctx.fiber.dispose()
})
})
describe('disposeCursorChild', () => {
it('closes the stream, terminates, and waits for the managed tree', async () => {
const child = fakeChild()
const wire = new CursorStreamWire(child.handle.stdout!)
wire.start()
await disposeCursorChild(wire, child.handle)
expect(child.terminate).toHaveBeenCalledTimes(1)
expect(child.waitForExit).toHaveBeenCalledTimes(1)
})
it('does not finish disposal before the managed tree exits', async () => {
const child = fakeChild({ exitOnTerminate: false })
const wire = new CursorStreamWire(child.handle.stdout!)
let settled = false
const disposing = disposeCursorChild(wire, child.handle).then(() => { settled = true })
await nextTask()
expect(settled).toBe(false)
child.settle()
await disposing
expect(settled).toBe(true)
})
it('skips signalling a failed spawn and contains its observer rejection', async () => {
const child = fakeChild({ pid: -1, doneError: new Error('spawn failed') })
const wire = new CursorStreamWire(child.handle.stdout!)
await expect(disposeCursorChild(wire, child.handle)).resolves.toBeUndefined()
expect(child.terminate).not.toHaveBeenCalled()
})
it('reports a direct-child observer failure from a live tree', async () => {
const child = fakeChild({ exitOnTerminate: false })
const wire = new CursorStreamWire(child.handle.stdout!)
const disposing = disposeCursorChild(wire, child.handle)
child.fail(new Error('observer failed'))
await expect(disposing).rejects.toThrow('observer failed')
})
})