feat: add model-aware DSH image input

DeepSeek Harness previously treated every selected model as text-only. It now carries the model profile's image capability through Main, the Utility Host, ACP, and Pi-AI, rejects unsupported images before model invocation, and validates supported JPEG/PNG data in a bounded process-local attachment store.

The Composer keeps universal controls on its first row, places OpenCode and Continue controls on a dedicated responsive row, and anchors context compaction without shifting the footer. Runtime supervision ownership for future Subagents and Jobs is documented for the right sidebar.

Release note: DeepSeek Harness 现可按所选模型连接的能力安全接收 JPEG/PNG 图片;Composer 同时将 Runtime 专属选择器移到独立一行,并固定上下文压缩入口。
This commit is contained in:
mesalogo
2026-08-16 23:30:54 +08:00
parent 18263a6b23
commit b751c70d75
30 changed files with 1589 additions and 187 deletions
+2 -1
View File
@@ -125,6 +125,7 @@ npm run dist:linux:arm64
- OpenCode 平台二进制来自 `.runtime-resources/<arch>` - OpenCode 平台二进制来自 `.runtime-resources/<arch>`
- Continue Runtime 来自锁定版本的 `@continuedev/cli` - Continue Runtime 来自锁定版本的 `@continuedev/cli`
- DSH 插件安装使用精确锁定并从 `app.asar` 解包的 npm CLI,通过当前 Electron 的 Node 模式运行;最终用户不需要另装 Node.js 或 npm。 - DSH 插件安装使用精确锁定并从 `app.asar` 解包的 npm CLI,通过当前 Electron 的 Node 模式运行;最终用户不需要另装 Node.js 或 npm。
- DSH 图片输入使用精确锁定的 `@napi-rs/canvas` 完整解码 JPEG/PNG。通用包与当前平台的 Skia 原生包必须从 `app.asar` 解包;发布校验会检查版本、目标架构和 MIT 许可证。
- 打包钩子位于 `build/runtime-hooks.cjs` - 打包钩子位于 `build/runtime-hooks.cjs`
跨架构打包前,确认目标架构的 OpenCode 资源已经准备完成。不要用其他架构的二进制替代目标资源。 跨架构打包前,确认目标架构的 OpenCode 资源已经准备完成。不要用其他架构的二进制替代目标资源。
@@ -181,7 +182,7 @@ git push github "$tag"
4. 本地知识库导入、检索和知识图谱。 4. 本地知识库导入、检索和知识图谱。
5. Ask、Execute 的权限边界与旧版 Plan 数据兼容。 5. Ask、Execute 的权限边界与旧版 Plan 数据兼容。
6. OpenCode 与 Continue 的权限边界、取消和超时。 6. OpenCode 与 Continue 的权限边界、取消和超时。
7. DeepSeek Harness Ask 拒绝写入和第三方插件工具,可调用 Main 管理的 Web Search/FetchExecute 可调用已启用插件工具。 7. DeepSeek Harness Ask 拒绝写入和第三方插件工具,可调用 Main 管理的 Web Search/FetchExecute 可调用已启用插件工具。文本模型在网络调用前拒绝图片,声明图片能力的模型可以实际接收 JPEG/PNG。
8. OpenCode Agent/Command、原生上下文 Compact,以及 Continue Rules/Prompt 预设、结构化提问和 GoodBuddy 手动摘要压缩。 8. OpenCode Agent/Command、原生上下文 Compact,以及 Continue Rules/Prompt 预设、结构化提问和 GoodBuddy 手动摘要压缩。
9. Runtime 原生清单把 Tools 与 Commands/LSP/Formatters 分开,显示来源及 Ask/Execute 可用性,不混入 GoodBuddy 分配的 Skills/MCP;外部 OpenCode 只报告连接状态,Continue 明确标记原生 Tools 静态发现不支持;MCP 测试只读取有界 Prompt/Resource 元数据,不读取 Resource 内容。 9. Runtime 原生清单把 Tools 与 Commands/LSP/Formatters 分开,显示来源及 Ask/Execute 可用性,不混入 GoodBuddy 分配的 Skills/MCP;外部 OpenCode 只报告连接状态,Continue 明确标记原生 Tools 静态发现不支持;MCP 测试只读取有界 Prompt/Resource 元数据,不读取 Resource 内容。
10. DSH 市场可安装、停用、重新启用和移除插件;启动失败插件不会阻止 Host,并显示为自动停用。 10. DSH 市场可安装、停用、重新启用和移除插件;启动失败插件不会阻止 Host,并显示为自动停用。
+3 -2
View File
@@ -21,7 +21,7 @@
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、超时和活动记录。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、超时和活动记录。
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read``skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。 - [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read``skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。 - [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
- [x] **Ask 与 Execute 工作模式**Ask 保持只读;Execute 运行已启用且受边界约束的工具。 - [x] **Ask 与 Execute 工作模式**Ask 保持只读;Execute 运行已启用且受边界约束的工具。
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。 - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
@@ -32,7 +32,8 @@
- [x] **OpenCode Runtime 定制**GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 ResourcesTools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用原生 Compact;外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。 - [x] **OpenCode Runtime 定制**GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 ResourcesTools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用原生 Compact;外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。
- [x] **Continue Runtime 定制**:提供静态配置中的原生 Rules、Prompt 模板与 MCP 清单,以及可编辑的 GoodBuddy Rules/Prompt 配置预设;聊天可按请求选择预设和填入可继续编辑的 Prompt。当前 Continue Host 没有可信的静态原生 Tool 发现接口,且使用隔离的 `CONTINUE_GLOBAL_DIR`,因此界面明确标记 Tools 不支持静态发现,也不把 Host 实际不会加载的工作区或用户 Skills 冒充原生能力;GoodBuddy 分配的 Skills 仍按请求暂存执行。Continue 临时 Host 不复用原生会话压缩,手动压缩由 GoodBuddy 摘要模型完成并验证持久化摘要覆盖范围;Agent 交互提问转换为统一问答卡片。Resources、Hooks、后台 Job 和 Continue 原生会话管理继续暂缓。 - [x] **Continue Runtime 定制**:提供静态配置中的原生 Rules、Prompt 模板与 MCP 清单,以及可编辑的 GoodBuddy Rules/Prompt 配置预设;聊天可按请求选择预设和填入可继续编辑的 Prompt。当前 Continue Host 没有可信的静态原生 Tool 发现接口,且使用隔离的 `CONTINUE_GLOBAL_DIR`,因此界面明确标记 Tools 不支持静态发现,也不把 Host 实际不会加载的工作区或用户 Skills 冒充原生能力;GoodBuddy 分配的 Skills 仍按请求暂存执行。Continue 临时 Host 不复用原生会话压缩,手动压缩由 GoodBuddy 摘要模型完成并验证持久化摘要覆盖范围;Agent 交互提问转换为统一问答卡片。Resources、Hooks、后台 Job 和 Continue 原生会话管理继续暂缓。
- [x] **Runtime 原生清单语义**:原生能力以 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 个页签展示;清单状态独立于 Runtime 连通性,区分完整、部分、不可用、仅连接和不支持。DeepSeek Harness 通过 Host Registry 枚举有界的内置/插件 Tools 与 Skills,显示真实 Ask/Execute 边界,并排除 GoodBuddy 按请求分配的 Skills、Web/MCP 代理。 - [x] **Runtime 原生清单语义**:原生能力以 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 个页签展示;清单状态独立于 Runtime 连通性,区分完整、部分、不可用、仅连接和不支持。DeepSeek Harness 通过 Host Registry 枚举有界的内置/插件 Tools 与 Skills,显示真实 Ask/Execute 边界,并排除 GoodBuddy 按请求分配的 Skills、Web/MCP 代理。
- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,并保留父子任务审计 - [ ] **Runtime 监督侧栏**(规划中):在聊天右侧助手工作栏统一承载 OpenCode、Continue 和 DeepSeek Harness 的 Subagent 控制、后台 Job、Workflow/Hook、长任务与原生会话监督;Composer 只保留对当前消息生效的高频上下文选择
- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,在右侧 Runtime 监督页签显示父子状态、取消入口和审计归属。
### Skills、MCP 与知识库 ### Skills、MCP 与知识库
+5 -2
View File
@@ -472,11 +472,14 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。 - 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
- 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。 - 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。
- 模型、专家角色、工作模式、OpenCode Agent、Continue 预设和 Runtime 快捷操作使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。 - 模型、专家角色、工作模式、OpenCode Agent、Continue 预设和 Runtime 快捷操作使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。
- 输入区第一行工具栏只承载附件、语音、知识范围、专家角色、工作模式、Runtime 选择和发送等通用操作。OpenCode Agent、Continue 预设及 Runtime 快捷操作必须放入其下方独立的 Runtime 专属功能行,通过可见分组名称、顶部边界和差异化表面与通用操作分层;该行只承载对当前消息生效的高频选择,当前 Runtime 没有可选专属功能时不保留空行。
- OpenCode、Continue 和 DeepSeek Harness 后续的 Subagent 层级与取消、后台 Job 队列/进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止及原生会话监督统一进入右侧助手工作栏的“Runtime”页签,不加入 Composer。侧栏按当前会话和 Runtime 能力动态显示区块,不为未支持能力渲染空卡片或成排禁用按钮;切换会话或 Runtime 时必须同步清理上一归属的监督状态。
- 设置中心只管理持久 Runtime 配置、默认值和能力清单;右侧 Runtime 页签只管理当前活动会话的生命周期。两处不得复制同一实时操作,侧栏中的高风险操作仍须就地确认并保留取消、权限、用量和活动审计。
- Runtime Prompt 快捷操作只把模板填入输入草稿,用户可以继续编辑;OpenCode Command 由 Runtime 原生 API 执行,输入框只承载可选参数,不以普通斜杠文本冒充执行。 - Runtime Prompt 快捷操作只把模板填入输入草稿,用户可以继续编辑;OpenCode Command 由 Runtime 原生 API 执行,输入框只承载可选参数,不以普通斜杠文本冒充执行。
- Agent 回复进行中锁定模型、专家角色、工作模式和 Runtime 定制选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。 - Agent 回复进行中锁定模型、专家角色、工作模式和 Runtime 定制选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。
- 支持上下文状态的 Runtime 在输入区下方复用同一紧凑用量条;文案必须区分“本次模型调用”和“压缩后对话估算”。手动压缩仅在当前 Runtime 明确支持且没有活动回复时显示,进行中禁用重复操作,结果通过应用通知反馈。 - 支持上下文状态的 Runtime 在输入区下方复用同一紧凑用量条;文案必须区分“本次模型调用”和“压缩后对话估算”。手动压缩仅在当前 Runtime 明确支持且没有活动回复时显示,作为元信息区左下角的浮动次操作,不参与输入区高度计算;元信息区始终预留稳定高度,切换 Runtime 不得让输入框上下位移。元信息区与窗口底部只保留紧凑安全留白,不形成额外空白区。进行中禁用重复操作,结果通过应用通知反馈。
- 已选择的工作模式在触发按钮中只显示 `Ask``Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。 - 已选择的工作模式在触发按钮中只显示 `Ask``Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。
- 宽度大于 `700px` 时,添加内容、知识范围、专家、模式和模型控件保持同一行;仅在窄输入区中换行,不能因为允许换行而让所有窗口都固定显示两行 - 宽度大于 `700px` 时,通用工具栏内的添加内容、知识范围、专家、模式和 Runtime 选择保持同一行,Runtime 专属功能在自己的下一行横向排列。窄输入区中两行分别换行,专属选择器以至少 `220px` 的基准宽度换行而不是被挤压;不能把专属控件重新塞回通用工具栏
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,图片转换为本次消息附件。文件选择由上传按钮承担,不再提供独立“读取剪贴板”按钮;默认工具栏也不提供“截取当前屏幕”和“选择应用窗口”入口,避免与系统粘贴、文件选择和后续工具执行重复。 - 输入框原生支持 `Ctrl+V`:文本直接进入草稿,图片转换为本次消息附件。文件选择由上传按钮承担,不再提供独立“读取剪贴板”按钮;默认工具栏也不提供“截取当前屏幕”和“选择应用窗口”入口,避免与系统粘贴、文件选择和后续工具执行重复。
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。 - “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。 - 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
+41 -1
View File
@@ -52,6 +52,7 @@ const harnessHostEntry =
const harnessBundleManifest = 'out/main/package.json' const harnessBundleManifest = 'out/main/package.json'
const harnessPackageVersions = { const harnessPackageVersions = {
'@deepseek-ai/dsh-agent': '0.1.0-rc.6', '@deepseek-ai/dsh-agent': '0.1.0-rc.6',
'@napi-rs/canvas': '1.0.3',
'node-pty': '1.1.0' 'node-pty': '1.1.0'
} }
const koffiVersion = '3.1.4' const koffiVersion = '3.1.4'
@@ -60,6 +61,7 @@ const harnessLicenseFiles = [
'deepseek-cordis-MIT.txt', 'deepseek-cordis-MIT.txt',
'deepseek-harness-MIT.txt', 'deepseek-harness-MIT.txt',
'koffi-MIT.txt', 'koffi-MIT.txt',
'napi-rs-canvas-MIT.txt',
'node-pty-MIT.txt' 'node-pty-MIT.txt'
] ]
const portableRequiredFiles = [ const portableRequiredFiles = [
@@ -505,7 +507,14 @@ function targetHarnessPaths(options) {
macos: `darwin_${options.arch}/koffi.node`, macos: `darwin_${options.arch}/koffi.node`,
linux: `linux_${options.arch}/koffi.node` linux: `linux_${options.arch}/koffi.node`
}[options.platform] }[options.platform]
const canvasTarget = {
windows: `win32-${options.arch}-msvc`,
macos: `darwin-${options.arch}`,
linux: `linux-${options.arch}-gnu`
}[options.platform]
return { return {
canvasPackage: `@napi-rs/canvas-${canvasTarget}`,
canvasBinary: `skia.${canvasTarget}.node`,
koffiPackage, koffiPackage,
koffiBinary, koffiBinary,
nodePtyBinary: nodePtyBinary:
@@ -860,6 +869,18 @@ function verifyHarnessPackage(
`${target.koffiPackage} 版本错误:期望 ${koffiVersion},实际 ${String(targetKoffiManifest.version)}` `${target.koffiPackage} 版本错误:期望 ${koffiVersion},实际 ${String(targetKoffiManifest.version)}`
) )
} }
const targetCanvasManifest = readJson(
`node_modules/${target.canvasPackage}/package.json`,
`${target.canvasPackage} 元数据`
)
if (
targetCanvasManifest.version !==
harnessPackageVersions['@napi-rs/canvas']
) {
throw new Error(
`${target.canvasPackage} 版本错误:期望 ${harnessPackageVersions['@napi-rs/canvas']},实际 ${String(targetCanvasManifest.version)}`
)
}
const ptyBinary = join( const ptyBinary = join(
unpackedRoot, unpackedRoot,
@@ -873,6 +894,12 @@ function verifyHarnessPackage(
...target.koffiPackage.split('/'), ...target.koffiPackage.split('/'),
...target.koffiBinary.split('/') ...target.koffiBinary.split('/')
) )
const canvasBinary = join(
unpackedRoot,
'node_modules',
...target.canvasPackage.split('/'),
target.canvasBinary
)
assertBinaryArchitecture( assertBinaryArchitecture(
ptyBinary, ptyBinary,
options.arch, options.arch,
@@ -892,9 +919,17 @@ function verifyHarnessPackage(
'DeepSeek Harness Koffi 元数据', 'DeepSeek Harness Koffi 元数据',
statAsarFile statAsarFile
) )
const canvasMetadata = asarEntryMetadata(
asarPath,
entries,
`node_modules/${target.canvasPackage}/${target.canvasBinary}`,
'DeepSeek Harness Canvas 元数据',
statAsarFile
)
for (const [metadata, description] of [ for (const [metadata, description] of [
[nodePtyMetadata, 'DeepSeek Harness node-pty'], [nodePtyMetadata, 'DeepSeek Harness node-pty'],
[koffiMetadata, 'DeepSeek Harness Koffi'] [koffiMetadata, 'DeepSeek Harness Koffi'],
[canvasMetadata, 'DeepSeek Harness Canvas']
]) { ]) {
if (!('unpacked' in metadata) || !metadata.unpacked) { if (!('unpacked' in metadata) || !metadata.unpacked) {
throw new Error(`${description}未从 ASAR 解包`) throw new Error(`${description}未从 ASAR 解包`)
@@ -905,6 +940,11 @@ function verifyHarnessPackage(
options.arch, options.arch,
'DeepSeek Harness Koffi' 'DeepSeek Harness Koffi'
) )
assertBinaryArchitecture(
canvasBinary,
options.arch,
'DeepSeek Harness Canvas'
)
if (options.platform === 'darwin') { if (options.platform === 'darwin') {
const helper = join( const helper = join(
+9 -7
View File
@@ -17,8 +17,9 @@ const {
const { app, utilityProcess } = require('electron/main') const { app, utilityProcess } = require('electron/main')
const protocol = 'goodbuddy.deepseek-harness.control' const protocol = 'goodbuddy.deepseek-harness.control'
const version = 1 const controlVersion = 2
const byteProtocol = 'goodbuddy.deepseek-harness.byte-stream' const byteProtocol = 'goodbuddy.deepseek-harness.byte-stream'
const byteProtocolVersion = 1
const configuredHostPath = const configuredHostPath =
process.env.GOODBUDDY_HARNESS_SMOKE_HOST process.env.GOODBUDDY_HARNESS_SMOKE_HOST
const hostPath = configuredHostPath const hostPath = configuredHostPath
@@ -132,12 +133,12 @@ async function run() {
child.on('message', (message) => { child.on('message', (message) => {
if ( if (
message?.protocol === protocol && message?.protocol === protocol &&
message.version === version && message.version === controlVersion &&
message.type === 'ready' message.type === 'ready'
) { ) {
child.postMessage({ child.postMessage({
protocol: byteProtocol, protocol: byteProtocol,
version, version: byteProtocolVersion,
type: 'data', type: 'data',
stream: 'stdin', stream: 'stdin',
seq: 0, seq: 0,
@@ -147,7 +148,7 @@ async function run() {
} }
if ( if (
message?.protocol === byteProtocol && message?.protocol === byteProtocol &&
message.version === version && message.version === byteProtocolVersion &&
message.type === 'ack' && message.type === 'ack' &&
message.stream === 'stdin' && message.stream === 'stdin' &&
message.seq === 0 message.seq === 0
@@ -158,7 +159,7 @@ async function run() {
} }
if ( if (
message?.protocol === protocol && message?.protocol === protocol &&
message.version === version && message.version === controlVersion &&
message.type === 'fatal' message.type === 'fatal'
) { ) {
finish('fatal', String(message.code)) finish('fatal', String(message.code))
@@ -172,7 +173,7 @@ async function run() {
}) })
child.postMessage({ child.postMessage({
protocol, protocol,
version, version: controlVersion,
type: 'start', type: 'start',
config: { config: {
workspace, workspace,
@@ -181,11 +182,12 @@ async function run() {
api: 'openai-completions', api: 'openai-completions',
provider: 'goodbuddy', provider: 'goodbuddy',
model: 'qwen-plus', model: 'qwen-plus',
supportsImageInput: false,
harnessVersion: '0.1.0-rc.6', harnessVersion: '0.1.0-rc.6',
credentialRefs: ['GOODBUDDY_HARNESS_MODEL_API_KEY'], credentialRefs: ['GOODBUDDY_HARNESS_MODEL_API_KEY'],
skillPackages: [], skillPackages: [],
extensionPackages: [], extensionPackages: [],
maxFrameBytes: 1024 * 1024 maxFrameBytes: 8 * 1024 * 1024
} }
}) })
+41 -7
View File
@@ -75,6 +75,7 @@ GoodBuddy 自己的 Runtime 和控制面不包装成标准 DSH 插件,也不
- 增加 `deepseek-harness` Runtime,并在设置、聊天和消息通道中可选择。 - 增加 `deepseek-harness` Runtime,并在设置、聊天和消息通道中可选择。
- 使用 GoodBuddy 管理的模型连接,不在 Renderer 或持久化 Harness 配置中写入 API Key。 - 使用 GoodBuddy 管理的模型连接,不在 Renderer 或持久化 Harness 配置中写入 API Key。
- 当所选模型连接明确声明支持图像输入时,允许向 DeepSeek Harness 发送有界的 JPEG/PNG;文本模型在启动 Host 或调用模型前拒绝图片。
- Ask 模式在 Runtime 工具分发边界强制只读,阻止 Shell、写入和编辑工具。 - Ask 模式在 Runtime 工具分发边界强制只读,阻止 Shell、写入和编辑工具。
- 在 Web Search 能力启用时,通过 Main 代理向 Ask 与 Execute 提供有界的 `web_search``web_fetch`Harness Utility 不持有服务凭据。 - 在 Web Search 能力启用时,通过 Main 代理向 Ask 与 Execute 提供有界的 `web_search``web_fetch`Harness Utility 不持有服务凭据。
- Execute 模式使用 DSH 本地 Provider,以当前用户权限执行文件与命令工具;工作区是默认工作目录,不是 OS 权限边界。 - Execute 模式使用 DSH 本地 Provider,以当前用户权限执行文件与命令工具;工作区是默认工作目录,不是 OS 权限边界。
@@ -97,7 +98,7 @@ GoodBuddy 自己的 Runtime 和控制面不包装成标准 DSH 插件,也不
- 不提供 Runtime OS 沙箱模式或相关持久设置。 - 不提供 Runtime OS 沙箱模式或相关持久设置。
- 不向 Utility 暴露 MCP 凭据或建立直连 MCP Client。只有用户明确分配给 Harness 的 MCP 工具可以通过 Main 代理调用。 - 不向 Utility 暴露 MCP 凭据或建立直连 MCP Client。只有用户明确分配给 Harness 的 MCP 工具可以通过 Main 代理调用。
- 不在首版向 Harness 暴露 GoodBuddy 浏览器控制、知识库或 Magic Notes。 - 不在首版向 Harness 暴露 GoodBuddy 浏览器控制、知识库或 Magic Notes。
- 不在首版支持图像输入、会话恢复、Harness Subagent、后台 Job、Hook、浏览器控制或 WorkflowWeb Search/Fetch 只通过 Main 代理提供,不加载 Harness 自有网页服务。 - 不在首版支持会话恢复、Harness Subagent、后台 Job、Hook、浏览器控制或 WorkflowWeb Search/Fetch 只通过 Main 代理提供,不加载 Harness 自有网页服务。上述长生命周期能力未来统一进入右侧 Runtime 监督栏,不进入 Composer 工具栏。
- 不发布独立 npm 包,也不创建上游 PR。 - 不发布独立 npm 包,也不创建上游 PR。
- 不为第三方插件增加权限矩阵、风险等级、逐工具审批、沙箱档位、回滚代际或兼容性背书。 - 不为第三方插件增加权限矩阵、风险等级、逐工具审批、沙箱档位、回滚代际或兼容性背书。
@@ -158,6 +159,7 @@ Electron utilityProcess
├─ 固定 Cordis 组合 ├─ 固定 Cordis 组合
├─ GoodBuddy Harness Control Plane(内部组件) ├─ GoodBuddy Harness Control Plane(内部组件)
├─ DSH Agent 与 LLM seam ├─ DSH Agent 与 LLM seam
├─ 按模型能力挂载的有界内存图片存储
├─ 本地 Shell / Filesystem Provider ├─ 本地 Shell / Filesystem Provider
├─ 最小工具集与 Main 代理 MCP ├─ 最小工具集与 Main 代理 MCP
└─ Main 明确启用的第三方 Cordis 插件 └─ Main 明确启用的第三方 Cordis 插件
@@ -190,6 +192,7 @@ Harness 子进程崩溃、输出异常、拒绝协议或加载错误时,Main
- 在 Prompt 前应用 GoodBuddy 指定的 Ask/Execute 权限。 - 在 Prompt 前应用 GoodBuddy 指定的 Ask/Execute 权限。
- 将 DSH Session 事件转换为有界的 GoodBuddy 事件。 - 将 DSH Session 事件转换为有界的 GoodBuddy 事件。
- 将 LLM 用量转换为稳定的模型用量事件。 - 将 LLM 用量转换为稳定的模型用量事件。
- 根据 Main 传入的模型能力声明 ACP 图片能力,验证内联图片并转换为 DSH 的不可变 Attachment 引用。
- 在 dispose 时先取消 Agent,再等待子 Agent 和工具清理。 - 在 dispose 时先取消 Agent,再等待子 Agent 和工具清理。
- 保证 stdout 只包含协议帧,诊断只写 stderr。 - 保证 stdout 只包含协议帧,诊断只写 stderr。
@@ -223,10 +226,11 @@ GoodBuddy 不扫描任意目录、不读取用户 profile 插件清单,也不
- stdout 不得出现日志、Banner、进度条或调试输出。 - stdout 不得出现日志、Banner、进度条或调试输出。
- stderr 只允许有界诊断,不得包含 Prompt、工具完整输出或凭据。 - stderr 只允许有界诊断,不得包含 Prompt、工具完整输出或凭据。
- 每一帧、每一字段和每个请求累计输出都必须在解析前或接收时限流。 - 每一帧、每一字段和每个请求累计输出都必须在解析前或接收时限流。
- 图片只允许作为 ACP 内联 base64 内容传入;拒绝远程 URI,Host 不替用户获取图片 URL。
### 8.2 标准 ACP 方法 ### 8.2 标准 ACP 方法
首版保留 ACP 的初始化、`session/new``session/prompt``session/cancel` 语义。标准 ACP 客户端可以使用只读默认行为,但只有完成 GoodBuddy 能力握手的客户端才能启用 Execute。 首版保留 ACP 的初始化、`session/new``session/prompt``session/cancel` 语义。`promptCapabilities.image` 必须与所选模型连接的 `supportsImageInput` 完全一致,不能仅根据 Provider 或模型名称猜测。标准 ACP 客户端可以使用只读默认行为,但只有完成 GoodBuddy 能力握手的客户端才能启用 Execute。
### 8.3 GoodBuddy 扩展 ### 8.3 GoodBuddy 扩展
@@ -244,7 +248,7 @@ GoodBuddy 不扫描任意目录、不读取用户 profile 插件清单,也不
| `goodbuddy/native/snapshot` | Main → Control Plane | 从无 Agent scope 的 Host Registry 读取有界的原生 Tool/Skill 元数据,排除 GoodBuddy 分配项与请求级代理 | | `goodbuddy/native/snapshot` | Main → Control Plane | 从无 Agent scope 的 Host Registry 读取有界的原生 Tool/Skill 元数据,排除 GoodBuddy 分配项与请求级代理 |
| `goodbuddy/shutdown` | Main → Control Plane | 停止接收新请求并有序清理 | | `goodbuddy/shutdown` | Main → Control Plane | 停止接收新请求并有序清理 |
扩展版本独立于 ACP 版本。握手响应至少包含: Utility 启动控制协议使用版本 2,严格携带 `supportsImageInput` 与固定 8 MiB 帧上限;版本 1 或缺少该字段的启动消息失败关闭,不能让 Host 自行猜测模型能力。扩展版本独立于 ACP 版本。握手响应至少包含:
```ts ```ts
type GoodBuddyHarnessCapabilities = { type GoodBuddyHarnessCapabilities = {
@@ -386,6 +390,7 @@ GoodBuddy conversationId -> Harness sessionId + process generation
- Agent、Session、LLM 和 Tool Registry 基础服务。 - Agent、Session、LLM 和 Tool Registry 基础服务。
- GoodBuddy Harness Control Plane。 - GoodBuddy Harness Control Plane。
- OpenAI 兼容 Chat Completions LLM 适配器。 - OpenAI 兼容 Chat Completions LLM 适配器。
- 仅在所选模型声明图片能力时挂载的进程内 Attachment Store;它完整解码图片、校验格式/尺寸/摘要,以内容寻址引用保存,并随 Session 或 Host 释放。
- DSH 本地 Subprocess、Filesystem 和平台 Shell Provider。 - DSH 本地 Subprocess、Filesystem 和平台 Shell Provider。
- Token Meter 和必要的上下文压缩。 - Token Meter 和必要的上下文压缩。
- 有界的读取、写入、编辑和 Shell 工具。 - 有界的读取、写入、编辑和 Shell 工具。
@@ -418,6 +423,7 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
- 服务地址可以使用自定义主机、端口和部署路径,但不得包含用户名、密码、查询参数或片段。 - 服务地址可以使用自定义主机、端口和部署路径,但不得包含用户名、密码、查询参数或片段。
- 模型名称不限制为 DeepSeek 品牌,由所选 OpenAI 兼容服务决定。 - 模型名称不限制为 DeepSeek 品牌,由所选 OpenAI 兼容服务决定。
- 模型名称和服务地址由 Main 传入受控 Host。 - 模型名称和服务地址由 Main 传入受控 Host。
- 图片能力只读取所选 GoodBuddy 模型连接的 `supportsImageInput`Main、Utility 启动配置、ACP 能力和 Pi-AI 模型输入模态必须使用同一个布尔值。
- API Key 继续保存在 GoodBuddy 加密设置中。 - API Key 继续保存在 GoodBuddy 加密设置中。
- 启动环境提供的部署连接只由 Main 自动解析,不在 Renderer 中显示为可选来源。 - 启动环境提供的部署连接只由 Main 自动解析,不在 Renderer 中显示为可选来源。
@@ -429,8 +435,12 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
### 12.3 输入限制 ### 12.3 输入限制
- 首版只支持文本。 - 文本始终可用;图片是否可用完全取决于所选模型连接是否显式声明 `supportsImageInput: true`
- 图片输入应在发起网络调用前返回明确错误。 - 文本模型收到图片时必须在启动 Host 或发起模型网络调用前返回明确错误,不能静默丢弃图片
- 图片模型只接受内联 JPEG/PNG,不接受 URL、文件路径、ACP `uri` 或其他媒体类型。
- Main 已通过 `nativeImage` 解码用户选择的图片并生成有界模型输入;Utility 仍须独立执行严格 base64、签名、容器结构、CRC(PNG)、完整解码、尺寸和摘要校验,不能把 Main 校验当作跨进程信任替代。
- 每条消息最多 8 张图,单图编码后最多 1 MiB,图片合计最多 2 MiB,单图最多 1,600 万像素,累计解码像素最多 3,200 万(重复引用也计入预算)。进程内 Store 另设 32 MiB、256 个唯一对象的总上限。
- Attachment Store 只服务当前非持久 Harness Session;引用按 Session 释放,Host 退出时清空,不写入磁盘或 GoodBuddy 第二份会话日志。
- GoodBuddy 历史、Prompt、系统指令分别保持不同信任层。 - GoodBuddy 历史、Prompt、系统指令分别保持不同信任层。
- 任何用户文本都不能进入 Cordis 配置表达式或模块名。 - 任何用户文本都不能进入 Cordis 配置表达式或模块名。
@@ -440,7 +450,10 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
| 项目 | 默认上限 | | 项目 | 默认上限 |
| --- | --- | | --- | --- |
| 单个 JSON-RPC 帧 | 1 MiB | | 单个 JSON-RPC 帧 | 8 MiB |
| 单图 / 单条消息图片 | 1 MiB / 8 张且合计 2 MiB |
| 单图 / 单条消息解码像素 | 1,600 万 / 3,200 万 |
| Host 临时图片存储 | 32 MiB 且最多 256 个唯一对象 |
| 单个文本或推理事件 | 64 KiB | | 单个文本或推理事件 | 64 KiB |
| 单次请求累计协议输出 | 4 MiB | | 单次请求累计协议输出 | 4 MiB |
| 工具输入摘要 | 4,000 字符 | | 工具输入摘要 | 4,000 字符 |
@@ -495,9 +508,25 @@ Host 始终由当前 GoodBuddy 版本提供,不存在自定义 Host 入口。
- 已安装插件使用共享 Switch 启停,并提供 JSON 配置、明确移除确认和启动失败信息。 - 已安装插件使用共享 Switch 启停,并提供 JSON 配置、明确移除确认和启动失败信息。
- npm 目录离线时仍显示并允许管理已安装插件;目录错误就地显示并可重试。 - npm 目录离线时仍显示并允许管理已安装插件;目录错误就地显示并可重试。
- 安装、启停、配置和移除的短期结果通过应用通知显示,不重复保留页内成功提示。 - 安装、启停、配置和移除的短期结果通过应用通知显示,不重复保留页内成功提示。
- DSH 不提供独立的“允许图片”开关。Runtime 连接选择只引用“模型连接”中维护的图片能力声明,避免同一模型出现两份冲突配置。
聊天顶栏只显示简短 Runtime 状态,不显示文件路径和版本。完整诊断只在设置页展示。 聊天顶栏只显示简短 Runtime 状态,不显示文件路径和版本。完整诊断只在设置页展示。
### 14.3 Agent Runtime 交互表面归属
OpenCode、Continue 和 DeepSeek Harness 的后续能力按操作生命周期放置,不按上游产品分别堆叠入口:
| 表面 | 负责内容 | 不负责内容 |
| --- | --- | --- |
| Composer 通用行 | 附件、语音、知识范围、专家、Ask/Execute、Runtime 和发送 | Session 监督、后台进度、历史任务管理 |
| Composer Runtime 专属行 | 仅对当前消息生效且需要高频选择的 Agent、预设、Prompt/Command 快捷操作 | Subagent 树、后台 Job、Workflow/Hook 生命周期 |
| 右侧助手工作栏的未来“Runtime”页签 | 当前会话的 Runtime 状态、Subagent 层级与取消、后台 Job 队列/进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止和会话监督 | 持久模型、程序路径、默认 Agent/预设配置 |
| 设置 > Agent Runtime | 持久 Runtime 配置、默认值、插件管理、能力清单和连接诊断 | 某次活动会话的实时控制 |
右侧 Runtime 页签采用统一监督模型,再按当前 Runtime 能力显示 OpenCode、Continue 或 DSH 的具体区块。未支持的能力不渲染空卡片或一排禁用按钮;只有用户需要理解缺口时才显示简短说明。切换 Runtime 或会话时,侧栏必须明确更新归属,不能把上一 Runtime 的 Job/Subagent 状态留在当前会话中。
所有未来的 Subagent、Job、Workflow、Hook 和会话操作仍须经过 Main 的 Runtime 边界,保留取消、超时、权限、父子任务关系、用量和活动审计。高风险动作在侧栏就地确认,运行结果进入活动与成果记录,不以 Composer 按钮代替监督面板。DeepSeek Harness 首版仍不加载这些服务,本节只确定未来跨 Runtime 的产品位置和协议归属。
## 15. IPC 与共享契约 ## 15. IPC 与共享契约
共享 schema 需要覆盖: 共享 schema 需要覆盖:
@@ -506,6 +535,7 @@ Host 始终由当前 GoodBuddy 版本提供,不存在自定义 Host 入口。
- Runtime 选择中的 `deepseekHarness` 分支。 - Runtime 选择中的 `deepseekHarness` 分支。
- 检测结果中的路径、版本、详情和主机执行模式。 - 检测结果中的路径、版本、详情和主机执行模式。
- GoodBuddy 模型连接选择。 - GoodBuddy 模型连接选择。
- 从所选模型连接解析并传到 Host 的 `supportsImageInput`,以及 ACP 图片能力的一致性。
- DeepSeek Harness 模型用量归属。 - DeepSeek Harness 模型用量归属。
- Skill 与 MCP 对 `deepseek-harness` 的显式分配。 - Skill 与 MCP 对 `deepseek-harness` 的显式分配。
- 插件市场总开关、目录、已安装状态、启停状态、JSON 配置和有界启动错误。 - 插件市场总开关、目录、已安装状态、启停状态、JSON 配置和有界启动错误。
@@ -549,6 +579,7 @@ Renderer 只接收公开 npm 元数据和受管插件状态。任何凭据、完
- `node-pty`,用于受管理的工具子进程。 - `node-pty`,用于受管理的工具子进程。
- `koffi`,用于本地 Filesystem 在 Windows 上保持文件 ACL 和原子替换。 - `koffi`,用于本地 Filesystem 在 Windows 上保持文件 ACL 和原子替换。
- `@napi-rs/canvas` 及当前平台二进制,用于在 Utility 内完整解码并复核 JPEG/PNG;原生模块必须从 ASAR 解包并通过目标架构校验。
构建 GoodBuddy 自身时不得广泛批准依赖安装脚本;只允许生产组合实际需要、来源已审查、版本已锁定的脚本。这与用户确认后由市场插件执行自身 lifecycle scripts 是两个不同阶段。六个平台的构建必须验证: 构建 GoodBuddy 自身时不得广泛批准依赖安装脚本;只允许生产组合实际需要、来源已审查、版本已锁定的脚本。这与用户确认后由市场插件执行自身 lifecycle scripts 是两个不同阶段。六个平台的构建必须验证:
@@ -606,6 +637,8 @@ Renderer 只接收公开 npm 元数据和受管插件状态。任何凭据、完
- 未知授权结果失败关闭。 - 未知授权结果失败关闭。
- 超时、取消、迟到帧和进程意外退出。 - 超时、取消、迟到帧和进程意外退出。
- 协议帧、事件队列、工具摘要和 stderr 上限。 - 协议帧、事件队列、工具摘要和 stderr 上限。
- 文本模型在 Host 启动前拒绝图片;图片模型的能力声明、ACP 图片块、Pi-AI 模态和 Attachment Store 保持一致。
- 图片 base64、格式签名、PNG CRC、完整解码、尺寸、单图/单消息/Store 上限、内容摘要、Session 释放和 Host 清空。
- release 和 dispose 的幂等性。 - release 和 dispose 的幂等性。
- 状态卡中的状态、路径、版本和当前用户执行权限。 - 状态卡中的状态、路径、版本和当前用户执行权限。
- 插件 action 与目录 schema 接受严格的市场总开关并拒绝权限、回滚、任意路径和非精确版本等未支持字段。 - 插件 action 与目录 schema 接受严格的市场总开关并拒绝权限、回滚、任意路径和非精确版本等未支持字段。
@@ -690,7 +723,8 @@ npm run build
- DeepSeek Harness 底层库当前是 RC,但 GoodBuddy 不自动跟随升级;每次升级都可能要求同步修改内部控制面。 - DeepSeek Harness 底层库当前是 RC,但 GoodBuddy 不自动跟随升级;每次升级都可能要求同步修改内部控制面。
- Harness 文件和命令工具没有 Runtime OS 隔离,会继承 GoodBuddy 客户端当前用户能够访问的主机资源。 - Harness 文件和命令工具没有 Runtime OS 隔离,会继承 GoodBuddy 客户端当前用户能够访问的主机资源。
- 首版不恢复 Harness 原生 SessionRuntime 重启后由 GoodBuddy 历史重建。 - 首版不恢复 Harness 原生 SessionRuntime 重启后由 GoodBuddy 历史重建。
- 首版不支持图片、知识库、浏览器控制和 Harness SubagentWeb Search/Fetch 仅使用 Main 代理,MCP 仅支持用户分配、Main 代理和 Execute 自动单次授权路径。 - 图片输入仅在所选模型连接明确声明支持时可用;首版不支持知识库、浏览器控制和 Harness SubagentWeb Search/Fetch 仅使用 Main 代理,MCP 仅支持用户分配、Main 代理和 Execute 自动单次授权路径。
- Harness Subagent、后台 Job、Workflow、Hook 和原生会话监督尚未实现;未来入口固定在右侧 Runtime 监督栏,不扩张 Composer 工具栏。
- 推理、工具和用量扩展属于 GoodBuddy 协议,不是标准 ACP 保证。 - 推理、工具和用量扩展属于 GoodBuddy 协议,不是标准 ACP 保证。
- 市场来自公共 npm 关键字搜索,不是精选目录;包的质量、兼容性和维护状态由发布者负责。 - 市场来自公共 npm 关键字搜索,不是精选目录;包的质量、兼容性和维护状态由发布者负责。
- 插件安装、初始化、后台生命周期和 Execute 工具使用当前用户权限,不受 Runtime OS 沙箱保护;Ask 只控制模型工具调用。 - 插件安装、初始化、后台生命周期和 Execute 工具使用当前用户权限,不受 Runtime OS 沙箱保护;Ask 只控制模型工具调用。
+1
View File
@@ -35,6 +35,7 @@
"@deepseek-ai/dsh-tools": "0.1.0-rc.6", "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
"@deepseek-ai/dsh-user-approval": "0.1.0-rc.6", "@deepseek-ai/dsh-user-approval": "0.1.0-rc.6",
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
"@napi-rs/canvas": "1.0.3",
"@opencode-ai/sdk": "^1.18.9", "@opencode-ai/sdk": "^1.18.9",
"@wecom/aibot-node-sdk": "^1.0.6", "@wecom/aibot-node-sdk": "^1.0.6",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
+8 -1
View File
@@ -60,7 +60,9 @@
"node_modules/node-pty/prebuilds/**/*", "node_modules/node-pty/prebuilds/**/*",
"node_modules/node-pty/build/Release/**/*", "node_modules/node-pty/build/Release/**/*",
"node_modules/koffi/**/*", "node_modules/koffi/**/*",
"node_modules/@koromix/koffi-*/**/*" "node_modules/@koromix/koffi-*/**/*",
"node_modules/@napi-rs/canvas{,/**/*}",
"node_modules/@napi-rs/canvas-*/**/*"
], ],
"npmRebuild": false, "npmRebuild": false,
"compression": "maximum", "compression": "maximum",
@@ -132,6 +134,10 @@
"from": "node_modules/koffi/LICENSE.txt", "from": "node_modules/koffi/LICENSE.txt",
"to": "licenses/koffi-MIT.txt" "to": "licenses/koffi-MIT.txt"
}, },
{
"from": "node_modules/@napi-rs/canvas/LICENSE",
"to": "licenses/napi-rs-canvas-MIT.txt"
},
{ {
"from": "node_modules/@continuedev/cli", "from": "node_modules/@continuedev/cli",
"to": "runtimes/continue", "to": "runtimes/continue",
@@ -246,6 +252,7 @@
"@deepseek-ai/dsh-tools": "0.1.0-rc.6", "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
"@deepseek-ai/dsh-user-approval": "0.1.0-rc.6", "@deepseek-ai/dsh-user-approval": "0.1.0-rc.6",
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
"@napi-rs/canvas": "1.0.3",
"@opencode-ai/sdk": "^1.18.9", "@opencode-ai/sdk": "^1.18.9",
"@wecom/aibot-node-sdk": "^1.0.6", "@wecom/aibot-node-sdk": "^1.0.6",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
+12 -2
View File
@@ -103,7 +103,7 @@ describe('createAgentRuntime model compatibility', () => {
) )
}) })
it('creates DeepSeek Harness with a compatible HTTPS gateway profile', async () => { it('forwards a compatible gateway profile to DeepSeek Harness', async () => {
const profile = { const profile = {
id: '00000000-0000-4000-8000-000000000006', id: '00000000-0000-4000-8000-000000000006',
name: 'OpenAI-compatible gateway', name: 'OpenAI-compatible gateway',
@@ -111,9 +111,13 @@ describe('createAgentRuntime model compatibility', () => {
modelName: 'qwen-plus', modelName: 'qwen-plus',
protocol: 'openai-chat-completions' as const, protocol: 'openai-chat-completions' as const,
authentication: 'api-key' as const, authentication: 'api-key' as const,
supportsImageInput: true,
imageGenerationQuality: 'auto' as const, imageGenerationQuality: 'auto' as const,
apiKey: 'gateway-key' apiKey: 'gateway-key'
} }
const deepseekHarnessLauncher = vi
.fn()
.mockRejectedValue(new Error('stop after launch options'))
const runtime = createAgentRuntime( const runtime = createAgentRuntime(
process.cwd(), process.cwd(),
settings({ settings({
@@ -122,10 +126,16 @@ describe('createAgentRuntime model compatibility', () => {
defaultModelProfileId: profile.id, defaultModelProfileId: profile.id,
deepseekHarnessModelProfile: profile deepseekHarnessModelProfile: profile
}), }),
{ deepseekHarnessLauncher: vi.fn() } { deepseekHarnessLauncher }
) )
expect(runtime.runtimeId).toBe('deepseek-harness') expect(runtime.runtimeId).toBe('deepseek-harness')
await expect(runtime.getStatus()).resolves.toMatchObject({
available: false
})
expect(deepseekHarnessLauncher).toHaveBeenCalledWith(
expect.objectContaining({ supportsImageInput: true })
)
await runtime.dispose() await runtime.dispose()
}) })
+1
View File
@@ -171,6 +171,7 @@ export function createAgentRuntime(
defaultWorkspace: workspace, defaultWorkspace: workspace,
baseUrl: profile.baseUrl, baseUrl: profile.baseUrl,
model: profile.modelName, model: profile.modelName,
supportsImageInput: profile.supportsImageInput === true,
launch: capabilities.deepseekHarnessLauncher, launch: capabilities.deepseekHarnessLauncher,
credentialRefs: { credentialRefs: {
GOODBUDDY_HARNESS_MODEL_API_KEY: profile.apiKey GOODBUDDY_HARNESS_MODEL_API_KEY: profile.apiKey
@@ -8,6 +8,7 @@ import {
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { createCanvas } from '@napi-rs/canvas'
import { import {
CallId, CallId,
type GenerateOptions, type GenerateOptions,
@@ -31,8 +32,8 @@ import {
} from './deepseek-harness-runtime' } from './deepseek-harness-runtime'
import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './goodbuddy-harness-control-plane' import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './goodbuddy-harness-control-plane'
import { DshNpmExtensionInstaller } from './dsh-extension-marketplace' import { DshNpmExtensionInstaller } from './dsh-extension-marketplace'
import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol'
const MAX_FRAME_BYTES = 1024 * 1024
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY' const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
const SKILL_CALL_ID = 'e2e-skill-call' const SKILL_CALL_ID = 'e2e-skill-call'
const MCP_CALL_ID = 'e2e-mcp-call' const MCP_CALL_ID = 'e2e-mcp-call'
@@ -327,6 +328,7 @@ function createInProcessLaunch(
api: 'openai-completions', api: 'openai-completions',
provider: 'goodbuddy', provider: 'goodbuddy',
model: options.model, model: options.model,
supportsImageInput: options.supportsImageInput,
harnessVersion: '0.1.0-rc.6', harnessVersion: '0.1.0-rc.6',
credentialRefs: options.credentialRefs, credentialRefs: options.credentialRefs,
skillPackages: options.skillPackages, skillPackages: options.skillPackages,
@@ -334,7 +336,7 @@ function createInProcessLaunch(
stream: createBoundedNdJsonStream( stream: createBoundedNdJsonStream(
hostToClient.writable, hostToClient.writable,
clientToHost.readable, clientToHost.readable,
MAX_FRAME_BYTES DEEPSEEK_HARNESS_MAX_FRAME_BYTES
) )
}) })
hosts.push(host) hosts.push(host)
@@ -378,6 +380,88 @@ function createInProcessLaunch(
} }
describe('DeepSeek Harness real ACP control-plane E2E', () => { describe('DeepSeek Harness real ACP control-plane E2E', () => {
it('delivers bounded inline images to an image-capable Harness model', async () => {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-acp-image-'))
)
const workspace = join(root, 'workspace')
const dshHome = join(root, 'dsh-home')
await Promise.all([mkdir(workspace), mkdir(dshHome)])
let observedRequest: GenerateOptions | undefined
const inProcess = createInProcessLaunch(dshHome, {
stream(options) {
observedRequest = options
return textResponse('Image received.')
}
})
const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: workspace,
baseUrl: 'https://api.deepseek.com',
model: 'vision-test',
supportsImageInput: true,
launch: (options) => inProcess.launch(options),
credentialRefs: {
[CREDENTIAL_REF]: 'unused-in-memory-model-credential'
},
initializationTimeoutMs: 20_000,
promptTimeoutMs: 20_000,
shutdownTimeoutMs: 5_000
})
const png = createCanvas(1, 1).toBuffer('image/png')
try {
const events = await collect(
runtime.run(
{
requestId: 'request-acp-image',
conversationId: 'acp-image',
prompt: 'Describe this image.',
workMode: 'ask',
images: [
{
name: 'reference.png',
mediaType: 'image/png',
data: png.toString('base64')
}
]
},
new AbortController().signal
)
)
const image = observedRequest?.messages
.flatMap((message) => message.content)
.find(
(
block
): block is Extract<
GenerateOptions['messages'][number]['content'][number],
{ type: 'image' }
> => block.type === 'image'
)
expect(events).toContainEqual(
expect.objectContaining({ type: 'done' })
)
expect(image?.attachment).toMatchObject({
mediaType: 'image/png',
bytes: png.byteLength,
width: 1,
height: 1
})
const stored =
await inProcess.hosts[0]!.context.attachments.readImage(
image!.attachment
)
expect(Buffer.from(stored.data).equals(png)).toBe(true)
} finally {
await runtime.dispose()
await Promise.allSettled(
inProcess.hosts.map((host) => host.dispose())
)
await rm(root, { recursive: true, force: true })
}
})
it( it(
'coalesces micro reasoning deltas without losing content and caps each model step', 'coalesces micro reasoning deltas without losing content and caps each model step',
async () => { async () => {
@@ -8,10 +8,12 @@ import {
export const DEEPSEEK_HARNESS_CONTROL_PROTOCOL = export const DEEPSEEK_HARNESS_CONTROL_PROTOCOL =
'goodbuddy.deepseek-harness.control' 'goodbuddy.deepseek-harness.control'
export const DEEPSEEK_HARNESS_CONTROL_VERSION = 1 export const DEEPSEEK_HARNESS_CONTROL_VERSION = 2
export const DEEPSEEK_HARNESS_HOST_VERSION = '0.1.0-rc.6' export const DEEPSEEK_HARNESS_HOST_VERSION = '0.1.0-rc.6'
export const DEEPSEEK_HARNESS_CREDENTIAL_REF = export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
'GOODBUDDY_HARNESS_MODEL_API_KEY' 'GOODBUDDY_HARNESS_MODEL_API_KEY'
export const DEEPSEEK_HARNESS_MAX_FRAME_BYTES =
8 * 1024 * 1024
const skillPackageSchema = z const skillPackageSchema = z
.object({ .object({
@@ -43,13 +45,14 @@ export const controlledHarnessHostConfigSchema = z
api: z.literal('openai-completions'), api: z.literal('openai-completions'),
provider: z.literal('goodbuddy'), provider: z.literal('goodbuddy'),
model: z.string().min(1).max(128), model: z.string().min(1).max(128),
supportsImageInput: z.boolean(),
harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION), harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION),
credentialRefs: z credentialRefs: z
.tuple([z.literal(DEEPSEEK_HARNESS_CREDENTIAL_REF)]) .tuple([z.literal(DEEPSEEK_HARNESS_CREDENTIAL_REF)])
.readonly(), .readonly(),
skillPackages: z.array(skillPackageSchema).max(64), skillPackages: z.array(skillPackageSchema).max(64),
extensionPackages: z.array(extensionPackageSchema).max(64), extensionPackages: z.array(extensionPackageSchema).max(64),
maxFrameBytes: z.literal(1024 * 1024) maxFrameBytes: z.literal(DEEPSEEK_HARNESS_MAX_FRAME_BYTES)
}) })
.strict() .strict()
@@ -45,6 +45,8 @@ function setup(
promptTimeoutMs?: number promptTimeoutMs?: number
maxEventCharacters?: number maxEventCharacters?: number
maxRequestOutputCharacters?: number maxRequestOutputCharacters?: number
supportsImageInput?: boolean
advertisedImageInput?: boolean
} = {} } = {}
) { ) {
const exit = deferred<{ const exit = deferred<{
@@ -95,7 +97,13 @@ function setup(
if (method === 'initialize') { if (method === 'initialize') {
return { return {
protocolVersion: 1, protocolVersion: 1,
agentCapabilities: {} agentCapabilities: {
promptCapabilities: {
image:
options.advertisedImageInput ??
(options.supportsImageInput === true)
}
}
} }
} }
if (method === 'session/new') { if (method === 'session/new') {
@@ -212,6 +220,7 @@ function setup(
defaultWorkspace: 'C:\\workspace', defaultWorkspace: 'C:\\workspace',
baseUrl: 'https://api.deepseek.com', baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test', model: 'deepseek-test',
supportsImageInput: options.supportsImageInput,
launch, launch,
loadAcpSdk: async () => sdk, loadAcpSdk: async () => sdk,
initializationTimeoutMs: 100, initializationTimeoutMs: 100,
@@ -417,6 +426,86 @@ describe('DeepSeekHarnessRuntime', () => {
).toBeInstanceOf(RequestError) ).toBeInstanceOf(RequestError)
}) })
it('rejects images before launch when the selected model is text-only', async () => {
const harness = setup()
await expect(
collect(
harness.runtime.run(
{
...request('text-only-image'),
images: [
{
name: 'reference.png',
mediaType: 'image/png',
data: 'aW1hZ2U='
}
]
},
new AbortController().signal
)
)
).rejects.toThrow('未启用图像输入')
expect(harness.launch).not.toHaveBeenCalled()
})
it('forwards inline images when the selected model supports them', async () => {
const harness = setup({ supportsImageInput: true })
const running = collect(
harness.runtime.run(
{
...request('vision'),
images: [
{
name: 'reference.png',
mediaType: 'image/png',
data: 'aW1hZ2U='
}
]
},
new AbortController().signal
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
expect(harness.launch).toHaveBeenCalledWith(
expect.objectContaining({ supportsImageInput: true })
)
expect(
harness.requests.find(
(entry) => entry.method === 'session/prompt'
)?.params
).toMatchObject({
prompt: [
{ type: 'text', text: 'hello' },
{
type: 'image',
mimeType: 'image/png',
data: 'aW1hZ2U='
}
]
})
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
await expect(running).resolves.toContainEqual(
expect.objectContaining({ type: 'done' })
)
})
it('fails closed when Host image capability disagrees with the model', async () => {
const harness = setup({
supportsImageInput: true,
advertisedImageInput: false
})
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
available: false,
detail: expect.stringContaining('图片能力')
})
expect(harness.child.terminate).toHaveBeenCalled()
})
it('uses ACP stdio, maps conversations to sessions, and streams text', async () => { it('uses ACP stdio, maps conversations to sessions, and streams text', async () => {
const harness = setup() const harness = setup()
const first = collect( const first = collect(
@@ -456,6 +545,7 @@ describe('DeepSeekHarnessRuntime', () => {
signal: expect.any(AbortSignal), signal: expect.any(AbortSignal),
baseUrl: 'https://api.deepseek.com', baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test', model: 'deepseek-test',
supportsImageInput: false,
credentialRefs: [], credentialRefs: [],
skillPackages: [], skillPackages: [],
extensionPackages: [] extensionPackages: []
+36 -4
View File
@@ -171,6 +171,7 @@ export type DeepSeekHarnessLaunchOptions = {
signal: AbortSignal signal: AbortSignal
baseUrl: string baseUrl: string
model: string model: string
supportsImageInput: boolean
credentialRefs: readonly string[] credentialRefs: readonly string[]
skillPackages: readonly RuntimeSkillPackage[] skillPackages: readonly RuntimeSkillPackage[]
extensionPackages: readonly ControlledHarnessExtensionPackage[] extensionPackages: readonly ControlledHarnessExtensionPackage[]
@@ -180,6 +181,7 @@ export type DeepSeekHarnessRuntimeOptions = {
defaultWorkspace: string defaultWorkspace: string
baseUrl: string baseUrl: string
model: string model: string
supportsImageInput?: boolean
launch: ( launch: (
options: DeepSeekHarnessLaunchOptions options: DeepSeekHarnessLaunchOptions
) => Promise<DeepSeekHarnessChild> ) => Promise<DeepSeekHarnessChild>
@@ -812,6 +814,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
signal: launchController.signal, signal: launchController.signal,
baseUrl: this.options.baseUrl, baseUrl: this.options.baseUrl,
model: this.options.model, model: this.options.model,
supportsImageInput:
this.options.supportsImageInput === true,
credentialRefs: Object.keys( credentialRefs: Object.keys(
this.options.credentialRefs ?? {} this.options.credentialRefs ?? {}
), ),
@@ -1022,7 +1026,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
() => () =>
this.fail(new Error('DeepSeek Harness ACP 连接异常关闭')) this.fail(new Error('DeepSeek Harness ACP 连接异常关闭'))
) )
await withTimeout( const initialization = await withTimeout(
stateWithoutCapabilities.agent.initialize({ stateWithoutCapabilities.agent.initialize({
protocolVersion: sdk.PROTOCOL_VERSION, protocolVersion: sdk.PROTOCOL_VERSION,
clientCapabilities: {}, clientCapabilities: {},
@@ -1034,6 +1038,26 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
this.initializationTimeoutMs, this.initializationTimeoutMs,
'ACP 初始化' 'ACP 初始化'
) )
const advertisedImageInput =
Boolean(
initialization &&
typeof initialization === 'object' &&
(
initialization as {
agentCapabilities?: {
promptCapabilities?: { image?: unknown }
}
}
).agentCapabilities?.promptCapabilities?.image === true
)
if (
advertisedImageInput !==
(this.options.supportsImageInput === true)
) {
throw new Error(
'DeepSeek Harness Host 图片能力与所选模型连接不一致'
)
}
const capabilities = this.parseCapabilities( const capabilities = this.parseCapabilities(
await withTimeout( await withTimeout(
stateWithoutCapabilities.agent.extMethod( stateWithoutCapabilities.agent.extMethod(
@@ -1408,8 +1432,11 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
authorize?: RuntimeAuthorizer authorize?: RuntimeAuthorizer
): AsyncGenerator<RuntimeEvent, void, void> { ): AsyncGenerator<RuntimeEvent, void, void> {
signal.throwIfAborted() signal.throwIfAborted()
if (request.images?.length) { if (
throw new Error('DeepSeek Harness Runtime 暂不支持图像输入') request.images?.length &&
this.options.supportsImageInput !== true
) {
throw new Error('当前 DeepSeek Harness 模型连接未启用图像输入')
} }
const release = await this.acquireConversation( const release = await this.acquireConversation(
request.conversationId, request.conversationId,
@@ -1477,7 +1504,12 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
{ {
type: 'text', type: 'text',
text: flattenPrompt(request) text: flattenPrompt(request)
} },
...(request.images ?? []).map((image) => ({
type: 'image' as const,
data: image.data,
mimeType: image.mediaType
}))
] ]
}), }),
this.promptTimeoutMs, this.promptTimeoutMs,
@@ -52,6 +52,7 @@ async function fixture() {
signal: new AbortController().signal, signal: new AbortController().signal,
baseUrl: 'https://gateway.example/openai/v1', baseUrl: 'https://gateway.example/openai/v1',
model: 'qwen-plus', model: 'qwen-plus',
supportsImageInput: false,
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF], credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
skillPackages: [], skillPackages: [],
extensionPackages: [] extensionPackages: []
@@ -103,6 +104,7 @@ describe('DeepSeek Harness utility launcher', () => {
config: { config: {
baseUrl: 'https://gateway.example/openai/v1', baseUrl: 'https://gateway.example/openai/v1',
model: 'qwen-plus', model: 'qwen-plus',
supportsImageInput: false,
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF] credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF]
} }
}) })
@@ -14,6 +14,7 @@ import {
DEEPSEEK_HARNESS_CONTROL_VERSION, DEEPSEEK_HARNESS_CONTROL_VERSION,
DEEPSEEK_HARNESS_CREDENTIAL_REF, DEEPSEEK_HARNESS_CREDENTIAL_REF,
DEEPSEEK_HARNESS_HOST_VERSION, DEEPSEEK_HARNESS_HOST_VERSION,
DEEPSEEK_HARNESS_MAX_FRAME_BYTES,
parseHarnessControlMessage, parseHarnessControlMessage,
type DeepSeekHarnessControlMessage as HarnessControlMessage type DeepSeekHarnessControlMessage as HarnessControlMessage
} from './deepseek-harness-control-protocol' } from './deepseek-harness-control-protocol'
@@ -24,6 +25,7 @@ export {
DEEPSEEK_HARNESS_CONTROL_VERSION, DEEPSEEK_HARNESS_CONTROL_VERSION,
DEEPSEEK_HARNESS_CREDENTIAL_REF, DEEPSEEK_HARNESS_CREDENTIAL_REF,
DEEPSEEK_HARNESS_HOST_VERSION, DEEPSEEK_HARNESS_HOST_VERSION,
DEEPSEEK_HARNESS_MAX_FRAME_BYTES,
parseHarnessControlMessage parseHarnessControlMessage
} from './deepseek-harness-control-protocol' } from './deepseek-harness-control-protocol'
export type { export type {
@@ -274,11 +276,12 @@ export function createDeepSeekHarnessUtilityLauncher(
api: 'openai-completions', api: 'openai-completions',
provider: 'goodbuddy', provider: 'goodbuddy',
model: options.model, model: options.model,
supportsImageInput: options.supportsImageInput,
harnessVersion: DEEPSEEK_HARNESS_HOST_VERSION, harnessVersion: DEEPSEEK_HARNESS_HOST_VERSION,
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF], credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
skillPackages: canonicalSkillPackages, skillPackages: canonicalSkillPackages,
extensionPackages: canonicalExtensionPackages, extensionPackages: canonicalExtensionPackages,
maxFrameBytes: 1024 * 1024 maxFrameBytes: DEEPSEEK_HARNESS_MAX_FRAME_BYTES
}) })
utility.postMessage({ utility.postMessage({
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL, protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { import {
DEEPSEEK_HARNESS_BYTE_PROTOCOL, DEEPSEEK_HARNESS_BYTE_PROTOCOL,
DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION, DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION,
@@ -7,6 +9,10 @@ import {
createDeepSeekHarnessUtilityChild, createDeepSeekHarnessUtilityChild,
type DeepSeekHarnessParentPortLike type DeepSeekHarnessParentPortLike
} from './deepseek-harness-utility-transport' } from './deepseek-harness-utility-transport'
import {
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
DEEPSEEK_HARNESS_CONTROL_VERSION
} from './deepseek-harness-control-protocol'
type Listener = (value: unknown) => void type Listener = (value: unknown) => void
@@ -120,13 +126,27 @@ function setup() {
const tick = () => new Promise<void>((resolve) => queueMicrotask(resolve)) const tick = () => new Promise<void>((resolve) => queueMicrotask(resolve))
describe('DeepSeek Harness utility byte transport', () => { describe('DeepSeek Harness utility byte transport', () => {
it('keeps the Electron smoke protocol versions aligned', () => {
const smokeSource = readFileSync(
resolve('build/deepseek-harness-utility-smoke.cjs'),
'utf8'
)
expect(smokeSource).toContain(
`const controlVersion = ${DEEPSEEK_HARNESS_CONTROL_VERSION}`
)
expect(smokeSource).toContain(
`const byteProtocolVersion = ${DEEPSEEK_HARNESS_BYTE_PROTOCOL_VERSION}`
)
})
it('ignores trusted control-plane messages that share the UtilityProcess port', async () => { it('ignores trusted control-plane messages that share the UtilityProcess port', async () => {
const { child, hostPort, utility } = setup() const { child, hostPort, utility } = setup()
await tick() await tick()
utility.kill.mockClear() utility.kill.mockClear()
utility.emitMessage({ utility.emitMessage({
protocol: 'goodbuddy.deepseek-harness.control', protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
version: 1, version: DEEPSEEK_HARNESS_CONTROL_VERSION,
type: 'ready', type: 'ready',
failedExtensionIds: [] failedExtensionIds: []
}) })
@@ -153,8 +173,8 @@ describe('DeepSeek Harness utility byte transport', () => {
const { child, utility } = setup() const { child, utility } = setup()
const reader = child.stdout.getReader() const reader = child.stdout.getReader()
utility.emitMessage({ utility.emitMessage({
protocol: 'goodbuddy.deepseek-harness.control', protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
version: 1, version: DEEPSEEK_HARNESS_CONTROL_VERSION,
type: 'ready', type: 'ready',
failedExtensionIds: [], failedExtensionIds: [],
unexpected: true unexpected: true
@@ -0,0 +1,133 @@
// @vitest-environment node
import { Context } from '@deepseek-ai/cordis'
import { createCanvas } from '@napi-rs/canvas'
import { describe, expect, it } from 'vitest'
import { GoodBuddyHarnessAttachmentStore } from './goodbuddy-harness-attachment-store'
const canvas = createCanvas(1, 1)
const transparentPng = canvas.toBuffer('image/png')
const jpeg = canvas.toBuffer('image/jpeg')
const secondPng = createCanvas(2, 1).toBuffer('image/png')
describe('GoodBuddy Harness attachment store', () => {
it('decodes, stores, verifies, and releases inline images', async () => {
const store = new GoodBuddyHarnessAttachmentStore(new Context())
const input = {
data: transparentPng,
mediaType: 'image/png' as const,
name: '..\\screenshots\\reference.png'
}
const first = await store.saveImage(input)
const second = await store.saveImage(input)
expect(first).toEqual(second)
expect(first).toMatchObject({
mediaType: 'image/png',
bytes: transparentPng.byteLength,
width: 1,
height: 1,
name: 'reference.png'
})
const stored = await store.readImage(first)
expect(stored.ref).toBe(first)
expect(Buffer.from(stored.data).equals(transparentPng)).toBe(true)
store.releaseImage(first)
await expect(store.readImage(first)).resolves.toBeDefined()
store.releaseImage(second)
await expect(store.readImage(first)).rejects.toMatchObject({
code: 'NOT_FOUND'
})
const jpegRef = await store.saveImage({
data: jpeg,
mediaType: 'image/jpeg'
})
expect(jpegRef).toMatchObject({
mediaType: 'image/jpeg',
bytes: jpeg.byteLength,
width: 1,
height: 1
})
})
it('rejects mismatched, malformed, and over-capacity images', async () => {
const store = new GoodBuddyHarnessAttachmentStore(new Context(), {
maxStoredImages: 1
})
await expect(
store.saveImage({
data: transparentPng,
mediaType: 'image/jpeg'
})
).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
await expect(
store.saveImage({
data: Buffer.from([
0x89, 0x50, 0x4e, 0x47,
0x0d, 0x0a, 0x1a, 0x0a
]),
mediaType: 'image/png'
})
).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
const corruptPng = Buffer.from(transparentPng)
corruptPng[corruptPng.length - 8] =
(corruptPng[corruptPng.length - 8] ?? 0) ^ 1
await expect(
store.saveImage({
data: corruptPng,
mediaType: 'image/png'
})
).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
await store.saveImage({
data: transparentPng,
mediaType: 'image/png'
})
await expect(
store.saveImage({
data: secondPng,
mediaType: 'image/png'
})
).rejects.toMatchObject({ code: 'STORAGE_LIMIT' })
})
it('does not retain a partial batch when capacity is exceeded', async () => {
const store = new GoodBuddyHarnessAttachmentStore(new Context(), {
maxStoredImages: 1
})
await expect(
store.saveImages([
{ data: transparentPng, mediaType: 'image/png' },
{ data: jpeg, mediaType: 'image/jpeg' }
])
).rejects.toMatchObject({ code: 'STORAGE_LIMIT' })
await expect(
store.saveImage({
data: jpeg,
mediaType: 'image/jpeg'
})
).resolves.toMatchObject({ mediaType: 'image/jpeg' })
})
it('bounds aggregate decoded pixels before retaining a batch', async () => {
const store = new GoodBuddyHarnessAttachmentStore(new Context(), {
maxBatchImagePixels: 1
})
const input = {
data: transparentPng,
mediaType: 'image/png' as const
}
await expect(
store.saveImages([input, input])
).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
await expect(store.saveImage(input)).resolves.toMatchObject({
width: 1,
height: 1
})
})
})
@@ -0,0 +1,449 @@
import { createHash } from 'node:crypto'
import { basename } from 'node:path'
import { crc32 } from 'node:zlib'
import type { Context } from '@deepseek-ai/cordis'
import {
AttachmentError,
AttachmentId,
AttachmentStore,
type ImageAttachmentLimits,
type ImageAttachmentRef,
type ImageMediaType,
type SaveImageAttachment,
type StoredImageAttachment
} from '@deepseek-ai/dsh-attachment'
const DEFAULT_MAX_STORE_BYTES = 32 * 1024 * 1024
const DEFAULT_MAX_STORED_IMAGES = 256
const DEFAULT_MAX_BATCH_IMAGE_PIXELS = 32_000_000
export const GOODBUDDY_HARNESS_IMAGE_LIMITS: ImageAttachmentLimits =
Object.freeze({
maxImageBytes: 1024 * 1024,
maxImagesPerMessage: 8,
maxMessageImageBytes: 2 * 1024 * 1024,
maxImagePixels: 16_000_000,
mediaTypes: Object.freeze([
'image/png',
'image/jpeg'
] satisfies ImageMediaType[])
})
type StoredImage = {
ref: ImageAttachmentRef
data: Buffer
references: number
}
type InspectedImage = {
data: Buffer
width: number
height: number
}
export type GoodBuddyHarnessAttachmentStoreConfig = {
maxStoreBytes?: number
maxStoredImages?: number
maxBatchImagePixels?: number
}
function invalidImage(message: string, cause?: unknown): AttachmentError {
return new AttachmentError(message, 'INVALID_IMAGE', {
...(cause === undefined ? {} : { cause })
})
}
function safeImageName(name: string | undefined): string | undefined {
if (!name) {
return undefined
}
const safe = basename(name.replaceAll('\\', '/'))
.replace(/\p{Cc}/gu, '_')
.trim()
.slice(0, 200)
return safe || undefined
}
function matchesSignature(
data: Buffer,
mediaType: ImageMediaType
): boolean {
if (mediaType === 'image/png') {
return (
data.length >= 8 &&
data.subarray(0, 8).equals(
Buffer.from([
0x89, 0x50, 0x4e, 0x47,
0x0d, 0x0a, 0x1a, 0x0a
])
)
)
}
return (
data.length >= 4 &&
data[0] === 0xff &&
data[1] === 0xd8 &&
data.at(-2) === 0xff &&
data.at(-1) === 0xd9
)
}
function pngDimensions(data: Buffer): {
width: number
height: number
} | undefined {
let offset = 8
let chunks = 0
let width: number | undefined
let height: number | undefined
let sawImageData = false
while (offset + 12 <= data.length && chunks < 256) {
chunks += 1
const length = data.readUInt32BE(offset)
const typeStart = offset + 4
const dataStart = typeStart + 4
const dataEnd = dataStart + length
const chunkEnd = dataEnd + 4
if (dataEnd < dataStart || chunkEnd > data.length) {
return undefined
}
const typeBytes = data.subarray(typeStart, dataStart)
const type = typeBytes.toString('ascii')
if (!/^[A-Za-z]{4}$/u.test(type)) {
return undefined
}
if (
crc32(data.subarray(typeStart, dataEnd)) !==
data.readUInt32BE(dataEnd)
) {
return undefined
}
if (chunks === 1) {
if (type !== 'IHDR' || length !== 13) {
return undefined
}
width = data.readUInt32BE(dataStart)
height = data.readUInt32BE(dataStart + 4)
} else if (type === 'IHDR') {
return undefined
}
if (type === 'IDAT') {
sawImageData = true
}
if (type === 'IEND') {
return (
length === 0 &&
chunkEnd === data.length &&
sawImageData &&
width !== undefined &&
height !== undefined
)
? { width, height }
: undefined
}
offset = chunkEnd
}
return undefined
}
function jpegDimensions(data: Buffer): {
width: number
height: number
} | undefined {
let offset = 2
while (offset + 4 <= data.length - 2) {
if (data[offset] !== 0xff) {
return undefined
}
while (data[offset] === 0xff) {
offset += 1
}
const marker = data[offset]
offset += 1
if (marker === undefined || marker === 0x00 || marker === 0xd9) {
return undefined
}
if (marker === 0xda) {
return undefined
}
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
continue
}
if (offset + 2 > data.length - 2) {
return undefined
}
const length = data.readUInt16BE(offset)
if (length < 2 || offset + length > data.length - 2) {
return undefined
}
const isStartOfFrame =
marker >= 0xc0 &&
marker <= 0xcf &&
marker !== 0xc4 &&
marker !== 0xc8 &&
marker !== 0xcc
if (isStartOfFrame) {
if (length < 7) {
return undefined
}
return {
height: data.readUInt16BE(offset + 3),
width: data.readUInt16BE(offset + 5)
}
}
offset += length
}
return undefined
}
async function inspectImage(
input: SaveImageAttachment,
limits: ImageAttachmentLimits
): Promise<InspectedImage> {
if (!limits.mediaTypes.includes(input.mediaType)) {
throw invalidImage('Image media type is not supported')
}
if (
input.data.byteLength === 0 ||
input.data.byteLength > limits.maxImageBytes
) {
throw invalidImage('Image exceeds the per-image byte limit')
}
const data = Buffer.from(input.data)
if (!matchesSignature(data, input.mediaType)) {
throw invalidImage('Image media type does not match its bytes')
}
const encodedDimensions =
input.mediaType === 'image/png'
? pngDimensions(data)
: jpegDimensions(data)
if (!encodedDimensions) {
throw invalidImage('Image container is malformed')
}
if (
encodedDimensions.width < 1 ||
encodedDimensions.height < 1 ||
encodedDimensions.width * encodedDimensions.height >
limits.maxImagePixels
) {
throw invalidImage('Image dimensions exceed the pixel limit')
}
let width: number
let height: number
let loadImage: typeof import('@napi-rs/canvas')['loadImage']
try {
const canvas = await import('@napi-rs/canvas')
loadImage = canvas.loadImage
} catch (error) {
throw new AttachmentError(
'Harness image decoder is unavailable',
'DECODER_UNAVAILABLE',
{ cause: error }
)
}
try {
const image = await loadImage(data)
width = image.naturalWidth || image.width
height = image.naturalHeight || image.height
} catch (error) {
throw invalidImage('Image bytes could not be decoded', error)
}
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width < 1 ||
height < 1 ||
width * height > limits.maxImagePixels ||
width !== encodedDimensions.width ||
height !== encodedDimensions.height
) {
throw invalidImage('Image dimensions exceed the pixel limit')
}
return { data, width, height }
}
/**
* Process-local attachment storage for the non-persistent Harness sessions.
* Images are fully decoded before an immutable content-addressed reference is
* published. The store is bounded independently of per-message admission.
*/
export class GoodBuddyHarnessAttachmentStore extends AttachmentStore {
readonly imageLimits = GOODBUDDY_HARNESS_IMAGE_LIMITS
private readonly images = new Map<string, StoredImage>()
private readonly maxBatchImagePixels: number
private readonly maxStoreBytes: number
private readonly maxStoredImages: number
private storedBytes = 0
constructor(
ctx: Context,
config: GoodBuddyHarnessAttachmentStoreConfig = {}
) {
super(ctx)
this.maxStoreBytes =
config.maxStoreBytes ?? DEFAULT_MAX_STORE_BYTES
this.maxStoredImages =
config.maxStoredImages ?? DEFAULT_MAX_STORED_IMAGES
this.maxBatchImagePixels =
config.maxBatchImagePixels ??
DEFAULT_MAX_BATCH_IMAGE_PIXELS
if (
!Number.isSafeInteger(this.maxStoreBytes) ||
this.maxStoreBytes < this.imageLimits.maxImageBytes ||
!Number.isSafeInteger(this.maxStoredImages) ||
this.maxStoredImages < 1 ||
!Number.isSafeInteger(this.maxBatchImagePixels) ||
this.maxBatchImagePixels < 1
) {
throw new TypeError(
'GoodBuddy Harness attachment-store limits are invalid'
)
}
}
async validateImage(input: SaveImageAttachment): Promise<void> {
await inspectImage(input, this.imageLimits)
}
async saveImage(
input: SaveImageAttachment
): Promise<ImageAttachmentRef> {
return (await this.saveImages([input]))[0]!
}
async saveImages(
inputs: readonly SaveImageAttachment[]
): Promise<ImageAttachmentRef[]> {
const inspectedByContent = new Map<string, InspectedImage>()
const candidates: Array<{
input: SaveImageAttachment
inspected: InspectedImage
attachmentId: ImageAttachmentRef['attachmentId']
}> = []
let batchPixels = 0
for (const input of inputs) {
if (
!this.imageLimits.mediaTypes.includes(input.mediaType) ||
input.data.byteLength === 0 ||
input.data.byteLength > this.imageLimits.maxImageBytes
) {
throw invalidImage('Image exceeds the attachment limits')
}
const digest = createHash('sha256')
.update(input.data)
.digest('hex')
const contentKey = `${input.mediaType}:${digest}`
let inspected = inspectedByContent.get(contentKey)
if (!inspected) {
inspected = await inspectImage(input, this.imageLimits)
inspectedByContent.set(contentKey, inspected)
}
batchPixels += inspected.width * inspected.height
if (batchPixels > this.maxBatchImagePixels) {
throw invalidImage('Images exceed the batch pixel limit')
}
candidates.push({
input,
inspected,
attachmentId: AttachmentId(`sha256:${digest}`)
})
}
const additions = new Map<
ImageAttachmentRef['attachmentId'],
InspectedImage
>()
for (const candidate of candidates) {
if (
!this.images.has(candidate.attachmentId) &&
!additions.has(candidate.attachmentId)
) {
additions.set(candidate.attachmentId, candidate.inspected)
}
}
const additionalBytes = [...additions.values()].reduce(
(total, inspected) => total + inspected.data.byteLength,
0
)
if (
this.images.size + additions.size > this.maxStoredImages ||
this.storedBytes + additionalBytes > this.maxStoreBytes
) {
throw new AttachmentError(
'Harness attachment store is full',
'STORAGE_LIMIT'
)
}
return candidates.map(({ input, inspected, attachmentId }) => {
const existing = this.images.get(attachmentId)
if (existing) {
existing.references += 1
return existing.ref
}
const name = safeImageName(input.name)
const ref = Object.freeze({
attachmentId,
mediaType: input.mediaType,
bytes: inspected.data.byteLength,
width: inspected.width,
height: inspected.height,
...(name ? { name } : {})
})
this.images.set(attachmentId, {
ref,
data: inspected.data,
references: 1
})
this.storedBytes += inspected.data.byteLength
return ref
})
}
async readImage(
ref: ImageAttachmentRef,
signal?: AbortSignal
): Promise<StoredImageAttachment> {
signal?.throwIfAborted()
const stored = this.images.get(ref.attachmentId)
if (!stored) {
throw new AttachmentError(
'Harness image attachment was not found',
'NOT_FOUND'
)
}
if (
stored.ref.attachmentId !== ref.attachmentId ||
stored.ref.mediaType !== ref.mediaType ||
stored.ref.bytes !== ref.bytes ||
stored.ref.width !== ref.width ||
stored.ref.height !== ref.height ||
stored.ref.name !== ref.name
) {
throw new AttachmentError(
'Harness image attachment failed integrity validation',
'INTEGRITY'
)
}
return {
ref: stored.ref,
data: Uint8Array.from(stored.data)
}
}
releaseImage(ref: ImageAttachmentRef): void {
const stored = this.images.get(ref.attachmentId)
if (!stored) {
return
}
stored.references -= 1
if (stored.references > 0) {
return
}
this.images.delete(ref.attachmentId)
this.storedBytes -= stored.data.byteLength
}
clear(): void {
this.images.clear()
this.storedBytes = 0
}
}
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import { createCanvas } from '@napi-rs/canvas'
import type { Stream } from '@agentclientprotocol/sdk' import type { Stream } from '@agentclientprotocol/sdk'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { import {
@@ -9,9 +10,15 @@ import {
GoodBuddyHarnessControlPlane, GoodBuddyHarnessControlPlane,
createBoundedAcpStream createBoundedAcpStream
} from './goodbuddy-harness-control-plane' } from './goodbuddy-harness-control-plane'
import { GoodBuddyHarnessAttachmentStore } from './goodbuddy-harness-attachment-store'
function controlPlane() { function controlPlane() {
return new GoodBuddyHarnessControlPlane({} as Context, { return new GoodBuddyHarnessControlPlane(
{
on: vi.fn(),
get: vi.fn()
} as unknown as Context,
{
provider: 'goodbuddy', provider: 'goodbuddy',
model: 'deepseek-test', model: 'deepseek-test',
workspace: resolve('workspace'), workspace: resolve('workspace'),
@@ -19,7 +26,8 @@ function controlPlane() {
execution: { mode: 'host' }, execution: { mode: 'host' },
credentialRefs: ['GOODBUDDY_API_KEY'], credentialRefs: ['GOODBUDDY_API_KEY'],
skills: [] skills: []
}) }
)
} }
function stubAgentContext() { function stubAgentContext() {
@@ -123,6 +131,127 @@ function stubAgentContext() {
} }
describe('GoodBuddy Harness internal control plane', () => { describe('GoodBuddy Harness internal control plane', () => {
it('advertises and stores only model-enabled inline image prompts', async () => {
const textOnly = controlPlane() as unknown as {
createAgentApi(): {
initialize(): Promise<{
agentCapabilities: {
promptCapabilities: { image: boolean }
}
}>
}
storePromptImages(
prompt: Array<Record<string, unknown>>
): Promise<unknown[]>
}
await expect(textOnly.createAgentApi().initialize()).resolves.toMatchObject({
agentCapabilities: {
promptCapabilities: { image: false }
}
})
await expect(
textOnly.storePromptImages([
{
type: 'image',
mimeType: 'image/png',
data: 'aW1hZ2U='
}
])
).rejects.toThrow('does not accept image input')
const storeContext = new Context()
const store = new GoodBuddyHarnessAttachmentStore(storeContext)
const ctx = {
on: vi.fn(),
get: vi.fn((name: string) =>
name === 'attachments' ? store : undefined
)
} as unknown as Context
const subject = new GoodBuddyHarnessControlPlane(ctx, {
provider: 'goodbuddy',
model: 'vision-test',
supportsImageInput: true,
workspace: resolve('workspace'),
harnessVersion: '0.1.0-rc.6',
execution: { mode: 'host' },
credentialRefs: ['GOODBUDDY_API_KEY'],
skills: []
}) as unknown as {
createAgentApi(): {
initialize(): Promise<{
agentCapabilities: {
promptCapabilities: { image: boolean }
}
}>
}
storePromptImages(
prompt: Array<Record<string, unknown>>
): Promise<
Array<
Parameters<GoodBuddyHarnessAttachmentStore['readImage']>[0]
>
>
releaseAttachments(
refs: Array<
Parameters<GoodBuddyHarnessAttachmentStore['readImage']>[0]
>
): void
}
const png = createCanvas(1, 1).toBuffer('image/png')
await expect(subject.createAgentApi().initialize()).resolves.toMatchObject({
agentCapabilities: {
promptCapabilities: { image: true }
}
})
const refs = await subject.storePromptImages([
{ type: 'text', text: 'describe this image' },
{
type: 'image',
mimeType: 'image/png',
data: png.toString('base64')
}
])
expect(refs).toHaveLength(1)
await expect(store.readImage(refs[0]!)).resolves.toMatchObject({
ref: expect.objectContaining({
mediaType: 'image/png',
width: 1,
height: 1
})
})
subject.releaseAttachments(refs)
await expect(store.readImage(refs[0]!)).rejects.toMatchObject({
code: 'NOT_FOUND'
})
await expect(
subject.storePromptImages([
{
type: 'image',
mimeType: 'image/png',
data: png.toString('base64'),
uri: 'https://example.com/reference.png'
}
])
).rejects.toThrow('invalid inline image')
const saveImages = vi.spyOn(store, 'saveImages')
const largeInlineData = Buffer.alloc(800 * 1024).toString(
'base64'
)
await expect(
subject.storePromptImages(
Array.from({ length: 3 }, () => ({
type: 'image' as const,
mimeType: 'image/png',
data: largeInlineData
}))
)
).rejects.toThrow('invalid inline image')
expect(saveImages).not.toHaveBeenCalled()
await storeContext.fiber.dispose()
})
it('requires a versioned handshake before privileged extensions', async () => { it('requires a versioned handshake before privileged extensions', async () => {
const subject = controlPlane() const subject = controlPlane()
+168 -17
View File
@@ -6,10 +6,16 @@ import {
RequestError, RequestError,
type Agent, type Agent,
type AgentSideConnection as AcpAgentConnection, type AgentSideConnection as AcpAgentConnection,
type ContentBlock as AcpContentBlock,
type Stream type Stream
} from '@agentclientprotocol/sdk' } from '@agentclientprotocol/sdk'
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import type { AgentHandle } from '@deepseek-ai/dsh-agent' import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import {
AttachmentError,
type ImageAttachmentRef,
type SaveImageAttachment
} from '@deepseek-ai/dsh-attachment'
import { import {
CredentialProvider, CredentialProvider,
type CredentialInfo, type CredentialInfo,
@@ -19,6 +25,7 @@ import {
import { import {
createUserMessage, createUserMessage,
errorChain, errorChain,
type ContentBlock as HarnessContentBlock,
type TokenUsage type TokenUsage
} from '@deepseek-ai/dsh-llm' } from '@deepseek-ai/dsh-llm'
import { import {
@@ -39,6 +46,7 @@ import {
GOODBUDDY_TOOLS_CALL, GOODBUDDY_TOOLS_CALL,
GOODBUDDY_TOOLS_LIST GOODBUDDY_TOOLS_LIST
} from './deepseek-harness-protocol' } from './deepseek-harness-protocol'
import { GoodBuddyHarnessAttachmentStore } from './goodbuddy-harness-attachment-store'
export { export {
GOODBUDDY_CONTROL_PROTOCOL_VERSION, GOODBUDDY_CONTROL_PROTOCOL_VERSION,
GOODBUDDY_CREDENTIAL, GOODBUDDY_CREDENTIAL,
@@ -94,6 +102,7 @@ export type GoodBuddyHarnessCapabilities = {
export type GoodBuddyHarnessControlConfig = { export type GoodBuddyHarnessControlConfig = {
provider: string provider: string
model: string model: string
supportsImageInput?: boolean
workspace: string workspace: string
harnessVersion: string harnessVersion: string
execution: GoodBuddyHarnessCapabilities['execution'] execution: GoodBuddyHarnessCapabilities['execution']
@@ -117,6 +126,7 @@ type Preparation = {
type OwnedSession = { type OwnedSession = {
handle: AgentHandle handle: AgentHandle
attachmentRefs: ImageAttachmentRef[]
preparation?: Preparation preparation?: Preparation
proxyTools: Map< proxyTools: Map<
string, string,
@@ -375,24 +385,27 @@ function boundedJson(value: unknown): string | undefined {
} }
} }
function promptText( function promptText(prompt: readonly AcpContentBlock[]): string {
prompt: readonly { type: string; text?: string }[] const text: string[] = []
): string { for (const block of prompt) {
if ( switch (block.type) {
prompt.some( case 'text':
(block) => text.push(block.text)
block.type !== 'text' && break
block.type !== 'resource_link' case 'image':
) case 'resource_link':
) { break
case 'audio':
case 'resource':
throw RequestError.invalidParams( throw RequestError.invalidParams(
undefined, undefined,
'only text and resource_link prompt content is supported' `unsupported ACP prompt content: ${block.type}`
) )
default:
block satisfies never
} }
return prompt }
.map((block) => (block.type === 'text' ? block.text ?? '' : '')) return text.join('')
.join('')
} }
function turnReason(event: SessionEvent): string | undefined { function turnReason(event: SessionEvent): string | undefined {
@@ -957,6 +970,118 @@ export class GoodBuddyHarnessControlPlane {
} }
} }
private attachmentStore(): GoodBuddyHarnessAttachmentStore {
const store = this.ctx.get('attachments')
if (!(store instanceof GoodBuddyHarnessAttachmentStore)) {
throw RequestError.internalError(
undefined,
'Harness image attachment service is unavailable'
)
}
return store
}
private releaseAttachments(refs: readonly ImageAttachmentRef[]): void {
if (refs.length === 0) {
return
}
const store = this.attachmentStore()
for (const ref of refs) {
store.releaseImage(ref)
}
}
private async storePromptImages(
prompt: readonly AcpContentBlock[]
): Promise<ImageAttachmentRef[]> {
const imageBlocks = prompt.filter(
(
block
): block is Extract<AcpContentBlock, { type: 'image' }> =>
block.type === 'image'
)
if (imageBlocks.length === 0) {
return []
}
if (!this.config.supportsImageInput) {
throw RequestError.invalidParams(
undefined,
'the selected model does not accept image input'
)
}
const store = this.attachmentStore()
if (imageBlocks.length > store.imageLimits.maxImagesPerMessage) {
throw RequestError.invalidParams(
undefined,
'too many images in one prompt'
)
}
const inputs: SaveImageAttachment[] = []
let totalBytes = 0
for (const block of imageBlocks) {
if (
block.uri != null ||
!store.imageLimits.mediaTypes.includes(
block.mimeType as SaveImageAttachment['mediaType']
) ||
block.data.length === 0 ||
block.data.length % 4 !== 0 ||
block.data.length >
Math.ceil(store.imageLimits.maxImageBytes / 3) * 4 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
block.data
)
) {
throw RequestError.invalidParams(
undefined,
'invalid inline image prompt content'
)
}
const data = Buffer.from(block.data, 'base64')
totalBytes += data.byteLength
if (
data.byteLength === 0 ||
data.byteLength > store.imageLimits.maxImageBytes ||
totalBytes > store.imageLimits.maxMessageImageBytes ||
data.toString('base64') !== block.data
) {
throw RequestError.invalidParams(
undefined,
'invalid inline image prompt content'
)
}
inputs.push({
data,
mediaType: block.mimeType as SaveImageAttachment['mediaType']
})
}
try {
return await store.saveImages(inputs)
} catch (error) {
if (error instanceof AttachmentError) {
if (error.code === 'INVALID_IMAGE') {
throw RequestError.invalidParams(
undefined,
'invalid inline image prompt content'
)
}
if (
error.code === 'STORAGE_LIMIT' ||
error.code === 'DECODER_UNAVAILABLE'
) {
throw RequestError.internalError(
undefined,
'Harness image attachment service is unavailable'
)
}
}
throw RequestError.internalError(
undefined,
'Harness image attachment storage failed'
)
}
}
private createAgentApi(): Agent { private createAgentApi(): Agent {
this.observeSessions() this.observeSessions()
return { return {
@@ -968,7 +1093,7 @@ export class GoodBuddyHarnessControlPlane {
}, },
agentCapabilities: { agentCapabilities: {
promptCapabilities: { promptCapabilities: {
image: false, image: this.config.supportsImageInput === true,
audio: false, audio: false,
embeddedContext: false embeddedContext: false
}, },
@@ -1050,6 +1175,7 @@ export class GoodBuddyHarnessControlPlane {
} }
this.sessions.set(sessionId, { this.sessions.set(sessionId, {
handle, handle,
attachmentRefs: [],
proxyTools: new Map(), proxyTools: new Map(),
askToolDefinitions askToolDefinitions
}) })
@@ -1091,10 +1217,26 @@ export class GoodBuddyHarnessControlPlane {
'empty prompt' 'empty prompt'
) )
} }
const message = createUserMessage({ const attachmentRefs = await this.storePromptImages(
content: [{ type: 'text', text }], params.prompt
)
let message: ReturnType<typeof createUserMessage>
try {
const content: HarnessContentBlock[] = [
{ type: 'text', text },
...attachmentRefs.map((attachment) => ({
type: 'image' as const,
attachment
}))
]
message = createUserMessage({
content,
source: { kind: 'user' } source: { kind: 'user' }
}) })
} catch (error) {
this.releaseAttachments(attachmentRefs)
throw error
}
const stopReason = await new Promise<string>( const stopReason = await new Promise<string>(
(resolve, reject) => { (resolve, reject) => {
record.inflight = { record.inflight = {
@@ -1110,9 +1252,11 @@ export class GoodBuddyHarnessControlPlane {
record.handle.agent.followup(message) record.handle.agent.followup(message)
} catch (error) { } catch (error) {
record.inflight = undefined record.inflight = undefined
this.releaseAttachments(attachmentRefs)
reject(error) reject(error)
return return
} }
record.attachmentRefs.push(...attachmentRefs)
void record.handle.agent.whenIdle().then(() => { void record.handle.agent.whenIdle().then(() => {
const current = record.inflight const current = record.inflight
if (current?.messageId !== message.id) { if (current?.messageId !== message.id) {
@@ -1255,7 +1399,11 @@ export class GoodBuddyHarnessControlPlane {
record.inflight.resolve('cancelled') record.inflight.resolve('cancelled')
record.inflight = undefined record.inflight = undefined
} }
try {
await record.handle.dispose() await record.handle.dispose()
} finally {
this.releaseAttachments(record.attachmentRefs)
}
} }
async dispose(): Promise<void> { async dispose(): Promise<void> {
@@ -1273,6 +1421,9 @@ export class GoodBuddyHarnessControlPlane {
await Promise.allSettled( await Promise.allSettled(
sessions.map(([, record]) => record.handle.dispose()) sessions.map(([, record]) => record.handle.dispose())
) )
for (const [, record] of sessions) {
this.releaseAttachments(record.attachmentRefs)
}
})() })()
await this.disposing await this.disposing
} }
+5
View File
@@ -11,6 +11,7 @@ import type {
CreateAgentOptions CreateAgentOptions
} from '@deepseek-ai/dsh-agent' } from '@deepseek-ai/dsh-agent'
import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './agent/goodbuddy-harness-control-plane' import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './agent/goodbuddy-harness-control-plane'
import { GoodBuddyHarnessAttachmentStore } from './agent/goodbuddy-harness-attachment-store'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { basename, join } from 'node:path' import { basename, join } from 'node:path'
@@ -80,6 +81,7 @@ describe('controlled DeepSeek Harness host', () => {
api: 'openai-completions', api: 'openai-completions',
provider: 'goodbuddy', provider: 'goodbuddy',
model: 'qwen-plus', model: 'qwen-plus',
supportsImageInput: true,
harnessVersion: '0.1.0-rc.6', harnessVersion: '0.1.0-rc.6',
credentialRefs: ['GOODBUDDY_API_KEY'], credentialRefs: ['GOODBUDDY_API_KEY'],
skillPackages: [], skillPackages: [],
@@ -91,6 +93,9 @@ describe('controlled DeepSeek Harness host', () => {
expect(host.context.fs.sandboxMode).toBeUndefined() expect(host.context.fs.sandboxMode).toBeUndefined()
expect(host.context.shell.sandboxMode).toBeUndefined() expect(host.context.shell.sandboxMode).toBeUndefined()
expect(host.context.get('attachments')).toBeInstanceOf(
GoodBuddyHarnessAttachmentStore
)
expect( expect(
host.context.shell.resolve({ host.context.shell.resolve({
command: 'echo goodbuddy-host-execution' command: 'echo goodbuddy-host-execution'
+33 -7
View File
@@ -19,6 +19,9 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
import * as ShellEnv from '@deepseek-ai/dsh-shell-env' import * as ShellEnv from '@deepseek-ai/dsh-shell-env'
import {
GoodBuddyHarnessAttachmentStore
} from './agent/goodbuddy-harness-attachment-store'
import { import {
GoodBuddyCredentialProvider, GoodBuddyCredentialProvider,
GoodBuddyHarnessControlPlane, GoodBuddyHarnessControlPlane,
@@ -31,8 +34,8 @@ import {
} from './agent/deepseek-harness-extension-loader' } from './agent/deepseek-harness-extension-loader'
import type { Stream } from '@agentclientprotocol/sdk' import type { Stream } from '@agentclientprotocol/sdk'
import { isDeepSeekHarnessCompatibleBaseUrl } from '../shared/deepseek-harness-compatibility' import { isDeepSeekHarnessCompatibleBaseUrl } from '../shared/deepseek-harness-compatibility'
import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './agent/deepseek-harness-control-protocol'
const DEFAULT_MAX_FRAME_BYTES = 1024 * 1024
const MAX_DIAGNOSTIC_BYTES = 64 * 1024 const MAX_DIAGNOSTIC_BYTES = 64 * 1024
export type ControlledHarnessHostConfig = Omit< export type ControlledHarnessHostConfig = Omit<
@@ -204,9 +207,10 @@ async function loadControlledSkills(
* Boots a fixed, programmatic Cordis graph. It never imports app-boot, a * Boots a fixed, programmatic Cordis graph. It never imports app-boot, a
* profile loader, settings-file, local credentials, persistence, telemetry, * profile loader, settings-file, local credentials, persistence, telemetry,
* web, HMR, marketplace discovery, direct MCP clients, jobs, subagents, * web, HMR, marketplace discovery, direct MCP clients, jobs, subagents,
* hooks, or workflow packages. The control plane registers Main-selected * hooks, or workflow packages. When the selected model declares image input,
* Skill snapshots, Main-mediated MCP tool proxies, and explicitly enabled * the graph adds only a bounded process-local attachment store. The control
* extension entrypoints. * plane registers Main-selected Skill snapshots, Main-mediated MCP tool
* proxies, and explicitly enabled extension entrypoints.
*/ */
export async function startControlledDeepSeekHarnessHost( export async function startControlledDeepSeekHarnessHost(
input: ControlledHarnessHostConfig input: ControlledHarnessHostConfig
@@ -219,6 +223,9 @@ export async function startControlledDeepSeekHarnessHost(
const specs: PluginSpec[] = [ const specs: PluginSpec[] = [
{ plugin: LlmRuntime }, { plugin: LlmRuntime },
{ plugin: SessionStore }, { plugin: SessionStore },
...(config.supportsImageInput
? [{ plugin: GoodBuddyHarnessAttachmentStore }]
: []),
{ plugin: SkillRegistry }, { plugin: SkillRegistry },
{ {
plugin: SystemPrompt, plugin: SystemPrompt,
@@ -242,7 +249,14 @@ export async function startControlledDeepSeekHarnessHost(
apiKeyEnv: config.credentialRefs[0], apiKeyEnv: config.credentialRefs[0],
api: config.api, api: config.api,
baseURL: config.baseUrl, baseURL: config.baseUrl,
models: [{ id: config.model, input: ['text'] }] models: [
{
id: config.model,
input: config.supportsImageInput
? ['text', 'image']
: ['text']
}
]
} }
} }
} }
@@ -310,13 +324,22 @@ export async function startControlledDeepSeekHarnessHost(
'Controlled Harness credential provider failed to start' 'Controlled Harness credential provider failed to start'
) )
} }
const attachmentStore = ctx.get('attachments')
if (
config.supportsImageInput &&
!(attachmentStore instanceof GoodBuddyHarnessAttachmentStore)
) {
throw new Error(
'Controlled Harness attachment store failed to start'
)
}
startupCode = 'HOST_CONTROL_PLANE_FAILED' startupCode = 'HOST_CONTROL_PLANE_FAILED'
const rawStream = const rawStream =
config.stream ?? config.stream ??
createBoundedNdJsonStream( createBoundedNdJsonStream(
stdoutStream(), stdoutStream(),
stdinStream(), stdinStream(),
config.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES config.maxFrameBytes ?? DEEPSEEK_HARNESS_MAX_FRAME_BYTES
) )
const controlPlane = new GoodBuddyHarnessControlPlane(ctx, { const controlPlane = new GoodBuddyHarnessControlPlane(ctx, {
...config, ...config,
@@ -325,7 +348,7 @@ export async function startControlledDeepSeekHarnessHost(
execution: { mode: 'host' }, execution: { mode: 'host' },
stream: createBoundedAcpStream( stream: createBoundedAcpStream(
rawStream, rawStream,
config.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES config.maxFrameBytes ?? DEEPSEEK_HARNESS_MAX_FRAME_BYTES
) )
}) })
controlPlane.bindCredentialProvider(credentialProvider) controlPlane.bindCredentialProvider(credentialProvider)
@@ -337,6 +360,9 @@ export async function startControlledDeepSeekHarnessHost(
extensionFailures: extensions.failures, extensionFailures: extensions.failures,
async dispose() { async dispose() {
await controlPlane.dispose() await controlPlane.dispose()
if (attachmentStore instanceof GoodBuddyHarnessAttachmentStore) {
attachmentStore.clear()
}
await ctx.fiber.dispose() await ctx.fiber.dispose()
} }
} }
+33 -2
View File
@@ -886,6 +886,9 @@ describe('App', () => {
expect(await screen.findByText('桌面工作区')).toBeInTheDocument() expect(await screen.findByText('桌面工作区')).toBeInTheDocument()
expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument() expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument()
expect(
document.querySelector('.composer__runtime-toolbar')
).not.toBeInTheDocument()
}) })
it('schedules lazy workspace routes for idle preloading', () => { it('schedules lazy workspace routes for idle preloading', () => {
@@ -4555,6 +4558,18 @@ describe('App', () => {
const agentPicker = await screen.findByRole('button', { const agentPicker = await screen.findByRole('button', {
name: /OpenCode Runtime Agent/u name: /OpenCode Runtime Agent/u
}) })
const runtimeToolbar = screen.getByRole('group', {
name: 'OpenCode 专属功能'
})
const universalSettings = screen.getByRole('group', {
name: '对话设置'
})
expect(runtimeToolbar).toHaveClass('composer__runtime-toolbar')
expect(runtimeToolbar).toContainElement(agentPicker)
expect(universalSettings).not.toContainElement(agentPicker)
expect(universalSettings.closest('.composer__toolbar')).toHaveClass(
'composer__toolbar--with-runtime-controls'
)
fireEvent.click(agentPicker) fireEvent.click(agentPicker)
fireEvent.click( fireEvent.click(
within( within(
@@ -4566,6 +4581,7 @@ describe('App', () => {
const actionPicker = screen.getByRole('button', { const actionPicker = screen.getByRole('button', {
name: /Runtime /u name: /Runtime /u
}) })
expect(runtimeToolbar).toContainElement(actionPicker)
fireEvent.click(actionPicker) fireEvent.click(actionPicker)
fireEvent.click( fireEvent.click(
within( within(
@@ -4673,6 +4689,14 @@ describe('App', () => {
const presetPicker = await screen.findByRole('button', { const presetPicker = await screen.findByRole('button', {
name: /Continue .*使/u name: /Continue .*使/u
}) })
const runtimeToolbar = screen.getByRole('group', {
name: 'Continue 专属功能'
})
expect(runtimeToolbar).toHaveClass('composer__runtime-toolbar')
expect(runtimeToolbar).toContainElement(presetPicker)
expect(
screen.getByRole('group', { name: '对话设置' })
).not.toContainElement(presetPicker)
fireEvent.click(presetPicker) fireEvent.click(presetPicker)
fireEvent.click( fireEvent.click(
within( within(
@@ -4684,6 +4708,7 @@ describe('App', () => {
const actionPicker = screen.getByRole('button', { const actionPicker = screen.getByRole('button', {
name: /Runtime /u name: /Runtime /u
}) })
expect(runtimeToolbar).toContainElement(actionPicker)
fireEvent.click(actionPicker) fireEvent.click(actionPicker)
fireEvent.click( fireEvent.click(
within( within(
@@ -4801,11 +4826,17 @@ describe('App', () => {
render(<App />) render(<App />)
fireEvent.click( const compactContext = await screen.findByRole('button', {
await screen.findByRole('button', {
name: '压缩上下文' name: '压缩上下文'
}) })
expect(compactContext.parentElement).toHaveClass(
'composer-meta',
'composer-meta--with-context-compact'
) )
expect(
compactContext.parentElement?.firstElementChild
).toBe(compactContext)
fireEvent.click(compactContext)
await waitFor(() => await waitFor(() =>
expect(api.agent.compactConversation).toHaveBeenCalledWith({ expect(api.agent.compactConversation).toHaveBeenCalledWith({
requestId: expect.any(String), requestId: expect.any(String),
+143 -103
View File
@@ -6159,6 +6159,36 @@ function App(): React.JSX.Element {
activeConversation?.messages.some( activeConversation?.messages.some(
(message) => message.state === 'streaming' (message) => message.state === 'streaming'
) ?? false ) ?? false
const runtimeAgentControlAvailable =
activeRuntimeSelection?.provider === 'opencode' &&
runtimeAgentOptions.length > 1
const runtimePresetControlAvailable =
activeRuntimeSelection?.provider === 'continue' &&
runtimePresetOptions.length > 1
const runtimeActionControlAvailable =
(activeRuntimeSelection?.provider === 'opencode' ||
activeRuntimeSelection?.provider === 'continue') &&
runtimeActionOptions.length > 1
const runtimeControlsAvailable =
runtimeAgentControlAvailable ||
runtimePresetControlAvailable ||
runtimeActionControlAvailable
const runtimeControlsProvider = runtimeControlsAvailable
? activeRuntimeSelection?.provider === 'opencode'
? 'OpenCode'
: activeRuntimeSelection?.provider === 'continue'
? 'Continue'
: undefined
: undefined
const runtimeControlsLabel = runtimeControlsProvider
? t('composer.runtimeControls.groupLabel', {
runtime: runtimeControlsProvider
})
: ''
const runtimeContextCompactAvailable =
(activeRuntimeSelection?.provider === 'opencode' ||
activeRuntimeSelection?.provider === 'continue') &&
runtimeNativeSnapshot?.context.manualCompact === true
const composerContextMetrics = useMemo(() => { const composerContextMetrics = useMemo(() => {
if ( if (
@@ -7027,7 +7057,13 @@ function App(): React.JSX.Element {
}} }}
/> />
</div> </div>
<div className="composer__toolbar"> <div
className={`composer__toolbar${
runtimeControlsProvider
? ' composer__toolbar--with-runtime-controls'
: ''
}`}
>
<div className="composer__controls"> <div className="composer__controls">
<div <div
aria-label={t('composer.addContent')} aria-label={t('composer.addContent')}
@@ -7190,83 +7226,6 @@ function App(): React.JSX.Element {
options={assistantExpertOptions} options={assistantExpertOptions}
value={selectedExpertId} value={selectedExpertId}
/> />
{activeRuntimeSelection?.provider === 'opencode' &&
runtimeAgentOptions.length > 1 && (
<ComposerMenuSelect
ariaLabel={t(
'composer.runtimeControls.agentLabel'
)}
className="composer-picker--runtime"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === 'runtime-agent'
}
onChange={setSelectedRuntimeAgent}
onOpenChange={setRuntimeAgentMenuOpen}
options={runtimeAgentOptions}
value={selectedRuntimeAgent}
/>
)}
{activeRuntimeSelection?.provider === 'continue' &&
runtimePresetOptions.length > 1 && (
<ComposerMenuSelect
ariaLabel={t(
'composer.runtimeControls.presetLabel'
)}
className="composer-picker--runtime"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === 'runtime-preset'
}
onChange={setSelectedContinuePreset}
onOpenChange={setRuntimePresetMenuOpen}
options={runtimePresetOptions}
value={selectedContinuePreset}
/>
)}
{(activeRuntimeSelection?.provider === 'opencode' ||
activeRuntimeSelection?.provider === 'continue') &&
runtimeActionOptions.length > 1 && (
<ComposerMenuSelect
ariaLabel={t(
'composer.runtimeControls.actionLabel'
)}
className="composer-picker--runtime-action"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === 'runtime-action'
}
onChange={selectRuntimeAction}
onOpenChange={setRuntimeActionMenuOpen}
options={runtimeActionOptions}
value={
runtimeActionOptions.find(
(option) =>
option.action?.type === 'command' &&
option.action.id ===
selectedRuntimeCommand
)?.value ?? ''
}
/>
)}
<ComposerMenuSelect <ComposerMenuSelect
ariaLabel={t('composer.modeLabel')} ariaLabel={t('composer.modeLabel')}
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`} className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
@@ -7575,8 +7534,113 @@ function App(): React.JSX.Element {
</button> </button>
)} )}
</div> </div>
{runtimeControlsProvider && (
<div
aria-label={runtimeControlsLabel}
className="composer__runtime-toolbar"
role="group"
>
<strong className="composer__runtime-toolbar-label">
{runtimeControlsLabel}
</strong>
<div className="composer__runtime-controls">
{runtimeAgentControlAvailable && (
<ComposerMenuSelect
ariaLabel={t(
'composer.runtimeControls.agentLabel'
)}
className="composer-picker--runtime"
disabled={isRunning}
icon={
<TerminalSquare aria-hidden="true" size={15} />
}
menuOpen={
composerMenuOpen === 'runtime-agent'
}
onChange={setSelectedRuntimeAgent}
onOpenChange={setRuntimeAgentMenuOpen}
options={runtimeAgentOptions}
value={selectedRuntimeAgent}
/>
)}
{runtimePresetControlAvailable && (
<ComposerMenuSelect
ariaLabel={t(
'composer.runtimeControls.presetLabel'
)}
className="composer-picker--runtime"
disabled={isRunning}
icon={
<TerminalSquare aria-hidden="true" size={15} />
}
menuOpen={
composerMenuOpen === 'runtime-preset'
}
onChange={setSelectedContinuePreset}
onOpenChange={setRuntimePresetMenuOpen}
options={runtimePresetOptions}
value={selectedContinuePreset}
/>
)}
{runtimeActionControlAvailable && (
<ComposerMenuSelect
ariaLabel={t(
'composer.runtimeControls.actionLabel'
)}
className="composer-picker--runtime-action"
disabled={isRunning}
icon={
<TerminalSquare aria-hidden="true" size={15} />
}
menuOpen={
composerMenuOpen === 'runtime-action'
}
onChange={selectRuntimeAction}
onOpenChange={setRuntimeActionMenuOpen}
options={runtimeActionOptions}
value={
runtimeActionOptions.find(
(option) =>
option.action?.type === 'command' &&
option.action.id ===
selectedRuntimeCommand
)?.value ?? ''
}
/>
)}
</div> </div>
<div className="composer-meta"> </div>
)}
</div>
<div
className={`composer-meta${
runtimeContextCompactAvailable
? ' composer-meta--with-context-compact'
: ''
}`}
>
{runtimeContextCompactAvailable && (
<button
className="composer-context-compact"
disabled={runtimeContextCompacting || isRunning}
onClick={() => void compactRuntimeContext()}
title={runtimeNativeSnapshot?.context.detail}
type="button"
>
{runtimeContextCompacting ? (
<LoaderCircle
aria-hidden="true"
className="context-chip__spinner"
size={13}
/>
) : (
<RefreshCw aria-hidden="true" size={13} />
)}
{runtimeContextCompacting
? t('composer.context.compacting')
: t('composer.context.compact')}
</button>
)}
{composerContextMetrics && ( {composerContextMetrics && (
<div <div
className={`composer-context-meter${ className={`composer-context-meter${
@@ -7681,30 +7745,6 @@ function App(): React.JSX.Element {
)} )}
</div> </div>
)} )}
{(activeRuntimeSelection?.provider === 'opencode' ||
activeRuntimeSelection?.provider === 'continue') &&
runtimeNativeSnapshot?.context.manualCompact && (
<button
className="composer-context-compact"
disabled={runtimeContextCompacting || isRunning}
onClick={() => void compactRuntimeContext()}
title={runtimeNativeSnapshot.context.detail}
type="button"
>
{runtimeContextCompacting ? (
<LoaderCircle
aria-hidden="true"
className="context-chip__spinner"
size={13}
/>
) : (
<RefreshCw aria-hidden="true" size={13} />
)}
{runtimeContextCompacting
? t('composer.context.compacting')
: t('composer.context.compact')}
</button>
)}
{contextError && ( {contextError && (
<span className="composer-meta__error"> <span className="composer-meta__error">
{contextError} {contextError}
@@ -119,6 +119,39 @@ describe('WorkspacePrimitives', () => {
) )
}) })
it('floats Runtime context compaction without shifting the composer', () => {
expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u
)
expect(stylesheet).toMatch(
/\.composer-meta\s*\{[^}]*position:\s*relative;[^}]*min-height:\s*26px;[^}]*margin:\s*var\(--space-1\) 0 0;/u
)
expect(stylesheet).toMatch(
/\.composer-meta--with-context-compact\s*\{[^}]*padding-left:\s*calc\(/u
)
expect(stylesheet).toMatch(
/\.composer-context-compact\s*\{[^}]*position:\s*absolute;[^}]*bottom:\s*0;[^}]*left:\s*0;/u
)
})
it('separates Runtime-specific controls from the main composer toolbar', () => {
expect(stylesheet).toMatch(
/\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u
)
expect(stylesheet).toMatch(
/\.composer__runtime-toolbar\s*\{[^}]*display:\s*flex;[^}]*min-height:\s*52px;[^}]*border-top:\s*1px solid var\(--border-default\);[^}]*background:\s*var\(--surface-muted\);/u
)
expect(stylesheet).toMatch(
/\.composer__runtime-controls\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;/u
)
expect(stylesheet).toMatch(
/\.composer-picker--runtime > \.model-button,\s*\.composer-picker--runtime-action > \.model-button\s*\{[^}]*width:\s*220px;/u
)
expect(stylesheet).toMatch(
/\.composer__runtime-controls \.composer-picker\s*\{[^}]*flex-basis:\s*220px;/u
)
})
it('keeps shared controls keyboard and pointer accessible at narrow widths', () => { it('keeps shared controls keyboard and pointer accessible at narrow widths', () => {
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/button\s*>\s*svg,\s*button\s*>\s*svg\s+\*\s*\{[^}]*pointer-events:\s*none;/u /button\s*>\s*svg,\s*button\s*>\s*svg\s+\*\s*\{[^}]*pointer-events:\s*none;/u
@@ -322,6 +322,7 @@ export const app = {
expertLabel: 'Expert role', expertLabel: 'Expert role',
modeLabel: 'Work mode', modeLabel: 'Work mode',
runtimeControls: { runtimeControls: {
groupLabel: '{{runtime}} controls',
agentLabel: 'OpenCode Runtime Agent', agentLabel: 'OpenCode Runtime Agent',
presetLabel: 'Continue configuration preset', presetLabel: 'Continue configuration preset',
actionLabel: 'Runtime shortcut', actionLabel: 'Runtime shortcut',
@@ -314,6 +314,7 @@ export const app = {
expertLabel: '专家角色', expertLabel: '专家角色',
modeLabel: '工作模式', modeLabel: '工作模式',
runtimeControls: { runtimeControls: {
groupLabel: '{{runtime}} 专属功能',
agentLabel: 'OpenCode Runtime Agent', agentLabel: 'OpenCode Runtime Agent',
presetLabel: 'Continue 配置预设', presetLabel: 'Continue 配置预设',
actionLabel: 'Runtime 快捷操作', actionLabel: 'Runtime 快捷操作',
+70 -3
View File
@@ -4038,7 +4038,7 @@ button > svg {
padding: padding:
var(--space-3) var(--space-3)
max(var(--page-gutter), calc((100% - var(--content-reading)) / 2)) max(var(--page-gutter), calc((100% - var(--content-reading)) / 2))
var(--space-4); var(--space-2);
background: var(--surface-raised); background: var(--surface-raised);
} }
@@ -4387,6 +4387,42 @@ button > svg {
gap: var(--space-3); gap: var(--space-3);
} }
.composer__toolbar--with-runtime-controls {
border-radius: 0;
}
.composer__runtime-toolbar {
display: flex;
min-width: 0;
min-height: 52px;
padding: var(--space-2) var(--space-3);
align-items: center;
border-top: 1px solid var(--border-default);
border-radius: 0 0 calc(var(--radius-card) - 1px)
calc(var(--radius-card) - 1px);
background: var(--surface-muted);
gap: var(--space-3);
}
.composer__runtime-toolbar-label {
min-width: 0;
padding-right: var(--space-3);
border-right: 1px solid var(--border-default);
color: var(--text-secondary);
font-size: var(--font-caption);
font-weight: 650;
white-space: nowrap;
}
.composer__runtime-controls {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.composer__controls { .composer__controls {
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -4483,7 +4519,7 @@ button > svg {
.composer-picker--runtime > .model-button, .composer-picker--runtime > .model-button,
.composer-picker--runtime-action > .model-button { .composer-picker--runtime-action > .model-button {
width: 138px; width: 220px;
} }
.composer-picker--ask svg { .composer-picker--ask svg {
@@ -4670,8 +4706,12 @@ button > svg {
} }
.composer-meta { .composer-meta {
--composer-context-compact-width: 124px;
position: relative;
display: flex; display: flex;
margin: var(--space-2) 0 0; min-height: 26px;
margin: var(--space-1) 0 0;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
font-size: var(--font-caption); font-size: var(--font-caption);
@@ -4680,6 +4720,12 @@ button > svg {
line-height: 1.45; line-height: 1.45;
} }
.composer-meta--with-context-compact {
padding-left: calc(
var(--composer-context-compact-width) + var(--space-3)
);
}
.composer-meta__error { .composer-meta__error {
margin-right: auto; margin-right: auto;
} }
@@ -4694,12 +4740,17 @@ button > svg {
} }
.composer-context-compact { .composer-context-compact {
position: absolute;
bottom: 0;
left: 0;
display: inline-flex; display: inline-flex;
width: var(--composer-context-compact-width);
min-height: 26px; min-height: 26px;
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-2);
border: 1px solid var(--border-control); border: 1px solid var(--border-control);
border-radius: var(--radius-control); border-radius: var(--radius-control);
align-items: center; align-items: center;
justify-content: center;
background: var(--surface-raised); background: var(--surface-raised);
color: var(--text-secondary); color: var(--text-secondary);
font-size: var(--font-caption); font-size: var(--font-caption);
@@ -4786,6 +4837,22 @@ button > svg {
flex: 1 1 150px; flex: 1 1 150px;
} }
.composer__runtime-toolbar {
align-items: stretch;
flex-direction: column;
}
.composer__runtime-toolbar-label {
padding-right: 0;
padding-bottom: var(--space-2);
border-right: 0;
border-bottom: 1px solid var(--border-default);
}
.composer__runtime-controls .composer-picker {
flex-basis: 220px;
}
.composer-picker > .model-button, .composer-picker > .model-button,
.model-button { .model-button {
width: 100%; width: 100%;
+3 -1
View File
@@ -330,7 +330,9 @@ describe('release build arguments', () => {
'node_modules/node-pty/prebuilds/**/*', 'node_modules/node-pty/prebuilds/**/*',
'node_modules/node-pty/build/Release/**/*', 'node_modules/node-pty/build/Release/**/*',
'node_modules/koffi/**/*', 'node_modules/koffi/**/*',
'node_modules/@koromix/koffi-*/**/*' 'node_modules/@koromix/koffi-*/**/*',
'node_modules/@napi-rs/canvas{,/**/*}',
'node_modules/@napi-rs/canvas-*/**/*'
]) ])
) )
expect(packageJson.build.asarUnpack).not.toContain( expect(packageJson.build.asarUnpack).not.toContain(