The group's convention is package suffix == provider default (subagent-acp/'acp', subagent-spawn/'spawn', subagent-fork/'fork'), and the provider default became dsh-sdk in the last review round — so the package follows: @deepseek-ai/dsh-subagent-dsh-sdk at packages/subagent/subagent-dsh-sdk, plugin name subagent-dsh-sdk, diagnostics prefixed subagent-dsh-sdk:. The dsh echo has precedent (dsh-llm-deepseek). Directory, fixture path, knip/tsconfig/examples registrations, catalogs, READMEs (en+zh), and the Agent Note follow; the sdk-client dispose ladder moves to its own module (src/dispose.ts) with the deterministic FakeChild tier tests restored alongside it.
186 lines
7.4 KiB
TypeScript
186 lines
7.4 KiB
TypeScript
/**
|
|
* Unit coverage for the seam's out-of-process provider vocabulary: cwd
|
|
* resolution against the real filesystem, and the settlement/handle helpers
|
|
* under their never-reject and idempotence contracts.
|
|
*/
|
|
|
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, relative, resolve } from 'node:path'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
import {
|
|
assertPositiveFinite,
|
|
assertUsableCwd,
|
|
NO_START_CAPABILITIES,
|
|
resolveChildCwd,
|
|
settleRunResult,
|
|
subprocessRunHandle,
|
|
validateConfiguredCwd,
|
|
} from '../src/index.ts'
|
|
|
|
describe('NO_START_CAPABILITIES', () => {
|
|
it('advertises nothing and is frozen (shared by every out-of-process backend)', () => {
|
|
expect(NO_START_CAPABILITIES).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false })
|
|
expect(Object.isFrozen(NO_START_CAPABILITIES)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('assertPositiveFinite', () => {
|
|
it('accepts positive finite bounds and rejects zero, negatives, and NaN', () => {
|
|
expect(() => { assertPositiveFinite('p', 'graceMs', 1) }).not.toThrow()
|
|
expect(() => { assertPositiveFinite('p', 'graceMs', 0) }).toThrow('p: graceMs must be a positive finite number')
|
|
expect(() => { assertPositiveFinite('p', 'graceMs', -5) }).toThrow('positive finite')
|
|
expect(() => { assertPositiveFinite('p', 'graceMs', Number.NaN) }).toThrow('positive finite')
|
|
expect(() => { assertPositiveFinite('p', 'graceMs', Number.POSITIVE_INFINITY) }).toThrow('positive finite')
|
|
})
|
|
})
|
|
|
|
describe('child cwd resolution', () => {
|
|
it('accepts an absolute enterable directory and rejects relative or missing paths', () => {
|
|
expect(assertUsableCwd('p', 'config cwd', tmpdir())).toBe(tmpdir())
|
|
expect(() => assertUsableCwd('p', 'config cwd', 'relative/path')).toThrow('must be an absolute path')
|
|
expect(() => assertUsableCwd('p', 'config cwd', join(tmpdir(), 'dsh-no-such-dir-xyz'))).toThrow('not an accessible directory')
|
|
})
|
|
|
|
it('rejects an existing path that is a file, not a directory', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'oop-file-'))
|
|
const file = join(tmp, 'plain.txt')
|
|
try {
|
|
writeFileSync(file, 'not a dir\n')
|
|
expect(() => assertUsableCwd('p', 'config cwd', file)).toThrow('not an accessible directory')
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
// Windows ACLs do not expose the POSIX directory search-bit state this fixture creates.
|
|
it.skipIf(process.platform === 'win32')('rejects a directory without search permission', () => {
|
|
// statSync().isDirectory() is true for a mode-600 directory, but a
|
|
// subprocess cwd needs SEARCH permission — spawn would fail EACCES.
|
|
const tmp = mkdtempSync(join(tmpdir(), 'oop-noexec-'))
|
|
chmodSync(tmp, 0o600)
|
|
try {
|
|
expect(() => assertUsableCwd('p', 'config cwd', tmp)).toThrow('not an accessible directory')
|
|
} finally {
|
|
chmodSync(tmp, 0o700)
|
|
rmSync(tmp, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('validateConfiguredCwd: undefined passes through, empty fails, relative resolves at load', () => {
|
|
expect(validateConfiguredCwd('p', undefined)).toBeUndefined()
|
|
expect(() => validateConfiguredCwd('p', '')).toThrow('config cwd must not be empty')
|
|
const tmp = mkdtempSync(join(tmpdir(), 'oop-rel-'))
|
|
try {
|
|
const relativeCwd = relative(process.cwd(), tmp)
|
|
// Resolution is lexical against the launch directory; the probe then
|
|
// requires the resolved path to exist and be enterable.
|
|
expect(validateConfiguredCwd('p', relativeCwd)).toBe(resolve(relativeCwd))
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('resolveChildCwd: override wins, else the parent session cwd validates, else loud failure', () => {
|
|
expect(resolveChildCwd('p', tmpdir(), undefined)).toBe(tmpdir())
|
|
expect(resolveChildCwd('p', undefined, tmpdir())).toBe(tmpdir())
|
|
expect(() => resolveChildCwd('p', undefined, undefined)).toThrow('no working directory for the child')
|
|
expect(() => resolveChildCwd('p', undefined, 'relative/parent')).toThrow('parent session cwd must be an absolute path')
|
|
})
|
|
})
|
|
|
|
describe('settleRunResult', () => {
|
|
const wiring = () => {
|
|
const controller = new AbortController()
|
|
const onAbort = vi.fn()
|
|
controller.signal.addEventListener('abort', onAbort)
|
|
return { controller, onAbort }
|
|
}
|
|
|
|
it('passes a successful attempt through and removes the abort listener', async () => {
|
|
const { controller, onAbort } = wiring()
|
|
const result = await settleRunResult({
|
|
attempt: async () => ({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }),
|
|
collectOutput: () => [],
|
|
cancelled: () => false,
|
|
signal: controller.signal,
|
|
onAbort,
|
|
})
|
|
expect(result.stopReason).toBe('completed')
|
|
controller.abort()
|
|
// The listener was removed at settlement, so the abort never reaches it.
|
|
expect(onAbort).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reads an in-flight rejection as aborted when cancellation already settled', async () => {
|
|
const { controller, onAbort } = wiring()
|
|
const result = await settleRunResult({
|
|
attempt: async () => { throw new Error('pipe torn mid-cancel') },
|
|
collectOutput: () => [{ type: 'text', text: 'partial' }],
|
|
cancelled: () => true,
|
|
signal: controller.signal,
|
|
onAbort,
|
|
})
|
|
expect(result).toEqual({ output: [{ type: 'text', text: 'partial' }], stopReason: 'aborted' })
|
|
})
|
|
|
|
it('flattens a failure through a contained onError sink', async () => {
|
|
const { controller, onAbort } = wiring()
|
|
const seen: string[] = []
|
|
const result = await settleRunResult({
|
|
attempt: async () => { throw new Error('transport died') },
|
|
collectOutput: () => [],
|
|
cancelled: () => false,
|
|
onError: (error, stopReason) => {
|
|
seen.push(`${stopReason}:${error.message}`)
|
|
throw new Error('sink failure must be contained')
|
|
},
|
|
signal: controller.signal,
|
|
onAbort,
|
|
})
|
|
expect(result.stopReason).toBe('error')
|
|
expect(seen).toEqual(['error:transport died'])
|
|
})
|
|
|
|
it('flattens a failure without a sink', async () => {
|
|
const { controller, onAbort } = wiring()
|
|
const result = await settleRunResult({
|
|
attempt: async () => { throw new Error('no sink configured') },
|
|
collectOutput: () => [],
|
|
cancelled: () => false,
|
|
signal: controller.signal,
|
|
onAbort,
|
|
})
|
|
expect(result.stopReason).toBe('error')
|
|
})
|
|
})
|
|
|
|
describe('subprocessRunHandle', () => {
|
|
it('publishes an idempotent dispose that cancels locally and awaits teardown', async () => {
|
|
const controller = new AbortController()
|
|
const onAbort = vi.fn()
|
|
controller.signal.addEventListener('abort', onAbort)
|
|
const requestCancel = vi.fn()
|
|
const teardown = vi.fn(() => Promise.resolve())
|
|
const run = subprocessRunHandle({
|
|
id: SessionId('run-1'),
|
|
result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
|
signal: controller.signal,
|
|
onAbort,
|
|
requestCancel,
|
|
teardown,
|
|
})
|
|
expect(run.localAgent).toBeUndefined()
|
|
expect(String(run.id)).toBe('run-1')
|
|
const disposal = run.dispose()
|
|
expect(run.dispose()).toBe(disposal)
|
|
await disposal
|
|
expect(requestCancel).toHaveBeenCalledTimes(1)
|
|
expect(teardown).toHaveBeenCalledTimes(1)
|
|
controller.abort()
|
|
// dispose removed the abort listener before cancelling.
|
|
expect(onAbort).not.toHaveBeenCalled()
|
|
})
|
|
})
|