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 19dd4d4dc..48beb177e 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` 的连接桥调用,两端只各自实现界面。 ## 问题 @@ -42,10 +42,16 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **支持的 Agent 目录**:写在 `lithe-agent-host` 的 `catalog.rs`,Agent ID、npm 包名和固定版本都与 ACP 官方注册表一致。首批是 Codex(已用真实服务商验证)和 Claude(标为"未验证")。只有重新验证过的版本才会提升。 - **环境检测**:通过登录 shell 读取 `PATH`,所以 nvm、fnm 装的 Node 也能找到。检测 Node 和 npm 的版本,每个 Agent 各有最低 Node 版本(Codex 20、Claude 22),版本不够时只提示。 - **一键安装**:用用户的 npm 把固定版本的适配器装到 `Application Support/Lithe/agents/`。先装到临时目录,确认可执行文件存在后再替换旧目录,所以失败或取消不会破坏已经能用的旧版本。 - - **复用用户本机的 Agent CLI**:两个适配器都自带一份 Agent 的原生程序,都是可选依赖,单个平台约 200–370 MB,而用户本机本来就有。所以安装时加上 `--omit=optional`(Codex 装完约 18 MB)。启动时在登录 shell 的 PATH 里找到用户的 CLI 交给适配器:Codex 用 `CODEX_PATH`(最低 0.156.0),Claude Code 用 `CLAUDE_CODE_EXECUTABLE`(最低 2.1.280,对应 SDK 的 `claudeCodeVersion`)。CLI 找不到时可由 npm 安装;版本过旧时先确认当前 PATH 命令的安装来源,再通过原安装器更新。来源展示在预检项中,npm/Homebrew/原生安装可以按验证结果提供升级按钮,未知来源只给手动指引。所有安装与更新只在用户明确点击时运行,Node.js 和 npm 仍由用户自己安装。CLI 过旧不阻止安装适配器,只有 Node.js 或 npm 不可用才阻止。 + - **复用用户本机的 Agent CLI**:两个适配器都自带一份 Agent 的原生程序,都是可选依赖,单个平台约 200–370 MB,而用户本机本来就有。所以安装时加上 `--omit=optional`(Codex 装完约 18 MB)。启动时在登录 shell 的 PATH 里找到用户的 CLI 交给适配器:Codex 用 `CODEX_PATH`(最低 0.156.0),Claude Code 用 `CLAUDE_CODE_EXECUTABLE`(最低 2.1.280,对应 SDK 的 `claudeCodeVersion`)。CLI 找不到时可由 npm 安装;版本过旧时先确认当前 PATH 命令的安装来源,再通过原安装器更新。来源展示在预检项中,npm/Homebrew/原生安装可以按验证结果提供升级按钮,未知来源只给手动指引。Windows 上 npm 全局安装写的是前缀目录里的 `<命令>.cmd` 批处理启动器,不是符号链接,所以要先读同目录 `node_modules/<包>/package.json` 声明的 bin 再做身份校验,否则所有 npm 安装都会被当成未知来源、不给升级按钮。适配器不经 shell 启动 CLI,而 Node.js 拒绝这样启动 `.cmd`(报 `spawn EINVAL`,新建会话就失败),所以如果包声明的 bin 是原生 `.exe`(如 Claude Code),传给适配器的是这个 `.exe`;脚本型 bin(如 Codex 的 `codex.js`)仍传启动器,codex-acp 在 Windows 上自己用 shell 启动它。逻辑在 `cli_update::launch_executable`,API Key 与订阅两条启动路径共用。所有安装与更新只在用户明确点击时运行,Node.js 和 npm 仍由用户自己安装。CLI 过旧不阻止安装适配器,只有 Node.js 或 npm 不可用才阻止。 - **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 直接拒绝,不会顶掉在用的连接。事件只发给打开它的那个窗口,不广播给其他项目;`agent_send` 和 `agent_close` 同样注入 Webview,并在同一个注册表临界区内比对连接归属窗口,所以其他窗口即使拿到 ID 也不能驱动或关闭别人的连接。连接按窗口标签登记:项目窗口销毁时把连接移出注册表并交给阻塞池关闭,移出与“正在关闭”计数在同一个临界区完成,因此随后的应用退出既看不到这条连接、也不会漏等它;退出先在同一次加锁里停止接受新连接并取走剩余连接,同步关闭它们,再以 15 秒上限等待仍在阻塞池里的关闭,而 `AgentHandle::close` 自带停止窗口并强杀进程树,这个上限只兜底“任务还没被调度”的情况。Windows 桌面外壳是 GUI 进程、没有控制台,所以共享 host 在 Windows 上以 `CREATE_NO_WINDOW` 启动适配器,避免每开一次 Agent 都弹出一个控制台窗口。 + - **Windows 面板放在编辑器右栏**:Windows 界面在 `windows/tauri/src/features/agent/` 另写 React 面板,落点是编辑器右侧的右栏工具窗,与 macOS 的 `AgentConversationEntryPolicy.rightSidebar` 对齐;不复用也不扩写 `windows/tauri/src/features/ai/` 那套编辑器标签页实现(#440 已判定它不作为参考,#957 负责清理)。入口加在活动栏(`plugin-activity-rail.tsx`),命令是 `workbench.showAgent`。关闭面板只是隐藏:只有功能开关(`settings.agentPanel.enabled`)关掉或项目切换时才停止 Agent,所以隐藏面板不会打断正在运行的一轮。连接和设置按窗口一份,`stores/agent-connection-service.ts` 是模块级单例,面板与供应商设置存在 `settings.agentPanel` 并由 `normalizeAgentPanelSettings` 归一化,服务商 Key 进系统安全存储(键名 `agent-provider-api-key/`),不写设置文件。 + - **Windows 启动配置对齐 macOS**:`services/agent-launch.ts` 复刻 `AppModel.agentLaunchConfiguration(agentID:)` 的规则——订阅模式不携带供应商,且只有 `codex-acp` 能用订阅;API Key 模式需要 endpoint 和 Key,缺哪一项就报对应原因;自定义 Agent 必须有可执行文件。失败原因用 `AgentLaunchFailure` 枚举返回,由界面按当前语言翻译,Rust 或 Tauri 的原始错误不会直接给用户看。自定义参数按 macOS 的“一行一个”解析(`agentArguments`),不要按空格切分:带空格的参数会被拆成两个。 + - **Windows 的能力门禁**:`config/backend-capabilities.ts` 的 `agent` 改成 `true`,因为 Windows 已经有 Agent 后端。同样被判成 agent 的老命令(`acp_*`、`codex_*`、`ai_provider`、`_chat`、`get_available_agents`)改判给新增的 `aiChat` 能力并保持 `false`:它们在 Windows 上没有后端,直接翻 `agent` 只会把“待开发”提示变成运行期报错。#957 删掉 `features/ai` 后 `aiChat` 可以一起删。 + - **Windows 的安装进度与超时**:`windows/tauri/src-tauri/src/platform.rs` 的 `platform_invoke` 同时接受 `gitEvents` 和 `agentEvents` 两个通道,并按事件的 `kind` 分派:Git 执行事件走前者,`agentInstallProgress` 走后者。适配器安装用用户本机 npm,可能下载几分钟,所以这类请求不写 `timeoutMilliseconds`:信封默认的 30 秒会在下载中途取消它,而共享 host 自己已有 15 分钟的安装上限,macOS 同样不给 `agent.*` 设信封超时。设置页的 CLI 安装/升级与配置文本导入调用的是同一组共享命令(`agent.installCli`、`agent.parseProviderConfiguration`),界面只做表单、校验与结果展示:升级按钮只在 host 明确给出可更新来源(`canUpdate`)或完全没有检测到 CLI 时出现,未知来源只显示手动指引;配置导入只读 endpoint、模型与协议,不搬运密钥,协议不支持或与所选适配器不匹配时报错而不是猜一个协议。 + - **Windows 的历史标注存在平台 store**:收藏、本地标题和“已移除”是 Lithe 自己的标注,写在平台 store(`agent-history.json`),键为“Agent ID + 工作区路径”,绝不写进发行包或 Agent 的会话文件。macOS 用的是偏好存储(`MacAgentHistoryPersistence` 的 `lithe.agent-history.v1.`),两端都只存标注、不复制转录。每次修改前先重读再合并保存,避免两个窗口互相覆盖对方的标注;筛选、搜索和批量操作只作用于当前可见行。导出前先按需重放未打开的会话(这类加载不打开标签页),全部加载成功后才写用户选定的文件:取消保存对话框不写文件,超过 32 MiB 直接报错,任何一步失败都不产出半份记录。 - **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 文件,也不改变提交信息使用的服务商选择。 @@ -59,7 +65,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 而不校验调用窗口;退出时只清空注册表,把已经交给后台线程的关闭留在未登记状态。 ### 每轮耗时与上报 token @@ -117,10 +123,11 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 后果 -两端共享同一套协议和清理逻辑。功能关闭或没打开面板时,不会有 Agent 进程。一个项目的每种 Agent 只建立一个对话连接,会话再多也一样。订阅额度额外使用有界的短时官方查询进程,不新增常驻服务。 +两端共享同一套协议和清理逻辑。功能关闭或没打开面板时,不会有 Agent 进程。一个项目的每种 Agent 只建立一个对话连接,会话再多也一样。订阅额度额外使用有界的短时官方查询进程,不新增常驻服务。Windows 的连接桥只多一层连接登记、事件转发和进程回收,不会成为第二份协议实现;Windows 面板复用同一条连接,不新写协议。 代价: +- Windows 面板已覆盖会话、消息流、工具证据与权限确认、供应商与订阅切换、上下文用量和额度;历史已支持筛选、搜索、复制会话 ID、收藏、重命名、单条与批量移除及恢复,标注存在平台 store 并按项目与 Agent 隔离,也支持把选中的会话导出成一个 Markdown 文件;设置页已支持适配器安装/卸载、按 host 给出的安装来源做 CLI 安装与升级(显示 host 验证过的版本,安装器警告单独展示)、粘贴 Codex TOML / Claude JSON 导入供应商,输入框支持从系统、项目树拖入文件与 `@` 打开项目文件选择器。Windows 面板的界面以 macOS 为准一比一对齐,不再是简化版:设置和历史整页替换对话,对话始终显示完整布局、发送时才说明缺什么,工具调用分组、轮次统计、模型与权限面板、上下文与额度展示都照 macOS 的结构和文案实现。只有平台机制不同的地方允许不一样(凭据管理器、配置路径、`.cmd` 启动器、WebView2 拖放);任何有意的界面差异都要先得到确认再写进本 Note。纯展示逻辑(分组、搜索、Markdown 分段、选择器标签、轮次计时)放在 `services/` 与 `types/` 的纯模块里测试,组件只负责渲染,这样不导入 Tauri 运行时也能测。Windows 的拖入不能只靠 HTML5 `drop`:Tauri 默认开启原生拖放(`dragDropEnabled`),WebView2 收到系统文件时只发原生事件、DOM 拿不到文件;项目树又是用鼠标事件模拟拖动。所以两条路径都按落点找到带 `data-agent-file-drop-target` 的输入区,再派发 `lithe-agent-file-drop` 事件,输入区用同一个 `attach` 入口处理,去重与 32 个上限只有一份。正确做法是给新的拖入目标加落点标记并接这个事件;不要为了拿到 HTML5 拖放而关闭 `dragDropEnabled`,那会让文件夹打开项目和终端拖入失效。独立历史页(独立页面而非面板内列表)、本机 CLI 配置文件的一键读取、多供应商列表与取消关联仍未实现。共享 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 保存了未完成轮次。 @@ -131,9 +138,12 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentBrandIconResourceTests`:临时安装包布局覆盖 Codex/Claude 图标加载、资源包和图标缺失时安全回退、缓存隔离,比较读取前后的文件清单与内容,确认不改写发行资源。 - 订阅新增测试覆盖旧配置兼容、显式登录、已有账号、认证通知顺序、登录取消和超时、账号变更、额度真实窗口/缺失值/多 bucket、仅查询不发送 prompt,以及临时失败保留旧值。Linux 已运行 Agent Host 的逐测试计时套件;macOS Swift 编译、真实账号登录及深浅主题/窄宽布局仍须在目标环境验证,不将代码存在等同于运行验证。 -- 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 不出现订阅入口。Windows 已有共享协议和 host,但订阅选择和额度 UI 待接入。 +- 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 不出现订阅入口。Windows 面板已接入连接、供应商与订阅切换、上下文用量和额度,仍需在 Windows 上用真实适配器做一次端到端验收(本机 Node/npm 探测、`.cmd` shim 启动、进程树回收与完整对话),并在真实窗口里验证 CLI 升级、配置导入、拖拽与 `@` 引用。 +- `bun test src/features/agent/`(Windows 面板的纯函数:用量与额度解析、会话配置、转录归约、权限队列、文件引用、安装进度事件与 `agent.installCli` 结果解析、CLI 配置导入映射与协议校验、`@` 引用匹配与移除、历史标注与筛选排序、导出用的重放与 Markdown 渲染,以及 `agent.*` 响应解析)与 `bun test src/platform`(`agent_open`/`agent_send`/`agent_close` 的原生命令路由)。 +- `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml platform::`(信封期限与事件通道分派:`agent.install`/`agent.installCli` 不带 30 秒期限,`agentInstallProgress` 只发给 agent 通道,Git 执行事件只发给 git 通道) - `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 或网络。进程树回收用 `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 功能模型测试读取。 @@ -151,4 +161,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`、`windows/tauri/src-tauri/src/bin/fake_acp_adapter.rs`、`windows/tauri/src-tauri/src/platform.rs`、`windows/tauri/src/features/agent/`、`windows/tauri/src/config/backend-capabilities.ts`、`shared/contracts/rust-core-api.md`、`shared/contracts/application-boundary.md`。 diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 3f1ec085a..b70ece7f8 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -228,6 +228,10 @@ jobs: bun test src/features/file-explorer/lib/java-clipboard-class.test.ts src/features/file-explorer/lib/paste-java-class-from-clipboard.test.ts node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-file-watcher.json -- src/features/file-system/services/file-watcher-listener.test.ts src/features/file-system/stores/file-watcher.store.test.ts + - name: Test Agent panel and capability gate + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-agent.json -- src/features/agent src/config/backend-capabilities.test.ts src/features/file-system/utils/file-system-drop-controller.test.ts src/ui/icons.test.tsx + - name: Test editor tab scrolling and lifecycle shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-tabs.json -- src/features/tabs diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 767f5eb16..d43a45a4c 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -3,17 +3,17 @@ ide-api-run-output,AI,IDE MCP,插件与 MCP 列举运行配置、启动重启, ide-api-maven,AI,IDE MCP,插件与 MCP 读取 Maven 模块和 Profile,保存配置、重载并执行既有生命周期和插件目标,已实现,待验证,已实现,待验证,Agent,双端对同一示例项目设置 Profile/skipTests,重载、执行 clean verify,核对按钮与 MCP 的命令、输出、失败和取消。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。,,macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift; macos/Sources/Lithe/Views/App/McpSettingsView.swift; macos/Tests/LitheTests/IdeCapabilitiesTests.swift; rust/lithe-ide-host/src/lib.rs,windows/tauri/src/features/host-api/ide-capabilities.ts; windows/tauri/src/features/host-api/mcp-settings.tsx; windows/tauri/src/features/host-api/ide-capabilities.test.ts; rust/lithe-ide-host/src/lib.rs ide-api-environment,AI,IDE MCP,插件与 MCP 查询和修改项目 Java/Maven 默认环境,并返回实际选择结果,已实现,待验证,已实现,待验证,Agent,读取环境、修改一个默认值,检查其他字段保留;对照设置页与实际启动路径,验证保存失败及项目切换。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。,,macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift; macos/Sources/Lithe/Views/App/McpSettingsView.swift; macos/Tests/LitheTests/IdeCapabilitiesTests.swift; rust/lithe-ide-host/src/lib.rs,windows/tauri/src/features/host-api/ide-capabilities.ts; windows/tauri/src/features/host-api/mcp-settings.tsx; windows/tauri/src/features/host-api/ide-capabilities.test.ts; rust/lithe-ide-host/src/lib.rs ide-mcp-connection,AI,IDE MCP,从设置侧栏独立的 MCP 配置页按项目开启连接,分别授权配置与执行,复制 stdio 客户端配置;关闭项目撤销访问,已实现,待验证,已实现,待验证,Agent,检查设置侧栏 MCP 配置入口与搜索跳转;未打开项目时显示提示,切换项目后展示对应授权。双端打包启动后开启连接,配置真实 Agent,验证工具发现、默认拒绝写入、启用权限、禁用、项目关闭和多窗口隔离。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。,,macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift; macos/Sources/Lithe/Views/App/McpSettingsView.swift; macos/Tests/LitheTests/IdeCapabilitiesTests.swift; rust/lithe-ide-host/src/lib.rs; macos/Sources/Lithe/Views/App/SettingsView.swift,windows/tauri/src/features/host-api/ide-capabilities.ts; windows/tauri/src/features/host-api/mcp-settings.tsx; windows/tauri/src/features/host-api/ide-capabilities.test.ts; rust/lithe-ide-host/src/lib.rs; windows/tauri/src/features/settings/components/mcp-configuration-settings.tsx -agent-entry-discoverability,AI,Agent 对话,新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失,已实现,待验证,未实现,不适用,Agent,使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。,,macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift; macos/Sources/Lithe/Views/Agent/AgentConversationView.swift; macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift; macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg; macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.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 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host; macos/Tests/LitheTests/AgentBrandIconResourceTests.swift,rust/lithe-agent-host -agent-tool-activity-timeline,AI,Agent 对话,连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置,已实现,待验证,未实现,不适用,Agent,macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。,,macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,windows/tauri/src/features -agent-turn-statistics,AI,Agent 对话,发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情,已实现,待验证,部分实现,待验证,Agent,macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage,对话 UI 尚未接入。,,macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift; macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/prompt.rs; rust/lithe-agent-host/src/tests.rs; shared/fixtures/agent/acp-events-v1.json -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-entry-discoverability,AI,Agent 对话,新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失,已实现,待验证,已实现,待验证,Agent,使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 由活动栏入口与面板状态条提供同一入口,入口始终可见且关闭后不重复,状态条给出关闭或未配置的原因与设置入口;实机验证待完成。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。,,macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift; macos/Sources/Lithe/Views/Agent/AgentConversationView.swift; macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift; macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg; macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift,windows/tauri/src/features; windows/tauri/src/features/agent/components/agent-trigger.tsx; windows/tauri/src/features/agent/components/agent-panel.tsx; windows/tauri/src/features/layout/components/plugin-activity-rail.tsx +agent-conversation-history,AI,Agent 对话,CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出,已实现,待验证,部分实现,待验证,Agent,macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 面板已实现筛选(全部/收藏/已移除)、标题与 ID 搜索、复制会话 ID、收藏、本地重命名、单条与批量移除确认及恢复,标注按项目与 Agent 隔离并存在平台 store(agent-history.json),重复写入前先重读以免多窗口互相覆盖;Markdown 导出已实现:先按需重放未打开的会话(导出用的加载不打开标签页),全部加载成功后才写文件,取消保存对话框不写文件,超过 32 MiB 直接报错;独立历史页仍未实现,消息数量只在会话已加载时显示。,,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; windows/tauri/src/features/agent/components/agent-history-list.tsx; windows/tauri/src/features/agent/hooks/use-agent-history.ts; windows/tauri/src/features/agent/services/agent-history-annotations.ts; windows/tauri/src/features/agent/services/agent-history-export.ts; windows/tauri/src/features/agent/services/agent-history-save.ts; windows/tauri/src/features/agent/services/agent-history-store.ts; windows/tauri/src/features/agent/services/agent-history-view.ts; windows/tauri/src/features/agent/services/agent-history.test.ts +agent-context-usage,AI,Agent 对话,Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新,已实现,待验证,已实现,待验证,Agent,macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 面板按 macOS 在输入框顶部上下文栏显示 12px 圆环与百分比,90% 起警告色、100% 起错误色,未知时显示 0% 并提示“上下文: 0.0%”,有用量时提示“x% · 12.3k / 200k 上下文 token”;压缩与会话隔离的实机验证待完成。,,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; windows/tauri/src/features/agent/components/agent-usage-panel.tsx; windows/tauri/src/features/agent/components/agent-composer.tsx; windows/tauri/src/features/agent/types/agent.types.ts +agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,"macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 面板已覆盖消息流、工具证据与权限确认、会话创建与恢复、Agent 上报的配置选项和停止确认;面板结构已按 macOS 重做:会话标题头部与搜索/新对话/标签/历史/设置按钮、整页设置与历史、居中空状态与断线保留对话的重新连接、对话与输入框之间可拖动分隔(默认 min(210, 30%),最小 min(120, 40%),最大 60%)、Markdown 与代码块复制、右对齐用户气泡、可搜索模型面板(分组、品牌图标、思考强度与速度子面板)与带说明的权限模式面板、Agent 切换菜单与 Esc 停止;本机默认模型同步尚未实现,Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。",,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host; macos/Tests/LitheTests/AgentBrandIconResourceTests.swift,rust/lithe-agent-host; windows/tauri/src-tauri/src/agent.rs; windows/tauri/src/features/agent/components/agent-composer.tsx; windows/tauri/src/features/agent/components/agent-panel-parts.tsx; windows/tauri/src/features/agent/components/agent-panel.tsx; windows/tauri/src/features/agent/components/agent-session-selectors.tsx; windows/tauri/src/features/agent/components/agent-transcript.tsx; windows/tauri/src/features/agent/components/brand/agent-brand-icon.tsx; windows/tauri/src/features/agent/services/agent-session-selectors.test.ts; windows/tauri/src/features/agent/services/agent-session-selectors.ts; windows/tauri/src/features/agent/stores/agent-connection.model.ts +agent-tool-activity-timeline,AI,Agent 对话,连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置,已实现,待验证,已实现,待验证,Agent,macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows 相邻工具调用按 macOS 合并为可折叠的“工具操作(N)”组,头部显示失败/中断/全部完成/已完成 n/N 摘要,失败组红框,行内状态点与证据展开(输入、内容、结果、文件位置),列表最多 4 行高;对话下方常驻任务/运行中/编辑统计条,搜索命中时自动展开;实机验证待完成。,,macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,windows/tauri/src/features/agent/components/agent-transcript.tsx; windows/tauri/src/features/agent/services/agent-transcript-items.test.ts; windows/tauri/src/features/agent/services/agent-transcript-items.ts +agent-turn-statistics,AI,Agent 对话,发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情,已实现,待验证,已实现,待验证,Agent,macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 按 macOS 用单调时钟从发送时计时(含会话准备、工具与权限等待),回复中显示“思考中…/正在停止…”与实时耗时,完成、失败或断开后在该轮最后一条消息后冻结“耗时/输入/输出”,悬停列出总、推理、缓存读写 token;历史回放不伪造统计;实机验证待完成。,,macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift; macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/prompt.rs; rust/lithe-agent-host/src/tests.rs; shared/fixtures/agent/acp-events-v1.json; windows/tauri/src/features/agent/components/agent-transcript.tsx; windows/tauri/src/features/agent/stores/agent-connection.model.ts; windows/tauri/src/features/agent/stores/agent-turn-statistics.model.test.ts; windows/tauri/src/features/agent/types/agent-turn-statistics.ts +agent-file-references,AI,Agent 对话,从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿,已实现,待验证,已实现,待验证,Agent,macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 面板提供项目文件搜索选择器、可移除标签、去重与数量上限;从系统、项目树拖入文件到输入区与在输入框输入 @ 打开选择器都已接入(系统拖放走 Tauri 原生拖放事件、项目树走鼠标拖动,都按落点路由到输入区;两者共用同一套去重、数量上限与可移除标签,选中后把 @query 文本替换为引用标签),真实拖放体验与 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; windows/tauri/src/features/agent/components/agent-composer.tsx; windows/tauri/src/features/agent/types/agent-file-uri.ts; windows/tauri/src/features/agent/services/agent-composer-mentions.ts; windows/tauri/src/features/file-system/utils/file-system-drop-controller.ts; windows/tauri/src/features/file-system/utils/file-system-drop-controller.test.ts; windows/tauri/src/features/file-system/utils/file-system-dropped-paths.ts +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 面板已接入 Node.js/npm 检测、预检问题、适配器安装(含下载进度)与卸载;CLI 的安装与升级也已接入:只有 host 明确给出可更新来源(canUpdate)或完全没有检测到 CLI 时才显示按钮,升级后显示 host 验证过的版本,安装器报错但版本确实提升时单独一行展示警告,未知来源只显示手动指引;npm 全局安装在 Windows 上是前缀目录的 .cmd 启动器,按同目录包清单识别为 npm 来源并可升级;取消安装、预检清单与本机 CLI 配置文件的一键读取尚未接入。,,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; windows/tauri/src/features/agent/components/agent-settings-section.tsx; windows/tauri/src/features/agent/services/agent-management.ts; windows/tauri/src/features/agent/hooks/use-agent-management.ts; windows/tauri/src-tauri/src/platform.rs; rust/lithe-agent-host/src/cli_update.rs; rust/lithe-agent-host/src/cli_update/launcher_tests.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 面板当前是单一供应商表单:endpoint、模型、密钥安全存储与重连;粘贴配置文本导入已接入:由共享 Rust 解析出 endpoint、模型与协议后填入表单,只读元数据、不搬运密钥,chatCompletions 或与所选适配器协议不匹配时明确报错;一键读取本机 CLI 配置文件、多供应商列表与取消关联尚未实现。,,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; windows/tauri/src/features/agent/components/agent-settings-section.tsx; windows/tauri/src/features/agent/services/agent-launch.ts; windows/tauri/src/features/agent/services/agent-provider-import.ts +agent-codex-subscription,AI,Agent 对话,Codex 供应商选择官方订阅:复用本机账号、显式浏览器登录与取消、API Key 模式切换及旧配置兼容,已实现,待验证,已实现,待验证,Agent,macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 面板已接入订阅模式切换、登录与取消,订阅启动不携带供应商;真实账号验证待完成。,,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; windows/tauri/src/features/agent/components/agent-settings-section.tsx; windows/tauri/src/features/agent/components/agent-panel.tsx; windows/tauri/src/features/agent/services/agent-launch.ts +agent-subscription-quota,AI,Agent 对话,Codex 订阅额度:上下文右侧紧凑显示、可见时定时刷新、悬停多窗口与重置时间、未知与过期状态及账号隔离,已实现,待验证,已实现,待验证,Agent,macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows 面板按 macOS 在上下文栏右侧显示“5h · 已用 x%”或“额度 —”,只在对话页可见时每分钟刷新,悬停列出账号、各窗口、重置时间与更新时间以及过期/账号变更/等待提示,过期快照灰显且 API Key 模式不查询;真实账号验证待完成。,,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; windows/tauri/src/features/agent/components/agent-usage-panel.tsx; windows/tauri/src/features/agent/components/agent-panel.tsx; windows/tauri/src/features/agent/types/agent.types.ts 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 37a0ad01b..966043cc4 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -6,7 +6,7 @@ - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) - 功能项:116 - macOS:实现:✅ 102 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 105 待验证,— 11 不适用 -- Windows:实现:✅ 98 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 109 待验证,— 7 不适用 +- Windows:实现:✅ 105 已实现,🟡 7 部分实现,❌ 2 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 112 待验证,— 4 不适用 ## 实现状态定义 @@ -38,17 +38,17 @@ | IDE MCP | **插件与 MCP 读取 Maven 模块和 Profile,保存配置、重载并执行既有生命周期和插件目标**
ide-api-maven | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift`、`macos/Sources/Lithe/Views/App/McpSettingsView.swift`、`macos/Tests/LitheTests/IdeCapabilitiesTests.swift`、`rust/lithe-ide-host/src/lib.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/host-api/ide-capabilities.ts`、`windows/tauri/src/features/host-api/mcp-settings.tsx`、`windows/tauri/src/features/host-api/ide-capabilities.test.ts`、`rust/lithe-ide-host/src/lib.rs` | Agent | 双端对同一示例项目设置 Profile/skipTests,重载、执行 clean verify,核对按钮与 MCP 的命令、输出、失败和取消。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 | | | IDE MCP | **插件与 MCP 查询和修改项目 Java/Maven 默认环境,并返回实际选择结果**
ide-api-environment | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift`、`macos/Sources/Lithe/Views/App/McpSettingsView.swift`、`macos/Tests/LitheTests/IdeCapabilitiesTests.swift`、`rust/lithe-ide-host/src/lib.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/host-api/ide-capabilities.ts`、`windows/tauri/src/features/host-api/mcp-settings.tsx`、`windows/tauri/src/features/host-api/ide-capabilities.test.ts`、`rust/lithe-ide-host/src/lib.rs` | Agent | 读取环境、修改一个默认值,检查其他字段保留;对照设置页与实际启动路径,验证保存失败及项目切换。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 | | | IDE MCP | **从设置侧栏独立的 MCP 配置页按项目开启连接,分别授权配置与执行,复制 stdio 客户端配置;关闭项目撤销访问**
ide-mcp-connection | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift`、`macos/Sources/Lithe/Views/App/McpSettingsView.swift`、`macos/Tests/LitheTests/IdeCapabilitiesTests.swift`、`rust/lithe-ide-host/src/lib.rs`、`macos/Sources/Lithe/Views/App/SettingsView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/host-api/ide-capabilities.ts`、`windows/tauri/src/features/host-api/mcp-settings.tsx`、`windows/tauri/src/features/host-api/ide-capabilities.test.ts`、`rust/lithe-ide-host/src/lib.rs`、`windows/tauri/src/features/settings/components/mcp-configuration-settings.tsx` | Agent | 检查设置侧栏 MCP 配置入口与搜索跳转;未打开项目时显示提示,切换项目后展示对应授权。双端打包启动后开启连接,配置真实 Agent,验证工具发现、默认拒绝写入、启用权限、禁用、项目关闭和多窗口隔离。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 | | -| Agent 对话 | **新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失**
agent-entry-discoverability | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift`、`macos/Sources/Lithe/Views/Agent/AgentConversationView.swift`、`macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift`、`macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg`、`macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | 使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。 | | -| 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`、`macos/Tests/LitheTests/AgentBrandIconResourceTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 | | -| Agent 对话 | **连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置**
agent-tool-activity-timeline | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。 | | -| Agent 对话 | **发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情**
agent-turn-statistics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift`、`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`rust/lithe-agent-host/src/tests.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage,对话 UI 尚未接入。 | | -| 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 对话 | **新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失**
agent-entry-discoverability | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift`、`macos/Sources/Lithe/Views/Agent/AgentConversationView.swift`、`macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift`、`macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg`、`macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features`、`windows/tauri/src/features/agent/components/agent-trigger.tsx`、`windows/tauri/src/features/agent/components/agent-panel.tsx`、`windows/tauri/src/features/layout/components/plugin-activity-rail.tsx` | Agent | 使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 由活动栏入口与面板状态条提供同一入口,入口始终可见且关闭后不重复,状态条给出关闭或未配置的原因与设置入口;实机验证待完成。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。 | | +| 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`、`windows/tauri/src/features/agent/components/agent-history-list.tsx`、`windows/tauri/src/features/agent/hooks/use-agent-history.ts`、`windows/tauri/src/features/agent/services/agent-history-annotations.ts`、`windows/tauri/src/features/agent/services/agent-history-export.ts`、`windows/tauri/src/features/agent/services/agent-history-save.ts`、`windows/tauri/src/features/agent/services/agent-history-store.ts`、`windows/tauri/src/features/agent/services/agent-history-view.ts`、`windows/tauri/src/features/agent/services/agent-history.test.ts` | Agent | macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 面板已实现筛选(全部/收藏/已移除)、标题与 ID 搜索、复制会话 ID、收藏、本地重命名、单条与批量移除确认及恢复,标注按项目与 Agent 隔离并存在平台 store(agent-history.json),重复写入前先重读以免多窗口互相覆盖;Markdown 导出已实现:先按需重放未打开的会话(导出用的加载不打开标签页),全部加载成功后才写文件,取消保存对话框不写文件,超过 32 MiB 直接报错;独立历史页仍未实现,消息数量只在会话已加载时显示。 | | +| 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`、`windows/tauri/src/features/agent/components/agent-usage-panel.tsx`、`windows/tauri/src/features/agent/components/agent-composer.tsx`、`windows/tauri/src/features/agent/types/agent.types.ts` | Agent | macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 面板按 macOS 在输入框顶部上下文栏显示 12px 圆环与百分比,90% 起警告色、100% 起错误色,未知时显示 0% 并提示“上下文: 0.0%”,有用量时提示“x% · 12.3k / 200k 上下文 token”;压缩与会话隔离的实机验证待完成。 | | +| Agent 对话 | **可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host`、`macos/Tests/LitheTests/AgentBrandIconResourceTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host`、`windows/tauri/src-tauri/src/agent.rs`、`windows/tauri/src/features/agent/components/agent-composer.tsx`、`windows/tauri/src/features/agent/components/agent-panel-parts.tsx`、`windows/tauri/src/features/agent/components/agent-panel.tsx`、`windows/tauri/src/features/agent/components/agent-session-selectors.tsx`、`windows/tauri/src/features/agent/components/agent-transcript.tsx`、`windows/tauri/src/features/agent/components/brand/agent-brand-icon.tsx`、`windows/tauri/src/features/agent/services/agent-session-selectors.test.ts`、`windows/tauri/src/features/agent/services/agent-session-selectors.ts`、`windows/tauri/src/features/agent/stores/agent-connection.model.ts` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 面板已覆盖消息流、工具证据与权限确认、会话创建与恢复、Agent 上报的配置选项和停止确认;面板结构已按 macOS 重做:会话标题头部与搜索/新对话/标签/历史/设置按钮、整页设置与历史、居中空状态与断线保留对话的重新连接、对话与输入框之间可拖动分隔(默认 min(210, 30%),最小 min(120, 40%),最大 60%)、Markdown 与代码块复制、右对齐用户气泡、可搜索模型面板(分组、品牌图标、思考强度与速度子面板)与带说明的权限模式面板、Agent 切换菜单与 Esc 停止;本机默认模型同步尚未实现,Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 | | +| Agent 对话 | **连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置**
agent-tool-activity-timeline | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/agent/components/agent-transcript.tsx`、`windows/tauri/src/features/agent/services/agent-transcript-items.test.ts`、`windows/tauri/src/features/agent/services/agent-transcript-items.ts` | Agent | macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows 相邻工具调用按 macOS 合并为可折叠的“工具操作(N)”组,头部显示失败/中断/全部完成/已完成 n/N 摘要,失败组红框,行内状态点与证据展开(输入、内容、结果、文件位置),列表最多 4 行高;对话下方常驻任务/运行中/编辑统计条,搜索命中时自动展开;实机验证待完成。 | | +| Agent 对话 | **发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情**
agent-turn-statistics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift`、`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | ✅ 已实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`rust/lithe-agent-host/src/tests.rs`、`shared/fixtures/agent/acp-events-v1.json`、`windows/tauri/src/features/agent/components/agent-transcript.tsx`、`windows/tauri/src/features/agent/stores/agent-connection.model.ts`、`windows/tauri/src/features/agent/stores/agent-turn-statistics.model.test.ts`、`windows/tauri/src/features/agent/types/agent-turn-statistics.ts` | Agent | macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 按 macOS 用单调时钟从发送时计时(含会话准备、工具与权限等待),回复中显示“思考中…/正在停止…”与实时耗时,完成、失败或断开后在该轮最后一条消息后冻结“耗时/输入/输出”,悬停列出总、推理、缓存读写 token;历史回放不伪造统计;实机验证待完成。 | | +| 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`、`windows/tauri/src/features/agent/components/agent-composer.tsx`、`windows/tauri/src/features/agent/types/agent-file-uri.ts`、`windows/tauri/src/features/agent/services/agent-composer-mentions.ts`、`windows/tauri/src/features/file-system/utils/file-system-drop-controller.ts`、`windows/tauri/src/features/file-system/utils/file-system-drop-controller.test.ts`、`windows/tauri/src/features/file-system/utils/file-system-dropped-paths.ts` | Agent | macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 面板提供项目文件搜索选择器、可移除标签、去重与数量上限;从系统、项目树拖入文件到输入区与在输入框输入 @ 打开选择器都已接入(系统拖放走 Tauri 原生拖放事件、项目树走鼠标拖动,都按落点路由到输入区;两者共用同一套去重、数量上限与可移除标签,选中后把 @query 文本替换为引用标签),真实拖放体验与 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`、`windows/tauri/src/features/agent/components/agent-settings-section.tsx`、`windows/tauri/src/features/agent/services/agent-management.ts`、`windows/tauri/src/features/agent/hooks/use-agent-management.ts`、`windows/tauri/src-tauri/src/platform.rs`、`rust/lithe-agent-host/src/cli_update.rs`、`rust/lithe-agent-host/src/cli_update/launcher_tests.rs` | Agent | 在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 面板已接入 Node.js/npm 检测、预检问题、适配器安装(含下载进度)与卸载;CLI 的安装与升级也已接入:只有 host 明确给出可更新来源(canUpdate)或完全没有检测到 CLI 时才显示按钮,升级后显示 host 验证过的版本,安装器报错但版本确实提升时单独一行展示警告,未知来源只显示手动指引;npm 全局安装在 Windows 上是前缀目录的 .cmd 启动器,按同目录包清单识别为 npm 来源并可升级;取消安装、预检清单与本机 CLI 配置文件的一键读取尚未接入。 | | +| 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`、`windows/tauri/src/features/agent/components/agent-settings-section.tsx`、`windows/tauri/src/features/agent/services/agent-launch.ts`、`windows/tauri/src/features/agent/services/agent-provider-import.ts` | Agent | macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 面板当前是单一供应商表单:endpoint、模型、密钥安全存储与重连;粘贴配置文本导入已接入:由共享 Rust 解析出 endpoint、模型与协议后填入表单,只读元数据、不搬运密钥,chatCompletions 或与所选适配器协议不匹配时明确报错;一键读取本机 CLI 配置文件、多供应商列表与取消关联尚未实现。 | | +| 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`、`windows/tauri/src/features/agent/components/agent-settings-section.tsx`、`windows/tauri/src/features/agent/components/agent-panel.tsx`、`windows/tauri/src/features/agent/services/agent-launch.ts` | Agent | macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 面板已接入订阅模式切换、登录与取消,订阅启动不携带供应商;真实账号验证待完成。 | | +| 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`、`windows/tauri/src/features/agent/components/agent-usage-panel.tsx`、`windows/tauri/src/features/agent/components/agent-panel.tsx`、`windows/tauri/src/features/agent/types/agent.types.ts` | Agent | macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows 面板按 macOS 在上下文栏右侧显示“5h · 已用 x%”或“额度 —”,只在对话页可见时每分钟刷新,悬停列出账号、各窗口、重置时间与更新时间以及过期/账号变更/等待提示,过期快照灰显且 API Key 模式不查询;真实账号验证待完成。 | | | 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/cli_update.rs b/rust/lithe-agent-host/src/cli_update.rs index 0a8ddf90c..fce6c0a95 100644 --- a/rust/lithe-agent-host/src/cli_update.rs +++ b/rust/lithe-agent-host/src/cli_update.rs @@ -215,10 +215,16 @@ fn resolve( )); } } - let npm_owned = target.as_ref().is_some_and(|target| { - target.ancestors().any(|root| { + // The file the npm package declares: the link target on Unix, or the bin + // behind a Windows `.cmd` launcher, which does not resolve to it. + let package_bin = executable + .and_then(|executable| npm_launcher_bin(cli, executable)) + .map(|(_, canonical)| canonical) + .or_else(|| target.clone()); + let npm_owned = package_bin.as_ref().is_some_and(|package_bin| { + package_bin.ancestors().any(|root| { root.file_name().is_some_and(|name| name == "node_modules") - && owns_npm_bin(&root.join(cli.package), cli, target) + && owns_npm_bin(&root.join(cli.package), cli, package_bin) }) }); let source = if executable.is_none() { @@ -257,7 +263,9 @@ fn resolve( if !package .canonicalize() .is_ok_and(|package| package.starts_with(&root)) - || !owns_npm_bin(&package, cli, target) + || !package_bin + .as_ref() + .is_some_and(|package_bin| owns_npm_bin(&package, cli, package_bin)) || bin.canonicalize().ok().as_ref() != Some(target) { return Ok(UpdatePlan::manual(CliSource::Npm, @@ -283,33 +291,73 @@ fn resolve( } fn owns_npm_bin(package: &Path, cli: &AgentCli, target: &Path) -> bool { + npm_package_bin(package, cli).is_some_and(|(_, bin)| bin == target) +} + +/// The CLI's bin declared by the npm package at `package`, as written and +/// canonical. `None` unless the manifest names this CLI's package and the bin +/// stays inside it, so a foreign package can never claim the command. +fn npm_package_bin(package: &Path, cli: &AgentCli) -> Option<(PathBuf, PathBuf)> { let manifest = (|| { let file = std::fs::File::open(package.join("package.json")).ok()?; let mut bytes = Vec::new(); file.take(64 * 1024).read_to_end(&mut bytes).ok()?; serde_json::from_slice::(&bytes).ok() - })(); - let Some(manifest) = manifest else { - return false; - }; + })()?; if manifest["name"] != cli.package { - return false; + return None; } - let Some(bin) = manifest["bin"] + let bin = manifest["bin"] .get(cli.command) .unwrap_or(&manifest["bin"]) - .as_str() - else { - return false; - }; - let Ok(package_root) = package.canonicalize() else { - return false; - }; - package - .join(bin) - .canonicalize() - .is_ok_and(|bin| bin.starts_with(package_root) && bin == target) + .as_str()?; + let package_root = package.canonicalize().ok()?; + let declared = package.join(bin); + let canonical = declared.canonicalize().ok()?; + canonical + .starts_with(package_root) + .then_some((declared, canonical)) +} + +/// Package bin behind an npm `.cmd` launcher. npm on Windows writes a +/// batch launcher into the global prefix instead of a symlink, so neither the +/// launcher's path nor its canonical form leads to the package; its package is +/// always `node_modules/` next to it. Other platforms never select a +/// `.cmd` file, so this is a no-op there. +fn npm_launcher_bin(cli: &AgentCli, launcher: &Path) -> Option<(PathBuf, PathBuf)> { + let is_launcher = launcher + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("cmd")) + && launcher + .file_stem() + .is_some_and(|stem| stem.eq_ignore_ascii_case(cli.command)); + if !is_launcher { + return None; + } + npm_package_bin( + &launcher.parent()?.join("node_modules").join(cli.package), + cli, + ) +} + +/// Executable an adapter should start for the detected CLI. +/// +/// Adapters spawn this path without a shell, and Node.js refuses to start a +/// `.cmd` file that way on Windows (`spawn EINVAL`). An npm launcher whose +/// package ships a native executable is therefore replaced by that executable; +/// script bins keep the launcher, which needs Node.js to run anyway. +pub(crate) fn launch_executable(cli: &AgentCli, detected: &Path) -> PathBuf { + npm_launcher_bin(cli, detected) + .map(|(declared, _)| declared) + .filter(|bin| { + bin.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("exe")) + }) + .unwrap_or_else(|| detected.to_path_buf()) } #[cfg(all(test, unix))] mod tests; + +#[cfg(test)] +mod launcher_tests; diff --git a/rust/lithe-agent-host/src/cli_update/launcher_tests.rs b/rust/lithe-agent-host/src/cli_update/launcher_tests.rs new file mode 100644 index 000000000..ac320e191 --- /dev/null +++ b/rust/lithe-agent-host/src/cli_update/launcher_tests.rs @@ -0,0 +1,133 @@ +//! npm's Windows `.cmd` launchers. The layout is plain files, so the launcher +//! mapping runs on every platform; ownership resolution needs Windows paths. + +use super::*; + +struct Prefix(PathBuf); + +impl Prefix { + /// An npm global prefix holding `.cmd` and the package that owns it. + fn new(cli: &AgentCli, package_name: &str, bin: &str) -> Self { + let root = + std::env::temp_dir().join(format!("lithe-cli-launcher-{}", uuid::Uuid::new_v4())); + let package = root.join("node_modules").join(cli.package); + std::fs::create_dir_all(package.join("bin")).unwrap(); + std::fs::write( + package.join("package.json"), + serde_json::json!({ "name": package_name, "bin": { cli.command: bin } }).to_string(), + ) + .unwrap(); + std::fs::write(package.join(bin), "binary").unwrap(); + std::fs::write(root.join(format!("{}.cmd", cli.command)), "@ECHO off\r\n").unwrap(); + Self(root) + } + + fn launcher(&self, cli: &AgentCli) -> PathBuf { + self.0.join(format!("{}.cmd", cli.command)) + } +} + +impl Drop for Prefix { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn cli(agent: &str) -> &'static AgentCli { + crate::catalog::find(agent).unwrap().cli.as_ref().unwrap() +} + +#[test] +fn native_npm_bin_replaces_its_launcher_so_adapters_can_spawn_it() { + // Claude Code ships `bin/claude.exe`; Node.js cannot spawn `claude.cmd` + // without a shell, which surfaced as `spawn EINVAL` on the first session. + let claude = cli("claude-acp"); + let prefix = Prefix::new(claude, claude.package, "bin/claude.exe"); + assert_eq!( + launch_executable(claude, &prefix.launcher(claude)), + prefix + .0 + .join("node_modules") + .join(claude.package) + .join("bin/claude.exe") + ); +} + +#[test] +fn script_bins_and_foreign_packages_keep_the_detected_launcher() { + // Codex ships `bin/codex.js`, which still needs the launcher to find Node.js. + let codex = cli("codex-acp"); + let script = Prefix::new(codex, codex.package, "bin/codex.js"); + assert_eq!( + launch_executable(codex, &script.launcher(codex)), + script.launcher(codex) + ); + + // A package that is not the CLI's own never redirects the launch. + let claude = cli("claude-acp"); + let foreign = Prefix::new(claude, "unrelated-package", "bin/claude.exe"); + assert_eq!( + launch_executable(claude, &foreign.launcher(claude)), + foreign.launcher(claude) + ); + + // Paths that are not npm launchers are passed through unchanged. + let plain = PathBuf::from("/usr/local/bin/claude"); + assert_eq!(launch_executable(claude, &plain), plain); +} + +#[cfg(windows)] +#[test] +fn npm_cmd_launcher_is_recognised_as_an_updatable_npm_install() { + for (agent, bin) in [ + ("codex-acp", "bin/codex.js"), + ("claude-acp", "bin/claude.exe"), + ] { + let cli = cli(agent); + let prefix = Prefix::new(cli, cli.package, bin); + let npm = prefix.0.join("npm.cmd"); + std::fs::write(&npm, "@ECHO off\r\n").unwrap(); + let plan = resolve( + cli, + Some(&prefix.launcher(cli)), + Some(&npm), + None, + None, + &|_, args| match args { + ["prefix", "--global"] => Ok(prefix.0.display().to_string()), + ["root", "--global"] => Ok(prefix.0.join("node_modules").display().to_string()), + _ => panic!("unexpected probe: {args:?}"), + }, + ) + .unwrap(); + assert_eq!(plan.installation.source, CliSource::Npm, "{agent}"); + assert!(plan.installation.can_update, "{agent}"); + assert_eq!(plan.command.unwrap().program, npm); + } +} + +#[cfg(windows)] +#[test] +fn a_launcher_from_another_npm_prefix_is_never_updated_by_the_active_npm() { + let codex = cli("codex-acp"); + let active = Prefix::new(codex, codex.package, "bin/codex.js"); + let other = Prefix::new(codex, codex.package, "bin/codex.js"); + let npm = active.0.join("npm.cmd"); + std::fs::write(&npm, "@ECHO off\r\n").unwrap(); + let plan = resolve( + codex, + Some(&other.launcher(codex)), + Some(&npm), + None, + None, + &|_, args| match args { + ["prefix", "--global"] => Ok(active.0.display().to_string()), + ["root", "--global"] => Ok(active.0.join("node_modules").display().to_string()), + _ => panic!("unexpected probe: {args:?}"), + }, + ) + .unwrap(); + assert_eq!(plan.installation.source, CliSource::Npm); + assert!(!plan.installation.can_update); + assert!(plan.command.is_none()); +} diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index aa545fbbc..a019d5f9d 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; @@ -276,7 +281,9 @@ fn resolve_with( if let Some(tool) = detected { env.push(( cli.path_env.to_owned(), - tool.path.to_string_lossy().into_owned(), + cli_update::launch_executable(cli, &tool.path) + .to_string_lossy() + .into_owned(), )); } } @@ -703,6 +710,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/rust/lithe-agent-host/src/subscription.rs b/rust/lithe-agent-host/src/subscription.rs index 33dd8b387..443888be6 100644 --- a/rust/lithe-agent-host/src/subscription.rs +++ b/rust/lithe-agent-host/src/subscription.rs @@ -161,7 +161,9 @@ pub(super) fn resolve( env: vec![ ( cli.path_env.into(), - executable.to_string_lossy().into_owned(), + crate::cli_update::launch_executable(cli, &executable) + .to_string_lossy() + .into_owned(), ), ("MODEL_PROVIDER".into(), "openai".into()), ( diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 08f610fe4..8beae6f61 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -181,14 +181,17 @@ ] }, "windows": { - "implementationStatus": "missing", - "verificationStatus": "not-applicable", + "implementationStatus": "implemented", + "verificationStatus": "pending", "evidence": [ - "windows/tauri/src/features" + "windows/tauri/src/features", + "windows/tauri/src/features/agent/components/agent-trigger.tsx", + "windows/tauri/src/features/agent/components/agent-panel.tsx", + "windows/tauri/src/features/layout/components/plugin-activity-rail.tsx" ] }, "owner": "Agent", - "verification": "使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。" + "verification": "使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 由活动栏入口与面板状态条提供同一入口,入口始终可见且关闭后不重复,状态条给出关闭或未配置的原因与设置入口;实机验证待完成。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。" }, { "id": "agent-conversation-history", @@ -207,13 +210,21 @@ }, "windows": { "evidence": [ - "rust/lithe-agent-host/src/lib.rs" + "rust/lithe-agent-host/src/lib.rs", + "windows/tauri/src/features/agent/components/agent-history-list.tsx", + "windows/tauri/src/features/agent/hooks/use-agent-history.ts", + "windows/tauri/src/features/agent/services/agent-history-annotations.ts", + "windows/tauri/src/features/agent/services/agent-history-export.ts", + "windows/tauri/src/features/agent/services/agent-history-save.ts", + "windows/tauri/src/features/agent/services/agent-history-store.ts", + "windows/tauri/src/features/agent/services/agent-history-view.ts", + "windows/tauri/src/features/agent/services/agent-history.test.ts" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。" + "verification": "macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 面板已实现筛选(全部/收藏/已移除)、标题与 ID 搜索、复制会话 ID、收藏、本地重命名、单条与批量移除确认及恢复,标注按项目与 Agent 隔离并存在平台 store(agent-history.json),重复写入前先重读以免多窗口互相覆盖;Markdown 导出已实现:先按需重放未打开的会话(导出用的加载不打开标签页),全部加载成功后才写文件,取消保存对话框不写文件,超过 32 MiB 直接报错;独立历史页仍未实现,消息数量只在会话已加载时显示。" }, { "id": "agent-context-usage", @@ -233,13 +244,17 @@ "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", + "windows/tauri/src/features/agent/components/agent-usage-panel.tsx", + "windows/tauri/src/features/agent/components/agent-composer.tsx", + "windows/tauri/src/features/agent/types/agent.types.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "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 面板按 macOS 在输入框顶部上下文栏显示 12px 圆环与百分比,90% 起警告色、100% 起错误色,未知时显示 0% 并提示“上下文: 0.0%”,有用量时提示“x% · 12.3k / 200k 上下文 token”;压缩与会话隔离的实机验证待完成。" }, { "id": "agent-acp-conversation", @@ -258,13 +273,23 @@ }, "windows": { "evidence": [ - "rust/lithe-agent-host" + "rust/lithe-agent-host", + "windows/tauri/src-tauri/src/agent.rs", + "windows/tauri/src/features/agent/components/agent-composer.tsx", + "windows/tauri/src/features/agent/components/agent-panel-parts.tsx", + "windows/tauri/src/features/agent/components/agent-panel.tsx", + "windows/tauri/src/features/agent/components/agent-session-selectors.tsx", + "windows/tauri/src/features/agent/components/agent-transcript.tsx", + "windows/tauri/src/features/agent/components/brand/agent-brand-icon.tsx", + "windows/tauri/src/features/agent/services/agent-session-selectors.test.ts", + "windows/tauri/src/features/agent/services/agent-session-selectors.ts", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。" + "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 面板已覆盖消息流、工具证据与权限确认、会话创建与恢复、Agent 上报的配置选项和停止确认;面板结构已按 macOS 重做:会话标题头部与搜索/新对话/标签/历史/设置按钮、整页设置与历史、居中空状态与断线保留对话的重新连接、对话与输入框之间可拖动分隔(默认 min(210, 30%),最小 min(120, 40%),最大 60%)、Markdown 与代码块复制、右对齐用户气泡、可搜索模型面板(分组、品牌图标、思考强度与速度子面板)与带说明的权限模式面板、Agent 切换菜单与 Esc 停止;本机默认模型同步尚未实现,Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。" }, { "id": "agent-tool-activity-timeline", @@ -282,13 +307,15 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features" + "windows/tauri/src/features/agent/components/agent-transcript.tsx", + "windows/tauri/src/features/agent/services/agent-transcript-items.test.ts", + "windows/tauri/src/features/agent/services/agent-transcript-items.ts" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "implemented", + "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。" + "verification": "macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows 相邻工具调用按 macOS 合并为可折叠的“工具操作(N)”组,头部显示失败/中断/全部完成/已完成 n/N 摘要,失败组红框,行内状态点与证据展开(输入、内容、结果、文件位置),列表最多 4 行高;对话下方常驻任务/运行中/编辑统计条,搜索命中时自动展开;实机验证待完成。" }, { "id": "agent-turn-statistics", @@ -311,13 +338,17 @@ "evidence": [ "rust/lithe-agent-host/src/prompt.rs", "rust/lithe-agent-host/src/tests.rs", - "shared/fixtures/agent/acp-events-v1.json" + "shared/fixtures/agent/acp-events-v1.json", + "windows/tauri/src/features/agent/components/agent-transcript.tsx", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/stores/agent-turn-statistics.model.test.ts", + "windows/tauri/src/features/agent/types/agent-turn-statistics.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage,对话 UI 尚未接入。" + "verification": "macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 按 macOS 用单调时钟从发送时计时(含会话准备、工具与权限等待),回复中显示“思考中…/正在停止…”与实时耗时,完成、失败或断开后在该轮最后一条消息后冻结“耗时/输入/输出”,悬停列出总、推理、缓存读写 token;历史回放不伪造统计;实机验证待完成。" }, { "id": "agent-file-references", @@ -339,13 +370,19 @@ "windows": { "evidence": [ "rust/lithe-agent-host/src/prompt.rs", - "shared/fixtures/agent/acp-events-v1.json" + "shared/fixtures/agent/acp-events-v1.json", + "windows/tauri/src/features/agent/components/agent-composer.tsx", + "windows/tauri/src/features/agent/types/agent-file-uri.ts", + "windows/tauri/src/features/agent/services/agent-composer-mentions.ts", + "windows/tauri/src/features/file-system/utils/file-system-drop-controller.ts", + "windows/tauri/src/features/file-system/utils/file-system-drop-controller.test.ts", + "windows/tauri/src/features/file-system/utils/file-system-dropped-paths.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。" + "verification": "macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 面板提供项目文件搜索选择器、可移除标签、去重与数量上限;从系统、项目树拖入文件到输入区与在输入框输入 @ 打开选择器都已接入(系统拖放走 Tauri 原生拖放事件、项目树走鼠标拖动,都按落点路由到输入区;两者共用同一套去重、数量上限与可移除标签,选中后把 @query 文本替换为引用标签),真实拖放体验与 Claude 端到端仍待验证。" }, { "id": "agent-management", @@ -367,13 +404,19 @@ }, "windows": { "evidence": [ - "rust/lithe-core/src/agent/mod.rs" + "rust/lithe-core/src/agent/mod.rs", + "windows/tauri/src/features/agent/components/agent-settings-section.tsx", + "windows/tauri/src/features/agent/services/agent-management.ts", + "windows/tauri/src/features/agent/hooks/use-agent-management.ts", + "windows/tauri/src-tauri/src/platform.rs", + "rust/lithe-agent-host/src/cli_update.rs", + "rust/lithe-agent-host/src/cli_update/launcher_tests.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。" + "verification": "在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 面板已接入 Node.js/npm 检测、预检问题、适配器安装(含下载进度)与卸载;CLI 的安装与升级也已接入:只有 host 明确给出可更新来源(canUpdate)或完全没有检测到 CLI 时才显示按钮,升级后显示 host 验证过的版本,安装器报错但版本确实提升时单独一行展示警告,未知来源只显示手动指引;npm 全局安装在 Windows 上是前缀目录的 .cmd 启动器,按同目录包清单识别为 npm 来源并可升级;取消安装、预检清单与本机 CLI 配置文件的一键读取尚未接入。" }, { "id": "agent-provider-configuration", @@ -398,11 +441,14 @@ "verificationStatus": "pending", "evidence": [ "rust/lithe-core/src/ai/agent_configuration.rs", - "shared/fixtures/agent/provider-configuration-v1.json" + "shared/fixtures/agent/provider-configuration-v1.json", + "windows/tauri/src/features/agent/components/agent-settings-section.tsx", + "windows/tauri/src/features/agent/services/agent-launch.ts", + "windows/tauri/src/features/agent/services/agent-provider-import.ts" ] }, "owner": "Agent", - "verification": "macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 编辑器及 Claude 真实请求待验证。" + "verification": "macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 面板当前是单一供应商表单:endpoint、模型、密钥安全存储与重连;粘贴配置文本导入已接入:由共享 Rust 解析出 endpoint、模型与协议后填入表单,只读元数据、不搬运密钥,chatCompletions 或与所选适配器协议不匹配时明确报错;一键读取本机 CLI 配置文件、多供应商列表与取消关联尚未实现。" }, { "id": "agent-codex-subscription", @@ -421,15 +467,19 @@ ] }, "windows": { - "implementationStatus": "partial", + "implementationStatus": "implemented", "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", + "windows/tauri/src/features/agent/components/agent-settings-section.tsx", + "windows/tauri/src/features/agent/components/agent-panel.tsx", + "windows/tauri/src/features/agent/services/agent-launch.ts" ] }, "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 面板已接入订阅模式切换、登录与取消,订阅启动不携带供应商;真实账号验证待完成。" }, { "id": "agent-subscription-quota", @@ -447,15 +497,19 @@ ] }, "windows": { - "implementationStatus": "partial", + "implementationStatus": "implemented", "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", + "windows/tauri/src/features/agent/components/agent-usage-panel.tsx", + "windows/tauri/src/features/agent/components/agent-panel.tsx", + "windows/tauri/src/features/agent/types/agent.types.ts" ] }, "owner": "Agent", - "verification": "macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows UI 与真实账号验证待完成。" + "verification": "macOS:订阅会话上下文右侧显示额度,悬停查看窗口时长/已用百分比/重置和更新时间;真实账号验证可见且活跃时每分钟刷新、隐藏不轮询、单连接不并发探测,查询不创建会话或模型请求;周窗口作为 primary 仍显示 7d,缺失不报 0%;网络失败保留并灰显旧值,账号不匹配清除;API Key 模式不查询。窄宽面板、深浅主题、关闭面板/项目和取消时确认探测进程清理。Windows 面板按 macOS 在上下文栏右侧显示“5h · 已用 x%”或“额度 —”,只在对话页可见时每分钟刷新,悬停列出账号、各窗口、重置时间与更新时间以及过期/账号变更/等待提示,过期快照灰显且 API Key 模式不查询;真实账号验证待完成。" }, { "id": "workspace-open-switch", diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 31a935d30..5c85b293b 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2952,6 +2952,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 1d0527384..5aa4d68c2 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -14,12 +14,20 @@ 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 = [] } [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..e8a6f4c4c --- /dev/null +++ b/windows/tauri/src-tauri/src/agent.rs @@ -0,0 +1,988 @@ +//! 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 }`, 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, 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( + 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 owned by the caller. +#[tauri::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 owned by the caller, and its process tree. Repeated +/// calls are no-ops. +#[tauri::command] +pub async fn agent_close(webview: tauri::Webview, connection_id: String) -> Result<(), String> { + close_connection(webview.label(), &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, 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( + window: &str, + 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()); + } + 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}"), + } + }); + + if registry.connections.contains_key(&connection_id) { + return Err(format!("Agent connection {connection_id} is already open.")); + } + let handle = AgentHandle::open(launch, emit)?; + registry.connections.insert( + connection_id.clone(), + Connection { + window: window.to_owned(), + handle, + }, + ); + Ok(AgentConnectionInfo { + connection_id, + workspace_path: request.workspace_path, + }) +} + +/// 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 = 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) +} + +/// 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) +} + +/// 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. + 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. Dropping the handle detaches the task. +pub fn close_window_connections(window: &str) { + 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())); + } +} + +/// 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. Connections a destroyed +/// window already handed to the blocking pool are waited for afterwards, up to +/// `CLOSING_WAIT`. +pub fn shutdown() { + // 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(), + }; + 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)] +mod tests { + use super::*; + #[cfg(windows)] + use std::path::{Path, PathBuf}; + 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) { + // 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)); + } + } + + 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(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(), + ) + } + + /// 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 { + 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. + #[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 { + 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-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-window", "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-window-failure", "test-failed-launch"); + // A late close must stay a no-op instead of touching a newer connection. + close("test-window-failure", "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-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(); + open_connection( + "test-window-commands", + request("test-malformed-command"), + sink, + ) + .expect("open should succeed before the launch fails"); + + 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-window-commands", "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", + "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", + "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"); + } + + /// 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"); + } +} diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 86414f585..9a4d455aa 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; @@ -102,6 +103,7 @@ fn main() { }) .on_window_event(|window, event| { if matches!(event, tauri::WindowEvent::Destroyed) { + agent::close_window_connections(window.label()); core::close_ide_hosts(window.label()); project_windows::release_window(window.app_handle(), window.label().to_owned()); if let Some(watcher) = window.try_state::>() { @@ -117,6 +119,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, @@ -218,6 +223,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-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 6535b8f99..72adc7bb5 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,6 +13,7 @@ pub async fn platform_invoke( command: String, mut args: Value, git_events: Option, + agent_events: Option, git_execution: Option, ) -> Result { let ide_owner = if command == "ideHost.control" && args["action"] == "open" { @@ -23,7 +26,10 @@ pub async fn platform_invoke( 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 @@ -49,35 +55,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); } let response = lithe_core::execute_json(&request); if let Some((app, owner)) = ide_owner { @@ -147,6 +147,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, @@ -803,6 +865,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/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..2bbaca82e --- /dev/null +++ b/windows/tauri/src/features/agent/components/agent-composer.tsx @@ -0,0 +1,465 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import { + AGENT_FILE_DROP_EVENT, + type AgentFileDropDetail, +} from "@/features/file-system/utils/file-system-drop-controller"; +import { extractDroppedFilePaths } from "@/features/file-system/utils/file-system-dropped-paths"; +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 { + CheckIcon, + FileTextIcon, + PaperclipIcon, + PaperPlaneTiltIcon, + SlidersHorizontalIcon, + SquareIcon, + XIcon, +} from "@/ui/icons"; +import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"; +import { Spinner } from "@/ui/spinner"; +import { cn } from "@/utils/cn"; +import { useAgentSnapshot } from "../hooks/use-agent-connection"; +import { + agentMentionQuery, + removeAgentMention, + type AgentMentionQuery, +} from "../services/agent-composer-mentions"; +import { chooseAgentFiles } from "../services/agent-file-picker"; +import { agentConnection } from "../stores/agent-connection-service"; +import { agentFileUri } from "../types/agent-file-uri"; +import { + addFileReferences, + AgentFileReferenceError, + type AgentConversation, + type AgentFileReference, +} from "../types/agent.types"; +import { AgentBrandIcon } from "./brand/agent-brand-icon"; +import { AgentSessionSelectors } from "./agent-session-selectors"; +import { AgentContextUsageView, AgentSubscriptionQuotaView } 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; +} + +/** Shorten a file name in the middle, as macOS `.truncationMode(.middle)`. */ +function middleTruncate(name: string, maxLength = 28): string { + if (name.length <= maxLength) return name; + const keep = maxLength - 1; + return `${name.slice(0, Math.ceil(keep / 2))}…${name.slice(name.length - Math.floor(keep / 2))}`; +} + +function uriPath(uri: string): string { + try { + const path = decodeURIComponent(new URL(uri).pathname); + return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; + } catch { + return uri; + } +} + +interface AgentComposerProps { + conversation: AgentConversation | null; + agentName: string | null; + /** False while the conversation cannot accept a prompt yet. */ + canSend: boolean; + /** Why sending is refused, shown when the user tries anyway. */ + blockedReason: string; + onOpenSettings: () => void; + onError: (message: string | null) => void; +} + +/** + * Context strip, flexible writing area and a compact command bar, as + * `AgentComposerView` on macOS. Typing is always allowed; sending validates + * the setup and reports what is missing instead of hiding the composer. + */ +export function AgentComposer({ + conversation, + agentName, + canSend, + blockedReason, + onOpenSettings, + onError, +}: 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 [isFocused, setIsFocused] = useState(false); + const [isHovering, setIsHovering] = useState(false); + const [isDropTargeted, setIsDropTargeted] = useState(false); + const [mention, setMention] = useState(null); + const [fileQuery, setFileQuery] = useState(""); + const textareaRef = useRef(null); + const rootRef = useRef(null); + + const isResponding = conversation?.isResponding === true; + const isCancelling = conversation?.isCancelling === true; + const isConfiguring = conversation?.pendingConfigToken != null; + const hasContent = draft.trim().length > 0 || files.length > 0; + const sessionID = snapshot.selectedSessionID; + + // A different conversation starts with no attachments, as on macOS. + useEffect(() => { + if (sessionID !== null) setFiles([]); + }, [sessionID]); + + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + const attach = useCallback( + (paths: string[]) => { + if (paths.length === 0) return; + try { + setFiles((current) => addFileReferences(current, paths.map(agentFileUri))); + onError(null); + textareaRef.current?.focus(); + } catch (error) { + onError(error instanceof AgentFileReferenceError ? error.message : String(error)); + } + }, + [onError], + ); + + // OS drops in WebView2 and project-tree drags arrive as this event instead. + useEffect(() => { + const root = rootRef.current; + if (root === null) return; + const handleRoutedDrop = (event: Event) => { + attach((event as CustomEvent).detail?.paths ?? []); + }; + root.addEventListener(AGENT_FILE_DROP_EVENT, handleRoutedDrop); + return () => root.removeEventListener(AGENT_FILE_DROP_EVENT, handleRoutedDrop); + }, [attach]); + + const handleDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + setIsDropTargeted(false); + attach(extractDroppedFilePaths(event.dataTransfer)); + }, + [attach], + ); + + const chooseFiles = useCallback(() => { + void chooseAgentFiles() + .then(attach) + .catch((error: unknown) => onError(error instanceof Error ? error.message : String(error))); + }, [attach, onError]); + + const pickMention = useCallback( + (path: string) => { + attach([path]); + // The `@query` text that opened the picker is replaced by the reference. + if (mention !== null) setDraft((current) => removeAgentMention(current, mention)); + setMention(null); + setFileQuery(""); + }, + [attach, mention], + ); + + const send = useCallback(() => { + if (!hasContent || isResponding) return; + if (!canSend) { + onError(blockedReason); + return; + } + try { + agentConnection().send( + draft, + files.map((file) => file.uri), + ); + setDraft(""); + setFiles([]); + setMention(null); + onError(null); + } catch (error) { + onError(error instanceof Error ? error.message : String(error)); + } + }, [blockedReason, canSend, draft, files, hasContent, isResponding, onError]); + + const matches = useMemo(() => { + if (mention === null) return []; + 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, mention, projectFiles]); + + const options = conversation?.configOptions ?? []; + const usesSubscription = snapshot.usesSubscription; + + return ( +
+
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onDrop={handleDrop} + onDragOver={(event) => event.preventDefault()} + onDragEnter={(event) => { + if (!Array.from(event.dataTransfer.types).includes("Files")) return; + event.preventDefault(); + setIsDropTargeted(true); + }} + onDragLeave={(event) => { + // Moving between children also fires dragleave on the container. + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; + setIsDropTargeted(false); + }} + onKeyDown={(event) => { + if (event.key === "Escape" && isResponding) agentConnection().cancel(); + }} + > +
+ + + + {activeFilePath === null ? null : ( + + )} + + {usesSubscription ? ( + + ) : null} +
+ + {files.length === 0 ? null : ( +
+ {files.map((file) => ( + + + {middleTruncate(file.name)} + + + ))} +
+ )} + + {mention === null ? null : ( +
+ {matches.length === 0 ? ( +
+ {t("agent.composer.noFiles")} +
+ ) : ( + matches.map((file) => ( + + )) + )} +
+ )} + +