feat(acp): bridge durable image prompts and replies

This commit is contained in:
Tianyi Cui
2026-08-11 15:36:22 +08:00
parent 49426cae02
commit 4f87c1fe6d
37 changed files with 1808 additions and 223 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
README.md: 9cc4a5e271c7200f6ad8799a4b8fa9e64b2ca893
README.zh.md: eafae5602bdeb408ef548a9e706e059bd99bde17
README.md: 40d4b2df18f8102a352d8a8eb438e88da7fe720c
README.zh.md: 57b7e5a3f987861cfe0c5453f5d5a26d565f77ed

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
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).
Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text/image prompts, collect committed assistant text/images, 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 host and client modules.
@@ -21,21 +21,21 @@ Both fields are optional so another agent/request listener may supply the target
| 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. |
| `initialize` | Negotiates the supported version. Image prompts are advertised only when a durable attachment store is mounted and the configured exact provider/model resolves with explicit image input; audio and embedded context stay false. 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 waits for the whole agent to become idle. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. |
| `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/prompt` | Preserves ordered text and supported inline image blocks, renders resource links as bracketed textual references, and rejects audio, embedded resources, malformed/empty input, or an image when capability was not advertised. It validates the whole image batch and rechecks the session's latest exact route before any save, commits every image before the user event, permits one in-flight request per session, and waits for admission, whole-agent idle, and ordered output delivery. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. |
| `session/cancel` | Cancels only the addressed agent and marks any already-started admission so the pending prompt waits for it to quiesce, publishes no late user message, and settles as `cancelled`; unknown ids are no-ops. |
| `session/update` | Emits one `agent_message_chunk` per non-empty text or image block in a committed `assistant/message`, preserving order. Images are re-read and integrity-verified before inline base64 delivery. 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.
Committed-message output intentionally trades token-by-token latency for a clean automation result. Uncommitted provider chunks and retry attempts cannot leak partial text or images; reasoning and tool activity remain in the session log for observability through other interfaces. Per-session delivery is serialized because attachment reads are asynchronous, and a missing or corrupt committed image fails the prompt response instead of emitting a placeholder.
## Lifecycle
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, cancels and quiesces prompt admission, agent activity, and ordered output delivery, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit turn endings therefore do not become prompt-level ACP stop reasons (they settle as `end_turn`); a model error on the correlated turn rejects the prompt immediately.
@@ -45,15 +45,15 @@ ACP requires each prompt response to carry a `stopReason`, but the bridge does n
## Model Experience
### Prompt text
### Prompt text and images
#### 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.
`session/prompt` preserves text/image order in one user message; adjacent text is concatenated, and a resource link appears as a bracketed `[resource_link name=… uri=…]` reference the model may open with its own tools. Inline image base64 is discarded after batch admission, so the durable message contains only verified attachment references. 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.
Prompt tokens and image charges are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions retain independent contexts.
#### KV Cache effect
@@ -76,6 +76,6 @@ 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.
- **Raster images and one workspace only** — image prompts require a durable store plus an exact route that declares image input; only PNG, JPEG, WebP, and GIF are accepted. 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

@@ -2,7 +2,7 @@
[English](README.md) | 中文
通过 JSON-RPC stdio 提供的仅面向自动化的 [ACPAgent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent智能体、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
通过 JSON-RPC stdio 提供的仅面向自动化的 [ACPAgent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent智能体、发送文本/图片提示词、收集已提交的 assistant 文本/图片、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript文本记录回放、命令、模式、配置选择器、信息征集、推理reasoning、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 宿主和客户端模块。
@@ -21,21 +21,21 @@
| 方法 | 行为 |
|---|---|
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
| `initialize` | 协商受支持的版本。只有挂载持久附件存储,且配置的确切提供方/模型解析后明确支持图片输入时,才公布图片提示词能力;音频嵌入上下文保持 false。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent接受空的 `additionalDirectories``mcpServers`,拒绝非空值。 |
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入空闲状态。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 |
| `session/cancel` | 仅取消指定的 agent将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
| `session/prompt` | 保留文本与受支持内联图片块的顺序,将资源链接渲染为带方括号的文本引用,拒绝音频、嵌入资源、格式错误/空输入,或在未公布能力时提交图片。它会先校验完整图片批次并重新检查会话的最新确切路由,再保存任一成员;在用户事件前提交全部图片;每个会话只允许一个正在处理的请求,并等待准入、整个 agent 空闲和有序输出交付全部停稳。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 |
| `session/cancel` | 仅取消指定的 agent标记已经启动的准入工作,使待处理提示词等待其停稳、不发布迟到的用户消息,随后以 `cancelled` 结算;未知 id 为空操作。 |
| `session/update` | 为已提交 `assistant/message` 中的每个非空文本或图片块发出一个 `agent_message_chunk`,并保留顺序。图片在以内联 base64 交付前会重新读取并校验完整性。省略原始增量和非消息事件。 |
| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许拒绝选项。客户端可以自动回答。 |
一个连接可以拥有多个会话。桥接层以带品牌的会话 id 作为记录键,并在路由事件或权限请求前检查 agent 是否为同一对象。每个会话都有独立的提示词槽位、工作区、取消路径和资源释放器。
已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。
已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本或图片;推理与工具活动仍保留在会话日志中,以便其他界面观测。由于附件读取是异步的,每个会话会串行交付内容;已提交图片缺失或损坏时,提示词响应会失败,而不会发出占位符。
## 生命周期
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此仅 ACP 的插件重载不会遗留 agent。
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,取消并等待提示词准入、agent 活动和有序输出交付全部停稳,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此仅 ACP 的插件重载不会遗留 agent。
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出agent 进入空闲状态前发生的 steering中途引导或注入工作也可能参与其中。因此因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即拒绝该提示词。
@@ -45,15 +45,15 @@ ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它
## 模型体验
### 提示词文本
### 提示词文本与图片
#### 模型看到的内容
`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。
`session/prompt` 会在一条用户消息中保留文本/图片顺序;相邻文本会拼接,资源链接则表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。内联图片 base64 在批量准入后即被丢弃,因此持久消息只包含经过校验的附件引用。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。
#### Token 影响
提示词 token 取决于数据并保留在该会话的历史中直到上下文压缩context compaction。并发 ACP 会话保留独立上下文。
提示词 token 与图片费用取决于数据并保留在该会话的历史中直到上下文压缩context compaction。并发 ACP 会话保留独立上下文。
#### KV Cache 影响
@@ -76,6 +76,6 @@ ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它
## 已知限制与暂缓事项
- **仅新会话**:不支持加载、列出、恢复、删除和 fork。
- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。
- **仅光栅图片和一个 workspace**:图片提示词要求持久存储以及明确声明支持图片输入的确切路由;只接受 PNG、JPEG、WebP 和 GIF。音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。
- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不会通过协议传输。
- **由连接管理的生命周期**:一个连接会释放其所有会话;尚未实现单个会话关闭功能。

View File

@@ -36,13 +36,16 @@
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",

View File

@@ -3,7 +3,7 @@
* @module @deepseek-ai/dsh-acp/codec
*/
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
import type { StopReason } from '@agentclientprotocol/sdk'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
/**
@@ -32,35 +32,3 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
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,238 @@
/** ACP wire-content admission and projection owned by the ACP adapter. @module */
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
import type { Context } from '@deepseek-ai/cordis'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
/** Raster formats shared by ACP image blocks and the core attachment vocabulary. */
const IMAGE_MEDIA_TYPES: readonly ImageMediaType[] = [
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
]
/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
/** Content-admission failure category used by the protocol handler. */
export type AcpContentFailureKind = 'invalid' | 'internal'
/** Error with a stable ACP request-failure category and no raw binary payload. */
export class AcpContentError extends Error {
/** Whether the bridge should report invalid params or an internal failure. */
readonly kind: AcpContentFailureKind
/**
* @param message - safe protocol-facing detail without inline binary data.
* @param kind - request-failure category.
* @param options - optional causal chain for diagnostics.
*/
constructor(message: string, kind: AcpContentFailureKind, options?: ErrorOptions) {
super(message, options)
this.name = 'AcpContentError'
this.kind = kind
}
}
/** Narrow a wire MIME string to the durable raster vocabulary. */
function imageMediaType(value: string): ImageMediaType | undefined {
return IMAGE_MEDIA_TYPES.includes(value as ImageMediaType) ? value as ImageMediaType : undefined
}
/** Strictly decode one ACP inline image without accepting base64 aliases. */
function decodeImage(block: Extract<AcpContentBlock, { type: 'image' }>): SaveImageAttachment {
const mediaType = imageMediaType(block.mimeType)
if (mediaType === undefined) {
throw new AcpContentError('image mimeType must be image/png, image/jpeg, image/webp, or image/gif', 'invalid')
}
if (!CANONICAL_BASE64.test(block.data)) {
throw new AcpContentError('image data must be canonical base64', 'invalid')
}
const data = Buffer.from(block.data, 'base64')
if (data.toString('base64') !== block.data) {
throw new AcpContentError('image data must be canonical base64', 'invalid')
}
return { data, mediaType }
}
/** Resolve the exact current route and require explicit image input support. */
async function assertImageRoute(ctx: Context, agent: Agent, signal: AbortSignal): Promise<void> {
const routed = agent.session.requestHeader()?.config
const provider = routed?.provider ?? agent.options.provider
const model = routed?.model ?? agent.options.model
const llm = ctx.get('llm')
if (provider === undefined || model === undefined || llm === undefined) {
throw new AcpContentError('the current model route could not be resolved for image input', 'invalid')
}
let info: Awaited<ReturnType<typeof llm.resolveModelInfo>>
try {
info = await llm.resolveModelInfo(provider, model, signal)
} catch (error: unknown) {
throw new AcpContentError('the current model route could not be verified for image input', 'invalid', { cause: error })
}
if (info.inputModalities === undefined || !info.inputModalities.includes('image')) {
throw new AcpContentError(`model "${model}" does not declare image input`, 'invalid')
}
}
/**
* Determine whether initialization may truthfully advertise inline image prompts.
* Unknown service, route, capability, or deployment media support is negative.
* @param ctx - bridge context carrying optional attachment and model services.
* @param provider - configured provider route used for newly created sessions.
* @param model - configured exact model id used for newly created sessions.
* @returns whether this bridge can admit images at initialization time.
*/
export async function supportsAcpImagePrompts(
ctx: Context,
provider: string | undefined,
model: string | undefined,
): Promise<boolean> {
const attachments = ctx.get('attachments')
const llm = ctx.get('llm')
if (attachments === undefined || llm === undefined || provider === undefined || model === undefined) return false
if (!attachments.imageLimits.mediaTypes.some(mediaType => IMAGE_MEDIA_TYPES.includes(mediaType))) return false
try {
const info = await llm.resolveModelInfo(provider, model)
return info.inputModalities?.includes('image') === true
} catch {
return false
}
}
/** Render one baseline resource link into the core's current text vocabulary. */
function resourceLinkText(block: Extract<AcpContentBlock, { type: 'resource_link' }>): string {
return `\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`
}
/**
* Admit one ACP prompt into ordered durable core content.
* Every wire block and image is validated before the ordered image batch starts
* writing; cancellation after a successful content-addressed write may leave an
* unreachable object but never queues a late user message.
* @param ctx - bridge context carrying attachment and model services.
* @param agent - destination agent whose latest exact route controls admission.
* @param prompt - untrusted ACP prompt blocks in wire order.
* @param imageEnabled - capability result advertised during initialization.
* @param signal - admission cancellation signal.
* @returns core content with durable image references in wire order.
*/
export async function admitAcpPrompt(
ctx: Context,
agent: Agent,
prompt: readonly AcpContentBlock[],
imageEnabled: boolean,
signal: AbortSignal,
): Promise<ContentBlock[]> {
const images: SaveImageAttachment[] = []
for (const block of prompt) {
switch (block.type) {
case 'text':
case 'resource_link':
break
case 'image':
if (!imageEnabled) throw new AcpContentError('inline image prompts were not advertised by this connection', 'invalid')
images.push(decodeImage(block))
break
case 'audio':
throw new AcpContentError('audio prompt content is not supported', 'invalid')
case 'resource':
throw new AcpContentError('embedded resource prompt content is not supported', 'invalid')
/* v8 ignore next 2 -- ACP ContentBlock is a closed generated union. */
default:
throw new AcpContentError('unsupported ACP prompt content', 'invalid')
}
}
let refs: readonly ImageAttachmentRef[] = []
if (images.length > 0) {
const attachments = ctx.get('attachments')
if (attachments === undefined) throw new AcpContentError('no attachment store is mounted', 'invalid')
await assertImageRoute(ctx, agent, signal)
signal.throwIfAborted()
try {
refs = await attachments.saveImages(images)
} catch (error: unknown) {
if (error instanceof AttachmentError && error.code !== 'ATTACHMENT_WRITE_FAILED') {
throw new AcpContentError(error.message, 'invalid', { cause: error })
}
throw new AcpContentError('unable to persist the prompt image batch', 'internal', { cause: error })
}
signal.throwIfAborted()
}
const content: ContentBlock[] = []
let pendingText = ''
let imageIndex = 0
const flushText = (): void => {
if (pendingText.length === 0) return
content.push({ type: 'text', text: pendingText })
pendingText = ''
}
for (const block of prompt) {
switch (block.type) {
case 'text':
pendingText += block.text
break
case 'resource_link':
pendingText += resourceLinkText(block)
break
case 'image': {
flushText()
const ref = refs[imageIndex++] as ImageAttachmentRef
content.push({ type: 'image', attachment: ref })
break
}
/* v8 ignore start -- the validation pass above rejects both tags before reconstruction. */
case 'audio':
case 'resource':
break
/* v8 ignore stop */
/* v8 ignore next 2 -- validated by the first closed-union switch. */
default:
break
}
}
flushText()
if (!content.some(block => block.type === 'image' || (block.type === 'text' && block.text.trim().length > 0))) {
throw new AcpContentError('empty prompt', 'invalid')
}
return content
}
/**
* Translate one committed assistant block to ACP wire content.
* Images are re-read and integrity-verified before inline base64 delivery;
* unsupported core output blocks stay off the automation wire.
* @param ctx - bridge context carrying the authoritative attachment store.
* @param block - committed core assistant block.
* @returns ACP text/image content, or undefined for non-output blocks.
*/
export async function assistantBlockToAcp(
ctx: Context,
block: ContentBlock,
): Promise<AcpContentBlock | undefined> {
if (block.type === 'text') {
return block.text.length === 0 ? undefined : { type: 'text', text: block.text }
}
if (block.type !== 'image') return undefined
const attachments = ctx.get('attachments')
if (attachments === undefined) {
throw new AcpContentError('cannot deliver assistant image: no attachment store is mounted', 'internal')
}
let stored: Awaited<ReturnType<typeof attachments.readImage>>
try {
stored = await attachments.readImage(block.attachment)
} catch (error: unknown) {
throw new AcpContentError('cannot deliver assistant image: the attachment is unavailable or corrupt', 'internal', { cause: error })
}
return {
type: 'image',
data: Buffer.from(stored.data).toString('base64'),
mimeType: stored.ref.mediaType,
}
}

View File

@@ -2,9 +2,9 @@
* 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.
* carries prompt text/images, committed assistant text/images, cancellation,
* and one-shot permission decisions; presentation and human-interaction
* features stay with the harness's UI modules.
*
* @module @deepseek-ai/dsh-acp
*/
@@ -37,7 +37,8 @@ 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'
import { AcpContentError, admitAcpPrompt, assistantBlockToAcp, supportsAcpImagePrompts } from './content.ts'
import { turnEndToStopReason } from './codec.ts'
export const name = 'acp'
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
@@ -86,14 +87,27 @@ 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. */
/** Ordered assistant-output delivery; every task contains its own failure. */
outputTail: Promise<void>
/** In-flight admission/turn/output lifecycle for exact settlement. */
inflight: {
resolve: (reason: StopReason) => void
reject: (error: Error) => void
messageId: string
/** Set only after rich-content admission succeeds and the message is built. */
messageId: string | undefined
turn: number | undefined
/** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */
endReason: TurnEndReason | undefined
/** Admission quiescence gate, including any attachment write already in progress. */
admissionDone: Promise<void>
finishAdmission: () => void
admissionController: AbortController
cancelRequested: boolean
settlementStarted: boolean
/** Conversion failure for committed output owned by this prompt's turn. */
outputError: Error | undefined
/** Failure before a correlated turn exists. */
agentError: Error | undefined
} | undefined
}
@@ -110,6 +124,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
const sessions = new Map<SessionId, SessionRecord>()
let closed = false
let conn: AgentSideConnection
let imagePromptEnabled = false
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
const ownedRecord = (agent: Agent): SessionRecord | undefined => {
@@ -127,19 +142,15 @@ export function apply(ctx: Context, config: AcpConfig): void {
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) => {
/** Send one ordered protocol update while containing transport-only failure. */
const notify = async (notification: SessionNotification): Promise<void> => {
try {
await conn.sessionUpdate(notification)
/* v8 ignore start -- the ACP SDK contains notification-handler failures; only a transport write failure reaches this guard. */
} 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)
}
/* v8 ignore stop */
}
const rejectFromError = (
@@ -149,48 +160,89 @@ export function apply(ctx: Context, config: AcpConfig): void {
inflight.reject(internalError(`turn failed: ${reason.error.message}`))
}
// 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.
/**
* Settle one exact prompt only after admission, agent activity, and ordered
* assistant delivery have all reached quiescence.
*/
const settleAfterQuiescence = (
record: SessionRecord,
inflight: NonNullable<SessionRecord['inflight']>,
): void => {
if (inflight.settlementStarted) return
inflight.settlementStarted = true
void (async () => {
await inflight.admissionDone
await record.agent.whenIdle()
// session/event enqueues synchronously before the agent becomes idle;
// reading the live tail here includes every committed output task.
await record.outputTail
/* v8 ignore next -- this prompt owns the slot until this exact settlement clears it. */
if (record.inflight !== inflight) return
record.inflight = undefined
if (inflight.cancelRequested) {
inflight.resolve('cancelled')
return
}
if (inflight.outputError !== undefined) {
inflight.reject(internalError(`assistant output delivery failed: ${inflight.outputError.message}`))
return
}
if (inflight.agentError !== undefined) {
inflight.reject(internalError(`turn failed: ${inflight.agentError.message}`))
return
}
const end = inflight.endReason
if (end === undefined) {
inflight.resolve('cancelled')
} else if (end.kind === 'error') {
rejectFromError(inflight, end)
} else {
// Token-limit and other non-terminal endings are not prompt-level stop
// reasons; ordinary quiescence reports end_turn.
inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
}
})()
/* v8 ignore start -- admissionDone only resolves, whenIdle is a quiescence gate, and outputTail contains its own failures. */
.catch((error: unknown) => {
if (record.inflight !== inflight) return
record.inflight = undefined
inflight.reject(internalError(`prompt settlement failed: ${errorChain(error)}`))
})
/* v8 ignore stop */
}
// Emit only committed assistant text/images. Raw chunks, reasoning, tools,
// plans, titles, and retry markers are presentation or trace data and stay
// off the automation wire. One per-session chain preserves block/message
// order across asynchronous attachment reads.
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.message.content) {
if (block.type === 'text' && block.text.length > 0) {
notify({
const inflight = record.inflight?.turn === event.data.turn ? record.inflight : undefined
const previous = record.outputTail
const delivery = previous.then(async () => {
for (const block of event.data.message.content) {
const content = await assistantBlockToAcp(ctx, block)
if (content === undefined) continue
await 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}]`,
},
},
update: { sessionUpdate: 'agent_message_chunk', content },
})
}
}
})
record.outputTail = delivery.catch((error: unknown) => {
// assistantBlockToAcp owns conversion failures and always throws Error.
const failure = error as Error
if (inflight !== undefined) inflight.outputError ??= failure
logger.warn(`acp: assistant output conversion failed: ${errorChain(error)}`)
})
}
} finally {
const inflight = record.inflight
if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
if (event.data.reason.kind === 'error') {
// Model failures surface immediately as prompt errors; ordinary
// endings wait for whole-agent idle below.
record.inflight = undefined
rejectFromError(inflight, event.data.reason)
} else {
inflight.endReason = event.data.reason
}
inflight.endReason = event.data.reason
}
}
})
@@ -205,8 +257,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
const record = ownedRecord(agent)
const inflight = record?.inflight
if (record === undefined || inflight === undefined || inflight.turn === turn) return
record.inflight = undefined
inflight.reject(internalError(`turn failed: ${errorChain(error)}`))
inflight.agentError = new Error(errorChain(error))
settleAfterQuiescence(record, inflight)
})
// Permission requests are a machine policy channel for ACP clients such as
@@ -231,17 +283,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
conn = connection
return {
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
async 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({
imagePromptEnabled = await supportsAcpImagePrompts(ctx, config.provider, config.model)
return {
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
promptCapabilities: { image: false, audio: false, embeddedContext: false },
promptCapabilities: { image: imagePromptEnabled, audio: false, embeddedContext: false },
},
authMethods: [],
})
}
},
authenticate(_params: AuthenticateRequest): Promise<void> {
@@ -269,6 +322,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
sessions.set(sessionId, {
agent: handle.agent,
dispose: () => handle.dispose(),
outputTail: Promise.resolve(),
inflight: undefined,
})
return { sessionId }
@@ -280,66 +334,91 @@ export function apply(ctx: Context, config: AcpConfig): void {
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 completion = Promise.withResolvers<StopReason>()
const admission = Promise.withResolvers<void>()
const admissionController = new AbortController()
const inflight: NonNullable<SessionRecord['inflight']> = {
resolve: completion.resolve,
reject: completion.reject,
messageId: undefined,
turn: undefined,
endReason: undefined,
admissionDone: admission.promise,
finishAdmission: admission.resolve,
admissionController,
cancelRequested: false,
settlementStarted: false,
outputError: undefined,
agentError: undefined,
}
const text = acpPromptToText(params.prompt)
if (text.trim().length === 0) throw invalidParams('empty prompt')
// Reserve the one-prompt slot before the first asynchronous route or
// attachment operation so concurrent prompts and cancellation observe
// admission as genuinely in flight.
record.inflight = inflight
// Not driving a retired agent is this bridge's contract: an
// agent-loop-only reload disposes the loop's agents while the bridge
// record survives, so validate the record against the live registry
// before sending — a disposed machine would accept the item silently.
if (ctx.agents.get(record.agent.id) !== record.agent) {
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
let admissionFailed = false
let admissionFailure: unknown
try {
// Do not persist rich content for a retired destination. Re-check
// after admission too because an agent-loop reload may race storage.
if (ctx.agents.get(record.agent.id) !== record.agent) {
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
}
const content = await admitAcpPrompt(
ctx,
record.agent,
params.prompt,
imagePromptEnabled,
admissionController.signal,
)
// No await may separate this final abort check from followup: a
// cancellation that wins admission must never enqueue a late turn.
admissionController.signal.throwIfAborted()
if (ctx.agents.get(record.agent.id) !== record.agent) {
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
}
const message = createUserMessage({ content, source: { kind: 'user' } })
inflight.messageId = message.id
record.agent.followup(message)
} catch (error: unknown) {
admissionFailed = true
admissionFailure = error
} finally {
inflight.finishAdmission()
}
const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
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 (invalid input) must free the slot again or the session
// would reject every later prompt as already in flight.
const inflight: NonNullable<SessionRecord['inflight']> = {
resolve, reject, messageId: message.id, turn: undefined, endReason: undefined,
if (inflight.cancelRequested) {
settleAfterQuiescence(record, inflight)
return { stopReason: await completion.promise }
}
if (admissionFailed) {
record.inflight = undefined
if (admissionFailure instanceof AcpContentError) {
throw admissionFailure.kind === 'invalid'
? invalidParams(admissionFailure.message)
: internalError(admissionFailure.message)
}
record.inflight = inflight
try {
record.agent.followup(message)
// The machine's send() contains listener failures and accepts
// any typed input; this guards a future synchronous throw so the
// slot cannot wedge.
/* v8 ignore start -- future-proofing guard, see above */
} catch (error: unknown) {
record.inflight = undefined
const detail = error instanceof Error ? error.message : String(error)
throw internalError(`prompt was not queued: ${detail}`)
}
/* v8 ignore stop */
// Settlement waits for whole-agent idle: a correlated turn/end arms
// `endReason`, while a turnless slot (admission discarded the
// prompt) stays cancelled. Other producers may run further turns
// before quiescence; the prompt settles only when the agent stops.
void record.agent.whenIdle().then(() => {
if (record.inflight !== inflight) return
record.inflight = undefined
const end = inflight.endReason
if (end === undefined) {
inflight.resolve('cancelled')
} else {
// Token-limit and other non-terminal endings are not prompt-level
// stop reasons (see README); only normal quiescence reports end_turn.
inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
}
})
})
if (admissionFailure instanceof RequestError) throw admissionFailure
// The admission codec and same-process agent seam throw Error values.
const detail = (admissionFailure as Error).message
throw internalError(`prompt was not queued: ${detail}`)
}
settleAfterQuiescence(record, inflight)
const stopReason = await completion.promise
return { stopReason }
},
cancel(params: CancelNotification): Promise<void> {
const record = sessions.get(SessionId(params.sessionId))
if (record === undefined) return Promise.resolve()
const inflight = record.inflight
if (inflight !== undefined) {
inflight.cancelRequested = true
inflight.admissionController.abort(new Error('ACP prompt cancelled'))
settleAfterQuiescence(record, inflight)
}
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
return Promise.resolve()
},
}
@@ -362,10 +441,24 @@ export function apply(ctx: Context, config: AcpConfig): void {
// on persistence or scoped cleanup, and the top-level agents must not keep
// running model and tool calls for its whole duration.
for (const record of records) {
const inflight = record.inflight
if (inflight !== undefined) {
inflight.cancelRequested = true
inflight.admissionController.abort(new Error('ACP bridge disposed'))
settleAfterQuiescence(record, inflight)
}
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
}
quiescing = (async () => {
// Preserve the same prompt boundary during connection teardown: a rich
// admission already writing must stop before its slot settles, and every
// committed output conversion must drain while attachment services remain
// available. session/event enqueues output synchronously before idle.
await Promise.all(records.map(async (record) => {
await record.inflight?.admissionDone
await record.agent.whenIdle()
await record.outputTail
}))
// Continuable subagents outlive the turn that started them, and their
// Activations own descendant teardown. Drain only these sessions' forests
// child-first BEFORE disposing the top-level agents, so no descendant is

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
@@ -28,6 +29,17 @@ describe('automation-only ACP bridge', () => {
})
})
it('advertises image prompts only with an exact capable route and attachment store', async () => {
harness = await makeBridgeHarness({ imageCapable: true })
const capable = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
expect(capable.agentCapabilities?.promptCapabilities?.image).toBe(true)
await harness.dispose()
harness = await makeBridgeHarness({ imageCapable: true, attachments: false })
const noStore = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
expect(noStore.agentCapabilities?.promptCapabilities?.image).toBe(false)
})
it('negotiates an unsupported version and accepts the required no-op authentication call', async () => {
harness = await makeBridgeHarness()
const response = await harness.client.initialize({ protocolVersion: 0, clientCapabilities: {} })
@@ -77,6 +89,73 @@ describe('automation-only ACP bridge', () => {
expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'first second' }])
})
it('admits mixed text/image prompts in wire order and logs references only', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const resolve = vi.spyOn(harness.ctx.llm, 'resolveModelInfo')
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: 'before' },
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
{ type: 'text', text: 'between' },
{ type: 'image', data: 'Ag==', mimeType: 'image/jpeg' },
{ type: 'text', text: 'after' },
],
})
expect(resolve).toHaveBeenCalledWith('mock', 'mock', expect.any(AbortSignal))
expect(harness.attachments?.saved.map(input => [...input.data])).toEqual([[1], [2]])
const requestContent = harness.adapter.requests[0]?.messages.at(-1)?.content
expect(requestContent?.map(block => block.type)).toEqual(['text', 'image', 'text', 'image', 'text'])
expect(requestContent?.[0]).toEqual({ type: 'text', text: 'before' })
expect(requestContent?.[2]).toEqual({ type: 'text', text: 'between' })
expect(requestContent?.[4]).toEqual({ type: 'text', text: 'after' })
const firstImage = requestContent?.[1]
const secondImage = requestContent?.[3]
if (firstImage?.type !== 'image' || secondImage?.type !== 'image') throw new Error('expected ordered image blocks')
expect(firstImage.attachment.mediaType).toBe('image/png')
expect(firstImage.attachment.bytes).toBe(1)
expect(secondImage.attachment.mediaType).toBe('image/jpeg')
expect(secondImage.attachment.bytes).toBe(1)
const agent = harness.ctx.agents.get(SessionId(sessionId))
expect(JSON.stringify(agent?.session.events)).not.toContain('AQ==')
})
it('rejects a malformed image batch atomically and frees the prompt slot', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('recovered')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({
sessionId,
prompt: [
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
{ type: 'image', data: 'not base64', mimeType: 'image/png' },
],
})).rejects.toThrow(/canonical base64/)
expect(harness.attachments?.saved).toEqual([])
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'retry' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
})
it('reports durable image write failures as internal prompt failures', async () => {
harness = await makeBridgeHarness({ imageCapable: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
vi.spyOn(harness.attachments!, 'saveImages').mockRejectedValueOnce(
new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'),
)
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
})).rejects.toThrow(/unable to persist the prompt image batch/)
})
it('renders the deployment persona for an ACP-created agent', async () => {
harness = await makeBridgeHarness({ persona: 'Automation persona for {{model}} in {{cwd}}.', script: [textResponse('ok')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
@@ -107,7 +186,7 @@ describe('automation-only ACP bridge', () => {
})).resolves.toHaveProperty('sessionId')
})
it('rejects empty and beyond-baseline prompts before a turn starts', async () => {
it('rejects empty and unadvertised image prompts before a turn starts', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -117,7 +196,7 @@ describe('automation-only ACP bridge', () => {
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'image', data: '', mimeType: 'image/png' }],
})).rejects.toThrow(/only text and resource_link/)
})).rejects.toThrow(/inline image prompts were not advertised/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events.some(event => event.type === 'turn/start')).toBe(false)
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import { acpPromptToText, turnEndToStopReason } from '../src/codec.ts'
import { turnEndToStopReason } from '../src/codec.ts'
describe('ACP codec', () => {
it.each([
@@ -13,12 +13,4 @@ describe('ACP codec', () => {
] satisfies Array<[TurnEndReason, string]>)('maps %o to %s', (reason, expected) => {
expect(turnEndToStopReason(reason)).toBe(expected)
})
it('drops unsupported blocks from baseline text conversion', () => {
expect(acpPromptToText([{
type: 'image',
data: '',
mimeType: 'image/png',
}])).toBe('')
})
})

View File

@@ -0,0 +1,232 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Context } from '@deepseek-ai/cordis'
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
import type { Agent } from '@deepseek-ai/dsh-agent'
import {
AcpContentError,
admitAcpPrompt,
assistantBlockToAcp,
supportsAcpImagePrompts,
} from '../src/content.ts'
const REF: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${'1'.repeat(64)}`),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
}
interface AdmissionFixture {
ctx: Context
agent: Agent
saveImages: ReturnType<typeof vi.fn<(inputs: readonly SaveImageAttachment[]) => Promise<readonly ImageAttachmentRef[]>>>
resolveModelInfo: ReturnType<typeof vi.fn>
}
function admissionFixture(options: {
attachments?: boolean
llm?: boolean
provider?: string | undefined
model?: string | undefined
header?: { provider?: string; model?: string }
} = {}): AdmissionFixture {
const saveImages = vi.fn(async (inputs: readonly SaveImageAttachment[]) => inputs.map((input, index) => ({
...REF,
attachmentId: AttachmentId(`sha256:${String(index + 1).padStart(64, '0')}`),
mediaType: input.mediaType,
bytes: input.data.byteLength,
})))
const resolveModelInfo = vi.fn(async (provider: string, model: string) => ({
provider,
id: model,
name: model,
inputModalities: ['text', 'image'] as const,
}))
const attachments = options.attachments === false ? undefined : { saveImages }
const llm = options.llm === false ? undefined : { resolveModelInfo }
const ctx = {
get(name: string) {
if (name === 'attachments') return attachments
if (name === 'llm') return llm
return undefined
},
} as unknown as Context
const provider = 'provider' in options ? options.provider : 'mock'
const model = 'model' in options ? options.model : 'vision'
const agent = {
options: { provider, model },
session: { requestHeader: () => options.header === undefined ? undefined : { config: options.header } },
} as unknown as Agent
return { ctx, agent, saveImages, resolveModelInfo }
}
describe('ACP rich content codec', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('advertises image input only when every deployment prerequisite is explicit', async () => {
const absent = (attachments: unknown, llm: unknown): Context => ({
get: (name: string) => name === 'attachments' ? attachments : name === 'llm' ? llm : undefined,
}) as unknown as Context
const store = { imageLimits: { mediaTypes: ['image/png'] } }
const noMediaStore = { imageLimits: { mediaTypes: [] } }
const imageLlm = { resolveModelInfo: vi.fn().mockResolvedValue({ inputModalities: ['text', 'image'] }) }
const textLlm = { resolveModelInfo: vi.fn().mockResolvedValue({ inputModalities: ['text'] }) }
const unknownLlm = { resolveModelInfo: vi.fn().mockResolvedValue({}) }
const brokenLlm = { resolveModelInfo: vi.fn().mockRejectedValue(new Error('catalog down')) }
await expect(supportsAcpImagePrompts(absent(undefined, imageLlm), 'p', 'm')).resolves.toBe(false)
await expect(supportsAcpImagePrompts(absent(store, undefined), 'p', 'm')).resolves.toBe(false)
await expect(supportsAcpImagePrompts(absent(store, imageLlm), undefined, 'm')).resolves.toBe(false)
await expect(supportsAcpImagePrompts(absent(store, imageLlm), 'p', undefined)).resolves.toBe(false)
await expect(supportsAcpImagePrompts(absent(noMediaStore, imageLlm), 'p', 'm')).resolves.toBe(false)
await expect(supportsAcpImagePrompts(absent(store, brokenLlm), 'p', 'm')).resolves.toBe(false)
await expect(supportsAcpImagePrompts(absent(store, unknownLlm), 'p', 'm')).resolves.toBe(false)
await expect(supportsAcpImagePrompts(absent(store, textLlm), 'p', 'm')).resolves.toBe(false)
await expect(supportsAcpImagePrompts(absent(store, imageLlm), 'p', 'm')).resolves.toBe(true)
})
it('validates every rich wire block before any image write', async () => {
const fixture = admissionFixture()
const signal = new AbortController().signal
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
{ type: 'image', data: 'AQ==', mimeType: 'image/tiff' },
] as never, true, signal)).rejects.toThrow(/mimeType/)
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
{ type: 'image', data: 'not base64', mimeType: 'image/png' },
], true, signal)).rejects.toThrow(/canonical base64/)
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
{ type: 'image', data: 'AB==', mimeType: 'image/png' },
], true, signal)).rejects.toThrow(/canonical base64/)
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
{ type: 'audio', data: 'AQ==', mimeType: 'audio/wav' },
], true, signal)).rejects.toThrow(/audio prompt/)
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
{ type: 'resource', resource: { uri: 'file:///tmp/a', text: 'a' } },
], true, signal)).rejects.toThrow(/embedded resource/)
expect(fixture.saveImages).not.toHaveBeenCalled()
})
it('requires the advertised capability, store, and exact image-capable route', async () => {
const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const
const capable = admissionFixture()
await expect(admitAcpPrompt(capable.ctx, capable.agent, prompt, false, new AbortController().signal))
.rejects.toThrow(/not advertised/)
const noStore = admissionFixture({ attachments: false })
await expect(admitAcpPrompt(noStore.ctx, noStore.agent, prompt, true, new AbortController().signal))
.rejects.toThrow(/no attachment store/)
const noProvider = admissionFixture({ provider: undefined })
await expect(admitAcpPrompt(noProvider.ctx, noProvider.agent, prompt, true, new AbortController().signal))
.rejects.toThrow(/route could not be resolved/)
const noModel = admissionFixture({ model: undefined })
await expect(admitAcpPrompt(noModel.ctx, noModel.agent, prompt, true, new AbortController().signal))
.rejects.toThrow(/route could not be resolved/)
const noLlm = admissionFixture({ llm: false })
await expect(admitAcpPrompt(noLlm.ctx, noLlm.agent, prompt, true, new AbortController().signal))
.rejects.toThrow(/route could not be resolved/)
const broken = admissionFixture()
broken.resolveModelInfo.mockRejectedValueOnce(new Error('catalog down'))
await expect(admitAcpPrompt(broken.ctx, broken.agent, prompt, true, new AbortController().signal))
.rejects.toThrow(/route could not be verified/)
const unknown = admissionFixture()
unknown.resolveModelInfo.mockResolvedValueOnce({ provider: 'mock', id: 'vision', name: 'vision' })
await expect(admitAcpPrompt(unknown.ctx, unknown.agent, prompt, true, new AbortController().signal))
.rejects.toThrow(/does not declare image input/)
const textOnly = admissionFixture()
textOnly.resolveModelInfo.mockResolvedValueOnce({
provider: 'mock', id: 'vision', name: 'vision', inputModalities: ['text'],
})
await expect(admitAcpPrompt(textOnly.ctx, textOnly.agent, prompt, true, new AbortController().signal))
.rejects.toThrow(/does not declare image input/)
const routed = admissionFixture({ provider: 'fallback', model: 'fallback', header: { provider: 'live', model: 'vision-2' } })
await expect(admitAcpPrompt(routed.ctx, routed.agent, prompt, true, new AbortController().signal)).resolves.toHaveLength(1)
expect(routed.resolveModelInfo).toHaveBeenCalledWith('live', 'vision-2', expect.any(AbortSignal))
})
it('classifies image-policy failures separately from durable write failures', async () => {
const fixture = admissionFixture()
const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const
fixture.saveImages.mockRejectedValueOnce(new AttachmentError('too many', 'TOO_MANY_IMAGES'))
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal))
.rejects.toMatchObject({ kind: 'invalid', message: 'too many' })
fixture.saveImages.mockRejectedValueOnce(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'))
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal))
.rejects.toMatchObject({ kind: 'internal', message: 'unable to persist the prompt image batch' })
fixture.saveImages.mockRejectedValueOnce(new Error('unknown store failure'))
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal))
.rejects.toBeInstanceOf(AcpContentError)
})
it('honors cancellation on both sides of the durable image write', async () => {
const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const
const before = admissionFixture()
const beforeController = new AbortController()
beforeController.abort(new Error('cancel before write'))
await expect(admitAcpPrompt(before.ctx, before.agent, prompt, true, beforeController.signal))
.rejects.toThrow('cancel before write')
expect(before.saveImages).not.toHaveBeenCalled()
const after = admissionFixture()
const afterController = new AbortController()
after.saveImages.mockImplementationOnce(async () => {
afterController.abort(new Error('cancel after write'))
return [REF]
})
await expect(admitAcpPrompt(after.ctx, after.agent, prompt, true, afterController.signal))
.rejects.toThrow('cancel after write')
expect(after.saveImages).toHaveBeenCalledOnce()
})
it('reconstructs image-only and baseline prompts without empty text blocks', async () => {
const fixture = admissionFixture()
const imageOnly = await admitAcpPrompt(fixture.ctx, fixture.agent, [
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
], true, new AbortController().signal)
expect(imageOnly).toHaveLength(1)
expect(imageOnly[0]?.type).toBe('image')
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
{ type: 'text', text: 'before' },
{ type: 'resource_link', name: 'Guide', uri: 'https://example.test/guide' },
{ type: 'text', text: 'after' },
], true, new AbortController().signal)).resolves.toEqual([{
type: 'text',
text: 'before\n[resource_link name="Guide" uri="https://example.test/guide"]\nafter',
}])
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
{ type: 'text', text: ' \n ' },
], true, new AbortController().signal)).rejects.toThrow(/empty prompt/)
})
it('projects only non-empty text and verified durable images to ACP', async () => {
const fixture = admissionFixture()
await expect(assistantBlockToAcp(fixture.ctx, { type: 'text', text: '' })).resolves.toBeUndefined()
await expect(assistantBlockToAcp(fixture.ctx, { type: 'text', text: 'hello' })).resolves.toEqual({
type: 'text', text: 'hello',
})
await expect(assistantBlockToAcp(fixture.ctx, { type: 'reasoning', text: 'private' })).resolves.toBeUndefined()
const noStore = admissionFixture({ attachments: false })
await expect(assistantBlockToAcp(noStore.ctx, { type: 'image', attachment: REF }))
.rejects.toThrow(/no attachment store/)
const readImage = vi.fn().mockRejectedValue(new AttachmentError('gone', 'ATTACHMENT_NOT_FOUND'))
const missingCtx = { get: (name: string) => name === 'attachments' ? { readImage } : undefined } as unknown as Context
await expect(assistantBlockToAcp(missingCtx, { type: 'image', attachment: REF }))
.rejects.toThrow(/unavailable or corrupt/)
const storedCtx = {
get: (name: string) => name === 'attachments'
? { readImage: vi.fn().mockResolvedValue({ ref: REF, data: Uint8Array.of(1) }) }
: undefined,
} as unknown as Context
await expect(assistantBlockToAcp(storedCtx, { type: 'image', attachment: REF })).resolves.toEqual({
type: 'image', data: 'AQ==', mimeType: 'image/png',
})
})
})

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
@@ -26,6 +27,37 @@ describe('ACP connection ownership', () => {
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('disposal drains asynchronous assistant image delivery before releasing sessions', async () => {
const script: StreamChunk[][] = []
harness = await makeBridgeHarness({ script })
const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(4), mediaType: 'image/png' })
script.push([
{ type: 'block-start', index: 0, blockType: 'image' },
{ type: 'block-end', index: 0, block: { type: 'image', attachment: ref } },
{ type: 'finish', reason: { kind: 'stop' } },
])
const readStarted = Promise.withResolvers<undefined>()
const releaseRead = Promise.withResolvers<undefined>()
harness.attachments!.beforeRead = () => {
readStarted.resolve(undefined)
return releaseRead.promise
}
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] })
await readStarted.promise
let disposed = false
const disposal = harness.acpFiber.dispose().finally(() => { disposed = true })
await Promise.resolve()
expect(disposed).toBe(false)
releaseRead.resolve(undefined)
await disposal
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
})
it('drains continuable subagents before disposing its own sessions', async () => {
harness = await makeBridgeHarness()
const order: string[] = []

View File

@@ -54,6 +54,51 @@ describe('ACP automation output boundary', () => {
expect(harness.updates).toHaveLength(0)
})
it('delivers output from a bridge-owned session driven by another in-process producer', async () => {
harness = await makeBridgeHarness({ script: [textResponse('external')] })
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.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'plugin', plugin: 'test' } }))
await agent.whenIdle()
expect(harness.updates).toEqual([{
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'external' },
}])
})
it('contains output conversion failure outside an ACP prompt', async () => {
harness = await makeBridgeHarness({ script: [[
{ type: 'block-start', index: 0, blockType: 'image' },
{
type: 'block-end',
index: 0,
block: {
type: 'image',
attachment: {
attachmentId: `sha256:${'a'.repeat(64)}` as never,
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
},
},
},
{ type: 'finish', reason: { kind: 'stop' } },
]] })
const warn = vi.spyOn(harness.ctx.logger, 'warn')
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.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'plugin', plugin: 'test' } }))
await agent.whenIdle()
await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('output conversion failed')) })
expect(harness.updates).toEqual([])
})
// `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

View File

@@ -1,6 +1,7 @@
/** In-memory ACP transport fixture over the real agent factory and loop. */
import { Context } from '@deepseek-ai/cordis'
import { createHash } from 'node:crypto'
import {
ClientSideConnection,
ndJsonStream,
@@ -11,7 +12,9 @@ import {
type SessionNotification,
type Stream,
} from '@agentclientprotocol/sdk'
import { type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
import { type GenerateOptions, LlmAdapter, type LlmResolvedModelInfo, 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'
@@ -21,7 +24,10 @@ import type { AcpConfig } from '../src/index.ts'
class MockAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private readonly script: (StreamChunk[] | 'hang')[]) {
constructor(
private readonly script: (StreamChunk[] | 'hang')[],
private readonly imageCapable: boolean,
) {
super()
}
@@ -31,7 +37,21 @@ class MockAdapter extends LlmAdapter {
}
override listModels(provider: string) {
return Promise.resolve(provider === 'mock' ? [{ provider: 'mock', id: 'mock', name: 'Mock' }] : [])
return Promise.resolve(provider === 'mock' ? [{
provider: 'mock',
id: 'mock',
name: 'Mock',
inputModalities: this.imageCapable ? ['text', 'image'] as const : ['text'] as const,
}] : [])
}
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
inputModalities: this.imageCapable ? ['text', 'image'] : ['text'],
})
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
@@ -57,6 +77,49 @@ class MockAdapter extends LlmAdapter {
}
}
const IMAGE_LIMITS: ImageAttachmentLimits = {
maxImageBytes: 1024,
maxImagesPerMessage: 4,
maxMessageImageBytes: 2048,
maxImagePixels: 1024,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
}
/** In-memory durable store for ACP wire-order and lifecycle tests. */
class MemoryAttachmentStore extends AttachmentStore {
readonly imageLimits = IMAGE_LIMITS
readonly saved: SaveImageAttachment[] = []
readonly objects = new Map<string, StoredImageAttachment>()
beforeValidate: (() => Promise<void>) | undefined
beforeRead: (() => Promise<void>) | undefined
async validateImage(input: SaveImageAttachment): Promise<void> {
await this.beforeValidate?.()
if (input.data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE')
}
saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
this.saved.push(input)
const digest = createHash('sha256').update(input.data).digest('hex')
const ref: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${digest}`),
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 1,
height: 1,
}
this.objects.set(ref.attachmentId, { ref, data: Uint8Array.from(input.data) })
return Promise.resolve(ref)
}
async readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
await this.beforeRead?.()
const stored = this.objects.get(ref.attachmentId)
if (stored === undefined) throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
return { ref: stored.ref, data: Uint8Array.from(stored.data) }
}
}
/** Scripted text response ending in a clean stop. */
export function textResponse(text: string): StreamChunk[] {
return [
@@ -93,6 +156,7 @@ export interface BridgeHarness {
ctx: Context
client: ClientSideConnection
adapter: MockAdapter
attachments: MemoryAttachmentStore | undefined
updates: CapturedUpdate[]
sessionUpdates: { sessionId: string; update: CapturedUpdate }[]
permissionRequests: RequestPermissionRequest[]
@@ -113,10 +177,13 @@ export async function makeBridgeHarness(options: {
script?: (StreamChunk[] | 'hang')[]
config?: AcpConfigOverrides
persona?: string
imageCapable?: boolean
attachments?: boolean
} = {}): Promise<BridgeHarness> {
const adapter = new MockAdapter(options.script ?? [])
const adapter = new MockAdapter(options.script ?? [], options.imageCapable === true)
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } })
if (options.attachments !== false) await ctx.plugin(MemoryAttachmentStore)
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -135,6 +202,7 @@ export async function makeBridgeHarness(options: {
const harness: BridgeHarness = {
ctx,
adapter,
attachments: ctx.get('attachments') as MemoryAttachmentStore | undefined,
updates,
sessionUpdates,
permissionRequests,

View File

@@ -1,4 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
@@ -41,35 +41,100 @@ describe('ACP prompt lifecycle', () => {
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
})
it('renders an assistant image as an explicit attachment placeholder', async () => {
const attachmentId = `sha256:${'a'.repeat(64)}` as never
harness = await makeBridgeHarness({
script: [[
{ type: 'block-start', index: 0, blockType: 'image' },
{
type: 'block-end',
index: 0,
block: {
type: 'image',
attachment: {
attachmentId,
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
},
},
it('delivers a committed assistant image as verified ACP base64', async () => {
const script: StreamChunk[][] = []
harness = await makeBridgeHarness({ script })
const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(1), mediaType: 'image/png' })
script.push([
{ type: 'block-start', index: 0, blockType: 'image' },
{
type: 'block-end',
index: 0,
block: {
type: 'image',
attachment: ref,
},
{ type: 'finish', reason: { kind: 'stop' } },
]],
})
},
{ type: 'finish', reason: { kind: 'stop' } },
])
const sessionId = await newSession(harness)
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] })
await vi.waitFor(() => {
expect(messageText(harness!)).toBe(`[image attachment ${String(attachmentId)}]`)
expect(harness.updates).toContainEqual({
sessionUpdate: 'agent_message_chunk',
content: { type: 'image', data: 'AQ==', mimeType: 'image/png' },
})
})
it('preserves committed text/image/text order on the ACP wire', async () => {
const script: StreamChunk[][] = []
harness = await makeBridgeHarness({ script })
const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(2), mediaType: 'image/jpeg' })
script.push([
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'before' } },
{ type: 'block-start', index: 1, blockType: 'image' },
{ type: 'block-end', index: 1, block: { type: 'image', attachment: ref } },
{ type: 'block-start', index: 2, blockType: 'text' },
{ type: 'block-end', index: 2, block: { type: 'text', text: 'after' } },
{ type: 'finish', reason: { kind: 'stop' } },
])
const sessionId = await newSession(harness)
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] })
expect(harness.updates).toEqual([
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'before' } },
{ sessionUpdate: 'agent_message_chunk', content: { type: 'image', data: 'Ag==', mimeType: 'image/jpeg' } },
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'after' } },
])
})
it('does not settle a prompt before ordered output delivery drains', async () => {
const script: StreamChunk[][] = []
harness = await makeBridgeHarness({ script })
const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(3), mediaType: 'image/png' })
script.push([
{ type: 'block-start', index: 0, blockType: 'image' },
{ type: 'block-end', index: 0, block: { type: 'image', attachment: ref } },
{ type: 'finish', reason: { kind: 'stop' } },
])
const readStarted = Promise.withResolvers<undefined>()
const delivery = Promise.withResolvers<undefined>()
harness.attachments!.beforeRead = () => {
readStarted.resolve(undefined)
return delivery.promise
}
const sessionId = await newSession(harness)
let settled = false
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
.finally(() => { settled = true })
await readStarted.promise
expect(settled).toBe(false)
delivery.resolve(undefined)
await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' })
})
it('fails prompt delivery when a committed image attachment is missing', async () => {
const missing = {
attachmentId: `sha256:${'a'.repeat(64)}` as never,
mediaType: 'image/png' as const,
bytes: 1,
width: 1,
height: 1,
}
harness = await makeBridgeHarness({ script: [[
{ type: 'block-start', index: 0, blockType: 'image' },
{ type: 'block-end', index: 0, block: { type: 'image', attachment: missing } },
{ type: 'finish', reason: { kind: 'stop' } },
]] })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }))
.rejects.toThrow(/assistant output delivery failed/)
expect(harness.updates).toEqual([])
})
it('rejects a failed turn and never publishes its partial chunks', async () => {
harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] })
const sessionId = await newSession(harness)
@@ -194,6 +259,84 @@ describe('ACP prompt lifecycle', () => {
await expect(first).resolves.toEqual({ stopReason: 'cancelled' })
})
it('reserves the prompt slot during image admission and cancels without a late followup', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: [] })
const validationStarted = Promise.withResolvers<undefined>()
const releaseValidation = Promise.withResolvers<undefined>()
harness.attachments!.beforeValidate = () => {
validationStarted.resolve(undefined)
return releaseValidation.promise
}
const sessionId = await newSession(harness)
let settled = false
const first = harness.client.prompt({
sessionId,
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
}).finally(() => { settled = true })
await validationStarted.promise
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'second' }] }))
.rejects.toThrow(/already in flight/)
await harness.client.cancel({ sessionId })
expect(settled).toBe(false)
releaseValidation.resolve(undefined)
await expect(first).resolves.toEqual({ stopReason: 'cancelled' })
expect(harness.adapter.requests).toEqual([])
const events = harness.ctx.agents.get(SessionId(sessionId))?.session.events ?? []
expect(events.some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false)
})
it('does not queue admitted content into an agent retired during storage', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: [] })
const validationStarted = Promise.withResolvers<undefined>()
const releaseValidation = Promise.withResolvers<undefined>()
harness.attachments!.beforeValidate = () => {
validationStarted.resolve(undefined)
return releaseValidation.promise
}
const sessionId = await newSession(harness)
const prompt = harness.client.prompt({
sessionId,
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
})
await validationStarted.promise
await harness.loopFiber.dispose()
releaseValidation.resolve(undefined)
await expect(prompt).rejects.toThrow(/disposed outside the bridge/)
expect(harness.attachments!.saved).toHaveLength(1)
expect(harness.adapter.requests).toEqual([])
})
it('honors cancellation in the admission-to-followup handoff gap', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: [] })
const sessionId = await newSession(harness)
const saveImages = harness.attachments!.saveImages.bind(harness.attachments!)
vi.spyOn(harness.attachments!, 'saveImages').mockImplementationOnce(async (inputs) => {
const refs = await saveImages(inputs)
queueMicrotask(() => { void harness!.client.cancel({ sessionId }) })
return refs
})
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
})).resolves.toEqual({ stopReason: 'cancelled' })
expect(harness.adapter.requests).toEqual([])
})
it('wraps an unexpected same-process followup failure and frees the prompt slot', async () => {
harness = await makeBridgeHarness({ script: [] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))!
vi.spyOn(agent, 'followup').mockImplementationOnce(() => { throw new Error('synthetic followup failure') })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/prompt was not queued: synthetic followup failure/)
})
it('cancels a running turn and records the aborted outcome', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const sessionId = await newSession(harness)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md
README.md: 06f1cb67cfcd954254db480ea696d10d81b37438
README.zh.md: c4a5643f8c5d7e5b62a160cd32e5969187d34046
README.md: 31f4ec0caeb995a10202d4a452ee7e433749762f
README.zh.md: f97bac11de690fe980595c77375aead47b3b0214

View File

@@ -61,7 +61,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the JSON-RPC and Web snapshot recorders. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the JSON-RPC and Web snapshot recorders. Input scripts cover initialization, fresh-session creation, shorthand text prompts, exact structured ACP prompt blocks, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
## Model Experience

View File

@@ -61,7 +61,7 @@ defineAcpSnapshotSuite({
示例还发布 `cordis.snapshot.yml` 回放 overlay位于 `cordis.yml` 旁边bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM并重写已记录场景的模型 fixture`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay并从已提交模型脚本重写 stdout、可比较会话日志预期输出以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。
约束:`suite.ts``harness.ts` 导入 vitestharness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 JSON-RPC 和 Web 快照录制器消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once``reject_once` 等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
约束:`suite.ts``harness.ts` 导入 vitestharness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 JSON-RPC 和 Web 快照录制器消费。输入脚本覆盖初始化、新建会话、文本提示简写、精确结构化 ACP 提示词块、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once``reject_once` 等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
## 模型体验

View File

@@ -25,6 +25,7 @@ import { vi } from 'vitest'
import {
ClientSideConnection,
PROTOCOL_VERSION,
type ContentBlock as AcpContentBlock,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
@@ -69,6 +70,7 @@ export type InputStep =
| { op: 'newSession' }
| { op: 'newSessionExpectError'; additionalDirectories?: string[] }
| { op: 'prompt'; text: string }
| { op: 'promptContent'; content: AcpContentBlock[] }
| { op: 'promptAndWaitForAgentMessage'; text: string; waitForText: string }
| { op: 'promptExpectError'; text: string }
| {
@@ -422,6 +424,12 @@ async function runStep(
await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
return
}
case 'promptContent': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: promptContent before newSession')
await client.prompt({ sessionId, prompt: step.content })
return
}
case 'promptAndWaitForAgentMessage': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: promptAndWaitForAgentMessage before newSession')

View File

@@ -407,6 +407,24 @@ describe('runScenario', () => {
expect((JSON.parse(sessionLine) as { cwd?: string }).cwd).toBe(result.cwd)
})
it('drives a structured prompt-content step without flattening its wire blocks', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({})
const result = await runScenario(
{
steps: [...boot, {
op: 'promptContent',
content: [
{ type: 'text', text: 'before' },
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
{ type: 'text', text: 'after' },
],
}],
},
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.rawStdout).toContain('"stopReason":"end_turn"')
})
it('forwards override/child fixture paths into the child env and captures stderr', { timeout: 20_000 }, async () => {
const { dir, fixtureFile } = await scenario({ echoEnv: true, stderrNote: 'fake bin booted' })
const childFiles = [join(dir, 'session.1.jsonl'), join(dir, 'session.2.jsonl')]
@@ -1097,6 +1115,7 @@ describe('runScenario', () => {
it.each([
[{ op: 'prompt', text: 'x' }, /prompt before newSession/],
[{ op: 'promptContent', content: [{ type: 'text', text: 'x' }] }, /promptContent before newSession/],
[{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/],
[{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/],
[{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],