|
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
|
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
|
|
-import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
|
|
+import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
|
|
import * as acp from '../src/index.ts'
|
|
|
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
|
|
|
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
|
|
@@ -59,6 +59,14 @@ function text(blocks: { type: string; text?: string }[]): string {
|
|
|
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
|
|
|
}
|
|
|
|
|
|
+function expectedFailure(fields: string): string {
|
|
|
+ return `Subagent failure (provider: ACP; ${fields})`
|
|
|
+}
|
|
|
+
|
|
|
+function expectedPermission(policy: 'allow' | 'reject', requestKind: string, decision: 'allowed' | 'denied'): string {
|
|
|
+ return `ACP unattended decision (policy: ${policy}; request: ${requestKind}; decision: ${decision})`
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Poll until `file` exists (the mock touches it once its prompt is in flight),
|
|
|
* so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
|
|
|
@@ -73,6 +81,30 @@ async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function rejectFinalExitWait(child: SubprocessHandle, message: string): SubprocessHandle {
|
|
|
+ return {
|
|
|
+ pid: child.pid,
|
|
|
+ stdin: child.stdin,
|
|
|
+ stdout: child.stdout,
|
|
|
+ stderr: child.stderr,
|
|
|
+ collected: child.collected,
|
|
|
+ done: child.done,
|
|
|
+ terminate: () => { child.terminate() },
|
|
|
+ waitForExit: (signal?: AbortSignal) => signal === undefined
|
|
|
+ ? Promise.reject(new Error(message))
|
|
|
+ : Promise.resolve(false),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function rejectFinalExitWaitAfterExit(child: SubprocessHandle, message: string): SubprocessHandle {
|
|
|
+ return {
|
|
|
+ ...rejectFinalExitWait(child, message),
|
|
|
+ waitForExit: (signal?: AbortSignal) => signal === undefined
|
|
|
+ ? child.done.then(() => Promise.reject(new Error(message)))
|
|
|
+ : Promise.resolve(false),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
describe('acpStopReason', () => {
|
|
|
it('maps each ACP stop reason to the harness vocabulary', () => {
|
|
|
expect(acpStopReason('end_turn')).toBe('completed')
|
|
|
@@ -228,7 +260,7 @@ describe('cwd resolution', () => {
|
|
|
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
|
|
const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
|
|
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
|
|
- .rejects.toThrow('no working directory')
|
|
|
+ .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
|
|
|
// Resolution failed BEFORE the process boundary — nothing was launched.
|
|
|
expect(existsSync(sentinel)).toBe(false)
|
|
|
} finally {
|
|
|
@@ -349,7 +381,7 @@ describe('cwd resolution', () => {
|
|
|
const ctx = await setup({})
|
|
|
const parent = { id: 'parent', session: { header: { cwd: 'relative/workspace' } } } as unknown as Agent
|
|
|
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
|
|
- .rejects.toThrow('must be an absolute path')
|
|
|
+ .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
|
|
|
})
|
|
|
|
|
|
it('rejects a parent session cwd that names a FILE, not a directory', async () => {
|
|
|
@@ -360,7 +392,7 @@ describe('cwd resolution', () => {
|
|
|
const ctx = await setup({})
|
|
|
const parent = { id: 'parent', session: { header: { cwd: file } } } as unknown as Agent
|
|
|
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
|
|
- .rejects.toThrow('not an accessible directory')
|
|
|
+ .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
|
|
|
} finally {
|
|
|
rmSync(tmp, { recursive: true, force: true })
|
|
|
}
|
|
|
@@ -376,7 +408,7 @@ describe('cwd resolution', () => {
|
|
|
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
|
|
const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent
|
|
|
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
|
|
- .rejects.toThrow('not an accessible directory')
|
|
|
+ .rejects.toThrow(`subagent-acp: ${expectedFailure('stage: initialize; category: configuration')}`)
|
|
|
expect(existsSync(sentinel)).toBe(false)
|
|
|
} finally {
|
|
|
rmSync(tmp, { recursive: true, force: true })
|
|
|
@@ -391,6 +423,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
expect(run.id).not.toBe('acp-child-session')
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('completed')
|
|
|
+ expect(result.diagnostic).toBeUndefined()
|
|
|
expect(text(result.output)).toBe('hello from acp child')
|
|
|
const disposal = run.dispose()
|
|
|
expect(run.dispose()).toBe(disposal)
|
|
|
@@ -408,6 +441,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
const run = await ctx.subagents.start('acp', request())
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('max-tokens')
|
|
|
+ expect(result.diagnostic).toBeUndefined()
|
|
|
await run.dispose()
|
|
|
})
|
|
|
|
|
|
@@ -416,6 +450,59 @@ describe('dsh-subagent-acp', () => {
|
|
|
const run = await ctx.subagents.start('acp', request())
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('refusal')
|
|
|
+ expect(result.diagnostic).toBeUndefined()
|
|
|
+ await run.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each([
|
|
|
+ ['max_tokens', 'max-tokens', 'remote-limit'],
|
|
|
+ ['refusal', 'refusal', 'remote-refusal'],
|
|
|
+ ] as const)('adds a permission fact to %s without changing its stop reason', async (remote, stopReason, category) => {
|
|
|
+ const ctx = await setup({
|
|
|
+ MOCK_PERMISSION: '1',
|
|
|
+ MOCK_PERMISSION_IGNORE_DECISION: '1',
|
|
|
+ MOCK_TOOL_KIND: 'read',
|
|
|
+ MOCK_STOP: remote,
|
|
|
+ }, 'reject')
|
|
|
+ const run = await ctx.subagents.start('acp', request())
|
|
|
+ const result = await run.result
|
|
|
+ expect(result.stopReason).toBe(stopReason)
|
|
|
+ expect(result.diagnostic).toBe(
|
|
|
+ `${expectedFailure(`stage: prompt; category: ${category}; stop reason: ${remote}`)}\n`
|
|
|
+ + expectedPermission('reject', 'read', 'denied'),
|
|
|
+ )
|
|
|
+ await run.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps an ordinary remote cancelled stop diagnostic-free', async () => {
|
|
|
+ const ctx = await setup({ MOCK_STOP: 'cancelled' })
|
|
|
+ const run = await ctx.subagents.start('acp', request())
|
|
|
+ await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'mock child answer' }], stopReason: 'aborted' })
|
|
|
+ await run.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('preserves max_turn_requests as an actionable remote limit', async () => {
|
|
|
+ const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_STOP: 'max_turn_requests' })
|
|
|
+ const run = await ctx.subagents.start('acp', request())
|
|
|
+ const result = await run.result
|
|
|
+ expect(result).toEqual({
|
|
|
+ output: [{ type: 'text', text: 'partial' }],
|
|
|
+ diagnostic: expectedFailure('stage: prompt; category: remote-limit; stop reason: max_turn_requests'),
|
|
|
+ stopReason: 'error',
|
|
|
+ })
|
|
|
+ await run.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('uses a fixed fallback for an unknown remote stop reason', async () => {
|
|
|
+ const rawReason = 'private/path/SECRET_TOKEN'
|
|
|
+ const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_STOP: rawReason })
|
|
|
+ const run = await ctx.subagents.start('acp', request())
|
|
|
+ const result = await run.result
|
|
|
+ expect(result.stopReason).toBe('error')
|
|
|
+ expect(result.diagnostic).toBe(
|
|
|
+ expectedFailure('stage: prompt; category: unknown; stop reason: unknown'),
|
|
|
+ )
|
|
|
+ expect(result.diagnostic).not.toContain(rawReason)
|
|
|
await run.dispose()
|
|
|
})
|
|
|
|
|
|
@@ -432,6 +519,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
controller.abort('test')
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('aborted')
|
|
|
+ expect(result.diagnostic).toBeUndefined()
|
|
|
await run.dispose()
|
|
|
} finally {
|
|
|
rmSync(tmp, { recursive: true, force: true })
|
|
|
@@ -459,6 +547,35 @@ describe('dsh-subagent-acp', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
+ it('rejects a pre-aborted request through the registered provider before cwd resolution', async () => {
|
|
|
+ const ctx = await setup()
|
|
|
+ const controller = new AbortController()
|
|
|
+ controller.abort()
|
|
|
+ const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
|
|
+ await expect(ctx.subagents.start('acp', {
|
|
|
+ prompt: [{ type: 'text' as const, text: 'p' }],
|
|
|
+ parent,
|
|
|
+ signal: controller.signal,
|
|
|
+ })).rejects.toThrow('subagent request was aborted before the ACP child started')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports an initialize-stage process exit without copying the transport error', async () => {
|
|
|
+ const error = await startAcpRun(request(), {
|
|
|
+ command: process.execPath,
|
|
|
+ args: [mockServer],
|
|
|
+ cwd: process.cwd(),
|
|
|
+ permission: 'reject',
|
|
|
+ env: { MOCK_CRASH_ON_INITIALIZE: '1' },
|
|
|
+ disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
|
|
|
+ disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
|
+ spawn: spawnSubprocess,
|
|
|
+ }).catch((cause: unknown) => cause)
|
|
|
+ expect(error).toBeInstanceOf(Error)
|
|
|
+ expect((error as Error).message).toBe(
|
|
|
+ `subagent-acp: ${expectedFailure('stage: initialize; category: process-exit; exit code: 11')}`,
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
it('reaps a child whose session/new response omits the session id', async () => {
|
|
|
const tmp = mkdtempSync(join(tmpdir(), 'acp-malformed-session-'))
|
|
|
const flushed = join(tmp, 'flushed')
|
|
|
@@ -476,7 +593,9 @@ describe('dsh-subagent-acp', () => {
|
|
|
disposeEofGraceMs: 1000,
|
|
|
disposeGraceMs: 100,
|
|
|
spawn: spawnSubprocess,
|
|
|
- })).rejects.toThrow('ACP child published without a session id')
|
|
|
+ })).rejects.toThrow(
|
|
|
+ `subagent-acp: ${expectedFailure('stage: new-session; category: protocol')}`,
|
|
|
+ )
|
|
|
// Startup rejects only after its private child reaches quiescence. The
|
|
|
// marker proves rollback closed stdin and allowed the child's EOF flush.
|
|
|
expect(existsSync(flushed)).toBe(true)
|
|
|
@@ -485,6 +604,71 @@ describe('dsh-subagent-acp', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
+ it('aggregates safe startup and teardown facts when rollback itself fails', async () => {
|
|
|
+ const rawCleanup = 'rollback leaked /private/path SECRET_TOKEN'
|
|
|
+ let realChild: SubprocessHandle | undefined
|
|
|
+ const errors: string[] = []
|
|
|
+ const error = await startAcpRun(request(), {
|
|
|
+ command: process.execPath,
|
|
|
+ args: [mockServer],
|
|
|
+ cwd: process.cwd(),
|
|
|
+ permission: 'reject',
|
|
|
+ env: { MOCK_MISSING_SESSION_ID: '1' },
|
|
|
+ disposeEofGraceMs: 10,
|
|
|
+ disposeGraceMs: 10,
|
|
|
+ spawn: (spec) => {
|
|
|
+ realChild = spawnSubprocess(spec)
|
|
|
+ return rejectFinalExitWaitAfterExit(realChild, rawCleanup)
|
|
|
+ },
|
|
|
+ onError: (failure) => { errors.push(failure.message) },
|
|
|
+ }).catch((cause: unknown) => cause)
|
|
|
+ expect(error).toBeInstanceOf(AggregateError)
|
|
|
+ expect((error as Error).message).toContain(
|
|
|
+ `subagent-acp: ${expectedFailure('stage: new-session; category: protocol')}; `
|
|
|
+ + 'subagent-acp: Subagent failure (provider: ACP; stage: teardown; category: process-exit;',
|
|
|
+ )
|
|
|
+ expect((error as Error).message).not.toContain(rawCleanup)
|
|
|
+ expect(errors).toContain('ACP child published without a session id')
|
|
|
+ expect(errors).toContain(rawCleanup)
|
|
|
+ await realChild?.done
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports only the safe teardown failure when cancelled startup rollback fails', async () => {
|
|
|
+ const tmp = mkdtempSync(join(tmpdir(), 'acp-cancelled-rollback-'))
|
|
|
+ const ready = join(tmp, 'ready')
|
|
|
+ const go = join(tmp, 'go')
|
|
|
+ const rawCleanup = 'cancel rollback leaked SECRET_TOKEN'
|
|
|
+ let realChild: SubprocessHandle | undefined
|
|
|
+ try {
|
|
|
+ const controller = new AbortController()
|
|
|
+ const starting = startAcpRun(request('p', controller.signal), {
|
|
|
+ command: process.execPath,
|
|
|
+ args: [mockServer],
|
|
|
+ cwd: process.cwd(),
|
|
|
+ permission: 'reject',
|
|
|
+ env: { MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go },
|
|
|
+ disposeEofGraceMs: 10,
|
|
|
+ disposeGraceMs: 10,
|
|
|
+ spawn: (spec) => {
|
|
|
+ realChild = spawnSubprocess(spec)
|
|
|
+ return rejectFinalExitWait(realChild, rawCleanup)
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await waitForFile(ready)
|
|
|
+ controller.abort()
|
|
|
+ writeFileSync(go, 'go')
|
|
|
+ const error = await starting.catch((cause: unknown) => cause)
|
|
|
+ expect(error).toBeInstanceOf(Error)
|
|
|
+ expect((error as Error).message).toBe(
|
|
|
+ `subagent-acp: ${expectedFailure('stage: teardown; category: unknown')}`,
|
|
|
+ )
|
|
|
+ expect((error as Error).message).not.toContain(rawCleanup)
|
|
|
+ await realChild?.done
|
|
|
+ } finally {
|
|
|
+ rmSync(tmp, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
|
|
|
// The child traps SIGTERM and keeps its event loop alive, so a graceful
|
|
|
// term alone would hang dispose forever. With a short grace, dispose must
|
|
|
@@ -632,6 +816,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
controller.abort()
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('aborted')
|
|
|
+ expect(result.diagnostic).toBeUndefined()
|
|
|
await run.dispose()
|
|
|
} finally {
|
|
|
rmSync(tmp, { recursive: true, force: true })
|
|
|
@@ -639,11 +824,15 @@ describe('dsh-subagent-acp', () => {
|
|
|
})
|
|
|
|
|
|
it('auto-rejects a permission prompt by default (child settles cancelled→aborted)', async () => {
|
|
|
- const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1' }, 'reject')
|
|
|
+ const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1', MOCK_TOOL_KIND: 'execute' }, 'reject')
|
|
|
const run = await ctx.subagents.start('acp', request())
|
|
|
const result = await run.result
|
|
|
// The child asked permission, the backend rejected, the child returned cancelled.
|
|
|
expect(result.stopReason).toBe('aborted')
|
|
|
+ expect(result.diagnostic).toBe(
|
|
|
+ `${expectedFailure('stage: prompt; category: permission; stop reason: cancelled')}\n`
|
|
|
+ + expectedPermission('reject', 'execute', 'denied'),
|
|
|
+ )
|
|
|
await run.dispose()
|
|
|
})
|
|
|
|
|
|
@@ -652,6 +841,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
const run = await ctx.subagents.start('acp', request())
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('completed')
|
|
|
+ expect(result.diagnostic).toBeUndefined()
|
|
|
expect(text(result.output)).toBe('approved answer')
|
|
|
await run.dispose()
|
|
|
})
|
|
|
@@ -663,6 +853,44 @@ describe('dsh-subagent-acp', () => {
|
|
|
const run = await ctx.subagents.start('acp', request())
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('aborted')
|
|
|
+ expect(result.diagnostic).toBe(
|
|
|
+ `${expectedFailure('stage: prompt; category: permission; stop reason: cancelled')}\n`
|
|
|
+ + expectedPermission('allow', 'unknown', 'denied'),
|
|
|
+ )
|
|
|
+ await run.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('appends a rejected permission fact to a later remote failure', async () => {
|
|
|
+ const ctx = await setup({
|
|
|
+ MOCK_PERMISSION: '1',
|
|
|
+ MOCK_PERMISSION_IGNORE_DECISION: '1',
|
|
|
+ MOCK_TOOL_KIND: 'edit',
|
|
|
+ MOCK_STOP: 'max_turn_requests',
|
|
|
+ }, 'reject')
|
|
|
+ const run = await ctx.subagents.start('acp', request())
|
|
|
+ const result = await run.result
|
|
|
+ expect(result.stopReason).toBe('error')
|
|
|
+ expect(result.diagnostic).toBe(
|
|
|
+ `${expectedFailure('stage: prompt; category: remote-limit; stop reason: max_turn_requests')}\n`
|
|
|
+ + expectedPermission('reject', 'edit', 'denied'),
|
|
|
+ )
|
|
|
+ await run.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('appends an allowed permission fact only when the run later fails', async () => {
|
|
|
+ const ctx = await setup({
|
|
|
+ MOCK_PERMISSION: '1',
|
|
|
+ MOCK_PERMISSION_IGNORE_DECISION: '1',
|
|
|
+ MOCK_TOOL_KIND: 'execute',
|
|
|
+ MOCK_STOP: 'max_turn_requests',
|
|
|
+ }, 'allow')
|
|
|
+ const run = await ctx.subagents.start('acp', request())
|
|
|
+ const result = await run.result
|
|
|
+ expect(result.stopReason).toBe('error')
|
|
|
+ expect(result.diagnostic).toBe(
|
|
|
+ `${expectedFailure('stage: prompt; category: remote-limit; stop reason: max_turn_requests')}\n`
|
|
|
+ + expectedPermission('allow', 'execute', 'allowed'),
|
|
|
+ )
|
|
|
await run.dispose()
|
|
|
})
|
|
|
|
|
|
@@ -678,11 +906,50 @@ describe('dsh-subagent-acp', () => {
|
|
|
await run.dispose()
|
|
|
})
|
|
|
|
|
|
+ it('classifies a prompt transport failure without copying SDK text', async () => {
|
|
|
+ const run = await startAcpRun(request('private prompt text'), {
|
|
|
+ command: process.execPath,
|
|
|
+ args: [mockServer],
|
|
|
+ cwd: process.cwd(),
|
|
|
+ permission: 'reject',
|
|
|
+ env: { MOCK_CLOSE_PROTOCOL_ON_PROMPT: '1' },
|
|
|
+ disposeEofGraceMs: 100,
|
|
|
+ disposeGraceMs: 100,
|
|
|
+ spawn: spawnSubprocess,
|
|
|
+ })
|
|
|
+ const result = await run.result
|
|
|
+ expect(result).toEqual({
|
|
|
+ output: [],
|
|
|
+ diagnostic: expectedFailure('stage: prompt; category: transport'),
|
|
|
+ stopReason: 'error',
|
|
|
+ })
|
|
|
+ expect(result.diagnostic).not.toContain('private prompt text')
|
|
|
+ await run.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('preserves partial output and structured process facts when the child exits', async () => {
|
|
|
+ const ctx = await setup({ MOCK_TEXT: 'partial answer', MOCK_CRASH_AFTER_CHUNK: '1' })
|
|
|
+ const run = await ctx.subagents.start('acp', request())
|
|
|
+ const result = await run.result
|
|
|
+ expect(result).toEqual({
|
|
|
+ output: [{ type: 'text', text: 'partial answer' }],
|
|
|
+ diagnostic: expectedFailure('stage: process; category: process-exit; exit code: 17'),
|
|
|
+ stopReason: 'error',
|
|
|
+ })
|
|
|
+ await run.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
it('rejects a spawn failure after provider-owned cleanup', async () => {
|
|
|
- await expect(startAcpRun(
|
|
|
+ const privateCommand = '/nonexistent/private/SECRET_TOKEN/acp-agent'
|
|
|
+ const error = await startAcpRun(
|
|
|
request(),
|
|
|
- { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess },
|
|
|
- )).rejects.toThrow()
|
|
|
+ { command: privateCommand, args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess },
|
|
|
+ ).catch((cause: unknown) => cause)
|
|
|
+ expect(error).toBeInstanceOf(Error)
|
|
|
+ expect((error as Error).message).toBe(
|
|
|
+ `subagent-acp: ${expectedFailure('stage: process; category: process-start')}`,
|
|
|
+ )
|
|
|
+ expect((error as Error).message).not.toContain(privateCommand)
|
|
|
})
|
|
|
|
|
|
it('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => {
|
|
|
@@ -746,7 +1013,95 @@ describe('dsh-subagent-acp', () => {
|
|
|
permission: 'reject',
|
|
|
env: {},
|
|
|
})
|
|
|
- await expect(ctx.subagents.start('acp', request())).rejects.toThrow()
|
|
|
+ await expect(ctx.subagents.start('acp', request())).rejects.toThrow(
|
|
|
+ `subagent-acp: ${expectedFailure('stage: process; category: process-start')}`,
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps permission diagnostics isolated across concurrent runs', async () => {
|
|
|
+ const start = (permission: 'allow' | 'reject', kind: 'edit' | 'execute') => startAcpRun(
|
|
|
+ request(),
|
|
|
+ {
|
|
|
+ command: process.execPath,
|
|
|
+ args: [mockServer],
|
|
|
+ cwd: process.cwd(),
|
|
|
+ permission,
|
|
|
+ env: {
|
|
|
+ MOCK_PERMISSION: '1',
|
|
|
+ MOCK_PERMISSION_IGNORE_DECISION: '1',
|
|
|
+ MOCK_TOOL_KIND: kind,
|
|
|
+ MOCK_STOP: 'max_turn_requests',
|
|
|
+ },
|
|
|
+ disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
|
|
|
+ disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
|
+ spawn: spawnSubprocess,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ const [allowed, denied] = await Promise.all([
|
|
|
+ start('allow', 'execute'),
|
|
|
+ start('reject', 'edit'),
|
|
|
+ ])
|
|
|
+ const [allowedResult, deniedResult] = await Promise.all([allowed.result, denied.result])
|
|
|
+ expect(allowedResult.diagnostic).toContain(expectedPermission('allow', 'execute', 'allowed'))
|
|
|
+ expect(allowedResult.diagnostic).not.toContain('policy: reject')
|
|
|
+ expect(deniedResult.diagnostic).toContain(expectedPermission('reject', 'edit', 'denied'))
|
|
|
+ expect(deniedResult.diagnostic).not.toContain('policy: allow')
|
|
|
+ await Promise.all([allowed.dispose(), denied.dispose()])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('wraps a teardown rejection with safe facts and keeps the raw cause in Host diagnostics', async () => {
|
|
|
+ const rawMessage = 'teardown leaked /private/path SECRET_TOKEN'
|
|
|
+ const errors: string[] = []
|
|
|
+ let realChild: SubprocessHandle | undefined
|
|
|
+ const run = await startAcpRun(request(), {
|
|
|
+ command: process.execPath,
|
|
|
+ args: [mockServer],
|
|
|
+ cwd: process.cwd(),
|
|
|
+ permission: 'reject',
|
|
|
+ env: { MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1' },
|
|
|
+ disposeEofGraceMs: 10,
|
|
|
+ disposeGraceMs: 10,
|
|
|
+ spawn: (spec) => {
|
|
|
+ const child = spawnSubprocess(spec)
|
|
|
+ realChild = child
|
|
|
+ return rejectFinalExitWait(child, rawMessage)
|
|
|
+ },
|
|
|
+ onError: (error) => { errors.push(error.message) },
|
|
|
+ })
|
|
|
+ const error = await run.dispose().catch((cause: unknown) => cause)
|
|
|
+ expect(error).toBeInstanceOf(Error)
|
|
|
+ expect((error as Error).message).toBe(
|
|
|
+ `subagent-acp: ${expectedFailure('stage: teardown; category: unknown')}`,
|
|
|
+ )
|
|
|
+ expect((error as Error).message).not.toContain(rawMessage)
|
|
|
+ expect(errors).toContain(rawMessage)
|
|
|
+ await realChild?.done
|
|
|
+ await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('adds an observed process outcome to a teardown failure', async () => {
|
|
|
+ let realChild: SubprocessHandle | undefined
|
|
|
+ const run = await startAcpRun(request(), {
|
|
|
+ command: process.execPath,
|
|
|
+ args: [mockServer],
|
|
|
+ cwd: process.cwd(),
|
|
|
+ permission: 'reject',
|
|
|
+ env: { MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1' },
|
|
|
+ disposeEofGraceMs: 10,
|
|
|
+ disposeGraceMs: 10,
|
|
|
+ spawn: (spec) => {
|
|
|
+ const child = spawnSubprocess(spec)
|
|
|
+ realChild = child
|
|
|
+ return rejectFinalExitWaitAfterExit(child, 'post-exit wait failed')
|
|
|
+ },
|
|
|
+ })
|
|
|
+ const error = await run.dispose().catch((cause: unknown) => cause)
|
|
|
+ expect(error).toBeInstanceOf(Error)
|
|
|
+ expect((error as Error).message).toContain(
|
|
|
+ 'subagent-acp: Subagent failure (provider: ACP; stage: teardown; category: process-exit;',
|
|
|
+ )
|
|
|
+ expect((error as Error).message).toMatch(/(?:exit code|signal): /)
|
|
|
+ await realChild?.done
|
|
|
})
|
|
|
|
|
|
it('reports a flattened child failure through onError (preserved, not silently lost)', async () => {
|
|
|
@@ -771,6 +1126,9 @@ describe('dsh-subagent-acp', () => {
|
|
|
)
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('error')
|
|
|
+ expect(result.diagnostic).toBe(
|
|
|
+ expectedFailure('stage: process; category: process-exit; exit code: 1'),
|
|
|
+ )
|
|
|
expect(errors).toHaveLength(1)
|
|
|
expect(errors[0]!.stopReason).toBe('error')
|
|
|
expect(errors[0]!.message.length).toBeGreaterThan(0)
|
|
|
@@ -784,6 +1142,9 @@ describe('dsh-subagent-acp', () => {
|
|
|
const run = await ctx.subagents.start('acp', request())
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('error')
|
|
|
+ expect(result.diagnostic).toBe(
|
|
|
+ expectedFailure('stage: process; category: process-exit; exit code: 1'),
|
|
|
+ )
|
|
|
expect(warnings).toEqual([
|
|
|
expect.stringContaining('subagent-acp "acp": child run failed (error):'),
|
|
|
])
|
|
|
@@ -810,6 +1171,9 @@ describe('dsh-subagent-acp', () => {
|
|
|
)
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('error')
|
|
|
+ expect(result.diagnostic).toBe(
|
|
|
+ expectedFailure('stage: process; category: process-exit; exit code: 1'),
|
|
|
+ )
|
|
|
await run.dispose()
|
|
|
})
|
|
|
|
|
|
@@ -828,6 +1192,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
controller.abort('crash it')
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('aborted')
|
|
|
+ expect(result.diagnostic).toBeUndefined()
|
|
|
await run.dispose()
|
|
|
} finally {
|
|
|
rmSync(tmp, { recursive: true, force: true })
|
|
|
@@ -854,6 +1219,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
new Promise<never>((_r, reject) => { setTimeout(() => { reject(new Error('result did not settle on cancel — backend waited on the child')) }, 4000) }),
|
|
|
])
|
|
|
expect(result.stopReason).toBe('aborted')
|
|
|
+ expect(result.diagnostic).toBeUndefined()
|
|
|
await run.dispose()
|
|
|
} finally {
|
|
|
rmSync(tmp, { recursive: true, force: true })
|