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:
9
packages/acp/README.md
Normal file
9
packages/acp/README.md
Normal 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.
|
||||
77
packages/acp/acp/README.md
Normal file
77
packages/acp/acp/README.md
Normal 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.
|
||||
51
packages/acp/acp/package.json
Normal file
51
packages/acp/acp/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
63
packages/acp/acp/src/codec.ts
Normal file
63
packages/acp/acp/src/codec.ts
Normal 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')
|
||||
}
|
||||
341
packages/acp/acp/src/index.ts
Normal file
341
packages/acp/acp/src/index.ts
Normal 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')
|
||||
}
|
||||
30
packages/acp/acp/src/invariant.ts
Normal file
30
packages/acp/acp/src/invariant.ts
Normal 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 */
|
||||
78
packages/acp/acp/tests/approval.spec.ts
Normal file
78
packages/acp/acp/tests/approval.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
148
packages/acp/acp/tests/bridge.spec.ts
Normal file
148
packages/acp/acp/tests/bridge.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
38
packages/acp/acp/tests/codec.spec.ts
Normal file
38
packages/acp/acp/tests/codec.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
85
packages/acp/acp/tests/dispose.spec.ts
Normal file
85
packages/acp/acp/tests/dispose.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
69
packages/acp/acp/tests/edges.spec.ts
Normal file
69
packages/acp/acp/tests/edges.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
174
packages/acp/acp/tests/harness.ts
Normal file
174
packages/acp/acp/tests/harness.ts
Normal 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
|
||||
}
|
||||
95
packages/acp/acp/tests/multi-session.spec.ts
Normal file
95
packages/acp/acp/tests/multi-session.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
169
packages/acp/acp/tests/turns.spec.ts
Normal file
169
packages/acp/acp/tests/turns.spec.ts
Normal 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') })
|
||||
})
|
||||
})
|
||||
33
packages/acp/acp/tsconfig.json
Normal file
33
packages/acp/acp/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user