From d42d0b4036ef58fcb846f219a0d686082cd0fc78 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 1 Oct 2026 10:30:02 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E5=A4=8D=20Claude?= =?UTF-8?q?=20=E9=89=B4=E6=9D=83=E3=80=81=E4=BC=9A=E8=AF=9D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=92=8C=E5=93=81=E7=89=8C=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 Claude 适配器公开会话参数传递 API Key 路由,同时覆盖新建与历史恢复,清除冲突鉴权并保留 Codex gateway 行为。 直接观察所选 Agent 连接,等待会话设置确认后展示模型,防止旧配置闪现及准备期间提交。 统一底栏、模型入口与 Agent 菜单品牌图标,保留原生单选勾选,并补充回归、共享契约、Agent Note 与功能矩阵。 --- ...026-09-25-shared-acp-agent-conversation.md | 25 +- docs/development/platform-parity-matrix.csv | 5 +- docs/development/platform-parity-matrix.md | 13 +- macos/Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../Lithe/Views/Agent/AgentComposerView.swift | 47 ++- .../Views/Agent/AgentConversationView.swift | 57 +++- .../Lithe/Views/Agent/AgentPanelStyle.swift | 48 ++- .../Views/Agent/AgentSessionSelectors.swift | 6 +- .../AgentBrandIconResourceTests.swift | 39 +++ .../AgentConversationSelectionTests.swift | 281 ++++++++++++++++++ rust/lithe-agent-host/src/catalog.rs | 4 +- rust/lithe-agent-host/src/lib.rs | 28 +- rust/lithe-agent-host/src/session_defaults.rs | 8 +- rust/lithe-agent-host/src/session_routing.rs | 57 ++++ rust/lithe-agent-host/src/tests.rs | 97 +++++- rust/lithe-agent-host/tests/real_agent.rs | 69 +++-- shared/contracts/application-boundary.md | 2 +- shared/contracts/rust-core-api.md | 15 +- shared/fixtures/agent/acp-events-v1.json | 15 + shared/platform-feature-matrix.json | 83 +++++- 21 files changed, 818 insertions(+), 83 deletions(-) create mode 100644 macos/Tests/LitheTests/AgentConversationSelectionTests.swift create mode 100644 rust/lithe-agent-host/src/session_routing.rs diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index 19dd4d4dc..4356aa2fb 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 @@ -17,12 +17,13 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 ## 决策 - +- **Agent 菜单标志与勾选**:输入框的 Agent 下拉菜单为每个选项显示 16 点品牌图标,复用底栏同一套只读资源与内存染色。原生单选控件(Picker)将当前选项的勾选状态与品牌图像分别呈现,避免选中项用勾选图标替换品牌。控件直接绑定面板已有的选择值与切换动作,不在 View 里再存一份当前 Agent;图标和勾选以实际选择为准,不改变连接、会话或权限语义。 - **侧栏入口图标**:Agent 使用 Lithe 原创的“对话气泡 + 星光”线性 SVG,由工作台 renderer 显式绑定,避免落入通用模块图标而让用户误认成插件管理。图标作为固定源资源随应用打包,复用现有模板着色与选中态;不下载、不生成运行时文件,不影响安装包的只读边界。面板内 Codex/Claude 标志仍表示具体供应商。 - **安装包图标解析**:Agent 品牌图标从应用的 `Contents/Resources/Lithe_Lithe.bundle` 只读加载;资源包或图标缺失时使用默认标志。不要在已安装的 `.app` 中直接调用 SwiftPM(Swift 包管理器)生成的 `Bundle.module`:它只查应用旁的资源包和编译目录,找不到会直接终止进程,开发机器残留的构建资源还会掩盖问题。开发和测试入口保留 SwiftPM 回退;安装版不使用编译目录,也不通过启动时复制资源来修补发行包,避免改变代码签名和 Sparkle 增量更新基线。 +- **底栏品牌标志**:参考 [CC GUI 的紧凑入口](https://github.com/zhukunpenglinyutong/jetbrains-cc-gui/blob/7bcd6c3803ec44944cef90f5bff0aa666cab99a2/webview/src/components/ChatInputBox/selectors/ProviderSelect.tsx) 与[品牌组件](https://github.com/zhukunpenglinyutong/jetbrains-cc-gui/blob/7bcd6c3803ec44944cef90f5bff0aa666cab99a2/webview/src/components/shared/ProviderModelIcon.tsx),Claude 使用彩色星芒,Codex 使用主题单色结形标志。上游 Web 入口为 16 像素;Lithe 原生工具栏使用 18 点视觉尺寸、28 点点击区域,模型入口保留较小的同品牌标志。Claude 的 `#D97757` 与既有 `@lobehub/icons 5.8.0` 彩色组件一致。轮廓复用既有 SVG,欢迎区和历史页继续使用模板着色。SwiftUI 的前景色在原生 Menu 转换图像时可能丢失,因此底栏通过只读 SVG 的透明度蒙版在内存中生成带颜色的原始图像,不依赖原生控件替模板补色。图像缓存身份包含资源包、尺寸和颜色,深浅主题切换不会拿到旧色,也不修改其他视图共用的模板。正确做法是按品牌和主题选择颜色;不要复制 SVG 路径、改写源图或把染色图写进 bundle。该派生图像只在应用内存中存活,不新增磁盘缓存、下载或跨工作树复用资源,不影响代码签名和 Sparkle delta。 - **共享实现**:`rust/lithe-agent-host` 使用官方 `agent-client-protocol` SDK。一个 `AgentHandle` 对应一个项目的 Agent 进程和 ACP 连接,负责初始化、网关登录、会话新建/列出/加载、消息、权限、取消和进程树清理。Mac 通过 Rust Core C ABI(`lithe_agent_open_json`、`lithe_agent_send_json`、`lithe_agent_close`)调用;Windows 以后直接依赖同一个 crate。命令和事件的 JSON 形状由 `shared/fixtures/agent/acp-events-v1.json` 固定。 - **按需启动,跟着项目走**:`LitheAgentConversationModule` 是内置可选模块,默认禁用。每个项目有自己的模块运行时,所以会话天然属于项目。切换标签或窗口不会结束任何会话,后台项目的这一轮会继续跑完;只有关闭项目、关闭功能或退出应用时才停止 Agent。后台项目的会话在等待权限时,项目标签上会显示提醒点。 -- **API Key 模式显式登录**:初始化时声明 `auth._meta.gateway = true`,然后只用 `gateway` 方式登录,把服务商地址和 `Authorization: Bearer ` 放进 `authenticate` 请求,经 stdio 传给 Agent。Key 不进命令行参数、环境变量或文件,Lithe 也不设置 `APP_SERVER_LOGS`。Agent 不提供 `gateway` 登录时直接报错,不会退而使用它的账号登录。Codex 使用 Responses,Claude 使用 Anthropic Messages;二者均不静默回退到账户订阅。 +- **API Key 按上游公开接口传递**:凭据始终通过 ACP 的标准输入输出(stdio)通道传给适配器,不进入 Lithe 启动适配器的命令行参数、环境变量或文件,也不设置 `APP_SERVER_LOGS`。Codex 使用 Responses,在初始化时声明 `auth._meta.gateway = true`,通过 `gateway` 登录传服务商地址和 `Authorization: Bearer `;没有该登录能力就报错。Claude 使用 Anthropic Messages,通过上游公开的 `_meta.claudeCode.options` 在 `session/new` 与 `session/load` 同时传入地址、API Key 和模型;由上游适配器与 Claude SDK 管理其内部原生凭据转换。SDK 的环境配置和程序化 settings 两层都显式指定同一路由,并清除旧 Bearer、OAuth、自定义请求头、云平台路由与 `apiKeyHelper`,防止用户或项目设置覆盖所选服务商。缺少凭据时直接报错,二者都不静默回退到账户订阅。 - **Codex 订阅是 Agent 绑定方式**:供应商管理增加“Codex 订阅”选项,但不往提交信息共用的 HTTP 服务商注册表写一个假供应商。已有配置缺少认证类型时仍解释为 API Key;订阅绑定不保存 provider ID,启动时不读取该服务商的 Key、URL 或默认模型。只在当前子进程中指定官方 `openai` 路由并清除继承的 API 覆盖,会话配置同时清空 `openai_base_url` 并指定官方 `chatgpt_base_url`,防止本机旧地址继续覆盖订阅路由;额度探测也使用同样的覆盖。保留用户的 `CODEX_HOME`、MCP 和 Skills。本机 Codex 拥有账号存储和刷新,Lithe 不读取或复制其订阅凭据。已有账号直接复用;未登录先提示,用户点击才通过 ACP `chat-gpt` 方法打开官方浏览器登录。等待有五分钟期限,取消停止连接并停留在可重试状态,避免 idle 空态自动重连;认证通知与请求响应先后不作为成功依据,必须等上游确认账号。账号退出或邮箱改变时断开旧连接,不静默改计费来源。订阅错误不附加未经脱敏的上游 stderr,因为 Lithe 并不知道其凭据值。 - **额度复用官方查询能力**:核对 [codex-acp v1.13.1](https://github.com/agentclientprotocol/codex-acp/blob/v1.13.1/src/CodexAcpServer.ts) 的扩展方法后,确认它提供连接级认证状态推送,但没有结构化额度接口;`/status` 只有显示文本,token 统计也不是订阅窗口。因此共享 host 短暂启动已检测到的本机 `codex app-server`,用官方 `account/rateLimits/read` 查询,禁止创建线程或发送 prompt。查询前后读取账号并与 ACP 提供的邮箱比较;没有邮箱时不能确认归属,只显示未知。上游 ACP 没有稳定账户 ID,同邮箱切换组织仍是当前身份校验的限制。每连接最多一分钟一次、不并发,查询二十秒超时;连接取消或查询结束清理整个进程树。将来 ACP 暴露结构化额度后用同一连接替换此探测,不长期维护第二套认证或私有 HTTP API。 - **额度显示和资源边界**:参考 PR #870 保留真实窗口时长、未知不当零、失败显示过期的原则,不整合其独立页面和日志扫描。输入框上下文栏右侧显示使用率最高的窗口,例如 `5h · 已用 68%`;悬停列出各额度窗口、重置与更新时间。窗口按实际上报时长标注,primary 可以是七天。面板可见且应用活跃时每分钟刷新,回复结束也请求一次受限刷新;历史页、隐藏面板停止定时请求。短暂失败保留并灰显旧值,断连与身份失败清除旧额度。API Key 模式没有额度查询。快照只在连接内存中存活;未增加下载、缓存文件或可复用工作树资源,CLI 仍使用自身用户目录,安装目录和发行 bundle 只读,不影响代码签名或 Sparkle delta。 @@ -32,6 +33,7 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **上下文用量以上游为准**:输入框顶部参考 CC GUI 显示圆环与整数百分比,悬停显示一位小数百分比及已用/总容量 token。提示复用工作台已有的悬停浮层与面板局部 scope(浮层可绘制的范围),圆环和百分比共用完整命中区域,鼠标进入立即显示在指示器上方,移开或面板退出时关闭;不依赖系统原生 `.help` 的延迟提示,避免宿主视图内看不到详情。使用现有 ACP `usage_update.used/size`,共享 host 已按 SDK 原样转发,不另接 Codex 私有事件、不扫描历史文件、不根据文字长度或模型名推算。计费用量是累计消耗,不是当前上下文容量,不能混用。用量按会话保存,每条有效更新替换之前值,压缩后允许下降;没有上报、容量为零或无效数据在会话状态中仍保持未知;展示层按 CC GUI 使用 0% 占位,悬停仅提示“上下文: 0.0%”,真实零使用量也使用相同简短提示,不编造已用 token 或总容量。断连、确认模型改变、重新加载会话清除旧数据,权限或思考选项改变不清除。超过容量时保留上游数值与百分比,仅圆环限制为一整圈。数据只是内存会话状态,不写入 bundle 或新增缓存资源。 - **停止必须等上游确认**:只发送一次 `session/cancel`,撤销本轮权限请求,界面进入“正在停止”。收到原 prompt 的结束响应后才能发送下一轮。旧方案只屏蔽迟到的 prompt 响应,却不能阻止上游把新消息并入旧轮次,也不能识别没有轮次编号的迟到通知。因此改为十秒确认期限:超过期限则明确报错、停止该 Agent 的进程树,保留界面记录,用户重连后通过 `session/load` 恢复。正常取消不重启进程。这不是伪装成正常结束,用户会看到恢复原因;同一 Agent 进程里的其他会话也会断开,不能静默自动重试消息。 - **会话配置由上游提供**:面板连接完成后准备空会话,让用户发第一条消息前就能选择模型、权限模式、思考强度。选项、分组和当前值均来自 `session/new`、`session/load`、配置更新通知及 `session/set_config_option` 的响应;不硬编码模型列表。每次启动 Agent 连接前,通过既有本机配置端口刷新已绑定导入服务商的默认模型,避免旧导入值(例如已移除的模型名)再次成为所有新会话的默认值;只刷新模型,不改提交信息的服务商选择、手动服务商、端点或凭据。模型读取独立于完整 API 配置:Codex 没有自定义服务商或 API Key 时仍能读取顶层 `model`,不能把其他 TOML 表里的同名字段当成默认模型;凭据校验失败时也更新面板的模型回退显示。API Key 模式仍校验凭据;订阅模式绕过这条服务商模型同步路径,使用 Codex 自己的模型目录。仅刷新本机配置仍不够:codex-acp 1.13.1 会把不在实际模型目录中的配置值临时补进选项。共享 host 保留上游同时返回的旧式模型目录,并协商其版本化推荐值扩展(`jetbrains.air.recommendedValue`);只有当前值确实不在目录中、推荐值同时存在于目录和选项中时,新会话才通过标准 ACP 配置请求切到该推荐值。等待上游确认后再发布会话,两次请求共用创建会话的有界超时。配置中有效的模型、历史会话和用户文件不被自动改写;扩展或目录缺失时保留标准 ACP 行为,不猜默认模型、不按版本名或描述筛选。ACP 模型菜单使用该 Agent 已有的品牌 SVG,不使用通用 CPU 图标;显示标签仍由上游确认的当前值决定。请求完成前禁止重复配置和发送,失败保留之前确认的值并显示错误。适配器没暴露的配置不画假控件,也不替用户改变网关地址或全局 CLI 配置。 +- **会话配置准备期间的显示**:本地供应商默认模型只是启动输入,不是当前会话的已确认模型。切换到尚未初始化的 Agent 时,工具栏从连接启动、准备空会话到默认模型修正完成,连续显示“正在加载会话设置”;加载历史也等待该会话自己的配置,不先闪现本地默认模型或另一个会话的选项。进程报告 ready 只表示连接可用,不能当成模型目录已就绪。已有连接和已确认会话在来回切换时直接复用,不额外加载;创建或加载失败、需要登录、连接失败以及上游不提供配置时退出加载状态,保留真实错误和既有默认模型回退。发送按钮和回车发送都遵循准备状态。这是原生界面根据现有连接事件推导的显示行为,不新增 ACP 状态、模型缓存、资源目录或运行时 bundle 写入。 - **工具证据与文档保护**:工具详情合并上游的部分更新,展示类型、输入、输出、文件位置和修改前后文本;权限卡复用已收到的工具证据,区分允许和拒绝。显示文本每段限制 32 Ki 字符,内容和位置各限制 100 条,避免大工具输出堵住界面;截断处标记 `[...]`。点击项目内文件交给现有编辑器导航,不在视图中直接读写文件。磁盘刷新、脏缓冲区保护和 Git diff 沿用文档/Git 模块。Agent 从项目目录启动,不需要先附加当前文件才能读写磁盘;未保存的编辑器快照是后续独立能力。 - **未发送的空会话不能当成历史恢复**:为了提前显示配置,界面会先创建空会话,但 Codex 在首条消息前还没有把会话内容写入磁盘(rollout),旧进程关闭后用原 ID 加载会报 `no rollout found`。连接模型只跟踪本连接新建、未成功提交消息且未出现在上游历史中的会话;断开时移除这些会话的本地标签和临时记录,重连后通过原有准备流程新建。已有消息、上游列出或成功加载的会话继续恢复,不根据“当前消息列表为空”推断历史不存在,也不按错误字符串给真实历史自动新建替身。这个操作不删除用户的上游历史文件;代价是未发送会话的临时配置选择需要在新连接重新确认。 - **历史加载失败不清空记录**:回放时保留原会话快照,成功后使用上游回放,失败或连接退出时恢复之前记录并丢弃不完整的回放片段。旧连接事件任务被取消后不再消费缓冲事件。 @@ -46,7 +48,7 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **CLI 更新保留安装来源**:以 PATH 中实际命令及其真实文件为准,不能仅看到用户装了 npm 就把所有 CLI 交给 npm。Homebrew 通过自己报告的 Cellar/Caskroom 位置和已安装记录确认归属,保留 cask/formula 及 `claude-code@latest` 等渠道;npm 必须确认当前 global root、包名、bin 声明和链接都指向同一 CLI,另一套 Node 环境不能代更新;Claude 标准原生 launcher 使用上游 `claude update`。未知来源、损坏链接、缺少原安装器或安装记录时拒绝自动覆盖,明确提供手动指引。更新后重新读取登录 shell 的 PATH 并验证最低版本;更新命令退出成功但实际 CLI 仍过旧也应失败。安装器可能在首次下载失败后重试成功,却保留非零退出状态,因此正常结束的命令无论退出状态如何,都要检查实际版本。只有新安装或数字版本严格提升且达到最低要求时,才能把非零退出降为“已成功、带警告”,返回有界日志并在界面折叠展示;原本可用但版本没变、降级、仍过旧或找不到命令时继续报错。不能根据日志中的“successfully upgraded”字样猜测成功,也不能把取消、超时或启动失败改判成功。读取来源只使用有界的本地查询,不更新包管理器索引、不改变用户配置。Homebrew 和原生下载归原安装器拥有,仅显示“正在更新”与耗时,不伪造字节进度;它们的全局安装和缓存不注册成可复制的工作树构建资源,排除清单与测试同步维护。 - **下载进度以 npm 的真实传输为准**:安装与 CLI 升级通过现有 Core 事件回调报告已接收软件包字节数、最近采样速度、耗时和等待时间。npm 没有提供整次安装的总量,且会继续发现依赖,所以不显示总体百分比。内嵌的 Node 观察模块只统计 HTTP 响应进入流缓冲区的字节,不添加消费数据的监听器,也不重写下载、代理、重试、校验或缓存行为。模块通过内存中的 data URL 加载,启动后先恢复用户原有 `NODE_OPTIONS`,防止 npm 子脚本继承观察器;不生成辅助文件或新的可复用缓存。正确做法是显示“已下载 25 MB、75 KB/秒、已用时 300 秒”;不要把 npm 静默时的日志时间或整个共享缓存大小当成下载进度。Core 事件只携带数字和阶段,界面按操作标识丢弃迟到事件,完成、失败或取消后清除进度。 - **Rust Core 命令**:`agent.status`、`agent.install`、`agent.uninstall`、`agent.installCli`,复用现有信封的取消和超时。 - - **Key 和模型的传法**:API Key 模式的适配器通过 ACP `gateway` 登录,Key 经 stdio 传给 Agent,请求头按协议选择:Responses 协议用 `Authorization: Bearer`,Anthropic 协议用 `x-api-key`。模型按适配器分别传:Codex 用 `CODEX_CONFIG`,Claude 用 `ANTHROPIC_MODEL`。服务商配置里的"模型"必须传给 Agent:实测某个网关禁用了 Codex 的默认模型,不传模型时 Agent 只会回复一条网关报错。 + - **Key 和模型的传法**:API Key 经 ACP stdio 传给适配器。Responses 使用 `gateway` 的 `Authorization: Bearer`;Claude 使用公开会话参数中的原生 API Key 配置,由 SDK 发出 `x-api-key`。Codex 的模型用 `CODEX_CONFIG`,Claude 的模型用 `ANTHROPIC_MODEL` 并在会话 options 中显式提供。服务商配置里的"模型"必须传给 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 文件,也不改变提交信息使用的服务商选择。 - **关闭时仍能找到入口**:工作台始终保留 Agent 的静态入口声明,首次安装没有模块配置、手动关闭后也能打开面板。模块仍默认禁用,入口读取不调用 factory,不创建连接;禁用模块的通用贡献目录和其他模块的入口规则保持原样。未配置的面板立即显示原因和“打开 Agent 设置”操作,用户在面板内开启开关,无需修改本机配置文件。开启后复用运行时入口,按 ID 避免重复;关闭后保留面板和入口。不要把默认开关改为启用来修复发现入口的问题,否则会改变所有用户的后台启动行为。 @@ -54,6 +56,7 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **面板始终显示完整布局**:功能关闭或没有配置 Agent 时,面板照样显示会话标题、消息区和输入框,用户可以输入;发送时先校验(功能是否开启、是否有已配置的 Agent、模块是否启动完成),不通过就在输入框上方给出提示并提供进入设置的按钮,不会启动任何进程。 - **文件拖入对话框**:参考 CC GUI 普通文件的路径引用交互,原生输入框接受 Finder 和项目树的文件 URL,显示可移除标签,并支持文件选择器。一次最多 32 个引用,按拖入顺序去重;切换会话清除旧文件草稿,发送失败保留文字和引用。功能模型把引用与文字一起排队,Rust Host 用上游 ACP SDK 的 `resource_link` 发送,Agent 自己读取文件并遵守现有权限。文件 URL 是这次消息的原生上下文,不作为跨平台项目标识持久化。正确做法是发送“说明这两个文件”加两个引用;不要在 View 里读文件、复制进缓存或拼装隐式文件内容提示词。图片目前同样作为文件引用,没有缩略图或多模态字节上传;这避免了与本次需求无关的内存、大小和格式策略,后续需要图片输入时再按上游能力协商。 - **对话面板**:停靠在编辑器右侧,与 Maven 共用右侧槽位。输入框下方可以切换 Agent,列出的是已经绑定服务商或 Codex 订阅的 Agent;打开的会话以标签形式排列。每个 Agent 第一次被选中时才建立连接;项目里所有 Agent 的权限提醒会合并显示到项目标签上。 +- **选择状态由面板直接观察**:切换 Agent 时,解析当前连接的视图必须直接观察 `AgentConversationFeatureModel`,连接内的模型与权限则由 `AgentConnectionModel` 发布。不能只观察外层 AppModel 再读取可选模块,否则选择已经改变,品牌、模型列表与发送目标仍可能停留在旧连接。正确做法是让 `AgentConfiguredConversationView` 持有观察关系,并按 Agent ID 重建连接视图;每个 Agent 的原连接和会话继续保留。不要靠通知整个工作台或重启进程来刷新选择,这会增加无关重绘或中断后台会话。代价是视图明确分为项目选择与连接内容两层;新增菜单必须继续使用同一选择源。该修复只调整内存观察,不新增文件、缓存或下载资源,也不写发行 bundle。 - **对话区的视觉与交互**:按 CC GUI 参考设计使用单行会话标题、居中的品牌标志、上下文栏和底部工具栏。只有多会话或用户主动展开时才显示标签条,避免空会话标题重复。空态不放进滚动列表,否则无法在剩余高度内居中。输入区复用 `LitheSplitPaneView`,尺寸约束跟随可用高度,拖动只更新局部容器。搜索只筛选当前已加载消息,不请求 Agent 或改动历史。品牌 SVG 来自参考项目锁定的 `@lobehub/icons` 5.8.0,以静态源资源随 SwiftPM bundle 打包并携带 MIT 许可,不新增运行时下载或可复用缓存。配置选择器按参考项目的底栏设计:权限与模型入口位于输入区底部,模型弹出面板提供本地名称/ID/分组搜索、品牌图标、蓝色选中行和绿色勾选,不显示模型说明;权限弹出面板保留上游说明并本地化已有文案。思考强度、速度及其他上游配置放到模型面板底部的子菜单,未提供时不显示。选项数量和权限语义仍以 ACP 为准,不照截图补选项。选择后收起面板,原确认/失败状态机继续拥有当前值,等待确认时禁止再次配置。弹出与搜索状态只在视图内保存,会话切换重建选择器以清除旧会话的弹出状态;不重建输入框或清空草稿。使用 macOS 原生 popover 管理定位和关闭,设置子选项在同一个 popover 内展开侧栏,避免嵌套 popover 关闭父面板;不另写鼠标全局监听或位置缓存。上下文百分比使用 Agent 上报的用量,缺失时展示 0% 占位与“上下文: 0.0%”简短提示;占位不作为已上报用量写回会话状态。 - **进程与诊断**:启动时把 Agent 可执行文件所在目录放到 `PATH` 最前面。npm 把 `codex-acp` 和 `node` 装在同一个目录,而 Mac 图形应用拿不到登录 shell 的 `PATH`。关闭时先向整棵进程树发 SIGTERM,并记录树里的每个进程 ID,稍等后再对仍存活的进程发 SIGKILL,因为 codex-acp 的 app-server 会比外层进程晚几秒退出。API Key 模式下 Agent 意外退出时,报错里附上 stderr 最后 20 行,其中的 Key 会被替换掉;订阅模式仅显示受控错误,不附加账号工具的原始输出。 @@ -101,6 +104,16 @@ Zed 就是这么做的。但会装 Agent 的用户本机通常已经有 Node。 定时重发取消针对单个适配器缺陷,不能成为通用协议策略,因此不采用。单纯超时结束并清空会话会丢上下文,也不采用。当前选择保留记录、显式报错并重连加载的有界恢复:代价是重启连接,但能保证下一轮不与未结束的旧轮混在一起。 +### Claude 使用通用 gateway、手工修改适配器或另建 HTTP 代理 + +Claude ACP 0.81.2 的 gateway 模式会将 `ANTHROPIC_AUTH_TOKEN` 设成占位值 `acp-proxy`;0.84.0 仍使用同样逻辑。原生 CLI 请求头实验显示它与真实 `x-api-key` 同时发送,而部分服务商优先校验 Bearer,结果是有效密钥也收到 401。额外自定义 Authorization 未能覆盖占位值,单纯升级同样无法消除此原因。 + +上游已经公开 `NewSessionMeta.claudeCode.options`,且恢复会话同样接受它。实测不走 gateway、通过这条接口传 SDK 原生 API Key 配置时,同一固定适配器能够完成回复,只发正确的 x-api-key。因此采用此接口完整复用上游 Agent、权限、工具、取消和历史;不修改下载后的第三方文件、不维护供应商专用分支、不增加本地 HTTP 代理。代价是凭据路由需要在每次新建和恢复会话时携带,升级适配器时必须验证这条公开扩展。 + +正确做法:在共享 Host 的 `session_routing` 中生成两层一致的会话配置,并让新建、历史恢复共用它。不要只清理启动环境或只给 session/new 配置;用户 settings 后加载会覆盖环境,历史恢复也会重新创建 SDK Query。手工修改用户级 npm 包会被重装覆盖,新增代理则增加本可避免的监听端口和生命周期,不采用。 + +路由仅在连接内存中保存,不产生下载、缓存或生成资源,工作树复用清单无需新增资源;不写发行 bundle 或安装目录,不影响代码签名和 Sparkle delta。 + ### 环境变量传 Key、或把 `CODEX_HOME` 指到 Lithe 目录 `OPENAI_API_KEY` 这类环境变量只适用于官方地址,也更容易泄露。隔离 `CODEX_HOME` 会让 Agent 读不到用户自己的 MCP 服务器、Skills 和全局指令;而一旦设置了网关,请求本来就不会用到用户的账号登录,所以没有必要隔离。 @@ -127,14 +140,18 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 验证 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentConversationSelectionTests`:原生 SwiftUI 宿主不替换根视图,直接改变模块选择,验证 Agent、模型与连接立即同步;配置确认前保留旧值、确认后刷新,来回切换保持各自配置,删除当前 Agent 时正确回退,关闭全部测试窗口和连接。显式控制切换→连接→ready→创建确认及历史加载的事件顺序,比较原生工具栏,验证准备期间改变本地默认模型不影响显示、过期响应不能解除等待、已确认连接复用、失败和无配置能力不会一直加载。2026-10-01 修复版真实窗口已验证 Codex 初始化加载提示到 Astra/审批控件、Astra→Luna→Astra 模型切换和重新展开的勾选、模型名称搜索与空结果、Claude/Codex 来回切换保留配置,Codex 最小消息收到预期回复。关闭测试项目后确认无 Agent 子进程,990 个 bundle 文件哈希不变;Windows 对话 UI 与运行验收仍待完成。 + - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentConversationFeatureModelTests|AgentConversationPresentationTests'`:注入可推进的单调时钟验证排队、后台会话、取消确认、失败和断连;共享 fixture 校验用量、零值与缺失值,统计行的分组边界与历史回放不伪造。真实 Agent 的 token 口径和连续计时仍需人工验收,不能以合成数据截图代替供应商运行验证。 -- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentBrandIconResourceTests`:临时安装包布局覆盖 Codex/Claude 图标加载、资源包和图标缺失时安全回退、缓存隔离,比较读取前后的文件清单与内容,确认不改写发行资源。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentBrandIconResourceTests`:临时安装包布局覆盖 Codex/Claude 图标加载、资源包和图标缺失时安全回退、缓存隔离,比较读取前后的文件清单与内容,确认不改写发行资源;原生输入框 Menu 的像素验证同时覆盖 Claude/Codex 和深浅主题,确保橙色/灰色标志实际可见,染色不改变共用模板。 - 订阅新增测试覆盖旧配置兼容、显式登录、已有账号、认证通知顺序、登录取消和超时、账号变更、额度真实窗口/缺失值/多 bucket、仅查询不发送 prompt,以及临时失败保留旧值。Linux 已运行 Agent Host 的逐测试计时套件;macOS Swift 编译、真实账号登录及深浅主题/窄宽布局仍须在目标环境验证,不将代码存在等同于运行验证。 - 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 不出现订阅入口。Windows 已有共享协议和 host,但订阅选择和额度 UI 待接入。 - `cargo test -p lithe-agent-host --manifest-path rust/Cargo.toml` - `cargo test -p lithe-core agent`(`agent.*` 命令与 `shared/fixtures/agent/agent-management-v1.json`) +- Claude 路由回归使用 `shared/fixtures/agent/acp-events-v1.json` 的 `upstream.claudeSessionRouting`,覆盖凭据缺失不回退、冲突鉴权清理、新建和历史恢复都带路由、Codex gateway 不变;真实验收通过 `LITHE_ACP_E2E_AGENT_ID=claude-acp` 选择已安装的 Claude 适配器,验证回复、读文件、修改、执行测试与追问。 +- 真实模型切换集成测试必须选择不同的上游模型值,并在确认后发送最小消息,不能仅对当前值做一次成功往返。后续选项取自最新确认的目录;例如 Claude 切到 Haiku 后不再提供思考强度,应验证该能力消失,不向它发送旧目录里的 effort 选项。 - 真实 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 功能模型测试读取。 - `macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift` 使用真实模块运行时覆盖默认关闭、启用和再次关闭,验证静态入口及 action/renderer 绑定可用、不重复、不提前调用 factory,其他关闭模块不会跟着暴露。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index e208969af..569a92e0f 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -6,7 +6,10 @@ ide-mcp-connection,AI,IDE MCP,从设置侧栏独立的 MCP 配置页按项目开 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-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 鉴权、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 端到端仍待验证;Claude API Key 鉴权与完整工具流程由独立能力行记录。 安装包环境下打开 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-toolbar-brand-marks,AI,Agent 对话,Agent 品牌标志:Claude 橙色星芒、Codex 随主题变化的灰色结形图标,底栏切换入口、Agent 下拉菜单条目与模型入口保持清晰一致,已实现,已验证,未实现,待验证,Agent,macOS:原生真实输入框的 Menu 像素测试通过四种品牌/深浅主题组合,共用模板图像保持不变;87 项相关 Agent 测试通过。打包预览应用的深色界面实际展开 Agent 与模型菜单,确认底栏图标居中、点击区域一致、模型小标志同步;Agent 菜单中 Claude 与 Codex 都带对应品牌图标,Claude 选中与切到 Codex 后的勾选均与品牌图标并列正确显示,模型与权限跟随所选 Agent。菜单验证前后 bundle 的 990 个文件清单及 SHA-256 一致,旧版应用与辅助进程已退出,仅保留用户新版主应用及项目正常运行的进程。Windows 对话 UI 和验收待完成。,,macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift; macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentBrandIconResourceTests.swift,shared/contracts/application-boundary.md +agent-connection-model-switching,AI,Agent 对话,Agent 与模型切换:所选连接即时同步,准备期间明确等待会话设置,确认后显示模型,来回切换保留各自会话,已实现,已验证,未实现,待验证,Agent,macOS:不触发 AppModel 通知或替换宿主根视图,切换 Claude/Codex,确认品牌、当前连接和模型列表立即同步;切到未准备的连接时保持会话设置加载提示,不闪现本地保存的默认模型;进程 ready 后继续等待会话确认,过期响应不解除等待;加载历史等待其配置,创建/加载/连接失败和不提供配置能力时退出加载,回车与发送按钮都禁止准备期间发送;已准备的连接来回切换不重复加载。选择模型后等待上游确认,重新展开检查勾选,来回切换确认模型和会话分别保留;搜索名称/ID与空结果,当前 Agent 移除后正确回退,实际模型切换后发送最小消息验证回复。Windows 对话 UI 与验收待完成。,2026-10-01 macOS:原生 SwiftUI 回归、真实 Claude Sonnet→Haiku 切换及回复通过;修复版真实窗口验证 Codex 初始化加载提示→确认后的 Astra/审批控件、Astra→Luna→Astra 切换与重新展开勾选、名称搜索/空结果、Claude/Codex 往返保留配置,并收到 Codex 最小消息回复。测试项目关闭后无 Agent 子进程,990 个 bundle 文件哈希不变。Windows 对话 UI 与验收仍待完成。,macos/Sources/Lithe/Views/Agent/AgentConversationView.swift; macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentConversationSelectionTests.swift; rust/lithe-agent-host/tests/real_agent.rs,shared/contracts/application-boundary.md +agent-claude-api-key-authentication,AI,Agent 对话,Claude API Key 鉴权:通过公开会话参数使用所选供应商,清除冲突占位 Bearer,新建与历史恢复保持地址、密钥和模型,已实现,已验证,部分实现,待验证,Agent,macOS:使用支持 Anthropic Messages 的供应商,在 Claude 新会话中验证正常和流式回复、读取临时文件、修改错误实现、执行两项 Node 断言并追问;验证停止、重连、历史加载后仍使用原供应商并记住上下文。用本地假服务观察同版原生 CLI 与固定 ACP 适配器请求仅含有效 x-api-key、没有占位 Bearer;新建与恢复共用 fixture 配置,环境与程序化 settings 都清除旧鉴权和路由;关闭项目后检查子进程退出与 bundle 哈希不变。Windows 共享 Host 已具备路由修复,对话 UI 与真实 Windows 验收仍待完成。,,rust/lithe-agent-host/src/session_routing.rs; rust/lithe-agent-host/src/tests.rs; rust/lithe-agent-host/tests/real_agent.rs; shared/fixtures/agent/acp-events-v1.json; .agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md,rust/lithe-agent-host/src/session_routing.rs; shared/contracts/rust-core-api.md 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 diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 80d8ef0d0..5f3b54f0b 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-29 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:115 -- macOS:实现:✅ 101 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 104 待验证,— 11 不适用 -- Windows:实现:✅ 98 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 1 平台专属;验证:✔️ 0 已验证,🔍 109 待验证,— 6 不适用 +- 功能项:118 +- macOS:实现:✅ 104 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 3 已验证,🔍 104 待验证,— 11 不适用 +- Windows:实现:✅ 98 已实现,🟡 12 部分实现,❌ 7 未实现,🧩 1 平台专属;验证:✔️ 0 已验证,🔍 112 待验证,— 6 不适用 ## 实现状态定义 @@ -30,7 +30,7 @@ > 每一行对应一个可以单独验收的用户能力;区域和功能组只用于导航,不作为状态统计单位。单元格第一行是实现状态,第二行是验证状态。
-AI · 19 个能力点 +AI · 22 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | @@ -41,7 +41,10 @@ | 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 对话 | **可选 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 API Key 鉴权与完整工具流程由独立能力行记录。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 | | +| Agent 对话 | **Agent 品牌标志:Claude 橙色星芒、Codex 随主题变化的灰色结形图标,底栏切换入口、Agent 下拉菜单条目与模型入口保持清晰一致**
agent-toolbar-brand-marks | ✅ 已实现
✔️ 已验证
`macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift`、`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentBrandIconResourceTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | macOS:原生真实输入框的 Menu 像素测试通过四种品牌/深浅主题组合,共用模板图像保持不变;87 项相关 Agent 测试通过。打包预览应用的深色界面实际展开 Agent 与模型菜单,确认底栏图标居中、点击区域一致、模型小标志同步;Agent 菜单中 Claude 与 Codex 都带对应品牌图标,Claude 选中与切到 Codex 后的勾选均与品牌图标并列正确显示,模型与权限跟随所选 Agent。菜单验证前后 bundle 的 990 个文件清单及 SHA-256 一致,旧版应用与辅助进程已退出,仅保留用户新版主应用及项目正常运行的进程。Windows 对话 UI 和验收待完成。 | | +| Agent 对话 | **Agent 与模型切换:所选连接即时同步,准备期间明确等待会话设置,确认后显示模型,来回切换保留各自会话**
agent-connection-model-switching | ✅ 已实现
✔️ 已验证
`macos/Sources/Lithe/Views/Agent/AgentConversationView.swift`、`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentConversationSelectionTests.swift`、`rust/lithe-agent-host/tests/real_agent.rs` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | macOS:不触发 AppModel 通知或替换宿主根视图,切换 Claude/Codex,确认品牌、当前连接和模型列表立即同步;切到未准备的连接时保持会话设置加载提示,不闪现本地保存的默认模型;进程 ready 后继续等待会话确认,过期响应不解除等待;加载历史等待其配置,创建/加载/连接失败和不提供配置能力时退出加载,回车与发送按钮都禁止准备期间发送;已准备的连接来回切换不重复加载。选择模型后等待上游确认,重新展开检查勾选,来回切换确认模型和会话分别保留;搜索名称/ID与空结果,当前 Agent 移除后正确回退,实际模型切换后发送最小消息验证回复。Windows 对话 UI 与验收待完成。 | 2026-10-01 macOS:原生 SwiftUI 回归、真实 Claude Sonnet→Haiku 切换及回复通过;修复版真实窗口验证 Codex 初始化加载提示→确认后的 Astra/审批控件、Astra→Luna→Astra 切换与重新展开勾选、名称搜索/空结果、Claude/Codex 往返保留配置,并收到 Codex 最小消息回复。测试项目关闭后无 Agent 子进程,990 个 bundle 文件哈希不变。Windows 对话 UI 与验收仍待完成。 | +| Agent 对话 | **Claude API Key 鉴权:通过公开会话参数使用所选供应商,清除冲突占位 Bearer,新建与历史恢复保持地址、密钥和模型**
agent-claude-api-key-authentication | ✅ 已实现
✔️ 已验证
`rust/lithe-agent-host/src/session_routing.rs`、`rust/lithe-agent-host/src/tests.rs`、`rust/lithe-agent-host/tests/real_agent.rs`、`shared/fixtures/agent/acp-events-v1.json`、`.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/session_routing.rs`、`shared/contracts/rust-core-api.md` | Agent | macOS:使用支持 Anthropic Messages 的供应商,在 Claude 新会话中验证正常和流式回复、读取临时文件、修改错误实现、执行两项 Node 断言并追问;验证停止、重连、历史加载后仍使用原供应商并记住上下文。用本地假服务观察同版原生 CLI 与固定 ACP 适配器请求仅含有效 x-api-key、没有占位 Bearer;新建与恢复共用 fixture 配置,环境与程序化 settings 都清除旧鉴权和路由;关闭项目后检查子进程退出与 bundle 哈希不变。Windows 共享 Host 已具备路由修复,对话 UI 与真实 Windows 验收仍待完成。 | | | 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 端到端待验证。 | | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 355fc7a60..5c1835ef7 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1281,6 +1281,7 @@ "Repository needs attention" = "Repository needs attention"; "Codex subscription" = "Codex subscription"; +"Loading session settings…" = "Loading session settings…"; "Use your local ChatGPT account. No API key or URL is needed." = "Use your local ChatGPT account. No API key or URL is needed."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 2e8b7b013..3202a7275 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2518,6 +2518,7 @@ "Repository needs attention" = "仓库需要处理"; "Codex subscription" = "Codex 官方订阅(本机账号)"; +"Loading session settings…" = "正在加载会话设置…"; "Use your local ChatGPT account. No API key or URL is needed." = "使用本机已登录的 ChatGPT 账号,无需 API Key 或 URL。"; diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift index 1df5e9fc8..a03136fb1 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift @@ -15,6 +15,7 @@ struct AgentComposerView: View { let onError: (String?) -> Void var configOptions: [AgentSessionConfigOption] = [] var sessionID: String? + var isPreparingSession = false var isConfiguring = false var isCancelling = false var contextUsage: AgentContextUsage? @@ -108,7 +109,14 @@ struct AgentComposerView: View { .buttonStyle(AgentToolbarButtonStyle()) .help("Agent Settings") agentMenu - if !configOptions.isEmpty { + if isPreparingSession { + Label("Loading session settings…", systemImage: "hourglass") + .font(.system(size: 11)) + .foregroundStyle(AgentPanelStyle.secondary) + .lineLimit(1) + .padding(.horizontal, 4) + .accessibilityIdentifier("agent-session-settings-loading") + } else if !configOptions.isEmpty { AgentSessionSelectors( options: configOptions, agentName: selectedAgent?.name, @@ -117,10 +125,9 @@ struct AgentComposerView: View { ) .id(sessionID ?? selectedAgent?.id) if isConfiguring { ProgressView().controlSize(.mini) } - } - if configOptions.isEmpty, let model = selectedAgent?.modelName, !model.isEmpty { + } else if let model = selectedAgent?.modelName, !model.isEmpty { HStack(spacing: 5) { - AgentBrandIcon(name: selectedAgent?.name, size: 12) + AgentBrandIcon(name: selectedAgent?.name, size: 12, style: .brand) Text(model).lineLimit(1).truncationMode(.middle) } .font(.system(size: 11)) @@ -138,7 +145,7 @@ struct AgentComposerView: View { } .buttonStyle(.litheNoPress) .lithePointer() - .disabled(isCancelling || (!isResponding && (!hasContent || isBlocked || isConfiguring))) + .disabled(isCancelling || (!isResponding && (!hasContent || isBlocked || isPreparingSession || isConfiguring))) .help(isCancelling ? "Stopping…" : (isResponding ? "Stop" : "Send")) } .padding(.horizontal, 5) @@ -149,20 +156,31 @@ struct AgentComposerView: View { private var agentMenu: some View { Menu { - if agents.isEmpty { Text("No Agent is set up yet") } - ForEach(agents) { agent in - Button { onSelectAgent(agent.id) } label: { - if agent.id == selectedAgent?.id { - Label(agent.name, systemImage: "checkmark") - } else { - Text(agent.name) + if agents.isEmpty { + Text("No Agent is set up yet") + } else { + // The native picker owns the selection checkmark separately + // from each item's brand image. + Picker("Choose an Agent", selection: Binding( + get: { selectedAgent?.id }, + set: { if let id = $0 { onSelectAgent(id) } } + )) { + ForEach(agents) { agent in + Label { + Text(agent.name) + } icon: { + AgentBrandIcon(name: agent.name, size: 16, style: .brand) + } + .tag(Optional(agent.id)) } } + .pickerStyle(.inline) + .labelsHidden() } Divider() Button("Agent Settings…", action: onOpenSettings) } label: { - AgentBrandIcon(name: selectedAgent?.name, size: 16) + AgentBrandIcon(name: selectedAgent?.name, size: 18, style: .brand) .foregroundStyle(AgentPanelStyle.secondary) .frame(width: 28, height: 28) } @@ -171,6 +189,7 @@ struct AgentComposerView: View { .fixedSize() .help(selectedAgent?.name ?? String(localized: "Choose an Agent")) .accessibilityLabel("Switch Agent") + .accessibilityIdentifier("agent-composer-agent-selector") } private func addFiles(_ urls: [URL]) -> Bool { @@ -188,7 +207,7 @@ struct AgentComposerView: View { private func send() { guard hasContent, !isResponding else { return } - if isBlocked { + if isBlocked || isPreparingSession { onError(String(localized: "The conversation is still being prepared. Try again in a moment.")) return } diff --git a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift index f01e37aa5..f2535fb3e 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift @@ -18,19 +18,16 @@ struct AgentConversationView: View { feature: model.agentManagementFeature, onDone: { showsSettings = false } ) - } else if let feature, let connection = feature.selectedConnection, let agentID = feature.selectedAgentID { - AgentConnectionView( - feature: connection, - history: feature.history(for: agentID), - agents: feature.agents, - selectedAgentID: feature.selectedAgentID, + } else if let feature { + AgentConfiguredConversationView( + feature: feature, + setupError: model.agentConversationSetupError, onSelectAgent: { model.selectAgentConversationAgent($0) }, onConnect: { model.connectAgentConversation() }, onOpenSettings: { showsSettings = true }, onCopySessionID: { model.copyAgentSessionID($0) }, onOpenFile: { model.openAgentFile($0) } ) - .id(feature.selectedAgentID) } else { AgentUnconfiguredConversationView( setupError: model.agentConversationSetupError, @@ -44,6 +41,37 @@ struct AgentConversationView: View { } } +/// Observe selection where the selected connection is resolved. AppModel does +/// not forward this optional module's changes, and each connection owns its UI. +struct AgentConfiguredConversationView: View { + @ObservedObject var feature: AgentConversationFeatureModel + let setupError: AgentConversationError? + let onSelectAgent: (String) -> Void + let onConnect: () -> Void + let onOpenSettings: () -> Void + let onCopySessionID: (String) -> Void + let onOpenFile: (AgentToolDetails.Location) -> Void + + var body: some View { + if let connection = feature.selectedConnection, let agentID = feature.selectedAgentID { + AgentConnectionView( + feature: connection, + history: feature.history(for: agentID), + agents: feature.agents, + selectedAgentID: agentID, + onSelectAgent: onSelectAgent, + onConnect: onConnect, + onOpenSettings: onOpenSettings, + onCopySessionID: onCopySessionID, + onOpenFile: onOpenFile + ) + .id(agentID) + } else { + AgentUnconfiguredConversationView(setupError: setupError, onOpenSettings: onOpenSettings) + } + } +} + /// Title on the left and icon actions on the right, like a chat client's /// session header: new conversation, history, settings. private struct AgentPanelHeader: View { @@ -131,6 +159,20 @@ private struct AgentConnectionView: View { private var selectedAgent: AgentOption? { agents.first { $0.id == selectedAgentID } } + /// A saved provider model is not the current session's confirmed model. + /// Include the ready-to-create gap, but stop waiting on failure or sign-in. + private var isPreparingSession: Bool { + switch feature.connectionState { + case .idle, .connecting: + return true + case .ready: + return feature.isCreatingSession || feature.selectedConversation?.isLoading == true + || (feature.selectedSessionID == nil && feature.errorMessage == nil) + case .authenticationRequired, .authenticating, .failed: + return false + } + } + var body: some View { ZStack { conversation @@ -218,6 +260,7 @@ private struct AgentConnectionView: View { onError: { localError = $0 }, configOptions: feature.selectedConversation?.configOptions ?? [], sessionID: feature.selectedSessionID, + isPreparingSession: isPreparingSession, isConfiguring: feature.selectedConversation?.pendingConfigToken != nil, isCancelling: feature.selectedConversation?.isCancelling == true, contextUsage: feature.selectedConversation?.contextUsage, diff --git a/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift b/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift index f01612380..4702ee991 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift @@ -17,6 +17,14 @@ enum AgentPanelStyle { static let versionText = adaptive(dark: 0xddd6fe, light: 0x6d28d9) static let versionAccent = Color(red: 139 / 255, green: 92 / 255, blue: 246 / 255) + static func brandTint(for name: String?, isDark: Bool) -> UInt32? { + switch name?.lowercased() { + case "claude", "claude code": 0xd97757 + case "codex": isDark ? 0xcccccc : 0x333333 + default: nil + } + } + private static func adaptive(dark: UInt32, light: UInt32) -> Color { Color(nsColor: NSColor(name: nil) { appearance in let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light @@ -30,17 +38,25 @@ enum AgentPanelStyle { } } -/// Template SVGs use the same vendor silhouettes at welcome and toolbar sizes. +/// Reuse the vendor silhouettes; toolbar marks retain their color in native menus. struct AgentBrandIcon: View { + enum Style { case template, brand } + let name: String? var size: CGFloat = 16 + var style: Style = .template + @Environment(\.colorScheme) private var colorScheme + + private var tint: UInt32? { + style == .brand ? AgentPanelStyle.brandTint(for: name, isDark: colorScheme == .dark) : nil + } var body: some View { Group { - if let image = AgentBrandIconLoader.image(name: name, size: size) { + if let image = AgentBrandIconLoader.image(name: name, size: size, tint: tint) { Image(nsImage: image) .resizable() - .renderingMode(.template) + .renderingMode(tint == nil ? .template : .original) .aspectRatio(contentMode: .fit) } else { Image(systemName: "sparkles") @@ -59,11 +75,13 @@ enum AgentBrandIconLoader { let bundleURL: URL let filename: String let size: Int + let tint: UInt32? } private static var images: [CacheKey: NSImage] = [:] static func image( name: String?, size: CGFloat = 64, + tint: UInt32? = nil, resourceBundle: Bundle? = resolveResourceBundle() ) -> NSImage? { let filename: String @@ -73,15 +91,33 @@ enum AgentBrandIconLoader { default: return nil } guard let resourceBundle else { return nil } - let key = CacheKey(bundleURL: resourceBundle.bundleURL, filename: filename, size: max(1, Int(size.rounded()))) + let key = CacheKey(bundleURL: resourceBundle.bundleURL, filename: filename, + size: max(1, Int(size.rounded())), tint: tint) if let image = images[key] { return image } guard let url = resourceBundle.url(forResource: filename, withExtension: "svg", subdirectory: "AgentIcons"), let image = NSImage(contentsOf: url) else { return nil } image.isTemplate = true // Native Menu labels read NSImage.size rather than the SwiftUI frame. image.size = NSSize(width: key.size, height: key.size) - images[key] = image - return image + let rendered: NSImage + if let tint { + let color = NSColor(srgbRed: CGFloat((tint >> 16) & 255) / 255, + green: CGFloat((tint >> 8) & 255) / 255, + blue: CGFloat(tint & 255) / 255, alpha: 1) + // SwiftUI foreground styles can be lost when Menu converts its label + // to AppKit. Color the SVG's alpha mask in memory and keep it original. + rendered = NSImage(size: image.size, flipped: false) { rect in + image.draw(in: rect, from: .zero, operation: .copy, fraction: 1) + color.setFill() + rect.fill(using: .sourceIn) + return true + } + rendered.isTemplate = false + } else { + rendered = image + } + images[key] = rendered + return rendered } nonisolated static func resolveResourceBundle( diff --git a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift index e26f085db..8001be1c5 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift @@ -74,6 +74,7 @@ struct AgentSessionSelectors: View { .help(AgentSessionSelectorPresentation.localized(mode.name)) .accessibilityLabel(Text("Approval mode")) .accessibilityValue(AgentSessionSelectorPresentation.currentTitle(mode)) + .accessibilityIdentifier("agent-session-mode-selector") .popover(isPresented: $showsModes, arrowEdge: .top) { AgentModePopover(option: mode) { value in select(mode.id, value) } .onExitCommand { showsModes = false } @@ -82,7 +83,7 @@ struct AgentSessionSelectors: View { if let model { Button { showsModels.toggle() } label: { selectorLabel { - AgentBrandIcon(name: agentName, size: 12) + AgentBrandIcon(name: agentName, size: 12, style: .brand) Text(modelSummary(model)) } } @@ -90,6 +91,7 @@ struct AgentSessionSelectors: View { .help(model.currentLabel) .accessibilityLabel(Text("Model")) .accessibilityValue(model.currentLabel) + .accessibilityIdentifier("agent-session-model-selector") .popover(isPresented: $showsModels, arrowEdge: .top) { AgentModelPopover(option: model, settings: settings, agentName: agentName, onSelect: select) .onExitCommand { showsModels = false } @@ -210,7 +212,7 @@ struct AgentModelPopover: View { .padding(.horizontal, 12).padding(.vertical, 4) } AgentSelectorRow(isSelected: choice.id == option.currentValue, action: { onSelect(option.id, choice.id) }) { - AgentBrandIcon(name: agentName, size: 16) + AgentBrandIcon(name: agentName, size: 16, style: .brand) Text(choice.name).lineLimit(1).truncationMode(.middle) } } diff --git a/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift b/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift index 4e85e96d6..04f429c60 100644 --- a/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift +++ b/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift @@ -1,10 +1,49 @@ import AppKit +import SwiftUI import Testing @testable import Lithe @MainActor @Suite("Agent brand icon resources") struct AgentBrandIconResourceTests { + @Test(arguments: ["Claude", "Codex"], [false, true]) + func composerBrandMarksStayVisibleInNativeMenus(name: String, isDark: Bool) throws { + let host = NSHostingView(rootView: AgentComposerView( + agents: [.init(id: "example-agent", name: name)], + selectedAgent: .init(id: "example-agent", name: name), + isResponding: false, isBlocked: false, onSend: { _, _ in }, onCancel: {}, + onSelectAgent: { _ in }, onOpenSettings: {}, onError: { _ in } + ).environment(\.colorScheme, isDark ? .dark : .light)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 200), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua) + defer { window.close() } + window.contentView = host + host.layoutSubtreeIfNeeded() + + // Crop the real native Menu label, excluding neighboring controls and + // the text-field caret. No visible window or image baseline is required. + let region = NSRect(x: 46, y: host.isFlipped ? host.bounds.height - 42 : 10, width: 28, height: 26) + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: region)) + host.cacheDisplay(in: region, to: bitmap) + var markPixels = 0 + for y in 0.. 0.5, red > green * 1.3, green > blue * 1.1 { markPixels += 1 } + } else if abs(red - green) < 0.05, abs(green - blue) < 0.05 { + if isDark ? min(red, green, blue) > 0.6 : max(red, green, blue) < 0.4 { markPixels += 1 } + } + } + } + #expect(markPixels > 10, "The native selector must show a visible orange Claude or contrasting Codex mark") + #expect(try #require(AgentBrandIconLoader.image(name: name, size: 18)).isTemplate, + "Coloring the toolbar must not mutate the shared template used by other views") + } + @Test func installedAppLoadsBothMarksWithoutDevelopmentResources() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/macos/Tests/LitheTests/AgentConversationSelectionTests.swift b/macos/Tests/LitheTests/AgentConversationSelectionTests.swift new file mode 100644 index 000000000..a992cdc4b --- /dev/null +++ b/macos/Tests/LitheTests/AgentConversationSelectionTests.swift @@ -0,0 +1,281 @@ +import AppKit +import SwiftUI +import Testing +import LitheCoreContracts +@testable import Lithe +@testable import LitheAgentConversationModule + +@MainActor +@Suite(.serialized) +struct AgentConversationSelectionTests { + @Test + func preparingSessionSettingsHidesSavedModelsUntilTheSessionConfirms() async throws { + let transport = SelectionTransport() + let panel = AgentConversationFeatureModel(transport: transport) + func agents(_ model: String) -> [AgentOption] { + [.init(id: "claude-acp", name: "Claude"), .init(id: "codex-acp", name: "Codex", modelName: model)] + } + panel.setAgents(agents("Saved model")) + do { + _ = try prepare(panel, transport: transport, agentID: "claude-acp", model: "Claude model") + let host = hostingView(panel) + let window = hiddenWindow(host) + defer { window.close() } + host.layoutSubtreeIfNeeded() + let claudeToolbar = try toolbarPixels(in: host) + + // Hold each ACP boundary explicitly: first selection, process ready, + // pending session (including default repair), then the confirmed catalog. + panel.selectAgent("codex-acp") + host.layoutSubtreeIfNeeded() + let loadingToolbar = try toolbarPixels(in: host) + #expect(loadingToolbar != claudeToolbar) + panel.setAgents(agents("Another saved model")) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar, "Unconfirmed saved models must not flash") + + let codex = panel.connection(for: "codex-acp") + try connect(codex, agentID: "codex-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar) + codex.receive(try json(["kind": "ready", "agentName": "Codex", "canLoadSessions": true])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar, "Process readiness does not confirm session settings") + codex.prepareConversation() + let token = try #require(transport.connections.last?.commands.last?["token"]) + codex.receive(try json(["kind": "sessionCreated", "sessionId": "codex-session", "token": "stale", + "configOptions": options(model: "Wrong model", current: "default")])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar) + codex.receive(try json(["kind": "sessionCreated", "sessionId": "codex-session", "token": token, + "configOptions": options(model: "Confirmed model", current: "default")])) + host.layoutSubtreeIfNeeded() + let confirmedToolbar = try toolbarPixels(in: host) + #expect(confirmedToolbar != loadingToolbar) + #expect(codex.selectedConversation?.configOptions.first?.currentLabel == "Confirmed model") + panel.setAgents(agents("Saved model")) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == confirmedToolbar, "Confirmed choices remain authoritative") + + panel.selectAgent("claude-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == claudeToolbar) + panel.selectAgent("codex-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == confirmedToolbar, "Prepared connections do not reload on every switch") + #expect(transport.connections.count == 2) + + codex.selectSession("history-session") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar, "History waits for its own settings") + codex.receive(try json(["kind": "sessionLoaded", "sessionId": "history-session", + "token": transport.connections.last?.commands.last?["token"] as Any, + "configOptions": options(model: "History model", current: "default")])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) != loadingToolbar) + #expect(codex.selectedConversation?.configOptions.first?.currentLabel == "History model") + } catch { + await panel.stop() + throw error + } + await panel.stop() + #expect(transport.connections.allSatisfy { $0.closeCount == 1 }) + } + + @Test + func failedOrUnsupportedSettingsStopLoadingAndKeepTheConfiguredFallback() async throws { + let transport = SelectionTransport() + let panel = AgentConversationFeatureModel(transport: transport) + func agents(_ model: String) -> [AgentOption] { + [.init(id: "codex-acp", name: "Codex", modelName: model)] + } + panel.setAgents(agents("Saved model")) + do { + let codex = panel.connection(for: "codex-acp") + let host = hostingView(panel) + let window = hiddenWindow(host) + defer { window.close() } + host.layoutSubtreeIfNeeded() + let loadingToolbar = try toolbarPixels(in: host) + + codex.reportConnectionFailure("Missing API key") + host.layoutSubtreeIfNeeded() + let fallbackToolbar = try toolbarPixels(in: host) + #expect(fallbackToolbar != loadingToolbar, "Connection failures must not keep showing loading") + panel.setAgents(agents("Another saved model")) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) != fallbackToolbar, "Failed setup still shows the configured model") + panel.setAgents(agents("Saved model")) + + try connect(codex, agentID: "codex-acp") + codex.receive(try json(["kind": "ready", "agentName": "Codex", "canLoadSessions": true])) + host.layoutSubtreeIfNeeded() + codex.prepareConversation() + codex.receive(try json(["kind": "requestFailed", "message": "Session failed", + "token": transport.connections.last?.commands.last?["token"] as Any])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == fallbackToolbar, "Creation failures stop waiting") + #expect(codex.errorMessage == "Session failed") + + codex.prepareConversation() + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == loadingToolbar) + codex.receive(try json(["kind": "sessionCreated", "sessionId": "no-settings", + "token": transport.connections.last?.commands.last?["token"] as Any])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == fallbackToolbar, "An adapter without config options is ready, not loading") + #expect(codex.selectedConversation?.isAttached == true) + #expect(codex.selectedConversation?.configOptions.isEmpty == true) + + codex.selectSession("history-session") + codex.receive(try json(["kind": "requestFailed", "message": "History failed", + "token": transport.connections.last?.commands.last?["token"] as Any])) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == fallbackToolbar, "History failures stop waiting") + #expect(codex.selectedConversation?.errorMessage == "History failed") + } catch { + await panel.stop() + throw error + } + await panel.stop() + #expect(transport.connections.allSatisfy { $0.closeCount == 1 }) + } + + @Test + func selectedAgentAndConfirmedModelRefreshWithoutReplacingTheRootView() async throws { + let transport = SelectionTransport() + let panel = AgentConversationFeatureModel(transport: transport) + panel.setAgents([.init(id: "codex-acp", name: "Codex"), .init(id: "claude-acp", name: "Claude")]) + do { + let codex = try prepare(panel, transport: transport, agentID: "codex-acp", model: "Codex model") + let claude = try prepare(panel, transport: transport, agentID: "claude-acp", model: "Claude model") + let host = NSHostingView(rootView: AgentConfiguredConversationView( + feature: panel, setupError: nil, onSelectAgent: panel.selectAgent, + onConnect: {}, onOpenSettings: {}, onCopySessionID: { _ in }, onOpenFile: { _ in } + )) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 640), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + window.contentView = host + host.layoutSubtreeIfNeeded() + let codexToolbar = try toolbarPixels(in: host) + + // Only the module selection changes: no AppModel notification or + // rootView replacement may be needed to render the new connection. + panel.selectAgent("claude-acp") + host.layoutSubtreeIfNeeded() + let claudeToolbar = try toolbarPixels(in: host) + #expect(claudeToolbar != codexToolbar, "The native toolbar must render Claude and its model") + #expect(panel.selectedConnection === claude) + #expect(claude.selectedConversation?.configOptions.first?.currentLabel == "Claude model") + + claude.setConfigOption("model", value: "alternate") + let command = try #require(transport.connections[1].commands.last) + #expect(command["kind"] as? String == "setConfigOption") + #expect(claude.selectedConversation?.configOptions.first?.currentLabel == "Claude model") + claude.receive(try json(["kind": "sessionConfigured", "sessionId": "claude-acp", + "token": command["token"] as Any, + "configOptions": options(model: "Claude model", current: "alternate")])) + host.layoutSubtreeIfNeeded() + let alternateToolbar = try toolbarPixels(in: host) + #expect(alternateToolbar != claudeToolbar, "Confirmed model changes must render in the native toolbar") + #expect(claude.selectedConversation?.configOptions.first?.currentLabel == "Alternate model") + + panel.selectAgent("codex-acp") + host.layoutSubtreeIfNeeded() + #expect(panel.selectedConnection === codex) + #expect(try toolbarPixels(in: host) == codexToolbar) + panel.selectAgent("claude-acp") + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == alternateToolbar) + #expect(transport.connections.count == 2, "Switching reuses the project connections") + + panel.setAgents([.init(id: "codex-acp", name: "Codex")]) + host.layoutSubtreeIfNeeded() + #expect(try toolbarPixels(in: host) == codexToolbar) + } catch { + await panel.stop() + throw error + } + await panel.stop() + #expect(transport.connections.allSatisfy { $0.closeCount == 1 }) + } + + private func prepare(_ panel: AgentConversationFeatureModel, transport: SelectionTransport, + agentID: String, model: String) throws -> AgentConnectionModel { + let connection = panel.connection(for: agentID) + try connect(connection, agentID: agentID) + connection.receive(try json(["kind": "ready", "agentName": agentID, "canLoadSessions": true])) + connection.prepareConversation() + let native = try #require(transport.connections.last) + connection.receive(try json(["kind": "sessionCreated", "sessionId": agentID, + "token": native.commands.last?["token"] as Any, + "configOptions": options(model: model, current: "default")])) + return connection + } + + private func connect(_ connection: AgentConnectionModel, agentID: String) throws { + try connection.connect(configuration: .init( + agentID: agentID, command: "", arguments: [], workspaceURL: URL(fileURLWithPath: "/example/project"), + dataDirectory: URL(fileURLWithPath: "/example/agents"), providerProtocol: "responses", + providerEndpoint: "https://gateway.example.com/v1", apiKey: "test-key", providerName: "Example", + model: "", allowsInsecureHTTP: false + )) + } + + private func hostingView(_ panel: AgentConversationFeatureModel) -> NSHostingView { + NSHostingView(rootView: AgentConfiguredConversationView( + feature: panel, setupError: nil, onSelectAgent: panel.selectAgent, + onConnect: {}, onOpenSettings: {}, onCopySessionID: { _ in }, onOpenFile: { _ in } + )) + } + + private func hiddenWindow(_ host: NSView) -> NSWindow { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 640), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + return window + } + + private func options(model: String, current: String) -> [[String: Any]] { + [["id": "model", "name": "Model", "category": "model", "type": "select", "currentValue": current, + "options": [["value": "default", "name": model], ["value": "alternate", "name": "Alternate model"]]]] + } + + private func json(_ value: [String: Any]) throws -> String { + String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self) + } + + private func toolbarPixels(in view: NSView) throws -> Data { + // Capture only the static bottom toolbar; text-field caret blinking and + // the transcript are outside the comparison. No image files are written. + let region = NSRect(x: 16, y: view.isFlipped ? view.bounds.height - 44 : 8, + width: view.bounds.width - 32, height: 32) + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: region)) + view.cacheDisplay(in: region, to: bitmap) + let bytes = try #require(bitmap.bitmapData) + return Data(bytes: bytes, count: bitmap.bytesPerRow * bitmap.pixelsHigh) + } +} + +@MainActor +private final class SelectionTransport: AgentConversationTransport { + var connections: [SelectionConnection] = [] + func open(configuration: AgentLaunchConfiguration, onEvent: @escaping @Sendable (String) -> Void) throws -> any AgentConnection { + let connection = SelectionConnection() + connections.append(connection) + return connection + } +} + +@MainActor +private final class SelectionConnection: AgentConnection { + var commands: [[String: Any]] = [] + var closeCount = 0 + func send(commandJSON: String) throws { + commands.append(try #require(JSONSerialization.jsonObject(with: Data(commandJSON.utf8)) as? [String: Any])) + } + func close() async { closeCount += 1 } +} diff --git a/rust/lithe-agent-host/src/catalog.rs b/rust/lithe-agent-host/src/catalog.rs index 73418954b..18fe121b8 100644 --- a/rust/lithe-agent-host/src/catalog.rs +++ b/rust/lithe-agent-host/src/catalog.rs @@ -1,7 +1,7 @@ //! Agents Lithe can install and launch, and how each receives its settings. //! -//! Every adapter signs in through the ACP `gateway` method, so the user's API -//! key travels over stdio in the header of the agent's provider protocol. +//! API keys travel over ACP stdio. Codex uses `gateway` authentication; Claude +//! uses its public session options to avoid gateway placeholder credentials. //! //! Entries mirror the official ACP registry //! (`cdn.agentclientprotocol.com/registry/v1/latest/registry.json`): the same diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index aa545fbbc..afc521e0b 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -12,6 +12,7 @@ pub mod environment; pub mod install; mod prompt; mod session_defaults; +mod session_routing; mod subscription; pub use catalog::{ModelDelivery, ProviderProtocol}; @@ -52,7 +53,7 @@ const MAX_SESSION_LIST_PAGES: usize = 50; const STDERR_TAIL_BYTES: usize = 16 * 1024; const STDERR_TAIL_LINES: usize = 20; /// Auth method id and `_meta` key of the ACP custom model gateway extension. -/// API-key mode uses this method and never falls back to account login. +/// Responses API-key mode uses this method and never falls back to account login. const GATEWAY_AUTH_METHOD: &str = "gateway"; /// Launch configuration supplied by the owning desktop product. @@ -150,13 +151,15 @@ impl std::fmt::Debug for ProviderCredentials { } } -/// Gateway sign-in sent in ACP `authenticate`. +/// API-key routing sent over ACP stdio, using each adapter's supported interface. #[derive(Clone)] struct GatewaySignIn { + protocol: ProviderProtocol, base_url: String, /// Authentication headers in the dialect of the provider's protocol. headers: Vec<(String, String)>, provider_name: Option, + model: Option, } /// A validated launch: what to run and how the key reaches the agent. @@ -164,7 +167,7 @@ struct ResolvedLaunch { command: PathBuf, args: Vec, cwd: PathBuf, - /// Extra environment, used only by adapters that accept no other key input. + /// Non-secret CLI paths and model defaults for the native adapters. env: Vec<(String, String)>, gateway: Option, /// Key to redact from diagnostics. @@ -205,8 +208,8 @@ fn resolve_with( if !launch.cwd.is_absolute() { return Err("The workspace path must be absolute".into()); } - // Every adapter signs in through the ACP `gateway` method; the key travels - // over stdio in the header its protocol expects. + // Credentials travel over ACP stdio. Claude uses its public session options + // because its gateway mode adds a conflicting placeholder Bearer token. let gateway = |provider: &ProviderCredentials| -> Result { let (base_url, header) = match provider.protocol { ProviderProtocol::AnthropicMessages => ( @@ -222,9 +225,11 @@ fn resolve_with( ), }; Ok(GatewaySignIn { + protocol: provider.protocol, base_url, headers: vec![header], provider_name: provider.name.clone(), + model: provider.model.clone(), }) }; let Some(agent_id) = launch.agent_id else { @@ -829,6 +834,7 @@ where OB: futures::io::AsyncWrite + Send + 'static, IB: futures::io::AsyncRead + Send + 'static, { + let session_meta = session_routing::metadata(gateway.as_ref())?; let (auth_tx, mut auth_rx) = tokio::sync::watch::channel(None::); let turns: RunningTurns = Arc::new(Mutex::new(HashMap::new())); let updates = emit.clone(); @@ -945,7 +951,7 @@ where .await .map_err(|_| internal("The Agent did not finish initialization in time"))??; // API-key routing is explicit and never falls back to account login. - if let Some(gateway) = &gateway { + if let Some(gateway) = gateway.as_ref().filter(|route| route.protocol != ProviderProtocol::AnthropicMessages) { if !initialized .auth_methods .iter() @@ -1046,10 +1052,13 @@ where let connection = connection.clone(); let emit = emit.clone(); let cwd = cwd.clone(); + let session_meta = session_meta.clone(); tasks.spawn(async move { + let mut request = agent_client_protocol::schema::v1::NewSessionRequest::new(cwd); + request.meta = session_meta; let result = request_with_timeout( SESSION_REQUEST_TIMEOUT, - session_defaults::new_session(&connection, cwd), + session_defaults::new_session(&connection, request), ) .await; emit(match result { @@ -1066,11 +1075,14 @@ where let connection = connection.clone(); let emit = emit.clone(); let cwd = cwd.clone(); + let session_meta = session_meta.clone(); tasks.spawn(async move { + let mut request = LoadSessionRequest::new(session_id.clone(), cwd); + request.meta = session_meta; let result = request_with_timeout( LOAD_SESSION_TIMEOUT, connection - .send_request(LoadSessionRequest::new(session_id.clone(), cwd)) + .send_request(request) .block_task(), ) .await; diff --git a/rust/lithe-agent-host/src/session_defaults.rs b/rust/lithe-agent-host/src/session_defaults.rs index e2b39c8b4..b287150a4 100644 --- a/rust/lithe-agent-host/src/session_defaults.rs +++ b/rust/lithe-agent-host/src/session_defaults.rs @@ -4,8 +4,6 @@ //! Its legacy model state excludes that choice, and its versioned AIR extension //! supplies the actual recommended model. Neither fact is guessed from a name. -use std::path::PathBuf; - use agent_client_protocol::schema::v1::{ NewSessionRequest, NewSessionResponse, SessionConfigKind, SessionConfigOptionCategory, SessionConfigSelectOptions, SetSessionConfigOptionRequest, @@ -103,12 +101,10 @@ fn replacement(response: &CatalogSessionResponse) -> Option<(String, String)> { /// request deadline covers both requests; history and global CLI files stay intact. pub(crate) async fn new_session( connection: &ConnectionTo, - cwd: PathBuf, + request: NewSessionRequest, ) -> Result { let mut response = connection - .send_request(CatalogSessionRequest { - request: NewSessionRequest::new(cwd), - }) + .send_request(CatalogSessionRequest { request }) .block_task() .await?; if let Some((id, model)) = replacement(&response) { diff --git a/rust/lithe-agent-host/src/session_routing.rs b/rust/lithe-agent-host/src/session_routing.rs new file mode 100644 index 000000000..35e4756a2 --- /dev/null +++ b/rust/lithe-agent-host/src/session_routing.rs @@ -0,0 +1,57 @@ +//! Claude API-key routing through the upstream adapter's public session options. +//! +//! Credentials reach the adapter over ACP stdio, never through host launch +//! arguments, environment, or files. The adapter and Claude SDK own their native +//! credential conversion; Lithe does not patch installed third-party packages. + +use serde_json::{json, Map, Value}; + +use crate::{GatewaySignIn, ProviderProtocol}; + +/// Apply the same explicit route when creating or restoring a Claude session. +pub(crate) fn metadata( + route: Option<&GatewaySignIn>, +) -> Result>, String> { + let Some(route) = route.filter(|route| route.protocol == ProviderProtocol::AnthropicMessages) + else { + return Ok(None); + }; + let key = route + .headers + .iter() + .find(|(name, value)| name == "x-api-key" && !value.trim().is_empty()) + .ok_or("The Claude API key route is missing its credential")?; + // Gateway mode injects `Bearer acp-proxy`, even with a real x-api-key. Native + // API-key options avoid that token and retain the complete upstream agent. + let env = json!({ + "ANTHROPIC_BASE_URL": route.base_url, + "ANTHROPIC_API_KEY": key.1, + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_CUSTOM_HEADERS": "", + "CLAUDE_CODE_OAUTH_TOKEN": "", + "CLAUDE_CODE_USE_BEDROCK": "0", + "CLAUDE_CODE_USE_VERTEX": "0", + "CLAUDE_CODE_USE_FOUNDRY": "0", + "CLAUDE_CODE_USE_ANTHROPIC_AWS": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "", + "ANTHROPIC_VERTEX_BASE_URL": "" + }); + let mut options = json!({ + "env": env, + // Claude loads settings after subprocess env. Both public SDK tiers + // must carry the selected route so user/project settings cannot replace it. + "settings": {"apiKeyHelper": "", "env": env} + }); + if let Some(model) = route + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + { + options["model"] = json!(model); + } + Ok(Some(Map::from_iter([( + "claudeCode".into(), + json!({"options": options}), + )]))) +} diff --git a/rust/lithe-agent-host/src/tests.rs b/rust/lithe-agent-host/src/tests.rs index 9a7f98750..12839266a 100644 --- a/rust/lithe-agent-host/src/tests.rs +++ b/rust/lithe-agent-host/src/tests.rs @@ -26,12 +26,27 @@ fn provider() -> ProviderCredentials { fn gateway() -> Option { Some(GatewaySignIn { + protocol: ProviderProtocol::Responses, base_url: "https://gateway.example.com/v1".into(), headers: vec![("Authorization".into(), "Bearer test-key-123".into())], provider_name: Some("Example".into()), + model: None, }) } +fn claude_route() -> GatewaySignIn { + let provider: ProviderCredentials = + serde_json::from_value(fixture()["upstream"]["claudeSessionRouting"]["provider"].clone()) + .expect("Claude provider fixture"); + GatewaySignIn { + protocol: provider.protocol, + base_url: provider.anthropic_base_url().expect("valid endpoint"), + headers: vec![("x-api-key".into(), provider.api_key)], + provider_name: provider.name, + model: provider.model, + } +} + fn custom_launch(command: &str, provider: ProviderCredentials) -> AgentLaunch { AgentLaunch { agent_id: None, @@ -116,6 +131,10 @@ impl Harness { } fn start_with(subscription: bool) -> Self { + Self::start_with_route(subscription, if subscription { None } else { gateway() }) + } + + fn start_with_route(subscription: bool, route: Option) -> Self { let (client, peer) = tokio::io::duplex(64 * 1024); let (client_reader, client_writer) = tokio::io::split(client); let (peer_reader, peer_writer) = tokio::io::split(peer); @@ -125,7 +144,7 @@ impl Harness { let connection = tokio::spawn(run_connection( ByteStreams::new(client_writer.compat_write(), client_reader.compat()), std::env::temp_dir(), - if subscription { None } else { gateway() }, + route, subscription.then(|| PathBuf::from("/fixture/codex")), receiver, permissions.clone(), @@ -446,6 +465,78 @@ async fn handshake_signs_in_through_the_gateway_with_the_user_key() { assert_eq!(harness.stop().await, Ok(())); } +#[test] +fn claude_session_routing_clears_conflicting_credentials_and_preserves_sdk_options() { + let mut route = claude_route(); + let meta = session_routing::metadata(Some(&route)).unwrap().unwrap(); + assert_eq!( + json!(meta), + fixture()["upstream"]["claudeSessionRouting"]["meta"] + ); + let options = &meta["claudeCode"]["options"]; + assert!(options.get("permissionMode").is_none()); + assert!(options.get("tools").is_none()); + // No explicit model keeps the upstream default rather than inventing one. + route.model = Some(" ".into()); + let meta = session_routing::metadata(Some(&route)).unwrap().unwrap(); + assert!(meta["claudeCode"]["options"].get("model").is_none()); + assert!(session_routing::metadata(gateway().as_ref()) + .unwrap() + .is_none()); + assert!(session_routing::metadata(None).unwrap().is_none()); +} + +#[test] +fn claude_session_routing_rejects_missing_credentials_without_native_account_fallback() { + let mut route = claude_route(); + route.headers.clear(); + assert!(session_routing::metadata(Some(&route)).is_err()); + route.headers.push(("x-api-key".into(), " ".into())); + assert!(session_routing::metadata(Some(&route)).is_err()); +} + +#[tokio::test(flavor = "current_thread")] +async fn claude_new_and_restored_sessions_receive_credentials_over_stdio_without_gateway_login() { + let mut harness = Harness::start_with_route(false, Some(claude_route())); + let initialize = harness.agent.expect("initialize").await; + harness + .agent + .reply( + &initialize, + json!({ + "protocolVersion": 1, + "agentInfo": {"name": "claude-acp", "version": "fixture"}, + "agentCapabilities": {"loadSession": true}, + "authMethods": [] + }), + ) + .await; + assert!(matches!(harness.event().await, AgentEvent::Ready { .. })); + let expected = fixture()["upstream"]["claudeSessionRouting"]["meta"].clone(); + harness.send(json!({"kind": "newSession", "token": "new-claude"})); + // The very next request must be session/new, never gateway authenticate. + let created = harness.agent.expect("session/new").await; + assert_eq!(created["params"]["_meta"], expected); + harness + .agent + .reply(&created, json!({"sessionId": "claude-1"})) + .await; + assert!(matches!( + harness.event().await, + AgentEvent::SessionCreated { .. } + )); + harness.send(json!({"kind": "loadSession", "token": "load-claude", "sessionId": "claude-1"})); + let loaded = harness.agent.expect("session/load").await; + assert_eq!(loaded["params"]["_meta"], expected); + assert_eq!(loaded["params"]["sessionId"], "claude-1"); + harness.agent.reply(&loaded, json!({})).await; + assert!(matches!( + harness.event().await, + AgentEvent::SessionLoaded { .. } + )); + assert_eq!(harness.stop().await, Ok(())); +} + #[tokio::test(flavor = "current_thread")] async fn agent_without_gateway_sign_in_is_rejected_without_account_login() { let mut harness = Harness::start(); @@ -1139,8 +1230,8 @@ fn catalog_agents_resolve_to_their_install_and_key_delivery() { }, )) .unwrap(); - // Claude signs in through the gateway too, in the Anthropic dialect; the - // key never enters the adapter's environment. + // Claude uses ACP session options; the key never enters the adapter's + // launch environment or arguments. let sign_in = claude.gateway.expect("gateway sign-in"); assert_eq!(sign_in.base_url, "https://api.example"); assert_eq!( diff --git a/rust/lithe-agent-host/tests/real_agent.rs b/rust/lithe-agent-host/tests/real_agent.rs index ed00d7ac4..6596903fb 100644 --- a/rust/lithe-agent-host/tests/real_agent.rs +++ b/rust/lithe-agent-host/tests/real_agent.rs @@ -11,9 +11,10 @@ //! //! `LITHE_ACP_E2E_MODEL` optionally selects the provider model and //! `LITHE_ACP_E2E_ARGS` holds newline-separated arguments. With -//! `LITHE_ACP_E2E_DATA_DIR` set instead of a command, the Codex adapter is +//! `LITHE_ACP_E2E_DATA_DIR` set instead of a command, the catalog adapter is //! installed there with the user's npm (if missing) and launched as a catalog -//! agent, covering the one-click install path. +//! agent, covering the one-click install path. `LITHE_ACP_E2E_AGENT_ID` selects +//! the adapter and its provider protocol (defaults to `codex-acp`). use std::sync::{mpsc, Arc}; use std::time::{Duration, Instant}; @@ -129,15 +130,20 @@ fn required(name: &str) -> String { fn open(workspace: &std::path::Path) -> Session { let (sender, events) = mpsc::channel(); let data_directory = std::env::var_os("LITHE_ACP_E2E_DATA_DIR").map(std::path::PathBuf::from); + let agent_id = std::env::var("LITHE_ACP_E2E_AGENT_ID").unwrap_or_else(|_| "codex-acp".into()); + let agent = lithe_agent_host::catalog::find(&agent_id).expect("catalog E2E agent"); + let protocol = if data_directory.is_some() { + agent.protocol + } else { + ProviderProtocol::Responses + }; if let Some(data) = &data_directory { - if install::installed_version(data, lithe_agent_host::catalog::find("codex-acp").unwrap()) - .is_none() - { - install::install(data, "codex-acp", &|| false).expect("adapter installs with npm"); + if install::installed_version(data, agent).is_none() { + install::install(data, &agent_id, &|| false).expect("adapter installs with npm"); } } let launch = AgentLaunch { - agent_id: data_directory.as_ref().map(|_| "codex-acp".to_owned()), + agent_id: data_directory.as_ref().map(|_| agent_id), command: std::env::var("LITHE_ACP_E2E_COMMAND").ok(), args: std::env::var("LITHE_ACP_E2E_ARGS") .map(|args| { @@ -151,7 +157,7 @@ fn open(workspace: &std::path::Path) -> Session { data_directory, authentication: lithe_agent_host::AgentAuthentication::ApiKey, provider: Some(ProviderCredentials { - protocol: ProviderProtocol::Responses, + protocol, base_url: required("LITHE_ACP_E2E_BASE_URL"), api_key: required("LITHE_ACP_E2E_API_KEY"), name: Some("Lithe end-to-end test".into()), @@ -289,7 +295,7 @@ fn real_agent_reads_edits_tests_and_continues_in_temporary_project() { ) .unwrap(); std::fs::write(workspace.0.join("sum.test.cjs"), - "const assert = require('node:assert/strict');\nconst sum = require('./sum.cjs');\nassert.equal(sum(2, 3), 5);\nconsole.log('LITHE_TEST_PASSED');\n").unwrap(); + "const assert = require('node:assert/strict');\nconst sum = require('./sum.cjs');\nassert.equal(sum(2, 3), 5);\nassert.equal(sum(-2, 4), 2);\nconsole.log('LITHE_TEST_PASSED');\n").unwrap(); let session = open(&workspace.0); let id = session.new_session("workflow"); session.send(AgentCommand::Prompt { @@ -353,7 +359,7 @@ fn real_agent_reads_edits_tests_and_continues_in_temporary_project() { } #[test] -#[ignore = "requires a real Codex ACP agent and API key configuration"] +#[ignore = "requires a real catalog ACP agent and API key configuration"] fn real_agent_configuration_options_are_selectable() { let workspace = TemporaryProject::new(); let session = open(&workspace.0); @@ -377,23 +383,29 @@ fn real_agent_configuration_options_are_selectable() { _ => None, }, ); - let options = options.as_array().expect("Codex reports config options"); + let mut options = options + .as_array() + .expect("Agent reports config options") + .clone(); for category in ["model", "mode", "thought_level"] { - let option = options - .iter() - .find(|option| option["category"] == category) - .unwrap_or_else(|| panic!("missing category {category}")); + let option = options.iter().find(|option| option["category"] == category); + // Thinking choices are optional upstream capabilities, not a product list. + if category == "thought_level" && option.is_none() { + continue; + } + let option = option.unwrap_or_else(|| panic!("missing category {category}")); let config_id = option["id"].as_str().unwrap(); let current = option["currentValue"].as_str().unwrap(); - // Change reasoning to another supported value; model and permission - // round trips preserve the user's effective defaults without a prompt. - let value = if category == "thought_level" { + // Switch models as well as reasoning; a same-value acknowledgement + // would miss the user-visible failure. Keep the permission mode intact. + let value = if category != "mode" { option["options"] .as_array() .unwrap() .iter() .filter_map(|choice| choice["value"].as_str()) - .find(|value| *value != current) + .filter(|value| *value != current) + .next_back() .unwrap_or(current) } else { current @@ -424,6 +436,25 @@ fn real_agent_configuration_options_are_selectable() { .find(|o| o["id"] == config_id) .unwrap(); assert_eq!(actual["currentValue"], value); + println!("Configuration {category}: {current} -> {value}"); + if category == "model" { + assert_ne!( + value, current, + "the integration must switch to another model" + ); + } + // Changing the model can remove thinking options (e.g. Claude Haiku). + // Subsequent choices must come from the confirmed current catalog. + options = updated.as_array().unwrap().clone(); } + let (reason, reply) = session.prompt( + &id, + "Do not use any tools. Reply with only LITHE_MODEL_SWITCH_OK.", + ); + assert_eq!(reason, "end_turn"); + assert!( + reply.contains("LITHE_MODEL_SWITCH_OK"), + "no reply after switching: {reply:?}" + ); session.handle.close(); } diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 31fa664d0..7647d1c69 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -35,7 +35,7 @@ Missing usage and unmeasured replayed history remain unknown. See the | Git | workspace commit plans, dependency ordering, guarded steps and partial-success retry, changes, commits, branches, diffs, reviewed history actions and recovery, worktree listing and safe management, worktree-aware PR publication context, validation, and mutation results | Git executable discovery, credentials, process environment, opening checkout paths | | GitHub | remote parsing, trusted request plans, normalized branch comparisons and pull requests/reviews/comments, deterministic ordering, and stable errors | OAuth configuration, HTTPS, browser opening, and operating-system credential storage | | [AI commit messages](ai-commit.md) | provider configuration parsing, commit rules, bounded diff evidence, request plans, and response text | local configuration discovery, credentials, HTTP, cancellation, and draft UI | -| Agent conversation (ACP) | supported-agent catalog, Node.js/npm detection, adapter install with the user's npm and numbers-only live download progress, CLI provenance and owner-preserving updates, per-agent API-key and model delivery, explicit Codex subscription authentication via the installed CLI, bounded official App Server quota reads with account checks, ACP v1 connection per workspace and agent, agent-owned session history (list/load), session config options, user-selected file references as ACP resource links, streamed tool evidence, permission decisions, acknowledged cancellation with bounded recovery, and process-tree lifecycle in the shared Rust host | provider and agent settings, API-key storage, credential-independent local default-model reading through AI configuration ports, data directory, workspace selection, module enablement, conversation UI and compact subscription quota presentation, workspace/Agent-scoped local history annotations (favorites, title overrides and recoverable hidden rows), and user-selected Markdown export destinations | +| Agent conversation (ACP) | supported-agent catalog, Node.js/npm detection, adapter install with the user's npm and numbers-only live download progress, CLI provenance and owner-preserving updates, per-agent API-key and model delivery over ACP stdio (Codex gateway authentication; Claude public session options), explicit Codex subscription authentication via the installed CLI, bounded official App Server quota reads with account checks, ACP v1 connection per workspace and agent, agent-owned session history (list/load), session config options, user-selected file references as ACP resource links, streamed tool evidence, permission decisions, acknowledged cancellation with bounded recovery, and process-tree lifecycle in the shared Rust host | provider and agent settings, API-key storage, credential-independent local default-model reading through AI configuration ports, data directory, workspace selection, module enablement, conversation UI and compact subscription quota presentation, workspace/Agent-scoped local history annotations (favorites, title overrides and recoverable hidden rows), and user-selected Markdown export destinations | | [IDE capability API / MCP](ide-api/v1.md) | tool catalog, permission/argument validation, stable execution IDs, bounded output cursors and errors | explicit project authorization, live environment/Maven/Run application actions, helper discovery and writable connection storage | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index d31596308..6201aff20 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -50,9 +50,18 @@ per workspace and agent; one connection carries many conversation sessions. "baseUrl": string, "apiKey": string, "name"?: string, "model"?: string, "allowInsecureHttp"?: bool } }`. With `agentId`, the host starts the adapter installed by `agent.install` under `dataDirectory`. Omitted `authentication` -defaults to `apiKey`, which requires `provider` and signs in through -the ACP `gateway` method over stdio: Responses providers send -`Authorization: Bearer ` and Anthropic providers send `x-api-key`. The +defaults to `apiKey`, which requires `provider`. Credentials travel over ACP +stdio, never in Lithe's adapter launch arguments, environment, or files. +Responses providers use `gateway` authentication with `Authorization: Bearer +`. Claude uses the pinned adapter's public `_meta.claudeCode.options` on +both `session/new` and `session/load`: its native API-key route is supplied in +SDK `env` and programmatic `settings.env`, with `apiKeyHelper` and competing +Bearer/OAuth/custom-header/cloud routes cleared. This avoids the adapter's +gateway placeholder Bearer token overriding a valid `x-api-key`; it does not +patch the adapter or change native CLI configuration files. Missing Claude +credentials fail before session creation instead of falling back to an account. +The route fixture is `shared/fixtures/agent/acp-events-v1.json`'s +`upstream.claudeSessionRouting`. The user's own CLI is passed as `CODEX_PATH` or `CLAUDE_CODE_EXECUTABLE`, and a non-empty `model` as `CODEX_CONFIG` or `ANTHROPIC_MODEL`. Without `agentId`, `command` runs a user-provided agent that must support gateway sign-in with a Responses diff --git a/shared/fixtures/agent/acp-events-v1.json b/shared/fixtures/agent/acp-events-v1.json index acd9a305c..7cb57fbf6 100644 --- a/shared/fixtures/agent/acp-events-v1.json +++ b/shared/fixtures/agent/acp-events-v1.json @@ -101,6 +101,21 @@ "stopped": { "kind": "stopped", "message": "The Agent connection closed unexpectedly" } }, "upstream": { + "claudeSessionRouting": { + "provider": {"protocol": "anthropicMessages", "baseUrl": "https://gateway.example.com/v1/messages", "apiKey": "test-key-123", "name": "Example Claude", "model": "model-configured"}, + "meta": { + "claudeCode": { + "options": { + "model": "model-configured", + "env": {"ANTHROPIC_BASE_URL": "https://gateway.example.com", "ANTHROPIC_API_KEY": "test-key-123", "ANTHROPIC_AUTH_TOKEN": "", "ANTHROPIC_CUSTOM_HEADERS": "", "CLAUDE_CODE_OAUTH_TOKEN": "", "CLAUDE_CODE_USE_BEDROCK": "0", "CLAUDE_CODE_USE_VERTEX": "0", "CLAUDE_CODE_USE_FOUNDRY": "0", "CLAUDE_CODE_USE_ANTHROPIC_AWS": "0", "ANTHROPIC_BEDROCK_BASE_URL": "", "ANTHROPIC_VERTEX_BASE_URL": ""}, + "settings": { + "apiKeyHelper": "", + "env": {"ANTHROPIC_BASE_URL": "https://gateway.example.com", "ANTHROPIC_API_KEY": "test-key-123", "ANTHROPIC_AUTH_TOKEN": "", "ANTHROPIC_CUSTOM_HEADERS": "", "CLAUDE_CODE_OAUTH_TOKEN": "", "CLAUDE_CODE_USE_BEDROCK": "0", "CLAUDE_CODE_USE_VERTEX": "0", "CLAUDE_CODE_USE_FOUNDRY": "0", "CLAUDE_CODE_USE_ANTHROPIC_AWS": "0", "ANTHROPIC_BEDROCK_BASE_URL": "", "ANTHROPIC_VERTEX_BASE_URL": ""} + } + } + } + } + }, "promptResponseWithUsage": { "stopReason": "end_turn", "usage": { "totalTokens": 25000, "inputTokens": 18000, "outputTokens": 2000, "thoughtTokens": 1000, "cachedReadTokens": 3000, "cachedWriteTokens": 1000 } }, "staleModelSession": { "sessionId": "session-repaired", diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 3cd1e2042..5f4671042 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -245,7 +245,7 @@ "id": "agent-acp-conversation", "area": "AI", "group": "Agent 对话", - "capability": "可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理", + "capability": "可选 ACP Agent 对话:API Key 鉴权、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理", "macos": { "evidence": [ "macos/Sources/LitheAgentConversationModule", @@ -264,7 +264,86 @@ "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 端到端仍待验证;Claude API Key 鉴权与完整工具流程由独立能力行记录。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。" + }, + { + "id": "agent-toolbar-brand-marks", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 品牌标志:Claude 橙色星芒、Codex 随主题变化的灰色结形图标,底栏切换入口、Agent 下拉菜单条目与模型入口保持清晰一致", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "verified", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift", + "macos/Sources/Lithe/Views/Agent/AgentComposerView.swift", + "macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift", + "macos/Tests/LitheTests/AgentBrandIconResourceTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "pending", + "evidence": [ + "shared/contracts/application-boundary.md" + ] + }, + "owner": "Agent", + "verification": "macOS:原生真实输入框的 Menu 像素测试通过四种品牌/深浅主题组合,共用模板图像保持不变;87 项相关 Agent 测试通过。打包预览应用的深色界面实际展开 Agent 与模型菜单,确认底栏图标居中、点击区域一致、模型小标志同步;Agent 菜单中 Claude 与 Codex 都带对应品牌图标,Claude 选中与切到 Codex 后的勾选均与品牌图标并列正确显示,模型与权限跟随所选 Agent。菜单验证前后 bundle 的 990 个文件清单及 SHA-256 一致,旧版应用与辅助进程已退出,仅保留用户新版主应用及项目正常运行的进程。Windows 对话 UI 和验收待完成。" + }, + { + "id": "agent-connection-model-switching", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 与模型切换:所选连接即时同步,准备期间明确等待会话设置,确认后显示模型,来回切换保留各自会话", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "verified", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentConversationView.swift", + "macos/Sources/Lithe/Views/Agent/AgentComposerView.swift", + "macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift", + "macos/Tests/LitheTests/AgentConversationSelectionTests.swift", + "rust/lithe-agent-host/tests/real_agent.rs" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "pending", + "evidence": [ + "shared/contracts/application-boundary.md" + ] + }, + "owner": "Agent", + "verification": "macOS:不触发 AppModel 通知或替换宿主根视图,切换 Claude/Codex,确认品牌、当前连接和模型列表立即同步;切到未准备的连接时保持会话设置加载提示,不闪现本地保存的默认模型;进程 ready 后继续等待会话确认,过期响应不解除等待;加载历史等待其配置,创建/加载/连接失败和不提供配置能力时退出加载,回车与发送按钮都禁止准备期间发送;已准备的连接来回切换不重复加载。选择模型后等待上游确认,重新展开检查勾选,来回切换确认模型和会话分别保留;搜索名称/ID与空结果,当前 Agent 移除后正确回退,实际模型切换后发送最小消息验证回复。Windows 对话 UI 与验收待完成。", + "notes": "2026-10-01 macOS:原生 SwiftUI 回归、真实 Claude Sonnet→Haiku 切换及回复通过;修复版真实窗口验证 Codex 初始化加载提示→确认后的 Astra/审批控件、Astra→Luna→Astra 切换与重新展开勾选、名称搜索/空结果、Claude/Codex 往返保留配置,并收到 Codex 最小消息回复。测试项目关闭后无 Agent 子进程,990 个 bundle 文件哈希不变。Windows 对话 UI 与验收仍待完成。" + }, + { + "id": "agent-claude-api-key-authentication", + "area": "AI", + "group": "Agent 对话", + "capability": "Claude API Key 鉴权:通过公开会话参数使用所选供应商,清除冲突占位 Bearer,新建与历史恢复保持地址、密钥和模型", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "verified", + "evidence": [ + "rust/lithe-agent-host/src/session_routing.rs", + "rust/lithe-agent-host/src/tests.rs", + "rust/lithe-agent-host/tests/real_agent.rs", + "shared/fixtures/agent/acp-events-v1.json", + ".agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-agent-host/src/session_routing.rs", + "shared/contracts/rust-core-api.md" + ] + }, + "owner": "Agent", + "verification": "macOS:使用支持 Anthropic Messages 的供应商,在 Claude 新会话中验证正常和流式回复、读取临时文件、修改错误实现、执行两项 Node 断言并追问;验证停止、重连、历史加载后仍使用原供应商并记住上下文。用本地假服务观察同版原生 CLI 与固定 ACP 适配器请求仅含有效 x-api-key、没有占位 Bearer;新建与恢复共用 fixture 配置,环境与程序化 settings 都清除旧鉴权和路由;关闭项目后检查子进程退出与 bundle 哈希不变。Windows 共享 Host 已具备路由修复,对话 UI 与真实 Windows 验收仍待完成。" }, { "id": "agent-tool-activity-timeline", From 5dc54c0384daf6f58912ed98d7dbbf96d465b1e8 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 2 Oct 2026 00:10:27 +0800 Subject: [PATCH 2/2] test(git): bound graph frame sampling to visible viewports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 PR #1007 CI:原用例重复绘制完整 1000 行历史,CI 耗时 11.791 秒超过未调整的 10 秒上限。改为开头、中段、末段 40 行视口采样,校验实际像素,保留完整结构、单帧预算与 Release 基线;同步 CI 文档和中文工程笔记。 --- ...13-ci-build-cache-and-artifact-strategy.md | 19 ++++++++ docs/ci-builds.md | 5 +- .../GitGraphPerformanceBaselineTests.swift | 47 ++++++++++++++----- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md index ebc1a2269..23c3ee677 100644 --- a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md @@ -50,6 +50,16 @@ Git 性能测试增加等待时间。推送到 `main` 或手动运行时,再 组装通用 DMG。Windows PR 把前端验证与 Rust 测试放在两个独立 job 中并行 执行;Windows x64 NSIS 安装包只由 preview 和稳定版发布工作流生成。 +Git 图形的离屏帧采样保留完整 1,000 行历史,但每次只绘制 40 行的可见区域, +分别覆盖开头、中间和末尾。绘制前把该区域的坐标映射到小位图,绘制后检查 +实际有图形像素,避免中段被裁剪为空时得到虚假的快速结果。完整 1,000/5,000 +行图结构、顺序和签名仍由独立用例与 Release 验证器检查;单帧中位数和整个 +测试的时间上限保持不变。 + +正确做法:模拟滚动窗口的一帧,把可见区域作为绘制输入。不要把全部历史 +重复绘制 220 次当作单帧采样:这会把画面之外的工作混进测试总耗时,在共享 +runner 上可能出现断言都通过、计时工具仍因超过十秒而失败的情况。 + CI 缓存 Cargo fingerprints、build script outputs 和依赖 outputs,不缓存 最终可执行文件。缓存覆盖 `rust/target/macos` 的 Rust Core 和 `rust/target` 的数据库辅助 crate;缓存键必须包含运行器架构、编译器、Xcode/SDK/macOS @@ -110,11 +120,19 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 任务结束。双架构验证因此保留给真正影响包内容和构建边界的改动;开发者需要 临时安装包时使用手动工作流。 +### 提高 Git 帧采样的总时间上限 + +这能容纳重复绘制完整历史的耗时,却保留了与可见窗口不符的工作量,也削弱 +其他测试的时间门禁。因此修正采样范围与位图坐标,继续使用原有上限;完整 +图的正确性检查不减少,批量绘制不能替代可见区域的帧成本测量。 + ## 后果 - 普通 macOS Swift PR 更快得到必需检查结果;被选中的打包改动仍获得两个架构 的真实安装物。 - Git 专项验证不会再延长无关 Swift 改动的反馈时间。 +- 帧采样只绘制可见区域,减少与屏幕无关的测试工作;它不衡量完整历史的一次 + 批量导出,也不等同于需要显式开启的真实窗口帧率采样。 - Windows 前端失败与 Rust 失败可以独立、并行反馈,不再等待 NSIS 安装包。 - 缓存命中时可减少 Rust 相关重复编译,同时通过完整缓存键避免跨环境误复用。 - artifact、校验和、合并提交与 gate 结果共同提供可追溯的测试交付物。 @@ -126,6 +144,7 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 - `actionlint .github/workflows/ci-macos.yml .github/workflows/ci-windows.yml` - `./scripts/test-macos.sh` +- `./scripts/test-git-performance-baseline.sh` - `./scripts/build-macos.sh --configuration debug --triple arm64-apple-macosx` - `./scripts/build-macos.sh --configuration debug --triple x86_64-apple-macosx` - `./scripts/build-official-plugins.sh --configuration debug --triple arm64-apple-macosx` diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 5eec402cb..ce828fe20 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -97,7 +97,10 @@ change should be evaluated separately if queueing continues to dominate. 普通 `./scripts/test-macos.sh` 和 `./scripts/test-git-performance-baseline.sh` 默认跳过两个 WindowServer/display-link 真实窗口采样用例,继续运行 Git 图布局、 -离屏绘制和其他性能回归验证。真实窗口采样需要 macOS 14+ 和可用的桌面显示; +离屏绘制和其他性能回归验证。图形离屏帧采样保留完整 1,000 行历史,分别在 +开头、中间、末尾采样 40 行可见区域,并检查位图确实绘制了图形。完整图的 +结构和 Release 基线仍覆盖 1,000/5,000 行;单帧与测试总耗时上限保持不变。 +真实窗口采样需要 macOS 14+ 和可用的桌面显示; 只在专门测量滚动帧率时显式开启: ```bash diff --git a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift index 8a0550d76..4ad9656d9 100644 --- a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift +++ b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift @@ -74,17 +74,25 @@ struct GitGraphPerformanceBaselineTests { #expect(benchmark.viewportDrawReductionPercent == 97.5) } - @Test("The native graph view frame sample stays within the test budget") + @Test("The native graph view samples only a visible viewport", arguments: [0, 480, 960]) @MainActor - func nativeGraphViewFrameSample() { - let commits = SyntheticGitGraphFixture.mergeHeavy(commitCount: 1_000) + func nativeGraphViewFrameSample(startRow: Int) throws { + let rowCount = 1_000 + let viewportRowCount = 40 + let rowHeight = GitGraphGeometry.rowHeight + let commits = SyntheticGitGraphFixture.mergeHeavy(commitCount: rowCount) let layout = GitGraphLayoutService.layout(commits: commits) - let view = GitGraphNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 1_000 * 30)) - view.update(snapshot: GitGraphLayoutService.routingSnapshot(for: layout), width: 120, rowHeight: 30) - let bitmap = NSBitmapImageRep( + let view = GitGraphNSView(frame: NSRect(x: 0, y: 0, width: 120, height: CGFloat(rowCount) * rowHeight)) + view.update(snapshot: GitGraphLayoutService.routingSnapshot(for: layout), width: 120, rowHeight: rowHeight) + // Keep the complete history, but draw a viewport as AppKit does while + // scrolling. Repainting all 1,000 rows 220 times measures bulk raster + // work rather than a frame and can exceed the unchanged CI test budget. + let viewport = NSRect(x: 0, y: CGFloat(startRow) * rowHeight, + width: 120, height: CGFloat(viewportRowCount) * rowHeight) + let bitmap = try #require(NSBitmapImageRep( bitmapDataPlanes: nil, pixelsWide: 120, - pixelsHigh: 1_000 * 30, + pixelsHigh: Int(viewport.height), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, @@ -93,26 +101,38 @@ struct GitGraphPerformanceBaselineTests { bitmapFormat: [], bytesPerRow: 0, bitsPerPixel: 0 - )! - let context = NSGraphicsContext(bitmapImageRep: bitmap)! + )) + let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + context.cgContext.clear(NSRect(origin: .zero, size: viewport.size)) + // Mid-history view coordinates must land inside the viewport bitmap; + // otherwise clipping would make an empty render look artificially fast. + context.cgContext.translateBy(x: 0, y: -viewport.minY) let clock = ContinuousClock() var samples: [Double] = [] samples.reserveCapacity(10) for _ in 0..<1 { - _ = sampleFrame(view: view, context: context, clock: clock) + _ = sampleFrame(view: view, dirtyRect: viewport, context: context, clock: clock) } for _ in 0..<10 { - samples.append(sampleFrame(view: view, context: context, clock: clock)) + samples.append(sampleFrame(view: view, dirtyRect: viewport, context: context, clock: clock)) } let sorted = samples.sorted() let median = sorted[sorted.count / 2] let p95 = sorted[Int(ceil(Double(sorted.count) * 0.95)) - 1] let maximum = sorted.last ?? 0 - print("GitGraph frame sample: rows=1000, samples=10, median=\(String(format: "%.3f", median))ms, p95=\(String(format: "%.3f", p95))ms, max=\(String(format: "%.3f", maximum))ms") + print("GitGraph frame sample: rows=\(rowCount), viewportRows=\(viewportRowCount), startRow=\(startRow), samples=10, median=\(String(format: "%.3f", median))ms, p95=\(String(format: "%.3f", p95))ms, max=\(String(format: "%.3f", maximum))ms") #expect(samples.count == 10) #expect(median < 100) + let bytes = try #require(bitmap.bitmapData) + // Only inspect pixel alpha, never uninitialized row-padding bytes. + let didPaint = (0.. Double { @@ -997,7 +1018,7 @@ private func sampleFrame( NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = context for _ in 0..<20 { - view.draw(view.bounds) + view.draw(dirtyRect) } NSGraphicsContext.restoreGraphicsState() return milliseconds(clock.now - start) / 20