deepseek-harness/packages/acp/acp/tests/bridge.spec.ts

983 lines
50 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
import { startHttpMcpFixture } from '../../../mcp/mcp-client/tests/http-fixture.ts'
function oneToolCall(): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: ToolCallId('call-switch'), name: 'switch_model', argumentsDelta: '{}' },
{
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: ToolCallId('call-switch'), name: 'switch_model', arguments: '{}' },
},
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
describe('automation-only ACP bridge', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
it('advertises the standard automation controls without private metadata', async () => {
harness = await makeBridgeHarness()
const response = await harness.client.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: { _meta: { terminal_output: true } },
})
expect(response).toEqual({
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
mcpCapabilities: { http: true },
promptCapabilities: { image: false, audio: false, embeddedContext: false },
sessionCapabilities: { close: {}, list: {}, resume: {} },
},
authMethods: [],
})
})
it('advertises image prompts only with an exact capable route and attachment store', async () => {
harness = await makeBridgeHarness({ imageCapable: true })
const capable = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
expect(capable.agentCapabilities?.promptCapabilities?.image).toBe(true)
await harness.dispose()
harness = await makeBridgeHarness({ imageCapable: true, attachments: false })
const noStore = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
expect(noStore.agentCapabilities?.promptCapabilities?.image).toBe(false)
})
it('negotiates an unsupported version and accepts the required no-op authentication call', async () => {
harness = await makeBridgeHarness()
const response = await harness.client.initialize({ protocolVersion: 0, clientCapabilities: {} })
expect(response.protocolVersion).toBe(PROTOCOL_VERSION)
await expect(harness.client.authenticate({ methodId: 'unused' })).resolves.toEqual({})
})
it('creates a session, emits one committed answer, and settles the prompt', async () => {
harness = await makeBridgeHarness({ script: [textResponse('hello there')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const result = await harness.client.prompt({
sessionId,
prompt: [{ type: 'text', text: 'say hello' }],
})
expect(result.stopReason).toBe('end_turn')
await vi.waitFor(() => { expect(harness!.updates.at(-1)?.sessionUpdate).toBe('usage_update') })
expect(harness.updates[0]).toMatchObject({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'hello there' },
})
expect('messageId' in harness.updates[0]!).toBe(true)
if ('messageId' in harness.updates[0]!) expect(typeof harness.updates[0].messageId).toBe('string')
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.header.cwd).toBe(process.cwd())
expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'say hello' }])
})
it('closes one active session without affecting its neighbor', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.closeSession({ sessionId: first.sessionId })
expect(harness.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined()
expect(harness.ctx.agents.get(SessionId(second.sessionId))).toBeDefined()
await expect(harness.client.prompt({
sessionId: first.sessionId,
prompt: [{ type: 'text', text: 'closed' }],
})).rejects.toThrow(/unknown session/)
})
it('cancels a running prompt and makes its session resumable before close returns', async () => {
harness = await makeBridgeHarness({ script: ['hang', textResponse('resumed')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const prompt = harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'hang' }] })
await vi.waitFor(() => {
expect(harness!.ctx.agents.get(SessionId(created.sessionId))?.status).toBe('running')
})
await harness.client.closeSession({ sessionId: created.sessionId })
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
await expect(harness.client.listSessions({})).resolves.toMatchObject({
sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }],
})
await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd(), mcpServers: [] })
})
it('shares one close operation and rejects new work while close is draining', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const flushing: PromiseWithResolvers<void> = Promise.withResolvers()
const flush = vi.spyOn(harness.ctx.sessions, 'flush').mockImplementationOnce(() => flushing.promise.then(() => true))
const first = harness.client.closeSession({ sessionId: created.sessionId })
await vi.waitFor(() => { expect(flush).toHaveBeenCalled() })
const second = harness.client.closeSession({ sessionId: created.sessionId })
harness.registerCatalogProvider('closing-topology')
await expect(harness.client.prompt({
sessionId: created.sessionId,
prompt: [{ type: 'text', text: 'too late' }],
})).rejects.toThrow(/session is closing/)
flushing.resolve()
await expect(Promise.all([first, second])).resolves.toEqual([{}, {}])
})
it('disposes the Agent and reports an explicit close drain failure', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(created.sessionId))!
vi.spyOn(agent, 'whenIdle').mockRejectedValueOnce(new Error('idle probe failed'))
await expect(harness.client.closeSession({ sessionId: created.sessionId })).rejects.toThrow(/session close failed/)
expect(harness.ctx.agents.get(SessionId(created.sessionId))).toBeUndefined()
})
it('resumes a closed persisted session without replaying its history', async () => {
harness = await makeBridgeHarness({ script: [textResponse('first answer'), textResponse('second answer')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first prompt' }] })
await harness.client.closeSession({ sessionId: created.sessionId })
const updatesBeforeResume = harness.updates.length
const resumed = await harness.client.resumeSession({
sessionId: created.sessionId,
cwd: process.cwd(),
mcpServers: [],
})
expect(Array.isArray(resumed.configOptions)).toBe(true)
expect(harness.updates).toHaveLength(updatesBeforeResume)
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second prompt' }] })
expect(harness.adapter.requests[1]?.messages.map(message => message.content)).toContainEqual([
{ type: 'text', text: 'first prompt' },
])
})
it('materializes an empty closed session for list and resume', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.closeSession({ sessionId: created.sessionId })
await expect(harness.client.listSessions({})).resolves.toEqual({
sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }],
})
await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() }))
.resolves.toHaveProperty('configOptions')
})
it('rejects active or wrong-workspace resume before composing another Agent', async () => {
harness = await makeBridgeHarness({ script: [textResponse('persisted')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.resumeSession({
sessionId: created.sessionId,
cwd: process.cwd(),
mcpServers: [],
})).rejects.toThrow(/already active/)
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] })
await harness.client.closeSession({ sessionId: created.sessionId })
const resume = vi.spyOn(harness.ctx.agents, 'resume')
await expect(harness.client.resumeSession({
sessionId: created.sessionId,
cwd: tmpdir(),
mcpServers: [],
})).rejects.toThrow(/cwd does not match/)
expect(resume).not.toHaveBeenCalled()
await expect(harness.client.resumeSession({
sessionId: created.sessionId,
cwd: `${process.cwd()}/packages/..`,
mcpServers: [],
})).resolves.toHaveProperty('configOptions')
})
it('reserves a persisted id across concurrent resume admission', async () => {
harness = await makeBridgeHarness({ script: [textResponse('persisted')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] })
await harness.client.closeSession({ sessionId: created.sessionId })
const resume = harness.ctx.agents.resume.bind(harness.ctx.agents)
const entered: PromiseWithResolvers<void> = Promise.withResolvers()
const release: PromiseWithResolvers<void> = Promise.withResolvers()
vi.spyOn(harness.ctx.agents, 'resume').mockImplementationOnce(async (options) => {
entered.resolve()
await release.promise
return resume(options)
})
const first = harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })
await entered.promise
await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() }))
.rejects.toThrow(/already active/)
await expect(harness.client.listSessions({})).resolves.toEqual({ sessions: [] })
release.resolve()
await expect(first).resolves.toHaveProperty('configOptions')
})
it('excludes a globally live session owned outside this ACP bridge', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const sessionId = SessionId('other-frontend-live')
harness.ctx.sessions.create(sessionId, { meta: { cwd: process.cwd() } })
vi.spyOn(harness.ctx.sessionPersistence, 'list').mockResolvedValue([{
version: 0,
id: sessionId,
createdAt: 1,
cwd: process.cwd(),
}])
const resume = vi.spyOn(harness.ctx.agents, 'resume')
await expect(harness.client.listSessions({})).resolves.toEqual({ sessions: [] })
await expect(harness.client.resumeSession({
sessionId,
cwd: process.cwd(),
mcpServers: [],
})).rejects.toThrow(/already active/)
expect(resume).not.toHaveBeenCalled()
})
it('rejects unknown resume ids and rolls back invalid resume MCP', async () => {
harness = await makeBridgeHarness({ script: [textResponse('persisted')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.resumeSession({
sessionId: 'missing',
cwd: process.cwd(),
})).rejects.toThrow(/not resumable/)
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] })
await harness.client.closeSession({ sessionId: created.sessionId })
const duplicate = { name: 'same', command: process.execPath, args: [], env: [] }
await expect(harness.client.resumeSession({
sessionId: created.sessionId,
cwd: process.cwd(),
mcpServers: [duplicate, duplicate],
})).rejects.toThrow(/duplicate normalized name/)
expect(harness.ctx.agents.list()).toHaveLength(0)
})
it('restores the deployment selection when persisted events have no request header', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(created.sessionId))!
agent.session.append('session/title', { title: 'materialized', messageSeqs: [], source: { kind: 'fallback' } })
await harness.client.closeSession({ sessionId: created.sessionId })
const resumed = await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })
expect(resumed.configOptions?.find(option => option.id === 'model')).toMatchObject({
currentValue: '["mock","mock"]',
})
})
it('restores an explicitly selected reasoning effort', async () => {
harness = await makeBridgeHarness({ script: [textResponse('persisted')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.setSessionConfigOption({
sessionId: created.sessionId,
configId: 'reasoning_effort',
value: 'low',
})
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] })
await harness.client.closeSession({ sessionId: created.sessionId })
const resumed = await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })
expect(resumed.configOptions?.find(option => option.id === 'reasoning_effort')).toMatchObject({
currentValue: 'low',
})
})
it('lists closed persisted sessions without presentation metadata', async () => {
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist me' }] })
await harness.client.closeSession({ sessionId: created.sessionId })
await expect(harness.client.listSessions({})).resolves.toEqual({
sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }],
})
})
it('paginates resumable sessions with an opaque deterministic cursor', async () => {
harness = await makeBridgeHarness({
config: { sessionListPageSize: 1 },
script: [textResponse('first'), textResponse('second')],
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId: first.sessionId, prompt: [{ type: 'text', text: 'first' }] })
await harness.client.closeSession({ sessionId: first.sessionId })
const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId: second.sessionId, prompt: [{ type: 'text', text: 'second' }] })
await harness.client.closeSession({ sessionId: second.sessionId })
const firstPage = await harness.client.listSessions({})
expect(firstPage.sessions).toHaveLength(1)
expect(firstPage.nextCursor).toEqual(expect.any(String))
if (typeof firstPage.nextCursor !== 'string') throw new Error('expected a pagination cursor')
const secondPage = await harness.client.listSessions({ cursor: firstPage.nextCursor })
expect(secondPage.sessions).toHaveLength(1)
expect(secondPage.nextCursor).toBeUndefined()
expect(new Set([...firstPage.sessions, ...secondPage.sessions].map(item => item.sessionId)))
.toEqual(new Set([first.sessionId, second.sessionId]))
await expect(harness.client.listSessions({ cursor: 'not-a-cursor' })).rejects.toThrow(/cursor is invalid/)
})
it('filters non-resumable headers and canonical missing workspaces', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const active = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const persistence = harness.ctx.get('sessionPersistence')!
vi.spyOn(persistence, 'list').mockResolvedValue([
{ version: 0, id: SessionId(active.sessionId), createdAt: 9, cwd: process.cwd() },
{ version: 0, id: SessionId('subagent'), createdAt: 8, cwd: '/missing/filter', origin: 'subagent' },
{ version: 0, id: SessionId('fork'), createdAt: 7, cwd: '/missing/filter', parentSession: SessionId('parent') },
{ version: 0, id: SessionId('no-cwd'), createdAt: 6 },
{ version: 0, id: SessionId('relative'), createdAt: 5, cwd: 'relative' },
{ version: 0, id: SessionId('other'), createdAt: 4, cwd: '/missing/other' },
{ version: 0, id: SessionId('valid-b'), createdAt: 3, cwd: '/missing/filter' },
{ version: 0, id: SessionId('valid-a'), createdAt: 3, cwd: '/missing/filter' },
])
await expect(harness.client.listSessions({ cwd: 'relative' })).rejects.toThrow(/absolute path/)
await expect(harness.client.listSessions({ cwd: '/missing/filter' })).resolves.toEqual({
sessions: [
{ sessionId: 'valid-a', cwd: '/missing/filter' },
{ sessionId: 'valid-b', cwd: '/missing/filter' },
],
})
await expect(harness.client.resumeSession({
sessionId: 'no-cwd',
cwd: '/missing/filter',
})).rejects.toThrow(/cwd does not match/)
})
it.each([
[null],
[[]],
[['not-a-number', 'id']],
[[-1, 'id']],
[[1, '']],
] as const)('rejects malformed decoded list cursors %#', async (decoded) => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const cursor = Buffer.from(JSON.stringify(decoded)).toString('base64url')
await expect(harness.client.listSessions({ cursor })).rejects.toThrow(/cursor is invalid/)
})
it('rejects invalid and non-canonical cursor encodings', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.listSessions({ cursor: '*' })).rejects.toThrow(/cursor is invalid/)
const bytes = Buffer.from(JSON.stringify([1, 'id']))
const canonical = bytes.toString('base64url')
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
const nonCanonical = alphabet.split('')
.map(char => canonical.slice(0, -1) + char)
.find(candidate => candidate !== canonical && Buffer.from(candidate, 'base64url').equals(bytes))
if (nonCanonical === undefined) throw new Error('expected an alternate base64url spelling')
await expect(harness.client.listSessions({ cursor: nonCanonical })).rejects.toThrow(/cursor is invalid/)
})
it('rolls back new and resume when configuration discovery fails', async () => {
harness = await makeBridgeHarness({ script: [textResponse('persisted')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const resolve = vi.spyOn(harness.ctx.llm, 'resolveCallConfig')
resolve.mockRejectedValueOnce(new Error('catalog resolution failed'))
await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }))
.rejects.toThrow(/Internal error/)
expect(harness.ctx.agents.list()).toHaveLength(0)
await expect(harness.ctx.sessionPersistence.list()).resolves.toEqual([])
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] })
await harness.client.closeSession({ sessionId: created.sessionId })
resolve.mockRejectedValueOnce(new Error('resume catalog failed'))
await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() }))
.rejects.toThrow(/Internal error/)
expect(harness.ctx.agents.list()).toHaveLength(0)
})
it('propagates non-MCP Agent factory failures and non-config selection failures', async () => {
harness = await makeBridgeHarness({ script: [textResponse('persisted')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const create = vi.spyOn(harness.ctx.agents, 'create')
create.mockRejectedValueOnce(new Error('factory create failed'))
await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }))
.rejects.toThrow(/Internal error/)
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const model = created.configOptions?.find(option => option.id === 'model')
if (model?.type !== 'select') throw new Error('expected model options')
const plain = model.options.flatMap(option => 'group' in option ? option.options : [option])
.find(option => option.name === 'Mock Plain')
if (plain === undefined) throw new Error('expected plain model')
const resolution = vi.spyOn(harness.ctx.llm, 'resolveCallConfig').mockRejectedValue(new Error('selection failed'))
await expect(harness.client.setSessionConfigOption({
sessionId: created.sessionId,
configId: 'model',
value: plain.value,
})).rejects.toThrow(/Internal error/)
resolution.mockRestore()
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] })
await harness.client.closeSession({ sessionId: created.sessionId })
vi.spyOn(harness.ctx.agents, 'resume').mockRejectedValueOnce(new Error('factory resume failed'))
await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() }))
.rejects.toThrow(/Internal error/)
})
it('lists and resumes persisted sessions after an equivalent process restart', async () => {
const persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-acp-restart-'))
try {
harness = await makeBridgeHarness({ persistenceRoot, script: [textResponse('before restart')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first' }] })
await harness.client.closeSession({ sessionId: created.sessionId })
await harness.dispose()
harness = await makeBridgeHarness({ persistenceRoot, script: [textResponse('after restart')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.listSessions({})).resolves.toEqual({
sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }],
})
await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({
sessionId: created.sessionId,
prompt: [{ type: 'text', text: 'second' }],
})).resolves.toEqual({ stopReason: 'end_turn' })
} finally {
await harness?.dispose()
harness = undefined
await rm(persistenceRoot, { recursive: true, force: true })
}
})
it('discovers and selects a session model through standard config options', async () => {
harness = await makeBridgeHarness({ script: [textResponse('plain answer')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const model = created.configOptions?.find(option => option.id === 'model')
if (model?.type !== 'select') throw new Error('expected a model select option')
const choices = model.options.flatMap(option => 'group' in option ? option.options : [option])
const plain = choices.find(option => option.name === 'Mock Plain')
if (plain === undefined) throw new Error('expected Mock Plain in the model catalog')
const selected = await harness.client.setSessionConfigOption({
sessionId: created.sessionId,
configId: 'model',
value: plain.value,
})
expect(selected.configOptions.find(option => option.id === 'reasoning_effort')).toBeUndefined()
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use plain' }] })
expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'plain' })
})
it('publishes complete config options when adapter topology changes', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
harness.registerCatalogProvider('other')
await vi.waitFor(() => {
const update = harness!.updates.find(item => item.sessionUpdate === 'config_option_update')
expect(update).toBeDefined()
if (update?.sessionUpdate !== 'config_option_update') return
const model = update.configOptions.find(option => option.id === 'model')
if (model?.type !== 'select') throw new Error('expected a model select option')
expect(model.options.some(option => 'group' in option && option.group === 'other')).toBe(true)
})
expect(harness.sessionUpdates.at(-1)?.sessionId).toBe(created.sessionId)
})
it('does not let hung topology discovery block prompt completion or close', async () => {
harness = await makeBridgeHarness({ script: [textResponse('still responsive')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const original = harness.ctx.llm.listModels.bind(harness.ctx.llm)
const blocked = Promise.withResolvers<Awaited<ReturnType<typeof original>>>()
const listModels = vi.spyOn(harness.ctx.llm, 'listModels').mockImplementation((provider: string) => (
provider === 'hung' ? blocked.promise : original(provider)
))
try {
harness.registerCatalogProvider('hung')
await vi.waitFor(() => { expect(listModels).toHaveBeenCalledWith('hung') })
await expect(harness.client.prompt({
sessionId: created.sessionId,
prompt: [{ type: 'text', text: 'continue while discovery is pending' }],
})).resolves.toEqual({ stopReason: 'end_turn' })
await expect(harness.client.closeSession({ sessionId: created.sessionId })).resolves.toEqual({})
} finally {
blocked.resolve([])
listModels.mockRestore()
}
})
it('publishes recoverable options when the selected adapter disappears', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
harness.registerCatalogProvider('other')
await vi.waitFor(() => {
expect(harness!.updates.some(update => update.sessionUpdate === 'config_option_update')).toBe(true)
})
harness.replacePrimaryProviders([])
expect(harness.ctx.llm.listProviders().map(provider => provider.id)).toEqual(['other'])
await vi.waitFor(() => {
const configUpdates = harness!.updates.filter(item => item.sessionUpdate === 'config_option_update')
expect(configUpdates).toHaveLength(2)
const update = configUpdates.at(-1)
if (update?.sessionUpdate !== 'config_option_update') throw new Error('expected config update')
const model = update.configOptions.find(option => option.id === 'model')
if (model?.type !== 'select') throw new Error('expected model options')
const groups = model.options.filter(option => 'group' in option)
expect(groups.map(group => group.group)).toEqual(['other', 'mock'])
expect(model.currentValue).toBe('["mock","mock"]')
})
expect(harness.sessionUpdates.at(-1)?.sessionId).toBe(created.sessionId)
})
it('selects an advertised reasoning effort for the next turn', async () => {
harness = await makeBridgeHarness({ script: [textResponse('reasoned')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const reasoning = created.configOptions?.find(option => option.id === 'reasoning_effort')
if (reasoning?.type !== 'select') throw new Error('expected a reasoning select option')
const low = reasoning.options.find(option => !('group' in option) && option.name === 'Low')
if (low === undefined || 'group' in low) throw new Error('expected Low reasoning effort')
await harness.client.setSessionConfigOption({
sessionId: created.sessionId,
configId: 'reasoning_effort',
value: low.value,
})
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'reason' }] })
expect(harness.adapter.requests[0]?.reasoningEffort).toBe('low')
})
it('rejects unknown config choices without changing the selected route', async () => {
harness = await makeBridgeHarness({ script: [textResponse('unchanged')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.setSessionConfigOption({
sessionId: created.sessionId,
configId: 'model',
value: 'not-advertised',
})).rejects.toThrow(/unknown model option/)
await expect(harness.client.setSessionConfigOption({
sessionId: created.sessionId,
configId: 'private_option',
value: 'anything',
})).rejects.toThrow(/unknown session config option/)
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' })
})
it('serializes concurrent standard config changes in receive order', async () => {
harness = await makeBridgeHarness({ script: [textResponse('plain')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const model = created.configOptions?.find(option => option.id === 'model')
const reasoning = created.configOptions?.find(option => option.id === 'reasoning_effort')
if (model?.type !== 'select' || reasoning?.type !== 'select') throw new Error('expected model and reasoning options')
const plain = model.options.flatMap(option => 'group' in option ? option.options : [option])
.find(option => option.name === 'Mock Plain')
const low = reasoning.options.find(option => !('group' in option) && option.name === 'Low')
if (plain === undefined || low === undefined || 'group' in low) throw new Error('expected selectable values')
await Promise.all([
harness.client.setSessionConfigOption({
sessionId: created.sessionId,
configId: 'reasoning_effort',
value: low.value,
}),
harness.client.setSessionConfigOption({
sessionId: created.sessionId,
configId: 'model',
value: plain.value,
}),
])
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'plain' })
expect(harness.adapter.requests[0]?.reasoningEffort).toBeUndefined()
})
it('pins image admission and request routing to one prompt selection', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('image accepted')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const model = created.configOptions?.find(option => option.id === 'model')
if (model?.type !== 'select') throw new Error('expected a model option')
const plain = model.options.flatMap(option => 'group' in option ? option.options : [option])
.find(option => option.name === 'Mock Plain')
if (plain === undefined) throw new Error('expected Mock Plain')
const validationStarted = Promise.withResolvers<undefined>()
const releaseValidation = Promise.withResolvers<undefined>()
harness.attachments!.beforeValidate = () => {
validationStarted.resolve(undefined)
return releaseValidation.promise
}
const prompt = harness.client.prompt({
sessionId: created.sessionId,
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
})
await validationStarted.promise
await harness.client.setSessionConfigOption({
sessionId: created.sessionId,
configId: 'model',
value: plain.value,
})
releaseValidation.resolve(undefined)
await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' })
expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' })
harness.attachments!.beforeValidate = undefined
await expect(harness.client.prompt({
sessionId: created.sessionId,
prompt: [{ type: 'image', data: 'Ag==', mimeType: 'image/png' }],
})).rejects.toThrow(/does not declare image input/)
})
it('applies a mid-turn model change to the following turn', async () => {
harness = await makeBridgeHarness({ script: [oneToolCall(), textResponse('first turn'), textResponse('second turn')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const model = created.configOptions?.find(option => option.id === 'model')
if (model?.type !== 'select') throw new Error('expected a model select option')
const choices = model.options.flatMap(option => 'group' in option ? option.options : [option])
const plain = choices.find(option => option.name === 'Mock Plain')
if (plain === undefined) throw new Error('expected Mock Plain in the model catalog')
harness.ctx.tools.register(defineContentToolFixture({
name: 'switch_model',
description: 'Switch the following turn to the plain model.',
parameters: {},
execute: async () => {
await harness!.client.setSessionConfigOption({
sessionId: created.sessionId,
configId: 'model',
value: plain.value,
})
return [{ type: 'text', text: 'selected' }]
},
}))
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first' }] })
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second' }] })
expect(harness.adapter.requests.map(request => request.model)).toEqual(['mock', 'mock', 'plain'])
})
it('mounts a standard stdio MCP server inside the created session', async () => {
harness = await makeBridgeHarness({ script: [textResponse('used MCP')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const fixtureServer = fileURLToPath(new URL('../../../mcp/mcp-client/tests/fixture-server.ts', import.meta.url))
const created = await harness.client.newSession({
cwd: process.cwd(),
mcpServers: [{ name: 'fixture', command: process.execPath, args: [fixtureServer], env: [] }],
})
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use MCP' }] })
expect(harness.adapter.requests[0]?.tools?.map(tool => tool.name)).toContain('mcp__fixture__add')
await harness.client.closeSession({ sessionId: created.sessionId })
}, 30_000)
it('mounts a standard Streamable HTTP MCP server with request headers', async () => {
const fixture = await startHttpMcpFixture()
try {
harness = await makeBridgeHarness({ script: [textResponse('used HTTP MCP')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({
cwd: process.cwd(),
mcpServers: [{
type: 'http',
name: 'web',
url: fixture.url,
headers: [{ name: 'Authorization', value: 'Bearer acp-test' }],
}],
})
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use HTTP MCP' }] })
expect(harness.adapter.requests[0]?.tools?.map(tool => tool.name)).toContain('mcp__web__ping')
expect(fixture.authorization).toContain('Bearer acp-test')
await harness.client.closeSession({ sessionId: created.sessionId })
} finally {
await fixture.close()
}
}, 30_000)
it('allows the same MCP server namespace in independent sessions', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const fixtureServer = fileURLToPath(new URL('../../../mcp/mcp-client/tests/fixture-server.ts', import.meta.url))
const mcpServers = [{ name: 'fixture', command: process.execPath, args: [fixtureServer], env: [] }]
const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers })
const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers })
await Promise.all([
harness.client.closeSession({ sessionId: first.sessionId }),
harness.client.closeSession({ sessionId: second.sessionId }),
])
}, 30_000)
it('validates standard MCP declarations before publishing an Agent', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const stdio = { name: 'fixture', command: process.execPath, args: [], env: [] }
const invalidLists = [
[stdio, stdio],
[{ ...stdio, name: ' ' }],
[{ ...stdio, command: 'node' }],
[{ ...stdio, env: [{ name: 'BAD=NAME', value: 'x' }] }],
[{ type: 'http' as const, name: 'web', url: 'file:///tmp/mcp', headers: [] }],
[{ type: 'http' as const, name: 'web', url: 'https://example.test/mcp', headers: [{ name: 'bad header', value: 'x' }] }],
[{ type: 'sse' as const, name: 'legacy', url: 'https://example.test/sse', headers: [] }],
[{ type: 'acp' as const, name: 'nested', serverId: 'server-1' }],
]
for (const mcpServers of invalidLists) {
await expect(harness.client.newSession({
cwd: process.cwd(),
mcpServers,
})).rejects.toThrow(/mcpServers/)
expect(harness.ctx.agents.list()).toHaveLength(0)
}
})
it('reconnects requested MCP servers when resuming a closed session', async () => {
harness = await makeBridgeHarness({ script: [textResponse('first'), textResponse('second')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const fixtureServer = fileURLToPath(new URL('../../../mcp/mcp-client/tests/fixture-server.ts', import.meta.url))
const mcpServers = [{ name: 'fixture', command: process.execPath, args: [fixtureServer], env: [] }]
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers })
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first' }] })
await harness.client.closeSession({ sessionId: created.sessionId })
await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd(), mcpServers })
await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second' }] })
expect(harness.adapter.requests[1]?.tools?.map(tool => tool.name)).toContain('mcp__fixture__add')
}, 30_000)
it('leaves absent agent targets for request listeners to supply', async () => {
harness = await makeBridgeHarness({ config: { provider: undefined, model: undefined } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(harness.ctx.agents.get(SessionId(sessionId))?.options).toEqual({})
})
it('allows request listeners to supply a route when ACP has no initial selection', async () => {
harness = await makeBridgeHarness({
config: { provider: undefined, model: undefined },
script: [textResponse('listener-routed')],
})
harness.ctx.on('agent/request', async (_payload, next) => ({
...await next(),
provider: 'mock',
model: 'mock',
}))
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(created.configOptions).toEqual([])
await expect(harness.client.prompt({
sessionId: created.sessionId,
prompt: [{ type: 'text', text: 'route me' }],
})).resolves.toEqual({ stopReason: 'end_turn' })
expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' })
})
it('concatenates text blocks without exposing protocol framing to the model', async () => {
harness = await makeBridgeHarness({ script: [textResponse('done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: 'first' },
{ type: 'text', text: ' second' },
],
})
expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'first second' }])
})
it('admits mixed text/image prompts in wire order and logs references only', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const resolve = vi.spyOn(harness.ctx.llm, 'resolveModelInfo')
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: 'before' },
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
{ type: 'text', text: 'between' },
{ type: 'image', data: 'Ag==', mimeType: 'image/jpeg' },
{ type: 'text', text: 'after' },
],
})
expect(resolve).toHaveBeenCalledWith('mock', 'mock', expect.any(AbortSignal))
expect(harness.attachments?.saved.map(input => [...input.data])).toEqual([[1], [2]])
const requestContent = harness.adapter.requests[0]?.messages.at(-1)?.content
expect(requestContent?.map(block => block.type)).toEqual(['text', 'image', 'text', 'image', 'text'])
expect(requestContent?.[0]).toEqual({ type: 'text', text: 'before' })
expect(requestContent?.[2]).toEqual({ type: 'text', text: 'between' })
expect(requestContent?.[4]).toEqual({ type: 'text', text: 'after' })
const firstImage = requestContent?.[1]
const secondImage = requestContent?.[3]
if (firstImage?.type !== 'image' || secondImage?.type !== 'image') throw new Error('expected ordered image blocks')
expect(firstImage.attachment.mediaType).toBe('image/png')
expect(firstImage.attachment.bytes).toBe(1)
expect(secondImage.attachment.mediaType).toBe('image/jpeg')
expect(secondImage.attachment.bytes).toBe(1)
const agent = harness.ctx.agents.get(SessionId(sessionId))
expect(JSON.stringify(agent?.session.snapshotEvents())).not.toContain('AQ==')
})
it('rejects a malformed image batch atomically and frees the prompt slot', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('recovered')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({
sessionId,
prompt: [
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
{ type: 'image', data: 'not base64', mimeType: 'image/png' },
],
})).rejects.toThrow(/canonical base64/)
expect(harness.attachments?.saved).toEqual([])
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'retry' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
})
it('reports durable image write failures as internal prompt failures', async () => {
harness = await makeBridgeHarness({ imageCapable: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
vi.spyOn(harness.attachments!, 'saveImages').mockRejectedValueOnce(
new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'),
)
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
})).rejects.toThrow(/unable to persist the prompt image batch/)
})
it('renders the deployment persona for an ACP-created agent', async () => {
harness = await makeBridgeHarness({ persona: 'Automation persona for {{model}} in {{cwd}}.', script: [textResponse('ok')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(harness.adapter.requests[0]?.system).toContain(`Automation persona for mock in ${process.cwd()}.`)
})
it('requires one absolute primary workspace', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.newSession({ cwd: 'relative', mcpServers: [] })).rejects.toThrow(/absolute path/)
await expect(harness.client.newSession({
cwd: process.cwd(),
mcpServers: [],
additionalDirectories: ['/tmp/other'],
})).rejects.toThrow(/additionalDirectories/)
await expect(harness.client.newSession({
cwd: process.cwd(),
mcpServers: [],
additionalDirectories: [],
})).resolves.toHaveProperty('sessionId')
})
it('rejects empty and unadvertised image prompts before a turn starts', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: ' ' }] }))
.rejects.toThrow(/empty prompt/)
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'image', data: '', mimeType: 'image/png' }],
})).rejects.toThrow(/inline image prompts were not advertised/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false)
})
it('renders baseline resource links as textual references in the user message', async () => {
harness = await makeBridgeHarness({ script: [textResponse('done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: 'summarize' },
{ type: 'resource_link', name: 'notes.txt', uri: 'file:///tmp/notes.txt' },
],
})
expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{
type: 'text',
text: 'summarize\n[resource_link name="notes.txt" uri="file:///tmp/notes.txt"]\n',
}])
})
it('rejects prompts for unknown sessions and ignores unknown cancellation', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.prompt({ sessionId: 'missing', prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/unknown session/)
await expect(harness.client.cancel({ sessionId: 'missing' })).resolves.toBeUndefined()
})
})