From f107358eaf1a8e979803c336f595f12c6160e42d Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Mon, 28 Sep 2026 17:12:02 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat(windows):=20=E6=8E=A5=E9=80=9A?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=20lithe-agent-host=20=E7=9A=84=20Agent=20?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=A1=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 端此前没有 Agent 后端:backendCapabilities.agent 为 false, platform_invoke 不认识任何 agent 命令,features/ai 是不接后端的遗留代码。 macOS 的 Agent 侧栏早已使用共享的 rust/lithe-agent-host,本提交把 Windows 接到同一个 crate,为后续面板 UI 打底(Refs #956)。 - windows/tauri/src-tauri/src/agent.rs(新增):按连接 ID 登记 AgentHandle, 只提供 agent_open / agent_send / agent_close 三个命令;连接 ID 由界面生成, 界面先订阅 agent_event 再开连接,避免事件早于标识到达;重复或空 ID 直接 拒绝,不会顶掉在用的连接;事件只发给打开它的窗口,不广播给其他项目; 显示关闭走阻塞池,避免 AgentHandle::close 的等待卡住异步运行时;项目窗口 销毁和应用退出都会释放连接与进程树,退出路径同步等待。 - windows/tauri/src-tauri/src/main.rs:注册三个命令,在 WindowEvent::Destroyed 里释放该窗口的连接,在 RunEvent::Exit 里关闭全部连接。 - windows/tauri/src-tauri/Cargo.toml、Cargo.lock:直接依赖 lithe-agent-host。 - windows/tauri/src/platform/tauri-core.ts:把三个命令登记为原生命令,让前端 的 invoke 边界直接把长连接命令交给 Tauri 主机,而不是走共享命令信封。 - windows/tauri/src/platform/tauri-core.test.ts:新增路由断言。 - rust/lithe-agent-host/src/lib.rs:Windows 上以 CREATE_NO_WINDOW 启动适配器, 否则 GUI 外壳每开一次 Agent 都会弹出一个控制台窗口。 - .agents/notes/.../2026-09-25-shared-acp-agent-conversation.md:记录 Windows 连接桥的所有权、事件寻址和退出清理,并更新适用范围与验证命令。 - shared/platform-feature-matrix.json 与生成的 docs/development/platform-parity-matrix.*: 把连接桥加入四个 Agent 条目的 Windows 证据,并注明 UI 仍待接入。 未包含:Agent 面板 UI、供应商切换、订阅额度展示,以及 backendCapabilities.agent 的开关(留给面板 PR,避免顺带放开 features/ai 里那些没有后端的历史命令)。 验证: - cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml agent:: → 5 passed - test-stability-windows.ps1 -Scope WindowsRust → 202 passed,0 failed(新增 5 个 用例 80–926 ms,报告 .artifacts/test-stability/index.html) - cargo test -p lithe-agent-host → 47 passed - test-stability-windows.ps1 -Scope Frontend -FrontendTestPath src/platform/tauri-core.test.ts → 5 passed - bun run typecheck → 干净;cargo fmt --check(两个 crate)→ 干净 - verify-test-stability.ps1、verify-agent-notes.mjs、generate-platform-feature-matrix.mjs --check → 通过 - verify-windows-boundaries.ps1 在本机因未跟踪的 windows/build-* 与 windows/winui 构建目录报「must not restore the retired Qt/C++ implementation」,与本次改动无关 (git ls-files 中没有任何 .cpp/.h);其余检查项逐条通过。 已知限制:连接桥还没有用真实适配器在 Windows 上跑过端到端,平台矩阵的 Windows 验证状态仍是待验证。 --- ...026-09-25-shared-acp-agent-conversation.md | 11 +- docs/development/platform-parity-matrix.csv | 8 +- docs/development/platform-parity-matrix.md | 10 +- rust/lithe-agent-host/src/lib.rs | 9 + shared/platform-feature-matrix.json | 22 +- windows/tauri/src-tauri/Cargo.lock | 1 + windows/tauri/src-tauri/Cargo.toml | 1 + windows/tauri/src-tauri/src/agent.rs | 403 ++++++++++++++++++ windows/tauri/src-tauri/src/main.rs | 6 + windows/tauri/src/platform/tauri-core.test.ts | 18 + windows/tauri/src/platform/tauri-core.ts | 5 + 11 files changed, 472 insertions(+), 22 deletions(-) create mode 100644 windows/tauri/src-tauri/src/agent.rs diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index 8942b1c3f..a527ea79b 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -4,7 +4,7 @@ ## 先说结论 -Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 Agent。每个项目的每种 Agent 只有一个对话连接,一个连接里可以有多个会话;会话历史由 Agent 自己保存;Lithe 负责显示,并单独保存收藏、自定义标题和可恢复的隐藏状态。供应商管理允许 Codex 在自定义 API Key 和本机 ChatGPT 订阅间切换;Claude 仍只支持 API Key。订阅额度放在输入框上下文的右侧,不新增页面。Agent 需要的 Node.js 由用户自己安装,Lithe 只负责检测;ACP 适配器由 Lithe 提供一键安装,安装时用的是用户本机的 npm。ACP(Agent Client Protocol,编辑器与 Agent 之间的对话协议)连接和进程管理写在同一个 Rust crate 里,Mac 与未来的 Windows 只各自实现界面。 +Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 Agent。每个项目的每种 Agent 只有一个对话连接,一个连接里可以有多个会话;会话历史由 Agent 自己保存;Lithe 负责显示,并单独保存收藏、自定义标题和可恢复的隐藏状态。供应商管理允许 Codex 在自定义 API Key 和本机 ChatGPT 订阅间切换;Claude 仍只支持 API Key。订阅额度放在输入框上下文的右侧,不新增页面。Agent 需要的 Node.js 由用户自己安装,Lithe 只负责检测;ACP 适配器由 Lithe 提供一键安装,安装时用的是用户本机的 npm。ACP(Agent Client Protocol,编辑器与 Agent 之间的对话协议)连接和进程管理写在同一个 Rust crate 里:Mac 通过 C ABI 调用,Windows 通过 `windows/tauri/src-tauri/src/agent.rs` 的连接桥调用,两端只各自实现界面。 ## 问题 @@ -43,6 +43,7 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **CLI 更新保留安装来源**:以 PATH 中实际命令及其真实文件为准,不能仅看到用户装了 npm 就把所有 CLI 交给 npm。Homebrew 通过自己报告的 Cellar/Caskroom 位置和已安装记录确认归属,保留 cask/formula 及 `claude-code@latest` 等渠道;npm 必须确认当前 global root、包名、bin 声明和链接都指向同一 CLI,另一套 Node 环境不能代更新;Claude 标准原生 launcher 使用上游 `claude update`。未知来源、损坏链接、缺少原安装器或安装记录时拒绝自动覆盖,明确提供手动指引。更新后重新读取登录 shell 的 PATH 并验证最低版本;更新命令退出成功但实际 CLI 仍过旧也应失败。安装器可能在首次下载失败后重试成功,却保留非零退出状态,因此正常结束的命令无论退出状态如何,都要检查实际版本。只有新安装或数字版本严格提升且达到最低要求时,才能把非零退出降为“已成功、带警告”,返回有界日志并在界面折叠展示;原本可用但版本没变、降级、仍过旧或找不到命令时继续报错。不能根据日志中的“successfully upgraded”字样猜测成功,也不能把取消、超时或启动失败改判成功。读取来源只使用有界的本地查询,不更新包管理器索引、不改变用户配置。Homebrew 和原生下载归原安装器拥有,仅显示“正在更新”与耗时,不伪造字节进度;它们的全局安装和缓存不注册成可复制的工作树构建资源,排除清单与测试同步维护。 - **下载进度以 npm 的真实传输为准**:安装与 CLI 升级通过现有 Core 事件回调报告已接收软件包字节数、最近采样速度、耗时和等待时间。npm 没有提供整次安装的总量,且会继续发现依赖,所以不显示总体百分比。内嵌的 Node 观察模块只统计 HTTP 响应进入流缓冲区的字节,不添加消费数据的监听器,也不重写下载、代理、重试、校验或缓存行为。模块通过内存中的 data URL 加载,启动后先恢复用户原有 `NODE_OPTIONS`,防止 npm 子脚本继承观察器;不生成辅助文件或新的可复用缓存。正确做法是显示“已下载 25 MB、75 KB/秒、已用时 300 秒”;不要把 npm 静默时的日志时间或整个共享缓存大小当成下载进度。Core 事件只携带数字和阶段,界面按操作标识丢弃迟到事件,完成、失败或取消后清除进度。 - **Rust Core 命令**:`agent.status`、`agent.install`、`agent.uninstall`、`agent.installCli`,复用现有信封的取消和超时。 + - **Windows 连接桥**:`windows/tauri/src-tauri/src/agent.rs` 直接依赖 `lithe-agent-host`,用 `agent_open`、`agent_send`、`agent_close` 三个 Tauri 命令转发 fixture 里的同一套 JSON,协议、会话和取消语义仍只有共享 crate 一份。连接 ID 由界面自己生成:界面先订阅 `agent_event` 再开连接,因此不存在“事件先于连接标识到达”的竞态;重复或为空的 ID 直接拒绝,不会顶掉在用的连接。事件只发给打开它的那个窗口,不广播给其他项目。连接按窗口标签登记,项目窗口销毁和应用退出都会释放连接与进程树,退出路径同步等待。Windows 桌面外壳是 GUI 进程、没有控制台,所以共享 host 在 Windows 上以 `CREATE_NO_WINDOW` 启动适配器,避免每开一次 Agent 都弹出一个控制台窗口。 - **Key 和模型的传法**:API Key 模式的适配器通过 ACP `gateway` 登录,Key 经 stdio 传给 Agent,请求头按协议选择:Responses 协议用 `Authorization: Bearer`,Anthropic 协议用 `x-api-key`。模型按适配器分别传:Codex 用 `CODEX_CONFIG`,Claude 用 `ANTHROPIC_MODEL`。服务商配置里的"模型"必须传给 Agent:实测某个网关禁用了 Codex 的默认模型,不传模型时 Agent 只会回复一条网关报错。 - **设置放在面板里,只有 Agent 管理一页**:Agent 的开关、预检清单(Node、npm、CLI、适配器、本机配置)、适配器和 CLI 的一键安装都在 Agent 面板右上角的设置视图里,不进全局设置窗口。布局仿照 Codeg 和 CC GUI:左侧图标栏,右侧标题加分段切换各个 Agent。 - **本机配置保留 CLI 所有权**:每个 Agent 通过本机配置行读取用户自己 CLI 的地址、模型和密钥(Codex 读 `~/.codex/config.toml` 和 `auth.json`,Claude 读 `~/.claude/settings.json` 和 `~/.claude.json`),生成的服务商配置绑定到该 Agent。本机模式的密钥不复制进 Lithe,启动时从用户文件现读;要改本机地址或密钥时编辑自己的文件再刷新。需要独立配置时使用上面的自定义供应商编辑器,不改写 CLI 文件,也不改变提交信息使用的服务商选择。 @@ -104,10 +105,11 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 后果 -两端共享同一套协议和清理逻辑。功能关闭或没打开面板时,不会有 Agent 进程。一个项目的每种 Agent 只建立一个对话连接,会话再多也一样。订阅额度额外使用有界的短时官方查询进程,不新增常驻服务。 +两端共享同一套协议和清理逻辑。功能关闭或没打开面板时,不会有 Agent 进程。一个项目的每种 Agent 只建立一个对话连接,会话再多也一样。订阅额度额外使用有界的短时官方查询进程,不新增常驻服务。Windows 的连接桥只多一层连接登记、事件转发和进程回收,不会成为第二份协议实现。 代价: +- Windows 面板 UI、供应商切换和额度展示仍需另做;共享 host 在 Windows 上的适配器启动和进程树回收还没有真机端到端验收。 - Rust C ABI 和 fixture 成为兼容面,两端界面仍要分别维护。 - 用户需要自行安装 Node.js,适配器可以在面板内安装。 - codex-acp 丢失取消时,最多等待十秒后需要用户重连;同一进程的其他会话也会断开。上游未持久化的最后片段可能无法完整回放,界面保留旧记录用于诊断,不能保证 Agent 保存了未完成轮次。 @@ -115,9 +117,10 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 验证 - 订阅新增测试覆盖旧配置兼容、显式登录、已有账号、认证通知顺序、登录取消和超时、账号变更、额度真实窗口/缺失值/多 bucket、仅查询不发送 prompt,以及临时失败保留旧值。Linux 已运行 Agent Host 的逐测试计时套件;macOS Swift 编译、真实账号登录及深浅主题/窄宽布局仍须在目标环境验证,不将代码存在等同于运行验证。 -- 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 不出现订阅入口。Windows 已有共享协议和 host,但订阅选择和额度 UI 待接入。 +- 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 不出现订阅入口。Windows 已有共享协议和连接桥,但面板 UI、供应商切换和额度展示待接入,连接桥还需要在 Windows 上做一次真实适配器的端到端验收。 - `cargo test -p lithe-agent-host --manifest-path rust/Cargo.toml` +- `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml agent::`(Windows 连接桥:非法启动配置、空或重复连接 ID、启动失败上报、未知连接与非法命令、按窗口释放连接;不需要真实 Agent 或网络) - `cargo test -p lithe-core agent`(`agent.*` 命令与 `shared/fixtures/agent/agent-management-v1.json`) - 真实 Agent 端到端测试默认忽略,需要设置 `LITHE_ACP_E2E_*` 环境变量后运行:`cargo test -p lithe-agent-host --test real_agent -- --ignored`。设置 `LITHE_ACP_E2E_DATA_DIR` 时,会先用 npm 安装适配器,再从 Lithe 数据目录启动。 - `shared/fixtures/agent/acp-events-v1.json` 同时由 Rust 序列化测试和 Swift 功能模型测试读取。 @@ -135,4 +138,4 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 适用范围 -`rust/lithe-agent-host/`、`rust/lithe-core/src/agent/`、`rust/lithe-core/src/runtime/ffi.rs`、`macos/Sources/Lithe/Views/Agent/`、`macos/Sources/LitheAgentConversationModule/`、`macos/Sources/Lithe/Platform/MacOS/Agent/`、`shared/contracts/rust-core-api.md`、`shared/contracts/application-boundary.md`。 +`rust/lithe-agent-host/`、`rust/lithe-core/src/agent/`、`rust/lithe-core/src/runtime/ffi.rs`、`macos/Sources/Lithe/Views/Agent/`、`macos/Sources/LitheAgentConversationModule/`、`macos/Sources/Lithe/Platform/MacOS/Agent/`、`windows/tauri/src-tauri/src/agent.rs`、`shared/contracts/rust-core-api.md`、`shared/contracts/application-boundary.md`。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index facdfba4c..9813e0b52 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -1,13 +1,13 @@ id,area,feature group,capability,macOS implementation,macOS verification,Windows implementation,Windows verification,owner,verification,notes,macOS evidence,Windows evidence agent-entry-discoverability,AI,Agent 对话,新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失,已实现,待验证,未实现,不适用,Agent,使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。,,macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift; macos/Sources/Lithe/Views/Agent/AgentConversationView.swift; macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift,windows/tauri/src/features agent-conversation-history,AI,Agent 对话,CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出,已实现,待验证,未实现,不适用,Agent,macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。,,macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift; macos/Tests/LitheTests/AgentHistoryTests.swift,rust/lithe-agent-host/src/lib.rs -agent-context-usage,AI,Agent 对话,Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新,已实现,待验证,部分实现,待验证,Agent,macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift; macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/lib.rs; shared/fixtures/agent/acp-events-v1.json -agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host,rust/lithe-agent-host +agent-context-usage,AI,Agent 对话,Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新,已实现,待验证,部分实现,待验证,Agent,macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 连接桥已转发 usage_update,页面待实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift; macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/lib.rs; shared/fixtures/agent/acp-events-v1.json; windows/tauri/src-tauri/src/agent.rs +agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 连接桥已接通共享 host,Windows 与 Claude 端到端仍待验证。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host,rust/lithe-agent-host; windows/tauri/src-tauri/src/agent.rs agent-file-references,AI,Agent 对话,从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿,已实现,待验证,部分实现,待验证,Agent,macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。,,macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift; macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift; macos/Tests/LitheTests/AgentFileReferenceTests.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; rust/lithe-agent-host/src/tests.rs,rust/lithe-agent-host/src/prompt.rs; shared/fixtures/agent/acp-events-v1.json agent-management,AI,Agent 对话,Agent 管理:面板内设置、Node.js/npm 检测、预检清单、ACP 适配器与 Agent CLI 一键安装或升级、一键获取本机 CLI 配置、实时下载数据量/速度/耗时与等待提示、按 CLI 安装来源更新、验证实际升级结果并区分成功警告与失败,已实现,待验证,部分实现,待验证,Agent,在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。,,macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift; macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift; rust/lithe-agent-host/src/install.rs; rust/lithe-core/src/agent/mod.rs; rust/lithe-agent-host/src/npm-progress.mjs; rust/lithe-agent-host/src/cli_update.rs; macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift,rust/lithe-core/src/agent/mod.rs agent-provider-configuration,AI,Agent 对话,Agent 供应商管理:读取本机配置、自定义 Codex/Claude 配置文本、添加与编辑、安全保存密钥、启用与重连、确认删除及取消关联,已实现,待验证,部分实现,待验证,Agent,macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 编辑器及 Claude 真实请求待验证。,,macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift; macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift; macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift; macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift; macos/Tests/LitheTests/AgentProviderConfigurationTests.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift,rust/lithe-core/src/ai/agent_configuration.rs; shared/fixtures/agent/provider-configuration-v1.json -agent-codex-subscription,AI,Agent 对话,Codex 供应商选择官方订阅:复用本机账号、显式浏览器登录与取消、API Key 模式切换及旧配置兼容,已实现,待验证,部分实现,待验证,Agent,macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 仅共享 host 已实现,UI 待接入。,,macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift; macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentProviderConfigurationTests.swift; rust/lithe-agent-host/src/subscription.rs,rust/lithe-agent-host/src/lib.rs; shared/fixtures/agent/acp-events-v1.json -agent-subscription-quota,AI,Agent 对话,Codex 订阅额度:上下文右侧紧凑显示、可见时定时刷新、悬停多窗口与重置时间、未知与过期状态及账号隔离,已实现,待验证,部分实现,待验证,Agent,macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows UI 与真实账号验证待完成。,,macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; rust/lithe-agent-host/src/subscription/tests.rs,rust/lithe-agent-host/src/subscription.rs; shared/fixtures/agent/acp-events-v1.json +agent-codex-subscription,AI,Agent 对话,Codex 供应商选择官方订阅:复用本机账号、显式浏览器登录与取消、API Key 模式切换及旧配置兼容,已实现,待验证,部分实现,待验证,Agent,macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 连接桥已接通共享 host,UI 待接入。,,macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift; macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentProviderConfigurationTests.swift; rust/lithe-agent-host/src/subscription.rs,rust/lithe-agent-host/src/lib.rs; shared/fixtures/agent/acp-events-v1.json; windows/tauri/src-tauri/src/agent.rs +agent-subscription-quota,AI,Agent 对话,Codex 订阅额度:上下文右侧紧凑显示、可见时定时刷新、悬停多窗口与重置时间、未知与过期状态及账号隔离,已实现,待验证,部分实现,待验证,Agent,macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows 连接桥已接通,UI 与真实账号验证待完成。,,macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; rust/lithe-agent-host/src/subscription/tests.rs,rust/lithe-agent-host/src/subscription.rs; shared/fixtures/agent/acp-events-v1.json; windows/tauri/src-tauri/src/agent.rs workspace-open-switch,工作区,工作区生命周期,打开工作区与切换项目,已实现,待验证,已实现,待验证,Workspace,打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确。,,macos/Sources/Lithe/Views/Workspace; macos/Sources/Lithe/Services/Workspace,windows/tauri/src/features/workspace workspace-files,工作区,工作区生命周期,文件树与文件操作,已实现,待验证,已实现,待验证,Workspace,新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。,,macos/Sources/Lithe/Views/Workspace,windows/tauri/src/features/file-system workspace-document-sync,工作区,工作区生命周期,脏状态、保存与外部修改,已实现,待验证,已实现,待验证,Workspace,编辑未保存文件、外部修改文件并重启应用,确认冲突、保存和恢复行为。,,macos/Sources/Lithe/Models/Editor; macos/Sources/Lithe/Services/Workspace,windows/tauri/src/features/editor; windows/tauri/src/features/file-system diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 2c9bb9b60..18e4fa66e 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -2,7 +2,7 @@ > 本页由 `shared/platform-feature-matrix.json` 自动生成。不要直接编辑本文件;新增或变更功能时更新源数据,再运行 `node scripts/generate-platform-feature-matrix.mjs`。 -- 最后复核:2026-09-27 +- 最后复核:2026-09-28 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) - 功能项:101 - macOS:实现:✅ 88 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 3 平台专属;验证:✔️ 0 已验证,🔍 91 待验证,— 10 不适用 @@ -36,13 +36,13 @@ | --- | --- | --- | --- | --- | --- | --- | | Agent 对话 | **新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失**
agent-entry-discoverability | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift`、`macos/Sources/Lithe/Views/Agent/AgentConversationView.swift`、`macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | 使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 | | | Agent 对话 | **CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出**
agent-conversation-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift`、`macos/Tests/LitheTests/AgentHistoryTests.swift` | ❌ 未实现
— 不适用
`rust/lithe-agent-host/src/lib.rs` | Agent | macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。 | | -| Agent 对话 | **Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新**
agent-context-usage | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift`、`macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/lib.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。 | | -| Agent 对话 | **可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 | | +| Agent 对话 | **Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新**
agent-context-usage | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift`、`macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/lib.rs`、`shared/fixtures/agent/acp-events-v1.json`、`windows/tauri/src-tauri/src/agent.rs` | Agent | macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 连接桥已转发 usage_update,页面待实现。 | | +| Agent 对话 | **可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host`、`windows/tauri/src-tauri/src/agent.rs` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 连接桥已接通共享 host,Windows 与 Claude 端到端仍待验证。 | | | Agent 对话 | **从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿**
agent-file-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift`、`macos/Tests/LitheTests/AgentFileReferenceTests.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`rust/lithe-agent-host/src/tests.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 | | | Agent 对话 | **Agent 管理:面板内设置、Node.js/npm 检测、预检清单、ACP 适配器与 Agent CLI 一键安装或升级、一键获取本机 CLI 配置、实时下载数据量/速度/耗时与等待提示、按 CLI 安装来源更新、验证实际升级结果并区分成功警告与失败**
agent-management | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift`、`macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift`、`rust/lithe-agent-host/src/install.rs`、`rust/lithe-core/src/agent/mod.rs`、`rust/lithe-agent-host/src/npm-progress.mjs`、`rust/lithe-agent-host/src/cli_update.rs`、`macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-core/src/agent/mod.rs` | Agent | 在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。 | | | Agent 对话 | **Agent 供应商管理:读取本机配置、自定义 Codex/Claude 配置文本、添加与编辑、安全保存密钥、启用与重连、确认删除及取消关联**
agent-provider-configuration | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift`、`macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift`、`macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift`、`macos/Tests/LitheTests/AgentProviderConfigurationTests.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-core/src/ai/agent_configuration.rs`、`shared/fixtures/agent/provider-configuration-v1.json` | Agent | macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 编辑器及 Claude 真实请求待验证。 | | -| Agent 对话 | **Codex 供应商选择官方订阅:复用本机账号、显式浏览器登录与取消、API Key 模式切换及旧配置兼容**
agent-codex-subscription | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentProviderConfigurationTests.swift`、`rust/lithe-agent-host/src/subscription.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/lib.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 仅共享 host 已实现,UI 待接入。 | | -| Agent 对话 | **Codex 订阅额度:上下文右侧紧凑显示、可见时定时刷新、悬停多窗口与重置时间、未知与过期状态及账号隔离**
agent-subscription-quota | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`rust/lithe-agent-host/src/subscription/tests.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/subscription.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows UI 与真实账号验证待完成。 | | +| Agent 对话 | **Codex 供应商选择官方订阅:复用本机账号、显式浏览器登录与取消、API Key 模式切换及旧配置兼容**
agent-codex-subscription | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentProviderConfigurationTests.swift`、`rust/lithe-agent-host/src/subscription.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/lib.rs`、`shared/fixtures/agent/acp-events-v1.json`、`windows/tauri/src-tauri/src/agent.rs` | Agent | macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 连接桥已接通共享 host,UI 待接入。 | | +| Agent 对话 | **Codex 订阅额度:上下文右侧紧凑显示、可见时定时刷新、悬停多窗口与重置时间、未知与过期状态及账号隔离**
agent-subscription-quota | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`rust/lithe-agent-host/src/subscription/tests.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/subscription.rs`、`shared/fixtures/agent/acp-events-v1.json`、`windows/tauri/src-tauri/src/agent.rs` | Agent | macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows 连接桥已接通,UI 与真实账号验证待完成。 | | | AI 提交信息 | **Provider 配置与提交信息生成**
ai-commit-generation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift`、`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/services/ai-commit-service.ts`、`shared/contracts/ai-commit.md` | Git / AI | 用相同 diff、规则和 Provider 配置比较请求计划、取消、错误和生成文本。 | | | AI 对话 | **对话会话与流式响应**
ai-chat-session | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/ai/components/chat/ai-chat.tsx`、`windows/tauri/src/features/ai/services/ai-chat-service.ts` | AI | Windows 验证新建会话、流式输出、取消和失败恢复;macOS 需要先定义产品范围。 | | | AI 对话 | **对话历史、置顶与归档**
ai-chat-history | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/ai/services/ai-chat-history-service.ts`、`windows/tauri/src/features/layout/components/sidebar/sidebar-history.tsx` | AI | Windows 验证历史加载、重命名、置顶、归档、删除和重启后持久化。 | | diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index 0c434a5c8..509ca4f56 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -48,6 +48,11 @@ const PERMISSION_TIMEOUT: Duration = Duration::from_secs(300); const CANCEL_TIMEOUT: Duration = Duration::from_secs(10); /// Upper bound on `session/list` pages so a misbehaving cursor cannot loop forever. const MAX_SESSION_LIST_PAGES: usize = 50; +/// Windows flag that runs the adapter with a console it cannot display. The +/// desktop shell is a GUI process without a console, so a console-subsystem +/// adapter would otherwise open a visible console window on every launch. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; /// Bytes of agent stderr kept for failure reports; older output is discarded. const STDERR_TAIL_BYTES: usize = 16 * 1024; const STDERR_TAIL_LINES: usize = 20; @@ -700,6 +705,10 @@ async fn run_agent( command.process_group(0); } let mut command = tokio::process::Command::from(command); + #[cfg(windows)] + { + command.creation_flags(CREATE_NO_WINDOW); + } command .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index df4308d1d..be2ecc973 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1,6 +1,6 @@ { "schemaVersion": 3, - "lastReviewed": "2026-09-27", + "lastReviewed": "2026-09-28", "review": { "status": "initial-static-inventory", "method": "根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。", @@ -117,13 +117,14 @@ "windows": { "evidence": [ "rust/lithe-agent-host/src/lib.rs", - "shared/fixtures/agent/acp-events-v1.json" + "shared/fixtures/agent/acp-events-v1.json", + "windows/tauri/src-tauri/src/agent.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。" + "verification": "macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 连接桥已转发 usage_update,页面待实现。" }, { "id": "agent-acp-conversation", @@ -141,13 +142,14 @@ }, "windows": { "evidence": [ - "rust/lithe-agent-host" + "rust/lithe-agent-host", + "windows/tauri/src-tauri/src/agent.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。" + "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 连接桥已接通共享 host,Windows 与 Claude 端到端仍待验证。" }, { "id": "agent-file-references", @@ -255,11 +257,12 @@ "verificationStatus": "pending", "evidence": [ "rust/lithe-agent-host/src/lib.rs", - "shared/fixtures/agent/acp-events-v1.json" + "shared/fixtures/agent/acp-events-v1.json", + "windows/tauri/src-tauri/src/agent.rs" ] }, "owner": "Agent", - "verification": "macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 仅共享 host 已实现,UI 待接入。" + "verification": "macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 连接桥已接通共享 host,UI 待接入。" }, { "id": "agent-subscription-quota", @@ -281,11 +284,12 @@ "verificationStatus": "pending", "evidence": [ "rust/lithe-agent-host/src/subscription.rs", - "shared/fixtures/agent/acp-events-v1.json" + "shared/fixtures/agent/acp-events-v1.json", + "windows/tauri/src-tauri/src/agent.rs" ] }, "owner": "Agent", - "verification": "macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows UI 与真实账号验证待完成。" + "verification": "macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows 连接桥已接通,UI 与真实账号验证待完成。" }, { "id": "workspace-open-switch", diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index fabd89d26..934b64834 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2869,6 +2869,7 @@ dependencies = [ "base64 0.22.1", "chrono", "keyring", + "lithe-agent-host", "lithe-core", "lithe-git-host", "lithe-project", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index d6ddba338..3d4062ebd 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-build = { version = "2", features = [] } [dependencies] base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } +lithe-agent-host = { path = "../../../rust/lithe-agent-host" } lithe-git-host = { path = "../../../rust/lithe-git-host" } lithe-core = { path = "../../../rust/lithe-core" } lithe-project = { path = "../crates/project" } diff --git a/windows/tauri/src-tauri/src/agent.rs b/windows/tauri/src-tauri/src/agent.rs new file mode 100644 index 000000000..942f68e46 --- /dev/null +++ b/windows/tauri/src-tauri/src/agent.rs @@ -0,0 +1,403 @@ +//! Windows Agent host: long-lived ACP connections to a user-installed Agent. +//! +//! ACP protocol behavior, session history, permission bookkeeping, and the agent +//! process tree stay in `lithe-agent-host`; macOS reaches that crate through the +//! `lithe_agent_*` C ABI. This module is the Windows equivalent and owns only the +//! per-window connection registry, the Tauri command surface, and the projection +//! of serialized events to the React layer. +//! +//! Byte flow: React subscribes to `agent_event`, calls `agent_open` with its own +//! connection id and the shared `AgentLaunch` JSON, then `agent_send` with the +//! shared `AgentCommand` JSON. Each event reaches the owning window as +//! `{ connectionId, event }`. `agent_close`, window destruction, and application +//! exit release the connection and its process tree. +//! See `.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md`. + +use lithe_agent_host::{AgentCommand, AgentEvent, AgentHandle, AgentLaunch}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; +use tauri::{Emitter, Manager}; + +/// Tauri event carrying the Agent events of every connection. +pub const AGENT_EVENT_NAME: &str = "agent_event"; + +/// Opens one Agent connection owned by the calling window. +#[tauri::command] +pub fn agent_open( + webview: tauri::Webview, + request: AgentOpenRequest, +) -> Result { + let sink = WindowEventSink { + app: webview.app_handle().clone(), + window: webview.label().to_string(), + }; + open_connection(webview.label(), request, sink) +} + +/// Queues one shared `AgentCommand` on an open connection. +#[tauri::command] +pub fn agent_send(connection_id: String, command: Value) -> Result<(), String> { + send_command(&connection_id, command) +} + +/// Stops one connection and its process tree. Repeated calls are no-ops. +#[tauri::command] +pub async fn agent_close(connection_id: String) -> Result<(), String> { + close_connection(&connection_id).await +} + +/// One `agent_open` request. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentOpenRequest { + /// Caller-chosen id, unique among live connections. The UI subscribes to + /// `agent_event` before opening, so no event can arrive without an owner. + pub connection_id: String, + /// Workspace root the Agent sessions belong to. + pub workspace_path: String, + /// Launch configuration in the shared `AgentLaunch` shape. + pub launch: Value, +} + +/// Identity of an open connection, echoed back to the UI. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgentConnectionInfo { + pub connection_id: String, + pub workspace_path: String, +} + +/// Receives one serialized event for a connection. +/// +/// Production delivers to the owning window; tests inject a recorder, so +/// lifecycle behavior is verifiable without a Tauri application. +trait AgentEventSink: Clone + Send + Sync + 'static { + fn emit_event(&self, connection_id: &str, event: Value); +} + +/// Addresses events to the window that opened the connection, so an open +/// project never receives another project's Agent traffic. +#[derive(Clone)] +struct WindowEventSink { + app: tauri::AppHandle, + window: String, +} + +impl AgentEventSink for WindowEventSink { + fn emit_event(&self, connection_id: &str, event: Value) { + let _ = self.app.emit_to( + self.window.as_str(), + AGENT_EVENT_NAME, + json!({ "connectionId": connection_id, "event": event }), + ); + } +} + +struct Connection { + /// Window label that owns this connection, used to release a whole project. + window: String, + handle: AgentHandle, +} + +/// Live connections keyed by the caller's connection id. Process-wide so the +/// window-destroyed and application-exit hooks reach the same registry without +/// an `AppHandle`, mirroring `debug.rs`. +fn connections() -> &'static Mutex> { + static CONNECTIONS: OnceLock>> = OnceLock::new(); + CONNECTIONS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn open_connection( + window: &str, + request: AgentOpenRequest, + sink: S, +) -> Result { + let connection_id = request.connection_id.trim().to_string(); + if connection_id.is_empty() { + return Err("An Agent connection id is required.".into()); + } + let launch: AgentLaunch = serde_json::from_value(request.launch) + .map_err(|error| format!("Invalid Agent launch configuration: {error}"))?; + + let events = connection_id.clone(); + let emit: Arc = Arc::new(move |event| { + // A failure to serialize must never take down the connection worker. + match serde_json::to_value(&event) { + Ok(event) => sink.emit_event(&events, event), + Err(error) => eprintln!("Could not serialize an Agent event: {error}"), + } + }); + + let mut current = connections() + .lock() + .map_err(|_| "Agent connection state is unavailable.".to_string())?; + if current.contains_key(&connection_id) { + return Err(format!("Agent connection {connection_id} is already open.")); + } + let handle = AgentHandle::open(launch, emit)?; + current.insert( + connection_id.clone(), + Connection { + window: window.to_owned(), + handle, + }, + ); + Ok(AgentConnectionInfo { + connection_id, + workspace_path: request.workspace_path, + }) +} + +fn send_command(connection_id: &str, command: Value) -> Result<(), String> { + let current = connections() + .lock() + .map_err(|_| "Agent connection state is unavailable.".to_string())?; + let connection = current + .get(connection_id) + .ok_or_else(|| format!("Agent connection {connection_id} is not open."))?; + let command: AgentCommand = serde_json::from_value(command) + .map_err(|error| format!("Invalid Agent command: {error}"))?; + connection.handle.send(command) +} + +fn take_connection(connection_id: &str) -> Result, String> { + let mut current = connections() + .lock() + .map_err(|_| "Agent connection state is unavailable.".to_string())?; + Ok(current.remove(connection_id)) +} + +async fn close_connection(connection_id: &str) -> Result<(), String> { + let Some(connection) = take_connection(connection_id)? else { + // Repeated or late closes are no-ops and never touch a newer connection. + return Ok(()); + }; + // `AgentHandle::close` waits for the bounded stop window and force-kills the + // process tree, so it must stay off the async runtime's worker threads. + tauri::async_runtime::spawn_blocking(move || connection.handle.close()) + .await + .map_err(|error| format!("Agent connection cleanup failed: {error}")) +} + +/// Releases every connection owned by one window when a project window closes, +/// so a closed project never leaves an Agent process behind. Cleanup runs on the +/// blocking pool because the window is already gone and stopping an Agent waits +/// for its bounded stop window. +pub fn close_window_connections(window: &str) { + for connection in take_window_connections(window) { + tauri::async_runtime::spawn_blocking(move || connection.handle.close()); + } +} + +fn take_window_connections(window: &str) -> Vec { + let Ok(mut current) = connections().lock() else { + return Vec::new(); + }; + let owned: Vec = current + .iter() + .filter(|(_, connection)| connection.window == window) + .map(|(connection_id, _)| connection_id.clone()) + .collect(); + owned + .into_iter() + .filter_map(|connection_id| current.remove(&connection_id)) + .collect() +} + +/// Closes every live connection during application exit so no Agent process +/// outlives the shell. Runs synchronously: the process is about to exit, and a +/// detached cleanup task would be abandoned with it. +pub fn shutdown() { + let Ok(mut current) = connections().lock() else { + return; + }; + let live: Vec = current.drain().map(|(_, connection)| connection).collect(); + drop(current); + for connection in live { + connection.handle.close(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + /// Bound for the one asynchronous step these tests wait on: the connection + /// worker reporting a launch failure as a `stopped` event. + const EVENT_WAIT: Duration = Duration::from_secs(15); + + #[derive(Clone)] + struct RecordingSink { + events: mpsc::Sender<(String, Value)>, + } + + impl AgentEventSink for RecordingSink { + fn emit_event(&self, connection_id: &str, event: Value) { + self.events + .send((connection_id.to_string(), event)) + .expect("recording sink receiver should remain active"); + } + } + + fn recording() -> (RecordingSink, mpsc::Receiver<(String, Value)>) { + let (sender, receiver) = mpsc::channel(); + (RecordingSink { events: sender }, receiver) + } + + /// A launch that resolves and then cannot start, so a connection reports a + /// `stopped` event without an installed Agent, a network, or a fake adapter. + fn request(connection_id: &str) -> AgentOpenRequest { + AgentOpenRequest { + connection_id: connection_id.to_string(), + workspace_path: std::env::temp_dir().to_string_lossy().into_owned(), + launch: json!({ + "command": "lithe-missing-agent-binary", + "cwd": std::env::temp_dir(), + "authentication": "apiKey", + "provider": { + "protocol": "responses", + "baseUrl": "https://gateway.example.com/v1", + "apiKey": "test-key", + }, + }), + } + } + + fn close(connection_id: &str) { + tauri::async_runtime::block_on(close_connection(connection_id)).expect("close"); + } + + fn wait_for_stopped(receiver: &mpsc::Receiver<(String, Value)>, connection_id: &str) { + let deadline = Instant::now() + EVENT_WAIT; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_else(|| { + panic!("no `stopped` event for {connection_id} before the deadline") + }); + let (event_connection, event) = receiver + .recv_timeout(remaining) + .expect("an Agent event before the deadline"); + assert_eq!(event_connection, connection_id); + if event["kind"] == "stopped" { + return; + } + } + } + + #[test] + fn an_invalid_launch_is_rejected_without_opening_a_connection() { + let (sink, _receiver) = recording(); + let mut invalid = request("test-invalid-launch"); + invalid.launch = json!({ "command": "lithe-missing-agent-binary" }); + + let error = open_connection("test-window", invalid, sink) + .expect_err("a launch without a workspace root must be rejected"); + + assert!( + error.contains("Invalid Agent launch configuration"), + "{error}" + ); + assert!(take_connection("test-invalid-launch") + .expect("registry") + .is_none()); + } + + #[test] + fn a_connection_id_must_be_present_and_unique() { + let (sink, _receiver) = recording(); + let mut blank = request("test-blank-id"); + blank.connection_id = " ".into(); + + let error = open_connection("test-window", blank, sink.clone()) + .expect_err("a blank connection id must be rejected"); + assert!(error.contains("connection id is required"), "{error}"); + + let opened = open_connection("test-window", request("test-unique-id"), sink.clone()) + .expect("first open should succeed"); + assert_eq!(opened.connection_id, "test-unique-id"); + assert_eq!( + opened.workspace_path, + std::env::temp_dir().to_string_lossy().into_owned() + ); + + let error = open_connection("test-window", request("test-unique-id"), sink) + .expect_err("a duplicate connection id must be rejected"); + assert!(error.contains("already open"), "{error}"); + + // The rejected duplicate must not have replaced the live connection. + let still_open = send_command("test-unique-id", json!("not a command")) + .expect_err("the command payload is invalid"); + assert!(still_open.contains("Invalid Agent command"), "{still_open}"); + close("test-unique-id"); + } + + #[test] + fn a_failed_launch_is_reported_and_close_is_repeatable() { + let (sink, receiver) = recording(); + let opened = open_connection("test-window-failure", request("test-failed-launch"), sink) + .expect("open should succeed before the launch fails"); + assert_eq!(opened.connection_id, "test-failed-launch"); + + wait_for_stopped(&receiver, "test-failed-launch"); + + close("test-failed-launch"); + // A late close must stay a no-op instead of touching a newer connection. + close("test-failed-launch"); + assert!(take_connection("test-failed-launch") + .expect("registry") + .is_none()); + } + + #[test] + fn unknown_connections_and_malformed_commands_are_rejected() { + let error = send_command("test-unknown-connection", json!("not a command")) + .expect_err("an unknown connection must be rejected"); + assert!(error.contains("is not open"), "{error}"); + + let (sink, _receiver) = recording(); + open_connection( + "test-window-commands", + request("test-malformed-command"), + sink, + ) + .expect("open should succeed before the launch fails"); + + let error = send_command("test-malformed-command", json!("not a command")) + .expect_err("a malformed command must be rejected"); + assert!(error.contains("Invalid Agent command"), "{error}"); + + close("test-malformed-command"); + } + + #[test] + fn closing_a_window_releases_only_its_own_connections() { + let (sink, _receiver) = recording(); + open_connection( + "test-window-a", + request("test-window-a-connection"), + sink.clone(), + ) + .expect("open the first window"); + open_connection("test-window-b", request("test-window-b-connection"), sink) + .expect("open the second window"); + + close_window_connections("test-window-a"); + + let released = send_command("test-window-a-connection", json!("not a command")) + .expect_err("the closed window's connection must be gone"); + assert!(released.contains("is not open"), "{released}"); + + // The other window keeps its connection: its send fails on the command + // payload, never on a missing connection. + let kept = send_command("test-window-b-connection", json!("not a command")) + .expect_err("the command payload is still invalid"); + assert!(kept.contains("Invalid Agent command"), "{kept}"); + + close_window_connections("test-window-b"); + } +} diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 260c7f9ba..7d7e8fedd 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -1,5 +1,6 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod agent; mod ai_commit; mod core; mod debug; @@ -101,6 +102,7 @@ fn main() { }) .on_window_event(|window, event| { if matches!(event, tauri::WindowEvent::Destroyed) { + agent::close_window_connections(window.label()); project_windows::release_window(window.app_handle(), window.label().to_owned()); if let Some(watcher) = window.try_state::>() { if let Err(error) = watcher.remove_owner(window.label()) { @@ -115,6 +117,9 @@ fn main() { } }) .invoke_handler(tauri::generate_handler![ + agent::agent_open, + agent::agent_send, + agent::agent_close, document::read_document_file, document::read_document_file_details, document::read_document_file_change, @@ -213,6 +218,7 @@ fn main() { if matches!(event, tauri::RunEvent::Exit) { language_tools::shutdown(); debug::shutdown(); + agent::shutdown(); if let Some(manager) = app.try_state::>() { manager.shutdown(); } diff --git a/windows/tauri/src/platform/tauri-core.test.ts b/windows/tauri/src/platform/tauri-core.test.ts index e549f5a9a..e4e04984c 100644 --- a/windows/tauri/src/platform/tauri-core.test.ts +++ b/windows/tauri/src/platform/tauri-core.test.ts @@ -27,6 +27,24 @@ test("Java index maintenance routes directly to the Tauri host", async () => { }); +test("Agent connection commands route directly to the Tauri host", async () => { + const args = { connectionId: "fixture-connection", command: { authenticate: null } }; + + expect(isNativeCommand("agent_open")).toBe(true); + expect(isNativeCommand("agent_send")).toBe(true); + expect(isNativeCommand("agent_close")).toBe(true); + + await invoke("agent_open", args); + await invoke("agent_close", { connectionId: "fixture-connection" }); + + expect(tauriInvoke).toHaveBeenCalledWith("agent_open", args, undefined); + expect(tauriInvoke).toHaveBeenCalledWith( + "agent_close", + { connectionId: "fixture-connection" }, + undefined, + ); +}); + test("pure console projection bypasses execution events and Git settings", async () => { const args = { records: [], search: "" }; await invoke("git.consolePresentation", args); diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index 05e92ac33..8947e925b 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -16,6 +16,11 @@ import { adaptCoreResult } from "./core-result-adapter"; export { Channel, convertFileSrc }; const nativeCommands = new Set([ + // Agent connections keep a long-lived handle in the Tauri host, so they are + // invoked directly instead of through the shared command envelope. + "agent_open", + "agent_send", + "agent_close", "begin_frontend_terminal_session", "clipboard_clear", "clipboard_get", From 2caa6ce21d0b4fe839aa8cd3201cf71b2b716c7d Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Tue, 29 Sep 2026 09:11:15 +0800 Subject: [PATCH 02/16] =?UTF-8?q?fix(windows):=20=E6=94=B6=E6=95=9B=20Agen?= =?UTF-8?q?t=20=E8=BF=9E=E6=8E=A5=E6=A1=A5=E7=9A=84=E9=80=80=E5=87=BA?= =?UTF-8?q?=E7=AD=89=E5=BE=85=E4=B8=8E=E7=AA=97=E5=8F=A3=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 review 提出的两个问题: - P1 退出前必须等待已从注册表移出的连接清理:窗口销毁时连接先被移出 注册表、清理却留在未登记的阻塞任务里,随后的应用退出只 drain 注册表, 看不到这些连接,可能残留 Agent 进程树。现在注册表的移出与「正在关闭」 计数在同一个临界区完成;退出先在同一次加锁里停止接受新连接并取走剩余 连接、同步关闭它们,再有界等待(CLOSING_WAIT,15 秒)仍在阻塞池里的 关闭,超时打印告警后放弃(AgentHandle::close 自带停止窗口并强杀进程树, 这个上限只兜底任务尚未被调度的情况)。 - P2 agent_send / agent_close 未校验调用窗口:其他窗口只要知道 connection ID 就能驱动或关闭别人的连接。现在两个命令都注入 Webview,并在注册表锁 内比对连接归属窗口;被拒绝的调用不会移除也不是关闭连接。 改动点: - windows/tauri/src-tauri/src/agent.rs:注册表改为 Registry{connections, accepting},新增 open_in / drain_in / take_connections(移出并计数), queue_close、wait_for_closing、verify_owner;关闭计数用 Condvar 通知, 退出等待有界。 - windows/tauri/src-tauri/src/agent.rs 测试从 5 个增加到 11 个:新增跨窗口 send/close 被拒且连接仍在、移出连接时计数立即生效、退出等待在有关闭时 挂起、超时后放弃、最后一个关闭结束时返回、退出 drain 后拒绝新连接;按 窗口释放连接的用例同时断言计数归零。 - .agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md: 补记窗口归属校验、退出停止接受新连接与有界等待,以及「未用存活 Agent 验证整棵进程树回收」的覆盖边界。 验证:cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml agent:: (11 passed);test-stability-windows.ps1 -Scope WindowsRust(208 passed, 最慢用例 2.45s,低于 15s 上限);rustfmt;node scripts/verify-agent-notes.mjs。 --- ...026-09-25-shared-acp-agent-conversation.md | 8 +- windows/tauri/src-tauri/src/agent.rs | 471 +++++++++++++++--- 2 files changed, 408 insertions(+), 71 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index a527ea79b..87d2b64e2 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -43,7 +43,7 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **CLI 更新保留安装来源**:以 PATH 中实际命令及其真实文件为准,不能仅看到用户装了 npm 就把所有 CLI 交给 npm。Homebrew 通过自己报告的 Cellar/Caskroom 位置和已安装记录确认归属,保留 cask/formula 及 `claude-code@latest` 等渠道;npm 必须确认当前 global root、包名、bin 声明和链接都指向同一 CLI,另一套 Node 环境不能代更新;Claude 标准原生 launcher 使用上游 `claude update`。未知来源、损坏链接、缺少原安装器或安装记录时拒绝自动覆盖,明确提供手动指引。更新后重新读取登录 shell 的 PATH 并验证最低版本;更新命令退出成功但实际 CLI 仍过旧也应失败。安装器可能在首次下载失败后重试成功,却保留非零退出状态,因此正常结束的命令无论退出状态如何,都要检查实际版本。只有新安装或数字版本严格提升且达到最低要求时,才能把非零退出降为“已成功、带警告”,返回有界日志并在界面折叠展示;原本可用但版本没变、降级、仍过旧或找不到命令时继续报错。不能根据日志中的“successfully upgraded”字样猜测成功,也不能把取消、超时或启动失败改判成功。读取来源只使用有界的本地查询,不更新包管理器索引、不改变用户配置。Homebrew 和原生下载归原安装器拥有,仅显示“正在更新”与耗时,不伪造字节进度;它们的全局安装和缓存不注册成可复制的工作树构建资源,排除清单与测试同步维护。 - **下载进度以 npm 的真实传输为准**:安装与 CLI 升级通过现有 Core 事件回调报告已接收软件包字节数、最近采样速度、耗时和等待时间。npm 没有提供整次安装的总量,且会继续发现依赖,所以不显示总体百分比。内嵌的 Node 观察模块只统计 HTTP 响应进入流缓冲区的字节,不添加消费数据的监听器,也不重写下载、代理、重试、校验或缓存行为。模块通过内存中的 data URL 加载,启动后先恢复用户原有 `NODE_OPTIONS`,防止 npm 子脚本继承观察器;不生成辅助文件或新的可复用缓存。正确做法是显示“已下载 25 MB、75 KB/秒、已用时 300 秒”;不要把 npm 静默时的日志时间或整个共享缓存大小当成下载进度。Core 事件只携带数字和阶段,界面按操作标识丢弃迟到事件,完成、失败或取消后清除进度。 - **Rust Core 命令**:`agent.status`、`agent.install`、`agent.uninstall`、`agent.installCli`,复用现有信封的取消和超时。 - - **Windows 连接桥**:`windows/tauri/src-tauri/src/agent.rs` 直接依赖 `lithe-agent-host`,用 `agent_open`、`agent_send`、`agent_close` 三个 Tauri 命令转发 fixture 里的同一套 JSON,协议、会话和取消语义仍只有共享 crate 一份。连接 ID 由界面自己生成:界面先订阅 `agent_event` 再开连接,因此不存在“事件先于连接标识到达”的竞态;重复或为空的 ID 直接拒绝,不会顶掉在用的连接。事件只发给打开它的那个窗口,不广播给其他项目。连接按窗口标签登记,项目窗口销毁和应用退出都会释放连接与进程树,退出路径同步等待。Windows 桌面外壳是 GUI 进程、没有控制台,所以共享 host 在 Windows 上以 `CREATE_NO_WINDOW` 启动适配器,避免每开一次 Agent 都弹出一个控制台窗口。 + - **Windows 连接桥**:`windows/tauri/src-tauri/src/agent.rs` 直接依赖 `lithe-agent-host`,用 `agent_open`、`agent_send`、`agent_close` 三个 Tauri 命令转发 fixture 里的同一套 JSON,协议、会话和取消语义仍只有共享 crate 一份。连接 ID 由界面自己生成:界面先订阅 `agent_event` 再开连接,因此不存在“事件先于连接标识到达”的竞态;重复或为空的 ID 直接拒绝,不会顶掉在用的连接。事件只发给打开它的那个窗口,不广播给其他项目;`agent_send` 和 `agent_close` 同样注入 Webview,并在同一个注册表临界区内比对连接归属窗口,所以其他窗口即使拿到 ID 也不能驱动或关闭别人的连接。连接按窗口标签登记:项目窗口销毁时把连接移出注册表并交给阻塞池关闭,移出与“正在关闭”计数在同一个临界区完成,因此随后的应用退出既看不到这条连接、也不会漏等它;退出先在同一次加锁里停止接受新连接并取走剩余连接,同步关闭它们,再以 15 秒上限等待仍在阻塞池里的关闭,而 `AgentHandle::close` 自带停止窗口并强杀进程树,这个上限只兜底“任务还没被调度”的情况。Windows 桌面外壳是 GUI 进程、没有控制台,所以共享 host 在 Windows 上以 `CREATE_NO_WINDOW` 启动适配器,避免每开一次 Agent 都弹出一个控制台窗口。 - **Key 和模型的传法**:API Key 模式的适配器通过 ACP `gateway` 登录,Key 经 stdio 传给 Agent,请求头按协议选择:Responses 协议用 `Authorization: Bearer`,Anthropic 协议用 `x-api-key`。模型按适配器分别传:Codex 用 `CODEX_CONFIG`,Claude 用 `ANTHROPIC_MODEL`。服务商配置里的"模型"必须传给 Agent:实测某个网关禁用了 Codex 的默认模型,不传模型时 Agent 只会回复一条网关报错。 - **设置放在面板里,只有 Agent 管理一页**:Agent 的开关、预检清单(Node、npm、CLI、适配器、本机配置)、适配器和 CLI 的一键安装都在 Agent 面板右上角的设置视图里,不进全局设置窗口。布局仿照 Codeg 和 CC GUI:左侧图标栏,右侧标题加分段切换各个 Agent。 - **本机配置保留 CLI 所有权**:每个 Agent 通过本机配置行读取用户自己 CLI 的地址、模型和密钥(Codex 读 `~/.codex/config.toml` 和 `auth.json`,Claude 读 `~/.claude/settings.json` 和 `~/.claude.json`),生成的服务商配置绑定到该 Agent。本机模式的密钥不复制进 Lithe,启动时从用户文件现读;要改本机地址或密钥时编辑自己的文件再刷新。需要独立配置时使用上面的自定义供应商编辑器,不改写 CLI 文件,也不改变提交信息使用的服务商选择。 @@ -57,7 +57,7 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 正确做法:新平台的界面通过平台适配器把 fixture 里的命令交给 `lithe-agent-host`,并把工具权限选择交给用户。 -不要这样做:在 Windows React 层重新实现 JSON-RPC 协议;在打开 IDE 时就启动 Agent;取消尚未确认就解锁发送;把取消超时伪装成成功而不提示用户重连;把 API Key 通过环境变量或命令行传给 Agent;替用户下载一份他本机已有的 Agent CLI。 +不要这样做:在 Windows React 层重新实现 JSON-RPC 协议;在打开 IDE 时就启动 Agent;取消尚未确认就解锁发送;把取消超时伪装成成功而不提示用户重连;把 API Key 通过环境变量或命令行传给 Agent;替用户下载一份他本机已有的 Agent CLI;只按连接 ID 授权 send/close 而不校验调用窗口;退出时只清空注册表,把已经交给后台线程的关闭留在未登记状态。 ## 考虑过的备选方案 @@ -109,7 +109,7 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 代价: -- Windows 面板 UI、供应商切换和额度展示仍需另做;共享 host 在 Windows 上的适配器启动和进程树回收还没有真机端到端验收。 +- Windows 面板 UI、供应商切换和额度展示仍需另做;共享 host 在 Windows 上的适配器启动和进程树回收还没有真机端到端验收。退出等待由注册表计数与可控 gate 测试覆盖,但没有用存活的真实 Agent 验证整棵进程树在“窗口销毁→退出”竞争下已被回收。 - Rust C ABI 和 fixture 成为兼容面,两端界面仍要分别维护。 - 用户需要自行安装 Node.js,适配器可以在面板内安装。 - codex-acp 丢失取消时,最多等待十秒后需要用户重连;同一进程的其他会话也会断开。上游未持久化的最后片段可能无法完整回放,界面保留旧记录用于诊断,不能保证 Agent 保存了未完成轮次。 @@ -120,7 +120,7 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 - 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 不出现订阅入口。Windows 已有共享协议和连接桥,但面板 UI、供应商切换和额度展示待接入,连接桥还需要在 Windows 上做一次真实适配器的端到端验收。 - `cargo test -p lithe-agent-host --manifest-path rust/Cargo.toml` -- `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml agent::`(Windows 连接桥:非法启动配置、空或重复连接 ID、启动失败上报、未知连接与非法命令、按窗口释放连接;不需要真实 Agent 或网络) +- `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml agent::`(Windows 连接桥:非法启动配置、空或重复连接 ID、启动失败上报、未知连接与非法命令、按窗口释放连接、跨窗口 send/close 被拒且连接仍在、关闭计数在移出连接时即生效、出口等待在有关闭时挂起并在最后一个关闭结束时返回、超时后放弃等待、退出 drain 后拒绝新连接;不需要真实 Agent 或网络) - `cargo test -p lithe-core agent`(`agent.*` 命令与 `shared/fixtures/agent/agent-management-v1.json`) - 真实 Agent 端到端测试默认忽略,需要设置 `LITHE_ACP_E2E_*` 环境变量后运行:`cargo test -p lithe-agent-host --test real_agent -- --ignored`。设置 `LITHE_ACP_E2E_DATA_DIR` 时,会先用 npm 安装适配器,再从 Lithe 数据目录启动。 - `shared/fixtures/agent/acp-events-v1.json` 同时由 Rust 序列化测试和 Swift 功能模型测试读取。 diff --git a/windows/tauri/src-tauri/src/agent.rs b/windows/tauri/src-tauri/src/agent.rs index 942f68e46..8a66a9c56 100644 --- a/windows/tauri/src-tauri/src/agent.rs +++ b/windows/tauri/src-tauri/src/agent.rs @@ -9,20 +9,28 @@ //! Byte flow: React subscribes to `agent_event`, calls `agent_open` with its own //! connection id and the shared `AgentLaunch` JSON, then `agent_send` with the //! shared `AgentCommand` JSON. Each event reaches the owning window as -//! `{ connectionId, event }`. `agent_close`, window destruction, and application -//! exit release the connection and its process tree. +//! `{ connectionId, event }`, and every command must come from that same window. +//! `agent_close`, window destruction, and application exit release the connection +//! and its process tree; exit stops accepting connections and waits a bounded +//! time for the closes that are already running. //! See `.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md`. use lithe_agent_host::{AgentCommand, AgentEvent, AgentHandle, AgentLaunch}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use tauri::{Emitter, Manager}; /// Tauri event carrying the Agent events of every connection. pub const AGENT_EVENT_NAME: &str = "agent_event"; +/// How long application exit waits for closes that are already running before +/// it abandons them. `AgentHandle::close` bounds its own stop window, so this +/// only covers a blocking task that never got scheduled. +const CLOSING_WAIT: Duration = Duration::from_secs(15); + /// Opens one Agent connection owned by the calling window. #[tauri::command] pub fn agent_open( @@ -36,16 +44,21 @@ pub fn agent_open( open_connection(webview.label(), request, sink) } -/// Queues one shared `AgentCommand` on an open connection. +/// Queues one shared `AgentCommand` on an open connection owned by the caller. #[tauri::command] -pub fn agent_send(connection_id: String, command: Value) -> Result<(), String> { - send_command(&connection_id, command) +pub fn agent_send( + webview: tauri::Webview, + connection_id: String, + command: Value, +) -> Result<(), String> { + send_command(webview.label(), &connection_id, command) } -/// Stops one connection and its process tree. Repeated calls are no-ops. +/// Stops one connection owned by the caller, and its process tree. Repeated +/// calls are no-ops. #[tauri::command] -pub async fn agent_close(connection_id: String) -> Result<(), String> { - close_connection(&connection_id).await +pub async fn agent_close(webview: tauri::Webview, connection_id: String) -> Result<(), String> { + close_connection(webview.label(), &connection_id).await } /// One `agent_open` request. @@ -101,12 +114,25 @@ struct Connection { handle: AgentHandle, } -/// Live connections keyed by the caller's connection id. Process-wide so the -/// window-destroyed and application-exit hooks reach the same registry without -/// an `AppHandle`, mirroring `debug.rs`. -fn connections() -> &'static Mutex> { - static CONNECTIONS: OnceLock>> = OnceLock::new(); - CONNECTIONS.get_or_init(|| Mutex::new(HashMap::new())) +/// Live connections keyed by the caller's connection id, plus whether new ones +/// are still accepted. Process-wide so the window-destroyed and +/// application-exit hooks reach the same registry without an `AppHandle`, +/// mirroring `debug.rs`. +struct Registry { + connections: HashMap, + /// Cleared by application exit in the same critical section that drains the + /// connections: one opened after that drain would never be closed. + accepting: bool, +} + +fn registry() -> &'static Mutex { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| { + Mutex::new(Registry { + connections: HashMap::new(), + accepting: true, + }) + }) } fn open_connection( @@ -114,6 +140,26 @@ fn open_connection( request: AgentOpenRequest, sink: S, ) -> Result { + let mut registry = registry() + .lock() + .map_err(|_| "Agent connection state is unavailable.".to_string())?; + open_in(&mut registry, window, request, sink) +} + +/// Opens one connection in `registry`. Takes the registry instead of locking it +/// so exit can hold a single critical section for its drain, and so tests can +/// drive the accepting rules without the process-wide state. +fn open_in( + registry: &mut Registry, + window: &str, + request: AgentOpenRequest, + sink: S, +) -> Result { + if !registry.accepting { + return Err( + "The application is shutting down, so no Agent connection can be opened.".into(), + ); + } let connection_id = request.connection_id.trim().to_string(); if connection_id.is_empty() { return Err("An Agent connection id is required.".into()); @@ -130,14 +176,11 @@ fn open_connection( } }); - let mut current = connections() - .lock() - .map_err(|_| "Agent connection state is unavailable.".to_string())?; - if current.contains_key(&connection_id) { + if registry.connections.contains_key(&connection_id) { return Err(format!("Agent connection {connection_id} is already open.")); } let handle = AgentHandle::open(launch, emit)?; - current.insert( + registry.connections.insert( connection_id.clone(), Connection { window: window.to_owned(), @@ -150,74 +193,193 @@ fn open_connection( }) } -fn send_command(connection_id: &str, command: Value) -> Result<(), String> { - let current = connections() +/// Queues one command on a connection, provided `window` opened it. +fn send_command(window: &str, connection_id: &str, command: Value) -> Result<(), String> { + let registry = registry() .lock() .map_err(|_| "Agent connection state is unavailable.".to_string())?; - let connection = current + let connection = registry + .connections .get(connection_id) .ok_or_else(|| format!("Agent connection {connection_id} is not open."))?; + verify_owner(window, connection_id, connection)?; let command: AgentCommand = serde_json::from_value(command) .map_err(|error| format!("Invalid Agent command: {error}"))?; connection.handle.send(command) } -fn take_connection(connection_id: &str) -> Result, String> { - let mut current = connections() - .lock() - .map_err(|_| "Agent connection state is unavailable.".to_string())?; - Ok(current.remove(connection_id)) +/// Rejects a command that names a connection another window opened. Events are +/// already addressed to the owner, so without this check a second window could +/// drive or close a connection it never opened. +fn verify_owner(window: &str, connection_id: &str, connection: &Connection) -> Result<(), String> { + if connection.window == window { + return Ok(()); + } + Err(format!( + "Agent connection {connection_id} belongs to another window." + )) +} + +/// Removes every connection `select` accepts from `registry`, counting them in +/// `state` as closing when a state is given. `select` may reject the whole call +/// with an error, which leaves the registry untouched. +/// +/// Accounting inside the same critical section is what makes application exit +/// safe: an exit that drains the registry can neither miss a connection a close +/// path has already taken, nor observe the registry empty while a close that +/// path handed to the blocking pool is still pending. +fn take_connections( + registry: &mut Registry, + state: Option<&ClosingState>, + select: impl Fn(&str, &Connection) -> Result, +) -> Result, String> { + let mut owned = Vec::new(); + for (connection_id, connection) in ®istry.connections { + if select(connection_id, connection)? { + owned.push(connection_id.clone()); + } + } + let taken: Vec = owned + .into_iter() + .filter_map(|connection_id| registry.connections.remove(&connection_id)) + .collect(); + if let Some(state) = state { + // The count is raised before the caller can queue the close, so a + // concurrent exit can never observe zero while a close is pending. + account_closing(state, taken.len()); + } + Ok(taken) } -async fn close_connection(connection_id: &str) -> Result<(), String> { - let Some(connection) = take_connection(connection_id)? else { - // Repeated or late closes are no-ops and never touch a newer connection. +/// Stops the connection `window` opened. Repeated or late closes are no-ops, and +/// another window's connection is rejected without being touched. +async fn close_connection(window: &str, connection_id: &str) -> Result<(), String> { + let taken = { + let mut registry = registry() + .lock() + .map_err(|_| "Agent connection state is unavailable.".to_string())?; + take_connections(&mut registry, Some(closing()), |id, connection| { + if id != connection_id { + return Ok(false); + } + verify_owner(window, connection_id, connection)?; + Ok(true) + })? + }; + let Some(connection) = taken.into_iter().next() else { + // Repeated, late, and unknown closes are no-ops that never touch a newer + // or another window's connection. return Ok(()); }; // `AgentHandle::close` waits for the bounded stop window and force-kills the // process tree, so it must stay off the async runtime's worker threads. - tauri::async_runtime::spawn_blocking(move || connection.handle.close()) + queue_close(move || connection.handle.close()) .await .map_err(|error| format!("Agent connection cleanup failed: {error}")) } +/// Closes already handed to the blocking pool, so application exit can wait for +/// them. A destroyed window removes its connection from the registry before the +/// close finishes, and closing the last window quits the application, so +/// without this counter the exit path would abandon a process tree mid-teardown. +type ClosingState = Arc<(Mutex, Condvar)>; + +fn closing() -> &'static ClosingState { + static CLOSING: OnceLock = OnceLock::new(); + CLOSING.get_or_init(|| Arc::new((Mutex::new(0), Condvar::new()))) +} + +/// Raises the running-close count for closes that are about to be queued. +/// +/// Poisoning is ignored on purpose: a waiter that cannot read the count does not +/// wait at all, so losing the count can only shorten a wait, never hang it. +fn account_closing(state: &ClosingState, added: usize) { + if let Ok(mut count) = state.0.lock() { + *count += added; + } +} + +/// Marks one queued close as finished and wakes application exit. +fn finish_closing(state: &ClosingState) { + if let Ok(mut count) = state.0.lock() { + *count = count.saturating_sub(1); + state.1.notify_all(); + } +} + +/// Runs one already-counted close on the blocking pool, reporting completion to +/// `finish_closing` so application exit can wait for it. +fn queue_close(close: C) -> tauri::async_runtime::JoinHandle<()> { + tauri::async_runtime::spawn_blocking(move || { + close(); + finish_closing(closing()); + }) +} + +/// Waits until no close is running; false means the deadline passed first. +fn wait_for_closing(state: &ClosingState, deadline: Instant) -> bool { + let Ok(mut count) = state.0.lock() else { + return true; + }; + while *count > 0 { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + match state.1.wait_timeout(count, remaining) { + Ok((next, _)) => count = next, + Err(_) => return true, + } + } + true +} + /// Releases every connection owned by one window when a project window closes, /// so a closed project never leaves an Agent process behind. Cleanup runs on the /// blocking pool because the window is already gone and stopping an Agent waits -/// for its bounded stop window. +/// for its bounded stop window. Dropping the handle detaches the task. pub fn close_window_connections(window: &str) { - for connection in take_window_connections(window) { - tauri::async_runtime::spawn_blocking(move || connection.handle.close()); + let taken = { + let Ok(mut registry) = registry().lock() else { + return; + }; + take_connections(&mut registry, Some(closing()), |_, connection| { + Ok(connection.window == window) + }) + .unwrap_or_default() + }; + for connection in taken { + drop(queue_close(move || connection.handle.close())); } } -fn take_window_connections(window: &str) -> Vec { - let Ok(mut current) = connections().lock() else { - return Vec::new(); - }; - let owned: Vec = current - .iter() - .filter(|(_, connection)| connection.window == window) - .map(|(connection_id, _)| connection_id.clone()) - .collect(); - owned - .into_iter() - .filter_map(|connection_id| current.remove(&connection_id)) +/// Takes every connection and stops accepting new ones, in one critical section, +/// so nothing can be opened behind an exit that already drained the registry. +fn drain_in(registry: &mut Registry) -> Vec { + registry.accepting = false; + registry + .connections + .drain() + .map(|(_, connection)| connection) .collect() } /// Closes every live connection during application exit so no Agent process /// outlives the shell. Runs synchronously: the process is about to exit, and a -/// detached cleanup task would be abandoned with it. +/// detached cleanup task would be abandoned with it. Connections a destroyed +/// window already handed to the blocking pool are waited for afterwards, up to +/// `CLOSING_WAIT`. pub fn shutdown() { - let Ok(mut current) = connections().lock() else { - return; + // These closes run inline, so they are not counted as pending pool work. + let live = match registry().lock() { + Ok(mut registry) => drain_in(&mut registry), + Err(_) => Vec::new(), }; - let live: Vec = current.drain().map(|(_, connection)| connection).collect(); - drop(current); for connection in live { connection.handle.close(); } + if !wait_for_closing(closing(), Instant::now() + CLOSING_WAIT) { + eprintln!("Agent shutdown timed out while waiting for an in-flight close."); + } } #[cfg(test)] @@ -267,8 +429,21 @@ mod tests { } } - fn close(connection_id: &str) { - tauri::async_runtime::block_on(close_connection(connection_id)).expect("close"); + fn close(window: &str, connection_id: &str) { + tauri::async_runtime::block_on(close_connection(window, connection_id)).expect("close"); + } + + /// Reads the registry without counting a close, for assertions about what is + /// still open. Production paths remove and account in one step. + fn take_connection(connection_id: &str) -> Result, String> { + let mut registry = registry() + .lock() + .map_err(|_| "Agent connection state is unavailable.".to_string())?; + Ok( + take_connections(&mut registry, None, |id, _| Ok(id == connection_id))? + .into_iter() + .next(), + ) } fn wait_for_stopped(receiver: &mpsc::Receiver<(String, Value)>, connection_id: &str) { @@ -330,10 +505,10 @@ mod tests { assert!(error.contains("already open"), "{error}"); // The rejected duplicate must not have replaced the live connection. - let still_open = send_command("test-unique-id", json!("not a command")) + let still_open = send_command("test-window", "test-unique-id", json!("not a command")) .expect_err("the command payload is invalid"); assert!(still_open.contains("Invalid Agent command"), "{still_open}"); - close("test-unique-id"); + close("test-window", "test-unique-id"); } #[test] @@ -345,9 +520,9 @@ mod tests { wait_for_stopped(&receiver, "test-failed-launch"); - close("test-failed-launch"); + close("test-window-failure", "test-failed-launch"); // A late close must stay a no-op instead of touching a newer connection. - close("test-failed-launch"); + close("test-window-failure", "test-failed-launch"); assert!(take_connection("test-failed-launch") .expect("registry") .is_none()); @@ -355,8 +530,12 @@ mod tests { #[test] fn unknown_connections_and_malformed_commands_are_rejected() { - let error = send_command("test-unknown-connection", json!("not a command")) - .expect_err("an unknown connection must be rejected"); + let error = send_command( + "test-window-unknown", + "test-unknown-connection", + json!("not a command"), + ) + .expect_err("an unknown connection must be rejected"); assert!(error.contains("is not open"), "{error}"); let (sink, _receiver) = recording(); @@ -367,11 +546,15 @@ mod tests { ) .expect("open should succeed before the launch fails"); - let error = send_command("test-malformed-command", json!("not a command")) - .expect_err("a malformed command must be rejected"); + let error = send_command( + "test-window-commands", + "test-malformed-command", + json!("not a command"), + ) + .expect_err("a malformed command must be rejected"); assert!(error.contains("Invalid Agent command"), "{error}"); - close("test-malformed-command"); + close("test-window-commands", "test-malformed-command"); } #[test] @@ -388,16 +571,170 @@ mod tests { close_window_connections("test-window-a"); - let released = send_command("test-window-a-connection", json!("not a command")) - .expect_err("the closed window's connection must be gone"); + let released = send_command( + "test-window-a", + "test-window-a-connection", + json!("not a command"), + ) + .expect_err("the closed window's connection must be gone"); assert!(released.contains("is not open"), "{released}"); // The other window keeps its connection: its send fails on the command // payload, never on a missing connection. - let kept = send_command("test-window-b-connection", json!("not a command")) - .expect_err("the command payload is still invalid"); + let kept = send_command( + "test-window-b", + "test-window-b-connection", + json!("not a command"), + ) + .expect_err("the command payload is still invalid"); assert!(kept.contains("Invalid Agent command"), "{kept}"); close_window_connections("test-window-b"); + + // Both windows' closes must drain to zero, otherwise application exit + // would wait the whole bounded window for closes that already finished. + assert!(wait_for_closing(closing(), Instant::now() + EVENT_WAIT)); + } + + #[test] + fn exit_wait_returns_at_once_when_no_close_is_running() { + let state: ClosingState = Arc::new((Mutex::new(0), Condvar::new())); + assert!(wait_for_closing(&state, Instant::now() + EVENT_WAIT)); + } + + #[test] + fn exit_wait_gives_up_at_its_deadline_while_a_close_is_pending() { + let state: ClosingState = Arc::new((Mutex::new(1), Condvar::new())); + let started = Instant::now(); + + assert!(!wait_for_closing( + &state, + started + Duration::from_millis(100) + )); + + assert!(started.elapsed() >= Duration::from_millis(100)); + } + + #[test] + fn exit_wait_returns_once_the_last_close_is_released() { + let state: ClosingState = Arc::new((Mutex::new(1), Condvar::new())); + let waiter = state.clone(); + let (result, released) = mpsc::channel(); + std::thread::spawn(move || { + let _ = result.send(wait_for_closing(&waiter, Instant::now() + EVENT_WAIT)); + }); + + finish_closing(&state); + + assert!( + released + .recv_timeout(EVENT_WAIT) + .expect("the wait must return"), + "waking a pending close must report that nothing is running" + ); + } + + #[test] + fn taking_a_connection_for_a_close_counts_it_before_any_task_runs() { + let (sink, _receiver) = recording(); + let mut registry = Registry { + connections: HashMap::new(), + accepting: true, + }; + open_in( + &mut registry, + "test-window-accounting", + request("test-accounting-connection"), + sink, + ) + .expect("open the window's connection"); + let state: ClosingState = Arc::new((Mutex::new(0), Condvar::new())); + + let taken = take_connections(&mut registry, Some(&state), |_, connection| { + Ok(connection.window == "test-window-accounting") + }) + .expect("registry"); + + assert_eq!(taken.len(), 1); + // The count is visible before any close is queued, which is what stops + // application exit from observing an empty registry and leaving then. + assert_eq!(*state.0.lock().expect("count"), 1); + assert!(registry.connections.is_empty()); + + for connection in taken { + connection.handle.close(); + } + finish_closing(&state); + assert!(wait_for_closing(&state, Instant::now() + EVENT_WAIT)); + } + + #[test] + fn an_exit_drain_takes_every_connection_and_refuses_new_ones() { + let (sink, _receiver) = recording(); + let mut registry = Registry { + connections: HashMap::new(), + accepting: true, + }; + open_in( + &mut registry, + "test-window-exit", + request("test-exit-connection"), + sink, + ) + .expect("open before exit"); + + let taken = drain_in(&mut registry); + + assert_eq!(taken.len(), 1); + assert!(!registry.accepting); + + let (later_sink, _later_receiver) = recording(); + let error = open_in( + &mut registry, + "test-window-exit", + request("test-after-exit"), + later_sink, + ) + .expect_err("exit must refuse a connection nothing would close"); + assert!(error.contains("shutting down"), "{error}"); + assert!(registry.connections.is_empty()); + + for connection in taken { + connection.handle.close(); + } + } + + #[test] + fn a_window_cannot_drive_or_close_another_windows_connection() { + let (sink, _receiver) = recording(); + open_connection("test-window-owner", request("test-owned-connection"), sink) + .expect("open the owner's connection"); + + let refused = send_command( + "test-window-intruder", + "test-owned-connection", + json!("not a command"), + ) + .expect_err("another window must not queue commands on the connection"); + assert!(refused.contains("belongs to another window"), "{refused}"); + + let refused = tauri::async_runtime::block_on(close_connection( + "test-window-intruder", + "test-owned-connection", + )) + .expect_err("another window must not close the connection"); + assert!(refused.contains("belongs to another window"), "{refused}"); + + // The refused calls left the owner's connection untouched: its send still + // fails on the payload, never on a missing connection. + let kept = send_command( + "test-window-owner", + "test-owned-connection", + json!("not a command"), + ) + .expect_err("the command payload is still invalid"); + assert!(kept.contains("Invalid Agent command"), "{kept}"); + + close("test-window-owner", "test-owned-connection"); } } From 2db71928b2f5ed9525d8292737fcb50111a4a8f2 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Tue, 29 Sep 2026 09:26:35 +0800 Subject: [PATCH 03/16] =?UTF-8?q?test(windows):=20=E7=94=A8=E5=8F=97?= =?UTF-8?q?=E6=8E=A7=E5=81=87=E9=80=82=E9=85=8D=E5=99=A8=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=20Agent=20=E8=BF=9B=E7=A8=8B=E6=A0=91=E5=9B=9E=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连接桥的关闭语义此前只用假启动失败和可控 gate 覆盖,没有用存活的 Agent 验证“整棵进程树”是否真的被回收。Windows 适配器(如 codex-acp)会派生出 独立的 app-server 并继承 stdout,只杀掉外层进程会留下它并占住输出管道。 - 新增 `fake_acp_adapter` 假适配器:以 NDJSON 应答 ACP 握手(initialize 返回 gateway authMethod,session/new 返回会话),派生一个继承 stdout 的 grandchild 模拟 app-server,并把两个进程 ID 写进 pid 文件;孙进程带 120 秒自活上限,避免测试失败时泄漏进程。 - 它是普通 bin 而不是 `test-support` 特性下的目标:后者没有任何测试套件 启用(`fake_dap_adapter` 的测试因此在 CI 中从不运行)。它也不会被打包, `tauri.conf.json` 只打包应用二进制。 - agent::tests 新增两条端到端路径断言:`agent_close` 关闭连接、以及窗口 销毁触发的 `close_window_connections`,都必须让外层进程和继承 stdout 的 孙进程同时退出,失败时先杀掉幸存者再断言。 - 顺带修掉测试噪音:RecordingSink 对已结束测试的迟到事件不再 panic,等 待中的测试仍以自己的 recv_timeout 失败。 - Agent Note 更新进程树回收的验证状态与仍待真机验收的边界。 --- ...026-09-25-shared-acp-agent-conversation.md | 6 +- windows/tauri/src-tauri/Cargo.toml | 7 + windows/tauri/src-tauri/src/agent.rs | 209 +++++++++++++++++- .../src-tauri/src/bin/fake_acp_adapter.rs | 141 ++++++++++++ 4 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 windows/tauri/src-tauri/src/bin/fake_acp_adapter.rs diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index aa71497e3..38e14bd06 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -111,7 +111,7 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 代价: -- Windows 面板 UI、供应商切换和额度展示仍需另做;共享 host 在 Windows 上的适配器启动和进程树回收还没有真机端到端验收。退出等待由注册表计数与可控 gate 测试覆盖,但没有用存活的真实 Agent 验证整棵进程树在“窗口销毁→退出”竞争下已被回收。 +- Windows 面板 UI、供应商切换和额度展示仍需另做;共享 host 在 Windows 上的适配器启动和进程树回收还没有真机端到端验收。进程树回收本身已用受控假适配器(`windows/tauri/src-tauri/src/bin/fake_acp_adapter.rs`)验证:它会派生一个继承 stdout 的孙进程,模拟 codex-acp 的 app-server,只杀外层进程会留下它并占住输出管道;普通 Rust 测试由此确认“关闭连接”和“销毁窗口”两条路径都回收了整棵树。退出与窗口销毁并发的时序仍由注册表计数和可控 gate 测试覆盖,不用真实 Agent 复现。 - Rust C ABI 和 fixture 成为兼容面,两端界面仍要分别维护。 - 用户需要自行安装 Node.js,适配器可以在面板内安装。 - codex-acp 丢失取消时,最多等待十秒后需要用户重连;同一进程的其他会话也会断开。上游未持久化的最后片段可能无法完整回放,界面保留旧记录用于诊断,不能保证 Agent 保存了未完成轮次。 @@ -123,7 +123,7 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 - 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 不出现订阅入口。Windows 已有共享协议和连接桥,但面板 UI、供应商切换和额度展示待接入,连接桥还需要在 Windows 上做一次真实适配器的端到端验收。 - `cargo test -p lithe-agent-host --manifest-path rust/Cargo.toml` -- `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml agent::`(Windows 连接桥:非法启动配置、空或重复连接 ID、启动失败上报、未知连接与非法命令、按窗口释放连接、跨窗口 send/close 被拒且连接仍在、关闭计数在移出连接时即生效、出口等待在有关闭时挂起并在最后一个关闭结束时返回、超时后放弃等待、退出 drain 后拒绝新连接;不需要真实 Agent 或网络) +- `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml agent::`(Windows 连接桥:非法启动配置、空或重复连接 ID、启动失败上报、未知连接与非法命令、按窗口释放连接、跨窗口 send/close 被拒且连接仍在、关闭计数在移出连接时即生效、出口等待在有关闭时挂起并在最后一个关闭结束时返回、超时后放弃等待、退出 drain 后拒绝新连接;不需要真实 Agent 或网络。进程树回收用 `fake_acp_adapter` 假适配器覆盖两条路径——关闭连接、销毁窗口——断言外层进程和继承 stdout 的孙进程都已退出) - `cargo test -p lithe-core agent`(`agent.*` 命令与 `shared/fixtures/agent/agent-management-v1.json`) - 真实 Agent 端到端测试默认忽略,需要设置 `LITHE_ACP_E2E_*` 环境变量后运行:`cargo test -p lithe-agent-host --test real_agent -- --ignored`。设置 `LITHE_ACP_E2E_DATA_DIR` 时,会先用 npm 安装适配器,再从 Lithe 数据目录启动。 - `shared/fixtures/agent/acp-events-v1.json` 同时由 Rust 序列化测试和 Swift 功能模型测试读取。 @@ -141,4 +141,4 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 适用范围 -`rust/lithe-agent-host/`、`rust/lithe-core/src/agent/`、`rust/lithe-core/src/runtime/ffi.rs`、`macos/Sources/Lithe/Views/Agent/`、`macos/Sources/LitheAgentConversationModule/`、`macos/Sources/Lithe/Platform/MacOS/Agent/`、`windows/tauri/src-tauri/src/agent.rs`、`shared/contracts/rust-core-api.md`、`shared/contracts/application-boundary.md`。 +`rust/lithe-agent-host/`、`rust/lithe-core/src/agent/`、`rust/lithe-core/src/runtime/ffi.rs`、`macos/Sources/Lithe/Views/Agent/`、`macos/Sources/LitheAgentConversationModule/`、`macos/Sources/Lithe/Platform/MacOS/Agent/`、`windows/tauri/src-tauri/src/agent.rs`、`windows/tauri/src-tauri/src/bin/fake_acp_adapter.rs`、`shared/contracts/rust-core-api.md`、`shared/contracts/application-boundary.md`。 diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index 3d4062ebd..9e0a59a24 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -14,6 +14,13 @@ name = "fake-dap-adapter" path = "src/bin/fake_dap_adapter.rs" required-features = ["test-support"] +# The connection bridge's process-tree tests must run in the ordinary Windows +# Rust suite, which never enables `test-support`, so this fixture is a regular +# binary. `tauri.conf.json` packages only the application binary. +[[bin]] +name = "fake-acp-adapter" +path = "src/bin/fake_acp_adapter.rs" + [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/windows/tauri/src-tauri/src/agent.rs b/windows/tauri/src-tauri/src/agent.rs index 8a66a9c56..d7fcd532f 100644 --- a/windows/tauri/src-tauri/src/agent.rs +++ b/windows/tauri/src-tauri/src/agent.rs @@ -385,6 +385,8 @@ pub fn shutdown() { #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::time::{Duration, Instant}; @@ -399,9 +401,10 @@ mod tests { impl AgentEventSink for RecordingSink { fn emit_event(&self, connection_id: &str, event: Value) { - self.events - .send((connection_id.to_string(), event)) - .expect("recording sink receiver should remain active"); + // A test that has already finished no longer reads events, and its + // connection worker can still report one; dropping it is not a + // failure. A test that is waiting asserts on its own `recv_timeout`. + let _ = self.events.send((connection_id.to_string(), event)); } } @@ -446,6 +449,152 @@ mod tests { ) } + /// Path of the ACP fixture the process-tree tests spawn. It is a regular + /// binary, so the suite has it without enabling `test-support`. + #[cfg(windows)] + fn fixture_path() -> PathBuf { + let test_binary = std::env::current_exe().expect("test binary path"); + test_binary + .parent() + .and_then(Path::parent) + .expect("target directory") + .join("fake-acp-adapter.exe") + } + + /// Where the fixture records the process ids of its wrapper and grandchild. + #[cfg(windows)] + fn fixture_pid_file(label: &str) -> PathBuf { + let path = + std::env::temp_dir().join(format!("lithe-fake-acp-{label}-{}.txt", std::process::id())); + let _ = std::fs::remove_file(&path); + path + } + + /// Starts the fixture adapter: a live ACP connection whose wrapper owns a + /// grandchild that inherits stdout, like a real adapter's app-server. + #[cfg(windows)] + fn fixture_request(connection_id: &str, pid_file: &Path) -> AgentOpenRequest { + AgentOpenRequest { + connection_id: connection_id.to_string(), + workspace_path: std::env::temp_dir().to_string_lossy().into_owned(), + launch: json!({ + "command": fixture_path().to_string_lossy(), + "args": ["serve", pid_file.to_string_lossy()], + "cwd": std::env::temp_dir(), + "authentication": "apiKey", + "provider": { + "protocol": "responses", + "baseUrl": "https://gateway.example.com/v1", + "apiKey": "test-key", + }, + }), + } + } + + /// Waits for one event, failing with the host's message when the connection + /// stops first: a fixture that cannot complete the handshake must not look + /// like a lifecycle failure. + #[cfg(windows)] + fn wait_for_event(receiver: &mpsc::Receiver<(String, Value)>, connection_id: &str, kind: &str) { + let deadline = Instant::now() + EVENT_WAIT; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_else(|| { + panic!("no `{kind}` event for {connection_id} before the deadline") + }); + let (event_connection, event) = receiver + .recv_timeout(remaining) + .expect("an Agent event before the deadline"); + assert_eq!(event_connection, connection_id); + if event["kind"] == kind { + return; + } + assert_ne!( + event["kind"], "stopped", + "the fixture adapter stopped before `{kind}`: {}", + event["message"] + ); + } + } + + /// Waits for the wrapper and grandchild process ids the fixture recorded. + #[cfg(windows)] + fn wait_for_fixture_tree(pid_file: &Path) -> (u32, u32) { + let deadline = Instant::now() + EVENT_WAIT; + loop { + if let Some(tree) = read_fixture_tree(pid_file) { + return tree; + } + assert!( + Instant::now() < deadline, + "the fixture must record its process tree before the deadline: {}", + pid_file.display() + ); + // test-stability: allow(rust-real-sleep) reason: the pid file is written by a separate fixture process, so bounded polling of that file is the only readiness signal; the deadline above already bounds the loop. + std::thread::sleep(Duration::from_millis(25)); + } + } + + #[cfg(windows)] + fn read_fixture_tree(pid_file: &Path) -> Option<(u32, u32)> { + let contents = std::fs::read_to_string(pid_file).ok()?; + let mut adapter = None; + let mut grandchild = None; + for line in contents.lines() { + let mut fields = line.split_whitespace(); + match (fields.next(), fields.next()) { + (Some("adapter"), Some(pid)) => adapter = pid.parse().ok(), + (Some("grandchild"), Some(pid)) => grandchild = pid.parse().ok(), + _ => {} + } + } + Some((adapter?, grandchild?)) + } + + /// Whether the process is still running. A killed process is only observable + /// through the operating system, so liveness is polled against a deadline. + #[cfg(windows)] + fn process_alive(pid: u32) -> bool { + let mut system = sysinfo::System::new(); + system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + system.process(sysinfo::Pid::from_u32(pid)).is_some() + } + + /// Asserts the host reclaimed the whole tree, and kills whatever survived so + /// a failure cannot leak fixture processes into the rest of the suite. + #[cfg(windows)] + fn assert_fixture_tree_reclaimed(tree: (u32, u32)) { + let (adapter, grandchild) = tree; + let deadline = Instant::now() + EVENT_WAIT; + loop { + let adapter_alive = process_alive(adapter); + let grandchild_alive = process_alive(grandchild); + if !adapter_alive && !grandchild_alive { + return; + } + if Instant::now() >= deadline { + kill_fixture_survivors(tree); + panic!( + "the whole adapter tree must be reclaimed; adapter {adapter} alive: {adapter_alive}, grandchild {grandchild} alive: {grandchild_alive}" + ); + } + // test-stability: allow(rust-real-sleep) reason: an externally killed process has no callback, so bounded operating-system liveness polling is the only way to verify the cleanup; the deadline above bounds the loop. + std::thread::sleep(Duration::from_millis(25)); + } + } + + #[cfg(windows)] + fn kill_fixture_survivors(tree: (u32, u32)) { + let mut system = sysinfo::System::new(); + system.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + for pid in [tree.1, tree.0] { + if let Some(process) = system.process(sysinfo::Pid::from_u32(pid)) { + process.kill(); + } + } + } + fn wait_for_stopped(receiver: &mpsc::Receiver<(String, Value)>, connection_id: &str) { let deadline = Instant::now() + EVENT_WAIT; loop { @@ -737,4 +886,58 @@ mod tests { close("test-window-owner", "test-owned-connection"); } + + /// Closing a live connection reclaims the whole Agent process tree, not just + /// the wrapper the host started: the fixture's grandchild inherits stdout, + /// so a host that only waited for the direct child would leave it running + /// and holding the connection's output pipe. + #[cfg(windows)] + #[test] + fn closing_a_live_connection_reclaims_the_whole_agent_tree() { + let (sink, receiver) = recording(); + let pid_file = fixture_pid_file("close"); + open_connection( + "test-window-tree-close", + fixture_request("test-tree-close", &pid_file), + sink, + ) + .expect("open a live fixture adapter"); + + wait_for_event(&receiver, "test-tree-close", "ready"); + let tree = wait_for_fixture_tree(&pid_file); + + close("test-window-tree-close", "test-tree-close"); + + assert_fixture_tree_reclaimed(tree); + assert!(take_connection("test-tree-close") + .expect("registry") + .is_none()); + let _ = std::fs::remove_file(&pid_file); + } + + /// Destroying a project window releases the connection it opened and its + /// whole process tree, so a closed project cannot leave an Agent running. + #[cfg(windows)] + #[test] + fn destroying_a_window_reclaims_the_agent_tree_it_opened() { + let (sink, receiver) = recording(); + let pid_file = fixture_pid_file("window"); + open_connection( + "test-window-tree-owner", + fixture_request("test-tree-window", &pid_file), + sink, + ) + .expect("open a live fixture adapter"); + + wait_for_event(&receiver, "test-tree-window", "ready"); + let tree = wait_for_fixture_tree(&pid_file); + + close_window_connections("test-window-tree-owner"); + + assert!(take_connection("test-tree-window") + .expect("registry") + .is_none()); + assert_fixture_tree_reclaimed(tree); + let _ = std::fs::remove_file(&pid_file); + } } diff --git a/windows/tauri/src-tauri/src/bin/fake_acp_adapter.rs b/windows/tauri/src-tauri/src/bin/fake_acp_adapter.rs new file mode 100644 index 000000000..6883c7993 --- /dev/null +++ b/windows/tauri/src-tauri/src/bin/fake_acp_adapter.rs @@ -0,0 +1,141 @@ +//! Deterministic ACP agent fixture for the Windows connection-bridge tests. +//! +//! The bridge's lifecycle guarantees need a *live* Agent whose process tree has +//! more than one process, so a test can prove that closing a connection, or +//! destroying the window that owns it, reclaims the whole tree instead of only +//! the wrapper process. Real adapters behave that way: `codex-acp` starts a +//! separate app-server that inherits stdout and can outlive the wrapper. +//! +//! `fake_dap_adapter` is gated behind `test-support`, which no test suite +//! enables, so its tests never run in CI. This fixture must run in the ordinary +//! Windows Rust suite, therefore it is a regular binary. It is never bundled: +//! `tauri.conf.json` packages only the application binary. +//! +//! Usage: `fake-acp-adapter serve ` answers the ACP handshake from a +//! wrapper process, starts `fake-acp-adapter grandchild `, records +//! both process ids in `` as ` ` lines, and stays alive +//! until its stdin closes. The grandchild inherits stdout to keep the host's +//! output pipe open, exactly like a wrapper that launches a second process, and +//! exits on its own after a bounded guard so a leaked fixture cannot outlive a +//! failed test run. + +use serde_json::{json, Value}; +use std::fs::OpenOptions; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Upper bound for the grandchild, so a fixture leaked by a failing test ends by +/// itself. Tests never wait for it: they assert the host reclaimed the tree. +const GRANDCHILD_GUARD: Duration = Duration::from_secs(120); + +fn main() { + let mut arguments = std::env::args().skip(1); + let role = arguments.next().unwrap_or_else(|| "serve".to_string()); + let pid_file = arguments.next().map(PathBuf::from); + match role.as_str() { + "grandchild" => grandchild(pid_file.as_deref()), + _ => serve(pid_file.as_deref()), + } +} + +/// Records a process id, then waits to be killed by the host. +fn grandchild(pid_file: Option<&Path>) { + record(pid_file, "grandchild"); + let guard = Instant::now() + GRANDCHILD_GUARD; + while Instant::now() < guard { + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// Answers the handshake, starts the grandchild, and then serves ACP requests. +fn serve(pid_file: Option<&Path>) { + record(pid_file, "adapter"); + spawn_grandchild(pid_file); + + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + for line in BufReader::new(stdin.lock()).lines() { + let Ok(line) = line else { + break; + }; + let Ok(message) = serde_json::from_str::(&line) else { + continue; + }; + // Notifications carry no id and need no reply. + let Some(id) = message.get("id").cloned() else { + continue; + }; + let method = message + .get("method") + .and_then(Value::as_str) + .unwrap_or_default(); + let reply = json!({ + "jsonrpc": "2.0", + "id": id, + "result": result_for(method), + }); + if writeln!(stdout, "{reply}").is_err() || stdout.flush().is_err() { + break; + } + } +} + +/// The ACP result the bridge needs: an agent that accepts a gateway API key. +fn result_for(method: &str) -> Value { + match method { + "initialize" => json!({ + "protocolVersion": 1, + "agentCapabilities": {}, + "authMethods": [{ "id": "gateway", "name": "Custom API key" }], + "agentInfo": { "name": "fake-acp-adapter", "version": "0.0.0" }, + }), + "session/new" => json!({ "sessionId": "fake-session" }), + _ => json!({}), + } +} + +fn spawn_grandchild(pid_file: Option<&Path>) { + let Ok(executable) = std::env::current_exe() else { + return; + }; + let mut command = Command::new(executable); + command.arg("grandchild"); + if let Some(pid_file) = pid_file { + command.arg(pid_file); + } + // Inherit stdout like the real adapters: the host must kill the tree rather + // than rely on the output pipe closing when the wrapper exits. + command + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + let _ = command.spawn(); +} + +fn record(pid_file: Option<&Path>, role: &str) { + let Some(pid_file) = pid_file else { + return; + }; + let Ok(mut file) = OpenOptions::new().create(true).append(true).open(pid_file) else { + return; + }; + let _ = writeln!(file, "{role} {}", std::process::id()); + let _ = file.flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_request_method_gets_a_parseable_result() { + let initialize = result_for("initialize"); + assert_eq!(initialize["protocolVersion"], 1); + assert_eq!(initialize["authMethods"][0]["id"], "gateway"); + assert!(initialize["agentInfo"]["name"].is_string()); + assert!(result_for("authenticate").is_object()); + assert_eq!(result_for("session/new")["sessionId"], "fake-session"); + } +} From bc66c2cece6070332bf780d0422ac312e73d1e26 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Tue, 29 Sep 2026 10:04:12 +0800 Subject: [PATCH 04/16] =?UTF-8?q?fix(windows):=20=E8=AE=A9=20Agent=20?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E6=A0=91=E6=B5=8B=E8=AF=95=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=81=87=E9=80=82=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo test --no-run 只产出 libtest harness,不会在 target/debug 下生成平名的 fake-acp-adapter.exe,干净检出上两条进程树测试因此以 "The system cannot find the file specified" 失败。 fixture_path() 改为在平名产物缺失时按需执行 cargo build --bin fake-acp-adapter,并在调用方传入 --target-dir 时跟随其 profile 目录;构建失败时把 cargo stderr 带进 panic,避免只看到 os error 2。 --- windows/tauri/src-tauri/src/agent.rs | 57 +++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/windows/tauri/src-tauri/src/agent.rs b/windows/tauri/src-tauri/src/agent.rs index d7fcd532f..e8a6f4c4c 100644 --- a/windows/tauri/src-tauri/src/agent.rs +++ b/windows/tauri/src-tauri/src/agent.rs @@ -451,14 +451,59 @@ mod tests { /// Path of the ACP fixture the process-tree tests spawn. It is a regular /// binary, so the suite has it without enabling `test-support`. + /// + /// `cargo test` emits only a libtest artifact for a `[[bin]]` target, so a + /// target directory that was never built with `cargo build` has no + /// `fake-acp-adapter.exe` next to `deps/`; spawning it then failed with the + /// host's "The system cannot find the file specified". Build the fixture + /// once when it is missing, so a full suite, a filtered + /// `cargo test -p lithe-windows agent::`, and the timing harness all work + /// from a clean checkout. #[cfg(windows)] fn fixture_path() -> PathBuf { - let test_binary = std::env::current_exe().expect("test binary path"); - test_binary - .parent() - .and_then(Path::parent) - .expect("target directory") - .join("fake-acp-adapter.exe") + use std::process::Command; + use std::sync::OnceLock; + + static FIXTURE: OnceLock> = OnceLock::new(); + let built = FIXTURE.get_or_init(|| { + let test_binary = std::env::current_exe().expect("test binary path"); + let profile_directory = test_binary + .parent() + .and_then(Path::parent) + .expect("target directory"); + let fixture = profile_directory.join("fake-acp-adapter.exe"); + if fixture.exists() { + return Ok(fixture); + } + let mut build = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())); + build + .arg("build") + .arg("--manifest-path") + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .arg("--bin") + .arg("fake-acp-adapter"); + // Keep the build in the directory the tests were launched from, which + // is not the default one when the caller passes `--target-dir`. + if matches!( + profile_directory.file_name().and_then(|name| name.to_str()), + Some("debug") | Some("release") + ) { + if let Some(target_directory) = profile_directory.parent() { + build.arg("--target-dir").arg(target_directory); + } + } + let output = build + .output() + .map_err(|error| format!("the fixture adapter could not be built: {error}"))?; + if !output.status.success() { + return Err(format!( + "the fixture adapter could not be built: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(fixture) + }); + built.clone().unwrap_or_else(|message| panic!("{message}")) } /// Where the fixture records the process ids of its wrapper and grandchild. From 44016eb518631c49b4dfbb0fed9eae62d3d6d699 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Tue, 29 Sep 2026 10:13:23 +0800 Subject: [PATCH 05/16] =?UTF-8?q?feat(windows):=20=E8=BD=AC=E5=8F=91=20Age?= =?UTF-8?q?nt=20=E5=AE=89=E8=A3=85=E8=BF=9B=E5=BA=A6=E5=B9=B6=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E5=AE=89=E8=A3=85=E4=BF=A1=E5=B0=81=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - platform_invoke 增加第 6 个参数 agent_events;Tauri 会把缺失的尾部 Option 参数注入为 None,所以旧的前端调用点不需要改动。 - agent.install / agent.installCli 的 SharedEvent 投递到 agent 通道, 其余事件(含 git.*)仍走原来的 git 通道。 - 安装类命令不再套用 30 秒默认信封超时:npm 下载可能持续几分钟, 共享 host 自己以 15 分钟为界,macOS 也不设信封超时。 - 前端 installProgressEvent 先校验 kind 与数值字段再上报,避免面板 渲染出 undefined。 --- windows/tauri/src-tauri/src/platform.rs | 188 +++++++++++++++--- .../agent/services/agent-management.test.ts | 60 ++++++ .../agent/services/agent-management.ts | 186 +++++++++++++++++ 3 files changed, 410 insertions(+), 24 deletions(-) create mode 100644 windows/tauri/src/features/agent/services/agent-management.test.ts create mode 100644 windows/tauri/src/features/agent/services/agent-management.ts diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index eec98c44e..836239cff 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -1,6 +1,8 @@ use serde_json::{json, Map, Value}; use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use tauri::ipc::Channel; use tauri::Manager; static REQUEST_ID: AtomicU64 = AtomicU64::new(1); @@ -11,12 +13,16 @@ pub async fn platform_invoke( command: String, args: Value, git_events: Option, + agent_events: Option, git_execution: Option, ) -> Result { if command.starts_with("ai_commit_") { return crate::ai_commit::dispatch(webview.app_handle().clone(), &command, args).await; } - let git_events = git_events.map(|id| id.channel_on::<_, Value>(webview)); + let git_events = git_events.map(|id| id.channel_on::<_, Value>(webview.clone())); + // An adapter install reports its npm transfer on its own channel, so the + // panel can show progress for as long as the download runs. + let agent_events = agent_events.map(|id| id.channel_on::<_, Value>(webview)); let preserve_history_rewrite = is_reviewed_history_rewrite(&command, &args); let preserve_stash_restore = command == "git_pull" && args @@ -42,35 +48,29 @@ pub async fn platform_invoke( if git_execution.is_object() { git_execution["interactive"] = json!(interactive_git && git_events.is_some()); } - let request = json!({ + let mut request = json!({ "id": operation_id, "operationId": operation_id, - "timeoutMilliseconds": if interactive_git { 900_000 } else { 30_000 }, "gitExecution": git_execution, "command": core_command, "payload": payload - }) - .to_string(); + }); + if let Some(timeout) = envelope_timeout_milliseconds(&core_command, interactive_git) { + request["timeoutMilliseconds"] = json!(timeout); + } + let request = request.to_string(); + let events = shared_event_sink( + if observe_git { git_events } else { None }, + if observes_agent_install(&core_command) { + agent_events + } else { + None + }, + ); let response = tauri::async_runtime::spawn_blocking(move || { - if observe_git { - if let Some(channel) = git_events { - let receiver_closed = std::sync::atomic::AtomicBool::new(false); - return lithe_core::execute_json_with_events( - &request, - std::sync::Arc::new(move |event| match serde_json::from_str::(event) { - Ok(event) => { - if !receiver_closed.load(Ordering::Relaxed) { - if let Err(error) = channel.send(event) { - receiver_closed.store(true, Ordering::Relaxed); - eprintln!("Git console receiver closed: {error}"); - } - } - } - Err(error) => eprintln!("Invalid Git execution event: {error}"), - }), - ); - } + if let Some(events) = events { + return lithe_core::execute_json_with_events(&request, events); } lithe_core::execute_json(&request) }) @@ -111,6 +111,68 @@ fn observes_git_execution(command: &str) -> bool { && command != "git.consolePresentation" } +/// Kind the shared Core uses for npm transfer progress. `agent.install` and +/// `agent.installCli` are the only commands that publish it. +const AGENT_INSTALL_PROGRESS_KIND: &str = "agentInstallProgress"; + +fn observes_agent_install(command: &str) -> bool { + matches!(command, "agent.install" | "agent.installCli") +} + +/// Envelope deadline for one shared request. +/// +/// Git operations are expected to answer quickly. An adapter install runs the +/// user's npm for as long as the download takes and no interface can cancel it, +/// so it carries no deadline here, exactly as macOS runs `agent.*`; the shared +/// host bounds the install itself. +fn envelope_timeout_milliseconds(core_command: &str, interactive_git: bool) -> Option { + if observes_agent_install(core_command) { + return None; + } + Some(if interactive_git { 900_000 } else { 30_000 }) +} + +/// Destinations for the shared events one request can publish. +/// +/// Git execution events and Agent install progress come from different commands +/// and never share a request, so the event kind selects the channel. A request +/// without a channel keeps the plain path and observes nothing. +fn shared_event_sink( + git_events: Option>, + agent_events: Option>, +) -> Option> { + if git_events.is_none() && agent_events.is_none() { + return None; + } + // A closed receiver must not make every later event report a failure. + let git_closed = AtomicBool::new(false); + let agent_closed = AtomicBool::new(false); + Some(Arc::new(move |event: &str| { + let parsed: Value = match serde_json::from_str(event) { + Ok(parsed) => parsed, + Err(error) => { + eprintln!("Invalid shared execution event: {error}"); + return; + } + }; + let is_agent_progress = + parsed.get("kind").and_then(Value::as_str) == Some(AGENT_INSTALL_PROGRESS_KIND); + let (closed, channel) = if is_agent_progress { + (&agent_closed, &agent_events) + } else { + (&git_closed, &git_events) + }; + if closed.load(Ordering::Relaxed) { + return; + } + let Some(channel) = channel else { return }; + if let Err(error) = channel.send(parsed) { + closed.store(true, Ordering::Relaxed); + eprintln!("Shared execution event receiver closed: {error}"); + } + })) +} + fn core_response( envelope: &Value, preserve_history_rewrite: bool, @@ -767,6 +829,84 @@ mod tests { assert!(!super::observes_git_execution("workspace.scan")); } + #[test] + fn only_adapter_installs_publish_install_progress() { + assert!(super::observes_agent_install("agent.install")); + assert!(super::observes_agent_install("agent.installCli")); + assert!(!super::observes_agent_install("agent.uninstall")); + assert!(!super::observes_agent_install("agent.status")); + assert!(!super::observes_agent_install("git.status")); + } + + #[test] + fn an_adapter_install_is_not_cut_off_by_the_default_envelope_deadline() { + // npm transfers take minutes and the shared host bounds the install + // itself, so a 30-second envelope deadline would cancel the download + // half way through. + assert_eq!( + super::envelope_timeout_milliseconds("agent.install", false), + None + ); + assert_eq!( + super::envelope_timeout_milliseconds("agent.installCli", false), + None + ); + assert_eq!( + super::envelope_timeout_milliseconds("agent.status", false), + Some(30_000) + ); + assert_eq!( + super::envelope_timeout_milliseconds("git.status", false), + Some(30_000) + ); + assert_eq!( + super::envelope_timeout_milliseconds("git.command", true), + Some(900_000) + ); + } + + #[test] + fn shared_events_reach_the_channel_that_asked_for_them() { + use serde_json::Value; + use std::sync::{Arc, Mutex}; + use tauri::ipc::{Channel, InvokeResponseBody}; + + fn recorder() -> (Channel, Arc>>) { + let captured = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&captured); + let channel = Channel::::new(move |body| { + let InvokeResponseBody::Json(text) = body else { + panic!("shared events are serialized as JSON"); + }; + let event = serde_json::from_str(&text).expect("a serialized shared event"); + sink.lock().expect("recorder").push(event); + Ok(()) + }); + (channel, captured) + } + + let (git_channel, git_events) = recorder(); + let (agent_channel, agent_events) = recorder(); + let sink = super::shared_event_sink(Some(git_channel), Some(agent_channel)) + .expect("two channels produce a sink"); + sink(r#"{"kind":"gitExecution","phase":"started"}"#); + sink(r#"{"kind":"agentInstallProgress","progress":{"stage":"downloading"}}"#); + assert_eq!(git_events.lock().unwrap()[0]["kind"], "gitExecution"); + assert_eq!( + agent_events.lock().unwrap()[0]["progress"]["stage"], + "downloading" + ); + assert_eq!(git_events.lock().unwrap().len(), 1); + assert_eq!(agent_events.lock().unwrap().len(), 1); + + // A request only ever carries the channel its command publishes to. + let (agent_channel, agent_events) = recorder(); + let sink = super::shared_event_sink(None, Some(agent_channel)).expect("one channel"); + sink(r#"{"kind":"gitExecution","phase":"started"}"#); + assert!(agent_events.lock().unwrap().is_empty()); + assert!(super::shared_event_sink(None, None).is_none()); + } + #[test] fn workspace_commit_steps_use_interactive_authentication_and_write_timeout() { assert!(super::is_interactive_git( diff --git a/windows/tauri/src/features/agent/services/agent-management.test.ts b/windows/tauri/src/features/agent/services/agent-management.test.ts new file mode 100644 index 000000000..902935843 --- /dev/null +++ b/windows/tauri/src/features/agent/services/agent-management.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import fixture from "../../../../../../shared/fixtures/agent/agent-management-v1.json"; +import { installProgressEvent, parseAgentManagementStatus } from "./agent-management"; + +const status = fixture.responses.status; + +describe("Agent management status", () => { + test("reads the detected runtime and the whole catalog from the shared fixture", () => { + const parsed = parseAgentManagementStatus(status); + if (parsed === null) throw new Error("the fixture must parse"); + + expect(parsed.environment.node).toEqual(status.environment.node); + expect(parsed.environment.npm).toEqual(status.environment.npm); + expect(parsed.agents.map((agent) => agent.id)).toEqual(["codex-acp", "claude-acp"]); + expect(parsed.agents[0]?.installedVersion).toBe("1.13.1"); + expect(parsed.agents[0]?.cli?.detected?.version).toBe("0.156.1"); + }); + + test("an agent without an install keeps its issues and installation owner", () => { + const parsed = parseAgentManagementStatus(status); + const claude = parsed?.agents[1]; + if (claude === undefined) throw new Error("the fixture must carry a second agent"); + + expect(claude.installedVersion).toBeNull(); + expect(claude.cli?.detected).toBeNull(); + expect(claude.cli?.installation.source).toBe("missing"); + expect(claude.issues).toHaveLength(2); + }); + + test("a response outside the documented shape is rejected rather than half read", () => { + expect(parseAgentManagementStatus(null)).toBeNull(); + expect(parseAgentManagementStatus("agents")).toBeNull(); + expect(parseAgentManagementStatus({ agents: "codex-acp" })).toEqual({ + environment: { node: null, npm: null }, + agents: [], + }); + // An entry without an id cannot be installed or selected, so it is dropped. + expect(parseAgentManagementStatus({ agents: [{ name: "Codex" }] })?.agents).toEqual([]); + }); +}); + +describe("Agent install progress", () => { + test("reads the npm transfer the host reported", () => { + expect(installProgressEvent(fixture.events.downloading)).toEqual({ + stage: "downloading", + downloadedBytes: 25000000, + bytesPerSecond: 75000, + elapsedMilliseconds: 300000, + idleMilliseconds: 0, + }); + expect(installProgressEvent(fixture.events.waiting)?.idleMilliseconds).toBe(20000); + }); + + test("only an install progress event is reported", () => { + expect(installProgressEvent({ kind: "agentInstallProgress" })).toBeNull(); + expect(installProgressEvent({ kind: "gitExecution", progress: {} })).toBeNull(); + expect(installProgressEvent(fixture.events.downloading.progress)).toBeNull(); + expect(installProgressEvent(null)).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/agent/services/agent-management.ts b/windows/tauri/src/features/agent/services/agent-management.ts new file mode 100644 index 000000000..96fe0d575 --- /dev/null +++ b/windows/tauri/src/features/agent/services/agent-management.ts @@ -0,0 +1,186 @@ +/** + * Adapter status and installs, through the shared `agent.*` core commands. + * + * Node.js and npm belong to the user; Lithe only detects them and installs the + * ACP adapter with the user's npm. The response shape is fixed by + * `shared/fixtures/agent/agent-management-v1.json`, so it is parsed here + * instead of being trusted field by field in the panel. + */ + +import { Channel, invoke } from "@/platform/tauri-core"; + +export interface DetectedRuntime { + version: string; + path: string; +} + +export interface AgentCliInstallation { + source: string; + canUpdate: boolean; + updateHint: string; +} + +export interface AgentCliStatus { + name: string; + command: string; + minimumVersion: string; + installHint: string; + detected: DetectedRuntime | null; + installation: AgentCliInstallation; +} + +export interface CatalogAgentStatus { + id: string; + name: string; + description: string; + package: string; + version: string; + installedVersion: string | null; + protocol: string; + minimumNodeMajor: number; + verified: boolean; + cli: AgentCliStatus | null; + issues: string[]; +} + +export interface AgentEnvironmentStatus { + node: DetectedRuntime | null; + npm: DetectedRuntime | null; +} + +export interface AgentManagementStatus { + environment: AgentEnvironmentStatus; + agents: CatalogAgentStatus[]; +} + +export interface AgentInstallProgress { + stage: string; + downloadedBytes: number; + bytesPerSecond: number; + elapsedMilliseconds: number; + idleMilliseconds: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function runtime(value: unknown): DetectedRuntime | null { + if (!isRecord(value)) return null; + const version = asString(value.version); + const path = asString(value.path); + return version !== null && path !== null ? { version, path } : null; +} + +/** + * One `agentInstallProgress` event, or `null` for anything else the channel + * carries. The counters are read after validation, so the panel never renders + * `undefined` megabytes. + */ +export function installProgressEvent(value: unknown): AgentInstallProgress | null { + if (!isRecord(value) || value.kind !== "agentInstallProgress") return null; + const progress = value.progress; + if (!isRecord(progress)) return null; + const counter = (field: string) => + typeof progress[field] === "number" ? (progress[field] as number) : 0; + return { + stage: asString(progress.stage) ?? "", + downloadedBytes: counter("downloadedBytes"), + bytesPerSecond: counter("bytesPerSecond"), + elapsedMilliseconds: counter("elapsedMilliseconds"), + idleMilliseconds: counter("idleMilliseconds"), + }; +} + +function cliStatus(value: unknown): AgentCliStatus | null { + if (!isRecord(value)) return null; + const installation = isRecord(value.installation) ? value.installation : {}; + return { + name: asString(value.name) ?? "", + command: asString(value.command) ?? "", + minimumVersion: asString(value.minimumVersion) ?? "", + installHint: asString(value.installHint) ?? "", + detected: runtime(value.detected), + installation: { + source: asString(installation.source) ?? "unknown", + canUpdate: installation.canUpdate === true, + updateHint: asString(installation.updateHint) ?? "", + }, + }; +} + +/** Read one agent entry, or `null` when the shape is not the documented one. */ +function agentStatus(value: unknown): CatalogAgentStatus | null { + if (!isRecord(value)) return null; + const id = asString(value.id); + if (id === null) return null; + const issues = Array.isArray(value.issues) + ? value.issues.filter((issue): issue is string => typeof issue === "string") + : []; + return { + id, + name: asString(value.name) ?? id, + description: asString(value.description) ?? "", + package: asString(value.package) ?? "", + version: asString(value.version) ?? "", + installedVersion: asString(value.installedVersion), + protocol: asString(value.protocol) ?? "", + minimumNodeMajor: typeof value.minimumNodeMajor === "number" ? value.minimumNodeMajor : 0, + verified: value.verified === true, + cli: cliStatus(value.cli), + issues, + }; +} + +export function parseAgentManagementStatus(value: unknown): AgentManagementStatus | null { + if (!isRecord(value)) return null; + const environment = isRecord(value.environment) ? value.environment : {}; + const agents = Array.isArray(value.agents) + ? value.agents.flatMap((entry) => { + const parsed = agentStatus(entry); + return parsed === null ? [] : [parsed]; + }) + : []; + return { + environment: { node: runtime(environment.node), npm: runtime(environment.npm) }, + agents, + }; +} + +export async function loadAgentManagementStatus( + dataDirectory: string, +): Promise { + const response = await invoke("agent.status", { dataDirectory }); + const status = parseAgentManagementStatus(response); + if (status === null) throw new Error("The Agent status response was not recognised."); + return status; +} + +/** Install one adapter, reporting npm transfer progress through a channel. */ +export async function installAgent( + dataDirectory: string, + agentId: string, + onProgress?: (progress: AgentInstallProgress) => void, +): Promise { + const channel = onProgress === undefined ? undefined : new Channel(); + if (channel !== undefined && onProgress !== undefined) { + const report = onProgress; + channel.onmessage = (event) => { + const progress = installProgressEvent(event); + if (progress !== null) report(progress); + }; + } + await invoke( + "agent.install", + { dataDirectory, agentId }, + channel === undefined ? undefined : { agentEvents: channel }, + ); +} + +export async function uninstallAgent(dataDirectory: string, agentId: string): Promise { + await invoke("agent.uninstall", { dataDirectory, agentId }); +} From b4da1b22c08c6e876c30b3dd127d80d2f040849a Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Tue, 29 Sep 2026 10:13:31 +0800 Subject: [PATCH 06/16] =?UTF-8?q?feat(windows):=20=E6=96=B0=E5=A2=9E=20Age?= =?UTF-8?q?nt=20=E4=BE=A7=E6=A0=8F=E9=9D=A2=E6=9D=BF=E3=80=81=E4=BE=9B?= =?UTF-8?q?=E5=BA=94=E5=95=86=E8=AE=BE=E7=BD=AE=E4=B8=8E=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面板(对齐 macOS 的右侧 Agent 侧栏) - features/agent 新增面板本体:会话标签、消息流、工具与权限提示、输入框、 上下文用量与订阅额度、历史会话列表与恢复、适配器状态/安装/卸载。 - 连接是「一个窗口一个项目」的模块单例:隐藏面板保留运行中的回合, 切换项目停掉旧进程树,窗口销毁交给 Tauri 宿主回收。 - 传输层直接复用 `@tauri-apps/api/event` 的 listen,并把 `listen` 从 tauri-core 里移除,恢复 tauri-core.test.ts 只 mock Channel/invoke 的前提。 - 可写目录统一走 platform/app-data-directory.ts(appDataDir),不从安装 目录推导,避免破坏发行包基线与增量更新。 设置与入口 - 设置页新增 Agent 供应商配置:API Key 存安全存储 (agent-provider-api-key/),参数改为每行一个的 Textarea, 与 macOS 的 agentArguments 按换行拆分保持一致。 - 侧栏活动栏、快捷键命令与视图动作新增 Agent 面板入口;settings.agentPanel 记录面板可见性并参与归一化。 - 能力开关:backendCapabilities.agent 打开,新增 aiChat=false;tauri-core 把 agent.status/install/uninstall/installCli 映射到 agent,旧的 acp_*/codex_*/ ai_provider/_chat 仍映射到 aiChat,继续显示「待开发」而不是「未实现」。 --- .../src/config/backend-capabilities.test.ts | 5 +- .../tauri/src/config/backend-capabilities.ts | 6 +- .../actions/agent-tool-window-actions.ts | 15 + .../agent/components/agent-composer.tsx | 270 ++++++ .../components/agent-conversation-tabs.tsx | 67 ++ .../agent/components/agent-history-list.tsx | 74 ++ .../features/agent/components/agent-panel.tsx | 334 +++++++ .../components/agent-settings-section.tsx | 318 ++++++ .../agent/components/agent-transcript.tsx | 211 ++++ .../agent/components/agent-trigger.tsx | 39 + .../agent/components/agent-usage-panel.tsx | 93 ++ .../agent/hooks/use-agent-connection.ts | 14 + .../agent/hooks/use-agent-management.ts | 91 ++ .../features/agent/services/agent-launch.ts | 160 +++ .../agent/services/agent-transport.ts | 119 +++ .../agent/stores/agent-connection-service.ts | 97 ++ .../stores/agent-connection.model.test.ts | 341 +++++++ .../agent/stores/agent-connection.model.ts | 918 ++++++++++++++++++ .../features/agent/types/agent-file-uri.ts | 16 + .../agent/types/agent-settings.types.ts | 125 +++ .../features/agent/types/agent.types.test.ts | 183 ++++ .../src/features/agent/types/agent.types.ts | 549 +++++++++++ .../keymaps/commands/command-registry.ts | 7 + .../keymaps/commands/view-command-actions.ts | 5 + .../layout/components/main-layout.tsx | 15 +- .../components/plugin-activity-rail.tsx | 2 + .../layout/utils/sidebar-pane-utils.ts | 1 + .../settings/config/default-settings.ts | 10 + .../settings/lib/settings-normalization.ts | 6 + .../features/settings/types/settings.types.ts | 3 + windows/tauri/src/i18n/locale.ts | 164 ++++ .../tauri/src/platform/app-data-directory.ts | 12 + windows/tauri/src/platform/tauri-core.ts | 33 +- 33 files changed, 4296 insertions(+), 7 deletions(-) create mode 100644 windows/tauri/src/features/agent/actions/agent-tool-window-actions.ts create mode 100644 windows/tauri/src/features/agent/components/agent-composer.tsx create mode 100644 windows/tauri/src/features/agent/components/agent-conversation-tabs.tsx create mode 100644 windows/tauri/src/features/agent/components/agent-history-list.tsx create mode 100644 windows/tauri/src/features/agent/components/agent-panel.tsx create mode 100644 windows/tauri/src/features/agent/components/agent-settings-section.tsx create mode 100644 windows/tauri/src/features/agent/components/agent-transcript.tsx create mode 100644 windows/tauri/src/features/agent/components/agent-trigger.tsx create mode 100644 windows/tauri/src/features/agent/components/agent-usage-panel.tsx create mode 100644 windows/tauri/src/features/agent/hooks/use-agent-connection.ts create mode 100644 windows/tauri/src/features/agent/hooks/use-agent-management.ts create mode 100644 windows/tauri/src/features/agent/services/agent-launch.ts create mode 100644 windows/tauri/src/features/agent/services/agent-transport.ts create mode 100644 windows/tauri/src/features/agent/stores/agent-connection-service.ts create mode 100644 windows/tauri/src/features/agent/stores/agent-connection.model.test.ts create mode 100644 windows/tauri/src/features/agent/stores/agent-connection.model.ts create mode 100644 windows/tauri/src/features/agent/types/agent-file-uri.ts create mode 100644 windows/tauri/src/features/agent/types/agent-settings.types.ts create mode 100644 windows/tauri/src/features/agent/types/agent.types.test.ts create mode 100644 windows/tauri/src/features/agent/types/agent.types.ts create mode 100644 windows/tauri/src/platform/app-data-directory.ts diff --git a/windows/tauri/src/config/backend-capabilities.test.ts b/windows/tauri/src/config/backend-capabilities.test.ts index d3692fe02..41c86a6f8 100644 --- a/windows/tauri/src/config/backend-capabilities.test.ts +++ b/windows/tauri/src/config/backend-capabilities.test.ts @@ -6,10 +6,13 @@ describe("default Windows workbench capability policy", () => { test("does not enable unavailable feature families by default", () => { expect(BACKEND_UNAVAILABLE_TOOLTIP).toBe("待开发"); - for (const capability of ["github", "remote", "docker", "agent"] as const) { + for (const capability of ["github", "remote", "docker", "aiChat"] as const) { expect(backendCapabilities[capability]).toBe(false); } + // The Agent panel is backed by the shared Agent host; only the legacy + // `features/ai` command family stays unavailable. + expect(backendCapabilities.agent).toBe(true); expect(defaultSettings.coreFeatures.github).toBe(false); expect(defaultSettings.coreFeatures.remote).toBe(false); expect(defaultSettings.coreFeatures.docker).toBe(false); diff --git a/windows/tauri/src/config/backend-capabilities.ts b/windows/tauri/src/config/backend-capabilities.ts index 3fc360521..6607257a0 100644 --- a/windows/tauri/src/config/backend-capabilities.ts +++ b/windows/tauri/src/config/backend-capabilities.ts @@ -1,7 +1,11 @@ export const BACKEND_UNAVAILABLE_TOOLTIP = "待开发"; export const backendCapabilities = { - agent: false, + // The Agent host is the shared `lithe-agent-host`, so the Windows panel's + // management commands are backed. `aiChat` is the legacy `features/ai` family + // that never had a Windows backend and is removed by #957. + agent: true, + aiChat: false, database: false, debugger: true, docker: false, diff --git a/windows/tauri/src/features/agent/actions/agent-tool-window-actions.ts b/windows/tauri/src/features/agent/actions/agent-tool-window-actions.ts new file mode 100644 index 000000000..28af2bc38 --- /dev/null +++ b/windows/tauri/src/features/agent/actions/agent-tool-window-actions.ts @@ -0,0 +1,15 @@ +import { applyRightToolWindowIntent } from "@/features/layout/actions/right-tool-window-actions"; + +const AGENT_TOOL_WINDOW_VIEW = "agent"; + +export function openAgentToolWindow(): void { + applyRightToolWindowIntent(AGENT_TOOL_WINDOW_VIEW, "open"); +} + +export function closeAgentToolWindow(): void { + applyRightToolWindowIntent(AGENT_TOOL_WINDOW_VIEW, "close"); +} + +export function toggleAgentToolWindow(): void { + applyRightToolWindowIntent(AGENT_TOOL_WINDOW_VIEW, "toggle"); +} diff --git a/windows/tauri/src/features/agent/components/agent-composer.tsx b/windows/tauri/src/features/agent/components/agent-composer.tsx new file mode 100644 index 000000000..04a8ef7df --- /dev/null +++ b/windows/tauri/src/features/agent/components/agent-composer.tsx @@ -0,0 +1,270 @@ +import { useCallback, useMemo, useState } from "react"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import type { FileEntry } from "@/features/file-system/types/app.types"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import { FilePlusIcon, PaperPlaneTiltIcon, StopIcon, XIcon } from "@/ui/icons"; +import Select from "@/ui/select"; +import { Spinner } from "@/ui/spinner"; +import Textarea from "@/ui/textarea"; +import { useAgentSnapshot } from "../hooks/use-agent-connection"; +import { agentConnection } from "../stores/agent-connection-service"; +import { agentFileUri } from "../types/agent-file-uri"; +import { + addFileReferences, + AgentFileReferenceError, + configOptionLabel, + type AgentConversation, + type AgentFileReference, +} from "../types/agent.types"; +import { AgentUsagePanel } from "./agent-usage-panel"; + +const EMPTY_PROJECT_FILES: FileEntry[] = []; +const FILE_RESULT_LIMIT = 50; + +function candidateFiles(entries: FileEntry[], limit: number): FileEntry[] { + const result: FileEntry[] = []; + const walk = (nodes: FileEntry[]) => { + for (const node of nodes) { + if (result.length >= limit) return; + if (node.isDir) { + if (node.children !== undefined) walk(node.children); + continue; + } + result.push(node); + } + }; + walk(entries); + return result; +} + +interface AgentComposerProps { + conversation: AgentConversation | null; + /** False while the conversation cannot accept a new turn. */ + canSend: boolean; +} + +/** + * Prompt entry for the selected conversation: the session options the agent + * reported, the draft, its attached project files, and send or stop. + */ +export function AgentComposer({ conversation, canSend }: AgentComposerProps) { + const { t } = useTranslation(); + const snapshot = useAgentSnapshot(); + const projectFiles = useFileSystemStore( + (state) => state.projectFilesCache?.files ?? EMPTY_PROJECT_FILES, + ); + const activeFilePath = useBufferStore((state) => { + const active = state.buffers.find((buffer) => buffer.isActive); + return typeof active?.path === "string" ? active.path : null; + }); + const [draft, setDraft] = useState(""); + const [files, setFiles] = useState([]); + const [composerError, setComposerError] = useState(null); + const [isPickingFile, setIsPickingFile] = useState(false); + const [fileQuery, setFileQuery] = useState(""); + + const isResponding = conversation?.isResponding === true; + const isCancelling = conversation?.isCancelling === true; + const isCreatingSession = snapshot.isCreatingSession; + + const attach = useCallback((paths: string[]) => { + if (paths.length === 0) return; + try { + setFiles((current) => addFileReferences(current, paths.map(agentFileUri))); + setComposerError(null); + } catch (error) { + setComposerError(error instanceof AgentFileReferenceError ? error.message : String(error)); + } + }, []); + + const send = useCallback(() => { + if (isResponding) return; + const text = draft.trim(); + if (text.length === 0 && files.length === 0) return; + try { + agentConnection().send(text, files.map((file) => file.uri)); + setDraft(""); + setFiles([]); + setComposerError(null); + } catch (error) { + setComposerError(error instanceof Error ? error.message : String(error)); + } + }, [draft, files, isResponding]); + + const matches = useMemo(() => { + const query = fileQuery.trim().toLowerCase(); + const candidates = candidateFiles(projectFiles, FILE_RESULT_LIMIT * 4); + const filtered = + query.length === 0 + ? candidates + : candidates.filter((file) => file.path.toLowerCase().includes(query)); + return filtered.slice(0, FILE_RESULT_LIMIT); + }, [fileQuery, projectFiles]); + + const options = conversation?.configOptions ?? []; + + return ( +
+ {options.length === 0 ? null : ( +
+ {options.map((option) => ( + setFileQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") setIsPickingFile(false); + }} + /> +
+ {matches.length === 0 ? ( +
+ {t("agent.composer.noFiles")} +
+ ) : ( + matches.map((file) => ( + + )) + )} +
+
+ ) : null} + +