Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/README.md
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.md
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/service.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/src/client/stores.ts
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/package.json
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/sessions.schema.ts
#	packages/host/runtime/package.json
#	packages/host/runtime/src/boot.ts
#	packages/host/runtime/tests/host-runtime.spec.ts
#	packages/host/runtime/tsconfig.json
#	packages/host/webserver/README.md
#	packages/host/webserver/src/index.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	packages/llm/llm-pi-ai/tests/convert.spec.ts
#	packages/ui/acp/src/codec.ts
#	packages/ui/acp/tests/codec.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-25 22:35:22 +08:00
1264 changed files with 37694 additions and 29428 deletions

9
packages/acp/README.md Normal file
View File

@@ -0,0 +1,9 @@
# acp/ — Agent Client Protocol automation
The ACP group exposes harness agents to programmatic clients. It is an interoperability transport, not a presentation or human-interaction layer.
| Package | Role |
|---|---|
| [`acp/`](acp/README.md) | Automation-only ACP server: fresh text sessions, committed assistant output, machine permission policy, cancellation, and connection-owned teardown. |
The matching out-of-process subagent client remains in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface; arbitrary ACP clients may drive the same server contract.

View File

@@ -0,0 +1,77 @@
# @deepseek-ai/dsh-acp
Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text prompts, collect committed assistant text, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md).
This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the web and TUI modules.
## Plugin
`apply(ctx, config)` opens an `AgentSideConnection` on stdin/stdout and drives `ctx.agents`. Stdout is reserved for protocol frames.
| Config | Default | Meaning |
|---|---|---|
| `provider` | — | Initial provider route for every created agent. |
| `model` | — | Initial model for every created agent. |
Both fields are optional so another agent/request listener may supply the target. The runnable ACP composition requires both.
## Protocol contract
| Method | Behavior |
|---|---|
| `initialize` | Negotiates the supported version and advertises baseline-only prompts (no image, audio, or embedded-context capability). No session, editor, terminal, filesystem, or MCP capability is advertised. |
| `authenticate` | No-op because the server advertises no authentication methods. |
| `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. |
| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and settles from that request's owning durable `turn/end`. |
| `session/cancel` | Cancels only the addressed agent and settles its pending prompt as `cancelled`; unknown ids are no-ops. |
| `session/update` | Emits one `agent_message_chunk` per non-empty text block in a committed `assistant/message`. Raw deltas and non-message events are omitted. |
| `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. |
One connection may own several sessions. The bridge keys records by branded session id and checks exact agent identity before routing events or permission requests. Each session has an independent prompt slot, workspace, cancellation path, and disposer.
Committed-message output intentionally trades token-by-token latency for a clean automation result. Uncommitted provider chunks and retry attempts cannot leak partial text; reasoning and tool activity remain in the session log for observability through other interfaces.
## Lifecycle
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then disposes all owned agent handles in parallel and awaits their loop/session cleanup. An ACP-only plugin reload therefore leaves no orphan agent.
## Running
`pnpm --dir /path/to/deepseek-harness run demo:acp` boots the repository's automation server composition. A parent harness can spawn it through [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md); other ACP clients need only the core methods above.
## Model Experience
### Prompt text
#### What the model sees
`session/prompt` text blocks are concatenated verbatim into one user message; a baseline resource link appears in that message as a bracketed `[resource_link name=… uri=…]` reference the model may open with its own tools. Protocol metadata, client capabilities, permission choices, and session ids never enter the model request.
#### Token effect
Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions retain independent contexts.
#### KV Cache effect
Append-only; the new user message follows the reusable request prefix and does not invalidate prior cache entries.
### Permission decisions
#### What the model sees
Nothing directly. The owning tool records its allowed, rejected, cancelled, or unavailable outcome through the normal tool-result path.
#### Token effect
Only the owning tool result contributes tokens.
#### KV Cache effect
Append-only through the owning tool result.
## Known Limitations and Deferred Work
- **Fresh sessions only** — load, list, resume, delete, and fork are unsupported.
- **Baseline prompts and one workspace only** — images, audio, embedded resources, non-empty additional directories, and MCP servers reject; resource links flatten to textual references rather than fetched content.
- **Committed answers only** — live progress, reasoning, tool activity, plans, titles, and usage stay off the wire.
- **Connection-owned lifetime** — one connection releases all of its sessions; per-session close is not implemented.

View File

@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-acp",
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
"schemastery": "^3.17.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,63 @@
/**
* Pure translation between the harness lifecycle and the automation-only ACP wire.
* @module @deepseek-ai/dsh-acp/codec
*/
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
/**
* Map a harness turn ending to ACP's terminal reason vocabulary.
* @param reason - harness turn outcome.
* @returns the closest legal ACP stop reason.
*/
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
switch (reason.kind) {
case 'completed':
return 'end_turn'
case 'max-tokens':
return 'max_tokens'
case 'aborted':
case 'disposed':
case 'rejected':
case 'interrupted':
return 'cancelled'
case 'error':
return 'end_turn'
// TurnEndReason is merge-extensible; future variants still need a legal wire value.
default:
return 'end_turn'
}
}
/**
* Flatten an ACP prompt's baseline blocks to text. Text blocks concatenate
* verbatim; resource links become explicit textual references so a baseline
* client can point at files without the bridge silently dropping that context.
* @param prompt - supported ACP prompt blocks.
* @returns text in wire order, with resource links rendered as bracketed references.
*/
export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
return prompt.flatMap((block): string[] => {
switch (block.type) {
case 'text':
return [block.text]
case 'resource_link':
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
default:
return []
}
}).join('')
}
/**
* Whether a prompt carries content beyond the ACP baseline. The spec requires
* every agent to accept `text` and `resource_link`; richer inline payloads
* (image, audio, embedded resource) are optional capabilities this bridge does
* not advertise, so they are rejected rather than silently dropped.
* @param prompt - ACP prompt blocks to inspect.
* @returns `true` when any block is neither `text` nor `resource_link`.
*/
export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link')
}

View File

@@ -0,0 +1,341 @@
/**
* Automation-only Agent Client Protocol server over JSON-RPC stdio.
*
* The bridge exposes fresh harness sessions to trusted programmatic clients. It
* carries prompt text, committed assistant text, cancellation, and one-shot
* permission decisions; presentation and human-interaction features stay with
* the harness's UI modules.
*
* @module @deepseek-ai/dsh-acp
*/
import type { Context } from 'cordis'
import { randomUUID } from 'node:crypto'
import { isAbsolute } from 'node:path'
import { Readable, Writable } from 'node:stream'
import Schema from 'schemastery'
import {
AgentSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
RequestError,
type Agent as AcpAgent,
type AuthenticateRequest,
type CancelNotification,
type InitializeRequest,
type InitializeResponse,
type NewSessionRequest,
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
type SessionNotification,
type StopReason,
type Stream,
} from '@agentclientprotocol/sdk'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
// Side-effect type import: declaration-merges the approval waterfall answered below.
import type {} from '@deepseek-ai/dsh-user-approval'
import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from './codec.ts'
export const name = 'acp'
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
export const inject = ['agents']
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
}
/** Plugin config: the provider/model target used for each ACP-created agent. */
export interface AcpConfig {
/** Provider route for created agents. */
provider?: string
/** Model name for created agents. */
model?: string
/** Runtime-only transport override; production uses stdio. */
stream?: Stream
}
export const Config: Schema<AcpConfig> = Schema.object({
provider: Schema.string(),
model: Schema.string(),
})
/** Per-session protocol state. */
interface SessionRecord {
agent: Agent
/** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */
dispose: () => Promise<void>
/** In-flight prompt and its captured turn number for exact settlement. */
inflight: {
resolve: (reason: StopReason) => void
reject: (error: Error) => void
turn: number | undefined
} | undefined
}
/**
* Mount the automation-only ACP server.
* @param ctx - Cordis context carrying the agent factory and session events.
* @param config - Initial provider/model target and optional test transport.
*/
export function apply(ctx: Context, config: AcpConfig): void {
// ACP handlers execute outside this plugin's injection scope, so capture the
// injected service during apply rather than reading it lazily in a callback.
const agents = ctx.agents
const logger = ctx.logger
const sessions = new Map<SessionId, SessionRecord>()
let closed = false
let conn: AgentSideConnection
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
const ownedRecord = (agent: Agent): SessionRecord | undefined => {
const record = sessions.get(agent.session.id)
return record?.agent === agent ? record : undefined
}
const assertOpen = (): void => {
if (closed) throw internalError('the ACP bridge has been disposed')
}
const requireSession = (sessionId: SessionId): SessionRecord => {
const record = sessions.get(sessionId)
if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`)
return record
}
/** Send a protocol update without letting a disconnected client fail an agent turn. */
const notify = (notification: SessionNotification): void => {
/* v8 ignore next 3 -- only a transport write failure reaches this guard. */
void conn.sessionUpdate(notification).catch((error: unknown) => {
logger.warn(`acp: session/update failed: ${String(error)}`)
})
}
const settlePrompt = (record: SessionRecord, reason: StopReason): void => {
const inflight = record.inflight
if (inflight === undefined) return
record.inflight = undefined
inflight.resolve(reason)
}
const settleFromTurnEnd = (
inflight: NonNullable<SessionRecord['inflight']>,
reason: TurnEndReason,
): void => {
if (reason.kind === 'error') {
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
return
}
inflight.resolve(turnEndToStopReason(reason))
}
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
// titles, and retry markers are presentation or trace data and stay off the
// automation wire.
ctx.on('session/event', (session, event: SessionEvent) => {
const record = sessions.get(session.header.id)
if (record === undefined || record.agent.session !== session) return
try {
if (event.type === 'assistant/message') {
for (const block of event.data.content) {
if (block.type === 'text' && block.text.length > 0) {
notify({
sessionId: record.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: block.text },
},
})
} else if (block.type === 'image') {
notify({
sessionId: record.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: `[image attachment ${block.attachment.attachmentId}]`,
},
},
})
}
}
}
} finally {
const inflight = record.inflight
if (inflight !== undefined && event.type === 'turn/start') {
if (inflight.turn === undefined && event.data.trigger.kind === 'message'
&& event.data.trigger.source.kind === 'user') {
inflight.turn = event.data.turn
}
} else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
record.inflight = undefined
settleFromTurnEnd(inflight, event.data.reason)
}
}
})
// Permission requests are a machine policy channel for ACP clients such as
// dsh-subagent-acp. The bridge offers one-shot choices only and never infers a
// durable grant from an unknown client response.
ctx.on('approval/request', (request, next) => {
const record = ownedRecord(request.agent)
if (record === undefined || request.callId === undefined) return next()
return conn.requestPermission({
sessionId: record.agent.session.id,
toolCall: { toolCallId: request.callId },
options: [
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
],
}).then(({ outcome }) => {
if (outcome.outcome === 'cancelled') return 'cancelled'
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
})
})
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
conn = connection
return {
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
// Single-version agent: the spec's "same version if supported, else
// the latest supported" both resolve to this server's one version.
return Promise.resolve({
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
promptCapabilities: { image: false, audio: false, embeddedContext: false },
},
authMethods: [],
})
},
authenticate(_params: AuthenticateRequest): Promise<void> {
return Promise.resolve()
},
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
assertOpen()
validateSessionParams(params)
const sessionId = SessionId(randomUUID())
const handle = await agents.create({
sessionId,
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
})
/* v8 ignore next 4 -- a real stdio close can race an in-flight create. */
if (closed) {
await handle.dispose()
throw internalError('connection closed during session/new')
}
sessions.set(sessionId, {
agent: handle.agent,
dispose: () => handle.dispose(),
inflight: undefined,
})
return { sessionId }
},
async prompt(params: PromptRequest): Promise<PromptResponse> {
assertOpen()
const record = requireSession(SessionId(params.sessionId))
if (record.inflight !== undefined) {
throw invalidParams('a prompt is already in flight for this session')
}
if (promptHasUnsupportedContent(params.prompt)) {
throw invalidParams('only text and resource_link prompt content is supported')
}
const text = acpPromptToText(params.prompt)
if (text.trim().length === 0) throw invalidParams('empty prompt')
const stopReason = await new Promise<StopReason>((resolve, reject) => {
// Arm the slot before followup() so a listener-driven synchronous
// turn cannot slip past correlation; a synchronous followup()
// failure (an agent disposed outside the bridge, e.g. an
// agent-loop-only reload) must free the slot again or the session
// would reject every later prompt as already in flight.
record.inflight = { resolve, reject, turn: undefined }
try {
record.agent.followup([{ type: 'text', text }])
} catch (error: unknown) {
record.inflight = undefined
// followup() throws only Errors (disposed agent / invalid input);
// the String arm is a defensive fallback for a non-Error throw.
/* v8 ignore next */
const detail = error instanceof Error ? error.message : String(error)
throw internalError(`prompt was not queued: ${detail}`)
}
})
return { stopReason }
},
cancel(params: CancelNotification): Promise<void> {
const record = sessions.get(SessionId(params.sessionId))
if (record === undefined) return Promise.resolve()
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
return Promise.resolve()
},
}
}
/* v8 ignore next 4 -- production stdio wiring; tests inject config.stream. */
const stream: Stream = config.stream ?? ndJsonStream(
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
)
conn = new AgentSideConnection(makeAgent, stream)
let quiescing: Promise<void> | undefined
const quiesce = (): Promise<void> => {
if (quiescing !== undefined) return quiescing
closed = true
const records = [...sessions.values()]
sessions.clear()
quiescing = Promise.all(records.map(async (record) => {
settlePrompt(record, 'cancelled')
await record.dispose()
})).then(() => {})
return quiescing
}
/* v8 ignore start -- production transport rejection and teardown failure. */
void conn.closed
.catch((error: unknown) => {
logger.warn(`acp: connection closed with an error: ${String(error)}`)
})
.then(quiesce)
.catch((error: unknown) => {
logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
})
/* v8 ignore stop */
ctx.effect(() => quiesce, 'acp.connection')
}
/**
* Build per-agent options from plugin config without assigning absent optional fields.
* @param config - ACP provider/model configuration.
* @returns the configured fields only.
*/
function agentOptions(config: AcpConfig): { provider?: string; model?: string } {
return {
...config.provider !== undefined ? { provider: config.provider } : {},
...config.model !== undefined ? { model: config.model } : {},
}
}
/** Reject session features outside the automation contract. */
function validateSessionParams(params: NewSessionRequest): void {
if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
throw invalidParams('additionalDirectories is not supported')
}
if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported')
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-acp`.
* @module @deepseek-ai/dsh-acp/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-acp'
/** Cordis companion plugin name. */
export const name = 'acp-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this transport owns no durable package-local event stream;
* protocol and lifecycle tests cover its mapping.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
describe('ACP machine permission policy', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
async function ownedRequest(overrides: Partial<ApprovalRequest> = {}): Promise<ApprovalRequest> {
if (harness === undefined) throw new Error('missing harness')
await harness.ctx.plugin(ApprovalService)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
return { agent, toolName: 'bash', callId: CallId('call-9'), ...overrides }
}
it('maps the two advertised one-shot choices', async () => {
harness = await makeBridgeHarness()
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
const request = await ownedRequest()
await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once')
expect(harness.permissionRequests[0]).toMatchObject({
sessionId: request.agent.session.id,
toolCall: { toolCallId: 'call-9' },
options: [
{ optionId: 'allow-once', kind: 'allow_once' },
{ optionId: 'reject-once', kind: 'reject_once' },
],
})
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } })
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
})
it('maps cancellation and unknown choices without granting access', async () => {
harness = await makeBridgeHarness()
const request = await ownedRequest()
await expect(harness.ctx.approval.request(request)).resolves.toBe('cancelled')
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'unknown-grant' } })
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
})
it('fails closed when the client errors the permission request', async () => {
harness = await makeBridgeHarness()
const request = await ownedRequest()
harness.onPermission = () => { throw new Error('client gone') }
await expect(harness.ctx.approval.request(request)).resolves.toBe('unavailable')
})
it('delegates a same-id foreign agent', async () => {
harness = await makeBridgeHarness()
const request = await ownedRequest()
const foreign = {
session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) },
} as unknown as Agent
await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') }))
.resolves.toBe('unavailable')
expect(harness.permissionRequests).toHaveLength(0)
})
it('delegates requests that have no protocol tool-call identity', async () => {
harness = await makeBridgeHarness()
const request = await ownedRequest()
await expect(harness.ctx.approval.request({ agent: request.agent, toolName: request.toolName }))
.resolves.toBe('unavailable')
expect(harness.permissionRequests).toHaveLength(0)
})
})

View File

@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
describe('automation-only ACP bridge', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
it('advertises only fresh text sessions', 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: {
promptCapabilities: { image: false, audio: false, embeddedContext: false },
},
authMethods: [],
})
})
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).toHaveLength(1) })
expect(harness.updates).toEqual([{
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'hello there' },
}])
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('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('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('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 workspace and no MCP servers', 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: [{ name: 'fs', command: 'node', args: [], env: [] }],
})).rejects.toThrow(/mcpServers/)
await expect(harness.client.newSession({
cwd: process.cwd(),
mcpServers: [],
additionalDirectories: [],
})).resolves.toHaveProperty('sessionId')
})
it('rejects empty and beyond-baseline 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(/only text and resource_link/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events.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()
})
})

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from '../src/codec.ts'
describe('ACP automation codec', () => {
it('maps every known turn outcome to a legal stop reason', () => {
const cases: [TurnEndReason, string][] = [
[{ kind: 'completed' }, 'end_turn'],
[{ kind: 'max-tokens' }, 'max_tokens'],
[{ kind: 'aborted' }, 'cancelled'],
[{ kind: 'disposed' }, 'cancelled'],
[{ kind: 'rejected', reason: 'blocked' }, 'cancelled'],
[{ kind: 'interrupted' }, 'cancelled'],
[{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'],
]
for (const [reason, expected] of cases) expect(turnEndToStopReason(reason)).toBe(expected)
})
it('uses a legal fallback for merge-extensible future outcomes', () => {
expect(turnEndToStopReason({ kind: 'future' } as unknown as TurnEndReason)).toBe('end_turn')
})
it('flattens baseline blocks and rejects everything richer', () => {
expect(acpPromptToText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab')
expect(acpPromptToText([
{ type: 'text', text: 'see' },
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
])).toBe('see\n[resource_link name="x" uri="file:///x"]\n')
expect(acpPromptToText([{ type: 'image', data: '', mimeType: 'image/png' }])).toBe('')
expect(promptHasUnsupportedContent([
{ type: 'text', text: 'ok' },
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
])).toBe(false)
expect(promptHasUnsupportedContent([
{ type: 'image', data: '', mimeType: 'image/png' },
])).toBe(true)
})
})

View File

@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
describe('ACP connection ownership', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
it('disposal cancels a running prompt and awaits agent teardown', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
await vi.waitFor(() => { expect(agent.status).toBe('running') })
await harness.acpFiber.dispose()
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
expect(agent.status).toBe('disposed')
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('an ACP-only reload rejects new sessions before creating an orphan', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.acpFiber.dispose()
await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }))
.rejects.toThrow(/disposed/)
expect(harness.ctx.agents.list()).toHaveLength(0)
})
it('a client disconnect disposes every owned session without root-context disposal', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await vi.waitFor(() => { expect(agent.status).toBe('running') })
await harness.closeClientTransport()
await harness.acpFiber.dispose()
expect(agent.status).toBe('disposed')
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
})
it('a failed client transport still disposes every owned session', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await vi.waitFor(() => { expect(agent.status).toBe('running') })
await harness.abortClientTransport()
await vi.waitFor(() => { expect(agent.status).toBe('disposed') })
await vi.waitFor(() => {
expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true)
})
})
it('disconnect and plugin disposal share one quiescence boundary', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await vi.waitFor(() => { expect(agent.status).toBe('running') })
await Promise.all([harness.closeClientTransport(), harness.acpFiber.dispose()])
expect(agent.status).toBe('disposed')
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('disposing a session-less bridge is idempotent', async () => {
harness = await makeBridgeHarness()
await Promise.all([harness.acpFiber.dispose(), harness.acpFiber.dispose()])
expect(harness.ctx.agents.list()).toHaveLength(0)
})
})

View File

@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { CallId, 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'
function toolCallResponse(): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('call-1'), name: 'echo', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-1'), name: 'echo', arguments: '{}' } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
describe('ACP automation output boundary', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
it('does not emit tool, terminal, plan, title, or reasoning presentation updates', async () => {
harness = await makeBridgeHarness({ script: [toolCallResponse(), textResponse('done')] })
harness.ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'Return a deterministic result.',
parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: 'tool result' }]),
}))
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' }] })
await vi.waitFor(() => { expect(harness!.updates).toHaveLength(1) })
expect(harness.updates).toEqual([{
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'done' },
}])
})
it('ignores events from agents the bridge does not own', async () => {
harness = await makeBridgeHarness({ script: [textResponse('foreign')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const { agent } = await harness.ctx.agents.create({
sessionId: SessionId('foreign'),
agentOptions: { provider: 'mock', model: 'mock' },
})
agent.followup([{ type: 'text', text: 'go' }])
await agent.whenIdle()
expect(harness.updates).toHaveLength(0)
})
// `session/update` is a JSON-RPC notification, so a client-side handler
// failure never reaches the bridge; this pins that the prompt still settles
// normally with such a client. The bridge's own write-failure guard is
// transport-level and documented untestable at `notify`.
it('settles the prompt normally when the client rejects update notifications', async () => {
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
harness.onSessionUpdateError = () => {}
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
})
})

View File

@@ -0,0 +1,174 @@
/** In-memory ACP transport fixture over the real agent factory and loop. */
import { Context } from 'cordis'
import {
ClientSideConnection,
ndJsonStream,
type Agent as AcpAgent,
type Client,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type Stream,
} from '@agentclientprotocol/sdk'
import { type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as AcpPlugin from '../src/index.ts'
import type { AcpConfig } from '../src/index.ts'
/** Scripted adapter for protocol tests. */
class MockAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private readonly script: (StreamChunk[] | 'hang')[]) {
super()
}
override providerInfo(provider: string) {
if (provider !== 'mock') throw new Error(`MockAdapter: unknown provider ${provider}`)
return { id: 'mock', name: 'Mock' }
}
override listModels(provider: string) {
return Promise.resolve(provider === 'mock' ? [{ provider: 'mock', id: 'mock', name: 'Mock' }] : [])
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()
if (entry === undefined) throw new Error('MockAdapter: script exhausted')
if (entry === 'hang') {
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'partial' }
await new Promise<void>((_resolve, reject) => {
if (options.signal?.aborted) {
reject(new Error('aborted'))
return
}
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
})
return
}
for (const chunk of entry) {
if (options.signal?.aborted) throw new Error('aborted')
yield chunk
}
}
}
/** Scripted text response ending in a clean stop. */
export function textResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 5, outputTokens: text.length } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
/** Scripted response ending at the output-token ceiling. */
export function maxTokensResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
]
}
/** Scripted response that fails after publishing an uncommitted partial chunk. */
export function errorResponse(message: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'partial' },
{ type: 'finish', reason: { kind: 'error', failure: { message, code: 'PROVIDER_ERROR' } } },
]
}
export type CapturedUpdate = SessionNotification['update']
export interface BridgeHarness {
ctx: Context
client: ClientSideConnection
adapter: MockAdapter
updates: CapturedUpdate[]
sessionUpdates: { sessionId: string; update: CapturedUpdate }[]
permissionRequests: RequestPermissionRequest[]
onPermission: (request: RequestPermissionRequest) => RequestPermissionResponse
onSessionUpdateError: (() => void) | undefined
closeClientTransport: () => Promise<void>
abortClientTransport: () => Promise<void>
acpFiber: Awaited<ReturnType<Context['plugin']>>
/** The AgentLoop fiber, so a test can reload the loop out from under the bridge. */
loopFiber: Awaited<ReturnType<Context['plugin']>>
dispose: () => Promise<void>
}
type AcpConfigOverrides = { [K in keyof AcpConfig]?: AcpConfig[K] | undefined }
/** Build the bridge and a connected SDK client over cross-wired byte streams. */
export async function makeBridgeHarness(options: {
script?: (StreamChunk[] | 'hang')[]
config?: AcpConfigOverrides
persona?: string
} = {}): Promise<BridgeHarness> {
const adapter = new MockAdapter(options.script ?? [])
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } })
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const agentToClient = new TransformStream<Uint8Array, Uint8Array>()
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>()
const clientToAgentWriter = clientToAgent.writable.getWriter()
const clientOutput = new WritableStream<Uint8Array>({
write: chunk => clientToAgentWriter.write(chunk),
})
const agentStream: Stream = ndJsonStream(agentToClient.writable, clientToAgent.readable)
const clientStream: Stream = ndJsonStream(clientOutput, agentToClient.readable)
const updates: CapturedUpdate[] = []
const sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = []
const permissionRequests: RequestPermissionRequest[] = []
const harness: BridgeHarness = {
ctx,
adapter,
updates,
sessionUpdates,
permissionRequests,
onPermission: () => ({ outcome: { outcome: 'cancelled' } }),
onSessionUpdateError: undefined,
client: undefined as unknown as ClientSideConnection,
acpFiber: undefined as unknown as BridgeHarness['acpFiber'],
loopFiber,
closeClientTransport: async () => { await clientToAgentWriter.close() },
abortClientTransport: async () => { await clientToAgentWriter.abort(new Error('client transport failed')) },
dispose: async () => { await ctx.fiber.dispose() },
}
const makeClient = (_agent: AcpAgent): Client => ({
sessionUpdate(params: SessionNotification): Promise<void> {
updates.push(params.update)
sessionUpdates.push({ sessionId: params.sessionId, update: params.update })
if (harness.onSessionUpdateError !== undefined) return Promise.reject(new Error('client update rejected'))
return Promise.resolve()
},
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
permissionRequests.push(params)
return Promise.resolve(harness.onPermission(params))
},
})
const config = { stream: agentStream, ...options.config } as AcpConfig
if (!(options.config && 'provider' in options.config)) config.provider = 'mock'
if (!(options.config && 'model' in options.config)) config.model = 'mock'
harness.acpFiber = await ctx.plugin({
name: 'acp-test',
inject: [...AcpPlugin.inject],
apply: (inner: Context) => { AcpPlugin.apply(inner, config) },
})
harness.client = new ClientSideConnection(makeClient, clientStream)
return harness
}

View File

@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
function messageTextFor(
updates: { sessionId: string; update: CapturedUpdate }[],
sessionId: string,
): string {
return updates.flatMap(({ sessionId: owner, update }) => (
owner === sessionId && update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text'
? [update.content.text]
: []
)).join('')
}
describe('ACP multi-session isolation', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
it('demultiplexes concurrent answers by session id', async () => {
harness = await makeBridgeHarness({ script: [textResponse('answer-A'), textResponse('answer-B')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const [resultA, resultB] = await Promise.all([
harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] }),
harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }),
])
expect(resultA.stopReason).toBe('end_turn')
expect(resultB.stopReason).toBe('end_turn')
await vi.waitFor(() => {
expect(messageTextFor(harness!.sessionUpdates, a)).toBe('answer-A')
expect(messageTextFor(harness!.sessionUpdates, b)).toBe('answer-B')
})
})
it('cancels one session without affecting another', async () => {
harness = await makeBridgeHarness({ script: ['hang', textResponse('B done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const pendingA = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'hang A' }] })
await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(a))?.status).toBe('running') })
await harness.client.cancel({ sessionId: a })
await expect(pendingA).resolves.toEqual({ stopReason: 'cancelled' })
await expect(harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await vi.waitFor(() => { expect(messageTextFor(harness!.sessionUpdates, b)).toBe('B done') })
})
it('enforces one in-flight prompt independently for each session', async () => {
harness = await makeBridgeHarness({ script: ['hang', 'hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const pendingA = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'A' }] })
const pendingB = harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'B' }] })
await vi.waitFor(() => {
expect(harness!.ctx.agents.get(SessionId(a))?.status).toBe('running')
expect(harness!.ctx.agents.get(SessionId(b))?.status).toBe('running')
})
await expect(harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'again' }] }))
.rejects.toThrow(/already in flight/)
await Promise.all([harness.client.cancel({ sessionId: a }), harness.client.cancel({ sessionId: b })])
await expect(pendingA).resolves.toEqual({ stopReason: 'cancelled' })
await expect(pendingB).resolves.toEqual({ stopReason: 'cancelled' })
})
it('drains every live session on bridge disposal', async () => {
harness = await makeBridgeHarness({ script: ['hang', 'hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const agentA = harness.ctx.agents.get(SessionId(a))!
const agentB = harness.ctx.agents.get(SessionId(b))!
void harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'A' }] }).catch(() => {})
void harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'B' }] }).catch(() => {})
await vi.waitFor(() => {
expect(agentA.status).toBe('running')
expect(agentB.status).toBe('running')
})
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(SessionId(a))).toBeUndefined()
expect(harness.ctx.agents.get(SessionId(b))).toBeUndefined()
})
})

View File

@@ -0,0 +1,169 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
errorResponse,
makeBridgeHarness,
maxTokensResponse,
textResponse,
type BridgeHarness,
} from './harness.ts'
async function newSession(harness: BridgeHarness): Promise<string> {
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
return (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
}
function messageText(harness: BridgeHarness): string {
return harness.updates.flatMap(update => (
update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text'
? [update.content.text]
: []
)).join('')
}
describe('ACP prompt lifecycle', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
it('maps a max-token turn without losing its committed text', async () => {
harness = await makeBridgeHarness({ script: [maxTokensResponse('cut off')] })
const sessionId = await newSession(harness)
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(result.stopReason).toBe('max_tokens')
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
})
it('rejects a failed turn and never publishes its partial chunks', async () => {
harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/turn failed: provider boom/)
expect(messageText(harness)).toBe('')
})
it('rejects an ordinary plugin failure through the same prompt boundary', async () => {
harness = await makeBridgeHarness({ script: [textResponse('must not run')] })
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/turn failed: plugin pre-step failed/)
})
it('settles even when an earlier turn observer throws', async () => {
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
harness.ctx.on('session/event', (_session, event) => {
if (event.type === 'turn/start' || event.type === 'turn/end') throw new Error('peer listener boom')
}, { prepend: true })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
})
it('ignores an injection turn while correlating the owning message turn', async () => {
harness = await makeBridgeHarness({ script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))!
let injected = false
harness.ctx.on('agent/inbox/enqueue', (subject) => {
if (subject === agent && !injected) {
injected = true
agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'test' } })
}
})
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(result.stopReason).toBe('end_turn')
await vi.waitFor(() => { expect(messageText(harness!)).toBe('real answer') })
})
it('ignores an autonomous message turn while correlating the client turn', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))!
let inserted = false
harness.ctx.on('agent/inbox/enqueue', (subject, message) => {
if (subject !== agent || message.source.kind !== 'user' || inserted) return
inserted = true
const source = { kind: 'plugin', plugin: 'test' } as const
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })
agent.session.append('user/message', {
content: [{ type: 'text', text: 'autonomous work' }],
source,
}, { surfaceOp: 'append' })
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
let settled = false
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
.finally(() => { settled = true })
await vi.waitFor(() => {
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
})
expect(settled).toBe(false)
await harness.client.cancel({ sessionId })
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
})
it('frees the prompt slot when the agent rejects the send synchronously', async () => {
harness = await makeBridgeHarness({ script: [] })
const sessionId = await newSession(harness)
// Reload the loop out from under the bridge: its agents dispose while the
// bridge record survives, so the next send() throws synchronously.
await harness.loopFiber.dispose()
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] }))
.rejects.toThrow(/prompt was not queued/)
// The failed prompt must not wedge the session's single prompt slot.
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
.rejects.toThrow(/prompt was not queued/)
})
it('permits only one in-flight prompt per session', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const sessionId = await newSession(harness)
const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] })
await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId))?.status).toBe('running') })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
.rejects.toThrow(/already in flight/)
await harness.client.cancel({ sessionId })
await expect(first).resolves.toEqual({ stopReason: 'cancelled' })
})
it('cancels a running turn and records the aborted outcome', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const sessionId = await newSession(harness)
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
await vi.waitFor(() => { expect(agent.status).toBe('running') })
await harness.client.cancel({ sessionId })
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
await agent.whenIdle()
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason).toEqual({ kind: 'aborted' })
})
it('an idle cancel does not affect the following prompt', async () => {
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
const sessionId = await newSession(harness)
await harness.client.cancel({ sessionId })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await vi.waitFor(() => { expect(messageText(harness!)).toBe('answer') })
})
it('a late end from a cancelled turn cannot settle the next prompt', async () => {
harness = await makeBridgeHarness({ script: ['hang', textResponse('next')] })
const sessionId = await newSession(harness)
const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] })
await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId))?.status).toBe('running') })
await harness.client.cancel({ sessionId })
await expect(first).resolves.toEqual({ stopReason: 'cancelled' })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') })
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/session"
},
{
"path": "../../core/agent"
},
{
"path": "../../ui/user-approval"
},
{
"path": "../../support/invariants"
}
]
}