Merge pull request #2252 from deepseek-harness/worktree/fix-1463-rich-content-bridge

fix: bridge durable image content across MCP and ACP
This commit is contained in:
Tianyi Cui
2026-08-17 18:38:01 +08:00
committed by GitHub
97 changed files with 3182 additions and 415 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: aaabb0c824e12c250851985e92c0473f147e8efa
README.zh.md: e722dbf06404f453dc746c7daf61d3e7b5b68fc2

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,23 +21,23 @@ 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 plus, once queued, 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` | Marks and aborts any in-progress admission without cancelling or waiting for unrelated Agent work; once this prompt has entered the Agent inbox, it cancels the addressed Agent and waits for the owned interval to quiesce. No late user message is published and the prompt settles as `cancelled`. With no in-flight prompt it cancels autonomous work; 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.
ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. The operation interval starts when the prompt enters the Agent inbox and ends after admission, whole-Agent idle, and ordered output delivery all quiesce; failures from unrelated Agent work before that inbox receipt are not attributed to the prompt. Committed assistant messages stream across the owned interval, and steering or injected work may contribute before idle. Settlement precedence is explicit cancellation, output-delivery failure, interval-wide Agent failure, then the correlated turn ending. Token-limit endings settle as `end_turn`; a correlated model error rejects only at the same quiescence boundary.
## Running
@@ -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,23 +21,23 @@
| 方法 | 行为 |
|---|---|
| `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 上无关的既有工作;该提示词进入 Agent inbox 后,才会取消指定的 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` 结算;关联轮次上的模型错误会立即拒绝提示词。
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。操作区间从提示词进入 Agent inbox 开始,在准入、整个 Agent 空闲和有序输出交付全部停稳后结束inbox 接收前无关 Agent 工作的失败不会归因给该提示词。已提交的 assistant 消息会在自有区间内流式输出,Agent 进入空闲状态前发生的 steering中途引导或注入工作也可能参与其中。结算优先级依次为显式取消、输出交付失败、区间内 Agent 失败、关联轮次结束。因 token 上限而结束时`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 { isImageAdmissionError } 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', 'internal', { 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 (isImageAdmissionError(error)) {
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,29 @@ 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
/** Whether this prompt has entered the Agent's durable inbox interval. */
messageQueued: boolean
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
/** Interval-wide failure outside the correlated turn. */
agentError: Error | undefined
} | undefined
}
@@ -110,6 +126,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 +144,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 +162,91 @@ 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
if (inflight.messageQueued) {
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, and the queued path's idle/output gates contain their 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
}
}
})
@@ -204,9 +260,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
ctx.on('agent/error', ({ agent, turn, error }) => {
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)}`))
if (record === undefined || inflight === undefined || !inflight.messageQueued || inflight.turn === turn) return
inflight.agentError = new Error(errorChain(error))
settleAfterQuiescence(record, inflight)
})
// Permission requests are a machine policy channel for ACP clients such as
@@ -231,17 +287,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 +326,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 +338,103 @@ 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,
messageQueued: false,
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')
}
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,
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')
}
record.inflight = inflight
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
inflight.messageQueued = true
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}`)
// The typed same-process seam may fail synchronously before durable
// inbox receipt; restore the pre-operation boundary for mapping.
inflight.messageQueued = false
throw error
}
/* 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))
}
})
})
} catch (error: unknown) {
admissionFailed = true
admissionFailure = error
} finally {
inflight.finishAdmission()
}
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)
}
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()
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
const inflight = record.inflight
if (inflight !== undefined) {
inflight.cancelRequested = true
inflight.admissionController.abort(new Error('ACP prompt cancelled'))
settleAfterQuiescence(record, inflight)
}
// Admission is not Agent work. Preserve unrelated producers until this
// prompt has entered the durable inbox; without a prompt, cancellation
// continues to target autonomous work on the addressed Agent.
if (inflight === undefined || inflight.messageQueued) record.agent.cancel({ kind: 'user' })
return Promise.resolve()
},
}
@@ -362,10 +457,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,236 @@
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'))
const routeFailure = admitAcpPrompt(broken.ctx, broken.agent, prompt, true, new AbortController().signal)
await expect(routeFailure).rejects.toMatchObject({ kind: 'internal' })
await expect(routeFailure).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 AttachmentError('corrupt object', 'ATTACHMENT_CORRUPT'))
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,148 @@ 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 cancel unrelated Agent work while its prompt is still in admission', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: ['hang'] })
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 agent = harness.ctx.agents.get(SessionId(sessionId))!
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'unrelated work' }],
source: { kind: 'plugin', plugin: 'test' },
}))
await vi.waitFor(() => { expect(harness!.adapter.requests).toHaveLength(1) })
const prompt = harness.client.prompt({
sessionId,
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
})
await validationStarted.promise
await harness.client.cancel({ sessionId })
expect(harness.adapter.requests[0]?.signal?.aborted).toBe(false)
releaseValidation.resolve(undefined)
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
expect(agent.status).toBe('running')
agent.cancel({ kind: 'hook', reason: 'test cleanup' })
await agent.whenIdle()
})
it('does not attribute an unrelated Agent failure during prompt admission', async () => {
harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('answer')] })
const validationStarted = Promise.withResolvers<undefined>()
const releaseValidation = Promise.withResolvers<undefined>()
harness.attachments!.beforeValidate = () => {
validationStarted.resolve(undefined)
return releaseValidation.promise
}
let failUnrelatedWork = true
harness.ctx.on('agent/pre-step', (_payload, next) => {
if (!failUnrelatedWork) return next()
failUnrelatedWork = false
throw new Error('unrelated pre-step failure')
})
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))!
const prompt = harness.client.prompt({
sessionId,
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
})
await validationStarted.promise
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'unrelated work' }],
source: { kind: 'plugin', plugin: 'test' },
}))
await agent.whenIdle()
releaseValidation.resolve(undefined)
await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' })
expect(messageText(harness)).toBe('answer')
})
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/attachment/attachment/README.md
README.md: baeeca0cf939f1a3d4608769b362d532507b90f5
README.zh.md: 238b90794c510e71fffe34d62b044a5c2ece8a6e
README.md: 4fe608552492c33d2bd9acddce51ea1cf20acae4
README.zh.md: a3093fc9dd1f926cb1c54831c6302eb7bbca25c5

View File

@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
The durable attachment seam. `ctx.attachments` validates and durably commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
## Model Experience

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象`saveImage` 会在发布任何模型可见的会话事件前提交张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇`saveImage` 会在发布任何模型可见的会话事件前提交张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
## 模型体验

View File

@@ -1,5 +1,31 @@
/** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */
const IMAGE_ADMISSION_ERROR_CODES = [
'TOO_MANY_IMAGES',
'IMAGES_TOO_LARGE',
'UNSUPPORTED_IMAGE_TYPE',
'INVALID_IMAGE_BASE64',
'INVALID_IMAGE',
'IMAGE_TYPE_MISMATCH',
'IMAGE_TOO_LARGE',
'IMAGE_TOO_MANY_PIXELS',
] as const
/** Caller-correctable attachment failure codes raised while admitting image input. */
export type ImageAdmissionErrorCode = typeof IMAGE_ADMISSION_ERROR_CODES[number]
/** Stable attachment failure codes used for protocol error routing. */
export type AttachmentErrorCode =
| ImageAdmissionErrorCode
| 'INVALID_ATTACHMENT_REF'
| 'ATTACHMENT_CORRUPT'
| 'ATTACHMENT_WRITE_FAILED'
| 'ATTACHMENT_NOT_FOUND'
| 'ATTACHMENT_READ_FAILED'
/** Runtime membership for structurally compatible errors crossing package boundaries. */
const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet<string> = new Set(IMAGE_ADMISSION_ERROR_CODES)
/**
* Stable failures suitable for host RPC error mapping.
*
@@ -11,16 +37,30 @@
*/
export class AttachmentError extends Error {
/** Stable machine-routing failure code. */
readonly code: string
readonly code: AttachmentErrorCode
/**
* @param message - human-readable failure description without raw bytes or host paths.
* @param code - stable machine-routing code.
* @param options - optional chained cause.
*/
constructor(message: string, code: string, options?: ErrorOptions) {
constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions) {
super(message, options)
this.name = 'AttachmentError'
this.code = code
}
}
/**
* Distinguish caller-correctable image admission failures from storage faults.
* @param error - failure raised while validating or persisting an image batch.
* @returns whether the caller can correct the proposed image content or batch.
*/
export function isImageAdmissionError(
error: unknown,
): error is AttachmentError & { readonly code: ImageAdmissionErrorCode } {
return error instanceof Error
&& 'code' in error
&& typeof error.code === 'string'
&& IMAGE_ADMISSION_ERROR_CODE_SET.has(error.code)
}

View File

@@ -1,6 +1,7 @@
/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */
import { Context, Service } from '@deepseek-ai/cordis'
import { AttachmentError } from './error.ts'
import type {
ImageAttachmentLimits,
ImageAttachmentRef,
@@ -9,7 +10,8 @@ import type {
} from './types.ts'
export { AttachmentId } from './brand.ts'
export { AttachmentError } from './error.ts'
export { AttachmentError, isImageAdmissionError } from './error.ts'
export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
export type {
AttachmentId as AttachmentIdType,
ImageAttachmentLimits,
@@ -42,6 +44,35 @@ export abstract class AttachmentStore extends Service {
*/
abstract validateImage(input: SaveImageAttachment): Promise<void>
/**
* Validate one ordered image batch before committing any member.
* Validation failures start no writes; storage failures return no partial
* references, although already published content-addressed objects may stay
* unreachable until a future retention policy collects them.
* @param inputs - encoded images in their owning message order.
* @returns durable references in the exact input order.
*/
async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]> {
const { maxImagesPerMessage, maxMessageImageBytes, mediaTypes } = this.imageLimits
if (inputs.length > maxImagesPerMessage) {
throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
}
const totalBytes = inputs.reduce((sum, input) => sum + input.data.byteLength, 0)
if (totalBytes > maxMessageImageBytes) {
throw new AttachmentError('Image batch exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
}
for (const input of inputs) {
if (!mediaTypes.includes(input.mediaType)) {
throw new AttachmentError(`Image type ${input.mediaType} is not accepted by this deployment.`, 'UNSUPPORTED_IMAGE_TYPE')
}
}
for (const input of inputs) await this.validateImage(input)
const refs: ImageAttachmentRef[] = []
for (const input of inputs) refs.push(await this.saveImage(input))
return refs
}
/**
* Validate and durably commit one image before its owning session event is appended.
* @param input - encoded bytes, declared media type, and optional display name.

View File

@@ -0,0 +1,109 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import AttachmentStore, {
AttachmentError,
AttachmentId,
isImageAdmissionError,
type ImageAttachmentRef,
type ImageMediaType,
type SaveImageAttachment,
type StoredImageAttachment,
} from '../src/index.ts'
const LIMITS = {
maxImageBytes: 4,
maxImagesPerMessage: 2,
maxMessageImageBytes: 5,
maxImagePixels: 4,
mediaTypes: ['image/png'] as const,
}
class RecordingStore extends AttachmentStore {
readonly imageLimits = LIMITS
readonly calls: string[] = []
rejectValidationAt: number | undefined
rejectSaveAt: number | undefined
async validateImage(input: SaveImageAttachment): Promise<void> {
const value = input.data[0] ?? 0
this.calls.push(`validate:${value}`)
if (value === this.rejectValidationAt) throw new Error(`invalid:${value}`)
}
async saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
const value = input.data[0] ?? 0
this.calls.push(`save:${value}`)
if (value === this.rejectSaveAt) throw new Error(`write:${value}`)
return {
attachmentId: AttachmentId(`sha256:${String(value).padStart(64, '0')}`),
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 1,
height: 1,
...input.name === undefined ? {} : { name: input.name },
}
}
readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
throw new Error('not used')
}
}
function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment {
return { data: Uint8Array.of(value), mediaType, name: `${value}.png` }
}
describe('AttachmentStore.saveImages', () => {
it('validates the complete batch before saving in input order', async () => {
const store = new RecordingStore(new Context())
const refs = await store.saveImages([image(1), image(2)])
expect(store.calls).toEqual(['validate:1', 'validate:2', 'save:1', 'save:2'])
expect(refs.map(ref => ref.name)).toEqual(['1.png', '2.png'])
})
it('rejects count, aggregate bytes, and deployment media types before validation', async () => {
const store = new RecordingStore(new Context())
await expect(store.saveImages([image(1), image(2), image(3)]))
.rejects.toMatchObject({ code: 'TOO_MANY_IMAGES' })
await expect(store.saveImages([
{ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' },
{ data: Uint8Array.of(4, 5, 6), mediaType: 'image/png' },
])).rejects.toMatchObject({ code: 'IMAGES_TOO_LARGE' })
await expect(store.saveImages([image(1, 'image/jpeg')]))
.rejects.toMatchObject({ code: 'UNSUPPORTED_IMAGE_TYPE' })
expect(store.calls).toEqual([])
})
it('starts no writes when any member fails validation', async () => {
const store = new RecordingStore(new Context())
store.rejectValidationAt = 2
await expect(store.saveImages([image(1), image(2)]))
.rejects.toThrow('invalid:2')
expect(store.calls).toEqual(['validate:1', 'validate:2'])
})
it('returns no partial references when storage fails after an earlier commit', async () => {
const store = new RecordingStore(new Context())
store.rejectSaveAt = 2
await expect(store.saveImages([image(1), image(2)]))
.rejects.toThrow('write:2')
expect(store.calls).toEqual(['validate:1', 'validate:2', 'save:1', 'save:2'])
})
})
describe('isImageAdmissionError', () => {
it('separates caller-correctable image admission failures from storage faults', () => {
expect(isImageAdmissionError(new AttachmentError('bad bytes', 'INVALID_IMAGE'))).toBe(true)
expect(isImageAdmissionError(new AttachmentError('bad base64', 'INVALID_IMAGE_BASE64'))).toBe(true)
expect(isImageAdmissionError(new AttachmentError('too many', 'TOO_MANY_IMAGES'))).toBe(true)
expect(isImageAdmissionError(Object.assign(new Error('foreign policy error'), { code: 'IMAGE_TOO_LARGE' }))).toBe(true)
expect(isImageAdmissionError(new AttachmentError('corrupt object', 'ATTACHMENT_CORRUPT'))).toBe(false)
expect(isImageAdmissionError(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'))).toBe(false)
expect(isImageAdmissionError(new Error('unknown failure'))).toBe(false)
})
})

View File

@@ -259,7 +259,7 @@ async function appendAdditionalContexts(ctx: Context, agent: Agent): Promise<num
const composedPrefixes = new WeakMap<object, Message[]>()
async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Message[]> {
const signal = AbortSignal.timeout(1000)
const signal = new AbortController().signal
await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [], turn: 1, step: 1, signal },

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/core/tools/README.md
README.md: 60841513ad5ad439ae8851dffc34e8d250acaa67
README.zh.md: 6e26a802c2c16a9d48e29236d358b15eafddcc07
README.md: dd20b1b8fdc4ff57ff9850683d6070fdb219e25a
README.zh.md: 3c687ed0aa1347e3ce66da3b7fa2b48f69267b83

View File

@@ -115,12 +115,12 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
### Code Mode
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`.
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. The program's outer logs and return value re-enter model context; when a successfully settled sub-call's final Native content contains an image, the bridge also defers that complete ordered content through the parent result so the image is not lost behind the JSON-only binding. Final post-execute blocking or content replacement is authoritative. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`.
Under `code` — not `both` — the transport is also the only entry the model may use: a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` at execution creation, before `tools/pre-execute`, approval `ask`, and guards, so nothing observes or approves a call that can only fail. The denial names the route back (`only \`run_code\` is callable directly — call \`<name>\` from inside a \`run_code\` program instead`), because the same prompt declares that tool and a bare `unknown tool` reads as a broken deployment. SDK sub-dispatches carry the outer execution's `parent` token and are exempt, so programs keep every binding the SDK declared. See the [executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md), the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs).
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry and every successful final content sequence containing an image is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and source attribution even when the program later fails.
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
- **Result size**: intermediate binding values cross the worker process whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that limit. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
@@ -177,7 +177,7 @@ Prefix-stable while the Code Mode selection, generated SDK, transport schema, an
#### What the model sees
The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: <message>`. Code Mode returns only the outer program's printed lines and rendered return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (<kind>): <message>` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only; post-execute listeners may append source-attributed context after the result.
The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: <message>`. Code Mode renders the outer program's printed lines and return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (<kind>): <message>` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only, while a successful image-bearing sub-result is appended after the outer result as source-attributed context; post-execute listeners may append other source-attributed context at the same boundary.
#### Token effect

View File

@@ -115,12 +115,12 @@ ctx.tools.register(defineTool({
### Code Mode
`code``both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK`python` → Python SDK只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型TypeScript 为 `ToolArgsMap`/`ToolOutputMap`Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度约定下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName``message`Native 内容和内部错误码留在 Code 约定之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。
`code``both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK`python` → Python SDK。SDK 为每个可见工具声明精确的参数与规范输出类型TypeScript 为 `ToolArgsMap`/`ToolOutputMap`Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度约定下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName``message`Native 内容和内部错误码留在 Code 约定之外。程序的外层日志与返回值会重新进入模型上下文;当成功结算的子调用最终 Native 内容包含图片时,桥接层还会经父结果延后完整有序内容,避免图片被 JSON 专用绑定遮蔽。最终 post-execute 阻止或内容替换具有权威性。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。
`code`(而非 `both`)下,该传输同时也是模型唯一可用的入口:模型直呼其他任何可见工具名,都会在创建执行时、早于 `tools/pre-execute`、审批 `ask` 和 guards 解析为 `UNKNOWN_TOOL`,因此没有任何一方会观察或批准一个注定失败的调用。拒绝信息会给出正确路径(`only \`run_code\` is callable directly — call \`<name>\` from inside a \`run_code\` program instead`),因为同一份提示词刚刚声明过那个工具,只说 `unknown tool` 会被读成部署损坏。SDK 子分发携带外层执行的 `parent` token不受此限制因此程序保留 SDK 声明的全部绑定。参见[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md)、[Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回约定](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。
- **SDK 段**`tools:sdk`,顺序 150一个在组装时求值的提示词段每次组装都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态会生成 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明,以及映射调用作用域最终可见工具的 `tools` 命名空间特殊名称使用带引号的键并附带固定的使用说明Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache。两个代码生成器都已导出且绝不会在提示词组装期间抛出`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown``jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。
- **分发桥接层**`run_code` 的 execute每个绑定调用都会在分发前快照为无损 JSON`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件也不持久化规范值。token 关联使按提交语义工作的观察器可以延后提交内部调用的成功结果,直到最终 `run_code` 结果确定,而无需暴露进行中的外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据
- **分发桥接层**`run_code` 的 execute每个绑定调用都会在分发前快照为无损 JSON`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件也不持久化规范值。token 关联使按提交语义工作的观察器可以延后提交内部调用的成功结果,直到最终 `run_code` 结果确定,而无需暴露进行中的外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目以及每份成功且含图片的最终内容序列都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系和来源归属,即使程序后来失败也不例外
- **结算纪律**:桥接层拥有一个运行作用域的中止机制;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前排空队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError``code: 'CODE_RUN_FAILED'`message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。
- **结果大小**:中间绑定值会完整传入 worker 进程,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB只应用于组合序列化后的外层日志数组、完成值或失败消息载荷固定的结果封装语法和呈现空白不计入该上限。无效和超限的完成会明确失败只有这个外层结果可以按常规 spill 机制处理。
@@ -177,7 +177,7 @@ The available tools:
#### 模型看到的内容
循环会保留模型发出的参数和注册表的最终内容。任何抛出异常或遭到拒绝的调用,都会转换为确切的 `Error: <message>`。Code Mode 只返回外层程序打印的行和呈现后的返回值;两者都为空时返回 `(run_code completed with no output)`;失败时返回 `Error: code run failed (<kind>): <message>`,并根据是否存在已捕获内容,在其后附加 `Captured output:` 与捕获的行。内部分发事件只保留在日志中;后置执行监听器可以在结果之后追加带来源归属的上下文。
循环会保留模型发出的参数和注册表的最终内容。任何抛出异常或遭到拒绝的调用,都会转换为确切的 `Error: <message>`。Code Mode 只返回外层程序打印的行和呈现后的返回值;两者都为空时返回 `(run_code completed with no output)`;失败时返回 `Error: code run failed (<kind>): <message>`,并根据是否存在已捕获内容,在其后附加 `Captured output:` 与捕获的行。内部分发事件只保留在日志中;成功且含图片的子结果会在外层结果之后作为带来源归属的上下文追加,后置执行监听器可以在同一边界追加其他带来源归属的上下文。
#### Token 影响

View File

@@ -6,7 +6,7 @@
* @module @deepseek-ai/dsh-tools/src/code-mode
*/
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import { CallId, createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
@@ -49,7 +49,8 @@ const TYPESCRIPT_FLAVOR: RunCodeFlavor = {
+ 'arguments: `code`, the BODY of an async function (erasable syntax only; top-level '
+ '`await` and `return` work), and `description`, a short summary of what the program '
+ 'does. Call tools as `await tools.name(args)` per the declarations in the system '
+ 'prompt. Only what you print or return comes back — curate it.',
+ 'prompt. Only what you print or return is program output — curate it. Image-bearing '
+ 'subtool results are attached after the run.',
codeDescription: 'The program: the body of an async TypeScript function.',
}
@@ -63,8 +64,9 @@ const PYTHON_FLAVOR: RunCodeFlavor = {
'Execute a Python program against the available tools. Takes two required '
+ 'arguments: `code`, the BODY of an async function (top-level `await` and `return` '
+ 'work), and `description`, a short summary of what the program does. Call tools as '
+ '`await tools.name(args)` per the declarations in the system prompt. Answer '
+ 'with `print(...)` and/or `return <value>` — only that comes back, so curate it.',
+ '`await tools.name(args)` per the declarations in the system prompt. Use '
+ '`print(...)` and/or `return <value>` for program output — curate it. Image-bearing '
+ 'subtool results are attached after the run.',
codeDescription: 'The program: the body of an async Python function.',
}
@@ -559,6 +561,12 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
const result = parked.kind === 'post-result'
? await scheduler.finalize(parked.exec, parked.result)
: scheduler.finish(parked.exec, parked.result)
if (!result.isError && result.content.some(block => block.type === 'image')) {
exec.deferContext(createUserMessage({
content: result.content,
source: { kind: 'plugin', plugin: 'tools-code-mode' },
}))
}
for (const context of result.additionalContexts ?? []) {
exec.deferContext(context)
}

View File

@@ -738,7 +738,7 @@ const SDK_INSTRUCTIONS = `## Writing code for run_code
- Call tools as \`await tools.name(args)\` — subscript access for exotic, reserved, or underscore-leading names: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON.
- A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue.
- Independent read-only calls MAY overlap under \`asyncio.gather\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`.
- Emit the run's answer with \`print(...)\` and/or a top-level \`return <value>\`; the returned value must be lossless JSON. ONLY what you print and the returned value come back — intermediate tool results never enter the conversation, so extract just what you need.
- Emit the run's answer with \`print(...)\` and/or a top-level \`return <value>\`; the returned value must be lossless JSON. Only what you print and return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.
The available tools:`

View File

@@ -254,7 +254,7 @@ const SDK_INSTRUCTIONS = `## Writing code for run_code
- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
- A FAILED tool call rejects with \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose \`message\` is human-readable — \`try/catch\` it to handle and continue.
- Independent read-only calls MAY overlap under \`Promise.all\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`.
- Emit results with \`return\` and/or \`console.log(...)\`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
- Emit results with \`return\` and/or \`console.log(...)\`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.
The available tools:`

View File

@@ -1087,6 +1087,73 @@ describe('the run_code dispatch bridge', () => {
])
})
it('defers image-bearing final sub-call content onto the outer run_code result', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineContentToolFixture({
name: 'image_result',
description: 'Return one durable image.',
parameters: {},
execute: () => Promise.resolve([
{ type: 'text', text: 'image result' },
{
type: 'image',
attachment: {
attachmentId: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as never,
mediaType: 'image/png', bytes: 1, width: 1, height: 1,
},
},
]),
}))
runtime.behavior = async (request) => {
await request.bindings[0]!.functions.image_result!({})
return { logs: [], value: 'done' }
}
const result = await runCode(ctx, 'program')
expect(result.additionalContexts).toMatchObject([{
role: 'user',
source: { kind: 'plugin', plugin: 'tools-code-mode' },
content: [
{ type: 'text', text: 'image result' },
{ type: 'image', attachment: { mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
],
}])
})
it('does not defer images removed by a nested post-execute decision', async () => {
for (const decision of ['block', 'replace'] as const) {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineContentToolFixture({
name: 'image_result',
description: 'Return one durable image.',
parameters: {},
execute: () => Promise.resolve([{
type: 'image',
attachment: {
attachmentId: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as never,
mediaType: 'image/png', bytes: 1, width: 1, height: 1,
},
}]),
}))
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
if (exec.name !== 'image_result') return next()
return Promise.resolve(decision === 'block'
? { kind: 'block', feedback: [{ type: 'text', text: 'blocked' }] }
: { kind: 'accept', content: [{ type: 'text', text: 'replaced' }] })
})
runtime.behavior = async (request) => {
await request.bindings[0]!.functions.image_result!({}).catch(() => undefined)
return { logs: [], value: 'done' }
}
const result = await runCode(ctx, 'program')
expect(result.additionalContexts).toBeUndefined()
await ctx.fiber.dispose()
}
})
it('keeps sub-call contexts when run_code fails after the nested dispatch', async () => {
const { ctx, runtime } = await setup({ mode: 'both' })
registerEcho(ctx)

View File

@@ -363,6 +363,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'input', description: 'encoded bytes, declared media type, and optional display name.' }],
returns: 'completion after the encoded raster has been fully decoded.',
},
{
signature: 'async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>',
description: 'Validate one ordered image batch before committing any member. Validation failures start no writes; storage failures return no partial references, although already published content-addressed objects may stay unreachable until a future retention policy collects them.',
parameters: [{ name: 'inputs', description: 'encoded images in their owning message order.' }],
returns: 'durable references in the exact input order.',
},
{
signature: 'abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>',
description: 'Validate and durably commit one image before its owning session event is appended.',

View File

@@ -15,7 +15,6 @@ import { basename, extname } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -209,12 +208,6 @@ export function applyReadImageTool(ctx: Context): void {
...ref.name === undefined ? {} : { name: ref.name },
},
}
if (exec.parent !== undefined) {
exec.deferContext(createUserMessage({
content: imageReadContent(value),
source: { kind: 'plugin', plugin: 'tool-fs' },
}))
}
return value
},
// Pure display: a generic card in the read family with a follow-along

View File

@@ -137,36 +137,25 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten
if (content.every(part => part.type === 'text')) {
return content.map(part => ({ type: 'text', text: part.text }))
}
const limits = ctx.attachments.imageLimits
if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) {
throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
}
const prepared = content.map(part => part.type === 'text'
? part
: { part, data: decodeBase64(part.data) })
const images = prepared.filter((part): part is Extract<typeof part, { data: Uint8Array }> => 'data' in part)
const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0)
if (totalBytes > limits.maxMessageImageBytes) {
throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
}
for (const image of images) {
await ctx.attachments.validateImage({
data: image.data,
mediaType: image.part.mediaType,
...image.part.name === undefined ? {} : { name: image.part.name },
})
}
const refs = await ctx.attachments.saveImages(images.map(image => ({
data: image.data,
mediaType: image.part.mediaType,
...image.part.name === undefined ? {} : { name: image.part.name },
})))
const blocks: ContentBlock[] = []
let imageIndex = 0
for (const item of prepared) {
if (!('data' in item)) {
blocks.push({ type: 'text', text: item.text })
continue
}
const attachment = await ctx.attachments.saveImage({
data: item.data,
mediaType: item.part.mediaType,
...item.part.name === undefined ? {} : { name: item.part.name },
})
const attachment = refs[imageIndex++]
/* v8 ignore next -- each prepared image supplied exactly one saveImages input and therefore one ordered ref. */
if (attachment === undefined) throw new Error('attachment batch result did not preserve input cardinality')
blocks.push({ type: 'image', attachment })
}
return blocks

View File

@@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AttachmentStore from '@deepseek-ai/dsh-attachment'
import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
@@ -140,7 +141,7 @@ describe('Web session model selection', () => {
height: 1,
...input.name === undefined ? {} : { name: input.name },
}))
ctx.provide('attachments', {
const attachments = {
imageLimits: {
maxImageBytes: 4,
maxImagesPerMessage: 2,
@@ -150,6 +151,12 @@ describe('Web session model selection', () => {
},
validateImage,
saveImage,
}
ctx.provide('attachments', {
...attachments,
saveImages(inputs: readonly Parameters<typeof saveImage>[0][]) {
return AttachmentStore.prototype.saveImages.call(attachments, inputs)
},
} as never)
const followup = vi.fn()
Object.assign(agent, { followup })

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/mcp/mcp-client/README.md
README.md: 266c3b7c2b38406800ae5dad1eb065c9dcbf50e6
README.zh.md: 1b5b5c523e0a477db30f97a748651dbe7e6992ea
README.md: f3bf65d90d72f9eb3271cbbbb8ae8c586a7fd082
README.zh.md: 1596ec72c28c4811eabb9f5cafe41cd7bdf1cf5e

View File

@@ -65,7 +65,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
- Listens for `notifications/tools/list_changed` → re-syncs; a fetch-phase failure keeps the previous generation registered, while a registration conflict rolls back the attempted generation and leaves no tools from that server.
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server.
- Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`.
- Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders.
- Native/model rendering preserves MCP block order. Text-like runs join with newlines; resource links keep their name and URI as text; supported images become durable core image blocks only when `ctx.attachments` is mounted and the exact calling model route explicitly declares image input. The whole image batch is decoded and admitted before any member is saved. A malformed/refused image batch, audio, embedded resources, and unsupported blocks become explicit diagnostic text rather than disappearing.
- On disconnect/crash: the supervisor restarts the original server config with exponential backoff (`reconnect.initialDelayMs` doubling up to `reconnect.maxDelayMs`) and re-runs discovery on success — the recovered generation replaces the previous one, so tools neither duplicate nor leak. During the outage the last good generation stays registered; calls against it fail until recovery.
- Reconnection is budgeted per outage: after `reconnect.maxAttempts` consecutive failures the server's tools are unregistered and reconnection stops until an HMR reload or Host restart. A connection that survives past `maxDelayMs` resets the budget, so an occasionally-crashing server recovers indefinitely while a crash-looping one — even with briefly successful connects — still exhausts the cap instead of restarting forever.
- Reconnect states are user-visible in logs: reconnecting (warn, with attempt count and delay), recovered (info), final failure and disabled-loss (error). Disposal cancels any pending reconnect. With `reconnect.enabled: false`, a lost connection keeps tools registered but failing until a reload — the manual-recovery behavior.
@@ -75,6 +75,8 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
| Service | Usage |
|---|---|
| `ctx.tools` | Register/unregister MCP tools |
| `ctx.attachments` | Optionally validate and persist image result batches before model projection |
| `ctx.llm` | Optionally prove the exact calling route explicitly supports image input |
## Model Experience
@@ -96,11 +98,11 @@ Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync
#### What the model sees
The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained Native text result; image, audio, resource, and unsupported blocks become short placeholders there. Their full JSON blocks and optional structured content remain in the execution-local canonical value, and MCP `isError` rejects the call through the registry's error path.
The public tool name and JSON arguments remain in assistant history. The execution-local canonical value always retains the complete JSON MCP blocks and optional structured content for programmatic and Code Mode callers. In Native context, supported image blocks are durably projected beside text in their original order after exact route-capability proof; Code Mode additionally ferries that settled rich projection through the outer `run_code` result without changing the canonical binding value. Refused images, audio, embedded resources, resource links, and unknown blocks remain visible as bounded text diagnostics, and MCP `isError` rejects the call before image persistence.
#### Token effect
Arguments and mapped text are retained until compaction. Binary and resource payloads are discarded rather than added to context.
Arguments, mapped text, and durable image references are retained until compaction. Inline MCP base64 stays only in the execution-local canonical value and is never copied into a session event; the provider reads verified bytes from the attachment store. Audio and embedded-resource payloads stay out of model context.
#### KV Cache effect
@@ -111,5 +113,5 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumer and are deferred.
- **Startup timeout is inherited from the MCP SDK** — DSH does not yet expose a connection/discovery timeout. Each initialize or paginated `tools/list` request uses the SDK's 60-second default, so an unresponsive server or cursor chain can delay both activation and teardown while the initial synchronization settles.
- **Reconnect triggers on transport close** — a crashed stdio child fires it; Streamable HTTP failures surface per request and through the SDK transport's own SSE-stream recovery, so an unreachable HTTP server is retried per call rather than respawned by the supervisor.
- **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred.
- **Image is the only durable rich-result bridge** — PNG, JPEG, WebP, and GIF can enter Native context after exact capability proof. Audio and embedded-resource payloads remain execution-local with explicit diagnostics, while resource links preserve only their name and URI as text.
- **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset.

View File

@@ -65,7 +65,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
- 监听 `notifications/tools/list_changed` → 重新同步;获取阶段失败时保留上一世代的注册,注册冲突则会回滚本次尝试的世代,并且不保留该服务器的任何工具。
- 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。
- 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`
- Native模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符
- Native模型渲染保留 MCP 块顺序。文本类连续块以换行连接;资源链接以文本保留名称和 URI只有挂载 `ctx.attachments` 且确切调用模型路由明确声明支持图片输入时,受支持的图片才会成为持久核心图片块。整个图片批次会先完成解码与准入,再保存任一成员。格式错误或被拒绝的图片批次、音频、嵌入资源和不受支持的块会成为明确诊断文本,而不会消失
- 断开崩溃时supervisor 以指数退避(`reconnect.initialDelayMs` 逐次翻倍,上限 `reconnect.maxDelayMs`)重启原始服务器配置,成功后重新执行发现——恢复的世代会替换前一个,因此工具既不会重复也不会泄漏。中断期间最后一个正常世代保持注册;针对它的调用在恢复前会失败。
- 重连按中断预算控制:连续失败达到 `reconnect.maxAttempts` 次后,该服务器的工具会被注销,重连停止,直到 HMR 重载或重启 Host。连接存活超过 `maxDelayMs` 会重置预算,因此偶尔崩溃的服务器可以无限恢复,而崩溃循环的服务器——即使短暂连接成功——仍会耗尽上限而非永远重启。
- 重连状态在日志中对用户可见reconnectingwarn含尝试次数和延迟、recoveredinfo、最终失败和 disabled-losserror。dispose资源释放会取消任何待执行的重连。设置 `reconnect.enabled: false` 时,连接丢失后工具保持注册但调用失败,直到重载——即手动恢复行为。
@@ -75,6 +75,8 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
| 服务 | 用途 |
|---|---|
| `ctx.tools` | 注册/注销 MCP 工具 |
| `ctx.attachments` | 可选;在模型投影前校验并持久保存图片结果批次 |
| `ctx.llm` | 可选;证明确切调用路由明确支持图片输入 |
## 模型体验
@@ -96,11 +98,11 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
#### 模型看到的内容
公开工具名称和 JSON 参数会保留在 assistant 历史中。文本结果块会以换行连接为一个保留的 Native 文本结果;图片、音频、资源和不受支持的块在其中变为简短占位符。它们的完整 JSON 块及可选结构化内容保留在执行局部的规范值中MCP `isError`通过注册表的错误路径拒绝调用。
公开工具名称和 JSON 参数会保留在 assistant 历史中。执行局部的规范值始终为程序化调用方和 Code Mode 保留完整 JSON MCP 块及可选结构化内容。在 Native 上下文中受支持的图片块会在确切路由能力得到证明后按原始顺序与文本一起持久投影Code Mode 还会经外层 `run_code` 结果转运这份已经结算的丰富投影,而不改变规范绑定值。被拒绝的图片、音频、嵌入资源、资源链接和未知块会继续以有界文本诊断可见MCP `isError`在持久化图片前拒绝调用。
#### Token 影响
参数映射后的文本会保留到压缩compaction发生时。二进制与资源载荷会被丢弃,而不会加入上下文。
参数映射后的文本和持久图片引用会保留到压缩compaction发生时。内联 MCP base64 只存在于执行局部的规范值中,绝不会复制进会话事件;提供方会从附件存储读取经过校验的字节。音频和嵌入资源载荷仍不会进入模型上下文。
#### KV Cache 影响
@@ -111,5 +113,5 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
- **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。
- **启动超时继承自 MCP SDK**DSH 尚未公开连接/发现超时。每次 initialize 请求或分页 `tools/list` 请求都使用 SDK 默认的 60 秒,因此在初始同步完成期间,无响应的 server 或 cursor chain 可能同时延迟激活与 teardown。
- **重连在传输关闭时触发**:崩溃的 stdio 子进程会触发重连Streamable HTTP 失败通过每次请求以及 SDK 传输自身的 SSEServer-Sent Events流恢复机制暴露因此不可达的 HTTP 服务器会按调用重试,而非由 supervisor 重新 spawn。
- **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现
- **图片是唯一的持久丰富结果桥接**PNG、JPEG、WebP 和 GIF 可以在确切能力得到证明后进入 Native 上下文。音频和嵌入资源载荷仍只存在于执行局部,并配有明确诊断;资源链接只以文本保留名称和 URI
- **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`

View File

@@ -32,6 +32,7 @@
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
@@ -45,6 +46,8 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-attachment-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",

View File

@@ -13,11 +13,15 @@
*/
import { createHash } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import type { Context } from '@deepseek-ai/cordis'
import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
import { isImageAdmissionError } from '@deepseek-ai/dsh-attachment'
import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools'
import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools'
@@ -53,6 +57,17 @@ const HASH_LENGTH = 12
/** Raw result record: the bridge owns JSON-value validation after transport. */
const RawCallToolResultSchema = z.record(z.string(), z.unknown())
/** Raster formats supported by the durable 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}=)?$/
/** List without mutating the SDK's per-page output-validator cache. */
function listToolsUncached(client: Client, cursor?: string) {
return client.request(
@@ -143,13 +158,17 @@ export async function syncTools(
`mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`,
)
}
definitions.set(publicName, {
name: publicName,
description: tool.description ?? '',
parameters: tool.inputSchema,
output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)),
execute: createExecutor(client, tool.name, tool.execution?.taskSupport === 'required', opts),
})
definitions.set(publicName, createDefinition(
client,
ctx,
publicName,
tool.name,
tool.description ?? '',
tool.inputSchema,
supportedOutputSchema(tool.outputSchema),
tool.execution?.taskSupport === 'required',
opts,
))
}
cursor = response.nextCursor
} while (cursor)
@@ -183,6 +202,19 @@ interface McpContentBlock {
type: string
text?: string
mimeType?: string
data?: string
name?: string
uri?: string
}
/** Async rich projection staged for one exact ToolRuntime execution. */
interface PreparedProjection {
/** Canonical MCP value returned by execute before registry materialization. */
value: McpResult
/** Synchronous output.render projection expected before finalization. */
fallback: ContentBlock[]
/** Image-enriched or explicit-refusal projection prepared during execute. */
content: ContentBlock[]
}
/** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */
@@ -196,6 +228,49 @@ function supportedOutputSchema(candidate: unknown): JsonSchemaNode | undefined {
}
}
/**
* Build one generation-local tool definition and its execution-local rich projections.
* @param client - connected MCP client used for calls.
* @param ctx - plugin context carrying optional attachment and model services.
* @param publicName - registry-qualified public tool name.
* @param rawName - MCP wire tool name.
* @param description - model-facing tool description.
* @param parameters - MCP input schema.
* @param structuredSchema - supported structured-output schema, when advertised.
* @param taskRequired - whether this MCP tool requires unsupported task execution.
* @param opts - bridge timeout and namespace options.
* @returns a complete ToolRuntime definition.
*/
function createDefinition(
client: Client,
ctx: Context,
publicName: string,
rawName: string,
description: string,
parameters: Record<string, unknown>,
structuredSchema: JsonSchemaNode | undefined,
taskRequired: boolean,
opts: ToolBridgeOptions,
): ToolDefinition {
const projections = new WeakMap<ToolExecution, PreparedProjection>()
return {
name: publicName,
description,
parameters,
output: createOutput(rawName, structuredSchema),
execute: createExecutor(client, ctx, rawName, taskRequired, opts, projections),
finalizeContent(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>) {
const projection = projections.get(exec)
if (projection === undefined) return undefined
projections.delete(exec)
if (result.isError) return undefined
if (!isDeepStrictEqual(result.value, projection.value)) return undefined
if (!isDeepStrictEqual(result.content, projection.fallback)) return undefined
return projection.content
},
}
}
/** Build the canonical result schema and existing Native text projection. */
function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefined): ToolDefinition['output'] {
return {
@@ -208,7 +283,7 @@ function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefi
required: structuredSchema === undefined ? ['content'] : ['content', 'structuredContent'],
additionalProperties: false,
},
render(_args, value) {
render(_args: unknown, value: JsonValue) {
const result = value as unknown as McpResult
return [{ type: 'text', text: extractText(result.content, rawName) }]
},
@@ -227,9 +302,11 @@ function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefi
*/
function createExecutor(
client: Client,
ctx: Context,
rawName: string,
taskRequired: boolean,
opts: ToolBridgeOptions,
projections: WeakMap<ToolExecution, PreparedProjection>,
): ToolDefinition['execute'] {
return async (args: unknown, exec: ToolExecution) => {
if (taskRequired) {
@@ -268,12 +345,144 @@ function createExecutor(
throw new Error(text)
}
return {
const value: McpResult = {
content,
...result.structuredContent !== undefined
? { structuredContent: result.structuredContent as JsonValue }
: {},
}
if (containsImage(content)) {
const fallback: ContentBlock[] = [{ type: 'text', text: extractText(content, rawName) }]
const projected = await prepareImageProjection(ctx, exec, content, rawName)
projections.set(exec, { value, fallback, content: projected })
}
return value
}
}
/** Whether an untrusted MCP content array contains a declared image block. */
function containsImage(content: JsonValue[]): boolean {
return content.some(value => isRecord(value) && value.type === 'image')
}
/** Narrow one JSON value to a string-keyed object. */
function isRecord(value: JsonValue): value is { [key: string]: JsonValue } {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Narrow a declared MIME string to the durable image vocabulary. */
function isImageMediaType(value: string): value is ImageMediaType {
return IMAGE_MEDIA_TYPES.includes(value as ImageMediaType)
}
/** Decode one untrusted MCP image block without accepting base64 aliases. */
function decodeImage(block: McpContentBlock): SaveImageAttachment {
if (block.mimeType === undefined || !isImageMediaType(block.mimeType)) {
throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF')
}
if (block.data === undefined || !CANONICAL_BASE64.test(block.data)) {
throw new Error('the image data is not canonical base64')
}
const data = Buffer.from(block.data, 'base64')
if (data.toString('base64') !== block.data) {
throw new Error('the image data is not canonical base64')
}
return { data, mediaType: block.mimeType }
}
/**
* Resolve the active model route and durable store for an image-bearing result.
* @param ctx - plugin context with optional services.
* @param exec - exact tool execution whose agent supplies the latest route.
* @returns the attachment store after exact positive image-capability proof.
*/
async function resolveImageAdmission(ctx: Context, exec: ToolExecution): Promise<AttachmentStore> {
const attachments = ctx.get('attachments')
if (attachments === undefined) throw new Error('no attachment store is mounted')
const routed = exec.agent?.session.requestHeader()?.config
const provider = routed?.provider ?? exec.agent?.options.provider
const model = routed?.model ?? exec.agent?.options.model
const llm = ctx.get('llm')
if (provider === undefined || model === undefined || llm === undefined) {
throw new Error('the current model route could not be resolved')
}
let info: Awaited<ReturnType<typeof llm.resolveModelInfo>>
try {
info = await llm.resolveModelInfo(provider, model, exec.signal)
} catch {
throw new Error('the current model route could not be verified')
}
if (info.inputModalities === undefined || !info.inputModalities.includes('image')) {
throw new Error(`model "${model}" does not declare image input`)
}
if (exec.signal.aborted) throw new Error('the tool call was canceled before image storage')
return attachments
}
/** Stable diagnostic text for an image block that was not admitted. */
function imageDiagnostic(block: McpContentBlock, reason: string): string {
const mediaType = block.mimeType ?? 'unknown media type'
return `[image unavailable: ${mediaType}; ${reason}; raw image data remains available to programmatic callers]`
}
/**
* Decode, preflight, and durably save one MCP result's ordered image batch.
* Any refusal projects every image as text while retaining the canonical raw
* value for programmatic callers.
*/
async function prepareImageProjection(
ctx: Context,
exec: ToolExecution,
content: JsonValue[],
toolName: string,
): Promise<ContentBlock[]> {
const decoded: SaveImageAttachment[] = []
const validationErrors = new Map<number, string>()
const imageIndexes: number[] = []
for (const [index, value] of content.entries()) {
if (!isRecord(value) || value.type !== 'image') continue
imageIndexes.push(index)
try {
decoded.push(decodeImage(value as unknown as McpContentBlock))
} catch (error: unknown) {
// decodeImage owns every throw above and always produces Error.
validationErrors.set(index, (error as Error).message)
}
}
if (validationErrors.size > 0) {
return projectContent(content, toolName, (block, index) => ({
type: 'text',
text: imageDiagnostic(
block,
validationErrors.get(index) ?? 'another image in the same result was invalid',
),
}))
}
let attachments: AttachmentStore
try {
attachments = await resolveImageAdmission(ctx, exec)
} catch (error: unknown) {
// resolveImageAdmission contains provider failures and throws Error only.
const reason = (error as Error).message
return projectContent(content, toolName, block => ({ type: 'text', text: imageDiagnostic(block, reason) }))
}
try {
const refs = await attachments.saveImages(decoded)
const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset] as ImageAttachmentRef] as const))
return projectContent(content, toolName, (_block, index) => ({
type: 'image',
attachment: byIndex.get(index) as ImageAttachmentRef,
}))
} catch (error: unknown) {
const reason = isImageAdmissionError(error)
? `image admission rejected the result: ${error.message}`
: 'durable image storage rejected the result'
return projectContent(content, toolName, block => ({
type: 'text',
text: imageDiagnostic(block, reason),
}))
}
}
@@ -286,32 +495,65 @@ function createExecutor(
* guarded with fallbacks because this is a network trust boundary.
*/
function extractText(mcpContent: JsonValue[], toolName: string): string {
const parts: string[] = []
const content = projectContent(mcpContent, toolName)
// The default image projector below also returns text, so this local call
// cannot produce a core image block.
return content.map(block => (block as Extract<ContentBlock, { type: 'text' }>).text).join('\n')
}
for (const value of mcpContent) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
parts.push('[unsupported content type: unknown]')
/**
* Project ordered MCP blocks into the core content vocabulary.
* Text-like runs are newline-coalesced; admitted images split those runs at
* their original position.
*/
function projectContent(
mcpContent: JsonValue[],
toolName: string,
image: (block: McpContentBlock, index: number) => ContentBlock = block => ({
type: 'text',
text: imageDiagnostic(block, 'this result was not admitted to durable model context'),
}),
): ContentBlock[] {
const projected: ContentBlock[] = []
const text: string[] = []
const flushText = (): void => {
if (text.length === 0) return
projected.push({ type: 'text', text: text.splice(0).join('\n') })
}
for (const [index, value] of mcpContent.entries()) {
if (!isRecord(value)) {
text.push('[unsupported MCP content block: expected an object]')
continue
}
const block = value as unknown as McpContentBlock
switch (block.type) {
case 'text':
if (block.text !== undefined) parts.push(block.text)
if (block.text !== undefined) text.push(block.text)
break
case 'image':
parts.push(`[image: ${block.mimeType ?? 'unknown'}, content discarded]`)
flushText()
projected.push(image(block, index))
break
case 'resource_link':
if (block.name === undefined || block.uri === undefined) {
text.push('[resource link unavailable: the MCP block is missing its name or URI]')
} else {
text.push(`Resource link: ${block.name} (${block.uri})`)
}
break
case 'audio':
parts.push(`[audio: ${block.mimeType ?? 'unknown'}, content discarded]`)
text.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}; raw audio data remains available to programmatic callers]`)
break
case 'resource':
case 'resource_link':
parts.push('[resource: content discarded]')
text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]')
break
default:
parts.push(`[unsupported content type: ${block.type}]`)
text.push(`[unsupported MCP content type: ${block.type}]`)
}
}
return parts.join('\n') || `(${toolName} returned no text content)`
flushText()
return projected.length > 0
? projected
: [{ type: 'text', text: `(${toolName} returned no model-visible content)` }]
}

View File

@@ -46,7 +46,7 @@ server.registerTool('image', {
}, async () => ({
content: [
{ type: 'text', text: 'Here is an image:' },
{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' },
{ type: 'image', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', mimeType: 'image/png' },
{ type: 'text', text: 'End of image.' },
],
}))

View File

@@ -19,9 +19,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { z } from 'zod'
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import { CallId } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter, LlmRuntime } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
@@ -43,6 +45,33 @@ async function mountRegistry(): Promise<Context> {
return ctx
}
/** Exact-route adapter used to prove real MCP image admission without an API key. */
class ImageAdapter extends LlmAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'] })
}
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
throw new Error('MCP image e2e never streams')
}
}
async function mountImageRegistry(dshHome: string): Promise<Context> {
const ctx = await mountRegistry()
await ctx.plugin(LocalAttachmentStore, { dshHome })
await ctx.plugin(LlmRuntime)
ctx.llm.registerAdapter(['visual'], new ImageAdapter())
return ctx
}
/** Calling-agent stand-in pinned to the keyless image-capable route. */
function imageAgent(): object {
return {
options: { provider: 'visual', model: 'vision' },
session: { requestHeader: () => undefined },
}
}
function sleep(ms: number): Promise<void> {
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
setTimeout(gate.resolve, ms)
@@ -66,6 +95,7 @@ function nextCallId(): CallId {
describe('fixture server — controlled scenarios', () => {
let ctx: Context
let home: string
const fixtureConfig: Config = {
transport: 'stdio',
@@ -79,13 +109,15 @@ describe('fixture server — controlled scenarios', () => {
}
beforeAll(async () => {
ctx = await mountRegistry()
home = await mkdtemp(join(tmpdir(), 'mcp-image-e2e-'))
ctx = await mountImageRegistry(home)
await apply(ctx, fixtureConfig)
}, 30_000)
afterAll(async () => {
if (ctx) await ctx.fiber.dispose()
await sleep(200)
await rm(home, { recursive: true, force: true })
})
it('discovers all fixture tools under the server namespace', () => {
@@ -141,16 +173,23 @@ describe('fixture server — controlled scenarios', () => {
expect(result.content[0]).toMatchObject({ type: 'text' })
})
it('executes image() → image placeholder', async () => {
it('executes image() → ordered durable image content', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
signal: testToolSignal, agent: imageAgent() as never,
callId: nextCallId(), name: 'mcp__fixture__image', arguments: {},
})
expect(result.isError).toBe(false)
const text = textOf(result.content[0])
expect(text).toContain('Here is an image:')
expect(text).toContain('[image: image/png, content discarded]')
expect(text).toContain('End of image.')
expect(result.content).toHaveLength(3)
expect(result.content[0]).toEqual({ type: 'text', text: 'Here is an image:' })
expect(result.content[2]).toEqual({ type: 'text', text: 'End of image.' })
const image = result.content[1]
if (image?.type !== 'image') throw new Error(`expected an image block, got ${JSON.stringify(image)}`)
expect(image.attachment).toMatchObject({ mediaType: 'image/png', width: 1, height: 1 })
const stored = await ctx.attachments.readImage(image.attachment)
expect(stored.data.byteLength).toBe(image.attachment.bytes)
if (result.isError) throw new Error('expected MCP image success')
expect(JSON.stringify(result.value)).toContain('iVBORw0KGgo')
expect(JSON.stringify(result.content)).not.toContain('iVBORw0KGgo')
})
})
@@ -334,13 +373,14 @@ describe('server-everything — official test server', () => {
expect(textOf(result.content[0])).toContain('10')
})
it('executes get-tiny-image → image placeholder', async () => {
it('executes get-tiny-image → explicit refusal without a durable route', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {},
})
expect(result.isError).toBe(false)
expect(textOf(result.content[0])).toContain('[image: image/png, content discarded]')
expect(result.content.map(block => block.type === 'text' ? block.text : '').join('\n'))
.toContain('[image unavailable: image/png; no attachment store is mounted;')
})
})

View File

@@ -2,9 +2,14 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { Context } from '@deepseek-ai/cordis'
import { CallId } 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 { CallId, LlmAdapter, LlmRuntime } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { type JsonValue } from '@deepseek-ai/dsh-tools'
import type { PostToolDecision } from '@deepseek-ai/dsh-tools'
import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
@@ -63,6 +68,79 @@ async function mountRegistry(): Promise<Context> {
return ctx
}
const IMAGE_LIMITS: ImageAttachmentLimits = {
maxImageBytes: 1024,
maxImagesPerMessage: 4,
maxMessageImageBytes: 2048,
maxImagePixels: 1024,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
}
/** Attachment fake that records exact decoded batches while using the real batch contract. */
class RecordingAttachmentStore extends AttachmentStore {
readonly imageLimits = IMAGE_LIMITS
readonly saved: SaveImageAttachment[] = []
validateImage(_input: SaveImageAttachment): Promise<void> {
return Promise.resolve()
}
saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
this.saved.push(input)
const marker = input.data[0] ?? 0
return Promise.resolve({
attachmentId: AttachmentId(`sha256:${marker.toString(16).padStart(64, '0')}`),
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 1,
height: 1,
})
}
readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
throw new Error('not used')
}
}
/** Exact-route fake used only for image-capability admission. */
class ImageCatalogAdapter extends LlmAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
inputModalities: model === 'vision' ? ['text', 'image'] : ['text'],
})
}
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
throw new Error('MCP bridge tests never stream')
}
}
async function mountRichRegistry(): Promise<{ ctx: Context; attachments: RecordingAttachmentStore }> {
const ctx = await mountRegistry()
await ctx.plugin(RecordingAttachmentStore)
await ctx.plugin(LlmRuntime)
ctx.llm.registerAdapter(['visual'], new ImageCatalogAdapter())
return { ctx, attachments: ctx.attachments as RecordingAttachmentStore }
}
/** Calling-agent stand-in with no durable request header yet. */
function agentOn(model: string | undefined = 'vision'): object {
return {
options: model === undefined ? {} : { provider: 'visual', model },
session: { requestHeader: () => undefined },
}
}
/** Require one text block and return its text for diagnostic assertions. */
function textAt(content: readonly ContentBlock[], index = 0): string {
const block = content[index]
if (block?.type !== 'text') throw new Error(`expected text content at index ${index}`)
return block.text
}
const defaultOpts: ToolBridgeOptions = {
registrationFailure: 'contain',
serverName: 'srv',
@@ -363,24 +441,329 @@ describe('tool execution', () => {
expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }])
})
it('preserves full JSON MCP blocks while Native rendering uses placeholders', async () => {
it('preserves canonical MCP JSON while admitting an ordered mixed image result', async () => {
const rich = await mountRichRegistry()
const blocks = [
{ type: 'text', text: 'before' },
{ type: 'image', mimeType: 'image/png', data: 'base64-data', annotations: { audience: ['assistant'] } },
{ type: 'image', mimeType: 'image/png', data: 'AQ==', annotations: { audience: ['assistant'] } },
{ type: 'text', text: 'between' },
{ type: 'image', mimeType: 'image/jpeg', data: 'Ag==' },
{ type: 'text', text: 'after' },
] satisfies JsonValue[]
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: blocks },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} })
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
const result = await rich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('c1'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' })
expect(result.content.map(block => block.type)).toEqual(['text', 'image', 'text', 'image', 'text'])
expect(result.content[0]).toEqual({ type: 'text', text: 'before' })
expect(result.content[2]).toEqual({ type: 'text', text: 'between' })
expect(result.content[4]).toEqual({ type: 'text', text: 'after' })
const firstImage = result.content[1]
const secondImage = result.content[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)
expect(rich.attachments.saved.map(input => [...input.data])).toEqual([[1], [2]])
expect(JSON.stringify(result.content)).not.toContain('AQ==')
expect(JSON.stringify(result.content)).not.toContain('Ag==')
if (result.isError) throw new Error('expected MCP success')
expect(result.value).toEqual({ content: blocks })
})
it('keeps a valid raw image result while explicitly refusing it without a durable route', async () => {
const blocks = [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] satisfies JsonValue[]
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: blocks },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('no-store'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(result.content).toEqual([{
type: 'text',
text: '[image unavailable: image/png; no attachment store is mounted; raw image data remains available to programmatic callers]',
}])
if (result.isError) throw new Error('image refusal must preserve MCP success')
expect(result.value).toEqual({ content: blocks })
})
it('rejects a malformed image batch before storing any member', async () => {
const rich = await mountRichRegistry()
const blocks = [
{ type: 'image', mimeType: 'image/png', data: 'AQ==' },
{ type: 'image', mimeType: 'image/png', data: 'not base64' },
] satisfies JsonValue[]
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: blocks },
)
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
const result = await rich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('bad-batch'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(rich.attachments.saved).toEqual([])
expect(result.content).toHaveLength(2)
expect(textAt(result.content, 0)).toContain('another image in the same result was invalid')
expect(textAt(result.content, 1)).toContain('not canonical base64')
})
it('rejects non-canonical and incomplete image blocks as one atomic batch', async () => {
const rich = await mountRichRegistry()
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: [
{ type: 'image', mimeType: 'image/tiff', data: 'AQ==' },
{ type: 'image', mimeType: 'image/png', data: 'AB==' },
{ type: 'image', mimeType: 'image/png' },
] },
)
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
const result = await rich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('strict-batch'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(rich.attachments.saved).toEqual([])
expect(result.content).toHaveLength(3)
expect(textAt(result.content, 0)).toContain('not PNG, JPEG, WebP, or GIF')
expect(textAt(result.content, 1)).toContain('not canonical base64')
expect(textAt(result.content, 2)).toContain('not canonical base64')
})
it('does not admit images for a route without declared image input', async () => {
const rich = await mountRichRegistry()
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] },
)
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
const result = await rich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('text-route'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn('text') as never,
})
expect(rich.attachments.saved).toEqual([])
expect(textAt(result.content)).toContain('does not declare image input')
})
it('refuses images when the exact route is missing, unverifiable, or canceled', async () => {
const rich = await mountRichRegistry()
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] },
)
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
const noProvider = await rich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('no-provider'),
name: 'mcp__srv__img',
arguments: {},
agent: { options: { model: 'vision' }, session: { requestHeader: () => undefined } } as never,
})
expect(textAt(noProvider.content)).toContain('route could not be resolved')
const noModel = await rich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('no-model'),
name: 'mcp__srv__img',
arguments: {},
agent: { options: { provider: 'visual' }, session: { requestHeader: () => undefined } } as never,
})
expect(textAt(noModel.content)).toContain('route could not be resolved')
const noLlmCtx = await mountRegistry()
await noLlmCtx.plugin(RecordingAttachmentStore)
await syncTools(client as never, noLlmCtx, defaultOpts, new Map())
const noLlm = await noLlmCtx.tools.execute({
signal: testToolSignal,
callId: CallId('no-llm'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(textAt(noLlm.content)).toContain('route could not be resolved')
vi.spyOn(rich.ctx.llm, 'resolveModelInfo').mockRejectedValueOnce(new Error('catalog down'))
const unverified = await rich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('unverified'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(textAt(unverified.content)).toContain('route could not be verified')
vi.spyOn(rich.ctx.llm, 'resolveModelInfo').mockResolvedValueOnce({
provider: 'visual', id: 'vision', name: 'vision',
})
const unknown = await rich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('unknown-modalities'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(textAt(unknown.content)).toContain('does not declare image input')
const controller = new AbortController()
vi.spyOn(rich.ctx.llm, 'resolveModelInfo').mockImplementationOnce(async (provider, model) => {
controller.abort(new Error('stop'))
return { provider, id: model, name: model, inputModalities: ['text', 'image'] }
})
const canceled = await rich.ctx.tools.execute({
signal: controller.signal,
callId: CallId('canceled'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(canceled.isError).toBe(true)
expect(canceled.content[0]).toEqual({ type: 'text', text: 'Error: tool call aborted' })
expect(rich.attachments.saved).toEqual([])
})
it('refuses images when attachment storage rejects the admitted batch', async () => {
const rich = await mountRichRegistry()
vi.spyOn(rich.attachments, 'saveImages').mockRejectedValueOnce(new Error('disk full'))
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] },
)
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
const result = await rich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('store-rejected'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(textAt(result.content)).toContain('durable image storage rejected the result')
})
it('reports attachment policy rejection as image admission rather than storage failure', async () => {
const rich = await mountRichRegistry()
vi.spyOn(rich.attachments, 'saveImages').mockRejectedValueOnce(
new AttachmentError('too many images', 'TOO_MANY_IMAGES'),
)
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] },
)
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
const result = await rich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('policy-rejected'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(textAt(result.content)).toContain('image admission rejected the result: too many images')
expect(textAt(result.content)).not.toContain('storage rejected')
})
it('lets post-execute replacement win over a prepared image projection', async () => {
const rich = await mountRichRegistry()
rich.ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
kind: 'accept',
content: [{ type: 'text', text: 'policy replacement' }],
}))
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] },
)
await syncTools(client as never, rich.ctx, defaultOpts, new Map())
const result = await rich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('replaced'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(rich.attachments.saved).toHaveLength(1)
expect(result.content).toEqual([{ type: 'text', text: 'policy replacement' }])
})
it('lets post-execute value replacement and blocking discard prepared projections', async () => {
const valueRich = await mountRichRegistry()
valueRich.ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
kind: 'accept',
value: { content: [{ type: 'text', text: 'value replacement' }] },
}))
const valueClient = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] },
)
await syncTools(valueClient as never, valueRich.ctx, defaultOpts, new Map())
const replaced = await valueRich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('value-replaced'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(replaced.content).toEqual([{ type: 'text', text: 'value replacement' }])
const blockedRich = await mountRichRegistry()
blockedRich.ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
kind: 'block',
feedback: [{ type: 'text', text: 'blocked by policy' }],
}))
const blockedClient = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: [{ type: 'image', mimeType: 'image/png', data: 'Ag==' }] },
)
await syncTools(blockedClient as never, blockedRich.ctx, defaultOpts, new Map())
const blocked = await blockedRich.ctx.tools.execute({
signal: testToolSignal,
callId: CallId('blocked'),
name: 'mcp__srv__img',
arguments: {},
agent: agentOn() as never,
})
expect(blocked.isError).toBe(true)
expect(blocked.content).toEqual([{ type: 'text', text: 'blocked by policy' }])
})
it('preserves primitive JSON MCP blocks while Native rendering marks them unsupported', async () => {
const blocks = [42, null, ['nested']] satisfies JsonValue[]
const client = createMockClient(
@@ -396,7 +779,7 @@ describe('tool execution', () => {
expect(result.content[0]).toEqual({
type: 'text',
text: '[unsupported content type: unknown]\n[unsupported content type: unknown]\n[unsupported content type: unknown]',
text: '[unsupported MCP content block: expected an object]\n[unsupported MCP content block: expected an object]\n[unsupported MCP content block: expected an object]',
})
if (result.isError) throw new Error('expected primitive MCP blocks to remain a successful JSON value')
expect(result.value).toEqual({ content: blocks })
@@ -547,7 +930,7 @@ describe('tool execution edge cases', () => {
ctx = await mountRegistry()
})
it('handles audio content with placeholder', async () => {
it('reports unsupported audio without claiming the raw block was discarded', async () => {
const client = createMockClient(
[{ name: 'audio_tool', inputSchema: { type: 'object' } }],
{ content: [{ type: 'audio', mimeType: 'audio/mp3' }] },
@@ -556,10 +939,13 @@ describe('tool execution edge cases', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' })
expect(result.content[0]).toEqual({
type: 'text',
text: '[audio result unsupported: audio/mp3; raw audio data remains available to programmatic callers]',
})
})
it('handles resource content with placeholder', async () => {
it('reports unsupported embedded resources without discarding the raw block', async () => {
const client = createMockClient(
[{ name: 'res_tool', inputSchema: { type: 'object' } }],
{ content: [{ type: 'resource' }] },
@@ -568,19 +954,36 @@ describe('tool execution edge cases', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
expect(result.content[0]).toEqual({
type: 'text',
text: '[embedded resource unsupported; raw resource data remains available to programmatic callers]',
})
})
it('handles resource_link content with placeholder', async () => {
it('preserves resource-link name and URI in the model projection', async () => {
const client = createMockClient(
[{ name: 'link_tool', inputSchema: { type: 'object' } }],
{ content: [{ type: 'resource_link' }] },
{ content: [{ type: 'resource_link', name: 'Design', uri: 'https://example.test/design' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
expect(result.content[0]).toEqual({ type: 'text', text: 'Resource link: Design (https://example.test/design)' })
})
it('diagnoses an incomplete resource link', async () => {
const client = createMockClient(
[{ name: 'link_tool', inputSchema: { type: 'object' } }],
{ content: [{ type: 'resource_link', name: 'Missing URI' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('missing-link'), name: 'mcp__srv__link_tool', arguments: {} })
expect(result.content[0]).toEqual({
type: 'text', text: '[resource link unavailable: the MCP block is missing its name or URI]',
})
})
it('handles unknown content types', async () => {
@@ -592,7 +995,7 @@ describe('tool execution edge cases', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' })
expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported MCP content type: video]' })
})
it('handles image with missing mimeType (buggy server)', async () => {
@@ -604,7 +1007,10 @@ describe('tool execution edge cases', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' })
expect(result.content[0]).toEqual({
type: 'text',
text: '[image unavailable: unknown media type; the declared media type is not PNG, JPEG, WebP, or GIF; raw image data remains available to programmatic callers]',
})
})
it('handles audio with missing mimeType (buggy server)', async () => {
@@ -616,7 +1022,10 @@ describe('tool execution edge cases', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' })
expect(result.content[0]).toEqual({
type: 'text',
text: '[audio result unsupported: unknown media type; raw audio data remains available to programmatic callers]',
})
})
it('handles text block with missing text (buggy server)', async () => {
@@ -628,7 +1037,7 @@ describe('tool execution edge cases', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' })
expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no model-visible content)' })
})
it('handles empty content array', async () => {
@@ -640,7 +1049,7 @@ describe('tool execution edge cases', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' })
expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no model-visible content)' })
})
@@ -678,7 +1087,10 @@ describe('tool execution edge cases', () => {
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' })
expect(result.content[0]).toEqual({
type: 'text',
text: 'Error: [image unavailable: image/png; this result was not admitted to durable model context; raw image data remains available to programmatic callers]',
})
})

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/test-support/acp-snapshot/README.md
README.md: 06f1cb67cfcd954254db480ea696d10d81b37438
README.zh.md: 1dfc023be843160f40b136382592d19f06e71d5c
README.md: 31f4ec0caeb995a10202d4a452ee7e433749762f
README.zh.md: e92d3f17c62b4fe7dea4f5b6c5a0738eef635327

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 伴随文件。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/],