Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters
# Conflicts: # docs/config-catalog.md # packages/examples/acp-demo/tests/acp-agent.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts
This commit is contained in:
155
packages/examples/acp-demo/tests/acp-agent.spec.ts
Normal file
155
packages/examples/acp-demo/tests/acp-agent.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import * as acpAgent from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* In-process unit coverage for the @deepseek-ai/dsh-acp-demo composition:
|
||||
* mounting it brings up the agent-core spine + JSONL persistence + the ACP
|
||||
* bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO
|
||||
* Loader-only plugin (no hmr), so it mounts in a plain Context.
|
||||
*
|
||||
* The REAL Loader-path guard (export shape via `unwrapExports`, the headline
|
||||
* ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`;
|
||||
* this spec asserts the composition and the persistenceRoot default branch.
|
||||
*/
|
||||
async function mount(config: acpAgent.Config): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(acpAgent, config)
|
||||
// The bundle mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services are ready.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<acpAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-demo-skills-'))
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
|
||||
const empty: Message[] = []
|
||||
return await agentEvents(ctx, agent).waterfall(
|
||||
'agent/session-prefix', empty, new AbortController().signal,
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-demo-default-skills-'))
|
||||
process.env.DSH_HOME = join(home, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(home, '.agents')
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (oldDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = oldDshHome
|
||||
}
|
||||
if (oldAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = oldAgentsHome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-acp-demo composition', () => {
|
||||
it('brings up the spine + persistence + the ACP bridge', async () => {
|
||||
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-demo-test', skills: await isolatedSkillsConfig() })
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('sessions')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('userInteraction')).toBeDefined()
|
||||
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
|
||||
// No pre-created agents — ACP session/new creates them on demand.
|
||||
expect(ctx.get('agents')!.list()).toHaveLength(0)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('defaults the persistence root when omitted', async () => {
|
||||
// Exercises the `?? './.sessions'` fallback for a direct-apply caller that
|
||||
// bypasses the schema's `.default(...)`: call `apply` directly (not via
|
||||
// `ctx.plugin`, which validates+defaults the config first) with no
|
||||
// persistenceRoot, so the runtime fallback is the one that fires.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
acpAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig() })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
acpAgent.apply(ctx, { provider: 'mock', model: 'mock' })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its plugin shape', () => {
|
||||
expect(acpAgent.name).toBe('acp-demo')
|
||||
expect(acpAgent.Config).toBeDefined()
|
||||
})
|
||||
|
||||
it('forwards toolOrder through agent-core to the system-prompt assembly', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
toolOrder: ['zulu', TOOL_ORDER_REST],
|
||||
persistenceRoot: '/tmp/dsh-acp-demo-test-tool-order',
|
||||
})
|
||||
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
|
||||
// this providerless mount, so register two plain tools to order.
|
||||
for (const name of ['alpha', 'zulu']) {
|
||||
ctx.get('tools')!.register({
|
||||
name,
|
||||
description: name,
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
|
||||
// A default export would make `unwrapExports` collapse this inject-less namespace and silently
|
||||
// drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly.
|
||||
expect('default' in acpAgent).toBe(false)
|
||||
expect(typeof acpAgent.apply).toBe('function')
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(acpAgent) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(acpAgent)
|
||||
expect(unwrapped.name).toBe('acp-demo')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
188
packages/examples/acp-demo/tests/built-bin.e2e.ts
Normal file
188
packages/examples/acp-demo/tests/built-bin.e2e.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
|
||||
* require a valid initialize response. This catches built-only settle races and stdout protocol
|
||||
* leaks that the tsx source-path smoke cannot. It skips before build; initialize is keyless, with a
|
||||
* dummy key used only to boot the adapter. `--expose-internals` enables Cordis bare-plugin loading.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const acpBin = join(repoRoot, 'packages/examples/acp-demo/lib/bin.js')
|
||||
|
||||
const dshPackages = [
|
||||
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
|
||||
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo',
|
||||
]
|
||||
const vendorPackages = [
|
||||
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
|
||||
'schemastery', 'cosmokit',
|
||||
]
|
||||
// Resolve ACP's declared third-party dependencies from that package, not this test: pnpm's strict
|
||||
// layout need not hoist them. Symlink those exact paths into the plain-Node consumer.
|
||||
const npmDeps = ['@agentclientprotocol/sdk', 'zod']
|
||||
const acpPkgDir = join(repoRoot, 'packages/ui/acp')
|
||||
|
||||
async function pkgName(absDir: string): Promise<string> {
|
||||
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
|
||||
return json.name
|
||||
}
|
||||
|
||||
async function link(target: string, name: string, nm: string): Promise<void> {
|
||||
const dest = join(nm, name)
|
||||
await mkdir(dirname(dest), { recursive: true })
|
||||
await symlink(target, dest)
|
||||
}
|
||||
|
||||
/** Build a temp consumer dir + a minimal acp `cordis.yml`. Returns the dir. */
|
||||
async function makeConsumer(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'acp-built-bin-'))
|
||||
const nm = join(dir, 'node_modules')
|
||||
for (const rel of dshPackages) {
|
||||
const abs = join(repoRoot, 'packages', rel)
|
||||
await link(abs, await pkgName(abs), nm)
|
||||
}
|
||||
for (const v of vendorPackages) {
|
||||
const abs = join(repoRoot, 'vendor', v)
|
||||
await link(abs, await pkgName(abs), nm)
|
||||
}
|
||||
for (const dep of npmDeps) {
|
||||
// Resolve from `ui/acp`'s package.json URL (the package that declares the
|
||||
// dep), not this test file's location — `acp-agent` does not depend on these.
|
||||
const fromAcp = pathToFileURL(join(acpPkgDir, 'package.json')).href
|
||||
const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp))
|
||||
await link(dirname(resolved), dep, nm)
|
||||
}
|
||||
await writeFile(join(dir, 'cordis.yml'), [
|
||||
'- id: llm-deepseek',
|
||||
' name: \'@deepseek-ai/dsh-llm-deepseek\'',
|
||||
' config:',
|
||||
' apiKey: !!js process.env.DEEPSEEK_API_KEY',
|
||||
'- id: bash',
|
||||
' name: \'@deepseek-ai/dsh-bash-local\'',
|
||||
'- id: acp-agent',
|
||||
' name: \'@deepseek-ai/dsh-acp-demo\'',
|
||||
' config:',
|
||||
' provider: deepseek',
|
||||
' model: deepseek-v4-flash',
|
||||
' persona: \'test agent\'',
|
||||
'',
|
||||
].join('\n'))
|
||||
return dir
|
||||
}
|
||||
|
||||
let consumer: string | undefined
|
||||
let child: ReturnType<typeof spawn> | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (child !== undefined) { child.kill('SIGKILL'); child = undefined }
|
||||
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true })
|
||||
consumer = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => {
|
||||
consumer = await makeConsumer()
|
||||
child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], {
|
||||
cwd: consumer,
|
||||
// Dummy key: initialize never reaches the model, so it is never used.
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_HOME: join(consumer, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(consumer, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
const stderr: string[] = []
|
||||
child.stderr!.setEncoding('utf8')
|
||||
child.stderr!.on('data', (c: string) => stderr.push(c))
|
||||
// Tee raw stdout for a protocol-purity check, and feed it to the SDK client.
|
||||
const rawOut: string[] = []
|
||||
const passthrough = new Readable({ read() {} })
|
||||
child.stdout!.on('data', (buf: Buffer) => { rawOut.push(buf.toString('utf8')); passthrough.push(buf) })
|
||||
child.stdout!.on('end', () => passthrough.push(null))
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
|
||||
)
|
||||
const makeClient = (_a: AcpAgent): Client => ({
|
||||
sessionUpdate(_p: SessionNotification): Promise<void> { return Promise.resolve() },
|
||||
requestPermission(_p: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
|
||||
},
|
||||
})
|
||||
const client = new ClientSideConnection(makeClient, stream)
|
||||
|
||||
const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// A response at all proves the built bin booted the bridge (the settle-race
|
||||
// regression would exit before answering); loadSession proves the real app
|
||||
// mounted, not a collapsed export shape.
|
||||
expect(init.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(stderr.join('')).not.toContain('without inject')
|
||||
// stdout purity: every emitted line is a JSON-RPC frame, no logger leak.
|
||||
for (const line of rawOut.join('').split('\n').filter(l => l.trim().length > 0)) {
|
||||
expect(() => JSON.parse(line) as unknown).not.toThrow()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
|
||||
// boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config
|
||||
// directory cannot break its import; the include plugin's own read must fail loud instead.
|
||||
const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml')
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('config file not found')
|
||||
}, 30_000)
|
||||
|
||||
it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
|
||||
// Existing directory plus missing config exercises the include plugin's fail-loud path.
|
||||
consumer = await makeConsumer()
|
||||
const { code, stderr } = await runBinExpectingExit('./does-not-exist.yml', consumer)
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('config file not found')
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
|
||||
function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, ['--expose-internals', acpBin, '--config', configArg], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
let stderr = ''
|
||||
proc.stderr.setEncoding('utf8')
|
||||
proc.stderr.on('data', (c: string) => { stderr += c })
|
||||
const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000)
|
||||
proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) })
|
||||
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
|
||||
proc.stdin.end()
|
||||
})
|
||||
}
|
||||
135
packages/examples/acp-demo/tests/load-path.e2e.ts
Normal file
135
packages/examples/acp-demo/tests/load-path.e2e.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
|
||||
/**
|
||||
* Source-path Loader smoke through the package's own bin, covering initialize, session/new, and
|
||||
* session/load across the `unwrapExports` path implicated by postmortem 0001. Session creation and
|
||||
* unknown-id loading reach factories but not the model, so a dummy key is sufficient. The temp cwd
|
||||
* is also the session workspace, and an explicit root tsconfig keeps unbuilt path aliases resolvable
|
||||
* when the child starts outside the repository.
|
||||
*/
|
||||
|
||||
const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
// Repo root is four levels up from packages/examples/acp-demo/tests.
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
// A minimal leaf that loads this app + the two backends — the same shape as
|
||||
// examples/acp-agent/cordis.yml, inlined so the package test owns its fixture.
|
||||
const CORDIS_YML = `
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
- id: acp-agent
|
||||
name: '@deepseek-ai/dsh-acp-demo'
|
||||
config:
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
persona: 'You are a test agent.'
|
||||
`
|
||||
|
||||
interface Spawned {
|
||||
child: ChildProcessWithoutNullStreams
|
||||
client: ClientSideConnection
|
||||
stderr: string[]
|
||||
}
|
||||
|
||||
let spawned: Spawned | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (spawned !== undefined) {
|
||||
spawned.child.kill('SIGKILL')
|
||||
spawned = undefined
|
||||
}
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
async function boot(): Promise<Spawned & { cwd: string }> {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'acp-agent-pkg-'))
|
||||
const cwd = workdir
|
||||
const configPath = join(cwd, 'cordis.yml')
|
||||
await writeFile(configPath, CORDIS_YML)
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, binScript, '--config', configPath],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
// Key-present check only; no prompt is sent, so the model is never called.
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
const stderr: string[] = []
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => stderr.push(chunk))
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
|
||||
)
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
sessionUpdate(_params: SessionNotification): Promise<void> {
|
||||
return Promise.resolve()
|
||||
},
|
||||
requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
|
||||
},
|
||||
})
|
||||
const client = new ClientSideConnection(makeClient, stream)
|
||||
spawned = { child, client, stderr }
|
||||
return { ...spawned, cwd }
|
||||
}
|
||||
|
||||
describe('dsh-acp-demo real-load-path smoke (bin + Loader, keyless)', () => {
|
||||
it('boots via its bin and answers initialize → session/new → session/load', async () => {
|
||||
const { client, cwd, stderr } = await boot()
|
||||
// initialize: a broken export shape (collapsed bridge plugin, dropped inject)
|
||||
// crashes the tree on the first service read here — see postmortem 0001.
|
||||
const init = await client.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {},
|
||||
})
|
||||
expect(init.agentCapabilities?.loadSession).toBe(true)
|
||||
|
||||
// session/new reaches the agent FACTORY (create) without the model.
|
||||
const { sessionId } = await client.newSession({ cwd, mcpServers: [] })
|
||||
expect(sessionId).toBeTruthy()
|
||||
|
||||
// session/load reaches the resume FACTORY + persistence without the model: load an UNKNOWN
|
||||
// id (loading the live `sessionId` would correctly reject as "already loaded"). Persistence
|
||||
// and resume run from the JSON-RPC loop outside bridge injection; a healthy tree reaches
|
||||
// not-found, while a collapsed export would fail earlier with missing injection.
|
||||
const unknownId = '00000000-0000-4000-8000-000000000000'
|
||||
await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then(
|
||||
() => { throw new Error('expected session/load of an unknown id to reject') },
|
||||
(error: unknown) => { expect(String(error)).not.toContain('without inject') },
|
||||
)
|
||||
|
||||
expect(stderr.join('')).not.toContain('without inject')
|
||||
}, 30_000)
|
||||
})
|
||||
Reference in New Issue
Block a user