release: v0.92.0

This commit is contained in:
coso
2026-03-20 23:03:38 +08:00
parent 276c7eb9d5
commit 7242707293
188 changed files with 43591 additions and 13993 deletions
+136
View File
@@ -0,0 +1,136 @@
name: PR Gate
on:
pull_request:
paths:
- ".github/workflows/**"
- "package.json"
- "package-lock.json"
- "src/**"
- "src-tauri/**"
- "scripts/**"
- "eslint.config.js"
- "tsconfig.json"
- "tsconfig.node.json"
- "tailwind.config.js"
- "postcss.config.js"
- "vite.config.ts"
- "index.html"
push:
branches:
- main
paths:
- ".github/workflows/**"
- "package.json"
- "package-lock.json"
- "src/**"
- "src-tauri/**"
- "scripts/**"
- "eslint.config.js"
- "tsconfig.json"
- "tsconfig.node.json"
- "tailwind.config.js"
- "postcss.config.js"
- "vite.config.ts"
- "index.html"
workflow_dispatch:
permissions:
contents: read
env:
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
CARGO_TERM_COLOR: always
jobs:
frontend:
name: Frontend Verify
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Run Vitest
run: npm test
bridge:
name: Bridge & Contracts Verify
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Run bridge tests
run: npm run test:bridge
- name: Run command contracts
run: npm run test:contracts
rust:
name: Rust Verify
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Linux dependencies
run: |
sudo apt update
sudo apt install -y \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libasound2-dev
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
shared-key: pr-gate-rust-ubuntu
cache-on-failure: true
- name: Run cargo test
run: cargo test --manifest-path "src-tauri/Cargo.toml"
- name: Run cargo clippy
run: cargo clippy --manifest-path "src-tauri/Cargo.toml"
+5 -1
View File
@@ -28,6 +28,10 @@ __pycache__/
.kiro/
.history
docs/prd/
!docs/prd/
docs/prd/*
!docs/prd/tools/
!docs/prd/tools/*.md
# Internal roadmap&gongzonghao (private)
docs/roadmap/
@@ -72,4 +76,4 @@ lime-claw.png
lime.db
.codex-*
.codex-*
+1
View File
@@ -30,6 +30,7 @@
2. **宽度按页面类型选** - 表单页保持窄阅读宽度,卡片/工作台页面使用更宽的自适应内容区,不要整仓统一 `max-width`
3. **中文排版优先** - 避免过大英文 tracking、重复标题和挤压式统计卡文案
4. **渐变只做氛围层** - 禁止用互相打架的多层渐变制造分割感,背景存在感必须弱于内容
5. **默认禁用半透明主表面** - 弹窗、浮层、工作台容器、主卡片默认使用实体底色,避免 `bg-white/80`、`backdrop-blur` 一类设计造成层级混乱与内容遮挡错觉
## 详细文档
+12 -11
View File
@@ -1,21 +1,22 @@
## Lime v0.91.0
## Lime v0.92.0
### ✨ 主要更新
- **Aster 运行时队列正式接入 Lime**:桌面端补齐 runtime queue service、Aster state support 与 session store 协作,Agent 会话恢复、排队执行和 runtime item 映射进一步收口
- **Agent 聊天输入链路继续统一**:输入栏、空状态、图片附件、模型选择与会话 hooks 继续围绕 Aster 聊天主链路整理,减少旧 compat 路径分叉
- **模型能力与视觉提示增强**:新增模型能力徽章、视觉能力提示与 provider model list 整理,模型选择和多模态提示更直接
- **Skills / Social Post 执行链路补齐**:新增技能执行运行时与社交内容技能集成,Aster Skills 在 Lime 内的发现、执行与同步更完整
- **数据库与治理清理继续推进**:移除旧 `unified_chat` / `tool_hooks` / `three stage workflow` 相关残留,统一到现役 Aster Agent、Memory 与 Workspace 路径
- **Team Workspace 正式成型**:Agent 聊天页新增 Team Workspace 主工作台、建议栏、Dock 与 Home Shell,围绕多代理协作视图重组交互结构
- **运行态与工具可视化增强**:`ToolCallDisplay`、Harness 状态面板、Runtime Strip、执行日志与子代理时间线继续增强,工具调用与运行态反馈更完整
- **Aster Agent 运行时继续收口**:Rust 侧补齐 session store、subagent control、agent tools inventory / execution、runtime queue 及命令桥接,统一现役 Agent Runtime 路径
- **治理与测试基建升级**:新增 `pr-gate`、本地校验脚本、命令契约检查、workspace smoke 与治理报告更新,发布前自检链路更清晰
- **Provider / 模型兼容性继续补强**:补充 Novita 与多种 OpenAI/Claude 兼容 provider 细节,推理内容与工具调用适配继续完善
### ⚠️ 兼容性说明
- Aster 相关聊天、会话与时间线事实源进一步集中到新的 runtime / session store 路径,旧 compat API 不再建议继续扩展
- 模型可见性与能力展示依赖新的 provider model 推断逻辑,历史仅按名称匹配的前端分支需要逐步淘汰
- Agent 聊天页结构继续向 Team Workspace 与现役 Runtime API 收口,旧 compat 会话 / 子代理展示路径不再建议扩展
- 工具面板、Harness 状态与时间线展示依赖新的事件元数据与运行时映射,历史 UI 分支需要逐步跟进
### 🔗 依赖同步
- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖固定到 `v0.19.0`
- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖固定到 `v0.20.0`
- 应用版本同步提升到 `v0.92.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace 版本入口
### 🧪 测试
@@ -26,7 +27,7 @@
### 📝 文档
- 更新 Aster 集成、治理、Skills 与发布相关文档,补充当前现役架构与发布说明
- 更新治理、测试、工具体系与 Aster 集成相关文档,补充当前现役架构与发布说明
### 📦 Windows 下载说明
@@ -36,4 +37,4 @@
---
**完整变更**: v0.90.0...v0.91.0
**完整变更**: v0.91.0...v0.92.0
+12 -1
View File
@@ -76,6 +76,8 @@ Lime 的整体界面应当接近以下气质:
- 背景先轻,再让卡片浮出来
- 如果背景已经有气氛层,卡片本身就要更克制
- 当页面出现“背景比内容更显眼”的情况,优先减背景,不要继续加组件装饰
- 弹窗、浮层、工作台主面板默认禁止半透明与磨砂效果;不要用 `bg-white/80`、`backdrop-blur`、半透明描边去制造“高级感”
- 需要悬浮感时,优先用实体底色、清晰边框和浅阴影,不要靠透出下层内容制造层级
## 容器与布局
@@ -147,13 +149,21 @@ Lime 的整体界面应当接近以下气质:
- 卡片底色优先白色或轻微染色白
- 边框比阴影更重要
- 阴影要浅,重点靠层级和留白,而不是重投影
- 面板内不要连续叠三层以上卡片;能用分区、留白、分隔线解决的,不要继续套一层圆角卡
推荐方向:
- `bg-white` 或 `bg-white/90`
- `bg-white`
- `bg-slate-50`
- `border-slate-200/80`
- `shadow-sm shadow-slate-950/5`
避免方向:
- 大面积 `bg-white/80`、`bg-slate-50/70` 一类半透明主表面
- `backdrop-blur-*` 用在正文承载容器、工作台、弹窗主体
- 在同一信息区连续嵌套多层圆角白卡,导致边界重复、滚动时视觉发花
### 2. 组件圆角
- 工作台容器:大圆角
@@ -211,6 +221,7 @@ Lime 的整体界面应当接近以下气质:
- 氛围背景应采用连续浅渐变,不要做分段叠色
- 工作台主操作应与统计信息分栏,而不是挤成一行
- 分组标题应使用“中文主标题 + 英文辅助标签”的组合,而不是反过来
- Team Workspace / 浮层类界面应避免半透明主体和多层套卡,否则会放大遮挡感与层级混乱
## 关联文档
+17 -4
View File
@@ -120,13 +120,20 @@ compat 层禁止:
```bash
npm run governance:legacy-report
npm run test:contracts
```
它用于扫描:
- `npm run governance:legacy-report` 用于扫描:
- 已被判定为 `deprecated` / `dead-candidate` 的前端入口
- 旧 Tauri 命令是否仍然只收口在指定 API 网关
- 哪些兼容壳层已经零引用,可以进入删除候选
- `npm run test:contracts` 用于检查跨层命令契约:
- 前端 `safeInvoke(...)` / `invoke(...)` 的实际命令调用
- Rust `tauri::generate_handler!` 的实际注册表
- `agentCommandCatalog` 中的 `deprecated` 命令与 `runtime gateway` 命令边界
- `mockPriorityCommands` 与 `defaultMocks` 是否仍然同步
- 已被判定为 `deprecated` / `dead-candidate` 的前端入口
- 旧 Tauri 命令是否仍然只收口在指定 API 网关
- 哪些兼容壳层已经零引用,可以进入删除候选
只看其中一侧都不够。只要能力仍然依赖命令边界,至少要同时看前端调用、Rust 注册、deprecated 目录、mock 集合这四个面。
原则只有一句:
@@ -195,6 +202,11 @@ npm run governance:legacy-report
至少加一条能自动失败的规则,阻止旧路径继续增长。
如果改动涉及 Tauri 命令、前端 API 网关、bridge 或 mock,优先补:
- `npm run test:contracts`
- `npm run governance:legacy-report`
### 第五步:迁旁路
确认统计、记忆、搜索、报表、审计、任务系统不再依赖旧实现。
@@ -209,6 +221,7 @@ npm run governance:legacy-report
- 前端唯一入口是不是 `useAgentChatUnified -> useAsterAgentChat`,还是 `useChat` / `useAgentChat` / `useUnifiedChat` 还在继续长逻辑?
- Rust 唯一入口是不是 `agent_runtime_*`,还是 `chat_*` / `general_chat_*` / `agent_*` / `aster_agent_*` 还在平行演进?
- 前端 `safeInvoke(...)` / `invoke(...)`、Rust `tauri::generate_handler!`、`agentCommandCatalog`、`mockPriorityCommands` / `defaultMocks` 这四个命令边界是不是仍然一致,还是已经产生漂移?
- 数据事实源是不是同一组表 / 同一套 Repository,还是还在同时写 `agent_*` 与 `general_chat_*`?
- 统计、记忆等旁路是不是已经切到新路径,还是还在读旧表?
+325
View File
@@ -0,0 +1,325 @@
# Lime 工具治理总览
更新时间:2026-03-20
## 1. 背景
当前 Lime 的工具体系已经不只是单一的 Aster native tools,还同时包含:
- Aster 默认内置工具
- Lime 注入工具
- Creator 专属工具
- Browser Assist 兼容工具面
- Lime MCP runtime tools
- Aster ExtensionManager 注入后的 prefixed tools
这套能力本身已经接近 Tool Calling 2.0,但过去存在两个核心问题:
1. **工具事实源分裂**
- MCP schema metadata、Aster runtime registry、Lime 注入 extension、provider 转换层分别做了解析
- 同一个字段(如 `deferred_loading` / `allowed_callers` / `input_examples`)在多处重复解释
2. **权限平面混杂**
- “工具是否应该进入上下文”
- “工具是否允许某个 caller 调用”
- “工具调用后是否需要 sandbox / approval”
- “参数是否受限”
过去没有被严格分层,导致工具越多,上下文与权限越容易错乱
本次治理的目标,是把 Lime 的工具系统收敛到一条清晰主链路,参考 Codex 的思路:
- **小而稳定的常驻工具面**
- **按需搜索 / 延迟加载的动态工具**
- **工具发现与权限执行分离**
- **MCP 作为独立体系接入,但统一进入 Agent runtime**
---
## 2. 本次结论
### 2.1 不是“没有 Tool Search”,而是“已经有一半,但事实源没收口”
Lime 实际已经具备这些能力:
- `search_tools`
- `list_tools_for_context`
- `tool_search` bridge tool
- `deferred_loading`
- `allowed_callers`
- `input_examples`
- MCP -> Aster extension 注入
真正的问题不是缺能力,而是:
- metadata 解析分散
- native tool 目录不完整
- MCP / extension / provider 多处重复解释
- runtime 缺少一份可审计的“工具库存快照”
### 2.2 现役事实源
本轮治理后,建议把事实源固定为:
- **工具元数据事实源**:`src-tauri/crates/core/src/tool_calling.rs`
- **native 工具目录事实源**:`src-tauri/src/agent_tools/catalog.rs`
- **执行权限事实源**:`src-tauri/src/agent_tools/execution.rs`
- **MCP runtime 工具事实源**:`src-tauri/crates/mcp/src/manager.rs`
- **Aster 注入工具面事实源**:`src-tauri/src/commands/aster_agent_cmd.rs`
- **工具库存 / 审计快照事实源**:`src-tauri/src/agent_tools/inventory.rs`
### 2.3 当前 / 兼容 / 待清理分类
| 分类 | 路径 / 对象 | 说明 |
| -------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| current | `lime_core::tool_calling` | 统一 metadata 读取与打分 |
| current | `src-tauri/src/agent_tools/catalog.rs` | 完整 native tool 目录与默认授权子集 |
| current | `src-tauri/src/agent_tools/execution.rs` | 统一 execution 层的 warning / sandbox / 参数限制事实源 |
| current | `src-tauri/crates/mcp/src/manager.rs` | MCP tools runtime registry |
| current | `src-tauri/src/commands/aster_agent_cmd.rs` | Aster runtime 注入、tool_search、inventory 命令 |
| current | `src-tauri/src/agent_tools/inventory.rs` | runtime 工具库存快照 |
| compat | `SubAgentTask` | 兼容旧子代理工具名,仍可见但应逐步退出 |
| compat | `workspace_allowed_tool_names(...)` | 当前保留为旧调用入口别名,实际委托默认授权目录 |
| dead-candidate | `src-tauri/crates/agent/src/tool_permissions.rs` | 已退出 `lime-agent` 的 `lib.rs` 编译图,仅通过 `src-tauri/crates/agent/tests/legacy_permission_surfaces.rs` 测试夹具加载 |
| dead-candidate | `src-tauri/crates/agent/src/shell_security.rs` | 已退出 `lime-agent` 的 `lib.rs` 编译图,仅通过 `src-tauri/crates/agent/tests/legacy_permission_surfaces.rs` 测试夹具加载 |
> 注意:`dead-candidate` 本轮只做标记,不直接删除。删除属于高风险操作,需要单独确认。
---
## 3. 本次已落地实现
### 3.1 元数据收口
已统一到 `lime_core::tool_calling`:
- `extract_tool_surface_metadata`
- `tool_visible_in_context`
- `tool_matches_caller`
- `score_tool_match`
- `normalize_tool_caller`
以下模块都已切换到共享逻辑:
- `src-tauri/crates/mcp/src/manager.rs`
- `src-tauri/src/commands/aster_agent_cmd.rs`
- `src-tauri/crates/providers/src/providers/openai_custom.rs`
- `src-tauri/crates/providers/src/providers/claude_custom.rs`
### 3.2 native 工具目录补全
`src-tauri/src/agent_tools/catalog.rs` 已升级为完整目录,覆盖:
- Aster built-ins
- Lime 注入工具
- Creator 工具面
- Browser Assist 兼容前缀
并明确了:
- `ToolSourceKind`
- `ToolPermissionPlane`
- `ToolLifecycle`
- `workspace_default_allow`
### 3.3 runtime 库存快照
新增:
- `src-tauri/src/agent_tools/inventory.rs`
- `agent_runtime_get_tool_inventory` Tauri 命令
- `src/lib/api/agentRuntime.ts` 对应 helper
这条命令可以一次返回:
- 当前 surface 的 catalog tools
- 默认允许工具集合
- runtime registry tools
- extension surfaces
- searchable / loaded extension tools
- MCP servers 与 MCP tools
- 映射缺口与可见性统计
### 3.4 执行权限事实源
新增:
- `src-tauri/src/agent_tools/execution.rs`
负责:
- `bash` / `Task` 的 warning gate 语义
- workspace 参数限制模板
- sandbox profile 归类
- execution permission 模板生成
- inventory execution profile 暴露
- 默认策略 + persisted policy + runtime override 合并
其中策略覆盖入口已经收口为:
- **持久化覆盖**:`src-tauri/crates/core/src/config/types.rs` -> `NativeAgentConfig.tool_execution`
- **运行时覆盖**:`request.metadata.harness.executionPolicy` / `execution_policy`
- **有效策略解析**:`src-tauri/src/agent_tools/execution.rs::resolve_tool_execution_policy`
结果:
- `aster_agent_cmd.rs` 不再手工拼整段 `ToolPermission` 模板
- execution 层事实源从命令层 if/else 收回 `agent_tools` 边界
- inventory 现在可直接审计 `execution_warning_policy` / `execution_restriction_profile` / `execution_sandbox_profile`
- `agent_runtime_get_tool_inventory` 可通过 `metadata` 观察 runtime override 后的 effective profile
- inventory 同时暴露每个 execution 字段的来源:
- `execution_warning_policy_source`
- `execution_restriction_profile_source`
- `execution_sandbox_profile_source`
### 3.5 前端契约同步
已同步更新:
- `src/lib/governance/agentCommandCatalog.json`
- `src/lib/tauri-mock/core.ts`
- `src/lib/dev-bridge/mockPriorityCommands.ts`
- `src/lib/api/agent.test.ts`
- `src/lib/api/agentRuntime.ts`
### 3.6 轻量测试通道
为避免 `lime` 主包在本地因 Tauri 链接过大而降低回归效率,已补一条对齐 Codex 思路的轻量测试通道:
- `src-tauri/crates/agent/src/lib.rs`
- `src-tauri/crates/agent/src/agent_tools/mod.rs`
这条通道直接复用 app crate 的:
- `src-tauri/src/agent_tools/catalog.rs`
- `src-tauri/src/agent_tools/execution.rs`
- `src-tauri/src/agent_tools/inventory.rs`
用于承接纯逻辑单测,而不是复制第二份实现。
结论:
- **runtime 事实源没有新增**
- **测试入口新增了一条更轻的执行面**
- `tool_search` 与 inventory 的 extension 状态判定也已继续收口到共享 helper,避免主包再次长出重复逻辑
### 3.7 旧权限表面下沉
本轮继续做了一刀减法:
- `src-tauri/crates/agent/src/tool_permissions.rs`
- `src-tauri/crates/agent/src/shell_security.rs`
现在文件仍保留在仓库中,但编译边界已经:
- 不再通过 `lime-agent` crate 根对外 `pub mod`
- 不再通过 `lime-agent` crate 根对外 `pub use`
- 不再进入 `lime-agent` 的 `lib.rs` 编译图
- 不再进入正常 `cargo check` / 运行时编译图
- 只通过 `src-tauri/crates/agent/tests/legacy_permission_surfaces.rs` 测试夹具加载,并继续复用文件内自测
同时新增了两层守卫:
- `scripts/report-legacy-surfaces.mjs`:防止旧权限模块重新公开、重新挂回 `lib.rs` 编译图,或被上层重新依赖
- `src/lib/governance/legacyToolPermissionGuard.test.ts`:防止 `lime-agent` 再次把这两套旧权限逻辑挂回 `lib.rs`,并约束测试夹具边界
---
## 4. 当前确认的静态工具面
### 4.1 Core surface
- **Aster built-ins**:19 个
`read` / `write` / `edit` / `glob` / `grep` / `bash` / `lsp` / `Skill` / `Task` / `TaskOutput` / `KillShell` / `TodoWrite` / `NotebookEdit` / `EnterPlanMode` / `ExitPlanMode` / `WebFetch` / `WebSearch` / `analyze_image` / `ask`
- **Lime injected core tools**:7 个
`tool_search` / `spawn_agent` / `send_input` / `wait_agent` / `resume_agent` / `close_agent` / `SubAgentTask`
- **Core surface catalog total**:26 个
### 4.2 Creator surface
在 Core 之上额外增加 8 个:
- `social_generate_cover_image`
- `lime_create_video_generation_task`
- `lime_create_broadcast_generation_task`
- `lime_create_cover_generation_task`
- `lime_create_modal_resource_search_task`
- `lime_create_image_generation_task`
- `lime_create_url_parse_task`
- `lime_create_typesetting_task`
- **Creator surface catalog total**:34 个
### 4.3 Browser Assist surface
目录里只保留一个前缀入口:
- `mcp__lime-browser__*`
但它实际映射到 Aster browser runtime 的一组 prefixed tools。
参考 Aster 的 `chrome_mcp/tools.rs`,当前浏览器工具定义为 **17 个**。
- **Browser Assist surface catalog total**:27 个
- **Creator + Browser Assist 全量 surface**:35 个
---
## 5. 为什么这套方案比现状合理
### 5.1 更像 Codex,而不是“把所有 schema 全塞 prompt”
Codex 的思路是:
- 常驻工具面尽量小
- 动态工具按 thread 存储
- 通过 `defer_loading` 控制是否默认进入上下文
- 权限配置与工具发现分离
Lime 现在的目标状态也应该是:
- native 常驻面稳定
- MCP / long-tail tools 搜索后按需进入
- `allowed_callers` 只管调用者可见性
- sandbox / approval 只管执行权限
### 5.2 MCP 单独成体系,但不单独造第二套 agent 认知
MCP 在 Lime 里仍然是独立运行时:
- server 启停
- tool cache
- prompt/resource
- runtime list/search/call
但一旦进入 Agent,会通过 Aster `ExtensionManager` 统一挂接。
这样模型只面对一个工具宇宙,不需要理解两套完全不同的上下文协议。
### 5.3 权限终于能分层
建议永久保留三层概念:
1. **目录层**
- 这个工具是否存在
- 属于哪个 surface / source / lifecycle
2. **上下文层**
- 这个工具是否默认进入上下文
- 是否 deferred
- caller 是否匹配
3. **执行层**
- 参数限制
- sandbox
- approval
- workspace allowlist
这三层不再混写,后续就不会随着工具数增加而指数级混乱。
---
## 6. 文档索引
- `docs/prd/tools/architecture.md`:架构、时序、流程图、Codex 对照
- `docs/prd/tools/inventory.md`:工具盘点、分类、库存命令说明
- `docs/prd/tools/development-plan.md`:开发计划、验收标准、下一刀
+342
View File
@@ -0,0 +1,342 @@
# Lime 工具治理架构
## 1. 设计目标
本次工具治理的设计目标只有四个:
1. **只保留一套 metadata 解释器**
2. **只保留一份 native tool catalog**
3. **MCP 独立运行,但统一注入 Agent runtime**
4. **把“工具发现”和“工具权限执行”彻底分开**
---
## 2. 核心事实源
### 2.1 统一元数据事实源
路径:`src-tauri/crates/core/src/tool_calling.rs`
负责:
- `deferred_loading`
- `always_visible`
- `allowed_callers`
- `tags`
- `input_examples`
- tool search 打分
- caller 归一化
任何地方如果还在自己读 `x-lime` / `x_lime`,都应视为治理退化。
### 2.2 native 目录事实源
路径:`src-tauri/src/agent_tools/catalog.rs`
负责:
- 工具目录完整性
- source / lifecycle / capability / permission_plane
- 默认 allowlist 子集
- Creator / Browser Assist surface 裁剪
- MCP extension surface 聚合
### 2.3 执行权限事实源
路径:`src-tauri/src/agent_tools/execution.rs`
负责:
- execution 层 warning gate 收口
- workspace 参数限制模板收口
- sandbox profile 归类
- permission 模板生成
- inventory execution profile 暴露
- 默认策略、persisted policy、runtime session override 的优先级合并
### 2.4 MCP runtime 事实源
路径:`src-tauri/crates/mcp/src/manager.rs`
负责:
- MCP server lifecycle
- tool cache
- `list_tools`
- `list_tools_for_context`
- `search_tools`
- runtime metadata 继承与自动 defer 策略
### 2.5 Agent 注入事实源
路径:`src-tauri/src/commands/aster_agent_cmd.rs`
负责:
- `tool_search` bridge tool
- MCP -> Aster extension 注入
- workspace tool allowlist
- runtime tool inventory 命令
### 2.6 轻量测试载体
路径:
- `src-tauri/crates/agent/src/lib.rs`
- `src-tauri/crates/agent/src/agent_tools/mod.rs`
负责:
- 把 `src-tauri/src/agent_tools/catalog.rs`
- 把 `src-tauri/src/agent_tools/execution.rs`
- 把 `src-tauri/src/agent_tools/inventory.rs`
以模块方式复用到 `lime-agent` crate 内,供纯逻辑单测执行。
注意:
- 它不是新的 runtime 事实源
- 它只是测试载体,避免 `lime` 主包为 Tauri/App wiring 做超大链接
- 运行时事实源仍然是 app crate 下的 `catalog.rs` / `execution.rs` / `inventory.rs`
---
## 3. 总体架构图
```mermaid
graph TD
UI[前端 / agentRuntime.ts] --> CMD[aster_agent_cmd.rs]
CMD --> CAT[agent_tools/catalog.rs]
CMD --> EXEC[agent_tools/execution.rs]
CMD --> INV[agent_tools/inventory.rs]
CMD --> META[lime_core::tool_calling.rs]
CMD --> MCP[lime_mcp::manager.rs]
CMD --> AGENT[Aster Agent]
TEST[lime-agent test carrier] -. 复用纯逻辑 .-> CAT
TEST -. 复用纯逻辑 .-> EXEC
TEST -. 复用纯逻辑 .-> INV
MCP --> MCPTOOLS[MCP Runtime Tools]
MCP --> PROMPTS[MCP Prompts / Resources]
AGENT --> REG[ToolRegistry]
AGENT --> EXT[ExtensionManager]
MCPTOOLS --> EXT
META --> MCP
META --> CMD
META --> PROVIDERS[Provider Tool Conversion]
CAT --> EXEC
CAT --> INV
EXEC --> INV
REG --> INV
EXT --> INV
MCP --> INV
```
---
## 4. 初始化时序图
下面是 Lime 启动 Agent 并把 MCP 工具面注入 Aster 的主链路。
```mermaid
sequenceDiagram
participant UI as 前端
participant CMD as aster_agent_init
participant STATE as AsterAgentState
participant MCP as McpClientManager
participant META as lime_core::tool_calling
participant EXT as Aster ExtensionManager
UI->>CMD: aster_agent_init()
CMD->>STATE: init_agent_with_db()
CMD->>MCP: ensure_lime_mcp_servers_running()
CMD->>MCP: list_tools()
MCP->>META: extract_tool_surface_metadata()
MCP-->>CMD: McpToolDefinition[]
CMD->>CMD: build_mcp_extension_surface()
CMD->>EXT: add_client(extension, bridge_client)
CMD->>CMD: ensure_tool_search_tool_registered()
CMD-->>UI: initialized + tool surface ready
```
---
## 5. 工具检索 / 按需加载流程图
这个流程对应 Anthropic Tool Search / Codex defer loading 的同类思路。
```mermaid
flowchart TD
A[用户任务] --> B{现有默认上下文能否完成?}
B -- 是 --> C[直接使用默认可见工具]
B -- 否 --> D[调用 tool_search]
D --> E[查询 native registry + extension searchable tools]
E --> F{目标工具是否 deferred?}
F -- 否 --> G[直接执行]
F -- 是 --> H[通过 ExtensionManager 加载目标工具]
H --> I[把工具加入可执行工具面]
I --> G
G --> J[执行时再走 sandbox / approval / 参数限制]
```
关键点:
- `tool_search` 负责“找工具”
- `allowed_callers` 负责“谁能看见 / 调用”
- `deferred_loading` 负责“是否默认进上下文”
- sandbox / approval 负责“执行时能不能做”
---
## 6. 权限平面拆分
## 6.1 目录层
由 `catalog.rs` 定义。
回答的问题:
- 这个工具属于哪个产品 surface
- 是 current 还是 compat
- 是 Aster builtin、Lime injected 还是 Browser compatibility
## 6.2 上下文层
由 `tool_calling.rs` + `mcp manager` + `tool_search` 定义。
回答的问题:
- 默认可见还是 deferred
- caller 是否匹配
- 是否需要 tool_search 后再进入
## 6.3 执行层
由 `agent_tools/execution.rs` + Aster `ToolPermissionManager` + workspace sandbox / runtime approval 定义。
回答的问题:
- 参数是否受限
- 是否要求审批
- 是否要进入 sandbox
- 当前 workspace 是否允许
> 结论:**不要再让目录层和执行层共享同一套“权限”语义。**
---
## 7. 与 Codex 的对照
## 6.4 执行策略解析时序图
下面是一次工具执行前,effective execution policy 的解析链路。
```mermaid
sequenceDiagram
participant UI as 前端 / request.metadata
participant CMD as aster_agent_cmd.rs
participant CFG as NativeAgentConfig.tool_execution
participant EXEC as agent_tools/execution.rs
participant PERM as ToolPermissionManager
UI->>CMD: submit turn / get inventory(metadata)
CMD->>CFG: 读取 persisted policy
CMD->>EXEC: resolve_tool_execution_policy(tool, config, metadata)
EXEC->>EXEC: 合并默认策略
EXEC->>EXEC: 叠加 persisted override
EXEC->>EXEC: 叠加 runtime override
EXEC-->>CMD: effective policy
CMD->>PERM: build_workspace_execution_permissions(...)
CMD-->>UI: inventory / runtime 权限结果
```
结论:
- `catalog.rs` 仍只定义目录层事实
- `execution.rs` 独占执行层合并逻辑
- `aster_agent_cmd.rs` 只负责 orchestration,不再手工散写 permission 模板
- provenance 也在 `execution.rs` 统一生成,inventory 只消费结果,不再自行推断来源
---
## 7. 与 Codex 的对照
## 7.1 Codex 怎么做
参考:
- `codex-rs/app-server-protocol/src/protocol/v2.rs`
- `codex-rs/state/src/runtime/threads.rs`
Codex 的关键点:
1. **小型常驻工具配置**
- `ToolsV2` 只保留少量稳定入口,例如 `web_search`、`view_image`
2. **动态工具独立建模**
- `DynamicToolSpec` 单独描述动态工具
- 包含 `defer_loading`
3. **线程级动态工具持久化**
- `thread_dynamic_tools`
- 动态工具跟着 thread,而不是全局把所有 schema 塞进 prompt
4. **权限与工具发现分离**
- `AppToolsConfig` 管具体 app tool 的 enable / approval mode
- sandbox / approval 是另一套系统
## 7.2 Lime 应该学什么
Lime 不需要一比一复制 Codex,但要学到这三个原则:
1. **常驻工具面要小**
2. **长尾工具靠搜索和 deferred loading**
3. **权限执行不要和工具目录绑死**
## 7.3 Lime 当前对应关系
| Codex | Lime 对应实现 |
| ------------------------------- | -------------------------------------------------------------------- |
| `DynamicToolSpec.defer_loading` | `x-lime.deferred_loading` + `McpToolDefinition.deferred_loading` |
| `thread_dynamic_tools` | Aster ExtensionManager searchable tools + MCP runtime cache |
| 小常驻工具面 | `workspace_default_allowed_tool_names(...)` |
| app tool config / approval 分离 | `catalog.rs` + `execution.rs` + workspace sandbox / Aster permission |
| persisted permissions profile | `NativeAgentConfig.tool_execution` |
| thread/request runtime override | `request.metadata.harness.executionPolicy` |
---
## 8. MCP 在架构里的位置
MCP 不是 native tools 的附属物,也不是另起一套 Agent。
它在 Lime 中应被视为:
- **独立的 runtime tool fabric**
- 但通过 **统一的 extension 注入边界** 进入 Agent
这意味着:
- MCP server 启停、缓存、resource/prompt 仍然独立
- Agent 只看到统一后的 prefixed tool surface
- 工具搜索可以同时搜 native 与 extension
- 库存命令可以同时做 catalog / runtime / MCP 三视角盘点
---
## 9. dead-candidate 说明
以下路径当前不在主链路:
- `src-tauri/crates/agent/src/tool_permissions.rs`
- `src-tauri/crates/agent/src/shell_security.rs`
状态建议:
- 当前标记为 `dead-candidate`
- 暂不删除
- 如果后续确认完全无运行时回流,再单独发起一次“删除旧权限系统”的治理变更
+222
View File
@@ -0,0 +1,222 @@
# Lime 工具治理开发计划
## 1. 目标
把 Lime 的工具系统从“多源并存、权限混写、上下文不可审计”收敛为:
- 单一 metadata 事实源
- 单一 native catalog
- MCP runtime 独立但统一注入
- inventory 可审计
- 权限平面分层
---
## 2. 本轮已完成
## Phase A:事实源收口(已完成)
- [x] 把 tool metadata 解析统一到 `lime_core::tool_calling`
- [x] MCP manager 改为复用共享 metadata
- [x] `tool_search` bridge tool 改为复用共享 metadata
- [x] provider 转换层改为复用共享 metadata
### 验收标准
- 不再存在多处独立解析 `deferred_loading` / `allowed_callers` / `input_examples`
- tool search 与 MCP list/search 的语义一致
---
## Phase B:native catalog 完整化(已完成)
- [x] 建立完整 `ToolCatalogEntry`
- [x] 引入 `ToolSourceKind`
- [x] 引入 `ToolPermissionPlane`
- [x] 引入 `ToolLifecycle`
- [x] 补全 core / creator / browser assist tools
- [x] 形成默认 allowlist 子集
### 验收标准
- 能回答“当前到底有哪些 native tools”
- 能区分 current / compat
- 能区分 session allowlist / parameter restricted / caller filtered
---
## Phase C:runtime inventory(已完成)
- [x] 新增 `agent_tools/inventory.rs`
- [x] 新增 `agent_runtime_get_tool_inventory`
- [x] 接入 `agentRuntime.ts`
- [x] 接入 mock / governance command catalog / API test
### 验收标准
- 一条命令能同时输出 catalog / registry / extension / MCP 四视角
- 能看到 visible / deferred / caller_allowed 状态
- 能发现 registry 未被 catalog 覆盖的漂移项
---
## Phase D:文档与治理说明(已完成)
- [x] 输出 `docs/prd/tools/README.md`
- [x] 输出 `docs/prd/tools/architecture.md`
- [x] 输出 `docs/prd/tools/inventory.md`
- [x] 输出 `docs/prd/tools/development-plan.md`
### 验收标准
- 有架构图
- 有时序图
- 有流程图
- 有 current / compat / dead-candidate 分类
- 有 Codex / Aster / Lime 对照
---
## Phase E:测试矩阵补强(本轮完成)
- [x] 补齐 `catalog.rs` 的 surface / lifecycle / default allowlist 边界测试
- [x] 补齐 `inventory.rs` 的 caller / extension source / deferred 状态测试
- [x] 补齐 `mcp manager` 的冲突保序与默认 defer 阈值测试
- [x] 补齐 `tool_search` 的 extension 前缀匹配与状态判定测试
- [x] 补齐 provider 层对 `x-lime` / `x_lime` alias 与去重行为测试
- [x] 补齐前端 `agentRuntime.ts` inventory helper 默认参数测试
- [x] 新增 `lime-agent` 轻量测试载体,复用 `catalog.rs` / `inventory.rs` 纯逻辑模块,绕开主包 Tauri 大链接
- [x] 把 `tool_permissions.rs` / `shell_security.rs` 迁出 `lime-agent` 的 `lib.rs` 编译图,改为独立 integration test 夹具加载,并补治理守卫与 Vitest 护栏
## Phase F:执行权限事实源收口(本轮完成)
- [x] 新增 `src-tauri/src/agent_tools/execution.rs`
- [x] 把 workspace execution permission 模板从 `aster_agent_cmd.rs` 收回 `agent_tools` 边界
- [x] 统一 `bash` / `Task` warning gate 语义
- [x] 把 execution profile 暴露到 inventory / `agentRuntime.ts`
- [x] 补齐 `execution.rs` 与 inventory 的定向测试
- [x] 新增 `NativeAgentConfig.tool_execution`,承接 persisted policy 覆盖
- [x] 让 `request.metadata.harness.executionPolicy` 承接 runtime session override
- [x] 让 `agent_runtime_get_tool_inventory` 支持 `metadata`,返回 runtime override 后的 effective profile
- [x] 给 inventory 增加 provenance 字段,逐项标记 `default` / `persisted` / `runtime`
- [x] 补齐配置层 default / alias / roundtrip 测试
### 验收标准
- 对齐 Codex 风格的四类断言:
1. **默认值是否稳定**
2. **legacy / alias 字段是否兼容**
3. **显式配置是否覆盖默认策略**
4. **持久语义是否在多层之间保持一致**
- 新增工具治理逻辑至少要被以下矩阵之一覆盖:
- catalog 边界
- runtime inventory 快照
- MCP runtime 过滤 / 搜索 / defer
- provider metadata 透传
- 前端命令 helper
### 本轮验证策略
- **小包优先**:优先跑 `lime-providers`、`lime-mcp` 与前端 `agent.test.ts`
- **轻量逻辑优先**:`catalog.rs` / `inventory.rs` 的纯逻辑单测优先从 `lime-agent` 执行,测试名使用模块前缀:
- `agent_tools::catalog::tests::...`
- `agent_tools::inventory::tests::...`
- **主包降级为编译检查**:`lime` 主包测试会触发超大链接;在当前环境磁盘仅余约 `4.4Gi` 时,优先做 `cargo check` / 定向编译验证
- **边界说明**:若后续 CI 或本地磁盘空间恢复,应追加一次 `lime` 主包的完整定向测试,把 `catalog.rs` / `inventory.rs` / `aster_agent_cmd.rs` 新增测试全部实跑
### 推荐命令
```bash
# 轻量 Rust 纯逻辑测试
cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-agent \
agent_tools::catalog::tests::test_tool_catalog_entries_for_surface_counts_and_lifecycle_boundaries -- --exact
cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-agent \
agent_tools::inventory::tests::test_build_tool_inventory_marks_extension_sources_and_statuses -- --exact
# MCP / Provider 定向测试
cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-mcp empty_query_prioritizes_always_visible_then_name -- --nocapture
cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-providers supports_x_lime_alias -- --nocapture
# 前端契约与治理守卫
npm test -- "src/lib/api/agent.test.ts"
npm test -- "src/lib/governance/legacyToolPermissionGuard.test.ts"
npm run test:contracts
npm run governance:legacy-report
```
---
## 3. 下一刀建议
## 3.1 优先级 P1:删除旧权限系统前先做守卫
本轮已完成:
- `tool_permissions.rs` / `shell_security.rs` 已明确标为 dead-candidate
- 已从 `lime-agent` crate 根移除公开表面
- 已退出 `lime-agent` 的 `lib.rs` 编译图,仅通过 `tests/legacy_permission_surfaces.rs` 测试夹具加载
- 已在治理脚本与 Vitest 护栏中禁止新引用回流
剩余原因:
- 这两套逻辑虽然已不在主链路,但文件级删除仍属于高风险动作,需要单独确认
---
## 3.2 优先级 P1:把“策略覆盖层”接到 execution 事实源
本轮已完成:
- `workspace allowlist` / `parameter restriction` / `warning gate` / `sandbox profile` 已有统一 execution 事实源
- `aster_agent_cmd.rs` 已从手工 permission 模板拼装降级为 orchestration
- inventory 已能审计 execution profile
- `NativeAgentConfig.tool_execution` 已承接 persisted policy
- `request.metadata.harness.executionPolicy` 已承接 runtime session override
- inventory 已可查看 runtime override 后的 effective execution profile
- inventory 已可逐项查看 execution provenance,而不是只看最终值
剩余下一步建议:
- 如果后续要做 UI 可视化,再把 effective policy 与来源标识直接展示在工具调试页
- 保持 `catalog.rs` 只描述目录层,`execution.rs` 只描述执行层,避免覆盖逻辑再次散回命令层
---
## 3.3 优先级 P2:继续做减法,而不是再加抽象
不要再新增:
- 第二套 tool metadata parser
- 第二套 native tool list
- 第二套 browser tool 目录
- 第二套 MCP 注入路径
后续所有新工具都应满足:
1. 在 `catalog.rs` 有记录
2. metadata 走 `lime_core::tool_calling`
3. runtime inventory 能看见
---
## 4. 风险与对策
| 风险 | 说明 | 对策 |
| --------------------------- | --------------------------------------------------- | ------------------------------------------ |
| catalog 与 runtime 再次漂移 | 新增工具时只改注册不改目录 | 以 inventory + command catalog 作为守卫 |
| MCP caller 语义再次分裂 | MCP tool schema / extension allowed_caller 各自解释 | 一律先过 `tool_calling.rs` |
| 权限语义再次混写 | catalog、allowlist、sandbox、approval 又掺在一起 | 强制按目录层 / 上下文层 / 执行层汇报与设计 |
| 旧权限系统回流 | 新代码重新引用 `tool_permissions.rs` | 标记 dead-candidate,并增加仓库级扫描守卫 |
---
## 5. 最终建议
这次治理完成后,后续迭代请遵守三条硬规则:
1. **新增工具先入 catalog**
2. **新增 metadata 字段先入 `tool_calling.rs`**
3. **新增运行时注入能力必须能被 inventory 看见**
只要守住这三条,Lime 的工具数继续增长,也不会再回到“上下文失控 + 权限混乱”的状态。
+274
View File
@@ -0,0 +1,274 @@
# Lime 工具库存与分类
## 1. 静态 catalog 盘点
## 1.1 Core surface
### Aster built-ins(19)
- `read`
- `write`
- `edit`
- `glob`
- `grep`
- `bash`
- `lsp`
- `Skill`
- `Task`
- `TaskOutput`
- `KillShell`
- `TodoWrite`
- `NotebookEdit`
- `EnterPlanMode`
- `ExitPlanMode`
- `WebFetch`
- `WebSearch`
- `analyze_image`
- `ask`
### Lime injected(7)
- `tool_search`
- `spawn_agent`
- `send_input`
- `wait_agent`
- `resume_agent`
- `close_agent`
- `SubAgentTask`
### Core 总数
- **26 个 catalog entries**
---
## 1.2 Creator surface 增量(8)
- `social_generate_cover_image`
- `lime_create_video_generation_task`
- `lime_create_broadcast_generation_task`
- `lime_create_cover_generation_task`
- `lime_create_modal_resource_search_task`
- `lime_create_image_generation_task`
- `lime_create_url_parse_task`
- `lime_create_typesetting_task`
### Creator 总数
- **34 个 catalog entries**
---
## 1.3 Browser Assist
目录层只保留一个兼容前缀:
- `mcp__lime-browser__*`
它不是一个单独真实工具,而是一组 browser runtime tools 的聚合入口。
参考 Aster `chrome_mcp/tools.rs`,当前浏览器工具定义为 **17 个**。
### Browser Assist 总数
- **27 个 catalog entries**
### Creator + Browser Assist 总数
- **35 个 catalog entries**
---
## 2. 默认授权子集
Core surface 当前默认 allow 的工具为:
- `Skill`
- `TaskOutput`
- `KillShell`
- `TodoWrite`
- `EnterPlanMode`
- `ExitPlanMode`
- `WebSearch`
- `ask`
- `tool_search`
- `spawn_agent`
- `send_input`
- `wait_agent`
- `resume_agent`
- `close_agent`
- `SubAgentTask`
结论:
- 默认 allowlist 是 **15 个**
- 明确排除了 `read` / `write` / `edit` / `bash` / `WebFetch` / `analyze_image` 这类需要参数约束或更强执行控制的工具
这符合“常驻工具面小而稳”的原则。
---
## 3. 新增库存命令
## 3.1 后端命令
- `agent_runtime_get_tool_inventory`
实现路径:
- `src-tauri/src/commands/aster_agent_cmd.rs`
- `src-tauri/src/agent_tools/inventory.rs`
## 3.2 前端 helper
- `src/lib/api/agentRuntime.ts`
- `getAgentRuntimeToolInventory(...)`
### 调用示例
```ts
import { getAgentRuntimeToolInventory } from "@/lib/api/agentRuntime";
const snapshot = await getAgentRuntimeToolInventory({
caller: "assistant",
creator: true,
browserAssist: true,
metadata: {
harness: {
executionPolicy: {
toolOverrides: {
bash: {
warningPolicy: "none",
},
},
},
},
},
});
```
## 3.3 返回内容
库存快照会同时返回:
- 请求 caller / surface
- 当前 agent 是否初始化
- warnings
- MCP servers
- 默认 allow 工具列表
- catalog tools
- runtime registry tools
- extension surfaces
- extension tools
- mcp tools
- catalog / registry 对应的 effective execution profile(warning / restriction / sandbox)
- 默认策略:`execution.rs`
- 持久化覆盖:`NativeAgentConfig.tool_execution`
- 运行时覆盖:`request.metadata.harness.executionPolicy`
- catalog / registry 对应的 provenance 字段:
- `execution_warning_policy_source`
- `execution_restriction_profile_source`
- `execution_sandbox_profile_source`
- counts
---
## 4. 这份库存解决了什么问题
过去你只能分别从这些地方猜测工具面:
- catalog
- registry
- mcp manager
- extension manager
- tool_search 输出
现在一条命令就能同时回答这些问题:
1. **静态目录里一共有多少工具?**
2. **当前 surface 下哪些是默认允许的?**
3. **Aster runtime registry 里实际注册了哪些工具?**
4. **哪些 runtime tools 没被 catalog 覆盖?**
5. **当前有哪些 extension surfaces?**
6. **哪些 extension tools 处于 deferred / loaded / visible?**
7. **MCP 真实运行了哪些 servers 和 tools?**
---
## 5. 分类建议
## 5.1 current
- `src-tauri/crates/core/src/tool_calling.rs`
- `src-tauri/src/agent_tools/catalog.rs`
- `src-tauri/src/agent_tools/execution.rs`
- `src-tauri/src/agent_tools/inventory.rs`
- `src-tauri/crates/mcp/src/manager.rs`
- `src-tauri/src/commands/aster_agent_cmd.rs`
## 5.2 compat
- `SubAgentTask`
- `workspace_allowed_tool_names(...)`
## 5.3 deprecated
当前目录层没有新增 deprecated 工具;建议不要提前扩充 deprecated 层。
## 5.4 dead-candidate
- `src-tauri/crates/agent/src/tool_permissions.rs`
- `src-tauri/crates/agent/src/shell_security.rs`
---
## 6. 建议的库存使用方式
### 6.1 PR / 回归检查
每次工具相关改动,至少回答:
- registry tools 是否出现 catalog 未覆盖项
- extension surface 是否混入不该存在的 caller
- MCP tools 是否无意中全部默认进入上下文
### 6.2 调试上下文爆炸
先看:
- `default_allowed_tools`
- `registry_visible_total`
- `extension_tool_visible_total`
- `mcp_tool_visible_total`
如果这些数字异常上升,说明“默认进入上下文”的面在膨胀。
### 6.3 调试权限错乱
先分清问题属于哪层:
- catalog 层:目录不全 / 生命周期错
- context 层:`deferred_loading` / `allowed_callers` 错
- execution 层:sandbox / approval / 参数限制错
此时优先看:
- `execution_warning_policy`
- `execution_restriction_profile`
- `execution_sandbox_profile`
- `execution_warning_policy_source`
- `execution_restriction_profile_source`
- `execution_sandbox_profile_source`
- `request.metadata` 是否传入了 runtime override
---
## 7. 结论
库存命令不是为了“多一个调试页面”,而是为了把工具系统从“猜”变成“看得见”。
只要 inventory 这层一直存在,后续无论工具从 20 个涨到 200 个,都还能保持:
- 工具目录可审计
- 上下文暴露可审计
- MCP 注入可审计
- 权限平面可审计
+68 -27
View File
@@ -1,14 +1,21 @@
# Lime 测试体系
> 基于 Anthropic AI Agent 评估指南与 Orchids Bridge 项目实践
> 面向 Lime 当前桌面端产品形态的测试入口与索引
## 概述
Lime 作为 AI API 代理和 Agent 集成平台,需要一套完整的测试体系来确保:
- API 代理的正确性和稳定性
- 凭证池管理的可靠性
- Aster Agent 集成的功能完整性
- 协议转换的准确性
Lime 当前是一个本地优先的 Tauri 桌面应用,而不是单一前端项目或单一 API 服务。
测试体系需要同时覆盖:
- 前端界面与工作台交互
- Tauri 命令边界
- Rust 服务层与业务逻辑
- 数据库、文件系统与工作区状态
- Provider、协议转换与本地 HTTP Server
- 浏览器运行时、终端、OpenClaw 等桌面能力
- Agent Runtime 与真实模型行为
- macOS / Windows 平台差异
## 测试分层
@@ -36,9 +43,10 @@ Lime 作为 AI API 代理和 Agent 集成平台,需要一套完整的测试体
```
docs/test/
├── README.md # 本文件 - 测试体系概览
├── testing-strategy-2026.md # 当前 Lime 主测试策略
├── unit-tests.md # 单元测试指南
├── integration-tests.md # 集成测试指南
├── e2e-tests.md # 端到端测试指南
├── e2e-tests.md # 浏览器续测与 E2E 总览
├── agent-evaluation.md # Agent 评估指南(核心文档)
└── test-cases/ # 测试用例模板
├── converter-tests.md # 协议转换器测试用例
@@ -48,15 +56,17 @@ docs/test/
## 文档索引
| 文档 | 说明 | 适用场景 |
|------|------|----------|
| [unit-tests.md](unit-tests.md) | 单元测试指南 | 独立模块测试 |
| [integration-tests.md](integration-tests.md) | 集成测试指南 | 模块间协作测试 |
| [e2e-tests.md](e2e-tests.md) | E2E 测试指南 | 完整用户流程测试 |
| [agent-evaluation.md](agent-evaluation.md) | Agent 评估指南 | AI Agent 行为评估 |
| [test-cases/converter-tests.md](test-cases/converter-tests.md) | 转换器测试用例 | OpenAI ↔ Claude 转换 |
| [test-cases/provider-tests.md](test-cases/provider-tests.md) | Provider 测试用例 | OAuth 和 API 调用 |
| [test-cases/agent-tests.md](test-cases/agent-tests.md) | Agent 测试用例 | Aster Agent 集成 |
| 文档 | 说明 | 适用场景 |
| ---------------------------------------------------------------- | -------------------------- | ------------------------------------- |
| [testing-strategy-2026.md](testing-strategy-2026.md) | 当前 Lime 测试体系建设建议 | 建立分层门禁、规划演进 |
| [unit-tests.md](unit-tests.md) | 单元测试指南 | 独立模块测试 |
| [integration-tests.md](integration-tests.md) | 集成测试指南 | 模块间协作测试 |
| [e2e-tests.md](e2e-tests.md) | 当前浏览器续测与 E2E 入口 | Playwright MCP / DevBridge 主路径验证 |
| [../aiprompts/playwright-e2e.md](../aiprompts/playwright-e2e.md) | 浏览器续测详细事实源 | 继续测试、复现、控制台与 Bridge 排障 |
| [agent-evaluation.md](agent-evaluation.md) | Agent 评估指南 | AI Agent 行为评估 |
| [test-cases/converter-tests.md](test-cases/converter-tests.md) | 转换器测试用例 | OpenAI ↔ Claude 转换 |
| [test-cases/provider-tests.md](test-cases/provider-tests.md) | Provider 测试用例 | OAuth 和 API 调用 |
| [test-cases/agent-tests.md](test-cases/agent-tests.md) | Agent 测试用例 | Aster Agent 集成 |
## 快速开始
@@ -72,6 +82,37 @@ cd src-tauri && cargo test
npm test
```
### 运行本地智能校验
```bash
npm run verify:local
```
### 运行本地全量校验
```bash
npm run verify:local:full
```
### 浏览器模式桥接检查
```bash
npm run bridge:health -- --timeout-ms 120000
```
### 运行首条自包含 smoke
```bash
npm run smoke:workspace-ready
```
### 当前浏览器续测入口
当前仓库的浏览器模式 E2E / 续测文档分两层:
- `docs/test/e2e-tests.md`:总览、命令矩阵、适用边界
- `docs/aiprompts/playwright-e2e.md`:详细操作流程与 Playwright MCP 续测事实源
### 运行代码检查
```bash
@@ -84,12 +125,12 @@ npm run lint
## 核心测试模块
| 模块 | 测试重点 | 文档 |
|------|----------|------|
| 协议转换 | OpenAI ↔ Claude 转换正确性 | [converter-tests.md](test-cases/converter-tests.md) |
| Provider 系统 | OAuth 刷新、API 调用 | [provider-tests.md](test-cases/provider-tests.md) |
| 凭证池 | 轮询、健康检查、负载均衡 | [integration-tests.md](integration-tests.md) |
| Aster Agent | 流式响应、工具调用 | [agent-tests.md](test-cases/agent-tests.md) |
| 模块 | 测试重点 | 文档 |
| ------------- | -------------------------- | --------------------------------------------------- |
| 协议转换 | OpenAI ↔ Claude 转换正确性 | [converter-tests.md](test-cases/converter-tests.md) |
| Provider 系统 | OAuth 刷新、API 调用 | [provider-tests.md](test-cases/provider-tests.md) |
| 凭证池 | 轮询、健康检查、负载均衡 | [integration-tests.md](integration-tests.md) |
| Aster Agent | 流式响应、工具调用 | [agent-tests.md](test-cases/agent-tests.md) |
## 测试原则
@@ -104,11 +145,11 @@ npm run lint
## 评分器类型
| 类型 | 适用场景 | 优点 | 缺点 |
|------|----------|------|------|
| **代码评分器** | 确定性验证 | 快速、可复现 | 对有效变体脆弱 |
| **模型评分器** | 语义评估 | 灵活、可扩展 | 非确定性、需校准 |
| **人工评分器** | 复杂判断 | 金标准质量 | 昂贵、慢 |
| 类型 | 适用场景 | 优点 | 缺点 |
| -------------- | ---------- | ------------ | ---------------- |
| **代码评分器** | 确定性验证 | 快速、可复现 | 对有效变体脆弱 |
| **模型评分器** | 语义评估 | 灵活、可扩展 | 非确定性、需校准 |
| **人工评分器** | 复杂判断 | 金标准质量 | 昂贵、慢 |
## 评估指标
+99 -249
View File
@@ -1,276 +1,126 @@
# Lime E2E 测试指南
# Lime 浏览器续测与 E2E 指南
> 端到端测试验证完整用户流程
> 本文只保留 Lime 当前仍有效的浏览器端 E2E 入口;详细操作与续测步骤以 `docs/aiprompts/playwright-e2e.md` 为准。
## 概述
## 1. 当前事实源
E2E 测试模拟真实用户操作,验证从前端到后端的完整流程。Lime 使用 Tauri 框架,E2E 测试需要覆盖:
### current
- 桌面应用启动和初始化
- 用户界面交互
- API 代理完整流程
- 凭证管理流程
- `docs/aiprompts/playwright-e2e.md`:浏览器续测、Playwright MCP 交互、DevBridge 排障的唯一详细事实源
- `npm run tauri:dev:headless`:当前浏览器模式启动入口
- `npm run bridge:health -- --timeout-ms 120000`:当前 DevBridge 就绪检查入口
- `npm run test:bridge`:当前浏览器桥接最小自动校验入口
- `npm run smoke:workspace-ready`:当前首条自包含 smoke,覆盖 DevBridge 就绪与默认 workspace 基础链路
## 测试框架
### supplement
### Tauri E2E 测试
- `npm run bridge:e2e`:偏排障性质的脚本,不是仓库统一 E2E 标准
- `npm run smoke:social-workbench`:现有专项 smoke,但仍依赖人工前置状态,暂不等于“自包含主链路冒烟”
使用 `tauri-driver` 进行自动化测试:
### deprecated
- `tauri-driver`:不再是当前仓库推荐的 E2E 方案
- `npm run test:e2e`:当前仓库已不存在,不应继续作为执行入口
## 2. 何时使用 E2E / 续测
以下场景优先走当前浏览器续测流程:
- 用户明确要求“继续测试”“继续复现”“继续用 Playwright MCP 验证”
- 需要复用已有页面状态或浏览器标签页
- 需要确认页面真实交互、控制台报错、DevBridge / mock fallback 行为
- 修改涉及前端页面主路径,而不是单一工具函数或纯后端逻辑
以下场景不要强行拉起整条 E2E:
- 只是模块级逻辑修改,可用单测或定向集成测试覆盖
- 只是 `safeInvoke`、mock、bridge 边界修改,且 `npm run test:bridge` 足以验证
- 只是命令注册 / 命令漂移问题,优先跑 `npm run test:contracts`
## 3. 当前标准流程
### 第 1 步:启动浏览器模式
```bash
# 安装依赖
cargo install tauri-driver
# 运行 E2E 测试
npm run test:e2e
npm run tauri:dev:headless
```
### 测试配置
用途:
```javascript
// playwright.config.ts
import { defineConfig } from "@playwright/test";
- 启动前端 dev server
- 启动 Tauri headless 环境
- 启动 DevBridge
- 让 Playwright MCP 可访问 `http://127.0.0.1:1420/`
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30000,
use: {
baseURL: "tauri://localhost",
},
});
```
## 测试场景
### 1. 应用启动流程
```typescript
import { test, expect } from "@playwright/test";
test.describe("应用启动", () => {
test("应用正常启动并显示主界面", async ({ page }) => {
// 等待应用加载
await page.waitForSelector('[data-testid="main-layout"]');
// 验证核心组件存在
await expect(page.locator('[data-testid="sidebar"]')).toBeVisible();
await expect(page.locator('[data-testid="content-area"]')).toBeVisible();
});
test("首次启动显示欢迎引导", async ({ page }) => {
// 清除本地存储模拟首次启动
await page.evaluate(() => localStorage.clear());
await page.reload();
await expect(page.locator('[data-testid="welcome-modal"]')).toBeVisible();
});
});
```
### 2. 凭证管理流程
```typescript
test.describe("凭证管理", () => {
test("添加 Kiro 凭证", async ({ page }) => {
// 打开凭证管理
await page.click('[data-testid="credentials-tab"]');
await page.click('[data-testid="add-credential-btn"]');
// 选择 Provider
await page.click('[data-testid="provider-kiro"]');
// 上传凭证文件
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles("./tests/fixtures/test-credential.json");
// 验证凭证添加成功
await expect(page.locator('[data-testid="credential-item"]')).toBeVisible();
await expect(page.locator("text=test@example.com")).toBeVisible();
});
test("删除凭证", async ({ page }) => {
// 假设已有凭证
await page.click('[data-testid="credentials-tab"]');
// 删除凭证
await page.click('[data-testid="credential-menu"]');
await page.click('[data-testid="delete-credential"]');
await page.click('[data-testid="confirm-delete"]');
// 验证凭证已删除
await expect(
page.locator('[data-testid="credential-item"]'),
).not.toBeVisible();
});
});
```
### 3. API 代理流程
```typescript
test.describe("API 代理", () => {
test("启动代理服务器", async ({ page }) => {
await page.click('[data-testid="server-tab"]');
await page.click('[data-testid="start-server-btn"]');
// 等待服务器启动
await expect(page.locator("text=服务器运行中")).toBeVisible();
await expect(page.locator('[data-testid="server-port"]')).toContainText(
"8080",
);
});
test("代理请求成功", async ({ page, request }) => {
// 启动服务器
await page.click('[data-testid="start-server-btn"]');
await page.waitForSelector("text=服务器运行中");
// 发送测试请求
const response = await request.post(
"http://localhost:8080/v1/chat/completions",
{
headers: {
"Content-Type": "application/json",
Authorization: "Bearer test-key",
},
data: {
model: "gpt-4",
messages: [{ role: "user", content: "Hello" }],
},
},
);
expect(response.ok()).toBeTruthy();
});
});
```
### 4. Agent 对话流程
```typescript
test.describe("Agent 对话", () => {
test("发送消息并接收响应", async ({ page }) => {
await page.click('[data-testid="agent-tab"]');
// 输入消息
await page.fill('[data-testid="message-input"]', "你好,请介绍一下自己");
await page.click('[data-testid="send-btn"]');
// 等待响应
await expect(page.locator('[data-testid="assistant-message"]')).toBeVisible(
{
timeout: 30000,
},
);
});
test("流式响应正确显示", async ({ page }) => {
await page.click('[data-testid="agent-tab"]');
await page.fill('[data-testid="message-input"]', "写一首短诗");
await page.click('[data-testid="send-btn"]');
// 验证流式显示(内容逐渐增加)
const messageEl = page.locator('[data-testid="assistant-message"]');
let prevLength = 0;
for (let i = 0; i < 5; i++) {
await page.waitForTimeout(500);
const text = await messageEl.textContent();
expect(text?.length).toBeGreaterThan(prevLength);
prevLength = text?.length || 0;
}
});
});
```
## 测试数据管理
### Fixtures
```
tests/
├── fixtures/
│ ├── test-credential.json # 测试凭证
│ ├── mock-responses/ # Mock API 响应
│ │ ├── chat-completion.json
│ │ └── streaming-response.txt
│ └── test-config.json # 测试配置
└── e2e/
└── *.spec.ts
```
### Mock 服务
```typescript
// tests/mocks/api-server.ts
import { setupServer } from "msw/node";
import { rest } from "msw";
export const mockServer = setupServer(
rest.post("*/v1/chat/completions", (req, res, ctx) => {
return res(
ctx.json({
id: "test-id",
choices: [
{
message: { role: "assistant", content: "Mock response" },
},
],
}),
);
}),
);
```
## 运行 E2E 测试
### 第 2 步:等待桥接就绪
```bash
# 构建应用
npm run build
# 运行 E2E 测试
npm run test:e2e
# 运行特定测试
npm run test:e2e -- --grep "凭证管理"
# 生成测试报告
npm run test:e2e -- --reporter=html
npm run bridge:health -- --timeout-ms 120000
```
## CI/CD 集成
用途:
```yaml
# .github/workflows/e2e.yml
name: E2E Tests
- 等待 `http://127.0.0.1:3030/health` 可用
- 降低页面早于 DevBridge 就绪时的 `Failed to fetch` 噪音
on: [push, pull_request]
### 第 3 步:使用 Playwright MCP 进入页面
jobs:
e2e:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
标准入口:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- 打开 `http://127.0.0.1:1420/`
- 等待“正在加载...”消失
- 确认默认首页已出现
- 检查一次 `browser_console_messages(level=error)`
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
### 第 4 步:沿主路径做最小验证
- name: Install dependencies
run: npm ci
当前优先验证以下路径:
- name: Build app
run: npm run build
1. 首页可加载,主导航可见
2. 社媒内容工作流可进入
3. 页面交互后控制台不新增关键 error
- name: Run E2E tests
run: npm run test:e2e
```
详细点击路径、控制台检查要求、交接格式,以 `docs/aiprompts/playwright-e2e.md` 为准。
## 下一步
## 4. 当前命令矩阵
- [Agent 评估指南](agent-evaluation.md)
- [测试用例:Agent](test-cases/agent-tests.md)
| 目标 | 命令 / 入口 | 角色 | 说明 |
| ---------------------- | ---------------------------------------------- | ---------- | ----------------------------------------------- |
| 启动浏览器模式 | `npm run tauri:dev:headless` | current | 当前标准启动命令 |
| 等待 Bridge 就绪 | `npm run bridge:health -- --timeout-ms 120000` | current | 当前标准健康检查 |
| 校验桥接基础能力 | `npm run test:bridge` | current | `safeInvoke` / mock / tauri-mock 最小自动校验 |
| Workspace 自包含 smoke | `npm run smoke:workspace-ready` | current | 验证 DevBridge、默认 workspace、路径回查链路 |
| 校验跨层命令契约 | `npm run test:contracts` | current | 检查前端命令、Rust 注册、catalog、mock 集合漂移 |
| 浏览器续测细则 | `docs/aiprompts/playwright-e2e.md` | current | Playwright MCP 唯一详细事实源 |
| 专项 bridge 排障 | `npm run bridge:e2e` | supplement | 适合排障,不是统一门禁 |
| 社媒内容专项 smoke | `npm run smoke:social-workbench` | supplement | 仍非自包含,不应冒充标准 E2E |
| 旧 E2E 命令 | `npm run test:e2e` | deprecated | 当前仓库不存在 |
## 5. 当前验证标准
一次有效的浏览器续测 / E2E 至少满足以下之一:
1. 主路径走通且控制台 error 归零
2. 主路径走通,且剩余错误已明确归类为非阻塞项
3. 已定位新的 bridge / mock / 命令注册缺口,并给出下一步最小修复点
## 6. 当前不做的假设
本文不再把以下内容当成当前标准:
- 假设仓库已接入本地 Playwright 测试目录与统一 `test:e2e` 命令
- 假设 `tauri-driver` 仍是推荐路径
- 假设浏览器 E2E 已进入 CI 标准门禁
当前 PR 门禁以 `.github/workflows/pr-gate.yml` 为准;完整浏览器主链路 smoke 仍属于后续建设项,详见 `docs/test/testing-strategy-2026.md`。
## 7. 给后续 Agent 的交接要求
如果本轮没有完全收口,请至少留下:
- 当前页面 URL
- 已完成的业务步骤
- 控制台 error 数量
- 是否走到了真实 bridge 或 mock fallback
- 最新暴露的命令缺口
- 下一轮应先补 mock、bridge,还是命令注册
+89
View File
@@ -0,0 +1,89 @@
# Lime 测试体系待办(2026)
> 本文件只保留当前仍未解决的测试问题;已落地能力已从优先级清单移除。
## 1. 事实源与分类
### current
以下路径已经是当前测试体系的事实源,不再作为“待建设能力”重复列入:
- `docs/test/README.md`:当前测试入口与命令索引
- `docs/test/e2e-tests.md`:当前浏览器续测与 E2E 总览入口
- `docs/aiprompts/playwright-e2e.md`:当前浏览器续测 / Playwright MCP 事实源
- `package.json`:当前统一测试命令入口
- `scripts/local-ci.mjs`:当前本地智能校验入口
- `scripts/report-legacy-surfaces.mjs`:当前 legacy / compat 回流护栏
- `.github/workflows/pr-gate.yml`:当前 PR 自动门禁入口
### compat
- 当前无仍需保留的 E2E compat 文档
### deprecated
- `tauri-driver` 作为仓库推荐 E2E 方案的说法
- `npm run test:e2e` 作为现行测试入口的说法
### dead
- `npm run test:e2e` 作为现行仓库命令已不存在,不应继续作为测试标准引用
## 2. 已从待办移除的事项
以下能力已具备基础,不再保留在优先级清单中:
- 前端 `Vitest` 覆盖已经足够广,`src/components`、`src/hooks`、`src/lib/api`、`src/features/browser-runtime` 等已有大量测试
- Rust 单测 / 集成测试基础已经存在,`src-tauri/src` 与多个 workspace crate 都有可运行测试
- 本地统一校验入口已经存在:`test:frontend`、`test:bridge`、`test:rust`、`verify:local`、`verify:local:full`
- 桥接基础测试已经存在:`src/lib/dev-bridge/safeInvoke.test.ts`、`src/lib/tauri-mock/core.test.ts`
- legacy 治理护栏已经存在:`npm run governance:legacy-report`
- 旧权限表面治理护栏已经补齐:`src/lib/governance/legacyToolPermissionGuard.test.ts` + `npm run governance:legacy-report`
- 跨层命令契约检查基础版已经落地:`npm run test:contracts` 已进入 `scripts/local-ci.mjs` 与 `.github/workflows/pr-gate.yml`
- 命令契约延期例外已经收口:`agent_terminal_command_response`、`agent_term_scrollback_response` 已退出 `runtimeGatewayCommands`,改为 `dead-candidate` 治理监控
- 首条自包含 smoke 已落地:`npm run smoke:workspace-ready` 可自动校验 DevBridge 就绪、默认 workspace 获取、目录修复与路径回查
- 测试文档事实源已经收口:`docs/test/README.md`、`docs/test/e2e-tests.md`、`docs/aiprompts/playwright-e2e.md` 已按“索引 / 总览 / 详细事实源”分层
- PR 自动门禁已经补齐:`.github/workflows/pr-gate.yml` 已覆盖前端、bridge、Rust 三类基础检查
## 3. 当前仍未解决的问题优先级
| 优先级 | 事项 | 为什么重要 | 当前证据 | 完成定义 |
| ------ | --------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| P0 | 自包含 smoke 仍然不足 | 单测很多,但主链路仍缺少无需人工准备的自动回归 | 目前仅有 `smoke:workspace-ready` 属于自包含 smoke;`smoke:social-workbench` 仍依赖已有 session,`bridge:e2e` 更像排障脚本 | 至少补齐 3 条无需人工准备的 smoke;当前已完成 1 条,仍需补 server / terminal / browser runtime 等 2 条以上 |
| P1 | Agent eval 尚未工程化 | 价值高,但建立在前面基础门禁稳定之后 | 仓库已有理念和局部真实测试,但缺少任务集、grader、nightly 报表 | 形成固定任务集、采样归档、grader、nightly 输出与趋势指标 |
## 4. 建议执行顺序
### 第 1 步:把 smoke 升级为自包含场景
先只挑 3 条最高价值场景,不要贪多:
1. 应用启动 + workspace 可创建 / 打开
2. server 基础链路可自动打通
3. terminal 或 browser runtime 至少有一条基础链路可自动打通
验收标准是“本地和 CI 都能重复执行”,而不是“方便人工排障”。
### 第 2 步:把 Agent eval 工程化
这一步放在最后,不是因为不重要,而是它依赖前面的基础设施稳定:
- 有稳定门禁
- 有稳定契约检查
- 有可重复 smoke
完成后再上:
- 固定任务集
- transcript 存档
- grader
- nightly 报表
## 5. 当前建议
如果只看投入产出比,当前最值得先做的两刀是:
1. 把 smoke 升级为自包含场景
2. 把 Agent eval 工程化
这两步做完之后,再继续往 nightly 与趋势报表收口,收益会更高。
+10 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "0.91.0",
"version": "0.92.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
@@ -28,14 +28,22 @@
"tauri:dev:profile:trace-console": "node scripts/run-tauri-profile.mjs trace-console",
"tauri:dev:profile:trace-console:devtools": "node scripts/run-tauri-profile.mjs trace-console --open-devtools",
"lint": "eslint src --max-warnings 0",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"prepare": "husky",
"test": "vitest --run",
"test:watch": "vitest",
"test:frontend": "npm run lint && npm run typecheck && npm test",
"test:bridge": "npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts",
"test:contracts": "node scripts/check-command-contracts.mjs",
"test:rust": "cargo test --manifest-path \"src-tauri/Cargo.toml\"",
"lint:rust": "cargo clippy --manifest-path \"src-tauri/Cargo.toml\"",
"detect-translations": "tsx scripts/detect-missing-translations.ts",
"detect-translations:fix": "tsx scripts/detect-missing-translations.ts --fix",
"detect-translations:verbose": "tsx scripts/detect-missing-translations.ts --verbose",
"verify:app-version": "node scripts/check-app-version-consistency.mjs",
"verify:local": "node scripts/local-ci.mjs",
"verify:local:full": "node scripts/local-ci.mjs --full",
"ai-verify": "tsx scripts/ai-code-verify.ts",
"ai-verify:level1": "tsx scripts/ai-code-verify.ts --level 1",
"ai-verify:level2": "tsx scripts/ai-code-verify.ts --level 2",
@@ -43,6 +51,7 @@
"ai-verify:file": "tsx scripts/ai-code-verify.ts --files",
"bridge:e2e": "node scripts/chrome-bridge-e2e.mjs",
"bridge:health": "node scripts/check-dev-bridge-health.mjs",
"smoke:workspace-ready": "node scripts/workspace-ready-smoke.mjs",
"smoke:social-workbench": "node scripts/social-workbench-e2e-smoke.mjs",
"dev:web-bridge": "node scripts/start-web-bridge-dev.mjs",
"governance:legacy-report": "node scripts/report-legacy-surfaces.mjs",
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const repoRoot = process.cwd();
const sourceRoots = ["src"];
const sourceExtensions = new Set([".ts", ".tsx", ".js", ".jsx"]);
const ignoredDirectories = new Set([
".git",
".idea",
".vscode",
"coverage",
"dist",
"docs",
"node_modules",
"target",
]);
const frontendCommandPatterns = [
/\bsafeInvoke(?:<[^>]+>)?\s*\(\s*["'`]([^"'`]+)["'`]/g,
/\binvoke(?:<[^>]+>)?\s*\(\s*["'`]([^"'`]+)["'`]/g,
];
const knownDeferredRegistrationReasons = new Map();
function normalizePath(filePath) {
return filePath.split(path.sep).join("/");
}
function isRuntimeSource(relativePath) {
const normalizedPath = normalizePath(relativePath);
const extension = path.extname(normalizedPath);
if (!sourceExtensions.has(extension)) {
return false;
}
if (normalizedPath.endsWith(".d.ts")) {
return false;
}
if (
normalizedPath.includes("/__tests__/") ||
normalizedPath.includes("/__mocks__/") ||
/\.test\.[^.]+$/.test(normalizedPath) ||
/\.spec\.[^.]+$/.test(normalizedPath)
) {
return false;
}
return true;
}
function walkDirectory(rootDirectory) {
const results = [];
if (!fs.existsSync(rootDirectory)) {
return results;
}
const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
for (const entry of entries) {
if (ignoredDirectories.has(entry.name)) {
continue;
}
const absolutePath = path.join(rootDirectory, entry.name);
if (entry.isDirectory()) {
results.push(...walkDirectory(absolutePath));
continue;
}
const relativePath = normalizePath(path.relative(repoRoot, absolutePath));
if (isRuntimeSource(relativePath)) {
results.push(relativePath);
}
}
return results;
}
function addUsage(map, command, relativePath) {
if (!map.has(command)) {
map.set(command, new Set());
}
map.get(command).add(relativePath);
}
function extractCommandsFromSource(sourceCode) {
const commands = new Set();
for (const pattern of frontendCommandPatterns) {
for (const match of sourceCode.matchAll(pattern)) {
commands.add(match[1]);
}
}
return commands;
}
function collectFrontendCommandUsage() {
const commandUsage = new Map();
for (const root of sourceRoots) {
const absoluteRoot = path.join(repoRoot, root);
for (const relativePath of walkDirectory(absoluteRoot)) {
const absolutePath = path.join(repoRoot, relativePath);
const sourceCode = fs.readFileSync(absolutePath, "utf8");
for (const command of extractCommandsFromSource(sourceCode)) {
addUsage(commandUsage, command, relativePath);
}
}
}
return commandUsage;
}
function extractBalancedBlock(sourceCode, startIndex, openChar, closeChar) {
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inTemplateString = false;
let inLineComment = false;
let inBlockComment = false;
let escaped = false;
for (let index = startIndex; index < sourceCode.length; index += 1) {
const currentChar = sourceCode[index];
const nextChar = sourceCode[index + 1];
if (inLineComment) {
if (currentChar === "\n") {
inLineComment = false;
}
continue;
}
if (inBlockComment) {
if (currentChar === "*" && nextChar === "/") {
inBlockComment = false;
index += 1;
}
continue;
}
if (inSingleQuote) {
if (!escaped && currentChar === "'") {
inSingleQuote = false;
}
escaped = !escaped && currentChar === "\\";
continue;
}
if (inDoubleQuote) {
if (!escaped && currentChar === '"') {
inDoubleQuote = false;
}
escaped = !escaped && currentChar === "\\";
continue;
}
if (inTemplateString) {
if (!escaped && currentChar === "`") {
inTemplateString = false;
}
escaped = !escaped && currentChar === "\\";
continue;
}
if (currentChar === "/" && nextChar === "/") {
inLineComment = true;
index += 1;
continue;
}
if (currentChar === "/" && nextChar === "*") {
inBlockComment = true;
index += 1;
continue;
}
if (currentChar === "'") {
inSingleQuote = true;
escaped = false;
continue;
}
if (currentChar === '"') {
inDoubleQuote = true;
escaped = false;
continue;
}
if (currentChar === "`") {
inTemplateString = true;
escaped = false;
continue;
}
if (currentChar === openChar) {
depth += 1;
continue;
}
if (currentChar === closeChar) {
depth -= 1;
if (depth === 0) {
return sourceCode.slice(startIndex + 1, index);
}
}
}
throw new Error(`无法提取 ${openChar}${closeChar} 平衡块`);
}
function collectRegisteredCommands() {
const runnerPath = path.join(repoRoot, "src-tauri/src/app/runner.rs");
const sourceCode = fs.readFileSync(runnerPath, "utf8");
const marker = "tauri::generate_handler![";
const markerIndex = sourceCode.indexOf(marker);
if (markerIndex < 0) {
throw new Error("未找到 tauri::generate_handler! 注册块");
}
const bracketStart = markerIndex + marker.length - 1;
const handlerBody = extractBalancedBlock(sourceCode, bracketStart, "[", "]");
const registeredCommands = new Set();
const withoutBlockComments = handlerBody.replace(/\/\*[\s\S]*?\*\//g, "");
for (const line of withoutBlockComments.split("\n")) {
const trimmedLine = line.replace(/\/\/.*$/, "").trim();
if (!trimmedLine) {
continue;
}
const match = trimmedLine.match(/^([A-Za-z0-9_:]+)\s*,?$/);
if (!match) {
continue;
}
const fullPath = match[1];
const command = fullPath.split("::").pop();
if (command) {
registeredCommands.add(command);
}
}
return registeredCommands;
}
function collectMockPriorityCommands() {
const filePath = path.join(
repoRoot,
"src/lib/dev-bridge/mockPriorityCommands.ts",
);
const sourceCode = fs.readFileSync(filePath, "utf8");
const match = sourceCode.match(
/const mockPriorityCommands = new Set<string>\(\[([\s\S]*?)\]\);/,
);
if (!match) {
throw new Error("未找到 mockPriorityCommands 定义");
}
const commands = new Set();
for (const stringMatch of match[1].matchAll(/["'`]([^"'`]+)["'`]/g)) {
commands.add(stringMatch[1]);
}
return commands;
}
function collectDefaultMockCommands() {
const filePath = path.join(repoRoot, "src/lib/tauri-mock/core.ts");
const sourceCode = fs.readFileSync(filePath, "utf8");
const marker = "const defaultMocks: Record<string, any> = {";
const markerIndex = sourceCode.indexOf(marker);
if (markerIndex < 0) {
throw new Error("未找到 tauri-mock defaultMocks 定义");
}
const braceStart = markerIndex + marker.length - 1;
const objectBody = extractBalancedBlock(sourceCode, braceStart, "{", "}");
const mockCommands = new Set();
for (const match of objectBody.matchAll(/^ ([A-Za-z0-9_]+)\s*:/gm)) {
mockCommands.add(match[1]);
}
return mockCommands;
}
function readAgentCommandCatalog() {
const catalogPath = path.join(
repoRoot,
"src/lib/governance/agentCommandCatalog.json",
);
return JSON.parse(fs.readFileSync(catalogPath, "utf8"));
}
function sortCommands(commands) {
return [...commands].sort((left, right) => left.localeCompare(right));
}
function printCommandGroup(title, commands, usageMap) {
console.error(`\n## ${title}`);
for (const command of sortCommands(commands)) {
console.error(`- ${command}`);
if (usageMap?.has(command)) {
const files = sortCommands(usageMap.get(command));
for (const file of files) {
console.error(` - ${file}`);
}
}
}
}
function main() {
const frontendUsage = collectFrontendCommandUsage();
const frontendCommands = new Set(frontendUsage.keys());
const registeredCommands = collectRegisteredCommands();
const mockPriorityCommands = collectMockPriorityCommands();
const defaultMockCommands = collectDefaultMockCommands();
const agentCommandCatalog = readAgentCommandCatalog();
const deprecatedCommands = new Set(
Object.keys(agentCommandCatalog.deprecatedCommandReplacements ?? {}),
);
const runtimeGatewayCommands = new Set(
agentCommandCatalog.runtimeGatewayCommands ?? [],
);
const deferredCommands = new Set(knownDeferredRegistrationReasons.keys());
const missingRegistrations = new Set(
[...frontendCommands].filter(
(command) =>
!registeredCommands.has(command) && !deferredCommands.has(command),
),
);
const deprecatedCommandsStillUsed = new Set(
[...frontendCommands].filter((command) => deprecatedCommands.has(command)),
);
const mockPriorityMissingMocks = new Set(
[...mockPriorityCommands].filter(
(command) => !defaultMockCommands.has(command),
),
);
const mockPriorityMissingRegistrations = new Set(
[...mockPriorityCommands].filter(
(command) =>
!registeredCommands.has(command) && !deferredCommands.has(command),
),
);
const runtimeGatewayMissingRegistrations = new Set(
[...runtimeGatewayCommands].filter(
(command) =>
!registeredCommands.has(command) && !deferredCommands.has(command),
),
);
console.log("[command-contracts] frontend commands:", frontendCommands.size);
console.log(
"[command-contracts] rust registered commands:",
registeredCommands.size,
);
console.log(
"[command-contracts] mock priority commands:",
mockPriorityCommands.size,
);
console.log(
"[command-contracts] default mock commands:",
defaultMockCommands.size,
);
if (knownDeferredRegistrationReasons.size > 0) {
console.log("\n[command-contracts] 已登记的延期命令:");
for (const command of sortCommands(
knownDeferredRegistrationReasons.keys(),
)) {
console.log(`- ${command}`);
console.log(` ${knownDeferredRegistrationReasons.get(command)}`);
}
}
let hasError = false;
if (missingRegistrations.size > 0) {
hasError = true;
printCommandGroup(
"前端调用但未注册的命令",
missingRegistrations,
frontendUsage,
);
}
if (deprecatedCommandsStillUsed.size > 0) {
hasError = true;
printCommandGroup(
"前端仍在调用的废弃命令",
deprecatedCommandsStillUsed,
frontendUsage,
);
}
if (mockPriorityMissingMocks.size > 0) {
hasError = true;
printCommandGroup("mock 优先命令缺少 mock 实现", mockPriorityMissingMocks);
}
if (mockPriorityMissingRegistrations.size > 0) {
hasError = true;
printCommandGroup(
"mock 优先命令缺少 Rust 注册",
mockPriorityMissingRegistrations,
);
}
if (runtimeGatewayMissingRegistrations.size > 0) {
hasError = true;
printCommandGroup(
"runtime gateway 命令缺少 Rust 注册",
runtimeGatewayMissingRegistrations,
);
}
if (hasError) {
process.exitCode = 1;
return;
}
console.log("\n[command-contracts] 所有命令契约检查通过。");
}
main();
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env node
import { execFileSync, spawnSync } from "node:child_process";
import process from "node:process";
const options = parseArgs(process.argv.slice(2));
const rootDir = process.cwd();
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const cargoCommand = process.platform === "win32" ? "cargo.exe" : "cargo";
const gitCommand = process.platform === "win32" ? "git.exe" : "git";
const FRONTEND_ROOT_FILES = new Set([
"package.json",
"package-lock.json",
"vite.config.ts",
"tsconfig.json",
"tsconfig.node.json",
"eslint.config.js",
"tailwind.config.js",
"postcss.config.js",
"index.html",
]);
const BRIDGE_FILES = new Set([
"vite.config.ts",
"scripts/check-dev-bridge-health.mjs",
"scripts/social-workbench-e2e-smoke.mjs",
"scripts/chrome-bridge-e2e.mjs",
"docs/aiprompts/playwright-e2e.md",
]);
function parseArgs(argv) {
const result = {
full: false,
staged: false,
base: "",
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--full") {
result.full = true;
continue;
}
if (arg === "--staged") {
result.staged = true;
continue;
}
if (arg === "--base" && argv[index + 1]) {
result.base = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--help" || arg === "-h") {
result.help = true;
}
}
return result;
}
function printHelp() {
console.log(`
Lime 本地校验入口
用法:
npm run verify:local
npm run verify:local -- --staged
npm run verify:local -- --base origin/main
npm run verify:local:full
选项:
--full 忽略改动检测,执行全量本地校验
--staged 仅基于已暂存文件判断要跑的检查
--base REF 基于指定基线计算改动文件
-h, --help 显示帮助
`);
}
function runCommand(command, args) {
console.log(`\n[local-ci] > ${command} ${args.join(" ")}`);
const result = spawnSync(command, args, {
cwd: rootDir,
stdio: "inherit",
env: process.env,
});
if (typeof result.status === "number" && result.status !== 0) {
process.exit(result.status);
}
if (result.error) {
throw result.error;
}
}
function gitOutput(args) {
try {
return execFileSync(gitCommand, args, {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return "";
}
}
function splitLines(value) {
if (!value) {
return [];
}
return value
.split("\n")
.map((item) => item.trim())
.filter(Boolean);
}
function resolveDiffBase() {
if (options.base) {
return options.base;
}
const upstream = gitOutput(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
if (upstream) {
return upstream;
}
for (const candidate of ["origin/main", "origin/master", "main", "master"]) {
const exists = gitOutput(["rev-parse", "--verify", candidate]);
if (exists) {
return candidate;
}
}
return "";
}
function collectChangedFiles() {
if (options.full) {
return [];
}
if (options.staged) {
return uniquePaths(
splitLines(gitOutput(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])),
);
}
const base = resolveDiffBase();
const candidates = [];
if (base) {
candidates.push(
...splitLines(gitOutput(["diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`])),
);
}
candidates.push(
...splitLines(gitOutput(["diff", "--name-only", "--diff-filter=ACMR", "HEAD"])),
);
candidates.push(
...splitLines(gitOutput(["ls-files", "--others", "--exclude-standard"])),
);
return uniquePaths(candidates);
}
function uniquePaths(paths) {
return Array.from(new Set(paths));
}
function isFrontendChange(file) {
return (
file.startsWith("src/") ||
FRONTEND_ROOT_FILES.has(file)
);
}
function isRustChange(file) {
return file.startsWith("src-tauri/");
}
function isBridgeChange(file) {
return (
file.startsWith("src/lib/dev-bridge/") ||
file.startsWith("src/lib/tauri-mock/") ||
BRIDGE_FILES.has(file)
);
}
function isDocsOnlyChange(files) {
return files.length > 0 && files.every((file) => file.startsWith("docs/"));
}
function detectTasks(changedFiles) {
if (options.full) {
return {
frontend: true,
rust: true,
bridge: true,
};
}
if (changedFiles.length === 0) {
return {
frontend: true,
rust: true,
bridge: true,
fallback: true,
};
}
if (isDocsOnlyChange(changedFiles)) {
return {
frontend: false,
rust: false,
bridge: false,
docsOnly: true,
};
}
const frontend = changedFiles.some(isFrontendChange);
const rust = changedFiles.some(isRustChange);
const bridge = changedFiles.some(isBridgeChange);
return {
frontend,
rust,
bridge,
};
}
function printSummary(changedFiles, tasks) {
console.log("[local-ci] 模式:", options.full ? "full" : "smart");
if (!options.full) {
console.log("[local-ci] 检测到改动文件数:", changedFiles.length);
if (changedFiles.length > 0) {
const preview = changedFiles.slice(0, 12);
for (const file of preview) {
console.log(`[local-ci] - ${file}`);
}
if (changedFiles.length > preview.length) {
console.log(`[local-ci] ... 其余 ${changedFiles.length - preview.length} 个文件省略`);
}
}
}
if (tasks.docsOnly) {
console.log("[local-ci] 当前仅检测到文档改动,跳过本地代码校验。");
return;
}
console.log("[local-ci] 计划执行:");
if (tasks.frontend) {
console.log("[local-ci] - 前端校验");
}
if (tasks.bridge) {
console.log("[local-ci] - bridge 校验");
}
if (tasks.rust) {
console.log("[local-ci] - Rust 校验");
}
if (tasks.fallback) {
console.log("[local-ci] - 未检测到改动,执行全量兜底校验");
}
}
function runSelectedTasks(tasks) {
if (tasks.docsOnly) {
return;
}
if (tasks.frontend) {
runCommand(npmCommand, ["run", "lint"]);
runCommand(npmCommand, ["run", "typecheck"]);
runCommand(npmCommand, ["test"]);
}
if (tasks.bridge) {
if (!tasks.frontend) {
runCommand(npmCommand, ["run", "test:bridge"]);
}
runCommand(npmCommand, ["run", "test:contracts"]);
}
if (tasks.rust) {
runCommand(cargoCommand, ["test", "--manifest-path", "src-tauri/Cargo.toml"]);
if (options.full) {
runCommand(cargoCommand, ["clippy", "--manifest-path", "src-tauri/Cargo.toml"]);
}
}
}
function main() {
if (options.help) {
printHelp();
return;
}
const changedFiles = collectChangedFiles();
const tasks = detectTasks(changedFiles);
printSummary(changedFiles, tasks);
runSelectedTasks(tasks);
console.log("\n[local-ci] 本地校验完成。");
}
main();
+204 -40
View File
@@ -172,6 +172,27 @@ const importSurfaceMonitors = [
targets: ["src/lib/api/contextMemory.ts"],
allowedPaths: [],
},
{
id: "team-subagent-scheduler-hook",
classification: "compat",
description: "旧 SubAgent scheduler Hook 只允许停留在 compat 展示层",
targets: ["src/hooks/useSubAgentScheduler.ts"],
allowedPaths: [
"src/components/agent/chat/hooks/useCompatSubagentRuntime.ts",
"src/components/subagent/SubAgentProgress.tsx",
"src/components/subagent/index.ts",
],
},
{
id: "team-subagent-scheduler-api",
classification: "compat",
description: "旧 SubAgent scheduler API 只允许被 compat Hook 与降级展示层引用",
targets: ["src/lib/api/subAgentScheduler.ts"],
allowedPaths: [
"src/hooks/useSubAgentScheduler.ts",
"src/components/agent/chat/utils/compatSubagentRuntime.ts",
],
},
];
const commandSurfaceMonitors = [
@@ -293,10 +314,24 @@ const commandSurfaceMonitors = [
],
allowedPaths: [],
},
{
id: "team-subagent-scheduler-commands",
classification: "compat",
description: "旧 execute_subagent_tasks/cancel_subagent_tasks 只允许通过 compat API 网关暴露",
commands: ["execute_subagent_tasks", "cancel_subagent_tasks"],
allowedPaths: ["src/lib/api/subAgentScheduler.ts"],
},
];
const frontendTextSurfaceMonitors = [
...agentLegacyHelperSurfaceMonitors,
{
id: "frontend-subagent-scheduler-event-bus",
classification: "compat",
description: "旧 subagent scheduler 事件名只允许 compat Hook 持有",
patterns: ["subagent-scheduler-event"],
allowedPaths: ["src/hooks/useSubAgentScheduler.ts"],
},
{
id: "frontend-assistant-settings-surfaces",
classification: "dead-candidate",
@@ -323,6 +358,13 @@ const frontendTextSurfaceMonitors = [
];
const rustTextSurfaceMonitors = [
{
id: "rust-subagent-scheduler-event-bus",
classification: "compat",
description: "旧 subagent scheduler 事件名只允许 compat Rust emitter 持有",
patterns: ["subagent-scheduler-event"],
allowedPaths: ["src-tauri/src/agent/subagent_scheduler.rs"],
},
{
id: "rust-general-chat-dao",
classification: "dead-candidate",
@@ -378,7 +420,8 @@ const rustTextSurfaceMonitors = [
{
id: "rust-legacy-general-module-imports",
classification: "dead-candidate",
description: "已零引用的 Rust 外部模块 direct pending/legacy general 子模块",
description:
"已零引用的 Rust 外部模块 direct pending/legacy general 子模块",
patterns: [
"crate::database::legacy_general_chat::",
"lime_core::database::legacy_general_chat::",
@@ -441,7 +484,8 @@ const rustTextSurfaceMonitors = [
{
id: "rust-memory-profile-prompt-helper-leak",
classification: "deprecated",
description: "低层 build_memory_profile_prompt helper 泄漏到统一装配边界之外",
description:
"低层 build_memory_profile_prompt helper 泄漏到统一装配边界之外",
patterns: ["build_memory_profile_prompt("],
includePathPrefixes: ["src-tauri/src"],
allowedPaths: ["src-tauri/src/services/memory_profile_prompt_service.rs"],
@@ -449,7 +493,8 @@ const rustTextSurfaceMonitors = [
{
id: "rust-memory-sources-prompt-helper-leak",
classification: "deprecated",
description: "低层 build_memory_sources_prompt helper 泄漏到统一装配边界之外",
description:
"低层 build_memory_sources_prompt helper 泄漏到统一装配边界之外",
patterns: ["build_memory_sources_prompt("],
includePathPrefixes: ["src-tauri/src"],
allowedPaths: [
@@ -525,7 +570,8 @@ const rustTextSurfaceMonitors = [
{
id: "rust-skill-runtime-command-bootstrap-leak",
classification: "dead-candidate",
description: "已零引用的 skill runtime 准备与 provider fallback 回流到 skill_exec_cmd 命令层",
description:
"已零引用的 skill runtime 准备与 provider fallback 回流到 skill_exec_cmd 命令层",
patterns: [
"ensure_browser_mcp_tools_registered(",
"ensure_social_image_tool_registered(",
@@ -540,7 +586,8 @@ const rustTextSurfaceMonitors = [
{
id: "rust-skill-catalog-command-leak",
classification: "dead-candidate",
description: "已零引用的 skill catalog 枚举与详情装配回流到 skill_exec_cmd 命令层",
description:
"已零引用的 skill catalog 枚举与详情装配回流到 skill_exec_cmd 命令层",
patterns: [
"get_skill_roots(",
"load_skills_from_directory(",
@@ -557,9 +604,10 @@ const rustTextSurfaceMonitors = [
{
id: "rust-skill-mode-branch-command-leak",
classification: "dead-candidate",
description: "已零引用的 skill execution_mode 分支回流到 skill_exec_cmd 命令层",
description:
"已零引用的 skill execution_mode 分支回流到 skill_exec_cmd 命令层",
patterns: [
"skill.execution_mode == \"workflow\"",
'skill.execution_mode == "workflow"',
"!skill.workflow_steps.is_empty()",
"execute_skill_workflow(",
"execute_skill_prompt(",
@@ -581,7 +629,8 @@ const rustTextSurfaceMonitors = [
{
id: "rust-service-agent-table-query-leak",
classification: "dead-candidate",
description: "已零引用的 Tauri service 层 direct agent_sessions/agent_messages 查询回流",
description:
"已零引用的 Tauri service 层 direct agent_sessions/agent_messages 查询回流",
patterns: [
"FROM agent_sessions s",
"FROM agent_messages m",
@@ -593,8 +642,12 @@ const rustTextSurfaceMonitors = [
{
id: "rust-service-model-usage-table-query-leak",
classification: "dead-candidate",
description: "已零引用的 Tauri service 层 direct model_usage_stats 查询回流",
patterns: ["FROM model_usage_stats", "SELECT COUNT(*) FROM model_usage_stats"],
description:
"已零引用的 Tauri service 层 direct model_usage_stats 查询回流",
patterns: [
"FROM model_usage_stats",
"SELECT COUNT(*) FROM model_usage_stats",
],
includePathPrefixes: ["src-tauri/src/services"],
allowedPaths: [],
},
@@ -617,12 +670,15 @@ const rustTextSurfaceMonitors = [
description: "legacy runtime queue 表名从数据库迁移边界向外扩散",
patterns: ["agent_runtime_queued_turns"],
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
allowedPaths: ["src-tauri/crates/core/src/database/agent_runtime_queue_repository.rs"],
allowedPaths: [
"src-tauri/crates/core/src/database/agent_runtime_queue_repository.rs",
],
},
{
id: "rust-agent-runtime-legacy-queue-migration-leak",
classification: "dead-candidate",
description: "已零引用的 legacy runtime queue 启动迁移 helper 回流到其他模块",
description:
"已零引用的 legacy runtime queue 启动迁移 helper 回流到其他模块",
patterns: ["migrate_legacy_runtime_queue_to_aster_store("],
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
allowedPaths: [],
@@ -638,15 +694,20 @@ const rustTextSurfaceMonitors = [
{
id: "rust-agent-session-structured-todo-helper-bypass",
classification: "dead-candidate",
description: "已零引用的 Lime 业务层绕过 unified todo helper 直接读取 TodoListState",
patterns: ["TodoListState::from_extension_data(", "TodoListState::from_markdown("],
description:
"已零引用的 Lime 业务层绕过 unified todo helper 直接读取 TodoListState",
patterns: [
"TodoListState::from_extension_data(",
"TodoListState::from_markdown(",
],
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
allowedPaths: [],
},
{
id: "rust-services-default-workspace-query-leak",
classification: "dead-candidate",
description: "已零引用的 services crate direct 默认 workspace root 查询回流",
description:
"已零引用的 services crate direct 默认 workspace root 查询回流",
patterns: ["SELECT root_path FROM workspaces WHERE is_default = 1 LIMIT 1"],
includePathPrefixes: ["src-tauri/crates/services/src"],
allowedPaths: [],
@@ -670,7 +731,9 @@ const rustTextSurfaceMonitors = [
"AgentDao::update_working_dir(",
"AgentDao::update_execution_strategy(",
],
allowedPaths: ["src-tauri/crates/core/src/database/agent_session_repository.rs"],
allowedPaths: [
"src-tauri/crates/core/src/database/agent_session_repository.rs",
],
},
{
id: "rust-agent-session-direct-delete",
@@ -684,7 +747,9 @@ const rustTextSurfaceMonitors = [
classification: "deprecated",
description: "Rust 业务层 direct AgentDao::create_session 回流",
patterns: ["AgentDao::create_session("],
allowedPaths: ["src-tauri/crates/core/src/database/agent_session_repository.rs"],
allowedPaths: [
"src-tauri/crates/core/src/database/agent_session_repository.rs",
],
},
{
id: "rust-agent-dao-row-type-leak",
@@ -746,6 +811,92 @@ const rustTextSurfaceMonitors = [
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
allowedPaths: [],
},
{
id: "rust-agent-tool-permission-public-module-leak",
classification: "dead-candidate",
description: "lime-agent 重新对 crate 外暴露旧 tool_permissions 模块",
patterns: ["pub mod tool_permissions;"],
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
allowedPaths: [],
},
{
id: "rust-agent-shell-security-public-module-leak",
classification: "dead-candidate",
description: "lime-agent 重新对 crate 外暴露旧 shell_security 模块",
patterns: ["pub mod shell_security;"],
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
allowedPaths: [],
},
{
id: "rust-agent-tool-permission-module-compiled-leak",
classification: "dead-candidate",
description: "旧 tool_permissions 模块重新回到 lime-agent lib.rs 编译图",
patterns: ["mod tool_permissions;"],
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
allowedPaths: [],
},
{
id: "rust-agent-shell-security-module-compiled-leak",
classification: "dead-candidate",
description: "旧 shell_security 模块重新回到 lime-agent lib.rs 编译图",
patterns: ["mod shell_security;"],
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
allowedPaths: [],
},
{
id: "rust-agent-tool-permission-root-export-leak",
classification: "dead-candidate",
description: "lime-agent crate 根重新暴露旧 tool_permissions 类型出口",
patterns: ["pub use tool_permissions::"],
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
allowedPaths: [],
},
{
id: "rust-agent-shell-security-root-export-leak",
classification: "dead-candidate",
description: "lime-agent crate 根重新暴露旧 shell_security 类型出口",
patterns: ["pub use shell_security::"],
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
allowedPaths: [],
},
{
id: "rust-agent-tool-permission-direct-module-usage",
classification: "dead-candidate",
description:
"上层模块重新 direct 依赖 lime_agent::tool_permissions 模块路径",
patterns: ["lime_agent::tool_permissions::"],
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
allowedPaths: [],
},
{
id: "rust-agent-shell-security-direct-module-usage",
classification: "dead-candidate",
description: "上层模块重新 direct 依赖 lime_agent::shell_security 模块路径",
patterns: ["lime_agent::shell_security::"],
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
allowedPaths: [],
},
{
id: "rust-agent-tool-permission-root-type-usage",
classification: "dead-candidate",
description: "上层模块重新 direct 依赖 lime_agent 根导出的旧权限类型",
patterns: [
"lime_agent::DynamicPermissionCheck",
"lime_agent::PermissionBehavior",
"lime_agent::ShellSecurityChecker",
],
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
allowedPaths: [],
},
{
id: "rust-agent-tool-permission-internal-module-usage",
classification: "dead-candidate",
description:
"lime-agent 内部除兼容壳外重新扩散 crate::tool_permissions 模块依赖",
patterns: ["crate::tool_permissions::"],
includePathPrefixes: ["src-tauri/crates/agent/src"],
allowedPaths: ["src-tauri/crates/agent/src/shell_security.rs"],
},
{
id: "rust-agent-integration-public-module-leak",
classification: "dead-candidate",
@@ -773,7 +924,8 @@ const rustTextSurfaceMonitors = [
{
id: "rust-agent-subagent-direct-module-usage",
classification: "dead-candidate",
description: "应用层重新 direct 依赖 crate::agent::subagent_scheduler 模块路径",
description:
"应用层重新 direct 依赖 crate::agent::subagent_scheduler 模块路径",
patterns: ["crate::agent::subagent_scheduler::"],
includePathPrefixes: ["src-tauri/src"],
allowedPaths: [],
@@ -781,7 +933,8 @@ const rustTextSurfaceMonitors = [
{
id: "rust-aster-runtime-snapshot-helper-leak",
classification: "dead-candidate",
description: "已零引用的 Lime 业务层 direct Aster runtime snapshot helper 回流",
description:
"已零引用的 Lime 业务层 direct Aster runtime snapshot helper 回流",
patterns: ["load_session_runtime_snapshot("],
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
allowedPaths: [],
@@ -789,7 +942,8 @@ const rustTextSurfaceMonitors = [
{
id: "rust-aster-runtime-store-leak",
classification: "dead-candidate",
description: "已零引用的 Lime 业务层 direct Aster shared runtime store 回流",
description:
"已零引用的 Lime 业务层 direct Aster shared runtime store 回流",
patterns: [
"shared_thread_runtime_store(",
"initialize_shared_thread_runtime_store(",
@@ -801,33 +955,43 @@ const rustTextSurfaceMonitors = [
{
id: "rust-aster-runtime-store-public-require-api-leak",
classification: "dead-candidate",
description: "Aster runtime support 重新对 crate 外暴露 require_aster_thread_runtime_store",
description:
"Aster runtime support 重新对 crate 外暴露 require_aster_thread_runtime_store",
patterns: ["pub fn require_aster_thread_runtime_store("],
includePathPrefixes: ["src-tauri/crates/agent/src/aster_runtime_support.rs"],
includePathPrefixes: [
"src-tauri/crates/agent/src/aster_runtime_support.rs",
],
allowedPaths: [],
},
{
id: "rust-aster-runtime-init-return-store-api-leak",
classification: "dead-candidate",
description: "Aster runtime 启动初始化 API 重新向 crate 外返回 runtime store",
description:
"Aster runtime 启动初始化 API 重新向 crate 外返回 runtime store",
patterns: [
"pub fn initialize_aster_thread_runtime_store() -> Result<Arc<dyn ThreadRuntimeStore>, String>",
],
includePathPrefixes: ["src-tauri/crates/agent/src/aster_runtime_support.rs"],
includePathPrefixes: [
"src-tauri/crates/agent/src/aster_runtime_support.rs",
],
allowedPaths: [],
},
{
id: "rust-aster-runtime-public-legacy-init-helper-leak",
classification: "dead-candidate",
description: "Aster runtime support 重新对 crate 外暴露旧 initialize_aster_thread_runtime_store helper",
description:
"Aster runtime support 重新对 crate 外暴露旧 initialize_aster_thread_runtime_store helper",
patterns: ["pub fn initialize_aster_thread_runtime_store("],
includePathPrefixes: ["src-tauri/crates/agent/src/aster_runtime_support.rs"],
includePathPrefixes: [
"src-tauri/crates/agent/src/aster_runtime_support.rs",
],
allowedPaths: [],
},
{
id: "rust-aster-runtime-snapshot-root-export-leak",
classification: "dead-candidate",
description: "lime-agent crate 根重新暴露 load_aster_runtime_snapshot helper",
description:
"lime-agent crate 根重新暴露 load_aster_runtime_snapshot helper",
patterns: ["load_aster_runtime_snapshot"],
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
allowedPaths: [],
@@ -835,16 +999,20 @@ const rustTextSurfaceMonitors = [
{
id: "rust-aster-runtime-queue-service-leak",
classification: "dead-candidate",
description: "已零引用的 Lime 业务层 direct Aster shared runtime queue service 回流",
description:
"已零引用的 Lime 业务层 direct Aster shared runtime queue service 回流",
patterns: [],
regexPatterns: [String.raw`(?<!require_)shared_session_runtime_queue_service\(`],
regexPatterns: [
String.raw`(?<!require_)shared_session_runtime_queue_service\(`,
],
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
allowedPaths: [],
},
{
id: "rust-aster-path-root-env-leak",
classification: "dead-candidate",
description: "已零引用的 Lime 业务层 direct ASTER_PATH_ROOT 环境变量处理回流",
description:
"已零引用的 Lime 业务层 direct ASTER_PATH_ROOT 环境变量处理回流",
patterns: ['"ASTER_PATH_ROOT"'],
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
allowedPaths: [],
@@ -852,7 +1020,8 @@ const rustTextSurfaceMonitors = [
{
id: "rust-aster-global-session-store-leak",
classification: "dead-candidate",
description: "已零引用的 Aster 全局 session store 注册回流到统一 runtime support 边界之外",
description:
"已零引用的 Aster 全局 session store 注册回流到统一 runtime support 边界之外",
patterns: ["set_global_session_store("],
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
allowedPaths: [],
@@ -1038,7 +1207,8 @@ const rustTextSurfaceMonitors = [
{
id: "rust-hardcoded-memory-path-leak",
classification: "dead-candidate",
description: "已零引用的 memory 相关模块硬编码 legacy memory 或 AGENTS 路径",
description:
"已零引用的 memory 相关模块硬编码 legacy memory 或 AGENTS 路径",
patterns: [
"~/.lime/AGENTS.md",
'join(".lime").join("AGENTS.md")',
@@ -1699,10 +1869,7 @@ const classificationDriftCandidates = [
),
...frontendTextResults
.filter((result) =>
isStatusClassificationDrift(
getTextStatus(result),
result.classification,
),
isStatusClassificationDrift(getTextStatus(result), result.classification),
)
.map(
(result) =>
@@ -1710,10 +1877,7 @@ const classificationDriftCandidates = [
),
...rustTextResults
.filter((result) =>
isStatusClassificationDrift(
getTextStatus(result),
result.classification,
),
isStatusClassificationDrift(getTextStatus(result), result.classification),
)
.map(
(result) =>
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env node
import path from "node:path";
import process from "node:process";
const DEFAULTS = {
healthUrl: "http://127.0.0.1:3030/health",
invokeUrl: "http://127.0.0.1:3030/invoke",
timeoutMs: 60_000,
intervalMs: 1_000,
sampleProjectName: "Lime Smoke Workspace",
};
function printHelp() {
console.log(`
Lime Workspace Ready Smoke
用途:
验证 DevBridge 已就绪,并检查默认 workspace 可获取、可修复、可按路径回查。
用法:
node scripts/workspace-ready-smoke.mjs [选项]
选项:
--health-url <url> 健康检查地址,默认 http://127.0.0.1:3030/health
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
--timeout-ms <ms> 等待健康检查超时,默认 60000
--interval-ms <ms> 健康检查轮询间隔,默认 1000
--sample-project-name <s> 用于校验目录解析的示例项目名
-h, --help 显示帮助
示例:
npm run smoke:workspace-ready
npm run smoke:workspace-ready -- --timeout-ms 120000
`);
}
function parseArgs(argv) {
const options = { ...DEFAULTS };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--health-url" && argv[index + 1]) {
options.healthUrl = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--invoke-url" && argv[index + 1]) {
options.invokeUrl = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--timeout-ms" && argv[index + 1]) {
options.timeoutMs = Number(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--interval-ms" && argv[index + 1]) {
options.intervalMs = Number(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--sample-project-name" && argv[index + 1]) {
options.sampleProjectName = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
}
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1000) {
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
}
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
throw new Error("--interval-ms 必须是 >= 100 的数字");
}
if (!options.sampleProjectName) {
throw new Error("--sample-project-name 不能为空");
}
return options;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function normalizePath(value) {
return String(value || "")
.trim()
.replace(/\\/g, "/")
.replace(/\/+$/, "");
}
function pickStringField(target, ...keys) {
if (!target || typeof target !== "object") {
return "";
}
for (const key of keys) {
const value = target[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
}
async function checkHealth(url) {
const response = await fetch(url, { method: "GET" });
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return text ? JSON.parse(text) : null;
}
async function waitForHealth(options) {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < options.timeoutMs) {
try {
const payload = await checkHealth(options.healthUrl);
console.log(
`[smoke:workspace-ready] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
payload?.status ? ` status=${payload.status}` : ""
}`,
);
return payload;
} catch (error) {
lastError = error;
await sleep(options.intervalMs);
}
}
const detail =
lastError instanceof Error
? lastError.message
: String(lastError || "unknown error");
throw new Error(
`[smoke:workspace-ready] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`,
);
}
async function invoke(invokeUrl, cmd, args) {
const response = await fetch(invokeUrl, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ cmd, args }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const payload = await response.json();
if (payload?.error) {
throw new Error(String(payload.error));
}
return payload?.result;
}
function assertWorkspaceShape(project, label) {
assert(project && typeof project === "object", `${label} 返回为空`);
assert(
typeof project.id === "string" && project.id.trim(),
`${label} 缺少 id`,
);
assert(
pickStringField(project, "rootPath", "root_path"),
`${label} 缺少 rootPath`,
);
}
async function main() {
if (typeof fetch !== "function") {
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
}
const options = parseArgs(process.argv.slice(2));
await waitForHealth(options);
const projectsRoot = await invoke(
options.invokeUrl,
"workspace_get_projects_root",
);
assert(
typeof projectsRoot === "string" && projectsRoot.trim(),
"workspace_get_projects_root 返回为空",
);
const resolvedSamplePath = await invoke(
options.invokeUrl,
"workspace_resolve_project_path",
{
name: options.sampleProjectName,
},
);
assert(
typeof resolvedSamplePath === "string" && resolvedSamplePath.trim(),
"workspace_resolve_project_path 返回为空",
);
const normalizedProjectsRoot = normalizePath(path.resolve(projectsRoot));
const normalizedResolvedSamplePath = normalizePath(
path.resolve(resolvedSamplePath),
);
assert(
normalizedResolvedSamplePath.startsWith(`${normalizedProjectsRoot}/`) ||
normalizedResolvedSamplePath === normalizedProjectsRoot,
`解析后的项目目录未落在 workspace 根目录下: ${resolvedSamplePath}`,
);
const defaultProject = await invoke(
options.invokeUrl,
"get_or_create_default_project",
);
assertWorkspaceShape(defaultProject, "get_or_create_default_project");
const defaultProjectDetail = await invoke(
options.invokeUrl,
"workspace_get",
{
id: defaultProject.id,
},
);
assertWorkspaceShape(defaultProjectDetail, "workspace_get");
const defaultProjectFromDefault = await invoke(
options.invokeUrl,
"workspace_get_default",
);
assertWorkspaceShape(defaultProjectFromDefault, "workspace_get_default");
assert(
defaultProjectFromDefault.id === defaultProject.id,
"workspace_get_default 与 get_or_create_default_project 返回的默认 workspace 不一致",
);
const ensureDefault = await invoke(
options.invokeUrl,
"workspace_ensure_default_ready",
);
assert(
ensureDefault && typeof ensureDefault === "object",
"默认 workspace 健康检查返回为空",
);
assert(
pickStringField(ensureDefault, "workspaceId", "workspace_id") ===
defaultProject.id,
"workspace_ensure_default_ready 返回的 workspace_id 不匹配",
);
assert(
pickStringField(ensureDefault, "rootPath", "root_path"),
"workspace_ensure_default_ready 缺少 rootPath",
);
const ensureExplicit = await invoke(
options.invokeUrl,
"workspace_ensure_ready",
{
id: defaultProject.id,
},
);
assert(
ensureExplicit && typeof ensureExplicit === "object",
"workspace_ensure_ready 返回为空",
);
assert(
pickStringField(ensureExplicit, "workspaceId", "workspace_id") ===
defaultProject.id,
"workspace_ensure_ready 返回的 workspace_id 不匹配",
);
const ensuredRootPath = pickStringField(
ensureExplicit,
"rootPath",
"root_path",
);
assert(ensuredRootPath, "workspace_ensure_ready 缺少 rootPath");
const workspaceByPath = await invoke(
options.invokeUrl,
"workspace_get_by_path",
{
rootPath: ensuredRootPath,
},
);
assertWorkspaceShape(workspaceByPath, "workspace_get_by_path");
assert(
workspaceByPath.id === defaultProject.id,
"workspace_get_by_path 未返回默认 workspace",
);
const workspaces = await invoke(options.invokeUrl, "workspace_list");
assert(Array.isArray(workspaces), "workspace_list 返回非数组");
assert(
workspaces.some((item) => item?.id === defaultProject.id),
"workspace_list 中未找到默认 workspace",
);
console.log("\n[smoke:workspace-ready] 通过");
console.log(
JSON.stringify(
{
projectsRoot,
sampleProjectPath: resolvedSamplePath,
defaultWorkspaceId: defaultProject.id,
defaultWorkspaceRoot: ensuredRootPath,
workspaceCount: workspaces.length,
repaired: Boolean(ensureExplicit.repaired),
relocated: Boolean(ensureExplicit.relocated),
},
null,
2,
),
);
}
main().catch((error) => {
console.error(
error instanceof Error ? error.message : String(error || "unknown error"),
);
process.exit(1);
});
+31 -31
View File
@@ -369,7 +369,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "aster-core"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"ahash",
"anyhow",
@@ -461,7 +461,7 @@ dependencies = [
[[package]]
name = "aster-models"
version = "0.19.0"
version = "0.20.0"
dependencies = [
"serde",
"serde_json",
@@ -2399,7 +2399,7 @@ dependencies = [
"dtoa-short",
"itoa",
"matches",
"phf 0.8.0",
"phf 0.10.1",
"proc-macro2",
"quote",
"smallvec",
@@ -2415,7 +2415,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.8.0",
"phf 0.11.3",
"smallvec",
]
@@ -4336,7 +4336,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.56.0",
"windows-core 0.57.0",
]
[[package]]
@@ -5062,7 +5062,7 @@ dependencies = [
[[package]]
name = "lime"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"anyhow",
"arboard",
@@ -5165,7 +5165,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"aster-core",
"async-trait",
@@ -5192,7 +5192,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"chrono",
"futures",
@@ -5209,7 +5209,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5225,7 +5225,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5265,7 +5265,7 @@ dependencies = [
[[package]]
name = "lime-credential"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5300,7 +5300,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -5321,7 +5321,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5341,7 +5341,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5373,7 +5373,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5392,7 +5392,7 @@ dependencies = [
[[package]]
name = "lime-providers"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5446,7 +5446,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5491,7 +5491,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5506,7 +5506,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5548,7 +5548,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5566,7 +5566,7 @@ dependencies = [
[[package]]
name = "lime-terminal"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5593,7 +5593,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "0.91.0"
version = "0.92.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -6266,7 +6266,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-crate 2.0.2",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -6993,9 +6993,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
dependencies = [
"phf_macros 0.8.0",
"phf_shared 0.8.0",
"proc-macro-hack",
]
[[package]]
@@ -7004,7 +7002,9 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
dependencies = [
"phf_macros 0.10.0",
"phf_shared 0.10.0",
"proc-macro-hack",
]
[[package]]
@@ -7108,12 +7108,12 @@ dependencies = [
[[package]]
name = "phf_macros"
version = "0.8.0"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
dependencies = [
"phf_generator 0.8.0",
"phf_shared 0.8.0",
"phf_generator 0.10.0",
"phf_shared 0.10.0",
"proc-macro-hack",
"proc-macro2",
"quote",
@@ -7525,7 +7525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.12.1",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -8992,7 +8992,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
dependencies = [
"dirs 4.0.0",
"dirs 6.0.0",
]
[[package]]
+4 -4
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.91.0"
version = "0.92.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -127,8 +127,8 @@ enigo = "0.3"
# 如需联调本地 aster-rust,请运行:
# npm run setup:local-aster -- /path/to/aster-rust
# 脚本会在仓库根 .cargo/config.toml 写入本地 patch 覆盖;该文件已被 .gitignore 忽略。
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.19.0" }
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.19.0" }
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.20.0" }
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.20.0" }
# MCP (Model Context Protocol)
rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] }
@@ -191,7 +191,7 @@ version = "2.4"
[package]
name = "lime"
version = "0.91.0"
version = "0.92.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -0,0 +1,8 @@
#[path = "../../../../src/agent_tools/catalog.rs"]
pub mod catalog;
#[path = "../../../../src/agent_tools/execution.rs"]
pub mod execution;
#[path = "../../../../src/agent_tools/inventory.rs"]
pub mod inventory;
@@ -150,7 +150,7 @@ pub(crate) async fn prepare_aster_runtime_queue_resumption() -> Result<Vec<Strin
list_aster_runtime_queued_turn_session_ids().await
}
async fn enqueue_aster_runtime_turn(
pub(crate) async fn enqueue_aster_runtime_turn(
queued_turn: QueuedTurnRuntime,
) -> Result<QueuedTurnRuntime, String> {
let store = require_aster_runtime_store()?;
+19 -74
View File
@@ -23,7 +23,6 @@
//! 参考文档:`docs/prd/chat-architecture-redesign.md`
use aster::agents::Agent;
use aster::model::ModelConfig;
#[cfg(test)]
use aster::skills::{global_registry, load_skills_from_directory, SkillSource};
use aster::tools::{create_shared_history, EditTool, WriteTool};
@@ -84,6 +83,8 @@ impl<T> QueuedTurnTask<T> {
pub struct ProviderConfig {
/// Provider 名称 (openai, anthropic, google, ollama 等)
pub provider_name: String,
/// Provider 选择器(优先保留前端 provider_id / pool provider_type)
pub provider_selector: Option<String>,
/// 模型名称
pub model_name: String,
/// API Key (可选,某些 provider 从环境变量读取)
@@ -92,6 +93,8 @@ pub struct ProviderConfig {
pub base_url: Option<String>,
/// 凭证 UUID(来自凭证池,用于记录使用和健康状态)
pub credential_uuid: Option<String>,
/// 是否强制 OpenAI provider 使用 Responses API
pub force_responses_api: bool,
}
/// Aster Agent 全局状态
@@ -234,17 +237,19 @@ impl AsterAgentState {
// 确保 Agent 已初始化(使用带数据库的版本)
self.init_agent_with_db(db).await?;
// 设置环境变量(Aster 的 provider 从环境变量读取配置)
self.set_provider_env_vars(&config);
// 创建 ModelConfig
let model_config = ModelConfig::new(&config.model_name)
.map_err(|e| format!("创建 ModelConfig 失败: {e}"))?;
// 创建 Provider
let provider = aster::providers::create(&config.provider_name, model_config)
.await
.map_err(|e| format!("创建 Provider 失败: {e}"))?;
let provider = create_aster_provider(&AsterProviderConfig {
provider_name: config.provider_name.clone(),
model_name: config.model_name.clone(),
api_key: config.api_key.clone(),
base_url: config.base_url.clone(),
credential_uuid: config
.credential_uuid
.clone()
.unwrap_or_else(|| format!("manual:{session_id}")),
force_responses_api: config.force_responses_api,
})
.await
.map_err(|e| format!("创建 Provider 失败: {e}"))?;
// 更新 Agent 的 Provider
let agent_guard = self.agent.read().await;
@@ -315,10 +320,12 @@ impl AsterAgentState {
// 保存当前配置
let config = ProviderConfig {
provider_name: aster_config.provider_name.clone(),
provider_selector: Some(provider_type.trim().to_string()),
model_name: aster_config.model_name.clone(),
api_key: aster_config.api_key.clone(),
base_url: aster_config.base_url.clone(),
credential_uuid: Some(aster_config.credential_uuid.clone()),
force_responses_api: aster_config.force_responses_api,
};
let mut config_guard = self.current_provider_config.write().await;
*config_guard = Some(config);
@@ -371,57 +378,6 @@ impl AsterAgentState {
}
}
/// 设置 Provider 相关的环境变量
fn set_provider_env_vars(&self, config: &ProviderConfig) {
tracing::info!(
"[AsterAgent] set_provider_env_vars: provider_name={}, model_name={}, has_api_key={}, base_url={:?}",
config.provider_name,
config.model_name,
config.api_key.is_some(),
config.base_url
);
// 根据 provider 类型设置对应的环境变量
let env_key = match config.provider_name.as_str() {
"openai" => "OPENAI_API_KEY",
"anthropic" => "ANTHROPIC_API_KEY",
"google" => "GOOGLE_API_KEY",
"deepseek" | "custom_deepseek" => "OPENAI_API_KEY", // DeepSeek 使用 OpenAI 兼容 API
"groq" => "OPENAI_API_KEY", // Groq 使用 OpenAI 兼容 API
"mistral" => "OPENAI_API_KEY", // Mistral 使用 OpenAI 兼容 API
"openrouter" => "OPENROUTER_API_KEY",
"ollama" => return, // Ollama 不需要 API Key
_ => {
tracing::warn!(
"[AsterAgent] 未知的 provider_name: {}, 使用通用 OpenAI 格式",
config.provider_name
);
// 通用 OpenAI 兼容格式
if let Some(api_key) = &config.api_key {
std::env::set_var("OPENAI_API_KEY", api_key);
}
if let Some(base_url) = &config.base_url {
std::env::set_var("OPENAI_BASE_URL", base_url);
}
return;
}
};
tracing::info!("[AsterAgent] 设置环境变量: {}=***", env_key);
if let Some(api_key) = &config.api_key {
std::env::set_var(env_key, api_key);
}
if let Some(base_url) = &config.base_url {
let base_url_key = format!(
"{}_BASE_URL",
config.provider_name.to_uppercase().replace("_", "")
);
std::env::set_var(base_url_key, base_url);
}
}
/// 获取当前 Provider 配置
pub async fn get_provider_config(&self) -> Option<ProviderConfig> {
self.current_provider_config.read().await.clone()
@@ -638,17 +594,6 @@ mod tests {
assert!(!state.cancel_session(session_id).await);
}
#[test]
fn test_session_turn_queue_manager_execution_gate() {
let gate = SessionTurnExecutionGate::default();
assert!(gate.try_start("session-queue"));
assert!(gate.is_active("session-queue"));
assert!(!gate.try_start("session-queue"));
assert!(gate.finish("session-queue"));
assert!(!gate.is_active("session-queue"));
}
#[test]
fn test_session_turn_queue_manager_snapshot() {
let task = QueuedTurnTask {
@@ -182,4 +182,15 @@ Lime 是一个 AI 代理服务应用,帮助用户:
- 简洁专业,直接给出解决方案
- 友好但不啰嗦,像经验丰富的技术伙伴
- 遇到问题时,先分析原因再提供方案
## Team 协作原则
- 只有在任务存在多个相互独立的子问题、并行评审/验证、或用户明确要求多代理时,才进入 team 模式
- 简单问题不要创建子代理;先判断当前阻塞步骤是否真的适合委派
- 先区分关键路径与 sidecar 任务:如果下一步立即依赖结果,优先主线程自己做;只有不会阻塞下一步的独立子任务才适合并发委派
- 多个子代理并发时,必须明确分工,避免让不同子代理修改同一片文件或重复劳动
- 子代理默认不应继续创建新的子代理,避免团队深度失控
- 优先复用已有子代理上下文,通过 send_input 继续推进强相关任务,而不是反复创建新子代理
- 只有当主线程确实被结果阻塞时,才调用 wait_agent;可以一次等待多个 id,且不要反复机械等待
- 旧的 SubAgentTask 仅视为兼容入口,不应作为新的 team runtime 主路径
"#;
@@ -1741,7 +1741,7 @@ mod tests {
let extracted = extract_tool_result_data(&payload);
assert_eq!(extracted.diagnostics.output_chars, 5);
assert_eq!(extracted.diagnostics.image_count, 0);
assert_eq!(extracted.diagnostics.text_truncated, false);
assert!(!extracted.diagnostics.text_truncated);
assert!(extracted.diagnostics.raw_json_bytes.is_some());
}
+26 -9
View File
@@ -10,6 +10,7 @@
#![allow(clippy::derivable_impls)]
#![allow(clippy::borrowed_box)]
pub mod agent_tools;
pub mod ask_bridge;
pub mod aster_runtime_support;
pub mod aster_state;
@@ -25,11 +26,11 @@ pub mod queued_turn;
pub mod request_tool_policy;
pub mod runtime_queue;
mod session_store;
pub mod shell_security;
pub mod skill_execution;
pub mod subagent_control;
pub mod subagent_profiles;
pub mod subagent_scheduler;
pub mod tool_io_offload;
pub mod tool_permissions;
pub mod tools;
mod write_artifact_events;
@@ -52,6 +53,7 @@ pub use event_converter::{
convert_agent_event, convert_item_runtime, convert_to_tauri_message, convert_turn_runtime,
TauriAgentEvent, TauriArtifactSnapshot, TauriRuntimeStatus,
};
pub use lime_mcp as mcp;
pub use lsp_bridge::create_lsp_callback;
pub use prompt::SystemPromptBuilder;
pub use queued_turn::QueuedTurnSnapshot;
@@ -63,25 +65,40 @@ pub use request_tool_policy::{
WebSearchExecutionTracker, REQUEST_TOOL_POLICY_MARKER,
};
pub use runtime_queue::{
clear_runtime_queue, list_runtime_queue_snapshots, remove_runtime_queued_turn,
resume_persisted_runtime_queues_on_startup, resume_runtime_queue_if_needed,
submit_runtime_turn, RuntimeQueueEventEmitter, RuntimeQueueExecutor,
clear_runtime_queue, list_runtime_queue_snapshots, promote_runtime_queued_turn,
remove_runtime_queued_turn, resume_persisted_runtime_queues_on_startup,
resume_runtime_queue_if_needed, submit_runtime_turn, RuntimeQueueEventEmitter,
RuntimeQueueExecutor,
};
pub use session_store::{
create_session_sync, delete_session, get_persisted_session_metadata_sync,
get_runtime_session_detail, get_session_sync, list_sessions_sync,
list_title_preview_messages_sync, rename_session_sync, update_session_execution_strategy_sync,
update_session_working_dir_sync, PersistedSessionMetadata, SessionDetail, SessionInfo,
SessionTitlePreviewMessage, SessionTodoItem,
update_session_working_dir_sync, ChildSubagentRuntimeStatus, ChildSubagentSession,
PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage,
SessionTodoItem, SubagentParentContext,
};
pub use shell_security::ShellSecurityChecker;
pub use skill_execution::{
execute_skill_prompt, execute_skill_workflow, SkillEventEmitter, SkillExecutionError,
SkillExecutionResult, SkillWorkflowExecution, StepResult,
};
pub use subagent_control::{
collect_subagent_cascade_session_ids, derive_subagent_runtime_status_kind,
list_subagent_cascade_session_ids, load_subagent_runtime_status, read_subagent_control_state,
write_subagent_control_state, SubagentControlState, SubagentRuntimeStatus,
SubagentRuntimeStatusInput, SubagentRuntimeStatusKind,
};
pub use subagent_profiles::{
build_subagent_customization_prompt, builtin_profile_descriptor_by_id,
builtin_profile_name_by_id, builtin_skill_descriptor_by_id,
builtin_team_preset_descriptor_by_id, builtin_team_preset_label_by_id,
summarize_builtin_profile, summarize_builtin_skill, summarize_builtin_team_preset,
BuiltinProfileDescriptor, BuiltinSkillDescriptor, BuiltinTeamPresetDescriptor,
SubagentCustomizationState, SubagentProfileSummary, SubagentSkillPromptBlock,
SubagentSkillSummary, TeamPresetSummary,
};
pub use subagent_scheduler::{
LimeScheduler, LimeSubAgentExecutor, SchedulerEventEmitter, SubAgentProgressEvent, SubAgentRole,
};
pub use tool_permissions::{DynamicPermissionCheck, PermissionBehavior};
pub use tools::{BrowserAction, BrowserTool, BrowserToolError, BrowserToolResult};
pub use write_artifact_events::WriteArtifactEventEmitter;
@@ -41,7 +41,8 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略
- **EnterPlanMode** / **ExitPlanMode**: 显式进入或结束规划阶段
### 委派工具
- **SubAgentTask**: 将独立子问题委派给隔离上下文的子代理执行
- **spawn_agent / send_input / wait_agent / resume_agent / close_agent**: 当前 team runtime 主路径
- **SubAgentTask**: 兼容入口,仅用于历史 prompt/schema 仍输出旧格式时兜底
### 人在环工具
- **ask**: 向用户请求确认或补充信息
@@ -52,7 +53,7 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略
2. **并行调用**:如果多个工具调用之间没有依赖关系,应该并行调用
3. **先读后改**:修改文件前必须先读取文件内容
4. **最小权限**:只执行必要的操作,避免不必要的文件修改
5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 SubAgentTask"#;
5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 team runtime 工具;优先 `spawn_agent`,不要默认走 `SubAgentTask`"#;
/// 代码编写指南
pub const CODING_GUIDELINES: &str = r#"# 代码编写指南
@@ -61,7 +62,7 @@ pub const CODING_GUIDELINES: &str = r#"# 代码编写指南
1. **先理解再修改**:在修改代码之前,先阅读相关文件理解现有模式和架构
2. **使用 TodoWrite 规划**:对于复杂任务,先用 TodoWrite 工具规划步骤
3. **需要隔离上下文时委派**:对于可以独立完成的研究、规划或执行子问题,使用 SubAgentTask
3. **需要隔离上下文时委派**:对于可以独立完成的研究、规划或执行子问题,使用 `spawn_agent` 创建真实子代理;对强依赖既有上下文的延续任务,优先 `send_input`
4. **安全第一**:避免引入安全漏洞(命令注入、XSS、SQL 注入等)
5. **避免过度工程**:只做必要的修改,保持解决方案简单
@@ -96,7 +97,7 @@ pub const TASK_MANAGEMENT: &str = r#"# 任务管理
不要批量完成多个任务后再标记,应该完成一个标记一个。
如果某个子问题可以独立分析、规划或执行,并且不需要持续共享主对话上下文,可以使用 SubAgentTask 委派出去。"#;
如果某个子问题可以独立分析、规划或执行,并且不需要持续共享主对话上下文,可以使用 `spawn_agent` 委派出去;`SubAgentTask` 只保留给兼容旧 schema 的场景。"#;
/// Git 操作指南
pub const GIT_GUIDELINES: &str = r#"# Git 操作
+51 -1
View File
@@ -1,5 +1,5 @@
use crate::aster_runtime_support::{
clear_aster_runtime_queued_turns, list_aster_runtime_queued_turns,
clear_aster_runtime_queued_turns, enqueue_aster_runtime_turn, list_aster_runtime_queued_turns,
prepare_aster_runtime_queue_resumption, queued_turn_event_name_from_runtime,
queued_turn_runtime_from_task, queued_turn_snapshot_from_runtime,
remove_aster_runtime_queued_turn,
@@ -242,6 +242,56 @@ pub async fn remove_runtime_queued_turn(
Ok(true)
}
pub async fn promote_runtime_queued_turn(
session_id: &str,
queued_turn_id: &str,
) -> Result<bool, String> {
let queued_turns = list_aster_runtime_queued_turns(session_id).await?;
if queued_turns.is_empty() {
return Ok(false);
}
if queued_turns
.first()
.map(|queued_turn| queued_turn.queued_turn_id == queued_turn_id)
.unwrap_or(false)
{
return Ok(true);
}
let Some(target_index) = queued_turns
.iter()
.position(|queued_turn| queued_turn.queued_turn_id == queued_turn_id)
else {
return Ok(false);
};
let mut reordered_turns = Vec::with_capacity(queued_turns.len());
reordered_turns.push(queued_turns[target_index].clone());
reordered_turns.extend(
queued_turns
.iter()
.enumerate()
.filter(|(index, _)| *index != target_index)
.map(|(_, queued_turn)| queued_turn.clone()),
);
let original_turns = queued_turns;
clear_aster_runtime_queued_turns(session_id).await?;
for queued_turn in &reordered_turns {
if let Err(error) = enqueue_aster_runtime_turn(queued_turn.clone()).await {
clear_aster_runtime_queued_turns(session_id).await?;
for original_turn in original_turns {
enqueue_aster_runtime_turn(original_turn).await?;
}
return Err(error);
}
}
Ok(true)
}
pub async fn resume_persisted_runtime_queues_on_startup<C>(
context: C,
executor: RuntimeQueueExecutor<C>,
+680 -1
View File
@@ -4,7 +4,10 @@
//! 数据事实源收敛到 lime_core::database::agent_session_repository + Lime 数据库。
use aster::session::extension_data::{resolve_todo_list_state, TodoListItem, TodoListItemStatus};
use aster::session::SessionRuntimeSnapshot;
use aster::session::{
list_subagent_child_sessions, resolve_subagent_session_metadata, Session as AsterSession,
SessionManager, SessionRuntimeSnapshot,
};
use chrono::Utc;
use lime_core::agent::types::{AgentMessage, AgentSession, ContentPart, MessageContent};
use lime_core::database::agent_session_repository::{
@@ -18,12 +21,15 @@ use lime_core::database::DbConnection;
use lime_core::workspace::WorkspaceManager;
use lime_services::aster_session_store::LimeSessionStore;
use std::collections::HashMap;
use std::path::Path;
use uuid::Uuid;
use crate::aster_runtime_support::load_aster_runtime_snapshot;
use crate::event_converter::{
convert_item_runtime, convert_turn_runtime, TauriMessage, TauriMessageContent,
};
use crate::subagent_control::{load_subagent_runtime_status, SubagentRuntimeStatusKind};
use crate::subagent_profiles::{SubagentCustomizationState, SubagentSkillSummary};
use crate::tool_io_offload::{
build_history_tool_io_eviction_plan_for_model, force_offload_plain_tool_output_for_history,
force_offload_tool_arguments_for_history, maybe_offload_plain_tool_output,
@@ -63,6 +69,105 @@ pub struct SessionDetail {
pub items: Vec<AgentThreadItem>,
#[serde(default)]
pub todo_items: Vec<SessionTodoItem>,
#[serde(default)]
pub child_subagent_sessions: Vec<ChildSubagentSession>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subagent_parent_context: Option<SubagentParentContext>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ChildSubagentSession {
pub id: String,
pub name: String,
pub created_at: i64,
pub updated_at: i64,
pub session_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub role_hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin_tool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_from_turn_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub role_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub team_preset_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_contract: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skill_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<SubagentSkillSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime_status: Option<ChildSubagentRuntimeStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_turn_status: Option<ChildSubagentRuntimeStatus>,
#[serde(default, skip_serializing_if = "is_zero_usize")]
pub queued_turn_count: usize,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SubagentParentContext {
pub parent_session_id: String,
pub parent_session_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub role_hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin_tool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_from_turn_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub role_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub team_preset_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_contract: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skill_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<SubagentSkillSummary>,
#[serde(default)]
pub sibling_subagent_sessions: Vec<ChildSubagentSession>,
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ChildSubagentRuntimeStatus {
Idle,
Queued,
Running,
Completed,
Failed,
Aborted,
Closed,
}
fn is_zero_usize(value: &usize) -> bool {
*value == 0
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
@@ -172,6 +277,260 @@ fn load_session_todo_items_from_conn(
.unwrap_or_default()
}
fn resolve_workspace_id_by_working_dir(
db: &DbConnection,
working_dir: Option<&str>,
) -> Option<String> {
let resolved_working_dir = working_dir?.trim();
if resolved_working_dir.is_empty() {
return None;
}
let manager = WorkspaceManager::new(db.clone());
match manager.get_by_path(Path::new(resolved_working_dir)) {
Ok(workspace) => workspace.map(|entry| entry.id),
Err(error) => {
tracing::warn!(
"[SessionStore] 解析 child subagent workspace 失败,已降级忽略: working_dir={}, error={}",
resolved_working_dir,
error
);
None
}
}
}
fn resolve_subagent_model_name(session: &AsterSession) -> Option<String> {
session
.model_config
.as_ref()
.map(|config| config.model_name.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| normalize_optional_text(session.provider_name.clone()))
}
fn map_child_subagent_runtime_status(
status: SubagentRuntimeStatusKind,
) -> Option<ChildSubagentRuntimeStatus> {
match status {
SubagentRuntimeStatusKind::Idle => Some(ChildSubagentRuntimeStatus::Idle),
SubagentRuntimeStatusKind::Queued => Some(ChildSubagentRuntimeStatus::Queued),
SubagentRuntimeStatusKind::Running => Some(ChildSubagentRuntimeStatus::Running),
SubagentRuntimeStatusKind::Completed => Some(ChildSubagentRuntimeStatus::Completed),
SubagentRuntimeStatusKind::Failed => Some(ChildSubagentRuntimeStatus::Failed),
SubagentRuntimeStatusKind::Aborted => Some(ChildSubagentRuntimeStatus::Aborted),
SubagentRuntimeStatusKind::Closed => Some(ChildSubagentRuntimeStatus::Closed),
SubagentRuntimeStatusKind::NotFound => None,
}
}
#[cfg(test)]
fn resolve_child_subagent_runtime_status_from_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> ChildSubagentRuntimeStatus {
snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.max_by(|left, right| {
left.updated_at
.cmp(&right.updated_at)
.then_with(|| left.created_at.cmp(&right.created_at))
.then_with(|| left.id.cmp(&right.id))
})
.and_then(|turn| {
map_child_subagent_runtime_status(match turn.status {
aster::session::TurnStatus::Queued => SubagentRuntimeStatusKind::Queued,
aster::session::TurnStatus::Running => SubagentRuntimeStatusKind::Running,
aster::session::TurnStatus::Completed => SubagentRuntimeStatusKind::Completed,
aster::session::TurnStatus::Failed => SubagentRuntimeStatusKind::Failed,
aster::session::TurnStatus::Aborted => SubagentRuntimeStatusKind::Aborted,
})
})
.unwrap_or(ChildSubagentRuntimeStatus::Idle)
}
fn build_child_subagent_session_summary(
db: Option<&DbConnection>,
session: AsterSession,
) -> Option<ChildSubagentSession> {
let metadata = resolve_subagent_session_metadata(&session.extension_data)?;
let customization = SubagentCustomizationState::from_session(&session).unwrap_or_default();
let working_dir =
normalize_optional_text(Some(session.working_dir.to_string_lossy().to_string()));
let workspace_id =
db.and_then(|conn| resolve_workspace_id_by_working_dir(conn, working_dir.as_deref()));
let model = resolve_subagent_model_name(&session);
let provider_name = normalize_optional_text(session.provider_name.clone());
let name = normalize_optional_text(Some(session.name.clone()))
.unwrap_or_else(|| "子代理会话".to_string());
Some(ChildSubagentSession {
id: session.id,
name,
created_at: session.created_at.timestamp(),
updated_at: session.updated_at.timestamp(),
session_type: session.session_type.to_string(),
model,
provider_name,
working_dir,
workspace_id,
task_summary: normalize_optional_nonempty_body(metadata.task_summary),
role_hint: normalize_optional_text(metadata.role_hint),
origin_tool: normalize_optional_text(Some(metadata.origin_tool)),
created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id),
profile_id: customization.profile_id,
profile_name: customization.profile_name,
role_key: customization.role_key,
team_preset_id: customization.team_preset_id,
theme: customization.theme,
output_contract: customization.output_contract,
skill_ids: customization.skill_ids,
skills: customization.skills,
runtime_status: None,
latest_turn_status: None,
queued_turn_count: 0,
})
}
fn apply_runtime_status_to_child_subagent_session(
summary: &mut ChildSubagentSession,
status: crate::subagent_control::SubagentRuntimeStatus,
) {
summary.runtime_status = map_child_subagent_runtime_status(status.kind);
summary.latest_turn_status = status
.latest_turn_status
.and_then(map_child_subagent_runtime_status);
summary.queued_turn_count = status.queued_turn_count;
}
fn build_child_subagent_session_summaries(
db: Option<&DbConnection>,
sessions: Vec<AsterSession>,
) -> Vec<ChildSubagentSession> {
let mut summaries = sessions
.into_iter()
.filter_map(|session| build_child_subagent_session_summary(db, session))
.collect::<Vec<_>>();
summaries.sort_by(|left, right| {
right
.updated_at
.cmp(&left.updated_at)
.then_with(|| left.id.cmp(&right.id))
});
summaries
}
fn build_subagent_parent_context(
current_session_id: &str,
parent_session: Option<&AsterSession>,
metadata: aster::session::SubagentSessionMetadata,
customization: Option<SubagentCustomizationState>,
sibling_subagent_sessions: Vec<ChildSubagentSession>,
) -> SubagentParentContext {
let parent_session_name = parent_session
.and_then(|session| normalize_optional_text(Some(session.name.clone())))
.unwrap_or_else(|| "父会话".to_string());
let customization = customization.unwrap_or_default();
SubagentParentContext {
parent_session_id: metadata.parent_session_id,
parent_session_name,
role_hint: normalize_optional_text(metadata.role_hint),
task_summary: normalize_optional_nonempty_body(metadata.task_summary),
origin_tool: normalize_optional_text(Some(metadata.origin_tool)),
created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id),
profile_id: customization.profile_id,
profile_name: customization.profile_name,
role_key: customization.role_key,
team_preset_id: customization.team_preset_id,
theme: customization.theme,
output_contract: customization.output_contract,
skill_ids: customization.skill_ids,
skills: customization.skills,
sibling_subagent_sessions: sibling_subagent_sessions
.into_iter()
.filter(|session| session.id != current_session_id)
.collect(),
}
}
async fn load_child_subagent_sessions(
db: &DbConnection,
session_id: &str,
) -> Result<Vec<ChildSubagentSession>, String> {
let sessions = list_subagent_child_sessions(session_id)
.await
.map_err(|error| format!("读取 child subagent sessions 失败: {error}"))?;
let mut summaries = build_child_subagent_session_summaries(Some(db), sessions);
for summary in &mut summaries {
match load_subagent_runtime_status(&summary.id).await {
Ok(status) => apply_runtime_status_to_child_subagent_session(summary, status),
Err(error) => {
tracing::debug!(
"[SessionStore] child subagent runtime 状态不可用,按 idle 展示: session_id={}, error={}",
summary.id,
error
);
}
}
}
Ok(summaries)
}
async fn load_subagent_parent_context(
db: &DbConnection,
session_id: &str,
) -> Result<Option<SubagentParentContext>, String> {
let current_session = SessionManager::get_session(session_id, false)
.await
.map_err(|error| format!("读取当前 subagent session 失败: {error}"))?;
let Some(metadata) = resolve_subagent_session_metadata(&current_session.extension_data) else {
return Ok(None);
};
let parent_session = match SessionManager::get_session(&metadata.parent_session_id, false).await
{
Ok(session) => Some(session),
Err(error) => {
tracing::warn!(
"[SessionStore] 读取 parent session 失败,已降级为匿名父会话: session_id={}, parent_session_id={}, error={}",
session_id,
metadata.parent_session_id,
error
);
None
}
};
let sibling_subagent_sessions = match load_child_subagent_sessions(
db,
&metadata.parent_session_id,
)
.await
{
Ok(sessions) => sessions,
Err(error) => {
tracing::warn!(
"[SessionStore] 读取 sibling subagent sessions 失败,已降级为空列表: session_id={}, parent_session_id={}, error={}",
session_id,
metadata.parent_session_id,
error
);
Vec::new()
}
};
Ok(Some(build_subagent_parent_context(
session_id,
parent_session.as_ref(),
metadata,
SubagentCustomizationState::from_session(&current_session),
sibling_subagent_sessions,
)))
}
fn sort_runtime_turns(turns: &mut [AgentThreadTurn]) {
turns.sort_by(|left, right| {
left.started_at
@@ -453,6 +812,8 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDe
turns,
items,
todo_items,
child_subagent_sessions: Vec::new(),
subagent_parent_context: None,
})
}
@@ -473,6 +834,32 @@ pub async fn get_runtime_session_detail(
}
}
match load_child_subagent_sessions(db, session_id).await {
Ok(child_subagent_sessions) => {
detail.child_subagent_sessions = child_subagent_sessions;
}
Err(error) => {
tracing::warn!(
"[SessionStore] 读取 child subagent sessions 失败: session_id={}, error={}",
session_id,
error
);
}
}
match load_subagent_parent_context(db, session_id).await {
Ok(subagent_parent_context) => {
detail.subagent_parent_context = subagent_parent_context;
}
Err(error) => {
tracing::warn!(
"[SessionStore] 读取 subagent parent context 失败: session_id={}, error={}",
session_id,
error
);
}
}
Ok(detail)
}
@@ -699,6 +1086,11 @@ fn convert_agent_message(
#[cfg(test)]
mod tests {
use super::*;
use aster::session::{
SessionType as AsterSessionType, SubagentSessionMetadata, ThreadRuntime,
ThreadRuntimeSnapshot, TurnRuntime, TurnStatus,
};
use chrono::{Duration, Utc};
use lime_core::agent::types::{FunctionCall, ImageUrl, ToolCall};
use lime_core::database::{schema, DbConnection};
use std::ffi::OsString;
@@ -787,6 +1179,38 @@ mod tests {
.expect("add message");
}
fn build_test_subagent_session(
session_id: &str,
name: &str,
parent_session_id: Option<&str>,
updated_at: chrono::DateTime<Utc>,
task_summary: Option<&str>,
role_hint: Option<&str>,
created_from_turn_id: Option<&str>,
) -> AsterSession {
let mut session = AsterSession {
id: session_id.to_string(),
name: name.to_string(),
session_type: AsterSessionType::SubAgent,
created_at: updated_at - Duration::minutes(1),
updated_at,
provider_name: Some("openai".to_string()),
working_dir: std::path::PathBuf::from("/tmp/workspace-child"),
..AsterSession::default()
};
if let Some(parent_session_id) = parent_session_id {
session.extension_data = SubagentSessionMetadata::new(parent_session_id.to_string())
.with_task_summary(task_summary.map(str::to_string))
.with_role_hint(role_hint.map(str::to_string))
.with_created_from_turn_id(created_from_turn_id.map(str::to_string))
.into_updated_extension_data(&AsterSession::default())
.expect("build child metadata");
}
session
}
#[test]
fn parse_tool_call_arguments_should_parse_json_or_keep_raw() {
let parsed = parse_tool_call_arguments(r#"{"path":"./a.txt"}"#);
@@ -796,6 +1220,261 @@ mod tests {
assert_eq!(fallback["raw"], serde_json::json!("not-json"));
}
#[test]
fn build_child_subagent_session_summaries_should_filter_and_sort_by_updated_at_desc() {
let now = Utc::now();
let summaries = build_child_subagent_session_summaries(
None,
vec![
build_test_subagent_session(
"child-old",
"旧子代理",
Some("parent-1"),
now - Duration::minutes(5),
Some("先检查日志"),
Some("explorer"),
Some("turn-1"),
),
build_test_subagent_session(
"ignored",
"忽略项",
None,
now - Duration::minutes(1),
None,
None,
None,
),
build_test_subagent_session(
"child-new",
"新子代理",
Some("parent-1"),
now,
Some("补充真实 team runtime"),
Some("planner"),
Some("turn-2"),
),
],
);
assert_eq!(summaries.len(), 2);
assert_eq!(summaries[0].id, "child-new");
assert_eq!(summaries[0].session_type, "sub_agent");
assert_eq!(
summaries[0].task_summary.as_deref(),
Some("补充真实 team runtime")
);
assert_eq!(summaries[0].role_hint.as_deref(), Some("planner"));
assert_eq!(summaries[0].created_from_turn_id.as_deref(), Some("turn-2"));
assert_eq!(summaries[1].id, "child-old");
}
#[test]
fn build_child_subagent_session_summary_should_merge_customization_state() {
let now = Utc::now();
let mut session = build_test_subagent_session(
"child-customized",
"自定义子代理",
Some("parent-1"),
now,
Some("整理 customization"),
Some("Image #1"),
Some("turn-9"),
);
session.extension_data = SubagentCustomizationState {
profile_id: Some("code-explorer".to_string()),
profile_name: Some("代码分析员".to_string()),
role_key: Some("explorer".to_string()),
team_preset_id: Some("code-triage-team".to_string()),
theme: Some("engineering".to_string()),
output_contract: Some("输出证据、影响面与建议。".to_string()),
system_overlay: None,
skill_ids: vec!["repo-exploration".to_string()],
skills: vec![SubagentSkillSummary {
id: "repo-exploration".to_string(),
name: "仓库探索".to_string(),
description: Some("优先读事实源".to_string()),
source: Some("builtin".to_string()),
directory: None,
}],
}
.into_updated_extension_data(&session)
.expect("merge customization");
let summary = build_child_subagent_session_summary(None, session)
.expect("child summary should exist");
assert_eq!(summary.profile_id.as_deref(), Some("code-explorer"));
assert_eq!(summary.profile_name.as_deref(), Some("代码分析员"));
assert_eq!(summary.role_key.as_deref(), Some("explorer"));
assert_eq!(summary.team_preset_id.as_deref(), Some("code-triage-team"));
assert_eq!(summary.theme.as_deref(), Some("engineering"));
assert_eq!(
summary.output_contract.as_deref(),
Some("输出证据、影响面与建议。")
);
assert_eq!(summary.skill_ids, vec!["repo-exploration".to_string()]);
assert_eq!(summary.skills.len(), 1);
assert_eq!(summary.skills[0].name, "仓库探索");
}
#[test]
fn build_subagent_parent_context_should_keep_parent_name_and_filter_current_session() {
let now = Utc::now();
let metadata = SubagentSessionMetadata::new("parent-1".to_string())
.with_task_summary(Some("处理父线程拆分出来的图片任务".to_string()))
.with_role_hint(Some("Image #1".to_string()))
.with_created_from_turn_id(Some("turn-2".to_string()));
let parent_session = AsterSession {
id: "parent-1".to_string(),
name: "主线程会话".to_string(),
session_type: AsterSessionType::User,
..AsterSession::default()
};
let sibling_subagent_sessions = build_child_subagent_session_summaries(
None,
vec![
build_test_subagent_session(
"child-current",
"Image #1",
Some("parent-1"),
now - Duration::seconds(10),
Some("当前子代理"),
Some("Image #1"),
Some("turn-2"),
),
build_test_subagent_session(
"child-sibling",
"Image #2",
Some("parent-1"),
now,
Some("兄弟子代理"),
Some("Image #2"),
Some("turn-2"),
),
],
);
let context = build_subagent_parent_context(
"child-current",
Some(&parent_session),
metadata,
None,
sibling_subagent_sessions,
);
assert_eq!(context.parent_session_id, "parent-1");
assert_eq!(context.parent_session_name, "主线程会话");
assert_eq!(context.role_hint.as_deref(), Some("Image #1"));
assert_eq!(
context.task_summary.as_deref(),
Some("处理父线程拆分出来的图片任务")
);
assert_eq!(context.created_from_turn_id.as_deref(), Some("turn-2"));
assert_eq!(context.sibling_subagent_sessions.len(), 1);
assert_eq!(context.sibling_subagent_sessions[0].id, "child-sibling");
}
#[test]
fn resolve_child_subagent_runtime_status_from_snapshot_should_use_latest_turn_status() {
let now = Utc::now();
let snapshot = SessionRuntimeSnapshot {
session_id: "child-session-1".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread: ThreadRuntime::new(
"thread-1",
"child-session-1",
std::path::PathBuf::from("/tmp/workspace-child"),
),
turns: vec![
TurnRuntime {
id: "turn-old".to_string(),
session_id: "child-session-1".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Running,
input_text: Some("旧任务".to_string()),
error_message: None,
context_override: None,
created_at: now - Duration::minutes(2),
started_at: Some(now - Duration::minutes(2)),
completed_at: None,
updated_at: now - Duration::minutes(1),
},
TurnRuntime {
id: "turn-new".to_string(),
session_id: "child-session-1".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("新任务".to_string()),
error_message: None,
context_override: None,
created_at: now - Duration::seconds(30),
started_at: Some(now - Duration::seconds(30)),
completed_at: Some(now - Duration::seconds(10)),
updated_at: now,
},
],
items: Vec::new(),
}],
};
assert_eq!(
resolve_child_subagent_runtime_status_from_snapshot(&snapshot),
ChildSubagentRuntimeStatus::Completed
);
}
#[test]
fn apply_runtime_status_to_child_subagent_session_should_keep_runtime_detail() {
let mut summary = ChildSubagentSession {
id: "child-1".to_string(),
name: "研究员".to_string(),
created_at: 1_710_000_000,
updated_at: 1_710_000_100,
session_type: "sub_agent".to_string(),
model: Some("claude-sonnet-4".to_string()),
provider_name: Some("openai".to_string()),
working_dir: Some("/tmp/workspace-child".to_string()),
workspace_id: Some("workspace-1".to_string()),
task_summary: Some("整理事实源".to_string()),
role_hint: Some("explorer".to_string()),
origin_tool: Some("spawn_agent".to_string()),
created_from_turn_id: Some("turn-1".to_string()),
profile_id: None,
profile_name: None,
role_key: None,
team_preset_id: None,
theme: None,
output_contract: None,
skill_ids: Vec::new(),
skills: Vec::new(),
runtime_status: None,
latest_turn_status: None,
queued_turn_count: 0,
};
apply_runtime_status_to_child_subagent_session(
&mut summary,
crate::subagent_control::SubagentRuntimeStatus {
session_id: "child-1".to_string(),
kind: SubagentRuntimeStatusKind::Queued,
latest_turn_id: Some("turn-queued".to_string()),
latest_turn_status: Some(SubagentRuntimeStatusKind::Completed),
queued_turn_count: 2,
closed: false,
},
);
assert_eq!(
summary.runtime_status,
Some(ChildSubagentRuntimeStatus::Queued)
);
assert_eq!(
summary.latest_turn_status,
Some(ChildSubagentRuntimeStatus::Completed)
);
assert_eq!(summary.queued_turn_count, 2);
}
#[test]
fn convert_agent_message_should_preserve_tool_request_and_response() {
let assistant = AgentMessage {
@@ -0,0 +1,417 @@
use crate::aster_runtime_support::{list_aster_runtime_queued_turns, load_aster_runtime_snapshot};
use aster::session::extension_data::{ExtensionData, ExtensionState};
use aster::session::{
list_subagent_sessions_with_metadata, require_shared_session_runtime_queue_service,
resolve_subagent_session_metadata, QueuedTurnRuntime, Session, SessionManager, SessionType,
TurnStatus,
};
use chrono::Utc;
use std::collections::{HashMap, VecDeque};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
pub struct SubagentControlState {
#[serde(default)]
pub closed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub closed_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub closed_reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stashed_queued_turns: Vec<QueuedTurnRuntime>,
}
impl ExtensionState for SubagentControlState {
const EXTENSION_NAME: &'static str = "subagent_control";
const VERSION: &'static str = "v0";
}
impl SubagentControlState {
pub fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
<Self as ExtensionState>::from_extension_data(extension_data)
}
pub fn from_session(session: &Session) -> Option<Self> {
Self::from_extension_data(&session.extension_data)
}
pub fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> {
<Self as ExtensionState>::to_extension_data(self, extension_data)
.map_err(|error| error.to_string())
}
pub fn into_updated_extension_data(self, session: &Session) -> Result<ExtensionData, String> {
let mut extension_data = session.extension_data.clone();
self.to_extension_data(&mut extension_data)?;
Ok(extension_data)
}
pub fn closed(reason: Option<String>, stashed_queued_turns: Vec<QueuedTurnRuntime>) -> Self {
Self {
closed: true,
closed_at: Some(Utc::now().to_rfc3339()),
closed_reason: normalize_optional_text(reason),
stashed_queued_turns,
}
}
pub fn opened(mut self) -> Self {
self.closed = false;
self.closed_at = None;
self.closed_reason = None;
self
}
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SubagentRuntimeStatusKind {
Idle,
Queued,
Running,
Completed,
Failed,
Aborted,
Closed,
NotFound,
}
impl SubagentRuntimeStatusKind {
pub fn is_final(self) -> bool {
matches!(
self,
Self::Completed | Self::Failed | Self::Aborted | Self::Closed | Self::NotFound
)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct SubagentRuntimeStatus {
pub session_id: String,
pub kind: SubagentRuntimeStatusKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_turn_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_turn_status: Option<SubagentRuntimeStatusKind>,
#[serde(default, skip_serializing_if = "is_zero")]
pub queued_turn_count: usize,
#[serde(default)]
pub closed: bool,
}
impl SubagentRuntimeStatus {
fn not_found(session_id: &str) -> Self {
Self {
session_id: session_id.to_string(),
kind: SubagentRuntimeStatusKind::NotFound,
latest_turn_id: None,
latest_turn_status: None,
queued_turn_count: 0,
closed: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LatestTurnProjection {
turn_id: String,
status: TurnStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubagentRuntimeStatusInput {
pub closed: bool,
pub has_active_turn: bool,
pub queued_turn_count: usize,
pub latest_turn_status: Option<TurnStatus>,
}
fn is_zero(value: &usize) -> bool {
*value == 0
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
let trimmed = value?.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn looks_like_session_not_found(error: &str) -> bool {
let normalized = error.to_ascii_lowercase();
normalized.contains("not found") || error.contains("不存在")
}
fn ensure_subagent_session(session: &Session) -> Result<(), String> {
if session.session_type != SessionType::SubAgent {
return Err(format!(
"会话不是 subagent session: session_id={}, session_type={}",
session.id, session.session_type
));
}
Ok(())
}
fn map_turn_status(status: TurnStatus) -> SubagentRuntimeStatusKind {
match status {
TurnStatus::Queued => SubagentRuntimeStatusKind::Queued,
TurnStatus::Running => SubagentRuntimeStatusKind::Running,
TurnStatus::Completed => SubagentRuntimeStatusKind::Completed,
TurnStatus::Failed => SubagentRuntimeStatusKind::Failed,
TurnStatus::Aborted => SubagentRuntimeStatusKind::Aborted,
}
}
fn latest_turn_projection(
snapshot: &aster::session::SessionRuntimeSnapshot,
) -> Option<LatestTurnProjection> {
snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.max_by(|left, right| {
left.updated_at
.cmp(&right.updated_at)
.then_with(|| left.created_at.cmp(&right.created_at))
.then_with(|| left.id.cmp(&right.id))
})
.map(|turn| LatestTurnProjection {
turn_id: turn.id.clone(),
status: turn.status,
})
}
pub fn derive_subagent_runtime_status_kind(
input: SubagentRuntimeStatusInput,
) -> SubagentRuntimeStatusKind {
if input.closed {
return SubagentRuntimeStatusKind::Closed;
}
if input.has_active_turn {
return SubagentRuntimeStatusKind::Running;
}
if input.queued_turn_count > 0 {
return SubagentRuntimeStatusKind::Queued;
}
input
.latest_turn_status
.map(map_turn_status)
.unwrap_or(SubagentRuntimeStatusKind::Idle)
}
pub async fn read_subagent_control_state(
session_id: &str,
) -> Result<(Session, SubagentControlState), String> {
let session = SessionManager::get_session(session_id, false)
.await
.map_err(|error| format!("读取 subagent session 失败: {error}"))?;
ensure_subagent_session(&session)?;
Ok((
session.clone(),
SubagentControlState::from_session(&session).unwrap_or_default(),
))
}
pub async fn write_subagent_control_state(
session: &Session,
control_state: &SubagentControlState,
) -> Result<(), String> {
ensure_subagent_session(session)?;
let extension_data = control_state
.clone()
.into_updated_extension_data(session)
.map_err(|error| format!("写入 subagent control state 失败: {error}"))?;
SessionManager::update_session(&session.id)
.extension_data(extension_data)
.apply()
.await
.map_err(|error| format!("持久化 subagent control state 失败: {error}"))
}
pub async fn load_subagent_runtime_status(
session_id: &str,
) -> Result<SubagentRuntimeStatus, String> {
let session = match SessionManager::get_session(session_id, false).await {
Ok(session) => session,
Err(error) => {
let message = error.to_string();
if looks_like_session_not_found(&message) {
return Ok(SubagentRuntimeStatus::not_found(session_id));
}
return Err(format!("读取 subagent session 失败: {message}"));
}
};
ensure_subagent_session(&session)?;
let control_state = SubagentControlState::from_session(&session).unwrap_or_default();
let latest_turn = match load_aster_runtime_snapshot(session_id).await {
Ok(snapshot) => latest_turn_projection(&snapshot),
Err(error) => {
tracing::debug!(
"[SubagentControl] 读取 runtime snapshot 失败,按无运行态继续: session_id={}, error={}",
session_id,
error
);
None
}
};
let queued_turn_count = list_aster_runtime_queued_turns(session_id).await?.len();
let has_active_turn = require_shared_session_runtime_queue_service()
.map_err(|error| format!("读取 runtime queue service 失败: {error}"))?
.has_active_turn(session_id);
let kind = derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput {
closed: control_state.closed,
has_active_turn,
queued_turn_count,
latest_turn_status: latest_turn.as_ref().map(|turn| turn.status),
});
Ok(SubagentRuntimeStatus {
session_id: session_id.to_string(),
kind,
latest_turn_id: latest_turn.as_ref().map(|turn| turn.turn_id.clone()),
latest_turn_status: latest_turn.map(|turn| map_turn_status(turn.status)),
queued_turn_count,
closed: control_state.closed,
})
}
pub async fn list_subagent_cascade_session_ids(session_id: &str) -> Result<Vec<String>, String> {
let root_session = SessionManager::get_session(session_id, false)
.await
.map_err(|error| format!("读取 subagent session 失败: {error}"))?;
ensure_subagent_session(&root_session)?;
let sessions = list_subagent_sessions_with_metadata()
.await
.map_err(|error| format!("读取 subagent session 列表失败: {error}"))?;
Ok(collect_subagent_cascade_session_ids(session_id, &sessions))
}
pub fn collect_subagent_cascade_session_ids(session_id: &str, sessions: &[Session]) -> Vec<String> {
let mut children_by_parent: HashMap<String, Vec<String>> = HashMap::new();
for session in sessions {
let Some(metadata) = resolve_subagent_session_metadata(&session.extension_data) else {
continue;
};
children_by_parent
.entry(metadata.parent_session_id)
.or_default()
.push(session.id.clone());
}
let mut ordered = vec![session_id.to_string()];
let mut queue = VecDeque::from([session_id.to_string()]);
while let Some(parent_id) = queue.pop_front() {
let Some(children) = children_by_parent.get(&parent_id) else {
continue;
};
for child_id in children {
ordered.push(child_id.clone());
queue.push_back(child_id.clone());
}
}
ordered
}
#[cfg(test)]
mod tests {
use super::*;
use aster::session::Session;
use chrono::{Duration, Utc};
#[test]
fn subagent_control_state_roundtrip() {
let state = SubagentControlState::closed(
Some("manual_close".to_string()),
vec![QueuedTurnRuntime {
queued_turn_id: "queued-1".to_string(),
session_id: "child-1".to_string(),
message_preview: "preview".to_string(),
message_text: "message".to_string(),
created_at: 1,
image_count: 0,
payload: serde_json::json!({ "message": "test" }),
metadata: HashMap::new(),
}],
);
let mut extension_data = ExtensionData::default();
state.to_extension_data(&mut extension_data).unwrap();
let restored = SubagentControlState::from_extension_data(&extension_data).unwrap();
assert_eq!(restored, state);
}
#[test]
fn collect_subagent_cascade_session_ids_returns_breadth_first_tree() {
let now = Utc::now();
let child_a = Session {
id: "child-a".to_string(),
session_type: SessionType::SubAgent,
updated_at: now,
extension_data: aster::session::SubagentSessionMetadata::new("root")
.into_updated_extension_data(&Session::default())
.unwrap(),
..Session::default()
};
let child_b = Session {
id: "child-b".to_string(),
session_type: SessionType::SubAgent,
updated_at: now - Duration::minutes(1),
extension_data: aster::session::SubagentSessionMetadata::new("root")
.into_updated_extension_data(&Session::default())
.unwrap(),
..Session::default()
};
let grandchild = Session {
id: "grandchild".to_string(),
session_type: SessionType::SubAgent,
updated_at: now - Duration::minutes(2),
extension_data: aster::session::SubagentSessionMetadata::new("child-a")
.into_updated_extension_data(&Session::default())
.unwrap(),
..Session::default()
};
let ids = collect_subagent_cascade_session_ids("root", &[child_a, child_b, grandchild]);
assert_eq!(ids, vec!["root", "child-a", "child-b", "grandchild"]);
}
#[test]
fn derive_subagent_runtime_status_kind_prioritizes_closed_and_final_states() {
assert_eq!(
derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput {
closed: true,
has_active_turn: true,
queued_turn_count: 2,
latest_turn_status: Some(TurnStatus::Running),
}),
SubagentRuntimeStatusKind::Closed
);
assert_eq!(
derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput {
closed: false,
has_active_turn: false,
queued_turn_count: 0,
latest_turn_status: Some(TurnStatus::Completed),
}),
SubagentRuntimeStatusKind::Completed
);
assert_eq!(
derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput {
closed: false,
has_active_turn: false,
queued_turn_count: 1,
latest_turn_status: Some(TurnStatus::Completed),
}),
SubagentRuntimeStatusKind::Queued
);
}
}
@@ -0,0 +1,428 @@
use aster::session::extension_data::{ExtensionData, ExtensionState};
use aster::session::Session;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct SubagentSkillSummary {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct SubagentProfileSummary {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_key: Option<String>,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_contract: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_overlay: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skill_ids: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct TeamPresetSummary {
pub id: String,
pub name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub profile_ids: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, Eq)]
pub struct SubagentCustomizationState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team_preset_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_contract: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_overlay: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skill_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<SubagentSkillSummary>,
}
impl ExtensionState for SubagentCustomizationState {
const EXTENSION_NAME: &'static str = "subagent_customization";
const VERSION: &'static str = "v0";
}
impl SubagentCustomizationState {
pub fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
<Self as ExtensionState>::from_extension_data(extension_data)
}
pub fn from_session(session: &Session) -> Option<Self> {
Self::from_extension_data(&session.extension_data)
}
pub fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> {
<Self as ExtensionState>::to_extension_data(self, extension_data)
.map_err(|error| error.to_string())
}
pub fn into_updated_extension_data(self, session: &Session) -> Result<ExtensionData, String> {
let mut extension_data = session.extension_data.clone();
self.to_extension_data(&mut extension_data)?;
Ok(extension_data)
}
pub fn is_empty(&self) -> bool {
self.profile_id.is_none()
&& self.profile_name.is_none()
&& self.role_key.is_none()
&& self.team_preset_id.is_none()
&& self.theme.is_none()
&& self.output_contract.is_none()
&& self.system_overlay.is_none()
&& self.skill_ids.is_empty()
&& self.skills.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubagentSkillPromptBlock {
pub title: String,
pub content: String,
}
#[derive(Debug, Clone, Copy)]
pub struct BuiltinSkillDescriptor {
pub id: &'static str,
pub name: &'static str,
pub description: &'static str,
pub prompt_overlay: &'static str,
}
#[derive(Debug, Clone, Copy)]
pub struct BuiltinProfileDescriptor {
pub id: &'static str,
pub name: &'static str,
pub role_key: &'static str,
pub description: &'static str,
pub theme: &'static str,
pub output_contract: &'static str,
pub system_overlay: &'static str,
pub skill_ids: &'static [&'static str],
}
#[derive(Debug, Clone, Copy)]
pub struct BuiltinTeamPresetDescriptor {
pub id: &'static str,
pub name: &'static str,
pub description: &'static str,
pub theme: &'static str,
pub profile_ids: &'static [&'static str],
}
const BUILTIN_SKILLS: &[BuiltinSkillDescriptor] = &[
BuiltinSkillDescriptor {
id: "repo-exploration",
name: "仓库探索",
description: "优先读事实源、收敛问题边界,并避免在未确认上下文前直接改动。",
prompt_overlay:
"先确认真实事实源,再输出发现、证据、影响面和下一步建议。不要直接跳到实现。",
},
BuiltinSkillDescriptor {
id: "bounded-implementation",
name: "边界实现",
description: "实现时只改明确归属的范围,避免与其他子代理写入冲突。",
prompt_overlay:
"实现只覆盖明确授权的范围。若存在未知依赖或潜在写入冲突,应先显式说明假设。",
},
BuiltinSkillDescriptor {
id: "verification-report",
name: "验证汇报",
description: "强调验证、回归、风险与剩余缺口,而不是泛化总结。",
prompt_overlay: "优先报告验证步骤、通过项、失败项、残余风险和建议回归范围,避免空泛总结。",
},
BuiltinSkillDescriptor {
id: "source-grounding",
name: "事实收敛",
description: "对调研与分析类任务要求明确区分事实、推断和待验证项。",
prompt_overlay: "输出中要明确区分事实、推断与待验证项;引用来源时尽量给出时间口径。",
},
BuiltinSkillDescriptor {
id: "structured-writing",
name: "结构写作",
description: "产出面向开发者可直接执行的方案、摘要或说明文档。",
prompt_overlay:
"写作优先输出可直接复用的结构化内容,避免空泛修辞,默认面向有经验的开发者。",
},
];
const BUILTIN_PROFILES: &[BuiltinProfileDescriptor] = &[
BuiltinProfileDescriptor {
id: "code-explorer",
name: "代码分析员",
role_key: "explorer",
description: "负责阅读代码、收敛问题、定位影响面与事实证据。",
theme: "engineering",
output_contract: "输出问题定位、证据、影响范围、候选方案,不直接大范围改文件。",
system_overlay:
"你是团队中的代码分析员。优先建立事实模型,明确根因和影响面,再给出最小变更建议。",
skill_ids: &["repo-exploration", "source-grounding"],
},
BuiltinProfileDescriptor {
id: "code-executor",
name: "代码执行员",
role_key: "executor",
description: "负责在清晰边界内实现改动,并回报改动与验证结果。",
theme: "engineering",
output_contract: "只在明确写入范围内实现,并说明改动点、验证结果、未覆盖风险。",
system_overlay:
"你是团队中的代码执行员。只在边界清晰、职责明确的范围里实现,不要扩散到无关模块。",
skill_ids: &["bounded-implementation", "verification-report"],
},
BuiltinProfileDescriptor {
id: "code-verifier",
name: "代码验证员",
role_key: "verifier",
description: "负责复核结果、补充测试与列出风险。",
theme: "engineering",
output_contract: "输出验证步骤、结论、失败项、剩余风险与建议回归范围。",
system_overlay:
"你是团队中的代码验证员。重点是验证与风险,不重复实现过程,不输出泛泛总结。",
skill_ids: &["verification-report", "source-grounding"],
},
BuiltinProfileDescriptor {
id: "research-analyst",
name: "研究分析员",
role_key: "researcher",
description: "负责多源材料整理、证据归并与结论提炼。",
theme: "research",
output_contract: "输出事实、结论、待验证项和来源时间口径。",
system_overlay: "你是团队中的研究分析员。优先整理来源、比对差异、提炼可支撑的结论。",
skill_ids: &["source-grounding", "structured-writing"],
},
BuiltinProfileDescriptor {
id: "doc-writer",
name: "文档起草员",
role_key: "writer",
description: "负责把分析结果转成方案、说明、PRD 或面向团队的文档。",
theme: "documentation",
output_contract: "输出结构清晰、可直接评审或落地的文档草稿。",
system_overlay:
"你是团队中的文档起草员。目标是产出可被开发者直接评审和执行的文档,而不是泛化描述。",
skill_ids: &["structured-writing"],
},
BuiltinProfileDescriptor {
id: "content-ideator",
name: "内容策划员",
role_key: "ideator",
description: "负责生成创意方向、候选结构与选题角度。",
theme: "content",
output_contract: "输出多个可比较方向,并说明适用场景与取舍。",
system_overlay: "你是团队中的内容策划员。优先给出有区分度的方向,而不是单一平均解。",
skill_ids: &["structured-writing"],
},
BuiltinProfileDescriptor {
id: "content-reviewer",
name: "内容复核员",
role_key: "reviewer",
description: "负责复核内容一致性、可读性与发布风险。",
theme: "content",
output_contract: "输出问题清单、建议修改项和发布前检查项。",
system_overlay: "你是团队中的内容复核员。重点识别表达问题、逻辑缺口和发布风险。",
skill_ids: &["verification-report", "structured-writing"],
},
];
const BUILTIN_TEAM_PRESETS: &[BuiltinTeamPresetDescriptor] = &[
BuiltinTeamPresetDescriptor {
id: "code-triage-team",
name: "代码排障团队",
description: "适合代码问题的分析、实现、验证闭环。",
theme: "engineering",
profile_ids: &["code-explorer", "code-executor", "code-verifier"],
},
BuiltinTeamPresetDescriptor {
id: "research-team",
name: "研究团队",
description: "适合事实收敛、资料分析和文档沉淀。",
theme: "research",
profile_ids: &["research-analyst", "doc-writer", "code-verifier"],
},
BuiltinTeamPresetDescriptor {
id: "content-creation-team",
name: "内容创作团队",
description: "适合创意拆分、内容起草与复核。",
theme: "content",
profile_ids: &["content-ideator", "doc-writer", "content-reviewer"],
},
];
fn normalize_optional_text(value: Option<String>) -> Option<String> {
let trimmed = value?.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
pub fn builtin_skill_descriptor_by_id(id: &str) -> Option<&'static BuiltinSkillDescriptor> {
BUILTIN_SKILLS
.iter()
.find(|descriptor| descriptor.id == id.trim())
}
pub fn builtin_profile_descriptor_by_id(id: &str) -> Option<&'static BuiltinProfileDescriptor> {
BUILTIN_PROFILES
.iter()
.find(|descriptor| descriptor.id == id.trim())
}
pub fn builtin_team_preset_descriptor_by_id(
id: &str,
) -> Option<&'static BuiltinTeamPresetDescriptor> {
BUILTIN_TEAM_PRESETS
.iter()
.find(|descriptor| descriptor.id == id.trim())
}
pub fn builtin_team_preset_label_by_id(id: &str) -> Option<&'static str> {
builtin_team_preset_descriptor_by_id(id).map(|descriptor| descriptor.name)
}
pub fn builtin_profile_name_by_id(id: &str) -> Option<&'static str> {
builtin_profile_descriptor_by_id(id).map(|descriptor| descriptor.name)
}
pub fn summarize_builtin_skill(id: &str) -> Option<SubagentSkillSummary> {
let descriptor = builtin_skill_descriptor_by_id(id)?;
Some(SubagentSkillSummary {
id: descriptor.id.to_string(),
name: descriptor.name.to_string(),
description: Some(descriptor.description.to_string()),
source: Some("builtin".to_string()),
directory: None,
})
}
pub fn summarize_builtin_profile(id: &str) -> Option<SubagentProfileSummary> {
let descriptor = builtin_profile_descriptor_by_id(id)?;
Some(SubagentProfileSummary {
id: descriptor.id.to_string(),
name: descriptor.name.to_string(),
role_key: Some(descriptor.role_key.to_string()),
description: descriptor.description.to_string(),
theme: Some(descriptor.theme.to_string()),
output_contract: Some(descriptor.output_contract.to_string()),
system_overlay: Some(descriptor.system_overlay.to_string()),
skill_ids: descriptor
.skill_ids
.iter()
.map(|skill_id| (*skill_id).to_string())
.collect(),
})
}
pub fn summarize_builtin_team_preset(id: &str) -> Option<TeamPresetSummary> {
let descriptor = builtin_team_preset_descriptor_by_id(id)?;
Some(TeamPresetSummary {
id: descriptor.id.to_string(),
name: descriptor.name.to_string(),
description: descriptor.description.to_string(),
theme: Some(descriptor.theme.to_string()),
profile_ids: descriptor
.profile_ids
.iter()
.map(|profile_id| (*profile_id).to_string())
.collect(),
})
}
pub fn build_subagent_customization_prompt(
customization: &SubagentCustomizationState,
local_skill_blocks: &[SubagentSkillPromptBlock],
) -> Option<String> {
if customization.is_empty() && local_skill_blocks.is_empty() {
return None;
}
let mut sections = Vec::new();
let mut header_lines = vec!["【Subagent 定制配置】".to_string()];
if let Some(team_preset_id) = customization.team_preset_id.as_deref() {
let preset_label =
builtin_team_preset_label_by_id(team_preset_id).unwrap_or(team_preset_id);
header_lines.push(format!("- 团队预设:{preset_label} ({team_preset_id})"));
}
if let Some(profile_name) = customization.profile_name.as_deref() {
let profile_id_suffix = customization
.profile_id
.as_deref()
.map(|profile_id| format!(" ({profile_id})"))
.unwrap_or_default();
header_lines.push(format!("- Profile:{profile_name}{profile_id_suffix}"));
} else if let Some(profile_id) = customization.profile_id.as_deref() {
let profile_name = builtin_profile_name_by_id(profile_id).unwrap_or(profile_id);
header_lines.push(format!("- Profile:{profile_name} ({profile_id})"));
}
if let Some(role_key) = customization.role_key.as_deref() {
header_lines.push(format!("- Role Key:{role_key}"));
}
if let Some(theme) = customization.theme.as_deref() {
header_lines.push(format!("- Theme:{theme}"));
}
if let Some(output_contract) = customization.output_contract.as_deref() {
header_lines.push(format!("- 输出契约:{output_contract}"));
}
sections.push(header_lines.join("\n"));
if let Some(system_overlay) = customization.system_overlay.as_deref() {
let trimmed = system_overlay.trim();
if !trimmed.is_empty() {
sections.push(format!("执行补充要求:\n{trimmed}"));
}
}
let builtin_skill_blocks = customization
.skill_ids
.iter()
.filter_map(|skill_id| builtin_skill_descriptor_by_id(skill_id))
.map(|descriptor| SubagentSkillPromptBlock {
title: format!("builtin skill · {}", descriptor.name),
content: descriptor.prompt_overlay.to_string(),
})
.collect::<Vec<_>>();
let mut all_skill_blocks = builtin_skill_blocks;
all_skill_blocks.extend(local_skill_blocks.iter().cloned());
if !all_skill_blocks.is_empty() {
let rendered_blocks = all_skill_blocks
.iter()
.filter_map(|block| {
let content = normalize_optional_text(Some(block.content.clone()))?;
Some(format!("### {}\n{}", block.title, content))
})
.collect::<Vec<_>>();
if !rendered_blocks.is_empty() {
sections.push(format!("附加技能:\n{}", rendered_blocks.join("\n\n")));
}
}
Some(sections.join("\n\n"))
}
@@ -230,6 +230,24 @@ impl Default for ToolPermissionChecker {
mod tests {
use super::*;
struct TestDynamicChecker;
impl DynamicPermissionCheck for TestDynamicChecker {
fn check_permissions(
&self,
tool_name: &str,
_input: &serde_json::Value,
) -> PermissionBehavior {
if tool_name == "bash" {
PermissionBehavior::Deny {
reason: "dynamic deny".to_string(),
}
} else {
PermissionBehavior::Allow
}
}
}
#[test]
fn test_default_permissions_loaded() {
let checker = ToolPermissionChecker::new();
@@ -384,4 +402,18 @@ mod tests {
PermissionBehavior::Allow
);
}
#[test]
fn test_set_dynamic_checker_overrides_static_decision() {
let mut checker = ToolPermissionChecker::new();
checker.set_dynamic_checker(Box::new(TestDynamicChecker));
let result = checker.check_permission("bash", Some(&serde_json::json!({})));
assert_eq!(
result,
PermissionBehavior::Deny {
reason: "dynamic deny".to_string(),
}
);
}
}
@@ -0,0 +1,5 @@
#[path = "../src/tool_permissions.rs"]
mod tool_permissions;
#[path = "../src/shell_security.rs"]
mod shell_security;
+6 -4
View File
@@ -39,9 +39,11 @@ pub use types::{
RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, RetrySettings, RoutingConfig,
ScreenshotChatConfig, SearchEngine, ServerConfig, ShellEnvironmentImportConfig, TaskSchedule,
TelegramAccountConfig, TelegramBotConfig, TelegramGroupConfig, TelegramTopicConfig, TlsConfig,
ToolCallingConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry, VertexModelAlias,
VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, VoiceOutputMode,
VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, WhisperLocalConfig, WhisperModelSize,
WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY,
ToolCallingConfig, ToolExecutionOverrideConfig, ToolExecutionPolicyConfig,
ToolExecutionRestrictionProfileConfig, ToolExecutionSandboxProfileConfig,
ToolExecutionWarningPolicyConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry,
VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig,
VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, WhisperLocalConfig,
WhisperModelSize, WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY,
};
pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService};
+146 -3
View File
@@ -492,6 +492,81 @@ impl Default for WorkspaceSandboxConfig {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ToolExecutionWarningPolicyConfig {
#[default]
None,
ShellCommandRisk,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ToolExecutionRestrictionProfileConfig {
#[default]
None,
WorkspacePathRequired,
WorkspacePathOptional,
WorkspaceAbsolutePathRequired,
WorkspaceShellCommand,
AnalyzeImageInput,
SafeHttpsUrlRequired,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ToolExecutionSandboxProfileConfig {
#[default]
None,
WorkspaceCommand,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ToolExecutionOverrideConfig {
#[serde(
default,
alias = "warningPolicy",
skip_serializing_if = "Option::is_none"
)]
pub warning_policy: Option<ToolExecutionWarningPolicyConfig>,
#[serde(
default,
alias = "restrictionProfile",
skip_serializing_if = "Option::is_none"
)]
pub restriction_profile: Option<ToolExecutionRestrictionProfileConfig>,
#[serde(
default,
alias = "sandboxProfile",
skip_serializing_if = "Option::is_none"
)]
pub sandbox_profile: Option<ToolExecutionSandboxProfileConfig>,
}
impl ToolExecutionOverrideConfig {
pub fn is_default(value: &Self) -> bool {
value.warning_policy.is_none()
&& value.restriction_profile.is_none()
&& value.sandbox_profile.is_none()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ToolExecutionPolicyConfig {
#[serde(
default,
alias = "toolOverrides",
skip_serializing_if = "HashMap::is_empty"
)]
pub tool_overrides: HashMap<String, ToolExecutionOverrideConfig>,
}
impl ToolExecutionPolicyConfig {
pub fn is_default(value: &Self) -> bool {
value.tool_overrides.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NativeAgentConfig {
/// 是否使用默认系统提示词
@@ -518,6 +593,9 @@ pub struct NativeAgentConfig {
/// workspace 本地 sandbox 配置(可选安全增强)
#[serde(default, skip_serializing_if = "WorkspaceSandboxConfig::is_default")]
pub workspace_sandbox: WorkspaceSandboxConfig,
/// 工具执行权限覆盖配置(默认策略之上的持久化覆盖)
#[serde(default, skip_serializing_if = "ToolExecutionPolicyConfig::is_default")]
pub tool_execution: ToolExecutionPolicyConfig,
}
fn default_use_default_prompt() -> bool {
@@ -546,6 +624,7 @@ impl Default for NativeAgentConfig {
temperature: default_temperature(),
max_tokens: default_max_tokens(),
workspace_sandbox: WorkspaceSandboxConfig::default(),
tool_execution: ToolExecutionPolicyConfig::default(),
}
}
}
@@ -573,7 +652,7 @@ fn current_workspace_preferences_schema_version() -> u8 {
}
fn default_enabled_themes() -> Vec<String> {
vec!["social-media".to_string(), "poster".to_string()]
vec!["social-media".to_string()]
}
impl Default for ContentCreatorConfig {
@@ -2637,7 +2716,7 @@ mod unit_tests {
assert_eq!(config.content_creator.schema_version, 1);
assert_eq!(
config.content_creator.enabled_themes,
vec!["social-media".to_string(), "poster".to_string()]
vec!["social-media".to_string()]
);
assert_eq!(config.navigation.schema_version, 1);
assert_eq!(
@@ -2654,6 +2733,70 @@ mod unit_tests {
"memory".to_string(),
]
);
assert!(config.agent.tool_execution.tool_overrides.is_empty());
}
#[test]
fn test_tool_execution_policy_config_supports_camel_case_runtime_shape() {
let value = serde_json::json!({
"toolOverrides": {
"bash": {
"warningPolicy": "none",
"restrictionProfile": "workspace_path_required",
"sandboxProfile": "none"
},
"Task": {
"warning_policy": "shell_command_risk"
}
}
});
let parsed: ToolExecutionPolicyConfig =
serde_json::from_value(value).expect("tool execution config should deserialize");
assert_eq!(
parsed
.tool_overrides
.get("bash")
.and_then(|item| item.warning_policy),
Some(ToolExecutionWarningPolicyConfig::None)
);
assert_eq!(
parsed
.tool_overrides
.get("bash")
.and_then(|item| item.restriction_profile),
Some(ToolExecutionRestrictionProfileConfig::WorkspacePathRequired)
);
assert_eq!(
parsed
.tool_overrides
.get("Task")
.and_then(|item| item.warning_policy),
Some(ToolExecutionWarningPolicyConfig::ShellCommandRisk)
);
}
#[test]
fn test_tool_execution_policy_config_roundtrip_preserves_non_default_overrides() {
let config = ToolExecutionPolicyConfig {
tool_overrides: HashMap::from([(
"bash".to_string(),
ToolExecutionOverrideConfig {
warning_policy: Some(ToolExecutionWarningPolicyConfig::None),
restriction_profile: Some(
ToolExecutionRestrictionProfileConfig::WorkspacePathRequired,
),
sandbox_profile: Some(ToolExecutionSandboxProfileConfig::None),
},
)]),
};
let value = serde_json::to_value(&config).expect("tool execution config should serialize");
let parsed: ToolExecutionPolicyConfig =
serde_json::from_value(value).expect("tool execution config should deserialize");
assert_eq!(parsed, config);
}
#[test]
@@ -2682,7 +2825,7 @@ mod unit_tests {
assert_eq!(config.content_creator.schema_version, 1);
assert_eq!(
config.content_creator.enabled_themes,
vec!["social-media".to_string(), "poster".to_string()]
vec!["social-media".to_string()]
);
assert_eq!(config.navigation.schema_version, 1);
assert_eq!(
@@ -917,8 +917,16 @@ impl AgentDao {
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
conn.execute(
"INSERT INTO agent_messages (session_id, role, content_json, timestamp, tool_calls_json, tool_call_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
"INSERT INTO agent_messages (
session_id,
role,
content_json,
timestamp,
tool_calls_json,
tool_call_id,
reasoning_content
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
session_id,
message.role,
@@ -926,6 +934,7 @@ impl AgentDao {
message.timestamp,
tool_calls_json,
message.tool_call_id,
message.reasoning_content.as_deref(),
],
)?;
@@ -944,7 +953,7 @@ impl AgentDao {
session_id: &str,
) -> Result<Vec<AgentMessage>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT role, content_json, timestamp, tool_calls_json, tool_call_id
"SELECT role, content_json, timestamp, tool_calls_json, tool_call_id, reasoning_content
FROM agent_messages WHERE session_id = ? ORDER BY id ASC",
)?;
@@ -954,6 +963,7 @@ impl AgentDao {
let timestamp: String = row.get(2)?;
let tool_calls_json: Option<String> = row.get(3)?;
let tool_call_id: Option<String> = row.get(4)?;
let reasoning_content: Option<String> = row.get(5)?;
// 解析 JSON - 支持多种格式
// 1. Aster 格式: [{"Text":"..."}, {"Text":"..."}]
@@ -969,7 +979,7 @@ impl AgentDao {
timestamp,
tool_calls,
tool_call_id,
reasoning_content: None,
reasoning_content,
})
})?;
@@ -1094,7 +1104,8 @@ mod tests {
content_json TEXT NOT NULL,
timestamp TEXT NOT NULL,
tool_calls_json TEXT,
tool_call_id TEXT
tool_call_id TEXT,
reasoning_content TEXT
);
",
)
@@ -1407,4 +1418,44 @@ mod tests {
assert_eq!(renamed.session.title.as_deref(), Some("新的标题"));
assert_eq!(renamed.session.updated_at, "2026-03-12T09:00:00+08:00");
}
#[test]
fn add_message_and_get_messages_should_roundtrip_reasoning_content() {
let conn = setup_pattern_test_db();
conn.execute(
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir, execution_strategy)
VALUES (?1, ?2, NULL, ?3, ?4, ?5, NULL, ?6)",
params![
"session-reasoning",
"deepseek-reasoner",
"推理会话",
"2026-03-19T10:00:00+08:00",
"2026-03-19T10:00:00+08:00",
"react"
],
)
.unwrap();
AgentDao::add_message(
&conn,
"session-reasoning",
&crate::agent::types::AgentMessage {
role: "assistant".to_string(),
content: MessageContent::Text("需要继续调用工具".to_string()),
timestamp: "2026-03-19T10:00:01+08:00".to_string(),
tool_calls: None,
tool_call_id: None,
reasoning_content: Some("先分析参数,再继续请求".to_string()),
},
)
.unwrap();
let messages = AgentDao::get_messages(&conn, "session-reasoning").unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(
messages[0].reasoning_content.as_deref(),
Some("先分析参数,再继续请求")
);
}
}
@@ -670,39 +670,40 @@ mod tests {
use super::*;
use rusqlite::{params, Connection};
struct ModelUsageStatSeed<'a> {
model_id: &'a str,
credential_id: &'a str,
date: &'a str,
request_count: i64,
success_count: i64,
error_count: i64,
total_tokens: i64,
total_latency_ms: i64,
}
fn setup_test_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
crate::database::schema::create_tables(&conn).unwrap();
conn
}
fn insert_model_usage_stat(
conn: &Connection,
model_id: &str,
credential_id: &str,
date: &str,
request_count: i64,
success_count: i64,
error_count: i64,
total_tokens: i64,
total_latency_ms: i64,
) {
fn insert_model_usage_stat(conn: &Connection, stat: ModelUsageStatSeed<'_>) {
conn.execute(
"INSERT INTO model_usage_stats (
model_id, credential_id, date, request_count, success_count,
error_count, total_tokens, total_latency_ms, avg_latency_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
model_id,
credential_id,
date,
request_count,
success_count,
error_count,
total_tokens,
total_latency_ms,
if request_count > 0 {
total_latency_ms as f64 / request_count as f64
stat.model_id,
stat.credential_id,
stat.date,
stat.request_count,
stat.success_count,
stat.error_count,
stat.total_tokens,
stat.total_latency_ms,
if stat.request_count > 0 {
stat.total_latency_ms as f64 / stat.request_count as f64
} else {
0.0
},
@@ -821,27 +822,43 @@ mod tests {
insert_model_usage_stat(
&conn,
"claude-3-opus",
"cred-1",
"2026-03-10",
2,
2,
0,
2000,
1000,
ModelUsageStatSeed {
model_id: "claude-3-opus",
credential_id: "cred-1",
date: "2026-03-10",
request_count: 2,
success_count: 2,
error_count: 0,
total_tokens: 2000,
total_latency_ms: 1000,
},
);
insert_model_usage_stat(
&conn,
"claude-3-opus",
"cred-2",
"2026-03-11",
1,
1,
0,
1200,
600,
ModelUsageStatSeed {
model_id: "claude-3-opus",
credential_id: "cred-2",
date: "2026-03-11",
request_count: 1,
success_count: 1,
error_count: 0,
total_tokens: 1200,
total_latency_ms: 600,
},
);
insert_model_usage_stat(
&conn,
ModelUsageStatSeed {
model_id: "gpt-4.1",
credential_id: "cred-3",
date: "2026-03-12",
request_count: 3,
success_count: 2,
error_count: 1,
total_tokens: 900,
total_latency_ms: 450,
},
);
insert_model_usage_stat(&conn, "gpt-4.1", "cred-3", "2026-03-12", 3, 2, 1, 900, 450);
assert!(OrchestratorDao::has_model_usage_stats(&conn).unwrap());
assert_eq!(
@@ -395,7 +395,8 @@ mod tests {
content_json TEXT NOT NULL,
timestamp TEXT NOT NULL,
tool_calls_json TEXT,
tool_call_id TEXT
tool_call_id TEXT,
reasoning_content TEXT
);
",
)
@@ -428,7 +429,8 @@ mod tests {
content_json TEXT NOT NULL,
timestamp TEXT NOT NULL,
tool_calls_json TEXT,
tool_call_id TEXT
tool_call_id TEXT,
reasoning_content TEXT
);
",
)
+106 -100
View File
@@ -565,11 +565,17 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
timestamp TEXT NOT NULL,
tool_calls_json TEXT,
tool_call_id TEXT,
reasoning_content TEXT,
FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
)",
[],
)?;
let _ = conn.execute(
"ALTER TABLE agent_messages ADD COLUMN reasoning_content TEXT",
[],
);
// 创建 agent_messages 索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_agent_messages_session ON agent_messages(session_id)",
@@ -1444,106 +1450,6 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_upgrade_legacy_browser_profile_table_with_transport_columns() {
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE browser_profiles (
id TEXT PRIMARY KEY,
profile_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
site_scope TEXT,
launch_url TEXT,
profile_dir TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_used_at TEXT,
archived_at TEXT
)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO browser_profiles (
id, profile_key, name, description, site_scope, launch_url, profile_dir,
created_at, updated_at, last_used_at, archived_at
) VALUES (?1, ?2, ?3, NULL, NULL, ?4, ?5, ?6, ?6, NULL, NULL)",
(
"profile-1",
"shop_us",
"美区资料",
"https://seller.example.com/",
"/tmp/lime/chrome_profiles/shop_us",
"2026-03-15T00:00:00Z",
),
)
.unwrap();
create_tables(&conn).expect("应成功升级旧版 browser_profiles 表");
let mut columns = conn.prepare("PRAGMA table_info(browser_profiles)").unwrap();
let column_names = columns
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(column_names.iter().any(|name| name == "transport_kind"));
assert!(column_names
.iter()
.any(|name| name == "managed_profile_dir"));
let upgraded = conn
.query_row(
"SELECT transport_kind, managed_profile_dir
FROM browser_profiles
WHERE id = ?1",
["profile-1"],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
)
.unwrap();
assert_eq!(upgraded.0, "managed_cdp");
assert_eq!(
upgraded.1.as_deref(),
Some("/tmp/lime/chrome_profiles/shop_us")
);
}
#[test]
fn should_upgrade_legacy_mcp_servers_table_with_enablement_columns() {
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL,
description TEXT,
enabled_claude INTEGER DEFAULT 0
)",
[],
)
.unwrap();
create_tables(&conn).expect("应成功升级旧版 mcp_servers 表");
let mut columns = conn.prepare("PRAGMA table_info(mcp_servers)").unwrap();
let column_names = columns
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(column_names.iter().any(|name| name == "enabled_lime"));
assert!(column_names.iter().any(|name| name == "enabled_codex"));
assert!(column_names.iter().any(|name| name == "enabled_gemini"));
assert!(column_names.iter().any(|name| name == "created_at"));
}
}
/// 迁移:添加proxy_url列到provider_pool_credentials表
/// 使用重建表结构的方式确保数据完整性
fn migrate_add_proxy_url_column(conn: &Connection) -> Result<(), rusqlite::Error> {
@@ -1658,3 +1564,103 @@ fn migrate_add_proxy_url_column(conn: &Connection) -> Result<(), rusqlite::Error
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_upgrade_legacy_browser_profile_table_with_transport_columns() {
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE browser_profiles (
id TEXT PRIMARY KEY,
profile_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
site_scope TEXT,
launch_url TEXT,
profile_dir TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_used_at TEXT,
archived_at TEXT
)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO browser_profiles (
id, profile_key, name, description, site_scope, launch_url, profile_dir,
created_at, updated_at, last_used_at, archived_at
) VALUES (?1, ?2, ?3, NULL, NULL, ?4, ?5, ?6, ?6, NULL, NULL)",
(
"profile-1",
"shop_us",
"美区资料",
"https://seller.example.com/",
"/tmp/lime/chrome_profiles/shop_us",
"2026-03-15T00:00:00Z",
),
)
.unwrap();
create_tables(&conn).expect("应成功升级旧版 browser_profiles 表");
let mut columns = conn.prepare("PRAGMA table_info(browser_profiles)").unwrap();
let column_names = columns
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(column_names.iter().any(|name| name == "transport_kind"));
assert!(column_names
.iter()
.any(|name| name == "managed_profile_dir"));
let upgraded = conn
.query_row(
"SELECT transport_kind, managed_profile_dir
FROM browser_profiles
WHERE id = ?1",
["profile-1"],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
)
.unwrap();
assert_eq!(upgraded.0, "managed_cdp");
assert_eq!(
upgraded.1.as_deref(),
Some("/tmp/lime/chrome_profiles/shop_us")
);
}
#[test]
fn should_upgrade_legacy_mcp_servers_table_with_enablement_columns() {
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL,
description TEXT,
enabled_claude INTEGER DEFAULT 0
)",
[],
)
.unwrap();
create_tables(&conn).expect("应成功升级旧版 mcp_servers 表");
let mut columns = conn.prepare("PRAGMA table_info(mcp_servers)").unwrap();
let column_names = columns
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(column_names.iter().any(|name| name == "enabled_lime"));
assert!(column_names.iter().any(|name| name == "enabled_codex"));
assert!(column_names.iter().any(|name| name == "enabled_gemini"));
assert!(column_names.iter().any(|name| name == "created_at"));
}
}
+180
View File
@@ -4,6 +4,7 @@
use crate::config::{Config, ToolCallingConfig};
use crate::env_compat;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -23,6 +24,20 @@ static TOOLCALL_V2_ENABLED: AtomicBool = AtomicBool::new(true);
static TOOLCALL_DYNAMIC_FILTERING_ENABLED: AtomicBool = AtomicBool::new(true);
static TOOLCALL_NATIVE_INPUT_EXAMPLES_ENABLED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ToolSurfaceMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deferred_loading: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub always_visible: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_callers: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub input_examples: Vec<Value>,
}
/// 将配置应用到进程内运行时开关。
pub fn apply_tool_calling_runtime_config(config: &Config) {
apply_tool_calling_runtime_config_with_flags(&config.tool_calling);
@@ -69,6 +84,109 @@ pub fn tool_calling_native_input_examples_enabled() -> bool {
false
}
fn metadata_extension(schema: &Value) -> &Value {
schema
.get("x-lime")
.or_else(|| schema.get("x_lime"))
.unwrap_or(schema)
}
fn metadata_read_bool(schema: &Value, key: &str, camel_key: &str) -> Option<bool> {
metadata_extension(schema)
.get(key)
.or_else(|| metadata_extension(schema).get(camel_key))
.and_then(|value| value.as_bool())
}
fn metadata_read_string_vec(schema: &Value, key: &str, camel_key: &str) -> Option<Vec<String>> {
let values = metadata_extension(schema)
.get(key)
.or_else(|| metadata_extension(schema).get(camel_key))
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str())
.map(|item| item.trim().to_ascii_lowercase())
.filter(|item| !item.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
(!values.is_empty()).then_some(values)
}
pub fn normalize_tool_caller(caller: Option<&str>) -> Option<String> {
caller
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase())
}
pub fn extract_tool_surface_metadata(tool_name: &str, schema: &Value) -> ToolSurfaceMetadata {
ToolSurfaceMetadata {
deferred_loading: metadata_read_bool(schema, "deferred_loading", "deferredLoading"),
always_visible: metadata_read_bool(schema, "always_visible", "alwaysVisible"),
allowed_callers: metadata_read_string_vec(schema, "allowed_callers", "allowedCallers"),
tags: metadata_read_string_vec(schema, "tags", "tags"),
input_examples: resolve_tool_input_examples(tool_name, schema),
}
}
pub fn tool_visible_in_context(metadata: &ToolSurfaceMetadata, include_deferred: bool) -> bool {
if include_deferred {
return true;
}
let deferred_loading = metadata.deferred_loading.unwrap_or(false);
let always_visible = metadata.always_visible.unwrap_or(false);
!deferred_loading || always_visible
}
pub fn tool_matches_caller(metadata: &ToolSurfaceMetadata, caller: Option<&str>) -> bool {
let Some(allowed_callers) = metadata.allowed_callers.as_ref() else {
return true;
};
let Some(caller) = normalize_tool_caller(caller) else {
return true;
};
allowed_callers.iter().any(|item| item == &caller)
}
pub fn score_tool_match(name: &str, description: &str, tags: &[String], query: &str) -> i32 {
let query = query.trim().to_ascii_lowercase();
if query.is_empty() {
return 1;
}
let name_lc = name.to_ascii_lowercase();
let description_lc = description.to_ascii_lowercase();
let mut score = 0;
if name_lc == query {
score += 120;
} else if name_lc.starts_with(&query) {
score += 90;
} else if name_lc.contains(&query) {
score += 70;
}
if description_lc.contains(&query) {
score += 40;
}
for tag in tags {
if tag == &query {
score += 35;
} else if tag.contains(&query) {
score += 20;
}
}
score
}
fn schema_read_examples(schema: &Value) -> Vec<Value> {
let extension = schema
.get("x-lime")
@@ -308,4 +426,66 @@ mod tests {
let examples = resolve_tool_input_examples("docs_search", &schema);
assert!(examples.is_empty());
}
#[test]
fn test_extract_tool_surface_metadata_reads_extension_fields() {
let schema = serde_json::json!({
"x-lime": {
"deferred_loading": true,
"always_visible": false,
"allowed_callers": ["assistant", "code_execution"],
"input_examples": [{"query":"rust"}],
"tags": ["docs", "search"]
}
});
let metadata = extract_tool_surface_metadata("docs_search", &schema);
assert_eq!(metadata.deferred_loading, Some(true));
assert_eq!(metadata.always_visible, Some(false));
assert_eq!(
metadata.allowed_callers,
Some(vec!["assistant".to_string(), "code_execution".to_string()])
);
assert_eq!(
metadata.tags,
Some(vec!["docs".to_string(), "search".to_string()])
);
assert_eq!(
metadata.input_examples,
vec![serde_json::json!({"query":"rust"})]
);
}
#[test]
fn test_tool_visibility_and_caller_match_follow_metadata() {
let metadata = ToolSurfaceMetadata {
deferred_loading: Some(true),
always_visible: Some(false),
allowed_callers: Some(vec!["assistant".to_string()]),
tags: None,
input_examples: Vec::new(),
};
assert!(!tool_visible_in_context(&metadata, false));
assert!(tool_visible_in_context(&metadata, true));
assert!(tool_matches_caller(&metadata, Some("assistant")));
assert!(!tool_matches_caller(&metadata, Some("code_execution")));
}
#[test]
fn test_score_tool_match_prefers_exact_name() {
let exact = score_tool_match(
"tool_search",
"Search tool surfaces",
&["search".to_string()],
"tool_search",
);
let partial = score_tool_match(
"tool_lookup",
"Search tool surfaces",
&["search".to_string()],
"tool_search",
);
assert!(exact > partial);
}
}
+10 -3
View File
@@ -183,7 +183,8 @@ pub struct McpClientWrapper {
pub process: Option<tokio::process::Child>,
pub server_info: Option<super::types::McpServerCapabilities>,
pub client_handler: Arc<LimeMcpClient>,
pub running_service: Option<rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>>,
pub running_service:
Option<Arc<rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>>>,
}
impl McpClientWrapper {
@@ -220,15 +221,21 @@ impl McpClientWrapper {
&mut self,
service: rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>,
) {
self.running_service = Some(service);
self.running_service = Some(Arc::new(service));
}
pub fn running_service(
&self,
) -> Option<&rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>> {
) -> Option<&Arc<rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>>> {
self.running_service.as_ref()
}
pub fn running_service_arc(
&self,
) -> Option<Arc<rmcp::service::RunningService<rmcp::RoleClient, LimeMcpClient>>> {
self.running_service.clone()
}
pub async fn kill_process(&mut self) -> Result<(), std::io::Error> {
if let Some(ref mut process) = self.process {
process.kill().await?;
+267 -106
View File
@@ -26,7 +26,7 @@
#![allow(dead_code)]
use lime_core::DynEmitter;
use lime_core::{tool_calling::ToolSurfaceMetadata, DynEmitter};
use std::collections::{HashMap, HashSet};
use std::process::Stdio;
use std::sync::Arc;
@@ -42,14 +42,7 @@ use rmcp::ServiceExt;
use crate::client::McpClientWrapper;
use crate::types::*;
#[derive(Debug, Default)]
struct ToolMetadataExtraction {
deferred_loading: Option<bool>,
always_visible: Option<bool>,
allowed_callers: Option<Vec<String>>,
input_examples: Option<Vec<serde_json::Value>>,
tags: Option<Vec<String>>,
}
const AUTO_DEFER_TOOL_COUNT_THRESHOLD: usize = 6;
/// MCP 客户端管理器
///
@@ -730,7 +723,8 @@ impl McpClientManager {
);
for tool in tools {
let input_schema = serde_json::Value::Object((*tool.input_schema).clone());
let metadata = Self::extract_tool_metadata(&input_schema);
let metadata =
Self::extract_tool_metadata(tool.name.as_ref(), &input_schema);
all_tools.push(McpToolDefinition {
name: tool.name.to_string(),
description: tool
@@ -743,7 +737,8 @@ impl McpClientManager {
deferred_loading: metadata.deferred_loading,
always_visible: metadata.always_visible,
allowed_callers: metadata.allowed_callers,
input_examples: metadata.input_examples,
input_examples: (!metadata.input_examples.is_empty())
.then_some(metadata.input_examples),
tags: metadata.tags,
});
}
@@ -761,7 +756,8 @@ impl McpClientManager {
drop(clients);
// 3. 解决名称冲突(添加服务器前缀)
let resolved_tools = Self::resolve_tool_name_conflicts(all_tools);
let resolved_tools =
Self::apply_default_loading_policy(Self::resolve_tool_name_conflicts(all_tools));
// 4. 更新缓存
self.update_tool_cache(resolved_tools.clone()).await;
@@ -782,36 +778,23 @@ impl McpClientManager {
caller: Option<&str>,
include_deferred: bool,
) -> Result<Vec<McpToolDefinition>, McpError> {
let caller = caller
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase());
let tools = self.list_tools().await?;
let filtered = tools
.into_iter()
.filter(|tool| {
// deferred_loading=true 且不是 always_visible 时,默认不注入上下文
if !include_deferred
&& tool.deferred_loading.unwrap_or(false)
&& !tool.always_visible.unwrap_or(false)
{
let metadata = ToolSurfaceMetadata {
deferred_loading: tool.deferred_loading,
always_visible: tool.always_visible,
allowed_callers: tool.allowed_callers.clone(),
tags: tool.tags.clone(),
input_examples: tool.input_examples.clone().unwrap_or_default(),
};
if !lime_core::tool_calling::tool_visible_in_context(&metadata, include_deferred) {
return false;
}
// caller 不在 allowed_callers 时,隐藏该工具
if let (Some(caller), Some(allowed)) = (&caller, tool.allowed_callers.as_ref()) {
let allowed_set: HashSet<String> = allowed
.iter()
.map(|v| v.trim().to_ascii_lowercase())
.filter(|v| !v.is_empty())
.collect();
if !allowed_set.is_empty() && !allowed_set.contains(caller) {
return false;
}
}
true
lime_core::tool_calling::tool_matches_caller(&metadata, caller)
})
.collect();
@@ -867,85 +850,53 @@ impl McpClientManager {
Ok(result)
}
fn extract_tool_metadata(input_schema: &serde_json::Value) -> ToolMetadataExtraction {
fn read_bool(root: &serde_json::Value, key: &str) -> Option<bool> {
root.get(key).and_then(|v| v.as_bool())
}
fn read_string_vec(root: &serde_json::Value, key: &str) -> Option<Vec<String>> {
let arr = root.get(key)?.as_array()?;
let values = arr
.iter()
.filter_map(|v| v.as_str())
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect::<Vec<_>>();
(!values.is_empty()).then_some(values)
}
fn read_examples(root: &serde_json::Value, key: &str) -> Option<Vec<serde_json::Value>> {
let arr = root.get(key)?.as_array()?;
let values = arr
.iter()
.filter(|v| !v.is_null())
.cloned()
.collect::<Vec<_>>();
(!values.is_empty()).then_some(values)
}
let extension = input_schema
.get("x-lime")
.or_else(|| input_schema.get("x_lime"))
.unwrap_or(input_schema);
ToolMetadataExtraction {
deferred_loading: read_bool(extension, "deferred_loading")
.or_else(|| read_bool(extension, "deferredLoading")),
always_visible: read_bool(extension, "always_visible")
.or_else(|| read_bool(extension, "alwaysVisible")),
allowed_callers: read_string_vec(extension, "allowed_callers")
.or_else(|| read_string_vec(extension, "allowedCallers")),
input_examples: read_examples(extension, "input_examples")
.or_else(|| read_examples(extension, "inputExamples")),
tags: read_string_vec(extension, "tags"),
}
fn extract_tool_metadata(
tool_name: &str,
input_schema: &serde_json::Value,
) -> ToolSurfaceMetadata {
lime_core::tool_calling::extract_tool_surface_metadata(tool_name, input_schema)
}
fn score_tool_match(tool: &McpToolDefinition, query: &str) -> i32 {
let name = tool.name.to_ascii_lowercase();
let description = tool.description.to_ascii_lowercase();
let mut score = 0;
if name == query {
score += 120;
} else if name.starts_with(query) {
score += 90;
} else if name.contains(query) {
score += 70;
}
if description.contains(query) {
score += 40;
}
if let Some(tags) = tool.tags.as_ref() {
for tag in tags {
let tag = tag.to_ascii_lowercase();
if tag == query {
score += 35;
} else if tag.contains(query) {
score += 20;
}
}
}
let score = lime_core::tool_calling::score_tool_match(
&tool.name,
&tool.description,
tool.tags.as_deref().unwrap_or(&[]),
query,
);
if tool.always_visible.unwrap_or(false) {
score += 5;
return score + 5;
}
score
}
fn apply_default_loading_policy(tools: Vec<McpToolDefinition>) -> Vec<McpToolDefinition> {
let mut server_tool_counts: HashMap<String, usize> = HashMap::new();
for tool in &tools {
*server_tool_counts
.entry(tool.server_name.clone())
.or_insert(0) += 1;
}
tools
.into_iter()
.map(|mut tool| {
if tool.deferred_loading.is_none() {
let should_auto_defer = server_tool_counts
.get(&tool.server_name)
.copied()
.unwrap_or_default()
> AUTO_DEFER_TOOL_COUNT_THRESHOLD
&& !tool.always_visible.unwrap_or(false);
tool.deferred_loading = Some(should_auto_defer);
}
tool
})
.collect()
}
/// 解决工具名称冲突
///
/// 当多个服务器提供同名工具时,为冲突的工具名称添加服务器前缀。
@@ -1975,14 +1926,14 @@ mod tests {
"tags": ["search", "docs"]
}
});
let meta = McpClientManager::extract_tool_metadata(&schema);
let meta = McpClientManager::extract_tool_metadata("docs_search", &schema);
assert_eq!(meta.deferred_loading, Some(true));
assert_eq!(meta.always_visible, Some(false));
assert_eq!(
meta.allowed_callers.unwrap_or_default(),
vec!["assistant".to_string(), "code_execution".to_string()]
);
assert_eq!(meta.input_examples.unwrap_or_default().len(), 1);
assert_eq!(meta.input_examples.len(), 1);
assert_eq!(
meta.tags.unwrap_or_default(),
vec!["search".to_string(), "docs".to_string()]
@@ -2189,6 +2140,160 @@ mod tests {
assert!(resolved.is_empty());
}
#[test]
fn test_apply_default_loading_policy_auto_defers_large_server_tools() {
let tools = (0..7)
.map(|index| McpToolDefinition {
name: format!("tool_{index}"),
description: format!("tool {index}"),
input_schema: serde_json::json!({}),
server_name: "large-server".to_string(),
deferred_loading: None,
always_visible: if index == 0 { Some(true) } else { None },
allowed_callers: None,
input_examples: None,
tags: None,
})
.collect::<Vec<_>>();
let resolved = McpClientManager::apply_default_loading_policy(tools);
assert_eq!(resolved.len(), 7);
assert_eq!(resolved[0].deferred_loading, Some(false));
assert!(resolved
.iter()
.skip(1)
.all(|tool| tool.deferred_loading == Some(true)));
}
#[test]
fn test_resolve_tool_name_conflicts_preserves_metadata_fields() {
let mut tool_a = create_test_tool("search", "Search docs", "server1");
tool_a.deferred_loading = Some(true);
tool_a.always_visible = Some(true);
tool_a.allowed_callers = Some(vec!["assistant".to_string()]);
tool_a.tags = Some(vec!["docs".to_string()]);
tool_a.input_examples = Some(vec![serde_json::json!({ "query": "rust" })]);
let mut tool_b = create_test_tool("search", "Search issues", "server2");
tool_b.deferred_loading = Some(false);
tool_b.always_visible = Some(false);
tool_b.allowed_callers = Some(vec!["code_execution".to_string()]);
tool_b.tags = Some(vec!["issues".to_string()]);
tool_b.input_examples = Some(vec![serde_json::json!({ "query": "bug" })]);
let resolved = McpClientManager::resolve_tool_name_conflicts(vec![tool_a, tool_b]);
let server1 = resolved
.iter()
.find(|tool| tool.name == "server1_search")
.expect("server1 tool should be renamed");
let server2 = resolved
.iter()
.find(|tool| tool.name == "server2_search")
.expect("server2 tool should be renamed");
assert_eq!(server1.deferred_loading, Some(true));
assert_eq!(server1.always_visible, Some(true));
assert_eq!(server1.allowed_callers, Some(vec!["assistant".to_string()]));
assert_eq!(server1.tags, Some(vec!["docs".to_string()]));
assert_eq!(
server1.input_examples,
Some(vec![serde_json::json!({ "query": "rust" })])
);
assert_eq!(server2.deferred_loading, Some(false));
assert_eq!(server2.always_visible, Some(false));
assert_eq!(
server2.allowed_callers,
Some(vec!["code_execution".to_string()])
);
assert_eq!(server2.tags, Some(vec!["issues".to_string()]));
assert_eq!(
server2.input_examples,
Some(vec![serde_json::json!({ "query": "bug" })])
);
}
#[test]
fn test_apply_default_loading_policy_respects_threshold_boundary_and_explicit_values() {
let threshold_tools = (0..AUTO_DEFER_TOOL_COUNT_THRESHOLD)
.map(|index| McpToolDefinition {
name: format!("threshold_{index}"),
description: format!("threshold {index}"),
input_schema: serde_json::json!({}),
server_name: "threshold-server".to_string(),
deferred_loading: None,
always_visible: None,
allowed_callers: None,
input_examples: None,
tags: None,
})
.collect::<Vec<_>>();
let mut large_tools = (0..5)
.map(|index| McpToolDefinition {
name: format!("auto_{index}"),
description: format!("auto {index}"),
input_schema: serde_json::json!({}),
server_name: "large-server".to_string(),
deferred_loading: None,
always_visible: None,
allowed_callers: None,
input_examples: None,
tags: None,
})
.collect::<Vec<_>>();
large_tools.push(McpToolDefinition {
name: "explicit_false".to_string(),
description: "explicit false".to_string(),
input_schema: serde_json::json!({}),
server_name: "large-server".to_string(),
deferred_loading: Some(false),
always_visible: None,
allowed_callers: None,
input_examples: None,
tags: None,
});
large_tools.push(McpToolDefinition {
name: "explicit_true".to_string(),
description: "explicit true".to_string(),
input_schema: serde_json::json!({}),
server_name: "large-server".to_string(),
deferred_loading: Some(true),
always_visible: None,
allowed_callers: None,
input_examples: None,
tags: None,
});
let mut all_tools = threshold_tools;
all_tools.extend(large_tools);
let resolved = McpClientManager::apply_default_loading_policy(all_tools);
assert!(resolved
.iter()
.filter(|tool| tool.server_name == "threshold-server")
.all(|tool| tool.deferred_loading == Some(false)));
let explicit_false = resolved
.iter()
.find(|tool| tool.name == "explicit_false")
.expect("explicit false tool should exist");
assert_eq!(explicit_false.deferred_loading, Some(false));
let explicit_true = resolved
.iter()
.find(|tool| tool.name == "explicit_true")
.expect("explicit true tool should exist");
assert_eq!(explicit_true.deferred_loading, Some(true));
assert!(resolved
.iter()
.filter(|tool| tool.server_name == "large-server" && tool.name.starts_with("auto_"))
.all(|tool| tool.deferred_loading == Some(true)));
}
// ========================================================================
// 工具列表缓存测试(Task 4.3)
// ========================================================================
@@ -2295,6 +2400,62 @@ mod tests {
assert_eq!(tools[0].name, "weather");
}
#[tokio::test]
async fn test_search_tools_empty_query_prioritizes_always_visible_then_name() {
let manager = McpClientManager::new(None);
manager
.update_tool_cache(vec![
McpToolDefinition {
name: "alpha".to_string(),
description: "alpha".to_string(),
input_schema: serde_json::json!({}),
server_name: "s1".to_string(),
deferred_loading: Some(false),
always_visible: Some(false),
allowed_callers: None,
input_examples: None,
tags: None,
},
McpToolDefinition {
name: "zeta".to_string(),
description: "zeta".to_string(),
input_schema: serde_json::json!({}),
server_name: "s1".to_string(),
deferred_loading: Some(true),
always_visible: Some(true),
allowed_callers: None,
input_examples: None,
tags: None,
},
McpToolDefinition {
name: "beta".to_string(),
description: "beta".to_string(),
input_schema: serde_json::json!({}),
server_name: "s1".to_string(),
deferred_loading: Some(false),
always_visible: Some(true),
allowed_callers: None,
input_examples: None,
tags: None,
},
])
.await;
let tools = manager
.search_tools("", 2, Some("assistant"))
.await
.expect("empty query search should succeed");
assert_eq!(tools.len(), 2);
assert_eq!(
tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
vec!["beta", "zeta"]
);
}
#[tokio::test]
async fn test_call_tool_with_caller_rejects_unauthorized_caller() {
let manager = McpClientManager::new(None);
@@ -6,7 +6,7 @@
//!
//! | 模型 | 字段名 | 多轮对话处理 |
//! |------|--------|--------------|
//! | DeepSeek R1/Reasoner | `reasoning_content` | 丢弃,只保留 `content` |
//! | DeepSeek R1/Reasoner | `reasoning_content` | 新 user 回合前清空,当前回合保留 |
//! | OpenAI o1/o3/o4 | `reasoning` | 通过 `previous_response_id` 引用 |
//!
//! # 设计原则
@@ -18,11 +18,8 @@
//!
//! # 使用状态
//!
//! 此模块为预留功能,将在 Proxy 层集成推理模型时启用。
//! 目前代码已完成,等待在 `proxy_handler.rs` 中调用 `ReasoningHandler::preprocess_messages`。
// 预留功能模块,暂未在主流程中调用
#![allow(dead_code)]
//! 当前已在 OpenAI 兼容 Provider 请求归一化阶段接入。
//! 主要用于 DeepSeek R1/Reasoner 的 tool calls + thinking 场景。
use lime_core::models::openai::ChatMessage;
@@ -66,9 +63,9 @@ impl ReasoningHandler {
/// # DeepSeek 处理规则
///
/// 根据 DeepSeek API 文档:
/// - 多轮对话时,历史消息中的 `reasoning_content` 应该被丢弃
/// - 只保留 `content` 字段用于上下文
/// - 这样可以节省网络带宽,避免 400 错误
/// - 新 user 回合开始后,上一轮 assistant 的 `reasoning_content` 应被清理
/// - 同一 user 回合中的 tool call 链路需要保留 assistant 的 `reasoning_content`
/// - 否则 DeepSeek Reasoner 在继续 tool calls 时可能返回 400 错误
///
/// # 参数
///
@@ -90,28 +87,22 @@ impl ReasoningHandler {
/// 处理 DeepSeek 消息
///
/// 清除历史消息中的 reasoning_content,只保留最后一条 assistant 消息的 reasoning_content
/// 清除最近一个 user 消息之前的 assistant reasoning_content,
/// 保留当前 user 回合内的 reasoning_content,以支持连续 tool calls。
fn process_deepseek_messages(mut messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
// 先找出最后一条 assistant 消息的索引
let last_assistant_idx = messages
let last_user_idx = messages
.iter()
.enumerate()
.rev()
.find(|(_, m)| m.role == "assistant")
.find(|(_, m)| m.role == "user")
.map(|(i, _)| i);
for (i, msg) in messages.iter_mut().enumerate() {
// 只处理 assistant 消息
if msg.role != "assistant" {
if msg.role != "assistant" || msg.reasoning_content.is_none() {
continue;
}
// 保留最后一条 assistant 消息的 reasoning_content(如果有 tool_calls)
// 因为 DeepSeek 在 tool calls 场景下需要这个字段
let is_last_assistant = Some(i) == last_assistant_idx;
if !is_last_assistant {
// 清除非最后一条 assistant 消息的 reasoning_content
if last_user_idx.is_some_and(|idx| i < idx) {
msg.reasoning_content = None;
}
}
@@ -221,4 +212,49 @@ mod tests {
// 最后一条 assistant 消息的 reasoning_content 应该保留
assert!(processed[3].reasoning_content.is_some());
}
#[test]
fn test_deepseek_keeps_reasoning_within_same_user_tool_chain() {
let messages = vec![
ChatMessage {
role: "user".to_string(),
content: Some(MessageContent::Text("帮我查天气".to_string())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
},
ChatMessage {
role: "assistant".to_string(),
content: Some(MessageContent::Text(String::new())),
tool_calls: None,
tool_call_id: None,
reasoning_content: Some("先确定城市".to_string()),
},
ChatMessage {
role: "tool".to_string(),
content: Some(MessageContent::Text("上海".to_string())),
tool_calls: None,
tool_call_id: Some("call_1".to_string()),
reasoning_content: None,
},
ChatMessage {
role: "assistant".to_string(),
content: Some(MessageContent::Text(String::new())),
tool_calls: None,
tool_call_id: None,
reasoning_content: Some("继续查询具体天气".to_string()),
},
];
let processed = ReasoningHandler::preprocess_messages(messages, "deepseek-reasoner");
assert_eq!(
processed[1].reasoning_content.as_deref(),
Some("先确定城市")
);
assert_eq!(
processed[3].reasoning_content.as_deref(),
Some("继续查询具体天气")
);
}
}
@@ -2313,7 +2313,7 @@ mod tests {
if expires_in_secs <= -2 {
prop_assert!(is_expired(&result), "Expected Expired for expires_in_secs={}", expires_in_secs);
} else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 {
} else if (2..=TOKEN_EXPIRING_SOON_THRESHOLD - 2).contains(&expires_in_secs) {
prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs);
} else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 {
prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs);
@@ -2338,7 +2338,7 @@ mod tests {
if expires_in_secs <= -2 {
prop_assert!(is_expired(&result), "Expected Expired for expires_in_secs={}", expires_in_secs);
} else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 {
} else if (2..=TOKEN_EXPIRING_SOON_THRESHOLD - 2).contains(&expires_in_secs) {
prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs);
} else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 {
prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs);
@@ -2364,7 +2364,7 @@ mod tests {
// 由于时间精度问题,允许 2 秒的误差
if expires_in_secs <= 1 {
prop_assert!(is_expired(&result) || is_expiring_soon(&result), "Expected Expired or ExpiringSoon for expires_in_secs={}", expires_in_secs);
} else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 {
} else if (2..=TOKEN_EXPIRING_SOON_THRESHOLD - 2).contains(&expires_in_secs) {
prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs);
} else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 {
prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs);
@@ -140,35 +140,12 @@ impl ClaudeCustomProvider {
.parameters
.clone()
.unwrap_or_else(|| serde_json::json!({"type":"object","properties":{}}));
let extension = input_schema
.get("x-lime")
.or_else(|| input_schema.get("x_lime"))
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let mut input_examples = extension
.get("input_examples")
.or_else(|| extension.get("inputExamples"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if input_examples.is_empty() {
input_examples = lime_core::tool_calling::resolve_tool_input_examples(
&function.name,
&input_schema,
);
}
let allowed_callers = extension
.get("allowed_callers")
.or_else(|| extension.get("allowedCallers"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let metadata = lime_core::tool_calling::extract_tool_surface_metadata(
&function.name,
&input_schema,
);
let input_examples = metadata.input_examples;
let allowed_callers = metadata.allowed_callers.unwrap_or_default();
let mut description = function.description.clone().unwrap_or_default();
if !input_examples.is_empty() && !description.contains("[InputExamples]") {
@@ -843,4 +820,75 @@ mod tests {
.map(|arr| !arr.is_empty())
.unwrap_or(false));
}
#[test]
fn test_convert_openai_tool_to_anthropic_supports_x_lime_alias() {
let tool = Tool::Function {
function: FunctionDef {
name: "create_ticket".to_string(),
description: Some("Create support ticket".to_string()),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"title": {"type": "string"}
},
"x_lime": {
"inputExamples": [{"title":"Billing issue"}],
"allowedCallers": ["tool_search"]
}
})),
},
};
let converted = ClaudeCustomProvider::convert_openai_tool_to_anthropic(&tool)
.expect("tool should be converted");
let description = converted
.get("description")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(description.contains("[InputExamples]"));
assert!(description.contains("[AllowedCallers]"));
assert_eq!(
converted["input_examples"],
serde_json::json!([{"title":"Billing issue"}])
);
assert_eq!(
converted["allowed_callers"],
serde_json::json!(["tool_search"])
);
}
#[test]
fn test_convert_openai_tool_to_anthropic_does_not_duplicate_markers() {
let tool = Tool::Function {
function: FunctionDef {
name: "create_ticket".to_string(),
description: Some(
"Create support ticket\n\n[InputExamples] {\"title\":\"Preset\"}\n\n[AllowedCallers] assistant"
.to_string(),
),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {
"title": {"type": "string"}
},
"x-lime": {
"input_examples": [{"title":"Billing issue"}],
"allowed_callers": ["assistant"]
}
})),
},
};
let converted = ClaudeCustomProvider::convert_openai_tool_to_anthropic(&tool)
.expect("tool should be converted");
let description = converted
.get("description")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert_eq!(description.matches("[InputExamples]").count(), 1);
assert_eq!(description.matches("[AllowedCallers]").count(), 1);
}
}
@@ -752,10 +752,7 @@ mod gemini_api_key_tests {
#[test]
fn test_gemini_api_key_provider_new() {
let provider = GeminiApiKeyProvider::new();
// Just verify it can be created
assert!(true);
let _ = provider;
let _provider = GeminiApiKeyProvider::new();
}
}
@@ -142,13 +142,10 @@ impl NovitaProvider {
let url = self.build_url("chat/completions");
eprintln!(
"[NOVITA] call_api URL: {url} model: {}",
request.model
);
eprintln!("[NOVITA] call_api URL: {url} model: {}", request.model);
let payload = serde_json::to_value(request)
.map_err(|e| format!("序列化 Novita 请求失败: {e}"))?;
let payload =
serde_json::to_value(request).map_err(|e| format!("序列化 Novita 请求失败: {e}"))?;
let resp = self
.client
@@ -367,10 +364,7 @@ mod tests {
Some("https://proxy.example.com/novita/v1".to_string()),
);
let url = provider.build_url("chat/completions");
assert_eq!(
url,
"https://proxy.example.com/novita/v1/chat/completions"
);
assert_eq!(url, "https://proxy.example.com/novita/v1/chat/completions");
}
#[test]
@@ -1,5 +1,6 @@
//! OpenAI Custom Provider (自定义 OpenAI 兼容 API)
use lime_core::models::openai::ChatCompletionRequest;
use crate::converter::ReasoningHandler;
use lime_core::models::openai::{ChatCompletionRequest, ChatMessage};
use reqwest::Client;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
@@ -51,6 +52,23 @@ impl OpenAICustomProvider {
}
fn normalize_openai_request_payload(&self, payload: &mut serde_json::Value) {
let model_name = payload
.get("model")
.and_then(|value| value.as_str())
.map(str::to_owned);
if let (Some(model_name), Some(messages_value)) = (model_name, payload.get_mut("messages"))
{
if let Ok(messages) = serde_json::from_value::<Vec<ChatMessage>>(messages_value.clone())
{
if let Ok(normalized_messages) = serde_json::to_value(
ReasoningHandler::preprocess_messages(messages, &model_name),
) {
*messages_value = normalized_messages;
}
}
}
if !Self::tool_calling_v2_enabled() {
return;
}
@@ -76,43 +94,15 @@ impl OpenAICustomProvider {
.get("parameters")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let extension = parameters
.get("x-lime")
.or_else(|| parameters.get("x_lime"))
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let mut input_examples = extension
.get("input_examples")
.or_else(|| extension.get("inputExamples"))
.and_then(|v| v.as_array())
.cloned()
let tool_name = function
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default();
if input_examples.is_empty() {
let tool_name = function
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default();
input_examples =
lime_core::tool_calling::resolve_tool_input_examples(tool_name, &parameters);
}
let allowed_callers = extension
.get("allowed_callers")
.or_else(|| extension.get("allowedCallers"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let deferred_loading = extension
.get("deferred_loading")
.or_else(|| extension.get("deferredLoading"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let metadata =
lime_core::tool_calling::extract_tool_surface_metadata(tool_name, &parameters);
let input_examples = metadata.input_examples;
let allowed_callers = metadata.allowed_callers.unwrap_or_default();
let deferred_loading = metadata.deferred_loading.unwrap_or(false);
let description = function
.get("description")
@@ -778,6 +768,116 @@ mod tests {
assert!(description.contains("[InputExamples]"));
}
#[test]
fn test_normalize_openai_request_payload_does_not_duplicate_existing_metadata_markers() {
let provider = OpenAICustomProvider::default();
let mut payload = serde_json::json!({
"model": "deepseek-chat",
"messages": [{"role":"user","content":"hi"}],
"tools": [{
"type":"function",
"function": {
"name":"search_docs",
"description":"Search docs\n\n[InputExamples] {\"query\":\"preset\"}\n\n[AllowedCallers] assistant\n\n[DeferredLoading] true",
"parameters": {
"type":"object",
"properties":{"query":{"type":"string"}},
"x_lime": {
"inputExamples":[{"query":"tool search"}],
"allowedCallers":["assistant"],
"deferredLoading": true
}
}
}
}]
});
provider.normalize_openai_request_payload(&mut payload);
let description = payload["tools"][0]["function"]["description"]
.as_str()
.unwrap_or_default();
assert_eq!(description.matches("[InputExamples]").count(), 1);
assert_eq!(description.matches("[AllowedCallers]").count(), 1);
assert_eq!(description.matches("[DeferredLoading]").count(), 1);
}
#[test]
fn test_normalize_openai_request_payload_keeps_reasoning_within_same_user_turn() {
let provider = OpenAICustomProvider::default();
let mut payload = serde_json::json!({
"model": "deepseek-reasoner",
"messages": [
{
"role":"user",
"content":"帮我查天气"
},
{
"role":"assistant",
"content":"",
"reasoning_content":"先确定城市"
},
{
"role":"tool",
"content":"上海",
"tool_call_id":"call_1"
},
{
"role":"assistant",
"content":"",
"reasoning_content":"继续查询具体天气"
}
]
});
provider.normalize_openai_request_payload(&mut payload);
assert_eq!(
payload["messages"][1]["reasoning_content"],
serde_json::json!("先确定城市")
);
assert_eq!(
payload["messages"][3]["reasoning_content"],
serde_json::json!("继续查询具体天气")
);
}
#[test]
fn test_normalize_openai_request_payload_clears_reasoning_before_latest_user() {
let provider = OpenAICustomProvider::default();
let mut payload = serde_json::json!({
"model": "deepseek-reasoner",
"messages": [
{
"role":"user",
"content":"第一轮"
},
{
"role":"assistant",
"content":"需要工具",
"reasoning_content":"第一轮思考"
},
{
"role":"user",
"content":"第二轮"
},
{
"role":"assistant",
"content":"继续处理",
"reasoning_content":"第二轮思考"
}
]
});
provider.normalize_openai_request_payload(&mut payload);
assert!(payload["messages"][1].get("reasoning_content").is_none());
assert_eq!(
payload["messages"][3]["reasoning_content"],
serde_json::json!("第二轮思考")
);
}
#[test]
fn test_build_urls_with_fallbacks_supports_nested_proxy_path() {
let provider = OpenAICustomProvider::with_config(
@@ -13,12 +13,6 @@ fn arb_lead_time_mins() -> impl Strategy<Value = i64> {
1i64..30i64
}
/// Generate a random offset from now in seconds (-3600 to +7200)
/// Negative means past, positive means future
fn arb_time_offset_secs() -> impl Strategy<Value = i64> {
-3600i64..7200i64
}
/// 生成不会与 lead_time 边界冲突的时间偏移
/// 避免 time_offset_secs 恰好等于 lead_time_mins * 60 的情况
#[allow(dead_code)]
@@ -599,6 +599,7 @@ mod tests {
timestamp TEXT NOT NULL,
tool_calls_json TEXT,
tool_call_id TEXT,
reasoning_content TEXT,
FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
)",
[],
@@ -618,33 +619,6 @@ mod tests {
conn
}
fn create_test_messages(session_id: &str, count: usize) -> Vec<ChatMessage> {
let mut messages = Vec::new();
let base_time = chrono::Utc::now().timestamp_millis();
for i in 0..count {
let role = if i % 2 == 0 {
MessageRole::User
} else {
MessageRole::Assistant
};
let content = format!("这是第 {} 条消息,包含一些测试内容", i + 1);
messages.push(ChatMessage {
id: format!("msg-{}", i + 1),
session_id: session_id.to_string(),
role,
content,
blocks: None,
status: "complete".to_string(),
created_at: base_time + i as i64,
metadata: None,
});
}
messages
}
fn create_unified_general_session(session_id: &str) -> UnifiedChatSession {
let now = chrono::Utc::now().to_rfc3339();
UnifiedChatSession {
+1 -1
View File
@@ -321,7 +321,7 @@ mod tests {
let matcher = SkillMatcher::new(skills);
let results = matcher.match_skills("review code quality and commit");
// code-review 应该有更高的 confidence(匹配了更多 trigger)
assert!(results.len() >= 1);
assert!(!results.is_empty());
}
#[test]
+1 -1
View File
@@ -8,7 +8,7 @@
//! - 事件结构序列化
#[cfg(test)]
mod tests {
mod unit_tests {
use super::super::error::TerminalError;
use super::super::events::{SessionStatus, TerminalOutputEvent, TerminalStatusEvent};
+1
View File
@@ -101,6 +101,7 @@ let stream = agent.reply(user_message, session_config, Some(cancel_token)).await
| `agent_runtime_submit_turn` | 统一提交 turn |
| `agent_runtime_interrupt_turn` | 统一中断 turn |
| `agent_runtime_create/list/get/update/delete_session` | 统一会话管理 |
| `agent_runtime_spawn/send_input/wait/resume/close_subagent` | subagent 控制面 |
| `agent_runtime_respond_action` | 统一响应工具确认 / ask / elicitation |
## 凭证池桥接
+4 -2
View File
@@ -24,8 +24,10 @@ pub use credential_bridge::{
create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError,
};
pub use lime_agent::{
convert_agent_event, convert_to_tauri_message, initialize_aster_runtime, QueuedTurnSnapshot,
QueuedTurnTask, TauriAgentEvent,
convert_agent_event, convert_to_tauri_message, initialize_aster_runtime,
ChildSubagentRuntimeStatus, ChildSubagentSession, QueuedTurnSnapshot, QueuedTurnTask,
SubagentControlState, SubagentParentContext, SubagentRuntimeStatus, SubagentRuntimeStatusKind,
TauriAgentEvent,
};
pub use subagent_scheduler::{
LimeScheduler, LimeSubAgentExecutor, SubAgentProgressEvent, SubAgentRole,
@@ -14,6 +14,7 @@ use aster::session::QueuedTurnRuntime;
use lime_agent::{
clear_runtime_queue as clear_runtime_queue_impl,
list_runtime_queue_snapshots as list_runtime_queue_snapshots_impl,
promote_runtime_queued_turn as promote_runtime_queued_turn_impl,
remove_runtime_queued_turn as remove_runtime_queued_turn_impl,
resume_persisted_runtime_queues_on_startup as resume_persisted_runtime_queues_on_startup_impl,
resume_runtime_queue_if_needed as resume_runtime_queue_if_needed_impl,
@@ -178,6 +179,13 @@ pub(crate) async fn remove_runtime_queued_turn(
.await
}
pub(crate) async fn promote_runtime_queued_turn(
session_id: &str,
queued_turn_id: &str,
) -> Result<bool, String> {
promote_runtime_queued_turn_impl(session_id, queued_turn_id).await
}
pub(crate) async fn resume_persisted_runtime_queues_on_startup(
app: AppHandle,
state: &AsterAgentState,
+805
View File
@@ -0,0 +1,805 @@
use crate::mcp::McpToolDefinition;
use serde::{Deserialize, Serialize};
pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
pub const SOCIAL_IMAGE_TOOL_NAME: &str = "social_generate_cover_image";
pub const LIME_CREATE_VIDEO_TASK_TOOL_NAME: &str = "lime_create_video_generation_task";
pub const LIME_CREATE_BROADCAST_TASK_TOOL_NAME: &str = "lime_create_broadcast_generation_task";
pub const LIME_CREATE_COVER_TASK_TOOL_NAME: &str = "lime_create_cover_generation_task";
pub const LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME: &str =
"lime_create_modal_resource_search_task";
pub const LIME_CREATE_IMAGE_TASK_TOOL_NAME: &str = "lime_create_image_generation_task";
pub const LIME_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "lime_create_url_parse_task";
pub const LIME_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "lime_create_typesetting_task";
pub const BROWSER_RUNTIME_TOOL_PREFIX: &str = "mcp__lime-browser__";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolSurfaceProfile {
Core,
Creator,
BrowserAssist,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCapability {
Planning,
Delegation,
WebSearch,
SkillExecution,
SessionControl,
ContentCreation,
BrowserRuntime,
WorkspaceIo,
Execution,
Vision,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolLifecycle {
Current,
Compat,
Deprecated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolSourceKind {
AsterBuiltin,
LimeInjected,
BrowserCompatibility,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolPermissionPlane {
SessionAllowlist,
ParameterRestricted,
CallerFiltered,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct ToolCatalogEntry {
pub name: &'static str,
pub profiles: &'static [ToolSurfaceProfile],
pub capabilities: &'static [ToolCapability],
pub lifecycle: ToolLifecycle,
pub source: ToolSourceKind,
pub permission_plane: ToolPermissionPlane,
pub workspace_default_allow: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WorkspaceToolSurface {
pub creator: bool,
pub browser_assist: bool,
}
impl WorkspaceToolSurface {
pub const fn core() -> Self {
Self {
creator: false,
browser_assist: false,
}
}
pub const fn creator() -> Self {
Self {
creator: true,
browser_assist: false,
}
}
pub const fn browser_assist() -> Self {
Self {
creator: false,
browser_assist: true,
}
}
pub const fn creator_with_browser_assist() -> Self {
Self {
creator: true,
browser_assist: true,
}
}
pub const fn includes_profile(self, profile: ToolSurfaceProfile) -> bool {
match profile {
ToolSurfaceProfile::Core => true,
ToolSurfaceProfile::Creator => self.creator,
ToolSurfaceProfile::BrowserAssist => self.browser_assist,
}
}
}
const CORE_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Core];
const CREATOR_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Creator];
const BROWSER_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::BrowserAssist];
const PLAN_CAP: &[ToolCapability] = &[ToolCapability::Planning];
const DELEGATION_CAP: &[ToolCapability] =
&[ToolCapability::Delegation, ToolCapability::SessionControl];
const SEARCH_CAP: &[ToolCapability] = &[ToolCapability::WebSearch];
const SKILL_CAP: &[ToolCapability] = &[ToolCapability::SkillExecution];
const CONTENT_CAP: &[ToolCapability] = &[ToolCapability::ContentCreation];
const BROWSER_CAP: &[ToolCapability] = &[ToolCapability::BrowserRuntime];
const WORKSPACE_IO_CAP: &[ToolCapability] = &[ToolCapability::WorkspaceIo];
const EXECUTION_CAP: &[ToolCapability] = &[ToolCapability::Execution];
const VISION_CAP: &[ToolCapability] = &[ToolCapability::Vision];
static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
ToolCatalogEntry {
name: "read",
profiles: CORE_PROFILES,
capabilities: WORKSPACE_IO_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: "write",
profiles: CORE_PROFILES,
capabilities: WORKSPACE_IO_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: "edit",
profiles: CORE_PROFILES,
capabilities: WORKSPACE_IO_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: "glob",
profiles: CORE_PROFILES,
capabilities: WORKSPACE_IO_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: "grep",
profiles: CORE_PROFILES,
capabilities: WORKSPACE_IO_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: "bash",
profiles: CORE_PROFILES,
capabilities: EXECUTION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: "lsp",
profiles: CORE_PROFILES,
capabilities: WORKSPACE_IO_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: "Skill",
profiles: CORE_PROFILES,
capabilities: SKILL_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "Task",
profiles: CORE_PROFILES,
capabilities: EXECUTION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: "TaskOutput",
profiles: CORE_PROFILES,
capabilities: PLAN_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "KillShell",
profiles: CORE_PROFILES,
capabilities: EXECUTION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "TodoWrite",
profiles: CORE_PROFILES,
capabilities: PLAN_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "NotebookEdit",
profiles: CORE_PROFILES,
capabilities: WORKSPACE_IO_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: "EnterPlanMode",
profiles: CORE_PROFILES,
capabilities: PLAN_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "ExitPlanMode",
profiles: CORE_PROFILES,
capabilities: PLAN_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "WebFetch",
profiles: CORE_PROFILES,
capabilities: SEARCH_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: "WebSearch",
profiles: CORE_PROFILES,
capabilities: SEARCH_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "analyze_image",
profiles: CORE_PROFILES,
capabilities: VISION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::ParameterRestricted,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: "ask",
profiles: CORE_PROFILES,
capabilities: PLAN_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::AsterBuiltin,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: TOOL_SEARCH_TOOL_NAME,
profiles: CORE_PROFILES,
capabilities: SEARCH_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "spawn_agent",
profiles: CORE_PROFILES,
capabilities: DELEGATION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "send_input",
profiles: CORE_PROFILES,
capabilities: DELEGATION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "wait_agent",
profiles: CORE_PROFILES,
capabilities: DELEGATION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "resume_agent",
profiles: CORE_PROFILES,
capabilities: DELEGATION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "close_agent",
profiles: CORE_PROFILES,
capabilities: DELEGATION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "SubAgentTask",
profiles: CORE_PROFILES,
capabilities: DELEGATION_CAP,
lifecycle: ToolLifecycle::Compat,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: SOCIAL_IMAGE_TOOL_NAME,
profiles: CREATOR_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_CREATE_VIDEO_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_CREATE_BROADCAST_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_CREATE_COVER_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_CREATE_IMAGE_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_CREATE_URL_PARSE_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_CREATE_TYPESETTING_TASK_TOOL_NAME,
profiles: CREATOR_PROFILES,
capabilities: CONTENT_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: BROWSER_RUNTIME_TOOL_PREFIX,
profiles: BROWSER_PROFILES,
capabilities: BROWSER_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::BrowserCompatibility,
permission_plane: ToolPermissionPlane::CallerFiltered,
workspace_default_allow: false,
},
];
pub fn native_tool_catalog() -> &'static [ToolCatalogEntry] {
NATIVE_TOOL_CATALOG
}
pub fn tool_catalog_entry(tool_name: &str) -> Option<&'static ToolCatalogEntry> {
let normalized_name = tool_name.trim();
native_tool_catalog()
.iter()
.filter(|entry| {
if entry.name.ends_with("__") {
normalized_name.starts_with(entry.name)
} else {
entry.name == normalized_name
}
})
.max_by_key(|entry| entry.name.len())
}
pub fn tool_catalog_entries_for_surface(
surface: WorkspaceToolSurface,
) -> Vec<&'static ToolCatalogEntry> {
native_tool_catalog()
.iter()
.filter(|entry| {
entry
.profiles
.iter()
.any(|profile| surface.includes_profile(*profile))
})
.collect()
}
pub fn workspace_default_allowed_tool_names(surface: WorkspaceToolSurface) -> Vec<&'static str> {
let mut names = tool_catalog_entries_for_surface(surface)
.into_iter()
.filter(|entry| entry.workspace_default_allow)
.filter(|entry| entry.lifecycle == ToolLifecycle::Current)
.filter(|entry| !entry.name.ends_with("__"))
.map(|entry| entry.name)
.collect::<Vec<_>>();
names.sort_unstable();
names.dedup();
names
}
pub fn workspace_allowed_tool_names(surface: WorkspaceToolSurface) -> Vec<&'static str> {
workspace_default_allowed_tool_names(surface)
}
pub fn creator_tool_names() -> Vec<&'static str> {
tool_catalog_entries_for_surface(WorkspaceToolSurface::creator())
.into_iter()
.filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::Creator))
.filter(|entry| entry.name != BROWSER_RUNTIME_TOOL_PREFIX)
.map(|entry| entry.name)
.collect()
}
pub fn browser_runtime_tool_prefix() -> &'static str {
BROWSER_RUNTIME_TOOL_PREFIX
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpExtensionSurface {
pub extension_name: String,
pub description: String,
pub available_tools: Vec<String>,
pub always_expose_tools: Vec<String>,
pub deferred_loading: bool,
pub allowed_caller: Option<String>,
}
impl McpExtensionSurface {
pub fn has_tools(&self) -> bool {
!self.available_tools.is_empty()
}
}
pub fn build_mcp_extension_surface(
extension_name: &str,
description: impl Into<String>,
tools: &[McpToolDefinition],
) -> McpExtensionSurface {
let mut available_tools = tools
.iter()
.map(|tool| tool.name.clone())
.collect::<Vec<_>>();
available_tools.sort();
available_tools.dedup();
let mut always_expose_tools = tools
.iter()
.filter(|tool| {
tool.always_visible.unwrap_or(false) || !tool.deferred_loading.unwrap_or(false)
})
.map(|tool| tool.name.clone())
.collect::<Vec<_>>();
always_expose_tools.sort();
always_expose_tools.dedup();
let deferred_loading = tools
.iter()
.any(|tool| tool.deferred_loading.unwrap_or(false));
let allowed_caller = collapse_extension_allowed_caller(tools);
McpExtensionSurface {
extension_name: extension_name.to_string(),
description: description.into(),
available_tools,
always_expose_tools,
deferred_loading,
allowed_caller,
}
}
fn collapse_extension_allowed_caller(tools: &[McpToolDefinition]) -> Option<String> {
let mut collapsed: Option<String> = None;
for tool in tools {
let allowed = tool.allowed_callers.as_ref()?;
if allowed.len() != 1 {
return None;
}
let caller = allowed[0].trim();
if caller.is_empty() {
return None;
}
match collapsed.as_deref() {
Some(existing) if existing != caller => return None,
Some(_) => {}
None => collapsed = Some(caller.to_string()),
}
}
collapsed
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn sample_tool(
name: &str,
deferred_loading: Option<bool>,
always_visible: Option<bool>,
allowed_callers: Option<Vec<&str>>,
) -> McpToolDefinition {
McpToolDefinition {
name: name.to_string(),
description: format!("desc for {name}"),
input_schema: serde_json::json!({ "type": "object" }),
server_name: "docs".to_string(),
deferred_loading,
always_visible,
allowed_callers: allowed_callers.map(|items| {
items
.into_iter()
.map(|item| item.to_string())
.collect::<Vec<_>>()
}),
input_examples: None,
tags: None,
}
}
#[test]
fn test_tool_catalog_entry_matches_browser_prefix() {
let entry = tool_catalog_entry("mcp__lime-browser__navigate")
.expect("browser tool should match prefix catalog entry");
assert_eq!(entry.name, BROWSER_RUNTIME_TOOL_PREFIX);
assert_eq!(entry.source, ToolSourceKind::BrowserCompatibility);
}
#[test]
fn test_workspace_default_allowed_tool_names_excludes_parameter_restricted_tools() {
let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::core());
assert!(names.contains(&"spawn_agent"));
assert!(names.contains(&"WebSearch"));
assert!(!names.contains(&"SubAgentTask"));
assert!(!names.contains(&"read"));
assert!(!names.contains(&"bash"));
assert!(!names.contains(&SOCIAL_IMAGE_TOOL_NAME));
}
#[test]
fn test_workspace_default_allowed_tool_names_includes_creator_surface() {
let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::creator());
assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(&LIME_CREATE_VIDEO_TASK_TOOL_NAME));
}
#[test]
fn test_tool_catalog_entries_for_surface_counts_and_lifecycle_boundaries() {
let core = tool_catalog_entries_for_surface(WorkspaceToolSurface::core());
assert_eq!(core.len(), 26);
assert_eq!(
core.iter()
.filter(|entry| entry.lifecycle == ToolLifecycle::Current)
.count(),
25
);
assert_eq!(
core.iter()
.filter(|entry| entry.lifecycle == ToolLifecycle::Compat)
.count(),
1
);
assert!(core
.iter()
.all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::Creator)));
assert!(core
.iter()
.all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::BrowserAssist)));
let creator = tool_catalog_entries_for_surface(WorkspaceToolSurface::creator());
assert_eq!(creator.len(), 34);
assert!(creator
.iter()
.any(|entry| entry.name == SOCIAL_IMAGE_TOOL_NAME));
assert!(!creator
.iter()
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
let browser = tool_catalog_entries_for_surface(WorkspaceToolSurface::browser_assist());
assert_eq!(browser.len(), 27);
assert!(browser
.iter()
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
let combined =
tool_catalog_entries_for_surface(WorkspaceToolSurface::creator_with_browser_assist());
assert_eq!(combined.len(), 35);
}
#[test]
fn test_creator_tool_names_only_returns_creator_increment() {
let names = creator_tool_names().into_iter().collect::<BTreeSet<_>>();
assert_eq!(names.len(), 8);
assert!(names.contains(SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(LIME_CREATE_VIDEO_TASK_TOOL_NAME));
assert!(!names.contains("tool_search"));
assert!(!names.contains(BROWSER_RUNTIME_TOOL_PREFIX));
}
#[test]
fn test_workspace_default_allowed_tool_names_creator_with_browser_assist_excludes_prefix_tool()
{
let names = workspace_default_allowed_tool_names(
WorkspaceToolSurface::creator_with_browser_assist(),
);
assert_eq!(names.len(), 22);
assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(&"tool_search"));
assert!(!names
.iter()
.any(|name| name.starts_with(BROWSER_RUNTIME_TOOL_PREFIX)));
}
#[test]
fn test_build_mcp_extension_surface_collapses_single_caller() {
let tools = vec![
sample_tool(
"search_docs",
Some(true),
Some(false),
Some(vec!["assistant"]),
),
sample_tool(
"read_docs",
Some(false),
Some(true),
Some(vec!["assistant"]),
),
];
let surface = build_mcp_extension_surface("docs", "docs tools", &tools);
assert!(surface.deferred_loading);
assert_eq!(surface.allowed_caller.as_deref(), Some("assistant"));
assert_eq!(surface.always_expose_tools, vec!["read_docs".to_string()]);
}
#[test]
fn test_build_mcp_extension_surface_drops_mixed_callers() {
let tools = vec![
sample_tool(
"search_docs",
Some(true),
Some(false),
Some(vec!["assistant"]),
),
sample_tool(
"admin_docs",
Some(true),
Some(false),
Some(vec!["code_execution"]),
),
];
let surface = build_mcp_extension_surface("docs", "docs tools", &tools);
assert_eq!(surface.allowed_caller, None);
}
#[test]
fn test_build_mcp_extension_surface_dedups_available_and_exposed_tools() {
let tools = vec![
sample_tool(
"search_docs",
Some(true),
Some(true),
Some(vec!["assistant"]),
),
sample_tool(
"read_docs",
Some(false),
Some(false),
Some(vec!["assistant"]),
),
sample_tool(
"search_docs",
Some(true),
Some(true),
Some(vec!["assistant"]),
),
];
let surface = build_mcp_extension_surface("docs", "docs tools", &tools);
assert!(surface.deferred_loading);
assert_eq!(surface.allowed_caller.as_deref(), Some("assistant"));
assert_eq!(
surface.available_tools,
vec!["read_docs".to_string(), "search_docs".to_string()]
);
assert_eq!(
surface.always_expose_tools,
vec!["read_docs".to_string(), "search_docs".to_string()]
);
}
#[test]
fn test_build_mcp_extension_surface_rejects_blank_allowed_caller() {
let tools = vec![
sample_tool(
"search_docs",
Some(true),
Some(false),
Some(vec!["assistant"]),
),
sample_tool("read_docs", Some(false), Some(true), Some(vec![" "])),
];
let surface = build_mcp_extension_surface("docs", "docs tools", &tools);
assert_eq!(surface.allowed_caller, None);
}
}
+966
View File
@@ -0,0 +1,966 @@
use crate::agent_tools::catalog::{
tool_catalog_entries_for_surface, tool_catalog_entry, workspace_default_allowed_tool_names,
ToolPermissionPlane, WorkspaceToolSurface,
};
use aster::permission::{ParameterRestriction, PermissionScope, RestrictionType, ToolPermission};
use lime_core::config::{
ToolExecutionOverrideConfig as ConfigToolExecutionOverrideConfig,
ToolExecutionPolicyConfig as ConfigToolExecutionPolicyConfig,
ToolExecutionRestrictionProfileConfig as ConfigToolExecutionRestrictionProfileConfig,
ToolExecutionSandboxProfileConfig as ConfigToolExecutionSandboxProfileConfig,
ToolExecutionWarningPolicyConfig as ConfigToolExecutionWarningPolicyConfig,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map as JsonMap, Value as JsonValue};
use std::collections::HashMap;
const DURABLE_MEMORY_PATH_PATTERN: &str = r"^/memories(?:/.*)?$";
const SAFE_HTTPS_URL_PATTERN: &str = r"^https://[^\s]+$";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolExecutionWarningPolicy {
None,
ShellCommandRisk,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolExecutionRestrictionProfile {
None,
WorkspacePathRequired,
WorkspacePathOptional,
WorkspaceAbsolutePathRequired,
WorkspaceShellCommand,
AnalyzeImageInput,
SafeHttpsUrlRequired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolExecutionSandboxProfile {
None,
WorkspaceCommand,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolExecutionPolicySource {
Default,
Persisted,
Runtime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolExecutionPolicy {
pub warning_policy: ToolExecutionWarningPolicy,
pub restriction_profile: ToolExecutionRestrictionProfile,
pub sandbox_profile: ToolExecutionSandboxProfile,
}
impl Default for ToolExecutionPolicy {
fn default() -> Self {
Self {
warning_policy: ToolExecutionWarningPolicy::None,
restriction_profile: ToolExecutionRestrictionProfile::None,
sandbox_profile: ToolExecutionSandboxProfile::None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolExecutionPolicyResolution {
pub policy: ToolExecutionPolicy,
pub warning_policy_source: ToolExecutionPolicySource,
pub restriction_profile_source: ToolExecutionPolicySource,
pub sandbox_profile_source: ToolExecutionPolicySource,
}
#[derive(Debug, Clone, Copy)]
pub struct WorkspaceExecutionPermissionInput<'a> {
pub surface: WorkspaceToolSurface,
pub workspace_root: &'a str,
pub auto_mode: bool,
pub execution_policy_input: ToolExecutionResolverInput<'a>,
}
#[derive(Debug, Clone)]
struct WorkspacePermissionPatterns {
workspace_path_pattern: String,
workspace_abs_path_pattern: String,
analyze_image_path_pattern: String,
safe_https_url_pattern: String,
shell_allow_pattern: String,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ToolExecutionResolverInput<'a> {
pub persisted_policy: Option<&'a ConfigToolExecutionPolicyConfig>,
pub request_metadata: Option<&'a JsonValue>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct ToolExecutionPolicyOverride {
warning_policy: Option<ToolExecutionWarningPolicy>,
restriction_profile: Option<ToolExecutionRestrictionProfile>,
sandbox_profile: Option<ToolExecutionSandboxProfile>,
}
pub fn tool_execution_policy(tool_name: &str) -> ToolExecutionPolicy {
let normalized_name = tool_name.trim();
let Some(catalog_entry) = tool_catalog_entry(normalized_name) else {
return ToolExecutionPolicy::default();
};
match catalog_entry.name {
"read" | "write" | "edit" | "lsp" => ToolExecutionPolicy {
restriction_profile: ToolExecutionRestrictionProfile::WorkspacePathRequired,
..ToolExecutionPolicy::default()
},
"glob" | "grep" => ToolExecutionPolicy {
restriction_profile: ToolExecutionRestrictionProfile::WorkspacePathOptional,
..ToolExecutionPolicy::default()
},
"bash" => ToolExecutionPolicy {
warning_policy: ToolExecutionWarningPolicy::ShellCommandRisk,
restriction_profile: ToolExecutionRestrictionProfile::WorkspaceShellCommand,
sandbox_profile: ToolExecutionSandboxProfile::WorkspaceCommand,
},
"Task" => ToolExecutionPolicy {
warning_policy: ToolExecutionWarningPolicy::ShellCommandRisk,
restriction_profile: ToolExecutionRestrictionProfile::WorkspaceShellCommand,
sandbox_profile: ToolExecutionSandboxProfile::None,
},
"NotebookEdit" => ToolExecutionPolicy {
restriction_profile: ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired,
..ToolExecutionPolicy::default()
},
"analyze_image" => ToolExecutionPolicy {
restriction_profile: ToolExecutionRestrictionProfile::AnalyzeImageInput,
..ToolExecutionPolicy::default()
},
"WebFetch" => ToolExecutionPolicy {
restriction_profile: ToolExecutionRestrictionProfile::SafeHttpsUrlRequired,
..ToolExecutionPolicy::default()
},
_ => ToolExecutionPolicy::default(),
}
}
pub fn resolve_tool_execution_policy(
tool_name: &str,
input: ToolExecutionResolverInput<'_>,
) -> ToolExecutionPolicy {
resolve_tool_execution_policy_resolution(tool_name, input).policy
}
pub fn resolve_tool_execution_policy_resolution(
tool_name: &str,
input: ToolExecutionResolverInput<'_>,
) -> ToolExecutionPolicyResolution {
let default_policy = tool_execution_policy(tool_name);
let persisted_override =
extract_persisted_tool_execution_override(tool_name, input.persisted_policy);
let runtime_override =
extract_runtime_execution_policy_override(tool_name, input.request_metadata);
apply_tool_execution_override(
apply_tool_execution_override(
ToolExecutionPolicyResolution {
policy: default_policy,
warning_policy_source: ToolExecutionPolicySource::Default,
restriction_profile_source: ToolExecutionPolicySource::Default,
sandbox_profile_source: ToolExecutionPolicySource::Default,
},
persisted_override,
ToolExecutionPolicySource::Persisted,
),
runtime_override,
ToolExecutionPolicySource::Runtime,
)
}
pub fn build_workspace_shell_allow_pattern(
escaped_root: &str,
allow_extended_shell_commands: bool,
) -> String {
if allow_extended_shell_commands {
return String::from(r"(?s)^\s*\S.*$");
}
format!(
r"^\s*(?:cd\s+({escaped_root}|\.|\./|\.\./)|pwd|ls(?:\s+[^;&|]+)?|find\s+({escaped_root}|\.|\./|\.\./)[^;&|]*|rg\b[^;&|]*|grep\b[^;&|]*|cat\s+({escaped_root}|\.|\./|\.\./)[^;&|]*)\s*$"
)
}
pub fn should_auto_approve_tool_warnings(
tool_name: &str,
auto_mode: bool,
input: ToolExecutionResolverInput<'_>,
) -> bool {
auto_mode
&& matches!(
resolve_tool_execution_policy(tool_name, input).warning_policy,
ToolExecutionWarningPolicy::ShellCommandRisk
)
}
pub fn build_workspace_execution_permissions(
input: WorkspaceExecutionPermissionInput<'_>,
) -> Vec<ToolPermission> {
let patterns = build_workspace_permission_patterns(input.workspace_root, input.auto_mode);
let mut permissions = tool_catalog_entries_for_surface(input.surface)
.into_iter()
.filter_map(|entry| {
build_parameter_restricted_permission(
entry.name,
input.auto_mode,
&patterns,
input.execution_policy_input,
)
})
.collect::<Vec<_>>();
if input.auto_mode {
permissions.push(ToolPermission {
tool: "*".to_string(),
allowed: true,
priority: 1000,
conditions: Vec::new(),
parameter_restrictions: Vec::new(),
scope: PermissionScope::Session,
reason: Some("Auto 模式:允许所有工具与参数".to_string()),
expires_at: None,
metadata: HashMap::new(),
});
}
for tool_name in workspace_default_allowed_tool_names(input.surface) {
permissions.push(ToolPermission {
tool: tool_name.to_string(),
allowed: true,
priority: 88,
conditions: Vec::new(),
parameter_restrictions: Vec::new(),
scope: PermissionScope::Session,
reason: Some(format!("允许默认工具: {tool_name}")),
expires_at: None,
metadata: HashMap::new(),
});
}
permissions.push(ToolPermission {
tool: "*".to_string(),
allowed: false,
priority: 10,
conditions: Vec::new(),
parameter_restrictions: Vec::new(),
scope: PermissionScope::Session,
reason: Some("workspace 安全策略:未显式授权的工具默认拒绝".to_string()),
expires_at: None,
metadata: HashMap::new(),
});
permissions
}
fn extract_persisted_tool_execution_override(
tool_name: &str,
persisted_policy: Option<&ConfigToolExecutionPolicyConfig>,
) -> ToolExecutionPolicyOverride {
let Some(tool_override) = persisted_policy
.and_then(|policy| find_tool_override_config(&policy.tool_overrides, tool_name))
else {
return ToolExecutionPolicyOverride::default();
};
ToolExecutionPolicyOverride {
warning_policy: tool_override
.warning_policy
.map(convert_warning_policy_config),
restriction_profile: tool_override
.restriction_profile
.map(convert_restriction_profile_config),
sandbox_profile: tool_override
.sandbox_profile
.map(convert_sandbox_profile_config),
}
}
fn extract_runtime_execution_policy_override(
tool_name: &str,
request_metadata: Option<&JsonValue>,
) -> ToolExecutionPolicyOverride {
let Some(execution_policy) = extract_runtime_execution_policy_object(request_metadata) else {
return ToolExecutionPolicyOverride::default();
};
let tool_overrides = find_named_object(execution_policy, &["tool_overrides", "toolOverrides"])
.unwrap_or(execution_policy);
let Some(tool_override) = find_case_insensitive_object(tool_overrides, tool_name) else {
return ToolExecutionPolicyOverride::default();
};
ToolExecutionPolicyOverride {
warning_policy: extract_named_string(tool_override, &["warning_policy", "warningPolicy"])
.and_then(parse_warning_policy),
restriction_profile: extract_named_string(
tool_override,
&["restriction_profile", "restrictionProfile"],
)
.and_then(parse_restriction_profile),
sandbox_profile: extract_named_string(
tool_override,
&["sandbox_profile", "sandboxProfile"],
)
.and_then(parse_sandbox_profile),
}
}
fn extract_runtime_execution_policy_object(
request_metadata: Option<&JsonValue>,
) -> Option<&JsonMap<String, JsonValue>> {
let harness = extract_runtime_harness_object(request_metadata)?;
find_named_object(harness, &["execution_policy", "executionPolicy"])
}
fn extract_runtime_harness_object(
request_metadata: Option<&JsonValue>,
) -> Option<&JsonMap<String, JsonValue>> {
let metadata = request_metadata?.as_object()?;
metadata
.get("harness")
.and_then(JsonValue::as_object)
.or(Some(metadata))
}
fn find_named_object<'a>(
object: &'a JsonMap<String, JsonValue>,
keys: &[&str],
) -> Option<&'a JsonMap<String, JsonValue>> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(JsonValue::as_object)
}
fn find_case_insensitive_object<'a>(
object: &'a JsonMap<String, JsonValue>,
key: &str,
) -> Option<&'a JsonMap<String, JsonValue>> {
let normalized_key = key.trim();
object
.get(normalized_key)
.and_then(JsonValue::as_object)
.or_else(|| {
object.iter().find_map(|(candidate, value)| {
candidate
.trim()
.eq_ignore_ascii_case(normalized_key)
.then_some(value)
.and_then(JsonValue::as_object)
})
})
}
fn find_tool_override_config<'a>(
tool_overrides: &'a HashMap<String, ConfigToolExecutionOverrideConfig>,
tool_name: &str,
) -> Option<&'a ConfigToolExecutionOverrideConfig> {
let normalized_name = tool_name.trim();
tool_overrides.get(normalized_name).or_else(|| {
tool_overrides
.iter()
.find_map(|(candidate, override_config)| {
candidate
.trim()
.eq_ignore_ascii_case(normalized_name)
.then_some(override_config)
})
})
}
fn extract_named_string<'a>(
object: &'a JsonMap<String, JsonValue>,
keys: &[&str],
) -> Option<&'a str> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn apply_tool_execution_override(
mut base: ToolExecutionPolicyResolution,
tool_override: ToolExecutionPolicyOverride,
source: ToolExecutionPolicySource,
) -> ToolExecutionPolicyResolution {
if let Some(value) = tool_override.warning_policy {
base.policy.warning_policy = value;
base.warning_policy_source = source;
}
if let Some(value) = tool_override.restriction_profile {
base.policy.restriction_profile = value;
base.restriction_profile_source = source;
}
if let Some(value) = tool_override.sandbox_profile {
base.policy.sandbox_profile = value;
base.sandbox_profile_source = source;
}
base
}
fn convert_warning_policy_config(
value: ConfigToolExecutionWarningPolicyConfig,
) -> ToolExecutionWarningPolicy {
match value {
ConfigToolExecutionWarningPolicyConfig::None => ToolExecutionWarningPolicy::None,
ConfigToolExecutionWarningPolicyConfig::ShellCommandRisk => {
ToolExecutionWarningPolicy::ShellCommandRisk
}
}
}
fn convert_restriction_profile_config(
value: ConfigToolExecutionRestrictionProfileConfig,
) -> ToolExecutionRestrictionProfile {
match value {
ConfigToolExecutionRestrictionProfileConfig::None => ToolExecutionRestrictionProfile::None,
ConfigToolExecutionRestrictionProfileConfig::WorkspacePathRequired => {
ToolExecutionRestrictionProfile::WorkspacePathRequired
}
ConfigToolExecutionRestrictionProfileConfig::WorkspacePathOptional => {
ToolExecutionRestrictionProfile::WorkspacePathOptional
}
ConfigToolExecutionRestrictionProfileConfig::WorkspaceAbsolutePathRequired => {
ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired
}
ConfigToolExecutionRestrictionProfileConfig::WorkspaceShellCommand => {
ToolExecutionRestrictionProfile::WorkspaceShellCommand
}
ConfigToolExecutionRestrictionProfileConfig::AnalyzeImageInput => {
ToolExecutionRestrictionProfile::AnalyzeImageInput
}
ConfigToolExecutionRestrictionProfileConfig::SafeHttpsUrlRequired => {
ToolExecutionRestrictionProfile::SafeHttpsUrlRequired
}
}
}
fn convert_sandbox_profile_config(
value: ConfigToolExecutionSandboxProfileConfig,
) -> ToolExecutionSandboxProfile {
match value {
ConfigToolExecutionSandboxProfileConfig::None => ToolExecutionSandboxProfile::None,
ConfigToolExecutionSandboxProfileConfig::WorkspaceCommand => {
ToolExecutionSandboxProfile::WorkspaceCommand
}
}
}
fn parse_warning_policy(value: &str) -> Option<ToolExecutionWarningPolicy> {
match value.trim() {
"none" => Some(ToolExecutionWarningPolicy::None),
"shell_command_risk" => Some(ToolExecutionWarningPolicy::ShellCommandRisk),
_ => None,
}
}
fn parse_restriction_profile(value: &str) -> Option<ToolExecutionRestrictionProfile> {
match value.trim() {
"none" => Some(ToolExecutionRestrictionProfile::None),
"workspace_path_required" => Some(ToolExecutionRestrictionProfile::WorkspacePathRequired),
"workspace_path_optional" => Some(ToolExecutionRestrictionProfile::WorkspacePathOptional),
"workspace_absolute_path_required" => {
Some(ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired)
}
"workspace_shell_command" => Some(ToolExecutionRestrictionProfile::WorkspaceShellCommand),
"analyze_image_input" => Some(ToolExecutionRestrictionProfile::AnalyzeImageInput),
"safe_https_url_required" => Some(ToolExecutionRestrictionProfile::SafeHttpsUrlRequired),
_ => None,
}
}
fn parse_sandbox_profile(value: &str) -> Option<ToolExecutionSandboxProfile> {
match value.trim() {
"none" => Some(ToolExecutionSandboxProfile::None),
"workspace_command" => Some(ToolExecutionSandboxProfile::WorkspaceCommand),
_ => None,
}
}
fn build_workspace_permission_patterns(
workspace_root: &str,
auto_mode: bool,
) -> WorkspacePermissionPatterns {
let escaped_root = regex::escape(workspace_root.trim());
WorkspacePermissionPatterns {
workspace_path_pattern: format!(
r"^(?:({escaped_root}|\.|\./|\.\./).*$|{DURABLE_MEMORY_PATH_PATTERN})"
),
workspace_abs_path_pattern: format!(r"^({escaped_root}).*$"),
analyze_image_path_pattern: format!(
r"^(base64:[A-Za-z0-9+/=]+|file://({escaped_root}).*|({escaped_root}|\.|\./|\.\./).*)$"
),
safe_https_url_pattern: SAFE_HTTPS_URL_PATTERN.to_string(),
shell_allow_pattern: build_workspace_shell_allow_pattern(&escaped_root, auto_mode),
}
}
fn build_parameter_restricted_permission(
tool_name: &str,
auto_mode: bool,
patterns: &WorkspacePermissionPatterns,
execution_policy_input: ToolExecutionResolverInput<'_>,
) -> Option<ToolPermission> {
let catalog_entry = tool_catalog_entry(tool_name)?;
if catalog_entry.permission_plane != ToolPermissionPlane::ParameterRestricted {
return None;
}
let policy = resolve_tool_execution_policy(tool_name, execution_policy_input);
let parameter_restrictions = if auto_mode {
Vec::new()
} else {
build_parameter_restrictions(tool_name, policy.restriction_profile, patterns)
};
Some(ToolPermission {
tool: tool_name.to_string(),
allowed: true,
priority: permission_priority(tool_name),
conditions: Vec::new(),
parameter_restrictions,
scope: PermissionScope::Session,
reason: Some(permission_reason(
tool_name,
policy.restriction_profile,
auto_mode,
)),
expires_at: None,
metadata: HashMap::new(),
})
}
fn build_parameter_restrictions(
tool_name: &str,
profile: ToolExecutionRestrictionProfile,
patterns: &WorkspacePermissionPatterns,
) -> Vec<ParameterRestriction> {
match profile {
ToolExecutionRestrictionProfile::None => Vec::new(),
ToolExecutionRestrictionProfile::WorkspacePathRequired => {
vec![pattern_restriction(
"path",
&patterns.workspace_path_pattern,
true,
Some(format!(
"{tool_name}.path 必须在 workspace、相对路径或 `/memories/` 内"
)),
)]
}
ToolExecutionRestrictionProfile::WorkspacePathOptional => {
vec![pattern_restriction(
"path",
&patterns.workspace_path_pattern,
false,
Some(format!(
"{tool_name}.path 必须在 workspace、相对路径或 `/memories/` 内"
)),
)]
}
ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired => {
vec![pattern_restriction(
"notebook_path",
&patterns.workspace_abs_path_pattern,
true,
Some("NotebookEdit.notebook_path 必须是 workspace 内绝对路径".to_string()),
)]
}
ToolExecutionRestrictionProfile::WorkspaceShellCommand => vec![
pattern_restriction(
"command",
&patterns.shell_allow_pattern,
false,
Some(format!("{tool_name}.command 仅允许 workspace 内安全命令")),
),
pattern_restriction(
"cmd",
&patterns.shell_allow_pattern,
false,
Some(format!("{tool_name}.cmd 兼容参数名,规则与 command 一致")),
),
],
ToolExecutionRestrictionProfile::AnalyzeImageInput => {
vec![pattern_restriction(
"file_path",
&patterns.analyze_image_path_pattern,
true,
Some(
"analyze_image.file_path 仅允许 base64、workspace 内绝对路径或相对路径"
.to_string(),
),
)]
}
ToolExecutionRestrictionProfile::SafeHttpsUrlRequired => {
vec![pattern_restriction(
"url",
&patterns.safe_https_url_pattern,
true,
Some("WebFetch.url 仅允许 https 且禁止内网/本机地址".to_string()),
)]
}
}
}
fn pattern_restriction(
parameter: &str,
pattern: &str,
required: bool,
description: Option<String>,
) -> ParameterRestriction {
ParameterRestriction {
parameter: parameter.to_string(),
restriction_type: RestrictionType::Pattern,
values: None,
pattern: Some(pattern.to_string()),
validator: None,
min: None,
max: None,
required,
description,
}
}
fn permission_priority(tool_name: &str) -> i32 {
match tool_name {
"read" | "write" | "edit" | "glob" | "grep" => 100,
"bash" => 90,
_ => 88,
}
}
fn permission_reason(
tool_name: &str,
profile: ToolExecutionRestrictionProfile,
auto_mode: bool,
) -> String {
if auto_mode {
return match profile {
ToolExecutionRestrictionProfile::WorkspaceShellCommand => {
format!("Auto 模式:允许 {tool_name} 执行任意命令")
}
ToolExecutionRestrictionProfile::SafeHttpsUrlRequired => {
format!("Auto 模式:允许 {tool_name} 访问任意 URL")
}
ToolExecutionRestrictionProfile::AnalyzeImageInput => {
format!("Auto 模式:允许 {tool_name} 分析任意图片路径或 base64")
}
ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired => {
format!("Auto 模式:允许 {tool_name} 访问任意绝对路径")
}
ToolExecutionRestrictionProfile::WorkspacePathRequired
| ToolExecutionRestrictionProfile::WorkspacePathOptional => {
format!("Auto 模式:允许 {tool_name} 访问任意路径")
}
ToolExecutionRestrictionProfile::None => format!("Auto 模式:允许工具 {tool_name}"),
};
}
match profile {
ToolExecutionRestrictionProfile::WorkspacePathRequired => {
format!("仅允许 {tool_name} 访问当前 workspace 或 `/memories/` 内容")
}
ToolExecutionRestrictionProfile::WorkspacePathOptional => {
format!("仅允许 {tool_name} 在当前 workspace 或 `/memories/` 搜索内容")
}
ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired => {
format!("仅允许 {tool_name} 访问 workspace 内绝对路径")
}
ToolExecutionRestrictionProfile::WorkspaceShellCommand => {
format!("workspace 安全策略:{tool_name} 仅允许 workspace 内安全命令")
}
ToolExecutionRestrictionProfile::AnalyzeImageInput => {
"允许分析 workspace 内图片或 base64 数据".to_string()
}
ToolExecutionRestrictionProfile::SafeHttpsUrlRequired => {
"允许安全的 WebFetch 请求".to_string()
}
ToolExecutionRestrictionProfile::None => format!("允许工具 {tool_name}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use lime_core::config::{
ToolExecutionOverrideConfig as ConfigToolExecutionOverrideConfig,
ToolExecutionPolicyConfig as ConfigToolExecutionPolicyConfig,
ToolExecutionRestrictionProfileConfig as ConfigToolExecutionRestrictionProfileConfig,
ToolExecutionSandboxProfileConfig as ConfigToolExecutionSandboxProfileConfig,
ToolExecutionWarningPolicyConfig as ConfigToolExecutionWarningPolicyConfig,
};
use serde_json::json;
#[test]
fn test_tool_execution_policy_marks_bash_as_sandboxed_shell_risk() {
let policy = tool_execution_policy("bash");
assert_eq!(
policy.warning_policy,
ToolExecutionWarningPolicy::ShellCommandRisk
);
assert_eq!(
policy.restriction_profile,
ToolExecutionRestrictionProfile::WorkspaceShellCommand
);
assert_eq!(
policy.sandbox_profile,
ToolExecutionSandboxProfile::WorkspaceCommand
);
}
#[test]
fn test_build_workspace_execution_permissions_strict_mode_restricts_parameter_tools() {
let permissions =
build_workspace_execution_permissions(WorkspaceExecutionPermissionInput {
surface: WorkspaceToolSurface::core(),
workspace_root: "/tmp/workspace",
auto_mode: false,
execution_policy_input: ToolExecutionResolverInput::default(),
});
let read = permissions
.iter()
.find(|permission| permission.tool == "read")
.expect("read permission should exist");
assert_eq!(read.parameter_restrictions.len(), 1);
assert_eq!(read.parameter_restrictions[0].parameter, "path");
assert!(read.parameter_restrictions[0]
.pattern
.as_deref()
.unwrap_or_default()
.contains("/tmp/workspace"));
let bash = permissions
.iter()
.find(|permission| permission.tool == "bash")
.expect("bash permission should exist");
assert_eq!(bash.parameter_restrictions.len(), 2);
assert!(permissions
.iter()
.any(|permission| permission.tool == "*" && !permission.allowed));
assert!(!permissions
.iter()
.any(|permission| permission.tool == "*" && permission.allowed));
}
#[test]
fn test_build_workspace_execution_permissions_auto_mode_adds_wildcard_allow() {
let permissions =
build_workspace_execution_permissions(WorkspaceExecutionPermissionInput {
surface: WorkspaceToolSurface::core(),
workspace_root: "/tmp/workspace",
auto_mode: true,
execution_policy_input: ToolExecutionResolverInput::default(),
});
let bash = permissions
.iter()
.find(|permission| permission.tool == "bash")
.expect("bash permission should exist");
assert!(bash.parameter_restrictions.is_empty());
assert!(permissions
.iter()
.any(|permission| permission.tool == "*" && permission.allowed));
}
#[test]
fn test_should_auto_approve_tool_warnings_only_for_shell_risk_tools() {
let input = ToolExecutionResolverInput::default();
assert!(should_auto_approve_tool_warnings("bash", true, input));
assert!(should_auto_approve_tool_warnings("Task", true, input));
assert!(!should_auto_approve_tool_warnings("read", true, input));
assert!(!should_auto_approve_tool_warnings("bash", false, input));
}
#[test]
fn test_build_workspace_shell_allow_pattern_auto_mode_allows_multiline() {
let escaped_root = regex::escape("/tmp/workspace");
let pattern = build_workspace_shell_allow_pattern(&escaped_root, true);
let regex = regex::Regex::new(&pattern).expect("pattern should compile");
assert!(regex.is_match("python3 <<'EOF'\nprint('hello')\nEOF"));
}
#[test]
fn test_resolve_tool_execution_policy_allows_persisted_override_to_replace_default() {
let persisted_policy = ConfigToolExecutionPolicyConfig {
tool_overrides: HashMap::from([(
"bash".to_string(),
ConfigToolExecutionOverrideConfig {
warning_policy: Some(ConfigToolExecutionWarningPolicyConfig::None),
restriction_profile: Some(
ConfigToolExecutionRestrictionProfileConfig::WorkspacePathRequired,
),
sandbox_profile: Some(ConfigToolExecutionSandboxProfileConfig::None),
},
)]),
};
let policy = resolve_tool_execution_policy(
"bash",
ToolExecutionResolverInput {
persisted_policy: Some(&persisted_policy),
request_metadata: None,
},
);
assert_eq!(policy.warning_policy, ToolExecutionWarningPolicy::None);
assert_eq!(
policy.restriction_profile,
ToolExecutionRestrictionProfile::WorkspacePathRequired
);
assert_eq!(policy.sandbox_profile, ToolExecutionSandboxProfile::None);
}
#[test]
fn test_resolve_tool_execution_policy_runtime_override_beats_persisted_policy() {
let persisted_policy = ConfigToolExecutionPolicyConfig {
tool_overrides: HashMap::from([(
"bash".to_string(),
ConfigToolExecutionOverrideConfig {
warning_policy: Some(ConfigToolExecutionWarningPolicyConfig::None),
restriction_profile: Some(
ConfigToolExecutionRestrictionProfileConfig::WorkspacePathRequired,
),
sandbox_profile: Some(ConfigToolExecutionSandboxProfileConfig::None),
},
)]),
};
let request_metadata = json!({
"harness": {
"executionPolicy": {
"toolOverrides": {
"BASH": {
"warningPolicy": "shell_command_risk",
"restrictionProfile": "workspace_shell_command",
"sandboxProfile": "workspace_command"
}
}
}
}
});
let policy = resolve_tool_execution_policy(
"bash",
ToolExecutionResolverInput {
persisted_policy: Some(&persisted_policy),
request_metadata: Some(&request_metadata),
},
);
assert_eq!(
policy.warning_policy,
ToolExecutionWarningPolicy::ShellCommandRisk
);
assert_eq!(
policy.restriction_profile,
ToolExecutionRestrictionProfile::WorkspaceShellCommand
);
assert_eq!(
policy.sandbox_profile,
ToolExecutionSandboxProfile::WorkspaceCommand
);
}
#[test]
fn test_resolve_tool_execution_policy_resolution_tracks_mixed_sources_per_field() {
let persisted_policy = ConfigToolExecutionPolicyConfig {
tool_overrides: HashMap::from([(
"bash".to_string(),
ConfigToolExecutionOverrideConfig {
warning_policy: Some(ConfigToolExecutionWarningPolicyConfig::None),
restriction_profile: None,
sandbox_profile: None,
},
)]),
};
let request_metadata = json!({
"harness": {
"executionPolicy": {
"toolOverrides": {
"bash": {
"sandboxProfile": "none"
}
}
}
}
});
let resolution = resolve_tool_execution_policy_resolution(
"bash",
ToolExecutionResolverInput {
persisted_policy: Some(&persisted_policy),
request_metadata: Some(&request_metadata),
},
);
assert_eq!(
resolution.policy.warning_policy,
ToolExecutionWarningPolicy::None
);
assert_eq!(
resolution.policy.restriction_profile,
ToolExecutionRestrictionProfile::WorkspaceShellCommand
);
assert_eq!(
resolution.policy.sandbox_profile,
ToolExecutionSandboxProfile::None
);
assert_eq!(
resolution.warning_policy_source,
ToolExecutionPolicySource::Persisted
);
assert_eq!(
resolution.restriction_profile_source,
ToolExecutionPolicySource::Default
);
assert_eq!(
resolution.sandbox_profile_source,
ToolExecutionPolicySource::Runtime
);
}
#[test]
fn test_build_workspace_execution_permissions_respects_runtime_override() {
let request_metadata = json!({
"harness": {
"execution_policy": {
"tool_overrides": {
"bash": {
"restriction_profile": "none"
}
}
}
}
});
let permissions =
build_workspace_execution_permissions(WorkspaceExecutionPermissionInput {
surface: WorkspaceToolSurface::core(),
workspace_root: "/tmp/workspace",
auto_mode: false,
execution_policy_input: ToolExecutionResolverInput {
persisted_policy: None,
request_metadata: Some(&request_metadata),
},
});
let bash = permissions
.iter()
.find(|permission| permission.tool == "bash")
.expect("bash permission should exist");
assert!(bash.parameter_restrictions.is_empty());
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
pub mod catalog;
pub mod execution;
pub mod inventory;
+19 -11
View File
@@ -191,6 +191,8 @@ pub fn run() {
.manage(automation_service_state)
.manage(workflow_service)
.manage(progress_store)
.manage(commands::subagent_cmd::SubAgentSchedulerState::default())
.manage(commands::websocket_cmd::WsServiceState::default())
.manage(lime_gateway::telegram::TelegramGatewayState::default())
.manage(lime_gateway::discord::DiscordGatewayState::default())
.manage(lime_gateway::feishu::FeishuGatewayState::default())
@@ -242,14 +244,6 @@ pub fn run() {
crate::commands::windows_startup_cmd::maybe_show_windows_startup_notice(&app.handle());
}
// TODO: 重新实现 TerminalTool 和 TermScrollbackTool 的 AppHandle 设置
// 当前暂时注释掉,等待适配 aster-rust 工具系统
// crate::agent::tools::set_terminal_tool_app_handle(app.handle().clone());
// tracing::info!("[启动] TerminalTool AppHandle 已设置");
// crate::agent::tools::set_term_scrollback_tool_app_handle(app.handle().clone());
// tracing::info!("[启动] TermScrollbackTool AppHandle 已设置");
// 初始化托盘管理器
// Requirements 1.4: 应用启动时显示停止状态图标
match TrayManager::new(app.handle()) {
@@ -1345,6 +1339,8 @@ pub fn run() {
commands::plugin_install_cmd::is_plugin_installed,
// Plugin UI commands
commands::plugin_cmd::get_plugins_with_ui,
commands::plugin_cmd::get_plugin_ui,
commands::plugin_cmd::handle_plugin_action,
commands::plugin_cmd::read_plugin_manifest_cmd,
commands::plugin_cmd::launch_plugin_ui,
commands::plugin_cmd::frontend_debug_log,
@@ -1387,9 +1383,6 @@ pub fn run() {
commands::agent_cmd::agent_stop_process,
commands::agent_cmd::agent_get_process_status,
commands::agent_cmd::agent_generate_title,
// TODO: 重新启用这些命令,适配 aster-rust 工具系统
// commands::agent_cmd::agent_terminal_command_response,
// commands::agent_cmd::agent_term_scrollback_response,
// Aster Agent commands
commands::aster_agent_cmd::aster_agent_init,
commands::aster_agent_cmd::aster_agent_status,
@@ -1398,10 +1391,17 @@ pub fn run() {
commands::aster_agent_cmd::aster_agent_configure_from_pool,
commands::aster_agent_cmd::agent_runtime_submit_turn,
commands::aster_agent_cmd::agent_runtime_interrupt_turn,
commands::aster_agent_cmd::agent_runtime_promote_queued_turn,
commands::aster_agent_cmd::agent_runtime_remove_queued_turn,
commands::aster_agent_cmd::agent_runtime_create_session,
commands::aster_agent_cmd::agent_runtime_list_sessions,
commands::aster_agent_cmd::agent_runtime_get_session,
commands::aster_agent_cmd::agent_runtime_get_tool_inventory,
commands::aster_agent_cmd::agent_runtime_spawn_subagent,
commands::aster_agent_cmd::agent_runtime_send_subagent_input,
commands::aster_agent_cmd::agent_runtime_wait_subagents,
commands::aster_agent_cmd::agent_runtime_resume_subagent,
commands::aster_agent_cmd::agent_runtime_close_subagent,
commands::aster_agent_cmd::agent_runtime_update_session,
commands::aster_agent_cmd::agent_runtime_delete_session,
commands::aster_agent_cmd::agent_runtime_respond_action,
@@ -1478,6 +1478,10 @@ pub fn run() {
commands::terminal_cmd::terminal_close,
commands::terminal_cmd::terminal_list_sessions,
commands::terminal_cmd::terminal_get_session,
// SubAgent commands
commands::subagent_cmd::init_subagent_scheduler,
commands::subagent_cmd::execute_subagent_tasks,
commands::subagent_cmd::cancel_subagent_tasks,
// Connection commands
commands::connection_cmd::connection_list,
commands::connection_cmd::connection_add,
@@ -1489,6 +1493,10 @@ pub fn run() {
commands::connection_cmd::connection_save_raw_config,
commands::connection_cmd::connection_test,
commands::connection_cmd::connection_import_ssh_host,
// WebSocket commands
commands::websocket_cmd::get_websocket_status,
commands::websocket_cmd::get_websocket_connections,
commands::websocket_cmd::set_websocket_enabled,
// Browser environment preset commands
commands::browser_environment_cmd::list_browser_environment_presets_cmd,
commands::browser_environment_cmd::save_browser_environment_preset_cmd,
File diff suppressed because it is too large Load Diff
+64 -10
View File
@@ -10,7 +10,7 @@ pub mod dispatcher;
#[cfg(debug_assertions)]
use axum::{
extract::State,
http::{HeaderValue, Method},
http::{request::Parts as RequestParts, HeaderValue, Method},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
@@ -22,7 +22,7 @@ use std::sync::Arc;
#[cfg(debug_assertions)]
use tokio::sync::RwLock;
#[cfg(debug_assertions)]
use tower_http::cors::CorsLayer;
use tower_http::cors::{AllowOrigin, CorsLayer};
#[cfg(debug_assertions)]
use crate::{app, database::DbConnection};
@@ -89,6 +89,23 @@ impl Default for DevBridgeConfig {
#[cfg(debug_assertions)]
pub struct DevBridgeServer;
#[cfg(debug_assertions)]
fn is_allowed_loopback_origin(origin: &HeaderValue, _request_parts: &RequestParts) -> bool {
let Ok(origin) = origin.to_str() else {
return false;
};
let Ok(parsed) = url::Url::parse(origin) else {
return false;
};
matches!(parsed.scheme(), "http" | "https")
&& matches!(
parsed.host_str(),
Some("localhost") | Some("127.0.0.1") | Some("[::1]") | Some("::1")
)
}
#[cfg(debug_assertions)]
impl DevBridgeServer {
/// 启动开发桥接服务器
@@ -124,20 +141,13 @@ impl DevBridgeServer {
shared_stats,
};
let allowed_origins = vec![
HeaderValue::from_static("http://localhost:1420"),
HeaderValue::from_static("http://127.0.0.1:1420"),
HeaderValue::from_static("http://localhost:5173"),
HeaderValue::from_static("http://127.0.0.1:5173"),
];
let app = Router::new()
.route("/invoke", post(invoke_command))
.route("/health", get(health_check).post(health_check))
.layer(
// CORS 配置 - 允许本地开发前端访问
CorsLayer::new()
.allow_origin(allowed_origins)
.allow_origin(AllowOrigin::predicate(is_allowed_loopback_origin))
.allow_methods([Method::POST, Method::GET, Method::OPTIONS])
.allow_headers([axum::http::header::CONTENT_TYPE]),
)
@@ -196,3 +206,47 @@ async fn health_check() -> impl IntoResponse {
"version": "1.0.0"
}))
}
#[cfg(all(test, debug_assertions))]
mod tests {
use super::is_allowed_loopback_origin;
use axum::http::{request::Parts as RequestParts, HeaderValue, Request};
fn empty_parts() -> RequestParts {
let request = Request::builder().uri("/invoke").body(()).unwrap();
let (parts, _) = request.into_parts();
parts
}
#[test]
fn allows_loopback_dev_origins_with_any_port() {
let parts = empty_parts();
assert!(is_allowed_loopback_origin(
&HeaderValue::from_static("http://127.0.0.1:1421"),
&parts,
));
assert!(is_allowed_loopback_origin(
&HeaderValue::from_static("http://localhost:5173"),
&parts,
));
assert!(is_allowed_loopback_origin(
&HeaderValue::from_static("https://localhost:3000"),
&parts,
));
}
#[test]
fn rejects_non_loopback_origins() {
let parts = empty_parts();
assert!(!is_allowed_loopback_origin(
&HeaderValue::from_static("https://example.com"),
&parts,
));
assert!(!is_allowed_loopback_origin(
&HeaderValue::from_static("http://192.168.1.10:1420"),
&parts,
));
}
}
+10
View File
@@ -11,10 +11,12 @@ mod memory;
mod memory_runtime;
mod models;
mod openclaw;
mod plugins;
mod project_resources;
mod providers;
mod runtime_queries;
mod skills;
mod tray;
mod workspace;
use crate::dev_bridge::DevBridgeState;
@@ -115,10 +117,18 @@ pub async fn handle_command(
return Ok(result);
}
if let Some(result) = plugins::try_handle(state, cmd, args.as_ref()).await? {
return Ok(result);
}
if let Some(result) = agent_sessions::try_handle(state, cmd, args.as_ref()).await? {
return Ok(result);
}
if let Some(result) = tray::try_handle(state, cmd, args.as_ref()).await? {
return Ok(result);
}
if let Some(result) = workspace::try_handle(state, cmd, args.as_ref())? {
return Ok(result);
}
@@ -1,12 +1,229 @@
use super::{args_or_default, get_string_arg, parse_nested_arg, require_app_handle};
use crate::dev_bridge::DevBridgeState;
use serde::de::DeserializeOwned;
use serde_json::Value as JsonValue;
use tauri::Manager;
type DynError = Box<dyn std::error::Error>;
fn parse_request<T: DeserializeOwned>(args: Option<&JsonValue>) -> Result<T, DynError> {
parse_nested_arg(&args_or_default(args), "request")
}
pub(super) async fn try_handle(
_state: &DevBridgeState,
_cmd: &str,
_args: Option<&JsonValue>,
state: &DevBridgeState,
cmd: &str,
args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, DynError> {
Ok(None)
if !matches!(
cmd,
"agent_runtime_submit_turn"
| "agent_runtime_interrupt_turn"
| "agent_runtime_create_session"
| "agent_runtime_list_sessions"
| "agent_runtime_get_session"
| "agent_runtime_update_session"
| "agent_runtime_delete_session"
| "agent_runtime_promote_queued_turn"
| "agent_runtime_remove_queued_turn"
| "agent_runtime_respond_action"
) {
return Ok(None);
}
let app_handle = require_app_handle(state)?;
let result = match cmd {
"agent_runtime_submit_turn" => {
let request = parse_request::<
crate::commands::aster_agent_cmd::AgentRuntimeSubmitTurnRequest,
>(args)?;
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
let db = app_handle.state::<crate::database::DbConnection>();
let api_key_provider_service =
app_handle
.state::<crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState>();
let logs = app_handle.state::<crate::app::LogState>();
let config_manager = app_handle.state::<crate::config::GlobalConfigManagerState>();
let mcp_manager = app_handle.state::<crate::mcp::McpManagerState>();
let automation_state =
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
crate::commands::aster_agent_cmd::agent_runtime_submit_turn(
app_handle.clone(),
aster_state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
request,
)
.await?;
JsonValue::Null
}
"agent_runtime_interrupt_turn" => {
let request = parse_request::<
crate::commands::aster_agent_cmd::AgentRuntimeInterruptTurnRequest,
>(args)?;
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
serde_json::to_value(
crate::commands::aster_agent_cmd::agent_runtime_interrupt_turn(
app_handle.clone(),
aster_state,
request,
)
.await?,
)?
}
"agent_runtime_create_session" => {
let args = args_or_default(args);
let workspace_id = get_string_arg(&args, "workspaceId", "workspace_id")?;
let name = args
.get("name")
.and_then(|value| value.as_str())
.map(ToString::to_string);
let execution_strategy = args
.get("executionStrategy")
.or_else(|| args.get("execution_strategy"))
.cloned()
.map(
serde_json::from_value::<
crate::commands::aster_agent_cmd::AsterExecutionStrategy,
>,
)
.transpose()?;
let db = app_handle.state::<crate::database::DbConnection>();
serde_json::to_value(
crate::commands::aster_agent_cmd::agent_runtime_create_session(
db,
workspace_id,
name,
execution_strategy,
)
.await?,
)?
}
"agent_runtime_list_sessions" => {
let db = app_handle.state::<crate::database::DbConnection>();
let logs = app_handle.state::<crate::app::LogState>();
serde_json::to_value(
crate::commands::aster_agent_cmd::agent_runtime_list_sessions(db, logs).await?,
)?
}
"agent_runtime_get_session" => {
let args = args_or_default(args);
let session_id = get_string_arg(&args, "sessionId", "session_id")?;
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
let db = app_handle.state::<crate::database::DbConnection>();
let api_key_provider_service =
app_handle
.state::<crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState>();
let logs = app_handle.state::<crate::app::LogState>();
let config_manager = app_handle.state::<crate::config::GlobalConfigManagerState>();
let mcp_manager = app_handle.state::<crate::mcp::McpManagerState>();
let automation_state =
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
serde_json::to_value(
crate::commands::aster_agent_cmd::agent_runtime_get_session(
app_handle.clone(),
aster_state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
session_id,
)
.await?,
)?
}
"agent_runtime_update_session" => {
let request = parse_request::<
crate::commands::aster_agent_cmd::AgentRuntimeUpdateSessionRequest,
>(args)?;
let db = app_handle.state::<crate::database::DbConnection>();
crate::commands::aster_agent_cmd::agent_runtime_update_session(db, request).await?;
JsonValue::Null
}
"agent_runtime_delete_session" => {
let args = args_or_default(args);
let session_id = get_string_arg(&args, "sessionId", "session_id")?;
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
let db = app_handle.state::<crate::database::DbConnection>();
crate::commands::aster_agent_cmd::agent_runtime_delete_session(
app_handle.clone(),
aster_state,
db,
session_id,
)
.await?;
JsonValue::Null
}
"agent_runtime_remove_queued_turn" => {
let request = parse_request::<
crate::commands::aster_agent_cmd::AgentRuntimeRemoveQueuedTurnRequest,
>(args)?;
serde_json::to_value(
crate::commands::aster_agent_cmd::agent_runtime_remove_queued_turn(
app_handle.clone(),
request,
)
.await?,
)?
}
"agent_runtime_promote_queued_turn" => {
let request = parse_request::<
crate::commands::aster_agent_cmd::AgentRuntimePromoteQueuedTurnRequest,
>(args)?;
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
let db = app_handle.state::<crate::database::DbConnection>();
let api_key_provider_service =
app_handle
.state::<crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState>();
let logs = app_handle.state::<crate::app::LogState>();
let config_manager = app_handle.state::<crate::config::GlobalConfigManagerState>();
let mcp_manager = app_handle.state::<crate::mcp::McpManagerState>();
let automation_state =
app_handle.state::<crate::services::automation_service::AutomationServiceState>();
serde_json::to_value(
crate::commands::aster_agent_cmd::agent_runtime_promote_queued_turn(
app_handle.clone(),
aster_state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
request,
)
.await?,
)?
}
"agent_runtime_respond_action" => {
let request = parse_request::<
crate::commands::aster_agent_cmd::AgentRuntimeRespondActionRequest,
>(args)?;
let aster_state = app_handle.state::<crate::agent::AsterAgentState>();
crate::commands::aster_agent_cmd::agent_runtime_respond_action(
app_handle.clone(),
aster_state,
request,
)
.await?;
JsonValue::Null
}
_ => unreachable!("已通过前置 matches! 过滤 agent_runtime 命令"),
};
Ok(Some(result))
}
@@ -60,6 +60,13 @@ pub(super) async fn try_handle(
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
serde_json::to_value(service.get_sync_state().await)?
}
"get_all_alias_configs" => {
let guard = state.model_registry.read().await;
let service = guard
.as_ref()
.ok_or_else(|| "模型注册服务未初始化".to_string())?;
serde_json::to_value(service.get_all_alias_configs().await)?
}
"refresh_model_registry" => {
let guard = state.model_registry.read().await;
let service = guard
@@ -0,0 +1,37 @@
use super::require_app_handle;
use crate::dev_bridge::DevBridgeState;
use serde_json::Value as JsonValue;
use tauri::Manager;
type DynError = Box<dyn std::error::Error>;
pub(super) async fn try_handle(
state: &DevBridgeState,
cmd: &str,
_args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, DynError> {
if cmd != "get_plugins_with_ui" {
return Ok(None);
}
let app_handle = require_app_handle(state)?;
let result = match cmd {
"get_plugins_with_ui" => {
let installer_state =
app_handle.state::<crate::commands::plugin_install_cmd::PluginInstallerState>();
let plugin_manager_state =
app_handle.state::<crate::commands::plugin_cmd::PluginManagerState>();
serde_json::to_value(
crate::commands::plugin_cmd::get_plugins_with_ui(
installer_state,
plugin_manager_state,
)
.await?,
)?
}
_ => unreachable!("已通过前置判断过滤插件命令"),
};
Ok(Some(result))
}
@@ -0,0 +1,77 @@
use super::{args_or_default, require_app_handle};
use crate::dev_bridge::DevBridgeState;
use serde_json::Value as JsonValue;
use tauri::Manager;
type DynError = Box<dyn std::error::Error>;
pub(super) async fn try_handle(
state: &DevBridgeState,
cmd: &str,
args: Option<&JsonValue>,
) -> Result<Option<JsonValue>, DynError> {
if cmd != "sync_tray_model_shortcuts" {
return Ok(None);
}
let app_handle = require_app_handle(state)?;
let result = match cmd {
"sync_tray_model_shortcuts" => {
let Some(tray_state) = app_handle.try_state::<crate::TrayManagerState<tauri::Wry>>()
else {
return Ok(Some(JsonValue::Null));
};
let args = args_or_default(args);
let current_model_provider_type = args
.get("currentModelProviderType")
.or_else(|| args.get("current_model_provider_type"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let current_model_provider_label = args
.get("currentModelProviderLabel")
.or_else(|| args.get("current_model_provider_label"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let current_model = args
.get("currentModel")
.or_else(|| args.get("current_model"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let current_theme_label = args
.get("currentThemeLabel")
.or_else(|| args.get("current_theme_label"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let quick_model_groups = args
.get("quickModelGroups")
.or_else(|| args.get("quick_model_groups"))
.cloned()
.map(serde_json::from_value::<Vec<crate::tray::TrayQuickModelGroup>>)
.transpose()?
.unwrap_or_default();
match crate::commands::tray_cmd::sync_tray_model_shortcuts(
tray_state,
current_model_provider_type,
current_model_provider_label,
current_model,
current_theme_label,
quick_model_groups,
)
.await
{
Ok(()) => JsonValue::Null,
Err(error) if error.contains("托盘管理器未初始化") => JsonValue::Null,
Err(error) => return Err(error.into()),
}
}
_ => unreachable!("已通过前置判断过滤托盘命令"),
};
Ok(Some(result))
}
+1
View File
@@ -39,6 +39,7 @@ pub use lime_mcp as mcp;
// 核心模块(Tauri 相关业务逻辑)
pub mod agent;
pub mod agent_tools;
pub mod app;
pub mod plugin;
pub mod screenshot;
@@ -212,7 +212,8 @@ mod tests {
content_json TEXT NOT NULL,
timestamp TEXT NOT NULL,
tool_calls_json TEXT,
tool_call_id TEXT
tool_call_id TEXT,
reasoning_content TEXT
);
CREATE TABLE general_chat_sessions (
id TEXT PRIMARY KEY,
@@ -556,7 +556,8 @@ mod tests {
content_json TEXT NOT NULL,
timestamp TEXT NOT NULL,
tool_calls_json TEXT,
tool_call_id TEXT
tool_call_id TEXT,
reasoning_content TEXT
);
CREATE TABLE general_chat_sessions (
id TEXT PRIMARY KEY,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "0.91.0",
"version": "0.92.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev:web-bridge",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "0.91.0",
"version": "0.92.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev",
+10
View File
@@ -594,12 +594,22 @@ function AppContent() {
initialUserPrompt={
(pageParams as AgentPageParams).initialUserPrompt
}
initialUserImages={
(pageParams as AgentPageParams).initialUserImages
}
initialCreationMode={
(pageParams as AgentPageParams).initialCreationMode
}
initialSessionName={
(pageParams as AgentPageParams).initialSessionName
}
entryBannerMessage={
(pageParams as AgentPageParams).entryBannerMessage
}
immersiveHome={(pageParams as AgentPageParams).immersiveHome}
openBrowserAssistOnMount={
(pageParams as AgentPageParams).openBrowserAssistOnMount
}
theme={(pageParams as AgentPageParams).theme}
lockTheme={(pageParams as AgentPageParams).lockTheme}
fromResources={(pageParams as AgentPageParams).fromResources}
+105
View File
@@ -0,0 +1,105 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentPageParams, PageParams } from "@/types/page";
import { AppSidebar } from "./AppSidebar";
const { mockGetConfig, mockGetPluginsForSurface } = vi.hoisted(() => ({
mockGetConfig: vi.fn(),
mockGetPluginsForSurface: vi.fn(),
}));
vi.mock("@/lib/api/appConfig", () => ({
getConfig: mockGetConfig,
}));
vi.mock("@/lib/api/pluginUI", () => ({
getPluginsForSurface: mockGetPluginsForSurface,
}));
interface MountedSidebar {
container: HTMLDivElement;
root: Root;
}
const mountedSidebars: MountedSidebar[] = [];
const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed";
function mountSidebar(
currentPageParams?: PageParams,
): MountedSidebar["container"] {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<AppSidebar
currentPage="agent"
currentPageParams={currentPageParams}
onNavigate={vi.fn()}
/>,
);
});
mountedSidebars.push({ container, root });
return container;
}
async function flushEffects() {
await act(async () => {
await Promise.resolve();
});
}
describe("AppSidebar", () => {
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
localStorage.clear();
mockGetConfig.mockResolvedValue({});
mockGetPluginsForSurface.mockResolvedValue([]);
});
afterEach(() => {
while (mountedSidebars.length > 0) {
const mounted = mountedSidebars.pop();
if (!mounted) {
continue;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("进入 Claw 任务中心时应自动折叠导航栏", async () => {
localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "false");
const container = mountSidebar({
agentEntry: "claw",
} as AgentPageParams);
await flushEffects();
expect(
container.querySelector('button[aria-label="展开导航栏"]'),
).not.toBeNull();
expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("true");
});
it("新建任务页应自动展开导航栏,不沿用上一个页面的折叠状态", async () => {
localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "true");
const container = mountSidebar({
agentEntry: "new-task",
} as AgentPageParams);
await flushEffects();
expect(
container.querySelector('button[aria-label="折叠导航栏"]'),
).not.toBeNull();
expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("false");
});
});
+16
View File
@@ -392,6 +392,9 @@ export function AppSidebar({
currentPageParams,
onNavigate,
}: AppSidebarProps) {
const agentEntry = (currentPageParams as AgentPageParams | undefined)?.agentEntry;
const isClawTaskCenter = currentPage === "agent" && agentEntry === "claw";
const isNewTaskHome = currentPage === "agent" && agentEntry === "new-task";
const [collapsed, setCollapsed] = useState<boolean>(() => {
if (typeof window === "undefined") {
return false;
@@ -545,6 +548,19 @@ export function AppSidebar({
);
}, [collapsed]);
useEffect(() => {
if (isNewTaskHome) {
setCollapsed(false);
return;
}
if (!isClawTaskCenter) {
return;
}
setCollapsed(true);
}, [isClawTaskCenter, isNewTaskHome]);
useEffect(() => {
if (isThemeWorkspacePage(currentPage)) {
setActiveThemeKey(currentPage);
@@ -0,0 +1,244 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentChatHomeShell } from "./AgentChatHomeShell";
const { mockBuildClawAgentParams, mockSaveChatToolPreferences } = vi.hoisted(() => ({
mockBuildClawAgentParams: vi.fn((overrides?: Record<string, unknown>) => ({
agentEntry: "claw",
...(overrides || {}),
})),
mockSaveChatToolPreferences: vi.fn(),
}));
vi.mock("./components/EmptyState", () => ({
EmptyState: ({
onSend,
onRecommendationClick,
}: {
onSend: (
value: string,
executionStrategy?: unknown,
images?: Array<{ data: string; mediaType: string }>,
) => void;
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
}) => (
<>
<button
type="button"
data-testid="home-shell-send"
onClick={() => onSend("整理成 notebook 工作方式", undefined, [])}
>
发送
</button>
<button
type="button"
data-testid="home-shell-team-recommendation"
onClick={() =>
onRecommendationClick?.(
"Team 冒烟测试",
"请按 team runtime 方式做一次冒烟测试:主线程先拆成两个子任务,再创建 explorer 与 executor 两个子代理并行处理;至少等待一个子代理完成,必要时继续 send_input,最后回到主线程输出 team workspace 总结。",
)
}
>
Team 推荐
</button>
</>
),
}));
vi.mock("@/lib/api/memory", () => ({
getProjectMemory: vi.fn(async () => ({
characters: [],
})),
}));
vi.mock("@/lib/api/skills", () => ({
skillsApi: {
getLocal: vi.fn(async () => []),
getAll: vi.fn(async () => []),
},
}));
vi.mock("./hooks/agentChatStorage", () => ({
DEFAULT_AGENT_MODEL: "mock-model",
DEFAULT_AGENT_PROVIDER: "mock-provider",
GLOBAL_MODEL_PREF_KEY: "global-model",
GLOBAL_PROVIDER_PREF_KEY: "global-provider",
getAgentPreferenceKeys: vi.fn(() => ({
providerKey: "provider-key",
modelKey: "model-key",
})),
loadPersisted: vi.fn((_key: string, fallback: unknown) => fallback),
loadPersistedString: vi.fn(() => ""),
savePersisted: vi.fn(),
}));
vi.mock("./hooks/agentChatCoreUtils", () => ({
normalizeExecutionStrategy: vi.fn((value: string) => value || "react"),
}));
vi.mock("./utils/chatToolPreferences", () => ({
loadChatToolPreferences: vi.fn(() => ({
webSearch: false,
thinking: false,
task: false,
subagent: false,
})),
saveChatToolPreferences: mockSaveChatToolPreferences,
}));
vi.mock("@/lib/workspace/navigation", () => ({
buildClawAgentParams: mockBuildClawAgentParams,
}));
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function renderShell(
props: Partial<React.ComponentProps<typeof AgentChatHomeShell>> = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultProps: React.ComponentProps<typeof AgentChatHomeShell> = {
onNavigate: vi.fn(),
projectId: "project-1",
theme: "general",
lockTheme: false,
onEnterWorkspace: vi.fn(),
};
act(() => {
root.render(<AgentChatHomeShell {...defaultProps} {...props} />);
});
mountedRoots.push({ root, container });
return {
container,
props: {
...defaultProps,
...props,
},
};
}
async function flushEffects(times = 4) {
for (let index = 0; index < times; index += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
describe("AgentChatHomeShell", () => {
it("发送首条消息时应直接导航到 claw 工作区", async () => {
const onNavigate = vi.fn();
const onEnterWorkspace = vi.fn();
const { container } = renderShell({
onNavigate,
onEnterWorkspace,
});
await flushEffects();
const sendButton = container.querySelector(
'[data-testid="home-shell-send"]',
) as HTMLButtonElement | null;
expect(sendButton).toBeTruthy();
act(() => {
sendButton?.click();
});
await flushEffects();
expect(mockBuildClawAgentParams).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
theme: "general",
initialCreationMode: "guided",
initialUserPrompt: "整理成 notebook 工作方式",
initialUserImages: [],
openBrowserAssistOnMount: undefined,
newChatAt: expect.any(Number),
}),
);
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
agentEntry: "claw",
projectId: "project-1",
initialUserPrompt: "整理成 notebook 工作方式",
newChatAt: expect.any(Number),
}),
);
expect(onEnterWorkspace).not.toHaveBeenCalled();
});
it("点击 team 推荐时应开启多代理偏好并直接进入工作区", async () => {
const onEnterWorkspace = vi.fn();
const { container } = renderShell({
onNavigate: undefined,
onEnterWorkspace,
});
await flushEffects();
const teamRecommendationButton = container.querySelector(
'[data-testid="home-shell-team-recommendation"]',
) as HTMLButtonElement | null;
expect(teamRecommendationButton).toBeTruthy();
act(() => {
teamRecommendationButton?.click();
});
await flushEffects();
expect(mockSaveChatToolPreferences).toHaveBeenLastCalledWith(
expect.objectContaining({
webSearch: false,
thinking: false,
task: false,
subagent: true,
}),
"general",
);
expect(onEnterWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
theme: "general",
initialCreationMode: "guided",
initialUserPrompt:
"请按 team runtime 方式做一次冒烟测试:主线程先拆成两个子任务,再创建 explorer 与 executor 两个子代理并行处理;至少等待一个子代理完成,必要时继续 send_input,最后回到主线程输出 team workspace 总结。",
newChatAt: expect.any(Number),
}),
);
});
});
@@ -0,0 +1,750 @@
import { useCallback, useEffect, useState } from "react";
import styled from "styled-components";
import { toast } from "sonner";
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import { getProjectMemory, type ProjectMemory } from "@/lib/api/memory";
import { logAgentDebug } from "@/lib/agentDebug";
import { skillsApi, type Skill } from "@/lib/api/skills";
import type { Page, PageParams } from "@/types/page";
import { SettingsTabs } from "@/types/settings";
import type { ThemeType } from "@/components/content-creator/types";
import { EmptyState } from "./components/EmptyState";
import type { CreationMode } from "./components/types";
import { buildClawAgentParams } from "@/lib/workspace/navigation";
import {
DEFAULT_AGENT_MODEL,
DEFAULT_AGENT_PROVIDER,
GLOBAL_MODEL_PREF_KEY,
GLOBAL_PROVIDER_PREF_KEY,
getAgentPreferenceKeys,
loadPersisted,
loadPersistedString,
savePersisted,
} from "./hooks/agentChatStorage";
import { normalizeExecutionStrategy } from "./hooks/agentChatCoreUtils";
import type { MessageImage } from "./types";
import {
loadChatToolPreferences,
saveChatToolPreferences,
type ChatToolPreferences,
} from "./utils/chatToolPreferences";
import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations";
import { normalizeProjectId } from "./utils/topicProjectResolution";
import {
createTeamDefinitionFromPreset,
listBuiltinTeamDefinitions,
type TeamDefinition,
} from "./utils/teamDefinitions";
import {
loadCustomTeams,
loadSelectedTeamReference,
persistSelectedTeam,
} from "./utils/teamStorage";
const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
"general",
"social-media",
"poster",
"music",
"knowledge",
"planning",
"document",
"video",
"novel",
];
const HOME_ENHANCEMENT_IDLE_TIMEOUT_MS = 1_500;
const HOME_ENHANCEMENT_FALLBACK_DELAY_MS = 180;
const LAST_PROJECT_ID_KEY = "agent_last_project_id";
const PageContainer = styled.div<{ $compact?: boolean }>`
display: flex;
height: 100%;
width: 100%;
position: relative;
min-height: 0;
gap: ${({ $compact }) => ($compact ? "8px" : "14px")};
padding: ${({ $compact }) => ($compact ? "8px" : "14px")};
box-sizing: border-box;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(
circle at 14% 18%,
rgba(56, 189, 248, 0.1),
transparent 30%
),
radial-gradient(
circle at 86% 14%,
rgba(16, 185, 129, 0.08),
transparent 28%
),
radial-gradient(
circle at 72% 84%,
rgba(245, 158, 11, 0.06),
transparent 24%
),
linear-gradient(
180deg,
rgba(248, 250, 252, 0.98) 0%,
rgba(248, 250, 252, 0.96) 42%,
rgba(242, 251, 247, 0.94) 100%
);
> * {
position: relative;
z-index: 1;
}
`;
const MainArea = styled.div<{ $compact?: boolean }>`
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
position: relative;
border: 1px solid rgba(226, 232, 240, 0.88);
border-radius: ${({ $compact }) => ($compact ? "24px" : "32px")};
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96) 0%,
rgba(248, 250, 252, 0.94) 56%,
rgba(248, 250, 252, 0.88) 100%
);
box-shadow:
0 24px 72px -36px rgba(15, 23, 42, 0.18),
0 16px 28px -24px rgba(15, 23, 42, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.76);
backdrop-filter: blur(18px);
`;
const ChatContainer = styled.div`
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
`;
const ChatContainerInner = styled.div`
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
background: linear-gradient(
180deg,
rgba(248, 250, 252, 0.78) 0%,
rgba(255, 255, 255, 0.12) 18%,
rgba(255, 255, 255, 0) 100%
);
`;
const ThemeWorkbenchLayoutShell = styled.div<{ $bottomInset: string }>`
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
padding-bottom: ${({ $bottomInset }) => $bottomInset};
transition: padding-bottom 0.2s ease;
`;
function normalizeInitialTheme(value?: string): ThemeType {
if (!value) return "general";
if (SUPPORTED_ENTRY_THEMES.includes(value as ThemeType)) {
return value as ThemeType;
}
return "general";
}
function resolvePersistedSelectedTeam(theme?: string | null): TeamDefinition | null {
const selection = loadSelectedTeamReference(theme);
if (!selection) {
return null;
}
if (selection.source === "builtin") {
return (
listBuiltinTeamDefinitions().find((team) => team.id === selection.id) ||
null
);
}
return loadCustomTeams().find((team) => team.id === selection.id) || null;
}
function scheduleDeferredHomeEnhancement(task: () => void): () => void {
if (typeof window === "undefined") {
return () => undefined;
}
if (typeof window.requestIdleCallback === "function") {
const idleId = window.requestIdleCallback(() => task(), {
timeout: HOME_ENHANCEMENT_IDLE_TIMEOUT_MS,
});
return () => {
if (typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(idleId);
}
};
}
const timeoutId = window.setTimeout(task, HOME_ENHANCEMENT_FALLBACK_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
}
function loadPersistedProjectId(key: string): string | null {
try {
const stored = localStorage.getItem(key);
if (!stored) {
return null;
}
try {
const parsed = JSON.parse(stored);
return normalizeProjectId(typeof parsed === "string" ? parsed : stored);
} catch {
return normalizeProjectId(stored);
}
} catch {
return null;
}
}
function savePersistedProjectId(key: string, projectId: string) {
const normalized = normalizeProjectId(projectId);
if (!normalized) {
return;
}
try {
localStorage.setItem(key, JSON.stringify(normalized));
} catch {
// ignore write errors
}
}
function resolveExecutionStrategyStorageKey(
projectId?: string | null,
): string | null {
const normalizedProjectId = normalizeProjectId(projectId);
if (!normalizedProjectId) {
return null;
}
return `aster_execution_strategy_${normalizedProjectId}`;
}
function resolvePersistedProviderModel(projectId?: string | null): {
providerType: string;
model: string;
} {
const { providerKey, modelKey } = getAgentPreferenceKeys(projectId);
return {
providerType:
loadPersistedString(providerKey) ||
loadPersistedString(GLOBAL_PROVIDER_PREF_KEY) ||
DEFAULT_AGENT_PROVIDER,
model:
loadPersistedString(modelKey) ||
loadPersistedString(GLOBAL_MODEL_PREF_KEY) ||
DEFAULT_AGENT_MODEL,
};
}
function resolvePersistedExecutionStrategy(
projectId?: string | null,
): AsterExecutionStrategy {
const storageKey = resolveExecutionStrategyStorageKey(projectId);
if (!storageKey) {
return "react";
}
return normalizeExecutionStrategy(loadPersisted<string | null>(storageKey, "react"));
}
export interface AgentChatWorkspaceBootstrap {
projectId?: string;
initialUserPrompt?: string;
initialUserImages?: MessageImage[];
theme?: string;
initialCreationMode?: CreationMode;
openBrowserAssistOnMount?: boolean;
newChatAt?: number;
}
interface AgentChatHomeShellProps {
onNavigate?: (page: Page, params?: PageParams) => void;
projectId?: string;
theme?: string;
initialCreationMode?: CreationMode;
lockTheme?: boolean;
onEnterWorkspace: (payload: AgentChatWorkspaceBootstrap) => void;
}
export function AgentChatHomeShell({
onNavigate,
projectId: externalProjectId,
theme: initialTheme,
initialCreationMode,
lockTheme = false,
onEnterWorkspace,
}: AgentChatHomeShellProps) {
const normalizedEntryTheme = normalizeInitialTheme(initialTheme);
const [input, setInput] = useState("");
const [activeTheme, setActiveTheme] = useState<string>(normalizedEntryTheme);
const [creationMode, setCreationMode] = useState<CreationMode>(
initialCreationMode ?? "guided",
);
const [selectedTeam, setSelectedTeam] = useState<TeamDefinition | null>(() =>
resolvePersistedSelectedTeam(initialTheme),
);
const [chatToolPreferences, setChatToolPreferences] =
useState<ChatToolPreferences>(() =>
loadChatToolPreferences(normalizedEntryTheme),
);
const [chatToolPreferencesTheme, setChatToolPreferencesTheme] =
useState<string>(normalizedEntryTheme);
const [currentProjectId, setCurrentProjectId] = useState<string | null>(
() =>
normalizeProjectId(externalProjectId) ??
loadPersistedProjectId(LAST_PROJECT_ID_KEY),
);
const initialProviderModel = resolvePersistedProviderModel(currentProjectId);
const [providerType, setProviderTypeState] = useState(
initialProviderModel.providerType,
);
const [model, setModelState] = useState(initialProviderModel.model);
const [executionStrategy, setExecutionStrategyState] =
useState<AsterExecutionStrategy>(() =>
resolvePersistedExecutionStrategy(currentProjectId),
);
const [projectMemory, setProjectMemory] = useState<ProjectMemory | null>(null);
const [skills, setSkills] = useState<Skill[]>([]);
const [skillsLoading, setSkillsLoading] = useState(false);
const [browserAssistLoading, setBrowserAssistLoading] = useState(false);
useEffect(() => {
setActiveTheme(normalizeInitialTheme(initialTheme));
}, [initialTheme]);
useEffect(() => {
if (!initialCreationMode) {
return;
}
setCreationMode(initialCreationMode);
}, [initialCreationMode]);
useEffect(() => {
setCurrentProjectId(
normalizeProjectId(externalProjectId) ??
loadPersistedProjectId(LAST_PROJECT_ID_KEY),
);
}, [externalProjectId]);
useEffect(() => {
if (chatToolPreferencesTheme === activeTheme) {
return;
}
setChatToolPreferences(loadChatToolPreferences(activeTheme));
setChatToolPreferencesTheme(activeTheme);
}, [activeTheme, chatToolPreferencesTheme]);
useEffect(() => {
if (chatToolPreferencesTheme !== activeTheme) {
return;
}
saveChatToolPreferences(chatToolPreferences, activeTheme);
}, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]);
useEffect(() => {
setSelectedTeam(resolvePersistedSelectedTeam(activeTheme));
}, [activeTheme]);
useEffect(() => {
persistSelectedTeam(selectedTeam, activeTheme);
}, [activeTheme, selectedTeam]);
useEffect(() => {
const nextPreferences = resolvePersistedProviderModel(currentProjectId);
setProviderTypeState(nextPreferences.providerType);
setModelState(nextPreferences.model);
setExecutionStrategyState(resolvePersistedExecutionStrategy(currentProjectId));
}, [currentProjectId]);
useEffect(() => {
const normalizedProjectId = normalizeProjectId(currentProjectId);
if (!normalizedProjectId) {
setProjectMemory(null);
return;
}
let cancelled = false;
const startedAt = Date.now();
logAgentDebug("AgentChatHomeShell", "loadProjectMemory.start", {
projectId: normalizedProjectId,
});
void getProjectMemory(normalizedProjectId)
.then((memory) => {
if (cancelled) {
return;
}
setProjectMemory(memory);
logAgentDebug("AgentChatHomeShell", "loadProjectMemory.success", {
durationMs: Date.now() - startedAt,
projectId: normalizedProjectId,
charactersCount: memory.characters.length,
});
})
.catch((error) => {
if (cancelled) {
return;
}
setProjectMemory(null);
logAgentDebug(
"AgentChatHomeShell",
"loadProjectMemory.error",
{
durationMs: Date.now() - startedAt,
error,
projectId: normalizedProjectId,
},
{ level: "warn" },
);
});
return () => {
cancelled = true;
};
}, [currentProjectId]);
const loadSkills = useCallback(
async (includeRemote = false): Promise<Skill[]> => {
const startedAt = Date.now();
logAgentDebug("AgentChatHomeShell", "loadSkills.start", {
includeRemote,
});
setSkillsLoading(true);
try {
const loadedSkills = includeRemote
? await skillsApi.getAll("lime")
: await skillsApi.getLocal("lime");
setSkills(loadedSkills);
logAgentDebug("AgentChatHomeShell", "loadSkills.success", {
durationMs: Date.now() - startedAt,
includeRemote,
skillsCount: loadedSkills.length,
});
return loadedSkills;
} catch (error) {
setSkills([]);
logAgentDebug(
"AgentChatHomeShell",
"loadSkills.error",
{
durationMs: Date.now() - startedAt,
error,
includeRemote,
},
{ level: "warn" },
);
return [];
} finally {
setSkillsLoading(false);
}
},
[],
);
useEffect(() => {
return scheduleDeferredHomeEnhancement(() => {
void loadSkills(false);
});
}, [loadSkills]);
const setProviderType = useCallback(
(nextProviderType: string) => {
setProviderTypeState(nextProviderType);
const { providerKey } = getAgentPreferenceKeys(currentProjectId);
savePersisted(providerKey, nextProviderType);
},
[currentProjectId],
);
const setModel = useCallback(
(nextModel: string) => {
setModelState(nextModel);
const { modelKey } = getAgentPreferenceKeys(currentProjectId);
savePersisted(modelKey, nextModel);
},
[currentProjectId],
);
const setExecutionStrategy = useCallback(
(nextExecutionStrategy: AsterExecutionStrategy) => {
const normalized = normalizeExecutionStrategy(nextExecutionStrategy);
setExecutionStrategyState(normalized);
const storageKey = resolveExecutionStrategyStorageKey(currentProjectId);
if (!storageKey) {
return;
}
savePersisted(storageKey, normalized);
},
[currentProjectId],
);
const handleRefreshSkills = useCallback(async () => {
await loadSkills(true);
}, [loadSkills]);
const handleProjectChange = useCallback(
(nextProjectId: string) => {
if (externalProjectId) {
return;
}
const normalizedProjectId = normalizeProjectId(nextProjectId);
setCurrentProjectId(normalizedProjectId);
if (normalizedProjectId) {
savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId);
}
},
[externalProjectId],
);
const handleEnterWorkspace = useCallback(
(payload: {
prompt?: string;
images?: MessageImage[];
openBrowserAssistOnMount?: boolean;
toolPreferences?: ChatToolPreferences;
}) => {
const normalizedProjectId = normalizeProjectId(currentProjectId);
const hasPrompt = Boolean(payload.prompt?.trim());
const hasImages = Boolean(payload.images?.length);
const effectiveToolPreferences =
payload.toolPreferences ?? chatToolPreferences;
if (!payload.openBrowserAssistOnMount && !normalizedProjectId) {
toast.error("缺少项目工作区,请先选择项目后再使用 Agent");
return;
}
if (!payload.openBrowserAssistOnMount && !hasPrompt && !hasImages) {
return;
}
if (normalizedProjectId) {
savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId);
}
saveChatToolPreferences(effectiveToolPreferences, activeTheme);
const nextNewChatAt = Date.now();
if (onNavigate) {
onNavigate(
"agent",
buildClawAgentParams({
projectId: normalizedProjectId ?? undefined,
theme: activeTheme,
initialCreationMode: creationMode,
initialUserPrompt: payload.prompt,
initialUserImages: payload.images,
openBrowserAssistOnMount: payload.openBrowserAssistOnMount,
newChatAt: nextNewChatAt,
}),
);
return;
}
onEnterWorkspace({
projectId: normalizedProjectId ?? undefined,
initialUserPrompt: payload.prompt,
initialUserImages: payload.images,
theme: activeTheme,
initialCreationMode: creationMode,
openBrowserAssistOnMount: payload.openBrowserAssistOnMount,
newChatAt: nextNewChatAt,
});
},
[
activeTheme,
chatToolPreferences,
creationMode,
currentProjectId,
onEnterWorkspace,
onNavigate,
],
);
const handleRecommendationClick = useCallback(
(shortLabel: string, fullPrompt: string) => {
setInput(fullPrompt);
if (
activeTheme !== "general" ||
!isTeamRuntimeRecommendation(shortLabel, fullPrompt)
) {
return;
}
const nextToolPreferences = chatToolPreferences.subagent
? chatToolPreferences
: {
...chatToolPreferences,
subagent: true,
};
if (!chatToolPreferences.subagent) {
setChatToolPreferences(nextToolPreferences);
}
saveChatToolPreferences(nextToolPreferences, activeTheme);
handleEnterWorkspace({
prompt: fullPrompt,
toolPreferences: nextToolPreferences,
});
},
[activeTheme, chatToolPreferences, handleEnterWorkspace],
);
const handleEnableSuggestedTeam = useCallback((suggestedPresetId?: string) => {
const resolvedPresetId = suggestedPresetId?.trim();
if (!resolvedPresetId) {
return;
}
const suggestedTeam = createTeamDefinitionFromPreset(resolvedPresetId);
if (suggestedTeam) {
persistSelectedTeam(suggestedTeam, activeTheme);
setSelectedTeam(suggestedTeam);
}
}, [activeTheme]);
const handleSelectTeam = useCallback(
(team: TeamDefinition | null) => {
persistSelectedTeam(team, activeTheme);
setSelectedTeam(team);
},
[activeTheme],
);
return (
<PageContainer>
<MainArea>
<ThemeWorkbenchLayoutShell $bottomInset="0">
<ChatContainer>
<ChatContainerInner>
<EmptyState
input={input}
setInput={setInput}
onSend={(value, sendExecutionStrategy, images) => {
if (sendExecutionStrategy) {
setExecutionStrategy(sendExecutionStrategy);
}
handleEnterWorkspace({
prompt: value,
images,
});
}}
providerType={providerType}
setProviderType={setProviderType}
model={model}
setModel={setModel}
modelSelectorBackgroundPreload="idle"
executionStrategy={executionStrategy}
setExecutionStrategy={setExecutionStrategy}
onManageProviders={() => {
onNavigate?.("settings", {
tab: SettingsTabs.Providers,
});
}}
webSearchEnabled={chatToolPreferences.webSearch}
onWebSearchEnabledChange={(enabled) =>
setChatToolPreferences((previous) => ({
...previous,
webSearch: enabled,
}))
}
thinkingEnabled={chatToolPreferences.thinking}
onThinkingEnabledChange={(enabled) =>
setChatToolPreferences((previous) => ({
...previous,
thinking: enabled,
}))
}
taskEnabled={chatToolPreferences.task}
onTaskEnabledChange={(enabled) =>
setChatToolPreferences((previous) => ({
...previous,
task: enabled,
}))
}
subagentEnabled={chatToolPreferences.subagent}
onSubagentEnabledChange={(enabled) =>
setChatToolPreferences((previous) => ({
...previous,
subagent: enabled,
}))
}
selectedTeam={selectedTeam}
onSelectTeam={handleSelectTeam}
onEnableSuggestedTeam={handleEnableSuggestedTeam}
creationMode={creationMode}
onCreationModeChange={setCreationMode}
activeTheme={activeTheme}
onThemeChange={(theme) => {
if (!lockTheme) {
setActiveTheme(theme);
}
}}
showThemeTabs={false}
hasCanvasContent={false}
hasContentId={false}
selectedText=""
onRecommendationClick={handleRecommendationClick}
characters={projectMemory?.characters || []}
skills={skills}
isSkillsLoading={skillsLoading}
onNavigateToSettings={() => {
onNavigate?.("settings", {
tab: SettingsTabs.Skills,
});
}}
onRefreshSkills={handleRefreshSkills}
onLaunchBrowserAssist={() => {
if (activeTheme !== "general") {
return;
}
setBrowserAssistLoading(true);
handleEnterWorkspace({
prompt: input,
openBrowserAssistOnMount: true,
});
}}
browserAssistLoading={browserAssistLoading}
projectId={currentProjectId}
onProjectChange={handleProjectChange}
skipProjectSelectorWorkspaceReadyCheck
deferProjectSelectorListLoad
configLoadStrategy="idle"
onOpenSettings={() => {
onNavigate?.("settings", {
tab: SettingsTabs.Appearance,
});
}}
/>
</ChatContainerInner>
</ChatContainer>
</ThemeWorkbenchLayoutShell>
</MainArea>
</PageContainer>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,20 +1,21 @@
import React, { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import type { SchedulerEvent, SchedulerProgress } from "@/lib/api/subAgentScheduler";
import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime";
import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime";
import type { HarnessSessionState } from "../utils/harnessState";
interface AgentRuntimeStripProps {
activeTheme?: string;
toolPreferences: ChatToolPreferences;
harnessState: HarnessSessionState;
subAgentRuntime: {
isRunning: boolean;
progress: SchedulerProgress | null;
events: SchedulerEvent[];
};
childSubagentSessions?: AsterSubagentSessionInfo[];
compatSubagentRuntime: Pick<
CompatSubagentRuntimeSnapshot,
"isRunning" | "progress"
>;
variant?: "standalone" | "embedded";
isSending?: boolean;
runtimeStatusTitle?: string | null;
@@ -42,7 +43,8 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
activeTheme,
toolPreferences,
harnessState,
subAgentRuntime,
compatSubagentRuntime,
childSubagentSessions = [],
variant = "standalone",
isSending = false,
runtimeStatusTitle = null,
@@ -67,6 +69,19 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
const statusItems = useMemo<StatusItem[]>(() => {
const nextItems: StatusItem[] = [];
const runningTeamSessions = childSubagentSessions.filter(
(session) => session.runtime_status === "running",
).length;
const queuedTeamSessions = childSubagentSessions.filter(
(session) => session.runtime_status === "queued",
).length;
const activeTeamSessions = runningTeamSessions + queuedTeamSessions;
const completedTeamSessions = childSubagentSessions.filter(
(session) =>
session.runtime_status === "completed" ||
session.runtime_status === "failed" ||
session.runtime_status === "aborted",
).length;
if (isSending) {
nextItems.push({
@@ -100,12 +115,30 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
});
}
if (subAgentRuntime.isRunning) {
if (activeTeamSessions > 0) {
nextItems.push({
key: "team_running",
label:
queuedTeamSessions > 0
? `Team 运行中 ${activeTeamSessions}/${childSubagentSessions.length} · 排队 ${queuedTeamSessions}`
: `Team 运行中 ${activeTeamSessions}/${childSubagentSessions.length}`,
tone: "secondary",
});
} else if (childSubagentSessions.length > 0) {
nextItems.push({
key: "team_sessions",
label:
completedTeamSessions > 0
? `Team 会话 ${childSubagentSessions.length} · 已收敛 ${completedTeamSessions}`
: `Team 会话 ${childSubagentSessions.length}`,
tone: "outline",
});
} else if (compatSubagentRuntime.isRunning) {
const progressLabel =
subAgentRuntime.progress &&
typeof subAgentRuntime.progress.completed === "number" &&
typeof subAgentRuntime.progress.total === "number"
? `子代理运行中 ${subAgentRuntime.progress.completed}/${subAgentRuntime.progress.total}`
compatSubagentRuntime.progress &&
typeof compatSubagentRuntime.progress.completed === "number" &&
typeof compatSubagentRuntime.progress.total === "number"
? `子代理运行中 ${compatSubagentRuntime.progress.completed}/${compatSubagentRuntime.progress.total}`
: "子代理运行中";
nextItems.push({
key: "subagent_running",
@@ -138,11 +171,12 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
return nextItems;
}, [
childSubagentSessions,
compatSubagentRuntime.isRunning,
compatSubagentRuntime.progress,
harnessState,
isSending,
runtimeStatusTitle,
subAgentRuntime.isRunning,
subAgentRuntime.progress,
]);
return (
@@ -131,6 +131,7 @@ function renderTimeline(
isCurrentTurn?: boolean;
turn?: Partial<AgentThreadTurn>;
actionRequests?: ActionRequired[];
onOpenSubagentSession?: (sessionId: string) => void;
},
): HTMLDivElement {
const container = document.createElement("div");
@@ -144,6 +145,7 @@ function renderTimeline(
items={items}
actionRequests={props?.actionRequests}
isCurrentTurn={props?.isCurrentTurn}
onOpenSubagentSession={props?.onOpenSubagentSession}
/>,
);
});
@@ -208,9 +210,23 @@ describe("AgentThreadTimeline", () => {
const container = renderTimeline(items, { isCurrentTurn: true });
expect(
container.querySelector('[data-testid="agent-thread-details-inline-text"]')
container.querySelector('[data-testid="agent-thread-overview"]')
?.textContent,
).toContain("已完成页面检查");
expect(
container.querySelector('[data-testid="agent-thread-details-inline-text"]')
?.textContent,
).toContain("思考与计划");
const overviewNode = container.querySelector('[data-testid="agent-thread-overview"]');
const toggleNode = container.querySelector('[data-testid="agent-thread-details-toggle"]');
expect(
Boolean(
overviewNode &&
toggleNode &&
overviewNode.compareDocumentPosition(toggleNode) &
Node.DOCUMENT_POSITION_FOLLOWING,
),
).toBe(true);
expect(
container.querySelector('[data-testid="agent-thread-flow"]'),
).toBeNull();
@@ -220,7 +236,27 @@ describe("AgentThreadTimeline", () => {
expect(
container.querySelector('[data-testid="agent-thread-summary"]'),
).not.toBeNull();
expect(container.textContent).toContain("本回合摘要");
expect(
container.querySelector('[data-testid="agent-thread-overview"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-details-inline-text"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-details-toggle"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-summary-collapse"]'),
).not.toBeNull();
expect(container.textContent).toContain("当前回合摘要");
expect(
container.querySelector('[data-testid="agent-thread-summary-header"]')
?.textContent,
).not.toContain("段流程");
expect(
container.querySelector('[data-testid="agent-thread-summary-header"]')
?.textContent,
).not.toContain("已完成");
expect(
container.querySelector('[data-testid="agent-thread-summary-shell"]'),
).not.toBeNull();
@@ -241,6 +277,50 @@ describe("AgentThreadTimeline", () => {
expect(container.textContent).toContain("技术细节");
});
it("展开后应在摘要头提供收起入口,并恢复折叠态头部", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("summary-1", 1),
type: "turn_summary",
text: "已整理出下一步执行顺序。",
},
{
...createBaseItem("browser-1", 2),
type: "tool_call",
tool_name: "browser_click",
arguments: { selector: "#publish" },
},
];
const container = renderTimeline(items, {
isCurrentTurn: true,
turn: {
status: "running",
},
});
clickTimelineToggle(container);
const collapseButton = container.querySelector<HTMLButtonElement>(
'[data-testid="agent-thread-summary-collapse"]',
);
expect(collapseButton).not.toBeNull();
act(() => {
collapseButton?.click();
});
expect(
container.querySelector('[data-testid="agent-thread-summary"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-details-toggle"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-overview"]'),
).not.toBeNull();
});
it("审批块应默认展开,技术细节块默认折叠", () => {
const items: AgentThreadItem[] = [
{
@@ -358,13 +438,17 @@ describe("AgentThreadTimeline", () => {
});
expect(
container.querySelector('[data-testid="agent-thread-details-inline-text"]')
container.querySelector('[data-testid="agent-thread-overview"]')
?.textContent,
).toContain("先梳理问题背景");
expect(
container.querySelector('[data-testid="agent-thread-details-stage"]')
?.textContent,
).toContain("阶段 02");
expect(
container.querySelector('[data-testid="agent-thread-details-inline-text"]')
?.textContent,
).toContain("先梳理问题背景");
).toContain("思考与计划");
});
it("运行中的块应被高亮,已完成块应降噪", () => {
@@ -422,6 +506,37 @@ describe("AgentThreadTimeline", () => {
expect(container.textContent).toContain("执行中");
});
it("流程展开后不应重复显示顶部当前进展卡片", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("search-1", 1),
status: "in_progress",
completed_at: undefined,
updated_at: at(1),
type: "web_search",
action: "web_search",
query: "team runtime 侧栏高度",
},
];
const container = renderTimeline(items, {
isCurrentTurn: true,
turn: {
status: "running",
},
});
clickTimelineToggle(container);
expect(
container.querySelector('[data-testid="agent-thread-details"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-overview"]'),
).toBeNull();
expect(container.textContent).toContain("当前回合摘要");
});
it("浏览器前置等待时不应显示已中断,而应显示待继续", () => {
const items: AgentThreadItem[] = [
{
@@ -563,6 +678,32 @@ describe("AgentThreadTimeline", () => {
expect(container.textContent).not.toContain("```a2ui");
});
it("纯 reasoning 阶段展开后不应重复渲染思考摘要卡", () => {
const reasoningText = "先核对执行链路,再立即恢复当前运行。";
const items: AgentThreadItem[] = [
{
...createBaseItem("reasoning-1", 1),
type: "reasoning",
text: reasoningText,
},
];
const container = renderTimeline(items, {
isCurrentTurn: true,
turn: {
status: "running",
},
});
clickTimelineToggle(container);
expect(
container.querySelector('[data-testid="agent-thread-block:1:thinking:details"]'),
).toBeNull();
expect(container.textContent).not.toContain("思考摘要");
expect((container.textContent?.split(reasoningText).length ?? 1) - 1).toBe(1);
});
it("已完成的 request_user_input 应以只读 A2UI 卡片回显", () => {
const items: AgentThreadItem[] = [
{
@@ -594,4 +735,39 @@ describe("AgentThreadTimeline", () => {
).not.toBeNull();
expect(container.querySelector('[data-testid="decision-panel"]')).toBeNull();
});
it("真实子代理 item 应支持打开子会话", () => {
const onOpenSubagentSession = vi.fn();
const items: AgentThreadItem[] = [
{
...createBaseItem("subagent-1", 1),
type: "subagent_activity",
status: "completed",
status_label: "completed",
title: "Image #1",
summary: "封面图已生成",
role: "image_editor",
model: "gpt-image-1",
session_id: "child-session-1",
},
];
const container = renderTimeline(items, {
onOpenSubagentSession,
});
clickTimelineToggle(container);
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((element) => element.textContent?.includes("打开子会话"));
expect(button).toBeTruthy();
act(() => {
button?.click();
});
expect(onOpenSubagentSession).toHaveBeenCalledWith("child-session-1");
});
});
@@ -16,6 +16,7 @@ import {
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
@@ -51,6 +52,7 @@ interface AgentThreadTimelineProps {
actionRequests?: ActionRequired[];
isCurrentTurn?: boolean;
onFileClick?: (fileName: string, content: string) => void;
onOpenSubagentSession?: (sessionId: string) => void;
onPermissionResponse?: (response: ConfirmResponse) => void;
}
@@ -640,7 +642,11 @@ function renderThinkingItemDetails(item: AgentThreadItem) {
);
}
if (item.type === "reasoning" || item.type === "turn_summary") {
if (item.type === "reasoning") {
return null;
}
if (item.type === "turn_summary") {
return <ThinkingItemCard item={item} />;
}
@@ -650,6 +656,7 @@ function renderThinkingItemDetails(item: AgentThreadItem) {
function renderGroupItemDetails(
item: AgentThreadItem,
onFileClick?: (fileName: string, content: string) => void,
onOpenSubagentSession?: (sessionId: string) => void,
onPermissionResponse?: (response: ConfirmResponse) => void,
) {
const toolCall = toToolCallState(item);
@@ -717,6 +724,8 @@ function renderGroupItemDetails(
}
if (item.type === "subagent_activity") {
const subagentSessionId = item.session_id?.trim();
return (
<SurfaceCard
icon={Bot}
@@ -737,6 +746,18 @@ function renderGroupItemDetails(
{item.model ? <Badge variant="outline">{item.model}</Badge> : null}
</div>
) : null}
{subagentSessionId && onOpenSubagentSession ? (
<div className="mt-3">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onOpenSubagentSession(subagentSessionId)}
>
打开子会话
</Button>
</div>
) : null}
</SurfaceCard>
);
}
@@ -1000,7 +1021,7 @@ function resolveLatestThinkingPreview(
};
}
function resolveCollapsedProcessText(params: {
function resolveCollapsedProcessSnapshot(params: {
compactTone: TimelineCompactTone;
displayModelSummaryText: string | null;
flowBlockCount: number;
@@ -1009,7 +1030,12 @@ function resolveCollapsedProcessText(params: {
orderedBlocks: AgentThreadOrderedBlock[];
promptPreview: string | null;
turnStatusMeta: TurnStatusMeta;
}): string {
}): {
statusLabel: string;
stageLabel: string | null;
detailText: string;
combinedText: string;
} {
const {
compactTone,
displayModelSummaryText,
@@ -1022,25 +1048,37 @@ function resolveCollapsedProcessText(params: {
} = params;
const focusInlineText = resolveFocusInlineText(focusBlock);
const latestThinkingPreview = resolveLatestThinkingPreview(orderedBlocks);
const normalizedOverview = turnStatusMeta.overviewText.trim();
const detail =
compactTone === "running"
? focusInlineText ||
turnStatusMeta.overviewText ||
displayModelSummaryText ||
promptPreview
promptPreview ||
focusBlock?.title ||
null
: compactTone === "waiting" ||
compactTone === "failed" ||
compactTone === "paused"
? turnStatusMeta.overviewText ||
focusInlineText ||
? focusInlineText ||
displayModelSummaryText ||
promptPreview
promptPreview ||
focusBlock?.title ||
null
: latestThinkingPreview.text ||
focusInlineText ||
displayModelSummaryText ||
turnStatusMeta.overviewText ||
promptPreview;
promptPreview ||
null;
const fallbackDetail =
compactTone === "done" && latestThinkingPreview.stageLabel
? "思考与计划"
: focusBlock?.title || (flowBlockCount > 0 ? `${flowBlockCount} 段流程` : null);
const distinctDetail =
detail?.trim() && detail.trim() !== normalizedOverview
? detail.trim()
: fallbackDetail;
const stageLabel =
compactTone === "running"
@@ -1051,20 +1089,25 @@ function resolveCollapsedProcessText(params: {
? latestThinkingPreview.stageLabel
: null;
const detailText =
shortenInlineText(
distinctDetail || "执行轨迹已收起,点击查看完整过程。",
compactTone === "running" ? 88 : 78,
) || "执行轨迹已收起,点击查看完整过程。";
const segments = [turnStatusMeta.label];
if (stageLabel) {
segments.push(stageLabel);
}
const shortDetail = shortenInlineText(
detail || "执行轨迹已收起,点击查看完整过程。",
compactTone === "running" ? 88 : 78,
);
if (shortDetail && shortDetail !== turnStatusMeta.label) {
segments.push(shortDetail);
if (detailText && detailText !== turnStatusMeta.label) {
segments.push(detailText);
}
return segments.join(" · ");
return {
statusLabel: turnStatusMeta.label,
stageLabel,
detailText,
combinedText: segments.join(" · "),
};
}
function TimelineCompactStatusIcon({
@@ -1147,6 +1190,7 @@ function TimelineBlockCard({
emphasis,
isExpanded,
onFileClick,
onOpenSubagentSession,
onPermissionResponse,
}: {
block: AgentThreadOrderedBlock;
@@ -1155,6 +1199,7 @@ function TimelineBlockCard({
emphasis: "active" | "default" | "quiet";
isExpanded: boolean;
onFileClick?: (fileName: string, content: string) => void;
onOpenSubagentSession?: (sessionId: string) => void;
onPermissionResponse?: (response: ConfirmResponse) => void;
}) {
const Icon = resolveGroupIcon(block.kind);
@@ -1164,6 +1209,34 @@ function TimelineBlockCard({
const isActive = emphasis === "active";
const isQuiet = emphasis === "quiet";
const stageLabel = `阶段 ${String(index + 1).padStart(2, "0")}`;
const detailEntries = block.items.flatMap((item) => {
const content =
block.kind === "thinking"
? renderThinkingItemDetails(item)
: renderGroupItemDetails(
item,
onFileClick,
onOpenSubagentSession,
onPermissionResponse,
);
return content ? [{ id: item.id, content }] : [];
});
const hasDetailEntries = detailEntries.length > 0;
const cardClassName = isActive
? "overflow-hidden rounded-2xl border border-primary/25 bg-primary/[0.045] shadow-md shadow-primary/10"
: isCompact
? "overflow-hidden rounded-2xl border border-border/45 bg-background/60"
: isQuiet
? "overflow-hidden rounded-2xl border border-border/45 bg-background/60"
: "overflow-hidden rounded-2xl border border-border/60 bg-background/75";
const summaryClassName = isCompact
? "flex items-start gap-3 px-4 py-2.5"
: "flex items-start gap-3 px-4 py-3";
const interactiveSummaryClassName = cn(
summaryClassName,
hasDetailEntries ? "cursor-pointer" : "cursor-default",
);
return (
<div
@@ -1205,90 +1278,129 @@ function TimelineBlockCard({
<Icon className="h-4 w-4" />
</div>
</div>
<details
className={
isActive
? "overflow-hidden rounded-2xl border border-primary/25 bg-primary/[0.045] shadow-md shadow-primary/10"
: isCompact
? "overflow-hidden rounded-2xl border border-border/45 bg-background/60"
: isQuiet
? "overflow-hidden rounded-2xl border border-border/45 bg-background/60"
: "overflow-hidden rounded-2xl border border-border/60 bg-background/75"
}
data-testid={dataTestId}
data-emphasis={emphasis}
open={isExpanded}
>
<summary
className={
isCompact
? "flex cursor-pointer items-start gap-3 px-4 py-2.5"
: "flex cursor-pointer items-start gap-3 px-4 py-3"
}
{hasDetailEntries ? (
<details
className={cardClassName}
data-testid={dataTestId}
data-emphasis={emphasis}
open={isExpanded}
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-medium tracking-wide text-muted-foreground">
{stageLabel}
</span>
<span className="text-sm font-medium text-foreground">
{block.title}
</span>
<Badge variant="outline">{block.countLabel}</Badge>
<Badge variant={resolveStatusBadgeVariant(block.status)}>
{block.status === "in_progress" ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
{resolveItemStatusLabel(block.status)}
</span>
) : (
resolveItemStatusLabel(block.status)
)}
</Badge>
{timestamp ? (
<span className="ml-auto text-xs text-muted-foreground">
{timestamp}
<summary className={interactiveSummaryClassName}>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-medium tracking-wide text-muted-foreground">
{stageLabel}
</span>
) : null}
<span className="text-sm font-medium text-foreground">
{block.title}
</span>
<Badge variant="outline">{block.countLabel}</Badge>
<Badge variant={resolveStatusBadgeVariant(block.status)}>
{block.status === "in_progress" ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
{resolveItemStatusLabel(block.status)}
</span>
) : (
resolveItemStatusLabel(block.status)
)}
</Badge>
{timestamp ? (
<span className="ml-auto text-xs text-muted-foreground">
{timestamp}
</span>
) : null}
</div>
{isCompact ? (
<div className="mt-1.5 text-sm text-muted-foreground">
{resolveCompactTechnicalSummary(block)}
</div>
) : block.previewLines.length > 0 ? (
<div className="mt-2 space-y-1">
{block.previewLines.map((line) => (
<div key={line} className="text-sm text-muted-foreground">
{line}
</div>
))}
</div>
) : (
<div className="mt-2 text-sm text-muted-foreground">
已归档该分组的执行细节。
</div>
)}
</div>
{isCompact ? (
<div className="mt-1.5 text-sm text-muted-foreground">
{resolveCompactTechnicalSummary(block)}
</div>
) : block.previewLines.length > 0 ? (
<div className="mt-2 space-y-1">
{block.previewLines.map((line) => (
<div key={line} className="text-sm text-muted-foreground">
{line}
</div>
))}
</div>
) : (
<div className="mt-2 text-sm text-muted-foreground">
已归档该分组的执行细节。
</div>
)}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{isCompact ? "展开查看" : block.rawDetailLabel}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
</summary>
<div
className="space-y-3 border-t border-border/60 px-4 py-3"
data-testid={`${dataTestId}:details`}
>
{detailEntries.map((entry) => (
<div key={entry.id}>{entry.content}</div>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{isCompact ? "展开查看" : block.rawDetailLabel}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
</summary>
</details>
) : (
<div
className="space-y-3 border-t border-border/60 px-4 py-3"
data-testid={`${dataTestId}:details`}
className={cardClassName}
data-testid={dataTestId}
data-emphasis={emphasis}
>
{block.items.map((item) => (
<div key={item.id}>
{block.kind === "thinking"
? renderThinkingItemDetails(item)
: renderGroupItemDetails(item, onFileClick, onPermissionResponse)}
<div className={interactiveSummaryClassName}>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-medium tracking-wide text-muted-foreground">
{stageLabel}
</span>
<span className="text-sm font-medium text-foreground">
{block.title}
</span>
<Badge variant="outline">{block.countLabel}</Badge>
<Badge variant={resolveStatusBadgeVariant(block.status)}>
{block.status === "in_progress" ? (
<span className="inline-flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
{resolveItemStatusLabel(block.status)}
</span>
) : (
resolveItemStatusLabel(block.status)
)}
</Badge>
{timestamp ? (
<span className="ml-auto text-xs text-muted-foreground">
{timestamp}
</span>
) : null}
</div>
{isCompact ? (
<div className="mt-1.5 text-sm text-muted-foreground">
{resolveCompactTechnicalSummary(block)}
</div>
) : block.previewLines.length > 0 ? (
<div className="mt-2 space-y-1">
{block.previewLines.map((line) => (
<div key={line} className="text-sm text-muted-foreground">
{line}
</div>
))}
</div>
) : (
<div className="mt-2 text-sm text-muted-foreground">
已归档该分组的执行细节。
</div>
)}
</div>
))}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">已展示完整内容</span>
</div>
</div>
</div>
</details>
)}
</div>
);
}
@@ -1299,6 +1411,7 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
actionRequests = [],
isCurrentTurn = false,
onFileClick,
onOpenSubagentSession,
onPermissionResponse,
}) => {
const visibleItems = useMemo(
@@ -1380,7 +1493,7 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
? "查看当前回合执行细节"
: "展开回合执行细节";
const compactTone = resolveCompactTone({ turn, turnStatusMeta });
const collapsedProcessText = resolveCollapsedProcessText({
const collapsedProcess = resolveCollapsedProcessSnapshot({
compactTone,
displayModelSummaryText: displayModel.summaryText,
flowBlockCount,
@@ -1391,184 +1504,276 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
turnStatusMeta,
});
const showRunningAccent = compactTone === "running";
const timelineOverviewText = turnStatusMeta.overviewText.trim() || null;
const hasSummarySupportContent =
Boolean(promptPreview) ||
Boolean(focusBlock) ||
displayModel.summaryChips.length > 0;
const focusBlockPreviewText =
focusBlock?.previewLines.find((line) => line.trim().length > 0)?.trim() || null;
const summaryPanelTextCandidate =
displayModel.summaryText?.trim() &&
displayModel.summaryText.trim() !== timelineOverviewText
? displayModel.summaryText.trim()
: timelineOverviewText;
const summaryPanelText =
summaryPanelTextCandidate &&
(summaryPanelTextCandidate !== timelineOverviewText ||
!hasSummarySupportContent) &&
summaryPanelTextCandidate !== focusBlockPreviewText
? summaryPanelTextCandidate
: null;
const overviewShellClassName = cn(
"mb-2 max-w-4xl rounded-2xl border px-3 py-2.5 shadow-sm shadow-slate-950/5",
compactTone === "running" &&
"border-sky-200/70 bg-sky-50/72",
compactTone === "waiting" &&
"border-amber-200/70 bg-amber-50/78",
compactTone === "failed" &&
"border-rose-200/70 bg-rose-50/78",
compactTone === "paused" &&
"border-slate-200/80 bg-slate-50/82",
compactTone === "done" &&
"border-border/55 bg-background/58",
);
const overviewLabelClassName = cn(
"text-[11px] font-medium",
compactTone === "running" && "text-sky-700",
compactTone === "waiting" && "text-amber-700",
compactTone === "failed" && "text-rose-700",
compactTone === "paused" && "text-slate-600",
compactTone === "done" && "text-muted-foreground",
);
const overviewTextClassName = cn(
"mt-1.5 text-sm leading-6",
compactTone === "running" && "text-sky-950/90",
compactTone === "waiting" && "text-amber-950/90",
compactTone === "failed" && "text-rose-950/90",
compactTone === "paused" && "text-slate-700",
compactTone === "done" && "text-foreground/90",
);
return (
<Collapsible
open={detailsExpanded}
onOpenChange={setDetailsExpanded}
className={detailsExpanded ? "mt-3" : "mt-2"}
>
<CollapsibleTrigger asChild>
<button
type="button"
aria-label={toggleActionLabel}
title={toggleActionLabel}
className={cn(
"group relative inline-flex w-full max-w-[28rem] items-center gap-2 overflow-hidden rounded-full border px-2.5 py-1.5 text-left shadow-sm transition-all duration-200",
detailsExpanded
? "border-border/65 bg-background/72"
: "bg-background/88 hover:bg-background",
compactTone === "running" &&
"border-sky-200/80 shadow-[0_10px_28px_-22px_rgba(56,189,248,0.75)] hover:border-sky-300/80",
compactTone === "waiting" &&
"border-amber-200/80 bg-amber-50/72 hover:border-amber-300/80",
compactTone === "failed" &&
"border-rose-200/80 bg-rose-50/72 hover:border-rose-300/80",
compactTone === "paused" &&
"border-slate-200/80 bg-slate-50/78 hover:border-slate-300/80",
compactTone === "done" &&
"border-border/60 hover:border-emerald-200/70",
)}
data-testid="agent-thread-details-toggle"
<div className={detailsExpanded ? "mt-3" : "mt-2"}>
{timelineOverviewText && !detailsExpanded ? (
<div
className={overviewShellClassName}
data-testid="agent-thread-overview"
>
{showRunningAccent ? (
<span className="pointer-events-none absolute inset-x-3 bottom-0.5 h-px rounded-full bg-gradient-to-r from-sky-400/0 via-sky-400/85 to-emerald-400/0 animate-pulse" />
) : null}
<TimelineCompactStatusIcon tone={compactTone} />
<span
className="min-w-0 flex-1 truncate text-[12px] font-medium text-foreground"
data-testid="agent-thread-details-inline-text"
>
{collapsedProcessText}
</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ${
detailsExpanded ? "rotate-180" : "rotate-0"
}`}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-3 space-y-3" data-testid="agent-thread-details">
<div className="flex items-center justify-between gap-3 px-1">
<div className="min-w-0 text-xs text-muted-foreground">
{turnStatusMeta.overviewText}
</div>
<div className="shrink-0 inline-flex items-center gap-1 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className={overviewLabelClassName}>当前进展</span>
<Badge
variant={turnStatusMeta.badgeVariant}
className={turnStatusMeta.badgeClassName}
>
{turnStatusMeta.label}
</Badge>
<span className="text-xs text-muted-foreground">
{isCurrentTurn ? "当前回合" : "历史回合"}
</span>
<span className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock3 className="h-3.5 w-3.5" />
<span>{formatTimestamp(turn.started_at) || "刚刚"}</span>
</div>
</div>
<div
className="relative pl-14"
data-testid="agent-thread-summary-shell"
>
<div className="absolute left-0 top-2.5 flex h-8 w-8 items-center justify-center rounded-full border border-primary/15 bg-primary/10 text-primary shadow-sm shadow-primary/10">
<Sparkles className="h-4 w-4" />
</div>
<div
className="rounded-xl border border-border/50 bg-background/70 px-4 py-2.5"
data-testid="agent-thread-summary"
>
<div className="flex flex-wrap items-center gap-2">
<div className="text-xs font-medium tracking-wide text-muted-foreground">
本回合摘要
</div>
<Badge variant="outline">{flowBlockCount} 段流程</Badge>
{isCurrentTurn ? <Badge variant="secondary">当前回合</Badge> : null}
<Badge
variant={turnStatusMeta.badgeVariant}
className={turnStatusMeta.badgeClassName}
>
{turnStatusMeta.label}
</Badge>
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<Clock3 className="h-3.5 w-3.5" />
<span>{formatTimestamp(turn.started_at) || "刚刚"}</span>
</div>
</div>
<div className="mt-1.5 text-sm leading-6 text-foreground">
{turnStatusMeta.overviewText}
</div>
{promptPreview || focusBlock ? (
<div className="mt-3 grid gap-2 md:grid-cols-2">
{promptPreview ? (
<div
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
data-testid="agent-thread-goal"
>
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
用户目标
</div>
<div className="mt-1 text-sm text-foreground">
{promptPreview}
</div>
</div>
) : null}
{focusBlock ? (
<div
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
data-testid="agent-thread-focus"
>
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
当前聚焦
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
{focusBlockStageLabel ? (
<Badge variant="outline">{focusBlockStageLabel}</Badge>
) : null}
<span className="text-sm font-medium text-foreground">
{focusBlock.title}
</span>
</div>
{focusBlock.previewLines[0] ? (
<div className="mt-1 text-sm text-muted-foreground">
{focusBlock.previewLines[0]}
</div>
) : null}
</div>
) : null}
</div>
) : null}
{displayModel.summaryChips.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{displayModel.summaryChips.map((chip) => (
<SummaryChip key={chip.kind} chip={chip} />
))}
</div>
) : null}
{turn.error_message &&
turnStatusMeta.badgeVariant === "destructive" ? (
<div className="mt-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{turn.error_message}
</div>
) : null}
</div>
</div>
<div className="space-y-3" data-testid="agent-thread-flow">
{displayModel.orderedBlocks.map((block, index) => (
<TimelineBlockCard
key={block.id}
block={block}
index={index}
isLast={index === displayModel.orderedBlocks.length - 1}
emphasis={
activeBlockIndex === index
? "active"
: block.status === "completed"
? "quiet"
: "default"
}
isExpanded={expandedBlockIndexes.has(index)}
onFileClick={onFileClick}
onPermissionResponse={onPermissionResponse}
/>
))}
</span>
</div>
<div className={overviewTextClassName}>{timelineOverviewText}</div>
</div>
</CollapsibleContent>
</Collapsible>
) : null}
<Collapsible open={detailsExpanded} onOpenChange={setDetailsExpanded}>
{!detailsExpanded ? (
<CollapsibleTrigger asChild>
<button
type="button"
aria-label={toggleActionLabel}
title={collapsedProcess.combinedText}
className={cn(
"group relative flex w-full max-w-4xl items-center gap-3 overflow-hidden rounded-2xl border bg-background/88 px-3.5 py-2.5 text-left shadow-sm transition-all duration-200 hover:bg-background",
compactTone === "running" &&
"border-sky-200/80 shadow-[0_10px_28px_-22px_rgba(56,189,248,0.75)] hover:border-sky-300/80",
compactTone === "waiting" &&
"border-amber-200/80 bg-amber-50/72 hover:border-amber-300/80",
compactTone === "failed" &&
"border-rose-200/80 bg-rose-50/72 hover:border-rose-300/80",
compactTone === "paused" &&
"border-slate-200/80 bg-slate-50/78 hover:border-slate-300/80",
compactTone === "done" &&
"border-border/60 hover:border-emerald-200/70",
)}
data-testid="agent-thread-details-toggle"
>
{showRunningAccent ? (
<span className="pointer-events-none absolute inset-y-3 left-0.5 w-0.5 rounded-full bg-gradient-to-b from-sky-400/30 via-sky-400 to-emerald-400/50 animate-pulse" />
) : null}
<div className="flex min-w-0 flex-1 items-start gap-3">
<TimelineCompactStatusIcon tone={compactTone} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="font-medium tracking-wide text-slate-500">
流程轨迹
</span>
<span className="rounded-full border border-border/60 bg-background/70 px-2 py-0.5">
{flowBlockCount} 段
</span>
{collapsedProcess.stageLabel ? (
<span
className="rounded-full border border-border/60 bg-background/70 px-2 py-0.5"
data-testid="agent-thread-details-stage"
>
{collapsedProcess.stageLabel}
</span>
) : null}
<Badge
variant={turnStatusMeta.badgeVariant}
className={turnStatusMeta.badgeClassName}
>
{collapsedProcess.statusLabel}
</Badge>
</div>
<div
className="mt-1 truncate text-sm font-medium text-foreground"
data-testid="agent-thread-details-inline-text"
>
{collapsedProcess.detailText}
</div>
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<span>展开流程</span>
<ChevronDown className="h-3.5 w-3.5 transition-transform duration-200" />
</div>
</button>
</CollapsibleTrigger>
) : null}
<CollapsibleContent>
<div
className={cn("space-y-3", detailsExpanded ? "" : "mt-3")}
data-testid="agent-thread-details"
>
<div
className="relative pl-14"
data-testid="agent-thread-summary-shell"
>
<div className="absolute left-0 top-2.5 flex h-8 w-8 items-center justify-center rounded-full border border-primary/15 bg-primary/10 text-primary shadow-sm shadow-primary/10">
<Sparkles className="h-4 w-4" />
</div>
<div
className="rounded-xl border border-border/50 bg-background/70 px-4 py-2.5"
data-testid="agent-thread-summary"
>
<div
className="flex flex-wrap items-center gap-2"
data-testid="agent-thread-summary-header"
>
<div className="text-xs font-medium tracking-wide text-muted-foreground">
{isCurrentTurn ? "当前回合摘要" : "回合摘要"}
</div>
<button
type="button"
aria-label="收起流程"
onClick={() => setDetailsExpanded(false)}
className="ml-auto inline-flex items-center gap-1 rounded-full border border-border/60 bg-background/70 px-2.5 py-1 text-xs text-muted-foreground transition hover:border-border hover:bg-background hover:text-foreground"
data-testid="agent-thread-summary-collapse"
>
<span>收起流程</span>
<ChevronDown className="h-3.5 w-3.5 rotate-180" />
</button>
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock3 className="h-3.5 w-3.5" />
<span>{formatTimestamp(turn.started_at) || "刚刚"}</span>
</div>
</div>
{summaryPanelText ? (
<div className="mt-1.5 text-sm leading-6 text-foreground">
{summaryPanelText}
</div>
) : null}
{promptPreview || focusBlock ? (
<div className="mt-3 grid gap-2 md:grid-cols-2">
{promptPreview ? (
<div
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
data-testid="agent-thread-goal"
>
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
用户目标
</div>
<div className="mt-1 text-sm text-foreground">
{promptPreview}
</div>
</div>
) : null}
{focusBlock ? (
<div
className="rounded-xl border border-border/60 bg-background/80 px-3 py-2"
data-testid="agent-thread-focus"
>
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
当前聚焦
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
{focusBlockStageLabel ? (
<Badge variant="outline">{focusBlockStageLabel}</Badge>
) : null}
<span className="text-sm font-medium text-foreground">
{focusBlock.title}
</span>
</div>
</div>
) : null}
</div>
) : null}
{displayModel.summaryChips.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{displayModel.summaryChips.map((chip) => (
<SummaryChip key={chip.kind} chip={chip} />
))}
</div>
) : null}
{turn.error_message &&
turnStatusMeta.badgeVariant === "destructive" ? (
<div className="mt-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{turn.error_message}
</div>
) : null}
</div>
</div>
<div className="space-y-3" data-testid="agent-thread-flow">
{displayModel.orderedBlocks.map((block, index) => (
<TimelineBlockCard
key={block.id}
block={block}
index={index}
isLast={index === displayModel.orderedBlocks.length - 1}
emphasis={
activeBlockIndex === index
? "active"
: block.status === "completed"
? "quiet"
: "default"
}
isExpanded={expandedBlockIndexes.has(index)}
onFileClick={onFileClick}
onOpenSubagentSession={onOpenSubagentSession}
onPermissionResponse={onPermissionResponse}
/>
))}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
@@ -94,6 +94,7 @@ const mountedRoots: MountedHarness[] = [];
interface MountOptions {
onManageProviders?: () => void;
chatModelSelectorProps?: Partial<React.ComponentProps<typeof ChatModelSelector>>;
}
function createModel(id: string, providerId: string) {
@@ -133,7 +134,7 @@ function mount(
workspaceId: string,
options: MountOptions = {},
): HTMLDivElement {
const { onManageProviders } = options;
const { onManageProviders, chatModelSelectorProps } = options;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
@@ -165,6 +166,7 @@ function mount(
setModel={chat.setModel}
activeTheme="general"
onManageProviders={onManageProviders}
{...chatModelSelectorProps}
/>
<div data-testid="current-model">
{chat.providerType}/{chat.model}
@@ -432,4 +434,52 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => {
expect(onManageProviders).toHaveBeenCalledTimes(1);
});
it("关闭后台预加载时,应在打开选择器后再加载 Provider 和模型", async () => {
const container = mount("ws-model-selector-lazy-provider-load", {
chatModelSelectorProps: {
backgroundPreload: "disabled",
},
});
await flushEffects();
expect(
mockUseConfiguredProviders.mock.calls.some(
([options]) => options?.autoLoad === false,
),
).toBe(true);
expect(
mockUseConfiguredProviders.mock.calls.some(
([options]) => options?.autoLoad === true,
),
).toBe(false);
expect(
mockUseProviderModels.mock.calls.some(
([, options]) => options?.autoLoad === false,
),
).toBe(true);
expect(
mockUseProviderModels.mock.calls.some(
([, options]) => options?.autoLoad === true,
),
).toBe(false);
await act(async () => {
getComboboxTrigger(container).click();
});
await flushEffects();
expect(
mockUseConfiguredProviders.mock.calls.some(
([options]) => options?.autoLoad === true,
),
).toBe(true);
expect(
mockUseProviderModels.mock.calls.some(
([, options]) => options?.autoLoad === true,
),
).toBe(true);
});
});
@@ -128,6 +128,79 @@ describe("ChatSidebar", () => {
expect(container.textContent).toContain("任务一");
});
it("Team Runtime 和任务列表应处于同一滚动区域", () => {
const container = renderSidebar({
childSubagentSessions: [
{
id: "child-1",
name: "代码审查代理",
created_at: 1_742_288_400,
updated_at: 1_742_288_520,
session_type: "sub_agent",
task_summary: "检查 team runtime 侧栏遗漏的交互入口。",
role_hint: "reviewer",
runtime_status: "running",
},
],
});
const scrollArea = container.querySelector(
'[data-testid="chat-sidebar-scroll-area"]',
) as HTMLDivElement | null;
const teamSection = container.querySelector(
'[data-testid="team-runtime-section"]',
) as HTMLElement | null;
expect(scrollArea).toBeTruthy();
expect(teamSection).toBeTruthy();
expect(scrollArea?.contains(teamSection)).toBe(true);
expect(scrollArea?.textContent).toContain("Team Runtime");
expect(scrollArea?.textContent).toContain("任务一");
});
it("点击 Team Runtime 的任务入口应收起顶部区块并滚动到任务列表", () => {
const container = renderSidebar({
childSubagentSessions: [
{
id: "child-1",
name: "代码审查代理",
created_at: 1_742_288_400,
updated_at: 1_742_288_520,
session_type: "sub_agent",
task_summary: "检查 team runtime 侧栏遗漏的交互入口。",
role_hint: "reviewer",
runtime_status: "running",
},
],
});
const taskHeading = container.querySelector(
'[data-testid="task-section-heading"]',
) as (HTMLDivElement & { scrollIntoView?: ReturnType<typeof vi.fn> }) | null;
expect(taskHeading).toBeTruthy();
const scrollIntoView = vi.fn();
if (taskHeading) {
taskHeading.scrollIntoView = scrollIntoView;
}
const jumpButton = container.querySelector(
'button[aria-label="跳转到任务列表"]',
) as HTMLButtonElement | null;
expect(jumpButton).toBeTruthy();
act(() => {
jumpButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(scrollIntoView).toHaveBeenCalledWith({
block: "start",
behavior: "smooth",
});
expect(container.textContent).toContain("已收起 · 1 个子代理 · 1 个运行中");
expect(container.textContent).not.toContain("代码审查代理");
});
it("点击任务时应触发切换", () => {
const onSwitchTopic = vi.fn();
const container = renderSidebar({ onSwitchTopic });
@@ -452,4 +525,344 @@ describe("ChatSidebar", () => {
expect(container.textContent).toContain("任务一");
expect(container.textContent).not.toContain("任务二");
});
it("父线程应在侧栏展示真实子代理并支持打开", () => {
const onOpenSubagentSession = vi.fn();
const container = renderSidebar({
childSubagentSessions: [
{
id: "child-1",
name: "代码审查代理",
created_at: 1_742_288_400,
updated_at: 1_742_288_520,
session_type: "sub_agent",
task_summary: "检查 team runtime 侧栏遗漏的交互入口。",
role_hint: "reviewer",
runtime_status: "running",
},
{
id: "child-2",
name: "文档校对代理",
created_at: 1_742_288_410,
updated_at: 1_742_288_480,
session_type: "sub_agent",
task_summary: "核对 roadmap 的阶段完成度。",
role_hint: "writer",
runtime_status: "completed",
},
],
onOpenSubagentSession,
});
expect(container.textContent).toContain("Team Runtime");
expect(container.textContent).toContain("代码审查代理");
expect(container.textContent).toContain("文档校对代理");
expect(container.textContent).toContain("运行中");
expect(container.textContent).toContain("已完成");
const sessionButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("代码审查代理"),
);
expect(sessionButton).toBeTruthy();
act(() => {
sessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onOpenSubagentSession).toHaveBeenCalledWith("child-1");
});
it("父线程 Team Runtime 区域应支持折叠和展开", () => {
const container = renderSidebar({
childSubagentSessions: [
{
id: "child-1",
name: "代码审查代理",
created_at: 1_742_288_400,
updated_at: 1_742_288_520,
session_type: "sub_agent",
task_summary: "检查 team runtime 侧栏遗漏的交互入口。",
role_hint: "reviewer",
runtime_status: "running",
},
{
id: "child-2",
name: "文档校对代理",
created_at: 1_742_288_410,
updated_at: 1_742_288_480,
session_type: "sub_agent",
task_summary: "核对 roadmap 的阶段完成度。",
role_hint: "writer",
runtime_status: "completed",
},
],
});
expect(container.textContent).toContain("代码审查代理");
expect(container.textContent).toContain("文档校对代理");
const collapseButton = container.querySelector(
'button[aria-label="收起 Team Runtime"]',
) as HTMLButtonElement | null;
expect(collapseButton).toBeTruthy();
act(() => {
collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("已收起 · 2 个子代理 · 1 个运行中 · 1 个已完成");
expect(container.textContent).not.toContain("代码审查代理");
expect(container.textContent).not.toContain("文档校对代理");
const expandButton = container.querySelector(
'button[aria-label="展开 Team Runtime"]',
) as HTMLButtonElement | null;
expect(expandButton).toBeTruthy();
act(() => {
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("代码审查代理");
expect(container.textContent).toContain("文档校对代理");
});
it("父线程 Team Runtime 在子代理较多时应默认收起,并支持展开更多子代理", () => {
const container = renderSidebar({
childSubagentSessions: [
{
id: "child-1",
name: "代码审查代理",
created_at: 1_742_288_400,
updated_at: 1_742_288_560,
session_type: "sub_agent",
task_summary: "检查 team runtime 侧栏遗漏的交互入口。",
role_hint: "reviewer",
runtime_status: "running",
},
{
id: "child-2",
name: "文档校对代理",
created_at: 1_742_288_410,
updated_at: 1_742_288_550,
session_type: "sub_agent",
task_summary: "核对 roadmap 的阶段完成度。",
role_hint: "writer",
runtime_status: "completed",
},
{
id: "child-3",
name: "数据整理代理",
created_at: 1_742_288_420,
updated_at: 1_742_288_540,
session_type: "sub_agent",
task_summary: "汇总运行日志中的关键告警。",
role_hint: "analyst",
runtime_status: "queued",
},
{
id: "child-4",
name: "回归验证代理",
created_at: 1_742_288_430,
updated_at: 1_742_288_530,
session_type: "sub_agent",
task_summary: "确认恢复链路和 UI 状态推进。",
role_hint: "qa",
runtime_status: "running",
},
],
});
expect(container.textContent).toContain(
"已收起 · 4 个子代理 · 2 个运行中 · 1 个排队中 · 1 个已完成",
);
expect(container.textContent).not.toContain("代码审查代理");
expect(container.textContent).not.toContain("文档校对代理");
expect(container.textContent).not.toContain("数据整理代理");
expect(container.textContent).not.toContain("回归验证代理");
const expandTeamButton = container.querySelector(
'button[aria-label="展开 Team Runtime"]',
) as HTMLButtonElement | null;
expect(expandTeamButton).toBeTruthy();
act(() => {
expandTeamButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(container.textContent).toContain("代码审查代理");
expect(container.textContent).toContain("文档校对代理");
expect(container.textContent).toContain("数据整理代理");
expect(container.textContent).not.toContain("回归验证代理");
expect(container.textContent).toContain("展开剩余 1 个子代理");
const expandMoreButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("展开剩余 1 个子代理"),
);
expect(expandMoreButton).toBeTruthy();
act(() => {
expandMoreButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(container.textContent).toContain("回归验证代理");
expect(container.textContent).toContain("收起子代理列表");
const collapseListButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("收起子代理列表"),
);
expect(collapseListButton).toBeTruthy();
act(() => {
collapseListButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(container.textContent).not.toContain("回归验证代理");
});
it("子线程同级子代理较多时应默认收起 Team Runtime", () => {
const container = renderSidebar({
topics: [
{
...defaultTopics[0],
id: "child-1",
title: "实现 team sidebar",
sourceSessionId: "child-1",
},
],
currentTopicId: "child-1",
subagentParentContext: {
parent_session_id: "parent-1",
parent_session_name: "主线程",
role_hint: "implementer",
task_summary: "把真实 child session 投影到常驻侧栏。",
created_from_turn_id: "turn-42",
sibling_subagent_sessions: [
{
id: "child-2",
name: "研究代理",
created_at: 1_742_288_430,
updated_at: 1_742_288_530,
session_type: "sub_agent",
task_summary: "比对 roadmap 与当前实现差异。",
role_hint: "researcher",
runtime_status: "queued",
},
{
id: "child-3",
name: "验证代理",
created_at: 1_742_288_431,
updated_at: 1_742_288_531,
session_type: "sub_agent",
task_summary: "验证 team runtime 行为。",
role_hint: "qa",
runtime_status: "running",
},
{
id: "child-4",
name: "文档代理",
created_at: 1_742_288_432,
updated_at: 1_742_288_532,
session_type: "sub_agent",
task_summary: "补齐回归说明。",
role_hint: "writer",
runtime_status: "completed",
},
],
},
});
expect(container.textContent).toContain(
"已收起 · 3 个同级子代理 · 1 个运行中 · 1 个排队中 · 1 个已完成",
);
expect(container.textContent).not.toContain("研究代理");
expect(container.textContent).not.toContain("验证代理");
expect(container.textContent).not.toContain("文档代理");
const expandButton = container.querySelector(
'button[aria-label="展开 Team Runtime"]',
) as HTMLButtonElement | null;
expect(expandButton).toBeTruthy();
act(() => {
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("验证代理");
expect(container.textContent).toContain("文档代理");
expect(container.textContent).not.toContain("研究代理");
expect(container.textContent).toContain("展开剩余 1 个同级子代理");
});
it("子线程应展示父会话和同级子代理入口", () => {
const onOpenSubagentSession = vi.fn();
const onReturnToParentSession = vi.fn();
const container = renderSidebar({
topics: [
{
...defaultTopics[0],
id: "child-1",
title: "实现 team sidebar",
sourceSessionId: "child-1",
},
],
currentTopicId: "child-1",
subagentParentContext: {
parent_session_id: "parent-1",
parent_session_name: "主线程",
role_hint: "implementer",
task_summary: "把真实 child session 投影到常驻侧栏。",
created_from_turn_id: "turn-42",
sibling_subagent_sessions: [
{
id: "child-2",
name: "研究代理",
created_at: 1_742_288_430,
updated_at: 1_742_288_530,
session_type: "sub_agent",
task_summary: "比对 roadmap 与当前实现差异。",
role_hint: "researcher",
runtime_status: "queued",
},
],
},
onOpenSubagentSession,
onReturnToParentSession,
});
expect(container.textContent).toContain("Team Runtime");
expect(container.textContent).toContain("主线程");
expect(container.textContent).toContain("当前子代理");
expect(container.textContent).toContain("实现 team sidebar");
expect(container.textContent).toContain("研究代理");
expect(container.textContent).toContain("排队中");
const returnButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("主线程"),
);
expect(returnButton).toBeTruthy();
act(() => {
returnButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onReturnToParentSession).toHaveBeenCalledTimes(1);
const siblingButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("研究代理"),
);
expect(siblingButton).toBeTruthy();
act(() => {
siblingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onOpenSubagentSession).toHaveBeenCalledWith("child-2");
});
});
@@ -1,8 +1,12 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowUpLeft,
Bot,
ChevronDown,
Clock3,
GitBranch,
Globe,
ListTodo,
Loader2,
MoreHorizontal,
PencilLine,
@@ -21,6 +25,10 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type {
AsterSubagentParentContext,
AsterSubagentSessionInfo,
} from "@/lib/api/agentRuntime";
import {
deriveTaskLiveState,
extractTaskPreviewFromMessages,
@@ -32,6 +40,8 @@ import type { Message } from "../types";
const RECENT_TASK_WINDOW_MS = 1000 * 60 * 60 * 24 * 3;
const OLDER_TASKS_INITIAL_COUNT = 8;
const TEAM_SECTION_INITIAL_CHILD_COUNT = 3;
const TEAM_SECTION_INITIAL_SIBLING_COUNT = 2;
const PINNED_TASK_IDS_STORAGE_KEY = "lime_task_sidebar_pinned_ids";
const STATUS_META: Record<
@@ -117,6 +127,10 @@ interface ChatSidebarProps {
pendingActionCount?: number;
queuedTurnCount?: number;
workspaceError?: boolean;
childSubagentSessions?: AsterSubagentSessionInfo[];
subagentParentContext?: AsterSubagentParentContext | null;
onOpenSubagentSession?: (sessionId: string) => void | Promise<void>;
onReturnToParentSession?: () => void | Promise<void>;
}
function isResumableStatusReason(statusReason?: TaskStatusReason) {
@@ -344,6 +358,107 @@ function buildTaskSections(items: TaskCardViewModel[]) {
] satisfies TaskSection[];
}
const SUBAGENT_STATUS_META: Record<
NonNullable<AsterSubagentSessionInfo["runtime_status"]> | "idle",
{
label: string;
badgeClassName: string;
}
> = {
idle: {
label: "待开始",
badgeClassName:
"border border-slate-200 bg-white text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
},
queued: {
label: "排队中",
badgeClassName:
"border border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200",
},
running: {
label: "运行中",
badgeClassName:
"border border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200",
},
completed: {
label: "已完成",
badgeClassName:
"border border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200",
},
failed: {
label: "失败",
badgeClassName:
"border border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/20 dark:bg-rose-500/10 dark:text-rose-200",
},
aborted: {
label: "已中止",
badgeClassName:
"border border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/20 dark:bg-rose-500/10 dark:text-rose-200",
},
closed: {
label: "已关闭",
badgeClassName:
"border border-slate-200 bg-slate-100 text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
},
};
const TEAM_STATUS_SUMMARY_ORDER: Array<
NonNullable<AsterSubagentSessionInfo["runtime_status"]> | "idle"
> = ["running", "queued", "completed", "failed", "aborted", "closed", "idle"];
function resolveSubagentStatusMeta(
status?: AsterSubagentSessionInfo["runtime_status"],
) {
return SUBAGENT_STATUS_META[status ?? "idle"];
}
function resolveSubagentSessionTypeLabel(value?: string) {
switch (value) {
case "sub_agent":
return "子代理";
case "fork":
return "分支会话";
case "user":
default:
return value?.trim() || "会话";
}
}
function resolveUnixDate(value?: number) {
if (!value) {
return null;
}
const timestamp = new Date(value * 1000);
return Number.isNaN(timestamp.getTime()) ? null : timestamp;
}
function buildCollapsedTeamSummary(
sessions: AsterSubagentSessionInfo[],
label: string,
) {
const counts = new Map<
NonNullable<AsterSubagentSessionInfo["runtime_status"]> | "idle",
number
>();
for (const session of sessions) {
const key = session.runtime_status ?? "idle";
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const statusSummary = TEAM_STATUS_SUMMARY_ORDER.map((status) => {
const count = counts.get(status) ?? 0;
if (count <= 0) {
return null;
}
return `${count} 个${SUBAGENT_STATUS_META[status].label}`;
}).filter((item): item is string => Boolean(item));
return ["已收起", label, ...statusSummary].join(" · ");
}
export const ChatSidebar: React.FC<ChatSidebarProps> = ({
onNewChat,
topics,
@@ -357,6 +472,10 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
pendingActionCount = 0,
queuedTurnCount = 0,
workspaceError = false,
childSubagentSessions = [],
subagentParentContext = null,
onOpenSubagentSession,
onReturnToParentSession,
}) => {
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
@@ -377,7 +496,13 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
recent: false,
older: false,
});
const [teamSectionCollapsedOverride, setTeamSectionCollapsedOverride] = useState<
boolean | null
>(null);
const [showAllChildSubagents, setShowAllChildSubagents] = useState(false);
const [showAllSiblingSubagents, setShowAllSiblingSubagents] = useState(false);
const editInputRef = useRef<HTMLInputElement>(null);
const taskSectionAnchorRef = useRef<HTMLDivElement>(null);
const currentTaskPreview = useMemo(
() => resolveCurrentTaskPreview(currentMessages),
@@ -438,6 +563,78 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
topics,
workspaceError,
]);
const currentTaskItem = useMemo(
() => taskItems.find((item) => item.id === currentTopicId) ?? null,
[currentTopicId, taskItems],
);
const sortedChildSubagentSessions = useMemo(
() =>
[...childSubagentSessions].sort(
(left, right) => right.updated_at - left.updated_at,
),
[childSubagentSessions],
);
const siblingSubagentSessions = useMemo(
() =>
[...(subagentParentContext?.sibling_subagent_sessions ?? [])].sort(
(left, right) => right.updated_at - left.updated_at,
),
[subagentParentContext?.sibling_subagent_sessions],
);
const visibleChildSubagentSessions = useMemo(
() =>
showAllChildSubagents
? sortedChildSubagentSessions
: sortedChildSubagentSessions.slice(0, TEAM_SECTION_INITIAL_CHILD_COUNT),
[showAllChildSubagents, sortedChildSubagentSessions],
);
const visibleSiblingSubagentSessions = useMemo(
() =>
showAllSiblingSubagents
? siblingSubagentSessions
: siblingSubagentSessions.slice(0, TEAM_SECTION_INITIAL_SIBLING_COUNT),
[showAllSiblingSubagents, siblingSubagentSessions],
);
const hiddenChildSubagentCount = Math.max(
0,
sortedChildSubagentSessions.length - visibleChildSubagentSessions.length,
);
const hiddenSiblingSubagentCount = Math.max(
0,
siblingSubagentSessions.length - visibleSiblingSubagentSessions.length,
);
const shouldShowTeamSection =
Boolean(subagentParentContext) || sortedChildSubagentSessions.length > 0;
const teamSummarySessions = subagentParentContext
? siblingSubagentSessions
: sortedChildSubagentSessions;
const shouldAutoCollapseTeamSection = subagentParentContext
? siblingSubagentSessions.length > TEAM_SECTION_INITIAL_SIBLING_COUNT
: sortedChildSubagentSessions.length > TEAM_SECTION_INITIAL_CHILD_COUNT;
const teamSectionIdentity = subagentParentContext
? `child:${subagentParentContext.parent_session_id}:${siblingSubagentSessions
.map((session) => session.id)
.join(",")}`
: `parent:${sortedChildSubagentSessions
.map((session) => session.id)
.join(",")}`;
const teamSectionCollapsed =
teamSectionCollapsedOverride ?? shouldAutoCollapseTeamSection;
const collapsedTeamSummary = useMemo(
() =>
buildCollapsedTeamSummary(
teamSummarySessions,
subagentParentContext
? `${siblingSubagentSessions.length} 个同级子代理`
: `${sortedChildSubagentSessions.length} 个子代理`,
),
[
siblingSubagentSessions,
sortedChildSubagentSessions,
subagentParentContext,
teamSummarySessions,
],
);
const filteredTaskItems = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase();
@@ -514,6 +711,24 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
setShowAllOlder(false);
}, [searchKeyword, statusFilter]);
useEffect(() => {
setTeamSectionCollapsedOverride(null);
setShowAllChildSubagents(false);
setShowAllSiblingSubagents(false);
}, [teamSectionIdentity]);
useEffect(() => {
if (sortedChildSubagentSessions.length <= TEAM_SECTION_INITIAL_CHILD_COUNT) {
setShowAllChildSubagents(false);
}
}, [sortedChildSubagentSessions.length]);
useEffect(() => {
if (siblingSubagentSessions.length <= TEAM_SECTION_INITIAL_SIBLING_COUNT) {
setShowAllSiblingSubagents(false);
}
}, [siblingSubagentSessions.length]);
useEffect(() => {
if (typeof window === "undefined") {
return;
@@ -551,6 +766,16 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
void onSwitchTopic(item.id);
};
const handleJumpToTaskSection = () => {
setTeamSectionCollapsedOverride(true);
setShowAllChildSubagents(false);
setShowAllSiblingSubagents(false);
taskSectionAnchorRef.current?.scrollIntoView({
block: "start",
behavior: "smooth",
});
};
const handleSaveEdit = () => {
if (editingTopicId && editTitle.trim() && onRenameTopic) {
onRenameTopic(editingTopicId, editTitle.trim());
@@ -572,6 +797,65 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
}
};
const renderSubagentSessionCard = (
session: AsterSubagentSessionInfo,
options?: {
highlightCurrent?: boolean;
subtitle?: string;
},
) => {
const statusMeta = resolveSubagentStatusMeta(session.runtime_status);
const updatedAt = resolveUnixDate(session.updated_at);
const canOpen = Boolean(onOpenSubagentSession);
return (
<button
key={session.id}
type="button"
onClick={() => {
if (!canOpen) {
return;
}
void onOpenSubagentSession?.(session.id);
}}
className={cn(
"w-full rounded-[20px] border px-3.5 py-3 text-left shadow-sm shadow-slate-950/5 transition",
options?.highlightCurrent
? "border-slate-300 bg-white/98 ring-1 ring-slate-100 dark:border-white/15 dark:bg-white/10"
: "border-slate-200/80 bg-white/86 hover:border-slate-300 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/10",
!canOpen ? "cursor-default" : "",
)}
disabled={!canOpen}
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-slate-100 text-slate-600 dark:bg-white/10 dark:text-slate-200">
<Bot className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{session.name || "未命名子代理"}
</div>
<Badge className={statusMeta.badgeClassName}>
{statusMeta.label}
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-slate-500 dark:text-slate-400">
<span>{options?.subtitle ?? resolveSubagentSessionTypeLabel(session.session_type)}</span>
{session.role_hint ? <span>角色 · {session.role_hint}</span> : null}
{updatedAt ? <span>更新于 {formatRelativeTime(updatedAt)}</span> : null}
</div>
{session.task_summary ? (
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-600 dark:text-slate-300">
{session.task_summary}
</p>
) : null}
</div>
</div>
</button>
);
};
return (
<aside
className="w-[308px] shrink-0 overflow-hidden rounded-[30px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.98)_0%,rgba(241,245,249,0.92)_100%)] shadow-sm shadow-slate-950/5 backdrop-blur dark:border-white/10 dark:bg-[#111318]"
@@ -694,42 +978,245 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
</div>
</div>
<div className="flex items-center justify-between px-1">
<div className="text-[11px] font-semibold tracking-[0.12em] text-slate-500">
任务
</div>
<div className="text-xs text-slate-400">
{searchKeyword.trim()
? `${filteredTaskItems.length} 条结果`
: `${topics.length} 条`}
</div>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:thin]"
data-testid="chat-sidebar-scroll-area"
>
<div className="space-y-4 pb-1">
{shouldShowTeamSection ? (
<section
className="rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.94)_0%,rgba(248,250,252,0.9)_100%)] px-3.5 py-3.5 shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5"
data-testid="team-runtime-section"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-slate-900 text-white dark:bg-white dark:text-slate-900">
<GitBranch className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Team Runtime
</div>
<p className="mt-1 line-clamp-2 text-[11px] leading-5 text-slate-500 dark:text-slate-400">
{teamSectionCollapsed
? collapsedTeamSummary
: subagentParentContext
? "当前线程来自父会话,可直接返回主线程并切换同级子代理。"
: "这里展示真实 child session,而不是 synthetic timeline。"}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge className="border border-slate-200 bg-white text-slate-600 dark:border-white/10 dark:bg-white/10 dark:text-slate-200">
{subagentParentContext
? "子线程"
: `${sortedChildSubagentSessions.length} 个子代理`}
</Badge>
{hasAnyTasks ? (
<button
type="button"
aria-label="跳转到任务列表"
title="跳转到任务列表"
onClick={handleJumpToTaskSection}
className="inline-flex h-9 w-9 items-center justify-center rounded-2xl border border-slate-200/80 bg-white/90 text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/10 dark:text-slate-300 dark:hover:bg-white/15 dark:hover:text-slate-100"
>
<ListTodo className="h-4 w-4" />
</button>
) : null}
<button
type="button"
aria-label={
teamSectionCollapsed
? "展开 Team Runtime"
: "收起 Team Runtime"
}
onClick={() =>
setTeamSectionCollapsedOverride(
(collapsed) =>
!(collapsed ?? shouldAutoCollapseTeamSection),
)
}
className="inline-flex h-9 w-9 items-center justify-center rounded-2xl border border-slate-200/80 bg-white/90 text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/10 dark:text-slate-300 dark:hover:bg-white/15 dark:hover:text-slate-100"
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
teamSectionCollapsed ? "-rotate-90" : "",
)}
/>
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:thin]">
{!hasAnyTasks ? (
<div className="rounded-[26px] border border-dashed border-slate-200/90 bg-white/82 px-4 py-8 text-center shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-100 text-slate-500 dark:bg-white/10 dark:text-slate-300">
<Clock3 className="h-5 w-5" />
{teamSectionCollapsed ? null : subagentParentContext ? (
<div className="mt-4 space-y-3">
<button
type="button"
onClick={() => {
void onReturnToParentSession?.();
}}
disabled={!onReturnToParentSession}
className={cn(
"w-full rounded-[20px] border border-slate-200/80 bg-white/88 px-3.5 py-3 text-left shadow-sm shadow-slate-950/5 transition dark:border-white/10 dark:bg-white/5",
onReturnToParentSession
? "hover:border-slate-300 hover:bg-white dark:hover:bg-white/10"
: "cursor-default",
)}
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-slate-100 text-slate-600 dark:bg-white/10 dark:text-slate-200">
<ArrowUpLeft className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{subagentParentContext.parent_session_name}
</div>
<Badge className="border border-slate-200 bg-white text-slate-600 dark:border-white/10 dark:bg-white/10 dark:text-slate-200">
父会话
</Badge>
</div>
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">
返回主线程,查看完整 team 视图和原始上下文。
</p>
</div>
</div>
</button>
<div className="rounded-[20px] border border-slate-200/80 bg-white/86 px-3.5 py-3 shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{currentTaskItem?.title || "当前子代理"}
</div>
<Badge className="border border-slate-900 bg-slate-900 text-white dark:border-white dark:bg-white dark:text-slate-900">
当前子代理
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-slate-500 dark:text-slate-400">
<span>来自父会话委派</span>
{subagentParentContext.role_hint ? (
<span>角色 · {subagentParentContext.role_hint}</span>
) : null}
{currentTaskItem?.updatedAt ? (
<span>
更新于 {formatRelativeTime(currentTaskItem.updatedAt)}
</span>
) : null}
</div>
{subagentParentContext.task_summary ? (
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-600 dark:text-slate-300">
{subagentParentContext.task_summary}
</p>
) : null}
</div>
{visibleSiblingSubagentSessions.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<div className="text-[11px] font-semibold tracking-[0.12em] text-slate-500">
同级子代理
</div>
<div className="text-[11px] text-slate-400">
{siblingSubagentSessions.length} 个
</div>
</div>
{visibleSiblingSubagentSessions.map((session) =>
renderSubagentSessionCard(session),
)}
{hiddenSiblingSubagentCount > 0 ? (
<button
type="button"
onClick={() => setShowAllSiblingSubagents(true)}
className="w-full rounded-2xl border border-dashed border-slate-200/80 bg-white/70 px-3 py-2 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-slate-100"
>
展开剩余 {hiddenSiblingSubagentCount} 个同级子代理
</button>
) : null}
{showAllSiblingSubagents &&
siblingSubagentSessions.length >
TEAM_SECTION_INITIAL_SIBLING_COUNT ? (
<button
type="button"
onClick={() => setShowAllSiblingSubagents(false)}
className="w-full rounded-2xl border border-slate-200/80 bg-white/78 px-3 py-2 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-slate-100"
>
收起同级子代理列表
</button>
) : null}
</div>
) : null}
</div>
) : (
<div className="mt-4 space-y-2">
{visibleChildSubagentSessions.map((session) =>
renderSubagentSessionCard(session, {
highlightCurrent: session.id === currentTopicId,
}),
)}
{hiddenChildSubagentCount > 0 ? (
<button
type="button"
onClick={() => setShowAllChildSubagents(true)}
className="w-full rounded-2xl border border-dashed border-slate-200/80 bg-white/70 px-3 py-2 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-slate-100"
>
展开剩余 {hiddenChildSubagentCount} 个子代理
</button>
) : null}
{showAllChildSubagents &&
sortedChildSubagentSessions.length >
TEAM_SECTION_INITIAL_CHILD_COUNT ? (
<button
type="button"
onClick={() => setShowAllChildSubagents(false)}
className="w-full rounded-2xl border border-slate-200/80 bg-white/78 px-3 py-2 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-slate-100"
>
收起子代理列表
</button>
) : null}
</div>
)}
</section>
) : null}
<div
ref={taskSectionAnchorRef}
className="flex items-center justify-between px-1"
data-testid="task-section-heading"
>
<div className="text-[11px] font-semibold tracking-[0.12em] text-slate-500">
任务
</div>
<div className="mt-4 text-sm font-semibold text-slate-800 dark:text-slate-100">
还没有任务
<div className="text-xs text-slate-400">
{searchKeyword.trim()
? `${filteredTaskItems.length} 条结果`
: `${topics.length} 条`}
</div>
<p className="mt-2 text-xs leading-6 text-slate-500 dark:text-slate-400">
从“新建任务”开始输入需求,创建后会出现在这里。
</p>
</div>
) : !hasFilteredResults ? (
<div className="rounded-[26px] border border-dashed border-slate-200/90 bg-white/82 px-4 py-8 text-center shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
<div className="text-sm font-semibold text-slate-800 dark:text-slate-100">
没有匹配的任务
{!hasAnyTasks ? (
<div className="rounded-[26px] border border-dashed border-slate-200/90 bg-white/82 px-4 py-8 text-center shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-100 text-slate-500 dark:bg-white/10 dark:text-slate-300">
<Clock3 className="h-5 w-5" />
</div>
<div className="mt-4 text-sm font-semibold text-slate-800 dark:text-slate-100">
还没有任务
</div>
<p className="mt-2 text-xs leading-6 text-slate-500 dark:text-slate-400">
从“新建任务”开始输入需求,创建后会出现在这里。
</p>
</div>
<p className="mt-2 text-xs leading-6 text-slate-500 dark:text-slate-400">
试试搜索标题、执行摘要或状态关键词。
</p>
</div>
) : (
<div className="space-y-3">
{sections.map((section) => {
) : !hasFilteredResults ? (
<div className="rounded-[26px] border border-dashed border-slate-200/90 bg-white/82 px-4 py-8 text-center shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
<div className="text-sm font-semibold text-slate-800 dark:text-slate-100">
没有匹配的任务
</div>
<p className="mt-2 text-xs leading-6 text-slate-500 dark:text-slate-400">
试试搜索标题、执行摘要或状态关键词。
</p>
</div>
) : (
<div className="space-y-3">
{sections.map((section) => {
const isOlderSection = section.key === "older";
const isResumableSection = section.key === "resumable";
const isSectionCollapsed = isResumableSection
@@ -1008,9 +1495,10 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
)}
</section>
);
})}
</div>
)}
})}
</div>
)}
</div>
</div>
</div>
</aside>
@@ -31,6 +31,7 @@ import {
import {
buildRecommendationPrompt,
getContextualRecommendations,
isTeamRuntimeRecommendation,
} from "../utils/contextualRecommendations";
import { EmptyStateComposerPanel } from "./EmptyStateComposerPanel";
import { EmptyStateHero } from "./EmptyStateHero";
@@ -49,6 +50,7 @@ import { useActiveSkill } from "./Inputbar/hooks/useActiveSkill";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { MessageImage } from "../types";
import type { TeamDefinition } from "../utils/teamDefinitions";
import { isGeneralResearchTheme } from "../utils/generalAgentPrompt";
import {
getClipboardImageCandidates,
@@ -60,8 +62,34 @@ import capabilitySkillsPlaceholder from "@/assets/claw-home/capability-skills-pl
import capabilityAutomationsPlaceholder from "@/assets/claw-home/capability-automations-placeholder.svg";
import capabilityAgentTeamsPlaceholder from "@/assets/claw-home/capability-agent-teams-placeholder.svg";
import capabilityBrowserAssistPlaceholder from "@/assets/claw-home/capability-browser-assist-placeholder.svg";
import type { ModelSelectorProps } from "@/components/input-kit";
const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover";
const CONFIG_LOAD_IDLE_TIMEOUT_MS = 1_500;
const CONFIG_LOAD_FALLBACK_DELAY_MS = 180;
function scheduleDeferredConfigLoad(task: () => void): () => void {
if (typeof window === "undefined") {
task();
return () => undefined;
}
if (typeof window.requestIdleCallback === "function") {
const idleId = window.requestIdleCallback(() => task(), {
timeout: CONFIG_LOAD_IDLE_TIMEOUT_MS,
});
return () => {
if (typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(idleId);
}
};
}
const timeoutId = window.setTimeout(task, CONFIG_LOAD_FALLBACK_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
}
const backgroundOrbDrift = keyframes`
0%, 100% {
@@ -171,6 +199,9 @@ interface EmptyStateProps {
onTaskEnabledChange?: (enabled: boolean) => void;
subagentEnabled?: boolean;
onSubagentEnabledChange?: (enabled: boolean) => void;
selectedTeam?: TeamDefinition | null;
onSelectTeam?: (team: TeamDefinition | null) => void;
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
hasCanvasContent?: boolean;
hasContentId?: boolean;
selectedText?: string;
@@ -196,6 +227,14 @@ interface EmptyStateProps {
onProjectChange?: (projectId: string) => void;
/** 打开设置 */
onOpenSettings?: () => void;
/** 是否跳过首页项目选择器的默认项目目录检查 */
skipProjectSelectorWorkspaceReadyCheck?: boolean;
/** 是否延后首页项目列表加载到展开时 */
deferProjectSelectorListLoad?: boolean;
/** 模型选择器后台预加载策略 */
modelSelectorBackgroundPreload?: ModelSelectorProps["backgroundPreload"];
/** 配置读取策略 */
configLoadStrategy?: "immediate" | "idle";
}
const ENTRY_THEME_ID = "social-media";
@@ -269,8 +308,7 @@ const THEME_WORKBENCH_COPY: Record<
> = {
general: {
title: "青柠一下,灵感即来",
description:
"从一句想法,到成稿、成图、成片、成事。",
description: "从一句想法,到成稿、成图、成片、成事。",
supportingDescription:
"Claw 工作台会围绕一个目标持续对话、检索网页、补充素材,并把结果沉淀到右侧画布,而不是只停留在一次性提问。",
},
@@ -349,6 +387,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onTaskEnabledChange,
subagentEnabled = false,
onSubagentEnabledChange,
selectedTeam = null,
onSelectTeam,
onEnableSuggestedTeam,
hasCanvasContent = false,
hasContentId = false,
selectedText = "",
@@ -363,6 +404,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
projectId = null,
onProjectChange,
onOpenSettings,
skipProjectSelectorWorkspaceReadyCheck = false,
deferProjectSelectorListLoad = false,
modelSelectorBackgroundPreload = "immediate",
configLoadStrategy = "immediate",
}) => {
const { activeSkill, setActiveSkill, clearActiveSkill, wrapTextWithSkill } =
useActiveSkill();
@@ -392,11 +437,27 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
console.error("加载主题配置失败:", e);
}
};
loadConfigPreferences();
let cancelPendingLoad: () => void = () => undefined;
if (configLoadStrategy === "idle") {
cancelPendingLoad = scheduleDeferredConfigLoad(() => {
void loadConfigPreferences();
});
} else {
void loadConfigPreferences();
}
// 监听配置变更事件
const handleConfigChange = () => {
loadConfigPreferences();
if (configLoadStrategy === "idle") {
cancelPendingLoad();
cancelPendingLoad = scheduleDeferredConfigLoad(() => {
void loadConfigPreferences();
});
return;
}
void loadConfigPreferences();
};
window.addEventListener("theme-config-changed", handleConfigChange);
window.addEventListener(
@@ -410,8 +471,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
"chat-appearance-config-changed",
handleConfigChange,
);
cancelPendingLoad();
};
}, []);
}, [configLoadStrategy]);
// 过滤后的主题列表
const categories = ALL_CATEGORIES.filter((cat) =>
@@ -495,6 +557,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
hasCanvasContent,
hasContentId,
selectedText: recommendationSelectedText,
subagentEnabled,
});
}, [
activeTheme,
@@ -505,6 +568,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
hasCanvasContent,
hasContentId,
recommendationSelectedText,
subagentEnabled,
]);
const selectedTextPreview = useMemo(() => {
@@ -683,6 +747,13 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
shortLabel: string,
fullPrompt: string,
) => {
const looksLikeTeamRuntimePrompt =
activeTheme === "general" &&
isTeamRuntimeRecommendation(shortLabel, fullPrompt);
if (looksLikeTeamRuntimePrompt) {
onSubagentEnabledChange?.(true);
}
const promptWithSelection = buildRecommendationPrompt(
fullPrompt,
selectedText,
@@ -963,8 +1034,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
[activeTheme, currentRecommendations],
);
const quickStartPresets = useMemo(
() => [
const quickStartPresets = useMemo(() => {
const presets = [
{
key: "generate-image",
label: "生成配图",
@@ -1014,9 +1085,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
prompt:
"请先进入研究模式,帮我围绕当前主题做信息收集、观点归纳、风险点识别和结论总结。",
},
],
[],
);
];
return presets;
}, []);
const composerPanel = (
<EmptyStateComposerPanel
@@ -1033,6 +1105,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
executionStrategyLabel={executionStrategyLabel}
setExecutionStrategy={setExecutionStrategy}
onManageProviders={onManageProviders}
modelSelectorBackgroundPreload={modelSelectorBackgroundPreload}
isGeneralTheme={isGeneralTheme}
isEntryTheme={isEntryTheme}
entryTaskType={entryTaskType}
@@ -1073,6 +1146,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onTaskEnabledChange={onTaskEnabledChange}
subagentEnabled={subagentEnabled}
onSubagentEnabledChange={onSubagentEnabledChange}
selectedTeam={selectedTeam}
onSelectTeam={onSelectTeam}
onEnableSuggestedTeam={onEnableSuggestedTeam}
webSearchEnabled={webSearchEnabled}
onWebSearchEnabledChange={onWebSearchEnabledChange}
pendingImages={pendingImages}
@@ -1097,44 +1173,47 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
/>
);
const headerControls =
onProjectChange ? (
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
<div className="inline-flex max-w-full items-center rounded-[24px] border border-white/85 bg-white/84 p-1 shadow-sm shadow-slate-950/5 backdrop-blur-sm">
<ProjectSelector
value={projectId ?? null}
onChange={onProjectChange}
workspaceType={activeTheme}
placeholder="选择项目"
dropdownSide="bottom"
dropdownAlign="end"
enableManagement={activeTheme === "general"}
density="compact"
chrome="embedded"
className="min-w-[180px] max-w-[260px]"
/>
{onOpenSettings ? (
<>
<div
className="mx-1 h-6 w-px shrink-0 bg-slate-200/80"
aria-hidden="true"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 rounded-[18px] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
onClick={onOpenSettings}
aria-label="打开设置"
title="打开设置"
>
<Settings2 size={18} />
</Button>
</>
) : null}
</div>
const headerControls = onProjectChange ? (
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
<div className="inline-flex max-w-full items-center rounded-[24px] border border-white/85 bg-white/84 p-1 shadow-sm shadow-slate-950/5 backdrop-blur-sm">
<ProjectSelector
value={projectId ?? null}
onChange={onProjectChange}
workspaceType={activeTheme}
placeholder="选择项目"
dropdownSide="bottom"
dropdownAlign="end"
enableManagement={activeTheme === "general"}
density="compact"
chrome="embedded"
skipDefaultWorkspaceReadyCheck={
skipProjectSelectorWorkspaceReadyCheck
}
deferProjectListLoad={deferProjectSelectorListLoad}
className="min-w-[180px] max-w-[260px]"
/>
{onOpenSettings ? (
<>
<div
className="mx-1 h-6 w-px shrink-0 bg-slate-200/80"
aria-hidden="true"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 rounded-[18px] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
onClick={onOpenSettings}
aria-label="打开设置"
title="打开设置"
>
<Settings2 size={18} />
</Button>
</>
) : null}
</div>
) : null;
</div>
) : null;
return (
<PageContainer>
@@ -20,6 +20,10 @@ vi.mock("./Inputbar/components/SkillSelector", () => ({
SkillSelector: () => <div data-testid="empty-state-skill-selector" />,
}));
vi.mock("./Inputbar/components/TeamSelector", () => ({
TeamSelector: () => <div data-testid="empty-state-team-selector" />,
}));
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
@@ -166,4 +170,63 @@ describe("EmptyStateComposerPanel", () => {
expect(onRemoveImage).toHaveBeenCalledWith(0);
});
it("复杂任务应显示 Team 建议并支持开启多代理", () => {
const onSubagentEnabledChange = vi.fn();
const container = renderPanel({
isGeneralTheme: true,
input:
"请帮我分析这个 Rust GUI 多代理实现差异,拆分任务并行推进,再补回归测试和最终汇总结论。",
onSubagentEnabledChange,
});
expect(container.textContent).toContain("当前任务更适合 Team 协作");
expect(container.textContent).toContain("建议角色:分析");
const enableTeamButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("启用 Team"));
expect(enableTeamButton).toBeTruthy();
act(() => {
enableTeamButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
});
it("继续单代理后应隐藏当前输入对应的 Team 建议", () => {
const container = renderPanel({
isGeneralTheme: true,
input:
"请把任务拆成多个子任务分别分析、实现、验证,并在最后统一汇总输出。",
onSubagentEnabledChange: vi.fn(),
});
const continueButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("继续单代理"),
);
expect(continueButton).toBeTruthy();
act(() => {
continueButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(container.textContent).not.toContain("当前任务更适合 Team 协作");
});
it("开启 Team mode 后应显示 TeamSelector", () => {
const container = renderPanel({
isGeneralTheme: true,
subagentEnabled: true,
});
expect(
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeTruthy();
});
});
@@ -1,4 +1,4 @@
import React, { useRef } from "react";
import React, { useMemo, useRef, useState } from "react";
import styled, { keyframes } from "styled-components";
import {
ArrowRight,
@@ -30,9 +30,11 @@ import {
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { ChatModelSelector } from "./ChatModelSelector";
import { TeamSuggestionBar } from "./TeamSuggestionBar";
import { CharacterMention } from "./Inputbar/components/CharacterMention";
import { SkillBadge } from "./Inputbar/components/SkillBadge";
import { SkillSelector } from "./Inputbar/components/SkillSelector";
import { TeamSelector } from "./Inputbar/components/TeamSelector";
import { CREATION_MODE_CONFIG } from "./constants";
import type {
CreationMode,
@@ -43,6 +45,7 @@ import type {
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { MessageImage } from "../types";
import type { TeamDefinition } from "../utils/teamDefinitions";
import iconXhs from "@/assets/platforms/xhs.png";
import iconGzh from "@/assets/platforms/gzh.png";
@@ -57,6 +60,8 @@ import {
EMPTY_STATE_SELECT_TRIGGER_CLASSNAME,
getEmptyStateIconToolButtonClassName,
} from "./emptyStateSurfaceTokens";
import type { ModelSelectorProps } from "@/components/input-kit";
import { getTeamSuggestion } from "../utils/teamSuggestion";
const composerReveal = keyframes`
from {
@@ -479,6 +484,7 @@ interface EmptyStateComposerPanelProps {
strategy: "react" | "code_orchestrated" | "auto",
) => void;
onManageProviders?: () => void;
modelSelectorBackgroundPreload?: ModelSelectorProps["backgroundPreload"];
isGeneralTheme: boolean;
isEntryTheme: boolean;
entryTaskType: EntryTaskType;
@@ -519,6 +525,9 @@ interface EmptyStateComposerPanelProps {
onTaskEnabledChange?: (enabled: boolean) => void;
subagentEnabled: boolean;
onSubagentEnabledChange?: (enabled: boolean) => void;
selectedTeam?: TeamDefinition | null;
onSelectTeam?: (team: TeamDefinition | null) => void;
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
webSearchEnabled: boolean;
onWebSearchEnabledChange?: (enabled: boolean) => void;
pendingImages: MessageImage[];
@@ -541,6 +550,7 @@ export function EmptyStateComposerPanel({
executionStrategyLabel,
setExecutionStrategy,
onManageProviders,
modelSelectorBackgroundPreload = "immediate",
isGeneralTheme,
isEntryTheme,
entryTaskType,
@@ -581,6 +591,9 @@ export function EmptyStateComposerPanel({
onTaskEnabledChange,
subagentEnabled,
onSubagentEnabledChange,
selectedTeam,
onSelectTeam,
onEnableSuggestedTeam,
webSearchEnabled,
onWebSearchEnabledChange,
pendingImages,
@@ -590,6 +603,9 @@ export function EmptyStateComposerPanel({
}: EmptyStateComposerPanelProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [dismissedSuggestionKey, setDismissedSuggestionKey] = useState<
string | null
>(null);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey) {
@@ -601,6 +617,31 @@ export function EmptyStateComposerPanel({
const getPlatformIcon = (value: string) => PLATFORM_ICON_MAP[value];
const getPlatformLabel = (value: string) =>
PLATFORM_LABEL_MAP[value] || value;
const suggestionKey = `${activeTheme}:${input.trim().toLowerCase()}`;
const teamSuggestion = useMemo(
() =>
getTeamSuggestion({
input,
activeTheme,
subagentEnabled,
}),
[activeTheme, input, subagentEnabled],
);
const shouldShowTeamSuggestion =
isGeneralTheme &&
Boolean(onSubagentEnabledChange) &&
teamSuggestion.shouldSuggest &&
dismissedSuggestionKey !== suggestionKey;
const handleEnableTeamSuggestion = () => {
onSubagentEnabledChange?.(true);
onEnableSuggestedTeam?.(teamSuggestion.suggestedPresetId);
setDismissedSuggestionKey(suggestionKey);
};
const handleContinueSingleAgent = () => {
setDismissedSuggestionKey(suggestionKey);
};
return (
<InputCard>
@@ -714,6 +755,17 @@ export function EmptyStateComposerPanel({
</>
) : null}
{shouldShowTeamSuggestion ? (
<TeamSuggestionBar
score={teamSuggestion.score}
reasons={teamSuggestion.reasons}
suggestedRoles={teamSuggestion.suggestedRoles}
suggestedPresetLabel={teamSuggestion.suggestedPresetLabel}
onEnableTeam={handleEnableTeamSuggestion}
onContinueSingleAgent={handleContinueSingleAgent}
/>
) : null}
<Toolbar>
<ToolLoginLeft>
{isGeneralTheme ? (
@@ -728,6 +780,14 @@ export function EmptyStateComposerPanel({
onRefreshSkills={onRefreshSkills}
/>
) : null}
{subagentEnabled ? (
<TeamSelector
activeTheme={activeTheme}
input={input}
selectedTeam={selectedTeam}
onSelectTeam={(team) => onSelectTeam?.(team)}
/>
) : null}
<ChatModelSelector
providerType={providerType}
@@ -738,6 +798,7 @@ export function EmptyStateComposerPanel({
compactTrigger
popoverSide="top"
onManageProviders={onManageProviders}
backgroundPreload={modelSelectorBackgroundPreload}
/>
{activeTheme === "social-media" ? (
@@ -1,6 +1,7 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentRuntimeToolInventory } from "@/lib/api/agentRuntime";
import { HarnessStatusPanel } from "./HarnessStatusPanel";
import type { HarnessSessionState } from "../utils/harnessState";
@@ -65,12 +66,14 @@ function renderPanel(
root.render(
<HarnessStatusPanel
harnessState={createHarnessState()}
subAgentRuntime={{
compatSubagentRuntime={{
isRunning: false,
progress: null,
events: [],
result: null,
error: null,
recentActivity: [],
hasSignals: false,
}}
environment={{
skillsCount: 2,
@@ -91,6 +94,176 @@ function renderPanel(
return rendered;
}
function createToolInventory(): AgentRuntimeToolInventory {
return {
request: {
caller: "assistant",
surface: {
creator: false,
browser_assist: true,
},
},
agent_initialized: true,
warnings: ["extension 搜索工具面存在延迟加载项"],
mcp_servers: ["lime-browser"],
default_allowed_tools: ["tool_search", "WebSearch"],
counts: {
catalog_total: 3,
catalog_current_total: 3,
catalog_compat_total: 0,
catalog_deprecated_total: 0,
default_allowed_total: 2,
registry_total: 2,
registry_visible_total: 1,
registry_catalog_unmapped_total: 0,
extension_surface_total: 1,
extension_mcp_bridge_total: 1,
extension_runtime_total: 0,
extension_tool_total: 1,
extension_tool_visible_total: 1,
mcp_server_total: 1,
mcp_tool_total: 1,
mcp_tool_visible_total: 1,
},
catalog_tools: [
{
name: "bash",
profiles: ["core"],
capabilities: ["execution"],
lifecycle: "current",
source: "aster_builtin",
permission_plane: "parameter_restricted",
workspace_default_allow: false,
execution_warning_policy: "shell_command_risk",
execution_warning_policy_source: "runtime",
execution_restriction_profile: "workspace_shell_command",
execution_restriction_profile_source: "runtime",
execution_sandbox_profile: "workspace_command",
execution_sandbox_profile_source: "runtime",
},
{
name: "write",
profiles: ["core"],
capabilities: ["filesystem"],
lifecycle: "current",
source: "aster_builtin",
permission_plane: "parameter_restricted",
workspace_default_allow: false,
execution_warning_policy: "none",
execution_warning_policy_source: "persisted",
execution_restriction_profile: "workspace_path_required",
execution_restriction_profile_source: "persisted",
execution_sandbox_profile: "none",
execution_sandbox_profile_source: "default",
},
{
name: "tool_search",
profiles: ["core"],
capabilities: ["discovery"],
lifecycle: "current",
source: "lime_injected",
permission_plane: "session_allowlist",
workspace_default_allow: true,
execution_warning_policy: "none",
execution_warning_policy_source: "default",
execution_restriction_profile: "none",
execution_restriction_profile_source: "default",
execution_sandbox_profile: "none",
execution_sandbox_profile_source: "default",
},
],
registry_tools: [
{
name: "bash",
description: "执行工作区命令",
catalog_entry_name: "bash",
catalog_source: "aster_builtin",
catalog_lifecycle: "current",
catalog_permission_plane: "parameter_restricted",
catalog_workspace_default_allow: false,
catalog_execution_warning_policy: "shell_command_risk",
catalog_execution_warning_policy_source: "runtime",
catalog_execution_restriction_profile: "workspace_shell_command",
catalog_execution_restriction_profile_source: "runtime",
catalog_execution_sandbox_profile: "workspace_command",
catalog_execution_sandbox_profile_source: "runtime",
deferred_loading: false,
always_visible: false,
allowed_callers: ["assistant"],
tags: ["shell"],
input_examples_count: 2,
caller_allowed: true,
visible_in_context: true,
},
{
name: "tool_search",
description: "搜索工具目录",
catalog_entry_name: "tool_search",
catalog_source: "lime_injected",
catalog_lifecycle: "current",
catalog_permission_plane: "session_allowlist",
catalog_workspace_default_allow: true,
catalog_execution_warning_policy: "none",
catalog_execution_warning_policy_source: "default",
catalog_execution_restriction_profile: "none",
catalog_execution_restriction_profile_source: "default",
catalog_execution_sandbox_profile: "none",
catalog_execution_sandbox_profile_source: "default",
deferred_loading: true,
always_visible: true,
allowed_callers: [],
tags: ["search"],
input_examples_count: 1,
caller_allowed: false,
visible_in_context: false,
},
],
extension_surfaces: [
{
extension_name: "lime-browser",
description: "浏览器桥接工具面",
source_kind: "mcp_bridge",
deferred_loading: true,
allowed_caller: "assistant",
available_tools: ["navigate", "click"],
always_expose_tools: ["navigate"],
loaded_tools: ["mcp__lime-browser__navigate"],
searchable_tools: [
"mcp__lime-browser__navigate",
"mcp__lime-browser__click",
],
},
],
extension_tools: [
{
name: "mcp__lime-browser__navigate",
description: "打开网页",
extension_name: "lime-browser",
source_kind: "mcp_bridge",
deferred_loading: false,
allowed_caller: "assistant",
status: "loaded",
caller_allowed: true,
visible_in_context: true,
},
],
mcp_tools: [
{
server_name: "lime-browser",
name: "mcp__lime-browser__navigate",
description: "导航到指定页面",
deferred_loading: false,
always_visible: true,
allowed_callers: ["assistant"],
tags: ["browser", "navigation"],
input_examples_count: 1,
caller_allowed: true,
visible_in_context: true,
},
],
};
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
@@ -154,8 +327,26 @@ describe("HarnessStatusPanel", () => {
expect(document.body.textContent).not.toContain("收起详情");
expect(panel?.className).toContain("flex");
expect(panel?.className).toContain("h-full");
expect(panel?.children.length).toBe(2);
expect(scrollArea?.className).toContain("flex-1");
expect(scrollArea?.className).toContain("min-h-0");
expect(panel?.querySelector(".sticky.top-0")).toBeNull();
});
it("弹窗模式应让前置概览跟随滚动区,而不是固定在顶部", () => {
const { container } = renderPanel({
layout: "dialog",
leadContent: <div>通用 Agent 运行概览</div>,
});
const panel = container.querySelector(
'[data-testid="harness-status-panel"]',
) as HTMLDivElement | null;
const scrollArea = container.querySelector(
'[data-testid="harness-status-panel"] > .relative.overflow-auto',
) as HTMLDivElement | null;
expect(panel?.children.length).toBe(2);
expect(scrollArea?.textContent).toContain("通用 Agent 运行概览");
});
it("应支持自定义标题说明与前置运行概览内容", () => {
@@ -209,6 +400,70 @@ describe("HarnessStatusPanel", () => {
expect(document.body.textContent).toContain("等待首个模型事件");
});
it("存在真实 child session 时应优先展示 Team 会话摘要,并将旧 scheduler 降级为兼容轨迹", () => {
renderPanel({
childSubagentSessions: [
{
id: "child-1",
name: "研究代理",
created_at: 1_710_000_000,
updated_at: 1_710_000_200,
session_type: "sub_agent",
runtime_status: "running",
latest_turn_status: "running",
task_summary: "并行整理竞品与证据链",
role_hint: "explorer",
},
{
id: "child-2",
name: "实现代理",
created_at: 1_710_000_010,
updated_at: 1_710_000_220,
session_type: "sub_agent",
runtime_status: "queued",
latest_turn_status: "queued",
task_summary: "起草第一版落地方案",
role_hint: "executor",
},
],
compatSubagentRuntime: {
isRunning: true,
progress: {
total: 2,
completed: 1,
failed: 0,
running: 1,
pending: 0,
skipped: 0,
cancelled: false,
currentTasks: ["legacy-task-1"],
percentage: 50,
},
events: [{ type: "started", totalTasks: 2 }],
result: null,
error: null,
recentActivity: [
{
id: "compat:1:started",
summary: "开始调度 2 个子任务",
},
],
hasSignals: true,
},
});
expect(document.body.textContent).toContain("Team 运行中");
expect(document.body.textContent).toContain("Team 会话");
expect(document.body.textContent).toContain("当前 Team 会话");
expect(document.body.textContent).toContain("真实 Team 会话");
expect(document.body.textContent).toContain("兼容回退");
expect(document.body.textContent).toContain("Fallback");
expect(document.body.textContent).not.toContain("兼容调度进度");
expect(document.body.textContent).not.toContain("兼容调度轨迹");
expect(document.body.textContent).toContain("研究代理");
expect(document.body.textContent).toContain("实现代理");
});
it("仅有计划摘要兜底时也应在工作台显示已就绪计划状态", () => {
renderPanel({
harnessState: createHarnessState({
@@ -735,21 +990,17 @@ describe("HarnessStatusPanel", () => {
const copyPathButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("复制路径"));
const revealButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("定位文件"),
);
const revealButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("定位文件"));
const openPathButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("系统打开"));
await act(async () => {
copyPathButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
copyPathButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
revealButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
openPathButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
openPathButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
@@ -850,4 +1101,65 @@ describe("HarnessStatusPanel", () => {
expect(onOpenPath).toHaveBeenCalledWith("/tmp/workspace/context/brief.md");
});
it("存在工具库存时应展示工具与权限区块及来源统计", () => {
renderPanel({
toolInventory: createToolInventory(),
});
expect(document.body.textContent).toContain("工具与权限");
expect(document.body.textContent).toContain("工具库存");
expect(document.body.textContent).toContain("运行时覆盖");
expect(document.body.textContent).toContain("持久化覆盖");
expect(document.body.textContent).toContain("默认策略");
expect(document.body.textContent).toContain("Catalog 工具");
});
it("工具库存应支持按来源筛选 catalog 条目", () => {
renderPanel({
toolInventory: createToolInventory(),
});
const runtimeFilterButton = document.body.querySelector(
'button[aria-label="工具库存筛选:运行时覆盖"]',
) as HTMLButtonElement | null;
act(() => {
runtimeFilterButton?.click();
});
const inventorySection = document.body.querySelector(
'[data-harness-section="inventory"]',
) as HTMLElement | null;
expect(inventorySection?.textContent).toContain("Catalog 工具");
expect(inventorySection?.textContent).toContain("1 / 3");
expect(inventorySection?.textContent).toContain("bash");
expect(inventorySection?.textContent).not.toContain("write");
});
it("工具库存加载失败时应展示错误并支持手动刷新", () => {
const onRefreshToolInventory = vi.fn();
renderPanel({
toolInventoryLoading: true,
toolInventoryError: "读取失败",
onRefreshToolInventory,
});
expect(document.body.textContent).toContain(
"正在同步当前工具库存与权限策略",
);
expect(document.body.textContent).toContain("读取失败");
const refreshButton = document.body.querySelector(
'button[aria-label="刷新工具库存"]',
) as HTMLButtonElement | null;
act(() => {
refreshButton?.click();
});
expect(onRefreshToolInventory).toHaveBeenCalledTimes(1);
});
});
File diff suppressed because it is too large Load Diff
@@ -208,6 +208,17 @@ function typeAt(textarea: HTMLTextAreaElement) {
});
}
async function typeAtAndWait(textarea: HTMLTextAreaElement) {
await act(async () => {
await import("./CharacterMentionPanel");
});
typeAt(textarea);
await act(async () => {
await Promise.resolve();
});
}
function createSkill(name: string, key: string, installed: boolean): Skill {
return {
key,
@@ -241,19 +252,19 @@ function createCharacter(name: string): Character {
}
describe("CharacterMention", () => {
it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", () => {
it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", async () => {
const container = renderHarness({
characters: [createCharacter("测试角色")],
syncValue: false,
});
const textarea = getTextarea(container);
typeAt(textarea);
await typeAtAndWait(textarea);
expect(document.body.textContent).toContain("测试角色");
});
it("无角色和技能时仍显示空态,并可跳转技能设置", () => {
it("无角色和技能时仍显示空态,并可跳转技能设置", async () => {
const onNavigateToSettings = vi.fn<() => void>();
const container = renderHarness({
characters: [],
@@ -262,7 +273,7 @@ describe("CharacterMention", () => {
});
const textarea = getTextarea(container);
typeAt(textarea);
await typeAtAndWait(textarea);
expect(document.body.textContent).toContain("暂无可用角色或技能");
const settingsButton = Array.from(document.body.querySelectorAll("button")).find(
@@ -276,7 +287,7 @@ describe("CharacterMention", () => {
expect(onNavigateToSettings).toHaveBeenCalledTimes(1);
});
it("未提供 onSelectSkill 时,选择已安装技能应回填到输入框", () => {
it("未提供 onSelectSkill 时,选择已安装技能应回填到输入框", async () => {
const onChangeSpy = vi.fn<(value: string) => void>();
const container = renderHarness({
skills: [createSkill("技能A", "skill-a", true)],
@@ -284,7 +295,7 @@ describe("CharacterMention", () => {
});
const textarea = getTextarea(container);
typeAt(textarea);
await typeAtAndWait(textarea);
const skillButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("技能A"),
@@ -4,15 +4,15 @@
* 在输入框中检测 @ 符号,显示角色和技能列表供选择
*/
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { User, Zap } from "lucide-react";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import React, {
Suspense,
lazy,
useState,
useEffect,
useMemo,
useRef,
useCallback,
} from "react";
import {
Popover,
PopoverContent,
@@ -21,6 +21,14 @@ import {
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import { toast } from "sonner";
import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload";
const preloadCharacterMentionPanel = () => import("./CharacterMentionPanel");
const CharacterMentionPanel = lazy(async () => {
const module = await preloadCharacterMentionPanel();
return { default: module.CharacterMentionPanel };
});
interface CharacterMentionProps {
/** 角色列表 */
@@ -57,6 +65,12 @@ export function CharacterMention({
const popoverRef = useRef<HTMLDivElement>(null);
const commandRef = useRef<HTMLDivElement>(null);
useEffect(() => {
return scheduleIdleModulePreload(() => {
void preloadCharacterMentionPanel();
});
}, []);
// 过滤角色列表
const filteredCharacters = useMemo(() => {
if (!mentionQuery) return characters;
@@ -264,11 +278,6 @@ export function CharacterMention({
if (!showMentions) return null;
const hasFilteredResults =
filteredCharacters.length > 0 ||
installedSkills.length > 0 ||
availableSkills.length > 0;
return (
<Popover open={showMentions} onOpenChange={setShowMentions}>
<PopoverTrigger asChild>
@@ -291,96 +300,35 @@ export function CharacterMention({
sideOffset={8}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command ref={commandRef} className="bg-background">
<CommandInput
placeholder="搜索角色或技能..."
value={mentionQuery}
onValueChange={setMentionQuery}
/>
<CommandList>
{!hasFilteredResults && (
{showMentions ? (
<Suspense
fallback={
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
<div>暂无可用角色或技能</div>
{onNavigateToSettings && (
<button
type="button"
className="mt-2 text-primary hover:underline"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
加载中...
</div>
}
>
<CharacterMentionPanel
mentionQuery={mentionQuery}
filteredCharacters={filteredCharacters}
installedSkills={installedSkills}
availableSkills={availableSkills}
commandRef={commandRef}
onQueryChange={setMentionQuery}
onSelectCharacter={handleSelectCharacter}
onSelectInstalledSkill={handleSelectInstalledSkill}
onSelectAvailableSkill={handleSelectAvailableSkill}
onNavigateToSettings={
onNavigateToSettings
? () => {
setShowMentions(false);
onNavigateToSettings();
}}
>
去技能设置
</button>
)}
</div>
)}
{filteredCharacters.length > 0 && (
<CommandGroup heading="角色">
{filteredCharacters.map((character) => (
<CommandItem
key={character.id}
onSelect={() => handleSelectCharacter(character)}
className="cursor-pointer"
>
<User className="mr-2 h-4 w-4" />
<div className="flex-1">
<div className="font-medium">{character.name}</div>
{character.description && (
<div className="text-xs text-muted-foreground line-clamp-1">
{character.description}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
{installedSkills.length > 0 && (
<CommandGroup heading="已安装技能">
{installedSkills.map((skill) => (
<CommandItem
key={skill.directory}
onSelect={() => handleSelectInstalledSkill(skill)}
className="cursor-pointer"
>
<Zap className="mr-2 h-4 w-4 text-primary" />
<div className="flex-1">
<div className="font-medium">{skill.name}</div>
{skill.description && (
<div className="text-xs text-muted-foreground line-clamp-1">
{skill.description}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
{availableSkills.length > 0 && (
<CommandGroup heading="未安装技能">
{availableSkills.map((skill) => (
<CommandItem
key={skill.directory}
onSelect={() => handleSelectAvailableSkill(skill)}
className="cursor-pointer opacity-60"
>
<Zap className="mr-2 h-4 w-4" />
<div className="flex-1">
<div className="font-medium">{skill.name}</div>
{skill.description && (
<div className="text-xs text-muted-foreground line-clamp-1">
{skill.description}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
}
: undefined
}
/>
</Suspense>
) : null}
</PopoverContent>
</Popover>
);
@@ -0,0 +1,132 @@
import React from "react";
import { User, Zap } from "lucide-react";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
interface CharacterMentionPanelProps {
mentionQuery: string;
filteredCharacters: Character[];
installedSkills: Skill[];
availableSkills: Skill[];
commandRef: React.RefObject<HTMLDivElement>;
onQueryChange: (query: string) => void;
onSelectCharacter: (character: Character) => void;
onSelectInstalledSkill: (skill: Skill) => void;
onSelectAvailableSkill: (skill: Skill) => void;
onNavigateToSettings?: () => void;
}
export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
mentionQuery,
filteredCharacters,
installedSkills,
availableSkills,
commandRef,
onQueryChange,
onSelectCharacter,
onSelectInstalledSkill,
onSelectAvailableSkill,
onNavigateToSettings,
}) => {
const hasFilteredResults =
filteredCharacters.length > 0 ||
installedSkills.length > 0 ||
availableSkills.length > 0;
return (
<Command ref={commandRef} className="bg-background">
<CommandInput
placeholder="搜索角色或技能..."
value={mentionQuery}
onValueChange={onQueryChange}
/>
<CommandList>
{!hasFilteredResults ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
<div>暂无可用角色或技能</div>
{onNavigateToSettings ? (
<button
type="button"
className="mt-2 text-primary hover:underline"
onMouseDown={(e) => e.preventDefault()}
onClick={onNavigateToSettings}
>
去技能设置
</button>
) : null}
</div>
) : null}
{filteredCharacters.length > 0 ? (
<CommandGroup heading="角色">
{filteredCharacters.map((character) => (
<CommandItem
key={character.id}
onSelect={() => onSelectCharacter(character)}
className="cursor-pointer"
>
<User className="mr-2 h-4 w-4" />
<div className="flex-1">
<div className="font-medium">{character.name}</div>
{character.description ? (
<div className="text-xs text-muted-foreground line-clamp-1">
{character.description}
</div>
) : null}
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
{installedSkills.length > 0 ? (
<CommandGroup heading="已安装技能">
{installedSkills.map((skill) => (
<CommandItem
key={skill.directory}
onSelect={() => onSelectInstalledSkill(skill)}
className="cursor-pointer"
>
<Zap className="mr-2 h-4 w-4 text-primary" />
<div className="flex-1">
<div className="font-medium">{skill.name}</div>
{skill.description ? (
<div className="text-xs text-muted-foreground line-clamp-1">
{skill.description}
</div>
) : null}
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
{availableSkills.length > 0 ? (
<CommandGroup heading="未安装技能">
{availableSkills.map((skill) => (
<CommandItem
key={skill.directory}
onSelect={() => onSelectAvailableSkill(skill)}
className="cursor-pointer opacity-60"
>
<Zap className="mr-2 h-4 w-4" />
<div className="flex-1">
<div className="font-medium">{skill.name}</div>
{skill.description ? (
<div className="text-xs text-muted-foreground line-clamp-1">
{skill.description}
</div>
) : null}
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
</CommandList>
</Command>
);
};
@@ -7,11 +7,13 @@ import type { MessageImage } from "../../../types";
import { CharacterMention } from "./CharacterMention";
import { InputbarCore } from "./InputbarCore";
import { SkillSelector } from "./SkillSelector";
import { TeamSelector } from "./TeamSelector";
import { ThemeWorkbenchStatusPanel } from "./ThemeWorkbenchStatusPanel";
import { InputbarModelExtra } from "./InputbarModelExtra";
import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice";
import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect";
import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt";
import type { TeamDefinition } from "../../../utils/teamDefinitions";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchQuickAction,
@@ -36,6 +38,8 @@ interface InputbarComposerSectionProps {
onNavigateToSettings?: () => void;
onImportSkill?: () => void | Promise<void>;
onRefreshSkills?: () => void | Promise<void>;
selectedTeam?: TeamDefinition | null;
onSelectTeam?: (team: TeamDefinition | null) => void;
onSend: () => void;
onToolClick: (tool: string) => void;
activeTools: Record<string, boolean>;
@@ -53,6 +57,7 @@ interface InputbarComposerSectionProps {
) => void;
topExtra?: React.ReactNode;
queuedTurns: QueuedTurnSnapshot[];
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
}
@@ -76,6 +81,8 @@ export const InputbarComposerSection: React.FC<
onNavigateToSettings,
onImportSkill,
onRefreshSkills,
selectedTeam,
onSelectTeam,
onSend,
onToolClick,
activeTools,
@@ -91,6 +98,7 @@ export const InputbarComposerSection: React.FC<
setExecutionStrategy,
topExtra,
queuedTurns,
onPromoteQueuedTurn,
onRemoveQueuedTurn,
}) => {
const showSkillSelector =
@@ -174,6 +182,7 @@ export const InputbarComposerSection: React.FC<
}
activeTheme={activeTheme}
queuedTurns={queuedTurns}
onPromoteQueuedTurn={onPromoteQueuedTurn}
onRemoveQueuedTurn={onRemoveQueuedTurn}
leftExtra={
<>
@@ -189,6 +198,14 @@ export const InputbarComposerSection: React.FC<
onRefreshSkills={onRefreshSkills}
/>
) : null}
{activeTools["subagent_mode"] ? (
<TeamSelector
activeTheme={activeTheme}
input={input}
selectedTeam={selectedTeam}
onSelectTeam={(team) => onSelectTeam?.(team)}
/>
) : null}
<InputbarModelExtra
isFullscreen={isFullscreen}
isThemeWorkbenchVariant={isThemeWorkbenchVariant}
@@ -77,6 +77,7 @@ interface InputbarCoreProps {
visualVariant?: "default" | "floating";
activeTheme?: string;
queuedTurns?: QueuedTurnSnapshot[];
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
}
@@ -107,6 +108,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
visualVariant = "default",
activeTheme,
queuedTurns = [],
onPromoteQueuedTurn,
onRemoveQueuedTurn,
}) => {
const [isComposerExpanded, setIsComposerExpanded] = useState(false);
@@ -275,6 +277,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
{topExtra}
<QueuedTurnsPanel
queuedTurns={queuedTurns}
onPromoteQueuedTurn={onPromoteQueuedTurn}
onRemoveQueuedTurn={onRemoveQueuedTurn}
/>
@@ -0,0 +1,105 @@
import React, { createRef } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InputbarOverlayShell } from "./InputbarOverlayShell";
vi.mock("../../TaskFiles", () => ({
TaskFileList: () => <div data-testid="task-file-list" />,
}));
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function renderShell(
props?: Partial<React.ComponentProps<typeof InputbarOverlayShell>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<InputbarOverlayShell
showHintPopup={false}
hintRoutes={[]}
hintIndex={0}
onHintSelect={vi.fn()}
taskFiles={[
{
id: "file-1",
name: "notes.md",
type: "document",
version: 1,
createdAt: Date.now(),
updatedAt: Date.now(),
},
]}
isSubmissionNoticeVisible={false}
fileInputRef={createRef<HTMLInputElement>()}
onFileSelect={vi.fn()}
{...props}
/>,
);
});
mountedRoots.push({ root, container });
return container;
}
describe("InputbarOverlayShell", () => {
it("应把任务文件与额外浮层控件放进同一条透明 overlay row", () => {
const container = renderShell({
overlayAccessory: (
<button type="button" data-testid="team-inline-toggle">
查看 Team · 2
</button>
),
});
const row = container.querySelector<HTMLElement>(
'[data-testid="inputbar-secondary-controls"]',
);
expect(row).toBeTruthy();
expect(getComputedStyle(row as HTMLElement).position).toBe("absolute");
expect(getComputedStyle(row as HTMLElement).pointerEvents).toBe("none");
expect(getComputedStyle(row as HTMLElement).zIndex).toBe("80");
expect(
row?.querySelector('[data-testid="task-files-panel-area"]'),
).toBeTruthy();
expect(
row?.querySelector('[data-testid="team-inline-toggle"]'),
).toBeTruthy();
});
it("没有任务文件和额外控件时不应渲染 overlay row", () => {
const container = renderShell({
taskFiles: [],
overlayAccessory: null,
});
expect(
container.querySelector('[data-testid="inputbar-secondary-controls"]'),
).toBeNull();
});
});
@@ -1,4 +1,5 @@
import React, { type ChangeEvent, type RefObject } from "react";
import styled from "styled-components";
import type { TaskFile } from "../../TaskFiles";
import type { A2UIResponse, A2UIFormData } from "@/components/content-creator/a2ui/types";
import {
@@ -20,6 +21,7 @@ interface InputbarOverlayShellProps {
taskFilesExpanded?: boolean;
onToggleTaskFiles?: () => void;
onTaskFileClick?: (file: TaskFile) => void;
overlayAccessory?: React.ReactNode;
submissionNotice?: A2UISubmissionNoticeData | null;
isSubmissionNoticeVisible: boolean;
pendingA2UIForm?: A2UIResponse | null;
@@ -28,6 +30,27 @@ interface InputbarOverlayShellProps {
onFileSelect: (event: ChangeEvent<HTMLInputElement>) => void;
}
const SecondaryControlsRow = styled.div.attrs({
"data-testid": "inputbar-secondary-controls",
})`
position: absolute;
right: 8px;
bottom: calc(100% + 8px);
left: 8px;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: flex-end;
gap: 8px;
pointer-events: none;
z-index: 80;
> * {
pointer-events: auto;
max-width: 100%;
}
`;
export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
showHintPopup,
hintRoutes,
@@ -38,6 +61,7 @@ export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
taskFilesExpanded = false,
onToggleTaskFiles,
onTaskFileClick,
overlayAccessory,
submissionNotice,
isSubmissionNoticeVisible,
pendingA2UIForm,
@@ -53,13 +77,18 @@ export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
onSelect={onHintSelect}
/>
) : null}
<TaskFilesPanel
files={taskFiles}
selectedFileId={selectedFileId}
expanded={taskFilesExpanded}
onToggle={onToggleTaskFiles}
onFileClick={onTaskFileClick}
/>
{taskFiles.length > 0 || overlayAccessory ? (
<SecondaryControlsRow>
<TaskFilesPanel
files={taskFiles}
selectedFileId={selectedFileId}
expanded={taskFilesExpanded}
onToggle={onToggleTaskFiles}
onFileClick={onTaskFileClick}
/>
{overlayAccessory}
</SecondaryControlsRow>
) : null}
{submissionNotice ? (
<A2UISubmissionNotice
notice={submissionNotice}
@@ -0,0 +1,94 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueuedTurnsPanel } from "./QueuedTurnsPanel";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function renderQueuedTurnsPanel(
props?: Partial<React.ComponentProps<typeof QueuedTurnsPanel>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<QueuedTurnsPanel
queuedTurns={[
{
queued_turn_id: "queued-1",
message_preview: "等待整理周报",
message_text: "请先整理周报结构,再补齐摘要。",
created_at: 1_700_000_000_000,
image_count: 0,
position: 1,
},
]}
{...props}
/>,
);
});
mountedRoots.push({ root, container });
return container;
}
describe("QueuedTurnsPanel", () => {
it("应展示立即执行按钮,并触发 promote 回调", async () => {
const onPromoteQueuedTurn = vi.fn().mockResolvedValue(true);
const container = renderQueuedTurnsPanel({ onPromoteQueuedTurn });
const promoteButton = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.includes("立即执行"));
expect(promoteButton).toBeTruthy();
await act(async () => {
promoteButton?.click();
await Promise.resolve();
});
expect(onPromoteQueuedTurn).toHaveBeenCalledWith("queued-1");
});
it("移除按钮仍应触发 remove 回调", async () => {
const onRemoveQueuedTurn = vi.fn().mockResolvedValue(true);
const container = renderQueuedTurnsPanel({ onRemoveQueuedTurn });
const removeButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="移除排队消息"]',
);
expect(removeButton).toBeTruthy();
await act(async () => {
removeButton?.click();
await Promise.resolve();
});
expect(onRemoveQueuedTurn).toHaveBeenCalledWith("queued-1");
});
});
@@ -1,17 +1,23 @@
import React, { useEffect, useState } from "react";
import { X } from "lucide-react";
import { Play, X } from "lucide-react";
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
interface QueuedTurnsPanelProps {
queuedTurns: QueuedTurnSnapshot[];
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
}
export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
queuedTurns,
onPromoteQueuedTurn,
onRemoveQueuedTurn,
}) => {
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<{
queuedTurnId: string;
type: "promote" | "remove";
} | null>(null);
useEffect(() => {
if (
@@ -26,6 +32,28 @@ export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
return null;
}
const runQueuedAction = async (
queuedTurnId: string,
type: "promote" | "remove",
) => {
const handler =
type === "promote" ? onPromoteQueuedTurn : onRemoveQueuedTurn;
if (!handler) {
return;
}
setPendingAction({ queuedTurnId, type });
try {
await handler(queuedTurnId);
} finally {
setPendingAction((current) =>
current?.queuedTurnId === queuedTurnId && current.type === type
? null
: current,
);
}
};
return (
<div className="px-3 pb-2">
<div className="mb-2 flex items-center justify-between text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
@@ -42,6 +70,13 @@ export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
: messageText;
const isExpanded = expandedTurnId === item.queued_turn_id;
const detailId = `queued-turn-detail-${item.queued_turn_id}`;
const isPromoting =
pendingAction?.queuedTurnId === item.queued_turn_id &&
pendingAction.type === "promote";
const isRemoving =
pendingAction?.queuedTurnId === item.queued_turn_id &&
pendingAction.type === "remove";
const isBusy = isPromoting || isRemoving;
return (
<div
@@ -86,14 +121,31 @@ export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
) : null}
</div>
</button>
<button
type="button"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border/80 text-muted-foreground transition hover:border-destructive/40 hover:bg-destructive/5 hover:text-destructive"
onClick={() => void onRemoveQueuedTurn?.(item.queued_turn_id)}
aria-label="移除排队消息"
>
<X size={14} />
</button>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-full border border-sky-200/80 bg-sky-50 px-3 text-xs font-medium text-sky-700 transition hover:border-sky-300 hover:bg-sky-100 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() =>
void runQueuedAction(item.queued_turn_id, "promote")
}
disabled={isBusy}
aria-label="插队立即执行"
>
<Play size={13} />
<span>{isPromoting ? "切换中" : "立即执行"}</span>
</button>
<button
type="button"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border/80 text-muted-foreground transition hover:border-destructive/40 hover:bg-destructive/5 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-60"
onClick={() =>
void runQueuedAction(item.queued_turn_id, "remove")
}
disabled={isBusy}
aria-label="移除排队消息"
>
<X size={14} />
</button>
</div>
</div>
);
})}
@@ -6,6 +6,10 @@ import { SkillSelector } from "./SkillSelector";
import type { Skill } from "@/lib/api/skills";
const mockToastInfo = vi.fn();
const mockPopoverState = vi.hoisted(() => ({
open: false,
setOpen: (_next: boolean) => {},
}));
vi.mock("sonner", () => ({
toast: {
@@ -14,15 +18,35 @@ vi.mock("sonner", () => ({
}));
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
PopoverTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
PopoverContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="skill-selector-popover">{children}</div>
),
Popover: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) => {
mockPopoverState.open = Boolean(open);
mockPopoverState.setOpen = onOpenChange ?? (() => {});
return <div>{children}</div>;
},
PopoverTrigger: ({ children }: { children: React.ReactNode }) => {
const child = React.Children.only(children) as React.ReactElement<{
onClick?: React.MouseEventHandler<HTMLElement>;
}>;
return React.cloneElement(child, {
onClick: (event: React.MouseEvent<HTMLElement>) => {
child.props.onClick?.(event);
mockPopoverState.setOpen(!mockPopoverState.open);
},
});
},
PopoverContent: ({ children }: { children: React.ReactNode }) =>
mockPopoverState.open ? (
<div data-testid="skill-selector-popover">{children}</div>
) : null,
}));
vi.mock("@/components/ui/command", () => {
@@ -108,6 +132,8 @@ afterEach(() => {
});
mounted.container.remove();
}
mockPopoverState.open = false;
mockPopoverState.setOpen = () => {};
vi.clearAllMocks();
});
@@ -135,8 +161,6 @@ function renderSkillSelector(
isLoading: false,
onSelectSkill: vi.fn(),
onClearSkill: vi.fn(),
onImportSkill: vi.fn(),
onRefreshSkills: vi.fn(),
};
act(() => {
@@ -147,8 +171,28 @@ function renderSkillSelector(
return container;
}
async function preloadSkillSelectorPanel() {
await act(async () => {
await import("./SkillSelectorPanel");
});
}
async function openSkillSelector(container: HTMLElement) {
await preloadSkillSelectorPanel();
const triggerButton = container.querySelector(
'[data-testid="skill-selector-trigger"]',
) as HTMLButtonElement | null;
expect(triggerButton).toBeTruthy();
await act(async () => {
triggerButton?.click();
});
}
describe("SkillSelector", () => {
it("选择已安装技能时应回调 onSelectSkill", () => {
it("选择已安装技能时应回调 onSelectSkill", async () => {
const onSelectSkill = vi.fn<(skill: Skill) => void>();
const installedSkill = createSkill("写作助手", "writer", true);
const container = renderSkillSelector({
@@ -156,6 +200,8 @@ describe("SkillSelector", () => {
onSelectSkill,
});
await openSkillSelector(container);
const skillButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("写作助手"),
);
@@ -168,7 +214,7 @@ describe("SkillSelector", () => {
expect(onSelectSkill).toHaveBeenCalledWith(installedSkill);
});
it("存在已选技能时应支持清空", () => {
it("存在已选技能时应支持清空", async () => {
const onClearSkill = vi.fn<() => void>();
const activeSkill = createSkill("研究助手", "research", true);
const container = renderSkillSelector({
@@ -177,6 +223,8 @@ describe("SkillSelector", () => {
onClearSkill,
});
await openSkillSelector(container);
expect(container.textContent).toContain("不使用技能");
const clearButton = Array.from(container.querySelectorAll("button")).find(
@@ -191,13 +239,15 @@ describe("SkillSelector", () => {
expect(onClearSkill).toHaveBeenCalledTimes(1);
});
it("点击未安装技能时应给出安装提示", () => {
it("点击未安装技能时应给出安装提示", async () => {
const onNavigateToSettings = vi.fn<() => void>();
const container = renderSkillSelector({
skills: [createSkill("表格导入", "xlsx", false)],
onNavigateToSettings,
});
await openSkillSelector(container);
const unavailableSkillButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("表格导入"));
@@ -223,6 +273,8 @@ describe("SkillSelector", () => {
onImportSkill,
});
await openSkillSelector(container);
const importButton = container.querySelector(
'[data-testid="skill-selector-import"]',
) as HTMLButtonElement | null;
@@ -238,10 +290,14 @@ describe("SkillSelector", () => {
it("点击底部刷新技能入口时应回调 onRefreshSkills", async () => {
const onRefreshSkills = vi.fn<() => void>();
const installedSkill = createSkill("写作助手", "writer", true);
const container = renderSkillSelector({
skills: [installedSkill],
onRefreshSkills,
});
await openSkillSelector(container);
const refreshButton = container.querySelector(
'[data-testid="skill-selector-refresh"]',
) as HTMLButtonElement | null;
@@ -255,12 +311,15 @@ describe("SkillSelector", () => {
expect(onRefreshSkills).toHaveBeenCalledTimes(1);
});
it("加载中且无技能时应显示加载状态", () => {
it("加载中且无技能时应显示加载状态", async () => {
const container = renderSkillSelector({
isLoading: true,
skills: [],
onRefreshSkills: vi.fn(),
});
await openSkillSelector(container);
expect(container.textContent).toContain("技能加载中");
});
});
@@ -1,20 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Check,
FolderOpen,
Loader2,
RefreshCw,
Settings2,
X,
Zap,
} from "lucide-react";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import React, {
Suspense,
lazy,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Zap } from "lucide-react";
import {
Popover,
PopoverContent,
@@ -23,6 +15,14 @@ import {
import type { Skill } from "@/lib/api/skills";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload";
const preloadSkillSelectorPanel = () => import("./SkillSelectorPanel");
const SkillSelectorPanel = lazy(async () => {
const module = await preloadSkillSelectorPanel();
return { default: module.SkillSelectorPanel };
});
interface SkillSelectorProps {
skills?: Skill[];
@@ -68,6 +68,12 @@ export const SkillSelector: React.FC<SkillSelectorProps> = ({
const [refreshing, setRefreshing] = useState(false);
const [autoRefreshTriggered, setAutoRefreshTriggered] = useState(false);
useEffect(() => {
return scheduleIdleModulePreload(() => {
void preloadSkillSelectorPanel();
});
}, []);
useEffect(() => {
if (!open) {
setQuery("");
@@ -200,179 +206,41 @@ export const SkillSelector: React.FC<SkillSelectorProps> = ({
align="start"
sideOffset={8}
>
<Command shouldFilter={false} className="bg-white">
<div className="border-b border-slate-200/80 bg-[linear-gradient(180deg,rgb(255,255,255)_0%,rgb(248,250,252)_100%)] px-4 py-3">
<div className="text-[11px] font-semibold tracking-[0.08em] text-slate-500">
技能能力
</div>
<div className="mt-1 text-sm text-slate-700">
{activeSkill ? `当前已启用 ${activeSkill.name}` : "为当前任务挂载额外能力"}
</div>
</div>
<div className="relative">
<CommandInput
className={cn(
"border-b-0 px-4 text-sm placeholder:text-slate-400",
canRefresh ? "pr-12" : undefined,
)}
placeholder="搜索技能或命令"
value={query}
onValueChange={setQuery}
/>
{canRefresh ? (
<button
type="button"
data-testid="skill-selector-refresh"
onClick={() => void handleRefresh()}
disabled={refreshBusy}
className="absolute right-3 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={refreshBusy ? "技能加载中" : "刷新技能"}
title={refreshBusy ? "技能加载中" : "刷新技能"}
>
{refreshBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
) : null}
</div>
<CommandList>
{activeSkill && onClearSkill ? (
<CommandGroup heading="当前已选">
<CommandItem
value="__clear_skill__"
onSelect={handleClearSkill}
className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50"
>
<X className="mr-2 h-4 w-4 text-slate-400" />
<div className="flex-1">
<div className="font-medium text-slate-900">不使用技能</div>
<div className="text-xs text-slate-500">
当前已选:{activeSkill.name}
</div>
</div>
</CommandItem>
</CommandGroup>
) : null}
{installedSkills.length > 0 ? (
<CommandGroup heading="可用技能">
{installedSkills.map((skill) => {
const selected = activeSkill?.key === skill.key;
return (
<CommandItem
key={skill.directory}
value={`${skill.name} ${skill.key} ${skill.description || ""}`}
onSelect={() => handleSelectInstalledSkill(skill)}
className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50"
>
<Zap
className={cn(
"mr-2 h-4 w-4",
selected ? "text-emerald-600" : "text-slate-400",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-slate-900">
{skill.name}
</span>
<span className="text-[11px] text-slate-400">
/{skill.key}
</span>
</div>
{skill.description ? (
<div className="line-clamp-1 text-xs text-slate-500">
{skill.description}
</div>
) : null}
</div>
{selected ? (
<Check className="ml-2 h-4 w-4 text-emerald-600" />
) : null}
</CommandItem>
);
})}
</CommandGroup>
) : null}
{availableSkills.length > 0 ? (
<CommandGroup heading="未安装技能">
{availableSkills.map((skill) => (
<CommandItem
key={skill.directory}
value={`${skill.name} ${skill.key} ${skill.description || ""}`}
onSelect={() => handleSelectAvailableSkill(skill)}
className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 opacity-80 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50"
>
<Settings2 className="mr-2 h-4 w-4 text-slate-400" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-slate-900">
{skill.name}
</span>
<span className="text-[11px] text-slate-400">
/{skill.key}
</span>
</div>
{skill.description ? (
<div className="line-clamp-1 text-xs text-slate-500">
{skill.description}
</div>
) : null}
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
{!hasResults ? (
{open ? (
<Suspense
fallback={
<div className="px-4 py-7 text-center text-sm text-slate-500">
{refreshBusy ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-slate-400" />
<div>技能加载中...</div>
</div>
) : (
<>
<div>暂无可用技能</div>
{onNavigateToSettings ? (
<button
type="button"
className="mt-2 text-slate-900 hover:underline"
onClick={() => {
setOpen(false);
onNavigateToSettings();
}}
>
去技能设置
</button>
) : null}
</>
)}
加载中...
</div>
) : null}
</CommandList>
{canImport ? (
<div className="border-t border-slate-200/80 p-1.5">
<button
type="button"
data-testid="skill-selector-import"
onClick={() => void handleImport()}
disabled={importing}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-sm text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{importing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FolderOpen className="h-4 w-4" />
)}
<span>{importing ? "导入中..." : "导入本地技能"}</span>
</button>
</div>
) : null}
</Command>
}
>
<SkillSelectorPanel
activeSkill={activeSkill}
installedSkills={installedSkills}
availableSkills={availableSkills}
query={query}
canRefresh={canRefresh}
refreshBusy={refreshBusy}
canImport={canImport}
importing={importing}
hasResults={hasResults}
onQueryChange={setQuery}
onRefresh={() => void handleRefresh()}
onSelectInstalledSkill={handleSelectInstalledSkill}
onSelectAvailableSkill={handleSelectAvailableSkill}
onClearSkill={handleClearSkill}
onNavigateToSettings={
onNavigateToSettings
? () => {
setOpen(false);
onNavigateToSettings();
}
: undefined
}
onImport={() => void handleImport()}
/>
</Suspense>
) : null}
</PopoverContent>
</Popover>
);

Some files were not shown because too many files have changed in this diff Show More