Compare commits
25
Commits
v0.13.0
...
agent-v0.13.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f47049c40d | ||
|
|
4c18d8d9c6 | ||
|
|
8a63ac9ee8 | ||
|
|
02e59427a1 | ||
|
|
34215c72a1 | ||
|
|
f8d07419d2 | ||
|
|
d898f420fd | ||
|
|
c4492e4a92 | ||
|
|
e5aa9ed688 | ||
|
|
b755951ecd | ||
|
|
d4f1668c9b | ||
|
|
e1fe1cf10a | ||
|
|
fbba2e3abf | ||
|
|
511f50aa63 | ||
|
|
6c1e8105dd | ||
|
|
3b83c90d0f | ||
|
|
327b06587b | ||
|
|
de14a6157a | ||
|
|
81ae9e0324 | ||
|
|
f49d6678a3 | ||
|
|
0d7ba55a50 | ||
|
|
c71da4b6ae | ||
|
|
ba6f6b8f22 | ||
|
|
49377206cc | ||
|
|
1c22e1a06d |
@@ -120,6 +120,11 @@ additional hardening beyond the actual internal-network threat model:
|
||||
|
||||
## Data and Compatibility
|
||||
|
||||
- Before changing released database schemas or large-data migrations, read
|
||||
[`docs/development/database-migrations.md`](./docs/development/database-migrations.md).
|
||||
Keep feature-specific storage rules and validation evidence in the owning
|
||||
feature documents.
|
||||
|
||||
- Preserve released user-owned data, migrations required by released data, and
|
||||
supported external contracts.
|
||||
- Compatibility does not apply to unshipped, branch-local, or safely disposable
|
||||
@@ -334,6 +339,10 @@ validation are not substitutes for this development-time check.
|
||||
- Keep all artifacts in GoodBuddy-owned test directories. Before stopping or
|
||||
changing a remote process, verify that it belongs to the test run; never
|
||||
modify unrelated Host files or processes.
|
||||
- During remote testing, prefer dedicated test directories under `/root/tmp`
|
||||
for temporary files. Avoid creating files under `/tmp` where possible, since
|
||||
it may be memory-backed and consume additional RAM. Set `TMPDIR` to the
|
||||
dedicated test directory when needed so test tools use it as well.
|
||||
- If neither route is reachable, report the real-Host validation as blocked and
|
||||
do not present the Agent work as complete or defer its first real validation
|
||||
to release time.
|
||||
|
||||
+84
-22
@@ -8,8 +8,11 @@ otherwise.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Available
|
||||
- [ ] In development or planned, as described by the item
|
||||
- [x] Completed
|
||||
- [ ] To implement
|
||||
|
||||
Status describes implementation, not publication or test coverage. Validation
|
||||
records are listed separately and do not introduce another feature status.
|
||||
|
||||
## Feature Matrix
|
||||
|
||||
@@ -21,7 +24,7 @@ otherwise.
|
||||
packages target compatible x64 and arm64 environments, including UOS, Kylin,
|
||||
Hygon, Zhaoxin, Kunpeng, and Phytium systems. This is not vendor certification.
|
||||
LoongArch has a separate experimental loong64 preview outside standard
|
||||
releases and automatic updates; no preview is planned for 0.13.0. See the
|
||||
releases and automatic updates; no preview is planned for 0.13.5. See the
|
||||
[preview boundaries](./docs/development/loongarch-preview-build.md).
|
||||
- [x] **Configurable global shortcut**: Enable, disable, or record an Electron
|
||||
accelerator under Platform Features / General. The default remains
|
||||
@@ -38,14 +41,25 @@ otherwise.
|
||||
Host heading and only the remote path on each project row.
|
||||
- [x] **Cross-project conversation activity**: A shared activity entry lists
|
||||
running conversations and conversations needing attention across projects,
|
||||
including older conversations with live work. Open the associated
|
||||
conversation directly; project selection also shows activity counts.
|
||||
including older conversations with live work. Anchored project-to-conversation
|
||||
menus provide keyboard navigation and exact conversation opening without a
|
||||
full activity dialog. The summary hides when idle, long conversation submenus
|
||||
scroll independently, and project selection continues to show activity counts.
|
||||
- [x] **Compact conversation controls**: Composer options share a compact
|
||||
settings panel while Runtime, mode, sending, and queue controls remain
|
||||
available. Conversation search has an inline clear action that restores the
|
||||
scoped list and returns focus to the input.
|
||||
- [x] **File, screenshot, window, and clipboard context**: Added to model
|
||||
context only after explicit user selection.
|
||||
context only after explicit user selection. Paste one or more supported local
|
||||
files into the chat composer with `Ctrl+V` to add attachments, using the same
|
||||
parsing progress and limits as the attachment button. Text and screenshot
|
||||
paste remain available. See [attachment rules](./docs/features/document-processing/prd.md#321-聊天附件入口).
|
||||
- [x] **Text recognition in image-based PPTX files**: With local OCR configured
|
||||
and an OCR-enabled parsing workflow, embedded PNG, JPEG, and WebP images are
|
||||
recognized alongside native text, retaining slide locations. OCR releases
|
||||
model memory after 60 seconds without active or queued work and reloads on
|
||||
demand; fast text/index modes still skip OCR. See the
|
||||
[parsing behavior and real-file evidence](./docs/features/document-processing/chat-attachments-technical-design.md).
|
||||
- [x] **Per-file workspace diffs**: Changed files expose separate staged and
|
||||
unstaged diffs, including deleted, renamed, and untracked files. Refresh
|
||||
reloads the selected diff, browsed directory, and expanded directories; more than 50 changes
|
||||
@@ -74,7 +88,8 @@ otherwise.
|
||||
citation metadata. Full-conversation copy uses the same validated
|
||||
Preload/Main clipboard path.
|
||||
- [x] **Assistant workbar, multiple terminals, and resizable layouts**: The
|
||||
right workbar uses a persistent “+” capability catalog and application tabs.
|
||||
toggle sits beside the theme switch. The right workbar uses a persistent “+”
|
||||
capability catalog and application tabs.
|
||||
Task Center and Workspace are permanent singleton tabs; browser tabs are
|
||||
independent instances. Task Center can show the current project, global
|
||||
tasks, or all projects, and retains the selected scope with the layout.
|
||||
@@ -111,6 +126,17 @@ otherwise.
|
||||
|
||||
### Agent Runtimes and model connections
|
||||
|
||||
- [x] **Compact subagent history without lost details**: Local and managed SSH
|
||||
subagent events store changes instead of repeated full progress snapshots.
|
||||
The first upgrade automatically converts existing history and reclaims disk
|
||||
space, with visible progress and quit/retry support. Chats, execution details,
|
||||
results, and remote event deduplication are preserved; older clients cannot
|
||||
reopen the upgraded database. The schema-35 repair released in 0.13.2 also handles
|
||||
repeated tool blocks written after upgrading to 0.13.1, without deleting events.
|
||||
Terminal tasks release their write caches; duplicate remote replay does not
|
||||
rebuild a released cache. See
|
||||
[storage and upgrade behavior](./docs/features/assistant-workbar/execution-history-storage.md).
|
||||
|
||||
- [x] **Direct model Runtime**: Supports question answering, knowledge
|
||||
synthesis, controlled tool execution, image generation, and reference-image
|
||||
editing through providers supporting the OpenAI-compatible image-editing
|
||||
@@ -121,6 +147,22 @@ otherwise.
|
||||
take priority. Missing editing support does not block text-based generation:
|
||||
the affected reply shows a quiet footer explaining that reference images were
|
||||
not used. See [image generation](./docs/features/image-generation/README.md).
|
||||
- [x] **Image tools in the current conversation**:
|
||||
Enable conversation access for an image model to let tool-capable chat models
|
||||
generate images and edit uploads or earlier artifacts in Execute, without a
|
||||
separate conversation or manually switching to an image model. Direct text
|
||||
models, local OpenCode, Continue, DeepSeek Harness, and managed remote OpenCode
|
||||
use the shared service; Runtime assignment is derived from model settings.
|
||||
Requests may incur provider charges and editing requires provider support.
|
||||
See the [implementation and validation records](./docs/features/conversation-media-generation/progress.md).
|
||||
- [x] **Cross-project Runtime process reuse**: Compatible local OpenCode and
|
||||
DeepSeek Harness configurations share heavyweight processes; managed OpenCode
|
||||
sessions share a process on the same Host. Project adapters and session
|
||||
workspaces, models, tools, questions, and cancellation stay independently
|
||||
routed. Remote pending questions and task status recover after reconnect or
|
||||
Desktop restart without resending accepted prompts. Requires Desktop `0.13.5`
|
||||
and Agent `0.13.0` together for the new remote behavior. See the
|
||||
[design and measured validation](./docs/features/assistant-workbar/runtime-process-reuse-technical-design.md).
|
||||
- [x] **Direct model programming agent**: Local direct text models can run the
|
||||
platform Shell in Execute mode and delegate one level of programming
|
||||
Subagent work while inheriting the parent request's mode, model, workspace,
|
||||
@@ -141,7 +183,7 @@ otherwise.
|
||||
credential without starting a throwaway server; the explicit settings test,
|
||||
native inventory still performs full startup and health checks, then shares
|
||||
that execution Runtime with the first real request instead of cold-starting
|
||||
another server. Execution Runtimes remain reusable per project, while local
|
||||
another server. Execution Runtime adapters remain reusable per project, while local
|
||||
OpenCode configuration dependencies and content-addressed Skill snapshots are
|
||||
shared globally across projects. Session data, tool output, and request-scoped
|
||||
MCP state remain isolated. GoodBuddy-managed local OpenCode keeps
|
||||
@@ -215,7 +257,7 @@ otherwise.
|
||||
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
|
||||
database. Successful tool START/END events appear exactly once, with no
|
||||
Prompt, provider, or tool replay observed. The current Agent source lock is
|
||||
`0.11.24`, while the current Desktop release candidate is `0.13.0`; formal
|
||||
`0.13.0`, while the current Desktop release candidate is `0.13.5`; formal
|
||||
publication status follows the separate Agent and Desktop
|
||||
release channels. Previous macOS validation covered native package installation,
|
||||
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
|
||||
@@ -251,7 +293,7 @@ otherwise.
|
||||
demand and run a fixed `attach-or-bootstrap`; registered health,
|
||||
capabilities, and prompt startup do not scan the full payload. See the
|
||||
[design](./docs/features/remote-host/environment-provisioning-technical-design.md).
|
||||
- [ ] **Real-Host acceptance for SSH Host environment provisioning**: The
|
||||
- **SSH Host environment provisioning validation records**: The
|
||||
current source passed isolated Linux x64 package installation, Ask/Execute,
|
||||
native subagent external writes, reconnection, and stop/bootstrap.
|
||||
The complete Host-card acquisition matrix across GitHub, Beijing mirror,
|
||||
@@ -269,7 +311,9 @@ otherwise.
|
||||
permissions. Image input follows the selected model connection's declared
|
||||
capability: text models reject images before Host or model invocation, while
|
||||
image-capable models receive bounded inline JPEG/PNG content through a
|
||||
temporary Attachment Store.
|
||||
temporary Attachment Store. On Windows, Host startup and ACP sessions share
|
||||
one canonical workspace path, so equivalent path spellings reuse the same
|
||||
working Runtime instead of failing session creation.
|
||||
- [x] **DSH npm plugin marketplace**: Disabled by default and searches public
|
||||
npm `dsh-plugin` packages only after explicit user enablement. It uses the
|
||||
bundled npm to install exact versions with ordinary lifecycle scripts and
|
||||
@@ -444,7 +488,7 @@ otherwise.
|
||||
installation validation on Windows x64. Managed Python archive validation
|
||||
follows the target filesystem: Linux preserves valid case-distinct paths,
|
||||
while every target still rejects exact duplicates and unsafe entries.
|
||||
- [ ] **Local tool-execution environment release acceptance**: All six
|
||||
- **Local tool-execution environment validation records**: All six
|
||||
platform/architecture OSS mirror objects have passed public byte, size, and
|
||||
SHA-256 verification. Managed Python remains an on-demand download and does
|
||||
not add license files to the Desktop package. Each standard package job uses
|
||||
@@ -494,7 +538,7 @@ otherwise.
|
||||
index, or modify remote content. Bounded cited snippets are retained locally
|
||||
with their conversations. See the
|
||||
[external knowledge-base PRD](./docs/features/knowledge-base/external-knowledge-prd.md).
|
||||
- [ ] **External knowledge-base acceptance**: All three providers have passed
|
||||
- **External knowledge-base validation records**: All three providers have passed
|
||||
real service, local HTTP MCP, and production-IPC short-answer checks; Dify
|
||||
additionally passed the complete App composer-to-answer-and-citation path.
|
||||
FastGPT/RAGFlow desktop-specific parameters, complex multi-library answers,
|
||||
@@ -527,7 +571,8 @@ otherwise.
|
||||
showing the previous item's source after a selection change. Failed reads
|
||||
use application notifications, and missing sources have an explicit fallback. The left
|
||||
navigation can show the incomplete-todo count, and create, save, and comment
|
||||
results use application-wide notifications.
|
||||
results use application-wide notifications. Agent/MCP note writes automatically
|
||||
refresh the open workbench while preserving selection and unsaved drafts.
|
||||
- [ ] **MCP Server Control Plane** (planned): Unified MCP lifecycle, health
|
||||
checks, reconnection, schema cache, isolation, approval, and audit.
|
||||
- [ ] **Traceable note excerpts and AI editing** (planned): Collects sourced
|
||||
@@ -551,6 +596,9 @@ otherwise.
|
||||
- [x] **Compact conversation tool records**: Expand individual tool records
|
||||
to read or copy results, errors, and input parameters. Conversation and
|
||||
child-task progress use concise rows without repeated Runtime summaries.
|
||||
OpenCode, Continue, DeepSeek Harness, and direct models show available file
|
||||
paths, commands, or search summaries and retain them through completion and
|
||||
conversation reloads.
|
||||
- [x] **Task and custom-task experience**: Each product-level Task belongs to
|
||||
one Conversation, while one Conversation can contain multiple Tasks. The
|
||||
left conversation list exposes Task children through a leading expand
|
||||
@@ -654,6 +702,9 @@ otherwise.
|
||||
(default) or the mirror. Manual checks, startup checks, and the download page
|
||||
use the same choice and read only fixed trusted release indexes; GoodBuddy
|
||||
does not automatically download or install updates.
|
||||
- [x] **Window recovery controls**: Rendering errors show a localized Reload
|
||||
action and renderer crashes offer a native recovery confirmation. Reload may
|
||||
lose unsaved input. See [window recovery](./docs/development/window-recovery.md).
|
||||
- [x] **In-app feedback**: About and Updates can submit problems, suggestions,
|
||||
or experience feedback with an optional email and one screenshot.
|
||||
Diagnostics are not uploaded by default; users can explicitly attach a
|
||||
@@ -668,15 +719,26 @@ otherwise.
|
||||
|
||||
### Open source, builds, and releases
|
||||
|
||||
- Current source candidates are Desktop `0.13.0` and Agent `0.11.24`, with
|
||||
- Current source candidates are Desktop `0.13.5` and Agent `0.13.0`, with
|
||||
OpenCode pinned to `1.18.29`. Publication status follows the independent
|
||||
Desktop and Agent release channels.
|
||||
- Candidate acceptance remains separate from version preparation. The current
|
||||
remote-question source has passed real Linux x64 SSH and Electron question
|
||||
tests with a deterministic loopback model, not the complete production
|
||||
Main/Preload path with a real provider. That real-model path and candidate
|
||||
native CI/package validation remain outstanding; see the
|
||||
[remote-question evidence](./docs/features/assistant-workbar/progress.md).
|
||||
- Agent `0.13.0` adds remote image-tool integration and shared Runtime processes
|
||||
with conversation recovery fixes. Its packages require Desktop `0.13.5`;
|
||||
update Desktop first, then the Host environment. Node remains `24.19.0`.
|
||||
- Validation records remain separate from implementation status. Current-source
|
||||
storage validation covers real local and Linux x64 Host tool workloads,
|
||||
concurrent projects/conversations, cancellation, and lossless database migration.
|
||||
Runtime process reuse has real Windows full-App and Linux x64 Host evidence;
|
||||
PPTX OCR and idle-memory release have real Windows file evidence. Image tools
|
||||
have real provider generation/editing and Host transport evidence. Full
|
||||
UI-driven natural-language invocation and local/remote switching are not
|
||||
covered by those records. The candidate still requires main CI and
|
||||
native release packaging. See the [Runtime evidence](./docs/features/assistant-workbar/progress.md)
|
||||
and [image-tool evidence](./docs/features/conversation-media-generation/progress.md).
|
||||
- Candidate checks on 2026-09-15 passed: bilingual release notes, `npm test`
|
||||
(4,293 passed, 67 skipped), `npm run typecheck`, and `npm run lint`.
|
||||
This metadata/documentation preparation made no new real model calls and did
|
||||
not rerun Host scenarios or build local production packages.
|
||||
- [x] **0BSD open-source license**: Original code can be freely used, copied,
|
||||
modified, distributed, and commercialized. Third-party components and
|
||||
resources retain their own licenses.
|
||||
@@ -707,7 +769,7 @@ otherwise.
|
||||
artifact API with scoped, expiring, rate-limited, and revocable tokens.
|
||||
- [ ] **GoodBuddy Team Hub** (planned): An optional service for organizations,
|
||||
RBAC, project sharing, remote Agents, policy distribution, and tenant audit.
|
||||
- [ ] **SSH Host and remote execution-space release acceptance**: Host CRUD,
|
||||
- **SSH Host and remote execution-space validation records**: Host CRUD,
|
||||
Host Key, encrypted credentials, Project UI, Workspace, OpenCode ACP v5,
|
||||
Agent-owned Prompt/gateway/transcript, read-only Ask, full-account Execute,
|
||||
cancellation, exact detached-Agent reconnection, and release-only
|
||||
|
||||
+25
-15
@@ -6,25 +6,28 @@
|
||||
|
||||
## 状态说明
|
||||
|
||||
- [x] 已提供
|
||||
- [ ] 开发中或规划中,具体以条目标注为准
|
||||
- [x] 已完成
|
||||
- [ ] 待实现
|
||||
|
||||
状态表示功能实现情况,不等同于版本已发布或全部测试已执行。验证记录单独列出,不作为第三种功能状态。
|
||||
|
||||
## 功能总表
|
||||
|
||||
### 桌面基础、工作空间与上下文
|
||||
|
||||
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
|
||||
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.0` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
|
||||
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.5` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
|
||||
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
|
||||
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
|
||||
- [x] **跨项目会话活动**:统一入口汇总各项目正在运行和需要处理的对话,保留仍有活动的较早会话,可直接打开对应对话;项目选择器也显示活动数量。
|
||||
- [x] **跨项目会话活动**:统一入口汇总各项目正在运行和需要处理的对话,保留仍有活动的较早会话。锚定的“项目 → 会话”菜单支持键盘导航和精确打开对话,不再使用整块活动弹窗;空闲时隐藏摘要,长会话子菜单独立滚动,项目选择器继续显示活动数量。
|
||||
- [x] **紧凑会话控件**:输入选项集中在紧凑设置面板,Runtime、工作模式、发送和队列操作保持可用;会话搜索提供内嵌清除按钮,清除后恢复当前范围列表并把焦点返回输入框。
|
||||
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
|
||||
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。聊天输入框支持 `Ctrl+V` 粘贴一个或多个本地支持文件并自动添加为附件,沿用附件按钮的解析进度与限制;普通文本和截图仍可直接粘贴。详见[聊天附件规则](./docs/features/document-processing/prd.md#321-聊天附件入口)。
|
||||
- [x] **图片型 PPTX 文字识别**:配置本地 OCR 并选择启用 OCR 的解析方式后,可识别内嵌 PNG、JPEG 和 WebP 图片,保留原生文字与幻灯片定位。OCR 在没有活动或排队任务时空闲 60 秒自动释放模型内存,下次按需重载;快速文本/索引模式仍跳过 OCR。详见[解析行为与真实文件验证](./docs/features/document-processing/chat-attachments-technical-design.md)。
|
||||
- [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异、当前浏览目录和已展开目录,超过 50 项变更可继续加载。
|
||||
- [x] **工作区文件与 Git 管理**:支持文件和目录的新建、重命名、移动、删除、属性查看,以及本地/远程分支搜索与切换、新建分支、显式 Fetch、提交历史和单文件提交差异。工具按文件/Git 视图分组,分支搜索使用统一紧凑表单,子目录保留文字导航而根目录不再显示孤立图标。提交历史贴底,首次展开占 Git 内容区一半,支持调整比例、独立滚动和统一刷新;切换项目自动检测 Git,不沿用其他项目状态。移动限于工作区内,删除须确认,分支冲突不自动 stash 或丢弃改动;托管 SSH 操作需要 Agent `0.11.23`。
|
||||
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
|
||||
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
|
||||
- [x] **助手工作栏、多终端与可调布局**:右侧工作栏使用持久“+”能力目录和应用 Tab;任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目、全局任务和全部项目,范围随布局保存。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
|
||||
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目、全局任务和全部项目,范围随布局保存。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
|
||||
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
|
||||
- [ ] **应用导航与系统工具快捷入口**(仅设计):规划固定系统入口、内置应用显隐和打开现有工作栏的底部工具按钮;尚未实现新界面。详见[设计文档](./docs/features/application-tool-navigation/README.md)。
|
||||
- [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入监督、统一 Runtime、受管进程、静态安全 HTML 预览、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。Task Center 保持 Task 的单例索引,附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/features/assistant-workbar/prd.md)。
|
||||
@@ -32,17 +35,21 @@
|
||||
|
||||
### Agent Runtime 与模型连接
|
||||
|
||||
- [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。已随 0.13.2 发布的 schema 35 补修还会整理升级到 0.13.1 后重复写入的工具块,不删除事件;终态及时释放写入缓存,远程重复重放不重建已释放的缓存。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。
|
||||
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。连续请求自动复用最近一次成功生成的图片和文字历史,已保存会话重开后仍可继续修改,本轮显式附图优先;上游明确不支持编辑时继续按文字生成,仅在回复底部提示未使用参考图。编辑使用 multipart 上传图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。详见[图片生成与连续修改](./docs/features/image-generation/README.md)。
|
||||
- [x] **当前会话图片工具**:为图片模型开启会话调用后,支持工具调用的聊天模型可在 Execute 中生成图片、修改上传图片或历史成果,无需另开会话或手动切换图片模型。直连文本模型、本地 OpenCode、Continue、DeepSeek Harness 和托管远程 OpenCode 复用统一服务,Runtime 分配由模型设置派生;图片请求可能产生费用,编辑需服务商支持。详见[实现与验证记录](./docs/features/conversation-media-generation/progress.md)。
|
||||
- [x] **跨项目 Runtime 进程复用**:兼容配置的本地 OpenCode 和 DeepSeek Harness 共享重型进程,托管 OpenCode 会话在同一 Host 上复用进程。项目适配器及会话的工作区、模型、工具、问答和取消仍独立路由;远程待答问题和任务状态可在重连或桌面重启后恢复,不重发已接受请求。新的远程行为需要 Desktop `0.13.5` 与 Agent `0.13.0` 配套更新,详见[设计与实测](./docs/features/assistant-workbar/runtime-process-reuse-technical-design.md)。
|
||||
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
|
||||
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 紧凑地发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 可以搜索和读取,但不能应用补丁或运行命令;用户无需另行安装 ripgrep。可处理的读取或搜索错误允许模型调整参数后继续,部分搜索结果保留并明确提示覆盖不完整。
|
||||
- [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
|
||||
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node 和 OpenCode 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。macOS 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证;这不等于新版本已经发布。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
|
||||
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
|
||||
- [ ] **SSH Host 环境准备真实 Host 验收**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵仍待验收,候选 CI/原生打包与系统休眠唤醒也尚未验证;部分开发证据不代表已经发布。
|
||||
- **SSH Host 环境准备验证记录**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。现有记录未覆盖 Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵,候选 CI/原生打包与系统休眠唤醒也尚未验证;开发记录不代表版本已经发布。
|
||||
- [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。
|
||||
- [x] **远程原生问答与取消修复**:Agent `0.11.24` 修复远程原生问答转交和取消待答后同一会话续发,需要先升级 Desktop 至 `0.13.0`。
|
||||
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择,设置页显示实际管理员或回退模型来源。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
|
||||
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择,设置页显示实际管理员或回退模型来源。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。Windows Host 启动和 ACP 会话共用规范工作区路径,同目录不同写法可复用正常 Runtime,不再因路径不一致而创建会话失败。
|
||||
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
|
||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权,包括工作区外路径及原生子代理工作。
|
||||
- [x] **Runtime 原生交互转交**:OpenCode 与 Continue 的选择、yes/no、自由文本回答和跳过使用现有问答卡片,本机与托管 SSH OpenCode 同时转交属于当前请求的子会话提问。托管 SSH 需要 Desktop `0.13.0`、Agent `0.11.24` 和新启动的托管 Runtime,不扩展到任意 ACP 服务。回答或跳过成功后保留原问题与答案,支持多轮记录和本地会话重载;旧版本已丢弃的答案不能恢复。并行问题按顺序等待回答,重复事件保留草稿,提交失败可重试;取消远程待答后可在同一会话继续发送。Execute 权限确认自动处理,不等待第二次审批;具体支持范围见[交互边界](./docs/features/assistant-workbar/runtime-interactions.md)。
|
||||
@@ -68,13 +75,13 @@
|
||||
|
||||
- [x] **Skills 按需接入**:可分配给直连模型、OpenCode、Continue 和 DeepSeek Harness,并使用有界资源和受控 Runtime 边界。
|
||||
- [x] **本机工具执行环境源码链路**:在“能力与工具 > 工具执行环境”中为本机 Skills 与 stdio MCP 选择 GoodBuddy 托管 Node.js、按需安装的托管 Python,或经过真实验证的自定义解释器;提供独立的原生地址/OSS 镜像选择、诊断、安装进度、取消和删除。新的本机 Runtime 与 stdio MCP 获得不可变 PATH 快照,不修改普通终端、系统环境或远程 Host。Windows x64 托管 Node 与原生地址 Python 已通过真实安装验证;托管 Python 归档按目标文件系统验证,Linux 保留大小写不同的合法路径,所有目标仍拒绝完全重复路径和不安全条目。
|
||||
- [ ] **本机工具执行环境发布验收**:六个平台/架构的 OSS 镜像对象已完成字节、大小和 SHA-256 公开回读验证。托管 Python 保持按需下载,不向 Desktop 发行包额外带入许可证文件。每个标准打包任务使用目标架构原生 Runner,并在打包前真实安装托管 Python,验证 SSL、pip 与 venv。不可变的 `v0.12.0` 尝试在发布前通过 Windows 与 macOS,但暴露了 Linux TAR 大小写处理问题;`v0.12.1` 在同步 Agent 发布时于原生打包前取消。已发布的 `v0.12.2` 随后通过全部六个原生打包任务及其托管 Python 安装探针。真实 Skill/MCP、自定义解释器和运行中进程协调仍是待完成验收。
|
||||
- **本机工具执行环境验证记录**:六个平台/架构的 OSS 镜像对象已完成字节、大小和 SHA-256 公开回读验证。托管 Python 保持按需下载,不向 Desktop 发行包额外带入许可证文件。每个标准打包任务使用目标架构原生 Runner,并在打包前真实安装托管 Python,验证 SSL、pip 与 venv。不可变的 `v0.12.0` 尝试在发布前通过 Windows 与 macOS,但暴露了 Linux TAR 大小写处理问题;`v0.12.1` 在同步 Agent 发布时于原生打包前取消。已发布的 `v0.12.2` 随后通过全部六个原生打包任务及其托管 Python 安装探针。现有记录未覆盖真实 Skill/MCP、自定义解释器和运行中进程协调。
|
||||
- [x] **内置 MCP 按需接入**:知识库、魔法笔记、GoodBuddy 配置与内置浏览器 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue;DeepSeek Harness 在设置中明确显示为暂不支持。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。
|
||||
- [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent Execute 和 DeepSeek Harness,并仅在 Execute 加载;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍经过发现、执行记录与权限边界。直连模型与 Harness 的健康调用复用当前请求已发现的工具清单,不在每次调用前重复发现。
|
||||
- [x] **MCP Prompts 与 Resources 元数据**:MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。
|
||||
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。知识范围按对话保存,新对话默认不选择知识库;创建与检索高级参数默认折叠。文档区分可用、处理中和失败,支持打开来源、失败重试及移除来源前确认。
|
||||
- [x] **外部知识库接入**:在知识库页面管理 Dify、FastGPT 和 RAGFlow 实例,通过目录或手工 ID 验证并绑定远端知识库,保留 Provider 专属检索配置和引用;对话支持按需检索及每次回答前检索。外部系统只负责知识检索,不接入其 App、Chat、Workflow 或 Agent,也不批量同步、本地索引或修改远端内容。被回答引用的有界检索片段随会话保存在本机。详见[外部知识库接入 PRD](./docs/features/knowledge-base/external-knowledge-prd.md)。
|
||||
- [ ] **外部知识库验收**:三家 Provider 已通过真实服务、本机 HTTP MCP 和生产 IPC 短答案检查,Dify 另通过完整 App 输入区到回答及引用的链路。FastGPT/RAGFlow 桌面专属参数、复杂多库问答和远程回答前检索仍待验证,详见[验证证据与剩余工作](./docs/features/knowledge-base/progress.md)。
|
||||
- **外部知识库验证记录**:三家 Provider 已通过真实服务、本机 HTTP MCP 和生产 IPC 短答案检查,Dify 另通过完整 App 输入区到回答及引用的链路。现有记录未覆盖 FastGPT/RAGFlow 桌面专属参数、复杂多库问答和远程回答前检索,详见[验证记录](./docs/features/knowledge-base/progress.md)。
|
||||
- [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。
|
||||
- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。
|
||||
- [x] **向量诊断与索引任务**:提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索。
|
||||
@@ -82,14 +89,14 @@
|
||||
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
|
||||
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
|
||||
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。Agent / MCP 写入后,已打开的工作台自动更新,并保留当前选择与未保存草稿。
|
||||
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
|
||||
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
|
||||
|
||||
### 工作管理、长期协作与工作流
|
||||
|
||||
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
|
||||
- [x] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 Runtime 摘要。
|
||||
- [x] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 Runtime 摘要。OpenCode、Continue、DeepSeek Harness 和直连模型可显示已有文件路径、命令或搜索摘要,并在工具完成和会话重载后保留。
|
||||
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
|
||||
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
|
||||
- [x] **定时消息沿用会话**:定时消息使用会话当前历史、Runtime、工作模式和已保存的知识检索设置;任务详情显示实际运行模式,当前 occurrence 未结束时禁止重复“立即运行”。
|
||||
@@ -114,13 +121,16 @@
|
||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。
|
||||
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
||||
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
|
||||
- [x] **窗口恢复入口**:渲染失败时显示本地化的重新加载页面,渲染进程崩溃时提供原生恢复确认;重新加载可能丢失未保存输入。详见[窗口恢复说明](./docs/development/window-recovery.md)。
|
||||
- [x] **应用内反馈**:在“关于与更新”中直接提交问题、建议或体验反馈,支持可选邮箱和单张截图;默认不上传诊断,用户可主动附加有界的最近桌面诊断摘要。失败后保留草稿、诊断选择和同一请求编号;不会附加对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。
|
||||
- [x] **内网兼容**:允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书;反馈、微信凭据与媒体等固定平台服务仍保持严格证书校验。
|
||||
|
||||
### 开源、构建与发布
|
||||
|
||||
- 当前源码候选为 Desktop `0.13.0`、Agent `0.11.24`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
|
||||
- 候选验收与版本准备分别记录。当前远程问答源码已通过真实 Linux x64 SSH 与 Electron 问答测试,但模型为确定性 loopback 服务,不是完整生产 Main/Preload 配合真实服务商;该真实模型链路和候选原生 CI/打包验证仍待完成,详见[远程问答证据](./docs/features/assistant-workbar/progress.md)。
|
||||
- 当前源码候选为 Desktop `0.13.5`、Agent `0.13.0`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
|
||||
- Agent `0.13.0` 接入远程图片工具、共享 Runtime 进程并修复会话恢复,复合包要求 Desktop `0.13.5`;请先升级 Desktop,再更新 Host 环境。Node 保持 `24.19.0`。
|
||||
- 验证记录与功能状态分开。存储已有本地及 Linux x64 Host 真实工具任务、多项目多会话、取消和无损迁移验证;进程复用有 Windows 完整 App 与 Linux x64 Host 证据,PPTX OCR 与空闲内存释放有 Windows 真实文件证据。图片工具已有真实服务商生成/编辑及 Host 传输证据,这些记录未覆盖完整界面自然语言调用和本地/远程切换。候选仍需主分支 CI 及原生发布打包,详见[Runtime 验证记录](./docs/features/assistant-workbar/progress.md)与[图片工具验证记录](./docs/features/conversation-media-generation/progress.md)。
|
||||
- 2026-09-15 候选检查通过:双语更新说明、`npm test`(4,293 项通过、67 项跳过)、`npm run typecheck`、`npm run lint`。本次版本元数据与文档准备新增真实模型调用为 0,未重跑 Host 场景或进行本地生产打包。
|
||||
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
|
||||
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
|
||||
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希。Windows 提供 NSIS 与 portable ZIP,macOS 仅 DMG,Linux 提供 AppImage、DEB 和 RPM,共 12 个安装包、20 个发布资产。
|
||||
@@ -132,7 +142,7 @@
|
||||
- [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。
|
||||
- [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。
|
||||
- [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。
|
||||
- [ ] **SSH 主机与远程执行空间发布验收**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v5、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验已经接线。Linux x64 当前源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;当前源码测试签名 Agent `0.11.17` 又通过两个并发 Ask 读取、一次 Execute 写入并读回,以及 6 次带自定义 Header 和 Body 的真实受管网关模型请求,未发现 Provider 请求重放,隔离 Agent state 中也没有 API Key 或自定义请求值。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包,但仍需公开核对当前 Linux x64/arm64 正式签名工件矩阵,并完成 GitHub/北京镜像、双架构和离线 GoodBuddy 传输的发布验收;门槛未满足时不宣称该路径已发布或通过正式发布验收。
|
||||
- **SSH 主机与远程执行空间验证记录**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v5、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验已经接线。Linux x64 当前源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;当前源码测试签名 Agent `0.11.17` 又通过两个并发 Ask 读取、一次 Execute 写入并读回,以及 6 次带自定义 Header 和 Body 的真实受管网关模型请求,未发现 Provider 请求重放,隔离 Agent state 中也没有 API Key 或自定义请求值。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包;现有记录未覆盖当前 Linux x64/arm64 正式签名工件的完整公开核对及 GitHub/北京镜像、双架构和离线 GoodBuddy 传输矩阵,发布结果另行记录。
|
||||
- [ ] **多云远程沙盒 Agent**(规划中):通过云厂商 API 和 SSH Agent 管理专用 Linux 沙盒;凭据留在 Main 进程,高风险控制面操作单独确认。
|
||||
|
||||
## 规划原则
|
||||
|
||||
@@ -156,7 +156,8 @@ npm run lint
|
||||
|
||||
Scan the QR code to join the GoodBuddy WeChat group:
|
||||
|
||||
<img width="1250" height="1194" alt="9627ce196b0de42f123c33dd9dea1568" src="https://github.com/user-attachments/assets/e806543f-6d8a-4c5c-ba44-19e74dac9a78" />
|
||||
<img width="1250" height="1743" alt="46b5c3b5936388b0a55db6fe034a7d7f" src="https://github.com/user-attachments/assets/45cd1736-f1ae-4f99-85be-cdb2cc1160c0" />
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -128,7 +128,8 @@ npm run lint
|
||||
|
||||
扫描二维码加入 GoodBuddy 微信群:
|
||||
|
||||
<img width="1250" height="1194" alt="9627ce196b0de42f123c33dd9dea1568" src="https://github.com/user-attachments/assets/ff87dfac-d72f-47b7-ac9a-c88b0b4c96d9" />
|
||||
<img width="1250" height="1743" alt="46b5c3b5936388b0a55db6fe034a7d7f" src="https://github.com/user-attachments/assets/cc094468-2885-447a-afb4-91c7ac117fd3" />
|
||||
|
||||
|
||||
|
||||
## 开源许可
|
||||
|
||||
+17
-6
@@ -210,6 +210,8 @@
|
||||
- 选项数量为 2 至 4 个。
|
||||
- 使用带 `aria-pressed` 的按钮组语义,支持方向键切换。
|
||||
- 不能用于多选筛选、页面导航或执行即时命令。
|
||||
- 与 `PageTabs` 一样,共享控件根节点必须 `flex: 0 0 auto`,不能被同级长表单或滚动区域
|
||||
压缩高度。横向空间不足时保留控件自身滚动,不把标签裁成一条窄缝。
|
||||
- 控件宽度由内容决定,除移动窄屏外不默认等分整行。
|
||||
|
||||
### 6.3 筛选工具栏
|
||||
@@ -283,7 +285,7 @@
|
||||
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。
|
||||
- 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。
|
||||
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
|
||||
- 项目选择器下方承载跨项目活动汇总,空闲时隐藏;项目计数与活动对话框遵守
|
||||
- 项目选择器下方常驻跨项目活动汇总,空闲时显示“暂无活动”;项目计数与项目、会话级联菜单遵守
|
||||
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
|
||||
活动数量不改变项目菜单的稳定分组和顺序。
|
||||
- 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整,
|
||||
@@ -371,7 +373,7 @@
|
||||
遮罩或隔离其内容。分栏计算排除最左侧主导航,主工作区与工作栏采用相同的 `300px`
|
||||
最小宽度;拖动保存两者在可用分栏宽度中的比例,任一侧到达最小值后都不得继续侵占。
|
||||
所有可容纳两侧最小值的尺寸均支持指针和键盘调整,并须保留全部栏目入口。
|
||||
- 工作栏开关固定在窗口关闭按钮正下方,同一个按钮负责展开与收起并持续暴露状态;图标中的
|
||||
- 工作栏开关固定在顶栏 `.topbar__actions` 内、紧邻浅色/深色主题按钮之前,同一个按钮负责展开与收起并持续暴露状态;图标中的
|
||||
箭头方向必须随当前动作切换,收起状态提示展开,展开状态提示收起。按钮保持普通图标按钮
|
||||
视觉,不通过持续高亮表达展开状态。工作栏内部不再重复关闭按钮;当前会话没有 Task 时,
|
||||
不得为了放置该开关而保留一条空上下文栏。
|
||||
@@ -395,6 +397,10 @@
|
||||
顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。
|
||||
- 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,避免页面层叠上下文影响;
|
||||
保留 React Context、关闭与焦点管理,表单样式不得依赖已移出的页面祖先选择器。
|
||||
- 原生浏览器 `WebContentsView` 不受网页 `z-index` 控制。应用级 Modal 打开时统一释放
|
||||
浏览器 viewport;非模态对话框、菜单、局部确认或应用通知与浏览器区域相交时也释放。
|
||||
浮层关闭后只恢复当前活动实例的 viewport,不关闭、重建或刷新网页。接线与测试见
|
||||
[浏览器多实例技术设计](./docs/features/assistant-workbar/browser-tabs-technical-design.md#4-renderer-与-viewport)。
|
||||
- 遮罩必须 `inset: 0` 铺满整窗,并由 `styles.css` 中统一的遮罩选择器声明
|
||||
`-webkit-app-region: no-drag`,否则 `.brand` 与 `.topbar` 的原生拖动区域会截获指针,
|
||||
使遮罩上的控件无法悬停和点击。新增整窗遮罩时扩展该共享选择器,不要在各自规则里复制
|
||||
@@ -409,7 +415,7 @@
|
||||
不再重复显示状态文字;完整状态详情继续通过可访问说明和工具提示提供。
|
||||
- 浅色与深色切换属于持续可用的窗口级操作,直接显示太阳或月亮图标,并通过可访问名称说明将切换到的主题。选择必须持久化,切换不得改变布局。
|
||||
- 顶栏只直接显示当前任务所需的高频操作。已有侧栏账户设置入口时,不再重复显示 Runtime/设置入口;使用帮助优先放在相关操作附近,而不是为单个帮助项创建“更多”菜单。
|
||||
- 助手工作栏开关固定在上下文栏最右侧并与窗口关闭按钮纵向对齐;展开后该开关保持原位并
|
||||
- 助手工作栏开关固定在顶栏 `.topbar__actions` 内、紧邻浅色/深色主题按钮之前;展开后该开关保持原位并
|
||||
负责收起,不在工作栏标题区域增加第二个关闭入口。
|
||||
- 只有存在至少两个无法由稳定入口承载的低频全局操作时才增加全局菜单,不为了容纳一个冗余入口而显示省略号按钮。
|
||||
- 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。
|
||||
@@ -623,15 +629,17 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- OpenCode 原生 Task 调用在本地项目和托管 SSH 项目中统一解析为子 Agent 状态卡,不显示为普通工具卡。状态卡持续显示 Agent 类型、任务说明、运行状态、完成输出或失败原因;身份徽标、Agent 名称和状态位于首层,任务说明作为主信息,Runtime 来源作为次级元数据,输出与错误在同一卡片内按需展开。父 Agent 的推理不冒充子 Agent 过程,必须与状态卡保持明确间距。状态不能只靠颜色表达。推理、子 Agent、工具和正文统一按事件首次出现顺序进入消息块;增量更新只更新原位置,Task 临时工具块重分类后必须原位替换,不能把子 Agent 卡统一提升到消息顶部。同一调用的增量事件按稳定调用 ID 更新,不能因 ACP 首帧尚无参数而永久保留重复工具卡或活动记录。一次请求内已观察到的子 Agent 均保留在消息与活动记录中;父请求取消、失败或意外中断时仍处于活动状态的卡片必须结算为对应终态,父请求成功结束但子 Agent 未报告终态时必须显示为失败而不是推断为完成。
|
||||
- Agent 回复进行中锁定模型、专家角色、工作模式和 Runtime 定制选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。
|
||||
- 普通工具在主会话和子 Agent 过程中使用同一轻量折叠记录:不显示外层“工具执行”标题、计数、背景和边框,保留无障碍区域名称。记录纵向左对齐,头部按内容收宽且不超过消息宽度;展开箭头在左,工具名作为主标题,有额外信息的单行摘要弱化显示。右侧以绿色对勾显示成功完成,以图标显示运行和待执行状态,提供悬停文字和无障碍名称;失败和可恢复状态保留文字。仅重复工具名和执行状态的已知 Runtime 模板摘要在头部与详情中均隐藏;刷新重试、审批等额外提示保留。默认折叠,状态和输出更新不改变用户的展开选择;折叠时露出一行错误,展开详情使用消息可用宽度、浅背景和左侧细线,按错误、结果、独立折叠的输入参数阅读。详情保留原始文本,完整 JSON 输入可缩进显示,复制仍使用收到的原文,并通过应用通知反馈。详情工具栏只保留复制按钮,文本默认自动换行,长内容在固定最大高度内滚动,不提供换行切换和展开全部按钮。
|
||||
- OpenCode 主会话和子 Agent 的工具记录均保留 Runtime 提供的操作标题作为摘要,显示文件路径、命令或操作描述;本地 SDK 标题去除首尾空白后最多保留 240 字符,没有标题时沿用工具名回退规则。主会话工具失败或中断时保留已收到的标题;远端 ACP 继续在记录名称中显示其提供的标题。
|
||||
- 工具取消后保留“已取消”文字,避免与失败图标混淆。
|
||||
- Continue、DeepSeek Harness 和直连模型的工具名后显示单行操作摘要,内容提取、长度与完成态保留规则见[工具操作摘要](./docs/features/assistant-workbar/runtime-interactions.md#工具操作摘要)。
|
||||
- Agent 回复进行中不能禁用普通消息发送。新消息与到期 Scheduled Task 共用 Conversation 级待发送队列。队列位于 Composer 容器之外、与输入框等宽并保持独立间距;有待发送项时直接显示无标题栏的极简列表,空队列不保留占位。每项固定为一行,按进入顺序显示轻量来源图标和截断摘要;普通消息使用消息图标,Scheduled Task 统一使用淡出时钟图标。
|
||||
- 待发送项默认在当前回复结束后顺序执行。每项提供明确的“立即中断并插入”(空闲时为“立即运行”)和删除操作;前者先取消当前 Conversation 的活动执行,再将所选项提升为下一项,不允许同一 Conversation 并发写入时间线。
|
||||
- 队列区域向上增长并设置有界滚动高度,不提供展开、收起或冗余计数标题。宽容器中的操作采用弱化的行内样式,不渲染成强调卡片或胶囊;窄容器中保留来源、摘要和图标操作并隐藏冗长按钮文字。区域、来源和每个操作必须有可读无障碍名称。队列异步错误进入应用通知,不在 Composer 内复制通知样式。
|
||||
- 支持上下文状态的 Runtime 在输入区下方复用同一紧凑用量条;文案必须区分“本次模型调用”和“压缩后对话估算”。手动压缩仅在当前 Runtime 明确支持且没有活动回复时显示,作为元信息区左下角的浮动次操作,不参与输入区高度计算;元信息区始终预留稳定高度,切换 Runtime 不得让输入框上下位移。元信息区与窗口底部只保留紧凑安全留白,不形成额外空白区。进行中禁用重复操作,结果通过应用通知反馈。
|
||||
- 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。
|
||||
- 宽度大于 `700px` 时,主工具栏的添加内容、Runtime、模式、会话选项与发送保持同一行。窄输入区按容器宽度换行,保留发送及停止入口;选项浮层不得超出输入区宽度,不再常驻独立的 Runtime 专属功能行。
|
||||
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,图片转换为本次消息附件。文件选择由上传按钮承担,不再提供独立“读取剪贴板”按钮;默认工具栏也不提供“截取当前屏幕”和“选择应用窗口”入口,避免与系统粘贴、文件选择和后续工具执行重复。
|
||||
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
|
||||
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,系统截图和从文件管理器复制的一个或多个支持文件加入本次消息附件。文件粘贴与上传按钮共用读取、解析进度和数量限制;读取期间阻止重复导入及发送,错误就地关联输入框和附件按钮。具体类型、大小及批次失败规则见[聊天附件要求](./docs/features/document-processing/prd.md#321-聊天附件入口)。默认工具栏不提供独立“读取剪贴板”“截取当前屏幕”和“选择应用窗口”入口。
|
||||
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴文件、图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
|
||||
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
|
||||
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
|
||||
- 消息流的滚动范围必须使用已渲染消息的真实高度。长会话可以按批次只挂载最近消息并由用户
|
||||
@@ -707,6 +715,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
||||
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
||||
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
||||
- Agent / MCP 或其他入口成功写入笔记后,已打开的工作台自动更新笔记列表、当前详情、待办及来源记录,不增加刷新按钮。保留仍存在的笔记与待办选择、搜索和筛选,以及未保存的标题、输入区和记录编辑草稿;普通后台刷新不重建编辑器。
|
||||
- 外部删除当前笔记时,无草稿则选择剩余笔记;有草稿则保留编辑区并提示先复制草稿。外部删除正在编辑的记录时,同样保留该编辑器并明确提示记录已删除;保存失败不得清空草稿。
|
||||
- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
|
||||
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
|
||||
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
|
||||
@@ -740,6 +750,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 Switch,添加与删除使用明确按钮和可访问名称。
|
||||
- MCP 设置按“内置 MCP / 直连模型 / 自定义 MCP / 电脑控制”四个同级 `PageTabs` 组织。联网搜索保留在直连模型;内置浏览器归入内置 MCP,提供持久启停和 Runtime 分配,并明确说明其操作 GoodBuddy 隔离浏览器、不控制客户端已安装的浏览器,开启后可由已分配 Runtime 的 Execute 直接使用,不逐次询问。该开关只控制 Agent 工具访问,关闭时不得禁用浏览器工作栏中的用户手动导航、返回、刷新、停止加载、交互或关闭操作。尚未生效的命名浏览器配置不得显示在界面中,“电脑控制”只显示实际操作客户端电脑的能力。内置 MCP 卡片与 Skills 一样提供持久启停和 Runtime 分配;直连模型、GoodBuddy 管理的 OpenCode 与 Continue 默认选中且可调整,DeepSeek Harness 必须以置灰、未选择和“暂不支持”文案持续显示,不能呈现为可保存的分配。魔法笔记 MCP 的自身启停与平台功能依赖分别显示,依赖未开启时保留用户配置并说明当前不会加载。
|
||||
- MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。
|
||||
- 内置 MCP 中的“图片生成与编辑”是自动管理例外:该行始终可见,标记“内置 · 自动管理”,不提供独立启停、分配保存、移除或凭据编辑。直连 Model、OpenCode、Continue、DeepSeek Harness 的分配只由已保存图片模型的“允许 AI 在会话中调用”设置推导;至少一个开启则全部勾选,没有开启模型则全部取消。使用禁用的原生 Checkbox、Runtime 标签及关联说明,保留可聚焦的“前往图片模型设置”导航;远程适用范围通过 GoodBuddy Agent 说明持续可见。Ask、离线、无工具能力或调用错误不改变勾选。图片模型默认关闭会话调用;默认生图模型独立于普通默认连接,关闭或删除后保留失效选择并提示改选,不自动回退。
|
||||
|
||||
### 13.9 文档解析设置
|
||||
|
||||
@@ -758,7 +769,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- DOC、XLS、PPT 等旧格式通过受控转换 Provider 生成新式 Office 文档和 PDF。转换子进程必须禁用宏和网络,限制输入、输出、内存、超时与临时目录,并在关闭或取消时清理。
|
||||
- OCR 来源使用“本地模型 / 远程服务”互斥选择。选择本地后显示模型下载、模型下拉选择和本地运行参数;选择远程后显示 MinerU、PaddleOCR-VL 等服务连接配置。未实现的远程服务入口保持可读但禁用,不再增加与来源选择重复的“隐私与云端处理”授权区。
|
||||
- 用户配置并保存远程 OCR 服务即表示选择该处理路径,不再逐场景重复询问。界面仍须明确显示当前服务名称、处理范围和远程属性,API 密钥只保存在主进程加密设置中,未选中远程服务时不得上传文档。
|
||||
- 解析结果使用统一文档结构,至少保留文档标题、来源格式、页码或工作表定位、正文块、置信度、处理方式和警告。聊天附件对结果做有界截断,知识库使用完整结果分块和索引。
|
||||
- 解析结果使用统一文档结构,至少保留文档标题、来源格式、页码或工作表定位、正文块、置信度、处理方式和警告。聊天附件保留完整解析文本,超过暂存容量时报错;知识库使用完整结果分块和索引,具体要求见[文档处理 PRD](./docs/features/document-processing/prd.md#32-场景工作流)。
|
||||
- 测试结果显示文件类型、页数、实际工作流、提取字数、OCR 页数、耗时和警告。测试文件不得自动进入聊天上下文或知识库。
|
||||
|
||||
## 14. 文案规则
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.11.24",
|
||||
"agentVersion": "0.13.0",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# 数据库迁移与大数据升级指南
|
||||
|
||||
本文约束已发布用户数据的数据库升级。具体字段、存储表示和功能行为由对应功能文档负责;
|
||||
本文不建立通用迁移框架、回滚引擎或独立状态日志。
|
||||
|
||||
## 先确认问题和数据边界
|
||||
|
||||
1. 先只读统计文件大小、WAL、页数、空闲页、表与字段的字节数,不先删除或压缩数据库。
|
||||
2. 区分有效内容、重复存储、索引和已释放但未回收的空间。空闲页少时,单独
|
||||
`VACUUM` 不能消除重复记录。
|
||||
3. 查清实际写入、读取、删除、重放与恢复入口。修改 Runtime 相关持久化时,同时检查
|
||||
本机和 GoodBuddy Agent 的桌面路径,不假设一个入口覆盖另一个入口。
|
||||
4. 明确用户必须保留的信息、稳定 ID、顺序、时间、引用与远程去重字段。
|
||||
分支内未发布、可安全重建的内部记录不需要兼容机制。
|
||||
|
||||
## 实现原则
|
||||
|
||||
- 先修新增数据路径,再修历史数据。历史转换与新写入尽量使用同一套转换函数。
|
||||
- 普通 schema 变更使用既有 `PRAGMA user_version` 和 SQLite 事务;
|
||||
已升级 schema 不允许旧客户端强行打开,不通过回写版本号支持降级。
|
||||
- 大数据转换不得同步阻塞 Electron Main 或 Renderer。放到工作线程,在业务连接
|
||||
打开前处理,并使用应用的共享字体、主题与页面组件显示进度和可执行的失败提示。
|
||||
- 根据实测设置有界读取批次与缓存,批次提交后再报告进度。保持 SQLite 事务与
|
||||
checkpoint,不靠关闭同步落盘、取消约束或一次性读入整库提速。
|
||||
- 从已提交数据直接重试和还原即可完成的迁移,不增加 durable snapshot、journal、
|
||||
receipt 或多阶段恢复状态。退出及失败至少回滚未提交批次,保留已完成部分。
|
||||
- 差量编码必须能精确还原原始事件。更新内容、删除或重排都要覆盖;涉及远程来源时,
|
||||
去重、冲突判断、消息投影和恢复序号不得失效。
|
||||
- 释放数据库页和归还文件系统空间是不同步骤。先减少实际存储内容,再执行需要的
|
||||
空间回收;保留足够临时空间,磁盘不足时明确失败并允许重试。
|
||||
- SQLite 同库写入是串行的。是否增加读取/转换并行,应由实际读取、解析、写入、
|
||||
提交与 checkpoint 的耗时决定,不把增加写线程当作默认优化。
|
||||
|
||||
## 验证与备份
|
||||
|
||||
### 合成回归
|
||||
|
||||
覆盖正常转换、无变化记录、重复打开、内容修订、失败事务和退出续跑。数据库大小问题
|
||||
必须同时验证新增写入的增长量、旧记录可还原性以及文件实际缩小,而不只检查 schema。
|
||||
对象比较必须覆盖真实生产形状和 JSON 往返,尤其是可选字段值为 `undefined` 时与字段缺省
|
||||
的差异。再次修复已发布迁移产生的数据时,提高目标 schema,并覆盖已升级用户的入口;
|
||||
不能只处理尚未迁移的旧库。
|
||||
|
||||
### 真实旧库
|
||||
|
||||
1. 关闭占用源数据库的应用,确认 WAL 状态。没有 WAL 时可复制已关闭的数据库;
|
||||
有活动 WAL 时使用 SQLite 一致性备份机制,不只复制主文件。
|
||||
2. 备份到空间足够的位置,记录字节数并校验 SHA-256。备份和测试副本分离,
|
||||
不直接在唯一原件或唯一备份上试跑。
|
||||
3. 使用当前源码或用户要求的 portable 正常启动入口执行升级,不用手工 SQL 代替
|
||||
产品升级过程。退出一次后重新启动,验证已提交数据可以继续处理。
|
||||
4. 比较升级前后消息、任务、成果等用户表的内容;对转换事件逐条还原后比较原始内容、
|
||||
ID、顺序、时间和去重来源,不只比较行数。日志只能包含统计和断言结果,不记录正文。
|
||||
5. 运行数据库完整性检查,记录主文件和 WAL 的实际字节数、回收比例、耗时、平台、
|
||||
源 schema 与目标 schema。确认原件和备份未被改写。
|
||||
6. 运行 `npm test`、`npm run typecheck`、`npm run lint`;生产构建变化按开发验证规则
|
||||
检查,发布准备则遵循[发布手册](./release-runbook.md),不重复本地打包。
|
||||
7. 用真实模型模拟正常用户任务,允许主代理与子代理按需调用工具、修改独立测试项目、
|
||||
运行测试和处理失败;只读纯文字模型调用不能证明工具事件增长正常。记录实际提供商
|
||||
请求数与统计范围,不把 Runtime 用量上报数当成全部 HTTP 请求数。
|
||||
|
||||
## 发布与维护
|
||||
|
||||
发布说明应告知升级范围、可能耗时、退出重试、额外磁盘空间和降级边界。不得把一个测试库
|
||||
的回收比例当作所有用户都能获得的保证。功能文档记录具体转换规则,进度文档记录实际
|
||||
证据和未完成项;未经验证的阶段不能标为完成。
|
||||
|
||||
### 已有实例
|
||||
|
||||
- Desktop 0.13.1:子任务累计过程重复保存导致数据库膨胀,转换到 schema 34 的差量事件
|
||||
表示,并回收空间。具体规则见
|
||||
[执行记录存储与升级回收](../features/assistant-workbar/execution-history-storage.md),
|
||||
数据保留与真实旧库验证见[工作栏进度](../features/assistant-workbar/progress.md)。
|
||||
- 当前未发布源码:schema 35 同时整理旧快照和 schema 34 的重复工具块,工具字段按
|
||||
JSON 可保存的值比较,不因 `undefined` 被省略而误判变化。
|
||||
@@ -0,0 +1,54 @@
|
||||
# Desktop Window Recovery
|
||||
|
||||
This is recovery hardening for intermittent blank windows, not an established
|
||||
diagnosis of their cause.
|
||||
|
||||
- `src/renderer/src/main.tsx` wraps the locale provider, App and storage-upgrade
|
||||
page in `AppErrorBoundary`. A React render/lifecycle failure replaces the tree
|
||||
with a blocking recovery page and an explicit Reload button. It uses the saved
|
||||
UI language (including system preference resolution), shared theme tokens and
|
||||
primary-button styling, without depending on the failed application providers.
|
||||
- Reload refreshes the current document, retaining its URL and storage-upgrade
|
||||
query. It can lose unsaved input. There are no automatic React retries.
|
||||
- `src/main/window.ts` listens for `render-process-gone`. Abnormal exits open one
|
||||
native dialog at a time, using the OS locale because renderer language storage
|
||||
is unavailable to Main. Reload occurs only on explicit confirmation. Cancel
|
||||
leaves the window unchanged; persistent failures require quitting and reopening
|
||||
GoodBuddy. Clean exits, shutdown and destroyed windows do not trigger recovery.
|
||||
- Main-frame load failures are logged; aborted navigation and subframe failures
|
||||
are ignored. Initial load promises are caught, without automatic load retries
|
||||
or a new load-failure UI.
|
||||
- Native crashes and main-frame load failures use the existing bounded desktop
|
||||
diagnostics store (`desktop.renderer.gone`, `desktop.renderer.load-failed`,
|
||||
stage `renderer`). Persistent records retain fixed summaries rather than raw
|
||||
errors. Crash reason/exit code and React errors/component stacks go to their
|
||||
respective process consoles. There is no new diagnostic IPC.
|
||||
|
||||
The boundary does not catch module evaluation/startup failures before React
|
||||
mounts, event-handler exceptions, asynchronous rejections, a hung renderer, or
|
||||
main-process failure. Reload does not establish that a repeated underlying error
|
||||
has been fixed. Agent runtimes and remote execution are unchanged.
|
||||
|
||||
Targeted regressions are in `AppErrorBoundary.test.tsx`, `window.test.ts` and
|
||||
`desktop-diagnostics.test.ts`. Electron API mocks verify decision paths but do
|
||||
not establish native dialog appearance or full application recovery.
|
||||
|
||||
Validation on Windows, 2026-09-15:
|
||||
|
||||
- Targeted Vitest run: 20 tests passed across the three files above.
|
||||
- `npm test`: 359 files passed, 9 skipped; 4,225 tests passed, 67 skipped.
|
||||
- `npm run typecheck`, `npm run lint` and `git diff --check` passed.
|
||||
- An isolated Electron fixture bundled the current boundary, shared styles and
|
||||
production window module. Both languages and themes at requested content sizes
|
||||
1280x800, 960x720, 720x640 and 640x420 passed visibility, focus and horizontal
|
||||
overflow checks. Windows scaling rounded actual widths down by one CSS pixel.
|
||||
The 16-image overview and Chinese/light and English/dark short-window originals
|
||||
were visually reviewed; no clipping or focus-style defect was found.
|
||||
- A native Space key activated the recovery button and reloaded the document.
|
||||
Two actual renderer crashes verified confirmed reload with URL retention and
|
||||
cancellation without retry. Only native dialog responses were mocked; native
|
||||
dialog appearance, full App business-state recovery and other OSes were not
|
||||
verified. No model calls were made by this fixture.
|
||||
- The first temporary Electron driver timed out during ESM startup. Removing its
|
||||
top-level readiness await fixed the driver; the subsequent run passed. This
|
||||
was a test-driver failure, not evidence of the reported application root cause.
|
||||
@@ -7,8 +7,14 @@
|
||||
当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。
|
||||
本轮更新实施计划与范围,旧版功能要求、验收和配套设计仍需按新计划同步,此项列为实现前的第一步。
|
||||
|
||||
新增计划包括 [私有化市场与 YAML 交换](./prd.md#fr-15-私有化市场与-yaml-交换后续):
|
||||
通过市场 URL 获取应用,支持 `.yml` 导入导出,未来由 ShareServer 提供私有目录与分发。
|
||||
[本机模型管理入口](./prd.md#fr-16-本机模型管理入口新增计划项)可独立于商店交付,拟作为
|
||||
可常驻左侧的内置管理应用,区分受管进程、外部服务和进程内模型。两项均为需求记录,
|
||||
未实现,相关后续验收见 [User Stories](./user-stories.md#7-后续需求场景)。
|
||||
|
||||
项目活动汇总独立于上述应用中心计划,范围见 [FR-14](./prd.md#fr-14-项目活动汇总)。
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与弹窗见
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与级联菜单见
|
||||
[活动界面](./ui-design.md#10-项目活动汇总),数据加载与会话跳转见
|
||||
[技术设计](./technical-design.md#12-项目活动汇总)。此处“活动”指当前运行或待处理会话,
|
||||
与用于审计的“运行记录”不同;已完成未读面板属于后续范围,本次未实现。
|
||||
@@ -22,6 +28,7 @@
|
||||
| [功能逻辑设计](./logic-design.md) | 分类规则、入口状态、优先级与失败结果 |
|
||||
| [UI 设计](./ui-design.md) | 侧栏信息架构、控件、文案、键盘和响应式行为 |
|
||||
| [技术设计](./technical-design.md) | 现有模块映射、状态契约、持久化和实施顺序 |
|
||||
| [实施进度](./progress.md) | 项目活动级联的生产实现与验证记录 |
|
||||
| [应用中心交互 Demo](./navigation-demo.html) | 对比六个候选方案;配套 CSS 和 JS 仅服务原型 |
|
||||
|
||||
Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加、移除、排序和商店模拟。
|
||||
@@ -34,6 +41,12 @@ Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加
|
||||
附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止,
|
||||
以及上下文详情和模拟压缩。文件只读取名称,语音使用固定示例文本,不录音、不上传、不调用 AI。
|
||||
这是输入区设计讨论用的原型,尚未改变正式产品或替代现有 UI 规范。
|
||||
左上角项目选择器下方仅在有运行中或待处理会话时显示活动摘要,全部空闲时隐藏。点击摘要打开项目
|
||||
列表,悬停或点击项目向右展开会话子菜单,点击会话才切换项目与对话。子菜单与项目列表
|
||||
相接,项目行保留子菜单箭头,会话行只显示名称与状态。支持上下方向键选择、右键进入、左键或 Esc 返回,顶层 Esc 关闭;窄屏在同一浮层内
|
||||
进入会话层,并提供返回按钮。页面上方可切换“有待处理、仅运行中、仅当前项目运行、全部
|
||||
空闲”四种场景。项目与会话均为演示数据;该级联交互已接入正式产品活动入口,范围与
|
||||
验证记录见[实施进度](./progress.md)。
|
||||
按最新讨论,Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧
|
||||
工作栏 Tab 中演示,不列入应用中心。上方正式文档仍保留原设计,待方案确定后同步。
|
||||
|
||||
|
||||
@@ -186,3 +186,11 @@ warming/loading/live/partial/error -> inactive
|
||||
|
||||
历史排队项、仅待调度的 Task、终态 Task 和未读标记本身不构成活动。活动结束且没有其他
|
||||
运行或等待条件时移除该行,不能因历史任务或未读状态继续计数。
|
||||
|
||||
级联菜单先按项目归属聚合活动行,沿用本地、SSH Host、通道的稳定项目顺序;缺少项目
|
||||
元数据的归属组及“未归属项目”组放在已知项目之后。无归属组只用于访问这些会话,不增加
|
||||
项目计数。每个项目内先列待处理会话,再列运行中会话,同类别保留来源顺序。
|
||||
|
||||
展开项目只改变菜单层级,不导航。选中项目的活动全部结束后返回项目列表;全局活动结束后
|
||||
关闭菜单并隐藏摘要入口。后台更新保留仍存在的焦点项,删除焦点项时将焦点
|
||||
移至当前层可用项。关闭菜单后,新的活动只恢复摘要,不会自动重新打开菜单。
|
||||
|
||||
@@ -5,7 +5,43 @@
|
||||
--accent: #086fc7; --accent-subtle: #e8f4ff; --accent-selected: #a7cfea;
|
||||
--font-family-ui: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", system-ui, sans-serif;
|
||||
--radius-control: 10px; --radius-card: 14px;
|
||||
--warning: #865200;
|
||||
}
|
||||
[data-theme="dark"] { --warning: #f2bd66; }
|
||||
.project-lab { max-width: 1440px; margin: 0 auto 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; color: var(--text-secondary); font-size: 12px; }
|
||||
.project-lab select { font: inherit; color: var(--text-primary); background: var(--surface-raised); padding: 4px 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); }
|
||||
.project-entry { position: relative; margin-bottom: 12px; }
|
||||
#project-trigger { width: 100%; display: flex; align-items: center; gap: 8px; text-align: left; border: 1px solid var(--border-default); }
|
||||
#project-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.project-waiting { color: var(--warning); font-weight: 600; }
|
||||
#activity-trigger { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: -8px 0 12px; padding: 4px 8px; color: var(--text-secondary); font-size: 11px; text-align: left; }
|
||||
#project-activity { position: fixed; inset: auto; margin: 0; padding: 0; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); max-width: calc(100vw - 32px); }
|
||||
#project-activity:popover-open { display: flex; align-items: stretch; }
|
||||
#project-activity > section { width: 264px; max-width: calc(100vw - 34px); max-height: min(480px, calc(100dvh - 32px)); overflow: auto; padding: 8px; }
|
||||
#project-activity h3 { margin: 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
#activity-project-list > p { padding: 8px; }
|
||||
#activity-project-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; gap: 4px 8px; text-align: left; }
|
||||
#activity-project-list button > span:first-child { overflow-wrap: anywhere; }
|
||||
#activity-project-list small { grid-column: 1; font-size: 11px; color: var(--text-secondary); }
|
||||
#activity-project-list button > span:last-child { grid-column: 2; grid-row: 1 / 3; align-self: center; }
|
||||
#activity-project-list [aria-expanded="true"] { background: var(--accent-subtle); color: var(--accent); }
|
||||
#activity-sessions { border-left: 1px solid var(--border-default); }
|
||||
#activity-back { display: none; font-size: 12px; color: var(--accent); }
|
||||
#project-activity.activity-left { flex-direction: row-reverse; }
|
||||
.activity-left #activity-sessions { border-left: 0; border-right: 1px solid var(--border-default); }
|
||||
.activity-compact.has-submenu #activity-projects { display: none; }
|
||||
.activity-compact #activity-back { display: block; }
|
||||
.activity-compact #activity-sessions { border: 0; }
|
||||
#project-menu { position: fixed; inset: auto; margin: 0; width: min(300px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow: auto; padding: 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); }
|
||||
#project-list h3, #activity-list h3 { margin: 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); }
|
||||
#project-list button { width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; text-align: left; }
|
||||
.project-row-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
#project-list [aria-current="true"] { background: var(--accent-subtle); color: var(--accent); }
|
||||
#project-list small { width: 100%; display: flex; gap: 12px; font-size: 11px; color: var(--text-secondary); }
|
||||
#activity-list button { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; text-align: left; padding: 12px; }
|
||||
#activity-list strong { font-weight: 500; overflow-wrap: anywhere; }
|
||||
#activity-list small { grid-column: 1; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
.history button small { display: block; font-size: 11px; color: var(--text-secondary); }
|
||||
[data-theme="dark"] { color-scheme: dark; --surface-canvas: #07131f; --surface-raised: #102335; --surface-subtle: #162c40; --text-primary: #edf7ff; --text-secondary: #b5c6d5; --border-default: #29445a; --accent: #72c5ff; --accent-subtle: #123b5b; --accent-selected: #28668f; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px 32px; font: 13px/1.6 var(--font-family-ui); background: var(--surface-subtle); color: var(--text-primary); }
|
||||
|
||||
@@ -21,9 +21,14 @@
|
||||
<button id="theme">切换深色</button>
|
||||
</header>
|
||||
<div class="design-note"><span id="variant-label">A / 紧凑 Dock</span><p id="variant-description"></p><span>交互原型 · 商店与指标均为演示数据</span></div>
|
||||
<div class="project-lab"><label>项目活动演示 <select id="project-scenario"><option value="waiting">有待处理</option><option value="running">仅运行中</option><option value="current">仅当前项目运行</option><option value="idle">全部空闲</option></select></label><span>有活动时显示摘要,悬停或点击项目向右展开会话。</span></div>
|
||||
<main class="app" data-variant="dock">
|
||||
<aside class="sidebar" aria-label="主侧栏">
|
||||
<div class="brand"><span class="brand-mark">GB</span><strong>GoodBuddy</strong><span class="muted">个人空间</span></div>
|
||||
<div class="project-entry"><button id="project-trigger" popovertarget="project-menu" aria-expanded="false" aria-controls="project-menu"><span id="project-name">GoodBuddy</span><span class="muted" aria-hidden="true">⌄</span></button></div>
|
||||
<button id="activity-trigger" popovertarget="project-activity" aria-expanded="false" aria-controls="project-activity"><span id="activity-summary"></span><span aria-hidden="true">⌄</span></button>
|
||||
<div id="project-menu" popover aria-label="切换项目"><div id="project-list"></div></div>
|
||||
<div id="project-activity" popover aria-label="所有项目活动"><section id="activity-projects"><h3>所有项目活动</h3><div id="activity-project-list"></div></section><section id="activity-sessions" hidden><button id="activity-back">‹ 返回项目列表</button><h3 id="activity-project-title"></h3><div id="activity-list"></div></section></div>
|
||||
<button class="new-chat" data-page="当前对话"><span>+</span> 新对话</button>
|
||||
<nav class="navigation" aria-label="应用导航">
|
||||
<button class="selected" data-page="当前对话"><span data-icon="chat"></span>当前对话</button>
|
||||
|
||||
@@ -26,6 +26,112 @@ const variants = {
|
||||
scenes: ['F / 商店发现', '底部入口强调应用发现,展开后查看商店应用与安装位置。', '先模拟安装,再决定是否常驻;我的应用始终可切换访问。']
|
||||
};
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const demoProjects = [
|
||||
{ id: 'goodbuddy', name: 'GoodBuddy', group: '本地', conversations: ['优化项目导航', '检查构建问题'] },
|
||||
{ id: 'website', name: '官网改版', group: '本地', conversations: ['发布首页'] },
|
||||
{ id: 'remote', name: '客户方案与交付文档工作空间', group: 'SSH', conversations: ['整理报价'] },
|
||||
{ id: 'channel', name: '团队助手', group: '通道', conversations: ['本周工作计划'] }
|
||||
];
|
||||
let currentProject = 'goodbuddy';
|
||||
let projectActivity = [];
|
||||
let expandedActivityProject = '';
|
||||
function renderProjects() {
|
||||
const scenario = $('#project-scenario').value;
|
||||
const activity = demoProjects.flatMap(project => project.conversations.map((name, index) => ({ project, name, index,
|
||||
state: scenario === 'idle' || project.id === 'channel' || (scenario === 'current' && project.id !== currentProject) ? '' : scenario === 'waiting' && project.id === 'website' ? '等待审批' : '运行中'
|
||||
}))).filter(row => row.state);
|
||||
projectActivity = activity;
|
||||
const waiting = activity.filter(row => row.state !== '运行中').length;
|
||||
const running = activity.length - waiting;
|
||||
const project = demoProjects.find(entry => entry.id === currentProject);
|
||||
$('#project-name').textContent = project.name;
|
||||
$('#project-trigger').setAttribute('aria-label', `切换项目,当前项目:${project.name}`);
|
||||
$('#activity-trigger').hidden = !activity.length;
|
||||
if (!activity.length && $('#project-activity').matches(':popover-open')) $('#project-activity').hidePopover();
|
||||
$('#activity-summary').innerHTML = `${running ? `运行中 ${running}` : ''}${waiting && running ? ' · ' : ''}${waiting ? `<span class="project-waiting">待处理 ${waiting}</span>` : ''}`;
|
||||
$('#activity-trigger').setAttribute('aria-label', `所有项目活动:${$('#activity-summary').textContent}`);
|
||||
$('#project-list').innerHTML = ['本地', 'SSH', '通道'].map(group => `<section><h3>${group}</h3>${demoProjects.filter(entry => entry.group === group).map(entry => {
|
||||
const rows = activity.filter(row => row.project.id === entry.id);
|
||||
const pending = rows.filter(row => row.state !== '运行中').length;
|
||||
const active = rows.length - pending;
|
||||
return `<button data-project="${entry.id}" ${entry.id === currentProject ? 'aria-current="true"' : ''}><span class="project-row-name">${entry.name}</span>${entry.id === currentProject ? '<span aria-hidden="true">✓</span>' : ''}${rows.length ? `<small>${pending ? `<span class="project-waiting">待处理 ${pending}</span>` : ''}${active ? `<span>运行中 ${active}</span>` : ''}</small>` : ''}</button>`;
|
||||
}).join('')}</section>`).join('');
|
||||
$('#activity-project-list').innerHTML = demoProjects.map(entry => {
|
||||
const rows = activity.filter(row => row.project.id === entry.id);
|
||||
if (!rows.length) return '';
|
||||
const pending = rows.filter(row => row.state !== '运行中').length;
|
||||
return `<button data-activity-project="${entry.id}" aria-expanded="false" aria-controls="activity-sessions"><span>${entry.name}</span><small>${pending ? `<span class="project-waiting">待处理 ${pending}</span>` : `运行中 ${rows.length}`}</small><span aria-hidden="true">›</span></button>`;
|
||||
}).join('') || '<p class="muted">暂无运行中或待处理会话。</p>';
|
||||
expandActivityProject('');
|
||||
$('.history').innerHTML = `<span class="eyebrow">当前项目对话</span>${project.conversations.map((name, index) => {
|
||||
const row = activity.find(entry => entry.project.id === currentProject && entry.index === index);
|
||||
return `<button data-conversation="${currentProject}" data-conversation-index="${index}">${name}${row ? `<small>${row.state}</small>` : ''}</button>`;
|
||||
}).join('')}`;
|
||||
}
|
||||
$('#project-scenario').onchange = renderProjects;
|
||||
const projectMenu = $('#project-menu');
|
||||
function positionProjectMenu() {
|
||||
const rect = $('#project-trigger').getBoundingClientRect();
|
||||
projectMenu.style.left = `${Math.max(16, Math.min(rect.left, innerWidth - projectMenu.offsetWidth - 16))}px`;
|
||||
projectMenu.style.top = `${Math.max(16, Math.min(rect.bottom + 8, window.innerHeight - projectMenu.offsetHeight - 16))}px`;
|
||||
}
|
||||
projectMenu.addEventListener('toggle', event => {
|
||||
$('#project-trigger').setAttribute('aria-expanded', String(event.newState === 'open'));
|
||||
if (event.newState === 'open') positionProjectMenu();
|
||||
});
|
||||
window.addEventListener('resize', () => { if (projectMenu.matches(':popover-open')) positionProjectMenu(); });
|
||||
window.addEventListener('scroll', () => { if (projectMenu.matches(':popover-open')) positionProjectMenu(); }, true);
|
||||
function positionActivityMenu() {
|
||||
const panel = $('#project-activity');
|
||||
const rect = $('#activity-trigger').getBoundingClientRect();
|
||||
panel.classList.toggle('activity-compact', innerWidth < 620);
|
||||
panel.classList.toggle('activity-left', innerWidth >= 620 && rect.left + panel.offsetWidth > innerWidth - 16 && rect.right >= panel.offsetWidth + 16);
|
||||
const left = panel.classList.contains('activity-left') ? rect.right - panel.offsetWidth : rect.left;
|
||||
panel.style.left = `${Math.max(16, Math.min(left, innerWidth - panel.offsetWidth - 16))}px`;
|
||||
panel.style.top = `${Math.max(16, Math.min(rect.bottom + 4, window.innerHeight - panel.offsetHeight - 16))}px`;
|
||||
}
|
||||
function expandActivityProject(id, focus = false) {
|
||||
expandedActivityProject = id;
|
||||
$('#activity-sessions').hidden = !id;
|
||||
$('#project-activity').classList.toggle('has-submenu', Boolean(id));
|
||||
document.querySelectorAll('[data-activity-project]').forEach(button => button.setAttribute('aria-expanded', String(button.dataset.activityProject === id)));
|
||||
if (id) {
|
||||
$('#activity-project-title').textContent = demoProjects.find(project => project.id === id).name;
|
||||
$('#activity-list').innerHTML = projectActivity.filter(row => row.project.id === id).sort((a, b) => Number(a.state === '运行中') - Number(b.state === '运行中')).map(row => `<button data-conversation="${id}" data-conversation-index="${row.index}"><strong>${row.name}</strong><small>${row.state}</small></button>`).join('');
|
||||
}
|
||||
if ($('#project-activity').matches(':popover-open')) positionActivityMenu();
|
||||
if (focus) $('#activity-list button')?.focus();
|
||||
}
|
||||
$('#project-activity').addEventListener('toggle', event => {
|
||||
$('#activity-trigger').setAttribute('aria-expanded', String(event.newState === 'open'));
|
||||
if (event.newState === 'open') { expandActivityProject(''); positionActivityMenu(); }
|
||||
});
|
||||
$('#activity-project-list').addEventListener('pointerover', event => {
|
||||
const button = event.target.closest('[data-activity-project]');
|
||||
if (event.pointerType === 'mouse' && innerWidth >= 620 && button && button.dataset.activityProject !== expandedActivityProject) expandActivityProject(button.dataset.activityProject);
|
||||
});
|
||||
$('#activity-project-list').addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-activity-project]');
|
||||
if (button) expandActivityProject(button.dataset.activityProject, true);
|
||||
});
|
||||
$('#activity-back').onclick = () => {
|
||||
const id = expandedActivityProject;
|
||||
expandActivityProject('');
|
||||
$(`[data-activity-project="${id}"]`)?.focus();
|
||||
};
|
||||
$('#project-activity').addEventListener('keydown', event => {
|
||||
const button = event.target.closest('[data-activity-project]');
|
||||
if (event.key === 'ArrowRight' && button) { event.preventDefault(); expandActivityProject(button.dataset.activityProject, true); }
|
||||
if ((event.key === 'ArrowLeft' || event.key === 'Escape') && expandedActivityProject) { event.preventDefault(); event.stopPropagation(); $('#activity-back').click(); }
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
const buttons = [...document.querySelectorAll(button ? '[data-activity-project]' : '#activity-list button')];
|
||||
const index = buttons.indexOf(document.activeElement);
|
||||
if (index >= 0) { event.preventDefault(); buttons[(index + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length].focus(); }
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', () => { if ($('#project-activity').matches(':popover-open')) positionActivityMenu(); });
|
||||
window.addEventListener('scroll', () => { if ($('#project-activity').matches(':popover-open')) positionActivityMenu(); }, true);
|
||||
renderProjects();
|
||||
let variant = 'dock';
|
||||
let catalogMode = 'apps';
|
||||
let residentApps = ['notes', 'knowledge'];
|
||||
@@ -125,6 +231,16 @@ function selectTool(id) {
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('button'); if (!button) return;
|
||||
const data = button.dataset;
|
||||
if (data.project || data.conversation) {
|
||||
currentProject = data.project || data.conversation;
|
||||
if (projectMenu.matches(':popover-open')) projectMenu.hidePopover();
|
||||
if ($('#project-activity').matches(':popover-open')) $('#project-activity').hidePopover();
|
||||
renderProjects();
|
||||
const project = demoProjects.find(entry => entry.id === currentProject);
|
||||
openPage(data.conversation ? project.conversations[Number(data.conversationIndex)] : '当前对话');
|
||||
$('#page-label').textContent = `${project.name} / ${data.conversation ? project.conversations[Number(data.conversationIndex)] : '当前对话'}`;
|
||||
$('#project-trigger').focus();
|
||||
}
|
||||
if (data.variant) selectVariant(data.variant);
|
||||
if (data.catalog) openCatalog(data.catalog);
|
||||
if (data.page) openPage(data.page);
|
||||
|
||||
@@ -142,7 +142,9 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定
|
||||
### FR-14 项目活动汇总
|
||||
|
||||
用户在任一项目中都能查看跨项目的运行中与待处理会话,并从活动列表直接进入所选会话。
|
||||
没有活动时隐藏汇总入口;项目菜单保留原有分组与顺序,并显示各项目的活动数量。
|
||||
汇总入口仅在有运行中或待处理会话时显示在项目选择器下方,无活动时整行隐藏。点击后先显示有活动会话的项目,
|
||||
悬停或点击项目展开会话子菜单,点击会话进入该条对话。项目菜单保留原有分组与顺序,并显示
|
||||
各项目的活动数量;级联菜单内的项目顺序不随待处理或运行中数量变化。
|
||||
汇总不受当前项目、搜索条件或最近历史条数限制。计数与排除规则见
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
@@ -219,3 +221,43 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定
|
||||
|
||||
现有 Demo 的商店页、项目管理和阅读空间安装流程仅保留为未来交互参考,不纳入首期验收,
|
||||
也不作为已有商店能力的说明。首期实现不预建插件安装服务或远程目录协议。
|
||||
|
||||
### FR-15 私有化市场与 YAML 交换(后续)
|
||||
|
||||
市场面向组织私有化部署。用户配置市场 URL 后即可获取该来源的应用目录与详情,并按需获取
|
||||
应用;需要认证的来源复用其登录流程。市场不可达时说明原因并允许重试,不影响已安装应用
|
||||
和内置应用使用,也不自动切换到公共市场。市场 URL 只影响应用市场,不改变模型、桌面更新
|
||||
或 Runtime 的下载源。
|
||||
|
||||
应用支持以 `.yml` 导入、导出,兼容 `.yaml` 扩展名,用于组织内部共享、备份和迁移。
|
||||
导出内容为应用定义及可迁移配置,包含格式版本、应用标识、应用版本、入口声明和依赖说明;
|
||||
凭据、会话、笔记及知识库业务数据不随应用定义导出。导入前展示应用摘要、缺失依赖和与已有
|
||||
应用的冲突;同标识应用由用户选择更新或取消,不静默覆盖本机配置。
|
||||
|
||||
YAML 描述文件不能代替模型权重或插件程序包。有外部依赖时应明确资源引用;离线导入仅在
|
||||
所需资源已具备时可完成使用,否则列出需要补充的资源,不显示为可运行。导入应用不会自动
|
||||
启动模型或执行任务。
|
||||
|
||||
未来 ShareServer 提供私有市场目录、应用定义的导入导出、发布和版本资源分发;桌面端负责
|
||||
配置来源、获取目录、本地导入导出及应用安装管理。服务端职责归属见
|
||||
[ShareServer 总体设计](../../architecture/share-network-architecture.md)。本节记录后续需求,
|
||||
尚未确定 API、YAML Schema 或服务端交付版本,不纳入应用中心首期。
|
||||
|
||||
### FR-16 本机模型管理入口(新增计划项)
|
||||
|
||||
应用中心提供“本机模型”内置管理应用,可选择常驻左侧,打开主内容区管理页。该入口统一
|
||||
查看各功能使用的本机模型及服务状态,包括已接入的 OCR、向量、语音或推理引擎;具体支持
|
||||
清单需核对现有模型服务后确定,不因提供入口而假定全部引擎均已支持进程控制。
|
||||
|
||||
页面展示模型名称、用途、所属引擎、管理方式、运行状态、占用情况及使用它的应用或任务。
|
||||
GoodBuddy 管理的进程提供启动、停止、重启和错误查看;外部启动的模型服务标明“外部管理”,
|
||||
提供连接状态与配置入口,未接入其管理接口时不提供停止或重启。进程内运行的模型按引擎实际
|
||||
能力提供加载与释放,不伪造独立进程。
|
||||
|
||||
停止或重启前展示正在使用该服务的任务和影响,由用户决定是否继续;取消时保留运行状态,
|
||||
失败时显示实际状态和原因。多个应用共用同一模型服务时展示同一个服务及其使用方,不重复
|
||||
启动进程。隐藏管理入口、关闭管理页面或取消应用常驻均不停止模型;卸载模型文件是独立操作。
|
||||
|
||||
资源监控仍是只读系统工具,本机模型管理负责模型生命周期,两者共享可用的状态来源。
|
||||
该项可独立于商店交付;当前只纳入计划,具体分期、任务使用期间的停止策略和各引擎管理能力
|
||||
在详细设计中确定。原首期资源监控的 GoodBuddy 桌面进程统计范围不因此扩大。
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 项目活动级联实施进度
|
||||
|
||||
## 2026-09-14:菜单独立高度修正
|
||||
|
||||
生产菜单移除共用双栏表面,会话层独立定位到选中项目行。打开较长会话列表时,项目层的位置、
|
||||
宽度与高度保持不变;会话层独立限制窗口边界和滚动。两侧均无法容纳子菜单时使用同层返回交互。
|
||||
|
||||
`npx vitest run src/renderer/src/ProjectActivity.test.tsx` 通过 13 项,类型检查与 Lint 通过。
|
||||
`npm test` 运行 60 秒后超时,未取得完整结果。
|
||||
|
||||
最终源码的 Electron 43.2.0 / Chromium 150 组件 fixture 使用生产样式与中文 i18n、模拟活动数据,
|
||||
深浅主题共 14 个场景、108 条检查通过,覆盖短/60 条会话、两层独立滚动、底部避让、向左展开、
|
||||
375px 窄屏及 640px 两侧空间不足。桌面项目层打开前后矩形一致,短窗口会话层底部保留 16px。
|
||||
此项验证实际组件排版和交互,不代表完整 Main/Preload 端到端验证;未修改远程执行路径。
|
||||
|
||||
## 2026-09-13:生产活动入口
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E1 / US-E2](./user-stories.md#6-epic-e项目活动级联)。
|
||||
本页只记录项目活动入口,不表示应用中心计划已交付。
|
||||
|
||||
`App` 已接入 `ProjectActivity` 常驻摘要和锚定级联菜单。会话和项目计数继续使用现有
|
||||
`deriveConversationActivity` 结果,点击会话继续调用 `openActivityConversation`。未增加
|
||||
持久化字段、IPC 或 Runtime 状态。项目选择器分组及计数保持原有行为。
|
||||
|
||||
组件与 App 定向回归通过 32 项,覆盖空闲入口、稳定项目及 SSH Host 顺序、待处理优先、
|
||||
跨项目精确跳转、设置离开检查、实时状态清理、键盘返回、外部关闭和窄侧栏焦点。
|
||||
|
||||
Windows Electron 视觉 fixture 使用当前 `ProjectActivity`、生产样式和 i18n,活动数据为
|
||||
受控样例。浅深主题分别验证 `1280×800`、`960×720`、`720×640`、`640×420`、`375×600`;
|
||||
原生键盘验证 Enter、上下及右方向键、End、两级 Escape、Tab 退出,另测左侧回退和空闲入口。
|
||||
此项证明组件的实际 Chromium 排版与交互,不代表完整生产 Main/Preload 的端到端验证。
|
||||
|
||||
浏览器遮挡复用共享菜单相交机制,组件测试检查相交和非相交结果;本轮未单独驱动原生
|
||||
`WebContentsView` 可见性。远程 Agent 执行路径未改动,未发出模型或知识库请求。
|
||||
|
||||
提交前复核:`npm run typecheck`、`npm run lint` 通过;单独运行
|
||||
`npx vitest run src/renderer/src/ProjectActivity.test.tsx`,11 项通过。
|
||||
`npm test` 在 240 秒后超时,超时前 `tests/agent-package.test.ts` 的
|
||||
`verifies and installs offline dependency inventories larger than one MiB` 用例失败,未取得完整结果。
|
||||
|
||||
Demo 已按最新讨论隐藏空闲摘要并移除会话行箭头;生产空闲摘要在下述非驻留修正中对齐。
|
||||
后续界面修正已移除生产会话行箭头,并为活动摘要与下方新建会话增加 `8px` 间距。
|
||||
|
||||
## 2026-09-14:摘要非驻留修正
|
||||
|
||||
生产 `ProjectActivity` 在无活动时返回空内容,活动归零时关闭级联菜单;新活动只恢复摘要。
|
||||
组件回归 11 项通过,覆盖初始空闲、活动归零关闭菜单及活动恢复后不自动展开。
|
||||
App 的 `project activity integration` 定向回归 18 项通过,类型检查与 Lint 通过。
|
||||
本轮 `npm test` 在 60 秒后超时,未取得全量结果;未重新执行 Electron 视觉验证。
|
||||
|
||||
## 2026-09-14:0.13.2 候选验证
|
||||
|
||||
正式发布准备时再次复现 App 问答用例的时序失败。活动摘要改为常驻后,等待该入口出现
|
||||
不再意味着会话初始化和 Runtime 状态读取已经完成;测试有时点击仍被禁用的发送按钮,
|
||||
留下未消费的一次性问答 mock,继而影响后续用例。
|
||||
|
||||
测试现等待已加载的会话和可用发送按钮,并在每个用例前重置问答及审批响应 mock。
|
||||
答案重载用例等待提交后的界面状态,而不只等待 API 被调用。未修改生产交互、
|
||||
未增加固定延时,也未跳过失败用例。相关 20 项连续三轮通过,完整 App 与 Agent
|
||||
发布说明检查共 227 项通过。
|
||||
|
||||
最终候选 `npm test` 通过 4,155 项、跳过 66 项、失败 0 项;`npm run typecheck`、
|
||||
`npm run lint` 和 `npm run release:notes:verify` 均通过。发布准备未执行本地生产构建、
|
||||
打包或安装包启动探针,原生包验收由主分支与标签 CI 完成。
|
||||
@@ -385,6 +385,23 @@ resourceMonitor.getLocalSnapshot(input: {
|
||||
首期使用内置应用清单,不依赖远程商店、插件注册或安装服务。商店目录、推荐、安装与
|
||||
更新机制在后续独立设计中确定;Demo 中的模拟商店不接入首期产品界面。
|
||||
|
||||
后续设计输入见 [FR-15](./prd.md#fr-15-私有化市场与-yaml-交换后续) 与
|
||||
[FR-16](./prd.md#fr-16-本机模型管理入口新增计划项):
|
||||
|
||||
- 私有市场的 YAML Schema 应由桌面本地导入与 ShareServer 发布共用,具体协议与字段在
|
||||
市场功能设计中确定。市场 URL 按设备保存,认证信息沿用受保护凭据存储;导出仅包含
|
||||
可迁移应用定义与非敏感配置。
|
||||
- 程序包与模型资源单独分发。受管下载声明不可变版本、预期字节数和 SHA-256,下载开始后
|
||||
固定来源,失败不静默切源;下载地址与重定向范围由 Main 验证,不接受 Renderer 任意覆盖。
|
||||
市场来源配置不覆盖现有模型、应用更新或 Runtime 下载源设置。
|
||||
- ShareServer 的市场发布与分发实现由其独立仓库承接,本仓库先记录依赖,不将桌面需求
|
||||
记录标为服务端已实现。
|
||||
- 本机模型管理实施前盘点现有 OCR、向量、语音及推理服务的生命周期接口,优先复用其
|
||||
Main 服务。只为实际支持的操作提供 typed IPC;区分受管子进程、外部服务和进程内模型,
|
||||
不把直连模型 HTTP 请求等同于独立模型进程。
|
||||
- 模型管理的详细逻辑需确定共享服务的使用方识别、忙碌时停止与重启、退出应用时的释放
|
||||
行为,再接入管理页面并验证实际引擎。该工作单独排期,不依赖市场上线。
|
||||
|
||||
## 12. 项目活动汇总
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),按[活动逻辑](./logic-design.md#9-项目活动汇总)
|
||||
@@ -392,7 +409,16 @@ resourceMonitor.getLocalSnapshot(input: {
|
||||
|
||||
- `conversation-activity.ts` 从 `App.tsx` 的会话、`activeConversationIds`、全部可见
|
||||
`assistantTasks` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。
|
||||
`ProjectActivity` 渲染汇总与弹窗,`ProjectSwitcher` 复用项目计数。
|
||||
`ProjectActivity` 在有活动时渲染汇总与项目、会话级联菜单,`ProjectSwitcher` 复用项目计数。
|
||||
- `App` 向 `ProjectActivity` 传入项目元数据、活动行、侧栏可见状态与既有精确跳转回调。
|
||||
菜单按项目 ID 分组并记忆化派生集合;排序仅影响展示,不改聚合状态或持久化数据。
|
||||
- 两层菜单在同一 body Portal 内相接,桌面会话层使用独立 fixed 定位,不参与项目层高度计算。
|
||||
项目层锚定摘要,会话层锚定选中项目行并独立限制窗口边界。布局在打开、层级变化、实时数据更新、窗口缩放和
|
||||
滚动时按锚点重算;窄窗口隐藏项目面板并保留返回操作。菜单不调用 Modal 焦点隔离,
|
||||
捕获阶段消费 Escape,防止触发窄侧栏的文档级监听;Tab 关闭后交还正常焦点顺序。
|
||||
菜单关闭与焦点恢复在导航回调之前同步完成。
|
||||
- `role="menu"` 使浮层直接进入共享 `browser-viewport-occlusion.ts` 相交检测。未增加
|
||||
浏览器实例的特殊隐藏条件,也不关闭或重建浏览器会话。
|
||||
- 持久化快照刷新时,同一条仍在流式输出的消息保留本地待审批、待回答信息,持久化终态
|
||||
则清除这些信息。响应成功后对应 Task 立即退出等待状态;响应期间到达的新问题、审批或
|
||||
终态不能被旧响应覆盖。
|
||||
@@ -406,3 +432,6 @@ resourceMonitor.getLocalSnapshot(input: {
|
||||
列表并合并,再查找同一 ID。刷新失败或会话已不存在时显示通知,不回退到项目最新会话。
|
||||
设置离开检查通过后才提交项目、会话及界面清理状态。
|
||||
- 远程恢复成功提示沿用独立的自动消失计时器;活动汇总更新不重置或替代该计时器。
|
||||
|
||||
验证记录见[项目活动级联实施进度](./progress.md)。本次只改 Renderer 展示与交互,远程
|
||||
Runtime、Agent 协议和桌面到 Agent 的执行路径未改变。
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
```text
|
||||
品牌
|
||||
项目选择
|
||||
项目活动汇总(有活动时)
|
||||
项目活动汇总(仅有活动时显示)
|
||||
新对话
|
||||
搜索
|
||||
|
||||
@@ -209,15 +209,26 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),状态与计数遵守
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
- 全局汇总按钮位于项目选择器下方,上行标明“全项目活动”,下行显示待处理与运行中数量;
|
||||
箭头跨两行居中,无活动时整个按钮隐藏。
|
||||
- 全局汇总按钮仅在有活动时显示在项目选择器下方,紧凑显示待处理与运行中数量,空闲时整行隐藏,
|
||||
右侧显示展开箭头,与下方新建会话保留 `8px` 间距。可访问名称明确数量属于“全项目活动”。
|
||||
- 项目菜单在项目行显示同口径数量,保留“本地项目 / 远程项目 / 远程通道”分组和 SSH Host
|
||||
层级,不因活动状态重排项目。数量为零时不显示标记,连接状态仍独立显示。
|
||||
项目名独占一行,状态数量排列在名称下方,避免窄侧栏中数量挤占名称宽度。
|
||||
- 点击汇总打开活动对话框,先列“待处理”,再列“运行中”,空分组隐藏。每行显示项目名称、
|
||||
会话标题及明确状态文字,不展开 Task、Job 或 Run 列表,也不自动切换用户正在查看的会话。
|
||||
- 点击一行先关闭对话框、释放 Modal 焦点隔离,再请求进入该条会话;允许离开当前页面后,
|
||||
- 点击汇总在按钮下方打开项目菜单,只列有活动会话的项目,每项显示项目名和同口径计数。
|
||||
桌面鼠标悬停或点击项目展开右侧会话菜单,右侧空间不足且左侧可容纳时向左展开。两个面板
|
||||
相接,各自拥有边框、圆角与阴影;会话层对齐所选项目行,底部空间不足时仅上移会话层,
|
||||
不改变项目层的位置或高度。指针离开项目行进入会话层时不收起。展开项目不切换当前会话。
|
||||
- 会话层顶部显示项目名,每行显示完整可换行的会话标题与明确状态文字,待处理会话排在
|
||||
运行中会话之前。项目行保留子菜单箭头,会话行不显示箭头。不展开 Task、Job 或 Run 列表。
|
||||
- 点击会话先关闭菜单并恢复入口焦点,再请求进入该条会话;允许离开当前页面后,
|
||||
选择所属项目、清除搜索和该会话未读标记、清除任务选择与会话操作菜单,窄窗口同时关闭侧栏。
|
||||
设置页沿用未保存修改的离开检查,取消离开时保留原页面。
|
||||
- 对话框沿用全局 Modal 规范,支持 Tab 循环、Escape、关闭按钮、点击遮罩关闭及焦点恢复;
|
||||
窄窗口限制宽高并在内容区滚动。活动全部结束时关闭对话框并隐藏入口。
|
||||
- 菜单挂载到 body,使用 `menu`、`menuitem`、`aria-haspopup`、`aria-expanded` 与控制关系,
|
||||
不隔离背景、不增加遮罩。原生浏览器相交遮挡沿用共享机制。
|
||||
- 打开后焦点进入首个项目;上下方向键循环选择当前层项目或会话,Home、End 到首尾,
|
||||
Enter、Space 激活,右方向键进入会话层。左方向键或 Escape 返回项目层并恢复对应项目
|
||||
焦点;顶层 Escape 关闭并恢复汇总焦点,不继续关闭窄侧栏。Tab 关闭菜单后继续正常焦点
|
||||
顺序;点击外部、焦点离开、再次点击入口或隐藏侧栏也关闭菜单。
|
||||
- 单面板宽度为 `264px`,窗口小于 `620px` 或项目菜单两侧均无法容纳子菜单时,就地进入会话层并显示“返回项目列表”,
|
||||
触控无需悬停。浮层距窗口边缘至少 `16px`,每层最大高度为 `480px`,短窗口进一步限制
|
||||
高度并独立滚动。活动全部结束时,关闭已打开菜单并隐藏摘要。
|
||||
|
||||
@@ -234,3 +234,74 @@
|
||||
- 不因宽度不足隐藏文件、终端、浏览器、资源监控或设置
|
||||
- 触控点击区域符合统一界面规范
|
||||
- 关闭侧栏抽屉后焦点返回原触发按钮
|
||||
|
||||
## 6. Epic E:项目活动级联
|
||||
|
||||
### US-E1 从摘要进入确切会话
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)。用户在当前项目工作时,可从项目选择器下方
|
||||
查看跨项目活动,先定位项目,再进入需要处理的会话。
|
||||
|
||||
- Given 多个项目存在运行中和待处理会话,当前搜索仅匹配当前项目
|
||||
- When 打开全项目活动并悬停或点击另一个项目
|
||||
- Then 项目顺序保持稳定,该项目会话按待处理优先展示,当前页面和会话保持原选择
|
||||
- When 点击会话
|
||||
- Then 进入该确切 Conversation ID,清除搜索、任务选择和会话菜单,窄窗口关闭侧栏
|
||||
- Given 设置页有未保存修改
|
||||
- Then 会话跳转仍需通过既有离开检查,取消离开保留设置草稿和原项目
|
||||
|
||||
### US-E2 键盘、窄窗口与实时结束
|
||||
|
||||
- Given 菜单已打开
|
||||
- Then 上下方向键与 Home、End 选择当前层,右方向键或 Enter 进入会话层,左方向键或
|
||||
Escape 返回;再次 Escape 关闭活动菜单并恢复摘要焦点,窄侧栏保持打开
|
||||
- Given 窗口小于 `620px`
|
||||
- Then 点击项目后仅显示会话层,可通过返回按钮回到项目列表;长列表可滚动到最后一项
|
||||
- Given 鼠标从项目行移向相接的会话面板
|
||||
- Then 子菜单保持打开,点击会话正常导航
|
||||
- Given 当前项目的活动全部结束
|
||||
- Then 返回项目层并恢复可用焦点;全局无活动时整行隐藏摘要并关闭菜单,新活动到来时只恢复摘要
|
||||
- When 点击外部、焦点离开、按 Tab 或隐藏侧栏
|
||||
- Then 菜单关闭,后台新活动不自动重新展开
|
||||
|
||||
## 7. 后续需求场景
|
||||
|
||||
本节对应新增计划,不计入应用中心首期完成条件。
|
||||
|
||||
### US-F1 连接组织应用市场
|
||||
|
||||
对应 [FR-15](./prd.md#fr-15-私有化市场与-yaml-交换后续)。
|
||||
|
||||
- Given 组织已部署提供应用市场的 ShareServer
|
||||
- When 用户配置市场 URL,并在来源要求时完成登录
|
||||
- Then 可以浏览该来源的应用目录、详情及可获取版本
|
||||
- Given 市场暂时不可达
|
||||
- Then 显示失败原因与重试入口,已安装和内置应用仍可打开,不切换公共市场
|
||||
|
||||
### US-F2 导入导出应用定义
|
||||
|
||||
对应 FR-15。
|
||||
|
||||
- Given 用户需要在私有部署间迁移应用
|
||||
- When 导出应用为 `.yml` 并在另一台设备导入
|
||||
- Then 预览应用身份、版本、可迁移配置和依赖,凭据与业务数据不包含在导出文件中
|
||||
- Given 同标识应用已存在
|
||||
- Then 用户可选择更新或取消,取消后原应用与配置保持不变
|
||||
- Given 文件格式无效、版本不支持或离线环境缺失程序包或模型资源
|
||||
- Then 显示具体问题与补充要求,不将应用误报为可运行,也不自动启动模型或任务
|
||||
|
||||
### US-F3 管理本机模型服务
|
||||
|
||||
对应 [FR-16](./prd.md#fr-16-本机模型管理入口新增计划项)。
|
||||
|
||||
- Given 用户使用依赖本机模型的应用
|
||||
- When 从应用中心或常驻菜单打开“本机模型”
|
||||
- Then 可查看已接入模型的状态、管理方式及使用方,两处入口访问同一页面
|
||||
- Given 多个应用共用一个 GoodBuddy 受管模型服务
|
||||
- Then 展示同一服务及使用方,启动操作不重复创建进程
|
||||
- When 用户停止或重启仍有任务使用的受管服务
|
||||
- Then 先展示任务影响供用户决定,取消后服务保持运行;操作失败显示原因与实际状态
|
||||
- Given 模型服务由外部程序启动,且未接入其管理接口
|
||||
- Then 展示“外部管理”与连接配置,不提供停止或重启
|
||||
- When 用户关闭管理页或取消常驻
|
||||
- Then 模型运行状态不变
|
||||
|
||||
@@ -10,7 +10,16 @@
|
||||
| [多终端页签 PRD](./terminal-tabs-prd.md) | 工作栏应用的固定、可关闭、单实例和多实例策略,以及多终端生命周期 |
|
||||
| [浏览器多实例技术设计](./browser-tabs-technical-design.md) | 浏览器 Tab 身份、Conversation 绑定、IPC、BrowserService 和 MCP 路由 |
|
||||
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
|
||||
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、托管 Runtime 并行与输出及消息底部请求状态的适配范围 |
|
||||
| [执行记录存储与升级回收](./execution-history-storage.md) | 子任务过程差量存储、远程去重、旧库转换、空间回收及升级界面 |
|
||||
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、并行与输出、消息请求状态及资源生命周期规则 |
|
||||
| [Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md) | 跨 Runtime 资源调研、锁定协议证据、进程与会话拆分、远端模型桥、实施顺序和性能验收 |
|
||||
| [Runtime 执行清单技术设计](./runtime-checklist-technical-design.md) | OC/CN 原生清单提取、远程 Agent 传递复用、消息恢复与真实部署验收;尚未实现 |
|
||||
|
||||
当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;相关内容仍保留在两份现有 PRD
|
||||
中。浏览器多实例涉及跨进程身份和工具路由,单独使用技术设计文档维护。
|
||||
当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;产品场景与验收保留在现有 PRD,
|
||||
Runtime 资源状态规则由交互边界文档维护。浏览器多实例和 Runtime 进程复用涉及不同的
|
||||
跨进程身份与工具路由,分别使用技术设计文档维护。
|
||||
|
||||
执行清单的需求、场景和顶部布局见 [PRD](./prd.md#131-runtime-执行清单),状态规则见
|
||||
[Runtime 交互边界](./runtime-interactions.md#runtime-执行清单)。本目录拥有该功能;
|
||||
[Task 与 Job](../task-and-job/README.md)拥有产品任务领域,
|
||||
[远程主机](../remote-host/technical-design.md#执行清单接入范围)拥有远程部署与连接边界。
|
||||
|
||||
@@ -77,8 +77,16 @@ Record<ConversationId, Record<BrowserTabId, BrowserLiveState>>
|
||||
非活动 Browser Tab 保持页面和状态,但隐藏 `WebContentsView` 并停止非必要画面采集。工作栏
|
||||
收起、切换主页面和窗口隐藏时采用相同规则,不销毁 Tab。
|
||||
普通导航、快照与页面操作只同步轻量导航状态,不自动截图、等待画面或传输 JPEG;只有显式
|
||||
`browser_screenshot` 调用生成图片。侧栏内终端关闭确认显示期间,同样隐藏原生浏览器视图,
|
||||
确认或取消后恢复当前实例,避免原生子视图遮挡 HTML 确认按钮。
|
||||
`browser_screenshot` 调用生成图片。
|
||||
|
||||
`BrowserViewport` 统一观察 body Portal 的插入、移除和可见属性变化,以及浮层尺寸、
|
||||
窗口尺寸、滚动和过渡结束。`browser-viewport-occlusion.ts` 按共享语义识别
|
||||
`aria-modal="true"`、dialog、alertdialog、menu 和应用通知:可见应用级 Modal 始终释放
|
||||
viewport,其余浮层只在与浏览器区域相交时释放。隐藏节点和隐藏祖先不阻塞浏览器。
|
||||
最后一个遮挡消失后,只有当前活动实例恢复同一 viewport 租约;网页、历史和页面状态不变。
|
||||
终端关闭确认复用此机制,不再维护单独的隐藏条件。页面无需逐个登记弹窗,也不能靠提高
|
||||
CSS `z-index` 覆盖原生子视图。此逻辑仅涉及 Desktop Renderer 的显示租约,不改变
|
||||
Runtime、MCP 或远端 Agent 的请求、导航与生命周期。
|
||||
|
||||
浏览器应用声明 `visibleAcrossContextSwitches: true`,打开的 Tab 跨项目、会话切换继续显示。
|
||||
切换活动 Conversation 不改变当前选中的浏览器或其 `targetRef.conversationId`;用户在会话 B
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 执行记录存储与升级回收
|
||||
|
||||
通用迁移、备份、性能测量和发布要求见
|
||||
[数据库迁移与大数据升级指南](../../development/database-migrations.md)。
|
||||
|
||||
## 用户行为
|
||||
|
||||
子任务仍实时显示完整的文字、思考、工具过程和最终结果。运行时间较长、工具输出较多时,
|
||||
数据库不再为每个流式更新重复保存此前的整份过程;本机和托管 SSH OpenCode 使用相同
|
||||
的桌面存储机制。此修复不关闭子任务、不截短已有过程,也不增加 Execute 权限门槛。
|
||||
|
||||
已有数据库在首次升级时自动处理。启动页铺满窗口,显示处理数量、全宽进度条和近期
|
||||
处理速度;处理时间取决于历史数据量与磁盘速度,不承诺固定几分钟。用户可以退出,
|
||||
下次启动重新检查并继续转换未完成的数据;完成后回收空闲页并进入应用。失败时留在
|
||||
该页提供重试与退出,磁盘空间不足会明确说明,不删除聊天来腾出空间。
|
||||
|
||||
## 存储规则
|
||||
|
||||
- `messages.metadata_json` 继续保存供会话显示的完整子任务过程。
|
||||
- `task_events` 的 `subagent` 内容由共享 `SubagentProgressStorage` 编码。
|
||||
`progressUpdates` 只保存文字追加、变更的块,或确有删除/重排时的完整重置。
|
||||
完全相同的过程使用空更新数组。工具字段按值比较,缺省字段与 `undefined` 等价,
|
||||
不因 JSON 省略空字段而重复写入整个工具结果;空字符串与实际输出/错误变化仍保留。
|
||||
其他事件内容不改变。
|
||||
- 每个任务内按事件 ID 顺序、按 `childTaskId` 独立还原过程。事件 ID、顺序、状态、
|
||||
最终输出、错误、时间及远程来源字段均保留,不按天数裁剪或删除旧事件。
|
||||
- Runtime、Agent transcript、Preload 和 Renderer 的公共 `SubagentEvent` 仍传完整
|
||||
`progress`;差量表示只存在于桌面数据库内部,不要求更新远端 Agent 协议。
|
||||
- 远程消息投影与事件插入仍共用 SQLite 事务。重放时还原已保存内容进行等值校验,
|
||||
相同来源重复事件不重复投影,不同内容继续报冲突。内存过程缓存通过已提交事件 ID
|
||||
核对;事务回滚或重新打开后从数据库重建,不保存独立快照或回收日志。
|
||||
- 存储层最多缓存 16 个任务;收到任务完成、错误、取消或中断的终态记录后立即移除该任务
|
||||
的缓存引用,不影响其他会话。若事务随后回滚或仍收到该任务的后续事件,按已提交记录重建。
|
||||
释放引用不等于进程 RSS 立即下降,垃圾回收和 Runtime 自身会话缓存另按生命周期回收。
|
||||
|
||||
## 旧库升级
|
||||
|
||||
桌面在打开业务数据库之前启动独立 Node 工作线程,使用同一套编码和还原函数转换旧库。
|
||||
按任务有序读取,每批最多 32 条,提交后报告进度;SQLite 保留事务原子性及正常 WAL
|
||||
自动 checkpoint,每个任务结束时截断 WAL。读取缓存为 64 MiB,避免 1 MiB 级历史
|
||||
快照在过小缓存中反复读取;不以关闭事务或同步落盘换取速度。
|
||||
|
||||
转换可以混合读取旧完整记录与已经转换的记录,也会重新编码 schema 34 中重复的工具块
|
||||
更新。因此退出、磁盘写入失败后可以重新扫描并继续;已是相同 JSON 的记录不重复更新,
|
||||
不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非
|
||||
在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。
|
||||
|
||||
当前源码的业务初始化随后将数据库升级为 schema 35;schema 34 用户也会执行本次整理,
|
||||
成功后后续启动直接跳过。继续复用 schema 34 的子任务事件部分索引和同一差量表示。
|
||||
低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用
|
||||
升级前备份,不要修改 `user_version` 强行绕过版本检查。
|
||||
|
||||
## 验证要求
|
||||
|
||||
- 使用可还原的合成过程验证文字追加、内容修订、块重排、清空和多次打开。
|
||||
- 工具 fixture 必须含生产对象会携带的 `undefined` 可选字段,并经过实际 JSON 写入/重读。
|
||||
同时验证旧完整事件和 schema 34 差量事件的整理、混合重试及无变化记录不被重写。
|
||||
- 验证远程重复事件、冲突事件,以及消息更新失败时事件和过程缓存的事务回滚。
|
||||
- 在真实旧库的已核验备份副本上运行 portable 的正常启动入口,核对事件逐条等价、
|
||||
其他用户表内容、数据库完整性和实际文件大小,不能只统计空闲页或只测试内存 fixture。
|
||||
- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。真实场景必须让主代理
|
||||
与子代理按正常任务需要读取、修改文件和执行测试,不能用“子代理不使用工具”的纯文字
|
||||
场景代替工具存储验收;记录实际请求数、有效变化、重复工具块及还原结果。具体证据见
|
||||
[实现与验证进度](./progress.md)。
|
||||
@@ -245,7 +245,7 @@ Runtime
|
||||
项目切换器和导航整体向下推移。
|
||||
- 对话内容与输入区始终保留对称的左右安全边距,工作栏边界不得覆盖或裁切输入框、发送
|
||||
按钮及主工作区右侧边距。
|
||||
- 唯一展开与收起开关固定在窗口关闭按钮正下方。工作栏内部不重复关闭入口。开关图标中的
|
||||
- 唯一展开与收起开关固定在顶栏操作区、紧邻浅色/深色主题按钮之前。工作栏内部不重复关闭入口。开关图标中的
|
||||
箭头方向随当前动作切换,按钮不以持续高亮表达展开状态;当前会话没有 Task 时不渲染空
|
||||
Task 上下文栏。
|
||||
- “新建任务”入口位于工作栏“任务索引”标题后方,不在会话 Task 上下文栏重复出现;索引
|
||||
@@ -937,6 +937,55 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
应用通知;预览失败、终端断线、Host Key 变化、监督证据失效等需要本地恢复的错误留在面板
|
||||
内。同一事件不得同时重复显示为面板警告和应用通知。
|
||||
|
||||
### 13.1 Runtime 执行清单
|
||||
|
||||
本节为待实施设计。用户需要在对话顶部查看 Agent 本轮列出的工作项及完成情况,避免在
|
||||
工具记录中反复查找。这里的“执行清单”由 Runtime 更新,术语边界见
|
||||
[Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md#17-runtime-执行清单)。
|
||||
|
||||
- **FR-CL1**:接入 OpenCode(OC)的 `todowrite` 与 Continue(CN)的 `Checklist`,
|
||||
范围包括本地及远程 GoodBuddy Agent 上运行这两个 Runtime。远程 CN 的部署和正常请求
|
||||
路径也属于交付范围,当前可用程度见[进度](./progress.md#2026-09-15-runtime-执行清单设计未实现)。
|
||||
- **FR-CL2**:顶部展示本轮最新有效清单,更新按整份替换处理,支持明确清空。每轮请求、
|
||||
会话、项目、执行空间与 Runtime 的清单分别归属;普通聊天续轮不能沿用旧清单冒充本轮进度。
|
||||
- **FR-CL3**:清单只读,用户不能手动勾选。请求完成、失败或取消保留清单实际状态,
|
||||
不自动完成工作项;原生工具调用继续进入消息与活动记录。
|
||||
- **FR-CL4**:重新打开会话可恢复已保存的清单及请求结果,远程重连遵循已有 Agent 恢复
|
||||
流程,不重新发送模型或工具请求。没有原生清单的请求不显示空占位。
|
||||
|
||||
ACP 仅是传输及远端协议,不分类为 Runtime。DeepSeek Harness(DS)真实默认环境无
|
||||
todo/plan,本次不为其增加工具。也不增加 plan 工作模式、计划文档、计划编辑器或定时任务
|
||||
能力;只有格式已确认的原生 plan entries 可以适配清单。
|
||||
|
||||
#### 顶部布局
|
||||
|
||||
执行清单位于聊天内容区顶部、已有 Task 上下文栏下方、消息流上方;没有 Task 时仍可独立
|
||||
显示。它不占用窗口标题栏操作区,也不替换 Task 摘要、任务索引或右侧 Runtime 栏目。
|
||||
布局遵循 [UI-DESIGN](../../../UI-DESIGN.md#610-应用顶栏与全局操作)。
|
||||
|
||||
默认折叠为一行“执行清单 · 已完成 m/n”,有进行中条目时附简短摘要;展开后按原生顺序
|
||||
显示内容、状态及原生提供的优先级。行内状态使用图标和“待处理 / 进行中 / 已完成 / 已取消”文字,
|
||||
不用可勾选 Checkbox,也不根据位置推测优先级或进行中状态。请求结束后的摘要附真实
|
||||
请求结果,例如“已取消”,不改变条目状态。后台更新不强制展开、不抢焦点。
|
||||
|
||||
展开按钮提供 `aria-expanded` 和 `aria-controls`,支持 Enter/Space,折叠后焦点留在
|
||||
按钮;实时区域只播报摘要变化。复用全局颜色、间距和字体令牌,浅深主题均可读。窄窗口
|
||||
摘要截短、展开内容换行并有界滚动,保留完成计数、请求结果与输入区;200% 缩放时仍可
|
||||
操作。没有清单或收到有效空清单时整行隐藏,历史内容仍按消息归属查看。
|
||||
|
||||
#### 场景验收
|
||||
|
||||
| 场景 | Given / When / Then | 需求 |
|
||||
| --- | --- | --- |
|
||||
| US-CL1,连续更新 | OC/CN 本轮产生清单并修改、重排或删除条目;顶部显示最新完整内容,工具活动仍可查 | FR-CL1–FR-CL3 |
|
||||
| US-CL2,请求隔离 | 同会话开始下一请求、切换 Runtime 或并行使用另一会话;旧轮迟到事件不覆盖当前清单,历史按原归属保留 | FR-CL2、FR-CL4 |
|
||||
| US-CL3,失败与取消 | CN 更新失败或请求被取消;失败候选不替换上次有效清单,取消不把未完成项勾完;有效空清单清除顶部 | FR-CL2、FR-CL3 |
|
||||
| US-CL4,本地与远程恢复 | 重新打开本地会话或重连共享 Linux Host 上的 OC/CN 请求;已有记录按原请求恢复,重复事件不重复展示,不重新调用模型或工具 | FR-CL1、FR-CL4 |
|
||||
| US-CL5,无清单与无障碍 | DS 默认请求或普通 Markdown/list 工具输出没有已确认清单;顶部不出现伪清单;有清单时键盘、窄窗口及浅深主题可用 | FR-CL1–FR-CL3 |
|
||||
|
||||
行为规则见 [Runtime 交互边界](./runtime-interactions.md#runtime-执行清单),接入与验证见
|
||||
[技术设计](./runtime-checklist-technical-design.md)。
|
||||
|
||||
## 14. 性能与资源边界
|
||||
|
||||
- 工作栏关闭或面板实例关闭时停止对应非必要画面和高频日志推送,但保留 Main 中的受管运行。
|
||||
@@ -949,6 +998,41 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
用户级 GoodBuddy Agent Daemon。依赖 Main 模型网关、审批或本机工具的 Run 不能被描述为
|
||||
可以无人值守继续。
|
||||
|
||||
### 14.1 Runtime 资源复用
|
||||
|
||||
2026-09-14 调研确认:多个项目可能各自保留一个重型 Runtime,状态或原生清单读取也会
|
||||
影响其启动与保留。用户需要多项目/多会话自然并行工作,而不是为避免内存增长关闭工具、
|
||||
子代理或删除历史。以下为新需求,**不表示 Desktop 0.13.2 / Agent 0.11.25 已实现**。
|
||||
|
||||
- **FR-RR1**:支持原生多会话的 Runtime,在同一账号和兼容启动配置下跨项目复用重型
|
||||
进程;不得仅因项目目录或会话不同重复启动。等价的默认和显式选择应得到相同复用结果。
|
||||
不兼容的 Runtime、Host、模型连接或插件配置可以分开,不要求全产品只有一个进程。
|
||||
- **FR-RR2**:仅查看被动状态不启动重型 Runtime。显式连接测试、原生清单和执行请求
|
||||
复用兼容的已启动实例;尚未握手时不能显示为已经验证连接。
|
||||
- **FR-RR3**:共享进程仍保持各会话的目录、历史、模型用量、工具、权限和业务问答归属。
|
||||
同一会话顺序执行,不同会话并行;Ask 保持只读,Execute 不增加第二层审批。
|
||||
- **FR-RR4**:请求结束、会话释放与进程退出分别处理。取消一个普通请求、关闭工作栏
|
||||
或释放一个会话不得误停其他健康会话;无会话的空闲重型实例及时释放。正常续聊保留
|
||||
原生上下文,应用退出、配置替换和真实进程故障仍有明确的终态与有界回收。
|
||||
- **FR-RR5**:托管 SSH 独立实现并验证复用,保留 Agent 对活动 Prompt 的所有权、
|
||||
断线续接、事件去重和模型调用结果不确定性规则。不能以本机通过代替远端交付。
|
||||
- **FR-RR6**:对不具备原生多会话 Host 的 Continue 保留正确的请求隔离;直连模型不
|
||||
新增独立模型进程。优化应有前后性能证据,不以删历史、禁用工具、全局串行化或
|
||||
推迟正常用户操作来换取进程/内存指标。
|
||||
|
||||
产品场景验收:
|
||||
|
||||
| 场景 | Given / When / Then |
|
||||
| --- | --- |
|
||||
| US-RR1,首次与热启动 | 同一有效配置的两个项目,分别查看状态、打开清单并开始四个对话;状态不冷启动,后续共用一个可多会话的重型 Runtime |
|
||||
| US-RR2,并行与取消 | 两个项目有同名不同内容文件,四个会话进行真实开发;目录及成果正确,取消其中一个后其余三个继续完成 |
|
||||
| US-RR3,续聊与释放 | 一轮任务完成后继续原会话,再释放其中一个;原生上下文可续用、其他会话不受影响,数据库完整历史仍可查看 |
|
||||
| US-RR4,边界与远程 | 切换不兼容配置、查看 Continue/直连状态或中断 SSH;不串配置、不伪造多会话能力,Agent 已接受工作按现有归属恢复 |
|
||||
|
||||
资源状态规则由 [Runtime 交互边界](./runtime-interactions.md#runtime-资源生命周期)
|
||||
定义;锁定版本研究、实现选择与逐项验收由
|
||||
[进程复用技术设计](./runtime-process-reuse-technical-design.md)定义。
|
||||
|
||||
## 15. 无障碍与响应式
|
||||
|
||||
- 能力目录、所有面板实例、目标选择器、终端控制和进程操作可用键盘完成。
|
||||
@@ -1091,7 +1175,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
|
||||
- [ ] 宽、中、窄窗口均可访问完整能力目录和用户打开的面板实例。
|
||||
- [ ] 工作栏在宽、中、窄窗口展开时都推动主工作区回流,不覆盖、遮罩或隔离主内容;唯一
|
||||
开关始终固定在窗口关闭按钮正下方。
|
||||
开关始终固定在顶栏操作区、紧邻浅色/深色主题按钮之前。
|
||||
- [ ] 仅使用键盘可以选择能力、面板实例、目标、执行空间和控制动作。
|
||||
- [ ] 状态不只依赖颜色,徽标具有文字或可访问名称。
|
||||
- [ ] 终端、HTML、监督证据和高频日志具有可访问替代或降噪行为。
|
||||
|
||||
@@ -1,5 +1,662 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-15 Runtime 执行清单设计(未实现)
|
||||
|
||||
对应 FR-CL1–FR-CL4。用户本轮明确只完善设计与进展文档,不改代码;
|
||||
[设计与验收](./runtime-checklist-technical-design.md)不代表功能已经接入或发布。
|
||||
|
||||
此前调研已通过真实模型完成以下本地测试;本次文档更新未重新运行测试:
|
||||
|
||||
| Runtime | 已确认的本地研究结果 | 调用口径 |
|
||||
| --- | --- | --- |
|
||||
| OC 1.18.29 | `todo.updated` 与 `session.todo` 为 `content/status/priority` 完整替换,无 item ID | 3 个真实模型轮次 |
|
||||
| CN 1.5.47 | `Checklist.checklist` 为 Markdown `- [ ]` / `- [x]` 完整替换,成功后才提交 | 3 次真实 HTTP 调用 |
|
||||
| DS | 真实默认环境无 todo/plan;本次不加工具 | 1 次真实模型 HTTP 调用,返回 200 |
|
||||
|
||||
上述口径不合并计算为同一种调用次数,也不算 GoodBuddy 顶部清单端到端通过。本轮新增
|
||||
模型调用为 0,未运行代码测试或远程部署验证。
|
||||
|
||||
本轮只读源码检查确认 OC 普通工具输入及 CN 宿主工具输入存在 4,000 字符截断;
|
||||
Agent 已传递带 Session、binding/operation 归属的通知,桌面已有 ACP plan/tool 消费,
|
||||
但 plan entries 当前降为状态文本。这些链路可供设计复用,尚未验证清单端到端接入。
|
||||
|
||||
| 部署 | 已有能力与本功能可用程度 | 剩余工作 |
|
||||
| --- | --- | --- |
|
||||
| 本地 OC/CN | 原生清单格式已有上述实测结论;顶部清单未实现 | CL-P1、CL-P2、CL-P4 待实施及验证 |
|
||||
| 远程 OC | 已有托管 Agent 安装与请求路径,其历史验证见远程文档;本功能没有远程实测 | 原生 entries/tool 格式、完整传递及 CL-P3、CL-P4 待验证 |
|
||||
| 远程 CN | 当前受管安装、项目校验与 Runtime 创建只支持 OC,不能声明 CN 已可部署使用 | CL-P3 需补齐 CN 真实部署路径,再验收 CL-P4;当前本功能未验证 |
|
||||
|
||||
未决技术点:远程 OC/CN 实际发送的原生清单格式与等价性、CN 原生清空表示及 Markdown
|
||||
接受规则、现有消息/事件字段的最小扩展位置、CN 受管工件与启动/模型桥接入方式。
|
||||
产品范围与隔离规则已明确,不因这些待办缩减远程 CN 范围或增加 DS 工具、plan 模式与
|
||||
文档功能。共享 Linux Host 的清单及重连验收全部待验证。
|
||||
|
||||
文档检查:已按 `deai-writing` 审校新增内容;新技术设计扫描为 0 个阻断项、12 个复核项,
|
||||
复核项为必要的实现边界和验收枚举,按原意保留。新增相对链接与章节锚点已人工核对。
|
||||
`git diff --check` 通过,仅输出工作区 LF/CRLF 提示;未修改本轮范围外的既有改动。
|
||||
|
||||
## 2026-09-14 Runtime 进程复用(实施中)
|
||||
|
||||
对应 FR-RR1–FR-RR6,设计及完整验收清单见
|
||||
[Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md)。
|
||||
用户已授权实施,本次复用改动尚未整体提交或发布,不包含在正式 Desktop 0.13.2 /
|
||||
Agent 0.11.25 中。最终验证结果见本节末尾,早期失败及中间状态按时间保留。
|
||||
|
||||
- P0–P2:Main 已组合 `LocalRuntimeRegistry`,OpenCode Server 和 DSH Utility 按兼容
|
||||
配置共享,工作区通过轻量适配器传入 Session。无会话 owner 空闲 60 秒回收;
|
||||
适配器淘汰不结束其他会话。DSH 被动状态不再启动 Host,取消超时先释放目标 Session,
|
||||
同时回收对应 Main 工具资源;配置替换期间的原生问题继续交给原请求的 Runtime。
|
||||
- 首轮 Windows 完整 UI 使用当时构建的生产 Main/Preload/Renderer 和隔离 profile,
|
||||
OpenCode、DSH 各两个项目、四个并行会话,通过输入区和发送按钮完成实际开发。
|
||||
OpenCode 工具数为 7、7、6、8;DSH 为 9、13、11、11。独立运行各项目测试合计
|
||||
60 项通过。两类 Runtime 分别共用一个重型进程,本轮未使用原生子代理。
|
||||
- 首轮 UI 提供方请求共 55 次 HTTP 200:OpenCode 23、DSH 32。其中包含驱动选择器
|
||||
失误引发的一条额外默认项目 Ask,工具为 0,不把它计为开发任务验收。
|
||||
同轮 OpenCode 单进程 Private Commit 约 882.88 MiB,工作集约 629.51 MiB;
|
||||
Electron 自身指标另存,不将两者混为整个 App 的测量结论。后续重复运行和空闲
|
||||
证据见下文,不把这一单点与正式用户库直接比较。
|
||||
- P3:生产 Agent 已接入单 ACP decoder/握手、Session → binding/operation 路由、
|
||||
逐 operation Unix 模型桥及空闲回收。共享 stdio 与 Prompt 输入/期限分离;
|
||||
Session 取消结算可如实保留 `processTree: running`。未增加持久进程池账本。
|
||||
- 共享 Linux x64 Host 已通过现有加密凭据与固定 Host identity 连接。使用当前源码、
|
||||
固定 Node 24.19.0 / OpenCode 1.18.29 构建隔离测试包,走真实 prepare/commit、
|
||||
activate、attach 和 Agent-owned Prompt,不改写已有 Host 安装。
|
||||
- Linux 首轮五条 Prompt 共 22 次已完成且已交付模型调用;两个 Execute 分别在正确
|
||||
项目完成修改,各通过 8 项测试,四个会话只启动一个原生进程。但 Ask 的搜索被原
|
||||
`kind === read` 规则误拒,原生子代理工具因权限请求未转发而等待,首轮验收失败。
|
||||
修正采用原生 Session 工具规则;原生子代理只继承父拒绝规则、不继承允许规则这一点
|
||||
已按固定版本源码核实,子 Session 同样设置当前模式,并保留原生显式限制。
|
||||
- 下一轮测试脚本在 transcript 尚有分页时按已完成状态提前结束,误报缺少项目 marker,
|
||||
随后 cleanup 中止了其他三个请求。这是验证驱动缺陷,产品自身已有 `hasMore` 处理;
|
||||
驱动已修正为读完全部分页、等待全部并行结果。该轮共 14 次 dispatch,
|
||||
其中 11 次完成、3 次结果不确定,失败记录保留,不将其计为通过。
|
||||
- 重复测试包构建曾遇到 Host ENOSPC。只清理本轮已确认可重建的 bundle、Runtime
|
||||
staging 和重复 `.gbagent`,保留测试 home、workspace、SQLite 及报告,继续验证。
|
||||
- 后续两轮 Linux 各 7 条 Prompt、35/34 次 dispatch,四个 Ask/Execute 与原生子代理
|
||||
续接均通过,分别取得 483/467 条子会话事件;取消没有关闭共享 owner。重连驱动先后
|
||||
漏发 `controller/resume`、错读 `agent/capabilities.generation`,导致两次后续验收
|
||||
失败;均已修正,保留失败报告。35 次调用一轮包含 1 次结果不确定,34 次一轮全部完成。
|
||||
- 最终当前源码 Linux 生命周期复验:2 条 Prompt、5 次完成且已交付调用,共享一个
|
||||
owner。目标返回 `cancelled / processTree: running`;另一会话跨真实 SSH 断线、
|
||||
controller resume 和重新 attach 继续执行,3 个工具返回正确项目 marker。
|
||||
全部 binding 释放后等待 65 秒,owner 数为 0;停止隔离 Agent 后仍为 0。
|
||||
- 原生 Compact 单独复验:两项目各先完成 3 工具请求,再并行压缩原生 Session。
|
||||
两次压缩分别归到各自的新 operation,各 1 次已交付调用,前后为同一个 owner;
|
||||
总计 4 条 Prompt、8 次完成且已交付调用。释放会话并等待 65 秒后 owner 为 0,
|
||||
Agent 停止后仍为 0。该结果没有用普通聊天返回“已压缩”代替原生 Compact。
|
||||
- 第二轮 Windows 完整 UI 重开同一隔离 profile,四项目八条续接开发请求全部完成,
|
||||
OpenCode 包含原生子代理,DSH 使用已有原生工具。独立项目测试合计 77 项通过。
|
||||
两个 Runtime 仍各一个重型进程。另有四条实际慢工具请求:前两条 30 秒测试在点击
|
||||
停止前已结束,不作为取消通过;后两条 120 秒测试中目标被正常 Stop 取消,另一项目
|
||||
的测试继续完成,DSH PID 未变化。第二个 App 共 12 条任务,11 完成、1 取消;
|
||||
100 次 HTTP 200(OpenCode 24、DSH 76),无失败请求。
|
||||
- 第二轮任务结束后的 631.573 秒采样共 121 点,提供方请求计数始终为 100,
|
||||
DB 始终 2,928,640 字节、WAL 始终 4,268,352 字节。期间查看了项目和截图,属于
|
||||
**无新任务/模型请求**,不是完全无人操作的 UI。原生 Session 有意保留,不要求其
|
||||
Runtime 退出;首个 App 自行退出时的进程消失不计为空闲计时验收。
|
||||
|
||||
| 进程 | Private Commit 起止(MiB) | 范围(MiB) | 区间 CPU 秒 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| OpenCode | 826.34 → 825.05 | 824.36–830.82 | 12.03125 |
|
||||
| DSH Utility | 98.73 → 98.73 | 98.73–98.73 | 0 |
|
||||
| Main | 184.16 → 185.05 | 182.90–186.77 | 0.953125 |
|
||||
| Renderer | 142.80 → 136.22 | 135.00–142.80 | 0.6875 |
|
||||
| GPU | 120.48 → 138.88 | 120.48–173.52 | 0.0625 |
|
||||
|
||||
Private Commit 不是独占物理内存,工作集相加会重复计算共享页。这组测量未发现两个
|
||||
Runtime 在该空闲区间持续增长,但不是整个 App 无泄漏证明,也不是旧新版本对照。
|
||||
|
||||
- 当前 Linux 六个 ACP/bridge/owner 测试文件为 82 通过、0 跳过,包含 Windows 跳过的
|
||||
Unix broker 并发及旧路由排队拒绝回归。本机完整回归曾为 4,170 通过、67 跳过;
|
||||
首轮唯一失败来自旧测试把 `failed + running` 当成非法,修正后仍保留 `unknown`
|
||||
的拒绝断言,并新增共享失败/取消接受用例。
|
||||
- 标准 `typecheck` 原先没有把部分 Agent 源码作为检查入口。已增加
|
||||
`tsconfig.agent.json`,并修正暴露的 ACP resume capability、spawn stdio 及几个局部
|
||||
类型问题;扩展类型检查、26 项受影响 Agent 回归及 lint 通过,之后已再次进行上述
|
||||
当前源码 Host 生命周期与压缩验证。
|
||||
- 三个只读审查分别检查复用、质量/兼容来源和效率。修复了被动状态刷新不断重置
|
||||
60 秒空闲计时的问题;状态检查不取得执行 owner,Native Session 映射成为唯一会话
|
||||
保留依据,不再另存重复 conversation Set。最后本机定向回归 98 通过、3 跳过,
|
||||
含真实 OpenCode 与 DSH ACP 组合;扩展类型检查通过。该补修仅涉及本机 registry,
|
||||
不改变 Agent/远程路径。当时全量 `npm test` 为 **4,171 通过、67 跳过、0 失败**
|
||||
(356 文件通过、9 跳过,609.57 秒);`npm run typecheck`、`npm run lint`、
|
||||
`npm run build` 均通过。构建仍报告既有 chunk 大小/混合导入警告,不影响成功结果。
|
||||
- 保留 exclusive raw ACP 是已有公开字节流路径,不是为本分支中间形态保留的兼容层。
|
||||
新的共享取消/失败结果需要配套 Desktop/Agent,不声称旧 Desktop 已支持新结果。
|
||||
原生插件两处祖先遍历可局部提取,但没有已证实故障或性能收益,本次不为此扩展改动。
|
||||
- 本轮真实模型调用合计 **273 次**:本机两轮 UI 155 次,Linux 六轮
|
||||
**118 次 dispatch**(29 条 Prompt,114 次完成、4 次结果不确定)。
|
||||
包含所有失败轮次和误发默认 Ask,不重复计算最新报告别名;本机 HTTP 200 不等于
|
||||
Agent ledger 的完整交付证明。原生确定性协议测试不计入真实提供方调用。
|
||||
|
||||
补充的 owner 成本对照使用同一当前源码、同一 Windows 机器、全新空工作区,分别经
|
||||
生产 factory 创建两个独占实例或两个共享适配器。每种组合重复 3 轮,每轮显式连接后
|
||||
再做 5 次双工作区连接检查;共启动 18 个真实 OpenCode/DSH Utility,全部退出,
|
||||
**真实模型和本机合成模型请求均为 0**。测量前全量测试已结束,没有同时运行测试套件。
|
||||
|
||||
| Runtime / owner 方式 | 进程数 | Private Commit 中位数及范围(MiB) | 双工作区首次 ready 中位数及范围(ms) |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| OpenCode 两个独占实例 | 2 | 1,401.17(1,396.15–1,517.91) | 1,195.00(1,184.14–1,213.49) |
|
||||
| OpenCode 一个共享 owner | 1 | 777.59(667.04–802.78) | 1,135.71(1,130.32–1,140.81) |
|
||||
| DSH 两个独占实例 | 2 | 171.62(170.79–173.50) | 167.06(163.36–167.78) |
|
||||
| DSH 一个共享 owner | 1 | 86.64(86.18–87.29) | 155.31(155.28–157.79) |
|
||||
|
||||
这是**进程所有权方式**的对照,不是旧版 App 对新版 App、等规模历史或真实开发负载
|
||||
对照;没有测模型首字节。DSH 后续连接检查读取已缓存握手,不能当作热 Prompt 延迟。
|
||||
最后一个共享 DSH 在无 Session 时每 5 秒读取状态,持续 65 秒,原 Host 已退出且没有
|
||||
重启,证明状态轮询不会续期空闲 owner。探针早期的类型及 CJS/ESM 打包错误均发生在
|
||||
模型调用前;改为正确 ESM 依赖解析并加入启动前语法检查后完成测量,未修改产品以适应驱动。
|
||||
|
||||
清理时,完整 UI 的“关闭窗口”按现有产品行为隐藏到托盘,并不退出 App。随后只对已无
|
||||
活动任务且重新核对启动命令的隔离 App 进程树进行清理;这不作为正常托盘退出验收。
|
||||
命名浏览器会话已关闭,本机按本轮启动路径筛选的进程数为 0。最终通过固定身份 SSH
|
||||
只读核对本轮 Linux 路径下的 `/proc` 进程和各测试安装 owner 数据库,两者均为 0;
|
||||
保留测试 profile、工作区、数据库和报告,未删除用户历史或停止用户 Runtime。
|
||||
|
||||
此时尚未完成的完整 UI、十项目和问答恢复验收,后续结果见以下继续验收记录。
|
||||
旧新版本全 App 对照、完整共享能力组合及 Continue/直连资源分解仍需独立完成,
|
||||
不能将 P0–P4/V1–V8 整体标为完成。
|
||||
|
||||
本机证据位于忽略目录 `.agent-resources/runtime-reuse-implementation-20260914/`,
|
||||
包括 UI 聚合指标、截图、Host 聚合报告及保留的失败轮次。这些不是 checkout 自带资产,
|
||||
不包含可提交的模型或 SSH 凭据。
|
||||
|
||||
### 继续验收:远端完整 UI 与模式切换
|
||||
|
||||
- 用户要求继续后,已从完整生产 Electron UI 启用远程项目、保存两个 SSH 项目并在
|
||||
各项目创建两条会话。仅在测试 SSH 命令入口将 `HOME` 指向既有隔离安装;项目保存、
|
||||
Host/Agent/Runtime 发现、Main Runtime factory、传输、协议、模型和工具均保持生产实现,
|
||||
未伪造 capability、Prompt、事件或权限结果。
|
||||
- 四条正常开发任务均完成,共用一个原生 owner。分别在各自工作区实现 words/prices,
|
||||
独立运行两项目测试为 12 + 13 = **25 项通过**。实际重叠峰值为 2 条请求,
|
||||
不是四条同时运行;四个会话完成后,共享的原生 owner 仍然存活。
|
||||
- 新增真实模型调用 **21 次**,分别为 5、5、5、6,均 completed 且已交付。
|
||||
与上一段 273 次相加,累计 **294 次**;没有把旧 Agent ledger 的 13 次基线重复计入。
|
||||
- 随后在原价格会话中从 Execute 切到 Ask 并发送原生业务问答请求,实际失败,UI
|
||||
显示“没有可安全附加的远端 Agent 请求,且恢复不会重放任务”。Agent 仍把 workMode
|
||||
固定在 binding 首次启动值,拒绝新模式;此次在模型 dispatch 前失败,新增调用为 0。
|
||||
此问题不能由先前“不同 binding 使用不同模式”的通过结果排除。
|
||||
- 当前修正只允许共享 Session 的后续准备变更模式,独占进程仍保持原契约;
|
||||
`AgentOwnedAcpPrompt.start` 显式接收当前请求模式,原生 Session 路由与 ACP 备用权限
|
||||
决策均使用该模式,不再捕获构造时模式。48 项定向回归和扩展类型检查、lint 通过,
|
||||
包含 Execute → Ask → Execute 保留 Session 与原生父/子问答。
|
||||
后续当前源码 Linux 包和完整 UI 复验已通过,见下一节;48 项是当时的定向结果,
|
||||
不是后续全部修正后的全量结果。
|
||||
- 首轮 UI 通过正常 `app.quit()` 进入生产退出清理,本机测试进程退出;
|
||||
隔离 Agent 随后按 installation identity 停止,`/proc` 与 owner 数据库均为 0。
|
||||
首轮报告、失败任务和工作区保留。再次构建前 Host 仅余约 251 MiB,已明确限定只回收
|
||||
更早停止的测试安装软件及重复测试包,不清理状态、历史或工作区。
|
||||
|
||||
### 继续验收:断线、取消、模式续接与 Desktop 重启
|
||||
|
||||
- 四个原生问题同时等待时切断实际 SSH,复现“ACP channel does not exist”,继而出现
|
||||
重复注册 binary channel。诊断确认 Agent-owned Prompt 可能没有 raw ACP journal 行,
|
||||
replay 却对不存在的行 ACK 0。修正只在已有 raw cursor 时确认 journal,不放宽正序号、
|
||||
channel/epoch 或 installation 检查。真实临时 SQLite 回归先失败再通过;94 项后端/
|
||||
journal/channel 测试、当前源码 Linux 六文件 86 项通过,失败报告保留。
|
||||
- 保留旧 Desktop profile 并更换隔离 Agent 后,旧安装的恢复曾无限重试阻塞项目。
|
||||
attach-only 路径现在在打开 channel 前明确报告安装身份不匹配,不重放 Prompt。
|
||||
同一 Agent 的新连接则保留 Runtime,对已有 binding 旋转 transport generation,
|
||||
新 channel 使用实际连接 generation;不同 daemon 或倒退身份仍按原规则拒绝/替换。
|
||||
- 完整生产 Electron UI 再次同时等待四个问题,实际断线重连后原表单和 Session 均保留,
|
||||
没有新增模型调用或 owner。四会话尚在等待时,第五个新 Session 可以执行文件读写;
|
||||
该 Session 的 Execute → Ask → Execute 三轮均完成,各 2 个工具、3 次模型调用,
|
||||
验证实际写入、只读读取、再次写入,不以模型自述代替文件结果。
|
||||
- 取消一个 120 秒原生命令时,其余三个待答会话仍可各自回答并完成。取消后立即在原
|
||||
对话显式发送新请求也完成,使用新的原生 Session,仍共用原 owner。已确认取消通过
|
||||
专用错误进入终态;未确认的 Stop 保留原恢复逻辑,不把网络中断等同于取消成功。
|
||||
曾出现 Task 已取消但 assistant message 仍 streaming,改用既有数据库终态事务
|
||||
同时更新消息、任务和终态事件。最终真实 UI、Desktop SQLite、Agent 状态一致,
|
||||
streaming message 和 active Task 均为 0,共享进程未被误杀。
|
||||
- Desktop 重启前的首次尝试因测试观察 SSH 挂起,实际上未退出,已明确作废该证据。
|
||||
驱动加有界观察超时和 `finally` 退出后,真实重启复现两个缺陷:live question 没有
|
||||
transcript provenance 却被当作持久事件拒绝;同项目串行恢复让第二个待答 Session
|
||||
无法附加。现由 Main 并行恢复任务,live question 使用已有待答 Map 和活动请求租约,
|
||||
只投影到实时会话快照,不伪造 provenance,不写新的问题日志。
|
||||
- 修正后的真实正常退出/重开恢复两个同时待答的 Session,后一个先回答并完成,
|
||||
前一个独立 Stop。两端终态分别 completed/cancelled,UI 无残留活动或恢复提示,
|
||||
仍为同一个原生 owner。到该轮累计真实提供方调用 **348 次**:
|
||||
273 + 首轮远端 UI 21 + 旧隔离安装诊断 6 + 当前隔离安装 48。
|
||||
48 次全部完成并交付;更早 273 次中的 4 次不确定仍保留,Prompt 取消不等同于
|
||||
模型调用交付不确定。后续审查复验的增量另记,不覆盖这一时间点。
|
||||
- 重启修正后的全量回归为 **4,186 通过、67 跳过、1 失败**,唯一失败是
|
||||
`ActivityPanel` 的 501 条历史显示测试;该文件单独重跑 **16 项全通过**。
|
||||
不将单文件重跑写成全量通过,原全量报告保留。此前 4,171 通过的报告早于这些修正。
|
||||
|
||||
### 继续验收:共享能力与配置退役审查
|
||||
|
||||
- OpenCode 生产 factory、真实二进制测试覆盖 2/10 项目,每项目两 Session、两轮原生
|
||||
文件读取并逐项目原生 Compact,manager 容量设为 1,仍只启动一个 Server。
|
||||
DSH 实际 Host/ACP composition 同样覆盖 2/10 项目、每项目两 Session、两轮原生
|
||||
文件读写和平台 Shell,一个 Host。二者使用确定性模型,新增真实提供方调用为 0;
|
||||
这是原生组合测试,不是十项目完整 Electron 性能测试。
|
||||
- DSH 补充双项目共享组合:原生 Skill、实际 stdio MCP、Host 插件清单、不同尺寸的
|
||||
inline 图片和 Execute → Ask 均通过。两项目使用不同 Main ToolProvider 和正确的
|
||||
conversation context,Ask 不调用先前 MCP,图片原始字节分别匹配。该文件当前
|
||||
**9 通过、3 跳过**;Host 在测试进程内实际运行,不能表述为完整 Utility/UI 能力矩阵。
|
||||
- 完整 61 文件复查分别检查复用、质量/兼容来源和效率。没有新增仅服务分支中间形态的
|
||||
兼容层;raw ACP 独占和 Continue 单请求 Host 保留其已有契约。
|
||||
复查发现重启后的回答/跳过未保存到 remote-authoritative assistant message,以及
|
||||
manager reset 后旧 controller 拒绝所属问题的回答。修正复用原消息 `answeredQuestions`
|
||||
metadata 和既有 question owner,不新增 schema、恢复日志或重复历史。
|
||||
- 真实 UI 复验又确认设置保存入口仍主动取消全部请求,不能以 controller 单测通过
|
||||
宣称生产配置切换完整。Runtime 设置、原生定制和能力更新现在只激活/退役配置,
|
||||
不批量 abort 或清除待答确认;已接受请求继续使用原配置。用户 Stop、清除数据和
|
||||
App 退出的原有取消/分离规则不变。真实 SQLite 的三入口回归覆盖并行恢复、反序回答、
|
||||
跳过、重新打开答案和终态;manager reset 回归覆盖旧问题可回答、新工作仍拒绝。
|
||||
最新 **152 项 IPC/controller/manager 测试、typecheck、lint 和生产构建通过**;
|
||||
这次补修的最终真实 UI 复验及全量回归仍在进行。
|
||||
- 后续真实 UI 的设置保存不再取消原生待答 Prompt;一次连接 readiness 超时后,
|
||||
重新启动 Desktop 附加同一请求并回答 `KeepYes`,原 owner 未更换,任务 completed,
|
||||
原消息的 `answeredQuestions` 已落库。再一次正常退出/重开后,“问题与回答”仍显示
|
||||
正确答案和完成状态,截图已检查。该时点累计真实调用 **352 次**,相对 348 新增
|
||||
当前 Agent 3 次及误选默认项目的本机 Ask 1 次;后者未使用问题工具,不计问答验收。
|
||||
当前 Agent ledger 共 51 次,均 completed 且已交付。正常退出 Desktop 后按已核对的
|
||||
隔离 installation 停止 Agent,所属 `/proc` 进程和 active owner 记录均为 0。
|
||||
- 复查设置更新的专家团队分支发现,既有调度器会取消剩余专家,但父请求可能用替换后的
|
||||
模型继续综合。仅对活动团队父请求保留配置替换取消,普通原生请求不受影响;
|
||||
**156 项 IPC/controller/manager/SubagentService 回归通过**。此分支属于 Main 专家
|
||||
调度,不是 Agent 原生子代理;远端普通 Prompt 的 `teamMode` 为 false,先前验证的
|
||||
SSH 问答路径不改变。
|
||||
- 工作区随后出现其他任务的新提交和 Magic Notes API 修改。整仓类型检查曾因其
|
||||
`DesktopApi.magicNotes.onChanged` 与 App 测试 mock 不匹配失败,没有覆盖这些并行
|
||||
改动。冻结 candidate 的 typecheck、lint、生产构建通过,前后性能对照
|
||||
使用该隔离源码,不滚入后来的功能变更;其已带入的按钮位置调整见下节范围说明。
|
||||
- 随后完成 baseline/candidate 两个独立生产构建、相同 seed 历史、三次冷启动及
|
||||
无新任务十分钟的全 App 对照,结果见下节。驱动校准失败不计性能样本,
|
||||
测量期间未并发全量测试;既有 owner-only 数据不替代此结果。
|
||||
|
||||
### 2026-09-15:前后完整 App 对照与剩余 Runtime 检查
|
||||
|
||||
对照基线为 `de14a6157a6c34f1e3ef22619eca374a50a83706`。通过隔离源码构建 baseline
|
||||
和包含本任务改动的 candidate,使用同一 Windows 机器、同一依赖、同一 seed
|
||||
SQLite(四项目、八会话、320 条等量历史消息)和相同工作区 marker。后续其他工作区
|
||||
提交不滚入该冻结对照;这里的冷启动是新的 App/profile/Runtime 进程,不清 OS 文件缓存。
|
||||
|
||||
截图及源码复核发现,candidate 的 `App.tsx` 同时带入了 `fbba2e3` 的工作栏按钮
|
||||
移到主题切换按钮旁的调整。用户明确要求保留,工作区及冻结 candidate 均未剔除,
|
||||
也不为此补跑。以下结果是**包含该 UI 差异的完整 App 实测对照**,不是严格的
|
||||
纯 Runtime 单变量实验;此前“只含本任务改动”的范围描述不准确,现予以更正。
|
||||
|
||||
- 每组 3 次冷启动,各 55 条请求、110 次确定性 loopback 模型请求。每次冷启动包含
|
||||
八会话一轮工具请求和五条热请求,最后一次另加两轮八会话续接。每轮等待八个真实
|
||||
Native Prompt 同时到达提供方再释放响应,随后实际原生 `read` 读取各项目 marker。
|
||||
全部目录结果正确,无全局 Prompt 串行化。该对照新增真实提供方调用为 **0**;
|
||||
自然开发、写入、Shell 和真实模型验收仍以前文证据为准。
|
||||
- baseline 稳态为 3 个 OpenCode Server、2 个 DSH Host;三轮分别累计启动 3/3/3 个
|
||||
OpenCode 和 4/4/6 个 DSH(包含被动状态临时 Host)。candidate 每轮只启动一个
|
||||
OpenCode、一个 DSH;六次 App 正常退出后,本次登记的全部 Runtime 进程均退出。
|
||||
- 早期驱动的必填模型字段、Windows 子进程继承输出管道、Runtime 未就绪时点击以及
|
||||
读到半份 JSON 报告导致的失败均保留,不纳入以下样本。最终报告原子替换,发送前等待
|
||||
实际启用状态并核对 Main 已接收请求;不重放失败/不确定的真实模型请求。
|
||||
|
||||
| 指标 | baseline,中位数(范围) | candidate,中位数(范围) | 样本口径 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| 全 App 采样峰值 Private Commit,MiB | 3,543.18(3,485.58–3,589.01) | 2,040.11(1,698.68–2,085.82) | 每组 3 个 App,分别取采样峰值 |
|
||||
| Runtime 采样峰值 Private Commit,MiB | 2,843.18(2,831.00–2,854.82) | 1,338.93(1,229.31–1,347.63) | 同上,仅 Runtime 进程 |
|
||||
| bootstrap 到首个模型请求到达,ms | 4,519(4,464–4,529) | 3,573(3,519–4,040) | 每组 3 次,包含 UI 启动与操作,不是独立握手计时 |
|
||||
| OpenCode 热请求总时长,ms | 225(198–286) | 200(187–308) | 每组 9 次 |
|
||||
| OpenCode 首个可见事件,ms | 139(118–201) | 124(102–187) | 文本/推理/工具任一事件,不是模型首字节 |
|
||||
| DSH 热请求总时长,ms | 66.5(54–80) | 83(62–104) | 每组 6 次 |
|
||||
| DSH 首个可见事件,ms | 32(28–39) | 38(30–45) | 同上 |
|
||||
|
||||
这批包含上述 UI 差异的样本中,全 App 采样峰值中位数降低 **42.42%**,不能将全部
|
||||
差值单独归因于 Runtime 复用。DSH 的热请求中位数反而增加 16.5 ms,
|
||||
没有隐去该回退,也没有据此增加未经测量的队列优化。Private Commit 不是独占物理
|
||||
RAM,工作集求和会重复统计共享页;5 秒采样不等于捕获了瞬时峰值。两组使用相同
|
||||
测试观察代码,数据不是未插桩 App 或生产大库的性能承诺。
|
||||
|
||||
最后一次各有 **124 个**无新任务空闲样本,覆盖 **610.588 / 610.544 秒**,最大间隔
|
||||
5.149 / 5.068 秒。两组模型请求均固定为 58,active Task 为 0,DB 固定
|
||||
2,007,040 字节、WAL 固定 4,128,272 字节、事件数固定为 276。
|
||||
|
||||
| 空闲组 | baseline Private Commit 起止,MiB / CPU 秒 | candidate Private Commit 起止,MiB / CPU 秒 |
|
||||
| --- | ---: | ---: |
|
||||
| 全部 OpenCode | 2,433.02 → 2,382.73 / 19.359375 | 1,007.88 → 973.12 / 11.609375 |
|
||||
| 全部 DSH | 180.00 → 179.19 / 0.09375 | 86.92 → 87.62 / 0 |
|
||||
| Main | 211.00 → 200.34 / 4.9375 | 205.33 → 197.70 / 4.4375 |
|
||||
| Renderer | 183.32 → 137.40 / 0.265625 | 157.90 → 127.23 / 0.4375 |
|
||||
|
||||
Session 有意保留;DSH 尾端有 0.70 MiB 增量,不把这一段描述为所有点都下降,
|
||||
也不从十分钟数据宣称整个 App 已无泄漏。聚合报告同时保留工作集、堆、句柄和逐进程 CPU。
|
||||
原始数据位于忽略目录 `matched-app-WBCdJL/{before,after}-{10,11,12}`,汇总为 `summary.json`;
|
||||
这些是本机验证资产,不进入版本库。
|
||||
|
||||
其后才运行剩余 Runtime 检查,避免干扰对照:
|
||||
|
||||
- OpenCode 双项目真实 Server 组合通过原生 Skill、真实 stdio MCP 和不同 inline 图片,
|
||||
两轮八次 MCP 调用均用请求 ID 核对原 grant 归属,读取结果仍匹配各自目录;十项目
|
||||
测试仍通过。DSH 的对应共享组合也通过。四文件合计 **114 通过、4 跳过**,
|
||||
跳过的是 3 个显式启用的真实 DSH 模型测试和 1 个真实图像生成测试,不把这些记为通过。
|
||||
- 直连模型的会话 release/dispose 与实际 Shell 完整输出分页检查通过:删除目标会话
|
||||
缓存并释放对应 ToolProvider;dispose 先释放所有已知会话,再释放 provider。实际命令
|
||||
结束后输出可分页,dispose 后句柄失效。没有新增模型进程池、历史字节 LRU 或持久缓存,
|
||||
此处也没有做直连模型整体 retained-heap 泄漏证明。
|
||||
- Continue 使用实际 1.5.47 包和生产 Utility adapter,3 轮各两个并发请求,另 5 次热请求。
|
||||
11 次本机确定性提供方请求全部返回预期内容,11 个请求级 Utility 均退出,真实模型调用
|
||||
为 0。准备耗时中位数:冷准备 **333.93 ms**(326.06–381.94),同 adapter 缓存
|
||||
**0.0031 ms**(0.0010–0.0054),新 adapter 命中磁盘产物仍 **312.97 ms**
|
||||
(276.08–334.63),各 3 次。11 次原生 HTTP ready 中位数 **936.96 ms**
|
||||
(925.60–959.27),首个提供方请求从 run 起算 **980 ms**(969–1,013),总时长
|
||||
**1,353 ms**(1,314–1,373)。准备在 run 前单独测量,不能把它再说成总时长已包含。
|
||||
跨 adapter 重复准备没有消失;本次保留现有检查与缓存,不为约 0.31 秒的准备成本
|
||||
修改单 Session Host 或新增常驻池。
|
||||
- 最终整仓 `npm test -- --reporter=json --outputFile=<本机证据目录>/full-validation-final.json`
|
||||
为 **4,204 通过、67 跳过、0 失败**,耗时约 **733.80 秒**。包括最后加强的
|
||||
OpenCode 逐请求图片互斥断言、共享能力组合、设置退役及专家父请求回归;
|
||||
先前失败的 `ActivityPanel` 本次在全量中 **16 项通过**。早期失败报告仍保留,
|
||||
不将跳过的真实提供方或平台专用测试计为通过。
|
||||
- 全量结束后再次运行 `npm run typecheck`、`npm run lint` 和 `git diff --check`,
|
||||
全部通过,先前并行 Magic Notes mock 不匹配已不再出现。10 个相关文档的
|
||||
**140 个相对文件链接**均可解析,未独立复验标题锚点。全量测试进程及按本次测试
|
||||
路径筛选的本机验证进程均已退出;Linux 隔离安装的清理证据见前文。
|
||||
- 最终门禁及报告修订没有新增真实提供方调用,累计仍为 **352 次**。按钮位置调整及
|
||||
其他并行工作均保留;本次未执行 commit、push、tag 或发布。性能对照仍使用原冻结
|
||||
样本,不把整仓后来通过的回归结果描述成已重新测量该版本的性能。
|
||||
|
||||
验收边界仍需保留:性能对照是 Windows 四项目确定性读取,不是大库自然开发或十项目
|
||||
完整 UI 性能报告;十项目为实际 Native composition 测试。未测所有第三方插件的组合,
|
||||
未证明整个 App/直连模型无泄漏,macOS/Linux arm64 原生运行仍需可用平台或 CI。
|
||||
|
||||
### 2026-09-15:追加真实模型复验与资源观察
|
||||
|
||||
用户要求继续使用真实模型,并监控数据库及内存。此次将 `e1fe1cf` 与当时工作区改动
|
||||
冻结到独立源码目录,构建真实 Main/Preload/Renderer,使用新的隔离 profile 和四个专用
|
||||
项目,预置 320 条合成历史。按钮位置调整保留;没有把正式用户历史送入测试。
|
||||
原始证据位于忽略目录 `.agent-resources/runtime-live-20260915/run-tQ1xtk/`,
|
||||
不是 checkout 自带资产。本轮只调整测试驱动,没有修改 Runtime 产品源码。
|
||||
|
||||
- 独立生产构建通过。冻结快照的类型检查暴露并行附件功能的 App 测试 mock 缺少
|
||||
`getFilePath` / `importFiles`,未覆盖或撤销那部分工作;这一失败不被早先整仓通过
|
||||
的结果覆盖。初次构建取证脚本误用 Preload 扩展名、同步 esbuild 不支持插件等驱动
|
||||
错误均发生在模型调用前,生产构建与 Runtime 未因此修改。
|
||||
- 显式启用 DSH 的三个真实模型专项,全部通过:自定义 Header、Ask 下 Main 代理的
|
||||
Web Search/Fetch、真实 npm 插件在 Ask 被拒绝且在 Execute 执行。共 **7 次**
|
||||
实际模型请求;该次按测试名选择,仅运行这三项,不把其余九项的过滤跳过算作通过。
|
||||
- 完整 App 从输入区发出四项目八条开发任务,实际读写模块、添加边界测试并执行
|
||||
`node --test`。项目选择器曾因模糊名称匹配到路径中的 `live-2` 而选错项目,
|
||||
驱动在发送前被会话身份检查挡住,改用已验证的精确项目文本;没有重复发送已接受任务。
|
||||
- 第一轮六条任务完成、两条 DSH 以 `pi-ai stream idle timeout after 300000ms`
|
||||
失败。一次未观察到上游响应状态,另一条有 HTTP 200 但任务仍超时,不能把 HTTP 200
|
||||
当作交付成功。保留失败与数据库后,正常退出测试 App,将测试转发层改为独立
|
||||
`undici.request` 流式转发,再显式重跑这两条,均完成。由于同时重启了 App,
|
||||
不能据此断言已经唯一定位到转发器或提供方;本轮未盲目修改 Runtime 来规避失败。
|
||||
- 四项目独立执行测试分别 **16、17、18、19 项通过,共 70 项**。每类 Runtime 的
|
||||
项目请求存在重叠,使用同组 owner;本轮开发请求重叠峰值为 OpenCode 4、DSH 2。
|
||||
两类 Runtime 分批发送,不声称八条同时运行。
|
||||
后续真实原生子代理读取模块及测试,父请求执行测试完成;DSH 两个真实慢命令重叠时,
|
||||
只 Stop 其中一个,另一项目继续完成,DSH Host PID 未变。
|
||||
- 直连与 Continue 从同一完整 App 会话各发出一次无工具短请求,分别持久化
|
||||
`LIVE_DIRECT_OK` / `LIVE_CONTINUE_OK`。当前已配置的图像模型另发出一次生成请求,
|
||||
实际得到白底蓝色圆形图片并可在生产预览中打开;不是配置检查,也没有读取提供方
|
||||
返回的图片 URL。这验证当前配置路径,不冒充运行了绑定指定服务商的可选测试。
|
||||
- 此时 App 共 **16 条 Prompt:13 completed、2 failed、1 cancelled**,
|
||||
**67 次模型请求**;加上 DSH 专项 7 次,本机新增 **74 次真实调用(73 次文本、
|
||||
1 次图像)**。计数包含失败及显式重跑。早期累计 352 次暂增至 426 次,
|
||||
后续 Linux 增量另记;本机 HTTP 计数不等于 Agent ledger 的交付证明。
|
||||
- 真实子代理的 **153 条事件**,实际保存 **70,668 字节**,等价完整快照
|
||||
**495,934 字节**,该事件负载减少 **85.75%**。使用生产 codec 逐条还原及重新编码
|
||||
校验通过;6 次工具块 upsert 中无变化重复写入为 **0**,数据库 `quick_check` 为
|
||||
`ok`。这是同批事件的编码差额,不是整个数据库文件缩小 85.75%,也没有删历史。
|
||||
- 模型结束后连续 **610.857 秒、123 个样本**,最大采样间隔 **5.292 秒**。
|
||||
请求数固定为 16,模型调用数固定为 67,活动任务和 streaming 消息为 0;
|
||||
DB 固定 **1,765,376 字节**、WAL 固定 **4,140,632 字节**,消息数 355、
|
||||
事件数 663、事件 JSON 负载 284,577 字节均不变。
|
||||
- 本轮全 App 采样峰值 Private Commit 为 **1,728.79 MiB**,OpenCode + DSH
|
||||
合计采样峰值 **997.66 MiB**。没有同负载的旧版真实模型样本,不能把它与前文
|
||||
确定性对照相减生成新的“节省百分比”,也不把采样峰值称为瞬时峰值或独占物理 RAM。
|
||||
- App 收到正常 quit 后回收了本轮登记的全部 Runtime,OS 核验最终 Main、OpenCode、
|
||||
DSH 和 Continue Utility 均已消失。退出探针曾因等待 `will-quit` 误报超时:
|
||||
生产实现实际在 `before-quit` 清理后调用 `app.exit(0)`。该驱动失败记录保留,
|
||||
后续探针改从独立父进程观察退出,不修改产品退出路径或伪造原始报告中的 phase。
|
||||
关闭后 WAL 为 0,DB 为 **3,125,248 字节**,属于正常 checkpoint,不是空闲增长;
|
||||
已保存图片内容为 **1,351,526 字节**,不能把图片占用与事件差量节省混为一谈。
|
||||
|
||||
| 空闲进程 | Private Commit 起止(MiB) | 范围(MiB) | 区间 CPU 秒 | 句柄起止 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 全 App | 1,345.77 → 1,345.95 | 逐进程样本保留 | 分进程见下 | 分进程见下 |
|
||||
| OpenCode | 750.42 → 746.00 | 741.62–750.42 | 5.984375 | 333 → 333 |
|
||||
| DSH | 89.86 → 89.86 | 89.86–89.93 | 0.015625 | 364 → 364 |
|
||||
| Main | 196.61 → 200.37 | 193.13–205.09 | 5.703125 | 1,137 → 1,137 |
|
||||
| Renderer | 120.66 → 121.51 | 120.64–122.67 | 0.15625 | 337 → 335 |
|
||||
|
||||
Main 堆使用量为 67.27 → 69.44 MiB;Main 与 Renderer 有小幅增量,不能表述为
|
||||
“所有进程都下降”。采样包含测试观察器累积记录的分配,这一窗口未见数据库增长或
|
||||
Runtime 句柄积累,但不构成整个 App 无泄漏证明。
|
||||
|
||||
当前源码 Linux 复验使用 `/root/tmp` 下的专用目录。旧 `/tmp` 测试 Node/依赖缓存
|
||||
已不存在,初次准备因失效链接失败,未发出模型请求。随后只替换本轮创建的失效链接,
|
||||
核验锁定 Node 24.19.0 的 SHA-256,并安装锁定依赖,完成当前源码 Agent/Runtime
|
||||
组包、隔离安装及 OpenCode 1.18.29 激活,不修改旧安装或无关 Host 文件。
|
||||
|
||||
- 两项目各 Ask/Execute 一会话,共四会话使用同一个进程 owner、一个原生 OpenCode PID。
|
||||
Ask 实际读取,Execute 实际修改和运行测试,目录 marker 均正确。后续原生子代理
|
||||
产生 **497 条**子会话事件,同一 Session 续接完成;两项目独立测试各 **8 项通过**。
|
||||
- 两个真实 90 秒慢命令期间,目标 Prompt 明确结算为
|
||||
`cancelled / processTree: running`。另一会话经真实 attach 传输断开、
|
||||
controller resume 和重新附加后完成,原 Session 与 owner 均保留。
|
||||
本轮走生产 SSH/ACP/Agent/模型桥,不是再次运行完整远程 Electron UI 或 Desktop 重启。
|
||||
- 共 **7 条 Prompt:6 completed、1 cancelled**,**36 次模型请求全部 completed
|
||||
且已交付**,逐 operation 归账。取消 Prompt 不等于模型交付不确定。释放全部 binding,
|
||||
等待 65 秒后 owner 为 0;随后停止本轮隔离 Agent,独立 `/proc` 与 owner 表核验均为 0。
|
||||
- Host 共 71 次资源快照,未出现采样错误;所属进程私有驻留页合计采样峰值
|
||||
**1,033.00 MiB**,其中 OpenCode 峰值 **771.60 MiB**。这是 Linux 私有驻留页,
|
||||
不是 Windows Private Commit;也不是 Linux 十分钟空闲或旧新版本对照。
|
||||
退出后 semantic-prompts、model-calls、runtime-owners 数据库分别为
|
||||
**1,634,304 / 36,864 / 24,576 字节**,WAL 均为 0。
|
||||
- 本轮最终新增 **110 次模型请求:109 次文本、1 次图像**,包括本机失败和显式重跑;
|
||||
加上此前 352 次,累计 **462 次**。其中本机一次未观察到上游响应状态,不能把全部
|
||||
110 次写成成功交付。实际开发项目测试合计 **86 项通过(Windows 70 + Linux 16)**。
|
||||
- 收尾时当前工作区 `npm run typecheck`、`npm run lint` 和差异检查通过。
|
||||
238 个 Runtime/Agent/远程适配器文件与冻结快照的文本内容一致,原始字节差异来自
|
||||
checkout 的 CRLF/LF;Main、Preload、Renderer 入口产物哈希未变化。并行附件测试
|
||||
mock 的缺项已在当前工作区消失,不回写冻结快照,也不掩盖其当时的类型检查失败。
|
||||
10 个相关文档的 140 个相对文件链接有效,标题锚点未独立复验。
|
||||
本轮没有产品源码修复,未再次运行全仓 `npm test`;前文 4,204 项通过的报告
|
||||
早于后来的并行附件/窗口改动,不能将其当成这些改动的完整回归结果。
|
||||
- 本机命名浏览器会话及所有本轮测试进程均已关闭;保留隔离工作区、数据库、生成图片、
|
||||
失败诊断及汇总报告,没有清理正式用户历史,也没有 commit、push、tag 或发布。
|
||||
|
||||
## 2026-09-14 Runtime 资源调研与方案(未实施)
|
||||
|
||||
- 按用户要求完成文档与源码调研,方案见
|
||||
[Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md),对应
|
||||
FR-RR1–FR-RR6。当前工作仅文档和本机忽略目录内的隔离探针,未修改 Runtime/Agent
|
||||
源码、停止用户 Runtime、清理用户数据或准备新发布。
|
||||
- 源码基线 `de14a6157a6c34f1e3ef22619eca374a50a83706`,保留此前活动 UI 修复。
|
||||
manager/controller、OpenCode、Continue、DeepSeek、直连、远程 ACP、Agent backend /
|
||||
owner、模型桥和原生插件均已检查。此前同基线生命周期定向回归为 11 文件、
|
||||
346 通过/1 跳过;不是本方案实施后的产品验收。
|
||||
- 已安装 0.13.2 的 09:10:12–09:15:12 五分钟只读采样:数据库约增加 2.72 MiB,
|
||||
3,367 条新增子代理事件约 1.49 MiB、平均约 465 字节,93 个工具 upsert 中未变化的
|
||||
upsert 为 0;App 相关进程 Private Commit 约 3,229–3,866 MiB,CPU 平均约为
|
||||
16 核整机的 3.15%。后续一分钟又完成两个任务,不能作为真正空闲或泄漏结论。
|
||||
- 两个 OpenCode Server 属于当前 App,启动工作区不同。其中一个原生库 0 Session /
|
||||
0 Message、约 527 MiB Private Commit。源码确认按工作区保留实例、清单可启动执行
|
||||
Runtime、无时间型空闲回收;没有独立调用轨迹证明该空 Server 最早由清单启动。
|
||||
- 首轮两个无真实模型探针中 OpenCode 通过,DSH 驱动误用工具内部 `filePath` 而不是
|
||||
原生 schema 的 `file_path`,断言失败。修正驱动参数后两项通过,未调整产品代码。
|
||||
- 随后完整三探针最终通过,Vitest 3 passed / 0 failed,39.20 秒:
|
||||
- 生产 OpenCode launcher 单次 spawn,两个目录四个原生 Session,文件路由与单会话
|
||||
删除后的其他会话存活通过;没有调用生产 `Runtime.run`。
|
||||
- 真实 DSH Host composition、Agent factory、文件和 PowerShell 工具,两个不同 cwd
|
||||
的 Agent 并行读写各自测试文件;生产控制面拒绝第二工作区也得到断言确认。
|
||||
这不是 Electron Utility/ACP 生产多工作区测试。
|
||||
- 一个 Windows 原生 `opencode acp`、一条 ACP connection、两个工作区 Session,
|
||||
并行完成两个合成 Prompt;`chat.headers` 的 sessionID 与两个 HTTP 请求归属一致。
|
||||
尚未经过 Agent backend、生产模型桥、真实工具/子代理或 Linux Host。
|
||||
- 本次研究真实模型调用 **0 次**;最终 ACP 探针向本机确定性服务发出 **2 次 HTTP
|
||||
请求**。测试服务、原生进程和专用工作区在 finally 中关闭/删除,不操作用户运行中的
|
||||
Runtime 或其临时数据库。
|
||||
- 复现命令:`npx vitest run --config .agent-resources/runtime-reuse-research-20260914/vitest.config.ts`。
|
||||
`probe.test.ts`、`opencode-result.json`、`dsh-result.json`、`acp-result.json` 保留在
|
||||
同一忽略目录,仅为本机证据,不作为他人 checkout 后必然存在的测试资产。
|
||||
- 未实施项及生产验收见技术设计 P0–P4/V1–V8;本次未进行新的 Linux Host 验证、
|
||||
真实模型开发任务或修复前后内存对照,不将原生能力探针描述成修复完成。
|
||||
- 文档核验覆盖 10 个改动文档、131 个相对链接和其中 44 个标题锚点,全部通过;
|
||||
FR-RR1–FR-RR6 的定义唯一且有验收引用,`git diff --check` 通过。研究用 OpenCode
|
||||
二进制剩余进程数为 0。产品源码未改动,因此本轮未重跑全仓 test/typecheck/lint。
|
||||
|
||||
本页下方 schema 35 段落保留当时的失败与未发布记录。其后已随 Desktop `0.13.2`
|
||||
(发布提交 `81ae9e0`)正式交付,Agent `0.11.25` 同期为维护发布;新的进程复用方案
|
||||
不包含在这两个已发布版本内。
|
||||
|
||||
## 2026-09-14 多 Runtime 工具操作摘要
|
||||
|
||||
显示与事件合并规则见[工具操作摘要](./runtime-interactions.md#工具操作摘要)。Continue、
|
||||
DeepSeek Harness 和直连模型已接入共享摘要函数,完成事件缺少输入时保留已有摘要。
|
||||
|
||||
Windows 完整 App 使用当前生产 Main、Preload、Renderer 和隔离配置,模型为
|
||||
`deepseek-v4-flash`。从真实输入区输入并点击发送读取测试文件的请求,校验码只存于
|
||||
文件中。三条成功会话都返回正确校验码,工具完成后仍显示路径;原生点击展开可查看
|
||||
输入与输出,离开后重新打开会话、重新加载 Renderer 后再次打开均保留摘要和详情。
|
||||
六张对话区截图已实际打开审阅。
|
||||
|
||||
| Runtime | 成功模式 | 提交数(含失败重跑) | 模型 HTTP dispatch | 工具执行 |
|
||||
| --- | --- | ---: | ---: | ---: |
|
||||
| 直连模型 | Ask | 1 | 3 | 3 |
|
||||
| Continue | Execute | 2 | 4 | 3 |
|
||||
| DeepSeek Harness | Execute | 3 | 3 | 2 |
|
||||
| 合计 | | 6 | 10 | 8 |
|
||||
|
||||
计数来自 Main 和真实 Utility Host 的 HTTP dispatch,未注入答案或工具事件。Continue
|
||||
首轮 Ask 仅提供配置工具,未能读取文件;改用 Execute 成功。DS 前两次因 Windows
|
||||
工作区路径写法与 Host `realpath` 比较不一致而返回 ACP `-32602`,均未调用模型;
|
||||
同目录改写路径仍复用缓存 Runtime,改用全新规范路径目录后成功。该路径问题随后由
|
||||
[DS 工作区路径修复](../deepseek-harness/progress.md#2026-09-14-windows-工作区路径修复)
|
||||
单独处理,新增验证计数见该记录,不计入上表。驱动的包解析、
|
||||
DOM 定位及卡片比较器失败已记录,修正比较器后的复验没有追加模型请求。
|
||||
|
||||
本机临时 `runtime-summary-live/report.json` 保留逐次结果和产物哈希;源码基线为
|
||||
`c71da4b6ae1d7228ff968d39c63db205caf61339` 加本次工作树改动,Main SHA-256 为
|
||||
`2153d72a947ab6f5eab22eab28d7a3f2f86a47740d5223a3e1547f199fa7de84`。
|
||||
测试进程、隔离配置、数据库、工作区和构建均已清理,正式配置字节未改变;报告和截图
|
||||
保留在本机临时目录,不作为长期可用的仓库资源。请求使用已配置 HTTPS,沿用生产
|
||||
证书策略,本轮未验证严格证书校验。未测试 Main 重启或远程 Agent。
|
||||
|
||||
专项回归 210 项通过、1 项跳过。完整 `npm test` 首轮 4154 项通过、66 项跳过,另有
|
||||
6 个 `.agent-resources` 内的本地 `node:test` 文件被 Vitest 误收集导致套件失败。
|
||||
使用 `npm test -- --exclude "**/.agent-resources/**"` 完整重跑后,353 个文件通过、
|
||||
9 个跳过,4154 项通过、66 项跳过、0 失败,耗时 646.04 秒。`npm run typecheck`、
|
||||
`npm run lint` 和 `git diff --check` 通过。
|
||||
|
||||
## 2026-09-13–14 schema 35 工具重复写入补修(未发布)
|
||||
|
||||
- 0.13.1 的历史库回收成功,但其真实模型验收限制子代理不使用工具,遗漏了生产工具对象
|
||||
的 `undefined` 可选字段。JSON 重读省略这些键,原深比较误判未变化的工具块。正常开发
|
||||
任务复现同批 928 条事件的 7,787 次重复工具块写入;仅归一化空字段的对照将负载从
|
||||
10,467,622 降为 452,391 字节,逐条还原一致。该对照不是产品修复验收。
|
||||
- 当前修复按共享工具 schema 的字段值比较,不序列化整份工具输出进行比较。空字符串、
|
||||
输出修订、状态和错误变化仍保留。schema 35 使用同一编码器重整旧完整事件及 schema 34
|
||||
已存差量,跳过 JSON 未变化的行;不会删除事件、会话、工具结果或来源字段。
|
||||
- 存储缓存仍最多 16 个任务,终态及时移除缓存引用;回滚后按已提交记录重建,其他任务
|
||||
不受影响。远程先按来源主键判断是否重放,重复事件不重建已经释放的写入缓存;
|
||||
`INSERT OR IGNORE`、冲突校验及消息投影事务仍保留。
|
||||
- Windows 当前源码加便携版内置 OpenCode 完成正常 CSV 项目开发,主/子代理实际读取、
|
||||
改代码、补测试和执行命令;独立 59 项测试及 CLI 通过。698 条子代理事件共
|
||||
368,575 字节,重复工具块为 0,与归一化对照字节数完全相同,每条事件还原一致。
|
||||
- 多项目实测使用两个独立 OpenCode 实例、每项目两个会话,两轮共 8 次 Execute。
|
||||
第一轮四个开发任务全部完成;第二轮复用会话,主动取消一个正在使用工具的子代理任务,
|
||||
其余三个完成并通过测试。2,841 条子代理事件共 1,305,340 字节,重复工具块为 0,
|
||||
全部逐条还原一致。每轮结束后缓存任务数、打开的 SSE 响应流均为 0。
|
||||
隔离 Main 测试进程在显式 GC 后的堆占用为 12.94 → 12.91 MiB;
|
||||
这证明本轮未持续积累,不是整个 Electron UI/Runtime 的 RSS 上限或无限负载保证。
|
||||
- 共享 Linux x64 Host 使用当前桌面源码经真实 SSH、Agent、ACP 和模型 gateway 完成
|
||||
Ask 与正常工具 Execute。912 条子代理事件共 405,360 字节,重复工具块为 0,
|
||||
逐条还原及重复重放通过,远程 Python 项目测试通过。修复位于共享桌面层,不修改 Agent
|
||||
或公开事件协议。两次早期测试驱动错误中断操作,修正瞬态事件处理后才完成上述验收;
|
||||
不能将早期失败写成产品通过。
|
||||
- 本轮实际提供商请求为 121 次:本地开发 26,多会话 79,远程账本 16(成功验收 14,
|
||||
早期驱动中断 2 次结果未知、未交付)。本地计数器只记录 HTTP 状态,远程按测试
|
||||
request/operation 的 Agent ledger 统计,不记录凭据、正文或请求头。另有先前诊断的
|
||||
24 次请求,不计入这 121 次。已核对本轮本机和远程测试工作区无残留 Runtime 进程。
|
||||
- 活动 schema 34 库通过 SQLite online backup 取得一致性备份,并校验独立测试副本。
|
||||
正常启动升级成功,2,898,984,960 → 607,055,872 字节(2.70 GiB → 578.93 MiB,
|
||||
回收 79.06%)。完整比较 424,148 条原事件的内容、ID、时间及来源一致。
|
||||
隔离启动未复制模型配置,既有启动修复调整一条项目/会话 Runtime 选择及一个在途任务、
|
||||
消息和活动状态,另追加一个状态事件;不是迁移删除或改写历史内容。
|
||||
- 同一真实备份的另一个副本直接运行实际构建的升级 Worker,在处理 64 条后取消,
|
||||
重启后完成 316,664 条子代理记录;全量事件再次还原一致。全部非事件表逐表内容一致,
|
||||
包括 150 个会话、1,915 条消息、914 个任务和 136 个成果;`integrity_check=ok`。
|
||||
原运行库和校验备份没有被测试改写。schema 35 副本再次进入实际 Worker 时直接完成,
|
||||
进度事件数为 0,不重复整理。
|
||||
- 最终专项 99 项、typecheck、lint 及生产开发构建通过。首轮全量产品用例 4,154 通过、
|
||||
66 跳过,但 6 个临时 Node 测试项目被 Vitest 错误发现;配置已排除 `.agent-resources/`,
|
||||
独立项目仍单独运行自己的 Node 测试。随后显式双 worker 全量为 4,148 通过、66 跳过、
|
||||
`App.test.tsx` 6 项时序失败;恢复仓库默认单 worker 后该文件 220 项全部通过,
|
||||
未修改 UI 代码。最终默认配置全量为 4,151 通过、66 跳过、3 个问答 UI 测试失败,
|
||||
失败项与双 worker 不同;本轮全量回归尚未全绿。随后仅调整测试等待和 mock 隔离的
|
||||
尝试仍有不稳定失败,已完整撤回这些额外 UI 测试改动,不把单次重跑通过当作稳定修复。
|
||||
存储专项、真实工具任务、并发内存和真实数据迁移证据均通过;UI 测试问题仍需单独处理,
|
||||
本次未提交、未发布,也未替换用户正在运行的 0.13.1。
|
||||
|
||||
## 2026-09-13 Desktop 0.13.1 执行记录去重与大库升级
|
||||
|
||||
- 实现与兼容边界见[执行记录存储与升级回收](./execution-history-storage.md),通用开发
|
||||
要求见[数据库迁移指南](../../development/database-migrations.md)。本机和远程写入共用
|
||||
差量编码,不修改 Runtime 公共事件或 Agent 协议;消息投影与来源去重继续保持事务。
|
||||
- 真实 portable 旧库先备份并校验 SHA-256,再在独立新版 portable 中启动升级:
|
||||
13,094,912 → 12,595,200 字节,schema 33 → 34。659 条消息、52 条会话、133 条成果、
|
||||
笔记/待办及模型用量表逐表内容一致,完整性检查通过。
|
||||
- 另对真实膨胀库的核验备份副本运行同一 portable 启动入口:
|
||||
97,308,667,904 → 594,182,144 字节,即 90.63 GiB → 566.66 MiB,
|
||||
实际回收 90.07 GiB(99.39%),freelist 为 0。原库和原 portable 数据库未改写。
|
||||
- 全量还原比较 411,981 条事件,所有内容、事件 ID、时间及远程来源字段一致;
|
||||
SQLite `integrity_check` 返回 `ok`。1,876 条消息、895 个任务、136 个成果及笔记、
|
||||
待办、用量和活动记录等表内容一致。隔离 profile 没有复制模型配置,既有启动修复更新了
|
||||
一条项目及一条会话的 Runtime 选择,项目 `updated_at` 随之更新;不是事件迁移修改。
|
||||
- 真实升级中通过“退出,稍后继续”停止,再用更新后的 portable 继续并完成。初版 UI
|
||||
只按内容高度展开且进度条过窄,用户指出后修复为全高画布和全宽进度条,并显示处理速度;
|
||||
实包测得页面高度约 761 CSS px、viewport 761 px,进度条宽约 960 px,截图人工复核通过。
|
||||
- 大库采样的读取耗时占约 65%、JSON 解析约 17%。将升级读取缓存由 8 MiB 提至
|
||||
64 MiB,保留每批 32 条事务和 SQLite 自动 checkpoint,只在任务结束时截断 WAL。
|
||||
相邻 2,048 条转换采样从约 12.0 秒降至 8.8 秒;样本和缓存状态不同,不作为固定加速比。
|
||||
未增加同库并行写入,也未关闭事务或同步落盘。
|
||||
- 当前源码通过真实本地 OpenCode Execute 和共享 Linux x64 Host 的托管 Ask/Execute。
|
||||
本机与远程各观察到 65 条子任务事件,最终过程仍可显示,远程重复事件不重复投影;
|
||||
测试资源均位于专用目录。共提交 3 次真实 Prompt,Runtime 上报 4 笔模型用量;
|
||||
子代理内部 HTTP 调用未单独计数,不将该上报数称为服务商请求总数。
|
||||
- 首轮完整 `npm test` 为 4,114 通过、66 跳过,637.97 秒;后续 8 项新增/修正的专项
|
||||
回归通过,`npm run typecheck`、`npm run lint` 通过。生产构建及用户要求的 portable
|
||||
实包开发验证通过。0.13.1 版本与发布说明变更后的本地最终验证被用户主动取消,按要求
|
||||
不再追加本地测试,候选的最终全量测试、类型、lint 与生产构建由 main CI 判定。
|
||||
- 用户批准 0.13.1 中英文说明及仅标准六平台发布;Agent 保持 0.11.24。
|
||||
已发布提交 `4937720` 和不可变标签 `v0.13.1`,main CI 与六平台发布通过;
|
||||
后续真实工具场景的遗漏及补修见本页 schema 35 记录。
|
||||
|
||||
## 2026-09-13 原生浏览器与应用浮层修复
|
||||
|
||||
- 用户在“添加实例”中复现原生网页盖住弹窗。原先只有终端关闭确认释放 viewport,
|
||||
现在所有应用 Modal 与相交浮层共用
|
||||
[浏览器遮挡机制](./browser-tabs-technical-design.md#4-renderer-与-viewport)。
|
||||
新增五个组件回归在修复前全部失败,修复后通过;独立检测器另有十项回归。
|
||||
- Windows 当前 Main、Preload、Renderer,通过 `electron-vite dev` 和独立 profile
|
||||
启动完整 App,不是旧安装包或仅有 Renderer 的 fixture。浏览器导航到本机合成页后,
|
||||
实例管理与项目设置均验证原生视图 `visible → hidden → visible`;视图 ID 不变且未销毁。
|
||||
本次不修改 Runtime、Agent、MCP 或远程生产路径。
|
||||
- 实例编辑器在浅深主题、目标 1280×800、960×720、720×640、640×420 下完成八组
|
||||
几何、关闭按钮命中和原生视图隐藏断言。Windows 缩放产生不足 1 CSS px 的取整差异,
|
||||
记录实际尺寸而非把目标尺寸当测量值。PNG 实际审阅包括浅色 1280、720 和深色 640;
|
||||
其余尺寸执行程序化检查,不将其描述为全部 PNG 人工审阅。
|
||||
- 临时驱动曾出现新浏览器 target 抢占自动化目标、隐藏窗口截图阻塞及严格像素比较
|
||||
误判;修正驱动后重新取证,不把这些失败改写为产品修复。最终使用 pinned renderer、
|
||||
独立测试窗口和 CDP PNG;原生视图状态单独从 Main 读取,避免网页截图漏掉原生视图。
|
||||
- 顺带复现已发布更新说明资源为 136,554 字节,超过原 128 KiB 读取上限。上限提高到
|
||||
1 MiB,仍使用有界读取;回归直接读取实际打包资源并确认当前版本可展示、可确认。
|
||||
完整 App 已显示并关闭 0.13.0 更新说明,重启后不再次显示。
|
||||
- 首轮 `npm test -- --maxWorkers=1`:4,108 通过、66 跳过,608.30 秒;
|
||||
`npm run typecheck`、`npm run lint` 通过。补长名称修正后的全量执行为 4,105 通过、
|
||||
66 跳过、3 失败,586.51 秒;失败全部位于并行修改中的子代理进度测试。
|
||||
未修改这些文件,随后定向重跑 ACP 与 OpenCode 子代理共 66 项全部通过。
|
||||
全仓类型与 lint 检查期间先后遇到并行子代理/存储升级改动的缺失字段和未使用导入,
|
||||
这些改动不纳入本次提交,不能把早先的通过结果当作当前并行工作区全绿。
|
||||
- 两组 Harness 单测补 `onTestFinished` 回收临时目录。最后定向四文件 73 项通过,
|
||||
运行前后检查新增残留目录为 0。测试清理不改变 deployed Runtime。
|
||||
- 本次修改文档的 66 个相对链接目标存在,暂存差异无空白错误;最终发布仍需对确定的、
|
||||
不再变动的候选提交执行发布验证,不能将未提交的并行改动混入候选。
|
||||
- 临时脚本、截图和隔离构建按用户要求在提交后清理;这里只保留脱敏结论。知识库布局、
|
||||
合成服务保存验证与调用计数见[知识库进度](../knowledge-base/progress.md#2026-09-13-知识库布局与自动名称修复)。
|
||||
|
||||
## 2026-09-13 OpenCode 事件流关闭顺序修复
|
||||
|
||||
候选检查中的 lifecycle 失败已稳定复现,4 路并行后 `openEventBodies` 为 3 而不是 0。
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Runtime 执行清单技术设计
|
||||
|
||||
状态:设计阶段,尚未实现。需求、场景与顶部布局由 [PRD](./prd.md#131-runtime-执行清单)
|
||||
维护,替换、隔离和终态规则由 [Runtime 交互边界](./runtime-interactions.md#runtime-执行清单)
|
||||
维护。研究证据与交付状态见[进度](./progress.md#2026-09-15-runtime-执行清单设计未实现)。
|
||||
|
||||
## 1. 原生数据与提取位置
|
||||
|
||||
| Runtime / 路径 | 已确认格式与设计接入 |
|
||||
| --- | --- |
|
||||
| 本地 OC 1.18.29 | `todo.updated` 与 `session.todo` 返回完整条目列表,字段为 `content/status/priority`,无 item ID;以原生更新事件驱动本轮整份替换 |
|
||||
| 本地 CN 1.5.47 | 精确原生工具 `Checklist` 的参数 `checklist` 是 Markdown 勾选列表,`- [ ]` / `- [x]` 表示未完成/完成;按一次成功调用提交完整替换 |
|
||||
| 远程 OC/CN | 先核对实际部署版本、原生输出与 Agent 传递结果;只有格式已确认且来源归属明确的原生 plan entries 或精确清单工具参数可以映射,当前尚无本功能远程实测 |
|
||||
|
||||
`opencode-runtime.ts` 当前将普通工具 input 经 `boundedToolDetail(..., 4_000)` 转成
|
||||
展示字符串。接入应在 SDK 原始事件层消费 `todo.updated`;如需核对 `todowrite` 参数,
|
||||
必须在该截断之前处理。`session.todo` 是 Session 当前快照,读取结果必须与请求生命周期
|
||||
关联;不在每轮开始时无条件读旧快照充当新清单,也不轮询代替事件。
|
||||
|
||||
CN 的提取位置在 `continue-host-adapter.ts`:宿主产生工具事件、HTTP 状态合并及输入
|
||||
校验均存在 4,000 字符展示限制,不能等到 `continue-runtime.ts` 收到截断字符串后再解析。
|
||||
宿主应在最早的原始参数层提取 `checklist`,按工具调用 ID 保留候选,结合最终成功状态
|
||||
提交;成功帧省略参数时使用同次调用的候选,失败不得提交。普通工具事件继续沿现有路径
|
||||
流转。超出 4,000 字符的清单需要独立验证完整性,但不扩大所有普通工具输入的展示上限。
|
||||
|
||||
Markdown 解析只适用于已确认的 CN `Checklist.checklist`,不扫描助手正文或其他工具
|
||||
输出。空数组在统一事件中明确表示清除;CN 原生如何表达空清单,以及空白、大小写和
|
||||
非条目行的实际接受规则,实施时核对后固定解析规则。未确认或无效输入保留旧清单,
|
||||
不能误当清空。
|
||||
|
||||
## 2. 统一事件与消息归属
|
||||
|
||||
拟在现有 Runtime 事件联合中增加完整清单事件,承载 `requestId`、条目数组和原生来源;
|
||||
条目包含 `content`、`status` 及可选 `priority`。最终命名随实现确定,不设计条目级增删
|
||||
协议或稳定 item ID。空数组必须经过 schema、IPC、消息投影和保存链路,不能被“无内容”
|
||||
过滤丢弃。
|
||||
|
||||
Main 使用现有请求到助手消息的映射投影清单,Renderer 顶部读取该消息的最新有效状态。
|
||||
同一请求若同时观察到原生清单事件与其工具记录,只以前者更新清单,后者保留活动;不能
|
||||
提交两次替换。原生父 Session、请求开始/结束边界需共同校验,子 Session 或旧请求事件
|
||||
不得通过当前活动请求映射到新轮。
|
||||
|
||||
优先扩展现有消息 metadata、Runtime 事件和数据库投影,历史清单与所属助手消息一起保存。
|
||||
远端沿用事件提交、消息投影和 ACK 的既有事务及去重规则;相关存储边界见
|
||||
[执行记录存储](./execution-history-storage.md)。实施前核对本地会话与远程任务实际落库
|
||||
字段,选择能复用的最小表示。不新建清单数据库、独立快照系统、恢复日志或持久化服务。
|
||||
旧消息没有结构化清单时保持缺省,不从已截断工具字符串或状态文案补造历史。
|
||||
|
||||
## 3. 远程 Agent 与 ACP 复用
|
||||
|
||||
本次源码检查确认以下传递位置,属于设计依据,不是本功能远程验证结果:
|
||||
|
||||
1. `AgentAcpConnection` 按原生 `sessionId` 分派 `sessionUpdate`;
|
||||
`AgentOwnedAcpPrompt` 校验 Session 后,以 `bindingId/operationId` 将通知写入
|
||||
既有 semantic transcript。
|
||||
2. `ProtocolRemoteRuntimeChannel` 已有 transcript 读取/ACK、身份核对,以及 ACP
|
||||
channel 恢复与重放;`AcpRemoteRuntime` 消费 `session-update` 并转换桌面事件。
|
||||
3. `AcpRemoteRuntime` 当前将 `plan.entries` 及 `plan_update` 的 items 转成 `status`
|
||||
字符串;markdown/URI 也只形成状态消息。工具事件按 `toolCallId` 合并 `rawInput`、
|
||||
`status` 和结果,再有界保留。清单提取应发生在结构丢失或截断前。
|
||||
|
||||
优先复用上述通知和 transcript,不另建远端清单通道。对实际 OC/CN 部署确认过的
|
||||
`plan.entries`,或已确认等价的 `plan_update` items,校验条目字段和状态后转成统一
|
||||
清单事件。普通 `status`、plan markdown/URI、`plan_removed` 文案以及泛化工具名均不
|
||||
触发清单更新;在没有确认对应原生清单身份前,`plan_removed` 也不能清空顶部。
|
||||
|
||||
若远程原生输出走精确清单工具,须确认原始参数、调用身份与成功状态确实贯穿 Agent,
|
||||
再按同一提取规则接入。是否能直接复用原生 entries,或需补充最小 Agent 适配,取决于
|
||||
真实格式证据,不能根据 ACP 类型定义推断 OC/CN 一定会发送。ACP 是协议,Runtime 身份
|
||||
仍为 OC 或 CN。
|
||||
|
||||
当前托管 SSH 产品及受管安装、校验、创建链路只支持 OC,详见
|
||||
[远程范围](../remote-host/technical-design.md#执行清单接入范围)。远程 CN 交付需补齐
|
||||
受管工件、安装与启动、项目选择/校验及原生事件接入,确认 Main 模型桥、请求归属、取消
|
||||
和已有恢复流程能用于该部署。不能只放开选择菜单或使用本地 CN 调远程模型来宣称完成。
|
||||
|
||||
重连按[既有 ACP 与断线规则](../remote-host/technical-design.md#acp-与断线)执行。
|
||||
已接受 Prompt 使用原 binding/operation 恢复,清单从已提交消息和其后的既有事件恢复;
|
||||
重复事件不重复展示。连接丢失不表示条目完成,不为恢复清单重新发送 Prompt、模型请求或
|
||||
工具调用,也不新增独立重连机制。
|
||||
|
||||
## 4. 实施与验收
|
||||
|
||||
| 阶段 | 必须完成的工作 | 对应需求 |
|
||||
| --- | --- | --- |
|
||||
| CL-P1 | 原始层提取、严格格式校验、统一事件及请求归属;保留工具活动 | FR-CL1–FR-CL3 |
|
||||
| CL-P2 | 消息投影和既有存储恢复、顶部只读布局、空数组清除及终态展示 | FR-CL2–FR-CL4 |
|
||||
| CL-P3 | 确认远程 OC 原生格式,补齐远程 CN 真实部署与事件接入,复用 Agent 传递和恢复 | FR-CL1、FR-CL4 |
|
||||
| CL-P4 | 本地与共享 Linux 真实 Host 逐项验收,记录版本、结果、调用口径与剩余阻塞 | FR-CL1–FR-CL4 |
|
||||
|
||||
以下均为待执行验收,本次文档工作不运行模型或代码测试:
|
||||
|
||||
- 本地 OC/CN 各经桌面正常发送路径产生并连续修改清单,检查新增、修改、重排、删除、
|
||||
重复文本、有效清空及超过 4,000 字符的完整内容;CN 成功才替换,失败保留前值。
|
||||
- 同会话连续两轮、并行会话、切换 Runtime/项目/执行空间、旧轮迟到事件和子会话事件
|
||||
不串数据;完成、失败、取消不改写条目状态。默认 DS、任意 status/Markdown/list
|
||||
工具不生成伪清单,工具活动仍可查。
|
||||
- 本地会话重新打开及应用重启恢复历史;顶部折叠、键盘操作、浅深主题、窄窗口和 200%
|
||||
缩放符合 PRD,保存与重放后的空清单不会恢复旧内容。
|
||||
- 在共享 Linux x64 真实 Host 使用开发中的 Agent 与实际部署的 OC/CN,分别从远程项目
|
||||
完成原生清单更新、取消、断线续接及历史恢复。沿用
|
||||
[Agent 开发验证规则](../../../AGENTS.md#goodbuddy-agent-development-validation),
|
||||
LAN/VPN 是同一机器的两条路径,不能计作两台 Host。
|
||||
- 逐个记录 OC/CN 的安装、启动、模型请求、清单传递、顶部显示、取消和重连结果;基础
|
||||
Runtime 成功不等于清单通过。任一步未执行写“待验证”,被外部条件阻塞则记录原因,
|
||||
不以 mock、协议 fixture 或本地成功代替远程验收。
|
||||
@@ -10,9 +10,10 @@
|
||||
| DeepSeek Harness | 固定 Host 的 Execute 文件与命令工具不询问目录权限;若收到 ACP 权限请求,活跃 Execute 直接选一次性允许,缺少该选项时使用 Runtime 提供的允许选项,Ask 拒绝,不等待额外 authorizer | 固定 Host 当前未提供通用原生业务问答服务,不把普通模型正文误识别成待答协议。模型以普通回复提问时,用户通过下一条消息回答 |
|
||||
|
||||
托管 SSH OpenCode 的权限、原生问题事件与回复链路由
|
||||
[远程主机技术设计](../remote-host/technical-design.md)定义。本次候选的远端业务问答需要
|
||||
[远程主机技术设计](../remote-host/technical-design.md)定义。该远端业务问答基线需要
|
||||
Desktop `0.13.0`、Agent `0.11.24` 和新启动的托管 Runtime;请先升级 Desktop,再更新
|
||||
Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答接口,候选不表示已经发布。
|
||||
Host 环境。2026-09-14 的已发布 Desktop `0.13.2` / Agent `0.11.25` 已包含此前问答修复,
|
||||
但不包含本文末尾尚未发布的进程复用改动。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答接口。
|
||||
|
||||
## 运行与失败
|
||||
|
||||
@@ -30,10 +31,17 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答
|
||||
提交失败时保留输入与重试入口,不生成已回答记录;取消未回答的问题不记为主动跳过。
|
||||
- 提交响应晚于任务结束时仍保存已提交的问答,但不覆盖完成或失败状态;晚于下一轮提问时
|
||||
不清除新的待答卡片。旧版会话未保存的问题与答案无法从“已提交”状态文案恢复。
|
||||
- 待答队列仅保存在当前桌面会话内存中,不写入会话快照。刷新仍在运行的消息时保留整个
|
||||
- 待答队列仅保存在当前桌面会话内存中,不写入持久会话快照。刷新仍在运行的消息时保留整个
|
||||
本地队列;完成、失败、取消事件或持久化终态快照清除整个队列。迟到的提交结果可以保存
|
||||
已回答记录,但不会恢复表单或覆盖终态。本机 Runtime 不恢复重启前未提交的问题;托管 SSH
|
||||
重新附加同一存活 Agent Prompt 时,由 Agent 当前待答列表补发问题,已回答的历史事件不补发。
|
||||
- 未发布的进程复用源码在 Desktop 重启后由 Main 并行附加同项目的存活任务,将待答
|
||||
列表和 Stop 所需的请求 ID 投影到实时 `activeRequest`,不保存该字段或重新发送任务。
|
||||
回答/跳过成功由 Main 写回原远程助理消息的既有问答历史,Renderer 不覆盖远程正文或终态。
|
||||
Runtime 设置、定制和能力更新让旧请求在原配置上结束,已有问题仍可回答;新请求使用
|
||||
新配置。专家团队例外:既有专家调度器在设置替换时取消子任务,团队父请求也随之停止,
|
||||
不使用替换后的模型汇总旧结果。详细实现见
|
||||
[恢复与终态投影](./runtime-process-reuse-technical-design.md#63-恢复与终态投影)。
|
||||
- 后台任务收到结构化提问时,通过现有 Main 路径明确失败并提示改为前台对话,不永久等待。
|
||||
- 请求结束或取消后清除对应待答映射;过期回答明确报错,不转交给后来的请求。
|
||||
- 任意终端程序的密码、OAuth 登录、许可证确认或 stdin 提示不自动回复 `yes`。这类程序应
|
||||
@@ -42,6 +50,9 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答
|
||||
|
||||
## 托管 Runtime 的并行与输出
|
||||
|
||||
子任务完整过程的显示与数据库差量存储相互独立,本机和托管 SSH 共用桌面存储层;
|
||||
旧库升级与空间回收规则见[执行记录存储与升级回收](./execution-history-storage.md)。
|
||||
|
||||
- 本机和 Agent 管理的 OpenCode 启动配置都显式设置 `snapshot: false`。GoodBuddy 不消费
|
||||
原生自动 Git 快照、快照差异摘要或快照撤销;关闭这条内部路径,避免大行级差异的同步
|
||||
计算阻塞同一原生进程中的其他会话。
|
||||
@@ -54,6 +65,21 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答
|
||||
- 远端最终启动配置由[远程主机技术设计](../remote-host/technical-design.md)定义,
|
||||
模型桥生成最终配置时同样保留上述快照策略。
|
||||
|
||||
## 工具操作摘要
|
||||
|
||||
工具记录沿用 [UI 设计系统](../../../UI-DESIGN.md)的折叠布局。Continue、DeepSeek Harness
|
||||
和直连模型共用 `toolOperationSummary`:优先使用有效原生标题,否则从参数提取命令、
|
||||
文件路径、搜索条件或 URL,合并空白后最多显示 240 字符。无法识别时保留原有工具名
|
||||
回退规则;文件正文、整份参数 JSON 不进入标题,参数详情仍可展开查看。
|
||||
|
||||
Continue 在宿主截断输入前提取摘要,流式完成与失败事件使用合并后的输入和摘要。
|
||||
DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省略输入时仍能显示操作内容。
|
||||
直连模型从原始参数生成一次摘要,在待执行、执行中和完成时复用;审批失败、拒绝和
|
||||
执行失败、刷新重试仍保留已有提示。
|
||||
|
||||
本机 OpenCode 沿用原生操作标题。远端 ACP 独立使用原生标题作为记录名称,不经过上述
|
||||
本地转换,本次没有修改 Agent、远程桥或协议。
|
||||
|
||||
## 消息底部请求状态
|
||||
|
||||
- 状态属于当前请求,不与顶部 Runtime 的连接中、就绪、不可用混为一谈。提交后先显示
|
||||
@@ -72,3 +98,78 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答
|
||||
Runtime 的重试上限、请求重放或取消策略。
|
||||
|
||||
实现与真实 Runtime 验证证据见[进度记录](./progress.md)。
|
||||
|
||||
## Runtime 执行清单
|
||||
|
||||
本节为未实现的状态设计,对应 [FR-CL1–FR-CL4、US-CL1–US-CL5](./prd.md#131-runtime-执行清单)。
|
||||
原始格式和传递边界由[技术设计](./runtime-checklist-technical-design.md)维护。
|
||||
|
||||
清单属于一次请求及其助手消息。沿用项目、执行空间、Conversation、Runtime 选择和
|
||||
原生 Session 到请求的既有映射;远端还需核对 binding/operation。不能仅以原生 Session
|
||||
或 Conversation 为键更新顶部,因为同一会话会连续执行多轮请求。
|
||||
|
||||
| 输入或操作 | 清单与顶部行为 |
|
||||
| --- | --- |
|
||||
| 本轮尚无有效清单 | 隐藏顶部清单,不复制上一轮内容 |
|
||||
| 收到本轮有效完整清单 | 按原生顺序整体替换,包括修改、删除、重排和重复文本;不按条目文本合并 |
|
||||
| 收到有效空数组 | 记录明确清空并隐藏;空数组与缺少清单字段不同 |
|
||||
| CN 调用进行中 | 候选只按该请求的调用 ID 暂存;成功后才提交完整替换 |
|
||||
| CN 调用失败、取消或格式无效 | 丢弃该候选,保留上次有效清单,工具记录保留实际结果 |
|
||||
| 请求完成、失败、取消或中断 | 冻结最新已提交清单,并显示请求真实结果;不推断条目完成,迟到输入不改写已结算清单 |
|
||||
| 同一会话开始下一轮 | 顶部转向新请求,上一轮清单留在原消息;旧轮事件不得归入新轮 |
|
||||
| 切换会话、项目、执行空间或 Runtime | 只读取目标归属的数据;原请求在原归属结算,不把原清单标为新 Runtime 的进度 |
|
||||
| 查看历史或重连 | 从原消息及既有事件恢复;按原请求和远程来源顺序去重,不能用最新原生 Session 快照覆盖历史轮次 |
|
||||
|
||||
条目状态支持 `pending`、`in_progress`、`completed`、`cancelled`。OC 原生提供的 priority 保留;
|
||||
CN 未勾选项映射为 `pending`,已勾选项为 `completed`,不推断 `in_progress` 或 priority。
|
||||
OC 无稳定 item ID,视图索引仅服务本次完整列表渲染,不生成跨更新的条目身份。
|
||||
|
||||
清单内容不创建、调度或完成产品 Task,也不改变底部请求状态。清单更新与工具活动可以
|
||||
同时保留,但顶部计数只来自已确认的结构化清单,不能反向解析普通状态文本、任意
|
||||
Markdown 或名称含 `list` 的工具。子会话清单不覆盖父请求顶部。
|
||||
|
||||
以上产品状态规则已明确;远程 CN 接入、原生格式确认及历史字段选择仍属技术待办,见
|
||||
[进度记录](./progress.md#2026-09-15-runtime-执行清单设计未实现)。
|
||||
|
||||
## Runtime 资源生命周期
|
||||
|
||||
本节对应 [FR-RR1–FR-RR6、US-RR1–US-RR4](./prd.md#141-runtime-资源复用),
|
||||
定义进程复用的行为规则。当前源码已接入本机共享 owner 和 Agent 共享 ACP;
|
||||
完整验收状态以[进度记录](./progress.md#2026-09-14-runtime-进程复用实施中)为准,不表示已发布。
|
||||
具体 owner、兼容键和协议实现见[技术设计](./runtime-process-reuse-technical-design.md)。
|
||||
|
||||
资源有三个独立维度:进程是否启动/退役,会话是否仍持有原生上下文,请求是否在途。
|
||||
“任务完成”“当前没有选中该项目”“工作栏关闭”均不能直接推导进程可以销毁。
|
||||
|
||||
| 触发 | 请求与会话行为 | 进程行为 | 对应需求/场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| 被动状态读取 | 不取得新执行会话,已有状态可读取 | 不冷启动,不延长无会话 owner 的空闲期限;不为探测替换服务中的进程 | RR2 / US-RR1 |
|
||||
| 显式测试/原生清单 | 取得临时检查引用;清单带明确工作区 | 与执行共用启动 Promise 和 owner;结束只释放检查引用 | RR1、RR2 / US-RR1 |
|
||||
| 首次或并行 Prompt | 取得对应 execution space + conversation 的绑定 | 同兼容组复用,非兼容组分别启动 | RR1、RR3 / US-RR1、RR2 |
|
||||
| 普通请求完成 | 释放请求订阅、临时 token、问题和 callback,保留可续聊 Session | 不因为一个请求完成关闭整个进程 | RR3、RR4 / US-RR3 |
|
||||
| 取消一个请求 | 取消其模型/工具调用并结算,其他会话继续 | 不以 kill 共享进程代替正常会话取消 | RR3、RR4 / US-RR2 |
|
||||
| 关闭面板/切换项目 | 停止非必要 UI 推送;不取消后台任务或抹掉会话所有权 | 不触发隐式关闭 | RR4 / US-RR3 |
|
||||
| 显式释放/删除会话 | 释放该 Session 与会话工具资源;删除历史仍只由原有明确删除操作决定 | 其他引用仍在则保留 | RR4 / US-RR3 |
|
||||
| 无会话、无请求、无检查引用 | 不存在需要保留的原生上下文 | 进入短空闲等待,再关闭;新使用者到来取消等待 | RR4 / US-RR1、RR3 |
|
||||
| 配置替换 | 已接受请求使用旧配置结算,新请求使用新配置 | 旧 owner 排空后退役;不积累只被历史 ID 持有的旧配置进程 | RR1、RR4 / US-RR4 |
|
||||
| 进程故障/不可响应 | 只给该 owner 下受影响请求明确中断;未知结果不自动重放 | 有界终止失效 owner,不伪造逐会话故障隔离 | RR3、RR4 / US-RR2、RR4 |
|
||||
| SSH detach | 保留 Agent 已接受 Prompt 及既有恢复归属 | 关闭传输不关闭 Agent 或活动 Runtime | RR5 / US-RR4 |
|
||||
|
||||
表中 RRn 是 PRD 稳定需求 ID `FR-RRn` 的简写。
|
||||
|
||||
不变量:
|
||||
|
||||
1. 一条请求始终使用接受时的 execution space、工作模式、配置和归属;不能读取后来
|
||||
切换的 UI 选择,也不以进程 cwd 表示所有会话的 cwd。
|
||||
2. 同一会话串行,跨会话不增加全局 Prompt 队列。权限、子代理、问题、用量和最终清理
|
||||
必须沿同一 Session/请求归属,不因共享进程放宽 Ask 或限制 Execute。
|
||||
3. owner 的退出条件依据所有使用者,不依据某个 adapter 的空闲状态。
|
||||
未启动对象的 dispose 不产生冷启动;一个等待者取消不取消其他等待者的初始化。
|
||||
4. 正常会话仍持有原生上下文时,不用任意 TTL 清掉它来声称“空闲内存已回收”。
|
||||
无会话 owner 的短空闲等待为 60 秒;
|
||||
不增加用户配置项。它是进程引用回收,不是聊天历史保留期限。
|
||||
5. 持久历史不因请求结束、进程 idle、配置变化或测试 cleanup 被删除。
|
||||
原生进程确实重启后的续聊沿现有历史重建规则,不新增第二套持久会话快照。
|
||||
|
||||
已明确普通并行、取消、释放、配置替换及断线行为。各项实际通过的场景及尚未完成的
|
||||
组合/性能验证以[进度记录](./progress.md#2026-09-14-runtime-进程复用实施中)为准。
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
# Runtime 进程复用技术设计
|
||||
|
||||
## 1. 状态与结论
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 已接入本机与 Agent 生产源码,Windows / Linux x64 验证结果及边界已记录,未发布 |
|
||||
| 日期 | 2026-09-15 |
|
||||
| 调研源码 | `de14a6157a6c34f1e3ef22619eca374a50a83706`;相关 Runtime 与 Desktop 0.13.2 一致 |
|
||||
| 需求 | [工作栏 PRD §14.1,FR-RR1–FR-RR6](./prd.md#141-runtime-资源复用) |
|
||||
| 行为规则 | [Runtime 资源生命周期](./runtime-interactions.md#runtime-资源生命周期) |
|
||||
| 验证事实 | [实施与验证记录](./progress.md#2026-09-14-runtime-进程复用实施中),原始调研记录保留在同页 |
|
||||
|
||||
**修复对象是进程所有权过细,不是工具太多,也不是历史记录应该删除。** 本机 OpenCode
|
||||
和 DeepSeek Harness 应在兼容启动配置内共享重型进程,工作区与会话上下文仍各自独立。
|
||||
远端 OpenCode 必须同时调整 Agent 进程所有权、ACP 连接和模型桥,不能把桌面修复当作远端
|
||||
修复。Continue 的锁定版本是单会话 Host,不做未经验证的常驻进程池;直连模型没有独立
|
||||
模型进程,应检查对象、历史和工具资源的生命周期。
|
||||
|
||||
“一个进程”指一组兼容启动配置对应一个 Runtime Server/Host,不是把不同 Runtime、
|
||||
不同 SSH 账号、不同插件组合强行塞入一个进程。正常工具命令产生的短期子进程不属于重复
|
||||
Server,也不能通过关闭 Shell、子代理或 MCP 来达成进程数指标。
|
||||
|
||||
## 2. 修复前证据与缺陷
|
||||
|
||||
### 2.1 已测现象和解释边界
|
||||
|
||||
Windows 已安装 0.13.2 的只读采样发现两个属于同一 App 的 OpenCode Server:一个原生
|
||||
数据库为 0 Session / 0 Message,约 527 MiB Private Commit;另一个已有会话并执行请求。
|
||||
两个 Server 的启动工作区不同。源码能解释“清单查询也可启动按工作区缓存的 Runtime”,
|
||||
但没有取得能独立证明空 Server 最初由哪一个 UI 操作启动的调用链记录。
|
||||
|
||||
该缺陷不等于整个 App 内存的唯一来源。五分钟活跃采样的 App 相关进程 Private Commit
|
||||
约 3.15–3.78 GiB,CPU 平均占整机约 3.15%;后续一分钟包含新任务,不是空闲回收试验。
|
||||
数据库五分钟增长约 2.72 MiB,新增子代理负载约 1.49 MiB,重复工具 upsert 为 0。
|
||||
schema 35 的存储修复与本方案独立,不能回退或删历史来改善指标。
|
||||
|
||||
同次源数据检查还发现最近 100 个会话的消息 metadata 合计约 107.75 MiB,
|
||||
`listConversations()` 会读入这些会话的全部消息详情。它是 Renderer 大对象加载的明确
|
||||
检查方向,不是堆快照已经证明的泄漏。进程复用不能代替历史按需加载的独立性能工作。
|
||||
|
||||
### 2.2 调研基线的源码归属
|
||||
|
||||
| 位置 | 已确认行为 | 修复含义 |
|
||||
| --- | --- | --- |
|
||||
| [SelectedRuntimeManager](../../../src/main/agent/selected-runtime-manager.ts) | 缓存键包含原始 selection 与 execution-space identity;默认 8 个缓存、16 个保留实例;只在容量压力下淘汰 | 轻量工作区适配器和重型进程不能继续按同一键、同一数量计费 |
|
||||
| [AgentRuntimeController](../../../src/main/agent/runtime-controller.ts) | `canRetire` 同时要求无请求、无 owned conversation;普通完成不移除会话所有权 | 不能把“已完成请求”和“已释放原生会话”混为一谈 |
|
||||
| [本机 OpenCode](../../../src/main/agent/opencode-runtime.ts) | 对象私有 Server/client/初始化 Promise;已有多会话映射;多个入口使用固定 `defaultWorkspace` | 仅删除 manager 的 workspace 键会把后续操作送进错误目录 |
|
||||
| [DeepSeek Runtime](../../../src/main/agent/deepseek-harness-runtime.ts) | 每对象一个 Utility Host;`getStatus()` 调用 `getState()` 启动 Host | manager 的临时状态实例会额外启动、探测并销毁 Host |
|
||||
| [Harness Host](../../../src/main/deepseek-harness-host.ts)、[控制面](../../../src/main/agent/goodbuddy-harness-control-plane.ts) | 根 Provider 有 cwd 默认值;控制面拒绝与 Host workspace 不同的 `newSession.cwd` | 限制主要在 GoodBuddy 创建入口,不是原生工具缺少会话 cwd |
|
||||
| [Continue Host Adapter](../../../src/main/agent/continue-host-adapter.ts) | 每次 run 启动临时 Host,最终 `/exit` 与进程树回收;准备结果已在 adapter 内缓存 | 不重复添加已有缓存;研究跨 adapter 重复准备,不直接共享活动 Host |
|
||||
| [直连模型](../../../src/main/agent/model-runtime.ts) | Main 内 HTTP;历史 Map 按 50 个会话限制,其他归属及工具资源依赖 release/dispose | 没有可合并的常驻模型子进程,按对象和资源检查 |
|
||||
| [远端 ACP](../../../src/main/agent/acp-remote-runtime.ts)、[Agent backend](../../../src/agent-daemon/runtime-acp-backend.ts) | 会话取得 binding/channel;backend 按 binding 拥有进程并拒绝跨 binding 复用 process identity | 必须拆开会话绑定与进程所有权 |
|
||||
| [Agent-owned Prompt](../../../src/agent-daemon/agent-owned-acp-prompt.ts)、[模型桥](../../../src/agent-daemon/model-bridge-helper.ts) | owner 一个 ACP connection/session/当前 operation;helper 固定模型和工作模式并串行 dispatch | 共享进程还要求会话路由、请求归属和取消隔离 |
|
||||
|
||||
原始 selection 的 `auto`、默认 profile 和显式 profile 可能解析为同一有效配置。当前
|
||||
已确认“先按原始 selection 建键、后解析配置”的顺序,但尚未证明全部 UI 调用会实际生成
|
||||
等价重复实例。实施时从 [runtime-selection](../../../src/main/agent/runtime-selection.ts)
|
||||
和调用者验证,不把可能性记为另一次生产复现。
|
||||
|
||||
### 2.3 锁定依赖与协议基础
|
||||
|
||||
以仓库 lock 和本机已安装代码为准,不用最新网页替代锁定版本:
|
||||
|
||||
| 依赖 | 基线 | 本次核验 |
|
||||
| --- | --- | --- |
|
||||
| OpenCode binary / SDK / plugin | 1.18.29 | HTTP API 接受 directory;ACP 接受多个 Session;`chat.headers` 有 sessionID |
|
||||
| ACP SDK | 0.25.1 | 一条连接关联多个 request ID,session notification/permission 带 sessionId |
|
||||
| DeepSeek Harness 包族 | 0.1.2-rc.1 | Agent factory/scoped context、Session header.cwd、真实文件与 Shell 工具 |
|
||||
| Cordis | 4.0.2 | Host 根组件与 Agent scope 的生命周期 |
|
||||
| Continue CLI | 1.5.47 | 实际受管补丁入口的单 `M.session`、QuizService 和权限状态 |
|
||||
|
||||
已通过的三个隔离探针:
|
||||
|
||||
1. **OpenCode HTTP**:调用当前生产 Server launcher,只有一次 spawn;同一个 SDK client
|
||||
在两个目录创建四个 Session,读取各自的同名文件;删除一个 Session 后其余三个仍可取回。
|
||||
没有调用 `Runtime.run`、模型或活动取消。
|
||||
2. **DeepSeek 工具**:当前 Host composition 和真实 Agent factory 在同一 Node 测试进程中
|
||||
创建两个不同 cwd 的原生 Agent;并行执行真正的 `read`、`write` 和 PowerShell,
|
||||
同名相对路径各自命中正确目录。另确认生产控制面仍拒绝第二工作区。没有启动 Electron
|
||||
Utility、ACP Prompt 或模型流,不能据此声称生产多工作区已经可用。
|
||||
3. **原生 ACP 与模型归属**:Windows 上一个 1.18.29 `opencode acp` 进程、一条 ACP
|
||||
connection、两个工作区 Session;分别选模型并发出并行 Prompt。本机确定性 HTTP
|
||||
服务收到两次请求,原生 `chat.headers` 插件传来的 sessionID 分别匹配两个 Session。
|
||||
不经过已发布 Agent backend/helper,没有测试真实工具、子代理、Linux 或断线恢复。
|
||||
|
||||
三个探针真实模型调用均为 **0**,其中第三个有 **2 次本机合成提供方请求**。
|
||||
它们证明方案有原生基础,不替代后文的真实开发任务验收。
|
||||
|
||||
特别纠正:`dsh-tool-fs` 将 `exec.agent.session.header.cwd` 传给 `fs.resolve`;
|
||||
`dsh-tool-bash` / `dsh-tool-pwsh` 也从 Session cwd 补全相对 workdir。
|
||||
`dsh-fs-local` / 本地 Shell 的根 cwd 是非会话调用的默认值,不是工作区隔离边界。
|
||||
不需要为这个已具备的能力新建一套“每工作区 Filesystem/Shell Provider”。
|
||||
|
||||
官方背景资料:[OpenCode Server](https://opencode.ai/docs/server/)、
|
||||
[DeepSeek Harness](https://www.deepseek.com/harness/en/)、
|
||||
[Continue CLI](https://docs.continue.dev/cli/quickstart)。
|
||||
网页只用于核对产品入口;上述版本结论来自本地锁定代码和探针。
|
||||
|
||||
## 3. 选定的结构
|
||||
|
||||
### 3.1 两层对象,不建立通用进程池框架
|
||||
|
||||
```text
|
||||
SelectedRuntimeManager
|
||||
-> 轻量 Runtime adapter(有效 selection + execution space)
|
||||
-> OpenCode process owner / Harness Host owner(兼容启动配置)
|
||||
-> Session binding(execution space + conversationId)
|
||||
-> 当前 requestId / 原生 sessionId / 工作目录
|
||||
```
|
||||
|
||||
- 保留 Main 已解析、校验的 execution space;adapter 每次将其显式交给共享 owner。
|
||||
目录不是 Renderer 新增的任意启动参数,也不依赖 `process.chdir` 或进程级可变 cwd。
|
||||
- owner 持有唯一启动 Promise、client/connection、子进程、会话映射与关闭逻辑。两个
|
||||
工作区同时冷启动时复用同一 Promise;一个调用者取消等待不取消其他使用者的启动。
|
||||
- adapter 的 `dispose` 只释放其引用,不直接关闭同组进程。会话映射由 owner 持有,避免
|
||||
adapter 缓存淘汰丢掉原生续接。会话操作通过 binding 查目录,不从“当前选中项目”反查。
|
||||
- 设置重建沿用现有 reset/drain:新请求取得新配置,旧请求持有启动时的配置直到结算。
|
||||
旧配置不再有在途使用者后释放旧绑定和进程,不能被历史会话 ID 永久钉住。
|
||||
IPC 的 Runtime 设置、定制及能力更新不再主动取消全部请求或清空审批;controller
|
||||
退役仍允许回答其已拥有的问题,但拒绝新请求和未知问题。
|
||||
既有专家调度器仍在配置替换时取消专家子任务,团队父请求一起停止,防止用新模型
|
||||
汇总旧 generation 的部分结果;不为该单独生命周期新建通用退役框架。
|
||||
- 8/16 的旧上限不应继续限制轻量项目 adapter 数量。若保留重型 owner 的容量保护,
|
||||
应统计实际进程配置组及退役进程;不靠提高常数掩盖“一项目一进程”。
|
||||
- 只用进程内 Map、现有 controller、Promise 和普通定时器。会话历史仍以现有 SQLite /
|
||||
Agent transcript 为准,不增加数据库表、迁移、持久化池快照、恢复日志或新配额。
|
||||
|
||||
### 3.2 兼容键与上下文
|
||||
|
||||
先通过 `applyRuntimeSelection` 等现有解析取得有效配置,再生成进程键;公开的 selection
|
||||
仍按现有语义保存,不能把“跟随默认”永久改写成某个显式 profile。
|
||||
|
||||
| 范围 | 参与进程兼容性 | 留在会话/请求 |
|
||||
| --- | --- | --- |
|
||||
| 本机 OpenCode | 本机账号、二进制身份、有效模型连接/配置、启动环境、原生配置和插件组合 | 目录、conversation/session、Prompt、模式、当前权限和问答、请求级 MCP/知识 token |
|
||||
| 本机 Harness | Host/依赖版本、有效模型连接、图片能力、凭据引用、启动环境、启用插件及配置、启动 Skill 集合 | cwd、conversation/session、请求模式、Main Web/MCP 代理与用量归属 |
|
||||
| SSH OpenCode | 已有 Host/SSH 身份、Agent/Runtime installation identity、模型协议/连接、原生插件与启动配置 | Workspace、binding/session、operation、请求凭据和账本归属 |
|
||||
|
||||
第一轮只合并同一有效模型连接及等价默认选择,不顺便支持任意不同模型连接共享:
|
||||
本机 OpenCode 配置使用固定 provider ID,Harness 用量也读取 Host config,跨 profile
|
||||
混用会引入另一项真实风险。不同 profile ID 不通过比较或序列化明文 API Key 来合并;
|
||||
密钥、Header 和插件配置更新通过现有设置失效路径使旧 owner 退役。
|
||||
|
||||
调研基线的 SSH launch profile 把 workMode 写入原生启动配置。当前实现把工作模式
|
||||
放入 Session/当前 Prompt 路由,并设置原生 Session 工具规则,详见 §6.1;
|
||||
不能仅从兼容键中删除 workMode,也不向用户增加逐工具审批。
|
||||
|
||||
## 4. 本机 OpenCode
|
||||
|
||||
在 [opencode-runtime.ts](../../../src/main/agent/opencode-runtime.ts) 中抽出重型 owner,
|
||||
由 [create-runtime](../../../src/main/agent/create-runtime.ts) 的 Main composition 复用。
|
||||
保留工作区 adapter,逐项替换隐式 `options.defaultWorkspace`:
|
||||
|
||||
1. create/get/delete Session、Prompt、历史初始化和压缩。
|
||||
2. 原生能力、工具、文件、Skill、MCP 查询的 directory。
|
||||
3. SSE 订阅、父子 Session 归属、待答 question 的 directory 与原生 ID。
|
||||
4. 权限更新、abort、问题答复/拒绝、MCP 临时注册及最终清理。
|
||||
|
||||
会话绑定记录规范化绝对目录及原生 sessionId。同一 conversation 的请求继续串行,
|
||||
不同 conversation 并行,不引入覆盖整个 Server 的 Prompt 队列。
|
||||
现有 `snapshot: false` 必须保留。
|
||||
|
||||
`mcpMutationTail` 目前属于 Runtime 对象;共享后同一原生目录的变更必须由同一 owner
|
||||
串行协调。注册名称、工具可见性、knowledge token 和清理仍按请求区分,不能用一个全局
|
||||
“当前 MCP 列表”覆盖同目录或跨目录的其他请求。探针未覆盖动态 MCP,这是实施验收项。
|
||||
|
||||
第一轮保留现有按请求消费事件流和结束清理的方式,扩展目录及 Session 过滤即可。
|
||||
只有测出重复 SSE 消费是瓶颈时才另做 owner 级事件分发,不同时引入第二套事件总线。
|
||||
健康检查也读取 owner 的总 activeRequests,不能从一个空闲 adapter 判断整个 Server
|
||||
无任务后重启。异常退出使该 owner 的全部绑定失效,明确结算受影响请求,不重放已执行工具。
|
||||
|
||||
## 5. DeepSeek Harness
|
||||
|
||||
### 5.1 共享 Host 的最小改法
|
||||
|
||||
- 将 `HarnessState`、Utility 启动 Promise、ACP connection 和事件路由提升到兼容配置
|
||||
owner;Runtime adapter 保留请求工作区。
|
||||
- 每个新 Session 在 Main 解析 `realpath`,Host 校验绝对路径格式,
|
||||
不再要求它等于 Host 启动工作区;`meta.cwd` 传入已有 Agent factory。
|
||||
不依赖 Windows 大小写字符串猜测,也不取消路径存在性校验。
|
||||
- 保留一个本地 Filesystem、Subprocess 和平台 Shell Provider。Session 使用原生 cwd
|
||||
路由;非 Session 的清单查询显式传入查询工作区,不能回退到第一个项目。
|
||||
- 保留 `setup(agentCtx)` 注册 Skill 和工具视图、每 Session 的 Ask definition 身份校验、
|
||||
`sessionId + requestId` 准备及事件路由。Main 的 Web/MCP callback 按对应请求找
|
||||
ToolProvider,不挂到某个最先创建的 adapter 上。
|
||||
- 第一轮保持同 profile、图片能力和插件组合共享,`queueUsage` 的模型配置因此仍一致。
|
||||
给用量加跨会话断言,不借本次改造扩大跨模型 Host 能力。
|
||||
|
||||
第三方插件的初始化可能依赖配置中的路径,不能因为内置工具支持 cwd 就声称所有插件
|
||||
天然多工作区安全。已启用插件组合属于进程配置;对实际产品支持的插件验证它使用
|
||||
Agent scope/Session cwd。若某插件确实有项目级初始化状态,将该状态显式作为不兼容
|
||||
启动配置,而不是默认给所有工作区各起一套 Provider 或禁止 Execute 工具。
|
||||
|
||||
### 5.2 状态与取消
|
||||
|
||||
被动 `getStatus` 改为已有 owner 快照或本地配置/Host 资源检查,不调用 `getState()`。
|
||||
没有启动过时不能写“握手已验证”,可表示资源已安装、尚未连接。
|
||||
状态检查不取得执行引用,也不清除或重置无会话 owner 的空闲计时;owner 已回收时,
|
||||
临时配置检查对象用完即释放,不将其登记成新的执行 owner。
|
||||
用户显式连接测试与原生清单可启动共享 owner;已有 owner 时只做有界控制检查,最终
|
||||
释放的是检查引用,不能在 `finally` dispose 掉仍服务其他会话的 Host。
|
||||
|
||||
当前取消超时会 terminate 整个 Utility。共享后先取消并释放目标 Session 的 Agent /
|
||||
工具资源,不因一个请求结算较慢就杀掉仍健康服务其他 Session 的 Host。若真实控制面
|
||||
已失去响应,保留整个 Host 的有界终止,向受影响会话分别报告中断;不得把它说成
|
||||
“取消完全不影响其他任务”。同步插件卡死的进程级故障无法在同一 JS 进程中强行隔离,
|
||||
不为此新增通用恢复框架。普通长工具取消与整 Host 故障必须分开测试。
|
||||
|
||||
## 6. SSH OpenCode 与 Agent
|
||||
|
||||
本节定义当前源码的共享进程改造;当前已发布版本仍见
|
||||
[远程技术设计](../remote-host/technical-design.md)。不能删除或绕过其持久 binding、
|
||||
operation、transcript、ACK 和模型 dispatch 结果不确定性规则。
|
||||
|
||||
### 6.1 连接与所有权
|
||||
|
||||
将 `RuntimeAcpBackend` 的进程 owner 与 binding 分开:
|
||||
|
||||
```text
|
||||
Agent 当前 Runtime installation + 兼容配置
|
||||
-> 一个 process owner / 一条 ACP ClientSideConnection
|
||||
-> sessionId -> binding -> 当前 operation / 原始 Prompt Promise
|
||||
```
|
||||
|
||||
- 一条 stdio 流只交给一套 ACP decoder/connection,不能让几个 ClientSideConnection
|
||||
竞争读取同一 stdout。消息和 permission 按 sessionId 分派给 binding 的当前 operation。
|
||||
- `AgentAcpConnection` 统一连接级握手、stdio 解码与退出监听,Prompt
|
||||
生命周期仍留在 binding。同一 Session 串行,其他 Session 不等待它完成。
|
||||
- `closeAcpChannel`、取消和 release 只释放所属 binding/Session;最后一个 binding
|
||||
离开后空闲 60 秒关闭进程。关闭 Session 时等待原生 `closeSession` 和原始 Prompt
|
||||
结算,不等待排在 backend 控制队列后面的完成回调。只有原生控制面不响应时才有界
|
||||
终止共享进程,并向其余请求报告中断,不声称这类故障可以逐 Session 隔离。
|
||||
- terminal 与物理进程状态分开:取消/失败的 Session 也可返回
|
||||
`processTree: running`,表示进程继续服务其他 Session,不能伪报进程树为空。
|
||||
Desktop 与 Agent 须使用配套的新契约;原始字节透传 binding 仍保留独占进程规则。
|
||||
- 共享 stdio owner 不使用单 Prompt 的累积输入和墙钟期限管理整进程;单次写入、
|
||||
待写队列、输出背压继续有界,Prompt 输入与截止时间由 binding 管理。
|
||||
- SSH attach 仍不拥有活动 Prompt,断线不能取消或重发 Agent 已接受的工作。
|
||||
历史 binding 的语义和原生 sessionId 保留;内存进程索引从当前安装和已有绑定得出,
|
||||
不增加第二份持久进程恢复账本。
|
||||
- 同一 PID 的多个 binding 不代表同一权限或同一 operation。Host/installation identity
|
||||
变化仍使用已有失效和退役规则,不把不同 Host、账号或 Runtime generation 合并。
|
||||
|
||||
原生 `chat.message` 在模型请求前设置该 Session 的工具规则:Execute 允许工具;
|
||||
Ask 只开放原生只读、搜索和读取网络内容的工具,不开放 Shell/编辑/写入。
|
||||
OpenCode 1.18.29 的 ACP 不转发未注册子 Session 的权限请求,且原生子代理只继承父
|
||||
Session 的拒绝规则、不继承允许规则,因此子 Session 也按根 Prompt 设置规则,再
|
||||
保留原生子代理的显式限制。不能只把进程启动配置统一设成 `ask`,否则子代理工具会等待。
|
||||
|
||||
### 6.2 模型桥归属
|
||||
|
||||
选用锁定 OpenCode 已有的 **`chat.headers` 会话标记**,扩展当前受管
|
||||
[原生插件](../../../src/agent-daemon/opencode-subagent-plugin.ts),不靠解析正文、猜测
|
||||
“当前最新 Prompt”或重写用户模型名来路由:
|
||||
|
||||
1. 现有 Agent prepare/start 顺序在 helper 的进程内登记 Session/根 Session 与
|
||||
binding、operation;插件从原生 sessionID 及已建立的父子关系取得精确归属。
|
||||
2. 在请求生成时固定所属 operation,将有界内部归属 Header 加到 loopback 模型请求。
|
||||
归属必须覆盖原生子代理、自动/手动压缩,不能等 HTTP 到达后才绑定“目前活动的请求”。
|
||||
3. helper 检查它是否仍属于已登记的 Prompt,选择对应现有 Agent gateway/凭据上下文。
|
||||
转发真实提供方前剥离内部 Header;不将其当作密钥或新增认证体系。
|
||||
4. 当前 helper 的单 socket/全局 dispatchBusy 改成按 operation 隔离的派发状态;
|
||||
每个 operation 的现有轮次、顺序、字节和结果不确定性规则不变,取消 A 只关闭 A 的
|
||||
提供方流,不关闭 B 的 socket、response 或待处理请求。
|
||||
5. Prompt 终态撤销对应映射及凭据;迟到请求不能借用下一轮 operation。保留
|
||||
`snapshot: false`、关闭不消费的 title 请求和现有父子问题转交。
|
||||
|
||||
普通 ACP 双 Session 的 Header 路由已有探针证据;子代理、压缩和终态竞态的实际
|
||||
验证结果以进度记录为准。若锁定版本某条实际调用路径不触发 hook,先定位那条原生路径,
|
||||
不得悄悄用全局串行 Prompt 或一个共享凭据槽冒充并行复用。
|
||||
内部桥字段如需调整,按既有受管 Agent/Runtime 版本配套交付,不移动发布标签,不给
|
||||
旧 Agent 宣称具备新行为。
|
||||
|
||||
### 6.3 恢复与终态投影
|
||||
|
||||
- Agent-owned 语义 Prompt 不一定产生原始 ACP journal 行。binary replay 仍校验现有
|
||||
cursor 边界,但不存在 raw cursor 时不向 journal ACK 0;不虚建记录来满足 replay。
|
||||
- 同一 Agent 断线恢复后,保留的 Session binding 使用新 transport generation,
|
||||
Runtime 可以同时接纳新 Session。安装身份已经更换的 attach-only 请求明确失败,
|
||||
不能无限重试阻塞项目,也不能借此重发旧 Prompt。
|
||||
- Desktop 重启时,同项目的可恢复任务并行附加,不能等待一个待答 Prompt 结束才附加
|
||||
第二个。live `question`/`question-resolved` 不属于有 provenance 的 transcript,
|
||||
使用既有 pending Map,清除时只影响对应 request/question。
|
||||
- `conversations.list` 将恢复租约的 request/message ID 和待答列表投影为可选
|
||||
`activeRequest`,Renderer 合并表单并使 Stop 指向真实请求。保存 header/消息时不保存
|
||||
这个实时字段。回答/跳过成功由 Main 按 task → assistant message 归属更新既有
|
||||
`answeredQuestions`,不写另一份问题日志,不覆盖流式正文、已完成或失败状态。
|
||||
- 明确确认的取消使用现有消息/任务终态事务一并结算,避免 Task cancelled 而消息仍
|
||||
streaming。仍无法确认的 Stop 保持可恢复;连接中断本身不是已取消或已失败的证据。
|
||||
|
||||
## 7. Continue、直连模型与相邻内存问题
|
||||
|
||||
### 7.1 Continue
|
||||
|
||||
保留每请求 Host 的隔离和最终清理。1.5.47 的单 Session、QuizService/权限队列意味着
|
||||
“一个 HTTP Server”不等于可安全承载多会话;这一例外有锁定源码依据。
|
||||
|
||||
`getPreparedHost()` 已缓存本 adapter 的准备 Promise,磁盘也有按内容确认的补丁产物。
|
||||
先分别测量读取/哈希/补丁准备、Utility 启动、HTTP ready、首个提供方请求和退出耗时。
|
||||
若确认跨 adapter 重复准备明显,再将只读准备结果与启动 Promise 按已验证的 CLI
|
||||
identity/补丁版本放到 Main composition 复用;失败仍允许重试。不得复用临时 config、
|
||||
token、global dir、活动 Session 或权限/问题队列。设置替换及窗口状态刷新也纳入测量。
|
||||
|
||||
不在本次给 Continue 加多 Session 补丁或常驻池。将来只有锁定版本真正支持多 Session
|
||||
且生产桥验证通过,才替换这一实现。
|
||||
|
||||
### 7.2 直连模型及历史详情
|
||||
|
||||
- 不新增模型 Utility 或进程池。检查 `conversations`、`knownConversationIds`、
|
||||
summary、ToolProvider 与 DirectModelProcessService 的实际引用与 release 调用链。
|
||||
- 活动请求、会话可续读的有界输出和数据库历史是不同资源。请求结束回收临时闭包/
|
||||
callback;会话释放才结束其可续读输出和工具资源,不在工具刚完成时删掉用户可查看结果。
|
||||
- 当前 50 个会话的 history 限制不是字节上限。先采堆与 retained size,再决定可从
|
||||
SQLite 重建的历史缓存是否使用字节约束和 LRU,不能先增加一套持久历史副本。
|
||||
- Renderer 最近 100 个会话整包 metadata 加载另做列表摘要、当前会话分页、展开后取
|
||||
详情的性能改造;本方案只记录依赖,不将这一尚未设计完成的 UI 改造算进进程修复交付。
|
||||
必须保持完整历史、搜索、重新打开和当前活动事件可用。
|
||||
- 旧 Runtime 磁盘缓存可单独评估版本清理策略,不在本次研究或进程 idle 时删除安装、
|
||||
用户工作区、数据库、备份或历史。
|
||||
|
||||
## 8. 内部实施顺序
|
||||
|
||||
| 阶段 | 具体工作 | 必须产出的证据 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 固化本次原生探针为聚焦回归;补有效 selection/进程计数和状态启动计数 | 修复前能复现重复启动,失败原因不依赖真实用户配置 |
|
||||
| P1 | Main composition、兼容键、owner 与 adapter 生命周期;本机 OpenCode 全入口目录化 | 真实 UI 两项目四会话同一 Server,文件/工具/问答/取消不串线 |
|
||||
| P2 | DeepSeek 共享 Utility、会话 cwd、被动状态、请求级代理及取消收敛 | 实际 Utility/ACP 两项目四会话,原生工具与插件验证,状态刷新无额外 Host |
|
||||
| P3 | Agent process/session owner、ACP 单连接路由、Header 模型桥、权限和终态清理 | 开发中的真实 Linux x64 Host,含多会话工具、断线/重连和结果不确定性 |
|
||||
| P4 | 全 Runtime 资源回归;Continue 冷启动分解与有证据的准备复用;直连对象释放检查 | 相同输入的前后进程/内存/CPU/磁盘/延迟报告;没有残留测试进程 |
|
||||
|
||||
这是依赖顺序,不是发布一半实现的许可。涉及的正常生产 UI 路径、远端专用修复和下节
|
||||
验收全部完成后,才能称为 Runtime 复用修复。Continue 测量若没有显示重复准备成本,
|
||||
明确记录不改;不要为凑齐阶段增加代码。历史 UI 分页是后续独立工作。
|
||||
|
||||
## 9. 验收与性能比较
|
||||
|
||||
### 9.1 必测场景
|
||||
|
||||
| 编号 | 对应需求 | 场景与通过条件 |
|
||||
| --- | --- | --- |
|
||||
| V1 | FR-RR1、FR-RR2 | 全新 profile 仅查看状态、切换设置,OpenCode/DSH 重型启动次数为 0;已有 Host 时刷新不新建;清单与首个真实请求并发只有一次初始化 |
|
||||
| V2 | FR-RR1、FR-RR3 | 同一有效模型连接,两个独立项目各两会话同时自然开发,OpenCode/DSH 各一重型进程;各项目同名不同内容文件、命令 cwd、Git/测试结果都正确 |
|
||||
| V3 | FR-RR3 | 第二轮复用同一批会话;Ask/Execute 混合,Ask 写入被边界拒绝,Execute 不增审批;Skill、MCP、图片能力、用量和业务问答不串归属 |
|
||||
| V4 | FR-RR3、FR-RR4 | 一会话取消真实长工具/子代理时另外三会话完成并通过独立项目测试;分别检查请求 abort、SSE、问题映射和工具句柄释放 |
|
||||
| V5 | FR-RR4 | 关闭面板不取消工作;释放一个会话不关闭共享进程;无会话 owner 空闲后退出;App 退出后本机受管进程无残留,远端按已有 detach 规则保留 Agent-owned 工作 |
|
||||
| V6 | FR-RR1、FR-RR4 | 等价默认/显式选择复用;不同实际连接/插件配置不串用;配置更新期间旧请求完成,旧 generation 不永久积累;超过八个项目不因轻量 adapter 报重型容量满 |
|
||||
| V7 | FR-RR5 | 当前源码 Desktop → SSH → 当前开发 Agent → 原生 Runtime,两个项目/四会话;断线、重连、取消、重复 ACK/事件、迟到模型请求不重复调用或跨 operation 归账 |
|
||||
| V8 | FR-RR6 | Continue 并发请求仍各自正确清理;直连 HTTP 不生出额外模型进程;会话 release 后工具/分页输出资源释放;性能采样不靠删除历史或禁用工具达标 |
|
||||
|
||||
V2/V4 必须是正常开发任务,模型可自然调用读写、Shell、测试和已支持的原生子代理;
|
||||
不是要求模型“不用工具”后回复一行。验证产出应用本身可运行、独立测试通过,模型口头
|
||||
说完成不算完成。每个 Runtime 只验证它已经支持的能力,不伪造 DSH/Continue 子代理。
|
||||
|
||||
### 9.2 采样口径
|
||||
|
||||
- 使用同一源码变更前后、同一机器、隔离 profile 与等规模历史副本;不改写用户运行库。
|
||||
覆盖至少三次冷启动、五次热请求,以及三轮多项目任务和至少十分钟**确无新任务**的
|
||||
空闲观察。分别记录中位数、范围与实际样本数,不从一次采样推断泄漏已消失。
|
||||
- 记录 Main、Renderer、Runtime 和工具子进程的 Private Commit、工作集/RSS、堆、
|
||||
CPU、PID/启动次数、会话数、活动请求、SSE/ACP 订阅与残留句柄。Windows 工作集求和
|
||||
会重复计算共享页,Private Commit 也不等于独占物理 RAM。
|
||||
- 同时报首轮准备、HTTP/ACP ready、首个模型请求、首次可见输出和总耗时。降低进程数
|
||||
但把不同会话全局串行化,不能通过验收。
|
||||
- 数据库/WAL 字节与新增事件负载分开;进程 I/O 包含管道,不能称为物理磁盘读写。
|
||||
继续检查未变化工具 upsert 为 0,旧历史读取/搜索/展开正常。
|
||||
- 核心硬指标是每个兼容组一重型进程、无额外被动启动、无跨会话错误、无结束后持续
|
||||
增加的活动句柄、无重复提供方 dispatch。没有证据前不承诺“固定省 527 MiB”或
|
||||
“整个 App 降到某个 RAM 上限”;原生 Session 仍保留时必须单独说明其常驻成本。
|
||||
|
||||
### 9.3 源码与平台验证
|
||||
|
||||
定向覆盖 manager/controller、各 Runtime lifecycle、Harness Host/control plane、
|
||||
ACP backend/owner、模型桥与插件,随后执行仓库要求的 `npm test`、`npm run typecheck`、
|
||||
`npm run lint`;生产构建改动在开发阶段按仓库规则构建验证。
|
||||
|
||||
凡改变 Agent 或桌面到 Agent 路径,必须在开发期间按
|
||||
[真实 Host 验证规则](../remote-host/technical-design.md#agent-开发期间的真实-host-验证)
|
||||
使用当前源码的共享 Linux x64 Host 验证。Windows 原生 ACP 探针、旧版本 Host、mock、
|
||||
CI 和将来发布测试都不能替代它。Windows PowerShell 与 Linux Bash 的工作目录及取消
|
||||
分别验证;macOS/Linux arm64 生命周期与原生依赖由可用原生平台和 CI 补齐并明确报告。
|
||||
|
||||
当前源码的真实 Linux Host、完整生产 UI 和最终整仓门禁结果均保留在进度记录;
|
||||
各次失败、修正、模型调用数及未测平台也一并记录,不以本机单测或原生协议探针
|
||||
替代完整产品验收。完整 App 性能样本保留用户要求的按钮位置调整,属于包含该 UI
|
||||
差异的实测对照,不是纯 Runtime 单变量实验。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 会话媒体生成
|
||||
|
||||
实现状态:已完成。纳入 Desktop 0.13.5 / Agent 0.13.0 候选,尚未发布。日期:2026-09-15。实际验证范围单独见[进度记录](./progress.md)。
|
||||
|
||||
用户添加图片模型后,可以开启“允许 AI 在会话中调用”。支持工具调用的聊天模型通过 GoodBuddy 生成图片、编辑上传图片和历史成果。切换聊天模型或 Agent Runtime 后仍可发起生成和编辑;已提交操作固定生成模型,结果保存到原会话。
|
||||
|
||||
本目录负责普通 LLM 调用媒体模型的产品行为、调用入口和生命周期。现有直连图片协议及连续修改仍由[图片生成](../image-generation/README.md)维护;本设计不表示现有功能已改变。
|
||||
|
||||
## 范围
|
||||
|
||||
图片生成与编辑覆盖直连文本 Model Runtime、本地 OpenCode、Continue、DeepSeek Harness,以及 GoodBuddy Agent 远程路径上的适用 Runtime。复用已有图片协议和各 Runtime 的内部工具或 MCP 注入,由 GoodBuddy 自动管理;用户无需创建 Skill、搭建 MCP server 或重复配置凭据。
|
||||
|
||||
使用稳定工具 `generate_image` 完成生成与编辑。全局默认和本次指定模型足够,不新增会话级选择器或独立生成表单。无工具能力的聊天模型明确提示限制,可使用已有直连图片工作流。视频不在本设计范围;完整需求以[产品需求](./prd.md)为准。
|
||||
|
||||
图片能力在现有内置 MCP 列表持续可见,Runtime 分配以只读复选框展示,由图片模型的会话调用设置自动管理;没有开启的模型时全部取消勾选,仍保留该行和模型设置入口。分配规则见[行为规则](./logic-design.md),展示与说明见[界面设计](./ui-design.md)。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [产品需求](./prd.md) | 范围、需求编号与产品验收 |
|
||||
| [用户场景](./user-stories.md) | 用户操作及场景验收 |
|
||||
| [行为规则](./logic-design.md) | 选择优先级、切换规则、状态和失败处理 |
|
||||
| [界面设计](./ui-design.md) | 配置入口、会话操作、结果卡片和反馈 |
|
||||
| [技术设计](./technical-design.md) | 工具注入、Main 服务、存储与 Runtime 接入 |
|
||||
| [进度记录](./progress.md) | 当前代码事实、剩余工作与验证证据 |
|
||||
|
||||
## 术语与依赖
|
||||
|
||||
聊天模型负责理解请求、调用工具和组织回答;生成模型负责图片生成与编辑;Agent Runtime 承载聊天模型及工具执行。工具提供结构化调用入口,使用说明随工具提供,不要求用户管理 Skill。
|
||||
|
||||
“生成操作”指一次图片生成或编辑及其结果记录,不自动创建任务中心的 Task,也不引入新的通用 Job 调度系统。领域边界遵循 [Task 与 Job 模型](../task-and-job/task-and-job-model.md)。
|
||||
|
||||
- [模型连接](../model-connections/README.md):连接、凭据、协议及请求定制。
|
||||
- [直连文本 Agent](../direct-model-agent/README.md):文本工具循环与上下文。
|
||||
- [远程主机](../remote-host/README.md):远程 Runtime 与桌面通信。
|
||||
- [统一界面规则](../../../UI-DESIGN.md):控件、反馈和可访问性。
|
||||
@@ -0,0 +1,78 @@
|
||||
# 行为规则
|
||||
|
||||
状态:设计草案。此文是模型选择、切换与生成状态的权威定义,范围见[产品需求](./prd.md)。L-1 至 L-5 编号保留,以下规则替代原视频、会话级选择及通用查询恢复规则。
|
||||
|
||||
## 能力与调用时点
|
||||
|
||||
L-1(FR-1、FR-3、FR-7、FR-9;US-01、US-08、US-11、US-15 至 US-19):自动生成与编辑要求图片连接允许会话调用、协议支持所需操作、聊天模型支持工具调用且当前请求处于 Execute。直连文本 Model Runtime、本地 OpenCode、Continue、DeepSeek Harness 及 GoodBuddy Agent 远程适用 Runtime 均须接入同一图片能力,内部工具或 MCP 注入由 GoodBuddy 管理。
|
||||
|
||||
允许调用的模型目录取自已保存且“允许 AI 在会话中调用”开启的图片模型配置。目录非空时,图片生成与编辑能力的全部支持 Runtime 分配自动勾选;目录为空时全部取消勾选。模型配置是唯一事实来源,不保存独立可变的能力启用或分配设置,用户不能操作分配复选框。
|
||||
|
||||
| 配置或运行变化 | 分配与目录结果 |
|
||||
| --- | --- |
|
||||
| 首次开启或重新开启一个图片模型 | 自动勾选全部支持 Runtime,目录包含该模型 |
|
||||
| 关闭或删除一个模型,仍有其他开启模型 | 分配保持勾选,从目录移除该模型 |
|
||||
| 最后一个开启模型被关闭或删除,或从未开启任何模型 | 全部分配取消勾选,目录为空;能力行、说明和模型设置入口仍可见 |
|
||||
| Runtime 离线、聊天模型无工具能力、Ask、配置或执行错误 | 保持按模型开关推导的勾选和目录,另行显示调用限制或错误 |
|
||||
|
||||
配置保存成功后同步刷新分配展示和允许模型目录;保存失败时保留最后已保存配置推导的状态。分配不表示运行就绪,也不保证每个图片模型支持编辑;协议及实际提交校验仍适用。
|
||||
|
||||
请求开始和聊天模型、Runtime、配置切换后刷新可用能力;进程复用也须刷新。运行中的聊天回合保留原工具协议,Main 在实际提交前检查最新配置及工作模式,已关闭的连接不能接受新操作。无工具模型说明限制并提示已有直连图片工作流;Ask 不提交生成或编辑,不自动切换模式。Runtime 接入尚未完成属于实施缺口,不能作为减少支持范围的规则。
|
||||
|
||||
## 模型和素材选择
|
||||
|
||||
L-2(FR-2、FR-5;US-02、US-03、US-05、US-07、US-14、US-15):先确定新建或编辑,再选择图片模型。
|
||||
|
||||
| 操作 | 选择优先级,由高到低 |
|
||||
| --- | --- |
|
||||
| 新建图片 | 用户本次明确指定 > 全局默认 |
|
||||
| 编辑历史成果 | 用户本次明确指定 > 原成果模型 > 全局默认 |
|
||||
| 编辑上传图片(无来源模型) | 用户本次明确指定 > 全局默认 |
|
||||
|
||||
只有较高优先级未设置时才查看下一项。选定模型被删除、关闭或不支持编辑时提示改选,不静默跳过。未设默认时提示本次指定或去设置默认后重新发送;名称歧义先澄清,LLM 不得编造模型 ID。不持久化会话级生成模型选择。
|
||||
|
||||
显式附件或点选成果优先。“刚才那张”等指代仅在对象唯一时解析;多义指代先澄清。新建不相关图片不附带旧图。编辑能力不足或原图缺失时保留输入并提示,不把编辑自动降为文生图;用户可以明确要求按文字重新生成。已有直连图片路径的行为仍由[图片技术设计](../image-generation/technical-design.md)定义。
|
||||
|
||||
## 切换规则
|
||||
|
||||
L-3(FR-4;US-03 至 US-07、US-11、US-15):Main 接受提交时固定原会话、触发消息、实际图片模型、当次连接参数、生成参数及素材引用。未提交请求在实际提交时解析默认值并校验显式选择;提交后不重选模型。图片请求由 Main 持有,独立于聊天 Runtime 生命周期。
|
||||
|
||||
| 变化 | 未提交操作 | 已提交操作与成果 |
|
||||
| --- | --- | --- |
|
||||
| 切换聊天模型 | 新回合使用新模型及可用工具,无工具能力时说明限制 | 图片模型不变,操作独立继续 |
|
||||
| 切换本地或远程 Runtime | 新回合注入图片工具,可发起生成和编辑 | 不依赖旧进程,不向新进程补发旧工具响应,不重放生成 |
|
||||
| 更改全局默认或本次指定 | 新操作按 L-2 解析 | 保持提交时模型 |
|
||||
| 关闭或删除连接 | 拒绝新操作并提示改选 | 已发请求使用当次配置尽力完成 |
|
||||
| 更改地址、凭据或参数 | 新操作使用新配置 | 当前操作不混用新端点或凭据 |
|
||||
| 离开或重开会话 | 使用当前请求及全局配置 | 只更新原会话,历史成果仍可引用 |
|
||||
| 远程断连 | 未被 Main 接收的请求不能声称已提交 | 桌面仍运行时由 Main 继续管理已接收操作 |
|
||||
|
||||
“停止回复”只结束文字回合;“取消生成”单独处理图片操作。切到 Ask 不撤销先前 Execute 已提交的操作,也不允许新增生成。运行时切换不自动唤起新的 LLM 请求。
|
||||
|
||||
## 操作状态
|
||||
|
||||
L-4(FR-6、FR-8;US-04、US-09、US-13):复用现有操作与成果反馈,状态只表达实际知道的事实。没有上游百分比时显示状态及等待时间,不虚构进度。
|
||||
|
||||
| 状态表达 | 条件与处理 |
|
||||
| --- | --- |
|
||||
| 进行中 | 正在发送或等待响应;仅有上游确认时才说明已接受生成 |
|
||||
| 保存中 | 已取得图片,Main 正在校验并保存 |
|
||||
| 已完成 | 图片及会话引用已保存,可重新读取 |
|
||||
| 取消中 | 已请求终止,尚无明确结果 |
|
||||
| 已停止等待/已取消 | 本地中断只说明“已停止等待,上游可能继续生成”;上游确认停止才说明“生成已取消” |
|
||||
| 失败 | 有明确的请求或保存失败原因,保留输入和错误上下文 |
|
||||
| 结果未确认 | 响应丢失、超时或重启后无法确认结果,不当作上游失败或取消 |
|
||||
|
||||
同一操作的通知更新同一记录。取消后仍取得图片时,在原卡片保存并说明“取消未生效,已生成”,不恢复旧工具回合。超时不等于上游取消,不承诺退款。
|
||||
|
||||
人工重新生成创建新操作,沿用原模型与输入并重新校验;改选模型时在新卡片显示实际选择。失败、结果未确认及保存失败均不自动重发收费请求,不设置独立保存重试入口。应用退出后不承诺继续本地执行,重启时未完成记录显示结果未确认,不建立跨重启查询恢复或通用恢复框架。
|
||||
|
||||
## 历史成果
|
||||
|
||||
L-5(FR-5、FR-8;US-07、US-12、US-14、US-15):历史上下文提供成果引用、提示词及实际图片模型信息。只有具备视觉输入能力的聊天模型才接收适用的图片内容;无视觉模型可根据用户文字将原图引用交给编辑工具,但不能声称看过图片。上传图片和历史成果均使用已有可持久化素材引用。
|
||||
|
||||
删除会话沿用已有确认流程,说明活动生成的取消及无法保证上游停止的情况;删除后的晚到结果不能重建会话。
|
||||
|
||||
## 完整性与实施验证
|
||||
|
||||
规则覆盖配置、新建、上传图与历史图编辑、模型与 Runtime 切换、Ask、无工具或无视觉模型、取消、失败、重启和会话归属。没有待定产品范围;各 Runtime 工具注入、远程往返及取消传播的具体接法需在实施时核验,按[技术设计](./technical-design.md)完成真实路径验收。视频、跨设备同步和应用退出后的本地执行不在范围内。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 产品需求
|
||||
|
||||
状态:设计草案。本文描述目标行为,不作为现有能力说明。
|
||||
|
||||
## 用户问题与范围
|
||||
|
||||
用户希望在文字会话中直接提出“画一张海报”“把上传图片的背景改成蓝色”“继续修改刚才的图片”,由聊天助手调用已配置的图片模型。切换聊天模型或 Runtime 后,应能继续生成、引用和编辑图片。
|
||||
|
||||
范围包括图片模型调用开关、全局默认与本次指定、文生图、已有协议支持的上传图片和历史图片编辑、成果保存、取消及状态反馈。支持直连文本 Model Runtime、本地 OpenCode、Continue、DeepSeek Harness,以及 GoodBuddy Agent 远程路径上的适用 Runtime;各路径的生成、编辑和切换均属于验收范围。
|
||||
|
||||
GoodBuddy 自动管理内部工具或 MCP 注入,复用模型连接与凭据。用户无需创建 Skill、搭建 MCP server 或填写第二套凭据。自动调用要求聊天模型支持工具调用;无工具能力时明确说明限制,用户可使用已有直连图片工作流。
|
||||
|
||||
不包含视频生成、模型训练、自动采购服务、通用文本指令执行器、批量渲染平台或应用退出后继续本地执行的承诺。不新增图片协议、会话级生成模型选择器、独立生成表单或通用恢复框架,也不安排视频交付计划。
|
||||
|
||||
## 功能需求
|
||||
|
||||
保留 FR-1 至 FR-9 编号。原需求中的视频部分移出范围,FR-2 的会话级选择由全局默认和本次指定替代,FR-7 的新增显式入口由已有直连图片工作流替代;下表为当前定义。
|
||||
|
||||
| 编号 | 要求 | 产品验收 |
|
||||
| --- | --- | --- |
|
||||
| FR-1 | 图片模型提供“允许 AI 在会话中调用”开关;新增和已有连接默认关闭,由用户主动开启;模型配置是图片能力启用与 Runtime 分配的唯一事实来源 | 至少一个模型开启时自动勾选全部支持 Runtime;全部关闭或删除时全部取消勾选;关闭或删除其中一个后若仍有开启模型,保持勾选并更新允许调用的模型目录;被关闭模型拒绝新操作,历史成果保留 |
|
||||
| FR-2 | 支持全局默认和本次明确指定;编辑可沿用原成果模型 | 按[选择规则](./logic-design.md)解析并显示实际模型;失效或有歧义时提示,不静默换服务 |
|
||||
| FR-3 | 在支持工具调用的聊天模型下,以自然语言生成和编辑图片 | 上传图片、历史图片及文生图均可经真实服务处理,保存后在原会话展示,活动记录可见 |
|
||||
| FR-4 | 支持聊天模型、Runtime 及生成模型切换 | 切换后可发起新的生成和编辑;已提交操作固定原模型和原会话,不重复提交 |
|
||||
| FR-5 | 支持上传素材、历史成果引用与连续编辑 | 重开会话及切换 Runtime 后可引用已保存原图;缺失素材或能力不足时保留输入并提示处理方式 |
|
||||
| FR-6 | 生成操作独立显示状态,支持取消及人工重新生成 | 离开会话不取消;保存成功才完成;不虚构进度,不自动重试收费请求;重启后未完成操作显示结果未确认 |
|
||||
| FR-7 | 遵循工作模式及模型能力边界 | Ask 不提交生成或编辑;无工具模型说明限制,可使用已有直连图片工作流,无需新增表单 |
|
||||
| FR-8 | 图片作为会话成果保存 | 可预览、导出和重新打开,来源模型可追溯;无视觉聊天模型只接收文字事实及引用,不编造观察 |
|
||||
| FR-9 | 本地及 GoodBuddy Agent 远程适用 Runtime 均支持图片工具;GoodBuddy 管理的图片生成与编辑能力及 Runtime 分配持续可见 | 分别完成真实生成、编辑、切换验收;分配复选框只读,不可手动切换;无开启模型时仍显示该行、未勾选状态、自动管理说明及模型设置入口;无独立能力启停、分配、移除或凭据编辑设置,用户无需维护 Skill 或 MCP server |
|
||||
|
||||
分配表示模型配置允许的调用范围。聊天模型不支持工具、Ask、Runtime 离线、配置错误或执行失败均单独说明,不取消配置分配的勾选;不保存可独立修改的能力启用或 Runtime 分配状态。具体推导与刷新规则由 L-1 定义。
|
||||
|
||||
## 产品验收
|
||||
|
||||
以[用户场景](./user-stories.md)验收。各 Runtime 分别记录真实工具往返、生成、上传图编辑、历史图编辑及切换结果,不能用单一路径成功代替其他路径。配置检查或工具注入成功不等于生成成功,成果必须可在会话中重新打开。
|
||||
|
||||
Runtime 接入细节和真实 Host 验证属于实施工作,未验证时如实记录未完成,不据此缩减 FR-9 的支持范围。
|
||||
@@ -0,0 +1,83 @@
|
||||
# 进度记录
|
||||
|
||||
## 2026-09-15
|
||||
|
||||
### 真实图片请求与构建修复
|
||||
|
||||
已使用现有 `gpt-image2` 连接(模型 `sub-gpt-image-2`)经生产 `ImageGenerationService` 发起两次真实图片请求:生成白底蓝色圆形,再上传该成果将圆形改成红色。两次均 HTTP 200,保存并重新解码成功,导出图片目视检查符合要求。请求数为 2,自动重试为 0,用户保存的配置未修改。请求尺寸为 1024×1024,供应商实际返回两张 1254×1254 PNG;未对结果重采样。证据保存在本机临时目录 `image-bounded-oiOPRi/`。此项验证 Main 图片服务与真实供应商,尚不替代真实聊天模型经各 Runtime 自行调用工具及完整界面切换验收。
|
||||
|
||||
用户报告 Renderer 生产构建失败,定位到 `remote-image-tool-contracts.ts` 的 `node:crypto` 被共享契约间接引入浏览器。已将 MCP 名称哈希移至仅 Main 与 Agent 引用的 `remote-image-tool-node.ts`,共享消息编解码使用标准 `TextEncoder`/`TextDecoder`,无新增依赖或 polyfill。修复后 `npm run build`(含类型检查、Main、Preload、Renderer 构建)、`npm run lint` 通过,4 个相关测试文件共 89 项通过。此前仅运行类型检查和测试未发现这一构建问题,不能作为生产构建通过证据。
|
||||
|
||||
### 实施与集成复验
|
||||
|
||||
工作区已有模型会话调用开关、默认图片模型、配置派生的四种 Runtime 只读分配、Main 图片服务、上传与历史成果引用、操作状态及取消/重新生成 IPC。本地 Model、OpenCode、Continue、DeepSeek Harness 和受管远程 OpenCode 均已接入同一服务。操作复用消息 metadata 和成果存储,未新增任务表或独立分配设置。
|
||||
|
||||
本轮修复远程保留会话在图片目录清空后未刷新 MCP,以及 Continue 准备自定义 MCP 失败时未释放本次图片工具令牌的问题。图片卡片更新保持原顺序;取消提示区分请求取消、停止等待和取消未生效后的结果保存。失败卡片提供模型设置与素材重选入口,无工具能力及 Ask 有相应提示。长名称和提示词自动换行,窄窗口及 200% 缩放下恢复按钮可换行。
|
||||
|
||||
验证证据如下;此前小节中的超时和未验证记录仅代表各次检查时点:
|
||||
|
||||
| 验证 | 结果与边界 |
|
||||
| --- | --- |
|
||||
| `npm test` | 364 个文件通过、9 个跳过;4,267 项通过、67 项跳过。后续恢复入口及样式修改另有 269 项定向测试通过,未再运行全量测试 |
|
||||
| `npm run typecheck`、`npm run lint` | 最后一次 Renderer 修改后通过 |
|
||||
| 真实 Linux Host | 最终 Agent 构建通过固定主机身份连接,使用实际远程 OpenCode;Ask、Execute 各 1 次真实文本请求均 HTTP 200 并完成,真实图片请求 0 次 |
|
||||
| Host 图片工具往返 | 最终运行含 7 次受控图片请求、15 次受控文本请求;生成、上传和历史图编辑、目录刷新、Ask 隐藏、并发会话归属、停止回复和断连后 Main 继续保存均通过。图片服务返回固定内联 PNG,不构成真实生图证明 |
|
||||
| Electron 界面 | 当前生产组件、样式、字体及双语文案,使用隔离 fixture API;初轮 94 项检查,恢复入口完成后 40 项检查通过。覆盖明暗主题、四种窗口尺寸、键盘导航及 200% 缩放;没有修改用户配置 |
|
||||
|
||||
最终 Host 运行退出码为 0,残留自有进程为 0,隔离目录和上传文件已清理。此前受控复验另有 7 次图片请求和 15 次文本请求,因此本轮合计真实文本请求 2 次、真实图片请求 0 次、受控图片请求 14 次、受控文本请求 30 次。此前历史测试记录不计入此合计。
|
||||
|
||||
本机临时证据位于 `C:/Users/jiang/AppData/Local/Temp/opencode/`:`image-review-full-tests-20260915.stdout.log`、`image-host-final-real-20260915.stdout.log`、`image-host-review-20260915.stdout.log` 及 `image-visual-0915/`。这些是开发检查产物,不随产品发布。
|
||||
|
||||
当时尚未完成真实图片供应商生成与编辑;后续验证见上方记录。完整 Electron Main/Preload 路径中的自然语言触发及本地/远程双向切换验收仍待完成,FR-3、FR-9 尚未完成全部产品验收。
|
||||
|
||||
隔离的真实图片验证脚本离线校验通过后,已按用户要求执行上述两次真实请求。该连接的会话调用仅在验证内存中启用,不修改保存配置。此前 `deai-writing` 文档扫描阻断项为 0,25 个候选经语境复核保留产品边界、状态限制和历史证据;`git diff --check` 通过。
|
||||
|
||||
### Renderer 与设置复查
|
||||
|
||||
本轮在已有未提交实现上修复图片操作卡片:状态事件原位更新,保留同一消息中多次生成的卡片顺序;取消提示区分正在请求取消、已停止等待和取消未生效但图片已保存。按钮通过 `aria-describedby` 关联就地操作错误,中英文提示同步更新。补充组件回归,并在现有 App 测试中检查离开原会话、结果晚到、返回及切换 Runtime 后继续修改时的卡片顺序。
|
||||
|
||||
已检查模型设置、RuntimeSettingsStore、CapabilityService 和共享设置契约。现有实现从已保存模型开关派生四种 Runtime 的只读分配,不持久化独立分配;关闭或删除默认图片模型后保留失效默认。相关测试本轮通过,未改动这些生产契约。共享工具目录测试原先仅排除浏览器工具,现补充图片受管能力例外,确认 `generate_image` 保持写操作且不重复进入直连模型设置分组。
|
||||
|
||||
验证记录:
|
||||
|
||||
- `npx vitest run src/renderer/src/ImageOperationStatus.test.tsx src/renderer/src/App.test.tsx src/renderer/src/SettingsPanel.test.tsx src/main/runtime-settings-store.test.ts src/main/capabilities/capability-service.test.ts`:5 个文件、451 项通过。
|
||||
- 修正目录测试后,`npx vitest run src/shared/builtin-model-tools.test.ts src/renderer/src/ImageOperationStatus.test.tsx`:2 个文件、5 项通过。
|
||||
- `npm run typecheck`、`npm run lint` 通过。
|
||||
- `npm test` 在 120 秒上限被终止;期间报告的共享工具目录测试失败已单独修正并复验,未取得全量测试通过结论。
|
||||
- 未调用真实文本或图片服务,请求数均为 0;未执行 Electron 视觉验收或真实 Host 验证。本轮没有修改 `src/main/agent` 或 `src/agent-daemon`。各 Runtime 的真实生成、编辑、取消及切换验收仍待完成。
|
||||
|
||||
### 此前文档修订记录
|
||||
|
||||
本日补充:已按最新明确接受的要求修订 FR-1、FR-9、L-1、界面与技术设计,并新增 US-16 至 US-19。只读分配及模型配置派生规则见[行为规则](./logic-design.md),受管行、说明与无障碍要求见[界面设计](./ui-design.md)。已读取根目录与 features 的 AGENTS、根 UI-DESIGN.md 及本目录七份现有文档,保留此前未提交修订;本次未修改目录外文件或应用代码,相关行为仍待实现与产品验收。
|
||||
|
||||
本日更正:此前将其他 Runtime 自动调用排除在交付范围之外的记录不符合用户要求,现由本条替代。七份文档已统一为图片生成和编辑,支持范围及验收以[产品需求](./prd.md)和[用户场景](./user-stories.md)为准,不再保留分期规则。视频移出范围,US-10 保留编号并明确标注;其他被替代要求已记录编号变更。
|
||||
|
||||
本次只修改本目录设计文档,未检查或改变应用实现。2026-09-14 记录仅为当日历史证据,不表示已重新验证。图片工具、Main 请求生命周期、各本地及 GoodBuddy Agent 远程适用 Runtime 的生成、编辑和切换均仍需实施与实际验证;具体注入及桥接核验属于实施工作,不缩减支持范围。
|
||||
|
||||
本次文档验证:7 份文档的 38 个相对 Markdown 链接均可解析;一致性审阅覆盖模型配置唯一来源、只读分配、空目录保留行、运行状态独立及完整 Runtime 范围,未发现分期或缩减支持范围的现行规则。`deai-writing` 扫描阻断项为 0,25 个复核候选涉及设计状态、职责与必要行为限制,逐条按语境保留,并通读检查结构及前后规则。`git diff --check -- docs/features/conversation-media-generation` 通过;本次七份文件均已被 Git 跟踪,检查覆盖其工作区差异。该命令不覆盖未跟踪文件,不能沿用此前“目录未跟踪”的结论。未运行应用测试或真实生成请求,未声称功能完成或重新验证此前代码。
|
||||
|
||||
## 2026-09-14(历史记录)
|
||||
|
||||
以下保留当日检查与原设计记录,其中视频、保存重试等旧范围已被 2026-09-15 更正取代,不作为当前实施要求。
|
||||
|
||||
当前阶段:设计草案已落盘,功能未实现。本次只新增设计文档与相关入口链接。
|
||||
|
||||
代码与文档检查确认以下基础能力:
|
||||
|
||||
| 事实 | 证据 |
|
||||
| --- | --- |
|
||||
| 已有直连图片生成、编辑及历史成果引用 | [图片技术设计](../image-generation/technical-design.md)、`src/main/agent/model-runtime.ts` |
|
||||
| 模型连接及 Main 凭据存储已存在 | `src/shared/contracts.ts`、`src/main/runtime-settings-store.ts` |
|
||||
| 已有 Skill 发现、Runtime 分配及工具分发 | `src/main/capabilities/capability-service.ts`、`src/main/agent/model-tool-provider.ts` |
|
||||
| 会话、消息 metadata 和成果已有 SQLite 存储 | `src/main/assistant/assistant-database.ts`、`src/shared/assistant-contracts.ts` |
|
||||
| 生图尚未成为普通 LLM 的内置生成工具 | 检查 `src/shared/builtin-model-tools.ts` 及工具分发路径 |
|
||||
| 本次检查未发现第一方视频生成协议实现 | 检查模型能力契约、Runtime 及现有功能文档;不据此推断外部 MCP 的能力 |
|
||||
|
||||
剩余工作对应[技术设计实施顺序](./technical-design.md):模型调用设置与选择、图片服务及会话入口、各 Runtime 接入、视频真实服务与成果交付均待实施。视频协议选择和远程工具桥接验证待完成。
|
||||
|
||||
本次未修改应用代码,未运行应用测试或真实生成请求。文档验证记录如下:
|
||||
|
||||
- 相对 Markdown 链接检查:7 份新文档及图片功能入口共 33 个链接,失效链接为 0。
|
||||
- `deai-writing` 扫描:阻断项为 0;人工复核保留设计状态、能力边界和禁止行为等必要限定,以及真实并列的验收项。
|
||||
- 独立一致性审阅发现“重新生成”与“重试保存”的操作身份表述不一致,已在 L-4 和 US-09 中统一:前者创建新操作,后者更新原操作。
|
||||
- `git diff --check` 通过。上述检查只验证文档,不构成功能验收。
|
||||
@@ -0,0 +1,81 @@
|
||||
# 技术设计
|
||||
|
||||
状态:工作区实现已接通,真实图片服务与完整产品验收未完成,证据见[进度记录](./progress.md)。内部契约实现 [FR-1 至 FR-9](./prd.md)及[行为规则 L-1 至 L-5](./logic-design.md),不作为外部公开 API。
|
||||
|
||||
## 调用结构
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Direct[直连文本 Model Runtime] --> Tool[generate_image]
|
||||
Local[本地 OpenCode / Continue / DeepSeek Harness] --> Injection[GoodBuddy 管理的内部 MCP 注入]
|
||||
Remote[GoodBuddy Agent 远程适用 Runtime] --> Bridge[现有 Agent 通信与工具桥接]
|
||||
Bridge --> Injection
|
||||
Injection --> Tool
|
||||
Tool --> Main[Main 图片服务]
|
||||
Main --> Profile[现有模型连接与凭据]
|
||||
Main --> Adapter[现有图片生成与编辑协议]
|
||||
Main --> Artifact[原会话操作记录与图片成果]
|
||||
```
|
||||
|
||||
Main 持有图片请求、取消句柄及结果保存职责,生命周期独立于聊天 Runtime controller。切换或释放聊天进程不释放已提交图片操作。Renderer 的取消等操作经明确 preload/IPC 方法进入 Main,沿用共享契约和发送方校验。凭据只在 Main 解密,不写入工具说明、参数、输出或远程 Runtime 配置。
|
||||
|
||||
`ImageGenerationService` 复用 `ModelAgentRuntime.generateImage` 的图片请求和校验,在 Main 保存操作及成果。工具路径按 L-2 明确区分新建和编辑,不自动将缺少素材或不支持的编辑降为文生图;已有直连图片路径保持原上下文规则。
|
||||
|
||||
## 工具与注入
|
||||
|
||||
稳定图片工具 `generate_image` 同时负责生成和编辑。当前输入为 `prompt`、可选 `modelProfileId`、`intent`(`create` 或 `edit`)、`sourceArtifactIds` 和可选 `quality`;类型及编辑素材约束由 `src/shared/image-generation-contracts.ts` 校验。每次请求沿用已有协议的单图生成和尺寸行为,没有新增数量或尺寸参数。原会话、触发消息和工具调用关联由运行上下文绑定,LLM 不能自由指定结果写入位置或任意文件路径。
|
||||
|
||||
每个新回合提供可调用图片模型的脱敏 ID、名称及生成、编辑能力,模型切换、配置变化或进程复用时刷新。Main 提交前重新校验工作模式、连接开关、所需能力及素材。优先由工具描述承载模型选择、图片引用和结果使用说明,不把 Skill 管理作为依赖。
|
||||
|
||||
直连文本 Model Runtime 使用内置工具契约及分发;外部 Runtime 复用内部 MCP 注入,由 GoodBuddy 自动注册、配置和管理生命周期,远程路径经现有 Agent 工具桥接调用同一服务。内置 MCP 列表中的图片行只展示这项受管能力,不代表直连 Model 也通过 MCP 调用。无需用户创建 Skill、另建 MCP server 或复制凭据,不按模型连接复制工具或 Skill。
|
||||
|
||||
Main 从已保存图片 profile 的会话调用开关推导允许模型目录,并以目录是否非空推导全部支持 Runtime 的分配。Renderer 只读取派生分配;该受管行不接入通用 MCP 的启停、分配、删除或凭据编辑写入接口。模型配置保存后刷新展示及注入目录,进程复用和下一回合重新读取;运行中的回合及已提交操作继续遵循 L-1、L-3。配置错误、Runtime 在线状态、聊天模型工具能力与执行结果独立于派生分配,不因这些状态移除允许目录中的模型或取消勾选。
|
||||
|
||||
不预建能力发现、查询或取消工具目录。只有实际 Runtime 协议要求独立发现、查询或取消交互,且现有工具描述、响应及 UI 操作不能满足时,才增加所需的最小适配,并记录协议依据;这些适配仍调用同一 Main 图片服务,不形成新的媒体平台。取消默认从会话卡片进入 Main。
|
||||
|
||||
工具等待遵循现有超时约束,返回操作 ID、实际模型、真实状态及完成后的成果 ID,不把 Base64 塞入工具历史。原回合仍有效时通过合法工具响应返回结果;回合结束或 Runtime 切换后,Main 继续写原会话,下一回合读取操作摘要及成果引用,不补发旧响应、不自动唤起 LLM 或重发图片请求。
|
||||
|
||||
## 配置与持久化
|
||||
|
||||
| 对象 | 最小数据与职责 |
|
||||
| --- | --- |
|
||||
| 图片模型连接 | 复用 settings/profile,增加会话调用开关;现有连接缺少开关时解释为关闭 |
|
||||
| 图片能力与 Runtime 分配 | 从图片模型配置派生,只读展示;不新增独立可变的 capability enabled、assignment 设置或持久化副本 |
|
||||
| 全局偏好 | 默认图片 profile ID,复用应用设置;本次指定只属于请求,不新增会话级选择 |
|
||||
| 生成操作 | 优先使用现有消息 metadata,保存操作 ID、原会话/消息、工具关联、状态、实际模型 ID 与名称、提示词、有效参数、素材引用、时间、错误及成果 ID |
|
||||
| 素材与成果 | 复用附件和 artifact 持久化,保存上传图片、生成图片及编辑来源关系,不能只存于 Runtime 内存 |
|
||||
|
||||
活动请求在 Main 内存持有提交时连接参数,持久记录不复制凭据。应用重启后未完成操作显示结果未确认,不建立跨重启查询恢复、凭据历史、通用恢复日志或自动重试。取消和晚到结果按 L-4 更新同一记录;人工重新生成使用新操作 ID。
|
||||
|
||||
不新增独立任务表、视频字段或通用调度器,操作不自动成为 Task/Job。若实施涉及已发布数据,按[迁移规范](../../development/database-migrations.md)处理实际兼容需求,不预建迁移或恢复机制。
|
||||
|
||||
Main 仅在图片和会话引用持久化成功后发布完成事件。素材必须是当前会话可访问的附件或成果,沿用已有项目和会话边界;删除会话后的晚到结果不重建会话。
|
||||
|
||||
## 图片协议
|
||||
|
||||
沿用[图片技术设计](../image-generation/technical-design.md)的已有生成、编辑协议及素材限制。接收并校验有界内联图片,不下载 Provider 返回的图片 URL。沿用现有成果存储限制,实施时核对编码开销与超限反馈,不以配置检查成功代替真实生成验收。视频协议、下载、播放及交付计划均不属于本设计。
|
||||
|
||||
## Runtime 接入
|
||||
|
||||
| 路径 | 设计接入点 | 必须完成的验证 |
|
||||
| --- | --- | --- |
|
||||
| 直连文本 Model Runtime | 现有内置工具契约及工具分发转入 Main 图片服务 | 自然语言生成、上传图编辑、历史图编辑及切换 |
|
||||
| 本地 OpenCode | 现有内部 MCP 注入转入同一服务 | 实际工具往返、能力刷新、生成编辑及切换 |
|
||||
| 本地 Continue | 现有内部 MCP 注入转入同一服务 | 实际工具往返、能力刷新、生成编辑及切换 |
|
||||
| 本地 DeepSeek Harness | 现有内部 MCP 注入转入同一服务 | 实际工具往返、能力刷新、生成编辑及切换 |
|
||||
| GoodBuddy Agent 远程适用 Runtime | 复用桌面与 Agent 通信及工具桥接,将请求绑定原会话,由 Main 调用图片模型 | 逐一验证实际支持的远程 Runtime,覆盖真实 Host 工具往返、生成编辑、本地与远程双向切换、断连及成果归属 |
|
||||
| 无工具能力的聊天模型 | 说明自动调用限制,可使用已有直连图片工作流 | 不解析普通文字伪装工具调用,不新增表单;Ask 不提交 |
|
||||
|
||||
本地 OpenCode、Continue 和 DeepSeek Harness 经内部 MCP gateway 注入;直连 Model 使用内置工具分发。远程通过 `main-image-tool-session.ts`、Agent 协议和 `image-tool-mcp.ts` 回到同一 Main 服务。现有 GoodBuddy Agent 的受管远程 Runtime 为 OpenCode,项目校验和 Runtime 创建均沿用此范围;共享契约包含其他 Runtime 不表示存在其远程执行路径。保留会话在目录清空时也刷新 MCP,Continue 准备失败时释放本次分配的令牌。工具发现成功或 Skill 注入成功不能代替真实生成与编辑。
|
||||
|
||||
Main 已接收的请求在桌面仍运行时独立于远程连接继续;未确认是否接收的请求不自动重放。聊天回合停止等待与用户取消图片是不同动作,须验证现有取消传播不会误杀 Main 图片请求。
|
||||
|
||||
## 实施与验证
|
||||
|
||||
实施工作包括配置与选择、Main 图片服务提取、各本地及远程 Runtime 工具适配、成果与状态 UI。它们共同完成本图片功能,不按 Runtime 划分交付范围。
|
||||
|
||||
针对 L-1 至 L-5 验证选择优先级、失效配置、上传与历史素材、无视觉模型、Ask、会话归属、重复通知、取消及晚到结果。检查切换聊天模型、图片默认模型、本地与远程 Runtime 后的新生成和编辑,以及旧操作不重复提交。回归已有直连图片工作流。
|
||||
|
||||
按 US-16 至 US-19 验证从零到一个开启模型、多个中关闭或删除一个、最后一个关闭或删除、重新开启,以及配置错误和 Runtime 离线时的派生分配、目录刷新、行可见性与只读交互。重开设置和应用后均从模型配置推导,不恢复独立分配值;辅助技术可读取标签、勾选状态和说明,键盘可到达模型设置导航。
|
||||
|
||||
源代码实现后运行 `npm test`、`npm run typecheck`、`npm run lint`,并逐路径执行[用户场景](./user-stories.md)的真实产品验收,记录模型、Runtime、请求数量和结果。涉及 Agent 的实现按[远程主机验证要求](../remote-host/technical-design.md)在真实测试 Host 验证。实施和集成验证状态由[进度记录](./progress.md)维护。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 界面设计
|
||||
|
||||
状态:工作区已有实现及 Electron 组件验收,完整应用验收见[进度记录](./progress.md)。遵循[统一界面设计](../../../UI-DESIGN.md),实现 [FR-1 至 FR-9](./prd.md),行为以[行为规则](./logic-design.md)为准。
|
||||
|
||||
## 模型配置
|
||||
|
||||
在现有图片模型连接表单提供“允许 AI 在会话中调用”。说明文字:“开启后,支持工具调用的聊天助手可使用此模型生成和编辑图片,编辑能力取决于图片协议。结果保存在发起请求的会话中。”复用共享 Switch,提供 `role="switch"`、键盘操作及可见焦点。
|
||||
|
||||
开启后提供“设为默认生图模型”,列表显示可调用和默认标识。关闭或删除默认连接时提示默认选择不可用,保留失效选择供改选,不自动指定其他模型。用户无需创建 Skill、搭建 MCP server 或重复填写 API 凭据;内部注入由 GoodBuddy 管理,不作为配置步骤展示。
|
||||
|
||||
## 能力与 Runtime 分配
|
||||
|
||||
复用设置中心“MCP / 内置 MCP”的列表与分配布局,增加“图片生成与编辑”行,标记“内置 · 自动管理”。这是 GoodBuddy 管理能力的展示入口,直连 Model 通过内部工具调用,外部 Runtime 通过内部 MCP 接入;不显示为用户另行添加的 MCP 或 Skill,也不混入 Runtime 原生能力清单。
|
||||
|
||||
该行按 L-1 展示直连文本 Model Runtime、本地 OpenCode、Continue、DeepSeek Harness 及 GoodBuddy Agent 远程适用 Runtime 的只读分配复选框。此图片能力采用用户已接受的自动管理规则,不沿用其他内置 MCP 的手动启停、可调分配或 DeepSeek Harness“暂不支持”状态。没有开启模型时仍保留整行及全部未勾选项,不能因目录为空隐藏。
|
||||
|
||||
持续显示说明“由图片模型的「允许 AI 在会话中调用」设置自动管理。”,并提供“前往图片模型设置”导航。该行不提供独立启用开关、保存分配、移除、编辑连接或凭据入口。运行就绪、无工具能力、配置或调用错误单独显示,不改变分配勾选。
|
||||
|
||||
分配复用共享 Checkbox 的方形视觉和原生语义,以 `checked` 表示派生值并使用 `disabled` 禁止鼠标、标签点击或键盘切换;仅在共享控件已有受支持的只读实现时采用该实现,不给原生 Checkbox 添加无效的 `readonly` 或自行添加 `aria-readonly`。每项保留 Runtime 标签和可访问名称,以 `aria-describedby` 关联自动管理说明。说明放在禁用控件之外,保持可读及辅助技术可访问;导航保持正常 Tab 顺序和可见焦点。窄窗口下分配项换行,保留标签、勾选状态、说明和导航。
|
||||
|
||||
## 会话入口
|
||||
|
||||
在现有聊天输入区以自然语言发起图片生成或编辑,本次指定模型也通过请求表达。复用上传附件和历史成果引用入口,输入区显示素材缩略图或文件名并允许移除。不新增会话级图片模型选择器、独立生成表单或视频入口。
|
||||
|
||||
未设默认时,助手提示本次指定可用模型,或到设置选择默认后重新发送;名称歧义先澄清。没有图片连接时提供现有模型设置入口。模型不支持编辑或素材缺失时保留输入,提示更换模型或重新选择素材。
|
||||
|
||||
直连文本、本地各 Runtime 和 GoodBuddy Agent 远程适用 Runtime 使用一致的图片操作与成果反馈。切换后可继续生成及编辑历史图;工具调用异常时显示具体原因,不宣称已提交。接入未完成的开发状态记录在进度文档,不能作为产品支持范围。
|
||||
|
||||
不支持工具调用时提示“当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流”。Ask 下提示“生成或编辑图片需要 Execute 模式”,由用户切换,不自动切换。无视觉能力时说明无法直接查看图片,不妨碍按用户文字调用编辑工具。
|
||||
|
||||
## 图片卡片
|
||||
|
||||
复用现有图片成果展示,卡片就近显示实际模型、操作状态、成果或错误。提示词、适用参数和来源素材放在详情中;不显示密钥、认证头或供应商私有地址。活动记录与卡片使用同一操作标识。
|
||||
|
||||
完成后可预览、下载和“继续修改”。继续修改将成果加入输入区,用户填写修改要求;切换 Runtime 或重开会话后仍可使用。离开会话不取消操作,结果只出现在原会话。
|
||||
|
||||
进行中提供“取消生成”,与“停止回复”分开。取消、晚到结果和结果未确认的文案遵循 L-4。失败提供适用的“重新生成”“重新选择模型”或“重新选择素材”;重新生成由用户明确触发,并说明会发起新的模型请求。没有独立“重试保存”按钮。
|
||||
|
||||
“重新选择模型”进入已有模型连接设置;改变模型后,用户在新请求中明确指定。“重新选择素材”聚焦输入区并打开已有附件选择器,保留草稿和已有附件;空草稿填入原提示词及模型名称。历史成果仍使用“继续修改”加入引用并聚焦输入区。上述操作不自动发送请求,“重新生成”继续使用原操作的模型和输入。
|
||||
|
||||
卡片错误不重复弹全局通知;模型设置保存等非上下文反馈使用应用通知。状态使用文字,不能只靠颜色;状态变化通过适度的 `aria-live` 提示,避免逐秒播报等待时间。窄窗口操作可换行,图片不超过消息区宽度,模型长名称截断时保留完整可访问名称。复用的菜单关闭后焦点回到触发控件。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 用户场景
|
||||
|
||||
状态:设计草案。Given、When、Then 分别为前提、操作和预期结果。范围以[产品需求](./prd.md)为准,行为以[行为规则](./logic-design.md)为准。
|
||||
|
||||
## 场景验收
|
||||
|
||||
适用的自动调用场景须在直连文本 Model Runtime、本地 OpenCode、Continue、DeepSeek Harness 和 GoodBuddy Agent 远程适用 Runtime 分别验证,前提为 Execute 且聊天模型支持工具调用。切换验收同时检查原操作归属和目标 Runtime 的新生成、编辑能力。
|
||||
|
||||
| 编号 | 需求 | Given | When | Then |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| US-01 | FR-1、FR-3、FR-9 | 已添加图片模型、开启会话调用并设为默认 | 在文字会话中要求画海报 | 自动调用并显示活动,原会话出现图片及实际模型;无需创建 Skill、MCP server 或重复凭据 |
|
||||
| US-02 | FR-2 | 有两个可调用图片模型,未选默认 | 请求生成图片 | 提示本次指定模型或到设置选择默认后重新发送;名称歧义先澄清,不按列表顺序提交 |
|
||||
| US-03 | FR-4、FR-5 | 聊天模型 A 已调用图片模型生成图片 | 切换聊天模型 B,要求修改该图 | B 获得成果引用和可用工具,编辑模型按规则选择 |
|
||||
| US-04 | FR-4、FR-6、FR-9 | 生成操作已提交 | 切换 Runtime 并打开其他会话,再请求生成或编辑 | 原操作继续且只写回原会话;目标 Runtime 可提交新操作,不重放旧调用 |
|
||||
| US-05 | FR-2、FR-4 | 当前操作使用图片模型 X | 将全局默认改为 Y,再请求新建图片 | 旧操作仍用 X,新操作用 Y;本次明确指定可覆盖默认 |
|
||||
| US-06 | FR-1、FR-2、FR-4 | 默认或本次指定模型为 X | X 被关闭或删除后请求生成 | 显示 X 不可用并提示改选,历史图仍可打开,不静默换模型 |
|
||||
| US-07 | FR-5、FR-8 | 已保存图片并重新打开会话或切换 Runtime | 引用原图,指定另一个支持编辑的模型 | 新模型接收原图;素材缺失时要求重新选择,不能假装已编辑 |
|
||||
| US-08 | FR-7 | 当前聊天模型不支持工具调用,或处于 Ask | 请求生成或编辑 | 无工具能力时说明可使用已有直连图片工作流;Ask 下不提交并提示需要 Execute,不自动切换模式 |
|
||||
| US-09 | FR-6 | 操作正在生成或保存,或已经失败 | 取消,或明确要求重新生成 | 如实显示取消结果;人工重新生成创建新操作,不自动重复收费请求,不提供独立保存重试入口 |
|
||||
| US-11 | FR-4、FR-9 | 使用 GoodBuddy Agent 远程适用 Runtime | 生成及编辑图片,切换本地后继续编辑,再切回远程生成 | 工具往返可用,成果可继续引用;活动归原会话,凭据留在 Main;远程断连不取消 Main 已接收的操作 |
|
||||
| US-12 | FR-5、FR-7、FR-8 | 聊天模型无视觉能力但支持工具 | 请求分析图片或根据文字修改图片 | 说明无法直接查看图片;可传原图给编辑工具,不编造视觉观察 |
|
||||
| US-13 | FR-6 | 图片操作尚未取得可保存结果 | 退出并重开应用 | 未完成记录显示结果未确认,不承诺后台继续,不自动查询恢复或重新提交 |
|
||||
| US-14 | FR-5 | 会话有多张图片 | 要求“修改第二张”或“新画一张无关图片” | 前者引用指定成果,后者不附带旧图;指代不清先澄清 |
|
||||
| US-15 | FR-3、FR-5、FR-9 | 上传图片并选择支持编辑的图片模型 | 要求修改背景,再切换 Runtime 继续修改成果 | 上传原图及后续成果被正确引用,编辑结果保存在各自发起会话;不降为纯文生图 |
|
||||
| US-16 | FR-1、FR-9 | 尚无开启会话调用的图片模型 | 查看内置 MCP 列表,再开启一个图片模型并保存 | 图片生成与编辑行一直可见且标记“内置 · 自动管理”;全部支持 Runtime 从未勾选变为勾选,目录包含该模型;重开设置或应用仍按模型配置显示 |
|
||||
| US-17 | FR-1、FR-9 | 至少两个图片模型开启会话调用 | 关闭或删除其中一个并保存 | 全部支持 Runtime 保持勾选,允许目录移除该模型、保留其他开启模型;新操作不能调用已关闭或删除模型 |
|
||||
| US-18 | FR-1、FR-9 | 仅剩一个开启会话调用的图片模型 | 关闭或删除该模型,再重新开启或添加并开启一个模型 | 目录为空时全部取消勾选,整行和说明、模型设置导航仍可见;再次开启后自动恢复全部勾选,无需另行分配 |
|
||||
| US-19 | FR-1、FR-7、FR-9 | 至少一个图片模型开启会话调用 | 尝试用鼠标或键盘切换分配,或切换到无工具聊天模型、Ask、离线 Runtime,或遇到配置或执行错误 | 分配不可手动修改,运行限制与错误不取消勾选;无独立启用、分配保存、移除或凭据编辑操作;自动管理说明持续可读,辅助技术可识别勾选和禁用状态,键盘可通过“前往图片模型设置”导航修改源配置 |
|
||||
|
||||
## 编号变更
|
||||
|
||||
US-10(图生视频)移出本图片设计范围,编号保留,不安排实施。US-02、US-05、US-06 的会话选择预期由全局默认和本次指定替代;US-08 使用已有直连图片工作流;US-09 取消独立保存重试要求;US-13 的视频查询恢复由图片中断状态说明替代。其余编号保持,US-15 补充上传图片编辑的显式验收。
|
||||
|
||||
US-16 至 US-19 补充自动管理分配验收,设置展示不依赖 Execute 或当前聊天模型支持工具;自动生成与编辑仍遵循本节的调用前提。
|
||||
@@ -8,5 +8,11 @@
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [技术设计](./technical-design.md) | Runtime 产品与技术方案、协议、插件、打包、测试和验收 |
|
||||
| [实现进度](./progress.md) | Windows 工作区路径修复及验证记录 |
|
||||
|
||||
现有文档同时包含部分 PRD、功能逻辑和进度内容,后续修改该 Runtime 时再按标准职责拆分。
|
||||
|
||||
跨项目共享 Host 和状态检查不冷启动的实现设计,由
|
||||
[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md)
|
||||
统一维护;本 Runtime 的生命周期见[技术设计 §9](./technical-design.md#9-runtime-生命周期),
|
||||
未发布实现的验收状态见[工作栏进度](../assistant-workbar/progress.md#2026-09-14-runtime-进程复用实施中)。
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# DeepSeek Harness 实现进度
|
||||
|
||||
## 2026-09-14 Windows 工作区路径修复
|
||||
|
||||
本机 Runtime 的 Host 启动与 `newSession.cwd` 已共用初始化时解析的 `realpath`,
|
||||
规则见[技术设计](./technical-design.md#52-受控组合不启动用户-profile)。修复此前
|
||||
`C:/...\workspace` 写法触发 ACP `-32602`,以及随后换成同目录规范写法仍复用
|
||||
错误 Runtime 的问题。Host 严格比较与 execution-space 缓存规则保持原样。
|
||||
|
||||
新增 Windows 真目录回归,连接真实 ACP、Host 和控制面,分别用混合分隔符及规范
|
||||
路径创建会话,断言共用同一 Runtime、只启动一次 Host、两次均完成。该自动化回归
|
||||
使用内存模型;外部真实模型验证单独由完整 App 完成。
|
||||
|
||||
Windows 完整 App 使用当前源码构建的 Main、Preload、Renderer 和新建隔离配置。
|
||||
正式配置只读,模型凭据由隔离配置中的 safeStorage 解密;真实 composer 输入请求,
|
||||
由 `deepseek-v4-flash` 在 Ask 模式读取 `summary-probe.txt`,校验码未放入 prompt。
|
||||
|
||||
| 目录写法 | Composer 提交 | 真实模型 HTTP dispatch | 文件读取 | 结果 |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| Windows 混合分隔符 | 1 | 2 | 1 | 仅返回文件中校验码,任务完成 |
|
||||
| 同目录规范路径 | 1 | 2 | 1 | 仅返回文件中校验码,任务完成 |
|
||||
| 合计 | 2 | 4 | 2 | 两条会话均通过 |
|
||||
|
||||
两轮 dispatch 来自同一 DS Utility Host 进程,切换目录写法未新增 Host;ACP 会话
|
||||
ID 不同。两条工具卡片均显示路径摘要,原生点击展开后可查看输入与输出;重载
|
||||
Renderer 并重新选择项目后,已保存会话的摘要、详情和完成状态仍通过检查。四张
|
||||
局部截图已逐张打开审阅。驱动曾返回不可克隆的监听器函数,以及在重载后默认项目
|
||||
等待工具卡片超时;修正定位后通过,这两次驱动错误没有增加模型请求。
|
||||
|
||||
本机临时 `ds-windows-path-live/report.json`、`dispatch.jsonl` 与 `source.json`
|
||||
记录结果、逐请求计数和构建哈希,原 `runtime-summary-live` 报告未覆盖。
|
||||
本次 App 正常退出,退出前记录的 5 个所属进程均已停止;隔离 profile、数据库、
|
||||
工作区、临时构建和私有日志已清理。正式模型配置与启动前逐字节一致,报告和截图
|
||||
保留在本机临时目录,不作为长期可用的仓库资源。
|
||||
测试沿用生产 HTTPS 与证书处理,未单独验证严格证书校验或 Main 重启。
|
||||
|
||||
专项 7 个测试文件通过,91 项通过、3 项跳过。`npm run typecheck` 与
|
||||
`npm run lint` 通过。最初两个假时钟测试因异步目录解析尚未结束就推进启动时钟而
|
||||
超时;调整为等待 launcher 启动后,DS Runtime 的 34 项测试全部通过。
|
||||
最终全量 `vitest run`(`npm test` 的脚本入口)为 353 个文件通过、9 个跳过,
|
||||
4155 项通过、66 项跳过、0 失败,耗时 602.00 秒。`git diff --check` 通过。
|
||||
|
||||
远端核查:Main 的 SSH 分支创建托管远程 Runtime,最终使用 `AcpRemoteRuntime`;
|
||||
`create-runtime.ts` 的本机 DS 入口拒绝 SSH execution space。本次未改远端代码,
|
||||
未调用远端模型,也未进行 Linux Host 实测。
|
||||
@@ -121,6 +121,11 @@ GoodBuddy 使用自己固定的 Harness Host 入口和只读组合模板,不
|
||||
|
||||
模型名称、服务地址、工作区、Skills、MCP schema 和已启用插件的规范化入口通过严格校验的 Main 配置传给 Host。API Key 只通过受控凭据通道按需提供,不写入 YAML、命令行、Renderer 或日志。插件配置只来自 GoodBuddy 受管状态,不合并用户 profile 或全局补丁。
|
||||
|
||||
Main 在创建 Session 前解析该请求工作区的 `realpath`,把绝对路径传给 ACP
|
||||
`newSession.cwd`。Host 不再要求 Session cwd 等于启动 cwd,原生 Agent header.cwd
|
||||
驱动文件与 Shell 工具;启动 cwd 仅为默认值。远端 SSH 由 `ManagedRemoteAcpRuntime` /
|
||||
`AcpRemoteRuntime` 单独处理,不经过本机 DS Runtime 的目录解析。
|
||||
|
||||
### 5.3 双层内部控制面
|
||||
|
||||
Harness 子进程内控制面不能取代 Main 控制面,Main 控制面也不能代替进程内的 Session/Tool 适配层:
|
||||
@@ -307,6 +312,7 @@ Harness Control Plane 只发送 GoodBuddy 能稳定解释的字段:
|
||||
- `text`:已提交的助手文本分片。
|
||||
- `reasoning`:可选的有界推理摘要分片。
|
||||
- `tool`:工具 ID、名称、状态和有界输入/输出摘要。
|
||||
- 操作标题与增量完成事件的参数保留遵循[工具操作摘要规则](../assistant-workbar/runtime-interactions.md#工具操作摘要),本机适配器按调用 ID 合并,详情上限保持不变。
|
||||
- `model-usage`:模型、提供方、输入、输出和缓存 Token。
|
||||
- `done`:停止原因和 Session ID。
|
||||
|
||||
@@ -316,18 +322,18 @@ Harness Control Plane 只发送 GoodBuddy 能稳定解释的字段:
|
||||
|
||||
### 9.1 进程模型
|
||||
|
||||
- 每个活动的 DeepSeek Harness Runtime 实例拥有一个 `utilityProcess`。
|
||||
- 一个进程可以承载多个 Harness Session。
|
||||
- Main `LocalRuntimeRegistry` 按兼容模型及 Host 配置共享重型实例;
|
||||
每组拥有一个 `utilityProcess`,不同工作区使用轻量适配器及独立 Session cwd。
|
||||
- 同一 GoodBuddy 对话的 Prompt 串行执行。
|
||||
- 不同对话可以并行,但受全局并发上限控制。
|
||||
- Runtime 设置变化时创建新实例,旧实例等待在途请求结束或在宽限期后被取消。
|
||||
- Runtime 设置变化时旧配置停止接收新请求,在途请求结算后释放旧实例。
|
||||
|
||||
### 9.2 会话映射
|
||||
|
||||
Main 保存内存映射:
|
||||
|
||||
```text
|
||||
GoodBuddy conversationId -> Harness sessionId + process generation
|
||||
GoodBuddy conversationId -> Harness sessionId + workspace + process generation
|
||||
```
|
||||
|
||||
- 首次请求创建 Session。
|
||||
@@ -339,7 +345,8 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
||||
### 9.3 取消与有界控制操作
|
||||
|
||||
- 用户取消时立即发送 `session/cancel`。
|
||||
- 取消等待有界,超时后关闭连接并终止整个 Harness 进程。
|
||||
- 取消等待有界,超时先释放目标 Session 的 Agent、工具和 Main 代理资源;
|
||||
只有该控制操作也不响应时才终止整个 Harness 进程。
|
||||
- 初始化、握手、请求准备、权限回传和关闭分别使用独立控制超时。
|
||||
- 生产 Prompt 不设置固定墙钟总时限,由 Harness 自身协议终态或用户取消结束。测试可以
|
||||
显式注入短 Prompt 时限验证取消传播,超时错误与用户取消仍不能被宽泛 catch 抹平。
|
||||
@@ -347,7 +354,10 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
||||
|
||||
### 9.4 释放与退出
|
||||
|
||||
- 原生能力清单由 `SelectedRuntimeManager` 缓存的执行 Runtime 提供,与随后的对话请求共用同一实例,不再为清单单独启动并销毁一次 Host;该实例遵循执行缓存的空闲淘汰、设置变更 reset 和退出 dispose 规则。
|
||||
- 原生清单与执行使用共享 owner,查询携带工作区。轻量适配器被缓存淘汰不结束原生
|
||||
Session;无会话、无请求的 owner 空闲 60 秒退出,再次使用按需启动。
|
||||
- `getStatus()` 只读取已有状态和配置,不启动 Host,也不重置空闲回收计时;显式 `testConnection()` 才启动
|
||||
并验证连接,临时检查适配器的 dispose 不终止其他会话。
|
||||
- 删除或释放对话时调用 `goodbuddy/session/release`。
|
||||
- Runtime dispose 时先拒绝新请求,再取消所有 Session。
|
||||
- Harness Control Plane 完成 Agent、工具和会话清理,Host 完成 Cordis Fiber 与子进程的反向清理。
|
||||
@@ -356,6 +366,17 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
||||
- 应用退出时中止正在运行的 npm 插件安装并终止其完整进程树,不能让 lifecycle script 在 GoodBuddy 退出后继续运行。
|
||||
- 应用退出不得因 Harness 清理无限阻塞。
|
||||
|
||||
### 9.5 跨项目复用
|
||||
|
||||
锁定 0.1.2-rc.1 的文件和 Bash/PowerShell 工具已按原生 Session header.cwd 路由,
|
||||
Provider 的根 cwd 只是默认值。不为每工作区复制本地 Provider;
|
||||
Main 的 Web/MCP ToolProvider 按工作区保存,最后一个对应 Session 释放时 dispose。
|
||||
|
||||
共享 Utility、逐 Session cwd、被动状态、Main 工具代理和取消升级统一由
|
||||
[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md#5-deepseek-harness)
|
||||
定义,证据及剩余验收见[工作栏进度](../assistant-workbar/progress.md#2026-09-14-runtime-进程复用实施中)。
|
||||
本节描述当前未发布源码,不将新行为归入已发布的 Desktop 0.13.2。
|
||||
|
||||
## 10. 权限与主机执行
|
||||
|
||||
### 10.1 模式映射
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
直连模型在 Main 内执行 HTTP,本身没有独立模型进程。跨 Runtime 资源调研中的直连
|
||||
部分仅检查历史缓存、会话引用和工具资源释放,不为它新增进程池;分析与验证范围
|
||||
见 [Runtime 资源技术设计 §7.2](../assistant-workbar/runtime-process-reuse-technical-design.md#72-直连模型及历史详情)。
|
||||
以下当前功能不因此改变,进程工具产生的子进程仍按各自会话生命周期管理。
|
||||
|
||||
```text
|
||||
ModelAgentRuntime
|
||||
└─ ModelToolProvider
|
||||
|
||||
@@ -53,6 +53,8 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由
|
||||
|
||||
### 3.1 标题与摘要
|
||||
|
||||
正常执行状态的操作摘要遵循[工具操作摘要规则](../assistant-workbar/runtime-interactions.md#工具操作摘要),从原始调用参数提取并在完成后保留;异常状态继续显示原有提示。
|
||||
|
||||
```text
|
||||
进程执行
|
||||
PowerShell · packages/app · npm test
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [文档解析与本地 OCR PRD](./prd.md) | 产品范围、解析策略、OCR 模型管理和验收 |
|
||||
| [聊天附件技术说明](./chat-attachments-technical-design.md) | 文件选择与粘贴的进程链路、IPC、PPTX 图片 OCR、Worker 生命周期和验证证据 |
|
||||
|
||||
当前尚无独立 User Stories、功能逻辑、UI、技术设计和进度文档。
|
||||
当前尚无独立 User Stories、功能逻辑、UI 和进度文档;聊天输入区交互遵循根目录
|
||||
[UI 设计系统](../../../UI-DESIGN.md)。
|
||||
|
||||
Office 文件的浏览器内人工编辑、AI 选区修改、保存和撤销不属于文档解析职责,由
|
||||
[Office 协同编辑](../office-document-editing/README.md)负责。文档处理仍负责内容提取、OCR、
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# 聊天附件技术说明
|
||||
|
||||
产品规则见 [PRD 的聊天附件入口](./prd.md#321-聊天附件入口),输入区布局与反馈遵循
|
||||
[UI 设计系统](../../../UI-DESIGN.md)。本文说明本地文件选择、文件粘贴和截图粘贴的实现。
|
||||
|
||||
## 文件导入链路
|
||||
|
||||
`App.tsx` 在 textarea 的 `paste` 事件中同步读取 `clipboardData.files`。没有文件时
|
||||
保留浏览器默认文本粘贴。有文件时阻止默认插入,并通过 preload 的
|
||||
`context.getFilePath(file)` 调用 Electron `webUtils.getPathForFile`。
|
||||
|
||||
有本地路径的文件调用 `context.importFiles(paths)`,经 `context:import-files` IPC 到
|
||||
Main。IPC 先验证可信 sender,再使用共享 `contextImportFilesSchema` 校验输入。
|
||||
没有本地路径的截图沿用 `context.addPastedImage`;无路径的非图片文件和混有无路径项目
|
||||
的本地文件批次显示可处理的错误,不静默丢弃项目。
|
||||
|
||||
`ContextManager.selectFiles` 只负责文件对话框,再调用与粘贴共用的 `importFiles`。
|
||||
这个方法完成路径规范化、格式及大小检查、文本读取、图片转换、文档解析与上下文暂存。
|
||||
任一文件失败时删除本批次已经暂存的项目,保留之前的附件。文件系统错误不向 Renderer
|
||||
暴露绝对路径。
|
||||
|
||||
两种文件入口均发送 `context:file-selection-progress`。Renderer 共用正在导入状态、
|
||||
附件上限处理和附件清理逻辑,解析期间禁用附件按钮并阻止发送。成功结果加入发起操作的
|
||||
会话草稿;发送时通过已有 `contextIds` 和 `ContextManager.enrichRequest` 生成请求上下文。
|
||||
本次变更不修改 Agent、远端协议或 Runtime 的附件消费实现。
|
||||
|
||||
## PPTX 图片识别与 OCR 生命周期
|
||||
|
||||
产品策略与限制分别见 [PPTX 图片流程](./prd.md#75-pptx-图片流程)、
|
||||
[OCR 空闲资源回收](./prd.md#76-ocr-空闲资源回收)。
|
||||
|
||||
`DocumentParsingService` 在非快速工作流中使用 `knowledge/pptx-parser.ts` 读取
|
||||
演示文稿顺序、幻灯片文字、图片关系和内嵌图片。Office ZIP 读取复用
|
||||
`document-parser.ts` 的条目数量、单条目及总展开大小检查;不读取工作区中的关联文件。
|
||||
每张引用图片通过现有 `DocumentOcrBroker` 的图片请求契约,进入生产
|
||||
`document-ocr-bridge.ts` 和 WASM Worker。结果合并时保留原生文字,附上幻灯片页码、
|
||||
图片定位及 OCR 置信度,诊断中的 OCR 页数按页码去重。
|
||||
|
||||
Bridge 用待完成请求计数判断队列是否空闲,正常完成后安排一次空闲计时;
|
||||
新请求和 `terminateWorker` 均清除计时。到期通过 `Worker.terminate()` 释放整个推理环境,
|
||||
而不是只清空 JavaScript 引用或反复触发垃圾回收。此机制由 PDF 与 PPTX 共用,
|
||||
不增加 IPC、持久化设置或模型安装状态。
|
||||
|
||||
解析与回收发生在 Desktop 的 Main/Renderer,`gbagent` 不运行此 OCR Worker。
|
||||
远程 Runtime 消费的仍是 Desktop 已提取的附件文本,没有单独的 Agent 解析器或
|
||||
需要同步修改的远程 OCR 生命周期。
|
||||
|
||||
## Electron 验证
|
||||
|
||||
2026-09-15 在 Windows 的 Electron 43.2.0 上,从当前源码构建隔离输出并启动完整应用,
|
||||
使用独立用户数据目录,保持 `sandbox: true`、`contextIsolation: true`。
|
||||
|
||||
已实际验证:
|
||||
|
||||
- Windows `Shell.Application` 的文件 `copy` verb 生成系统剪贴板后,原生粘贴事件提供
|
||||
`File` 和有效路径,TXT 出现在聊天附件区。
|
||||
- `CF_HDROP` 同时携带 TXT、DOCX 时,两个文件经生产 preload、IPC 和解析器成为附件;
|
||||
DOCX 发出 `reading`、`parsing` 进度,包含正确文件名及 `2 / 2` 批次序号。
|
||||
- 普通文本原生粘贴进入草稿,后续文件粘贴保持文字不变。
|
||||
- ZIP 经生产导入路径报出不支持类型,已添加附件仍在。
|
||||
- 系统图片剪贴板产生的 `image.png` 没有本地路径,仍通过截图入口生成图片附件。
|
||||
|
||||
以上使用原生 `webContents.paste()`,未伪造 DOM 粘贴事件或替换附件实现。Windows Shell
|
||||
复制测试没有操纵资源管理器窗口中的键盘。产品未增加 Win32 剪贴板读取或兼容代码。
|
||||
探测程序的原生调用仅用于构造文件剪贴板和保存、恢复测试前的剪贴板对象;最终探测的
|
||||
`OleSetClipboard`、`OleFlushClipboard` 均返回成功。早期一次探测的恢复调用返回错误,
|
||||
无法追溯确认该次剪贴板是否完整恢复。
|
||||
|
||||
macOS、Linux 文件管理器的实际剪贴板行为尚未在本次环境验证。没有发起模型调用;附件
|
||||
进入发送请求的 `contextIds` 由 Renderer 回归测试验证,完整文档内容进入请求由生产
|
||||
`ContextManager` 的真实文件解析测试验证。
|
||||
|
||||
## 自动检查
|
||||
|
||||
聚焦用例位于 `src/main/context-manager.test.ts`、`src/main/ipc.test.ts`、
|
||||
`src/preload/context-files.test.ts`、`src/shared/context-contracts.test.ts` 和
|
||||
`src/renderer/src/App.test.tsx`,覆盖多文件、进度、重复粘贴、发送上下文、错误与草稿保留、
|
||||
大小及数量限制、批次回滚、可信 sender、输入契约和截图/文本回归。
|
||||
|
||||
```text
|
||||
npx vitest run src/main/context-manager.test.ts src/main/ipc.test.ts src/preload/context-files.test.ts src/shared/context-contracts.test.ts src/renderer/src/App.test.tsx
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
本次最终拆分检查通过 6 项附件相关 Renderer 用例和 160 项 Main、IPC、preload、契约及
|
||||
沙箱用例,类型检查与 lint 通过。完整 `npm test` 运行结果为 4214 项通过、67 项跳过、
|
||||
6 项窗口恢复用例失败;这些用例属于并行修改的 `window.test.ts`,随后该文件单独复跑
|
||||
10 项全部通过。本次没有再次完整重跑全仓测试。
|
||||
|
||||
## 2026-09-15 PPTX OCR 与空闲回收验证
|
||||
|
||||
在 Windows x64、Electron 43.2.0 上,从本次源码构建独立输出,使用独立用户数据目录和
|
||||
本机已安装的 PP-OCRv6 Tiny 模型完成真实验证。测试仅替换系统文件选择对话框的返回路径,
|
||||
保留生产 UI、preload、IPC、`ContextManager`、解析服务、OCR Broker 与 WASM Worker,
|
||||
未替换解析或推理实现。
|
||||
|
||||
- 从“添加附件”一次导入 4 份图片型 PPTX,共 40 页;4 个附件均成功显示,连续处理期间
|
||||
仅创建 1 个 OCR Worker。
|
||||
- 队列空闲后观察到 Worker 被终止。每 5 秒读取 Electron `app.getAppMetrics()`:
|
||||
Renderer 工作集采样高点为 733,492 KiB(约 716 MiB),回收后为 162,716 KiB
|
||||
(约 159 MiB),启动基线约 163 MiB。该结果是本次设备与文件上的测量,不是固定内存承诺。
|
||||
- 回收后,通过生产 `context.importFiles` 再导入一份 9 页 PPTX;新建第 2 个 Worker,
|
||||
9 页均返回非空文字,共 2,462 字符,附件成功生成。
|
||||
- 本次合计 49 次本地图片 OCR 请求,文本模型调用 0 次;没有发送聊天消息,也没有将文件
|
||||
交给云端模型。未修改源文件。未在 macOS、Linux 或其他 OCR 模型档位上做本次实测。
|
||||
|
||||
新增聚焦回归覆盖 PPTX 关系与顺序、各场景路由、原生文字合并、快速模式、页数上限、
|
||||
OCR 错误和取消;Worker 回归覆盖空闲到期、连续请求复用、队列保护、关闭清理与立即取消。
|
||||
空闲回收后重新加载也在真实模型路径上验证,不能只用模型配置检查代替。
|
||||
|
||||
最终检查:`npm test` 4,293 项通过、67 项跳过;`npm run typecheck`、
|
||||
`npm run lint` 通过。当前源码的隔离 Electron 生产构建通过;文档的 10 个相对文件链接
|
||||
均有效。测试应用已关闭,包含附件草稿的独立用户数据目录已删除,原文件和原模型安装未改动。
|
||||
@@ -22,13 +22,16 @@ GoodBuddy 需要用同一条可信文档解析链路服务以下场景:
|
||||
- XLSX 工作表 XML 与共享字符串;
|
||||
- PPTX 幻灯片文字。
|
||||
|
||||
聊天附件、知识库和测试解析通过统一的 `DocumentParsingService` 使用已保存的工作流。
|
||||
扫描 PDF 和 PPTX 幻灯片内嵌图片可使用已安装、已校验的本地 OCR 模型识别;
|
||||
PPTX 的当前能力和边界见 [PPTX 图片流程](#75-pptx-图片流程)。
|
||||
|
||||
现有局限:
|
||||
|
||||
- 纯扫描 PDF 没有文本层时无法提取内容;
|
||||
- DOC、XLS、PPT 等旧版二进制 Office 格式不支持;
|
||||
- Office 解析主要提取文字,不能完整保留表格、公式、图表和版面;
|
||||
- 聊天附件和知识库直接调用底层解析函数,缺少可配置的统一工作流;
|
||||
- 没有本地 OCR 模型状态、真实解析测试和按场景策略。
|
||||
- DOCX、XLSX 内嵌图片尚未接入 OCR;
|
||||
- Office 格式转换和远程 OCR 服务尚未接入。
|
||||
|
||||
## 3. 产品原则
|
||||
|
||||
@@ -52,10 +55,33 @@ PDF 不是所有文档唯一的中间格式。解析应同时保留:
|
||||
| 高保真审阅 | 视觉增强 | 原生解析、页面渲染、OCR 或视觉理解合并 |
|
||||
|
||||
PDF、DOCX、PPTX 和 XLSX 的解析结果保留章节定位及全文,不在附件入库时截断为
|
||||
256 KiB,也不以富化后 Prompt 的 1 MiB 上限阻止发送。本地选择与消息通道附件使用
|
||||
256 KiB,也不以富化后 Prompt 的 1 MiB 上限阻止发送。本地选择、文件粘贴与消息通道附件使用
|
||||
同一文本保存路径;原文件大小、解析器和 12 MiB 附件暂存容量仍按现有规则检查,超限时
|
||||
明确报错,不能用截断后的内容冒充全文。模型上下文窗口仍由所选 Runtime 处理。
|
||||
|
||||
#### 3.2.1 聊天附件入口
|
||||
|
||||
用户可点击附件按钮选择文件,也可在文件管理器中复制一个或多个文件后,在聊天输入框
|
||||
按 `Ctrl+V` 添加。macOS 使用系统粘贴快捷键 `Command+V`。粘贴文件不替换草稿文字,
|
||||
不自动发送;普通文本仍直接插入光标位置,系统截图仍作为图片附件添加。
|
||||
|
||||
本地文件选择与粘贴使用相同的格式及容量规则:
|
||||
|
||||
| 内容 | 支持范围 | 原文件大小上限 |
|
||||
| --- | --- | --- |
|
||||
| 文本、代码和配置 | C/C++、CSS、CSV、Go、HTML、Java、JS/JSX、JSON、日志、Markdown、Python、Rust、SQL、TS/TSX、TXT、XML、YAML | 256 KiB |
|
||||
| 图片 | JPEG、PNG、WebP | 12 MiB |
|
||||
| 文档 | PDF、DOCX、PPTX、XLSX | 20 MiB |
|
||||
|
||||
每条消息最多 8 个附件,Main 最多暂存 16 个上下文项目,合计不超过 12 MiB。
|
||||
图片转换和文档解析后的内容计入暂存容量。一次粘贴超过 8 个文件时拒绝本次粘贴并提示;
|
||||
批次本身未超限、但加上已有附件超过 8 个时,只保留剩余位置可容纳的附件并提示。
|
||||
|
||||
文件读取和解析期间显示文件名、处理阶段及批次序号,阻止重复文件导入和发送。
|
||||
不支持的扩展名、目录、超限文件、读取或解析失败会使本批次回滚;已有附件与草稿保留,
|
||||
错误显示在输入区。剪贴板中的虚拟文档若没有本地路径,提示先保存到本地后添加;
|
||||
无本地路径的截图继续使用图片粘贴入口。
|
||||
|
||||
### 3.3 本地优先
|
||||
|
||||
- 文本层和本地 OCR 均在设备上处理;
|
||||
@@ -254,6 +280,32 @@ ZIP 根目录包含模型文件和 `goodbuddy-model.json`。清单格式为 `goo
|
||||
|
||||
受密码保护、损坏或超限的 PDF 不得进入 OCR。
|
||||
|
||||
### 7.5 PPTX 图片流程
|
||||
|
||||
自动解析、完整索引和高保真工作流保留幻灯片原生文字,同时对幻灯片直接引用的
|
||||
内嵌 PNG、JPEG、WebP 图片运行本地 OCR。即使幻灯片已有标题或正文,也不会因此
|
||||
跳过图片中的文字。快速文本、快速索引工作流不运行 OCR;图片型 PPTX 在这些模式下
|
||||
明确提示当前工作流未启用 OCR。
|
||||
|
||||
按演示文稿中的幻灯片顺序返回结果,图片识别结果标注 `幻灯片 N · 图片 M`,
|
||||
保留页码和识别置信度。OCR 页数限制计算含图片的幻灯片数,而不是图片总数;
|
||||
图片顺序处理并复用已加载模型,沿用现有超时、取消和输出限制。
|
||||
|
||||
这不是 Office 渲染或转换:不识别母版、版式中继承的图片,不重建裁剪、旋转或遮挡后的
|
||||
完整画面,不把演讲者备注当作幻灯片正文,也不下载外链图片。其他内嵌图片格式在
|
||||
需要 OCR 时明确报错。自动聊天解析可在 OCR 失败时保留可靠原生文字并附带警告;
|
||||
图片型文件没有可靠原生文字时仍失败,知识库和高保真工作流不以此回退掩盖 OCR 失败。
|
||||
|
||||
### 7.6 OCR 空闲资源回收
|
||||
|
||||
OCR 队列清空且没有正在处理的请求后,保留 Worker 60 秒,以复用连续文档的模型。
|
||||
期间收到新请求立即取消回收计时;请求完成、队列再次空闲后重新计时。空闲到期后
|
||||
销毁 Worker,释放其模型会话和 WASM 运行环境;下一次请求按需重新加载已安装模型。
|
||||
|
||||
回收不会删除模型文件或已经解析的附件文字,不打断正在识别、初始化或排队的任务。
|
||||
取消、超时和服务关闭仍按原有路径立即清理。操作系统显示的进程内存不保证立刻恢复到
|
||||
启动值,不能仅凭一次内存快照判断是否发生泄漏。
|
||||
|
||||
## 8. Office 与转换
|
||||
|
||||
本节只定义 Office 文件的语义解析和格式转换,不提供可视化编辑器、编辑会话、选区、撤销或
|
||||
@@ -338,6 +390,7 @@ DOC、XLS、PPT 通过 `DocumentConversionProvider` 转换:
|
||||
## 12. 验收
|
||||
|
||||
- 同一份扫描 PDF 可从聊天附件和知识库得到一致的逐页文本;
|
||||
- 图片型 PPTX 在自动解析下使用已配置的本地 OCR,并保留幻灯片来源定位;
|
||||
- 文本型 PDF 在 `auto` 模式下不运行 OCR;
|
||||
- 本地 OCR 在六个平台和两种架构上完全离线运行;
|
||||
- 模型文件损坏时拒绝加载并显示可恢复错误;
|
||||
@@ -348,6 +401,7 @@ DOC、XLS、PPT 通过 `DocumentConversionProvider` 转换:
|
||||
- 语音和 OCR 模型可在联网设备导出 ZIP,并在离线设备导入后完成真实推理;
|
||||
- 路径穿越、未知条目、错误模型 ID、篡改文件和超限 ZIP 均被拒绝;
|
||||
- 超页数、超时、取消和关闭不会留下运行任务;
|
||||
- OCR 空闲 60 秒后释放 Worker,连续请求复用模型,回收后的下一次解析可重新加载模型完成;
|
||||
- 测试解析不会创建聊天消息或知识库文档;
|
||||
- 选择本地模型时没有任何文档上传;
|
||||
- 文档中的提示词不会改变系统、模式或工具权限。
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
## 相关功能
|
||||
|
||||
- [会话媒体生成设计](../conversation-media-generation/README.md):规划普通 LLM 通过工具调用图片、视频模型及模型切换行为,尚未实现。
|
||||
- [模型连接](../model-connections/README.md):地址、模型 ID、凭据与请求定制。
|
||||
- [直连文本 Agent](../direct-model-agent/README.md):文本模型的工具和上下文压缩,
|
||||
不等于图片模型支持 Responses 图片工具。
|
||||
|
||||
@@ -68,6 +68,10 @@ Renderer 丢弃已失效的目录、详情和测试响应,不把切换前的
|
||||
|
||||
## 5. 创建与编辑
|
||||
|
||||
新建绑定时,用户尚未编辑的显示名称跟随当前目录选择更新;更换实例或手工输入另一个
|
||||
目标 ID 时清除旧自动名称。用户手动填写过名称后保留该草稿。编辑或重新绑定已有库时,
|
||||
始终保留已保存的本地名称,除非用户主动修改;此修正不自动重命名已有知识库。
|
||||
|
||||
流程为选择 `local | dify | fastgpt | ragflow`,外部类型再选择实例、目录项或手工 ID、
|
||||
显示名称、配置与测试查询。表单测试成功后允许保存;服务保存前再次执行同一查询验证,
|
||||
检查实例、绑定和唯一性是否在等待期间改变,再写入数据库。
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
| 检索结果 | 外部组件显示总耗时、返回数量、片段、定位、实际 ID 和原始评分,包括 FastGPT 评分数组;不显示本地全文、向量、图谱通道或本地上下文操作 |
|
||||
| 聊天与引用 | 统一选择器显示 Provider、实例与状态;外部引用打开保存片段,显示实例 ID 和远端定位,不请求本地上下文或打开原始来源 |
|
||||
|
||||
创建类型分段控件复用全局不收缩规则,不能被目录和参数表单压扁;列表长名称的宽度约束见
|
||||
[知识库 UI 设计](./ui-design.md#2-信息架构)。新建时自动名称跟随目录选择,手动改名和已保存
|
||||
名称不被覆盖,具体规则见[逻辑设计](./external-knowledge-logic-design.md#5-创建与编辑)。
|
||||
实例 Modal 和其他浮层统一参与[原生浏览器遮挡处理](../assistant-workbar/browser-tabs-technical-design.md#4-renderer-与-viewport),
|
||||
不再仅验证没有浏览器的独立 Portal。
|
||||
|
||||
原 `knowledge-field` 的控件样式依赖知识库工作区祖先,实例 Modal 经 `createPortal` 挂到
|
||||
body 后丢失该作用域。当前 `Field` 使用全局 `.field` 和标签 span,密码也复用同一字段
|
||||
结构,确保 Portal 内的字体、输入边框、背景和焦点样式与应用一致。弹窗宽度为
|
||||
|
||||
@@ -16,6 +16,26 @@
|
||||
|
||||
## 验证证据
|
||||
|
||||
### 2026-09-13 知识库布局与自动名称修复
|
||||
|
||||
- 创建类型的共享分段控件被长表单压缩,修复共享不收缩规则,不添加页面专属高度。
|
||||
长名称卡片增加零最小宽度的 Grid 轨道,名称单行省略、图标保持 15px;详情名称允许换行。
|
||||
合成长名称实测列表横向溢出为 0;详情修复前溢出 658px,修复后为 0。
|
||||
- 连续选择两个目录项时,未修改过的自动名称原先停留在第一个目标。回归先失败再修复,
|
||||
现在跟随选择,手动名称和已保存名称不自动覆盖。规则见
|
||||
[创建与编辑](./external-knowledge-logic-design.md#5-创建与编辑)。
|
||||
- 当前完整 App 的真实 UI → Preload → Main → SQLite 路径完成实例保存、两个目录项
|
||||
切换、零结果检索后添加绑定、详情展示和配置草稿保留。上游是 loopback Dify 合成服务,
|
||||
不是真实知识平台;没有调用模型,也不能据此声称真实检索质量或远端验收。
|
||||
- 本轮合成服务共收到 8 次请求:1 次初始目录、2 次初始详情、2 次检索(测试与保存)、
|
||||
2 次后续详情、1 次后续目录;真实模型和真实知识服务请求均为 0。
|
||||
- 创建类型在浅深主题、目标 1280×800、960×720、720×640、640×420 的八组尺寸中
|
||||
检查控件高度至少 34px,并保存 PNG。实际审阅浅色 1280、深色 640 及最终添加后的
|
||||
长名称详情 PNG,确认 Tab 不再成为窄缝,卡片边框和图标完整,详情无横向滚动条。
|
||||
- 同期修复和通用验证结果见[工作栏进度](../assistant-workbar/progress.md#2026-09-13-原生浏览器与应用浮层修复)。
|
||||
临时脚本与合成 profile 在提交后清理,不把它们作为长期依赖;用户在另一测试窗口
|
||||
手工创建的实例与绑定不属于本轮合成数据,不随清理删除。
|
||||
|
||||
### 2026-09-13 检索路径与绑定查询简化
|
||||
|
||||
对 `3b560c9..ca4a58c` 的复查发现三处与本功能相关的实现问题,已按 KISS 与性能优先修正:
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
标题区提供“用于当前对话”“检索测试”、编辑和删除。新建知识库是页面主操作。
|
||||
“高级设置”承载问答效果入口、向量索引、分块与图谱配置,不把工程参数放在首次路径。
|
||||
|
||||
本地与外部库共用的列表以 `minmax(0, 1fr)` 限制条目宽度,再对名称单行省略;选中卡片
|
||||
的边框必须完整留在列表内,Provider/实例等辅助文字允许换行。不得用列表外层裁切
|
||||
掩盖被长名称撑宽的卡片,库图标不能随名称一起收缩。外部详情的长名称和远端名称允许
|
||||
换行,不能撑出横向滚动条。创建类型分段控件与详情页签保持固定内容高度,不随长表单收缩。
|
||||
|
||||
## 3. 首次使用
|
||||
|
||||
没有知识库时,页面空状态显示“新建知识库”主操作。创建时先选择类型;本地类型默认
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Magic Notes
|
||||
|
||||
Magic Notes is the local notes, checklist and AI-comment workbench. Desktop
|
||||
actions and authorized Agent/MCP tools write to the same desktop SQLite store.
|
||||
The open workbench subscribes to successful writes so users can see external
|
||||
changes without leaving the page or clicking a refresh button.
|
||||
|
||||
## Documents
|
||||
|
||||
- [Technical design](./technical-design.md): database notifications, process
|
||||
boundaries, refresh lifecycle and regression coverage.
|
||||
- [Progress](./progress.md): implementation and validation evidence.
|
||||
- [UI design system](../../../UI-DESIGN.md#137-魔法笔记): authoritative workbench
|
||||
interaction rules, including selection and draft preservation.
|
||||
|
||||
An entry is a saved rich-text record within a note. A todo is a checklist item
|
||||
derived from an entry. An unsaved draft belongs to the open editor; it is not
|
||||
written to storage by a background refresh.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Magic Notes Progress
|
||||
|
||||
## 2026-09-15: Automatic External Refresh
|
||||
|
||||
- Implemented the shared-database notification and Main/preload subscription
|
||||
described in [technical design](./technical-design.md).
|
||||
- The open workbench reloads external changes while retaining current selection
|
||||
and unsaved drafts. Deleted notes and edited entries retain drafts with an
|
||||
explicit explanation. The cancelled manual-refresh proposal is not implemented.
|
||||
- An initial completed `npm test` run passed 357 files and 4,201 tests, with
|
||||
9 files and 67 tests skipped (680.45 seconds). The first invocation had exceeded
|
||||
the command tool's 120-second limit.
|
||||
- After three additional renderer race/retry tests, the final `npm test` run
|
||||
reported 355 files and 4,202 tests passed, 9 files and 67 tests skipped, and two
|
||||
unrelated timeout failures: the large offline dependency inventory test in
|
||||
`tests/agent-package.test.ts` (60 seconds), and the two-project OpenCode case in
|
||||
`src/main/agent/local-runtime-reuse.test.ts` (40-second request timeout).
|
||||
- Rerunning those two files together passed 24 tests with one skipped in
|
||||
103.71 seconds, without source or timeout changes. The final renderer/preload
|
||||
focused run also passed all 38 tests. No Magic Notes regression failed.
|
||||
- `npm run typecheck` and `npm run lint`: passed. The new required subscription
|
||||
method is also present in the application test API fixture.
|
||||
- Validation uses production SQLite and Agent/MCP gateway code, the production
|
||||
preload with mocked Electron transport, and renderer interaction tests. An
|
||||
interactive Electron window was not exercised. No deployed Agent code or
|
||||
desktop-to-Agent protocol changed, so no separate Linux Host scenario applies.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Magic Notes Technical Design
|
||||
|
||||
## Change Notifications
|
||||
|
||||
`AssistantDatabase` accepts `onMagicNotesChanged: () => void`. Every successful
|
||||
note/entry create, update or delete emits once after the write commits, including
|
||||
empty-note creation, initial content, title/pin updates and cascading deletion.
|
||||
Saved note analysis, todo completion, saved todo analysis and assistant-data
|
||||
clearing also emit. Revision conflicts, missing rows, rolled-back writes and
|
||||
unchanged todo completion do not emit. Startup schema migrations run before the
|
||||
workbench loads and do not publish notifications.
|
||||
|
||||
Desktop IPC and `KnowledgeMcpGateway` use this same database instance. The latter
|
||||
also serves Agent tools; no tool-specific notification or daemon protocol change
|
||||
is needed. Raw SQL writes from an unrelated process are outside this callback
|
||||
contract.
|
||||
|
||||
Main queues notification delivery to the live window on `magic-notes:changed`.
|
||||
The typed preload method `magicNotes.onChanged(listener)` exposes no Electron
|
||||
objects and returns a function removing that listener. The existing
|
||||
`onTodoStatusChanged` event remains the separate sidebar-count signal.
|
||||
|
||||
## Refresh Lifecycle
|
||||
|
||||
`MagicNotesWorkspace` subscribes on mount and coalesces notifications with a
|
||||
100 ms debounce. Pending notifications wait for an active local save or analysis
|
||||
to finish. Cleanup removes the listener, clears its timer and invalidates pending
|
||||
refresh results. This does not introduce periodic database polling.
|
||||
|
||||
The existing list/detail loader reloads notes and todos, then the preferred note.
|
||||
Request counters reject superseded results and preserve newer user selections.
|
||||
Todo selection survives list reordering while the item exists; replacing the todo
|
||||
snapshot also reloads the selected todo's source entry. Search, filters and layout
|
||||
state are not reset.
|
||||
|
||||
Background application of a detail reads the current draft state after the
|
||||
asynchronous fetch. A dirty title is retained, and the composer is not remounted.
|
||||
An entry editor uses its original editing content and revision for both its React
|
||||
key and save request; external revisions cannot remount it or bypass optimistic
|
||||
concurrency checks. Deleted entries remain displayed only while their editor is
|
||||
open. If an externally deleted note has a draft, its editor remains available
|
||||
with a deletion explanation instead of moving the draft to another note.
|
||||
|
||||
Read failures use the existing retryable refresh error and retain successful
|
||||
data and drafts. Retry uses the same draft-preserving loader. Interaction rules
|
||||
are owned by [the UI design system](../../../UI-DESIGN.md#137-魔法笔记).
|
||||
|
||||
## Validation
|
||||
|
||||
- `assistant-database.test.ts` checks committed writes with a second SQLite
|
||||
connection, successful mutation coverage, failures, no-op completion and reset.
|
||||
- `knowledge-mcp-gateway.test.ts` exercises real database CRUD through Agent/MCP
|
||||
access, including revision failure without an extra notification.
|
||||
- `magic-notes-events.test.ts` executes the production preload, checking the
|
||||
channel, payload-free callback and listener removal.
|
||||
- `MagicNotesWorkspace.test.tsx` covers external updates and deletion, selection,
|
||||
source reload, editor identity and draft saves, coalescing, deferred writes,
|
||||
asynchronous selection races, error retry and unmount cleanup.
|
||||
|
||||
No schema migration, dependency, refresh control or network service is added.
|
||||
@@ -11,3 +11,7 @@
|
||||
| [远程环境准备技术设计](./environment-provisioning-technical-design.md) | Host 下载、GoodBuddy 传输、安装事务、更新和恢复边界 |
|
||||
|
||||
现有文档包含产品、功能逻辑和进度事实,后续修改远程功能时再按标准职责拆分。
|
||||
|
||||
跨会话共享 OpenCode 进程、ACP 连接与模型桥的实现设计,见
|
||||
[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md#6-ssh-opencode-与-agent)。
|
||||
当前实现尚未发布;验收状态见[工作栏进度](../assistant-workbar/progress.md#2026-09-14-runtime-进程复用实施中)。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
本文记录截至 2026-09-10 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
|
||||
下述状态段保留 2026-09-10 的实现与验证截面,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
|
||||
Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线,
|
||||
详细事务与验收边界见
|
||||
[SSH Host 远程环境准备与直连下载设计](./environment-provisioning-technical-design.md);
|
||||
@@ -11,9 +11,9 @@ Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Ho
|
||||
Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。
|
||||
Windows 到 Linux x64 的安装、Agent-owned Prompt、Agent 本地模型 gateway、断线恢复、
|
||||
同一 OpenCode Session 续接、取消和终态清理已经使用真实模型与工具验证。Agent
|
||||
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前源码
|
||||
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当时源码
|
||||
候选为 Agent `0.11.24`、Desktop `0.13.0`,修复远端原生问答转交和取消待答后的同会话
|
||||
续发。Agent `0.11.24` 要求先升级至 Desktop `0.13.0`;候选尚未发布。远程文件/Git 管理、
|
||||
续发。Agent `0.11.24` 要求先升级至 Desktop `0.13.0`;当时候选尚未发布。远程文件/Git 管理、
|
||||
可选模型限额、无限请求时长下的独立连接超时和移除固定十分钟 Prompt 时限由
|
||||
Agent `0.11.23` 提供,显式请求期限与取消仍有效。当前问答源码的真实 SSH/确定性模型证据
|
||||
及完整生产链路验证边界见[工作栏进度](../assistant-workbar/progress.md)。
|
||||
@@ -21,6 +21,32 @@ Agent `0.11.23` 提供,显式请求期限与取消仍有效。当前问答源
|
||||
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
|
||||
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
|
||||
|
||||
2026-09-14 发布状态补注:Desktop `0.13.2` 与 Agent `0.11.25` 已正式发布,包含此前
|
||||
问答修复;Agent `0.11.25` 的维护发布要求 Desktop `0.13.2`。下节进程复用为当前未发布源码,
|
||||
不是这两个发布的已有能力;历史验证段的日期与边界保留。
|
||||
|
||||
## 跨会话进程复用(未发布)
|
||||
|
||||
已发布版本的 `RuntimeAcpBackend` 按 binding 拥有原生进程,`AgentOwnedAcpPrompt` 的一条 ACP
|
||||
连接对应一个原生 Session;同一会话后续 Prompt 可复用,但不同会话不共用进程。
|
||||
上文“Host 共享 Runtime 环境”指安装与 Agent 环境,不代表原生 OpenCode 进程已共享。
|
||||
|
||||
锁定 OpenCode 1.18.29 / ACP SDK 0.25.1 的 Windows 隔离探针已确认:同一个 `acp`
|
||||
进程与一条连接可运行两个工作区 Session,原生 `chat.headers` 能向本机合成模型请求
|
||||
附带各自 sessionID。它未经过 Agent 所有权、生产 helper、真实工具或 Linux Host,
|
||||
不是远端交付证明。
|
||||
|
||||
进程 owner 与 binding 拆分、单 ACP 连接分派、会话级权限以及模型桥逐 operation
|
||||
路由的唯一方案见
|
||||
[Runtime 进程复用技术设计 §6](../assistant-workbar/runtime-process-reuse-technical-design.md#6-ssh-opencode-与-agent)。
|
||||
实施必须保留本文件的 released binding、Agent-owned Prompt、transcript/ACK、模型调用
|
||||
结果不确定性与断线规则,并在开发期间执行下文真实 Host 验证,不为复用新增持久化账本。
|
||||
当前源码已接入共享 process owner、`AgentAcpConnection` 和逐 operation 模型路由;
|
||||
验收进展见[工作栏进度](../assistant-workbar/progress.md#2026-09-14-runtime-进程复用实施中)。
|
||||
取消/失败的 Session 可以留下运行中的共享进程,结果中的 `processTree` 如实返回
|
||||
`running`,不能再据 Prompt 终态推断整进程为空。新行为需要配套 Desktop/Agent,
|
||||
已发布 Desktop `0.13.2` / Agent `0.11.25` 不包含这些改动。
|
||||
|
||||
## 产品语义
|
||||
|
||||
远程 Host 与项目能力是默认关闭的技术预览。用户在“设置 > 平台功能”的独立
|
||||
@@ -31,7 +57,7 @@ Agent `0.11.23` 提供,显式请求期限与取消仍有效。当前问答源
|
||||
|
||||
远程项目只有两种工作模式:
|
||||
|
||||
- **Ask**:Runtime 在操作系统边界以只读方式访问项目 Workspace。
|
||||
- **Ask**:Runtime 在原生工具权限边界只读访问项目 Workspace,不声称操作系统级只读隔离。
|
||||
- **Execute**:用户已授权使用所选 SSH 账号的完整权限。Runtime 可以使用该账号可访问的文件、进程、网络和工具,不再要求额外 trust tier、consent checklist、逐工具审批或“受控执行”授权。
|
||||
|
||||
Execute 不获得 root 或 SSH 账号本身没有的权限。托管 SSH Prompt 会把当前选中的文本模型
|
||||
@@ -41,6 +67,12 @@ profile 和凭据作为有界、逐 Prompt 的控制消息交给 Agent;该凭
|
||||
|
||||
## 组件
|
||||
|
||||
桌面保存托管 SSH 子任务事件时,共用
|
||||
[执行记录差量存储](../assistant-workbar/execution-history-storage.md)。
|
||||
Agent transcript 与公共 Runtime 事件不改变;桌面的来源去重、消息投影事务和恢复序号
|
||||
继续保留。工具字段比较与 schema 35 对 schema 34 重复块的整理也在该共享桌面层完成;
|
||||
不把工具过程改为仅存内存,不要求单独升级 Agent。
|
||||
|
||||
```text
|
||||
Renderer
|
||||
-> 窄 Preload / Main IPC
|
||||
@@ -206,15 +238,16 @@ selection,Renderer 会恢复为当前 OpenCode 配置;Main 的远程请求
|
||||
Ask 与 Execute 一样直接启动已签名 Runtime,不要求 Host 安装额外的进程隔离命令:
|
||||
|
||||
- `cwd` 为项目 Workspace,并继承 SSH 账号的正常环境;
|
||||
- ACP 权限请求只有在工具种类为原生 `read` 且 Runtime 提供 `allow_once` 选项时才允许;
|
||||
search、edit、execute、未知工具以及只提供持久授权的请求全部拒绝。
|
||||
- Ask 启动时同时注入 OpenCode 顶层和 build Agent 的 `permission: "ask"`;Agent
|
||||
工具权限分发边界只批准上述原生读取请求。该双重边界不另加文件系统 confinement,
|
||||
也不改变 Runtime 的直接启动方式。
|
||||
- Execute 的直接启动与模型桥启动均显式设置顶层和 build Agent 的 `permission: "allow"`,
|
||||
避免继承 OpenCode 默认的 `external_directory` 询问;项目目录只是默认工作目录。
|
||||
原生子代理同样使用当前 SSH 账号可访问的路径。仍出现的 ACP 权限请求由 Agent 按
|
||||
当前 Prompt 模式自动答复,不增加 Desktop 人工审批,也不依赖日志解析。
|
||||
- Agent-owned ACP 的备用权限分派允许原生 `read`/`search`,优先选择 `allow_once`;
|
||||
写入、执行和未知工具仍拒绝。独占 raw ACP 保留其原有的读取权限分派。
|
||||
- 未发布的共享路径以中性的 `permission: "ask"` 启动一个进程,再于每个 Session 的
|
||||
`chat.message` 按已登记 workMode 设置原生工具规则。Ask 默认拒绝,放行已支持的
|
||||
读取、搜索和业务问答;子 Session 同步根会话模式并保留原生显式限制,避免固定版本
|
||||
ACP 不转发子 Session 权限请求而永远等待。
|
||||
- Execute Session 放行工具,不再被另一个 Ask Session 的进程级默认值限制。项目目录
|
||||
是默认 cwd,原生子代理仍使用所选 SSH 账号可访问的路径;不增加 Desktop 人工审批。
|
||||
规则及模型路由的唯一实现说明见
|
||||
[共享 Agent Session 设计](../assistant-workbar/runtime-process-reuse-technical-design.md#6-ssh-opencode-与-agent)。
|
||||
|
||||
### Execute
|
||||
|
||||
@@ -226,7 +259,7 @@ Execute 直接启动已签名 Runtime:
|
||||
launcher 路径;
|
||||
- `cwd` 为项目 Workspace;
|
||||
- 继承 SSH 账号的正常环境、文件系统、进程和网络能力;
|
||||
- 不注入 OpenCode Ask 权限配置;
|
||||
- 独占启动使用 Execute 配置;共享启动由 Session 规则应用 Execute,不修改其他会话模式;
|
||||
- 不进行 T2/T3、confinement attestation、approval bridge 或逐工具批准。
|
||||
|
||||
两种模式都保留输入字节上限、用户取消和进程组清理;输出只用有界内存队列与 journal
|
||||
@@ -238,6 +271,19 @@ Execute 直接启动已签名 Runtime:
|
||||
`0.11.10` 及更早版本仍会按其 manifest 上限夹紧,因此桌面启用这一语义前必须配套更新
|
||||
到 Agent `0.11.13` 或更高版本。
|
||||
|
||||
## 执行清单接入范围
|
||||
|
||||
OC `todowrite` / CN `Checklist` 顶部展示属于待实施功能,权威需求及接入设计见
|
||||
[Runtime 执行清单](../assistant-workbar/runtime-checklist-technical-design.md),
|
||||
当前证据见[工作栏进度](../assistant-workbar/progress.md#2026-09-15-runtime-执行清单设计未实现)。
|
||||
目标范围包括远程 GoodBuddy Agent 运行 OC 和 CN;ACP 仅承担传输与远端协议。
|
||||
|
||||
当前托管安装与实时 Runtime 路径仍只接受 OC,不能把通用 ACP plan/tool 传递代码视作
|
||||
远程 CN 已可用。CN 的受管工件、安装/启动、项目选择与校验、模型桥及原生清单传递
|
||||
需要在该功能实施时补齐,并在共享 Linux 真实 Host 分别确认 OC/CN 的可用程度。
|
||||
本次只完善文档,未实测任一远程 Runtime 的清单;已有 OC Ask/Execute、问答或重连证据
|
||||
不作为本功能通过证据。清单恢复复用下节流程,不增加独立持久化或重连系统。
|
||||
|
||||
## ACP 与断线
|
||||
|
||||
- 管控面只负责有界 JSON-RPC:连接、查询、启动、取消、关闭和重连。数据面只负责
|
||||
@@ -286,6 +332,9 @@ Execute 直接启动已签名 Runtime:
|
||||
自动重放。插件随 OpenCode 实例释放临时端口,helper 退出时删除插件文件。
|
||||
以上只适用于当前 Agent 管理的 SSH OpenCode 生产路径,不声明任意 ACP 服务的问答兼容性。
|
||||
真实二进制、SSH 传输和取消验证见[工作栏进度](../assistant-workbar/progress.md#2026-09-13-托管-ssh-原生问答)。
|
||||
未发布进程复用源码补齐 Desktop 重启后的多任务并行附加、实时待答快照及 Main 答案
|
||||
持久化,详见[恢复与终态投影](../assistant-workbar/runtime-process-reuse-technical-design.md#63-恢复与终态投影);
|
||||
不把该待答投影当作 durable transcript 或旧版本已经支持的行为。
|
||||
- ACP 工具结果同时支持文本 `content` 和 `rawOutput.output`,移除 OpenCode 的
|
||||
`<task><task_result>` 外层包装后渲染正文。恢复已有子代理时使用 ACP 所带的所属
|
||||
`toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。
|
||||
@@ -347,7 +396,8 @@ Execute 直接启动已签名 Runtime:
|
||||
- 完成提示是项目选择器内恢复进度的收尾反馈,首次收到该项目该 `requestId` 的完成状态后
|
||||
显示 3 秒,然后从收起按钮和展开菜单同时隐藏;不另发全局成功通知。计时由
|
||||
`ProjectSwitcher` 持有,切换项目、开合菜单或重复状态快照不延长期限,也不重放已隐藏的
|
||||
提示;新 `requestId` 的恢复可以再次展示。进行中状态和失败/就地重试持续保留。
|
||||
提示;新 `requestId` 的恢复可以再次展示。进行中状态和失败提示持续保留;重试操作仅放在
|
||||
展开的项目列表对应项目旁,收起时只显示状态,不在新建项目按钮旁增加恢复按钮。
|
||||
隐藏仅作用于展示层,`App` 保留完成业务状态,继续用于发送、队列恢复和事件顺序判断。
|
||||
- 正常应用退出对已接受托管 SSH 请求执行 detach,不等待远端完成;本地请求和尚未接受的
|
||||
请求仍按原行为取消。用户显式“停止”始终发送稳定 operation cancellation,并等待 Agent
|
||||
@@ -382,11 +432,12 @@ Execute 直接启动已签名 Runtime:
|
||||
usage/task 数据。
|
||||
- 新组包的 Runtime manifest 将 `maximumPromptRuntimeMilliseconds` 设为 `0`,表示由
|
||||
Prompt 自身的 deadline 决定时长,不再额外截为十分钟。旧 manifest 中的正数限制仍按其
|
||||
声明执行。进程 owner 使用真实剩余时长安排 Prompt deadline;启动与停止等待的有界
|
||||
超时不再缩短 Prompt。
|
||||
- helper 可以接收同一 Prompt 内并发到达的模型桥请求;它在单一稳定模型桥上按到达
|
||||
顺序等待并交付,不返回本地 `bridge-busy`,每个响应只有在 HTTP 完整 flush 后才
|
||||
发送 delivery ACK。
|
||||
声明执行。独占进程 owner 使用真实剩余时长安排 Prompt deadline;共享路径由 binding
|
||||
执行逐 Prompt 输入与期限检查,不把历次 Prompt 累计当作整个进程的配额。
|
||||
启动与停止等待的有界超时不再缩短 Prompt。
|
||||
- helper 可以接收并发模型桥请求,同一 Session/operation 的稳定模型桥按到达顺序
|
||||
等待并交付,不同 operation 可并行。请求在排队后再次核对原路由,过期请求不转交给
|
||||
新 operation;每个响应只有在 HTTP 完整 flush 后才发送 delivery ACK。
|
||||
- GoodBuddy 自己管理会话标题,因此传给 OpenCode 的配置禁用 title Agent;一次用户
|
||||
Prompt 不会额外触发标题模型请求。
|
||||
- Agent gateway 不再把 Provider 请求往返 Desktop;legacy blob bridge 只保留给旧的
|
||||
|
||||
@@ -84,6 +84,14 @@ Subagent 是执行 Job 或 Subjob 的受限执行者,不是对象层级:
|
||||
- Subagent 不自动拥有独立 Task 或 Conversation。
|
||||
- Subagent 输出先回到所属 Job,再由 Task 协调器写入关联 Conversation。
|
||||
|
||||
### 1.7 Runtime 执行清单
|
||||
|
||||
Runtime 执行清单是一次请求中由 OC `todowrite` 或 CN `Checklist` 更新的只读工作项列表,
|
||||
属于请求及其助手消息,不创建 Task、Job、Run 或 Schedule,也不驱动定时任务状态。
|
||||
条目全部完成不表示关联 Task 目标达成,请求取消也不自动完成条目。
|
||||
该功能尚未实现,需求、顶部展示与本地/远程接入由
|
||||
[助手工作栏文档](../assistant-workbar/README.md)维护。
|
||||
|
||||
## 2. 对象关系
|
||||
|
||||
```text
|
||||
|
||||
@@ -26,6 +26,37 @@
|
||||
**历史**:`e9679fe`、`ca4a58c` 各修了一部分被人工点到的弹窗,同一批改动又新增了两个未覆盖
|
||||
的遮罩;后续复查改为共享规则加自动发现测试。
|
||||
|
||||
## 网页浮层遗漏原生浏览器视图
|
||||
|
||||
**症状**:知识库实例、项目设置等 Modal 的右侧被浏览器网页覆盖,通知或菜单也可能
|
||||
被压在原生视图下面。
|
||||
|
||||
**原因**:`WebContentsView` 不参与 Renderer 的 CSS 层叠。先前只在关闭终端确认时
|
||||
显式隐藏 viewport,没有覆盖其他页面的 Portal 和浮层。
|
||||
|
||||
**规则**:`BrowserViewport` 共用 `browser-viewport-occlusion.ts`,按 Modal 语义和
|
||||
浮层相交状态释放显示租约,不销毁网页会话。不得再增加逐个弹窗的特殊隐藏条件。
|
||||
|
||||
**防复发**:组件回归覆盖 body Modal、嵌套 Modal、已打开 Modal、隐藏或非相交浮层、
|
||||
通知、终端确认和非活动页签;实际 Electron 验证同时检查原生视图可见性,不能只看 DOM
|
||||
的遮罩或截图。
|
||||
|
||||
**历史**:先前浏览器多实例修正只覆盖了终端关闭确认;2026-09-13 用户在知识库实例
|
||||
Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。
|
||||
|
||||
## Flex 滚动内容压缩页签和分段控件
|
||||
|
||||
**症状**:知识库来源选择被长参数表单压成窄缝,标签和按钮文字被裁掉。
|
||||
|
||||
**规则**:`PageTabs` 与 `SegmentedControl` 在共享样式上固定 `flex: 0 0 auto`;
|
||||
直接包装层也必须保留高度。不要按页面复制高度常量。
|
||||
|
||||
**防复发**:`WorkspacePrimitives.test.tsx` 检查共享分段控件不收缩,已有平台功能、
|
||||
能力和 MCP 页签回归继续保留;Electron 复验必须包含长表单与短窗口。
|
||||
|
||||
**历史**:设置页签曾补过包装层不收缩规则;2026-09-13 知识库创建类型的共享
|
||||
`SegmentedControl` 又出现同类问题。
|
||||
|
||||
## 逐行查询代替主键查询
|
||||
|
||||
**症状**:列表渲染或校验函数随数据量线性变慢,且慢在单次操作内重复发生。
|
||||
@@ -40,6 +71,29 @@
|
||||
**历史**:外部知识库绑定同时出现三处:`requireLibrary`(30+ 调用点,一处在循环内)、
|
||||
`getKnowledgeSnapshot`(每个知识库两次全表扫描)、`listInstances`(每个实例过滤一次)。
|
||||
|
||||
## 将累计过程当作增量事件反复落库
|
||||
|
||||
**症状**:聊天正文很小,执行记录数据库却增长到几十 GiB;只增加几个字节的子任务输出,
|
||||
又写入接近 1 MiB 的旧过程副本。
|
||||
|
||||
**原因**:本机与远程 OpenCode 共用完整进度快照,两个桌面落库入口都按流式频率新增
|
||||
完整 JSON;正文流的缓冲器没有覆盖这种累计快照。
|
||||
|
||||
**规则**:界面快照不直接作为追加式日志的存储表示。子任务事件通过共享存储层记录变化,
|
||||
保留逐条还原和远程来源去重;会话仍保存完整显示结果。旧用户数据通过同一编码器无损
|
||||
转换后回收空间,不靠删聊天或单独修本机数据库解决。
|
||||
|
||||
**防复发**:使用含大工具输出的多次更新检查总存储量,并验证重启、重复远程事件、
|
||||
消息事务回滚及旧库中断续跑。工具对象须包含生产路径中的 `undefined` 可选字段,
|
||||
经过真实 JSON 往返后仍识别为无变化,不能按对象键存在性比较。真实模型验证须包含
|
||||
子代理正常使用工具、修改文件及运行测试,不能只测禁用工具的文本流。
|
||||
真实旧库需验证逐条等价和物理文件缩小,已升级 schema 的重复数据同样要覆盖。
|
||||
|
||||
**历史**:2026-09-13 检查到 305,551 条子任务事件占用 90.11 GiB;
|
||||
本机 `appendTaskEvent` 与远程 `insertRemoteTaskEventOnce` 同时存在重复保存路径。
|
||||
0.13.1 发布后的真实工具任务再次复现:同批 928 条事件有 7,787 次重复工具块写入;
|
||||
只统一 JSON 空字段后,事件负载从 10,467,622 降为 452,391 字节,逐条还原一致。
|
||||
|
||||
## 生产路径复用“测试”函数
|
||||
|
||||
**症状**:正常用户操作触发了只应在配置校验时出现的错误码,或多发了一次网络请求。
|
||||
|
||||
@@ -48,7 +48,7 @@ GoodBuddy 应能够:
|
||||
- 排除最左侧主导航后,主工作区与右侧栏使用相同的 `300px` 最小宽度,并按用户拖动后的
|
||||
比例分配可用宽度;任一侧到达最小值后停止拖动。
|
||||
- 主工作区中的消息流和输入区保留对称的左右安全边距,右侧栏不得覆盖或裁切其右边缘。
|
||||
- 唯一展开与收起开关固定在窗口关闭按钮正下方,右侧栏内部不重复关闭入口;当前会话没有
|
||||
- 唯一展开与收起开关固定在顶栏操作区、紧邻浅色/深色主题按钮之前,右侧栏内部不重复关闭入口;当前会话没有
|
||||
Task 时不保留空的 Task 上下文栏。
|
||||
- 右侧栏在对话、知识库和活动视图之间保持状态。
|
||||
- 知识图谱实体详情复用同一右栏容器,不再维护独立布局。
|
||||
|
||||
@@ -112,6 +112,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
'assistant-storage-worker': resolve('src/main/assistant-storage-worker.ts'),
|
||||
'wechat-sidecar': resolve(
|
||||
'src/main/channels/wechat-sidecar.ts'
|
||||
),
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.5",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.5",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
@@ -21,7 +21,7 @@
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"start": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.agent.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# GoodBuddy Agent 0.11.25 更新内容
|
||||
|
||||
## 维护更新
|
||||
|
||||
- **配套 Desktop 0.13.2 的维护版本**:本次重新发布受支持平台的 Agent 复合包,不新增独立远端功能,也不改变 Agent 协议。桌面端的工具记录和执行历史存储补修由 Desktop 0.13.2 提供,更新 Agent 不能代替桌面升级。
|
||||
|
||||
## 升级说明
|
||||
|
||||
- 需要 **Desktop 0.13.2 或更高版本**,请先升级 Desktop,再按需更新 Host 环境。提供 Linux x64、Linux arm64 和 macOS arm64 复合包,Node 保持 `24.19.0`,OpenCode 保持 `1.18.29`。
|
||||
- Ask 保持只读,Execute 继续使用所选 SSH 账号权限。更新不会热替换运行中的旧进程,请在当前任务结束后更新并使用新 Runtime。
|
||||
|
||||
---
|
||||
|
||||
# What's New in GoodBuddy Agent 0.11.25
|
||||
|
||||
## Maintenance
|
||||
|
||||
- **Maintenance release alongside Desktop 0.13.2**: Agent compound packages are republished for the supported platforms without adding standalone remote features or changing the Agent protocol. Desktop 0.13.2 provides the tool-record and execution-history storage repairs; updating Agent does not replace the Desktop upgrade.
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
- Requires **Desktop 0.13.2 or later**; upgrade Desktop before updating Host environments as needed. Compound packages target Linux x64, Linux arm64, and macOS arm64, retaining Node `24.19.0` and OpenCode `1.18.29`.
|
||||
- Ask remains read-only, and Execute retains the selected SSH account's permissions. Updates do not hot-replace running processes; finish active tasks before updating and using a new Runtime.
|
||||
@@ -0,0 +1,33 @@
|
||||
# GoodBuddy Agent 0.13.0 更新内容
|
||||
|
||||
## 功能更新
|
||||
|
||||
- **远程聊天也能直接生图**:使用托管远程 OpenCode 时,可在当前对话中调用桌面端配置的图片模型,生成图片、修改上传图片或继续调整历史成果。无需另开会话或切换聊天模型,图片结果保存到原会话。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- **多个远程项目更省资源**:同一 Host 上兼容的项目和会话可共享 OpenCode 底层进程,减少重复启动。各会话继续独立工作,取消其中一个不会结束其他会话使用的共享进程。
|
||||
- **断线后继续处理原对话**:配合新版桌面端,重新连接或重启桌面后可恢复原操作的待答问题与任务状态。重连不会重新发送已经接受的请求。
|
||||
|
||||
## 使用说明
|
||||
|
||||
- 请先升级至 **GoodBuddy 0.13.5 或更高版本**,再更新远程环境。聊天生图需开启图片模型的会话调用,并使用 Execute 模式。
|
||||
- 提供 Linux x64、Linux arm64 和 macOS arm64 安装包,内含 Node `24.19.0` 和 OpenCode `1.18.29`。新行为在更新后的 Agent 和 Runtime 中生效,正在运行的旧进程不会热更新。
|
||||
- 远程项目仍为技术预览;Ask 保持只读,Execute 使用所选 SSH 账号的权限。
|
||||
|
||||
# What's New in GoodBuddy Agent 0.13.0
|
||||
|
||||
## Features
|
||||
|
||||
- **Create images in remote chats**: Managed remote OpenCode can use Desktop-configured image models to generate images, edit uploads, or refine earlier results in the current conversation. No separate conversation or chat-model switch is needed, and results stay in the original conversation.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Use fewer resources across remote projects**: Compatible projects and conversations on the same Host share underlying OpenCode processes, reducing duplicate startup. Conversations remain independent, and cancelling one does not terminate the shared process used by others.
|
||||
- **Continue the original conversation after disconnection**: Together with the updated Desktop, pending questions and task status recover after reconnection or Desktop restart. Reconnection does not resend accepted requests.
|
||||
|
||||
## Usage Notes
|
||||
|
||||
- Upgrade to **GoodBuddy 0.13.5 or later** before updating the remote environment. In-chat image tools require conversation access to be enabled for an image model and Execute mode.
|
||||
- Packages cover Linux x64, Linux arm64, and macOS arm64, with Node `24.19.0` and OpenCode `1.18.29`. Changes take effect in the updated Agent and Runtime; running older processes are not hot-updated.
|
||||
- Remote projects remain a technical preview. Ask stays read-only, and Execute uses the selected SSH account’s permissions.
|
||||
@@ -1,6 +1,124 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.13.5",
|
||||
"releasedAt": "2026-09-15",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**聊天生图,无需切换模型**:生图模块现在可作为 MCP 能力,供支持工具调用的聊天模型使用,无需另开会话或手动切换到图片模型。在当前对话中即可要求 AI 生成图片、修改上传图片或继续调整之前的成果,图片和生成状态直接显示在原会话中。",
|
||||
"**复制文件,直接粘贴为附件**:在文件管理器中复制一个或多个支持的本地文件后,直接粘贴到聊天输入框即可添加附件。读取和解析进度清晰可见,失败时保留草稿与已有附件,文本和截图仍可照常粘贴。"
|
||||
],
|
||||
"fixes": [
|
||||
"**图片型 PPTX 也能读取文字**:配置本地 OCR 并选择启用 OCR 的解析方式后,可识别幻灯片内嵌图片中的文字,解决图片型 PPTX 无法提取内容的问题。识别结果保留幻灯片定位,原有文字也会一并保留。",
|
||||
"**识别结束后释放内存**:本地 OCR 在没有正在执行或排队的识别任务时,空闲 60 秒后自动释放模型运行内存。连续解析会复用模型,下次使用时自动重新加载。",
|
||||
"**多项目运行更省资源**:多个项目使用兼容配置时,本地 OpenCode、DeepSeek Harness 和托管远程 OpenCode 可分别复用各自的底层进程,减少重复启动和内存占用。不同会话仍可并行工作,停止其中一个不会连带停止其他会话。",
|
||||
"**远程对话恢复更连贯**:修复断线、桌面重启或配置更新后,远程待答问题和任务状态恢复不一致的问题。需要重试时,可直接在对应项目的菜单中操作。",
|
||||
"**项目菜单和工作栏更顺手**:长会话列表独立滚动,不再撑高项目菜单;没有会话活动时自动隐藏摘要。工作栏开关移到明暗主题按钮旁,不再悬浮遮挡内容。",
|
||||
"**魔法笔记自动同步显示**:AI 或 MCP 修改笔记后,已打开的笔记页面会自动刷新,无需手动重新打开。刷新保留当前选择和未保存草稿。",
|
||||
"**窗口异常时可以直接恢复**:界面渲染失败时提供“重新加载”入口,渲染进程崩溃时弹出恢复提示。重新加载可能丢失未保存的输入。"
|
||||
],
|
||||
"notices": [
|
||||
"聊天生图需先为图片模型开启会话调用,并在 **Execute 模式**使用;支持直连文本模型、本地 OpenCode、Continue、DeepSeek Harness 和托管远程 OpenCode。生成和编辑可能产生图片服务费用,参考图编辑需要服务商支持。",
|
||||
"远程用户请先升级至 **GoodBuddy 0.13.5**,再更新 Host 上的 **Agent 0.13.0**。远程项目仍为技术预览,Ask/Execute 权限不变。",
|
||||
"提供 Windows、macOS、Linux 的 x64 和 arm64 标准桌面安装包,本次不构建 LoongArch 实验预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Create images without switching chat models**: Image generation is now available as an MCP capability to tool-capable chat models, without opening another conversation or manually switching to an image model. Ask AI to create an image, edit an upload, or refine an earlier result, with images and generation status shown in the same conversation.",
|
||||
"**Paste files directly into chat**: Copy one or more supported local files in your file manager and paste them into the composer to attach them. Reading and parsing progress is shown, failures preserve your draft and existing attachments, and text and screenshot paste continue to work."
|
||||
],
|
||||
"fixes": [
|
||||
"**Read text from image-based PowerPoint files**: With local OCR configured and an OCR-enabled parsing mode selected, GoodBuddy can recognize text in embedded slide images instead of rejecting image-only PPTX files. Results retain slide locations alongside the original text.",
|
||||
"**Release memory after text recognition**: Local OCR releases its model memory after 60 idle seconds when no recognition tasks are running or queued. Consecutive tasks reuse the model, and it reloads automatically when needed again.",
|
||||
"**Use fewer resources across projects**: With compatible configurations, local OpenCode, DeepSeek Harness, and managed remote OpenCode each reuse their underlying processes to reduce duplicate startup and memory use. Conversations can still run in parallel, and stopping one does not stop the others.",
|
||||
"**Resume remote conversations more reliably**: Fixed inconsistent recovery of pending questions and task status after disconnection, Desktop restart, or configuration changes. Retry actions are available in the affected project’s menu.",
|
||||
"**Smoother project menus and workbar access**: Long conversation lists scroll independently without stretching the project menu, and the activity summary hides when idle. The workbar toggle now sits beside the theme switch instead of floating over content.",
|
||||
"**Keep Magic Notes up to date**: The open notes page refreshes automatically after AI or MCP edits, without needing to reopen it. Your selection and unsaved drafts are preserved.",
|
||||
"**Recover directly from window errors**: Rendering failures show a Reload action, and renderer-process crashes display a recovery prompt. Reloading may lose unsaved input."
|
||||
],
|
||||
"notices": [
|
||||
"Enable conversation access for an image model and use **Execute mode** for in-chat image tools. Supported runtimes are direct text models, local OpenCode, Continue, DeepSeek Harness, and managed remote OpenCode; image requests may incur provider charges, and reference-image editing requires provider support.",
|
||||
"Remote users should upgrade to **GoodBuddy 0.13.5** before updating Hosts to **Agent 0.13.0**. Remote projects remain a technical preview, with unchanged Ask/Execute permissions.",
|
||||
"Standard desktop packages cover Windows, macOS, and Linux on x64 and arm64. No LoongArch experimental preview is included."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.13.2",
|
||||
"releasedAt": "2026-09-14",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**修复升级后仍重复增长的执行记录**:修复 0.13.1 中子代理使用工具时,未变化的工具内容仍被重复保存的问题。首次启动会无损整理这些重复内容,保留会话、执行详情、结果和远程来源记录。任务结束后及时释放存储缓存,不清空其他正在运行会话的缓存。",
|
||||
"**工具正在做什么更清楚**:OpenCode、Continue、DeepSeek Harness 和直连模型的工具记录可显示文件路径、命令或搜索摘要。工具完成后仍保留已有操作说明,展开记录或重新打开会话可继续查看输入、结果和错误。",
|
||||
"**修复 Windows DeepSeek 工作区路径错误**:同一个目录使用不同分隔符或规范路径写法时,不再因为 Host 与会话路径不一致而导致请求失败。Host 启动和后续会话使用同一规范路径,保持原有工作区与权限边界。",
|
||||
"**跨项目活动菜单更紧凑**:点击活动摘要后,可按“项目 → 会话”两级菜单直接进入需要处理的对话,不再打开整块活动弹窗。菜单支持键盘导航,并调整了入口间距和会话行指示,保留原有活动计数及跨项目定位。"
|
||||
],
|
||||
"notices": [
|
||||
"本次会再次整理已升级至 0.13.1 的数据库;大库可能需要时间和额外磁盘空间,支持退出后继续。升级后的数据库不能由旧客户端打开,需要回退时请使用升级前备份,不要手工修改数据库版本。",
|
||||
"配套发布 **Agent 0.11.25** 维护版,需要先升级至 **Desktop 0.13.2**,再按需更新 Host 环境。存储补修由 Desktop 执行,不依赖升级 Agent;远程项目仍为技术预览,Ask/Execute 权限不变。",
|
||||
"Desktop 提供 Windows、macOS、Linux 的 x64/arm64 标准安装包;本次不构建 LoongArch 实验预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Fix repeated execution-history growth after upgrading**: Fixed an issue in 0.13.1 where subagents using tools could repeatedly save unchanged tool content. The first launch compacts these duplicates without losing conversations, execution details, results, or remote provenance. Finished tasks release their storage caches without clearing caches for other active conversations.",
|
||||
"**See what tools are doing**: Tool records from OpenCode, Continue, DeepSeek Harness, and direct models can show file paths, commands, or search summaries. Existing operation descriptions remain after completion, and expanded records or reopened conversations retain inputs, results, and errors.",
|
||||
"**Fix Windows DeepSeek workspace-path errors**: Equivalent paths to the same directory no longer fail because the Host and session disagree about separators or canonical spelling. Host startup and subsequent sessions use the same canonical path while preserving workspace and permission boundaries.",
|
||||
"**More compact cross-project activity navigation**: Open the activity summary and use a two-level project-to-conversation menu to reach conversations needing attention instead of opening a large activity dialog. Keyboard navigation, improved spacing, and simpler conversation indicators preserve activity counts and exact cross-project navigation."
|
||||
],
|
||||
"notices": [
|
||||
"This update also compacts databases already upgraded to 0.13.1; large databases may take time and require extra disk space, with quit-and-resume support. Older clients cannot open the upgraded database, so use a pre-upgrade backup for rollback rather than manually changing its version.",
|
||||
"The accompanying **Agent 0.11.25** maintenance release requires **Desktop 0.13.2** before updating a Host environment. Desktop performs the storage repair without requiring an Agent update; remote projects remain a technical preview and Ask/Execute permissions are unchanged.",
|
||||
"Desktop includes standard Windows, macOS, and Linux x64/arm64 packages, without a LoongArch experimental preview."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.13.1",
|
||||
"releasedAt": "2026-09-13",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**修复数据库异常膨胀**:长任务和子任务不再反复保存整份执行过程。首次启动自动整理旧记录并回收磁盘空间,保留聊天、执行详情和结果。",
|
||||
"**浏览器不再遮挡弹窗**:打开项目设置、知识库实例等弹窗时,内置网页会正确让出显示区域。关闭弹窗后恢复原网页,不丢失页面状态。",
|
||||
"**知识库配置更清楚**:长名称和长参数表单不再挤压来源选择控件。切换远端知识库时自动名称随之更新,手工填写的名称保持不变。",
|
||||
"**更新说明恢复显示**:修复历史更新说明增长后,本次更新内容可能无法加载的问题。当前版本说明可正常显示并确认,重启后不再重复弹出。"
|
||||
],
|
||||
"notices": [
|
||||
"整理较大的历史数据库可能需要较长时间和额外磁盘空间,支持退出后下次继续。升级后的数据库不能由旧版客户端打开,需要回退时请保留升级前备份。",
|
||||
"Agent 保持 **0.11.24**,本次不重新发布 Agent。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Fix excessive database growth**: Long-running tasks and subagents no longer repeatedly save the entire execution history. The first launch compacts existing records and reclaims disk space while preserving chats, execution details, and results.",
|
||||
"**Keep dialogs above embedded webpages**: Embedded webpages now yield their display area when project settings, knowledge-instance dialogs, and other overlays open. Closing the overlay restores the original webpage without losing its state.",
|
||||
"**Clearer knowledge configuration**: Long names and parameter forms no longer squeeze source-selection controls. Automatically filled names follow the selected remote knowledge base, while manually entered names are preserved.",
|
||||
"**Restore release-note display**: Fixed an issue where growing historical release notes could prevent the current update notes from loading. Current-version notes can be displayed and acknowledged without appearing again after restart."
|
||||
],
|
||||
"notices": [
|
||||
"Large history databases may require substantial processing time and extra disk space; you can quit and continue on the next launch. Older clients cannot open the upgraded database, so retain a pre-upgrade backup if rollback is needed.",
|
||||
"Agent remains at **0.11.24** and is not republished."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.13.0",
|
||||
"releasedAt": "2026-09-13",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, type Client } from '@agentclientprotocol/sdk'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import type { RuntimeAcpProcessOutput, RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
|
||||
describe('AgentAcpConnection', () => {
|
||||
it('uses one decoder and handshake and routes interleaved Sessions without closing peers', async () => {
|
||||
let output!: (value: RuntimeAcpProcessOutput) => void | Promise<void>
|
||||
let input!: ReadableStreamDefaultController<Uint8Array>
|
||||
const unsubscribe = vi.fn()
|
||||
const process: RuntimeAcpProcessOwner = {
|
||||
identity: { launchId: 'launch', processId: 'process', supervisorIdentityDigest: `sha256:${'a'.repeat(64)}` },
|
||||
beginPrompt: vi.fn(), completePrompt: vi.fn(), stop: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
subscribeOutput: vi.fn(listener => { output = listener; return unsubscribe }),
|
||||
subscribeExit: vi.fn(() => unsubscribe),
|
||||
writeStdin: async data => input.enqueue(data)
|
||||
}
|
||||
const initialize = vi.fn(async () => ({ protocolVersion: PROTOCOL_VERSION }))
|
||||
const server = new AgentSideConnection(() => ({
|
||||
initialize, newSession: async () => ({ sessionId: 'session' }),
|
||||
prompt: async () => ({ stopReason: 'end_turn' as const }),
|
||||
cancel: async () => undefined
|
||||
}), ndJsonStream(new WritableStream({
|
||||
write: async data => { await output({ stream: 'stdout', data }) }
|
||||
}), new ReadableStream({ start: controller => { input = controller } })))
|
||||
const transport = new AgentAcpConnection(process)
|
||||
const one: Client = { sessionUpdate: vi.fn(), requestPermission: vi.fn(async () => ({
|
||||
outcome: { outcome: 'selected', optionId: 'allow' }
|
||||
})) }
|
||||
const two: Client = { sessionUpdate: vi.fn(), requestPermission: vi.fn(async () => ({
|
||||
outcome: { outcome: 'cancelled' }
|
||||
})) }
|
||||
const release = transport.register('one', one)
|
||||
transport.register('two', two)
|
||||
try {
|
||||
await Promise.all([1, 2].map(() => transport.initialize({ protocolVersion: PROTOCOL_VERSION })))
|
||||
expect(initialize).toHaveBeenCalledOnce()
|
||||
for (const sessionId of ['one', 'two', 'one', 'two']) {
|
||||
await server.sessionUpdate({
|
||||
sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: sessionId } }
|
||||
})
|
||||
}
|
||||
await vi.waitFor(() => expect(two.sessionUpdate).toHaveBeenCalledTimes(2))
|
||||
expect(one.sessionUpdate).toHaveBeenCalledTimes(2)
|
||||
const permission = (sessionId: string) => server.requestPermission({
|
||||
sessionId, options: [{ kind: 'allow_once', optionId: 'allow', name: 'Allow' }],
|
||||
toolCall: { toolCallId: 'tool', title: 'write', kind: 'edit' }
|
||||
})
|
||||
expect(await permission('one')).toEqual({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
expect(await permission('two')).toEqual({ outcome: { outcome: 'cancelled' } })
|
||||
release()
|
||||
expect(await permission('one')).toEqual({ outcome: { outcome: 'cancelled' } })
|
||||
expect(await permission('two')).toEqual({ outcome: { outcome: 'cancelled' } })
|
||||
expect(process.subscribeOutput).toHaveBeenCalledOnce()
|
||||
expect(unsubscribe).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
transport.dispose()
|
||||
input.close()
|
||||
}
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
type Client,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
|
||||
/** One decoder and handshake per native process, with Session-scoped clients. */
|
||||
export class AgentAcpConnection {
|
||||
readonly connection: ClientSideConnection
|
||||
readonly exited: Promise<never>
|
||||
private initialization?: Promise<InitializeResponse>
|
||||
private readonly sessions = new Map<string, Client>()
|
||||
private readonly unsubscribeOutput: () => void
|
||||
private readonly unsubscribeExit: () => void
|
||||
private closed = false
|
||||
private closeInput?: () => void
|
||||
private bridgeOrigin?: string
|
||||
|
||||
constructor(
|
||||
process: RuntimeAcpProcessOwner,
|
||||
fallback?: () => Client,
|
||||
createConnection?: (client: () => Client, stream: ReturnType<typeof ndJsonStream>) => ClientSideConnection
|
||||
) {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let rejectExit!: (error: Error) => void
|
||||
this.exited = new Promise<never>((_resolve, reject) => { rejectExit = reject })
|
||||
void this.exited.catch(() => undefined)
|
||||
const input = new ReadableStream<Uint8Array>({
|
||||
start(value) { controller = value }
|
||||
})
|
||||
this.closeInput = () => {
|
||||
controller?.close()
|
||||
controller = undefined
|
||||
}
|
||||
this.unsubscribeOutput = process.subscribeOutput((output) => {
|
||||
if (!this.closed && output.stream === 'stdout' && output.data.byteLength > 0) {
|
||||
controller?.enqueue(output.data.slice())
|
||||
}
|
||||
}) ?? (() => undefined)
|
||||
this.unsubscribeExit = process.subscribeExit?.(() => {
|
||||
if (this.closed) return
|
||||
const error = new Error('ACP Runtime process exited')
|
||||
controller?.error(error)
|
||||
controller = undefined
|
||||
rejectExit(error)
|
||||
}) ?? (() => undefined)
|
||||
const output = new WritableStream<Uint8Array>({
|
||||
write: async (data) => { await process.writeStdin(data) }
|
||||
})
|
||||
const client = (): Client => ({
|
||||
sessionUpdate: async (notification) => {
|
||||
await (this.sessions.get(notification.sessionId) ?? fallback?.())
|
||||
?.sessionUpdate(notification)
|
||||
},
|
||||
requestPermission: async (request) =>
|
||||
await (this.sessions.get(request.sessionId) ?? fallback?.())
|
||||
?.requestPermission(request) ?? { outcome: { outcome: 'cancelled' } },
|
||||
extMethod: async () => ({}),
|
||||
extNotification: async (method, params) => {
|
||||
if (
|
||||
method === 'goodbuddy/modelBridgeReady' &&
|
||||
typeof params.origin === 'string' &&
|
||||
/^http:\/\/127\.0\.0\.1:\d+\/[A-Za-z0-9_-]{43}$/u.test(params.origin)
|
||||
) this.bridgeOrigin = params.origin
|
||||
}
|
||||
})
|
||||
const stream = ndJsonStream(output, input)
|
||||
this.connection = createConnection?.(client, stream) ?? new ClientSideConnection(client, stream)
|
||||
}
|
||||
|
||||
initialize(request: InitializeRequest): Promise<InitializeResponse> {
|
||||
return this.initialization ??= this.connection.initialize(request)
|
||||
}
|
||||
|
||||
register(sessionId: string, client: Client): () => void {
|
||||
if (this.sessions.has(sessionId)) throw new Error('ACP Session already has an owner')
|
||||
this.sessions.set(sessionId, client)
|
||||
return () => {
|
||||
if (this.sessions.get(sessionId) === client) this.sessions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
async setModelRoute(
|
||||
sessionId: string,
|
||||
operationId: string,
|
||||
socketPath?: string,
|
||||
workMode?: 'ask' | 'execute',
|
||||
imageToolName?: string
|
||||
): Promise<void> {
|
||||
if (!this.bridgeOrigin) throw new Error('Shared model bridge is not ready')
|
||||
const response = await fetch(`${this.bridgeOrigin}/session`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, operationId, ...(socketPath ? { socketPath, workMode, imageToolName } : { release: true }) }),
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
})
|
||||
if (!response.ok) throw new Error(`Shared model bridge route failed (${response.status})`)
|
||||
}
|
||||
dispose(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.sessions.clear()
|
||||
this.unsubscribeOutput()
|
||||
this.unsubscribeExit()
|
||||
this.closeInput?.()
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,72 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentOwnedAcpPrompt', () => {
|
||||
it('applies each prompt mode to the retained session and its permission decisions', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-modes-'))
|
||||
temporary.push(root)
|
||||
const transcript = new SemanticPromptStore(join(root, 'prompts.sqlite'))
|
||||
let client!: Client
|
||||
let finish!: (response: PromptResponse) => void
|
||||
const newSession = vi.fn(async () => ({ sessionId: 'session-1' }))
|
||||
const resumeSession = vi.fn(async () => ({}))
|
||||
let imageUrl: string | undefined
|
||||
const mcpServers = () => imageUrl ? [{ type: 'http' as const, name: 'images', url: imageUrl, headers: [] }] : []
|
||||
const prepareSession = vi.fn(async () => undefined)
|
||||
const completePrompt = vi.fn(async () => undefined)
|
||||
const owner = new AgentOwnedAcpPrompt({
|
||||
bindingId: 'binding-1', controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace', process: new MemoryProcess(),
|
||||
transcript, prepareSession, completePrompt, mcpServers,
|
||||
createConnection: factory => {
|
||||
client = factory()
|
||||
return {
|
||||
initialize: async () => ({ protocolVersion: 1, agentCapabilities: {} }),
|
||||
newSession, resumeSession,
|
||||
prompt: () => new Promise<PromptResponse>(resolve => { finish = resolve })
|
||||
} as unknown as ClientSideConnection
|
||||
}
|
||||
})
|
||||
try {
|
||||
for (const [index, workMode] of (['execute', 'execute', 'ask', 'execute', 'ask'] as const).entries()) {
|
||||
imageUrl = workMode === 'execute' && index !== 1 ? `http://127.0.0.1:1234/prompt-${index}` : undefined
|
||||
const operationId = `operation-${index}`
|
||||
transcript.prepare({
|
||||
bindingId: 'binding-1', operationId, requestId: operationId,
|
||||
controllerId: 'controller-1', preparationDigest: `sha256:${'a'.repeat(64)}`,
|
||||
promptSequence: index
|
||||
})
|
||||
await owner.start({
|
||||
bindingId: 'binding-1', operationId, requestId: operationId,
|
||||
prompt: [{ type: 'text', text: 'Continue with the selected mode' }]
|
||||
}, workMode)
|
||||
expect(prepareSession).toHaveBeenLastCalledWith('session-1', operationId, workMode)
|
||||
if (index !== 2) {
|
||||
expect(index === 0 ? newSession : resumeSession).toHaveBeenLastCalledWith(expect.objectContaining({ mcpServers: mcpServers() }))
|
||||
}
|
||||
const result = await client.requestPermission!({
|
||||
sessionId: 'session-1',
|
||||
toolCall: { toolCallId: `tool-${index}`, kind: 'execute', title: 'Run a test' },
|
||||
options: [
|
||||
{ kind: 'allow_once', optionId: 'allow', name: 'Allow' },
|
||||
{ kind: 'reject_once', optionId: 'reject', name: 'Reject' }
|
||||
]
|
||||
})
|
||||
expect(result.outcome).toEqual({
|
||||
outcome: 'selected', optionId: workMode === 'execute' ? 'allow' : 'reject'
|
||||
})
|
||||
finish({ stopReason: 'end_turn' })
|
||||
await vi.waitFor(() => expect(transcript.attach('binding-1', operationId, 'controller-1'))
|
||||
.toMatchObject({ state: 'completed' }))
|
||||
}
|
||||
expect(newSession).toHaveBeenCalledOnce()
|
||||
expect(resumeSession).toHaveBeenCalledTimes(3)
|
||||
expect(completePrompt).toHaveBeenCalledTimes(5)
|
||||
} finally {
|
||||
owner.close()
|
||||
transcript.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the original prompt promise through Desktop detach and recovers the final response', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-'))
|
||||
temporary.push(root)
|
||||
@@ -76,7 +142,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
expectedModel,
|
||||
process,
|
||||
transcript,
|
||||
@@ -101,7 +166,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text' as const, text: 'finish independently' }]
|
||||
}
|
||||
await expect(owner.start(request)).resolves.toMatchObject({
|
||||
await expect(owner.start(request, 'execute')).resolves.toMatchObject({
|
||||
state: 'running',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
@@ -141,7 +206,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
limit: 10
|
||||
}).events.map((event) => event.kind)
|
||||
).toEqual(['session-update', 'prompt-terminal'])
|
||||
await expect(owner.start(request)).resolves.toMatchObject({
|
||||
await expect(owner.start(request, 'execute')).resolves.toMatchObject({
|
||||
state: 'completed'
|
||||
})
|
||||
expect(prompt).toHaveBeenCalledOnce()
|
||||
@@ -170,7 +235,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
process: new MemoryProcess(),
|
||||
transcript,
|
||||
completePrompt,
|
||||
@@ -191,7 +255,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'finish independently' }]
|
||||
})
|
||||
}, 'execute')
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
transcript.attach('binding-1', 'operation-1', 'controller-1')
|
||||
@@ -239,7 +303,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
process,
|
||||
transcript,
|
||||
completePrompt,
|
||||
@@ -261,7 +324,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'wait for Runtime exit' }]
|
||||
})
|
||||
}, 'execute')
|
||||
await process.emitExit()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
@@ -315,7 +378,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'execute',
|
||||
process: new MemoryProcess(),
|
||||
transcript,
|
||||
completePrompt: async () => undefined,
|
||||
@@ -338,7 +400,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'run one tool' }]
|
||||
})
|
||||
}, 'execute')
|
||||
await expect(
|
||||
client.requestPermission({
|
||||
sessionId: 'session-1',
|
||||
@@ -388,7 +450,6 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace',
|
||||
workMode: 'ask',
|
||||
process: new MemoryProcess(),
|
||||
transcript,
|
||||
completePrompt: async () => undefined,
|
||||
@@ -411,7 +472,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
operationId: 'operation-1',
|
||||
requestId: 'operation-1',
|
||||
prompt: [{ type: 'text', text: 'inspect one file' }]
|
||||
})
|
||||
}, 'ask')
|
||||
const permission = (
|
||||
kind: 'read' | 'edit'
|
||||
): Parameters<Client['requestPermission']>[0] => ({
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PROTOCOL_VERSION,
|
||||
ndJsonStream,
|
||||
type Client,
|
||||
type McpServer,
|
||||
type PromptResponse,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
RemoteOwnedPromptStartResult
|
||||
} from '../shared/remote-agent-contracts'
|
||||
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import {
|
||||
SemanticPromptStore,
|
||||
SemanticPromptStoreError
|
||||
@@ -25,9 +27,11 @@ export type AgentOwnedAcpPromptOptions = {
|
||||
bindingId: string
|
||||
controllerId: string
|
||||
workspaceDirectory: string
|
||||
workMode: 'ask' | 'execute'
|
||||
mcpServers?: () => McpServer[]
|
||||
expectedModel?: string
|
||||
process: RuntimeAcpProcessOwner
|
||||
transport?: AgentAcpConnection
|
||||
prepareSession?: (sessionId: string, operationId: string, workMode: 'ask' | 'execute') => Promise<void>
|
||||
transcript: SemanticPromptStore
|
||||
completePrompt: (
|
||||
operationId: string,
|
||||
@@ -44,76 +48,44 @@ export type AgentOwnedAcpPromptOptions = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the ACP ClientSideConnection and the original prompt Promise. No
|
||||
* transport-connection signal is part of this lifetime.
|
||||
* Owns a native Session and its original prompt Promise. Desktop connection
|
||||
* signals are not part of this lifetime; the native transport may be shared.
|
||||
*/
|
||||
export class AgentOwnedAcpPrompt {
|
||||
readonly #options: AgentOwnedAcpPromptOptions
|
||||
readonly #connection: ClientSideConnection
|
||||
readonly #input: ReadableStream<Uint8Array>
|
||||
readonly #unsubscribeOutput: () => void
|
||||
readonly #unsubscribeExit: () => void
|
||||
readonly #transport: AgentAcpConnection
|
||||
#unregisterSession?: () => void
|
||||
readonly #processExit: Promise<never>
|
||||
#sessionId?: string
|
||||
#promptPromise?: Promise<void>
|
||||
#promptSettled?: Promise<void>
|
||||
#closed = false
|
||||
#initialized = false
|
||||
#hasMcpServers = false
|
||||
readonly #questions = new Map<string, {
|
||||
endpoint: string; operationId: string; questionCount: number; notification: SessionNotification
|
||||
}>()
|
||||
#acceptQuestions = false
|
||||
#active?: {
|
||||
operationId: string
|
||||
workMode: 'ask' | 'execute'
|
||||
}
|
||||
|
||||
constructor(options: AgentOwnedAcpPromptOptions) {
|
||||
this.#options = options
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let rejectProcessExit!: (error: Error) => void
|
||||
this.#processExit = new Promise<never>((_resolve, reject) => {
|
||||
rejectProcessExit = reject
|
||||
})
|
||||
void this.#processExit.catch(() => undefined)
|
||||
this.#input = new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value
|
||||
}
|
||||
})
|
||||
const output = new WritableStream<Uint8Array>({
|
||||
write: async (chunk) => {
|
||||
await options.process.writeStdin(chunk)
|
||||
}
|
||||
})
|
||||
const unsubscribe = options.process.subscribeOutput((event) => {
|
||||
if (
|
||||
event.stream === 'stdout' &&
|
||||
event.data.byteLength > 0 &&
|
||||
!this.#closed
|
||||
) {
|
||||
controller?.enqueue(event.data.slice())
|
||||
}
|
||||
})
|
||||
this.#unsubscribeOutput = unsubscribe ?? (() => undefined)
|
||||
const unsubscribeExit = options.process.subscribeExit?.(() => {
|
||||
if (this.#closed) {
|
||||
return
|
||||
}
|
||||
const error = new Error('ACP Runtime process exited')
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
controller?.error(error)
|
||||
controller = undefined
|
||||
rejectProcessExit(error)
|
||||
})
|
||||
this.#unsubscribeExit = unsubscribeExit ?? (() => undefined)
|
||||
const stream = ndJsonStream(output, this.#input)
|
||||
this.#connection =
|
||||
options.createConnection?.(() => this.#client(), stream) ??
|
||||
new ClientSideConnection(() => this.#client(), stream)
|
||||
this.#transport = options.transport ?? new AgentAcpConnection(
|
||||
options.process, () => this.#client(), options.createConnection
|
||||
)
|
||||
this.#connection = this.#transport.connection
|
||||
this.#processExit = this.#transport.exited
|
||||
}
|
||||
|
||||
get sessionId(): string | undefined { return this.#sessionId }
|
||||
|
||||
async start(
|
||||
request: RemoteOwnedPromptStartRequest
|
||||
request: RemoteOwnedPromptStartRequest,
|
||||
workMode: 'ask' | 'execute'
|
||||
): Promise<RemoteOwnedPromptStartResult> {
|
||||
if (
|
||||
request.bindingId !== this.#options.bindingId
|
||||
@@ -140,9 +112,10 @@ export class AgentOwnedAcpPrompt {
|
||||
)
|
||||
}
|
||||
|
||||
const mcpServers = this.#options.mcpServers?.() ?? []
|
||||
if (!this.#initialized) {
|
||||
const initialization = await this.#whileProcessAlive(
|
||||
this.#connection.initialize({
|
||||
this.#transport.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
terminal: false,
|
||||
@@ -166,17 +139,17 @@ export class AgentOwnedAcpPrompt {
|
||||
this.#connection.loadSession({
|
||||
sessionId: request.acpSessionId,
|
||||
cwd: this.#options.workspaceDirectory,
|
||||
mcpServers: []
|
||||
mcpServers
|
||||
})
|
||||
)
|
||||
} else if (
|
||||
initialization.agentCapabilities?.sessionCapabilities?.resume === true
|
||||
initialization.agentCapabilities?.sessionCapabilities?.resume != null
|
||||
) {
|
||||
await this.#whileProcessAlive(
|
||||
this.#connection.resumeSession({
|
||||
sessionId: request.acpSessionId,
|
||||
cwd: this.#options.workspaceDirectory,
|
||||
mcpServers: []
|
||||
mcpServers
|
||||
})
|
||||
)
|
||||
} else {
|
||||
@@ -187,7 +160,7 @@ export class AgentOwnedAcpPrompt {
|
||||
const created = await this.#whileProcessAlive(
|
||||
this.#connection.newSession({
|
||||
cwd: this.#options.workspaceDirectory,
|
||||
mcpServers: []
|
||||
mcpServers
|
||||
})
|
||||
)
|
||||
this.#sessionId = created.sessionId
|
||||
@@ -210,15 +183,24 @@ export class AgentOwnedAcpPrompt {
|
||||
throw new Error('ACP Runtime did not select the prepared model')
|
||||
}
|
||||
}
|
||||
this.#unregisterSession = this.#transport.register(this.#sessionId!, this.#client())
|
||||
this.#initialized = true
|
||||
} else if (
|
||||
request.acpSessionId !== undefined &&
|
||||
request.acpSessionId !== this.#sessionId
|
||||
) {
|
||||
throw new Error('ACP session identity cannot change within a binding')
|
||||
} else if (mcpServers.length > 0 || this.#hasMcpServers) {
|
||||
await this.#whileProcessAlive(this.#connection.resumeSession({
|
||||
sessionId: this.#sessionId!, cwd: this.#options.workspaceDirectory,
|
||||
mcpServers
|
||||
}))
|
||||
}
|
||||
this.#hasMcpServers = mcpServers.length > 0
|
||||
await this.#options.prepareSession?.(this.#sessionId!, request.operationId, workMode)
|
||||
this.#active = {
|
||||
operationId: request.operationId
|
||||
operationId: request.operationId,
|
||||
workMode
|
||||
}
|
||||
this.#acceptQuestions = true
|
||||
const begun = this.#options.transcript.begin({
|
||||
@@ -229,13 +211,14 @@ export class AgentOwnedAcpPrompt {
|
||||
})
|
||||
// Keep this exact Promise alive on Agent. The caller receives only the
|
||||
// durable operation identity and may disconnect immediately.
|
||||
this.#promptPromise = this.#whileProcessAlive(
|
||||
const promptOperation = this.#whileProcessAlive(
|
||||
this.#connection.prompt({
|
||||
sessionId: this.#sessionId,
|
||||
sessionId: this.#sessionId!,
|
||||
prompt: request.prompt
|
||||
})
|
||||
)
|
||||
.then(
|
||||
this.#promptSettled = promptOperation.then(() => undefined, () => undefined)
|
||||
this.#promptPromise = promptOperation.then(
|
||||
async (response) => {
|
||||
const proposed =
|
||||
response.stopReason === 'cancelled'
|
||||
@@ -305,6 +288,17 @@ export class AgentOwnedAcpPrompt {
|
||||
this.#clear()
|
||||
}
|
||||
|
||||
async releaseSession(): Promise<void> {
|
||||
await this.cancel()
|
||||
if (this.#sessionId && !this.#closed) {
|
||||
await this.#connection.closeSession({ sessionId: this.#sessionId })
|
||||
}
|
||||
// Wait for the original native Prompt, not its completion callback, which
|
||||
// may be queued behind the caller's backend control operation.
|
||||
await this.#promptSettled
|
||||
this.close()
|
||||
}
|
||||
|
||||
async respondToQuestion(request: RemoteQuestionResponse): Promise<void> {
|
||||
const pending = this.#questions.get(request.questionId)
|
||||
if (this.#closed || !pending || this.#active?.operationId !== request.operationId ||
|
||||
@@ -384,14 +378,15 @@ export class AgentOwnedAcpPrompt {
|
||||
#handlePermission(
|
||||
request: RequestPermissionRequest
|
||||
): RequestPermissionResponse {
|
||||
const active = this.#requireActive()
|
||||
const selected =
|
||||
this.#options.workMode === 'execute'
|
||||
? request.options.find((option) => option.kind === 'allow_always') ??
|
||||
request.options.find((option) => option.kind === 'allow_once')
|
||||
: request.toolCall.kind === 'read'
|
||||
active.workMode === 'execute'
|
||||
? request.options.find((option) => option.kind === 'allow_once') ??
|
||||
request.options.find((option) => option.kind === 'allow_always')
|
||||
: request.toolCall.kind === 'read' || request.toolCall.kind === 'search'
|
||||
? request.options.find((option) => option.kind === 'allow_once')
|
||||
: request.options.find((option) => option.kind === 'reject_always') ??
|
||||
request.options.find((option) => option.kind === 'reject_once')
|
||||
: request.options.find((option) => option.kind === 'reject_once') ??
|
||||
request.options.find((option) => option.kind === 'reject_always')
|
||||
const response: RequestPermissionResponse =
|
||||
selected === undefined
|
||||
? { outcome: { outcome: 'cancelled' } }
|
||||
@@ -403,7 +398,7 @@ export class AgentOwnedAcpPrompt {
|
||||
}
|
||||
this.#options.transcript.append({
|
||||
bindingId: this.#options.bindingId,
|
||||
operationId: this.#requireActive().operationId,
|
||||
operationId: active.operationId,
|
||||
kind: 'permission-decision',
|
||||
payload: {
|
||||
sessionId: request.sessionId,
|
||||
@@ -434,15 +429,15 @@ export class AgentOwnedAcpPrompt {
|
||||
this.#closed = true
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
this.#unsubscribeOutput()
|
||||
this.#unsubscribeExit()
|
||||
this.#unregisterSession?.()
|
||||
if (!this.#options.transport) this.#transport.dispose()
|
||||
}
|
||||
|
||||
async #whileProcessAlive<T>(operation: Promise<T>): Promise<T> {
|
||||
return await Promise.race([operation, this.#processExit])
|
||||
}
|
||||
|
||||
#requireActive(): NonNullable<AgentOwnedAcpPrompt['#active']> {
|
||||
#requireActive(): { operationId: string; workMode: 'ask' | 'execute' } {
|
||||
if (this.#active === undefined) {
|
||||
throw new Error('ACP notification has no active prompt')
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ async function runModelBridgeHelper(
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'shared-sessions',
|
||||
'opencode-entrypoint'
|
||||
])
|
||||
requireOptions(options, [
|
||||
@@ -212,6 +213,9 @@ async function runModelBridgeHelper(
|
||||
if (workMode !== 'ask' && workMode !== 'execute') {
|
||||
throw new Error('Invalid model bridge work-mode option')
|
||||
}
|
||||
if (options['shared-sessions'] !== undefined && options['shared-sessions'] !== 'true') {
|
||||
throw new Error('Invalid model bridge shared-sessions option')
|
||||
}
|
||||
return await (
|
||||
dependencies.runModelBridgeHelper ??
|
||||
runOpenCodeModelBridgeHelper
|
||||
@@ -221,6 +225,7 @@ async function runModelBridgeHelper(
|
||||
model: options.model!,
|
||||
supportsImageInput: imageInput === 'true',
|
||||
workMode,
|
||||
...(options['shared-sessions'] === 'true' ? { sharedSessions: true } : {}),
|
||||
opencodeEntrypoint: options['opencode-entrypoint']!
|
||||
})
|
||||
}
|
||||
|
||||
@@ -513,7 +513,7 @@ function diagnosticErrorName(error: unknown): string {
|
||||
return error instanceof Error ? 'Error' : 'NonError'
|
||||
}
|
||||
|
||||
const SAFE_DIAGNOSTIC_ERROR_CODES = new Set([
|
||||
const SAFE_DIAGNOSTIC_ERROR_CODES: ReadonlySet<string> = new Set([
|
||||
'EACCES',
|
||||
'EADDRINUSE',
|
||||
'ECONNREFUSED',
|
||||
|
||||
@@ -24,6 +24,32 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('direct Linux stdio Runtime ownership', () => {
|
||||
it('keeps shared stdin available across Session completion without a cumulative prompt quota', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(), profile: profile(), sharedSessions: true,
|
||||
identity: { launchId: 'shared-launch', processId: 'shared-process' },
|
||||
installationId: 'installation-1', registry, platform: 'linux',
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z', maximumInputBytes: 16,
|
||||
spawn: () => { queueMicrotask(() => child.emit('spawn')); return child },
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity: async () => identity()
|
||||
})
|
||||
try {
|
||||
for (let index = 0; index < 8; index++) {
|
||||
owner.beginPrompt({ deadlineAt: '2030-01-01T00:00:00.000Z', maximumInputBytes: 16 })
|
||||
await owner.writeStdin(Uint8Array.from(Buffer.from('12345678')))
|
||||
await owner.completePrompt()
|
||||
}
|
||||
await owner.writeStdin(Uint8Array.from(Buffer.from('cancel-peer')))
|
||||
} finally {
|
||||
child.exitCode = 0
|
||||
child.emit('close', 0, null)
|
||||
registry.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('spawns Ask directly without systemd using detached fixed stdio', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
|
||||
@@ -108,6 +108,7 @@ export type DirectLinuxStdioProcessOwnerOptions = {
|
||||
bridgeDirectory: string
|
||||
socketPath: string
|
||||
policy: ModelBridgePolicy
|
||||
sharedSessions?: boolean
|
||||
}
|
||||
}
|
||||
identity: { launchId: string; processId: string }
|
||||
@@ -115,6 +116,7 @@ export type DirectLinuxStdioProcessOwnerOptions = {
|
||||
registry: RuntimeOwnerRegistry
|
||||
deadlineAt: string
|
||||
maximumInputBytes: number
|
||||
sharedSessions?: boolean
|
||||
signal?: AbortSignal
|
||||
platform?: NodeJS.Platform
|
||||
spawn?: DirectLinuxStdioSpawn
|
||||
@@ -151,6 +153,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
readonly processIdentity: LinuxRuntimeProcessIdentity
|
||||
|
||||
readonly #child: DirectLinuxStdioChild
|
||||
readonly #sharedSessions: boolean
|
||||
readonly #registry: RuntimeOwnerRegistry
|
||||
readonly #now: () => number
|
||||
readonly #readIdentity: typeof readLinuxRuntimeProcessIdentity
|
||||
@@ -194,6 +197,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
listPidNamespaceMembers: typeof listLinuxPidNamespaceMembers
|
||||
sendSignal: typeof process.kill
|
||||
maximumInputBytes: number
|
||||
sharedSessions?: boolean
|
||||
maximumPendingStdinBytes: number
|
||||
maximumStdinWriteBytes: number
|
||||
maximumOutputBytes: number
|
||||
@@ -208,6 +212,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
this.ownerId = input.ownerId
|
||||
this.processIdentity = Object.freeze({ ...input.processIdentity })
|
||||
this.#child = input.child
|
||||
this.#sharedSessions = input.sharedSessions === true
|
||||
this.#registry = input.registry
|
||||
this.#now = input.now
|
||||
this.#readIdentity = input.readProcessIdentity
|
||||
@@ -222,7 +227,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
input.maximumPromptRuntimeMilliseconds
|
||||
this.#maximumOutputQueueChunks = input.maximumOutputQueueChunks
|
||||
this.#maximumListeners = input.maximumListeners
|
||||
this.#scheduleDeadline(input.deadlineAt)
|
||||
if (!this.#sharedSessions) this.#scheduleDeadline(input.deadlineAt)
|
||||
input.child.stdout.on('data', (chunk: Buffer | Uint8Array) => {
|
||||
this.#acceptOutput(chunk)
|
||||
})
|
||||
@@ -245,13 +250,13 @@ export class DirectLinuxStdioProcessOwner {
|
||||
}
|
||||
if (
|
||||
payload.byteLength > this.#maximumStdinWriteBytes ||
|
||||
this.#inputBytes + payload.byteLength > this.#maximumInputBytes ||
|
||||
(!this.#sharedSessions && this.#inputBytes + payload.byteLength > this.#maximumInputBytes) ||
|
||||
this.#pendingInputBytes + payload.byteLength > this.#maximumPendingStdinBytes
|
||||
) {
|
||||
throw capacityError('Runtime stdin quota reached')
|
||||
}
|
||||
const copy = Buffer.from(payload)
|
||||
this.#inputBytes += copy.byteLength
|
||||
if (!this.#sharedSessions) this.#inputBytes += copy.byteLength
|
||||
this.#pendingInputBytes += copy.byteLength
|
||||
const write = this.#writeTail.then(async () => {
|
||||
if (this.#closed || this.#stdinClosed || !this.#promptActive) {
|
||||
@@ -268,6 +273,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
}
|
||||
|
||||
beginPrompt(input: { deadlineAt: string; maximumInputBytes: number }): void {
|
||||
if (this.#sharedSessions && !this.#closed && !this.#stdinClosed) return
|
||||
if (this.#closed || this.#stdinClosed || this.#promptActive) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
this.#promptActive ? 'A Runtime prompt is already active' : 'Runtime process is closed',
|
||||
@@ -293,6 +299,7 @@ export class DirectLinuxStdioProcessOwner {
|
||||
}
|
||||
|
||||
async completePrompt(): Promise<void> {
|
||||
if (this.#sharedSessions) return
|
||||
if (!this.#promptActive) return
|
||||
await this.#writeTail
|
||||
this.#promptActive = false
|
||||
@@ -655,6 +662,7 @@ export async function launchDirectLinuxStdioProcessOwner(
|
||||
options.listPidNamespaceMembers ?? listLinuxPidNamespaceMembers,
|
||||
sendSignal: options.sendSignal ?? process.kill,
|
||||
maximumInputBytes,
|
||||
sharedSessions: options.sharedSessions,
|
||||
maximumPendingStdinBytes,
|
||||
maximumStdinWriteBytes,
|
||||
maximumOutputBytes: manifest.limits.maximumPromptOutputBytes,
|
||||
@@ -975,7 +983,7 @@ function defaultSpawn(
|
||||
args: readonly string[],
|
||||
options: Parameters<DirectLinuxStdioSpawn>[2]
|
||||
): DirectLinuxStdioChild {
|
||||
return nodeSpawn(executable, [...args], options)
|
||||
return nodeSpawn(executable, [...args], { ...options, stdio: [...options.stdio] })
|
||||
}
|
||||
|
||||
async function waitForSpawn(
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentImageToolMcp } from './image-tool-mcp'
|
||||
import { imageOperationSchema } from '../shared/image-generation-contracts'
|
||||
import { AssistantDatabase } from '../main/assistant/assistant-database'
|
||||
import { ImageGenerationService } from '../main/agent/image-generation-service'
|
||||
import { MainImageToolSession } from '../main/remote-agent/main-image-tool-session'
|
||||
import type { RuntimeProtocolBinaryChannel, RuntimeProtocolBinaryFrame } from '../main/remote-agent/protocol-remote-runtime-channel'
|
||||
|
||||
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='
|
||||
vi.mock('electron', () => ({ nativeImage: { createFromBuffer: () => ({ isEmpty: () => false, toPNG: () => Buffer.from(png, 'base64') }) } }))
|
||||
const cleanup: (() => void | Promise<void>)[] = []
|
||||
afterEach(async () => { for (const close of cleanup.splice(0).reverse()) await close() })
|
||||
|
||||
async function setup() {
|
||||
const received: { path: string; body: string; authorization?: string }[] = []
|
||||
let release: (() => void) | undefined
|
||||
let hold = false
|
||||
const provider = createServer((req, res) => {
|
||||
void (async () => {
|
||||
let body = ''
|
||||
for await (const chunk of req) body += chunk
|
||||
received.push({ path: req.url!, body, authorization: req.headers.authorization })
|
||||
if (hold) await new Promise<void>(resolve => { release = resolve })
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify({ data: [{ b64_json: png }] }))
|
||||
})()
|
||||
})
|
||||
await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
|
||||
cleanup.push(() => { release?.(); provider.closeAllConnections(); provider.close() })
|
||||
const address = provider.address() as { port: number }
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
cleanup.push(() => database.close())
|
||||
const context = { conversationId: randomUUID(), messageId: randomUUID(), requestId: randomUUID(), workMode: 'execute' as 'ask' | 'execute' }
|
||||
database.saveLocalConversations([{ header: { id: context.conversationId, title: 'Remote images', updatedAt: 1 },
|
||||
messages: [{ id: context.messageId, role: 'assistant', content: '', createdAt: 1, state: 'complete' }] }])
|
||||
const profile = { id: randomUUID(), name: 'Controlled image provider', modelName: 'controlled-image',
|
||||
protocol: 'openai-images-generations', allowConversationInvocation: true,
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`, authentication: 'api-key' as const, apiKey: 'controlled-main-only' }
|
||||
const service = new ImageGenerationService({ database, getSettings: async () => ({ modelProfiles: [profile], defaultImageModelProfileId: profile.id }) })
|
||||
service.initialize()
|
||||
cleanup.push(() => service.dispose())
|
||||
const frames: Uint8Array[] = []
|
||||
const connect = async (mode: 'ask' | 'execute' = 'execute') => {
|
||||
const binding = service.bind({ ...context, requestId: randomUUID(), workMode: mode })
|
||||
const queue: RuntimeProtocolBinaryFrame[] = []
|
||||
let receiver: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined
|
||||
let rejectReceive: ((error: unknown) => void) | undefined
|
||||
const listeners = new Set<() => void>()
|
||||
let closed = false
|
||||
const channel: RuntimeProtocolBinaryChannel = {
|
||||
channelId: randomUUID(), channelEpoch: '1',
|
||||
send: async payload => { frames.push(payload); adapter.onReply(payload) },
|
||||
receive: async () => queue.shift() ?? await new Promise((resolve, reject) => { receiver = resolve; rejectReceive = reject }),
|
||||
close: () => { if (closed) return; closed = true; rejectReceive?.(new Error('closed')); for (const fn of listeners) fn() },
|
||||
onClose: fn => { listeners.add(fn); return () => { listeners.delete(fn) } }
|
||||
}
|
||||
const wait = new AbortController()
|
||||
const main = new MainImageToolSession(channel, binding, wait.signal)
|
||||
const adapter = new AgentImageToolMcp({ channelId: channel.channelId, channelEpoch: '1', description: await binding.describe() ?? 'Not available' }, async payload => {
|
||||
if (closed) throw new Error('disconnected')
|
||||
frames.push(payload)
|
||||
const frame = { payload, sequence: '1', consume: async () => {} }
|
||||
if (receiver) { const resolve = receiver; receiver = undefined; resolve(frame) } else queue.push(frame)
|
||||
})
|
||||
await adapter.start()
|
||||
const client = new Client({ name: 'remote-image-test', version: '1' })
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(adapter.url!)))
|
||||
cleanup.push(async () => { main.close(); adapter.close(); await client.close() })
|
||||
return { client, adapter, wait, main, binding }
|
||||
}
|
||||
return { received, context, profile, service, database, connect, frames,
|
||||
hold: () => { hold = true }, release: () => release?.() }
|
||||
}
|
||||
|
||||
describe('remote image MCP to Main service', () => {
|
||||
it('generates, edits persisted uploads and history through a controlled HTTP provider', async () => {
|
||||
const h = await setup()
|
||||
const remote = await h.connect()
|
||||
expect((await remote.client.listTools()).tools[0]!.description).toContain(h.profile.id)
|
||||
const call = async (args: Record<string, unknown>) => {
|
||||
const result = await remote.client.callTool({ name: 'generate_image', arguments: args })
|
||||
expect(result.isError).not.toBe(true)
|
||||
return imageOperationSchema.parse(JSON.parse((result.content as { text: string }[])[0]!.text))
|
||||
}
|
||||
const first = await call({ intent: 'create', prompt: 'A blue square' })
|
||||
const uploaded = h.service.persistUploads(h.context, [{ name: 'source.png', mediaType: 'image/png', data: png }])
|
||||
const uploadEdit = await call({ intent: 'edit', prompt: 'Make the uploaded square red', sourceArtifactIds: uploaded })
|
||||
const historyEdit = await call({ intent: 'edit', prompt: 'Make the previous result green', sourceArtifactIds: first.artifactIds })
|
||||
expect([first, uploadEdit, historyEdit].map(op => op.state)).toEqual(['completed', 'completed', 'completed'])
|
||||
expect(h.received.map(call => call.path)).toEqual(['/v1/images/generations', '/v1/images/edits', '/v1/images/edits'])
|
||||
expect(h.received.every(call => call.authorization === 'Bearer controlled-main-only')).toBe(true)
|
||||
expect(h.received[1]!.body).toContain('name="image"')
|
||||
expect(h.database.getArtifact(historyEdit.artifactIds[0]!).content).toContain(png)
|
||||
expect(h.database.getConversation(h.context.conversationId).messages[0]!.imageOperations).toHaveLength(3)
|
||||
const wire = Buffer.concat(h.frames).toString()
|
||||
expect(wire).not.toContain(png)
|
||||
expect(wire).not.toContain('controlled-main-only')
|
||||
expect(wire).not.toContain(h.profile.baseUrl)
|
||||
})
|
||||
|
||||
it.each(['chat cancellation', 'disconnect'])('preserves Main operation after %s without replay', async reason => {
|
||||
const h = await setup()
|
||||
h.hold()
|
||||
const remote = await h.connect()
|
||||
const waiting = remote.client.callTool({ name: 'generate_image', arguments: { intent: 'create', prompt: 'Blue' } }).catch(() => undefined)
|
||||
await vi.waitFor(() => expect(h.received).toHaveLength(1))
|
||||
if (reason === 'chat cancellation') remote.wait.abort()
|
||||
else remote.main.close()
|
||||
remote.adapter.close()
|
||||
h.release()
|
||||
await vi.waitFor(() => expect(h.database.getConversation(h.context.conversationId).messages[0]!.imageOperations?.[0]?.state).toBe('completed'))
|
||||
await waiting
|
||||
expect(h.received).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects Ask calls at the desktop binding and refreshes a new prompt description', async () => {
|
||||
const h = await setup()
|
||||
const ask = await h.connect('ask')
|
||||
const denied = await ask.client.callTool({ name: 'generate_image', arguments: { intent: 'create', prompt: 'Blue' } })
|
||||
expect(denied.isError).toBe(true)
|
||||
expect(h.received).toHaveLength(0)
|
||||
h.profile.name = 'Changed next prompt'
|
||||
const execute = await h.connect()
|
||||
expect((await execute.client.listTools()).tools[0]!.description).toContain('Changed next prompt')
|
||||
})
|
||||
|
||||
it('returns unknown delivery without sending a second request for the same MCP call', async () => {
|
||||
const send = vi.fn(async () => { throw new Error('connection lost after send') })
|
||||
const adapter = new AgentImageToolMcp({ channelId: randomUUID(), channelEpoch: '1', description: 'Images' }, send)
|
||||
await adapter.start()
|
||||
cleanup.push(() => adapter.close())
|
||||
const request = { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'generate_image', arguments: { intent: 'create', prompt: 'Blue' } } }
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const response = await fetch(adapter.url!, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: JSON.stringify(request) })
|
||||
const result = await response.json()
|
||||
expect(JSON.stringify(result)).toContain('unknown')
|
||||
}
|
||||
expect(send).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod'
|
||||
import { imageToolInputSchema, imageToolName } from '../shared/image-generation-contracts'
|
||||
import {
|
||||
decodeRemoteImageToolMessage, encodeRemoteImageToolMessage,
|
||||
remoteImageToolMaximumBytes, remoteImageToolReplySchema, type remoteImageToolSchema
|
||||
} from '../shared/remote-image-tool-contracts'
|
||||
|
||||
/** One prompt's local MCP adapter. It never holds provider credentials or image bytes. */
|
||||
export class AgentImageToolMcp {
|
||||
private readonly calls = new Map<string, { input: string; result: Promise<CallToolResult> }>()
|
||||
private readonly pending = new Map<string, {
|
||||
resolve: (value: CallToolResult) => void
|
||||
reject: (error: Error) => void
|
||||
}>()
|
||||
private server?: Server
|
||||
private closed = false
|
||||
url?: string
|
||||
|
||||
constructor(
|
||||
readonly descriptor: z.infer<typeof remoteImageToolSchema>,
|
||||
private readonly send: (payload: Uint8Array) => Promise<void>
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const path = `/${randomUUID()}/mcp`
|
||||
const schema = z.toJSONSchema(imageToolInputSchema, { target: 'draft-7', io: 'input' })
|
||||
Reflect.deleteProperty(schema, '$schema')
|
||||
this.server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
if (this.closed || request.url !== path) { response.writeHead(404).end(); return }
|
||||
if (request.method !== 'POST') { response.writeHead(405).end(); return }
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
for await (const chunk of request) {
|
||||
const bytes = Buffer.from(chunk)
|
||||
size += bytes.length
|
||||
if (size > remoteImageToolMaximumBytes) { response.writeHead(413).end(); return }
|
||||
chunks.push(bytes)
|
||||
}
|
||||
const body: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
const mcp = new McpServer({ name: 'goodbuddy-image', version: '1' }, { capabilities: { tools: {} } })
|
||||
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{
|
||||
name: imageToolName, description: this.descriptor.description,
|
||||
inputSchema: schema as { type: 'object' }
|
||||
}] }))
|
||||
mcp.setRequestHandler(CallToolRequestSchema, async (call, extra) => {
|
||||
if (call.params.name !== imageToolName) throw new Error('Unknown image tool')
|
||||
const input = imageToolInputSchema.parse(call.params.arguments)
|
||||
// Repeated delivery of an MCP request returns the same outcome, including unknown delivery.
|
||||
const key = `${typeof extra.requestId}:${extra.requestId}`
|
||||
let existing = this.calls.get(key)
|
||||
if (existing && existing.input !== JSON.stringify(input)) throw new Error('Image tool request ID reused with different input')
|
||||
if (!existing) {
|
||||
const result = this.call(input).catch((error: unknown) => ({
|
||||
isError: true,
|
||||
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : 'Image tool delivery failed; do not retry automatically' }]
|
||||
}))
|
||||
existing = { input: JSON.stringify(input), result }
|
||||
this.calls.set(key, existing)
|
||||
}
|
||||
return await existing.result
|
||||
})
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
|
||||
response.once('close', () => { void mcp.close().catch(() => undefined) })
|
||||
await mcp.connect(transport)
|
||||
await transport.handleRequest(request, response, body)
|
||||
})().catch(() => {
|
||||
if (!response.headersSent) response.writeHead(400)
|
||||
response.end()
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.once('error', reject)
|
||||
this.server!.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = this.server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Image MCP endpoint did not start')
|
||||
this.url = `http://127.0.0.1:${address.port}${path}`
|
||||
}
|
||||
|
||||
onReply(payload: Uint8Array): void {
|
||||
const reply = remoteImageToolReplySchema.parse(decodeRemoteImageToolMessage(payload))
|
||||
const pending = this.pending.get(reply.callId)
|
||||
if (!pending) return
|
||||
if (reply.error || !reply.result) pending.reject(new Error(reply.error ?? 'Image tool response has no result'))
|
||||
else pending.resolve({ content: [{ type: 'text', text: JSON.stringify(reply.result) }] })
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
for (const pending of this.pending.values()) pending.reject(new Error('Desktop image tool disconnected; delivery may be unknown. Do not retry automatically.'))
|
||||
this.pending.clear()
|
||||
this.server?.closeAllConnections()
|
||||
this.server?.close()
|
||||
}
|
||||
|
||||
private async call(input: z.infer<typeof imageToolInputSchema>): Promise<CallToolResult> {
|
||||
if (this.closed) throw new Error('Image tool prompt has ended')
|
||||
const callId = randomUUID()
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
const result = new Promise<CallToolResult>((resolve, reject) => {
|
||||
this.pending.set(callId, { resolve, reject })
|
||||
timer = setTimeout(() => reject(new Error('Image tool wait timed out; delivery may be unknown. Do not retry automatically.')), 5 * 60_000)
|
||||
timer.unref()
|
||||
})
|
||||
// Register the wait before sending; send failures must not cause a retry.
|
||||
void this.send(encodeRemoteImageToolMessage({ callId, input })).catch(() => {
|
||||
this.pending.get(callId)?.reject(new Error('Image tool delivery may be unknown. Do not retry automatically.'))
|
||||
})
|
||||
try { return await result } finally {
|
||||
clearTimeout(timer)
|
||||
this.pending.delete(callId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,24 +132,27 @@ const FORWARDED_HEADER_NAMES = [
|
||||
|
||||
export class ModelBridgeLoopbackProxy {
|
||||
readonly #exchange: ModelBridgeExchange
|
||||
readonly #sharedSessions: boolean
|
||||
readonly #sessionRoutes = new Map<string, { operationId: string; socketPath: string; workMode: 'ask' | 'execute'; imageToolName?: string }>()
|
||||
readonly #routeToken: string
|
||||
readonly #maximumConnections: number
|
||||
readonly #requestTimeoutMs: number
|
||||
readonly #sockets = new Set<Socket>()
|
||||
readonly #dispatchWaiters: DispatchWaiter[] = []
|
||||
readonly #dispatches = new Map<string, DispatchWaiter[]>()
|
||||
#server?: Server
|
||||
#origin?: string
|
||||
#dispatchBusy = false
|
||||
#closing = false
|
||||
#closePromise?: Promise<void>
|
||||
|
||||
constructor(options: {
|
||||
exchange: ModelBridgeExchange
|
||||
sharedSessions?: boolean
|
||||
routeToken?: string
|
||||
maximumConnections?: number
|
||||
requestTimeoutMs?: number
|
||||
}) {
|
||||
this.#exchange = options.exchange
|
||||
this.#sharedSessions = options.sharedSessions === true
|
||||
this.#routeToken = parseModelBridgeRouteToken(
|
||||
options.routeToken ??
|
||||
randomBytes(32).toString('base64url')
|
||||
@@ -259,6 +262,7 @@ export class ModelBridgeLoopbackProxy {
|
||||
)
|
||||
} finally {
|
||||
this.#sockets.clear()
|
||||
this.#sessionRoutes.clear()
|
||||
this.#closing = false
|
||||
}
|
||||
}
|
||||
@@ -280,6 +284,9 @@ export class ModelBridgeLoopbackProxy {
|
||||
let responseFlushed = false
|
||||
let deliveryAcknowledged = false
|
||||
let dispatchHeld = false
|
||||
let dispatchKey = ''
|
||||
let sessionId: string | undefined
|
||||
let sessionRoute: { operationId: string; socketPath: string } | undefined
|
||||
const onAborted = (): void => {
|
||||
cancellation.abort(
|
||||
new ModelBridgeBrokerError('request-cancelled')
|
||||
@@ -303,14 +310,59 @@ export class ModelBridgeLoopbackProxy {
|
||||
timeout?.unref?.()
|
||||
|
||||
try {
|
||||
if (this.#sharedSessions && incoming.url?.startsWith(`/${this.#routeToken}/session`)) {
|
||||
const url = new URL(incoming.url, 'http://localhost')
|
||||
if (url.pathname !== `/${this.#routeToken}/session`) throw new HttpRequestError(404, 'path-not-allowed')
|
||||
if (incoming.method === 'GET') {
|
||||
const route = this.#sessionRoutes.get(url.searchParams.get('sessionId') ?? '')
|
||||
if (!route) throw new HttpRequestError(409, 'session-inactive')
|
||||
outgoing.setHeader('content-type', 'application/json')
|
||||
outgoing.end(JSON.stringify({ operationId: route.operationId, workMode: route.workMode, imageToolName: route.imageToolName }))
|
||||
return
|
||||
}
|
||||
if (incoming.method !== 'POST') throw new HttpRequestError(405, 'method-not-allowed')
|
||||
const value = JSON.parse((await readBoundedBody(incoming)).toString('utf8')) as {
|
||||
sessionId?: string; operationId?: string; socketPath?: string; release?: boolean; workMode?: 'ask' | 'execute'; imageToolName?: string
|
||||
}
|
||||
if (
|
||||
typeof value.sessionId !== 'string' || value.sessionId.length > 128 ||
|
||||
typeof value.operationId !== 'string' || value.operationId.length > 128
|
||||
) throw new HttpRequestError(400, 'request-invalid')
|
||||
if (value.release) {
|
||||
if (this.#sessionRoutes.get(value.sessionId)?.operationId === value.operationId) {
|
||||
this.#sessionRoutes.delete(value.sessionId)
|
||||
}
|
||||
} else {
|
||||
if (value.workMode !== 'ask' && value.workMode !== 'execute') throw new HttpRequestError(400, 'request-invalid')
|
||||
const socketPath = normalizedAbsolutePath(value.socketPath ?? '', 'Model bridge socket')
|
||||
if (value.imageToolName !== undefined && !/^goodbuddy_image_[a-f0-9]{24}$/.test(value.imageToolName)) throw new HttpRequestError(400, 'request-invalid')
|
||||
this.#sessionRoutes.set(value.sessionId, { operationId: value.operationId, socketPath, workMode: value.workMode, imageToolName: value.imageToolName })
|
||||
}
|
||||
outgoing.end('{}')
|
||||
return
|
||||
}
|
||||
const request = await parseHttpRequest(
|
||||
incoming,
|
||||
this.#routeToken
|
||||
)
|
||||
await this.#acquireDispatch(cancellation.signal)
|
||||
let exchange = this.#exchange
|
||||
if (this.#sharedSessions) {
|
||||
const sessionHeader = incoming.headers['x-goodbuddy-session']
|
||||
const operationId = incoming.headers['x-goodbuddy-operation']
|
||||
sessionId = typeof sessionHeader === 'string' ? sessionHeader : undefined
|
||||
const route = sessionId ? this.#sessionRoutes.get(sessionId) : undefined
|
||||
if (!route || route.operationId !== operationId) throw new HttpRequestError(409, 'session-inactive')
|
||||
sessionRoute = route
|
||||
dispatchKey = `${sessionId}\0${operationId}`
|
||||
exchange = createUnixModelBridgeExchange({ socketPath: route.socketPath })
|
||||
}
|
||||
await this.#acquireDispatch(dispatchKey, cancellation.signal)
|
||||
dispatchHeld = true
|
||||
if (sessionId && this.#sessionRoutes.get(sessionId) !== sessionRoute) {
|
||||
throw new HttpRequestError(409, 'session-inactive')
|
||||
}
|
||||
dispatched = normalizeExchangeResult(
|
||||
await raceWithAbort(this.#exchange(request, {
|
||||
await raceWithAbort(exchange(request, {
|
||||
requestId: `model-${randomUUID()}`,
|
||||
signal: cancellation.signal
|
||||
}), cancellation.signal)
|
||||
@@ -351,34 +403,36 @@ export class ModelBridgeLoopbackProxy {
|
||||
incoming.off('aborted', onAborted)
|
||||
outgoing.off('close', onResponseClose)
|
||||
if (dispatchHeld) {
|
||||
this.#releaseDispatch()
|
||||
this.#releaseDispatch(dispatchKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #acquireDispatch(signal: AbortSignal): Promise<void> {
|
||||
async #acquireDispatch(key: string, signal: AbortSignal): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
if (!this.#dispatchBusy) {
|
||||
this.#dispatchBusy = true
|
||||
const queue = this.#dispatches.get(key)
|
||||
if (!queue) {
|
||||
this.#dispatches.set(key, [])
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const waiter: DispatchWaiter = { resolve, reject, signal }
|
||||
waiter.abort = (): void => {
|
||||
const index = this.#dispatchWaiters.indexOf(waiter)
|
||||
const index = queue.indexOf(waiter)
|
||||
if (index >= 0) {
|
||||
this.#dispatchWaiters.splice(index, 1)
|
||||
queue.splice(index, 1)
|
||||
}
|
||||
reject(signal.reason)
|
||||
}
|
||||
signal.addEventListener('abort', waiter.abort, { once: true })
|
||||
this.#dispatchWaiters.push(waiter)
|
||||
queue.push(waiter)
|
||||
})
|
||||
}
|
||||
|
||||
#releaseDispatch(): void {
|
||||
while (this.#dispatchWaiters.length > 0) {
|
||||
const waiter = this.#dispatchWaiters.shift()!
|
||||
#releaseDispatch(key: string): void {
|
||||
const queue = this.#dispatches.get(key)
|
||||
while (queue && queue.length > 0) {
|
||||
const waiter = queue.shift()!
|
||||
if (waiter.abort !== undefined) {
|
||||
waiter.signal.removeEventListener('abort', waiter.abort)
|
||||
}
|
||||
@@ -389,7 +443,7 @@ export class ModelBridgeLoopbackProxy {
|
||||
waiter.resolve()
|
||||
return
|
||||
}
|
||||
this.#dispatchBusy = false
|
||||
this.#dispatches.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +453,7 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
model: string
|
||||
supportsImageInput: boolean
|
||||
workMode: 'ask' | 'execute'
|
||||
sharedSessions?: boolean
|
||||
opencodeEntrypoint: string
|
||||
environment?: Readonly<NodeJS.ProcessEnv>
|
||||
spawn?: ModelBridgeHelperSpawn
|
||||
@@ -412,19 +467,19 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
'OpenCode entrypoint'
|
||||
)
|
||||
const exchange = createUnixModelBridgeExchange({ socketPath })
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange, sharedSessions: options.sharedSessions })
|
||||
const origin = await proxy.listen()
|
||||
let pluginDirectory: string | undefined
|
||||
try {
|
||||
pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-'))
|
||||
const pluginPath = join(pluginDirectory, 'subagent.mjs')
|
||||
await writeFile(pluginPath, openCodeSubagentPluginSource(), 'utf8')
|
||||
await writeFile(pluginPath, openCodeSubagentPluginSource(options.sharedSessions ? origin : undefined), 'utf8')
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: options.protocol,
|
||||
model: options.model,
|
||||
loopbackOrigin: origin,
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
workMode: options.workMode
|
||||
workMode: options.sharedSessions ? 'ask' : options.workMode
|
||||
})
|
||||
const environment = credentialFreeHelperEnvironment(
|
||||
options.environment ?? process.env,
|
||||
@@ -433,6 +488,11 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
plugin: [pathToFileURL(pluginPath).href]
|
||||
})
|
||||
)
|
||||
if (options.sharedSessions) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0', method: 'goodbuddy/modelBridgeReady', params: { origin }
|
||||
}) + '\n')
|
||||
}
|
||||
const child = (options.spawn ?? defaultHelperSpawn)(
|
||||
opencodeEntrypoint,
|
||||
['acp'],
|
||||
|
||||
@@ -615,6 +615,50 @@ describe('model bridge loopback helper', () => {
|
||||
describe('model bridge Unix broker', () => {
|
||||
const runOnUnix = process.platform === 'win32' ? it.skip : it
|
||||
|
||||
runOnUnix('routes shared Sessions concurrently and rejects queued work after its operation ends', async () => {
|
||||
let unblock!: () => void
|
||||
const blocked = new Promise<void>(resolve => { unblock = resolve })
|
||||
const dispatchOne = vi.fn(async () => { await blocked; return validResponse })
|
||||
const dispatchTwo = vi.fn(async () => validResponse)
|
||||
const brokers = [dispatchOne, dispatchTwo].map(dispatch => new ModelBridgeBrokerServer({
|
||||
scratchDirectory: privateTemporaryDirectory(), dispatch
|
||||
}))
|
||||
const fallback = vi.fn(async () => validResponse)
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange: fallback, sharedSessions: true })
|
||||
await Promise.all(brokers.map(broker => broker.listen()))
|
||||
const origin = await proxy.listen()
|
||||
const route = (sessionId: string, operationId: string, index: number) => fetch(`${origin}/session`, {
|
||||
method: 'POST', body: JSON.stringify({ sessionId, operationId, socketPath: brokers[index]!.socketPath, workMode: 'execute' })
|
||||
})
|
||||
const prompt = (sessionId: string, operationId: string) => fetch(`${origin}/v1/messages`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', 'x-goodbuddy-session': sessionId, 'x-goodbuddy-operation': operationId },
|
||||
body: '{"model":"fixture"}'
|
||||
})
|
||||
try {
|
||||
expect((await route('one', 'operation-1', 0)).ok).toBe(true)
|
||||
expect((await route('two', 'operation-2', 1)).ok).toBe(true)
|
||||
const first = prompt('one', 'operation-1')
|
||||
await vi.waitFor(() => expect(dispatchOne).toHaveBeenCalledOnce())
|
||||
const queued = prompt('one', 'operation-1')
|
||||
expect((await prompt('two', 'operation-2')).status).toBe(201)
|
||||
await route('one', 'next-operation', 0)
|
||||
unblock()
|
||||
expect((await first).status).toBe(201)
|
||||
expect((await queued).status).toBe(409)
|
||||
expect((await prompt('one', 'operation-1')).status).toBe(409)
|
||||
expect((await prompt('one', 'next-operation')).status).toBe(201)
|
||||
expect(dispatchOne).toHaveBeenCalledTimes(2)
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
for (const dispatch of [dispatchOne, dispatchTwo]) {
|
||||
expect(dispatch.mock.calls[0]?.[0]).not.toHaveProperty('headers.x-goodbuddy-session')
|
||||
}
|
||||
} finally {
|
||||
unblock()
|
||||
await proxy.close()
|
||||
await Promise.all(brokers.map(broker => broker.close()))
|
||||
}
|
||||
})
|
||||
|
||||
it('allows a delayed connection with the default unlimited request timeout', async () => {
|
||||
const socket = new Duplex({
|
||||
read() {},
|
||||
|
||||
@@ -41,6 +41,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
bridgeDirectory: string;
|
||||
socketPath: string;
|
||||
policy: ModelBridgePolicy;
|
||||
sharedSessions?: boolean;
|
||||
};
|
||||
}): OpenCodeLaunchProfile {
|
||||
const manifest = remoteRuntimeBundleManifestSchema.parse(input.manifest);
|
||||
@@ -90,6 +91,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
"Model bridge socket",
|
||||
),
|
||||
policy: modelBridgePolicySchema.parse(input.modelBridge.policy),
|
||||
sharedSessions: input.modelBridge.sharedSessions === true,
|
||||
};
|
||||
if (
|
||||
modelBridge !== undefined &&
|
||||
@@ -132,6 +134,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
modelBridge.policy.supportsImageInput ? "true" : "false",
|
||||
"--work-mode",
|
||||
input.workMode,
|
||||
...(modelBridge.sharedSessions ? ["--shared-sessions", "true"] : []),
|
||||
"--opencode-entrypoint",
|
||||
executablePath,
|
||||
],
|
||||
|
||||
@@ -5,6 +5,92 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
|
||||
describe('OpenCode ACP child event plugin', () => {
|
||||
it('hides foreign image tools and removes the current image tool in Ask on a reused Session', async () => {
|
||||
let mode = 'execute'
|
||||
const imageToolName = `goodbuddy_image_${'a'.repeat(24)}`
|
||||
const patch = vi.fn()
|
||||
const factory = runInNewContext(
|
||||
`(${openCodeSubagentPluginSource('http://127.0.0.1:12345/fixture').replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
|
||||
{ process: { stdout: { write: vi.fn() } }, createServer, randomBytes,
|
||||
fetch: async () => ({ ok: true, json: async () => ({ operationId: mode, workMode: mode, imageToolName }) }) }
|
||||
)
|
||||
const hooks = await factory({ client: {
|
||||
_client: { get: vi.fn(), post: vi.fn(), patch },
|
||||
session: { get: async () => ({ data: { id: 'root' } }) }
|
||||
} })
|
||||
try {
|
||||
for (mode of ['execute', 'ask', 'execute']) {
|
||||
await hooks.event({ event: { type: 'session.status', properties: { sessionID: 'root', status: { type: 'idle' } } } })
|
||||
await hooks['chat.message']({ sessionID: 'root' }, { message: { id: mode } })
|
||||
const rules = patch.mock.calls.at(-1)![0].body.permission
|
||||
expect(rules).toContainEqual({ permission: 'goodbuddy_image_*', pattern: '*', action: 'deny' })
|
||||
expect(rules.some((rule: { permission: string; action: string }) => rule.permission === `${imageToolName}_*` && rule.action === 'allow')).toBe(mode === 'execute')
|
||||
}
|
||||
} finally { await hooks.dispose() }
|
||||
})
|
||||
it('attributes child and compaction model rounds to the frozen root operation', async () => {
|
||||
const operations = new Map([['one', 'operation-1'], ['two', 'operation-2']])
|
||||
const fetchRoute = vi.fn(async (url: string) => {
|
||||
const session = new URL(url).searchParams.get('sessionId')!
|
||||
return { ok: operations.has(session), json: async () => ({ operationId: operations.get(session), workMode: 'execute' }) }
|
||||
})
|
||||
const factory = runInNewContext(
|
||||
`(${openCodeSubagentPluginSource('http://127.0.0.1:12345/fixture').replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
|
||||
{ process: { stdout: { write: vi.fn() } }, createServer, randomBytes, fetch: fetchRoute }
|
||||
)
|
||||
const patch = vi.fn()
|
||||
const hooks = await factory({ client: {
|
||||
_client: { get: vi.fn(), post: vi.fn(), patch },
|
||||
session: { get: async ({ path }: { path: { id: string } }) => ({
|
||||
data: {
|
||||
id: path.id, parentID: path.id === 'child' ? 'one' : undefined,
|
||||
permission: path.id === 'child' ? [{ permission: 'task', pattern: '*', action: 'deny' }] : undefined
|
||||
}
|
||||
}) }
|
||||
} })
|
||||
const headers = async (sessionID: string, id: string) => {
|
||||
const output = { headers: {} }
|
||||
await hooks['chat.headers']({ sessionID, message: { id } }, output)
|
||||
return output.headers
|
||||
}
|
||||
try {
|
||||
await hooks['chat.message']({ sessionID: 'one' }, { message: { id: 'first' } })
|
||||
expect(patch).toHaveBeenCalledWith({
|
||||
url: '/session/one', body: { permission: [
|
||||
{ permission: '*', pattern: '*', action: 'allow' },
|
||||
{ permission: 'goodbuddy_image_*', pattern: '*', action: 'deny' }
|
||||
] }, throwOnError: true
|
||||
})
|
||||
expect(await headers('child', 'child-message')).toEqual({
|
||||
'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'operation-1'
|
||||
})
|
||||
await hooks['chat.message']({ sessionID: 'child' }, { message: { id: 'child-message' } })
|
||||
expect(patch).toHaveBeenLastCalledWith({
|
||||
url: '/session/child', body: { permission: [
|
||||
{ permission: '*', pattern: '*', action: 'allow' },
|
||||
{ permission: 'task', pattern: '*', action: 'deny' },
|
||||
{ permission: 'goodbuddy_image_*', pattern: '*', action: 'deny' }
|
||||
] }, throwOnError: true
|
||||
})
|
||||
expect(await headers('two', 'compact')).toEqual({
|
||||
'x-goodbuddy-session': 'two', 'x-goodbuddy-operation': 'operation-2'
|
||||
})
|
||||
operations.set('one', 'next-operation')
|
||||
expect(await headers('one', 'first')).toEqual({
|
||||
'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'operation-1'
|
||||
})
|
||||
await hooks.event({ event: { type: 'session.status', properties: { sessionID: 'one', status: { type: 'idle' } } } })
|
||||
expect(await headers('one', 'next-message')).toEqual({
|
||||
'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'next-operation'
|
||||
})
|
||||
operations.delete('two')
|
||||
await expect(headers('two', 'stale-new-message')).rejects.toThrow('no longer active')
|
||||
expect(await headers('two', 'compact')).toEqual({
|
||||
'x-goodbuddy-session': 'two', 'x-goodbuddy-operation': 'operation-2'
|
||||
})
|
||||
} finally { await hooks.dispose() }
|
||||
})
|
||||
|
||||
it('authenticates native replies, checks live ownership, rejects stale events and closes its endpoint', async () => {
|
||||
const write = vi.fn()
|
||||
const question = { id: 'q', sessionID: 'grandchild', questions: [
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* OpenCode's ACP adapter omits native questions and child sessions. Native
|
||||
* events use ACP metadata; question replies use a process-local capability.
|
||||
*/
|
||||
export function openCodeSubagentPluginSource(): string {
|
||||
export function openCodeSubagentPluginSource(modelBridgeOrigin?: string): string {
|
||||
return `import { createServer } from 'node:http'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
export default (input) => (${plugin.toString()})(input, { createServer, randomBytes })`
|
||||
export default (input) => (${plugin.toString()})(input, { createServer, randomBytes, modelBridgeOrigin: ${JSON.stringify(modelBridgeOrigin)} })`
|
||||
}
|
||||
|
||||
async function plugin(input?: {
|
||||
@@ -13,17 +13,41 @@ async function plugin(input?: {
|
||||
_client: {
|
||||
get(input: { url: string; throwOnError: true }): Promise<{ data: unknown }>
|
||||
post(input: { url: string; body?: unknown; throwOnError: true }): Promise<unknown>
|
||||
patch(input: { url: string; body: unknown; throwOnError: true }): Promise<unknown>
|
||||
}
|
||||
session: { get(input: { path: { id: string }; throwOnError: true }): Promise<{
|
||||
data: { id: string; parentID?: string }
|
||||
data: { id: string; parentID?: string; permission?: Array<{ permission: string; pattern: string; action: string }> }
|
||||
}> }
|
||||
}
|
||||
}, modules?: {
|
||||
createServer: typeof import('node:http').createServer
|
||||
randomBytes: typeof import('node:crypto').randomBytes
|
||||
modelBridgeOrigin?: string
|
||||
}) {
|
||||
const tasks = new Map<string, { sessionId: string; callId: string }>()
|
||||
const pending = new Map<string, { sessionId: string; root: string }>()
|
||||
const modelMessages = new Map<string, { sessionId: string; operationId: string; workMode: 'ask' | 'execute'; imageToolName?: string }>()
|
||||
const modelRoute = async (sessionId: string, messageId: string) => {
|
||||
const key = `${sessionId}\0${messageId}`
|
||||
const previous = modelMessages.get(key)
|
||||
if (previous) return previous
|
||||
if (!input || !modules?.modelBridgeOrigin) return undefined
|
||||
let root = sessionId
|
||||
const seen = new Set<string>()
|
||||
for (;;) {
|
||||
if (seen.has(root)) throw new Error('OpenCode Session parent cycle')
|
||||
seen.add(root)
|
||||
const session = await input.client.session.get({ path: { id: root }, throwOnError: true })
|
||||
if (!session.data.parentID) break
|
||||
root = session.data.parentID
|
||||
}
|
||||
const response = await fetch(`${modules.modelBridgeOrigin}/session?sessionId=${encodeURIComponent(root)}`)
|
||||
if (!response.ok) throw new Error('GoodBuddy model operation is no longer active')
|
||||
const { operationId, workMode, imageToolName } = await response.json() as { operationId: string; workMode: 'ask' | 'execute'; imageToolName?: string }
|
||||
const route = { sessionId: root, operationId, workMode, imageToolName }
|
||||
modelMessages.set(key, route)
|
||||
return route
|
||||
}
|
||||
let endpoint: string | undefined
|
||||
let closed = false
|
||||
let closeServer: (() => void) | undefined
|
||||
@@ -82,8 +106,48 @@ async function plugin(input?: {
|
||||
closed = true
|
||||
pending.clear()
|
||||
tasks.clear()
|
||||
modelMessages.clear()
|
||||
closeServer?.()
|
||||
},
|
||||
'chat.message': async (
|
||||
request: { sessionID: string },
|
||||
output: { message: { id: string } }
|
||||
) => {
|
||||
const route = await modelRoute(request.sessionID, output.message.id)
|
||||
if (!route) return
|
||||
// Native ACP only forwards permissions for its registered root Sessions.
|
||||
// Use native Session rules so child tools use Execute and Ask's
|
||||
// unavailable tools are omitted instead of interrupting the prompt.
|
||||
const permission = route.workMode === 'execute'
|
||||
? [{ permission: '*', pattern: '*', action: 'allow' }]
|
||||
: [
|
||||
{ permission: '*', pattern: '*', action: 'deny' },
|
||||
...['read', 'glob', 'grep', 'list', 'lsp', 'webfetch', 'websearch', 'codesearch', 'question', 'external_directory']
|
||||
.map(permission => ({ permission, pattern: '*', action: 'allow' }))
|
||||
]
|
||||
if (route.sessionId !== request.sessionID) {
|
||||
const child = await input!.client.session.get({ path: { id: request.sessionID }, throwOnError: true })
|
||||
// OpenCode copies parent denies, not parent allows, into children.
|
||||
// Keep its explicit subagent restrictions after the request rules.
|
||||
permission.push(...(child.data.permission ?? []).filter(rule => rule.permission !== '*'))
|
||||
}
|
||||
permission.push({ permission: 'goodbuddy_image_*', pattern: '*', action: 'deny' })
|
||||
if (route.workMode === 'execute' && route.imageToolName) {
|
||||
permission.push({ permission: `${route.imageToolName}_*`, pattern: '*', action: 'allow' })
|
||||
}
|
||||
await input!.client._client.patch({
|
||||
url: `/session/${encodeURIComponent(request.sessionID)}`, body: { permission }, throwOnError: true
|
||||
})
|
||||
},
|
||||
'chat.headers': async (
|
||||
request: { sessionID: string; message: { id: string } },
|
||||
output: { headers: Record<string, string> }
|
||||
) => {
|
||||
const route = await modelRoute(request.sessionID, request.message.id)
|
||||
if (!route) return
|
||||
output.headers['x-goodbuddy-session'] = route.sessionId
|
||||
output.headers['x-goodbuddy-operation'] = route.operationId
|
||||
},
|
||||
event: async ({ event }: { event: {
|
||||
type: string
|
||||
properties: {
|
||||
@@ -105,6 +169,9 @@ async function plugin(input?: {
|
||||
const properties = event.properties
|
||||
if (closed) return
|
||||
if (event.type === 'session.status' && properties.status?.type === 'idle' && properties.sessionID) {
|
||||
for (const [key, route] of modelMessages) {
|
||||
if (route.sessionId === properties.sessionID) modelMessages.delete(key)
|
||||
}
|
||||
for (const [id, question] of pending) {
|
||||
if (question.root !== properties.sessionID && question.sessionId !== properties.sessionID) continue
|
||||
pending.delete(id)
|
||||
|
||||
@@ -74,7 +74,7 @@ it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as c
|
||||
controllerId: 'controller', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
|
||||
let complete = false
|
||||
const owner = new AgentOwnedAcpPrompt({ bindingId: 'binding', controllerId: 'controller',
|
||||
workspaceDirectory: root, workMode: 'execute', transcript,
|
||||
workspaceDirectory: root, transcript,
|
||||
process: {
|
||||
writeStdin: async (data: Uint8Array) => { child.stdin.write(data) },
|
||||
subscribeOutput: (listener: (event: { stream: string; data: Uint8Array }) => void) => {
|
||||
@@ -88,7 +88,7 @@ it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as c
|
||||
})
|
||||
try {
|
||||
await owner.start({ bindingId: 'binding', operationId: 'operation', requestId: 'operation',
|
||||
prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] })
|
||||
prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] }, 'execute')
|
||||
let questionId: string | undefined
|
||||
await expect.poll(async () => {
|
||||
const page = transcript.page({ bindingId: 'binding', operationId: 'operation',
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import { decodeRemoteImageToolMessage, encodeRemoteImageToolMessage, remoteImageToolCallSchema } from '../shared/remote-image-tool-contracts'
|
||||
import type { AgentFrame } from '../shared/agent-protocol'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
type AcpJournalCursor,
|
||||
type RemotePromptOperationPreparation,
|
||||
@@ -7,7 +14,7 @@ import {
|
||||
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
|
||||
import type { ControllerLease } from './controller-registry'
|
||||
import { AgentModelGatewayError } from './agent-model-gateway'
|
||||
import { EventJournalCapacityError } from './event-journal'
|
||||
import { EventJournal, EventJournalCapacityError } from './event-journal'
|
||||
import type { ProtocolMethodContext } from './protocol-server'
|
||||
import type {
|
||||
ModelBridgeBrokerDispatch,
|
||||
@@ -280,6 +287,8 @@ function harness(input: {
|
||||
uniqueProcesses?: boolean
|
||||
outputGate?: Promise<void>
|
||||
agentOwned?: boolean
|
||||
shareOwnedProcesses?: boolean
|
||||
journal?: RuntimeAcpJournal
|
||||
modelGateway?: {
|
||||
dispatch: ReturnType<typeof vi.fn>
|
||||
finalizePrompt: ReturnType<typeof vi.fn>
|
||||
@@ -304,7 +313,7 @@ function harness(input: {
|
||||
throw new Error('broker close failed')
|
||||
}
|
||||
})
|
||||
const blobSink = vi.fn(async () => undefined)
|
||||
const blobSink = vi.fn<(frame: AgentFrame) => Promise<void>>(async () => undefined)
|
||||
let bridgeDispatch: ModelBridgeBrokerDispatch | undefined
|
||||
const launchModelBridges: unknown[] = []
|
||||
const resolved = resolvedBundle()
|
||||
@@ -319,7 +328,9 @@ function harness(input: {
|
||||
}
|
||||
: undefined
|
||||
const backend = new RuntimeAcpBackend({
|
||||
journal,
|
||||
shareOwnedProcesses: input.shareOwnedProcesses,
|
||||
sharedProcessIdleMs: 20,
|
||||
journal: input.journal ?? journal,
|
||||
resolveRuntimeBundle: vi.fn(async () => resolved),
|
||||
loadRegisteredRuntimeBundle: vi.fn(async () => verifiedBundle()),
|
||||
resolveWorkspace: vi.fn(async (preparation) => ({
|
||||
@@ -444,6 +455,109 @@ function harness(input: {
|
||||
}
|
||||
|
||||
describe('RuntimeAcpBackend', () => {
|
||||
it('binds MCP image calls to authenticated blob framing and closes each prompt endpoint', async () => {
|
||||
const fixture = harness({ workMode: 'execute' })
|
||||
const client = new Client({ name: 'image-backend-test', version: '1' })
|
||||
try {
|
||||
await open(fixture)
|
||||
const accepted = await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
imageTool: { channelId: 'image-channel', channelEpoch: '1', description: 'Image catalog' }
|
||||
})) as { imageToolUrl: string }
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(accepted.imageToolUrl)))
|
||||
expect((await client.listTools()).tools.map(tool => tool.name)).toEqual(['generate_image'])
|
||||
const result = client.callTool({ name: 'generate_image', arguments: { intent: 'create', prompt: 'Blue' } })
|
||||
await vi.waitFor(() => expect(fixture.blobSink).toHaveBeenCalledOnce())
|
||||
const frame = fixture.blobSink.mock.calls[0]![0]
|
||||
expect(frame.header).toMatchObject({ channelId: 'image-channel', channelEpoch: '1', sequence: '1', direction: 'agent-to-main' })
|
||||
expect(fixture.backend.authorizeBlobFrame(frame, fixture.context.controller)).toBe(true)
|
||||
expect(fixture.backend.authorizeBlobFrame(frame, { ...fixture.context.controller, connectionId: 'foreign' })).toBe(false)
|
||||
const call = remoteImageToolCallSchema.parse(decodeRemoteImageToolMessage(frame.payload))
|
||||
const payload = encodeRemoteImageToolMessage({ callId: call.callId, error: 'Controlled test failure' })
|
||||
const reply = { header: { ...frame.header, direction: 'main-to-agent' as const, payloadLength: payload.byteLength }, payload }
|
||||
await expect(fixture.backend.onBlobFrame(reply, { ...fixture.context, channelId: 'image-channel', controller: { ...fixture.context.controller, connectionId: 'foreign' } })).rejects.toThrow('authority')
|
||||
await fixture.backend.onBlobFrame(reply, { ...fixture.context, channelId: 'image-channel' })
|
||||
expect(await result).toMatchObject({ isError: true })
|
||||
await invoke(fixture, 'runtime/completePrompt', { bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1' })
|
||||
await expect(fetch(accepted.imageToolUrl)).rejects.toThrow()
|
||||
const next = await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
requestId: 'request-2', operationId: 'request-2', promptSequence: 1,
|
||||
imageTool: { channelId: 'image-channel-2', channelEpoch: '2', description: 'Refreshed image catalog' }
|
||||
})) as { imageToolUrl: string }
|
||||
expect(next.imageToolUrl).not.toBe(accepted.imageToolUrl)
|
||||
} finally { await client.close(); await fixture.backend.dispose() }
|
||||
})
|
||||
|
||||
it('rejects image tool preparation in Ask before launching a process or bridge', async () => {
|
||||
const fixture = harness()
|
||||
try {
|
||||
await open(fixture)
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
imageTool: { channelId: 'image-channel', channelEpoch: '1', description: 'Images' }
|
||||
}))).rejects.toThrow('Execute')
|
||||
expect(fixture.launches).toHaveLength(0)
|
||||
expect(fixture.lifecycle).not.toContain('bridge-listen')
|
||||
} finally { await fixture.backend.dispose() }
|
||||
})
|
||||
it.each([false, true])('allows mode changes only on a shared Session (shared=%s)', async (shared) => {
|
||||
const fixture = harness({ agentOwned: true, shareOwnedProcesses: shared, workMode: 'execute' })
|
||||
try {
|
||||
await open(fixture)
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation())
|
||||
await invoke(fixture, 'runtime/completePrompt', {
|
||||
bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1'
|
||||
})
|
||||
const next = fixture.preparation({
|
||||
operationId: 'request-2', requestId: 'request-2', promptSequence: 1, workMode: 'ask'
|
||||
})
|
||||
if (shared) {
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', next)).resolves.toMatchObject({ workMode: 'ask' })
|
||||
await invoke(fixture, 'runtime/completePrompt', {
|
||||
bindingId: 'binding-1', operationId: 'request-2', requestId: 'request-2'
|
||||
})
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
operationId: 'request-3', requestId: 'request-3', promptSequence: 2, workMode: 'execute'
|
||||
}))).resolves.toMatchObject({ workMode: 'execute' })
|
||||
} else {
|
||||
await expect(invoke(fixture, 'runtime/preparePrompt', next)).rejects.toThrow('work mode cannot change')
|
||||
}
|
||||
expect(fixture.launches).toHaveLength(1)
|
||||
} finally {
|
||||
await fixture.backend.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('shares Agent-owned workspaces and modes, retaining peers and reclaiming only an unused process', async () => {
|
||||
const fixture = harness({ agentOwned: true, shareOwnedProcesses: true })
|
||||
const channels: Array<{ bindingId: string; channelId: string; channelEpoch: string }> = []
|
||||
try {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const bindingId = `shared-${index}`
|
||||
const workspaceIdentity = `workspace-${index % 2}`
|
||||
const channel = await invoke(fixture, 'runtime/openAcpChannel', {
|
||||
...fixture.openRequest, bindingId, workspaceIdentity
|
||||
}) as (typeof channels)[number]
|
||||
channels.push({ bindingId, channelId: channel.channelId, channelEpoch: channel.channelEpoch })
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
bindingId, workspaceIdentity, channelEpoch: channel.channelEpoch,
|
||||
workMode: index % 2 ? 'execute' : 'ask',
|
||||
operationId: `operation-${index}`, requestId: `operation-${index}`
|
||||
}))
|
||||
}
|
||||
expect(fixture.launches).toHaveLength(1)
|
||||
for (const channel of channels.slice(0, 3)) {
|
||||
await invoke(fixture, 'runtime/closeAcpChannel', {
|
||||
...channel, reason: 'released'
|
||||
})
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 40))
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
await invoke(fixture, 'runtime/closeAcpChannel', { ...channels[3], reason: 'released' })
|
||||
await vi.waitFor(() => expect(fixture.process.stops).toEqual(['binding-closed']))
|
||||
} finally {
|
||||
await fixture.backend.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects Agent-first unknown blob channels before prompt authority exists', async () => {
|
||||
const fixture = harness()
|
||||
await expect(
|
||||
@@ -1536,6 +1650,71 @@ describe('RuntimeAcpBackend', () => {
|
||||
).rejects.toMatchObject({ code: 'stale-controller' })
|
||||
})
|
||||
|
||||
it('resumes shared semantic bindings with no binary output in the real journal', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-empty-acp-replay-'))
|
||||
const journal = new EventJournal(join(directory, 'events.sqlite'))
|
||||
const fixture = harness({
|
||||
agentOwned: true,
|
||||
shareOwnedProcesses: true,
|
||||
journal
|
||||
})
|
||||
const channels: Array<{
|
||||
bindingId: string
|
||||
channelId: string
|
||||
channelEpoch: string
|
||||
}> = []
|
||||
try {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const bindingId = `empty-${index}`
|
||||
const workspaceIdentity = `workspace-${index % 2}`
|
||||
const channel = await invoke(fixture, 'runtime/openAcpChannel', {
|
||||
...fixture.openRequest, bindingId, workspaceIdentity
|
||||
}) as (typeof channels)[number]
|
||||
channels.push({
|
||||
bindingId,
|
||||
channelId: channel.channelId,
|
||||
channelEpoch: channel.channelEpoch
|
||||
})
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({
|
||||
bindingId, workspaceIdentity,
|
||||
channelEpoch: channel.channelEpoch,
|
||||
operationId: `operation-${index}`,
|
||||
requestId: `operation-${index}`,
|
||||
workMode: index % 2 ? 'execute' : 'ask'
|
||||
}))
|
||||
expect(journal.getAcpCursor(
|
||||
bindingId, channel.channelEpoch, 'runtime-to-main'
|
||||
)).toBeUndefined()
|
||||
}
|
||||
fixture.context.abort.abort()
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
const resumed = protocolContext({
|
||||
generation: 2,
|
||||
connectionId: 'connection-2'
|
||||
})
|
||||
for (const channel of channels) {
|
||||
await fixture.backend.methods['runtime/resumeAcpChannel']!(
|
||||
channel, resumed
|
||||
)
|
||||
await expect(fixture.backend.methods['runtime/replayAcpChannel']!(
|
||||
{ ...channel, acknowledgedSequence: '1' }, resumed
|
||||
)).rejects.toThrow('ACK exceeds durable output')
|
||||
await expect(fixture.backend.methods['runtime/replayAcpChannel']!(
|
||||
{ ...channel, acknowledgedSequence: '0' }, resumed
|
||||
)).resolves.toMatchObject({
|
||||
...channel, replayedThroughSequence: '0', live: true
|
||||
})
|
||||
}
|
||||
expect(fixture.launches).toHaveLength(1)
|
||||
expect(fixture.process.stops).toEqual([])
|
||||
expect(fixture.process.writes).toEqual([])
|
||||
} finally {
|
||||
await fixture.backend.dispose()
|
||||
journal.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed on supervised process identity conflict', async () => {
|
||||
const fixture = harness()
|
||||
await open(fixture)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { AgentImageToolMcp } from './image-tool-mcp'
|
||||
import { remoteImageToolMcpName } from '../shared/remote-image-tool-node'
|
||||
import { remoteQuestionResponseSchema } from '../shared/remote-question-contracts'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
@@ -59,6 +61,7 @@ import type {
|
||||
ProtocolMethodHandler
|
||||
} from './protocol-server'
|
||||
import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import type { SemanticPromptStore } from './semantic-prompt-store'
|
||||
import {
|
||||
AgentModelGatewayError,
|
||||
@@ -83,6 +86,7 @@ const DEFAULT_MAXIMUM_PENDING_OUTPUT_BYTES_GLOBAL =
|
||||
const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000
|
||||
const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000
|
||||
const OWNED_PROMPT_CANCEL_GRACE_MS = 1_000
|
||||
const SHARED_PROCESS_IDLE_MS = 60_000
|
||||
const OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000
|
||||
const ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS = 25
|
||||
const ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS = 1_000
|
||||
@@ -228,6 +232,7 @@ export type RuntimeAcpProcessLaunch = {
|
||||
workMode: 'ask' | 'execute'
|
||||
deadlineAt: string
|
||||
budget: RemotePromptOperationPreparation['budget']
|
||||
sharedSessions?: boolean
|
||||
modelBridge?: {
|
||||
socketPath: string
|
||||
policy: ModelBridgePolicy
|
||||
@@ -249,6 +254,8 @@ export type RuntimeAcpBackendLimits = {
|
||||
}
|
||||
|
||||
export type RuntimeAcpBackendOptions = {
|
||||
shareOwnedProcesses?: boolean
|
||||
sharedProcessIdleMs?: number
|
||||
journal: RuntimeAcpJournal
|
||||
resolveRuntimeBundle(
|
||||
runtimeId: string,
|
||||
@@ -298,6 +305,7 @@ type PreparedOperation = {
|
||||
budget: RemotePromptOperationPreparation['budget']
|
||||
completion?: z.infer<typeof acpCompletePromptResultSchema>
|
||||
terminalState?: 'completed' | 'failed' | 'cancelled' | 'interrupted'
|
||||
processTree?: 'running' | 'empty'
|
||||
modelProfile?: AgentPromptModelProfile
|
||||
promptSequence: number
|
||||
ownedPromptStarted?: boolean
|
||||
@@ -314,6 +322,7 @@ type BindingState = {
|
||||
state: 'open' | 'running' | 'stopping' | 'closed' | 'outcome-unknown'
|
||||
workMode?: 'ask' | 'execute'
|
||||
process?: RuntimeAcpProcessOwner
|
||||
sharedProcess?: SharedProcess
|
||||
unsubscribeOutput?: () => void
|
||||
unsubscribeExit?: () => void
|
||||
stopRequested: boolean
|
||||
@@ -331,6 +340,7 @@ type BindingState = {
|
||||
ownedPromptStartTimer?: NodeJS.Timeout
|
||||
modelBridgePolicy?: ModelBridgePolicy
|
||||
modelBridgeClient?: ModelBridgeBlobClient
|
||||
imageTool?: AgentImageToolMcp
|
||||
modelBridgeBroker?: ModelBridgeBrokerServer
|
||||
ownedAcp?: AgentOwnedAcpPrompt
|
||||
workspaceDirectory?: string
|
||||
@@ -338,12 +348,23 @@ type BindingState = {
|
||||
poisoned: boolean
|
||||
}
|
||||
|
||||
type SharedProcess = {
|
||||
key: string
|
||||
process: RuntimeAcpProcessOwner
|
||||
transport: AgentAcpConnection
|
||||
bindings: Set<BindingState>
|
||||
unsubscribeExit?: () => void
|
||||
idleTimer?: NodeJS.Timeout
|
||||
exited: boolean
|
||||
}
|
||||
|
||||
export class RuntimeAcpBackend {
|
||||
readonly methods: Readonly<Record<string, ProtocolMethodHandler>>
|
||||
readonly #options: RuntimeAcpBackendOptions
|
||||
readonly #limits: RuntimeAcpBackendLimits
|
||||
readonly #bindings = new Map<string, BindingState>()
|
||||
readonly #processIdentityBindings = new Map<string, string>()
|
||||
readonly #sharedProcesses = new Map<string, SharedProcess>()
|
||||
readonly #boundConnectionSignals = new WeakSet<AbortSignal>()
|
||||
#nextChannelEpoch: bigint
|
||||
#lifetimeBindings = 0
|
||||
@@ -540,6 +561,18 @@ export class RuntimeAcpBackend {
|
||||
'identity'
|
||||
)
|
||||
}
|
||||
const imageBinding = [...this.#bindings.values()].find(binding =>
|
||||
binding.controllerId === context.controller.controllerId &&
|
||||
binding.controllerGeneration === context.controller.generation &&
|
||||
binding.connectionId === context.controller.connectionId &&
|
||||
binding.activeOperationId !== undefined &&
|
||||
binding.imageTool?.descriptor.channelId === frame.header.channelId &&
|
||||
binding.imageTool.descriptor.channelEpoch === frame.header.channelEpoch &&
|
||||
context.channelId === frame.header.channelId)
|
||||
if (imageBinding) {
|
||||
imageBinding.imageTool!.onReply(frame.payload)
|
||||
return
|
||||
}
|
||||
const matches = [...this.#bindings.values()].filter(
|
||||
(binding) =>
|
||||
binding.controllerId === context.controller.controllerId &&
|
||||
@@ -595,10 +628,10 @@ export class RuntimeAcpBackend {
|
||||
binding.controllerId === controller.controllerId &&
|
||||
binding.controllerGeneration === controller.generation &&
|
||||
binding.connectionId === controller.connectionId &&
|
||||
binding.modelBridgeClient?.binding.channelId ===
|
||||
frame.header.channelId &&
|
||||
binding.modelBridgeClient.binding.channelEpoch ===
|
||||
frame.header.channelEpoch &&
|
||||
((binding.modelBridgeClient?.binding.channelId === frame.header.channelId &&
|
||||
binding.modelBridgeClient.binding.channelEpoch === frame.header.channelEpoch) ||
|
||||
(binding.imageTool?.descriptor.channelId === frame.header.channelId &&
|
||||
binding.imageTool.descriptor.channelEpoch === frame.header.channelEpoch)) &&
|
||||
frame.header.connectionId === binding.connectionId &&
|
||||
frame.header.generation === binding.controllerGeneration
|
||||
)
|
||||
@@ -636,6 +669,14 @@ export class RuntimeAcpBackend {
|
||||
result.status === 'rejected'
|
||||
)
|
||||
.map((result) => result.reason)
|
||||
const sharedResults = await Promise.allSettled(
|
||||
[...this.#sharedProcesses.values()].map(group =>
|
||||
this.#stopSharedProcess(group, 'binding-closed')
|
||||
)
|
||||
)
|
||||
for (const result of sharedResults) {
|
||||
if (result.status === 'rejected') failures.push(result.reason)
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
@@ -930,11 +971,15 @@ export class RuntimeAcpBackend {
|
||||
'identity'
|
||||
)
|
||||
}
|
||||
this.#options.journal.acknowledgeAcpFromMain?.({
|
||||
bindingId: binding.request.bindingId,
|
||||
channelEpoch: binding.openResult.channelEpoch,
|
||||
sequence: request.acknowledgedSequence
|
||||
})
|
||||
// Agent-owned prompts may have semantic events but no binary journal.
|
||||
// In that case the validated zero cursor has nothing to acknowledge.
|
||||
if (initialCursor !== undefined) {
|
||||
this.#options.journal.acknowledgeAcpFromMain?.({
|
||||
bindingId: binding.request.bindingId,
|
||||
channelEpoch: binding.openResult.channelEpoch,
|
||||
sequence: request.acknowledgedSequence
|
||||
})
|
||||
}
|
||||
if (this.#options.journal.replayAcpFrames === undefined) {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Durable ACP replay is unavailable',
|
||||
@@ -1011,7 +1056,8 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
if (
|
||||
binding.workMode !== undefined &&
|
||||
binding.workMode !== preparation.workMode
|
||||
binding.workMode !== preparation.workMode &&
|
||||
binding.sharedProcess === undefined
|
||||
) {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Runtime process work mode cannot change',
|
||||
@@ -1109,11 +1155,37 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (preparation.imageTool && (preparation.workMode !== 'execute' || !this.#options.blobSink)) {
|
||||
throw new RuntimeAcpBackendError('Image tools require Execute and a desktop transport', 'identity')
|
||||
}
|
||||
if (requestedModelBridgePolicy !== undefined) {
|
||||
await this.#startModelBridge(binding, preparation, workspace)
|
||||
}
|
||||
const acceptedAt = new Date(this.#now()).toISOString()
|
||||
binding.imageTool?.close()
|
||||
binding.imageTool = undefined
|
||||
if (preparation.imageTool) {
|
||||
let sequence = 0n
|
||||
const imageTool = new AgentImageToolMcp(preparation.imageTool, async payload => {
|
||||
if (binding.activeOperationId !== preparation.operationId || binding.transportState === 'detached') {
|
||||
throw new Error('Image tool prompt is no longer connected')
|
||||
}
|
||||
await this.#options.blobSink!({
|
||||
header: {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major, protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
kind: 'blob', direction: 'agent-to-main',
|
||||
connectionId: binding.connectionId, generation: binding.controllerGeneration,
|
||||
channelId: preparation.imageTool!.channelId, channelEpoch: preparation.imageTool!.channelEpoch,
|
||||
sequence: String(++sequence), payloadLength: payload.byteLength
|
||||
}, payload
|
||||
}, { bindingId: binding.request.bindingId, controllerId: binding.controllerId,
|
||||
controllerGeneration: binding.controllerGeneration })
|
||||
})
|
||||
binding.imageTool = imageTool
|
||||
await imageTool.start()
|
||||
}
|
||||
const acceptance = remotePromptOperationAcceptanceSchema.parse({
|
||||
...(binding.imageTool?.url ? { imageToolUrl: binding.imageTool.url } : {}),
|
||||
bindingId: preparation.bindingId,
|
||||
operationId: preparation.operationId,
|
||||
requestId: preparation.requestId,
|
||||
@@ -1124,12 +1196,22 @@ export class RuntimeAcpBackend {
|
||||
|
||||
if (binding.process === undefined) {
|
||||
let process: RuntimeAcpProcessOwner
|
||||
const sharedKey =
|
||||
this.#options.shareOwnedProcesses &&
|
||||
preparation.modelProfile && this.#options.modelGateway &&
|
||||
this.#options.semanticPrompts && binding.modelBridgePolicy
|
||||
? canonicalJson([
|
||||
verified.manifest.bundleDigest,
|
||||
verified.manifest.adapterDigest,
|
||||
binding.modelBridgePolicy
|
||||
])
|
||||
: undefined
|
||||
this.#options.diagnostics?.tryRecord('runtime.starting', {
|
||||
runtimeId: verified.manifest.runtimeId,
|
||||
workMode: preparation.workMode
|
||||
})
|
||||
try {
|
||||
process = await this.#options.launchProcess({
|
||||
const launch: RuntimeAcpProcessLaunch = {
|
||||
manifest: verified.manifest,
|
||||
bundle: verified,
|
||||
workspace,
|
||||
@@ -1137,6 +1219,7 @@ export class RuntimeAcpBackend {
|
||||
workMode: preparation.workMode,
|
||||
deadlineAt: preparation.deadlineAt,
|
||||
budget: preparation.budget,
|
||||
...(sharedKey === undefined ? {} : { sharedSessions: true }),
|
||||
...(binding.modelBridgePolicy === undefined ||
|
||||
binding.modelBridgeBroker === undefined
|
||||
? {}
|
||||
@@ -1146,7 +1229,15 @@ export class RuntimeAcpBackend {
|
||||
policy: binding.modelBridgePolicy
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if (sharedKey !== undefined) {
|
||||
const group = await this.#acquireSharedProcess(sharedKey, launch)
|
||||
binding.sharedProcess = group
|
||||
group.bindings.add(binding)
|
||||
process = group.process
|
||||
} else {
|
||||
process = await this.#options.launchProcess(launch)
|
||||
}
|
||||
assertProcessIdentity(process.identity)
|
||||
} catch (error) {
|
||||
this.#options.diagnostics?.tryRecord('runtime.start.failed', {
|
||||
@@ -1166,7 +1257,7 @@ export class RuntimeAcpBackend {
|
||||
const identityOwner = this.#processIdentityBindings.get(processKey)
|
||||
if (
|
||||
identityOwner !== undefined &&
|
||||
identityOwner !== binding.request.bindingId
|
||||
identityOwner !== (binding.sharedProcess?.key ?? binding.request.bindingId)
|
||||
) {
|
||||
await Promise.resolve(
|
||||
process.stop({
|
||||
@@ -1182,7 +1273,7 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
this.#processIdentityBindings.set(
|
||||
processKey,
|
||||
binding.request.bindingId
|
||||
binding.sharedProcess?.key ?? binding.request.bindingId
|
||||
)
|
||||
binding.process = process
|
||||
binding.workspaceDirectory = workspace.workspaceDirectory
|
||||
@@ -1208,7 +1299,7 @@ export class RuntimeAcpBackend {
|
||||
binding.unsubscribeOutput = unsubscribe
|
||||
}
|
||||
}
|
||||
const unsubscribeExit = process.subscribeExit?.(() => {
|
||||
const unsubscribeExit = binding.sharedProcess ? undefined : process.subscribeExit?.(() => {
|
||||
void this.#enqueueControl(async () => {
|
||||
await this.#handleProcessExit(binding)
|
||||
}).catch(() => undefined)
|
||||
@@ -1230,7 +1321,7 @@ export class RuntimeAcpBackend {
|
||||
runtimeId: verified.manifest.runtimeId,
|
||||
workMode: preparation.workMode
|
||||
})
|
||||
} else {
|
||||
} else if (!binding.sharedProcess) {
|
||||
try {
|
||||
await binding.process.beginPrompt({
|
||||
deadlineAt: preparation.deadlineAt,
|
||||
@@ -1246,6 +1337,7 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.workMode = preparation.workMode
|
||||
binding.inputBytes = 0
|
||||
if (!binding.operations.has(preparation.operationId)) {
|
||||
binding.operations.set(preparation.operationId, {
|
||||
@@ -1389,12 +1481,17 @@ export class RuntimeAcpBackend {
|
||||
'conflict'
|
||||
)
|
||||
}
|
||||
if (Buffer.byteLength(canonicalJson(request.prompt)) > prepared.budget.maximumInputBytes) {
|
||||
throw new RuntimeAcpBackendError('Prompt input quota reached', 'capacity')
|
||||
}
|
||||
if (binding.ownedAcp === undefined) {
|
||||
binding.ownedAcp = new AgentOwnedAcpPrompt({
|
||||
bindingId: binding.request.bindingId,
|
||||
controllerId: binding.controllerId,
|
||||
workspaceDirectory: binding.workspaceDirectory,
|
||||
workMode: binding.workMode!,
|
||||
mcpServers: () => binding.imageTool?.url ? [{
|
||||
type: 'http', name: remoteImageToolMcpName(binding.request.bindingId), url: binding.imageTool.url, headers: []
|
||||
}] : [],
|
||||
...(binding.modelBridgePolicy === undefined
|
||||
? {}
|
||||
: {
|
||||
@@ -1404,6 +1501,15 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}),
|
||||
process: binding.process,
|
||||
...(binding.sharedProcess ? {
|
||||
transport: binding.sharedProcess.transport,
|
||||
prepareSession: async (sessionId: string, operationId: string, workMode: 'ask' | 'execute') => {
|
||||
await binding.sharedProcess!.transport.setModelRoute(
|
||||
sessionId, operationId, binding.modelBridgeBroker!.socketPath, workMode,
|
||||
binding.imageTool ? remoteImageToolMcpName(binding.request.bindingId) : undefined
|
||||
)
|
||||
}
|
||||
} : {}),
|
||||
transcript: this.#options.semanticPrompts,
|
||||
completePrompt: async (operationId, status) => {
|
||||
await this.#enqueueControl(async () => {
|
||||
@@ -1418,7 +1524,7 @@ export class RuntimeAcpBackend {
|
||||
binding.poisoned ? 'outcome-unknown' : status
|
||||
})
|
||||
}
|
||||
const result = await binding.ownedAcp.start(request)
|
||||
const result = await binding.ownedAcp.start(request, prepared.acceptance.workMode)
|
||||
prepared.ownedPromptStarted = true
|
||||
if (binding.ownedPromptStartTimer !== undefined) {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
@@ -1532,7 +1638,7 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
await this.#closeModelBridge(binding, true)
|
||||
try {
|
||||
await binding.process.completePrompt()
|
||||
if (!binding.sharedProcess) await binding.process.completePrompt()
|
||||
} catch {
|
||||
binding.state = 'outcome-unknown'
|
||||
prepared.modelProfile = undefined
|
||||
@@ -1556,6 +1662,7 @@ export class RuntimeAcpBackend {
|
||||
: status === 'failed'
|
||||
? 'failed'
|
||||
: 'completed'
|
||||
prepared.processTree = 'running'
|
||||
if (status === 'completed') {
|
||||
prepared.completion = acpCompletePromptResultSchema.parse({
|
||||
bindingId: binding.request.bindingId,
|
||||
@@ -1754,11 +1861,18 @@ export class RuntimeAcpBackend {
|
||||
poisonIfActive: boolean,
|
||||
preserveRuntime = false
|
||||
): Promise<void> {
|
||||
binding.imageTool?.close()
|
||||
binding.imageTool = undefined
|
||||
const broker = binding.modelBridgeBroker
|
||||
const client = binding.modelBridgeClient
|
||||
binding.modelBridgeBroker = undefined
|
||||
binding.modelBridgeClient = undefined
|
||||
let brokerError: unknown
|
||||
if (binding.sharedProcess && binding.ownedAcp?.sessionId && binding.activeOperationId) {
|
||||
await binding.sharedProcess.transport.setModelRoute(
|
||||
binding.ownedAcp.sessionId, binding.activeOperationId
|
||||
).catch(() => undefined)
|
||||
}
|
||||
try {
|
||||
await broker?.close()
|
||||
} catch (error) {
|
||||
@@ -1902,7 +2016,7 @@ export class RuntimeAcpBackend {
|
||||
status: 'terminal',
|
||||
terminalState: prepared.terminalState,
|
||||
processTree:
|
||||
prepared.terminalState === 'completed' ? 'running' : 'empty'
|
||||
prepared.processTree ?? (prepared.terminalState === 'completed' ? 'running' : 'empty')
|
||||
}
|
||||
}
|
||||
if (binding.activeOperationId !== operation.operationId) {
|
||||
@@ -2074,6 +2188,8 @@ export class RuntimeAcpBackend {
|
||||
return
|
||||
}
|
||||
binding.transportState = 'detached'
|
||||
binding.imageTool?.close()
|
||||
binding.imageTool = undefined
|
||||
if (binding.modelBridgeClient !== undefined) {
|
||||
await this.#closeModelBridge(binding, false, true).catch(
|
||||
() => undefined
|
||||
@@ -2113,6 +2229,25 @@ export class RuntimeAcpBackend {
|
||||
this.#finishBinding(binding, 'interrupted')
|
||||
return
|
||||
}
|
||||
if (binding.sharedProcess) {
|
||||
const group = binding.sharedProcess
|
||||
const prepared = binding.activeOperationId
|
||||
? binding.operations.get(binding.activeOperationId) : undefined
|
||||
try {
|
||||
await rejectAfter(
|
||||
binding.ownedAcp?.releaseSession() ?? Promise.resolve(),
|
||||
OWNED_PROMPT_CANCEL_GRACE_MS * 3,
|
||||
'Shared Runtime Session release timed out'
|
||||
)
|
||||
if (prepared) prepared.processTree = group.exited ? 'empty' : 'running'
|
||||
this.#finishBinding(binding, reason === 'user-cancelled' ? 'cancelled' : 'interrupted')
|
||||
} catch {
|
||||
// A non-responsive native control plane affects the whole process.
|
||||
// Ordinary Session cancellation never reaches this fallback.
|
||||
await this.#stopSharedProcess(group, reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
binding.state = 'stopping'
|
||||
if (!binding.stopRequested) {
|
||||
binding.stopRequested = true
|
||||
@@ -2277,7 +2412,12 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
|
||||
#finishProcess(binding: BindingState): void {
|
||||
if (binding.process !== undefined) {
|
||||
const group = binding.sharedProcess
|
||||
if (group) {
|
||||
group.bindings.delete(binding)
|
||||
binding.sharedProcess = undefined
|
||||
this.#scheduleSharedIdle(group)
|
||||
} else if (binding.process !== undefined) {
|
||||
this.#processIdentityBindings.delete(
|
||||
processIdentityKey(binding.process.identity)
|
||||
)
|
||||
@@ -2305,6 +2445,68 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
}
|
||||
|
||||
async #acquireSharedProcess(key: string, launch: RuntimeAcpProcessLaunch): Promise<SharedProcess> {
|
||||
const existing = this.#sharedProcesses.get(key)
|
||||
if (existing && !existing.exited) {
|
||||
if (existing.idleTimer) clearTimeout(existing.idleTimer)
|
||||
existing.idleTimer = undefined
|
||||
return existing
|
||||
}
|
||||
const process = await this.#options.launchProcess(launch)
|
||||
assertProcessIdentity(process.identity)
|
||||
const group: SharedProcess = {
|
||||
key, process, transport: new AgentAcpConnection(process),
|
||||
bindings: new Set(), exited: false
|
||||
}
|
||||
group.unsubscribeExit = process.subscribeExit?.(() => {
|
||||
void this.#enqueueControl(() => this.#handleSharedExit(group)).catch(() => undefined)
|
||||
}) ?? undefined
|
||||
this.#sharedProcesses.set(key, group)
|
||||
return group
|
||||
}
|
||||
|
||||
#scheduleSharedIdle(group: SharedProcess): void {
|
||||
if (group.bindings.size || group.exited || this.#disposed || group.idleTimer) return
|
||||
group.idleTimer = setTimeout(() => {
|
||||
group.idleTimer = undefined
|
||||
void this.#enqueueControl(async () => {
|
||||
if (!group.bindings.size) await this.#stopSharedProcess(group, 'binding-closed')
|
||||
}).catch(() => undefined)
|
||||
}, this.#options.sharedProcessIdleMs ?? SHARED_PROCESS_IDLE_MS)
|
||||
group.idleTimer.unref?.()
|
||||
}
|
||||
|
||||
async #stopSharedProcess(group: SharedProcess, reason: RuntimeAcpProcessStopReason): Promise<void> {
|
||||
if (group.exited) return
|
||||
if (group.idleTimer) clearTimeout(group.idleTimer)
|
||||
group.idleTimer = undefined
|
||||
await group.process.stop({
|
||||
reason, deadlineAt: new Date(this.#now() + 10_000).toISOString()
|
||||
})
|
||||
const result = await group.process.reconcile()
|
||||
if (!sameProcessIdentity(result.identity, group.process.identity) || result.processTree !== 'empty') {
|
||||
throw new RuntimeAcpBackendError('Shared Runtime process stop did not reconcile', 'process')
|
||||
}
|
||||
await this.#handleSharedExit(group)
|
||||
}
|
||||
|
||||
async #handleSharedExit(group: SharedProcess): Promise<void> {
|
||||
if (group.exited) return
|
||||
group.exited = true
|
||||
if (group.idleTimer) clearTimeout(group.idleTimer)
|
||||
group.unsubscribeExit?.()
|
||||
group.transport.dispose()
|
||||
if (this.#sharedProcesses.get(group.key) === group) this.#sharedProcesses.delete(group.key)
|
||||
this.#processIdentityBindings.delete(processIdentityKey(group.process.identity))
|
||||
for (const binding of [...group.bindings]) {
|
||||
if (binding.activeOperationId) {
|
||||
const prepared = binding.operations.get(binding.activeOperationId)
|
||||
if (prepared) prepared.processTree = 'empty'
|
||||
}
|
||||
await this.#handleProcessExit(binding)
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleDeadline(binding: BindingState, deadlineAt: string): void {
|
||||
if (binding.deadlineTimer !== undefined) {
|
||||
clearTimeout(binding.deadlineTimer)
|
||||
|
||||
@@ -245,6 +245,7 @@ export async function createProductionRuntimeProtocol(
|
||||
stateDirectory
|
||||
)
|
||||
backend = new RuntimeAcpBackend({
|
||||
shareOwnedProcesses: true,
|
||||
journal: options.events,
|
||||
resolveRuntimeBundle: (runtimeId, bundleDigest) =>
|
||||
currentRegistry().resolve(
|
||||
@@ -254,7 +255,9 @@ export async function createProductionRuntimeProtocol(
|
||||
),
|
||||
loadRegisteredRuntimeBundle: async (resolved) => {
|
||||
return await loadRegistered(
|
||||
resolved.entry,
|
||||
// This composition's resolver above returns the complete registry
|
||||
// entry; the backend itself only needs its public identity fields.
|
||||
resolved.entry as RuntimeRegistryEntry,
|
||||
resolved.bundleDirectory
|
||||
)
|
||||
},
|
||||
@@ -283,7 +286,8 @@ export async function createProductionRuntimeProtocol(
|
||||
bridgeDirectory:
|
||||
launch.workspace.bridgeDirectory!,
|
||||
socketPath: launch.modelBridge.socketPath,
|
||||
policy: launch.modelBridge.policy
|
||||
policy: launch.modelBridge.policy,
|
||||
sharedSessions: launch.sharedSessions
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -294,7 +298,8 @@ export async function createProductionRuntimeProtocol(
|
||||
installationId,
|
||||
registry: ownerRegistry,
|
||||
deadlineAt: launch.deadlineAt,
|
||||
maximumInputBytes: launch.budget.maximumInputBytes
|
||||
maximumInputBytes: launch.budget.maximumInputBytes,
|
||||
sharedSessions: launch.sharedSessions
|
||||
}),
|
||||
outputSink: options.outputSink,
|
||||
semanticPrompts,
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
import type { RuntimeEvent } from './runtime'
|
||||
import {
|
||||
AcpRemoteRuntime,
|
||||
RemotePromptCancelledError,
|
||||
RemotePromptRecoveryUnavailableError,
|
||||
type AcpRemoteRuntimeOptions
|
||||
} from './acp-remote-runtime'
|
||||
import type {
|
||||
@@ -2979,6 +2981,42 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the recovered generation for retained and new Sessions and mode changes', async () => {
|
||||
const fixture = ownedChannel()
|
||||
const fresh = ownedChannel()
|
||||
try {
|
||||
await collect(fixture.instance.run(
|
||||
{ ...request, workMode: 'ask' }, new AbortController().signal
|
||||
))
|
||||
Object.defineProperty(fixture.channel, 'generation', { value: 2 })
|
||||
await collect(fixture.instance.run(
|
||||
{ ...request, requestId: 'after-reconnect', workMode: 'execute' },
|
||||
new AbortController().signal
|
||||
))
|
||||
Object.defineProperty(fresh.channel, 'generation', { value: 2 })
|
||||
await collect(fresh.instance.run(
|
||||
{ ...request, requestId: 'new-after-reconnect', conversationId: 'another-conversation' },
|
||||
new AbortController().signal
|
||||
))
|
||||
expect([
|
||||
...fixture.preparePrompt.mock.calls.slice(1),
|
||||
...fresh.preparePrompt.mock.calls
|
||||
].map(([prepared]) => ({
|
||||
controllerGeneration: prepared.controllerGeneration,
|
||||
connectionGeneration: prepared.connectionGeneration
|
||||
}))).toEqual([
|
||||
{ controllerGeneration: 2, connectionGeneration: 2 },
|
||||
{ controllerGeneration: 2, connectionGeneration: 2 }
|
||||
])
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenNthCalledWith(
|
||||
2, expect.objectContaining({ acpSessionId: 'owned-session' })
|
||||
)
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await Promise.all([fixture.instance.dispose(), fresh.instance.dispose()])
|
||||
}
|
||||
})
|
||||
|
||||
it('does not abandon an accepted Agent prompt for a later request', async () => {
|
||||
const fixture = ownedChannel({ waitForCancellation: true })
|
||||
const first = fixture.instance.run(
|
||||
@@ -3115,6 +3153,30 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('definitively ends recovery after the original Agent installation is replaced', async () => {
|
||||
const fixture = ownedChannel({ waitForCancellation: true })
|
||||
const first = fixture.instance.run(request, new AbortController().signal)
|
||||
await first.next()
|
||||
await first.return()
|
||||
const channelFactory = vi.fn(async () => fixture.channel)
|
||||
const replacement = factoryRuntime(channelFactory, fixture.store, {
|
||||
identity: { ...identity, agentInstallationId: 'replacement-agent' },
|
||||
modelBridgePolicy,
|
||||
modelProfile: ownedModelProfile
|
||||
})
|
||||
try {
|
||||
await expect(collect(replacement.run(
|
||||
{ ...request, remoteRecoveryOnly: true },
|
||||
new AbortController().signal
|
||||
))).rejects.toBeInstanceOf(RemotePromptRecoveryUnavailableError)
|
||||
expect(channelFactory).not.toHaveBeenCalled()
|
||||
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
|
||||
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await Promise.all([replacement.dispose(), fixture.instance.dispose()])
|
||||
}
|
||||
})
|
||||
|
||||
it('reconciles an owned cancellation before the next prompt in the same conversation', async () => {
|
||||
const fixture = ownedChannel()
|
||||
fixture.pageOwnedPromptTranscript.mockImplementationOnce(() => new Promise<never>(() => {}))
|
||||
@@ -3124,7 +3186,7 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
const pending = stream.next()
|
||||
await vi.waitFor(() => expect(fixture.pageOwnedPromptTranscript).toHaveBeenCalledOnce())
|
||||
controller.abort(new Error('cancel pending question'))
|
||||
await expect(pending).rejects.toThrow('cancel pending question')
|
||||
await expect(pending).rejects.toBeInstanceOf(RemotePromptCancelledError)
|
||||
expect(await fixture.store.getByConversation(request.conversationId)).toBeUndefined()
|
||||
const events = await collect(fixture.instance.run(
|
||||
{ ...request, requestId: 'after-cancel' }, new AbortController().signal
|
||||
@@ -3170,7 +3232,7 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
expect(settled).toBe(false)
|
||||
|
||||
releaseEscalation()
|
||||
await expect(pending).rejects.toThrow('cancel owned prompt')
|
||||
await expect(pending).rejects.toBeInstanceOf(RemotePromptCancelledError)
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { remoteImageToolMcpName } from '../../shared/remote-image-tool-node'
|
||||
import { remoteQuestionSchema } from '../../shared/remote-question-contracts'
|
||||
import type { AgentQuestionAnswer } from '../../shared/contracts'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
@@ -138,6 +139,13 @@ export class RemotePromptRecoveryUnavailableError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class RemotePromptCancelledError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'RemotePromptCancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
type ActivePrompt = {
|
||||
reportedQuestions?: Set<string>
|
||||
subagentProgress?: OpenCodeSubagentProgress
|
||||
@@ -184,6 +192,7 @@ type SessionRecord = {
|
||||
sessionId: string
|
||||
context: ChannelContext
|
||||
modelBridge?: RemoteModelBridgeSession
|
||||
imageToolUrl?: string
|
||||
sendHistoryWithNextPrompt: boolean
|
||||
ownedPromptAttached?: boolean
|
||||
}
|
||||
@@ -1016,9 +1025,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
false
|
||||
).catch(() => undefined)
|
||||
if (
|
||||
result?.status === 'terminal' &&
|
||||
(result.processTree === 'empty' ||
|
||||
result.terminalState === 'completed')
|
||||
result?.status === 'terminal'
|
||||
) {
|
||||
const terminal = await this.persist(
|
||||
context,
|
||||
@@ -1178,7 +1185,9 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
raw = await this.awaitOperation(
|
||||
context,
|
||||
'启动远端请求',
|
||||
context.channel.preparePrompt(preparation),
|
||||
request.imageToolBinding
|
||||
? context.channel.preparePrompt(preparation, request.imageToolBinding, signal)
|
||||
: context.channel.preparePrompt(preparation),
|
||||
signal
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -1310,6 +1319,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
return runtimeSessionBindingSchema.parse({
|
||||
bindingId,
|
||||
...this.options.identity,
|
||||
controllerGeneration: context.channel.generation,
|
||||
conversationId,
|
||||
runtimeId: this.options.runtimeId,
|
||||
acpSessionId: `opening-${randomUUID()}`,
|
||||
@@ -1353,6 +1363,15 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
'没有可安全附加的远端 Agent 请求,且恢复不会重放任务'
|
||||
)
|
||||
}
|
||||
if (
|
||||
request.remoteRecoveryOnly === true &&
|
||||
binding &&
|
||||
!this.bindingIdentityMatches(binding)
|
||||
) {
|
||||
throw new RemotePromptRecoveryUnavailableError(
|
||||
'原远端 Agent 或 Runtime 已变更,无法恢复原请求且不会重放任务'
|
||||
)
|
||||
}
|
||||
if (
|
||||
request.remoteRecoveryOnly !== true &&
|
||||
this.options.modelProfile !== undefined &&
|
||||
@@ -1581,7 +1600,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
state.agent.loadSession({
|
||||
sessionId: binding.acpSessionId,
|
||||
cwd: this.options.workspacePath,
|
||||
mcpServers: []
|
||||
mcpServers: this.imageMcpServers(binding.bindingId, prepared.acceptance.imageToolUrl)
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -1592,7 +1611,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
state.agent.resumeSession({
|
||||
sessionId: binding.acpSessionId,
|
||||
cwd: this.options.workspacePath,
|
||||
mcpServers: []
|
||||
mcpServers: this.imageMcpServers(binding.bindingId, prepared.acceptance.imageToolUrl)
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -1604,6 +1623,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
sessionId: binding.acpSessionId,
|
||||
context,
|
||||
modelBridge: prepared.modelBridge,
|
||||
imageToolUrl: prepared.acceptance.imageToolUrl,
|
||||
sendHistoryWithNextPrompt: false
|
||||
}
|
||||
pendingModelBridge = undefined
|
||||
@@ -1653,7 +1673,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
'创建会话',
|
||||
state.agent.newSession({
|
||||
cwd: this.options.workspacePath,
|
||||
mcpServers: []
|
||||
mcpServers: this.imageMcpServers(binding.bindingId, prepared.acceptance.imageToolUrl)
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -1682,6 +1702,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
sessionId: response.sessionId,
|
||||
context,
|
||||
modelBridge: prepared.modelBridge,
|
||||
imageToolUrl: prepared.acceptance.imageToolUrl,
|
||||
sendHistoryWithNextPrompt: true
|
||||
}
|
||||
pendingModelBridge = undefined
|
||||
@@ -1710,6 +1731,10 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private imageMcpServers(bindingId: string, url?: string) {
|
||||
return url ? [{ type: 'http' as const, name: remoteImageToolMcpName(bindingId), url, headers: [] }] : []
|
||||
}
|
||||
|
||||
private async getSession(
|
||||
conversationId: string,
|
||||
request: AgentExecutionRequest,
|
||||
@@ -1741,6 +1766,11 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
current.context,
|
||||
current.binding
|
||||
)
|
||||
current.binding = await this.rotateBindingTransport(
|
||||
current.binding,
|
||||
current.context,
|
||||
signal
|
||||
)
|
||||
current.binding = await this.persist(
|
||||
current.context,
|
||||
current.binding,
|
||||
@@ -1762,6 +1792,13 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
current.modelBridge = prepared.modelBridge
|
||||
if (this.options.modelProfile !== undefined) {
|
||||
current.ownedPromptAttached = false
|
||||
} else if (prepared.acceptance.imageToolUrl || current.imageToolUrl) {
|
||||
const state = await this.getState(current.context, signal)
|
||||
await this.awaitOperation(current.context, 'Refresh image tools', state.agent.resumeSession({
|
||||
sessionId: current.sessionId, cwd: this.options.workspacePath,
|
||||
mcpServers: this.imageMcpServers(current.binding.bindingId, prepared.acceptance.imageToolUrl)
|
||||
}), signal)
|
||||
current.imageToolUrl = prepared.acceptance.imageToolUrl
|
||||
}
|
||||
return current
|
||||
} catch (error) {
|
||||
@@ -2685,9 +2722,10 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
)
|
||||
session.binding = binding
|
||||
if (terminal.state === 'cancelled') {
|
||||
throw signal.aborted
|
||||
? abortReason(signal)
|
||||
: new Error('远端 Runtime 请求已取消')
|
||||
throw new RemotePromptCancelledError(
|
||||
'远端 Runtime 请求已取消',
|
||||
signal.aborted ? { cause: abortReason(signal) } : undefined
|
||||
)
|
||||
}
|
||||
if (terminal.state === 'failed') {
|
||||
throw new Error(
|
||||
@@ -2810,6 +2848,14 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
signal,
|
||||
authorize
|
||||
)
|
||||
} catch (error) {
|
||||
if (signal.aborted && session.binding.state === 'closed') {
|
||||
throw new RemotePromptCancelledError(
|
||||
error instanceof Error ? error.message : '远端 Runtime 请求已取消',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
this.sessionReservations.delete(request.conversationId)
|
||||
this.notifyDrain()
|
||||
|
||||
@@ -1603,7 +1603,7 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(existsSync(globalDirectory)).toBe(false)
|
||||
})
|
||||
|
||||
it('uses auto mode and returns audit metadata for agent tools', async () => {
|
||||
it.each(['completed', 'failed'] as const)('uses auto mode and retains streamed audit metadata on %s', async (toolState) => {
|
||||
const distribution = await createDistribution()
|
||||
let launchArgs: string[] = []
|
||||
const permissionBodies: unknown[] = []
|
||||
@@ -1681,7 +1681,7 @@ describe('ContinueHostAdapter', () => {
|
||||
toolCall: {
|
||||
function: { name: 'Bash' }
|
||||
},
|
||||
status: 'done'
|
||||
status: toolState
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1695,7 +1695,7 @@ describe('ContinueHostAdapter', () => {
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
state: toolState,
|
||||
output:
|
||||
'Tests passed\nAuthorization: Bearer secret-token'
|
||||
},
|
||||
@@ -1737,9 +1737,10 @@ describe('ContinueHostAdapter', () => {
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
state: toolState,
|
||||
input:
|
||||
'{"command":"npm test","token":"secret-token"}',
|
||||
summary: 'npm test',
|
||||
output:
|
||||
'Tests passed\nAuthorization: Bearer secret-token'
|
||||
}
|
||||
@@ -1754,7 +1755,8 @@ describe('ContinueHostAdapter', () => {
|
||||
name: 'Bash',
|
||||
state: 'running',
|
||||
input:
|
||||
'{"command":"npm test","token":"secret-token"}'
|
||||
'{"command":"npm test","token":"secret-token"}',
|
||||
summary: 'npm test'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1762,9 +1764,11 @@ describe('ContinueHostAdapter', () => {
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
state: toolState,
|
||||
output:
|
||||
'Tests passed\nAuthorization: Bearer secret-token'
|
||||
'Tests passed\nAuthorization: Bearer secret-token',
|
||||
input: '{"command":"npm test","token":"secret-token"}',
|
||||
summary: 'npm test'
|
||||
}
|
||||
},
|
||||
{ type: 'text', delta: 'TOOLS_OK' }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import spawn from 'cross-spawn'
|
||||
import { toolOperationSummary } from './tool-operation-summary'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import {
|
||||
copyFile,
|
||||
@@ -112,6 +113,7 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
|
||||
name: z.string().min(1).max(200),
|
||||
state: z.enum(['running', 'completed', 'failed']),
|
||||
input: z.string().max(4_000).optional(),
|
||||
summary: z.string().max(240).optional(),
|
||||
output: z.string().max(16_000).optional(),
|
||||
error: z.string().max(1_000).optional()
|
||||
})
|
||||
@@ -181,6 +183,7 @@ export type ContinueHostTool = {
|
||||
name: string
|
||||
state: 'pending' | 'running' | 'completed' | 'failed'
|
||||
input?: string
|
||||
summary?: string
|
||||
output?: string
|
||||
error?: string
|
||||
}
|
||||
@@ -729,6 +732,10 @@ function extractContinueTools(
|
||||
callId,
|
||||
name: name.trim().slice(0, 200),
|
||||
state: normalizedState,
|
||||
summary: toolOperationSummary(
|
||||
(toolFunction as Record<string, unknown>).arguments,
|
||||
state.title
|
||||
),
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {}),
|
||||
...(error ? { error } : {})
|
||||
@@ -749,6 +756,7 @@ function mergeContinueTools(
|
||||
...previous,
|
||||
...tool,
|
||||
input: tool.input ?? previous?.input,
|
||||
summary: tool.summary ?? previous?.summary,
|
||||
output: tool.output ?? previous?.output,
|
||||
error: tool.error ?? previous?.error
|
||||
})
|
||||
@@ -976,6 +984,11 @@ export class ContinueHostAdapter {
|
||||
serverStateMarker,
|
||||
'pendingPermission:null,goodbuddyEvents:[],goodbuddyEventsBytes:0,goodbuddyEventsOverflow:!1},B='
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
'state:"running",input:(()=>',
|
||||
`state:"running",summary:(${toolOperationSummary.toString()})(l),input:(()=>`
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
serverStateEndpointMarker,
|
||||
@@ -1682,6 +1695,7 @@ export class ContinueHostAdapter {
|
||||
callId: event.callId,
|
||||
name: event.name,
|
||||
state: event.state,
|
||||
summary: toolOperationSummary(event.input, event.summary),
|
||||
...(event.input
|
||||
? { input: boundedToolDetail(event.input, 4_000) }
|
||||
: {}),
|
||||
@@ -1693,7 +1707,10 @@ export class ContinueHostAdapter {
|
||||
: {})
|
||||
}
|
||||
observedTools = mergeContinueTools(observedTools, [tool])
|
||||
await runOptions.onEvent?.({ type: 'tool', tool })
|
||||
await runOptions.onEvent?.({
|
||||
type: 'tool',
|
||||
tool: observedTools.find((item) => item.callId === tool.callId)!
|
||||
})
|
||||
}
|
||||
const pendingQuestion = state.goodbuddyQuestion
|
||||
if (
|
||||
|
||||
@@ -464,6 +464,34 @@ describe('ContinueAgentRuntime', () => {
|
||||
expect(gateway.grantCustomMcp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([undefined, 'existing-capability'])('cleans up image capabilities after custom MCP preparation fails (existing=%s)', async existingToken => {
|
||||
const gateway = {
|
||||
getEndpoint: () => 'http://127.0.0.1:4567/mcp',
|
||||
bindImageTool: vi.fn(() => existingToken ?? 'image-capability'),
|
||||
grantCustomMcp: vi.fn(() => 'custom-capability'),
|
||||
prepareCustomMcpTools: vi.fn(async () => { throw new Error('Custom MCP unavailable') }),
|
||||
revoke: vi.fn()
|
||||
}
|
||||
const context = { requestId: randomUUID(), conversationId: 'conversation-1', messageId: 'message-1', workMode: 'execute' as const }
|
||||
const runtime = new ContinueAgentRuntime({
|
||||
binaryPath: '', configPath: 'C:\\safe config\\continue.yaml', defaultWorkspace: process.cwd(),
|
||||
hostCacheRoot: 'C:\\safe\\continue-host',
|
||||
knowledgeGateway: gateway as unknown as KnowledgeMcpGateway,
|
||||
mcpServers: [{ id: randomUUID(), name: 'Custom', description: '', enabled: true, allowDynamicTools: false,
|
||||
assignments: ['continue'], secretConfigured: false, transport: 'http', url: 'https://mcp.test' }],
|
||||
createHostAdapter: () => ({ getPreparedHost: mocks.prepareHost, run: mocks.runHost, dispose: mocks.disposeHost })
|
||||
})
|
||||
try {
|
||||
await expect((async () => {
|
||||
for await (const event of runtime.run({ ...context, prompt: 'Draw an image', knowledgeCapabilityToken: existingToken,
|
||||
imageToolBinding: { context, describe: async () => 'Images', call: vi.fn() }
|
||||
}, new AbortController().signal)) void event
|
||||
})()).rejects.toThrow('Custom MCP unavailable')
|
||||
expect(gateway.revoke.mock.calls).toEqual(existingToken ? [['custom-capability']] : [['custom-capability'], ['image-capability']])
|
||||
expect(mocks.runHost).not.toHaveBeenCalled()
|
||||
} finally { await runtime.dispose() }
|
||||
})
|
||||
|
||||
it('adds assigned Skill instructions to the Continue prompt', async () => {
|
||||
let hostOptions: ContinueHostAdapterOptions | undefined
|
||||
const runtime = new ContinueAgentRuntime({
|
||||
@@ -1005,7 +1033,7 @@ describe('ContinueAgentRuntime', () => {
|
||||
type: 'tool',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
summary: 'Continue 工具:Bash',
|
||||
summary: 'npm test',
|
||||
input: '{"command":"npm test"}',
|
||||
output: 'Tests passed'
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { toolOperationSummary } from './tool-operation-summary'
|
||||
import type {
|
||||
AgentQuestionAnswer,
|
||||
AgentEvent,
|
||||
@@ -88,7 +89,7 @@ function toContinueToolEvent(
|
||||
: terminalize && tool.state !== 'completed'
|
||||
? 'failed'
|
||||
: tool.state,
|
||||
summary: `Continue 工具:${tool.name}`,
|
||||
summary: toolOperationSummary(tool.input, tool.summary) ?? `Continue 工具:${tool.name}`,
|
||||
...(tool.input ? { input: tool.input } : {}),
|
||||
...(tool.output ? { output: tool.output } : {}),
|
||||
...(tool.error ? { error: tool.error } : {})
|
||||
@@ -454,48 +455,46 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
const execute = request.workMode === 'execute'
|
||||
const imageCapabilityToken = request.imageToolBinding && execute
|
||||
? this.options.knowledgeGateway?.bindImageTool(request.imageToolBinding, signal, request.knowledgeCapabilityToken)
|
||||
: undefined
|
||||
const knowledgeEndpoint = this.options.knowledgeGateway?.getEndpoint()
|
||||
const knowledgeCapability =
|
||||
request.knowledgeCapabilityToken && knowledgeEndpoint
|
||||
(imageCapabilityToken ?? request.knowledgeCapabilityToken) && knowledgeEndpoint
|
||||
? {
|
||||
endpoint: knowledgeEndpoint,
|
||||
token: request.knowledgeCapabilityToken
|
||||
token: (imageCapabilityToken ?? request.knowledgeCapabilityToken)!
|
||||
}
|
||||
: undefined
|
||||
let customMcpCapability:
|
||||
| { endpoint: string; token: string }
|
||||
| undefined
|
||||
if (
|
||||
execute &&
|
||||
knowledgeEndpoint &&
|
||||
this.options.mcpServers?.length
|
||||
) {
|
||||
const token = this.options.knowledgeGateway?.grantCustomMcp(
|
||||
request.requestId,
|
||||
this.options.mcpServers,
|
||||
signal
|
||||
)
|
||||
if (token) {
|
||||
customMcpCapability = {
|
||||
endpoint: knowledgeEndpoint,
|
||||
token
|
||||
}
|
||||
try {
|
||||
await this.options.knowledgeGateway?.prepareCustomMcpTools(
|
||||
token,
|
||||
signal
|
||||
)
|
||||
} catch (error) {
|
||||
this.options.knowledgeGateway?.revoke(token)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
let result: ContinueHostRunResult
|
||||
const emittedTools = new Map<string, ContinueHostTool>()
|
||||
let emittedText = ''
|
||||
const requestQuestionIds = new Set<string>()
|
||||
try {
|
||||
if (
|
||||
execute &&
|
||||
knowledgeEndpoint &&
|
||||
this.options.mcpServers?.length
|
||||
) {
|
||||
const token = this.options.knowledgeGateway?.grantCustomMcp(
|
||||
request.requestId,
|
||||
this.options.mcpServers,
|
||||
signal
|
||||
)
|
||||
if (token) {
|
||||
customMcpCapability = {
|
||||
endpoint: knowledgeEndpoint,
|
||||
token
|
||||
}
|
||||
await this.options.knowledgeGateway?.prepareCustomMcpTools(
|
||||
token,
|
||||
signal
|
||||
)
|
||||
}
|
||||
}
|
||||
const host = this.getHostAdapter(
|
||||
binaryPath,
|
||||
execute || knowledgeCapability ? 'agent' : 'chat'
|
||||
@@ -642,6 +641,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
customMcpCapability.token
|
||||
)
|
||||
}
|
||||
if (imageCapabilityToken && imageCapabilityToken !== request.knowledgeCapabilityToken) this.options.knowledgeGateway?.revoke(imageCapabilityToken)
|
||||
}
|
||||
if (!result.text) {
|
||||
throw new Error('Continue CLI 未返回内容')
|
||||
|
||||
@@ -134,6 +134,10 @@ describe('createAgentRuntime model compatibility', () => {
|
||||
|
||||
expect(runtime.runtimeId).toBe('deepseek-harness')
|
||||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
expect(deepseekHarnessLauncher).not.toHaveBeenCalled()
|
||||
await expect(runtime.testConnection!()).resolves.toMatchObject({
|
||||
available: false
|
||||
})
|
||||
expect(deepseekHarnessLauncher).toHaveBeenCalledWith(
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
type ModelRuntimeOptions
|
||||
} from './model-runtime'
|
||||
import { ContinueAgentRuntime } from './continue-runtime'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
import { OpenCodeRuntime, type OpenCodeRuntimeOptions } from './opencode-runtime'
|
||||
import type { LocalRuntimeRegistry } from './local-runtime-registry'
|
||||
import {
|
||||
DeepSeekHarnessRuntime,
|
||||
type DeepSeekHarnessRuntimeOptions
|
||||
@@ -57,6 +58,7 @@ const noModelTaskTools: ModelToolProviderLike = {
|
||||
}
|
||||
|
||||
export type AgentCapabilityContext = {
|
||||
localRuntimeRegistry?: LocalRuntimeRegistry
|
||||
skillInstructions?: string
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
mcpServers?: ResolvedMcpServer[]
|
||||
@@ -213,8 +215,8 @@ export function createAgentRuntime(
|
||||
if (!capabilities.deepseekHarnessLauncher) {
|
||||
throw new Error('DeepSeek Harness 受控 Host 启动器不可用')
|
||||
}
|
||||
return new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace,
|
||||
const options: DeepSeekHarnessRuntimeOptions = {
|
||||
defaultWorkspace: capabilities.localRuntimeRegistry ? defaultWorkspace : workspace,
|
||||
baseUrl: profile.baseUrl,
|
||||
model: profile.modelName,
|
||||
supportsImageInput: profile.supportsImageInput === true,
|
||||
@@ -225,16 +227,26 @@ export function createAgentRuntime(
|
||||
},
|
||||
skillPackages: capabilities.skillPackages,
|
||||
extensionPackages: capabilities.deepseekHarnessExtensions,
|
||||
toolProvider: new ModelToolProvider(
|
||||
getWorkspaceAccess(),
|
||||
capabilities.mcpServers,
|
||||
undefined,
|
||||
capabilities.knowledgeGateway,
|
||||
capabilities.webSearchEnabled === true,
|
||||
{},
|
||||
capabilities.launchEnvironmentProvider
|
||||
createToolProvider: (directory) => new ModelToolProvider(
|
||||
new LocalWorkspaceAccess(directory), capabilities.mcpServers, undefined,
|
||||
capabilities.knowledgeGateway, capabilities.webSearchEnabled === true,
|
||||
{}, capabilities.launchEnvironmentProvider
|
||||
)
|
||||
})
|
||||
}
|
||||
const create = () => new DeepSeekHarnessRuntime(options)
|
||||
if (!capabilities.localRuntimeRegistry) return create()
|
||||
const configuration = { ...options, defaultWorkspace: undefined, createToolProvider: undefined }
|
||||
return capabilities.localRuntimeRegistry.acquire(
|
||||
`deepseek-harness:${profile.id}`,
|
||||
{
|
||||
...configuration,
|
||||
mcpServers: capabilities.mcpServers,
|
||||
webSearchEnabled: capabilities.webSearchEnabled === true,
|
||||
launchEnvironment: capabilities.launchEnvironmentProvider?.()
|
||||
},
|
||||
workspace,
|
||||
create
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === 'continue') {
|
||||
@@ -293,7 +305,7 @@ export function createAgentRuntime(
|
||||
'OpenCode 独立模型连接仅支持文本对话协议,不支持图像生成协议'
|
||||
)
|
||||
}
|
||||
return new OpenCodeRuntime({
|
||||
const options: OpenCodeRuntimeOptions = {
|
||||
baseUrl,
|
||||
embedded,
|
||||
binaryPath:
|
||||
@@ -311,13 +323,25 @@ export function createAgentRuntime(
|
||||
skillInstructions: capabilities.skillInstructions,
|
||||
skillPackages: capabilities.skillPackages,
|
||||
sharedCacheRoot: capabilities.opencodeSharedCacheRoot,
|
||||
defaultWorkspace: workspace,
|
||||
defaultWorkspace: capabilities.localRuntimeRegistry ? defaultWorkspace : workspace,
|
||||
knowledgeGateway: capabilities.knowledgeGateway,
|
||||
mcpServers: capabilities.mcpServers,
|
||||
customization: settings?.runtimeCustomization.opencode,
|
||||
launchEnvironmentProvider:
|
||||
capabilities.launchEnvironmentProvider
|
||||
})
|
||||
}
|
||||
const create = () => new OpenCodeRuntime(options)
|
||||
if (!capabilities.localRuntimeRegistry) return create()
|
||||
const configuration = { ...options, defaultWorkspace: undefined }
|
||||
return capabilities.localRuntimeRegistry.acquire(
|
||||
`opencode:${settings?.opencodeModelProfile?.id ?? 'native'}`,
|
||||
{
|
||||
...configuration,
|
||||
launchEnvironment: capabilities.launchEnvironmentProvider?.()
|
||||
},
|
||||
workspace,
|
||||
create
|
||||
)
|
||||
}
|
||||
|
||||
const defaultModelProfile =
|
||||
|
||||
@@ -35,6 +35,70 @@ import { DshNpmExtensionInstaller } from './dsh-extension-marketplace'
|
||||
import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol'
|
||||
import { createOpenAIChatCompletionsUrl } from './openai-endpoint'
|
||||
import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
|
||||
import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
|
||||
import { SelectedRuntimeManager } from './selected-runtime-manager'
|
||||
import { LocalRuntimeRegistry } from './local-runtime-registry'
|
||||
import { createAgentRuntime } from './create-runtime'
|
||||
import { defaultRuntimeSettings } from '../../shared/contracts'
|
||||
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import { ImageGenerationService } from './image-generation-service'
|
||||
|
||||
vi.mock('electron', () => ({ nativeImage: {} }))
|
||||
|
||||
it('generates and edits durable images through the controlled Harness Main proxy and refreshes it in Ask', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-images-')))
|
||||
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
|
||||
database.initialize(root)
|
||||
const conversationId = crypto.randomUUID()
|
||||
const profileId = crypto.randomUUID()
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: 'iVBORw0KGgo=' }] }))
|
||||
const service = new ImageGenerationService({ database, fetcher, getSettings: async () => ({ defaultImageModelProfileId: profileId, modelProfiles: [{
|
||||
id: profileId, name: 'Image fixture', modelName: 'fixture', protocol: 'openai-images-generations',
|
||||
allowConversationInvocation: true, baseUrl: 'https://image.test/v1', authentication: 'none'
|
||||
}] }) })
|
||||
let input: Record<string, unknown> = { intent: 'create', prompt: 'Blue circle' }
|
||||
const inProcess = createInProcessLaunch(root, {
|
||||
stream(options) {
|
||||
const prompt = latestUserText(options)
|
||||
const tool = options.tools?.find(tool => tool.name === 'generate_image')
|
||||
if (prompt === 'ask-image') {
|
||||
expect(tool).toBeUndefined()
|
||||
return textResponse('Ask is read-only')
|
||||
}
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool?.description).toContain(profileId)
|
||||
if (!toolResultText(options, prompt)) return toolCall(prompt, 'generate_image', input)
|
||||
expect(toolResultText(options, prompt)).toContain('completed')
|
||||
return textResponse('IMAGE_SAVED')
|
||||
}
|
||||
})
|
||||
const runtime = new DeepSeekHarnessRuntime({ defaultWorkspace: root, baseUrl: 'https://chat.test/v1', model: 'fixture',
|
||||
launch: inProcess.launch, credentialRefs: { [CREDENTIAL_REF]: 'fixture' }, toolProvider: new ModelToolProvider(root),
|
||||
initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000 })
|
||||
try {
|
||||
for (const intent of ['create', 'edit'] as const) {
|
||||
const messageId = crypto.randomUUID()
|
||||
const requestId = crypto.randomUUID()
|
||||
database.saveLocalConversations([{ header: { id: conversationId, title: 'Images', updatedAt: Date.now() },
|
||||
messages: [{ id: messageId, role: 'assistant', content: '', createdAt: Date.now(), state: 'complete' }] }])
|
||||
const events = await collect(runtime.run({ requestId, conversationId, prompt: `${intent}-${requestId}`, workMode: 'execute',
|
||||
imageToolBinding: service.bind({ conversationId, messageId, requestId, workMode: 'execute' }) }, new AbortController().signal, async () => 'once'))
|
||||
expect(events.at(-1)?.type).toBe('done')
|
||||
const operation = database.getConversation(conversationId).messages.find(message => message.id === messageId)!.imageOperations![0]!
|
||||
expect(operation.state).toBe('completed')
|
||||
input = { intent: 'edit', prompt: 'Turn red', sourceArtifactIds: operation.artifactIds }
|
||||
}
|
||||
await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId, prompt: 'ask-image', workMode: 'ask' }, new AbortController().signal))
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(String(fetcher.mock.calls[1]![0])).toMatch(/images\/edits$/u)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await service.dispose()
|
||||
database.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
|
||||
const SKILL_CALL_ID = 'e2e-skill-call'
|
||||
@@ -383,6 +447,221 @@ function createInProcessLaunch(
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
it.each([2, 10])('shares one production-composed Host across %i projects with independent native file and shell tools', async (projectCount) => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-reuse-')))
|
||||
const directories = Array.from(
|
||||
{ length: projectCount }, (_, index) => join(root, `project-${index}`)
|
||||
)
|
||||
await Promise.all(directories.map(async (directory, index) => {
|
||||
await mkdir(directory)
|
||||
await writeFile(join(directory, 'marker.txt'), `PROJECT_${index}`)
|
||||
}))
|
||||
const inProcess = createInProcessLaunch(root, {
|
||||
stream(options) {
|
||||
const id = latestUserText(options)
|
||||
const read = toolResultText(options, `${id}-read`)
|
||||
if (read === undefined) return toolCall(`${id}-read`, 'read', { file_path: 'marker.txt' })
|
||||
const write = toolResultText(options, `${id}-write`)
|
||||
if (write === undefined) return toolCall(`${id}-write`, 'write', {
|
||||
file_path: `${id}.txt`, content: read
|
||||
})
|
||||
const shell = toolResultText(options, `${id}-shell`)
|
||||
if (shell === undefined) return toolCall(`${id}-shell`, process.platform === 'win32' ? 'pwsh' : 'bash', {
|
||||
command: process.platform === 'win32' ? 'Get-Content marker.txt' : 'cat marker.txt',
|
||||
description: 'Read the session workspace fixture'
|
||||
})
|
||||
return textResponse(shell)
|
||||
}
|
||||
})
|
||||
const launch = vi.fn(inProcess.launch)
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const resolver = new ExecutionSpaceResolver()
|
||||
const profile = {
|
||||
id: '00000000-0000-4000-8000-000000000009',
|
||||
name: 'Reuse fixture', baseUrl: 'http://127.0.0.1:9',
|
||||
modelName: 'reuse', protocol: 'openai-chat-completions' as const,
|
||||
authentication: 'api-key' as const, apiKey: 'unused-deterministic-model'
|
||||
}
|
||||
const settings = {
|
||||
...defaultRuntimeSettings, provider: 'deepseek-harness',
|
||||
modelProfiles: [profile], deepseekHarnessModelProfile: profile
|
||||
} as ResolvedRuntimeSettings
|
||||
const manager = new SelectedRuntimeManager(async (_selection, space) =>
|
||||
createAgentRuntime(root, settings, {
|
||||
executionSpace: space, localRuntimeRegistry: registry,
|
||||
deepseekHarnessLauncher: launch
|
||||
}), 1, 1, undefined, undefined, (id) => registry.releaseConversation(id))
|
||||
try {
|
||||
const selection = { provider: 'deepseek-harness' as const }
|
||||
for (let wave = 0; wave < 2; wave++) {
|
||||
const outputs = await Promise.all(directories.flatMap((directory, project) =>
|
||||
[0, 1].map(async (conversation) => {
|
||||
const runtime = await manager.getRuntime(selection, resolver.resolveLocal(directory))
|
||||
const id = `p${project}-c${conversation}-w${wave}`
|
||||
const events = await collect(runtime.run({
|
||||
requestId: id, conversationId: `p${project}-c${conversation}`,
|
||||
prompt: id, workMode: 'execute'
|
||||
}, AbortSignal.timeout(20_000)))
|
||||
expect(events.at(-1)?.type).toBe('done')
|
||||
expect(await readFile(join(directory, `${id}.txt`), 'utf8')).toContain(`PROJECT_${project}`)
|
||||
expect(events.filter((event) => event.type === 'text').map((event) => event.delta).join(''))
|
||||
.toContain(`PROJECT_${project}`)
|
||||
return events
|
||||
})
|
||||
))
|
||||
expect(outputs).toHaveLength(projectCount * 2)
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
}
|
||||
await manager.releaseConversation('p0-c0')
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
expect(inProcess.hosts).toHaveLength(1)
|
||||
} finally {
|
||||
await manager.dispose()
|
||||
await registry.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('reuses a working runtime for mixed-separator and canonical Windows paths', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-path-')))
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'home')
|
||||
await Promise.all([mkdir(workspace), mkdir(dshHome)])
|
||||
const canonical = await realpath(workspace)
|
||||
const mixed = `${root.replaceAll('\\', '/')}\\workspace`
|
||||
const resolver = new ExecutionSpaceResolver()
|
||||
const inProcess = createInProcessLaunch(dshHome, {
|
||||
stream: () => textResponse('PATH_OK')
|
||||
})
|
||||
const launch = vi.fn(inProcess.launch)
|
||||
const manager = new SelectedRuntimeManager(async (_selection, space) => {
|
||||
if (space?.kind !== 'local') throw new Error('local workspace required')
|
||||
return new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: space.rootPath, baseUrl: 'https://api.deepseek.com',
|
||||
model: 'path-test', launch,
|
||||
credentialRefs: { [CREDENTIAL_REF]: 'unused-in-memory-model-credential' },
|
||||
initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000
|
||||
})
|
||||
})
|
||||
try {
|
||||
expect(mixed).not.toBe(canonical)
|
||||
expect(await realpath(mixed)).toBe(canonical)
|
||||
const first = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(mixed))
|
||||
for (const [index, spelling] of [mixed, canonical].entries()) {
|
||||
const runtime = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(spelling))
|
||||
expect(runtime).toBe(first)
|
||||
const events = await collect(runtime.run({
|
||||
requestId: `path-${index}`, conversationId: `path-${index}`,
|
||||
workMode: 'ask', prompt: 'Return PATH_OK.'
|
||||
}, AbortSignal.timeout(20_000)))
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
expect(events.filter(event => event.type === 'text').map(event => event.delta).join('')).toBe('PATH_OK')
|
||||
}
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
expect(launch.mock.calls[0]?.[0].cwd).toBe(canonical)
|
||||
} finally {
|
||||
await manager.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps native Skills, plugin inventory, MCP calls and images scoped across shared projects', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-shared-capabilities-')))
|
||||
const directories = [join(root, 'one'), join(root, 'two')]
|
||||
await Promise.all(directories.map(directory => mkdir(directory)))
|
||||
const plugin = join(root, 'inventory-plugin.mjs')
|
||||
await writeFile(plugin, [
|
||||
"export const name = 'shared-inventory'",
|
||||
"export const inject = ['skills']",
|
||||
'export function apply(ctx) {',
|
||||
"ctx.skills.register({ name: 'shared-plugin-skill', description: 'Shared plugin fixture', content: '# Shared plugin', source: 'custom' })",
|
||||
'}'
|
||||
].join('\n'))
|
||||
const models = [new FakeGameModel(), new FakeGameModel()]
|
||||
const images = [createCanvas(1, 1).toBuffer('image/png'), createCanvas(2, 1).toBuffer('image/png')]
|
||||
const seenImages = new Map<number, GenerateOptions['messages'][number]['content']>()
|
||||
const inProcess = createInProcessLaunch(root, {
|
||||
stream(options) {
|
||||
const project = latestUserText(options).includes('PROJECT_1') ? 1 : 0
|
||||
seenImages.set(project, options.messages.flatMap(message => message.content).filter(block => block.type === 'image'))
|
||||
return models[project]!.stream(options)
|
||||
}
|
||||
})
|
||||
const launch = vi.fn(inProcess.launch)
|
||||
const calls = vi.spyOn(ModelToolProvider.prototype, 'callTool')
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const profile = {
|
||||
id: '00000000-0000-4000-8000-000000000010',
|
||||
name: 'Capabilities fixture', baseUrl: 'http://127.0.0.1:9',
|
||||
modelName: 'capabilities', protocol: 'openai-chat-completions' as const,
|
||||
authentication: 'api-key' as const, apiKey: 'unused-deterministic-model',
|
||||
supportsImageInput: true
|
||||
}
|
||||
const resolver = new ExecutionSpaceResolver()
|
||||
const runtimes = directories.map(directory => createAgentRuntime(root, {
|
||||
...defaultRuntimeSettings, provider: 'deepseek-harness',
|
||||
modelProfiles: [profile], deepseekHarnessModelProfile: profile
|
||||
} as ResolvedRuntimeSettings, {
|
||||
executionSpace: resolver.resolveLocal(directory), localRuntimeRegistry: registry,
|
||||
deepseekHarnessLauncher: launch,
|
||||
skillPackages: [{ id: 'web-3d-game', directory: resolve('tests/fixtures/web-3d-game-skill') }],
|
||||
deepseekHarnessExtensions: [{ id: 'shared-inventory', entrypoint: plugin, configuration: {} }],
|
||||
mcpServers: [{
|
||||
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54', name: 'Shared blueprint', description: '',
|
||||
enabled: true, allowDynamicTools: false, assignments: ['deepseek-harness'],
|
||||
secretConfigured: false, transport: 'stdio',
|
||||
command: process.versions.electron ? 'node' : process.execPath,
|
||||
args: [resolve('tests/fixtures/web-3d-game-mcp.mjs')]
|
||||
}]
|
||||
}))
|
||||
try {
|
||||
for (const mode of ['execute', 'ask'] as const) {
|
||||
await Promise.all(runtimes.map(async (runtime, project) => {
|
||||
const events = await collect(runtime.run({
|
||||
requestId: `capabilities-${project}-${mode}`, conversationId: `capabilities-${project}`,
|
||||
workMode: mode,
|
||||
prompt: `${mode === 'ask' ? 'ASK_BOUNDARY_PROBE' : 'Use Skill and MCP'} PROJECT_${project}`,
|
||||
...(mode === 'execute' ? { images: [{
|
||||
name: `project-${project}.png`, mediaType: 'image/png', data: images[project]!.toString('base64')
|
||||
}] } : {})
|
||||
}, AbortSignal.timeout(30_000), async () => 'once'))
|
||||
expect(events.at(-1)?.type).toBe('done')
|
||||
const model = models[project]!
|
||||
if (mode === 'execute') {
|
||||
expect(model.skillResult).toContain('window.__GOODBUDDY_GAME__')
|
||||
expect(model.blueprint).toMatchObject({ title: 'Prism Relay' })
|
||||
const seen = seenImages.get(project)!
|
||||
expect(seen).toHaveLength(1)
|
||||
const image = seen[0]!
|
||||
if (image.type !== 'image') throw new Error('Expected image block')
|
||||
expect(image.attachment).toMatchObject({ width: project + 1, height: 1 })
|
||||
const stored = await inProcess.hosts[0]!.context.attachments.readImage(image.attachment)
|
||||
expect(Buffer.from(stored.data).equals(images[project]!)).toBe(true)
|
||||
} else {
|
||||
expect(model.askToolNames).not.toContain(model.mcpToolName)
|
||||
expect(model.askToolResult).toContain('Ask 模式不允许执行非只读工具')
|
||||
}
|
||||
expect(await runtime.getNativeSnapshot?.()).toMatchObject({
|
||||
skills: expect.arrayContaining([expect.objectContaining({ id: 'shared-plugin-skill', source: 'plugin' })])
|
||||
})
|
||||
}))
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
expect(calls).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
expect(new Set(calls.mock.contexts).size).toBe(2)
|
||||
expect(calls.mock.calls.map(call => call[3]?.conversationId).sort())
|
||||
.toEqual(['capabilities-0', 'capabilities-1'])
|
||||
await runtimes[0]!.releaseConversation?.('capabilities-0')
|
||||
expect(await runtimes[1]!.getStatus()).toMatchObject({ available: true })
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
calls.mockRestore()
|
||||
await Promise.all(runtimes.map(runtime => runtime.dispose()))
|
||||
await registry.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-')))
|
||||
const workspace = join(root, 'workspace')
|
||||
@@ -735,7 +1014,7 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
)
|
||||
|
||||
try {
|
||||
await runtime.getStatus()
|
||||
await runtime.testConnection()
|
||||
expect(inProcess.hosts[0]?.extensionFailures).toEqual([])
|
||||
await expect(runtime.getNativeSnapshot()).resolves.toMatchObject({
|
||||
provider: 'deepseek-harness',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import type { RuntimeEvent } from './runtime'
|
||||
import {
|
||||
ModelToolProvider,
|
||||
@@ -243,7 +244,7 @@ function setup(
|
||||
})
|
||||
)
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: 'C:\\workspace',
|
||||
defaultWorkspace: process.cwd(),
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
@@ -442,6 +443,16 @@ function toolProvider(
|
||||
}
|
||||
|
||||
describe('DeepSeekHarnessRuntime', () => {
|
||||
it('reports passive readiness without launching a Host or reading a model', async () => {
|
||||
const harness = setup()
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
available: true, detail: expect.stringContaining('首次使用')
|
||||
})
|
||||
expect(harness.launch).not.toHaveBeenCalled()
|
||||
await harness.runtime.dispose()
|
||||
expect(harness.launch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('includes bounded failed-extension cleanup in the startup budget', () => {
|
||||
expect(deepSeekHarnessStartupBudget(11)).toEqual({
|
||||
hostTimeoutMs: 76_000,
|
||||
@@ -473,7 +484,8 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const status = harness.runtime.getStatus()
|
||||
const status = harness.runtime.testConnection()
|
||||
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(10_001)
|
||||
|
||||
await expect(status).resolves.toMatchObject({
|
||||
@@ -502,7 +514,8 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const status = harness.runtime.getStatus()
|
||||
const status = harness.runtime.testConnection()
|
||||
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(12_000)
|
||||
|
||||
await expect(status).resolves.toMatchObject({
|
||||
@@ -525,7 +538,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const status = harness.runtime.getStatus()
|
||||
const status = harness.runtime.testConnection()
|
||||
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
|
||||
|
||||
await harness.runtime.dispose()
|
||||
@@ -630,7 +643,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
advertisedImageInput: false
|
||||
})
|
||||
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
|
||||
available: false,
|
||||
detail: expect.stringContaining('图片能力')
|
||||
})
|
||||
@@ -674,7 +687,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
harness.child.stdout
|
||||
)
|
||||
expect(harness.launch).toHaveBeenCalledWith({
|
||||
cwd: 'C:\\workspace',
|
||||
cwd: await realpath(process.cwd()),
|
||||
signal: expect.any(AbortSignal),
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-test',
|
||||
@@ -710,7 +723,12 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('keeps the original tool name on generic completion updates', async () => {
|
||||
it.each([
|
||||
['completed', undefined, 'GoodBuddy'],
|
||||
['failed', undefined, 'GoodBuddy'],
|
||||
['completed', 'Search the release notes', 'Search the release notes'],
|
||||
['failed', 'Search the release notes', 'Search the release notes']
|
||||
] as const)('keeps tool input and summary on incremental %s updates with title %s', async (status, title, summary) => {
|
||||
const harness = setup()
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
@@ -725,6 +743,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-web-search',
|
||||
name: 'web_search',
|
||||
title,
|
||||
status: 'pending',
|
||||
rawInput: { query: 'GoodBuddy' }
|
||||
})
|
||||
@@ -732,7 +751,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-web-search',
|
||||
name: 'tool',
|
||||
status: 'completed',
|
||||
status,
|
||||
rawOutput: 'search result'
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
@@ -743,13 +762,16 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
type: 'tool',
|
||||
callId: 'call-web-search',
|
||||
name: 'web_search',
|
||||
state: 'pending'
|
||||
state: 'pending',
|
||||
summary
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-web-search',
|
||||
name: 'web_search',
|
||||
state: 'completed'
|
||||
state: status,
|
||||
summary,
|
||||
input: expect.stringContaining('GoodBuddy')
|
||||
})
|
||||
])
|
||||
)
|
||||
@@ -892,11 +914,14 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
}
|
||||
])
|
||||
const harness = setup({ toolProvider: provider })
|
||||
await harness.runtime.getStatus()
|
||||
const running = collect(harness.runtime.run(
|
||||
request('catalog', 'execute'), new AbortController().signal
|
||||
))
|
||||
await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1))
|
||||
|
||||
await expect(
|
||||
harness.extension('goodbuddy/tools/list', {
|
||||
sessionId: 'session-catalog'
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
tools: [
|
||||
@@ -926,10 +951,12 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
expect(
|
||||
JSON.stringify(
|
||||
await harness.extension('goodbuddy/tools/list', {
|
||||
sessionId: 'session-catalog'
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
)
|
||||
).not.toContain('secret')
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -1384,8 +1411,9 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('bounds cancellation when the harness prompt never settles', async () => {
|
||||
const harness = setup({ useProductionPromptTimeout: true })
|
||||
it('releases an uncooperative cancelled session without terminating its peer', async () => {
|
||||
const provider = toolProvider()
|
||||
const harness = setup({ useProductionPromptTimeout: true, toolProvider: provider })
|
||||
const controller = new AbortController()
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
@@ -1397,6 +1425,10 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
expect(harness.promptGates).toHaveLength(1)
|
||||
)
|
||||
|
||||
const peer = collect(harness.runtime.run(
|
||||
request('cancel-peer'), new AbortController().signal
|
||||
))
|
||||
await vi.waitFor(() => expect(harness.promptGates).toHaveLength(2))
|
||||
controller.abort(new Error('cancelled by user'))
|
||||
|
||||
await expect(running).rejects.toThrow('cancelled by user')
|
||||
@@ -1404,7 +1436,14 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
method: 'session/cancel',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
expect(harness.child.terminate).toHaveBeenCalled()
|
||||
expect(harness.requests).toContainEqual({
|
||||
method: 'goodbuddy/session/release',
|
||||
params: { sessionId: 'session-1' }
|
||||
})
|
||||
expect(harness.child.terminate).not.toHaveBeenCalled()
|
||||
expect(provider.releaseConversation).toHaveBeenCalledWith(request('uncooperative-cancel').conversationId)
|
||||
harness.promptGates[1]!.resolve({ stopReason: 'end_turn' })
|
||||
expect((await peer).at(-1)?.type).toBe('done')
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -1435,7 +1474,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
|
||||
it('reports process exit and fully disposes the connection and child', async () => {
|
||||
const harness = setup()
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true
|
||||
})
|
||||
harness.exit.resolve({ exitCode: 9 })
|
||||
@@ -1457,7 +1496,7 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
new Error('method not found')
|
||||
)
|
||||
|
||||
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
|
||||
await expect(harness.runtime.testConnection()).resolves.toMatchObject({
|
||||
available: false,
|
||||
detail: 'method not found'
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { toolOperationSummary } from './tool-operation-summary'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import type {
|
||||
AgentRuntimeStatus,
|
||||
ModelRequestHeaders,
|
||||
@@ -202,20 +204,26 @@ export type DeepSeekHarnessRuntimeOptions = {
|
||||
skillPackages?: RuntimeSkillPackage[]
|
||||
extensionPackages?: ControlledHarnessExtensionPackage[]
|
||||
toolProvider?: ModelToolProviderLike
|
||||
createToolProvider?: (workspace: string) => ModelToolProviderLike
|
||||
loadAcpSdk?: () => Promise<DeepSeekHarnessAcpSdk>
|
||||
}
|
||||
|
||||
type ActiveRun = {
|
||||
request: AgentExecutionRequest
|
||||
toolProvider?: ModelToolProviderLike
|
||||
toolController: AbortController
|
||||
authorize?: RuntimeAuthorizer
|
||||
updates: AcpSessionNotification['update'][]
|
||||
toolNames: Map<string, string>
|
||||
toolNames: Map<
|
||||
string,
|
||||
{ name: string; input?: string; summary?: string; title?: string }
|
||||
>
|
||||
wake?: () => void
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
type HarnessState = {
|
||||
workspace: string
|
||||
child: DeepSeekHarnessChild
|
||||
connection: AcpConnection
|
||||
agent: AcpAgent
|
||||
@@ -282,7 +290,7 @@ function isMainWebTool(
|
||||
)
|
||||
}
|
||||
|
||||
function dshCompatibleWebInputSchema(
|
||||
function dshCompatibleMainInputSchema(
|
||||
schema: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const compatible: Record<string, unknown> = {}
|
||||
@@ -303,7 +311,7 @@ function dshCompatibleWebInputSchema(
|
||||
propertySchema &&
|
||||
typeof propertySchema === 'object' &&
|
||||
!Array.isArray(propertySchema)
|
||||
? dshCompatibleWebInputSchema(
|
||||
? dshCompatibleMainInputSchema(
|
||||
propertySchema as Record<string, unknown>
|
||||
)
|
||||
: propertySchema
|
||||
@@ -317,7 +325,7 @@ function dshCompatibleWebInputSchema(
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
compatible.items = dshCompatibleWebInputSchema(
|
||||
compatible.items = dshCompatibleMainInputSchema(
|
||||
value as Record<string, unknown>
|
||||
)
|
||||
continue
|
||||
@@ -327,7 +335,7 @@ function dshCompatibleWebInputSchema(
|
||||
candidate &&
|
||||
typeof candidate === 'object' &&
|
||||
!Array.isArray(candidate)
|
||||
? dshCompatibleWebInputSchema(
|
||||
? dshCompatibleMainInputSchema(
|
||||
candidate as Record<string, unknown>
|
||||
)
|
||||
: candidate
|
||||
@@ -342,8 +350,8 @@ function proxyToolInputSchema(
|
||||
ReturnType<ModelToolProviderLike['listTools']>
|
||||
>[number]
|
||||
): Record<string, unknown> {
|
||||
return isMainWebTool(tool)
|
||||
? dshCompatibleWebInputSchema(tool.inputSchema)
|
||||
return isMainWebTool(tool) || tool.name === 'generate_image'
|
||||
? dshCompatibleMainInputSchema(tool.inputSchema)
|
||||
: tool.inputSchema
|
||||
}
|
||||
|
||||
@@ -360,7 +368,7 @@ function boundedProxyToolCatalog(
|
||||
const catalog = tools.filter(
|
||||
(tool) =>
|
||||
isMainWebTool(tool) ||
|
||||
(workMode === 'execute' && tool.source === 'mcp')
|
||||
(workMode === 'execute' && (tool.source === 'mcp' || tool.name === 'generate_image'))
|
||||
)
|
||||
if (catalog.length > MAX_MCP_PROXY_TOOLS) {
|
||||
throw new Error(
|
||||
@@ -378,7 +386,7 @@ function boundedProxyToolCatalog(
|
||||
names.add(tool.name)
|
||||
const description = tool.description.slice(
|
||||
0,
|
||||
MAX_MCP_TOOL_DESCRIPTION_CHARACTERS
|
||||
tool.name === 'generate_image' ? imageToolDescriptionLimit : MAX_MCP_TOOL_DESCRIPTION_CHARACTERS
|
||||
)
|
||||
const inputSchema = proxyToolInputSchema(tool)
|
||||
let serialized: string
|
||||
@@ -448,7 +456,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
private disposed = false
|
||||
private fatalError?: Error
|
||||
private stderrBytes = 0
|
||||
private readonly sessions = new Map<string, string>()
|
||||
private readonly sessions = new Map<string, { id: string; workspace: string }>()
|
||||
private readonly workspaceTools = new Map<string, ModelToolProviderLike>()
|
||||
private readonly proxyToolCatalogs = new Map<string, ModelToolDefinition[]>()
|
||||
private readonly sessionInitializations = new Map<
|
||||
string,
|
||||
@@ -778,9 +787,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
this.launchController = launchController
|
||||
let child: DeepSeekHarnessChild | undefined
|
||||
try {
|
||||
const workspace = await realpath(this.options.defaultWorkspace)
|
||||
child = await withTimeout(
|
||||
this.options.launch({
|
||||
cwd: this.options.defaultWorkspace,
|
||||
cwd: workspace,
|
||||
signal: launchController.signal,
|
||||
baseUrl: this.options.baseUrl,
|
||||
model: this.options.model,
|
||||
@@ -825,11 +835,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
'DeepSeek Harness MCP 工具上下文不可用'
|
||||
)
|
||||
}
|
||||
if (!this.options.toolProvider) {
|
||||
return { tools: [] }
|
||||
}
|
||||
const run = this.activeRuns.get(params.sessionId)
|
||||
if (!run?.toolProvider || run.closed) return { tools: [] }
|
||||
const context = {
|
||||
imageToolBinding: run.request.imageToolBinding,
|
||||
conversationId:
|
||||
run?.request.conversationId ??
|
||||
'deepseek-harness-tool-catalog',
|
||||
@@ -841,13 +850,13 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
knowledgeCapabilityToken:
|
||||
run?.request.knowledgeCapabilityToken
|
||||
}
|
||||
const tools = await this.options.toolProvider.listTools(
|
||||
const tools = await run.toolProvider.listTools(
|
||||
context,
|
||||
connection.signal
|
||||
)
|
||||
const catalog = boundedProxyToolCatalog(tools, context.workMode)
|
||||
this.proxyToolCatalogs.set(params.sessionId, tools.filter(
|
||||
(tool) => tool.source === 'mcp' || isMainWebTool(tool)
|
||||
(tool) => tool.source === 'mcp' || isMainWebTool(tool) || tool.name === 'generate_image'
|
||||
))
|
||||
return { tools: catalog }
|
||||
}
|
||||
@@ -862,7 +871,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
if (
|
||||
!run ||
|
||||
run.closed ||
|
||||
!this.options.toolProvider ||
|
||||
!run.toolProvider ||
|
||||
typeof name !== 'string' ||
|
||||
!argumentsValue ||
|
||||
typeof argumentsValue !== 'object' ||
|
||||
@@ -873,6 +882,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
)
|
||||
}
|
||||
const context = {
|
||||
imageToolBinding: run.request.imageToolBinding,
|
||||
...(name === 'generate_image' ? { toolCallId: typeof params.callId === 'string' ? params.callId : crypto.randomUUID() } : {}),
|
||||
conversationId: run.request.conversationId,
|
||||
browserTabId: run.request.browserTabId,
|
||||
workMode:
|
||||
@@ -886,7 +897,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
const tool = tools.find(
|
||||
(candidate) =>
|
||||
candidate.name === name &&
|
||||
(candidate.source === 'mcp' ||
|
||||
(candidate.source === 'mcp' || candidate.name === 'generate_image' ||
|
||||
isMainWebTool(candidate))
|
||||
)
|
||||
if (!tool) {
|
||||
@@ -928,7 +939,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
if (!isWebTool) {
|
||||
const approval =
|
||||
this.options.toolProvider.getApproval(
|
||||
run.toolProvider.getApproval(
|
||||
tool,
|
||||
argumentsValue as Record<string, unknown>,
|
||||
argumentSummary,
|
||||
@@ -943,7 +954,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
)
|
||||
}
|
||||
}
|
||||
const result = await this.options.toolProvider.callTool(
|
||||
const result = await run.toolProvider.callTool(
|
||||
name,
|
||||
argumentsValue as Record<string, unknown>,
|
||||
run.toolController.signal,
|
||||
@@ -977,6 +988,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
throw new Error('DeepSeek Harness ACP 客户端初始化失败')
|
||||
}
|
||||
const stateWithoutCapabilities = {
|
||||
workspace,
|
||||
child,
|
||||
connection,
|
||||
agent
|
||||
@@ -1083,6 +1095,35 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async getStatus(): Promise<AgentRuntimeStatus> {
|
||||
return {
|
||||
id: 'deepseek-harness',
|
||||
label: 'DeepSeek Harness',
|
||||
available: !this.disposed && !this.fatalError,
|
||||
supportsToolExecution: true,
|
||||
detail: this.disposed
|
||||
? 'DeepSeek Harness Runtime 已关闭'
|
||||
: this.fatalError?.message ??
|
||||
(this.state
|
||||
? `DeepSeek Harness ${this.state.capabilities.harnessVersion} · 当前用户权限`
|
||||
: 'DeepSeek Harness 已配置,将在首次使用时启动')
|
||||
}
|
||||
}
|
||||
|
||||
get hasRetainedSessions(): boolean {
|
||||
return this.sessions.size > 0 || this.sessionInitializations.size > 0
|
||||
}
|
||||
|
||||
private toolsFor(workspace: string): ModelToolProviderLike | undefined {
|
||||
if (!this.options.createToolProvider) return this.options.toolProvider
|
||||
let provider = this.workspaceTools.get(workspace)
|
||||
if (!provider) {
|
||||
provider = this.options.createToolProvider(workspace)
|
||||
this.workspaceTools.set(workspace, provider)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
async testConnection(): Promise<AgentRuntimeStatus> {
|
||||
try {
|
||||
await this.getState()
|
||||
return {
|
||||
@@ -1106,10 +1147,13 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
async getNativeSnapshot(): Promise<RuntimeNativeSnapshot> {
|
||||
async getNativeSnapshot(workspace?: string): Promise<RuntimeNativeSnapshot> {
|
||||
const state = await this.getState()
|
||||
const response = await withTimeout(
|
||||
state.agent.extMethod(GOODBUDDY_NATIVE_SNAPSHOT, {}),
|
||||
state.agent.extMethod(
|
||||
GOODBUDDY_NATIVE_SNAPSHOT,
|
||||
workspace ? { workspace } : {}
|
||||
),
|
||||
this.initializationTimeoutMs,
|
||||
'原生能力清单'
|
||||
)
|
||||
@@ -1294,11 +1338,15 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
|
||||
private async getSession(
|
||||
state: HarnessState,
|
||||
conversationId: string
|
||||
conversationId: string,
|
||||
workspace: string
|
||||
): Promise<string> {
|
||||
const current = this.sessions.get(conversationId)
|
||||
if (current?.workspace === workspace) {
|
||||
return current.id
|
||||
}
|
||||
if (current) {
|
||||
return current
|
||||
await this.releaseConversation(conversationId)
|
||||
}
|
||||
const pending = this.sessionInitializations.get(conversationId)
|
||||
if (pending) {
|
||||
@@ -1306,14 +1354,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
const creation = state.agent
|
||||
.newSession({
|
||||
cwd: this.options.defaultWorkspace,
|
||||
cwd: workspace,
|
||||
mcpServers: []
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.sessionId) {
|
||||
throw new Error('DeepSeek Harness 未返回 ACP 会话 ID')
|
||||
}
|
||||
this.sessions.set(conversationId, response.sessionId)
|
||||
this.sessions.set(conversationId, { id: response.sessionId, workspace })
|
||||
return response.sessionId
|
||||
})
|
||||
this.sessionInitializations.set(conversationId, creation)
|
||||
@@ -1336,7 +1384,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
private toRuntimeEvent(
|
||||
requestId: string,
|
||||
update: AcpSessionNotification['update'],
|
||||
toolNames: Map<string, string>
|
||||
toolNames: ActiveRun['toolNames']
|
||||
): RuntimeEvent | undefined {
|
||||
if (update.goodBuddyEvent) {
|
||||
return this.toUsageEvent(
|
||||
@@ -1378,16 +1426,23 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
'DeepSeek Harness 工具'
|
||||
).slice(0, 200)
|
||||
const callId = update.toolCallId.slice(0, 256)
|
||||
const previous = toolNames.get(callId)
|
||||
const name =
|
||||
reportedName === 'tool'
|
||||
? toolNames.get(callId) ?? reportedName
|
||||
reportedName === 'tool' || (!update.name && !update.title)
|
||||
? previous?.name ?? reportedName
|
||||
: reportedName
|
||||
const title = toolOperationSummary(
|
||||
undefined,
|
||||
update.title === 'tool' || update.title === update.name || update.title === previous?.name
|
||||
? undefined : update.title
|
||||
) ?? previous?.title
|
||||
const input = safeStringify(update.rawInput) ?? previous?.input
|
||||
const summary = toolOperationSummary(update.rawInput, title) ?? previous?.summary
|
||||
if (state === 'pending' || state === 'running') {
|
||||
toolNames.set(callId, name)
|
||||
toolNames.set(callId, { name, input, summary, title })
|
||||
} else {
|
||||
toolNames.delete(callId)
|
||||
}
|
||||
const input = safeStringify(update.rawInput)
|
||||
const output = safeStringify(update.rawOutput)
|
||||
return {
|
||||
requestId,
|
||||
@@ -1395,7 +1450,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
callId,
|
||||
name,
|
||||
state,
|
||||
summary: `DeepSeek Harness 工具:${name}`,
|
||||
summary: summary ?? `DeepSeek Harness 工具:${name}`,
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {})
|
||||
}
|
||||
@@ -1430,12 +1485,17 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
try {
|
||||
state = await this.getState()
|
||||
signal.throwIfAborted()
|
||||
const workspace = request.executionWorkspace
|
||||
? await realpath(request.executionWorkspace)
|
||||
: state.workspace
|
||||
sessionId = await this.getSession(
|
||||
state,
|
||||
request.conversationId
|
||||
request.conversationId,
|
||||
workspace
|
||||
)
|
||||
run = {
|
||||
request,
|
||||
toolProvider: this.toolsFor(workspace),
|
||||
toolController,
|
||||
authorize,
|
||||
updates: [],
|
||||
@@ -1571,7 +1631,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
this.shutdownTimeoutMs,
|
||||
'取消请求'
|
||||
).catch(async () => {
|
||||
await this.terminate(state!.child)
|
||||
await withTimeout(
|
||||
state!.agent.extMethod(GOODBUDDY_RELEASE, { sessionId }),
|
||||
this.shutdownTimeoutMs,
|
||||
'释放已取消会话'
|
||||
).catch(async () => {
|
||||
await this.terminate(state!.child)
|
||||
})
|
||||
await this.forgetSession(request.conversationId)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1594,25 +1661,39 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
const sessionId = this.sessions.get(conversationId)
|
||||
this.sessions.delete(conversationId)
|
||||
if (sessionId) this.proxyToolCatalogs.delete(sessionId)
|
||||
if (!sessionId || !this.state) {
|
||||
await this.options.toolProvider
|
||||
?.releaseConversation(conversationId)
|
||||
.catch(() => undefined)
|
||||
return
|
||||
await this.sessionInitializations.get(conversationId)?.catch(() => undefined)
|
||||
const session = this.sessions.get(conversationId)
|
||||
const sessionId = session?.id
|
||||
if (sessionId && this.state) {
|
||||
await this.state.agent
|
||||
.extMethod(GOODBUDDY_RELEASE, { sessionId })
|
||||
.catch(async () => {
|
||||
await this.state?.agent
|
||||
.cancel({ sessionId })
|
||||
.catch(() => undefined)
|
||||
})
|
||||
}
|
||||
await this.state.agent
|
||||
.extMethod(GOODBUDDY_RELEASE, { sessionId })
|
||||
.catch(async () => {
|
||||
await this.state?.agent
|
||||
.cancel({ sessionId })
|
||||
.catch(() => undefined)
|
||||
})
|
||||
await this.options.toolProvider
|
||||
await this.forgetSession(conversationId)
|
||||
}
|
||||
|
||||
private async forgetSession(conversationId: string): Promise<void> {
|
||||
const session = this.sessions.get(conversationId)
|
||||
const provider = session
|
||||
? this.workspaceTools.get(session.workspace) ?? this.options.toolProvider
|
||||
: this.options.toolProvider
|
||||
this.sessions.delete(conversationId)
|
||||
if (session) this.proxyToolCatalogs.delete(session.id)
|
||||
await provider
|
||||
?.releaseConversation(conversationId)
|
||||
.catch(() => undefined)
|
||||
if (
|
||||
session && provider &&
|
||||
this.workspaceTools.get(session.workspace) === provider &&
|
||||
![...this.sessions.values()].some((other) => other.workspace === session.workspace)
|
||||
) {
|
||||
this.workspaceTools.delete(session.workspace)
|
||||
await provider.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
@@ -1647,8 +1728,16 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
this.proxyToolCatalogs.clear()
|
||||
this.sessionInitializations.clear()
|
||||
this.conversationTails.clear()
|
||||
const providers = new Set([
|
||||
...this.workspaceTools.values(),
|
||||
...(this.options.toolProvider ? [this.options.toolProvider] : [])
|
||||
])
|
||||
this.workspaceTools.clear()
|
||||
const disposeTools = () => Promise.allSettled(
|
||||
[...providers].map((provider) => provider.dispose())
|
||||
)
|
||||
if (!state) {
|
||||
await this.options.toolProvider?.dispose().catch(() => undefined)
|
||||
await disposeTools()
|
||||
return
|
||||
}
|
||||
await withTimeout(
|
||||
@@ -1656,7 +1745,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
this.shutdownTimeoutMs,
|
||||
'内部控制面关闭'
|
||||
).catch(() => undefined)
|
||||
await this.options.toolProvider?.dispose().catch(() => undefined)
|
||||
await disposeTools()
|
||||
await this.terminate(state.child)
|
||||
await withTimeout(
|
||||
Promise.allSettled([
|
||||
@@ -1668,3 +1757,4 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
import { imageToolDescriptionLimit } from '../../shared/image-generation-contracts'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import {
|
||||
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
|
||||
DEEPSEEK_HARNESS_CONTROL_VERSION,
|
||||
@@ -33,6 +33,7 @@ async function fixture() {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-launcher-'))
|
||||
)
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'home')
|
||||
const hostPath = join(
|
||||
|
||||
@@ -221,7 +221,7 @@ function parseProxyToolCatalog(
|
||||
!/^[a-zA-Z0-9_-]{1,64}$/u.test(tool.name) ||
|
||||
names.has(tool.name) ||
|
||||
typeof tool.description !== 'string' ||
|
||||
tool.description.length > 1_000 ||
|
||||
tool.description.length > (tool.name === 'generate_image' ? imageToolDescriptionLimit : 1_000) ||
|
||||
!tool.inputSchema ||
|
||||
typeof tool.inputSchema !== 'object' ||
|
||||
Array.isArray(tool.inputSchema)
|
||||
@@ -885,6 +885,7 @@ export class GoodBuddyHarnessControlPlane {
|
||||
{
|
||||
sessionId,
|
||||
name: tool.name,
|
||||
callId: randomUUID(),
|
||||
arguments: argumentsValue
|
||||
}
|
||||
)
|
||||
@@ -926,7 +927,9 @@ export class GoodBuddyHarnessControlPlane {
|
||||
}
|
||||
}
|
||||
|
||||
private async nativeSnapshot(): Promise<Record<string, unknown>> {
|
||||
private async nativeSnapshot(
|
||||
workspace = this.config.workspace
|
||||
): Promise<Record<string, unknown>> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
@@ -938,7 +941,7 @@ export class GoodBuddyHarnessControlPlane {
|
||||
try {
|
||||
const skills = await Promise.race([
|
||||
this.ctx.skills.list({
|
||||
cwd: this.config.workspace,
|
||||
cwd: workspace,
|
||||
signal: controller.signal
|
||||
}),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
@@ -1132,12 +1135,11 @@ export class GoodBuddyHarnessControlPlane {
|
||||
}
|
||||
if (
|
||||
!isAbsolute(params.cwd) ||
|
||||
params.cwd !== this.config.workspace ||
|
||||
params.mcpServers.length > 0
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'the controlled workspace and no MCP servers are required'
|
||||
'an absolute workspace and no MCP servers are required'
|
||||
)
|
||||
}
|
||||
const sessionId = SessionId(randomUUID())
|
||||
@@ -1393,7 +1395,11 @@ export class GoodBuddyHarnessControlPlane {
|
||||
return { released: true }
|
||||
}
|
||||
if (method === GOODBUDDY_NATIVE_SNAPSHOT) {
|
||||
return this.nativeSnapshot()
|
||||
const workspace = params.workspace === undefined
|
||||
? this.config.workspace
|
||||
: requiredString(params, 'workspace')
|
||||
if (!isAbsolute(workspace)) throw RequestError.invalidParams(undefined, 'workspace must be absolute')
|
||||
return this.nativeSnapshot(workspace)
|
||||
}
|
||||
if (method === GOODBUDDY_SHUTDOWN) {
|
||||
await this.dispose()
|
||||
@@ -1445,3 +1451,4 @@ export class GoodBuddyHarnessControlPlane {
|
||||
await this.disposing
|
||||
}
|
||||
}
|
||||
import { imageToolDescriptionLimit } from '../../shared/image-generation-contracts'
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import { ImageGenerationService, type ImageServiceSettings, type ImageGenerationServiceOptions } from './image-generation-service'
|
||||
import { imageOperationSchema, type ImageOperation } from '../../shared/image-generation-contracts'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { ModelToolProvider } from './model-tool-provider'
|
||||
|
||||
vi.mock('electron', () => ({ nativeImage: { createFromBuffer: () => ({ isEmpty: () => false, toPNG: () => Buffer.from(png, 'base64') }) } }))
|
||||
const png = 'iVBORw0KGgo='
|
||||
const cleanups: (() => void | Promise<void>)[] = []
|
||||
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
|
||||
|
||||
function setup(fetcher = vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: png }] })), observers: Pick<ImageGenerationServiceOptions, 'onOperation' | 'onUsage' | 'onError'> = {}) {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
cleanups.push(() => database.close())
|
||||
const context = { conversationId: randomUUID(), messageId: randomUUID(), requestId: randomUUID(), workMode: 'execute' as const }
|
||||
const message = { id: context.messageId, role: 'assistant' as const, content: '', createdAt: Date.now(), state: 'complete' as const }
|
||||
const header = { id: context.conversationId, title: 'Images', updatedAt: Date.now() }
|
||||
database.saveLocalConversations([{ header, messages: [message] }])
|
||||
const profile = { id: randomUUID(), name: 'Image A', modelName: 'image-a', protocol: 'openai-images-generations',
|
||||
allowConversationInvocation: true, baseUrl: 'https://provider.test/v1', authentication: 'api-key' as const, apiKey: 'test-secret' }
|
||||
const settings: ImageServiceSettings = { modelProfiles: [profile], defaultImageModelProfileId: profile.id }
|
||||
const events: ImageOperation[] = []
|
||||
const service = new ImageGenerationService({ database, getSettings: async () => settings, fetcher,
|
||||
onOperation: operation => { imageOperationSchema.parse(operation); events.push(operation); observers.onOperation?.(operation) }, onUsage: observers.onUsage, onError: observers.onError })
|
||||
service.initialize()
|
||||
cleanups.push(() => service.dispose())
|
||||
return { database, service, context, fetcher, settings, profile, events, header, message }
|
||||
}
|
||||
|
||||
describe('Main conversation image service', () => {
|
||||
it('does not let a destroyed Renderer or usage observer prevent submission or overwrite completion', async () => {
|
||||
const onError = vi.fn()
|
||||
const h = setup(vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: png }], usage: { input_tokens: 2, output_tokens: 3 } })), {
|
||||
onOperation: () => { throw new Error('Renderer is destroyed') },
|
||||
onUsage: () => { throw new Error('Usage observer failed') }, onError
|
||||
})
|
||||
const operation = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'observer')
|
||||
expect(operation.state).toBe('completed')
|
||||
expect(h.service.getOperation(h.context.conversationId, operation.id).state).toBe('completed')
|
||||
expect(h.fetcher).toHaveBeenCalledOnce()
|
||||
expect(onError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the known persistence failure instead of a stale running state when the database stops accepting writes', async () => {
|
||||
const onError = vi.fn()
|
||||
const h = setup(undefined, { onError })
|
||||
h.fetcher.mockImplementation(async () => {
|
||||
vi.spyOn(h.database, 'saveConversationImageOperation').mockImplementation(() => { throw new Error('Disk full') })
|
||||
return Response.json({ data: [{ b64_json: png }] })
|
||||
})
|
||||
const operation = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'disk-full')
|
||||
expect(operation).toMatchObject({ state: 'failed', error: 'Disk full', artifactIds: [] })
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'Disk full' }))
|
||||
expect(h.fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('saves before publishing, preserves metadata against stale renderer saves, and edits durable history', async () => {
|
||||
const h = setup()
|
||||
const first = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue circle' }, 'first')
|
||||
expect(first.state).toBe('completed')
|
||||
expect(h.database.getArtifact(first.artifactIds[0]!).content).toContain(png)
|
||||
h.database.saveLocalConversations([{ header: h.header, messages: [h.message] }])
|
||||
expect(h.service.getOperation(h.context.conversationId, first.id)).toEqual(first)
|
||||
expect(h.database.getConversation(h.context.conversationId).messages[0]!.artifactIds).toEqual(first.artifactIds)
|
||||
h.settings.defaultImageModelProfileId = randomUUID()
|
||||
const second = await h.service.bind({ ...h.context, requestId: randomUUID() }).call({ intent: 'edit', prompt: 'Turn red', sourceArtifactIds: first.artifactIds }, 'edit')
|
||||
expect(second.modelProfileId).toBe(first.modelProfileId)
|
||||
expect(String(h.fetcher.mock.calls[1]![0])).toMatch(/images\/edits$/u)
|
||||
expect((h.fetcher.mock.calls[1]![1]!.body as FormData).get('image')).toBeInstanceOf(Blob)
|
||||
expect(h.events.map(event => event.state)).toEqual(['running', 'saving', 'completed', 'running', 'saving', 'completed'])
|
||||
})
|
||||
|
||||
it('persists upload bytes and rejects cross-conversation or missing source references', async () => {
|
||||
const h = setup()
|
||||
const ids = h.service.persistUploads(h.context, [{ name: 'upload.png', mediaType: 'image/png', data: png }])
|
||||
h.database.saveLocalConversations([{ header: h.header, messages: [h.message] }])
|
||||
expect(h.database.getConversation(h.context.conversationId).messages[0]!.imageSourceArtifactIds).toEqual(ids)
|
||||
const binding = h.service.bind(h.context)
|
||||
expect((await binding.call({ intent: 'edit', prompt: 'Blue', sourceArtifactIds: ids }, 'upload')).state).toBe('completed')
|
||||
await expect(binding.call({ intent: 'edit', prompt: 'Blue', sourceArtifactIds: [randomUUID()] }, 'foreign')).rejects.toThrow('not available')
|
||||
await expect(binding.call({ intent: 'edit', prompt: 'Blue' }, 'missing')).rejects.toThrow('requires source')
|
||||
expect(h.fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects absent, disabled, deleted defaults without silent fallback and rechecks current mode', async () => {
|
||||
const h = setup()
|
||||
let mode: 'ask' | 'execute' = 'execute'
|
||||
const binding = h.service.bind(h.context, () => mode)
|
||||
expect(await binding.describe()).toContain(h.profile.id)
|
||||
h.profile.allowConversationInvocation = false
|
||||
await expect(binding.call({ intent: 'create', prompt: 'Blue' }, 'disabled')).rejects.toThrow('unavailable')
|
||||
h.profile.allowConversationInvocation = true
|
||||
h.settings.defaultImageModelProfileId = randomUUID()
|
||||
await expect(binding.call({ intent: 'create', prompt: 'Blue' }, 'deleted')).rejects.toThrow('unavailable')
|
||||
h.settings.defaultImageModelProfileId = null
|
||||
await expect(binding.call({ intent: 'create', prompt: 'Blue' }, 'absent')).rejects.toThrow('default')
|
||||
mode = 'ask'
|
||||
await expect(binding.call({ intent: 'create', prompt: 'Blue', modelProfileId: h.profile.id }, 'ask')).rejects.toThrow('Execute')
|
||||
expect(h.fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues after chat abort and pins the accepted profile while a replacement binding starts', async () => {
|
||||
let resolve!: (response: Response) => void
|
||||
const h = setup(vi.fn<typeof fetch>(() => new Promise(done => { resolve = done })))
|
||||
const controller = new AbortController()
|
||||
const pending = h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'call', controller.signal)
|
||||
await vi.waitFor(() => expect(h.fetcher).toHaveBeenCalledOnce())
|
||||
const providerSignal = h.fetcher.mock.calls[0]![1]!.signal!
|
||||
controller.abort()
|
||||
expect((await pending).state).toBe('running')
|
||||
expect(providerSignal.aborted).toBe(false)
|
||||
h.profile.modelName = 'replacement'
|
||||
h.profile.allowConversationInvocation = false
|
||||
resolve(Response.json({ data: [{ b64_json: png }] }))
|
||||
await vi.waitFor(() => expect(h.events.at(-1)?.state).toBe('completed'))
|
||||
expect(h.events.at(-1)).toMatchObject({ modelName: 'image-a', modelProfileName: 'Image A' })
|
||||
expect(JSON.parse(h.fetcher.mock.calls[0]![1]!.body as string).model).toBe('image-a')
|
||||
await expect(h.service.bind({ ...h.context, requestId: randomUUID() }).call({ intent: 'create', prompt: 'Other' }, 'other')).rejects.toThrow('unavailable')
|
||||
})
|
||||
|
||||
it('deduplicates one invocation, creates a new operation on manual regenerate, and retains late cancellation output', async () => {
|
||||
let resolve!: (response: Response) => void
|
||||
const h = setup(vi.fn<typeof fetch>(() => new Promise(done => { resolve = done })))
|
||||
const binding = h.service.bind(h.context)
|
||||
const input = { intent: 'create', prompt: 'Blue' }
|
||||
const one = binding.call(input, 'same')
|
||||
const two = binding.call(input, 'same')
|
||||
await vi.waitFor(() => expect(h.events).toHaveLength(1))
|
||||
h.service.cancel(h.context.conversationId, h.events[0]!.id)
|
||||
resolve(Response.json({ data: [{ b64_json: png }] }))
|
||||
const completed = await one
|
||||
expect(await two).toEqual(completed)
|
||||
expect(completed).toMatchObject({ state: 'completed', cancellationRequested: true })
|
||||
expect(h.fetcher).toHaveBeenCalledOnce()
|
||||
h.fetcher.mockImplementation(async () => Response.json({ data: [{ b64_json: png }] }))
|
||||
const regenerated = await h.service.regenerate(h.context, completed.id)
|
||||
expect(regenerated.id).not.toBe(completed.id)
|
||||
expect(h.fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('never falls back to a paid generation after an edit rejection and redacts provider errors', async () => {
|
||||
const h = setup(vi.fn<typeof fetch>(async () => Response.json({ error: { message: 'Image editing not supported Authorization: Bearer private-key' } }, { status: 405 })))
|
||||
const ids = h.service.persistUploads(h.context, [{ name: 'source', mediaType: 'image/png', data: png }])
|
||||
const result = await h.service.bind(h.context).call({ intent: 'edit', prompt: 'Blue', sourceArtifactIds: ids }, 'edit')
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('405')
|
||||
expect(result.error).not.toContain('private-key')
|
||||
expect(h.fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not resurrect a deleted conversation or retain its late artifact', async () => {
|
||||
let resolve!: (response: Response) => void
|
||||
const h = setup(vi.fn<typeof fetch>(() => new Promise(done => { resolve = done })))
|
||||
const pending = h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'deleted')
|
||||
await vi.waitFor(() => expect(h.fetcher).toHaveBeenCalledOnce())
|
||||
h.database.deleteLocalConversation(h.context.conversationId)
|
||||
resolve(Response.json({ data: [{ b64_json: png }] }))
|
||||
await pending
|
||||
expect(() => h.database.getConversation(h.context.conversationId)).toThrow()
|
||||
expect(h.database.listArtifacts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('marks unfinished operations unconfirmed on initialization without issuing requests', () => {
|
||||
const h = setup()
|
||||
h.database.saveConversationImageOperation({ id: randomUUID(), conversationId: h.context.conversationId, messageId: h.context.messageId,
|
||||
requestId: h.context.requestId, callId: 'old', modelProfileId: h.profile.id, modelName: h.profile.name,
|
||||
input: { intent: 'create', prompt: 'Blue', sourceArtifactIds: [] }, state: 'running', artifactIds: [], createdAt: 1, updatedAt: 1 })
|
||||
h.service.initialize()
|
||||
expect(h.database.getConversation(h.context.conversationId).messages[0]!.imageOperations![0]!.state).toBe('unconfirmed')
|
||||
expect(h.fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports provider-side uncertainty after network loss and local cancellation without retrying', async () => {
|
||||
const h = setup(vi.fn<typeof fetch>(async () => { throw new TypeError('fetch failed') }))
|
||||
const uncertain = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'network')
|
||||
expect(uncertain.state).toBe('unconfirmed')
|
||||
h.fetcher.mockImplementation((_url, init) => new Promise((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true })
|
||||
}))
|
||||
const pending = h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'cancel')
|
||||
await vi.waitFor(() => expect(h.fetcher).toHaveBeenCalledTimes(2))
|
||||
h.service.cancel(h.context.conversationId, h.events.at(-1)!.id)
|
||||
expect(await pending).toMatchObject({ state: 'stopped', cancellationRequested: true })
|
||||
expect(h.fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reuses an accepted invocation across bindings and does not skip a disabled historical model', async () => {
|
||||
const h = setup()
|
||||
const created = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'same')
|
||||
expect((await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'same')).id).toBe(created.id)
|
||||
const other = { ...h.profile, id: randomUUID(), name: 'Other', modelName: 'other' }
|
||||
h.settings.modelProfiles = [h.profile, other]
|
||||
h.settings.defaultImageModelProfileId = other.id
|
||||
h.profile.allowConversationInvocation = false
|
||||
await expect(h.service.bind(h.context).call({ intent: 'edit', prompt: 'Red', sourceArtifactIds: created.artifactIds }, 'blocked-edit')).rejects.toThrow('unavailable')
|
||||
const edited = await h.service.bind(h.context).call({ intent: 'edit', prompt: 'Red', sourceArtifactIds: created.artifactIds, modelProfileId: other.id }, 'explicit-edit')
|
||||
expect(edited.modelProfileId).toBe(other.id)
|
||||
expect(h.fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('dispatches the native Model tool through its scoped binding and denies Ask', async () => {
|
||||
const h = setup()
|
||||
const provider = new ModelToolProvider(process.cwd())
|
||||
cleanups.push(() => provider.dispose())
|
||||
const context = { conversationId: h.context.conversationId, workMode: 'execute' as const, imageToolBinding: h.service.bind(h.context), toolCallId: 'native' }
|
||||
const result = await provider.callTool('generate_image', { intent: 'create', prompt: 'Blue' }, new AbortController().signal, context)
|
||||
expect(JSON.stringify(result)).toContain('completed')
|
||||
expect(JSON.stringify(result)).not.toContain(png)
|
||||
await expect(provider.callTool('generate_image', { intent: 'create', prompt: 'Blue' }, new AbortController().signal, { ...context, workMode: 'ask' })).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('round trips image-only MCP discovery/call and does not abort accepted work on capability revocation', async () => {
|
||||
const h = setup()
|
||||
const gateway = new KnowledgeMcpGateway({} as never)
|
||||
await gateway.start()
|
||||
cleanups.push(() => gateway.dispose())
|
||||
const token = gateway.bindImageTool(h.service.bind(h.context), new AbortController().signal)
|
||||
const client = new Client({ name: 'image-test', version: '1' })
|
||||
cleanups.push(() => client.close())
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { Authorization: `Bearer ${token}` } } }))
|
||||
expect((await client.listTools()).tools.map(tool => tool.name)).toEqual(['generate_image'])
|
||||
const result = await client.callTool({ name: 'generate_image', arguments: { intent: 'create', prompt: 'Blue' } })
|
||||
expect(JSON.stringify(result)).toContain('completed')
|
||||
expect(h.fetcher).toHaveBeenCalledOnce()
|
||||
gateway.revoke(token)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
imageToolDescription, imageToolInputSchema,
|
||||
type ImageOperation, type ImageRequestContext, type ImageToolInput
|
||||
} from '../../shared/image-generation-contracts'
|
||||
import type { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import { ModelAgentRuntime, type ModelRuntimeOptions } from './model-runtime'
|
||||
import { withImageConversationContext } from './image-conversation-context'
|
||||
import type { AgentImage, RuntimeEvent } from './runtime'
|
||||
import { redactSensitiveText, safeToolErrorDetail } from './approval-summary'
|
||||
import type { ImageToolBinding } from './image-tool-binding'
|
||||
export type { ImageToolBinding } from './image-tool-binding'
|
||||
|
||||
export type ConversationImageProfile = {
|
||||
id: string
|
||||
name: string
|
||||
protocol: string
|
||||
modelName: string
|
||||
allowConversationInvocation?: boolean
|
||||
} & Pick<ModelRuntimeOptions, 'baseUrl' | 'apiKey' | 'authentication' | 'requestHeaders' | 'requestBody' | 'imageGenerationQuality'>
|
||||
|
||||
export type ImageServiceSettings = {
|
||||
modelProfiles: readonly ConversationImageProfile[]
|
||||
defaultImageModelProfileId?: string | null
|
||||
}
|
||||
export type ImageGenerationServiceOptions = {
|
||||
database: Pick<AssistantDatabase, 'getConversation' | 'getArtifact' | 'saveConversationImageOperation' | 'saveConversationImageSources' | 'markUnfinishedImageOperationsUnconfirmed'>
|
||||
getSettings(): Promise<ImageServiceSettings>
|
||||
onOperation?(operation: ImageOperation): void
|
||||
onUsage?(event: Extract<RuntimeEvent, { type: 'model-usage' }>): void
|
||||
/** Diagnostic sink for observer or terminal-persistence failures; must not throw. */
|
||||
onError?(error: Error): void
|
||||
fetcher?: typeof fetch
|
||||
requestTimeoutMs?: number
|
||||
toolWaitMs?: number
|
||||
}
|
||||
|
||||
/** Main owns requests; request-scoped bindings own only admission and waiting. */
|
||||
export class ImageGenerationService {
|
||||
private readonly active = new Map<string, { operation: ImageOperation; controller: AbortController; done: Promise<void> }>()
|
||||
private closing = false
|
||||
|
||||
constructor(private readonly options: ImageGenerationServiceOptions) {}
|
||||
|
||||
initialize(): void {
|
||||
this.options.database.markUnfinishedImageOperationsUnconfirmed()
|
||||
}
|
||||
|
||||
bind(context: ImageRequestContext, currentWorkMode: () => 'ask' | 'execute' = () => context.workMode): ImageToolBinding {
|
||||
const bound = Object.freeze({ ...context })
|
||||
const submissions = new Map<string, Promise<ImageOperation>>()
|
||||
return {
|
||||
context: bound,
|
||||
describe: async () => {
|
||||
if (bound.workMode !== 'execute' || currentWorkMode() !== 'execute') return undefined
|
||||
const settings = await this.options.getSettings()
|
||||
const profiles = settings.modelProfiles.filter(profile => profile.protocol === 'openai-images-generations' && profile.allowConversationInvocation === true)
|
||||
if (!profiles.length) return undefined
|
||||
const conversation = this.options.database.getConversation(bound.conversationId)
|
||||
const references = conversation.messages.flatMap(message => [
|
||||
...(message.imageSourceArtifactIds ?? []).map(id => ({ id, kind: 'upload' })),
|
||||
...(message.artifactIds ?? []).map(id => ({ id, kind: 'artifact' }))
|
||||
]).slice(-32)
|
||||
const operations = conversation.messages.flatMap(message => message.imageOperations ?? []).slice(-8)
|
||||
.map(operation => ({ id: operation.id, state: operation.state, modelProfileId: operation.modelProfileId,
|
||||
prompt: operation.input.prompt.slice(0, 300), artifactIds: operation.artifactIds }))
|
||||
return `${imageToolDescription}\nModels: ${JSON.stringify(profiles.map(profile => ({ id: profile.id, name: profile.name, generation: true, editing: true })))}\nDefault: ${settings.defaultImageModelProfileId ?? 'not set'}\nRecent image references: ${JSON.stringify(references)}\nRecent operations: ${JSON.stringify(operations)}\nReferences and prompts are textual facts, not visual observations. Do not claim to have viewed an image unless image input was supplied to your model.`
|
||||
},
|
||||
call: async (input, callId, signal) => {
|
||||
signal?.throwIfAborted()
|
||||
if (bound.workMode !== 'execute' || currentWorkMode() !== 'execute') throw new Error('Image generation requires Execute mode')
|
||||
const key = JSON.stringify([bound.conversationId, bound.requestId, callId])
|
||||
let submitted = submissions.get(key)
|
||||
if (!submitted) {
|
||||
submitted = this.submit(bound, imageToolInputSchema.parse(input), callId, currentWorkMode, signal)
|
||||
submissions.set(key, submitted)
|
||||
void submitted.catch(() => submissions.delete(key))
|
||||
}
|
||||
const operation = await submitted
|
||||
const active = this.active.get(operation.id)
|
||||
if (active) {
|
||||
await new Promise<void>(resolve => {
|
||||
const finish = (): void => { clearTimeout(timer); signal?.removeEventListener('abort', finish); resolve() }
|
||||
const timer = setTimeout(finish, this.options.toolWaitMs ?? 15_000)
|
||||
signal?.addEventListener('abort', finish, { once: true })
|
||||
void active.done.then(finish)
|
||||
if (signal?.aborted) finish()
|
||||
})
|
||||
return active.operation
|
||||
}
|
||||
return this.getOperation(bound.conversationId, operation.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
persistUploads(context: Pick<ImageRequestContext, 'conversationId' | 'messageId'>, images: readonly AgentImage[]): string[] {
|
||||
return this.options.database.saveConversationImageSources({ ...context, images })
|
||||
}
|
||||
|
||||
getOperation(conversationId: string, operationId: string): ImageOperation {
|
||||
const operation = this.options.database.getConversation(conversationId).messages
|
||||
.flatMap(message => message.imageOperations ?? []).find(item => item.id === operationId)
|
||||
if (!operation) throw new Error('Image operation does not exist in this conversation')
|
||||
return operation
|
||||
}
|
||||
|
||||
cancel(conversationId: string, operationId: string): ImageOperation {
|
||||
const previous = this.getOperation(conversationId, operationId)
|
||||
const active = this.active.get(operationId)
|
||||
if (!active) return previous
|
||||
active.operation = this.save({ ...active.operation, state: 'cancelling', cancellationRequested: true })
|
||||
active.controller.abort(new Error('Stopped waiting; the image provider may still generate the image'))
|
||||
return active.operation
|
||||
}
|
||||
|
||||
regenerate(context: ImageRequestContext, operationId: string, currentWorkMode?: () => 'ask' | 'execute'): Promise<ImageOperation> {
|
||||
const previous = this.getOperation(context.conversationId, operationId)
|
||||
return this.bind(context, currentWorkMode).call({ ...previous.input, modelProfileId: previous.modelProfileId }, randomUUID())
|
||||
}
|
||||
|
||||
cancelConversation(conversationId: string): void {
|
||||
for (const active of this.active.values()) {
|
||||
if (active.operation.conversationId === conversationId) this.cancel(conversationId, active.operation.id)
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.closing = true
|
||||
for (const active of this.active.values()) active.controller.abort(new Error('Application is closing; image result is unconfirmed'))
|
||||
await Promise.all([...this.active.values()].map(active => active.done))
|
||||
}
|
||||
|
||||
private save(operation: ImageOperation, image?: { mimeType: 'image/png' | 'image/jpeg' | 'image/webp'; data: string }): ImageOperation {
|
||||
const saved = this.options.database.saveConversationImageOperation({ ...operation, updatedAt: Date.now() }, image)
|
||||
try {
|
||||
this.options.onOperation?.(saved)
|
||||
} catch (error) {
|
||||
// A closed Renderer must not stop an accepted provider request or change its outcome.
|
||||
this.report(error)
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
private report(error: unknown): void {
|
||||
this.options.onError?.(new Error(redactSensitiveText(safeToolErrorDetail(error) ?? 'Image operation notification or persistence failed')))
|
||||
}
|
||||
|
||||
private async submit(context: ImageRequestContext, input: ImageToolInput, callId: string, currentWorkMode: () => 'ask' | 'execute', signal?: AbortSignal): Promise<ImageOperation> {
|
||||
if (this.closing) throw new Error('Image service is closing')
|
||||
const settings = await this.options.getSettings()
|
||||
const conversation = this.options.database.getConversation(context.conversationId)
|
||||
const existing = conversation.messages.flatMap(message => message.imageOperations ?? [])
|
||||
.find(operation => operation.requestId === context.requestId && operation.callId === callId)
|
||||
if (existing) return existing
|
||||
if (!conversation.messages.some(message => message.id === context.messageId)) throw new Error('Originating image message must be persisted before submission')
|
||||
const allowed = new Set(conversation.messages.flatMap(message => [
|
||||
...(message.artifactIds ?? []), ...(message.imageSourceArtifactIds ?? []),
|
||||
...(message.imageOperations?.flatMap(operation => operation.artifactIds) ?? [])
|
||||
]))
|
||||
if (input.sourceArtifactIds.some(id => !allowed.has(id))) throw new Error('Source image is not available in this conversation')
|
||||
const origins = new Set(conversation.messages.flatMap(message => message.imageOperations ?? [])
|
||||
.filter(operation => operation.artifactIds.some(id => input.sourceArtifactIds.includes(id)))
|
||||
.map(operation => operation.modelProfileId))
|
||||
if (!input.modelProfileId && origins.size > 1) throw new Error('Source images use different models; specify the image model')
|
||||
const profileId = input.modelProfileId ?? (input.intent === 'edit' ? [...origins][0] : undefined) ?? settings.defaultImageModelProfileId
|
||||
if (!profileId) throw new Error('Specify an image model or set the default image model')
|
||||
const profile = settings.modelProfiles.find(item => item.id === profileId)
|
||||
if (!profile || !profile.allowConversationInvocation || profile.protocol !== 'openai-images-generations') throw new Error('Selected image model is unavailable; select another model')
|
||||
if (profile.authentication === 'api-key' && !profile.apiKey) throw new Error('Selected image model has no API key configured')
|
||||
const connection = structuredClone(profile)
|
||||
const request = withImageConversationContext({ requestId: context.requestId, conversationId: context.conversationId, prompt: input.prompt, imageContextArtifactIds: input.sourceArtifactIds }, id => this.options.database.getArtifact(id))
|
||||
if (input.intent === 'edit' && (request.imageContextNotice || request.images?.length !== input.sourceArtifactIds.length)) throw new Error('Source image is missing or unsupported; select the image again')
|
||||
signal?.throwIfAborted()
|
||||
if (this.closing) throw new Error('Image service is closing')
|
||||
if (currentWorkMode() !== 'execute') throw new Error('Image generation requires Execute mode')
|
||||
const operation = this.save({ id: randomUUID(), conversationId: context.conversationId, messageId: context.messageId, requestId: context.requestId,
|
||||
callId, modelProfileId: connection.id, modelName: connection.modelName, modelProfileName: connection.name,
|
||||
input: { ...input, quality: input.quality ?? connection.imageGenerationQuality ?? 'auto' },
|
||||
state: 'running', artifactIds: [], createdAt: Date.now(), updatedAt: Date.now() })
|
||||
const runtime = new ModelAgentRuntime({ ...connection, model: connection.modelName, protocol: 'openai-images-generations',
|
||||
imageGenerationQuality: input.quality ?? connection.imageGenerationQuality, fetcher: this.options.fetcher, requestTimeoutMs: this.options.requestTimeoutMs })
|
||||
const active = { operation, controller: new AbortController(), done: Promise.resolve() }
|
||||
this.active.set(operation.id, active)
|
||||
active.done = (async () => {
|
||||
try {
|
||||
for await (const event of runtime.generateImage(request, active.controller.signal)) {
|
||||
if (event.type === 'model-usage') {
|
||||
try { this.options.onUsage?.(event) } catch (error) { this.report(error) }
|
||||
}
|
||||
if (event.type === 'generated-image') {
|
||||
active.operation = this.save({ ...active.operation, state: 'saving' })
|
||||
active.operation = this.save(active.operation, event)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const state = this.closing ? 'unconfirmed' : active.controller.signal.aborted ? 'stopped' : /timeout|timed out|fetch failed|network|超时/iu.test(String(error)) ? 'unconfirmed' : 'failed'
|
||||
const detail = safeToolErrorDetail(error) ?? 'Image request failed'
|
||||
const suffix = detail.match(/(HTTP [^)]+)$/u)?.[0] ?? ''
|
||||
const publicDetail = redactSensitiveText(suffix ? detail.slice(0, -suffix.length) : detail) + suffix
|
||||
active.operation = { ...active.operation, state, error: publicDetail.slice(0, 2_000), updatedAt: Date.now() }
|
||||
try { active.operation = this.save(active.operation) } catch (storageError) { this.report(storageError) }
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
this.active.delete(operation.id)
|
||||
}
|
||||
})()
|
||||
return operation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import { ImageGenerationService } from './image-generation-service'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { ModelAgentRuntime } from './model-runtime'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
import { ContinueAgentRuntime } from './continue-runtime'
|
||||
|
||||
vi.mock('electron', () => ({ nativeImage: {} }))
|
||||
const openCodeBinary = join(process.cwd(), '.runtime-resources', process.arch, process.platform === 'win32' ? 'opencode.exe' : 'opencode')
|
||||
const continueBinary = join(process.cwd(), 'node_modules', '@continuedev', 'cli', 'dist', 'cn.js')
|
||||
|
||||
it.for(['model', 'opencode', 'continue'] as const)('generates and edits through the real local %s tool loop', { timeout: 120_000 }, async (name, { skip }) => {
|
||||
if ((name === 'opencode' && !existsSync(openCodeBinary)) || (name === 'continue' && !existsSync(continueBinary))) return skip()
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-image-runtime-'))
|
||||
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
|
||||
database.initialize(root)
|
||||
const conversationId = randomUUID()
|
||||
const imageProfileId = randomUUID()
|
||||
let toolInput: Record<string, unknown> = { intent: 'create', prompt: 'Blue circle' }
|
||||
const textRequests: unknown[] = []
|
||||
const model = createServer((request, response) => {
|
||||
void (async () => {
|
||||
let content = ''
|
||||
for await (const chunk of request) content += String(chunk)
|
||||
const body = JSON.parse(content) as { messages?: { role: string; content: unknown }[]; tools?: { function: { name: string } }[] }
|
||||
textRequests.push(body)
|
||||
let lastUser = -1
|
||||
body.messages?.forEach((message, index) => { if (message.role === 'user') lastUser = index })
|
||||
const results = body.messages?.slice(lastUser + 1).filter(message => message.role === 'tool') ?? []
|
||||
const tool = body.tools?.find(tool => tool.function.name.endsWith('generate_image'))
|
||||
if (!tool) throw new Error('Image tool was not exposed')
|
||||
const call = results.length === 0
|
||||
const delta = call ? { role: 'assistant', tool_calls: [{ index: 0, id: randomUUID(), type: 'function',
|
||||
function: { name: tool.function.name, arguments: JSON.stringify(toolInput) } }] } : { role: 'assistant', content: 'IMAGE_SAVED' }
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
for (const [value, finish] of [[delta, null], [{}, call ? 'tool_calls' : 'stop']]) {
|
||||
response.write(`data: ${JSON.stringify({ id: randomUUID(), object: 'chat.completion.chunk', created: 1, model: 'fixture',
|
||||
choices: [{ index: 0, delta: value, finish_reason: finish }] })}\n\n`)
|
||||
}
|
||||
response.end('data: [DONE]\n\n')
|
||||
})().catch(error => { response.writeHead(500).end(String(error)) })
|
||||
})
|
||||
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
|
||||
const port = (model.address() as { port: number }).port
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: 'iVBORw0KGgo=' }] }))
|
||||
const service = new ImageGenerationService({ database, fetcher, getSettings: async () => ({ defaultImageModelProfileId: imageProfileId, modelProfiles: [{
|
||||
id: imageProfileId, name: 'Image fixture', modelName: 'fixture', protocol: 'openai-images-generations',
|
||||
allowConversationInvocation: true, authentication: 'none', baseUrl: 'https://image.test/v1'
|
||||
}] }) })
|
||||
const gateway = new KnowledgeMcpGateway({} as never)
|
||||
await gateway.start()
|
||||
const profile = { id: randomUUID(), name: 'Chat fixture', modelName: 'fixture', protocol: 'openai-chat-completions' as const,
|
||||
authentication: 'none' as const, baseUrl: `http://127.0.0.1:${port}/v1` }
|
||||
const common = { defaultWorkspace: root, knowledgeGateway: gateway, modelProfile: profile }
|
||||
const runtime = name === 'model'
|
||||
? new ModelAgentRuntime({ ...profile, model: profile.modelName, defaultWorkspace: root })
|
||||
: name === 'opencode'
|
||||
? new OpenCodeRuntime({ ...common, embedded: true, binaryPath: openCodeBinary, configPath: '' })
|
||||
: new ContinueAgentRuntime({ ...common, binaryPath: '', bundledBinaryPath: continueBinary, configPath: '', hostCacheRoot: join(root, 'continue') })
|
||||
try {
|
||||
for (const intent of ['create', 'edit'] as const) {
|
||||
const messageId = randomUUID()
|
||||
const requestId = randomUUID()
|
||||
database.saveLocalConversations([{ header: { id: conversationId, title: 'Image integration', updatedAt: Date.now() },
|
||||
messages: [{ id: messageId, role: 'assistant', content: '', createdAt: Date.now(), state: 'complete' }] }])
|
||||
const binding = service.bind({ conversationId, messageId, requestId, workMode: 'execute' })
|
||||
const events = []
|
||||
for await (const event of runtime.run({ requestId, conversationId, prompt: `${intent} image ${requestId}`, workMode: 'execute', imageToolBinding: binding }, new AbortController().signal, async () => 'once')) events.push(event)
|
||||
expect(events.some(event => event.type === 'done')).toBe(true)
|
||||
const operation = database.getConversation(conversationId).messages.find(message => message.id === messageId)!.imageOperations?.[0]
|
||||
expect(operation?.state).toBe('completed')
|
||||
expect(database.getArtifact(operation!.artifactIds[0]!).content).toContain('iVBORw0KGgo=')
|
||||
toolInput = { intent: 'edit', prompt: 'Turn red', sourceArtifactIds: operation!.artifactIds }
|
||||
}
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(String(fetcher.mock.calls[1]![0])).toMatch(/images\/edits$/u)
|
||||
expect(textRequests.length).toBeGreaterThanOrEqual(4)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await gateway.dispose()
|
||||
await service.dispose()
|
||||
model.closeAllConnections()
|
||||
await new Promise<void>(resolve => model.close(() => resolve()))
|
||||
database.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
import { imageToolInputSchema, imageToolName, type ImageOperation, type ImageRequestContext } from '../../shared/image-generation-contracts'
|
||||
|
||||
export type ImageToolBinding = {
|
||||
context: Readonly<ImageRequestContext>
|
||||
describe(): Promise<string | undefined>
|
||||
call(input: unknown, callId: string, waitSignal?: AbortSignal): Promise<ImageOperation>
|
||||
}
|
||||
|
||||
export async function imageToolDefinition(binding: ImageToolBinding | undefined) {
|
||||
const description = await binding?.describe()
|
||||
if (!description) return undefined
|
||||
const inputSchema = z.toJSONSchema(imageToolInputSchema, { target: 'draft-7', io: 'input' })
|
||||
Reflect.deleteProperty(inputSchema, '$schema')
|
||||
return { name: imageToolName, displayName: 'Generate or edit image', description, inputSchema, source: 'builtin' as const }
|
||||
}
|
||||
@@ -783,8 +783,10 @@ describe('KnowledgeMcpGateway', () => {
|
||||
const { service } = createService()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-note-mcp-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const onMagicNotesChanged = vi.fn()
|
||||
const database = new AssistantDatabase(
|
||||
join(directory, 'assistant.sqlite')
|
||||
join(directory, 'assistant.sqlite'),
|
||||
{ onMagicNotesChanged }
|
||||
)
|
||||
databases.push(database)
|
||||
database.initialize('C:\\Workspace')
|
||||
@@ -838,12 +840,20 @@ describe('KnowledgeMcpGateway', () => {
|
||||
})
|
||||
])
|
||||
expect(created.entries[0]?.content).toBe('核对构建产物')
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(1)
|
||||
gateway.updateMagicNote(writeToken, {
|
||||
noteId: created.id,
|
||||
title: 'Updated plan',
|
||||
expectedRevision: created.revision
|
||||
})
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(2)
|
||||
const withEntry = gateway.createMagicNoteEntry(writeToken, {
|
||||
noteId: created.id,
|
||||
content: '通知发布负责人'
|
||||
})
|
||||
const entry = withEntry.entries[1]!
|
||||
expect(entry.content).toBe('通知发布负责人')
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(3)
|
||||
|
||||
const updatedEntry = gateway.updateMagicNoteEntry(writeToken, {
|
||||
entryId: entry.id,
|
||||
@@ -859,6 +869,7 @@ describe('KnowledgeMcpGateway', () => {
|
||||
expectedRevision: entry.revision
|
||||
})
|
||||
).toThrow('已被更新')
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(4)
|
||||
|
||||
const withoutEntry = gateway.deleteMagicNoteEntry(writeToken, {
|
||||
entryId: entry.id,
|
||||
@@ -873,6 +884,7 @@ describe('KnowledgeMcpGateway', () => {
|
||||
expectedRevision: withoutEntry.revision
|
||||
})
|
||||
).toEqual({ deleted: true, noteId: created.id })
|
||||
expect(onMagicNotesChanged).toHaveBeenCalledTimes(6)
|
||||
expect(() =>
|
||||
gateway.getMagicNote(readToken, { noteId: created.id })
|
||||
).toThrow('笔记不存在')
|
||||
|
||||
@@ -102,6 +102,7 @@ export {
|
||||
}
|
||||
|
||||
export type GoodBuddyBuiltinToolName =
|
||||
| 'generate_image'
|
||||
| ScopedDataToolName
|
||||
| BrowserToolName
|
||||
|
||||
@@ -184,6 +185,7 @@ export type KnowledgeLibraryListItem = {
|
||||
}
|
||||
|
||||
type Capability = {
|
||||
imageToolBinding?: ImageToolBinding
|
||||
requestId: string
|
||||
libraryIds: readonly string[]
|
||||
magicNotesAccess: MagicNotesCapabilityAccess
|
||||
@@ -530,6 +532,20 @@ export class KnowledgeMcpGateway {
|
||||
}
|
||||
}
|
||||
|
||||
bindImageTool(binding: ImageToolBinding, signal: AbortSignal, existingToken?: string): string {
|
||||
signal.throwIfAborted()
|
||||
if (existingToken) {
|
||||
const capability = this.getCapability(existingToken)
|
||||
if (capability.requestId !== binding.context.requestId) throw new Error('Image capability request does not match')
|
||||
capability.imageToolBinding = binding
|
||||
return existingToken
|
||||
}
|
||||
return this.storeCapability({
|
||||
requestId: binding.context.requestId, libraryIds: [], magicNotesAccess: 'none', configAccess: 'none',
|
||||
customMcpServers: [], signal, imageToolBinding: binding
|
||||
})
|
||||
}
|
||||
|
||||
grantCustomMcp(
|
||||
requestId: string,
|
||||
servers: readonly ResolvedMcpServer[],
|
||||
@@ -760,6 +776,7 @@ export class KnowledgeMcpGateway {
|
||||
getAvailableToolNames(token: string): GoodBuddyBuiltinToolName[] {
|
||||
const capability = this.getCapability(token)
|
||||
return [
|
||||
...(capability.imageToolBinding ? ['generate_image' as const] : []),
|
||||
...(capability.libraryIds.length > 0
|
||||
? knowledgeToolNames
|
||||
: []),
|
||||
@@ -1570,6 +1587,7 @@ export class KnowledgeMcpGateway {
|
||||
}
|
||||
)
|
||||
const capability = this.getCapability(token)
|
||||
const imageTool = await imageToolDefinition(capability.imageToolBinding)
|
||||
const browserTools = capability.browserConversationId && capability.browserTabId
|
||||
? new BrowserModelTools({
|
||||
service: this.browserService!,
|
||||
@@ -1596,6 +1614,7 @@ export class KnowledgeMcpGateway {
|
||||
session.listedTools = true
|
||||
return {
|
||||
tools: [
|
||||
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
|
||||
...scopedTools,
|
||||
...browserDefinitions,
|
||||
...[...customBindings.values()].map(
|
||||
@@ -1610,6 +1629,12 @@ export class KnowledgeMcpGateway {
|
||||
async (call, extra) => {
|
||||
const name = call.params.name
|
||||
const input = call.params.arguments ?? {}
|
||||
if (name === 'generate_image') {
|
||||
const capability = this.getCapability(token)
|
||||
if (!capability.imageToolBinding) throw new Error('Image capability is unavailable')
|
||||
const operation = await capability.imageToolBinding.call(input, `${session.registryKey}:${extra.requestId}`, AbortSignal.any([extra.signal, capability.signal]))
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(operation) }] }
|
||||
}
|
||||
if (availableTools.has(name as ScopedDataToolName)) {
|
||||
const definition = scopedDataToolByName.get(
|
||||
name as ScopedDataToolName
|
||||
@@ -1868,3 +1893,4 @@ export class KnowledgeMcpGateway {
|
||||
}
|
||||
}
|
||||
}
|
||||
import { imageToolDefinition, type ImageToolBinding } from './image-tool-binding'
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LocalRuntimeRegistry } from './local-runtime-registry'
|
||||
import type { AgentExecutionRequest, AgentRuntime, RuntimeEvent } from './runtime'
|
||||
|
||||
function fixture() {
|
||||
const requests: AgentExecutionRequest[] = []
|
||||
const sessions = new Set<string>()
|
||||
const runtime: AgentRuntime & { readonly hasRetainedSessions: boolean } = {
|
||||
runtimeId: 'opencode',
|
||||
requiresToolApproval: false,
|
||||
supportsToolExecution: true,
|
||||
get hasRetainedSessions() { return sessions.size > 0 },
|
||||
getStatus: vi.fn(async () => ({
|
||||
id: 'opencode' as const, label: 'OpenCode', available: true,
|
||||
supportsToolExecution: true, detail: 'ready'
|
||||
})),
|
||||
async *run(request) {
|
||||
requests.push(request)
|
||||
sessions.add(request.conversationId)
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
},
|
||||
releaseConversation: vi.fn(async (id) => { sessions.delete(id) }),
|
||||
dispose: vi.fn(async () => { sessions.clear() })
|
||||
}
|
||||
return { runtime, requests, sessions }
|
||||
}
|
||||
|
||||
async function run(runtime: AgentRuntime, id: string) {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of runtime.run({
|
||||
requestId: id, conversationId: id, prompt: 'continue', workMode: 'execute'
|
||||
}, new AbortController().signal)) events.push(event)
|
||||
return events
|
||||
}
|
||||
|
||||
describe('LocalRuntimeRegistry', () => {
|
||||
it('shares the Runtime but fixes each request to its resolved workspace', async () => {
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const host = fixture()
|
||||
const create = vi.fn(() => host.runtime)
|
||||
const first = registry.acquire('opencode:profile', { model: 'one' }, '/first', create)
|
||||
const second = registry.acquire('opencode:profile', { model: 'one' }, '/second', create)
|
||||
await Promise.all([run(first, 'a'), run(second, 'b')])
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
expect(host.requests.map((request) => request.executionWorkspace).sort()).toEqual(['/first', '/second'])
|
||||
await first.dispose()
|
||||
expect(host.runtime.dispose).not.toHaveBeenCalled()
|
||||
await run(second, 'b')
|
||||
await registry.releaseConversation('a')
|
||||
expect(host.sessions).toEqual(new Set(['b']))
|
||||
await registry.dispose()
|
||||
expect(host.runtime.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes a no-session idle host and recreates it through an existing facade', async () => {
|
||||
vi.useFakeTimers()
|
||||
const registry = new LocalRuntimeRegistry(100)
|
||||
const first = fixture()
|
||||
const second = fixture()
|
||||
const create = vi.fn().mockReturnValueOnce(first.runtime).mockReturnValueOnce(second.runtime)
|
||||
try {
|
||||
const facade = registry.acquire('opencode:profile', {}, '/first', create)
|
||||
await facade.getStatus()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(first.runtime.dispose).toHaveBeenCalledOnce()
|
||||
await run(facade, 'a')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(second.runtime.dispose).not.toHaveBeenCalled()
|
||||
await registry.releaseConversation('a')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(second.runtime.dispose).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await registry.dispose()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drains an old configuration without closing a parallel replacement', async () => {
|
||||
const registry = new LocalRuntimeRegistry()
|
||||
const old = fixture()
|
||||
let release!: () => void
|
||||
const pending = new Promise<void>((resolve) => { release = resolve })
|
||||
old.runtime.run = async function* (request) {
|
||||
await pending
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
old.runtime.respondToQuestion = vi.fn(async () => undefined)
|
||||
const original = registry.acquire('opencode:profile', { model: 'old' }, '/one', () => old.runtime)
|
||||
const active = run(original, 'a')
|
||||
await Promise.resolve()
|
||||
const next = fixture()
|
||||
const replacement = registry.acquire('opencode:profile', { model: 'new' }, '/two', () => next.runtime)
|
||||
expect(old.runtime.dispose).not.toHaveBeenCalled()
|
||||
await original.respondToQuestion?.('active-question', [])
|
||||
expect(old.runtime.respondToQuestion).toHaveBeenCalledWith('active-question', [])
|
||||
await run(replacement, 'b')
|
||||
release()
|
||||
await active
|
||||
await vi.waitFor(() => expect(old.runtime.dispose).toHaveBeenCalledOnce())
|
||||
expect(next.runtime.dispose).not.toHaveBeenCalled()
|
||||
await registry.dispose()
|
||||
})
|
||||
|
||||
it('does not renew or reacquire an idle owner during passive status polling', async () => {
|
||||
vi.useFakeTimers()
|
||||
const registry = new LocalRuntimeRegistry(100)
|
||||
const host = fixture()
|
||||
const inspection = fixture()
|
||||
const restarted = fixture()
|
||||
const create = vi.fn()
|
||||
.mockReturnValueOnce(host.runtime)
|
||||
.mockReturnValueOnce(inspection.runtime)
|
||||
.mockReturnValueOnce(restarted.runtime)
|
||||
try {
|
||||
const facade = registry.acquire('opencode:profile', {}, '/first', create)
|
||||
for (let poll = 0; poll < 3; poll++) {
|
||||
await vi.advanceTimersByTimeAsync(30)
|
||||
expect(await facade.getStatus()).toMatchObject({ available: true })
|
||||
}
|
||||
expect(host.runtime.dispose).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(host.runtime.dispose).toHaveBeenCalledOnce()
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
|
||||
await facade.getStatus()
|
||||
expect(inspection.runtime.dispose).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(create).toHaveBeenCalledTimes(2)
|
||||
expect(restarted.runtime.dispose).not.toHaveBeenCalled()
|
||||
|
||||
await run(facade, 'a')
|
||||
expect(create).toHaveBeenCalledTimes(3)
|
||||
expect(restarted.sessions).toEqual(new Set(['a']))
|
||||
} finally {
|
||||
await registry.dispose()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user