|
|
@@ -1,4 +1,7 @@
|
|
|
+import { readFileSync } from 'node:fs'
|
|
|
+import { dirname, resolve } from 'node:path'
|
|
|
import { PassThrough } from 'node:stream'
|
|
|
+import { fileURLToPath } from 'node:url'
|
|
|
import type {
|
|
|
Options,
|
|
|
Query,
|
|
|
@@ -9,6 +12,7 @@ import type {
|
|
|
} from '@anthropic-ai/claude-agent-sdk'
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
|
|
+import * as yaml from 'js-yaml'
|
|
|
import {
|
|
|
afterEach,
|
|
|
beforeEach,
|
|
|
@@ -55,6 +59,19 @@ type QueryFactory = (params: {
|
|
|
|
|
|
const queryMock = vi.hoisted(() => vi.fn<QueryFactory>())
|
|
|
|
|
|
+const CLAUDE_AGENT_SDK_VERSION = '0.3.220'
|
|
|
+const CLAUDE_CODE_VERSION = '2.1.220'
|
|
|
+const CLAUDE_PLATFORM_PACKAGES = [
|
|
|
+ '@anthropic-ai/claude-agent-sdk-darwin-arm64',
|
|
|
+ '@anthropic-ai/claude-agent-sdk-darwin-x64',
|
|
|
+ '@anthropic-ai/claude-agent-sdk-linux-arm64',
|
|
|
+ '@anthropic-ai/claude-agent-sdk-linux-arm64-musl',
|
|
|
+ '@anthropic-ai/claude-agent-sdk-linux-x64',
|
|
|
+ '@anthropic-ai/claude-agent-sdk-linux-x64-musl',
|
|
|
+ '@anthropic-ai/claude-agent-sdk-win32-arm64',
|
|
|
+ '@anthropic-ai/claude-agent-sdk-win32-x64',
|
|
|
+] as const
|
|
|
+
|
|
|
vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({
|
|
|
...await importOriginal<typeof import('@anthropic-ai/claude-agent-sdk')>(),
|
|
|
query: queryMock,
|
|
|
@@ -265,7 +282,6 @@ function fakeRun(
|
|
|
const options: FakeRun['options'] = []
|
|
|
const spec: ClaudeCodeRunSpec = {
|
|
|
cwd: '/workspace',
|
|
|
- executable: '/native/claude',
|
|
|
permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
|
|
|
env: { ANTHROPIC_API_KEY: 'fake-key' },
|
|
|
disposeGraceMs: 5,
|
|
|
@@ -300,6 +316,66 @@ afterEach(() => {
|
|
|
})
|
|
|
|
|
|
describe('task admission and package contracts', () => {
|
|
|
+ it('ships one independently installable provider-only Bundle patch', () => {
|
|
|
+ const root = fileURLToPath(new URL('..', import.meta.url))
|
|
|
+ const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as {
|
|
|
+ dependencies?: Record<string, string>
|
|
|
+ files?: string[]
|
|
|
+ dsh?: { bundle?: { patch?: string } }
|
|
|
+ }
|
|
|
+ expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml')
|
|
|
+ expect(manifest.files).toContain('cordis.patch.yml')
|
|
|
+ expect(manifest.dependencies).toHaveProperty(
|
|
|
+ '@anthropic-ai/claude-agent-sdk',
|
|
|
+ CLAUDE_AGENT_SDK_VERSION,
|
|
|
+ )
|
|
|
+ expect(manifest.dependencies).toHaveProperty(
|
|
|
+ '@modelcontextprotocol/sdk',
|
|
|
+ '^1.29.0',
|
|
|
+ )
|
|
|
+ expect(manifest.dependencies).toHaveProperty('zod', '^4.4.3')
|
|
|
+ expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex')
|
|
|
+
|
|
|
+ const sdkRoot = dirname(fileURLToPath(
|
|
|
+ import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
|
|
|
+ ))
|
|
|
+ const sdkManifest = JSON.parse(readFileSync(
|
|
|
+ resolve(sdkRoot, 'package.json'),
|
|
|
+ 'utf8',
|
|
|
+ )) as {
|
|
|
+ version: string
|
|
|
+ claudeCodeVersion: string
|
|
|
+ optionalDependencies: Record<string, string>
|
|
|
+ }
|
|
|
+ expect(sdkManifest.version).toBe(CLAUDE_AGENT_SDK_VERSION)
|
|
|
+ expect(sdkManifest.claudeCodeVersion).toBe(CLAUDE_CODE_VERSION)
|
|
|
+ expect(sdkManifest.optionalDependencies).toEqual(Object.fromEntries(
|
|
|
+ CLAUDE_PLATFORM_PACKAGES.map(packageName => [
|
|
|
+ packageName,
|
|
|
+ CLAUDE_AGENT_SDK_VERSION,
|
|
|
+ ]),
|
|
|
+ ))
|
|
|
+ const lockfile = readFileSync(resolve(root, '../../../pnpm-lock.yaml'), 'utf8')
|
|
|
+ for (const packageName of CLAUDE_PLATFORM_PACKAGES) {
|
|
|
+ expect(lockfile).toContain(
|
|
|
+ ` '${packageName}@${CLAUDE_AGENT_SDK_VERSION}':`,
|
|
|
+ )
|
|
|
+ expect(lockfile).toContain(
|
|
|
+ ` '${packageName}': ${CLAUDE_AGENT_SDK_VERSION}`,
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'))
|
|
|
+ const rows = Array.isArray(parsed)
|
|
|
+ ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? [])
|
|
|
+ : []
|
|
|
+ expect(rows).toEqual([{
|
|
|
+ id: 'subagent-claude-code',
|
|
|
+ name: '@deepseek-ai/dsh-subagent-claude-code',
|
|
|
+ }])
|
|
|
+ expect(JSON.stringify(rows)).not.toContain('tool-subagent')
|
|
|
+ })
|
|
|
+
|
|
|
it('preserves text sequences and rejects empty, blank, and non-text tasks', () => {
|
|
|
expect(textTask([
|
|
|
{ type: 'text', text: 'one' },
|
|
|
@@ -491,7 +567,7 @@ describe('task admission and package contracts', () => {
|
|
|
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
|
|
|
.mockImplementation(() => child.handle)
|
|
|
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
|
|
|
- .mockResolvedValue('/native/claude')
|
|
|
+ .mockResolvedValue('/host/bin/claude')
|
|
|
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
|
|
await ctx.plugin(claudeCode, {
|
|
|
providerName: 'claude-diagnostic',
|
|
|
@@ -515,10 +591,15 @@ describe('task admission and package contracts', () => {
|
|
|
)
|
|
|
expect(queryMock).not.toHaveBeenCalled()
|
|
|
|
|
|
- resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH'))
|
|
|
+ vi.stubEnv('PATH', '/host/bin')
|
|
|
+ queryMock.mockImplementationOnce(() => {
|
|
|
+ throw new Error(
|
|
|
+ 'Native CLI binary for fixture-platform not found. Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set options.pathToClaudeCodeExecutable.',
|
|
|
+ )
|
|
|
+ })
|
|
|
await expect(ctx.subagents.start('claude-diagnostic', request()))
|
|
|
- .rejects.toThrow('claude missing from PATH')
|
|
|
- expect(queryMock).not.toHaveBeenCalled()
|
|
|
+ .rejects.toThrow('Native CLI binary for fixture-platform not found')
|
|
|
+ expect(resolveExecutable).not.toHaveBeenCalled()
|
|
|
|
|
|
const run = await ctx.subagents.start('claude-diagnostic', request())
|
|
|
child.settle({ exitCode: 9, signal: null })
|
|
|
@@ -530,14 +611,10 @@ describe('task admission and package contracts', () => {
|
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
|
|
|
'subagent-claude-code "claude-diagnostic": child run failed (error):',
|
|
|
))
|
|
|
- expect(resolveExecutable).toHaveBeenCalledWith(
|
|
|
- 'claude',
|
|
|
- expect.objectContaining({ ANTHROPIC_API_KEY: 'provider-fake-key' }),
|
|
|
- expect.any(AbortSignal),
|
|
|
- )
|
|
|
- expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable)
|
|
|
- .toBe('/native/claude')
|
|
|
- expect(queryMock.mock.calls[0]?.[0].options.permissionMode).toBe('auto')
|
|
|
+ expect(resolveExecutable).not.toHaveBeenCalled()
|
|
|
+ expect(queryMock.mock.calls[1]?.[0].options)
|
|
|
+ .not.toHaveProperty('pathToClaudeCodeExecutable')
|
|
|
+ expect(queryMock.mock.calls[1]?.[0].options.permissionMode).toBe('auto')
|
|
|
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
cwd: process.cwd(),
|
|
|
graceMs: 29,
|
|
|
@@ -617,20 +694,17 @@ describe('official spawn projection', () => {
|
|
|
)).toThrow('SDK spawn request omitted its workspace')
|
|
|
})
|
|
|
|
|
|
- it.each(['cmd', 'bat'])('routes a Windows .%s shim through cmd.exe', (extension) => {
|
|
|
- const command = String.raw`C:\Program Files\Claude\claude.${extension}`
|
|
|
+ it('forwards the SDK-selected Windows native executable without a batch shim', () => {
|
|
|
+ const command = String.raw`C:\Program Files\Claude\claude.exe`
|
|
|
const spec = claudeSpawnSpec(sdkSpawnOptions({
|
|
|
command,
|
|
|
args: ['--output-format', 'stream-json'],
|
|
|
- }), 7, 'win32')
|
|
|
+ }), 7)
|
|
|
|
|
|
expect(spec.argv).toEqual([
|
|
|
- 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%',
|
|
|
- '--output-format', 'stream-json',
|
|
|
+ command, '--output-format', 'stream-json',
|
|
|
])
|
|
|
- expect(spec.env).toEqual(expect.objectContaining({
|
|
|
- DSH_CLAUDE_CODE_EXECUTABLE: `"${command}"`,
|
|
|
- }))
|
|
|
+ expect(spec.env).not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE')
|
|
|
})
|
|
|
|
|
|
it('projects streams, exit facts, listeners, and idempotent tree termination', async () => {
|
|
|
@@ -701,7 +775,6 @@ describe('query options and result mapping', () => {
|
|
|
const diagnostics: string[] = []
|
|
|
const spec: ClaudeCodeRunSpec = {
|
|
|
cwd: '/workspace',
|
|
|
- executable: '/native/claude',
|
|
|
permissionMode: 'acceptEdits',
|
|
|
env: {
|
|
|
HOST_VISIBLE: 'overridden',
|
|
|
@@ -723,12 +796,12 @@ describe('query options and result mapping', () => {
|
|
|
expect(options).toMatchObject({
|
|
|
abortController: controller,
|
|
|
cwd: '/workspace',
|
|
|
- pathToClaudeCodeExecutable: '/native/claude',
|
|
|
persistSession: false,
|
|
|
disallowedTools: ['AskUserQuestion'],
|
|
|
permissionMode: 'acceptEdits',
|
|
|
supportedDialogKinds: ['refusal_fallback_prompt'],
|
|
|
})
|
|
|
+ expect(options).not.toHaveProperty('pathToClaudeCodeExecutable')
|
|
|
expect(options).not.toHaveProperty('allowDangerouslySkipPermissions')
|
|
|
expect(options.env).toMatchObject({
|
|
|
HOST_VISIBLE: 'overridden',
|
|
|
@@ -792,7 +865,6 @@ describe('query options and result mapping', () => {
|
|
|
const child = fakeChild()
|
|
|
const options = claudeQueryOptions({
|
|
|
cwd: '/workspace',
|
|
|
- executable: '/native/claude',
|
|
|
permissionMode,
|
|
|
env: {},
|
|
|
disposeGraceMs: 17,
|
|
|
@@ -816,7 +888,6 @@ describe('query options and result mapping', () => {
|
|
|
const child = fakeChild()
|
|
|
const options = claudeQueryOptions({
|
|
|
cwd: '/workspace',
|
|
|
- executable: '/native/claude',
|
|
|
permissionMode: 'plan',
|
|
|
env: {},
|
|
|
disposeGraceMs: 17,
|
|
|
@@ -942,7 +1013,6 @@ describe('run publication, cancellation, and settlement', () => {
|
|
|
let childIndex = 0
|
|
|
const spec: ClaudeCodeRunSpec = {
|
|
|
cwd: '/workspace',
|
|
|
- executable: '/native/claude',
|
|
|
permissionMode: 'dontAsk',
|
|
|
env: {},
|
|
|
disposeGraceMs: 5,
|
|
|
@@ -1012,7 +1082,6 @@ describe('run publication, cancellation, and settlement', () => {
|
|
|
let index = 0
|
|
|
const spec: ClaudeCodeRunSpec = {
|
|
|
cwd: '/workspace',
|
|
|
- executable: '/native/claude',
|
|
|
permissionMode: 'dontAsk',
|
|
|
env: {},
|
|
|
disposeGraceMs: 5,
|
|
|
@@ -1064,7 +1133,6 @@ describe('run publication, cancellation, and settlement', () => {
|
|
|
request(undefined, parentAbort.signal),
|
|
|
{
|
|
|
cwd: '/workspace',
|
|
|
- executable: '/native/claude',
|
|
|
permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
|
|
|
env: {},
|
|
|
disposeGraceMs: 5,
|
|
|
@@ -1153,14 +1221,101 @@ describe('run publication, cancellation, and settlement', () => {
|
|
|
expect(factoryController?.signal.aborted).toBe(true)
|
|
|
expect(spawned.terminate).toHaveBeenCalledOnce()
|
|
|
|
|
|
+ const spawnError = Object.assign(
|
|
|
+ new Error('spawn /sdk/claude EACCES'),
|
|
|
+ { code: 'EACCES', path: '/sdk/claude' },
|
|
|
+ )
|
|
|
const failedSpawn = fakeChild({
|
|
|
pid: -1,
|
|
|
- doneError: new Error('spawn failed'),
|
|
|
+ doneError: spawnError,
|
|
|
})
|
|
|
const failed = fakeRun([], undefined, failedSpawn)
|
|
|
await expect(startClaudeCodeRun(request(), failed.spec))
|
|
|
- .rejects.toBeInstanceOf(AggregateError)
|
|
|
+ .rejects.toBe(spawnError)
|
|
|
expect(failed.close).toHaveBeenCalledOnce()
|
|
|
+ expect(failedSpawn.terminate).not.toHaveBeenCalled()
|
|
|
+ expect(failedSpawn.waitForExit).not.toHaveBeenCalled()
|
|
|
+
|
|
|
+ const failedSpawnAbort = new AbortController()
|
|
|
+ const cancelledFailedSpawn = fakeChild({
|
|
|
+ pid: -1,
|
|
|
+ doneError: spawnError,
|
|
|
+ })
|
|
|
+ const cancelledFailedClose = vi.fn()
|
|
|
+ queryMock.mockImplementationOnce(({ options }) => {
|
|
|
+ options.spawnClaudeCodeProcess!(sdkSpawnOptions())
|
|
|
+ failedSpawnAbort.abort(new Error('startup cancelled'))
|
|
|
+ return queryFrom([], undefined, cancelledFailedClose)
|
|
|
+ })
|
|
|
+ await expect(startClaudeCodeRun(
|
|
|
+ request(undefined, failedSpawnAbort.signal),
|
|
|
+ { ...unused.spec, spawn: () => cancelledFailedSpawn.handle },
|
|
|
+ )).rejects.toThrow('aborted before SDK startup')
|
|
|
+ expect(cancelledFailedClose).toHaveBeenCalledOnce()
|
|
|
+
|
|
|
+ const cancelledFailedSpawnCloseError = new Error('cancelled query close failed')
|
|
|
+ const cancelledFailedSpawnClose = vi.fn(() => {
|
|
|
+ throw cancelledFailedSpawnCloseError
|
|
|
+ })
|
|
|
+ const cancelledFailedSpawnWithCloseFailure = fakeChild({
|
|
|
+ pid: -1,
|
|
|
+ doneError: spawnError,
|
|
|
+ })
|
|
|
+ const failedSpawnAbortWithCloseFailure = new AbortController()
|
|
|
+ queryMock.mockImplementationOnce(({ options }) => {
|
|
|
+ options.spawnClaudeCodeProcess!(sdkSpawnOptions())
|
|
|
+ failedSpawnAbortWithCloseFailure.abort(new Error('startup cancelled'))
|
|
|
+ return queryFrom([], undefined, cancelledFailedSpawnClose)
|
|
|
+ })
|
|
|
+ const cancelledWithCloseFailure = startClaudeCodeRun(
|
|
|
+ request(undefined, failedSpawnAbortWithCloseFailure.signal),
|
|
|
+ { ...unused.spec, spawn: () => cancelledFailedSpawnWithCloseFailure.handle },
|
|
|
+ )
|
|
|
+ await expect(cancelledWithCloseFailure).rejects.toMatchObject({
|
|
|
+ message: 'subagent-claude-code: request was aborted before SDK startup; Claude Code process startup also failed: spawn /sdk/claude EACCES; query cleanup also failed',
|
|
|
+ errors: [
|
|
|
+ expect.objectContaining({ message: 'subagent-claude-code: request was aborted before SDK startup' }),
|
|
|
+ spawnError,
|
|
|
+ cancelledFailedSpawnCloseError,
|
|
|
+ ],
|
|
|
+ })
|
|
|
+ expect(cancelledFailedSpawnClose).toHaveBeenCalledOnce()
|
|
|
+
|
|
|
+ const failedSpawnCloseError = new Error('query close failed')
|
|
|
+ const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError })
|
|
|
+ const failedSpawnWithCloseFailure = fakeChild({
|
|
|
+ pid: -1,
|
|
|
+ doneError: spawnError,
|
|
|
+ })
|
|
|
+ queryMock.mockImplementationOnce(({ options }) => {
|
|
|
+ options.spawnClaudeCodeProcess!(sdkSpawnOptions())
|
|
|
+ return queryFrom([], undefined, failedSpawnClose)
|
|
|
+ })
|
|
|
+ const failedWithCloseFailure = startClaudeCodeRun(request(), {
|
|
|
+ ...unused.spec,
|
|
|
+ spawn: () => failedSpawnWithCloseFailure.handle,
|
|
|
+ })
|
|
|
+ await expect(failedWithCloseFailure)
|
|
|
+ .rejects.toThrow('spawn /sdk/claude EACCES')
|
|
|
+ await expect(failedWithCloseFailure).rejects.toMatchObject({
|
|
|
+ errors: [spawnError, failedSpawnCloseError],
|
|
|
+ })
|
|
|
+
|
|
|
+ const cleanupError = new Error('live child cleanup failed')
|
|
|
+ const constructionError = new Error(
|
|
|
+ 'query construction failed with a live child',
|
|
|
+ )
|
|
|
+ const liveChildCleanupFailure = fakeChild({ doneError: cleanupError })
|
|
|
+ queryMock.mockImplementationOnce(({ options }) => {
|
|
|
+ options.spawnClaudeCodeProcess!(sdkSpawnOptions())
|
|
|
+ throw constructionError
|
|
|
+ })
|
|
|
+ await expect(startClaudeCodeRun(request(), {
|
|
|
+ ...unused.spec,
|
|
|
+ spawn: () => liveChildCleanupFailure.handle,
|
|
|
+ })).rejects.toMatchObject({
|
|
|
+ errors: [constructionError, cleanupError],
|
|
|
+ })
|
|
|
})
|
|
|
})
|
|
|
|