|
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
import SubagentService 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 * 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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
|
|
@@ -108,14 +109,19 @@ describe('child env layering (through the subprocess seam)', () => {
|
|
|
// The spec.env layer merges after the seam's scrub, so the child's own
|
|
|
// explicitly-forwarded key survives while ambient credentials do not.
|
|
|
const running = spawnSubprocess({
|
|
|
- argv: ['bash', '-c', 'echo "[${ACP_TEST_AMBIENT_SECRET_TOKEN:-absent}|$DEEPSEEK_API_KEY]"'],
|
|
|
+ argv: [
|
|
|
+ process.execPath,
|
|
|
+ '--input-type=module',
|
|
|
+ '--eval',
|
|
|
+ 'process.stdout.write(JSON.stringify([process.env.ACP_TEST_AMBIENT_SECRET_TOKEN ?? "absent", process.env.DEEPSEEK_API_KEY]))',
|
|
|
+ ],
|
|
|
cwd: process.cwd(),
|
|
|
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
|
|
graceMs: 1000,
|
|
|
env: { DEEPSEEK_API_KEY: 'explicit' },
|
|
|
})
|
|
|
await running.done
|
|
|
- expect(running.collected.stdout!.readFrom(0).text.trim()).toBe('[absent|explicit]')
|
|
|
+ expect(running.collected.stdout!.readFrom(0).text).toBe('["absent","explicit"]')
|
|
|
} finally {
|
|
|
delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN
|
|
|
}
|
|
|
@@ -139,42 +145,50 @@ describe('child env layering (through the subprocess seam)', () => {
|
|
|
})
|
|
|
|
|
|
describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', () => {
|
|
|
- const bash = (command: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({
|
|
|
- argv: ['bash', '-c', command],
|
|
|
+ const node = (source: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({
|
|
|
+ argv: [process.execPath, '--input-type=module', '--eval', source],
|
|
|
cwd: process.cwd(),
|
|
|
stdio: { stdin, stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
|
|
graceMs: 200,
|
|
|
})
|
|
|
+ const expectHostTermination = (outcome: SubprocessOutcome, posixSignal: NodeJS.Signals): void => {
|
|
|
+ if (process.platform === 'win32') {
|
|
|
+ expect(outcome.signal).toBeNull()
|
|
|
+ expect(outcome.exitCode).not.toBe(0)
|
|
|
+ } else {
|
|
|
+ expect(outcome.signal).toBe(posixSignal)
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
|
|
|
- const child = bash('read -r line; exit 0')
|
|
|
+ const child = node('process.stdin.resume(); process.stdin.on("end", () => process.exit(0))')
|
|
|
await disposeAcpChild(child, 5_000)
|
|
|
const outcome = await child.done
|
|
|
expect(outcome.exitCode).toBe(0)
|
|
|
expect(outcome.signal).toBeNull()
|
|
|
})
|
|
|
|
|
|
- it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => {
|
|
|
- const child = bash('sleep 60')
|
|
|
+ it('tier 2: an EOF-deaf child reaches the host terminate outcome', async () => {
|
|
|
+ const child = node('setInterval(() => {}, 60_000)')
|
|
|
await disposeAcpChild(child, 100)
|
|
|
const outcome = await child.done
|
|
|
- expect(outcome.signal).toBe('SIGTERM')
|
|
|
+ expectHostTermination(outcome, 'SIGTERM')
|
|
|
})
|
|
|
|
|
|
- it('tier 3: a TERM-trapping child dies by the escalation SIGKILL', async () => {
|
|
|
- const child = bash("trap '' TERM; echo armed; sleep 60", 'ignore')
|
|
|
+ it('tier 3: a TERM-trapping child reaches the host force-termination outcome', async () => {
|
|
|
+ const child = node('process.on("SIGTERM", () => {}); process.stdout.write("armed\\n"); setInterval(() => {}, 60_000)', 'ignore')
|
|
|
// Wait for the trap to arm so SIGTERM cannot race the default handler.
|
|
|
while (!child.collected.stdout!.readFrom(0).text.includes('armed')) {
|
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
|
}
|
|
|
await disposeAcpChild(child, 50)
|
|
|
const outcome = await child.done
|
|
|
- expect(outcome.signal).toBe('SIGKILL')
|
|
|
+ expectHostTermination(outcome, 'SIGKILL')
|
|
|
})
|
|
|
|
|
|
it('observes a spawn-level rejection and returns without a process to reap', async () => {
|
|
|
const child = spawnSubprocess({
|
|
|
- argv: ['bash', '-c', 'true'],
|
|
|
+ argv: [process.execPath, '--input-type=module', '--eval', ''],
|
|
|
cwd: '/nonexistent-dir-dsh-acp-ladder-test',
|
|
|
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
|
|
graceMs: 200,
|