chore: release v0.97.0

This commit is contained in:
coso
2026-03-27 14:35:56 +08:00
parent e380c98649
commit 26e7c60fdd
234 changed files with 36543 additions and 3657 deletions
+62
View File
@@ -0,0 +1,62 @@
name: Harness Nightly
on:
schedule:
- cron: "0 18 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
harness_eval_summary:
name: Harness Eval Summary
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Restore harness eval history cache
id: restore-harness-history
uses: actions/cache/restore@v4
with:
path: artifacts/history
key: harness-nightly-history-${{ github.run_id }}
restore-keys: |
harness-nightly-history-
- name: Generate harness eval summary
run: |
mkdir -p "./artifacts/history"
node scripts/harness-eval-runner.mjs \
--output-json "./artifacts/harness-eval-summary.json" \
--output-markdown "./artifacts/harness-eval-summary.md"
cp "./artifacts/harness-eval-summary.json" "./artifacts/history/$(date -u +%Y%m%dT%H%M%SZ)-harness-eval-summary.json"
ls -1t "./artifacts/history"/*.json 2>/dev/null | tail -n +31 | xargs -r rm -f
- name: Generate harness eval trend
run: |
node scripts/harness-eval-trend-report.mjs \
--history-dir "./artifacts/history" \
--output-json "./artifacts/harness-eval-trend.json" \
--output-markdown "./artifacts/harness-eval-trend.md"
- name: Upload harness eval artifact
uses: actions/upload-artifact@v4
with:
name: harness-eval-nightly
path: artifacts
if-no-files-found: error
- name: Save harness eval history cache
if: always()
uses: actions/cache/save@v4
with:
path: artifacts/history
key: harness-nightly-history-${{ github.run_id }}
+1
View File
@@ -45,6 +45,7 @@ docs/roadmap/*
docs/gongzonghao/
docs/bussniss/
docs/oem/
docs/tech/
# Issues tracking (internal use only)
.issues/
+11
View File
@@ -35,6 +35,17 @@
14. **不要继续放大历史大文件** - 现有超大文件属于历史包袱,但新增逻辑应优先拆边界,不继续堆叠
15. **质量门禁保持单一主线** - `.github/workflows/quality.yml`、`scripts/quality-task-planner.mjs`、本地统一入口要保持一致
## 路线图主线护栏
当用户明确要求“对齐路线图 / 按顺序完成目标 / 继续主线”时,额外遵守以下规则:
1. **先重述主目标** - 开始新一轮实现前,先用一句话重述当前路线图的主目标、当前阶段和下一刀
2. **主线优先于清理** - 默认优先推进路线图中尚未完成的主链事项;零引用清理、README 同步、dead util 删除只能作为从属动作,不能替代主线
3. **每一刀都要回挂路线图** - 任何改动都必须能明确回答“它对应路线图哪一节、缩短了哪条主链距离”
4. **连续清理后强制回看路线图** - 如果连续两轮工作主要是治理减法或 dead surface 清退,下一轮必须重新打开路线图并优先选择尚未完成的主链项
5. **发现偏离要立即纠偏** - 如果当前改动无法直接服务 `Conversation Runtime 效率 / 前端瘦身 / Team 委派 / 协议收敛 / 状态统一` 这五条主线,应立即停止扩散并回到路线图
6. **汇报必须带主线判断** - 阶段汇报时必须显式说明“这一步为什么仍在主目标上”,不能只汇报局部文件改动
## UI 规则
1. **改界面先读视觉规范** - 先看 `docs/aiprompts/design-language.md`
+23 -19
View File
@@ -1,38 +1,42 @@
## Lime v0.96.0
## Lime v0.97.0
### ✨ 主要更新
- **Aster Agent 运行时与 Artifact 工作台继续落主链**:桌面端补齐了新的运行时协议、Artifact 文档处理链路与工作台渲染入口,产物预览、自动持久化、运行态元数据和时间线展示现在统一走同一套事实源
- **Browser Runtime 新增站点适配器目录与调试能力**:内置站点适配器注册表开始随应用打包,Browser Runtime 可以列出、搜索、调试、执行并保存站点适配结果,为后续站点自动化与内容采集提供统一入口
- **云端账户与 Provider 消费态完成收口**:设置页新增云端 Offer / 模型目录 / 本地 Provider 的分层视图,OAuth / 会话 / 控制面 bootstrap / 默认来源切换都统一到 OEM Cloud 运行时链路
- **快捷键与工作区交互体验更完整**:快捷键设置页改成“已审计、已接入、可测试”的目录视图,工作台、终端、文档画布、海报画布与系统能力的可用热键与运行时状态都能集中查看
- **发布质量入口与 GUI 冒烟主线收敛**:仓库新增 `verify:tasks`、`verify:gui-smoke` 与统一质量工作流,版本发布不再只看静态检查,而是把契约、Bridge 与 GUI 壳准备态一起纳入门槛
- **Harness 导出链路补齐四类制品**:处理工作台与统一运行时接通 `handoff bundle`、`evidence pack`、`replay case`、`analysis handoff` 导出,支持 pending request 重放、外部诊断交接和问题复盘闭环
- **Replay Eval / Nightly 骨架进入 current 主线**:仓库新增 `docs/test/harness-evals.*`、固定 replay fixture、`harness-eval-runner`、`harness-eval-trend-report`、`harness-replay-promote` 与 nightly workflow,把 replay 样本、grader 合同和趋势摘要收口到统一入口
- **Service Skill 到 Automation 的落地链路更完整**:Home Shell、Workspace 与自动化设置页现在可以直接从服务技能创建本地 automation job,保留技能与任务关联,并回填 workspace/content 上下文
- **Browser Runtime 站点采集继续收口**:站点采集工作台补齐推荐适配器、资料自动选择、目录状态展示和结果回写当前内容/项目的主链,优先复用已连接 Chrome 的真实登录态
- **Agent Chat 提交流程与处理面板继续瘦身**:slash skill、selected team、session/runtime steady-state 与 Harness 状态面板的交互拆分重组,关键回归测试同步补齐
### ⚠️ 兼容性说明
- 现网包发布仍由 `v*` tag 触发,`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文;只推 `main` 不会自动出包
- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖已同步到 `v0.22.0`;如本地仍在用 `.cargo/config.toml` 覆盖本地 Aster,请确认覆盖版本与本次发布一致
- 站点适配器目录现在会随桌面端资源一起打包,同时支持服务端同步目录;打包前请确认目标环境允许下发对应的站点脚本与运行时配置
- 云端 Provider、服务技能目录与站点适配目录都依赖 OEM 控制面 bootstrap;发布到不同品牌/环境前,请确认 `public/oem-runtime-config.js` 已替换为目标环境值
- 正式发布仍由 `v*` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文
- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向的是干净的 `v0.22.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖
- 站点适配器与 Browser Runtime 冒烟现在默认依赖已就绪的 `DevBridge`、浏览器资料和服务端同步目录;发布到目标环境前请确认对应控制面与 Browser Bridge 状态可用
- Harness 新增 handoff/evidence/replay/analysis 导出后,会在工作区 `.lime/harness/sessions/<session_id>/...` 下沉淀更多制品;如有路径清理策略,请同步评估磁盘与归档规则
### 🔗 依赖同步
- 应用版本已同步提升到 `v0.96.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json`
- Lime 内置的 `aster-rust` 依赖已从 `v0.21.0` 升级到 `v0.22.0`
- `src-tauri/Cargo.lock` 已随本次 Rust 校验更新,确保发布时依赖解析结果可复现
- 应用版本已同步提升到 `v0.97.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `src-tauri/Cargo.lock`
- 当前仓库声明的 `aster-rust` 依赖仍为 `v0.22.0`;本地覆盖仓库已核对为干净 `v0.22.0` 状态
- `src-tauri/Cargo.lock` 已随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `0.97.0` 对齐
### 🧪 测试
- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml --all`
- 发布前执行:`npm run verify:app-version`
- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.96.0 npm run verify:local`
- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.96.0 cargo clippy --manifest-path src-tauri/Cargo.toml`
- 发布前执行:`npm run lint`
- 发布前执行:`npm run test:contracts`
- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml --all`
- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.97.0 cargo test --manifest-path src-tauri/Cargo.toml`
- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.97.0 cargo clippy --manifest-path src-tauri/Cargo.toml`
- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.97.0 npm run verify:gui-smoke -- --timeout-ms 480000`
- 格式状态:已执行 `cargo fmt --manifest-path src-tauri/Cargo.toml --all`
### 📝 文档
- 发布说明随 `RELEASE_NOTES.md` 更新,供 GitHub Release 工作流直接读取
- 工程质量与命令边界文档已同步更新到新的 GUI 冒烟 / 契约 / 版本校验主线
- 发布说明已切换到 `v0.97.0`,供 GitHub Release 工作流直接读取
- Harness eval / 工程质量 / 命令边界与 GUI 冒烟相关文档已在当前工作区同步演进
---
**完整变更**: v0.95.0...v0.96.0
**完整变更**: v0.96.0...v0.97.0
+4 -1
View File
@@ -5,7 +5,7 @@
`docs/` 是 Lime 文档中心,分为两类受众:
- 普通创作者:优先阅读 `content/` 下的入门与用户指南
- 开发者与维护者:阅读 `aiprompts/`、`develop/`、`tests/` 等工程文档
- 开发者与维护者:阅读 `aiprompts/`、`develop/`、`tech/`、`tests/` 等工程文档
文档站基于 Nuxt Content 构建。
@@ -13,6 +13,7 @@
- `content/`:对外文档站正文(产品介绍、用户指南、进阶能力)
- `aiprompts/`:模块级工程文档(前后端组件、服务、命令、数据层)
- `tech/`:跨模块技术蓝图与专题工程文档(当前已包含 Harness Engineering 指导文档)
- `bussniss/`:商务合作与代理运营方案
- `develop/`:开发流程与协作规范
- `plugins/`:插件与扩展相关文档
@@ -24,6 +25,8 @@
- `develop/execution-tracker-deprecation-plan.md`:统一执行追踪旧路径退场计划(P0 收口)
- `develop/execution-tracker-p0-acceptance-report.md`:统一执行追踪 P0 验收报告
- `develop/execution-tracker-p1-p2-roadmap.md`:统一执行追踪后续路线(P1/P2)
- `tech/harness/README.md`:Lime Harness Engineering 总入口
- `tech/harness/implementation-blueprint.md`:Lime Harness 分阶段实施蓝图
- `develop/scheduler-task-governance-p1.md`:调度任务治理 P1(连续失败、自动停用、冷却恢复)
- `roadmap/lime-skills-standardization-roadmap.md`:Skills 标准化与产品化路线图
- `roadmap/lime-service-skill-cloud-config-prd.md`:服务型技能的端优先执行与云配置同步 PRD
+1
View File
@@ -19,6 +19,7 @@
- `quality-workflow.md` - 本地校验、GUI smoke、契约检查、CI 门禁
- `project-heatmap.md` - 仓库热力图与治理候选分析
- `limecore-collaboration-entry.md` - 跨仓库联动入口
- `../tech/harness/README.md` - Lime Harness Engineering 总入口与实施蓝图
### GUI 与前端
+20 -3
View File
@@ -145,8 +145,9 @@ npm run verify:local
如果命令边界改动影响会话运行时恢复语义,例如:
- `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy`
- 话题切换时的 provider/model 恢复从本地 fallback 向 `execution_runtime` 收敛
- `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy / recent_preferences / recent_team_selection`
- `getSession/listSessions` 的 `execution_runtime` 新增或调整 `recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id`
- 话题切换时的 provider/model、工具偏好、Team 选择,或 `theme / session_mode / gate_key / run_title / content_id` 恢复从本地 fallback 向 `execution_runtime` 收敛
除了契约检查,还应补对应 Hook / UI 稳定回归,确认切换话题后模型选择器恢复的是会话 runtime,而不是陈旧本地缓存。
@@ -188,7 +189,15 @@ npm run verify:local
以下是仓库当前已经明确收敛的几个方向:
- **Agent / Codex 主命令**:继续收敛到 `agent_runtime_*`
- **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略以及 session provider/model 的轻量持久化回写
- **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略、session provider/model、`recent_preferences` 以及 `recent_team_selection` 的轻量持久化回写
- **运行时交接导出主链**:继续收敛到 `agent_runtime_export_handoff_bundle`;前端统一通过 `src/lib/api/agentRuntime.ts` 网关进入,当前 GUI 入口位于 `HarnessStatusPanel`
- **运行时证据导出主链**:继续收敛到 `agent_runtime_export_evidence_pack`,用于把 runtime / timeline / artifacts 打包成最小问题证据
- **运行时 replay 样本主链**:继续收敛到 `agent_runtime_export_replay_case`,复用 handoff bundle + evidence pack 生成 `input / expected / grader / evidence-links`
- **运行时外部分析交接主链**:继续收敛到 `agent_runtime_export_analysis_handoff`,复用 handoff bundle + evidence pack + replay case 生成 `analysis-brief.md / analysis-context.json / copy_prompt`,供外部 Claude Code / Codex 直接诊断与最小修复;当前 GUI 入口位于 `HarnessStatusPanel`
- **运行时人工审核记录主链**:继续收敛到 `agent_runtime_export_review_decision_template`,复用 `analysis handoff` 生成 `review-decision.md / review-decision.json`,把开发者的接受 / 延后 / 拒绝与回归要求回挂到工作区;当前 GUI 入口位于 `HarnessStatusPanel`
- **会话主题上下文主链**:`getSession` 返回的 `execution_runtime.recent_theme / recent_session_mode` 负责承接最近一次运行态主题上下文;当前端已命中同一 steady-state theme/workbench mode 时,不应继续每回合重复携带 `harness.theme / harness.session_mode`
- **会话运行阶段上下文主链**:`getSession` 返回的 `execution_runtime.recent_gate_key / recent_run_title` 负责承接最近一次 Theme Workbench 运行阶段上下文;当前端已命中同一 steady-state gate/run 时,不应继续每回合重复携带 `harness.gate_key / harness.run_title`
- **会话内容上下文主链**:`getSession` 返回的 `execution_runtime.recent_content_id` 负责承接最近一次运行态 `content_id`;当前端已命中同一 steady-state 内容时,不应继续每回合重复携带 `harness.content_id`
- **运行态摘要主链**:Aster `runtime_status` item -> timeline `turn_summary`
- **旧 `chat_*` 命令**:已停止注册,不应重新回到 `commands::mod` 或 `generate_handler!`
- **旧 `general_chat_*` 边界**:前端 compat 网关与 Rust 命令都已移除,不应重新接入
@@ -198,6 +207,14 @@ npm run verify:local
**不要再造第三套入口,优先继续把能力收敛到已存在的主链。**
补充约定:
- **站点能力主链**:继续收敛到 `site_list_adapters / site_recommend_adapters / site_search_adapters / site_get_adapter_info / site_run_adapter`
- **站点 Agent 工具主链**:继续收敛到 `lime_site_list / lime_site_recommend / lime_site_search / lime_site_info / lime_site_run`
- **站点结果沉淀主线**:`site_run_adapter` / `lime_site_run` 优先透传 `content_id` 写回当前主稿;只有缺少 `content_id` 时,才回退到 `project_id` 新建结果文档
- **站点运行失败语义**:`SiteAdapterRunResult` 至少统一输出 `auth_required / no_matching_context / adapter_runtime_error`,并在前端与 Agent 结果里保留 `report_hint`
- **浏览器资料 / 环境预设主链**:`list/save/archive/restore_browser_profile_cmd` 与 `list/save/archive/restore_browser_environment_preset_cmd` 已进入真实 DevBridge 主路径;浏览器模式下不应再默认放进 `mockPriorityCommands`,仅在 DevBridge 不可用时才允许回落 `defaultMocks`
## 相关检查脚本
```bash
+12
View File
@@ -17,6 +17,18 @@
其余实现必须被明确归类。
## 路线图任务防跑偏
如果用户明确绑定了某份路线图,尤其是要求“按顺序继续”“对齐目标”“先完成主线”,治理动作必须服从路线图主线,而不是反过来主导路线图。
执行时额外遵守:
1. 先重述当前路线图的 **主目标 / 当前阶段 / 下一刀**
2. 只有当 dead / compat / deprecated surface **直接阻碍主线收口** 时,才优先做治理减法
3. 不要把“还能删一点旧代码”误当成“继续推进目标”
4. 连续两轮主要都在删零引用或补文档时,必须重新打开路线图,改选尚未完成的主链项
5. 汇报治理结果时,必须补一句“这一步如何服务路线图主线”;如果说不出来,就说明这一步不该先做
## 分类语言
治理默认使用这四类:
+78
View File
@@ -129,6 +129,18 @@ npm run test:contracts
6. 点击 `确认生成`
7. 验证页面出现 `Theme Workbench` 或相关工作台内容
8. 再次检查控制台 error
9. 如能查看运行时摘要,继续确认当前 gate 与任务标题恢复自该话题最近一次 `execution_runtime.recent_gate_key / recent_run_title`
### 浏览器工作台站点采集验证
1. 进入带有 browser assist 的工作区或浏览器运行时面板
2. 打开 `站点采集工作台` 或对应调试面板
3. 先确认推荐区已出现,并至少看到一个推荐适配器卡片
4. 点击一个推荐项,确认适配器、资料提示和标签页提示同步变化
5. 触发一次执行失败场景时,确认结果区展示业务级错误码与 `report_hint`
6. 如当前页面带有 `contentId` 上下文,再确认执行成功后默认是“写回当前主稿”,而不是新建资源文档
7. 如工作台模式开启自动保存,再确认执行成功后保存态文案与打开入口正常
8. 打开控制台并确认浏览器资料 / 环境预设读取没有落回 web mock,尤其不应出现 `[Mock] invoke: list_browser_profiles_cmd` 或 `[Mock] invoke: list_browser_environment_presets_cmd`
### 话题模型恢复验证
@@ -138,6 +150,71 @@ npm run test:contracts
4. 验证模型选择器恢复的是该话题最近一次 session runtime,而不是陈旧的 localStorage 默认值
5. 如页面暴露运行时摘要条,再确认 provider/model 文案与选择器一致
### 话题工具偏好恢复验证
1. 进入同一工作区中的两个话题
2. 分别切换 `联网 / 深度思考 / 任务模式 / 子代理` 开关组合
3. 在两个话题之间来回切换,必要时新建一个空白话题再切回
4. 验证工具开关恢复的是该话题最近一次 session runtime,而不是主题级 localStorage 默认值
5. 如首次切回旧话题时只能命中 fallback,再继续切换一次,确认第二次开始已优先走 runtime 恢复
### 话题 Team 恢复验证
1. 进入同一工作区中的两个话题
2. 在话题 A 里选择一个 builtin Team,在话题 B 里选择另一个 builtin 或 custom Team
3. 在两个话题之间来回切换,必要时新建一个空白话题再切回
4. 验证 Team 选择器、摘要区和 Team Workbench 展示恢复的是该话题最近一次 `recent_team_selection`,而不是主题级 localStorage 的旧值
5. 对 custom Team 额外确认:切回后 label / description / roles 没丢;如果本轮是从 fallback 回填,继续切换一次确认第二次开始已优先走 runtime 恢复
### 运行时交接制品验证
1. 进入带有 `HarnessStatusPanel` 的对话工作区,并确保当前话题已经拿到 `sessionId`
2. 展开 `交接制品` 区块,点击 `导出交接制品`
3. 验证区块内出现:
- 导出时间
- 线程状态 / 最新 Turn 状态
- Todo 统计
- `plan / progress / handoff / review` 文件列表
4. 继续点击单个制品的 `预览`,确认预览弹窗能打开,并能看到对应绝对路径
5. 如页面桥接到了真实后端,再点击 `打开目录` 或单文件 `打开`,确认不会落回 mock,且工作区内确实生成 `.lime/harness/sessions/<session_id>/...`
6. 如果这轮继续开发问题证据包,再把同一条续测链扩展为“先导出 handoff,再导出 evidence pack”,确认两者目录与状态卡不会串线
7. 如果这轮继续开发 replay 样本导出,再点击 `导出 Replay 样本`,确认:
- `input / expected / grader / evidence-links` 文件列表出现
- replay 区块能显示 handoff / evidence 的关联根路径
- 打开目录后工作区内确实生成 `.lime/harness/sessions/<session_id>/replay`
8. 如果这轮继续开发外部分析交接,再点击 `导出分析交接` 与 `一键复制给 AI`,确认:
- `analysis-brief.md / analysis-context.json` 文件列表出现
- 复制内容直接来自后端 `copy_prompt`,不需要前端再手写 prompt
- analysis 区块能显示 handoff / evidence / replay 的关联目录
9. 如果这轮继续开发人工审核记录,再点击 `导出人工审核记录`,确认:
- `review-decision.md / review-decision.json` 文件列表出现
- 区块能显示默认状态、审核清单与关联 analysis 文件
- 打开目录后工作区内确实生成 `.lime/harness/sessions/<session_id>/review`
### 话题内容上下文恢复验证
1. 进入带 `contentId` 的工作台话题并完成至少一次发送
2. 留在同一话题下再次发送,保持目标主稿不变
3. 验证本轮仍写回当前主稿,没有误新建资源文档或切到其他内容
4. 如能查看调试面板或运行时摘要,继续确认恢复依据是当前话题最近一次 `execution_runtime.recent_content_id`,而不是页面一次性参数或陈旧缓存
5. 再切到另一个 `contentId` 后立即发送一次,确认同步窗口内仍能命中新主稿,而不是被旧 runtime 误覆盖
### 话题主题上下文恢复验证
1. 进入普通对话话题完成一次发送,再切到 `Theme Workbench` 话题完成一次发送
2. 在两个话题之间来回切换,必要时新建一个空白话题再切回
3. 验证 UI 恢复的是该话题最近一次主题上下文,而不是页面一次性参数或主题级缓存
4. 如能查看调试面板或运行时摘要,继续确认依据是当前话题最近一次 `execution_runtime.recent_theme / recent_session_mode`
5. 再从普通对话切到新的 `theme_workbench` 后立即发送一次,确认同步窗口内仍命中新 theme / session mode,而不是被旧 runtime 误覆盖
### Theme Workbench 运行阶段恢复验证
1. 进入同一个 Theme Workbench 话题,至少完成一次 `write_mode` 或 `publish_confirm` 阶段发送
2. 留在同一话题下再次发送,保持当前 gate 和任务标题不变
3. 验证本轮仍衔接当前 gate / 任务标题,而不是掉回旧阶段或空标题
4. 如能查看调试面板或运行时摘要,继续确认恢复依据是当前话题最近一次 `execution_runtime.recent_gate_key / recent_run_title`
5. 再切到新的 gate 或新的运行标题后立即发送一次,确认同步窗口内仍命中新 gate / run title,而不是被旧 runtime 误覆盖
### 服务型技能自动化交付链
1. 从首页进入服务型技能卡片
@@ -228,6 +305,7 @@ npm run test:contracts
- 如果该命令属于浏览器模式可接受的降级能力,加入 mock 优先列表
- 如果该命令属于当前主路径必须能力,补真实 bridge
- 对浏览器资料 / 环境预设这类已桥接命令,优先排查真实 DevBridge 或默认种子,不要再把它们加回 mock 优先集合
## 何时补 mock,何时补真实 bridge
+48 -10
View File
@@ -32,6 +32,19 @@
4. **用户可见回归已补齐** - 用户可见 UI 改动有稳定断言或既有 snapshot 回归
5. **文档与锁文件不掉队** - 相关文档、schema、锁文件与实际实现保持一致
## 路线图任务防跑偏
如果任务明确绑定路线图主线,质量校验除了回答“是否通过”,还必须回答“这次改动是否真的推进了路线图目标”。
执行时额外遵守:
1. 校验前先确认本轮改动对应路线图哪一项
2. 如果本轮改动只是清理 dead surface、补 README 或局部整理,但没有直接推进主链,不能把“校验通过”当作完成目标
3. 汇报时必须同时给出:
- 本轮改动对应的路线图节点
- 本轮校验覆盖了哪条主线风险
- 当前距离该路线图阶段完成还差什么
## 执行硬规则
### 1. 不要继续扩展 compat / deprecated 路径
@@ -120,6 +133,8 @@ npm run verify:gui-smoke
- 启动或复用 `headless Tauri`
- 等待 `DevBridge` 健康检查通过
- 验证默认 workspace 的准备态可用
- 验证 `browser runtime` 的启动、状态读取与审计主链可用
- 验证 `site adapter catalog` 的状态、列表与推荐主链可读
它解决的是 GUI 产品特有风险:
@@ -142,13 +157,17 @@ npm run bridge:health -- --timeout-ms 120000
作用:
- 检查前端命令调用与 Rust 注册表是否一致
- 检查 harness metadata / execution runtime / 后端 request metadata 的关键字段是否漂移
- 检查浏览器桥接 / mock 优先路径是否同步
- 检查 `DevBridge` 是否可用
高频场景:
- 修改 `safeInvoke` / `invoke`
- 修改 `agent_runtime_update_session` 或会话 provider/model 恢复语义
- 修改 `agent_runtime_update_session` 或会话 provider/model / recent_preferences / recent_team_selection 恢复语义
- 修改 `execution_runtime.recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` 恢复语义,或前端 `harness.theme / harness.session_mode / harness.gate_key / harness.run_title / harness.content_id` steady-state 去重逻辑
- 修改 `site_*` 站点适配器命令族,例如 `site_recommend_adapters`、`site_run_adapter`
- 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级
- 修改 `src/lib/dev-bridge/`
- 修改 `src/lib/tauri-mock/`
- 修改 `src-tauri/src/app/runner.rs`
@@ -172,19 +191,35 @@ npm run bridge:health -- --timeout-ms 120000
## 改动类型与最低门槛
| 改动类型 | 至少运行 | 额外要求 |
| ----------------------------------- | -------------------------------------------------- | ------------------------------------------- |
| 普通前端改动 | `npm run verify:local` | 如有用户可见变化,补稳定回归 |
| Tauri 命令 / Bridge / mock 改动 | `npm run verify:local`、`npm run test:contracts` | 必要时补 `npm run governance:legacy-report` |
| GUI 壳 / Workspace / 页面主路径改动 | `npm run verify:local`、`npm run verify:gui-smoke` | 必须补对应 UI 回归 |
| 配置结构改动 | `npm run verify:local` | 同步 schema、消费者、文档 |
| 版本相关改动 | `npm run verify:app-version` | 与发布配置一起核对 |
| Rust 模块改动 | 受影响 crate / 模块定向测试 | 再决定是否跑全量 `cargo test` |
| 真实页面交互验证 | 先跑 `npm run verify:gui-smoke` | 再进入 `playwright-e2e.md` |
| 改动类型 | 至少运行 | 额外要求 |
| ----------------------------------- | ------------------------------------------------------ | ------------------------------------------- |
| 普通前端改动 | `npm run verify:local` | 如有用户可见变化,补稳定回归 |
| Tauri 命令 / Bridge / mock 改动 | `npm run verify:local`、`npm run test:contracts` | 必要时补 `npm run governance:legacy-report` |
| GUI 壳 / Workspace / 页面主路径改动 | `npm run verify:local`、`npm run verify:gui-smoke` | 必须补对应 UI 回归 |
| 运行时 handoff / 证据包导出改动 | `npm run test:contracts`、相关 `vitest`、Rust 定向测试 | 如入口落在工作台 UI,再补最小 GUI 续测 |
| 配置结构改动 | `npm run verify:local` | 同步 schema、消费者、文档 |
| 版本相关改动 | `npm run verify:app-version` | 与发布配置一起核对 |
| Rust 模块改动 | 受影响 crate / 模块定向测试 | 再决定是否跑全量 `cargo test` |
| 真实页面交互验证 | 先跑 `npm run verify:gui-smoke` | 再进入 `playwright-e2e.md` |
补充说明:
- 如果这次改动把 `ServiceSkill -> automation_job -> agent_turn` 接到 Artifact 主线,除了常规 `verify:local` / `test:contracts` 之外,还应至少补一条稳定回归,证明 `content_id + request_metadata.artifact` 没在表单编辑或执行链路里丢失。
- 如果这次改动把 `content_id` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
- session 已有 `execution_runtime.recent_content_id` 时,前端不会重复提交相同 `harness.content_id`
- 切换到新 content 但 runtime 尚未同步时,前端仍会保留显式 `content_id`
- 如果这次改动把 `theme / session_mode` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
- session 已有 `execution_runtime.recent_theme / recent_session_mode` 时,前端不会重复提交相同 `harness.theme / harness.session_mode`
- 切换到新 theme 或 `theme_workbench` 但 runtime 尚未同步时,前端仍会保留显式 `theme / session_mode`
- 如果这次改动把 `gate_key / run_title` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
- session 已有 `execution_runtime.recent_gate_key / recent_run_title` 时,前端不会重复提交相同 `harness.gate_key / harness.run_title`
- 切换到新的 Theme Workbench gate 或运行标题、但 runtime 尚未同步时,前端仍会保留显式 `gate_key / run_title`
- 如果这次改动影响浏览器工作台里的站点采集链路,例如推荐区、资料自动选择、`report_hint` 展示、`lime_site_recommend`,或“优先写回当前 `content_id` 而不是新建资源文档”的主线收敛,除了契约检查,还应补对应 `*.test.tsx` 回归并执行 `verify:gui-smoke`。
- 如果这次改动影响浏览器资料 / 环境预设的真实来源,还应补一次浏览器模式实测,确认控制台不再出现 `[Mock] invoke: list_browser_profiles_cmd` 或 `[Mock] invoke: list_browser_environment_presets_cmd`。
- 如果这次改动影响 `agent_runtime_export_handoff_bundle`、`agent_runtime_export_evidence_pack`、`agent_runtime_export_analysis_handoff`、`agent_runtime_export_review_decision_template` 或 `agent_runtime_export_replay_case` 这条 Harness 导出主链,除了契约检查,还应至少补:
- `src/lib/api/agent.test.ts` 一类的网关回归,确认仍走统一 `agent_runtime_*` 主命令
- `HarnessStatusPanel.test.tsx` 一类的 UI 回归,确认导出入口、状态与制品展示正常
- 受影响 Rust 服务 / 命令的定向测试,确认 `.lime/harness/sessions/<session_id>/...` 一类制品仍能生成
## CI 事实源
@@ -223,6 +258,9 @@ npm run verify:local:full
# GUI 最小冒烟
npm run verify:gui-smoke
npm run smoke:workspace-ready
npm run smoke:browser-runtime
npm run smoke:site-adapters
# 前端 / 桥接 / 契约
npm test
@@ -15,6 +15,10 @@
我们现在更需要的,不是继续堆更多脚本,而是把已有能力收敛成一条清晰、分层、可执行的开发与交付路径。
如果要把这些工程方法进一步落实到 Lime 的 Agent / Harness 改造,请继续阅读:
- `docs/tech/harness/README.md`
Codex 值得借鉴的,不是 Bazel、不是纯 Rust、也不是它的体量;
真正值得借鉴的是:
+31 -11
View File
@@ -48,6 +48,7 @@ docs/test/
├── integration-tests.md # 集成测试指南
├── e2e-tests.md # 浏览器续测与 E2E 总览
├── agent-evaluation.md # Agent 评估指南(核心文档)
├── harness-evals.md # Harness eval 任务集与 runner 入口
└── test-cases/ # 测试用例模板
├── converter-tests.md # 协议转换器测试用例
├── provider-tests.md # Provider 测试用例
@@ -56,17 +57,18 @@ docs/test/
## 文档索引
| 文档 | 说明 | 适用场景 |
| ---------------------------------------------------------------- | -------------------------- | ------------------------------------- |
| [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 集成 |
| 文档 | 说明 | 适用场景 |
| ---------------------------------------------------------------- | ---------------------------- | ------------------------------------- |
| [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 行为评估 |
| [harness-evals.md](harness-evals.md) | Harness eval 任务集与 runner | Replay 样本、grader、nightly 摘要 |
| [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 集成 |
## 快速开始
@@ -106,6 +108,24 @@ npm run bridge:health -- --timeout-ms 120000
npm run smoke:workspace-ready
```
### 运行 Harness eval 摘要
```bash
npm run harness:eval
```
### 提升工作区 Replay 为仓库样本
```bash
npm run harness:eval:promote -- --session-id "session-123" --slug "pending-request-runtime"
```
### 运行 Harness eval 趋势报告
```bash
npm run harness:eval:trend
```
### 当前浏览器续测入口
当前仓库的浏览器模式 E2E / 续测文档分两层:
+89
View File
@@ -0,0 +1,89 @@
{
"manifestVersion": "v1",
"title": "Lime Harness Eval Manifest",
"defaults": {
"requiredArtifacts": [
"input.json",
"expected.json",
"grader.md",
"evidence-links.json"
],
"requiredInputFields": [
"session.sessionId",
"session.threadId",
"task.goalSummary",
"classification.suiteTags",
"classification.failureModes",
"linkedArtifacts.handoffBundle.relativeRoot",
"linkedArtifacts.evidencePack.relativeRoot"
],
"requiredExpectedFields": [
"successCriteria",
"blockingChecks",
"artifactChecks",
"graderSuggestion.preferredMode"
],
"requiredEvidenceFields": [
"handoffBundle.relativeRoot",
"evidencePack.relativeRoot"
]
},
"suites": [
{
"id": "repo-fixtures",
"title": "仓库固定 Replay 样本",
"priority": "P0",
"roadmap": "P3-2 Eval runner",
"description": "固定一条可在 CI 和 nightly 中稳定运行的 replay fixture,先验证 grader 合同与样本结构,而不是等真实工作区样本才能开始。",
"upstream": {
"codex": "沿用 Codex evidence-first 的 replay / grader 形状。",
"aster": "沿用 Aster runtime / thread / turn 的会话事实边界。",
"lime": "把样本、脚本和摘要统一落在 Lime 仓库 current 主线。"
},
"cases": [
{
"id": "fixture-minimal-pending-request",
"title": "最小 pending request Replay 样本",
"source": "repo_fixture",
"caseDir": "docs/test/harness-fixtures/replay/minimal-pending-request",
"tags": ["conversation-runtime", "replay", "handoff", "evidence"]
}
]
},
{
"id": "repo-promoted-replays",
"title": "仓库沉淀 Replay 样本",
"priority": "P1",
"roadmap": "P3-6 Replay 样本沉淀",
"description": "把工作区导出的高价值 replay case 提升为仓库 current 样本,用于固定回归入口与 nightly 趋势对比。",
"upstream": {
"codex": "沿用 Codex 把真实失败沉淀为 replay 资产的做法。",
"aster": "继续复用 Aster runtime 导出的 thread / turn / evidence 边界。",
"lime": "由 Lime 持有 promotion 命令、repo fixture 目录与 current manifest。"
},
"cases": []
},
{
"id": "workspace-replay-discovery",
"title": "工作区 Replay 自动发现",
"priority": "P1",
"roadmap": "P3-2 Eval runner",
"description": "扫描当前工作区 `.lime/harness/sessions/*/replay`,把真实会话导出的 replay case 接进固定摘要格式。",
"upstream": {
"codex": "参考 Codex 把真实失败沉淀为 replay / eval 样本的习惯。",
"aster": "复用 Aster session runtime 导出的 thread / turn 上下文。",
"lime": "让 Lime 的 handoff bundle 与 evidence pack 成为 eval 的事实源。"
},
"cases": [
{
"id": "workspace-session-replays",
"title": "工作区会话 Replay 样本",
"source": "workspace_replay_discovery",
"root": ".lime/harness/sessions",
"allowZeroMatches": true,
"tags": ["workspace", "replay", "runtime-export"]
}
]
}
]
}
+230
View File
@@ -0,0 +1,230 @@
# Lime Harness Evals
> 面向 Lime `P3-6 Replay 样本沉淀` 的 current 事实源
> 目标:把 replay 样本、grader 合同、仓库固定任务集与 nightly 摘要,收口到一条可执行主链。
## 先给结论
Lime 当前不直接把“真实模型重放平台”一次做完,而是先固定四件事:
1. **固定任务集入口**
由 [harness-evals.manifest.json](harness-evals.manifest.json) 持有机可读任务清单。
2. **固定样本形状**
所有 replay case 统一要求最小四件套:
- `input.json`
- `expected.json`
- `grader.md`
- `evidence-links.json`
其中 `input.json` 继续承载 `classification.suiteTags` 与 `classification.failureModes`。
3. **固定摘要出口**
由 `scripts/harness-eval-runner.mjs` 统一产出 JSON / Markdown 摘要,后续 nightly 与趋势报表都从这里接。
4. **固定趋势入口**
由 `scripts/harness-eval-trend-report.mjs` 把一个或多个 summary JSON 聚合成 trend 报告。
这一步对应 Harness 路线图里的 `P3-2 Eval runner`,不是终点,但它把“评估理念”升级成了仓库内可执行入口。
## 为什么这一步要先做
如果没有固定 manifest 和 runner,Lime 当前的 replay 样本会停留在“可以导出”,却还不能稳定回答:
- 当前有哪些回放样本可以复用
- 哪些样本结构不完整
- grader 需要哪些输入字段
- nightly 应该上传什么摘要
先把这层收口,后面的真实模型评估、trend 报表、熵管理和清理才有统一入口。
## 三层来源挂载
| 层次 | 作用 | 当前落点 |
| ------------ | --------------------------------------------------------------------- | --------------------------------------------------------------- |
| `codex-rs` | 提供 replay / grader / evidence-first 的形状参照 | manifest 中的 replay case 四件套与评分原则 |
| `aster-rust` | 提供 thread / turn / runtime / telemetry 的事实边界 | `input.json` 中的 session / thread / turn / runtimeContext 结构 |
| `lime` | 持有产品层 handoff bundle、evidence pack、workspace `.lime/` 样本目录 | runner、fixture、nightly 摘要与工作区发现逻辑 |
一句话:
**Codex 决定评估形状,Aster 决定运行时事实边界,Lime 负责把 replay case、grader 和 nightly 摘要落到 current 主链。**
## 当前任务集
当前 manifest 默认分三条 suite:
1. **仓库固定 Replay 样本**
- 用于 CI / nightly 的稳定入口
- 当前固定一条 fixture:
- `fixture-minimal-pending-request`
- 目的不是替代真实会话,而是先验证 grader 合同、字段预算和摘要出口不会漂移
2. **仓库沉淀 Replay 样本**
- 用于把高价值真实失败从工作区提升为仓库 current 资产
- 默认进入 `repo-promoted-replays` suite
- 由 `scripts/harness-replay-promote.mjs` 负责:
- 复制最小四件套
- 把绝对工作区路径脱敏为稳定占位路径
- 回写 manifest case
- 目的不是把所有会话都进仓,而是把“值得长期回归”的失败收进固定任务集
3. **工作区 Replay 自动发现**
- 扫描 `.lime/harness/sessions/*/replay`
- 自动把真实导出的 replay case 纳入统一摘要
- 默认允许零样本,避免没有本地会话时误报失败
这是一种“固定入口、允许样本增长”的设计:
- 入口是固定的
- 样本既可以来自仓库 fixture,也可以来自已沉淀的 current case,还可以来自真实工作区导出
- 不需要为每个新 session 再发明一套单独脚本
## Runner 做什么
`scripts/harness-eval-runner.mjs` 当前负责四件事:
1. 读取 manifest
2. 解析固定 fixture 与工作区自动发现 case
3. 校验 replay case 最小四件套与关键 JSON 字段
4. 输出统一 JSON / Markdown 摘要,并聚合 `suite tag / failure mode` 分布
当前它**不直接执行真实模型重放**,而是先把“样本是否可评估、摘要是否可归档”工程化。
这符合 Lime 当前阶段的约束:
- 先复用现有 `handoff bundle + evidence pack + replay export`
- 不引入第二套总控平台
- 先把仓库 fixture、repo current 样本和工作区 replay case 变成稳定资产
## 如何把真实 Replay 提升为 current 样本
当某个工作区 replay case 已经足够稳定、足够重要,应该把它从“工作区临时样本”提升到“仓库固定样本”。当前主入口:
```bash
npm run harness:eval:promote -- \
--session-id "session-123" \
--slug "pending-request-runtime" \
--title "Pending request 会话不会被误判为完成"
```
也可以直接指定 replay 目录:
```bash
node scripts/harness-replay-promote.mjs \
--replay-dir ".lime/harness/sessions/session-123/replay" \
--slug "pending-request-runtime"
```
这个命令会做四件事:
1. 读取 replay 最小四件套。
2. 把工作区绝对路径脱敏成稳定占位路径,避免把本机路径直接写进仓库。
3. 把样本复制到 `docs/test/harness-fixtures/replay/<slug>/`。
4. 把 case 回写到 `repo-promoted-replays` suite,成为 nightly 与 trend 的 current 样本。
默认原则:
- 不是每个 replay 都要 promotion,只提升高价值、可重复、能代表失败模式的样本。
- promotion 之后,样本不再只是“本机能看到”,而是仓库 current 主线的一部分。
- 仓库沉淀样本仍然复用原来的 handoff / evidence 形状,不另造 schema。
## Trend Report 做什么
`scripts/harness-eval-trend-report.mjs` 当前负责三件事:
1. 读取一个或多个 `harness eval summary` JSON
2. 生成 baseline / latest 对比、suite 级 delta,以及 `suite tag / failure mode` 聚合变化
3. 输出 JSON / Markdown 趋势报告
如果没有显式提供输入,它会先调用 `harness-eval-runner` 生成当前 summary,再把它当作第一条 trend seed。
这一步的目的不是假装已经有完整历史,而是先把:
- trend 报告字段
- nightly 报告出口
- baseline / latest / suite delta 的最小合同
固定下来。
当前 nightly 还会恢复并追加 `artifacts/history/*.json` 历史窗口,用于让 trend 不只停留在单次 seed。
## 常用命令
```bash
# 人类可读摘要
npm run harness:eval
# JSON 输出,适合脚本和 CI 消费
npm run harness:eval:json
# 把工作区 replay 提升为仓库 current 样本
npm run harness:eval:promote -- --session-id "session-123" --slug "pending-request-runtime"
# 生成当前趋势报告;若没有历史输入,会先生成当前 summary 作为 trend seed
npm run harness:eval:trend
# 指定工作区根目录扫描真实 replay 样本
node scripts/harness-eval-runner.mjs --workspace-root "/path/to/workspace"
# 生成 nightly 可上传的双格式摘要
node scripts/harness-eval-runner.mjs \
--output-json "./tmp/harness-eval-summary.json" \
--output-markdown "./tmp/harness-eval-summary.md"
# 从历史 summary 目录生成趋势报告
node scripts/harness-eval-trend-report.mjs \
--history-dir "./artifacts/history" \
--output-json "./tmp/harness-eval-trend.json" \
--output-markdown "./tmp/harness-eval-trend.md"
```
## 输出摘要里应该看什么
Runner 摘要至少回答下面这些问题:
- 总共有多少 suite / case
- 有多少 case 已经 ready
- 哪些 case 缺文件
- 哪些 case JSON 字段不完整
- 哪些 case 属于什么 suite tag / failure mode
- 哪些 case 默认需要人工复核
- 工作区 replay 是否已经开始形成增量样本
如果摘要回答不了这些问题,就说明 runner 还不算进入 current 主链。
Trend 报告至少还要回答:
- baseline 和 latest 之间,ready / invalid / pending request 有没有变化
- 哪些 suite 在 latest 里变差了
- 哪些 failure mode / suite tag 在 latest 里增长或退化了
- 当前只有 trend seed,还是已经开始形成真正的历史窗口
## 与其他事实源的关系
| 文档 / 文件 | 角色 |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------- |
| [agent-evaluation.md](agent-evaluation.md) | 解释评估原则、pass@k / pass^k、grader 类型 |
| [testing-strategy-2026.md](testing-strategy-2026.md) | 解释为什么 eval 工程化排在 smoke 之后 |
| [../tech/harness/implementation-blueprint.md](../tech/harness/implementation-blueprint.md) | 解释 `P3-2 Eval runner` 在 Harness 主线中的位置 |
| [../tech/harness/tooling-roadmap.md](../tech/harness/tooling-roadmap.md) | 解释 runner、nightly、trend 的后续工具面 |
| `scripts/harness-eval-runner.mjs` | 当前唯一的 runner 入口 |
| `scripts/harness-eval-trend-report.mjs` | 当前 trend 聚合与 nightly 趋势出口 |
| [harness-evals.manifest.json](harness-evals.manifest.json) | 当前任务集与 suite 机可读事实源 |
## 下一刀
`P3-6` 做完之后,下一刀优先级建议固定为:
1. 把分类聚合直接挂到熵治理清单,形成 replay 驱动 cleanup 主线
2. 继续补 observability 证据字段,让 grader 能消费更多 request / timeline / artifact 关联
3. 逐步提高 repo current 样本质量,而不是只增加数量
4. 再考虑是否引入真实模型执行或 transcript grading
## 非目标
当前阶段默认不做:
- 不把 runner 变成第二套 CI 总控
- 不要求所有 replay case 都进仓库版本控制
- 不在这一刀里直接引入真实模型调用成本
- 不绕开 `handoff bundle / evidence pack / replay export` 另造样本格式
@@ -0,0 +1,43 @@
{
"replayCaseVersion": "v1",
"exportedAt": "2026-03-27T11:30:00Z",
"handoffBundle": {
"relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff",
"absoluteRoot": "/workspace/lime/.lime/harness/sessions/fixture-session-minimal-pending-request/handoff",
"artifacts": [
{
"kind": "plan",
"title": "执行计划",
"relativePath": ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff/plan.md"
},
{
"kind": "handoff",
"title": "交接摘要",
"relativePath": ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md"
}
]
},
"evidencePack": {
"relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence",
"absoluteRoot": "/workspace/lime/.lime/harness/sessions/fixture-session-minimal-pending-request/evidence",
"knownGaps": [
"当前 fixture 不包含真实浏览器快照,只验证 replay 样本结构与 grader 约定。"
],
"artifacts": [
{
"kind": "summary",
"title": "证据摘要",
"relativePath": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence/summary.md"
},
{
"kind": "runtime",
"title": "运行时快照",
"relativePath": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence/runtime.json"
}
]
},
"recentArtifacts": [
"docs/tech/harness/implementation-blueprint.md",
".lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md"
]
}
@@ -0,0 +1,28 @@
{
"replayCaseVersion": "v1",
"exportedAt": "2026-03-27T11:30:00Z",
"sessionId": "fixture-session-minimal-pending-request",
"threadId": "fixture-thread-minimal-pending-request",
"goalSummary": "确认评估链不会把仍存在 approval request 的会话误判为已完成。",
"successCriteria": [
"评分结果必须明确说明 pending request 是否已解决、保留还是不影响结论。",
"若沿用 handoff bundle 与 evidence pack,结论必须引用至少一条证据来源。",
"不得因为工具调用路径不同而直接判失败。"
],
"blockingChecks": [
"确认 approval-fixture-001 是否仍处于待处理状态。",
"确认 `waiting_request` 不会被误判为 `completed`。"
],
"artifactChecks": [
"确认 `.lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md` 仍与目标一致。",
"确认 evidence pack 中记录的 known gaps 没有被当作 PASS 证据。"
],
"nonGoals": [
"不要要求与原始会话完全相同的工具调用顺序。",
"不要把措辞差异当作失败,除非它改变了交付结果或风险判断。"
],
"graderSuggestion": {
"preferredMode": "result_artifact_and_request_resolution",
"requiresHumanReview": false
}
}
@@ -0,0 +1,37 @@
# Replay Case 评分说明
- 会话:`fixture-session-minimal-pending-request`
- 线程:`fixture-thread-minimal-pending-request`
- 导出时间:2026-03-27T11:30:00Z
- 目标摘要:确认评估链不会把仍存在 approval request 的会话误判为已完成。
## 建议读取顺序
1. 先读 `input.json`,理解当前任务与运行时上下文。
2. 再读 `expected.json`,确认只评估结果与风险。
3. 再读 `evidence-links.json`,跳转到已有证据源。
4. 如需补证据,优先回看 handoff bundle 与 evidence pack。
## 评分原则
- 只评结果,不评路径。
- 先证据后结论;没有证据支撑的 PASS 不成立。
- 如仍存在 pending request,必须解释它是已处理、仍保留,还是不影响判定。
## 最小通过条件
- 结果必须解释 pending request 的处理状态。
- 结果必须引用 handoff 或 evidence 中的至少一条证据。
- 不得把 `waiting_request` 误判成 `completed`。
## 建议输出模板
```text
verdict: pass | fail | needs_review
reason:
- ...
evidence:
- ...
risks:
- ...
```
@@ -0,0 +1,102 @@
{
"replayCaseVersion": "v1",
"source": "lime.fixture.replay_case",
"exportedAt": "2026-03-27T11:30:00Z",
"session": {
"sessionId": "fixture-session-minimal-pending-request",
"threadId": "fixture-thread-minimal-pending-request",
"workspaceId": "fixture-workspace",
"workspaceRoot": "/workspace/lime",
"model": "fixture-model",
"executionStrategy": "agent_runtime"
},
"task": {
"goalSummary": "确认评估链不会把仍存在 approval request 的会话误判为已完成。",
"latestPlan": "先导出 handoff bundle,再导出 evidence pack,最后生成 replay case。",
"latestTurnSummary": "当前会话已生成 replay case,但还有一个待处理审批请求。",
"latestTurnPrompt": "请把这次 pending request 会话导出为可回放样本。",
"latestTurnId": "turn-fixture-001",
"latestTurnStatus": "action_required",
"threadStatus": "waiting_request",
"primaryBlockingSummary": "存在待审批写文件请求,需要在评分时显式说明是否已处理。"
},
"classification": {
"sourceKind": "repo_fixture",
"suiteTags": [
"conversation-runtime",
"replay",
"pending-request",
"handoff",
"evidence"
],
"failureModes": ["pending_request", "unfinished_todo"],
"primaryBlockingKind": "pending_request"
},
"runtimeContext": {
"pendingRequests": [
{
"requestId": "approval-fixture-001",
"requestType": "approval_request",
"title": "允许写入 replay 样本目录",
"actionType": "write_file",
"prompt": "需要确认是否允许写入 `.lime/harness/sessions/.../replay`。"
}
],
"queuedTurns": [],
"todoItems": [
{
"content": "导出 replay case",
"status": "completed"
},
{
"content": "评估 pending request 风险",
"status": "in_progress"
}
],
"activeSubagents": [],
"recentArtifacts": [
"docs/tech/harness/implementation-blueprint.md",
".lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md"
],
"recentTimeline": [
{
"itemId": "timeline-fixture-001",
"turnId": "turn-fixture-001",
"payloadKind": "plan",
"status": "completed",
"summary": "产出 replay 导出计划",
"updatedAt": "2026-03-27T11:20:00Z"
},
{
"itemId": "timeline-fixture-002",
"turnId": "turn-fixture-001",
"payloadKind": "approval_request",
"status": "pending",
"summary": "等待写文件审批",
"updatedAt": "2026-03-27T11:24:00Z"
}
],
"lastOutcome": {
"thread_id": "fixture-thread-minimal-pending-request",
"turn_id": "turn-fixture-001",
"outcome_type": "partial_success",
"summary": "handoff 与 evidence 已导出,replay 样本待审批确认",
"primary_cause": "pending_request",
"retryable": true
},
"incidents": []
},
"linkedArtifacts": {
"handoffBundle": {
"relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff",
"artifactCount": 4
},
"evidencePack": {
"relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence",
"artifactCount": 4,
"knownGaps": [
"当前 fixture 不包含真实浏览器快照,只验证 replay 样本结构与 grader 约定。"
]
}
}
}
+13 -8
View File
@@ -45,10 +45,10 @@
## 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 输出与趋势指标 |
| 优先级 | 事项 | 为什么重要 | 当前证据 | 完成定义 |
| ------ | ------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| P0 | 自包含 smoke 仍然不足 | 单测很多,但主链路仍缺少无需人工准备的自动回归 | 目前仅有 `smoke:workspace-ready` 属于自包含 smoke;`smoke:social-workbench` 仍依赖已有 session,`bridge:e2e` 更像排障脚本 | 至少补齐 3 条无需人工准备的 smoke;当前已完成 1 条,仍需补 server / terminal / browser runtime 等 2 条以上 |
| P1 | Agent eval 仍未完全工程化 | 价值高,但建立在前面基础门禁稳定之后 | 已补 `docs/test/harness-evals.md`、`harness-evals.manifest.json`、`scripts/harness-eval-runner.mjs`、`scripts/harness-eval-trend-report.mjs` 与 nightly 摘要 / trend 骨架,但真实执行与更多高价值样本仍缺 | 形成稳定任务集、可增长 replay 样本、grader、nightly 输出与趋势指标 |
## 4. 建议执行顺序
@@ -70,12 +70,17 @@
- 有稳定契约检查
- 有可重复 smoke
完成后再上:
当前已先补:
- 固定 manifest 与 replay fixture
- runner 摘要出口
- nightly artifact 与 trend 骨架
后续再继续补:
- 固定任务集
- transcript 存档
- grader
- nightly 报表
- 更多真实高价值 replay 样本
- 更长窗口的趋势报表
## 5. 当前建议
+21 -1
View File
@@ -242,7 +242,7 @@ async function executeRemoteCommand(commandData) {
return;
}
const tabId = await resolveTargetTabId();
const tabId = await resolveCommandTargetTabId(commandData.target);
if (!tabId) {
sendCommandResult({
requestId,
@@ -290,6 +290,26 @@ async function executeRemoteCommand(commandData) {
}
}
async function resolveCommandTargetTabId(rawTarget) {
const normalizedTarget = String(rawTarget || "").trim();
if (!normalizedTarget) {
return await resolveTargetTabId();
}
const byId = Number(normalizedTarget);
if (Number.isInteger(byId) && byId > 0) {
try {
const tab = await chrome.tabs.get(byId);
if (tab?.id) {
activeTabId = tab.id;
return tab.id;
}
} catch (_) {}
}
return await resolveTargetTabId();
}
async function handleOpenUrl(commandData, waitForPageInfo) {
const requestId = commandData.requestId;
const sourceClientId = commandData.sourceClientId;
+11 -3
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "0.96.0",
"version": "0.97.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
@@ -20,7 +20,7 @@
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "CARGO_TARGET_DIR=target tauri dev",
"tauri:dev:headless": "CARGO_TARGET_DIR=src-tauri/target tauri dev --config src-tauri/tauri.conf.headless.json",
"tauri:dev:headless": "CARGO_TARGET_DIR=target tauri dev --config src-tauri/tauri.conf.headless.json",
"tauri:dev:nowatch": "CARGO_TARGET_DIR=target tauri dev --no-watch",
"tauri:dev:profile:trace": "node scripts/run-tauri-profile.mjs trace",
"tauri:dev:profile:trace:devtools": "node scripts/run-tauri-profile.mjs trace --open-devtools",
@@ -37,8 +37,14 @@
"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:contracts": "node scripts/check-command-contracts.mjs && node scripts/check-harness-contracts.mjs",
"test:rust": "cargo test --manifest-path \"src-tauri/Cargo.toml\"",
"harness:analysis": "node scripts/harness-analysis-brief.mjs",
"harness:eval": "node scripts/harness-eval-runner.mjs",
"harness:eval:json": "node scripts/harness-eval-runner.mjs --format json",
"harness:eval:promote": "node scripts/harness-replay-promote.mjs",
"harness:eval:trend": "node scripts/harness-eval-trend-report.mjs",
"harness:eval:trend:json": "node scripts/harness-eval-trend-report.mjs --format json",
"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",
@@ -56,6 +62,8 @@
"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:browser-runtime": "node scripts/browser-runtime-smoke.mjs",
"smoke:site-adapters": "node scripts/site-adapter-catalog-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",
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env node
import process from "node:process";
const DEFAULTS = {
healthUrl: "http://127.0.0.1:3030/health",
invokeUrl: "http://127.0.0.1:3030/invoke",
timeoutMs: 90_000,
intervalMs: 1_000,
launchUrl: "about:blank",
openWindow: false,
streamMode: "both",
};
function printHelp() {
console.log(`
Lime Browser Runtime Smoke
用途:
验证 browser runtime 最短主链可用:启动会话、读取状态、执行最小动作,并确认审计日志带出 session / target 关联键。
用法:
node scripts/browser-runtime-smoke.mjs [选项]
选项:
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
--timeout-ms <ms> 等待健康检查超时,默认 90000
--interval-ms <ms> 健康检查轮询间隔,默认 1000
--launch-url <url> 启动浏览器会话的 URL,默认 about:blank
--open-window 显式打开浏览器窗口
--stream-mode <mode> events | frames | both,默认 both
-h, --help 显示帮助
`);
}
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 === "--launch-url" && argv[index + 1]) {
options.launchUrl = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--stream-mode" && argv[index + 1]) {
options.streamMode = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--open-window") {
options.openWindow = true;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
}
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1_000) {
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
}
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
throw new Error("--interval-ms 必须是 >= 100 的数字");
}
if (!["events", "frames", "both"].includes(options.streamMode)) {
throw new Error("--stream-mode 只支持 events / frames / both");
}
if (!options.launchUrl) {
throw new Error("--launch-url 不能为空");
}
return options;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
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;
}
async function waitForHealth(options) {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < options.timeoutMs) {
try {
const response = await fetch(options.healthUrl, { method: "GET" });
const payload = await response.json();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
console.log(
`[smoke:browser-runtime] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
payload?.status ? ` status=${payload.status}` : ""
}`,
);
return;
} catch (error) {
lastError = error;
await sleep(options.intervalMs);
}
}
const detail =
lastError instanceof Error
? lastError.message
: String(lastError || "unknown error");
throw new Error(
`[smoke:browser-runtime] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`,
);
}
function findLatestAudit(logs, matcher) {
return (logs || []).find((item) => matcher(item));
}
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 profileKey = `smoke-browser-runtime-${Date.now()}`;
let sessionId = null;
try {
const launchResponse = await invoke(options.invokeUrl, "launch_browser_session", {
request: {
profile_key: profileKey,
url: options.launchUrl,
open_window: options.openWindow,
stream_mode: options.streamMode,
},
});
sessionId = launchResponse?.session?.session_id ?? null;
assert(
typeof sessionId === "string" && sessionId.trim(),
"launch_browser_session 未返回 session.session_id",
);
assert(
launchResponse?.session?.profile_key === profileKey,
"launch_browser_session 返回的 profile_key 与请求不一致",
);
const sessionState = await invoke(
options.invokeUrl,
"get_browser_session_state",
{
request: {
session_id: sessionId,
},
},
);
assert(
sessionState?.session_id === sessionId,
"get_browser_session_state 返回的 session_id 不一致",
);
assert(
sessionState?.profile_key === profileKey,
"get_browser_session_state 返回的 profile_key 不一致",
);
assert(
typeof sessionState?.target_id === "string" && sessionState.target_id.trim(),
"get_browser_session_state 未返回 target_id",
);
const actionResult = await invoke(options.invokeUrl, "browser_execute_action", {
request: {
profile_key: profileKey,
action: "read_page",
timeout_ms: 20_000,
},
});
assert(actionResult?.success === true, "browser_execute_action(read_page) 未成功");
assert(
actionResult?.session_id === sessionId,
"browser_execute_action 未返回对应的 session_id",
);
assert(
actionResult?.target_id === sessionState.target_id,
"browser_execute_action 未返回对应的 target_id",
);
const auditLogs = await invoke(options.invokeUrl, "get_browser_action_audit_logs", {
limit: 10,
});
const launchAudit = findLatestAudit(
auditLogs,
(item) =>
item?.kind === "launch" &&
item?.profile_key === profileKey &&
item?.session_id === sessionId,
);
assert(launchAudit, "未找到对应的 launch audit 记录");
assert(
launchAudit?.target_id === sessionState.target_id,
"launch audit 缺少 target_id 关联键",
);
const actionAudit = findLatestAudit(
auditLogs,
(item) =>
item?.kind === "action" &&
item?.action === "read_page" &&
item?.profile_key === profileKey,
);
assert(actionAudit, "未找到对应的 action audit 记录");
assert(
actionAudit?.session_id === sessionId,
`action audit 缺少 session_id 关联键,record=${actionAudit?.id ?? "unknown"}`,
);
assert(
actionAudit?.target_id === sessionState.target_id,
`action audit 缺少 target_id 关联键,record=${actionAudit?.id ?? "unknown"}`,
);
console.log(
`[smoke:browser-runtime] 通过 session=${sessionId} target=${sessionState.target_id} profile=${profileKey}`,
);
} finally {
if (sessionId) {
try {
await invoke(options.invokeUrl, "close_cdp_session", {
request: {
session_id: sessionId,
},
});
} catch (error) {
console.warn(
`[smoke:browser-runtime] 清理会话失败: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const repoRoot = process.cwd();
function readSource(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
function assertMatch(source, pattern, message, failures) {
if (!pattern.test(source)) {
failures.push(message);
}
}
function assertIncludes(source, needle, message, failures) {
if (!source.includes(needle)) {
failures.push(message);
}
}
function assertNotMatch(source, pattern, message, failures) {
if (pattern.test(source)) {
failures.push(message);
}
}
function extractBalancedBlock(sourceCode, marker, openChar, closeChar) {
const markerIndex = sourceCode.indexOf(marker);
if (markerIndex < 0) {
throw new Error(`未找到标记: ${marker}`);
}
const openIndex = sourceCode.indexOf(openChar, markerIndex);
if (openIndex < 0) {
throw new Error(`标记后未找到 ${openChar}: ${marker}`);
}
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inTemplateString = false;
let inLineComment = false;
let inBlockComment = false;
let escaped = false;
for (let index = openIndex; 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(openIndex + 1, index);
}
}
}
throw new Error(`无法提取 ${marker} 的平衡块`);
}
function main() {
const failures = [];
const harnessMetadataPath =
"src/components/agent/chat/utils/harnessRequestMetadata.ts";
const executionRuntimePath =
"src/components/agent/chat/utils/sessionExecutionRuntime.ts";
const requestMetadataPath =
"src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs";
const harnessMetadataSource = readSource(harnessMetadataPath);
const executionRuntimeSource = readSource(executionRuntimePath);
const requestMetadataSource = readSource(requestMetadataPath);
const legacyKeysBlock = extractBalancedBlock(
harnessMetadataSource,
"const LEGACY_HARNESS_STATE_KEYS = [",
"[",
"]",
);
const metadataBuilderBlock = extractBalancedBlock(
harnessMetadataSource,
"const metadata: Record<string, unknown> = {",
"{",
"}",
);
const requiredMetadataKeys = [
"preferences:",
"preferred_team_preset_id:",
"selected_team_id:",
"selected_team_source:",
"selected_team_label:",
"selected_team_description:",
"selected_team_summary:",
"selected_team_roles:",
"browser_requirement:",
"browser_requirement_reason:",
"browser_launch_url:",
"browser_assist:",
];
const forbiddenLegacyOutputKeys = [
"creation_mode:",
"creationMode:",
"chat_mode:",
"chatMode:",
"web_search_enabled:",
"webSearchEnabled:",
"thinking_enabled:",
"thinkingEnabled:",
"task_mode_enabled:",
"taskModeEnabled:",
"subagent_mode_enabled:",
"subagentModeEnabled:",
"turn_team_decision:",
"turnTeamDecision:",
"turn_team_reason:",
"turnTeamReason:",
"turn_team_blueprint:",
"turnTeamBlueprint:",
];
const requiredLegacyCleanupKeys = [
"creation_mode",
"chat_mode",
"web_search_enabled",
"thinking_enabled",
"task_mode_enabled",
"subagent_mode_enabled",
"turn_team_decision",
"turn_team_reason",
"turn_team_blueprint",
];
const requiredBackendMappings = [
'("preferred_team_preset_id", "preferred_team_preset_id")',
'("preferredTeamPresetId", "preferred_team_preset_id")',
'("selected_team_id", "selected_team_id")',
'("selectedTeamId", "selected_team_id")',
'("selected_team_source", "selected_team_source")',
'("selectedTeamSource", "selected_team_source")',
'("selected_team_label", "selected_team_label")',
'("selectedTeamLabel", "selected_team_label")',
'("selected_team_description", "selected_team_description")',
'("selectedTeamDescription", "selected_team_description")',
'("selected_team_summary", "selected_team_summary")',
'("selectedTeamSummary", "selected_team_summary")',
'("selected_team_roles", "selected_team_roles")',
'("selectedTeamRoles", "selected_team_roles")',
'("browser_requirement", "browser_requirement")',
'("browserRequirement", "browser_requirement")',
'("browser_requirement_reason", "browser_requirement_reason")',
'("browserRequirementReason", "browser_requirement_reason")',
'("browser_launch_url", "browser_launch_url")',
'("browserLaunchUrl", "browser_launch_url")',
];
const requiredRuntimeFields = [
"session_id:",
"execution_strategy:",
"recent_preferences:",
"recent_team_selection:",
"recent_content_id:",
];
requiredMetadataKeys.forEach((key) => {
assertIncludes(
metadataBuilderBlock,
key,
`[harness-contracts] 前端 metadata builder 缺少字段: ${key}`,
failures,
);
});
forbiddenLegacyOutputKeys.forEach((key) => {
assertNotMatch(
metadataBuilderBlock,
new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`),
`[harness-contracts] 前端 metadata builder 仍在输出 legacy 字段: ${key}`,
failures,
);
});
requiredLegacyCleanupKeys.forEach((key) => {
assertIncludes(
legacyKeysBlock,
`"${key}"`,
`[harness-contracts] LEGACY_HARNESS_STATE_KEYS 缺少清理项: ${key}`,
failures,
);
});
assertMatch(
harnessMetadataSource,
/preferences:\s*\{\s*web_search:\s*preferences\.webSearch,\s*thinking:\s*preferences\.thinking,\s*task:\s*preferences\.task,\s*subagent:\s*preferences\.subagent,\s*\}/s,
"[harness-contracts] 前端未按约定输出 preferences.web_search/thinking/task/subagent",
failures,
);
requiredBackendMappings.forEach((mapping) => {
assertIncludes(
requestMetadataSource,
mapping,
`[harness-contracts] 后端 request metadata 映射缺少字段: ${mapping}`,
failures,
);
});
assertMatch(
requestMetadataSource,
/\("web_search_enabled",\s*&\["web_search", "webSearch"\]\[\.\.\]\)/,
"[harness-contracts] 后端未从 preferences 回填 web_search_enabled",
failures,
);
assertIncludes(
requestMetadataSource,
'&["thinking", "thinking_enabled", "thinkingEnabled"][..]',
"[harness-contracts] 后端未从 preferences 回填 thinking_enabled",
failures,
);
assertMatch(
requestMetadataSource,
/\("task_mode_enabled",\s*&\["task", "task_mode", "taskMode"\]\[\.\.\]\)/,
"[harness-contracts] 后端未从 preferences 回填 task_mode_enabled",
failures,
);
assertIncludes(
requestMetadataSource,
'&["subagent", "subagent_mode", "subagentMode"][..]',
"[harness-contracts] 后端未从 preferences 回填 subagent_mode_enabled",
failures,
);
requiredRuntimeFields.forEach((field) => {
assertIncludes(
executionRuntimeSource,
field,
`[harness-contracts] execution runtime 缺少字段: ${field}`,
failures,
);
});
assertIncludes(
executionRuntimeSource,
"createSessionRecentPreferencesFromChatToolPreferences",
"[harness-contracts] execution runtime 缺少 recent preferences 适配函数",
failures,
);
assertIncludes(
executionRuntimeSource,
"createTeamDefinitionFromExecutionRuntimeRecentTeamSelection",
"[harness-contracts] execution runtime 缺少 recent team 反序列化函数",
failures,
);
assertIncludes(
executionRuntimeSource,
"createSessionRecentTeamSelectionFromTeamDefinition",
"[harness-contracts] execution runtime 缺少 recent team 序列化函数",
failures,
);
console.log("[harness-contracts] 检查文件:");
console.log(`- ${harnessMetadataPath}`);
console.log(`- ${executionRuntimePath}`);
console.log(`- ${requestMetadataPath}`);
if (failures.length > 0) {
console.error("\n[harness-contracts] 发现契约漂移:");
failures.forEach((failure) => {
console.error(`- ${failure}`);
});
process.exitCode = 1;
return;
}
console.log("\n[harness-contracts] Harness 契约检查通过。");
}
main();
+724
View File
@@ -0,0 +1,724 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const DEFAULT_SANITIZED_WORKSPACE_ROOT = "/workspace/lime";
const REQUIRED_REPLAY_ARTIFACTS = [
"input.json",
"expected.json",
"grader.md",
"evidence-links.json",
];
const HANDOFF_ARTIFACTS = [
"plan.md",
"progress.json",
"handoff.md",
"review-summary.md",
];
const EVIDENCE_ARTIFACTS = [
"summary.md",
"runtime.json",
"timeline.json",
"artifacts.json",
];
const ANALYSIS_BRIEF_FILE_NAME = "analysis-brief.md";
const ANALYSIS_CONTEXT_FILE_NAME = "analysis-context.json";
function parseArgs(argv) {
const result = {
dryRun: false,
format: "text",
help: false,
outputDir: "",
replayDir: "",
sanitizedWorkspaceRoot: DEFAULT_SANITIZED_WORKSPACE_ROOT,
sessionId: "",
title: "",
workspaceRoot: process.cwd(),
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--session-id" && argv[index + 1]) {
result.sessionId = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--replay-dir" && argv[index + 1]) {
result.replayDir = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--workspace-root" && argv[index + 1]) {
result.workspaceRoot = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--output-dir" && argv[index + 1]) {
result.outputDir = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--title" && argv[index + 1]) {
result.title = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--sanitized-workspace-root" && argv[index + 1]) {
result.sanitizedWorkspaceRoot = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--format" && argv[index + 1]) {
result.format = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--dry-run") {
result.dryRun = true;
continue;
}
if (arg === "--help" || arg === "-h") {
result.help = true;
}
}
return result;
}
function printHelp() {
console.log(`
Lime Harness Analysis Brief Export
用法:
node scripts/harness-analysis-brief.mjs --session-id "session-123"
node scripts/harness-analysis-brief.mjs --replay-dir ".lime/harness/sessions/session-123/replay"
选项:
--session-id ID 从 <workspace>/.lime/harness/sessions/<id>/replay 生成分析交接包
--replay-dir PATH 直接指定 replay 目录;与 --session-id 二选一
--workspace-root PATH 工作区根目录,默认当前目录
--output-dir PATH 输出目录;默认 <session>/analysis
--title TEXT 分析包标题;默认从 goal summary 推导
--sanitized-workspace-root PATH 导出到外部 AI 时使用的工作区占位路径,默认 /workspace/lime
--dry-run 只预览,不写文件
--format FMT 标准输出格式:text | json
-h, --help 显示帮助
`);
}
function resolvePath(baseDir, targetPath) {
return path.resolve(baseDir, targetPath);
}
function toPortablePath(value) {
return String(value).replaceAll("\\", "/");
}
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJsonFile(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function ensureDirectory(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function truncateText(value, maxLength = 800) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (trimmed.length <= maxLength) {
return trimmed;
}
return `${trimmed.slice(0, maxLength)}…`;
}
function normalizeStringList(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
}
function replaceWorkspaceRootInString(value, workspaceRoot, placeholder) {
if (typeof value !== "string" || value.length === 0) {
return value;
}
let nextValue = value;
const rawRoot = String(workspaceRoot);
const portableRoot = toPortablePath(rawRoot);
if (rawRoot) {
nextValue = nextValue.replaceAll(rawRoot, placeholder);
}
if (portableRoot && portableRoot !== rawRoot) {
nextValue = nextValue.replaceAll(portableRoot, placeholder);
}
if (nextValue.includes(placeholder) && nextValue.includes("\\")) {
nextValue = nextValue.replaceAll("\\", "/");
}
return nextValue;
}
function sanitizeValue(value, workspaceRoot, placeholder) {
if (typeof value === "string") {
return replaceWorkspaceRootInString(value, workspaceRoot, placeholder);
}
if (Array.isArray(value)) {
return value.map((entry) => sanitizeValue(entry, workspaceRoot, placeholder));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entryValue]) => [
key,
sanitizeValue(entryValue, workspaceRoot, placeholder),
]),
);
}
return value;
}
function resolveReplayDirectory(options, workspaceRoot) {
if (options.replayDir) {
return resolvePath(process.cwd(), options.replayDir);
}
if (!options.sessionId) {
throw new Error("必须提供 --session-id 或 --replay-dir。");
}
return path.join(
workspaceRoot,
".lime",
"harness",
"sessions",
options.sessionId,
"replay",
);
}
function validateReplayDirectory(replayDir) {
if (!fs.existsSync(replayDir) || !fs.statSync(replayDir).isDirectory()) {
throw new Error(`replay 目录不存在: ${replayDir}`);
}
const missing = REQUIRED_REPLAY_ARTIFACTS.filter(
(artifact) => !fs.existsSync(path.join(replayDir, artifact)),
);
if (missing.length > 0) {
throw new Error(`replay 目录缺少文件: ${missing.join(", ")}`);
}
}
function deriveSessionRootFromReplayDirectory(replayDir) {
if (path.basename(replayDir) === "replay") {
return path.dirname(replayDir);
}
return replayDir;
}
function safeReadFile(filePath) {
if (!fs.existsSync(filePath)) {
return null;
}
return fs.readFileSync(filePath, "utf8");
}
function safeReadJson(filePath) {
if (!fs.existsSync(filePath)) {
return null;
}
return readJsonFile(filePath);
}
function sanitizeAbsolutePathForExternalUse(absolutePath, workspaceRoot, placeholder) {
const relativePath = path.relative(workspaceRoot, absolutePath);
if (
!relativePath.startsWith("..") &&
!path.isAbsolute(relativePath) &&
relativePath !== ""
) {
return toPortablePath(path.join(placeholder, relativePath));
}
return "";
}
function listExistingArtifacts(rootPath, artifactNames, workspaceRoot, placeholder) {
return artifactNames.map((fileName) => {
const absolutePath = path.join(rootPath, fileName);
const exists = fs.existsSync(absolutePath);
return {
fileName,
exists,
absolutePath: exists
? sanitizeAbsolutePathForExternalUse(
absolutePath,
workspaceRoot,
placeholder,
)
: "",
relativePath: exists ? toPortablePath(path.relative(rootPath, absolutePath)) : "",
};
});
}
function deriveTitle(options, inputPayload, replayDir) {
if (options.title) {
return options.title;
}
const goalSummary = inputPayload?.task?.goalSummary;
if (typeof goalSummary === "string" && goalSummary.trim().length > 0) {
return goalSummary.trim();
}
const sessionId =
inputPayload?.session?.sessionId ?? path.basename(path.dirname(replayDir));
return `外部分析交接 / ${sessionId}`;
}
function buildReadingOrder(handoffArtifacts, evidenceArtifacts) {
const order = ["先读 replay/input.json 与 replay/expected.json,确认任务目标与判定标准。"];
if (handoffArtifacts.some((entry) => entry.fileName === "handoff.md" && entry.exists)) {
order.push("再读 handoff/handoff.md 与 handoff/progress.json,确认当前状态、待继续事项与恢复顺序。");
}
if (evidenceArtifacts.some((entry) => entry.fileName === "summary.md" && entry.exists)) {
order.push("再读 evidence/summary.md 与 evidence/runtime.json,确认当前阻塞、pending request 与 diagnostics。");
}
if (evidenceArtifacts.some((entry) => entry.fileName === "timeline.json" && entry.exists)) {
order.push("如需复盘过程,再读 evidence/timeline.json。");
}
order.push("最后回看 replay/grader.md,按约定输出根因、修复建议、回归建议与风险项。");
return order;
}
function buildExternalAnalysisPromptContract() {
return {
audience: "Claude Code / Codex",
task: "基于 Lime 导出的结构化证据做问题分析与修复建议,不直接代替团队做最终决策。",
requiredSections: [
"结论",
"根因判断",
"关键证据",
"修复建议",
"回归建议",
"风险与未知项",
],
rules: [
"优先引用现有证据文件,不要求重建完整会话。",
"如果证据不足,显式列出缺口,不要假装已经确认。",
"只给分析与建议,不直接替团队批准或拒绝修复方案。",
"如果怀疑路径、凭证或外部系统状态影响结论,先标注为待人工复核。",
],
};
}
function buildHumanReviewChecklist(inputPayload, expectedPayload) {
const checklist = [
"确认外部 AI 是否引用了现有证据,而不是凭空推断。",
"确认修复建议是否直接服务当前失败模式,而不是顺手扩大范围。",
"确认回归建议是否能沉淀为 replay / eval / smoke,而不是停留在口头建议。",
];
if (expectedPayload?.graderSuggestion?.requiresHumanReview === true) {
checklist.unshift("当前样本本来就要求人工复核,不应把外部 AI 结论当成最终裁决。");
}
if (
normalizeStringList(inputPayload?.classification?.failureModes).includes(
"pending_request",
)
) {
checklist.push("确认外部 AI 没有把 pending request 误判成已完成。");
}
return checklist;
}
function buildAnalysisContext({
evidenceArtifacts,
evidenceJson,
evidenceRoot,
expectedPayload,
handoffArtifacts,
handoffJson,
inputPayload,
options,
replayDir,
replayRootArtifacts,
sessionRoot,
title,
workspaceRoot,
}) {
const sanitizedInput = sanitizeValue(
{
session: inputPayload?.session ?? {},
task: inputPayload?.task ?? {},
classification: inputPayload?.classification ?? {},
runtimeContext: {
pendingRequests: inputPayload?.runtimeContext?.pendingRequests ?? [],
queuedTurns: inputPayload?.runtimeContext?.queuedTurns ?? [],
todoItems: inputPayload?.runtimeContext?.todoItems ?? [],
activeSubagents: inputPayload?.runtimeContext?.activeSubagents ?? [],
},
linkedArtifacts: inputPayload?.linkedArtifacts ?? {},
},
workspaceRoot,
options.sanitizedWorkspaceRoot,
);
const sanitizedExpected = sanitizeValue(
{
goalSummary: expectedPayload?.goalSummary ?? "",
successCriteria: expectedPayload?.successCriteria ?? [],
blockingChecks: expectedPayload?.blockingChecks ?? [],
artifactChecks: expectedPayload?.artifactChecks ?? [],
graderSuggestion: expectedPayload?.graderSuggestion ?? {},
nonGoals: expectedPayload?.nonGoals ?? [],
},
workspaceRoot,
options.sanitizedWorkspaceRoot,
);
return {
schemaVersion: "v1",
source: {
contractShape: "lime_external_analysis_handoff",
derivedFrom: [
"lime_workspace_handoff_bundle",
"lime_workspace_evidence_pack",
"lime_runtime_export_replay_case",
],
},
title,
exportedAt: new Date().toISOString(),
sanitizedWorkspaceRoot: options.sanitizedWorkspaceRoot,
replayRoot:
sanitizeAbsolutePathForExternalUse(
replayDir,
workspaceRoot,
options.sanitizedWorkspaceRoot,
) || "",
summary: {
sessionId: sanitizedInput.session.sessionId ?? "",
threadId: sanitizedInput.session.threadId ?? "",
executionStrategy: sanitizedInput.session.executionStrategy ?? "",
model: sanitizedInput.session.model ?? "",
goalSummary: sanitizedInput.task.goalSummary ?? "",
latestTurnStatus:
sanitizedInput.task.latestTurnStatus ??
handoffJson?.status?.latestTurnStatus ??
evidenceJson?.thread?.latestTurnStatus ??
"",
threadStatus:
sanitizedInput.task.threadStatus ??
handoffJson?.status?.threadStatus ??
evidenceJson?.thread?.status ??
"",
primaryBlockingKind:
sanitizedInput.classification.primaryBlockingKind ??
handoffJson?.diagnostics?.primaryBlockingKind ??
evidenceJson?.thread?.diagnostics?.primaryBlockingKind ??
"",
primaryBlockingSummary:
sanitizedInput.task.primaryBlockingSummary ??
handoffJson?.diagnostics?.primaryBlockingSummary ??
evidenceJson?.thread?.diagnostics?.primaryBlockingSummary ??
"",
failureModes: sanitizedInput.classification.failureModes ?? [],
suiteTags: sanitizedInput.classification.suiteTags ?? [],
pendingRequestCount:
Array.isArray(sanitizedInput.runtimeContext.pendingRequests)
? sanitizedInput.runtimeContext.pendingRequests.length
: handoffJson?.status?.pendingRequestCount ??
evidenceJson?.thread?.pendingRequestCount ??
0,
queuedTurnCount:
Array.isArray(sanitizedInput.runtimeContext.queuedTurns)
? sanitizedInput.runtimeContext.queuedTurns.length
: handoffJson?.status?.queuedTurnCount ??
evidenceJson?.thread?.queuedTurnCount ??
0,
},
replay: {
artifacts: replayRootArtifacts,
graderExcerpt: truncateText(
sanitizeValue(
safeReadFile(path.join(replayDir, "grader.md")) ?? "",
workspaceRoot,
options.sanitizedWorkspaceRoot,
),
),
input: sanitizedInput,
expected: sanitizedExpected,
},
handoff: {
artifacts: handoffArtifacts,
progress: sanitizeValue(handoffJson ?? {}, workspaceRoot, options.sanitizedWorkspaceRoot),
handoffExcerpt: truncateText(
sanitizeValue(
safeReadFile(path.join(sessionRoot, "handoff.md")) ?? "",
workspaceRoot,
options.sanitizedWorkspaceRoot,
),
),
reviewSummaryExcerpt: truncateText(
sanitizeValue(
safeReadFile(path.join(sessionRoot, "review-summary.md")) ?? "",
workspaceRoot,
options.sanitizedWorkspaceRoot,
),
),
},
evidence: {
artifacts: evidenceArtifacts,
runtime: sanitizeValue(evidenceJson ?? {}, workspaceRoot, options.sanitizedWorkspaceRoot),
summaryExcerpt: truncateText(
sanitizeValue(
safeReadFile(path.join(evidenceRoot, "summary.md")) ?? "",
workspaceRoot,
options.sanitizedWorkspaceRoot,
),
),
},
readingOrder: buildReadingOrder(handoffArtifacts, evidenceArtifacts),
externalAnalysisContract: buildExternalAnalysisPromptContract(),
humanReviewChecklist: buildHumanReviewChecklist(inputPayload, expectedPayload),
};
}
function renderArtifactList(artifacts, labelPrefix) {
const available = artifacts.filter((entry) => entry.exists);
if (available.length === 0) {
return ["- 当前未检测到可用文件。"];
}
return available.map(
(entry) =>
`- \`${labelPrefix}${entry.relativePath}\`${
entry.absolutePath ? ` (${entry.absolutePath})` : ""
}`,
);
}
function buildAnalysisBrief(context) {
const lines = [
"# 外部分析交接简报",
"",
`- 标题:${context.title}`,
`- 生成时间:${context.exportedAt}`,
`- 会话:\`${context.summary.sessionId || "unknown"}\``,
`- 线程:\`${context.summary.threadId || "unknown"}\``,
`- 执行策略:${context.summary.executionStrategy || "unknown"}`,
`- 模型:${context.summary.model || "unknown"}`,
"",
"## 当前问题",
"",
`- 目标摘要:${context.summary.goalSummary || "未知"}`,
`- 线程状态:${context.summary.threadStatus || "未知"}`,
`- 最新 turn 状态:${context.summary.latestTurnStatus || "未知"}`,
`- 主要阻塞:${context.summary.primaryBlockingKind || "未知"}${context.summary.primaryBlockingSummary ? ` · ${context.summary.primaryBlockingSummary}` : ""}`,
`- failure modes:${
context.summary.failureModes.length > 0
? context.summary.failureModes.join(", ")
: "无"
}`,
`- suite tags:${
context.summary.suiteTags.length > 0
? context.summary.suiteTags.join(", ")
: "无"
}`,
`- pending request:${context.summary.pendingRequestCount}`,
`- queued turn:${context.summary.queuedTurnCount}`,
"",
"## 推荐读取顺序",
"",
...context.readingOrder.map((entry, index) => `${index + 1}. ${entry}`),
"",
"## Replay 文件",
"",
...renderArtifactList(context.replay.artifacts, "replay/"),
"",
"## Handoff 文件",
"",
...renderArtifactList(context.handoff.artifacts, ""),
"",
"## Evidence 文件",
"",
...renderArtifactList(context.evidence.artifacts, "evidence/"),
"",
"## 可直接给外部 AI 的任务说明",
"",
"```text",
"你将收到一个由 Lime 导出的分析包。你的职责是做问题分析和修复建议,不直接替团队做最终决策。",
"",
"请优先读取 analysis-context.json 与 analysis-brief.md 中提到的 replay / handoff / evidence 文件。",
"",
"输出必须包含以下部分:",
"- 结论",
"- 根因判断",
"- 关键证据",
"- 修复建议",
"- 回归建议",
"- 风险与未知项",
"",
"约束:",
"- 优先引用现有证据,不要假装看到不存在的信息。",
"- 如果证据不足,明确写出缺口和需要人工确认的地方。",
"- 不直接代表团队批准、拒绝或自动应用修复方案。",
"```",
"",
"## 人工审核检查清单",
"",
...context.humanReviewChecklist.map((entry) => `- ${entry}`),
"",
"## 关键摘录",
"",
"### Replay Grader 摘录",
"",
context.replay.graderExcerpt || "当前无可用摘录。",
"",
"### Handoff 摘录",
"",
context.handoff.handoffExcerpt || "当前无可用摘录。",
"",
"### Evidence 摘录",
"",
context.evidence.summaryExcerpt || "当前无可用摘录。",
"",
"## 注意",
"",
`- 所有路径默认已按 \`${context.sanitizedWorkspaceRoot}\` 占位规则输出,便于外部 AI 消费。`,
"- 这份简报只负责分析交接,不负责自动修复或自动回写 Lime。",
"",
];
return `${lines.join("\n")}\n`;
}
function renderText(result) {
return [
`[harness-analysis] title : ${result.title}`,
`[harness-analysis] replay: ${result.replayDir}`,
`[harness-analysis] output: ${result.outputDir}`,
`[harness-analysis] brief : ${result.briefPath}`,
`[harness-analysis] json : ${result.contextPath}`,
`[harness-analysis] dry-run: ${result.dryRun ? "yes" : "no"}`,
].join("\n").concat("\n");
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const workspaceRoot = resolvePath(process.cwd(), options.workspaceRoot);
const replayDir = resolveReplayDirectory(options, workspaceRoot);
validateReplayDirectory(replayDir);
const inputPayload = readJsonFile(path.join(replayDir, "input.json"));
const expectedPayload = readJsonFile(path.join(replayDir, "expected.json"));
const sessionRoot = deriveSessionRootFromReplayDirectory(replayDir);
const evidenceRoot = path.join(sessionRoot, "evidence");
const outputDir = options.outputDir
? resolvePath(process.cwd(), options.outputDir)
: path.join(sessionRoot, "analysis");
const workspaceRootFromInput =
inputPayload?.session?.workspaceRoot && typeof inputPayload.session.workspaceRoot === "string"
? path.resolve(inputPayload.session.workspaceRoot)
: workspaceRoot;
const replayRootArtifacts = listExistingArtifacts(
replayDir,
REQUIRED_REPLAY_ARTIFACTS,
workspaceRootFromInput,
options.sanitizedWorkspaceRoot,
);
const handoffArtifacts = listExistingArtifacts(
sessionRoot,
HANDOFF_ARTIFACTS,
workspaceRootFromInput,
options.sanitizedWorkspaceRoot,
);
const evidenceArtifacts = listExistingArtifacts(
evidenceRoot,
EVIDENCE_ARTIFACTS,
workspaceRootFromInput,
options.sanitizedWorkspaceRoot,
);
const handoffJson = safeReadJson(path.join(sessionRoot, "progress.json"));
const evidenceJson = safeReadJson(path.join(evidenceRoot, "runtime.json"));
const title = deriveTitle(options, inputPayload, replayDir);
const analysisContext = buildAnalysisContext({
evidenceArtifacts,
evidenceJson,
evidenceRoot,
expectedPayload,
handoffArtifacts,
handoffJson,
inputPayload,
options,
replayDir,
replayRootArtifacts,
sessionRoot,
title,
workspaceRoot: workspaceRootFromInput,
});
const analysisBrief = buildAnalysisBrief(analysisContext);
const briefPath = path.join(outputDir, ANALYSIS_BRIEF_FILE_NAME);
const contextPath = path.join(outputDir, ANALYSIS_CONTEXT_FILE_NAME);
if (!options.dryRun) {
ensureDirectory(outputDir);
fs.writeFileSync(briefPath, analysisBrief, "utf8");
writeJsonFile(contextPath, analysisContext);
}
const result = {
briefPath: toPortablePath(briefPath),
contextPath: toPortablePath(contextPath),
dryRun: options.dryRun,
outputDir: toPortablePath(outputDir),
replayDir: toPortablePath(replayDir),
title,
};
if (options.format === "json") {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
process.stdout.write(renderText(result));
}
main();
+700
View File
@@ -0,0 +1,700 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const DEFAULT_MANIFEST_PATH = "docs/test/harness-evals.manifest.json";
function parseArgs(argv) {
const result = {
format: "text",
help: false,
manifest: DEFAULT_MANIFEST_PATH,
outputJson: "",
outputMarkdown: "",
strict: true,
workspaceRoot: process.cwd(),
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--manifest" && argv[index + 1]) {
result.manifest = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--workspace-root" && argv[index + 1]) {
result.workspaceRoot = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--format" && argv[index + 1]) {
result.format = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--output-json" && argv[index + 1]) {
result.outputJson = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--output-markdown" && argv[index + 1]) {
result.outputMarkdown = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--no-strict") {
result.strict = false;
continue;
}
if (arg === "--strict") {
result.strict = true;
continue;
}
if (arg === "--help" || arg === "-h") {
result.help = true;
}
}
return result;
}
function printHelp() {
console.log(`
Lime Harness Eval Runner
用法:
node scripts/harness-eval-runner.mjs
node scripts/harness-eval-runner.mjs --format json
node scripts/harness-eval-runner.mjs --workspace-root "/path/to/workspace"
node scripts/harness-eval-runner.mjs --output-json "./tmp/harness-eval-summary.json" --output-markdown "./tmp/harness-eval-summary.md"
选项:
--manifest PATH 指定 manifest,默认 docs/test/harness-evals.manifest.json
--workspace-root PATH 指定工作区根目录,默认当前目录
--format FMT 控制标准输出格式:text | json | markdown
--output-json PATH 将 JSON 摘要写入指定路径
--output-markdown PATH 将 Markdown 摘要写入指定路径
--strict 严格模式(默认),发现 invalid case 时返回非 0
--no-strict 非严格模式,只输出摘要,不因 invalid case 退出失败
-h, --help 显示帮助
`);
}
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function resolvePath(baseDir, relativePath) {
return path.resolve(baseDir, relativePath);
}
function ensureParentDirectory(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function normalizeStringList(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
}
function mergeUniqueStrings(...groups) {
return [...new Set(groups.flatMap((group) => normalizeStringList(group)))];
}
function createBreakdownEntry(name) {
return {
name,
caseCount: 0,
readyCount: 0,
invalidCount: 0,
pendingRequestCaseCount: 0,
needsHumanReviewCount: 0,
};
}
function aggregateCaseBreakdown(cases, selector) {
const breakdownMap = new Map();
for (const entry of cases) {
const labels = mergeUniqueStrings(selector(entry));
for (const label of labels) {
const current = breakdownMap.get(label) ?? createBreakdownEntry(label);
current.caseCount += 1;
if (entry.status === "ready") {
current.readyCount += 1;
} else if (entry.status === "invalid") {
current.invalidCount += 1;
}
if (entry.pendingRequestCount > 0) {
current.pendingRequestCaseCount += 1;
}
if (entry.requiresHumanReview) {
current.needsHumanReviewCount += 1;
}
breakdownMap.set(label, current);
}
}
return Array.from(breakdownMap.values()).sort((left, right) => {
if (right.caseCount !== left.caseCount) {
return right.caseCount - left.caseCount;
}
return left.name.localeCompare(right.name);
});
}
function getValueByPath(target, dottedPath) {
return dottedPath
.split(".")
.reduce(
(current, segment) => (current == null ? undefined : current[segment]),
target,
);
}
function isPresentValue(value) {
if (value == null) {
return false;
}
if (typeof value === "string") {
return value.trim().length > 0;
}
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
}
function collectFieldIssues(jsonPayload, fields, label) {
const issues = [];
for (const field of fields) {
if (!isPresentValue(getValueByPath(jsonPayload, field))) {
issues.push(`${label} 缺少字段: ${field}`);
}
}
return issues;
}
function listReplayDirectories(rootPath) {
if (!fs.existsSync(rootPath)) {
return [];
}
const entries = fs.readdirSync(rootPath, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(rootPath, entry.name, "replay"))
.filter((replayPath) => {
try {
return fs.statSync(replayPath).isDirectory();
} catch {
return false;
}
})
.sort((left, right) => left.localeCompare(right));
}
function validateCaseDirectory(caseDir, caseConfig, defaults, context) {
const requiredArtifacts = normalizeStringList(
caseConfig.requiredArtifacts ?? defaults.requiredArtifacts,
);
const requiredInputFields = normalizeStringList(
caseConfig.requiredInputFields ?? defaults.requiredInputFields,
);
const requiredExpectedFields = normalizeStringList(
caseConfig.requiredExpectedFields ?? defaults.requiredExpectedFields,
);
const requiredEvidenceFields = normalizeStringList(
caseConfig.requiredEvidenceFields ?? defaults.requiredEvidenceFields,
);
const issues = [];
const resolvedCaseDir = path.resolve(caseDir);
const files = {};
for (const artifactName of requiredArtifacts) {
const artifactPath = path.join(resolvedCaseDir, artifactName);
files[artifactName] = artifactPath;
if (!fs.existsSync(artifactPath)) {
issues.push(`缺少文件: ${artifactName}`);
}
}
let inputPayload = null;
let expectedPayload = null;
let evidencePayload = null;
if (fs.existsSync(files["input.json"] ?? "")) {
try {
inputPayload = readJsonFile(files["input.json"]);
issues.push(
...collectFieldIssues(inputPayload, requiredInputFields, "input.json"),
);
} catch (error) {
issues.push(`input.json 解析失败: ${String(error.message ?? error)}`);
}
}
if (fs.existsSync(files["expected.json"] ?? "")) {
try {
expectedPayload = readJsonFile(files["expected.json"]);
issues.push(
...collectFieldIssues(
expectedPayload,
requiredExpectedFields,
"expected.json",
),
);
} catch (error) {
issues.push(`expected.json 解析失败: ${String(error.message ?? error)}`);
}
}
if (fs.existsSync(files["evidence-links.json"] ?? "")) {
try {
evidencePayload = readJsonFile(files["evidence-links.json"]);
issues.push(
...collectFieldIssues(
evidencePayload,
requiredEvidenceFields,
"evidence-links.json",
),
);
} catch (error) {
issues.push(
`evidence-links.json 解析失败: ${String(error.message ?? error)}`,
);
}
}
const pendingRequestCount = Array.isArray(
inputPayload?.runtimeContext?.pendingRequests,
)
? inputPayload.runtimeContext.pendingRequests.length
: 0;
const classificationTags = mergeUniqueStrings(
context.tags,
inputPayload?.classification?.suiteTags,
);
const failureModes = normalizeStringList(
inputPayload?.classification?.failureModes,
);
const primaryBlockingKind =
typeof inputPayload?.classification?.primaryBlockingKind === "string"
? inputPayload.classification.primaryBlockingKind.trim()
: "";
const requiresHumanReview =
expectedPayload?.graderSuggestion?.requiresHumanReview === true;
const preferredMode =
typeof expectedPayload?.graderSuggestion?.preferredMode === "string"
? expectedPayload.graderSuggestion.preferredMode
: "";
return {
caseId: context.caseId,
title: context.title,
suiteId: context.suiteId,
suiteTitle: context.suiteTitle,
source: context.source,
priority: context.priority ?? "",
tags: classificationTags,
failureModes,
primaryBlockingKind,
caseDir: resolvedCaseDir,
relativeCaseDir: path.relative(context.repoRoot, resolvedCaseDir) || ".",
sessionId:
inputPayload?.session?.sessionId ??
expectedPayload?.sessionId ??
path.basename(path.dirname(resolvedCaseDir)),
threadId:
inputPayload?.session?.threadId ?? expectedPayload?.threadId ?? "",
goalSummary:
inputPayload?.task?.goalSummary ?? expectedPayload?.goalSummary ?? "",
pendingRequestCount,
requiresHumanReview,
preferredMode,
status: issues.length === 0 ? "ready" : "invalid",
issues,
};
}
function expandSuiteCases(suiteConfig, defaults, repoRoot, workspaceRoot) {
const suiteCases = [];
const configuredCases = Array.isArray(suiteConfig.cases)
? suiteConfig.cases
: [];
for (const caseConfig of configuredCases) {
const source = String(caseConfig.source ?? "").trim();
if (source === "repo_fixture") {
const caseDir = resolvePath(repoRoot, String(caseConfig.caseDir ?? ""));
suiteCases.push(
validateCaseDirectory(caseDir, caseConfig, defaults, {
caseId: String(caseConfig.id ?? "unnamed-case"),
priority: suiteConfig.priority,
repoRoot,
source,
suiteId: String(suiteConfig.id ?? "unnamed-suite"),
suiteTitle: String(suiteConfig.title ?? "未命名 Suite"),
tags: caseConfig.tags,
title: String(caseConfig.title ?? caseConfig.id ?? "未命名 Case"),
}),
);
continue;
}
if (source === "workspace_replay_discovery") {
const discoveryRoot = resolvePath(
workspaceRoot,
String(caseConfig.root ?? ".lime/harness/sessions"),
);
const replayDirectories = listReplayDirectories(discoveryRoot);
if (
replayDirectories.length === 0 &&
caseConfig.allowZeroMatches !== true
) {
suiteCases.push({
caseId: String(caseConfig.id ?? "workspace-discovery"),
title: String(caseConfig.title ?? "工作区 Replay 自动发现"),
suiteId: String(suiteConfig.id ?? "unnamed-suite"),
suiteTitle: String(suiteConfig.title ?? "未命名 Suite"),
source,
priority: suiteConfig.priority ?? "",
tags: normalizeStringList(caseConfig.tags),
failureModes: [],
primaryBlockingKind: "",
caseDir: discoveryRoot,
relativeCaseDir: path.relative(repoRoot, discoveryRoot) || ".",
sessionId: "",
threadId: "",
goalSummary: "",
pendingRequestCount: 0,
requiresHumanReview: false,
preferredMode: "",
status: "invalid",
issues: [
`未发现 replay case 目录: ${path.relative(workspaceRoot, discoveryRoot) || "."}`,
],
});
continue;
}
for (const replayDir of replayDirectories) {
const sessionId = path.basename(path.dirname(replayDir));
suiteCases.push(
validateCaseDirectory(replayDir, caseConfig, defaults, {
caseId: `${String(caseConfig.id ?? "workspace-case")}:${sessionId}`,
priority: suiteConfig.priority,
repoRoot,
source,
suiteId: String(suiteConfig.id ?? "unnamed-suite"),
suiteTitle: String(suiteConfig.title ?? "未命名 Suite"),
tags: caseConfig.tags,
title: `${String(caseConfig.title ?? "工作区 Replay 样本")} / ${sessionId}`,
}),
);
}
continue;
}
suiteCases.push({
caseId: String(caseConfig.id ?? "unknown-case"),
title: String(caseConfig.title ?? "未命名 Case"),
suiteId: String(suiteConfig.id ?? "unnamed-suite"),
suiteTitle: String(suiteConfig.title ?? "未命名 Suite"),
source,
priority: suiteConfig.priority ?? "",
tags: normalizeStringList(caseConfig.tags),
failureModes: [],
primaryBlockingKind: "",
caseDir: "",
relativeCaseDir: "",
sessionId: "",
threadId: "",
goalSummary: "",
pendingRequestCount: 0,
requiresHumanReview: false,
preferredMode: "",
status: "invalid",
issues: [`不支持的 case source: ${source || "(empty)"}`],
});
}
const readyCount = suiteCases.filter(
(entry) => entry.status === "ready",
).length;
const invalidCount = suiteCases.length - readyCount;
const discoveredCount = suiteCases.filter(
(entry) => entry.source === "workspace_replay_discovery",
).length;
return {
id: String(suiteConfig.id ?? "unnamed-suite"),
title: String(suiteConfig.title ?? "未命名 Suite"),
priority: String(suiteConfig.priority ?? ""),
roadmap: String(suiteConfig.roadmap ?? ""),
description: String(suiteConfig.description ?? ""),
upstream: suiteConfig.upstream ?? {},
cases: suiteCases,
stats: {
configuredCaseCount: configuredCases.length,
discoveredCaseCount: discoveredCount,
caseCount: suiteCases.length,
readyCount,
invalidCount,
},
};
}
function buildSummary(manifest, suites, options) {
const allCases = suites.flatMap((suite) => suite.cases);
const readyCases = allCases.filter((entry) => entry.status === "ready");
const invalidCases = allCases.filter((entry) => entry.status === "invalid");
const reviewCases = allCases.filter((entry) => entry.requiresHumanReview);
const pendingCases = allCases.filter(
(entry) => entry.pendingRequestCount > 0,
);
return {
manifestVersion: String(manifest.manifestVersion ?? "unknown"),
title: String(manifest.title ?? "Lime Harness Eval Summary"),
generatedAt: new Date().toISOString(),
repoRoot: process.cwd(),
workspaceRoot: path.resolve(options.workspaceRoot),
strict: options.strict,
totals: {
suiteCount: suites.length,
caseCount: allCases.length,
readyCount: readyCases.length,
invalidCount: invalidCases.length,
needsHumanReviewCount: reviewCases.length,
pendingRequestCaseCount: pendingCases.length,
},
breakdowns: {
suiteTags: aggregateCaseBreakdown(allCases, (entry) => entry.tags),
failureModes: aggregateCaseBreakdown(
allCases,
(entry) => entry.failureModes,
),
},
suites,
};
}
function renderText(summary) {
const lines = [
`[harness-eval] manifest: ${summary.title} (${summary.manifestVersion})`,
`[harness-eval] workspace: ${summary.workspaceRoot}`,
`[harness-eval] suites: ${summary.totals.suiteCount}`,
`[harness-eval] cases : ${summary.totals.caseCount}`,
`[harness-eval] ready : ${summary.totals.readyCount}`,
`[harness-eval] invalid: ${summary.totals.invalidCount}`,
`[harness-eval] pending-request cases: ${summary.totals.pendingRequestCaseCount}`,
`[harness-eval] needs-review cases : ${summary.totals.needsHumanReviewCount}`,
];
const topFailureModes = summary.breakdowns.failureModes.slice(0, 5);
if (topFailureModes.length > 0) {
lines.push("[harness-eval] top failure modes:");
for (const entry of topFailureModes) {
lines.push(
` - ${entry.name}: case=${entry.caseCount}, invalid=${entry.invalidCount}, pending=${entry.pendingRequestCaseCount}`,
);
}
}
const topSuiteTags = summary.breakdowns.suiteTags.slice(0, 5);
if (topSuiteTags.length > 0) {
lines.push("[harness-eval] top suite tags:");
for (const entry of topSuiteTags) {
lines.push(
` - ${entry.name}: case=${entry.caseCount}, ready=${entry.readyCount}, invalid=${entry.invalidCount}`,
);
}
}
for (const suite of summary.suites) {
lines.push(
`[harness-eval] suite ${suite.id}: ready ${suite.stats.readyCount} / ${suite.stats.caseCount}`,
);
for (const entry of suite.cases) {
lines.push(
` - ${entry.caseId} [${entry.status}] (${entry.source}) ${entry.relativeCaseDir}`,
);
if (entry.tags.length > 0) {
lines.push(` tags: ${entry.tags.join(", ")}`);
}
if (entry.failureModes.length > 0) {
lines.push(` failure_modes: ${entry.failureModes.join(", ")}`);
}
for (const issue of entry.issues) {
lines.push(` * ${issue}`);
}
}
}
return `${lines.join("\n")}\n`;
}
function renderMarkdown(summary) {
const lines = [
"# Lime Harness Eval Summary",
"",
`- 生成时间:${summary.generatedAt}`,
`- manifest:${summary.title} (${summary.manifestVersion})`,
`- 工作区:\`${summary.workspaceRoot}\``,
`- suite 数:${summary.totals.suiteCount}`,
`- case 数:${summary.totals.caseCount}`,
`- ready:${summary.totals.readyCount}`,
`- invalid:${summary.totals.invalidCount}`,
`- pending request case:${summary.totals.pendingRequestCaseCount}`,
`- needs review case:${summary.totals.needsHumanReviewCount}`,
"",
];
if (summary.breakdowns.failureModes.length > 0) {
lines.push("## Failure Mode 分布");
lines.push("");
lines.push(
"| Failure Mode | case | invalid | pending_request | needs_review |",
);
lines.push("| --- | --- | --- | --- | --- |");
for (const entry of summary.breakdowns.failureModes) {
lines.push(
`| ${entry.name} | ${entry.caseCount} | ${entry.invalidCount} | ${entry.pendingRequestCaseCount} | ${entry.needsHumanReviewCount} |`,
);
}
lines.push("");
}
if (summary.breakdowns.suiteTags.length > 0) {
lines.push("## Suite Tag 分布");
lines.push("");
lines.push("| Suite Tag | case | ready | invalid |");
lines.push("| --- | --- | --- | --- |");
for (const entry of summary.breakdowns.suiteTags) {
lines.push(
`| ${entry.name} | ${entry.caseCount} | ${entry.readyCount} | ${entry.invalidCount} |`,
);
}
lines.push("");
}
for (const suite of summary.suites) {
lines.push(`## ${suite.title}`);
lines.push("");
if (suite.description) {
lines.push(suite.description);
lines.push("");
}
lines.push(`- ` + `suite_id:\`${suite.id}\``);
if (suite.priority) {
lines.push(`- 优先级:${suite.priority}`);
}
if (suite.roadmap) {
lines.push(`- 路线图:${suite.roadmap}`);
}
lines.push(
`- ready / total:${suite.stats.readyCount} / ${suite.stats.caseCount}`,
);
lines.push("");
lines.push("| Case | 状态 | 来源 | 分类 | 目录 | 问题 |");
lines.push("| --- | --- | --- | --- | --- | --- |");
for (const entry of suite.cases) {
const issueText =
entry.issues.length === 0 ? "无" : entry.issues.join("<br>");
const classificationText = [];
if (entry.tags.length > 0) {
classificationText.push(`tags: ${entry.tags.join(", ")}`);
}
if (entry.failureModes.length > 0) {
classificationText.push(`failure: ${entry.failureModes.join(", ")}`);
}
if (entry.primaryBlockingKind) {
classificationText.push(`blocking: ${entry.primaryBlockingKind}`);
}
lines.push(
`| ${entry.caseId} | ${entry.status} | ${entry.source} | ${classificationText.join("<br>") || "无"} | \`${entry.relativeCaseDir || "."}\` | ${issueText} |`,
);
}
lines.push("");
}
return `${lines.join("\n")}\n`;
}
function determineExitCode(summary, options) {
if (!options.strict) {
return 0;
}
return summary.totals.invalidCount > 0 ? 1 : 0;
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const repoRoot = process.cwd();
const manifestPath = resolvePath(repoRoot, options.manifest);
const manifest = readJsonFile(manifestPath);
const defaults = manifest.defaults ?? {};
const suiteConfigs = Array.isArray(manifest.suites) ? manifest.suites : [];
const suites = suiteConfigs.map((suiteConfig) =>
expandSuiteCases(
suiteConfig,
defaults,
repoRoot,
path.resolve(options.workspaceRoot),
),
);
const summary = buildSummary(manifest, suites, options);
const jsonOutput = `${JSON.stringify(summary, null, 2)}\n`;
const markdownOutput = renderMarkdown(summary);
const textOutput = renderText(summary);
if (options.outputJson) {
const outputPath = resolvePath(repoRoot, options.outputJson);
ensureParentDirectory(outputPath);
fs.writeFileSync(outputPath, jsonOutput, "utf8");
}
if (options.outputMarkdown) {
const outputPath = resolvePath(repoRoot, options.outputMarkdown);
ensureParentDirectory(outputPath);
fs.writeFileSync(outputPath, markdownOutput, "utf8");
}
if (options.format === "json") {
process.stdout.write(jsonOutput);
} else if (options.format === "markdown") {
process.stdout.write(markdownOutput);
} else {
process.stdout.write(textOutput);
}
const exitCode = determineExitCode(summary, options);
if (exitCode !== 0) {
process.exit(exitCode);
}
}
main();
+639
View File
@@ -0,0 +1,639 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const RUNNER_PATH = "scripts/harness-eval-runner.mjs";
function parseArgs(argv) {
const result = {
format: "text",
help: false,
historyDir: "",
inputs: [],
outputJson: "",
outputMarkdown: "",
workspaceRoot: process.cwd(),
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--input" && argv[index + 1]) {
result.inputs.push(String(argv[index + 1]).trim());
index += 1;
continue;
}
if (arg === "--history-dir" && argv[index + 1]) {
result.historyDir = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--workspace-root" && argv[index + 1]) {
result.workspaceRoot = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--format" && argv[index + 1]) {
result.format = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--output-json" && argv[index + 1]) {
result.outputJson = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--output-markdown" && argv[index + 1]) {
result.outputMarkdown = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--help" || arg === "-h") {
result.help = true;
}
}
return result;
}
function printHelp() {
console.log(`
Lime Harness Eval Trend Report
用法:
node scripts/harness-eval-trend-report.mjs
node scripts/harness-eval-trend-report.mjs --input "./tmp/harness-eval-summary.json"
node scripts/harness-eval-trend-report.mjs --history-dir "./artifacts/history"
node scripts/harness-eval-trend-report.mjs --output-json "./tmp/harness-eval-trend.json" --output-markdown "./tmp/harness-eval-trend.md"
选项:
--input PATH 显式加入一个或多个 harness eval summary JSON
--history-dir PATH 扫描目录下的历史 summary JSON
--workspace-root PATH 未提供输入时,用该工作区生成当前 summary
--format FMT 标准输出格式:text | json | markdown
--output-json PATH 将 JSON 趋势报告写入指定路径
--output-markdown PATH 将 Markdown 趋势报告写入指定路径
-h, --help 显示帮助
`);
}
function resolvePath(baseDir, relativePath) {
return path.resolve(baseDir, relativePath);
}
function ensureParentDirectory(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function collectJsonFiles(rootPath) {
if (!rootPath || !fs.existsSync(rootPath)) {
return [];
}
const files = [];
const pending = [rootPath];
while (pending.length > 0) {
const current = pending.pop();
const stat = fs.statSync(current);
if (stat.isDirectory()) {
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
pending.push(path.join(current, entry.name));
}
continue;
}
if (stat.isFile() && current.endsWith(".json")) {
files.push(current);
}
}
return files.sort((left, right) => left.localeCompare(right));
}
function isHarnessEvalSummary(candidate) {
return (
candidate != null &&
typeof candidate === "object" &&
typeof candidate.generatedAt === "string" &&
candidate.totals != null &&
typeof candidate.totals.caseCount === "number" &&
typeof candidate.totals.readyCount === "number" &&
typeof candidate.totals.invalidCount === "number"
);
}
function buildCurrentSummary(repoRoot, workspaceRoot) {
const nodeCommand = process.execPath;
const runnerPath = resolvePath(repoRoot, RUNNER_PATH);
const output = execFileSync(
nodeCommand,
[runnerPath, "--format", "json", "--workspace-root", workspaceRoot],
{
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
},
);
return JSON.parse(output);
}
function normalizeNumber(value) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function computeReadyRate(summary) {
const caseCount = normalizeNumber(summary?.totals?.caseCount);
if (caseCount <= 0) {
return 0;
}
return normalizeNumber(summary?.totals?.readyCount) / caseCount;
}
function getSuiteMap(summary) {
const suites = Array.isArray(summary?.suites) ? summary.suites : [];
return new Map(
suites.map((suite) => [
String(suite.id ?? ""),
{
id: String(suite.id ?? ""),
title: String(suite.title ?? ""),
caseCount: normalizeNumber(suite?.stats?.caseCount),
readyCount: normalizeNumber(suite?.stats?.readyCount),
invalidCount: normalizeNumber(suite?.stats?.invalidCount),
},
]),
);
}
function getBreakdownMap(summary, key) {
const entries = Array.isArray(summary?.breakdowns?.[key])
? summary.breakdowns[key]
: [];
return new Map(
entries.map((entry) => [
String(entry.name ?? ""),
{
name: String(entry.name ?? ""),
caseCount: normalizeNumber(entry.caseCount),
readyCount: normalizeNumber(entry.readyCount),
invalidCount: normalizeNumber(entry.invalidCount),
pendingRequestCaseCount: normalizeNumber(entry.pendingRequestCaseCount),
needsHumanReviewCount: normalizeNumber(entry.needsHumanReviewCount),
},
]),
);
}
function buildSuiteDeltas(baseline, latest) {
const baselineSuites = getSuiteMap(baseline);
const latestSuites = getSuiteMap(latest);
const suiteIds = new Set([...baselineSuites.keys(), ...latestSuites.keys()]);
return Array.from(suiteIds)
.filter(Boolean)
.sort((left, right) => left.localeCompare(right))
.map((suiteId) => {
const baselineSuite = baselineSuites.get(suiteId) ?? {
id: suiteId,
title: suiteId,
caseCount: 0,
readyCount: 0,
invalidCount: 0,
};
const latestSuite = latestSuites.get(suiteId) ?? {
id: suiteId,
title: baselineSuite.title,
caseCount: 0,
readyCount: 0,
invalidCount: 0,
};
return {
id: suiteId,
title: latestSuite.title || baselineSuite.title || suiteId,
baseline: baselineSuite,
latest: latestSuite,
delta: {
caseCount: latestSuite.caseCount - baselineSuite.caseCount,
readyCount: latestSuite.readyCount - baselineSuite.readyCount,
invalidCount: latestSuite.invalidCount - baselineSuite.invalidCount,
},
};
});
}
function buildBreakdownDeltas(baseline, latest, key) {
const baselineMap = getBreakdownMap(baseline, key);
const latestMap = getBreakdownMap(latest, key);
const names = new Set([...baselineMap.keys(), ...latestMap.keys()]);
return Array.from(names)
.filter(Boolean)
.sort((left, right) => left.localeCompare(right))
.map((name) => {
const baselineEntry = baselineMap.get(name) ?? {
name,
caseCount: 0,
readyCount: 0,
invalidCount: 0,
pendingRequestCaseCount: 0,
needsHumanReviewCount: 0,
};
const latestEntry = latestMap.get(name) ?? {
name,
caseCount: 0,
readyCount: 0,
invalidCount: 0,
pendingRequestCaseCount: 0,
needsHumanReviewCount: 0,
};
return {
name,
baseline: baselineEntry,
latest: latestEntry,
delta: {
caseCount: latestEntry.caseCount - baselineEntry.caseCount,
readyCount: latestEntry.readyCount - baselineEntry.readyCount,
invalidCount: latestEntry.invalidCount - baselineEntry.invalidCount,
pendingRequestCaseCount:
latestEntry.pendingRequestCaseCount -
baselineEntry.pendingRequestCaseCount,
needsHumanReviewCount:
latestEntry.needsHumanReviewCount -
baselineEntry.needsHumanReviewCount,
},
};
})
.sort((left, right) => {
const invalidDeltaDiff =
Math.abs(right.delta.invalidCount) - Math.abs(left.delta.invalidCount);
if (invalidDeltaDiff !== 0) {
return invalidDeltaDiff;
}
const caseDeltaDiff =
Math.abs(right.delta.caseCount) - Math.abs(left.delta.caseCount);
if (caseDeltaDiff !== 0) {
return caseDeltaDiff;
}
return left.name.localeCompare(right.name);
});
}
function buildStatusSignals(baseline, latest, sampleCount) {
const signals = [];
if (sampleCount < 2) {
signals.push("样本数不足 2,当前仅形成 trend seed,还不能判断长期退化。");
return signals;
}
const readyRateDelta = computeReadyRate(latest) - computeReadyRate(baseline);
const invalidDelta =
normalizeNumber(latest?.totals?.invalidCount) -
normalizeNumber(baseline?.totals?.invalidCount);
const pendingDelta =
normalizeNumber(latest?.totals?.pendingRequestCaseCount) -
normalizeNumber(baseline?.totals?.pendingRequestCaseCount);
if (invalidDelta > 0) {
signals.push(`invalid case 增加 ${invalidDelta},存在回归候选。`);
}
if (readyRateDelta < 0) {
signals.push(
`ready rate 下降 ${(Math.abs(readyRateDelta) * 100).toFixed(1)}%,需检查最近样本或字段漂移。`,
);
}
if (pendingDelta > 0) {
signals.push(
`pending request case 增加 ${pendingDelta},需确认是否属于真实阻塞还是样本结构变化。`,
);
}
const failureModeDeltas = buildBreakdownDeltas(
baseline,
latest,
"failureModes",
);
const increasedInvalidFailureMode = failureModeDeltas.find(
(entry) => entry.delta.invalidCount > 0,
);
if (increasedInvalidFailureMode) {
signals.push(
`failure mode \`${increasedInvalidFailureMode.name}\` 的 invalid case 增加 ${increasedInvalidFailureMode.delta.invalidCount}。`,
);
}
if (signals.length === 0) {
signals.push("当前没有检测到明显退化信号。");
}
return signals;
}
function buildTrendReport(samples, repoRoot) {
const sortedSamples = [...samples].sort((left, right) => {
const leftTime = Date.parse(left.summary.generatedAt);
const rightTime = Date.parse(right.summary.generatedAt);
if (
Number.isFinite(leftTime) &&
Number.isFinite(rightTime) &&
leftTime !== rightTime
) {
return leftTime - rightTime;
}
return left.summary.generatedAt.localeCompare(right.summary.generatedAt);
});
const baselineEntry = sortedSamples[0];
const latestEntry = sortedSamples[sortedSamples.length - 1];
const baseline = baselineEntry.summary;
const latest = latestEntry.summary;
const readyRateDelta = computeReadyRate(latest) - computeReadyRate(baseline);
return {
reportVersion: "v1",
generatedAt: new Date().toISOString(),
repoRoot,
sampleCount: sortedSamples.length,
baseline: {
generatedAt: baseline.generatedAt,
sourcePath: baselineEntry.sourcePath,
totals: baseline.totals,
},
latest: {
generatedAt: latest.generatedAt,
sourcePath: latestEntry.sourcePath,
totals: latest.totals,
},
delta: {
suiteCount:
normalizeNumber(latest?.totals?.suiteCount) -
normalizeNumber(baseline?.totals?.suiteCount),
caseCount:
normalizeNumber(latest?.totals?.caseCount) -
normalizeNumber(baseline?.totals?.caseCount),
readyCount:
normalizeNumber(latest?.totals?.readyCount) -
normalizeNumber(baseline?.totals?.readyCount),
invalidCount:
normalizeNumber(latest?.totals?.invalidCount) -
normalizeNumber(baseline?.totals?.invalidCount),
pendingRequestCaseCount:
normalizeNumber(latest?.totals?.pendingRequestCaseCount) -
normalizeNumber(baseline?.totals?.pendingRequestCaseCount),
needsHumanReviewCount:
normalizeNumber(latest?.totals?.needsHumanReviewCount) -
normalizeNumber(baseline?.totals?.needsHumanReviewCount),
readyRate: readyRateDelta,
},
signals: buildStatusSignals(baseline, latest, sortedSamples.length),
samples: sortedSamples.map((entry) => ({
generatedAt: entry.summary.generatedAt,
sourcePath: entry.sourcePath,
totals: entry.summary.totals,
})),
suiteDeltas: buildSuiteDeltas(baseline, latest),
classificationDeltas: {
suiteTags: buildBreakdownDeltas(baseline, latest, "suiteTags"),
failureModes: buildBreakdownDeltas(baseline, latest, "failureModes"),
},
};
}
function renderText(report) {
const lines = [
`[harness-eval-trend] samples: ${report.sampleCount}`,
`[harness-eval-trend] baseline: ${report.baseline.generatedAt}`,
`[harness-eval-trend] latest : ${report.latest.generatedAt}`,
`[harness-eval-trend] delta caseCount: ${report.delta.caseCount}`,
`[harness-eval-trend] delta readyCount: ${report.delta.readyCount}`,
`[harness-eval-trend] delta invalidCount: ${report.delta.invalidCount}`,
`[harness-eval-trend] delta pendingRequestCaseCount: ${report.delta.pendingRequestCaseCount}`,
`[harness-eval-trend] delta readyRate: ${(report.delta.readyRate * 100).toFixed(1)}%`,
];
for (const signal of report.signals) {
lines.push(`[harness-eval-trend] signal: ${signal}`);
}
const topFailureModeDeltas = report.classificationDeltas.failureModes.slice(
0,
5,
);
if (topFailureModeDeltas.length > 0) {
lines.push("[harness-eval-trend] top failure mode deltas:");
for (const entry of topFailureModeDeltas) {
lines.push(
` - ${entry.name}: delta_case=${entry.delta.caseCount}, delta_invalid=${entry.delta.invalidCount}, delta_pending=${entry.delta.pendingRequestCaseCount}`,
);
}
}
return `${lines.join("\n")}\n`;
}
function renderMarkdown(report) {
const lines = [
"# Lime Harness Eval Trend",
"",
`- 生成时间:${report.generatedAt}`,
`- 样本数:${report.sampleCount}`,
`- baseline:${report.baseline.generatedAt}`,
`- latest:${report.latest.generatedAt}`,
"",
"## 核心变化",
"",
`- suite 数变化:${report.delta.suiteCount}`,
`- case 数变化:${report.delta.caseCount}`,
`- ready 数变化:${report.delta.readyCount}`,
`- invalid 数变化:${report.delta.invalidCount}`,
`- pending request case 变化:${report.delta.pendingRequestCaseCount}`,
`- needs review case 变化:${report.delta.needsHumanReviewCount}`,
`- ready rate 变化:${(report.delta.readyRate * 100).toFixed(1)}%`,
"",
"## 信号",
"",
];
for (const signal of report.signals) {
lines.push(`- ${signal}`);
}
if (report.classificationDeltas.failureModes.length > 0) {
lines.push("");
lines.push("## Failure Mode 变化");
lines.push("");
lines.push(
"| Failure Mode | baseline case | latest case | delta case | delta invalid | delta pending_request |",
);
lines.push("| --- | --- | --- | --- | --- | --- |");
for (const entry of report.classificationDeltas.failureModes) {
lines.push(
`| ${entry.name} | ${entry.baseline.caseCount} | ${entry.latest.caseCount} | ${entry.delta.caseCount} | ${entry.delta.invalidCount} | ${entry.delta.pendingRequestCaseCount} |`,
);
}
}
if (report.classificationDeltas.suiteTags.length > 0) {
lines.push("");
lines.push("## Suite Tag 变化");
lines.push("");
lines.push(
"| Suite Tag | baseline case | latest case | delta case | delta invalid |",
);
lines.push("| --- | --- | --- | --- | --- |");
for (const entry of report.classificationDeltas.suiteTags) {
lines.push(
`| ${entry.name} | ${entry.baseline.caseCount} | ${entry.latest.caseCount} | ${entry.delta.caseCount} | ${entry.delta.invalidCount} |`,
);
}
}
lines.push("");
lines.push("## 时间线样本");
lines.push("");
lines.push("| 时间 | 来源 | case | ready | invalid | pending_request |");
lines.push("| --- | --- | --- | --- | --- | --- |");
for (const sample of report.samples) {
lines.push(
`| ${sample.generatedAt} | \`${sample.sourcePath}\` | ${sample.totals.caseCount} | ${sample.totals.readyCount} | ${sample.totals.invalidCount} | ${sample.totals.pendingRequestCaseCount} |`,
);
}
lines.push("");
lines.push("## Suite 变化");
lines.push("");
lines.push(
"| Suite | baseline ready/total | latest ready/total | invalid delta |",
);
lines.push("| --- | --- | --- | --- |");
for (const suite of report.suiteDeltas) {
lines.push(
`| ${suite.title} | ${suite.baseline.readyCount}/${suite.baseline.caseCount} | ${suite.latest.readyCount}/${suite.latest.caseCount} | ${suite.delta.invalidCount} |`,
);
}
return `${lines.join("\n")}\n`;
}
function loadSamples(options, repoRoot) {
const sampleEntries = [];
const seenFingerprints = new Set();
const candidateFiles = [];
for (const input of options.inputs) {
candidateFiles.push(resolvePath(repoRoot, input));
}
if (options.historyDir) {
candidateFiles.push(
...collectJsonFiles(resolvePath(repoRoot, options.historyDir)),
);
}
for (const filePath of candidateFiles) {
if (!fs.existsSync(filePath)) {
continue;
}
let parsed;
try {
parsed = readJsonFile(filePath);
} catch {
continue;
}
if (!isHarnessEvalSummary(parsed)) {
continue;
}
const fingerprint = JSON.stringify([
parsed.generatedAt,
parsed.totals.caseCount,
parsed.totals.readyCount,
parsed.totals.invalidCount,
parsed.totals.pendingRequestCaseCount,
]);
if (seenFingerprints.has(fingerprint)) {
continue;
}
seenFingerprints.add(fingerprint);
sampleEntries.push({
sourcePath: path.relative(repoRoot, filePath) || ".",
summary: parsed,
});
}
if (sampleEntries.length === 0) {
const currentSummary = buildCurrentSummary(
repoRoot,
path.resolve(options.workspaceRoot),
);
sampleEntries.push({
sourcePath: "(generated-current-summary)",
summary: currentSummary,
});
}
return sampleEntries;
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const repoRoot = process.cwd();
const samples = loadSamples(options, repoRoot);
const report = buildTrendReport(samples, repoRoot);
const jsonOutput = `${JSON.stringify(report, null, 2)}\n`;
const markdownOutput = renderMarkdown(report);
const textOutput = renderText(report);
if (options.outputJson) {
const outputPath = resolvePath(repoRoot, options.outputJson);
ensureParentDirectory(outputPath);
fs.writeFileSync(outputPath, jsonOutput, "utf8");
}
if (options.outputMarkdown) {
const outputPath = resolvePath(repoRoot, options.outputMarkdown);
ensureParentDirectory(outputPath);
fs.writeFileSync(outputPath, markdownOutput, "utf8");
}
if (options.format === "json") {
process.stdout.write(jsonOutput);
return;
}
if (options.format === "markdown") {
process.stdout.write(markdownOutput);
return;
}
process.stdout.write(textOutput);
}
main();
+635
View File
@@ -0,0 +1,635 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const DEFAULT_MANIFEST_PATH = "docs/test/harness-evals.manifest.json";
const DEFAULT_FIXTURES_ROOT = "docs/test/harness-fixtures/replay";
const DEFAULT_SUITE_ID = "repo-promoted-replays";
const DEFAULT_SANITIZED_WORKSPACE_ROOT = "/workspace/lime";
const REQUIRED_ARTIFACTS = [
"input.json",
"expected.json",
"grader.md",
"evidence-links.json",
];
function parseArgs(argv) {
const result = {
caseId: "",
dryRun: false,
fixturesRoot: DEFAULT_FIXTURES_ROOT,
format: "text",
help: false,
manifest: DEFAULT_MANIFEST_PATH,
replace: false,
replayDir: "",
sanitizedWorkspaceRoot: DEFAULT_SANITIZED_WORKSPACE_ROOT,
sessionId: "",
slug: "",
suiteId: DEFAULT_SUITE_ID,
title: "",
workspaceRoot: process.cwd(),
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--session-id" && argv[index + 1]) {
result.sessionId = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--replay-dir" && argv[index + 1]) {
result.replayDir = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--workspace-root" && argv[index + 1]) {
result.workspaceRoot = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--manifest" && argv[index + 1]) {
result.manifest = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--fixtures-root" && argv[index + 1]) {
result.fixturesRoot = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--suite-id" && argv[index + 1]) {
result.suiteId = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--slug" && argv[index + 1]) {
result.slug = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--case-id" && argv[index + 1]) {
result.caseId = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--title" && argv[index + 1]) {
result.title = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--sanitized-workspace-root" && argv[index + 1]) {
result.sanitizedWorkspaceRoot = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--format" && argv[index + 1]) {
result.format = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--replace") {
result.replace = true;
continue;
}
if (arg === "--dry-run") {
result.dryRun = true;
continue;
}
if (arg === "--help" || arg === "-h") {
result.help = true;
}
}
return result;
}
function printHelp() {
console.log(`
Lime Harness Replay Promote
用法:
node scripts/harness-replay-promote.mjs --session-id "session-123" --slug "pending-request-runtime"
node scripts/harness-replay-promote.mjs --replay-dir ".lime/harness/sessions/session-123/replay" --slug "pending-request-runtime"
选项:
--session-id ID 从 <workspace>/.lime/harness/sessions/<id>/replay 提升
--replay-dir PATH 直接指定 replay 目录;与 --session-id 二选一
--workspace-root PATH 工作区根目录,默认当前目录
--manifest PATH manifest 路径,默认 docs/test/harness-evals.manifest.json
--fixtures-root PATH 目标 fixture 根目录,默认 docs/test/harness-fixtures/replay
--suite-id ID 目标 suite,默认 repo-promoted-replays
--slug NAME 目标目录名;未提供时会从 sessionId 推导
--case-id ID manifest 中的 case id;默认 repo-promoted-<slug>
--title TEXT manifest 中的 case 标题;默认用 goal summary 推导
--sanitized-workspace-root PATH 写入仓库样本时替换绝对工作区路径,默认 /workspace/lime
--replace 已存在同名 case / 目录时覆盖
--dry-run 只预览,不写文件
--format FMT 标准输出格式:text | json
-h, --help 显示帮助
`);
}
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJsonFile(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function ensureDirectory(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function resolvePath(baseDir, targetPath) {
return path.resolve(baseDir, targetPath);
}
function toPortablePath(value) {
return String(value).replaceAll("\\", "/");
}
function normalizeStringList(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
}
function mergeUniqueStrings(...groups) {
return [...new Set(groups.flatMap((group) => normalizeStringList(group)))];
}
function slugify(value) {
return String(value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/gi, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function deriveReplayDirectory(options, workspaceRoot) {
if (options.replayDir) {
return resolvePath(process.cwd(), options.replayDir);
}
if (!options.sessionId) {
throw new Error("必须提供 --session-id 或 --replay-dir。");
}
return path.join(
workspaceRoot,
".lime",
"harness",
"sessions",
options.sessionId,
"replay",
);
}
function validateReplayDirectory(replayDir) {
if (!fs.existsSync(replayDir) || !fs.statSync(replayDir).isDirectory()) {
throw new Error(`replay 目录不存在: ${replayDir}`);
}
const missing = REQUIRED_ARTIFACTS.filter(
(artifact) => !fs.existsSync(path.join(replayDir, artifact)),
);
if (missing.length > 0) {
throw new Error(`replay 目录缺少文件: ${missing.join(", ")}`);
}
}
function deriveSlug(options, inputPayload, fallbackSessionId) {
if (options.slug) {
return slugify(options.slug);
}
const derivedFromGoal = slugify(
inputPayload?.task?.goalSummary ??
inputPayload?.classification?.primaryBlockingKind ??
"",
);
if (derivedFromGoal) {
return derivedFromGoal;
}
const derivedFromSession = slugify(fallbackSessionId);
if (derivedFromSession) {
return derivedFromSession;
}
return "promoted-replay-case";
}
function deriveCaseId(options, slug) {
return options.caseId || `repo-promoted-${slug}`;
}
function deriveTitle(options, inputPayload, expectedPayload, sessionId) {
if (options.title) {
return options.title;
}
const goalSummary =
inputPayload?.task?.goalSummary ?? expectedPayload?.goalSummary ?? "";
if (typeof goalSummary === "string" && goalSummary.trim().length > 0) {
return goalSummary.trim();
}
return `工作区 Replay 沉淀 / ${sessionId}`;
}
function replaceWorkspaceRootInString(value, workspaceRoot, placeholder) {
if (typeof value !== "string" || value.length === 0) {
return value;
}
let nextValue = value;
const rawRoot = String(workspaceRoot);
const portableRoot = toPortablePath(rawRoot);
if (rawRoot) {
nextValue = nextValue.replaceAll(rawRoot, placeholder);
}
if (portableRoot && portableRoot !== rawRoot) {
nextValue = nextValue.replaceAll(portableRoot, placeholder);
}
if (nextValue.includes(placeholder) && nextValue.includes("\\")) {
nextValue = nextValue.replaceAll("\\", "/");
}
return nextValue;
}
function sanitizePayload(value, workspaceRoot, placeholder) {
if (typeof value === "string") {
return replaceWorkspaceRootInString(value, workspaceRoot, placeholder);
}
if (Array.isArray(value)) {
return value.map((entry) =>
sanitizePayload(entry, workspaceRoot, placeholder),
);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entryValue]) => [
key,
sanitizePayload(entryValue, workspaceRoot, placeholder),
]),
);
}
return value;
}
function getRelativeIfInside(rootPath, absolutePath) {
const relativePath = path.relative(rootPath, absolutePath);
if (
relativePath.startsWith("..") ||
path.isAbsolute(relativePath) ||
relativePath === ""
) {
return relativePath === "" ? "." : null;
}
return toPortablePath(relativePath);
}
function buildPromotionMetadata({
promotedAt,
replayDir,
sessionId,
workspaceRoot,
sanitizedWorkspaceRoot,
}) {
const replayRelativeDir = getRelativeIfInside(workspaceRoot, replayDir);
const metadata = {
promotedAt,
promotedBy: "scripts/harness-replay-promote.mjs",
sanitizedWorkspaceRoot,
sourceSessionId: sessionId,
};
if (replayRelativeDir && replayRelativeDir !== ".") {
metadata.sourceReplayDir = replayRelativeDir;
}
return metadata;
}
function appendPromotionSection(graderMarkdown, promotionMetadata) {
if (graderMarkdown.includes("## 仓库沉淀说明")) {
return graderMarkdown;
}
const lines = [
graderMarkdown.trimEnd(),
"",
"## 仓库沉淀说明",
"",
`- 提升时间:${promotionMetadata.promotedAt}`,
`- 来源会话:\`${promotionMetadata.sourceSessionId}\``,
`- 脱敏工作区根:\`${promotionMetadata.sanitizedWorkspaceRoot}\``,
];
if (promotionMetadata.sourceReplayDir) {
lines.push(`- 来源 replay 目录:\`${promotionMetadata.sourceReplayDir}\``);
}
return `${lines.join("\n")}\n`;
}
function loadSuite(manifestPayload, suiteId) {
const suites = Array.isArray(manifestPayload.suites) ? manifestPayload.suites : [];
const suiteIndex = suites.findIndex(
(suite) => String(suite.id ?? "").trim() === suiteId,
);
if (suiteIndex === -1) {
throw new Error(`manifest 中未找到目标 suite: ${suiteId}`);
}
return {
suite: suites[suiteIndex],
suiteIndex,
suites,
};
}
function buildManifestCaseEntry({
caseId,
caseTitle,
inputPayload,
targetCaseDirValue,
}) {
return {
id: caseId,
title: caseTitle,
source: "repo_fixture",
caseDir: targetCaseDirValue,
tags: mergeUniqueStrings(
["repo-promoted"],
inputPayload?.classification?.suiteTags,
),
};
}
function updateManifestCase({
manifestPath,
suiteId,
caseEntry,
replace,
targetCaseDirValue,
}) {
const manifestPayload = readJsonFile(manifestPath);
const { suite } = loadSuite(manifestPayload, suiteId);
const cases = Array.isArray(suite.cases) ? [...suite.cases] : [];
const normalizedTargetDir = toPortablePath(targetCaseDirValue);
const existingIndex = cases.findIndex((entry) => {
const caseId = String(entry.id ?? "").trim();
const caseDir = toPortablePath(String(entry.caseDir ?? "").trim());
return caseId === caseEntry.id || caseDir === normalizedTargetDir;
});
if (existingIndex >= 0 && !replace) {
throw new Error(
`manifest 已存在同名 case 或同目录 case,请使用 --replace 覆盖: ${caseEntry.id}`,
);
}
if (existingIndex >= 0) {
const existing = cases[existingIndex];
cases[existingIndex] = {
...existing,
...caseEntry,
tags: mergeUniqueStrings(existing.tags, caseEntry.tags),
};
} else {
cases.push(caseEntry);
}
cases.sort((left, right) =>
String(left.id ?? "").localeCompare(String(right.id ?? "")),
);
suite.cases = cases;
writeJsonFile(manifestPath, manifestPayload);
return {
manifestPayload,
replaced: existingIndex >= 0,
};
}
function writePromotedArtifacts({
evidencePayload,
expectedPayload,
graderMarkdown,
inputPayload,
targetDir,
}) {
ensureDirectory(targetDir);
writeJsonFile(path.join(targetDir, "input.json"), inputPayload);
writeJsonFile(path.join(targetDir, "expected.json"), expectedPayload);
writeJsonFile(path.join(targetDir, "evidence-links.json"), evidencePayload);
fs.writeFileSync(path.join(targetDir, "grader.md"), graderMarkdown, "utf8");
}
function toManifestCaseDirValue(repoRoot, targetDir) {
const relativeToRepo = path.relative(repoRoot, targetDir);
if (
relativeToRepo &&
!relativeToRepo.startsWith("..") &&
!path.isAbsolute(relativeToRepo)
) {
return toPortablePath(relativeToRepo);
}
return toPortablePath(targetDir);
}
function renderText(result) {
const lines = [
`[harness-replay-promote] suite: ${result.suiteId}`,
`[harness-replay-promote] case : ${result.caseId}`,
`[harness-replay-promote] title: ${result.title}`,
`[harness-replay-promote] replay: ${result.sourceReplayDir}`,
`[harness-replay-promote] target: ${result.targetCaseDir}`,
`[harness-replay-promote] manifest target: ${result.manifestCaseDir}`,
`[harness-replay-promote] dry-run: ${result.dryRun ? "yes" : "no"}`,
`[harness-replay-promote] replaced: ${result.replaced ? "yes" : "no"}`,
];
if (result.tags.length > 0) {
lines.push(`[harness-replay-promote] tags: ${result.tags.join(", ")}`);
}
return `${lines.join("\n")}\n`;
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const repoRoot = process.cwd();
const workspaceRoot = resolvePath(repoRoot, options.workspaceRoot);
const replayDir = deriveReplayDirectory(options, workspaceRoot);
validateReplayDirectory(replayDir);
const inputPath = path.join(replayDir, "input.json");
const expectedPath = path.join(replayDir, "expected.json");
const graderPath = path.join(replayDir, "grader.md");
const evidencePath = path.join(replayDir, "evidence-links.json");
const originalInputPayload = readJsonFile(inputPath);
const originalExpectedPayload = readJsonFile(expectedPath);
const originalEvidencePayload = readJsonFile(evidencePath);
const originalGraderMarkdown = fs.readFileSync(graderPath, "utf8");
const sessionId =
String(
originalInputPayload?.session?.sessionId ??
path.basename(path.dirname(replayDir)),
).trim() || "unknown-session";
const slug = deriveSlug(options, originalInputPayload, sessionId);
if (!slug) {
throw new Error("无法推导目标 slug,请显式提供 --slug。");
}
const caseId = deriveCaseId(options, slug);
const title = deriveTitle(
options,
originalInputPayload,
originalExpectedPayload,
sessionId,
);
const promotedAt = new Date().toISOString();
const promotionMetadata = buildPromotionMetadata({
promotedAt,
replayDir,
sanitizedWorkspaceRoot: options.sanitizedWorkspaceRoot,
sessionId,
workspaceRoot,
});
const inputPayload = sanitizePayload(
originalInputPayload,
workspaceRoot,
options.sanitizedWorkspaceRoot,
);
inputPayload.source = "lime.repo_promoted.replay_case";
inputPayload.classification = {
...(inputPayload.classification ?? {}),
sourceKind: "repo_promoted_fixture",
};
inputPayload.promotion = promotionMetadata;
const expectedPayload = sanitizePayload(
originalExpectedPayload,
workspaceRoot,
options.sanitizedWorkspaceRoot,
);
expectedPayload.promotion = promotionMetadata;
const evidencePayload = sanitizePayload(
originalEvidencePayload,
workspaceRoot,
options.sanitizedWorkspaceRoot,
);
evidencePayload.promotion = promotionMetadata;
const graderMarkdown = appendPromotionSection(
replaceWorkspaceRootInString(
originalGraderMarkdown,
workspaceRoot,
options.sanitizedWorkspaceRoot,
),
promotionMetadata,
);
const fixturesRoot = resolvePath(repoRoot, options.fixturesRoot);
const targetDir = path.join(fixturesRoot, slug);
const targetExists = fs.existsSync(targetDir);
if (targetExists && !options.replace) {
throw new Error(`目标目录已存在,请使用 --replace 覆盖: ${targetDir}`);
}
const manifestPath = resolvePath(repoRoot, options.manifest);
const manifestCaseDir = toManifestCaseDirValue(repoRoot, targetDir);
const caseEntry = buildManifestCaseEntry({
caseId,
caseTitle: title,
inputPayload,
targetCaseDirValue: manifestCaseDir,
});
let replaced = false;
if (!options.dryRun) {
if (targetExists) {
fs.rmSync(targetDir, { recursive: true, force: true });
}
writePromotedArtifacts({
evidencePayload,
expectedPayload,
graderMarkdown,
inputPayload,
targetDir,
});
const manifestUpdate = updateManifestCase({
caseEntry,
manifestPath,
replace: options.replace,
suiteId: options.suiteId,
targetCaseDirValue: manifestCaseDir,
});
replaced = manifestUpdate.replaced;
}
const result = {
caseId,
dryRun: options.dryRun,
manifestCaseDir,
manifestPath,
replaced,
sanitizedWorkspaceRoot: options.sanitizedWorkspaceRoot,
slug,
sourceReplayDir: toPortablePath(replayDir),
suiteId: options.suiteId,
tags: caseEntry.tags,
targetCaseDir: toPortablePath(targetDir),
title,
};
if (options.format === "json") {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
process.stdout.write(renderText(result));
}
main();
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env node
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,
};
function printHelp() {
console.log(`
Lime Site Adapter Catalog Smoke
用途:
验证站点适配器目录最小主链可用:目录状态、列表、推荐与检索结果可读。
用法:
node scripts/site-adapter-catalog-smoke.mjs [选项]
选项:
--health-url <url> DevBridge 健康检查地址,默认 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
-h, --help 显示帮助
`);
}
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 === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
}
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1_000) {
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
}
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
throw new Error("--interval-ms 必须是 >= 100 的数字");
}
return options;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function waitForHealth(options) {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < options.timeoutMs) {
try {
const response = await fetch(options.healthUrl, { method: "GET" });
const payload = await response.json();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
console.log(
`[smoke:site-adapters] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
payload?.status ? ` status=${payload.status}` : ""
}`,
);
return;
} catch (error) {
lastError = error;
await sleep(options.intervalMs);
}
}
const detail =
lastError instanceof Error
? lastError.message
: String(lastError || "unknown error");
throw new Error(
`[smoke:site-adapters] 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;
}
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 status = await invoke(options.invokeUrl, "site_get_adapter_catalog_status");
assert(status && typeof status === "object", "site_get_adapter_catalog_status 返回为空");
assert(
typeof status.adapter_count === "number" && status.adapter_count >= 0,
"site_get_adapter_catalog_status 缺少 adapter_count",
);
assert(
status.source_kind === "bundled" || status.source_kind === "server_synced",
"site_get_adapter_catalog_status 返回了未知 source_kind",
);
const adapters = await invoke(options.invokeUrl, "site_list_adapters");
assert(Array.isArray(adapters), "site_list_adapters 返回不是数组");
assert(adapters.length > 0, "site_list_adapters 返回为空");
const adapter = adapters[0];
assert(
typeof adapter?.name === "string" && adapter.name.trim(),
"site_list_adapters 首项缺少 name",
);
assert(
typeof adapter?.domain === "string" && adapter.domain.trim(),
"site_list_adapters 首项缺少 domain",
);
const recommendations = await invoke(options.invokeUrl, "site_recommend_adapters", {
request: {
limit: 3,
},
});
assert(Array.isArray(recommendations), "site_recommend_adapters 返回不是数组");
if (recommendations.length > 0) {
const recommendation = recommendations[0];
assert(
typeof recommendation?.adapter?.name === "string" &&
recommendation.adapter.name.trim(),
"site_recommend_adapters 首项缺少 adapter.name",
);
assert(
typeof recommendation?.reason === "string" && recommendation.reason.trim(),
"site_recommend_adapters 首项缺少 reason",
);
assert(
typeof recommendation?.entry_url === "string" &&
recommendation.entry_url.trim(),
"site_recommend_adapters 首项缺少 entry_url",
);
}
const searchResults = await invoke(options.invokeUrl, "site_search_adapters", {
request: {
query: adapter.name,
},
});
assert(Array.isArray(searchResults), "site_search_adapters 返回不是数组");
assert(
searchResults.some((item) => item?.name === adapter.name),
"site_search_adapters 未返回刚刚列出的适配器",
);
console.log(
`[smoke:site-adapters] 通过 adapters=${adapters.length} source=${status.source_kind} recommended=${recommendations.length}`,
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+30 -2
View File
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
const DEFAULTS = {
appUrl: "http://127.0.0.1:1420/",
healthUrl: "http://127.0.0.1:3030/health",
timeoutMs: 120_000,
timeoutMs: 180_000,
intervalMs: 1_000,
reuseRunning: false,
sampleProjectName: "Lime Smoke Workspace",
@@ -39,7 +39,7 @@ Lime GUI 冒烟入口
选项:
--app-url <url> 前端地址,默认 http://127.0.0.1:1420/
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
--timeout-ms <ms> 等待 headless / bridge / smoke 的超时,默认 120000
--timeout-ms <ms> 等待 headless / bridge / smoke 的超时,默认 180000
--interval-ms <ms> 轮询间隔,默认 1000
--sample-project-name <s> workspace 路径校验使用的示例项目名
--reuse-running 复用已启动的 headless Tauri,不主动拉起
@@ -357,6 +357,34 @@ async function main() {
"smoke:workspace-ready",
);
runCommand(
npmCommand,
[
"run",
"smoke:browser-runtime",
"--",
"--timeout-ms",
String(options.timeoutMs),
"--interval-ms",
String(options.intervalMs),
],
"smoke:browser-runtime",
);
runCommand(
npmCommand,
[
"run",
"smoke:site-adapters",
"--",
"--timeout-ms",
String(options.timeoutMs),
"--interval-ms",
String(options.intervalMs),
],
"smoke:site-adapters",
);
console.log("\n[verify:gui-smoke] 通过");
} finally {
if (startedByScript) {
+17 -17
View File
@@ -5101,7 +5101,7 @@ dependencies = [
[[package]]
name = "lime"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"anyhow",
"arboard",
@@ -5205,7 +5205,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5234,7 +5234,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"chrono",
"futures",
@@ -5251,7 +5251,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5267,7 +5267,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5307,7 +5307,7 @@ dependencies = [
[[package]]
name = "lime-credential"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5342,7 +5342,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"aes",
"axum 0.7.9",
@@ -5372,7 +5372,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5392,7 +5392,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5424,7 +5424,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5443,7 +5443,7 @@ dependencies = [
[[package]]
name = "lime-providers"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5498,7 +5498,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5558,7 +5558,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5600,7 +5600,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5618,7 +5618,7 @@ dependencies = [
[[package]]
name = "lime-terminal"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5645,7 +5645,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -2
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.96.0"
version = "0.97.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -192,7 +192,7 @@ version = "2.4"
[package]
name = "lime"
version = "0.96.0"
version = "0.97.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+7 -2
View File
@@ -102,7 +102,11 @@ pub use runtime_queue::{
RuntimeQueueExecutor,
};
pub use session_execution_runtime::{
build_session_execution_runtime, SessionExecutionRuntime, SessionExecutionRuntimeSource,
build_session_execution_runtime, extract_recent_content_id_from_runtime_snapshot,
persist_session_recent_preferences, persist_session_recent_team_selection,
SessionExecutionRuntime, SessionExecutionRuntimePreferences,
SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection,
SessionExecutionRuntimeSource,
};
pub use session_query::{
collect_subagent_cascade_session_ids, list_child_subagent_sessions,
@@ -115,7 +119,8 @@ pub use session_store::{
list_title_preview_messages_sync, rename_session_sync, update_session_execution_strategy_sync,
update_session_provider_config_sync, update_session_working_dir_sync,
ChildSubagentRuntimeStatus, ChildSubagentSession, PersistedSessionMetadata, SessionDetail,
SessionInfo, SessionTitlePreviewMessage, SessionTodoItem, SubagentParentContext,
SessionInfo, SessionTitlePreviewMessage, SessionTodoItem, SessionTodoStatus,
SubagentParentContext,
};
pub use session_update::{
create_subagent_session, persist_compaction_session_metrics_update,
@@ -1,3 +1,6 @@
use crate::session_query::read_session;
use crate::session_update::persist_session_extension_data;
use aster::session::extension_data::{ExtensionData, ExtensionState};
use aster::session::{Session, SessionRuntimeSnapshot, TurnOutputSchemaRuntime, TurnStatus};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -29,6 +32,165 @@ pub struct SessionExecutionRuntimePreferences {
pub subagent: bool,
}
impl ExtensionState for SessionExecutionRuntimePreferences {
const EXTENSION_NAME: &'static str = "lime_recent_preferences";
const VERSION: &'static str = "v0";
}
impl SessionExecutionRuntimePreferences {
fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
<Self as ExtensionState>::from_extension_data(extension_data)
}
fn from_session(session: &Session) -> Option<Self> {
Self::from_extension_data(&session.extension_data)
}
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())
}
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)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SessionExecutionRuntimeRecentTeamRole {
#[serde(default)]
pub id: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub summary: String,
#[serde(default, alias = "profile_id")]
pub profile_id: Option<String>,
#[serde(default, alias = "role_key")]
pub role_key: Option<String>,
#[serde(default, alias = "skill_ids")]
pub skill_ids: Vec<String>,
}
impl SessionExecutionRuntimeRecentTeamRole {
fn normalize(self) -> Option<Self> {
let id = self.id.trim().to_string();
let label = self.label.trim().to_string();
let summary = self.summary.trim().to_string();
if label.is_empty() && summary.is_empty() {
return None;
}
let skill_ids = self
.skill_ids
.into_iter()
.filter_map(|skill_id| normalize_optional_text(Some(skill_id)))
.collect();
Some(Self {
id,
label,
summary,
profile_id: normalize_optional_text(self.profile_id),
role_key: normalize_optional_text(self.role_key),
skill_ids,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SessionExecutionRuntimeRecentTeamSelection {
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub theme: Option<String>,
#[serde(default, alias = "preferred_team_preset_id")]
pub preferred_team_preset_id: Option<String>,
#[serde(default, alias = "selected_team_id")]
pub selected_team_id: Option<String>,
#[serde(default, alias = "selected_team_source")]
pub selected_team_source: Option<String>,
#[serde(default, alias = "selected_team_label")]
pub selected_team_label: Option<String>,
#[serde(default, alias = "selected_team_description")]
pub selected_team_description: Option<String>,
#[serde(default, alias = "selected_team_summary")]
pub selected_team_summary: Option<String>,
#[serde(default, alias = "selected_team_roles")]
pub selected_team_roles: Option<Vec<SessionExecutionRuntimeRecentTeamRole>>,
}
impl ExtensionState for SessionExecutionRuntimeRecentTeamSelection {
const EXTENSION_NAME: &'static str = "lime_recent_team_selection";
const VERSION: &'static str = "v0";
}
impl SessionExecutionRuntimeRecentTeamSelection {
fn normalize(self) -> Option<Self> {
let selected_team_roles = self
.selected_team_roles
.map(|roles| {
roles
.into_iter()
.filter_map(SessionExecutionRuntimeRecentTeamRole::normalize)
.collect::<Vec<_>>()
})
.filter(|roles| !roles.is_empty());
let normalized = Self {
disabled: self.disabled,
theme: normalize_optional_text(self.theme),
preferred_team_preset_id: normalize_optional_text(self.preferred_team_preset_id),
selected_team_id: normalize_optional_text(self.selected_team_id),
selected_team_source: normalize_optional_text(self.selected_team_source),
selected_team_label: normalize_optional_text(self.selected_team_label),
selected_team_description: normalize_optional_text(self.selected_team_description),
selected_team_summary: normalize_optional_text(self.selected_team_summary),
selected_team_roles,
};
if normalized.disabled {
return Some(normalized);
}
if normalized.preferred_team_preset_id.is_none()
&& normalized.selected_team_id.is_none()
&& normalized.selected_team_source.is_none()
&& normalized.selected_team_label.is_none()
&& normalized.selected_team_description.is_none()
&& normalized.selected_team_summary.is_none()
&& normalized.selected_team_roles.is_none()
{
return None;
}
Some(normalized)
}
fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
<Self as ExtensionState>::from_extension_data(extension_data).and_then(Self::normalize)
}
fn from_session(session: &Session) -> Option<Self> {
Self::from_extension_data(&session.extension_data)
}
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())
}
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)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionExecutionRuntime {
pub session_id: String,
@@ -51,6 +213,27 @@ pub struct SessionExecutionRuntime {
pub latest_turn_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_preferences: Option<SessionExecutionRuntimePreferences>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_team_selection: Option<SessionExecutionRuntimeRecentTeamSelection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_session_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_gate_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_run_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_content_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct RecentHarnessContext {
theme: Option<String>,
session_mode: Option<String>,
gate_key: Option<String>,
run_title: Option<String>,
content_id: Option<String>,
}
fn resolve_session_model_name(session: &Session) -> Option<String> {
@@ -80,6 +263,46 @@ fn extract_bool_from_metadata(
.find_map(|key| extract_bool_from_value(metadata.get(*key)))
}
fn extract_text_from_value(value: Option<&Value>) -> Option<String> {
normalize_optional_text(value.and_then(Value::as_str).map(ToString::to_string))
}
fn extract_text_from_object(
object: &serde_json::Map<String, Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.find_map(|key| extract_text_from_value(object.get(*key)))
}
fn extract_text_from_metadata(
metadata: &std::collections::HashMap<String, Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.find_map(|key| extract_text_from_value(metadata.get(*key)))
}
fn extract_array_from_object(
object: &serde_json::Map<String, Value>,
keys: &[&str],
) -> Option<Vec<Value>> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(Value::as_array)
.cloned()
}
fn extract_array_from_metadata(
metadata: &std::collections::HashMap<String, Value>,
keys: &[&str],
) -> Option<Vec<Value>> {
keys.iter()
.filter_map(|key| metadata.get(*key))
.find_map(Value::as_array)
.cloned()
}
fn extract_recent_preferences_from_metadata(
metadata: &std::collections::HashMap<String, Value>,
) -> Option<SessionExecutionRuntimePreferences> {
@@ -118,6 +341,170 @@ fn extract_recent_preferences_from_metadata(
})
}
fn extract_recent_team_roles_from_values(
values: Vec<Value>,
) -> Option<Vec<SessionExecutionRuntimeRecentTeamRole>> {
let roles = values
.into_iter()
.filter_map(|value| {
serde_json::from_value::<SessionExecutionRuntimeRecentTeamRole>(value).ok()
})
.filter_map(SessionExecutionRuntimeRecentTeamRole::normalize)
.collect::<Vec<_>>();
if roles.is_empty() {
None
} else {
Some(roles)
}
}
fn extract_recent_team_selection_from_metadata(
metadata: &std::collections::HashMap<String, Value>,
) -> Option<SessionExecutionRuntimeRecentTeamSelection> {
let harness = metadata.get("harness").and_then(Value::as_object);
let resolve_text = |keys: &[&str]| -> Option<String> {
harness
.and_then(|value| extract_text_from_object(value, keys))
.or_else(|| extract_text_from_metadata(metadata, keys))
};
let resolve_bool = |keys: &[&str]| -> Option<bool> {
harness
.and_then(|value| extract_bool_from_object(value, keys))
.or_else(|| extract_bool_from_metadata(metadata, keys))
};
let resolve_array = |keys: &[&str]| -> Option<Vec<Value>> {
harness
.and_then(|value| extract_array_from_object(value, keys))
.or_else(|| extract_array_from_metadata(metadata, keys))
};
SessionExecutionRuntimeRecentTeamSelection {
disabled: resolve_bool(&["selected_team_disabled", "selectedTeamDisabled"])
.unwrap_or(false),
theme: resolve_text(&["theme", "harness_theme", "harnessTheme"]),
preferred_team_preset_id: resolve_text(&[
"preferred_team_preset_id",
"preferredTeamPresetId",
]),
selected_team_id: resolve_text(&["selected_team_id", "selectedTeamId"]),
selected_team_source: resolve_text(&["selected_team_source", "selectedTeamSource"]),
selected_team_label: resolve_text(&["selected_team_label", "selectedTeamLabel"]),
selected_team_description: resolve_text(&[
"selected_team_description",
"selectedTeamDescription",
]),
selected_team_summary: resolve_text(&["selected_team_summary", "selectedTeamSummary"]),
selected_team_roles: resolve_array(&["selected_team_roles", "selectedTeamRoles"])
.and_then(extract_recent_team_roles_from_values),
}
.normalize()
}
fn extract_recent_harness_context_from_metadata(
metadata: &std::collections::HashMap<String, Value>,
) -> RecentHarnessContext {
let harness = metadata.get("harness").and_then(Value::as_object);
let resolve_text = |keys: &[&str]| -> Option<String> {
harness
.and_then(|value| extract_text_from_object(value, keys))
.or_else(|| extract_text_from_metadata(metadata, keys))
};
RecentHarnessContext {
theme: resolve_text(&["theme", "harness_theme", "harnessTheme"]),
session_mode: resolve_text(&["session_mode", "sessionMode"]),
gate_key: resolve_text(&["gate_key", "gateKey"]),
run_title: resolve_text(&["run_title", "runTitle", "title"]),
content_id: resolve_text(&["content_id", "contentId"]),
}
}
fn extract_recent_harness_context_from_runtime_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> RecentHarnessContext {
let from_turn = snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.filter_map(|turn| {
let context = turn
.context_override
.as_ref()
.map(|value| extract_recent_harness_context_from_metadata(&value.metadata))?;
Some((turn.updated_at, context))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, context)| context)
.unwrap_or_default();
if from_turn.theme.is_some()
&& from_turn.session_mode.is_some()
&& from_turn.gate_key.is_some()
&& from_turn.run_title.is_some()
&& from_turn.content_id.is_some()
{
return from_turn;
}
let from_thread = snapshot
.threads
.iter()
.filter_map(|thread| {
let context = extract_recent_harness_context_from_metadata(&thread.thread.metadata);
if context.theme.is_none()
&& context.session_mode.is_none()
&& context.content_id.is_none()
{
return None;
}
Some((thread.thread.updated_at, context))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, context)| context)
.unwrap_or_default();
RecentHarnessContext {
theme: from_turn.theme.or(from_thread.theme),
session_mode: from_turn.session_mode.or(from_thread.session_mode),
gate_key: from_turn.gate_key.or(from_thread.gate_key),
run_title: from_turn.run_title.or(from_thread.run_title),
content_id: from_turn.content_id.or(from_thread.content_id),
}
}
pub fn extract_recent_content_id_from_runtime_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> Option<String> {
extract_recent_harness_context_from_runtime_snapshot(snapshot).content_id
}
pub async fn persist_session_recent_preferences(
session_id: &str,
preferences: SessionExecutionRuntimePreferences,
) -> Result<(), String> {
let session = read_session(session_id, false, "读取会话 recent_preferences 失败").await?;
let extension_data = preferences.into_updated_extension_data(&session)?;
persist_session_extension_data(session_id, extension_data, "持久化会话 recent_preferences")
.await?;
Ok(())
}
pub async fn persist_session_recent_team_selection(
session_id: &str,
recent_team_selection: SessionExecutionRuntimeRecentTeamSelection,
) -> Result<(), String> {
let session = read_session(session_id, false, "读取会话 recent_team_selection 失败").await?;
let extension_data = recent_team_selection.into_updated_extension_data(&session)?;
persist_session_extension_data(
session_id,
extension_data,
"持久化会话 recent_team_selection",
)
.await?;
Ok(())
}
fn resolve_latest_turn(snapshot: &SessionRuntimeSnapshot) -> Option<&aster::session::TurnRuntime> {
snapshot
.threads
@@ -161,33 +548,62 @@ pub fn build_session_execution_runtime(
latest_turn_id: None,
latest_turn_status: None,
recent_preferences: None,
recent_team_selection: None,
recent_theme: None,
recent_session_mode: None,
recent_gate_key: None,
recent_run_title: None,
recent_content_id: None,
};
if let Some(latest_turn) = snapshot.and_then(resolve_latest_turn) {
runtime.latest_turn_id = Some(latest_turn.id.clone());
runtime.latest_turn_status = Some(map_turn_status(latest_turn.status));
runtime.output_schema_runtime = latest_turn.output_schema_runtime.clone();
runtime.model_name = latest_turn
.output_schema_runtime
.as_ref()
.and_then(|value| normalize_optional_text(value.model_name.clone()))
.or_else(|| {
latest_turn
.context_override
.as_ref()
.and_then(|value| normalize_optional_text(value.model.clone()))
})
.or(runtime.model_name);
runtime.provider_name = latest_turn
.output_schema_runtime
.as_ref()
.and_then(|value| normalize_optional_text(value.provider_name.clone()))
.or(runtime.provider_name);
runtime.recent_preferences = latest_turn
.context_override
.as_ref()
.and_then(|value| extract_recent_preferences_from_metadata(&value.metadata));
runtime.source = SessionExecutionRuntimeSource::RuntimeSnapshot;
if let Some(snapshot) = snapshot {
let recent_harness_context = extract_recent_harness_context_from_runtime_snapshot(snapshot);
runtime.recent_theme = recent_harness_context.theme;
runtime.recent_session_mode = recent_harness_context.session_mode;
runtime.recent_gate_key = recent_harness_context.gate_key;
runtime.recent_run_title = recent_harness_context.run_title;
runtime.recent_content_id = recent_harness_context.content_id;
if let Some(latest_turn) = resolve_latest_turn(snapshot) {
runtime.latest_turn_id = Some(latest_turn.id.clone());
runtime.latest_turn_status = Some(map_turn_status(latest_turn.status));
runtime.output_schema_runtime = latest_turn.output_schema_runtime.clone();
runtime.model_name = latest_turn
.output_schema_runtime
.as_ref()
.and_then(|value| normalize_optional_text(value.model_name.clone()))
.or_else(|| {
latest_turn
.context_override
.as_ref()
.and_then(|value| normalize_optional_text(value.model.clone()))
})
.or(runtime.model_name);
runtime.provider_name = latest_turn
.output_schema_runtime
.as_ref()
.and_then(|value| normalize_optional_text(value.provider_name.clone()))
.or(runtime.provider_name);
runtime.recent_preferences = latest_turn
.context_override
.as_ref()
.and_then(|value| extract_recent_preferences_from_metadata(&value.metadata));
runtime.recent_team_selection = latest_turn
.context_override
.as_ref()
.and_then(|value| extract_recent_team_selection_from_metadata(&value.metadata));
runtime.source = SessionExecutionRuntimeSource::RuntimeSnapshot;
}
}
if runtime.recent_preferences.is_none() {
runtime.recent_preferences =
session.and_then(SessionExecutionRuntimePreferences::from_session);
}
if runtime.recent_team_selection.is_none() {
runtime.recent_team_selection =
session.and_then(SessionExecutionRuntimeRecentTeamSelection::from_session);
}
if runtime.provider_selector.is_none()
@@ -195,6 +611,12 @@ pub fn build_session_execution_runtime(
&& runtime.model_name.is_none()
&& runtime.output_schema_runtime.is_none()
&& runtime.recent_preferences.is_none()
&& runtime.recent_team_selection.is_none()
&& runtime.recent_theme.is_none()
&& runtime.recent_session_mode.is_none()
&& runtime.recent_gate_key.is_none()
&& runtime.recent_run_title.is_none()
&& runtime.recent_content_id.is_none()
{
return None;
}
@@ -206,6 +628,7 @@ pub fn build_session_execution_runtime(
mod tests {
use super::{
build_session_execution_runtime, SessionExecutionRuntimePreferences,
SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection,
SessionExecutionRuntimeSource,
};
use aster::model::ModelConfig;
@@ -389,4 +812,385 @@ mod tests {
})
);
}
#[test]
fn keeps_recent_team_selection_from_latest_turn_metadata() {
let now = Utc::now();
let latest_turn = TurnRuntime {
id: "turn-team".to_string(),
session_id: "session-5".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("hello".to_string()),
error_message: None,
context_override: Some(TurnContextOverride {
metadata: std::collections::HashMap::from([(
"harness".to_string(),
json!({
"theme": "general",
"preferred_team_preset_id": "code-triage-team",
"selected_team_id": "custom-team-1",
"selected_team_source": "custom",
"selected_team_label": "前端联调团队",
"selected_team_description": "分析、实现、验证三段式推进。",
"selected_team_summary": "分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。",
"selected_team_roles": [
{
"id": "explorer",
"label": "分析",
"summary": "负责定位问题与影响范围。",
"profile_id": "code-explorer",
"role_key": "explorer",
"skill_ids": ["repo-exploration"]
}
]
}),
)]),
..TurnContextOverride::default()
}),
output_schema_runtime: None,
created_at: now - Duration::seconds(10),
started_at: Some(now - Duration::seconds(10)),
completed_at: Some(now - Duration::seconds(1)),
updated_at: now,
};
let snapshot = SessionRuntimeSnapshot {
session_id: "session-5".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread: ThreadRuntime::new(
"thread-1",
"session-5",
PathBuf::from("/tmp/workspace"),
),
turns: vec![latest_turn],
items: Vec::new(),
}],
};
let runtime =
build_session_execution_runtime("session-5", None, None, Some(&snapshot), None)
.expect("runtime");
assert_eq!(
runtime.recent_team_selection,
Some(SessionExecutionRuntimeRecentTeamSelection {
disabled: false,
theme: Some("general".to_string()),
preferred_team_preset_id: Some("code-triage-team".to_string()),
selected_team_id: Some("custom-team-1".to_string()),
selected_team_source: Some("custom".to_string()),
selected_team_label: Some("前端联调团队".to_string()),
selected_team_description: Some("分析、实现、验证三段式推进。".to_string()),
selected_team_summary: Some(
"分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。"
.to_string(),
),
selected_team_roles: Some(vec![SessionExecutionRuntimeRecentTeamRole {
id: "explorer".to_string(),
label: "分析".to_string(),
summary: "负责定位问题与影响范围。".to_string(),
profile_id: Some("code-explorer".to_string()),
role_key: Some("explorer".to_string()),
skill_ids: vec!["repo-exploration".to_string()],
}]),
})
);
}
#[test]
fn keeps_recent_content_id_from_latest_turn_metadata() {
let now = Utc::now();
let latest_turn = TurnRuntime {
id: "turn-content".to_string(),
session_id: "session-content".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("hello".to_string()),
error_message: None,
context_override: Some(TurnContextOverride {
metadata: std::collections::HashMap::from([(
"harness".to_string(),
json!({
"content_id": "content-current"
}),
)]),
..TurnContextOverride::default()
}),
output_schema_runtime: None,
created_at: now - Duration::seconds(10),
started_at: Some(now - Duration::seconds(10)),
completed_at: Some(now - Duration::seconds(1)),
updated_at: now,
};
let snapshot = SessionRuntimeSnapshot {
session_id: "session-content".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread: ThreadRuntime::new(
"thread-1",
"session-content",
PathBuf::from("/tmp/workspace"),
),
turns: vec![latest_turn],
items: Vec::new(),
}],
};
let runtime =
build_session_execution_runtime("session-content", None, None, Some(&snapshot), None)
.expect("runtime");
assert_eq!(
runtime.source,
SessionExecutionRuntimeSource::RuntimeSnapshot
);
assert_eq!(
runtime.recent_content_id.as_deref(),
Some("content-current")
);
}
#[test]
fn keeps_recent_theme_and_session_mode_from_latest_turn_metadata() {
let now = Utc::now();
let latest_turn = TurnRuntime {
id: "turn-harness".to_string(),
session_id: "session-harness".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("hello".to_string()),
error_message: None,
context_override: Some(TurnContextOverride {
metadata: std::collections::HashMap::from([(
"harness".to_string(),
json!({
"theme": "social-media",
"session_mode": "theme_workbench",
"gate_key": "write_mode",
"run_title": "社媒初稿",
"content_id": "content-current"
}),
)]),
..TurnContextOverride::default()
}),
output_schema_runtime: None,
created_at: now - Duration::seconds(10),
started_at: Some(now - Duration::seconds(10)),
completed_at: Some(now - Duration::seconds(1)),
updated_at: now,
};
let snapshot = SessionRuntimeSnapshot {
session_id: "session-harness".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread: ThreadRuntime::new(
"thread-1",
"session-harness",
PathBuf::from("/tmp/workspace"),
),
turns: vec![latest_turn],
items: Vec::new(),
}],
};
let runtime =
build_session_execution_runtime("session-harness", None, None, Some(&snapshot), None)
.expect("runtime");
assert_eq!(runtime.recent_theme.as_deref(), Some("social-media"));
assert_eq!(
runtime.recent_session_mode.as_deref(),
Some("theme_workbench")
);
assert_eq!(runtime.recent_gate_key.as_deref(), Some("write_mode"));
assert_eq!(runtime.recent_run_title.as_deref(), Some("社媒初稿"));
assert_eq!(
runtime.recent_content_id.as_deref(),
Some("content-current")
);
}
#[test]
fn falls_back_to_thread_metadata_recent_content_id() {
let now = Utc::now();
let latest_turn = TurnRuntime {
id: "turn-without-content".to_string(),
session_id: "session-thread-content".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("hello".to_string()),
error_message: None,
context_override: Some(TurnContextOverride::default()),
output_schema_runtime: None,
created_at: now - Duration::seconds(10),
started_at: Some(now - Duration::seconds(10)),
completed_at: Some(now - Duration::seconds(1)),
updated_at: now,
};
let mut thread = ThreadRuntime::new(
"thread-1",
"session-thread-content",
PathBuf::from("/tmp/workspace"),
);
thread
.metadata
.insert("content_id".to_string(), json!("content-from-thread"));
thread.updated_at = now;
let snapshot = SessionRuntimeSnapshot {
session_id: "session-thread-content".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread,
turns: vec![latest_turn],
items: Vec::new(),
}],
};
let runtime = build_session_execution_runtime(
"session-thread-content",
None,
None,
Some(&snapshot),
None,
)
.expect("runtime");
assert_eq!(
runtime.recent_content_id.as_deref(),
Some("content-from-thread")
);
}
#[test]
fn falls_back_to_thread_metadata_recent_theme_and_session_mode() {
let now = Utc::now();
let latest_turn = TurnRuntime {
id: "turn-without-harness".to_string(),
session_id: "session-thread-harness".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("hello".to_string()),
error_message: None,
context_override: Some(TurnContextOverride::default()),
output_schema_runtime: None,
created_at: now - Duration::seconds(10),
started_at: Some(now - Duration::seconds(10)),
completed_at: Some(now - Duration::seconds(1)),
updated_at: now,
};
let mut thread = ThreadRuntime::new(
"thread-1",
"session-thread-harness",
PathBuf::from("/tmp/workspace"),
);
thread
.metadata
.insert("theme".to_string(), json!("document"));
thread
.metadata
.insert("session_mode".to_string(), json!("theme_workbench"));
thread
.metadata
.insert("gate_key".to_string(), json!("publish_confirm"));
thread
.metadata
.insert("run_title".to_string(), json!("发布确认"));
thread
.metadata
.insert("content_id".to_string(), json!("content-from-thread"));
thread.updated_at = now;
let snapshot = SessionRuntimeSnapshot {
session_id: "session-thread-harness".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread,
turns: vec![latest_turn],
items: Vec::new(),
}],
};
let runtime = build_session_execution_runtime(
"session-thread-harness",
None,
None,
Some(&snapshot),
None,
)
.expect("runtime");
assert_eq!(runtime.recent_theme.as_deref(), Some("document"));
assert_eq!(
runtime.recent_session_mode.as_deref(),
Some("theme_workbench")
);
assert_eq!(runtime.recent_gate_key.as_deref(), Some("publish_confirm"));
assert_eq!(runtime.recent_run_title.as_deref(), Some("发布确认"));
assert_eq!(
runtime.recent_content_id.as_deref(),
Some("content-from-thread")
);
}
#[test]
fn falls_back_to_session_extension_data_recent_preferences() {
let mut session = Session::default();
session.id = "session-4".to_string();
session.extension_data = SessionExecutionRuntimePreferences {
web_search: false,
thinking: true,
task: true,
subagent: false,
}
.into_updated_extension_data(&Session::default())
.expect("extension data");
let runtime =
build_session_execution_runtime("session-4", Some(&session), None, None, None)
.expect("runtime");
assert_eq!(runtime.source, SessionExecutionRuntimeSource::Session);
assert_eq!(
runtime.recent_preferences,
Some(SessionExecutionRuntimePreferences {
web_search: false,
thinking: true,
task: true,
subagent: false,
})
);
}
#[test]
fn falls_back_to_session_extension_data_recent_team_selection() {
let mut session = Session::default();
session.id = "session-6".to_string();
session.extension_data = SessionExecutionRuntimeRecentTeamSelection {
disabled: true,
theme: Some("general".to_string()),
preferred_team_preset_id: None,
selected_team_id: None,
selected_team_source: None,
selected_team_label: None,
selected_team_description: None,
selected_team_summary: None,
selected_team_roles: None,
}
.into_updated_extension_data(&Session::default())
.expect("extension data");
let runtime =
build_session_execution_runtime("session-6", Some(&session), None, None, None)
.expect("runtime");
assert_eq!(
runtime.recent_team_selection,
Some(SessionExecutionRuntimeRecentTeamSelection {
disabled: true,
theme: Some("general".to_string()),
preferred_team_preset_id: None,
selected_team_id: None,
selected_team_source: None,
selected_team_label: None,
selected_team_description: None,
selected_team_summary: None,
selected_team_roles: None,
})
);
}
}
+15 -5
View File
@@ -55,6 +55,7 @@ async fn navigate(session: &CdpSessionHandle, args: &Value) -> Result<Value, Str
}
let url = get_string_arg(args, &["url"]).ok_or_else(|| "navigate 需要提供 url".to_string())?;
let wait_timeout_ms = get_u64_arg(args, &["timeout_ms"]).unwrap_or(DEFAULT_ACTION_TIMEOUT_MS);
let command_timeout_ms = resolve_navigation_command_timeout_ms(wait_timeout_ms);
let previous_url = session
.state()
.await
@@ -64,11 +65,7 @@ async fn navigate(session: &CdpSessionHandle, args: &Value) -> Result<Value, Str
.filter(|value| !value.trim().is_empty());
let mut event_rx = session.subscribe();
let response = session
.send_command(
"Page.navigate",
json!({ "url": url }),
DEFAULT_ACTION_TIMEOUT_MS,
)
.send_command("Page.navigate", json!({ "url": url }), command_timeout_ms)
.await?;
if let Some(error_text) = response
.get("errorText")
@@ -416,10 +413,23 @@ fn get_u64_arg(args: &Value, keys: &[&str]) -> Option<u64> {
.find_map(|key| args.get(*key).and_then(Value::as_u64))
}
fn resolve_navigation_command_timeout_ms(wait_timeout_ms: u64) -> u64 {
wait_timeout_ms.max(DEFAULT_ACTION_TIMEOUT_MS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_keep_navigation_command_timeout_at_least_default() {
assert_eq!(
resolve_navigation_command_timeout_ms(5_000),
DEFAULT_ACTION_TIMEOUT_MS
);
assert_eq!(resolve_navigation_command_timeout_ms(20_000), 20_000);
}
#[test]
fn should_accept_exact_expected_url_even_when_previous_matches() {
assert!(should_accept_navigation_page(
+23
View File
@@ -924,6 +924,16 @@ impl Default for ScreenshotChatConfig {
}
}
/// WebMCP 预留配置
///
/// 当前仅作为实验开关预留,不参与实际执行链。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct WebMcpConfig {
/// 是否允许未来接入 WebMCP 实验能力
#[serde(default)]
pub enabled: bool,
}
/// 实验室功能配置
///
/// 管理所有实验性功能的开关和配置
@@ -932,6 +942,9 @@ pub struct ExperimentalFeatures {
/// 截图对话功能配置
#[serde(default)]
pub screenshot_chat: ScreenshotChatConfig,
/// WebMCP 预留配置
#[serde(default)]
pub webmcp: WebMcpConfig,
/// 自动更新检查配置
#[serde(default)]
pub update_check: UpdateCheckConfig,
@@ -3157,11 +3170,18 @@ mod unit_tests {
assert_eq!(config.shortcut, "CommandOrControl+Alt+Q");
}
#[test]
fn test_webmcp_config_default() {
let config = WebMcpConfig::default();
assert!(!config.enabled);
}
#[test]
fn test_experimental_features_default() {
let config = ExperimentalFeatures::default();
assert!(!config.screenshot_chat.enabled);
assert_eq!(config.screenshot_chat.shortcut, "CommandOrControl+Alt+Q");
assert!(!config.webmcp.enabled);
}
#[test]
@@ -3171,10 +3191,12 @@ mod unit_tests {
enabled: true,
shortcut: "CommandOrControl+Alt+X".to_string(),
},
webmcp: WebMcpConfig { enabled: true },
..Default::default()
};
let yaml = serde_yaml::to_string(&config).unwrap();
assert!(yaml.contains("webmcp"));
assert!(yaml.contains("enabled: true"));
assert!(yaml.contains("shortcut: CommandOrControl+Alt+X"));
@@ -3215,6 +3237,7 @@ mod unit_tests {
config.experimental.screenshot_chat.shortcut,
"CommandOrControl+Alt+Q"
);
assert!(!config.experimental.webmcp.enabled);
// 语音输入测试
assert!(!config.experimental.voice_input.enabled);
assert_eq!(
@@ -40,6 +40,14 @@ async (args, helpers) => {
limit,
);
if (items.length === 0 && helpers.looksLikeLoginWall()) {
return {
ok: false,
error_code: "auth_required",
error_message: `B 站没有返回 "${query}" 的搜索结果,可能需要先登录。`,
};
}
return {
ok: true,
data: {
+14
View File
@@ -228,6 +228,20 @@ impl AsterAgentWrapper {
lime_agent::update_session_provider_config_sync(db, session_id, provider_name, model_name)
}
pub async fn persist_session_recent_preferences(
session_id: &str,
preferences: lime_agent::SessionExecutionRuntimePreferences,
) -> Result<(), String> {
lime_agent::persist_session_recent_preferences(session_id, preferences).await
}
pub async fn persist_session_recent_team_selection(
session_id: &str,
recent_team_selection: lime_agent::SessionExecutionRuntimeRecentTeamSelection,
) -> Result<(), String> {
lime_agent::persist_session_recent_team_selection(session_id, recent_team_selection).await
}
/// 删除会话
pub async fn delete_session(db: &DbConnection, session_id: &str) -> Result<(), String> {
lime_agent::delete_session(db, session_id).await
+14 -3
View File
@@ -12,6 +12,7 @@ pub const LIME_CREATE_IMAGE_TASK_TOOL_NAME: &str = "lime_create_image_generation
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 LIME_SITE_LIST_TOOL_NAME: &str = "lime_site_list";
pub const LIME_SITE_RECOMMEND_TOOL_NAME: &str = "lime_site_recommend";
pub const LIME_SITE_SEARCH_TOOL_NAME: &str = "lime_site_search";
pub const LIME_SITE_INFO_TOOL_NAME: &str = "lime_site_info";
pub const LIME_SITE_RUN_TOOL_NAME: &str = "lime_site_run";
@@ -451,6 +452,15 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SITE_RECOMMEND_TOOL_NAME,
profiles: BROWSER_PROFILES,
capabilities: SITE_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SITE_SEARCH_TOOL_NAME,
profiles: BROWSER_PROFILES,
@@ -714,14 +724,14 @@ mod tests {
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
let browser = tool_catalog_entries_for_surface(WorkspaceToolSurface::browser_assist());
assert_eq!(browser.len(), 31);
assert_eq!(browser.len(), 32);
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(), 39);
assert_eq!(combined.len(), 40);
}
#[test]
@@ -740,9 +750,10 @@ mod tests {
let names = workspace_default_allowed_tool_names(
WorkspaceToolSurface::creator_with_browser_assist(),
);
assert_eq!(names.len(), 26);
assert_eq!(names.len(), 27);
assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(&"tool_search"));
assert!(names.contains(&LIME_SITE_RECOMMEND_TOOL_NAME));
assert!(names.contains(&LIME_SITE_RUN_TOOL_NAME));
assert!(!names
.iter()
+2 -2
View File
@@ -910,8 +910,8 @@ mod tests {
.map(ToString::to_string)
.collect::<Vec<_>>();
assert_eq!(inventory.counts.catalog_total, 39);
assert_eq!(inventory.counts.catalog_current_total, 38);
assert_eq!(inventory.counts.catalog_total, 40);
assert_eq!(inventory.counts.catalog_current_total, 39);
assert_eq!(inventory.counts.catalog_compat_total, 1);
assert_eq!(inventory.default_allowed_tools, expected_default_allowed);
assert_eq!(
+16
View File
@@ -127,6 +127,22 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
}
}
{
let conn = database::lock_db(&db).map_err(|e| format!("Failed to lock database: {e}"))?;
let seeded_profiles =
crate::services::browser_profile_service::ensure_default_browser_profiles(&conn)
.map_err(|error| format!("初始化默认浏览器资料失败: {error}"))?;
let seeded_environment_presets = crate::services::browser_environment_service::ensure_default_browser_environment_presets(&conn)
.map_err(|error| format!("初始化默认浏览器环境预设失败: {error}"))?;
if seeded_profiles || seeded_environment_presets {
tracing::info!(
"[Bootstrap] 默认浏览器资源已就绪: profiles_seeded={}, presets_seeded={}",
seeded_profiles,
seeded_environment_presets
);
}
}
initialize_aster_runtime(db.clone()).map_err(|e| format!("Aster 运行时初始化失败: {e}"))?;
// 服务状态
+6
View File
@@ -1287,6 +1287,7 @@ pub fn run() {
commands::browser_runtime_cmd::launch_browser_session,
commands::browser_runtime_cmd::launch_browser_runtime_assist,
commands::site_capability_cmd::site_list_adapters,
commands::site_capability_cmd::site_recommend_adapters,
commands::site_capability_cmd::site_search_adapters,
commands::site_capability_cmd::site_get_adapter_info,
commands::site_capability_cmd::site_get_adapter_catalog_status,
@@ -1449,6 +1450,11 @@ pub fn run() {
commands::aster_agent_cmd::command_api::session_api::agent_runtime_list_sessions,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_session,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_thread_read,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_analysis_handoff,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_handoff_bundle,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_evidence_pack,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_review_decision_template,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_replay_case,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_tool_inventory,
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_spawn_subagent,
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_send_subagent_input,
@@ -47,7 +47,9 @@ pub(crate) use provider_api::{
aster_agent_reset, aster_agent_status,
};
pub(crate) use runtime_api::{
agent_runtime_compact_session, agent_runtime_get_session, agent_runtime_get_thread_read,
agent_runtime_compact_session, agent_runtime_export_analysis_handoff,
agent_runtime_export_evidence_pack, agent_runtime_export_handoff_bundle,
agent_runtime_export_replay_case, agent_runtime_get_session, agent_runtime_get_thread_read,
agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn,
agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn,
agent_runtime_replay_request, agent_runtime_resume_thread, agent_runtime_submit_turn,
@@ -1,5 +1,21 @@
use super::*;
use crate::services::runtime_analysis_handoff_service::{
export_runtime_analysis_handoff, RuntimeAnalysisHandoffExportResult,
};
use crate::services::runtime_evidence_pack_service::{
export_runtime_evidence_pack, RuntimeEvidencePackExportResult,
};
use crate::services::runtime_handoff_artifact_service::{
export_runtime_handoff_bundle, RuntimeHandoffBundleExportResult,
};
use crate::services::runtime_replay_case_service::{
export_runtime_replay_case, RuntimeReplayCaseExportResult,
};
use crate::services::runtime_review_decision_service::{
export_runtime_review_decision_template, RuntimeReviewDecisionTemplateExportResult,
};
use crate::services::thread_reliability_projection_service::sync_thread_reliability_projection;
use std::path::PathBuf;
#[tauri::command]
pub async fn agent_runtime_submit_turn(
@@ -205,6 +221,274 @@ pub async fn agent_runtime_get_thread_read(
))
}
struct RuntimeExportContext {
detail: SessionDetail,
thread_read: AgentRuntimeThreadReadModel,
workspace_root: PathBuf,
}
fn resolve_runtime_export_workspace_root(
db: &DbConnection,
detail: &SessionDetail,
) -> Result<PathBuf, String> {
if let Some(workspace_id) = detail
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let manager = WorkspaceManager::new(db.clone());
let workspace_id = workspace_id.to_string();
let workspace = manager
.get(&workspace_id)
.map_err(|error| format!("读取 workspace 失败: {error}"))?
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?;
return Ok(ensured.root_path);
}
if let Some(working_dir) = detail
.working_dir
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(PathBuf::from(working_dir));
}
Err("当前会话缺少 workspace / working_dir,无法导出运行时制品".to_string())
}
async fn load_runtime_export_context(
app: &AppHandle,
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
session_id: &str,
action_label: &str,
) -> Result<RuntimeExportContext, String> {
if let Err(error) = resume_runtime_queue_if_needed_service(
app.clone(),
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
session_id.to_string(),
build_runtime_queue_executor(),
)
.await
{
tracing::warn!(
"[AsterAgent][Queue] {} 前恢复排队执行失败: session_id={}, error={}",
action_label,
session_id,
error
);
}
let detail = AsterAgentWrapper::get_runtime_session_detail(db, session_id).await?;
let queued_turns = list_runtime_queue_snapshots_service(session_id).await?;
let projection = sync_thread_reliability_projection(db, &detail)?;
let interrupt_marker = state.get_interrupt_marker(session_id).await;
let thread_read = AgentRuntimeThreadReadModel::from_parts(
&detail,
&queued_turns,
projection.pending_requests,
projection.last_outcome,
projection.incidents,
interrupt_marker.as_ref(),
);
let workspace_root = resolve_runtime_export_workspace_root(db, &detail)?;
Ok(RuntimeExportContext {
detail,
thread_read,
workspace_root,
})
}
/// 统一运行时:导出当前会话的交接制品 bundle。
#[tauri::command]
pub async fn agent_runtime_export_handoff_bundle(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
session_id: String,
) -> Result<RuntimeHandoffBundleExportResult, String> {
tracing::info!("[AsterAgent] 导出 handoff bundle: {}", session_id);
let context = load_runtime_export_context(
&app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
&session_id,
"导出 handoff bundle",
)
.await?;
export_runtime_handoff_bundle(
&context.detail,
&context.thread_read,
&context.workspace_root,
)
}
/// 统一运行时:导出当前会话的最小问题证据包。
#[tauri::command]
pub async fn agent_runtime_export_evidence_pack(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
session_id: String,
) -> Result<RuntimeEvidencePackExportResult, String> {
tracing::info!("[AsterAgent] 导出 evidence pack: {}", session_id);
let context = load_runtime_export_context(
&app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
&session_id,
"导出 evidence pack",
)
.await?;
export_runtime_evidence_pack(
&context.detail,
&context.thread_read,
&context.workspace_root,
)
}
/// 统一运行时:导出当前会话的外部分析交接包。
#[tauri::command]
pub async fn agent_runtime_export_analysis_handoff(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
session_id: String,
) -> Result<RuntimeAnalysisHandoffExportResult, String> {
tracing::info!("[AsterAgent] 导出 analysis handoff: {}", session_id);
let context = load_runtime_export_context(
&app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
&session_id,
"导出 analysis handoff",
)
.await?;
export_runtime_analysis_handoff(
&context.detail,
&context.thread_read,
&context.workspace_root,
)
}
/// 统一运行时:导出当前会话的人工审核记录模板。
#[tauri::command]
pub async fn agent_runtime_export_review_decision_template(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
session_id: String,
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
tracing::info!("[AsterAgent] 导出 review decision 模板: {}", session_id);
let context = load_runtime_export_context(
&app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
&session_id,
"导出 review decision 模板",
)
.await?;
export_runtime_review_decision_template(
&context.detail,
&context.thread_read,
&context.workspace_root,
)
}
/// 统一运行时:导出当前会话的 replay case。
#[tauri::command]
pub async fn agent_runtime_export_replay_case(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
session_id: String,
) -> Result<RuntimeReplayCaseExportResult, String> {
tracing::info!("[AsterAgent] 导出 replay case: {}", session_id);
let context = load_runtime_export_context(
&app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
&session_id,
"导出 replay case",
)
.await?;
export_runtime_replay_case(
&context.detail,
&context.thread_read,
&context.workspace_root,
)
}
/// 统一运行时:重新拉起指定 pending request 的前端交互载荷。
#[tauri::command]
pub async fn agent_runtime_replay_request(
@@ -91,5 +91,21 @@ pub async fn agent_runtime_update_session(
)?;
}
if let Some(recent_preferences) = request.recent_preferences {
AsterAgentWrapper::persist_session_recent_preferences(
&trimmed_session_id,
recent_preferences,
)
.await?;
}
if let Some(recent_team_selection) = request.recent_team_selection {
AsterAgentWrapper::persist_session_recent_team_selection(
&trimmed_session_id,
recent_team_selection,
)
.await?;
}
Ok(())
}
@@ -140,7 +140,7 @@ pub struct AgentRuntimeSubmitTurnRequest {
#[serde(default)]
pub images: Option<Vec<ImageInput>>,
#[serde(alias = "workspaceId")]
pub workspace_id: String,
pub workspace_id: Option<String>,
#[serde(default, alias = "turnConfig")]
pub turn_config: Option<AgentTurnConfigSnapshot>,
#[serde(default, alias = "turnId")]
@@ -173,7 +173,7 @@ impl From<AgentRuntimeSubmitTurnRequest> for AsterChatRequest {
.as_ref()
.and_then(|config| config.thinking_enabled),
project_id: None,
workspace_id: request.workspace_id,
workspace_id: request.workspace_id.unwrap_or_default(),
web_search: turn_config.as_ref().and_then(|config| config.web_search),
search_mode: turn_config.as_ref().and_then(|config| config.search_mode),
execution_strategy: turn_config
@@ -1595,6 +1595,10 @@ pub struct AgentRuntimeUpdateSessionRequest {
pub model_name: Option<String>,
#[serde(default, alias = "executionStrategy")]
pub execution_strategy: Option<AsterExecutionStrategy>,
#[serde(default, alias = "recentPreferences")]
pub recent_preferences: Option<lime_agent::SessionExecutionRuntimePreferences>,
#[serde(default, alias = "recentTeamSelection")]
pub recent_team_selection: Option<lime_agent::SessionExecutionRuntimeRecentTeamSelection>,
}
/// 自动续写参数
+22 -12
View File
@@ -24,8 +24,8 @@ use crate::agent_tools::catalog::{
LIME_CREATE_IMAGE_TASK_TOOL_NAME, LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME,
LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, LIME_CREATE_URL_PARSE_TASK_TOOL_NAME,
LIME_CREATE_VIDEO_TASK_TOOL_NAME, LIME_SITE_INFO_TOOL_NAME, LIME_SITE_LIST_TOOL_NAME,
LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME,
TOOL_SEARCH_TOOL_NAME,
LIME_SITE_RECOMMEND_TOOL_NAME, LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME,
SOCIAL_IMAGE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME,
};
#[cfg(test)]
use crate::agent_tools::execution::build_workspace_shell_allow_pattern;
@@ -301,6 +301,8 @@ pub(crate) use browser_assist::{
#[allow(unused_imports)]
pub(crate) use command_api::{
agent_runtime_close_subagent, agent_runtime_compact_session, agent_runtime_create_session,
agent_runtime_export_analysis_handoff, agent_runtime_export_evidence_pack,
agent_runtime_export_handoff_bundle, agent_runtime_export_replay_case,
agent_runtime_get_session, agent_runtime_get_thread_read, agent_runtime_get_tool_inventory,
agent_runtime_interrupt_turn, agent_runtime_list_sessions, agent_runtime_promote_queued_turn,
agent_runtime_remove_queued_turn, agent_runtime_replay_request, agent_runtime_resume_subagent,
@@ -309,21 +311,23 @@ pub(crate) use command_api::{
aster_agent_configure_from_pool, aster_agent_configure_provider, aster_agent_init,
aster_agent_reset, aster_agent_status,
};
#[allow(unused_imports)]
pub(crate) use dto::{
build_incidents, build_last_outcome, build_pending_requests, AgentRuntimeActionType,
AgentRuntimeCloseSubagentRequest, AgentRuntimeCloseSubagentResponse,
AgentRuntimeCompactSessionRequest, AgentRuntimeIncidentView, AgentRuntimeInterruptTurnRequest,
AgentRuntimeOutcomeView, AgentRuntimePromoteQueuedTurnRequest,
AgentRuntimeRemoveQueuedTurnRequest, AgentRuntimeReplayRequestRequest,
AgentRuntimeReplayedActionRequiredView, AgentRuntimeRequestView,
AgentRuntimeRespondActionRequest, AgentRuntimeResumeSubagentRequest,
AgentRuntimeCompactSessionRequest, AgentRuntimeDiagnosticPendingRequestSample,
AgentRuntimeDiagnosticWarningSample, AgentRuntimeIncidentView,
AgentRuntimeInterruptTurnRequest, AgentRuntimeOutcomeView,
AgentRuntimePromoteQueuedTurnRequest, AgentRuntimeRemoveQueuedTurnRequest,
AgentRuntimeReplayRequestRequest, AgentRuntimeReplayedActionRequiredView,
AgentRuntimeRequestView, AgentRuntimeRespondActionRequest, AgentRuntimeResumeSubagentRequest,
AgentRuntimeResumeSubagentResponse, AgentRuntimeResumeThreadRequest,
AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse,
AgentRuntimeSessionDetail, AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse,
AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadReadModel, AgentRuntimeToolInventoryRequest,
AgentRuntimeUpdateSessionRequest, AgentRuntimeWaitSubagentsRequest,
AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, AsterChatRequest, AutoContinuePayload,
ConfigureFromPoolRequest, ConfigureProviderRequest,
AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadDiagnostics, AgentRuntimeThreadReadModel,
AgentRuntimeToolInventoryRequest, AgentRuntimeUpdateSessionRequest,
AgentRuntimeWaitSubagentsRequest, AgentRuntimeWaitSubagentsResponse, AsterAgentStatus,
AsterChatRequest, AutoContinuePayload, ConfigureFromPoolRequest, ConfigureProviderRequest,
};
pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensions};
#[cfg(test)]
@@ -351,9 +355,15 @@ use run_metadata::{
resolve_social_run_artifact_descriptor,
};
pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_executor};
#[cfg(test)]
pub(crate) use runtime_turn::{
resolve_request_web_search_preference_from_sources, resolve_workspace_id_from_sources,
};
pub(crate) use session_runtime::{
delete_runtime_session_internal, persist_session_provider_routing,
resolve_session_provider_selector,
resolve_recent_preference_from_sources, resolve_session_provider_selector,
resolve_session_recent_harness_context, resolve_session_recent_preferences,
resolve_session_recent_team_selection, SessionRecentHarnessContext,
};
pub(crate) use subagent_runtime::{
agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal,
@@ -234,32 +234,97 @@ fn render_team_roles(role_items: &[serde_json::Value]) -> Vec<String> {
pub(crate) fn build_team_preference_system_prompt(
request_metadata: Option<&serde_json::Value>,
session_recent_team_selection: Option<&lime_agent::SessionExecutionRuntimeRecentTeamSelection>,
subagent_mode_enabled: bool,
) -> Option<String> {
let subagent_mode_enabled = extract_harness_bool(
request_metadata,
&["subagent_mode_enabled", "subagentModeEnabled"],
)
.unwrap_or(false);
let preferred_team_preset_id = extract_harness_string(
let request_has_team_selection = extract_harness_string(
request_metadata,
&["preferred_team_preset_id", "preferredTeamPresetId"],
);
let selected_team_source = extract_harness_string(
request_metadata,
&["selected_team_source", "selectedTeamSource"],
);
let selected_team_label = extract_harness_string(
request_metadata,
&["selected_team_label", "selectedTeamLabel"],
);
let selected_team_summary = extract_harness_string(
request_metadata,
&["selected_team_summary", "selectedTeamSummary"],
);
let selected_team_roles = extract_harness_array(
request_metadata,
&["selected_team_roles", "selectedTeamRoles"],
);
)
.is_some()
|| extract_harness_string(request_metadata, &["selected_team_id", "selectedTeamId"])
.is_some()
|| extract_harness_string(
request_metadata,
&["selected_team_source", "selectedTeamSource"],
)
.is_some()
|| extract_harness_string(
request_metadata,
&["selected_team_label", "selectedTeamLabel"],
)
.is_some()
|| extract_harness_string(
request_metadata,
&["selected_team_summary", "selectedTeamSummary"],
)
.is_some()
|| extract_harness_array(
request_metadata,
&["selected_team_roles", "selectedTeamRoles"],
)
.is_some();
let preferred_team_preset_id = if request_has_team_selection {
extract_harness_string(
request_metadata,
&["preferred_team_preset_id", "preferredTeamPresetId"],
)
} else {
session_recent_team_selection
.and_then(|selection| selection.preferred_team_preset_id.clone())
};
let selected_team_source = if request_has_team_selection {
extract_harness_string(
request_metadata,
&["selected_team_source", "selectedTeamSource"],
)
} else {
session_recent_team_selection.and_then(|selection| selection.selected_team_source.clone())
};
let selected_team_label = if request_has_team_selection {
extract_harness_string(
request_metadata,
&["selected_team_label", "selectedTeamLabel"],
)
} else {
session_recent_team_selection.and_then(|selection| selection.selected_team_label.clone())
};
let selected_team_summary = if request_has_team_selection {
extract_harness_string(
request_metadata,
&["selected_team_summary", "selectedTeamSummary"],
)
} else {
session_recent_team_selection.and_then(|selection| selection.selected_team_summary.clone())
};
let selected_team_roles = if request_has_team_selection {
extract_harness_array(
request_metadata,
&["selected_team_roles", "selectedTeamRoles"],
)
.cloned()
.filter(|roles| !roles.is_empty())
} else {
session_recent_team_selection
.and_then(|selection| selection.selected_team_roles.as_ref())
.map(|roles| {
roles
.iter()
.map(|role| {
serde_json::json!({
"id": role.id,
"label": role.label,
"summary": role.summary,
"profile_id": role.profile_id,
"role_key": role.role_key,
"skill_ids": role.skill_ids,
})
})
.collect::<Vec<_>>()
})
.filter(|roles| !roles.is_empty())
};
if !subagent_mode_enabled {
return None;
@@ -299,7 +364,7 @@ pub(crate) fn build_team_preference_system_prompt(
lines.push(format!("- Team 摘要:{team_summary}"));
}
if let Some(role_items) = selected_team_roles {
if let Some(role_items) = selected_team_roles.as_ref() {
let rendered_roles = render_team_roles(role_items);
if !rendered_roles.is_empty() {
lines.push("- 当前 Team 角色参考:".to_string());
@@ -330,8 +395,14 @@ pub(crate) fn build_team_preference_system_prompt(
pub(crate) fn merge_system_prompt_with_team_preference(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
session_recent_team_selection: Option<&lime_agent::SessionExecutionRuntimeRecentTeamSelection>,
subagent_mode_enabled: bool,
) -> Option<String> {
let Some(team_prompt) = build_team_preference_system_prompt(request_metadata) else {
let Some(team_prompt) = build_team_preference_system_prompt(
request_metadata,
session_recent_team_selection,
subagent_mode_enabled,
) else {
return base_prompt;
};
@@ -144,28 +144,59 @@ fn message_suggests_content_generation(message: &str) -> bool {
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn build_turn_runtime_statuses(
fn resolve_request_thinking_enabled_from_sources(
request: &AsterChatRequest,
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> bool {
request
.thinking_enabled
.or_else(|| {
resolve_recent_preference_from_sources(
request.metadata.as_ref(),
&["thinking_enabled", "thinkingEnabled"],
session_recent_preferences.map(|preferences| preferences.thinking),
)
})
.unwrap_or(false)
}
fn resolve_request_task_enabled_from_sources(
request: &AsterChatRequest,
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> bool {
resolve_recent_preference_from_sources(
request.metadata.as_ref(),
&["task_mode_enabled", "taskModeEnabled"],
session_recent_preferences.map(|preferences| preferences.task),
)
.unwrap_or(false)
}
fn resolve_request_subagent_enabled_from_sources(
request: &AsterChatRequest,
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> bool {
resolve_recent_preference_from_sources(
request.metadata.as_ref(),
&["subagent_mode_enabled", "subagentModeEnabled"],
session_recent_preferences.map(|preferences| preferences.subagent),
)
.unwrap_or(false)
}
pub(super) async fn build_turn_runtime_statuses(
request: &AsterChatRequest,
effective_strategy: AsterExecutionStrategy,
request_tool_policy: &RequestToolPolicy,
model_name: Option<&str>,
) -> (AgentRuntimeStatus, AgentRuntimeStatus) {
let thinking_enabled = extract_harness_bool(
request.metadata.as_ref(),
&["thinking_enabled", "thinkingEnabled"],
)
.or(request.thinking_enabled)
.unwrap_or(false);
let task_enabled = extract_harness_bool(
request.metadata.as_ref(),
&["task_mode_enabled", "taskModeEnabled"],
)
.unwrap_or(false);
let subagent_enabled = extract_harness_bool(
request.metadata.as_ref(),
&["subagent_mode_enabled", "subagentModeEnabled"],
)
.unwrap_or(false);
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> Result<(AgentRuntimeStatus, AgentRuntimeStatus), String> {
let thinking_enabled =
resolve_request_thinking_enabled_from_sources(request, session_recent_preferences);
let task_enabled =
resolve_request_task_enabled_from_sources(request, session_recent_preferences);
let subagent_enabled =
resolve_request_subagent_enabled_from_sources(request, session_recent_preferences);
let reasoning_supported = model_supports_reasoning(model_name);
let news_expansion_needed = request_tool_policy.allows_web_search()
&& message_suggests_news_expansion(&request.message);
@@ -306,7 +337,7 @@ pub(super) fn build_turn_runtime_statuses(
)
};
(
Ok((
AgentRuntimeStatus {
phase: "preparing".to_string(),
title: "正在理解意图".to_string(),
@@ -322,7 +353,7 @@ pub(super) fn build_turn_runtime_statuses(
checkpoints: decided.2,
metadata: None,
},
)
))
}
fn emit_projected_runtime_item_event(
@@ -564,16 +564,26 @@ fn resolve_provider_model_compatibility(provider_key: &str, model_id: &str) -> S
model_id.to_string()
}
fn extract_request_thinking_enabled(request: &AsterChatRequest) -> bool {
request.thinking_enabled.unwrap_or_else(|| {
fn extract_request_thinking_enabled(request: &AsterChatRequest) -> Option<bool> {
request.thinking_enabled.or_else(|| {
extract_harness_bool(
request.metadata.as_ref(),
&["thinking_enabled", "thinkingEnabled"],
)
.unwrap_or(false)
})
}
async fn resolve_request_thinking_enabled(request: &AsterChatRequest) -> Result<bool, String> {
if let Some(thinking_enabled) = extract_request_thinking_enabled(request) {
return Ok(thinking_enabled);
}
Ok(resolve_session_recent_preferences(&request.session_id)
.await?
.map(|preferences| preferences.thinking)
.unwrap_or(false))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RequestPreferenceSource {
Request,
@@ -699,7 +709,7 @@ pub(super) async fn resolve_runtime_request_provider_config(
let context =
build_provider_resolution_context(db, api_key_provider_service, &provider_selector)?;
let (catalog, _alias_config) = load_model_registry_catalog(app, &context).await;
let thinking_enabled = extract_request_thinking_enabled(request);
let thinking_enabled = resolve_request_thinking_enabled(request).await?;
let has_images = request
.images
.as_ref()
@@ -125,6 +125,8 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
("selectedTeamSource", "selected_team_source"),
("selected_team_label", "selected_team_label"),
("selectedTeamLabel", "selected_team_label"),
("selected_team_description", "selected_team_description"),
("selectedTeamDescription", "selected_team_description"),
("selected_team_summary", "selected_team_summary"),
("selectedTeamSummary", "selected_team_summary"),
("selected_team_roles", "selected_team_roles"),
@@ -178,6 +180,7 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
request_tool_policy: &RequestToolPolicy,
auto_continue_enabled: bool,
auto_continue_metadata: Option<&AutoContinuePayload>,
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> serde_json::Map<String, serde_json::Value> {
let mut metadata = serde_json::Map::new();
metadata.insert("workspace_id".to_string(), serde_json::json!(workspace_id));
@@ -214,6 +217,34 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
serde_json::json!(auto_continue_metadata),
);
extend_map_with_harness_fields(&mut metadata, request.metadata.as_ref());
for (target_key, preference_keys, session_value) in [
(
"thinking_enabled",
&["thinking_enabled", "thinkingEnabled"][..],
session_recent_preferences.map(|preferences| preferences.thinking),
),
(
"task_mode_enabled",
&["task_mode_enabled", "taskModeEnabled"][..],
session_recent_preferences.map(|preferences| preferences.task),
),
(
"subagent_mode_enabled",
&["subagent_mode_enabled", "subagentModeEnabled"][..],
session_recent_preferences.map(|preferences| preferences.subagent),
),
] {
if metadata.contains_key(target_key) {
continue;
}
if let Some(value) = resolve_recent_preference_from_sources(
request.metadata.as_ref(),
preference_keys,
session_value,
) {
metadata.insert(target_key.to_string(), serde_json::json!(value));
}
}
metadata
}
@@ -56,6 +56,64 @@ fn merge_turn_context_with_artifact_output_schema(
)
}
fn normalize_runtime_turn_request_metadata(
request: &mut AsterChatRequest,
session_recent_theme: Option<&str>,
session_recent_session_mode: Option<&str>,
session_recent_gate_key: Option<&str>,
session_recent_run_title: Option<&str>,
session_recent_content_id: Option<&str>,
) {
request.metadata = crate::services::artifact_request_metadata_service::
normalize_request_metadata_with_artifact_defaults(
request.metadata.take(),
session_recent_theme,
session_recent_session_mode,
session_recent_gate_key,
session_recent_run_title,
session_recent_content_id,
);
}
pub(crate) fn resolve_workspace_id_from_sources(
request_workspace_id: Option<String>,
session_workspace_id: Option<String>,
) -> Option<String> {
normalize_optional_text(request_workspace_id)
.or_else(|| normalize_optional_text(session_workspace_id))
}
fn resolve_runtime_turn_workspace_id(
db: &DbConnection,
request: &AsterChatRequest,
) -> Result<String, String> {
if let Some(workspace_id) =
resolve_workspace_id_from_sources(Some(request.workspace_id.clone()), None)
{
return Ok(workspace_id);
}
let session_workspace_id =
AsterAgentWrapper::get_session_sync(db, &request.session_id)?.workspace_id;
resolve_workspace_id_from_sources(None, session_workspace_id)
.ok_or_else(|| "workspace_id 必填,请先选择项目工作区".to_string())
}
pub(crate) fn resolve_request_web_search_preference_from_sources(
request_web_search: Option<bool>,
request_metadata: Option<&serde_json::Value>,
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> Option<bool> {
request_web_search.or_else(|| {
resolve_recent_preference_from_sources(
request_metadata,
&["web_search_enabled", "webSearchEnabled"],
session_recent_preferences.map(|preferences| preferences.web_search),
)
})
}
fn should_skip_artifact_document_autopersist(
run_observation: &Arc<Mutex<ChatRunObservation>>,
final_text_output: &str,
@@ -75,6 +133,7 @@ fn should_skip_artifact_document_autopersist(
fn maybe_persist_artifact_document_after_stream(
app: &AppHandle,
db: &DbConnection,
event_name: &str,
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
run_observation: &Arc<Mutex<ChatRunObservation>>,
@@ -137,6 +196,19 @@ fn maybe_persist_artifact_document_after_stream(
},
);
if let Err(error) =
crate::services::artifact_document_service::sync_persisted_artifact_document_to_content(
db,
request_metadata,
&persisted,
)
{
tracing::warn!(
"[AsterAgent] ArtifactDocument 已落盘,但同步内容版本状态失败: {}",
error
);
}
if persisted.repaired || persisted.status == "failed" {
let (code, prefix) = if persisted.status == "failed" {
(
@@ -218,20 +290,49 @@ async fn execute_aster_chat_request(
{
request.provider_config = Some(resolved_provider_config);
}
let should_resolve_session_recent_harness_context = extract_harness_string(
request.metadata.as_ref(),
&["theme", "harness_theme", "harnessTheme"],
)
.is_none()
|| extract_harness_string(request.metadata.as_ref(), &["session_mode", "sessionMode"])
.is_none()
|| extract_harness_string(request.metadata.as_ref(), &["gate_key", "gateKey"]).is_none()
|| extract_harness_string(
request.metadata.as_ref(),
&["run_title", "runTitle", "title"],
)
.is_none()
|| extract_harness_string(request.metadata.as_ref(), &["content_id", "contentId"])
.is_none();
let session_recent_harness_context = if should_resolve_session_recent_harness_context {
resolve_session_recent_harness_context(&request.session_id).await?
} else {
SessionRecentHarnessContext::default()
};
normalize_runtime_turn_request_metadata(
&mut request,
session_recent_harness_context.theme.as_deref(),
session_recent_harness_context.session_mode.as_deref(),
session_recent_harness_context.gate_key.as_deref(),
session_recent_harness_context.run_title.as_deref(),
session_recent_harness_context.content_id.as_deref(),
);
// 直接使用前端传递的 session_id
// LimeSessionStore 会在 add_message 时自动创建不存在的 session
// 同时 get_session 也会自动创建不存在的 session
let session_id = &request.session_id;
let workspace_id = request.workspace_id.trim().to_string();
if workspace_id.is_empty() {
let message = "workspace_id 必填,请先选择项目工作区".to_string();
logs.write()
.await
.add("error", &format!("[AsterAgent] {}", message));
return Err(message);
}
let workspace_id = match resolve_runtime_turn_workspace_id(db, &request) {
Ok(workspace_id) => workspace_id,
Err(message) => {
logs.write()
.await
.add("error", &format!("[AsterAgent] {}", message));
return Err(message);
}
};
let manager = WorkspaceManager::new(db.clone());
let workspace = match manager.get(&workspace_id) {
@@ -336,12 +437,19 @@ async fn execute_aster_chat_request(
);
}
let session_recent_preferences = resolve_session_recent_preferences(session_id).await?;
let session_recent_team_selection = resolve_session_recent_team_selection(session_id).await?;
let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref());
let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode);
let resolved_request_web_search = resolve_request_web_search_preference_from_sources(
request.web_search,
request.metadata.as_ref(),
session_recent_preferences.as_ref(),
);
let (request_web_search, request_search_mode) =
apply_browser_requirement_to_request_tool_policy(
request.metadata.as_ref(),
request.web_search,
resolved_request_web_search,
request.search_mode,
);
@@ -497,6 +605,15 @@ async fn execute_aster_chat_request(
let prompt_with_team_preference = merge_system_prompt_with_team_preference(
prompt_with_elicitation,
request.metadata.as_ref(),
session_recent_team_selection.as_ref(),
resolve_recent_preference_from_sources(
request.metadata.as_ref(),
&["subagent_mode_enabled", "subagentModeEnabled"],
session_recent_preferences
.as_ref()
.map(|preferences| preferences.subagent),
)
.unwrap_or(false),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::TeamPreference,
@@ -698,8 +815,6 @@ async fn execute_aster_chat_request(
let tracker = ExecutionTracker::new(db.clone());
let cancel_token = state.create_cancel_token(session_id).await;
let auto_continue_metadata = auto_continue_config.clone();
request.metadata = crate::services::artifact_request_metadata_service::
normalize_request_metadata_with_artifact_defaults(request.metadata.take());
let request_metadata = request.metadata.clone();
sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await;
let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref());
@@ -768,6 +883,7 @@ async fn execute_aster_chat_request(
&request_tool_policy,
auto_continue_enabled,
auto_continue_metadata.as_ref(),
session_recent_preferences.as_ref(),
);
if let Ok(session_state_value) = serde_json::to_value(&session_state_snapshot) {
run_start_metadata.insert("session_state".to_string(), session_state_value);
@@ -830,7 +946,9 @@ async fn execute_aster_chat_request(
.provider_config
.as_ref()
.map(|config| config.model_name.as_str()),
);
session_recent_preferences.as_ref(),
)
.await?;
for status in [initial_runtime_status, decided_runtime_status] {
emit_runtime_status_with_projection(
agent,
@@ -936,6 +1054,7 @@ async fn execute_aster_chat_request(
Ok(execution) => {
maybe_persist_artifact_document_after_stream(
&app,
db,
&request.event_name,
&timeline_recorder,
&run_observation,
@@ -1026,6 +1145,7 @@ async fn execute_aster_chat_request(
.map(|execution| {
maybe_persist_artifact_document_after_stream(
&app,
db,
&request.event_name,
&timeline_recorder,
&run_observation,
@@ -1813,6 +1933,7 @@ mod tests {
use lime_core::database::schema::create_tables;
use lime_services::aster_session_store::LimeSessionStore;
use rusqlite::Connection;
use serde_json::{json, Value};
use std::fs;
use tokio::sync::OnceCell;
@@ -1839,6 +1960,270 @@ mod tests {
.await;
}
#[test]
fn normalize_runtime_turn_request_metadata_should_enable_artifact_prompt_before_turn_build() {
let mut request = AsterChatRequest {
message: "请基于目标先生成一版演示提纲".to_string(),
session_id: "session-artifact".to_string(),
event_name: "agent_stream".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: None,
workspace_id: "workspace-artifact".to_string(),
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt: None,
metadata: Some(json!({
"harness": {
"theme": "document",
"session_mode": "theme_workbench",
"content_id": "content-1"
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
};
let raw_prompt = merge_system_prompt_with_artifact_context(
Some("基础系统提示".to_string()),
request.metadata.as_ref(),
)
.expect("raw prompt");
assert!(!raw_prompt.contains("【Artifact 交付策略】"));
normalize_runtime_turn_request_metadata(&mut request, None, None, None, None, None);
let normalized_metadata = request.metadata.as_ref().expect("normalized metadata");
assert_eq!(
normalized_metadata
.pointer("/artifact/artifact_mode")
.and_then(Value::as_str),
Some("draft")
);
let prompt = merge_system_prompt_with_artifact_context(
Some("基础系统提示".to_string()),
Some(normalized_metadata),
)
.expect("normalized prompt");
assert!(prompt.contains("【Artifact 交付策略】"));
assert!(prompt.contains("【Artifact Stage 2 合同】"));
assert!(prompt.contains("artifact:content-1"));
let mut turn_input_builder =
TurnInputEnvelopeBuilder::new(&request.session_id, &request.workspace_id);
turn_input_builder
.set_base_system_prompt(
TurnSystemPromptSource::Frontend,
Some("基础系统提示".to_string()),
)
.set_turn_context_metadata_from_value(request.metadata.as_ref())
.set_effective_user_message(&request.message)
.apply_prompt_stage(TurnPromptAugmentationStageKind::Artifact, Some(prompt));
let envelope = turn_input_builder.build();
let diagnostics = envelope.diagnostics_snapshot();
let turn_context = envelope.turn_context_override().expect("turn context");
assert!(diagnostics.has_turn_context_metadata);
assert!(diagnostics
.turn_context_metadata_keys
.contains(&"artifact".to_string()));
assert_eq!(
turn_context
.metadata
.get("artifact")
.and_then(|artifact| artifact.get("artifact_stage"))
.and_then(Value::as_str),
Some("stage2")
);
}
#[test]
fn normalize_runtime_turn_request_metadata_should_backfill_content_id_from_session_runtime() {
let mut request = AsterChatRequest {
message: "继续完善当前文档".to_string(),
session_id: "session-artifact-content-fallback".to_string(),
event_name: "agent_stream".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: None,
workspace_id: "workspace-artifact".to_string(),
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt: None,
metadata: Some(json!({
"harness": {
"theme": "document",
"session_mode": "theme_workbench"
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
};
normalize_runtime_turn_request_metadata(
&mut request,
Some("document"),
Some("theme_workbench"),
None,
None,
Some("content-from-session"),
);
let normalized_metadata = request.metadata.as_ref().expect("normalized metadata");
assert_eq!(
normalized_metadata
.pointer("/harness/theme")
.and_then(Value::as_str),
Some("document")
);
assert_eq!(
normalized_metadata
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
);
assert_eq!(
normalized_metadata
.pointer("/harness/content_id")
.and_then(Value::as_str),
Some("content-from-session")
);
assert_eq!(
normalized_metadata
.pointer("/artifact/artifact_request_id")
.and_then(Value::as_str),
Some("artifact:content-from-session")
);
}
#[test]
fn normalize_runtime_turn_request_metadata_should_backfill_theme_and_session_mode_from_session_runtime(
) {
let mut request = AsterChatRequest {
message: "继续推进当前主题工作台".to_string(),
session_id: "session-artifact-theme-fallback".to_string(),
event_name: "agent_stream".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: None,
workspace_id: "workspace-artifact".to_string(),
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt: None,
metadata: Some(json!({
"harness": {
"content_id": "content-from-session"
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
};
normalize_runtime_turn_request_metadata(
&mut request,
Some("social-media"),
Some("theme_workbench"),
None,
None,
Some("content-from-session"),
);
let normalized_metadata = request.metadata.as_ref().expect("normalized metadata");
assert_eq!(
normalized_metadata
.pointer("/harness/theme")
.and_then(Value::as_str),
Some("social-media")
);
assert_eq!(
normalized_metadata
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
);
assert_eq!(
normalized_metadata
.pointer("/harness/content_id")
.and_then(Value::as_str),
Some("content-from-session")
);
}
#[test]
fn normalize_runtime_turn_request_metadata_should_backfill_gate_key_and_run_title_from_session_runtime(
) {
let mut request = AsterChatRequest {
message: "继续当前社媒运行".to_string(),
session_id: "session-social-gate-fallback".to_string(),
event_name: "agent_stream".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: None,
workspace_id: "workspace-social".to_string(),
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt: None,
metadata: Some(json!({
"harness": {
"theme": "social-media",
"session_mode": "theme_workbench",
"content_id": "content-social-1"
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
};
normalize_runtime_turn_request_metadata(
&mut request,
Some("social-media"),
Some("theme_workbench"),
Some("write_mode"),
Some("社媒初稿"),
Some("content-social-1"),
);
let normalized_metadata = request.metadata.as_ref().expect("normalized metadata");
assert_eq!(
normalized_metadata
.pointer("/harness/gate_key")
.and_then(Value::as_str),
Some("write_mode")
);
assert_eq!(
normalized_metadata
.pointer("/harness/run_title")
.and_then(Value::as_str),
Some("社媒初稿")
);
}
#[tokio::test]
async fn update_compaction_session_metrics_should_move_summary_tokens_to_current_window() {
ensure_runtime_turn_test_session_manager().await;
@@ -1,4 +1,5 @@
use super::*;
use aster::session::load_shared_session_runtime_snapshot;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SessionProviderRoutingState {
@@ -39,6 +40,15 @@ impl SessionProviderRoutingState {
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SessionRecentHarnessContext {
pub(crate) theme: Option<String>,
pub(crate) session_mode: Option<String>,
pub(crate) gate_key: Option<String>,
pub(crate) run_title: Option<String>,
pub(crate) content_id: Option<String>,
}
pub(crate) async fn persist_session_provider_routing(
session_id: &str,
provider_selector: &str,
@@ -59,6 +69,88 @@ pub(crate) fn resolve_session_provider_selector(
SessionProviderRoutingState::from_session(session).map(|state| state.provider_selector)
}
pub(crate) async fn resolve_session_recent_preferences(
session_id: &str,
) -> Result<Option<lime_agent::SessionExecutionRuntimePreferences>, String> {
let session = read_session(session_id, false, "读取会话 recent_preferences 失败").await?;
Ok(lime_agent::build_session_execution_runtime(
session_id,
Some(&session),
None,
None,
resolve_session_provider_selector(&session),
)
.and_then(|runtime| runtime.recent_preferences))
}
pub(crate) async fn resolve_session_recent_team_selection(
session_id: &str,
) -> Result<Option<lime_agent::SessionExecutionRuntimeRecentTeamSelection>, String> {
let session = read_session(session_id, false, "读取会话 recent_team_selection 失败").await?;
Ok(lime_agent::build_session_execution_runtime(
session_id,
Some(&session),
None,
None,
resolve_session_provider_selector(&session),
)
.and_then(|runtime| runtime.recent_team_selection))
}
pub(crate) async fn resolve_session_recent_harness_context(
session_id: &str,
) -> Result<SessionRecentHarnessContext, String> {
let trimmed_session_id = session_id.trim();
if trimmed_session_id.is_empty() {
return Ok(SessionRecentHarnessContext::default());
}
match load_shared_session_runtime_snapshot(trimmed_session_id).await {
Ok(snapshot) => {
let runtime = lime_agent::build_session_execution_runtime(
trimmed_session_id,
None,
None,
Some(&snapshot),
None,
);
Ok(SessionRecentHarnessContext {
theme: runtime
.as_ref()
.and_then(|value| value.recent_theme.clone()),
session_mode: runtime
.as_ref()
.and_then(|value| value.recent_session_mode.clone()),
gate_key: runtime
.as_ref()
.and_then(|value| value.recent_gate_key.clone()),
run_title: runtime
.as_ref()
.and_then(|value| value.recent_run_title.clone()),
content_id: runtime
.as_ref()
.and_then(|value| value.recent_content_id.clone()),
})
}
Err(error) => {
tracing::debug!(
"[AsterAgent] 读取 runtime snapshot 失败,跳过 recent harness context 回退: session_id={}, error={}",
trimmed_session_id,
error
);
Ok(SessionRecentHarnessContext::default())
}
}
}
pub(crate) fn resolve_recent_preference_from_sources(
request_metadata: Option<&serde_json::Value>,
keys: &[&str],
session_recent_preference: Option<bool>,
) -> Option<bool> {
extract_harness_bool(request_metadata, keys).or(session_recent_preference)
}
pub(crate) async fn create_runtime_session_internal(
db: &DbConnection,
working_dir: Option<String>,
+365 -5
View File
@@ -4,8 +4,8 @@ mod tests {
use crate::commands::aster_agent_cmd::action_runtime::build_runtime_action_scope;
use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope;
use async_trait::async_trait;
use lime_agent::AgentEvent as RuntimeAgentEvent;
use lime_agent::request_tool_policy::resolve_request_tool_policy;
use lime_agent::AgentEvent as RuntimeAgentEvent;
use regex::Regex;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -140,6 +140,28 @@ mod tests {
));
}
#[test]
fn test_resolve_workspace_id_from_sources_prefers_request_value() {
assert_eq!(
resolve_workspace_id_from_sources(
Some("workspace-request".to_string()),
Some("workspace-session".to_string()),
),
Some("workspace-request".to_string())
);
}
#[test]
fn test_resolve_workspace_id_from_sources_falls_back_to_session_value() {
assert_eq!(
resolve_workspace_id_from_sources(
Some(" ".to_string()),
Some("workspace-session".to_string()),
),
Some("workspace-session".to_string())
);
}
#[test]
fn test_aster_chat_request_deserialize_with_execution_strategy() {
let json = r#"{
@@ -582,6 +604,30 @@ mod tests {
);
}
#[test]
fn test_agent_runtime_submit_turn_request_allows_missing_workspace_id() {
let json = r#"{
"message": "Hello runtime",
"session_id": "runtime-session",
"event_name": "runtime_stream",
"turn_config": {
"execution_strategy": "auto",
"web_search": true
}
}"#;
let request: AgentRuntimeSubmitTurnRequest = serde_json::from_str(json).unwrap();
assert_eq!(request.workspace_id, None);
let mapped: AsterChatRequest = request.into();
assert_eq!(mapped.workspace_id, "");
assert_eq!(
mapped.execution_strategy,
Some(AsterExecutionStrategy::Auto)
);
assert_eq!(mapped.web_search, Some(true));
}
#[test]
fn test_build_runtime_user_message_includes_images() {
let message = build_runtime_user_message(
@@ -749,6 +795,91 @@ mod tests {
assert_eq!(request.queued_turn_id, "queued-2");
}
#[test]
fn test_agent_runtime_update_session_request_deserializes_recent_preferences_aliases() {
let request: AgentRuntimeUpdateSessionRequest = serde_json::from_value(serde_json::json!({
"sessionId": "session-1",
"providerName": "openai",
"modelName": "gpt-5.4",
"recentPreferences": {
"webSearch": true,
"thinking": false,
"task": true,
"subagent": true
}
}))
.expect("request should deserialize");
assert_eq!(request.session_id, "session-1");
assert_eq!(request.provider_name.as_deref(), Some("openai"));
assert_eq!(request.model_name.as_deref(), Some("gpt-5.4"));
assert_eq!(
request.recent_preferences,
Some(lime_agent::SessionExecutionRuntimePreferences {
web_search: true,
thinking: false,
task: true,
subagent: true,
})
);
}
#[test]
fn test_agent_runtime_update_session_request_deserializes_recent_team_selection_aliases() {
let request: AgentRuntimeUpdateSessionRequest = serde_json::from_value(serde_json::json!({
"sessionId": "session-1",
"recentTeamSelection": {
"disabled": false,
"theme": "general",
"preferredTeamPresetId": "code-triage-team",
"selectedTeamId": "custom-team-1",
"selectedTeamSource": "custom",
"selectedTeamLabel": "前端联调团队",
"selectedTeamDescription": "分析、实现、验证三段式推进。",
"selectedTeamSummary": "分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。",
"selectedTeamRoles": [
{
"id": "explorer",
"label": "分析",
"summary": "负责定位问题与影响范围。",
"profileId": "code-explorer",
"roleKey": "explorer",
"skillIds": ["repo-exploration"]
}
]
}
}))
.expect("request should deserialize");
assert_eq!(request.session_id, "session-1");
assert_eq!(
request.recent_team_selection,
Some(lime_agent::SessionExecutionRuntimeRecentTeamSelection {
disabled: false,
theme: Some("general".to_string()),
preferred_team_preset_id: Some("code-triage-team".to_string()),
selected_team_id: Some("custom-team-1".to_string()),
selected_team_source: Some("custom".to_string()),
selected_team_label: Some("前端联调团队".to_string()),
selected_team_description: Some("分析、实现、验证三段式推进。".to_string()),
selected_team_summary: Some(
"分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。"
.to_string(),
),
selected_team_roles: Some(vec![
lime_agent::SessionExecutionRuntimeRecentTeamRole {
id: "explorer".to_string(),
label: "分析".to_string(),
summary: "负责定位问题与影响范围。".to_string(),
profile_id: Some("code-explorer".to_string()),
role_key: Some("explorer".to_string()),
skill_ids: vec!["repo-exploration".to_string()],
},
]),
})
);
}
#[test]
fn test_extract_artifact_path_from_tool_start_reads_write_file_path() {
let path = extract_artifact_path_from_tool_start(
@@ -825,6 +956,7 @@ mod tests {
},
false,
None,
None,
);
let mut observation = ChatRunObservation::default();
observation.record_artifact_path(
@@ -897,6 +1029,77 @@ mod tests {
);
}
#[test]
fn test_resolve_request_web_search_preference_from_sources_prefers_request_flag() {
let metadata = serde_json::json!({
"harness": {
"preferences": {
"web_search": false
}
}
});
let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences {
web_search: false,
thinking: true,
task: false,
subagent: true,
};
assert_eq!(
resolve_request_web_search_preference_from_sources(
Some(true),
Some(&metadata),
Some(&session_recent_preferences),
),
Some(true)
);
}
#[test]
fn test_resolve_request_web_search_preference_from_sources_reads_nested_metadata() {
let metadata = serde_json::json!({
"harness": {
"preferences": {
"web_search": true
}
}
});
let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences {
web_search: false,
thinking: true,
task: false,
subagent: true,
};
assert_eq!(
resolve_request_web_search_preference_from_sources(
None,
Some(&metadata),
Some(&session_recent_preferences),
),
Some(true)
);
}
#[test]
fn test_resolve_request_web_search_preference_from_sources_falls_back_to_session_runtime() {
let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences {
web_search: true,
thinking: true,
task: false,
subagent: true,
};
assert_eq!(
resolve_request_web_search_preference_from_sources(
None,
None,
Some(&session_recent_preferences),
),
Some(true)
);
}
#[test]
fn test_build_chat_run_metadata_base_flattens_nested_preferences() {
let metadata = build_chat_run_metadata_base(
@@ -941,6 +1144,7 @@ mod tests {
},
false,
None,
None,
);
assert_eq!(
@@ -963,6 +1167,74 @@ mod tests {
);
}
#[test]
fn test_build_chat_run_metadata_base_falls_back_to_session_recent_preferences() {
let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences {
web_search: false,
thinking: true,
task: true,
subagent: false,
};
let metadata = build_chat_run_metadata_base(
&AsterChatRequest {
message: "hello".to_string(),
session_id: "session-1".to_string(),
event_name: "event-1".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: Some("project-1".to_string()),
workspace_id: "workspace-1".to_string(),
web_search: None,
search_mode: None,
execution_strategy: Some(AsterExecutionStrategy::React),
auto_continue: None,
system_prompt: None,
metadata: Some(serde_json::json!({
"harness": {
"theme": "general",
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
},
"workspace-1",
AsterExecutionStrategy::React,
&RequestToolPolicy {
search_mode: RequestToolPolicyMode::Disabled,
effective_web_search: false,
required_tools: vec![],
allowed_tools: vec![],
disallowed_tools: vec![],
},
false,
None,
Some(&session_recent_preferences),
);
assert_eq!(
metadata
.get("thinking_enabled")
.and_then(serde_json::Value::as_bool),
Some(true)
);
assert_eq!(
metadata
.get("task_mode_enabled")
.and_then(serde_json::Value::as_bool),
Some(true)
);
assert_eq!(
metadata
.get("subagent_mode_enabled")
.and_then(serde_json::Value::as_bool),
Some(false)
);
}
#[test]
fn test_chat_run_observation_records_nested_artifact_protocol_paths_from_tool_result() {
let mut observation = ChatRunObservation::default();
@@ -1493,7 +1765,7 @@ mod tests {
"subagent_mode_enabled": true,
"preferred_team_preset_id": "code-triage-team",
}
})))
})), None, true)
.expect("team prompt should exist");
assert!(prompt.contains(TEAM_PREFERENCE_PROMPT_MARKER));
@@ -1505,7 +1777,7 @@ mod tests {
"subagent_mode_enabled": false,
"preferred_team_preset_id": "code-triage-team",
}
})));
})), None, false);
assert!(disabled.is_none());
}
@@ -1531,7 +1803,7 @@ mod tests {
}
]
}
})))
})), None, true)
.expect("team prompt should exist");
assert!(prompt.contains("前端联调团队"));
@@ -1561,7 +1833,7 @@ mod tests {
}
]
}
})))
})), None, true)
.expect("team prompt should exist");
assert!(prompt.contains("当前调试 Team"));
@@ -1571,6 +1843,94 @@ mod tests {
assert!(prompt.contains("主动汇总关键进展、风险和下一步"));
}
#[test]
fn test_build_team_preference_system_prompt_accepts_session_fallback_flag() {
let prompt = build_team_preference_system_prompt(
Some(&serde_json::json!({
"harness": {
"preferred_team_preset_id": "code-triage-team",
}
})),
None,
true,
)
.expect("team prompt should exist");
assert!(prompt.contains("代码排障团队"));
assert!(prompt.contains("spawn_agent"));
}
#[test]
fn test_build_team_preference_system_prompt_falls_back_to_session_recent_team_selection() {
let prompt = build_team_preference_system_prompt(
None,
Some(&lime_agent::SessionExecutionRuntimeRecentTeamSelection {
disabled: false,
theme: Some("general".to_string()),
preferred_team_preset_id: Some("code-triage-team".to_string()),
selected_team_id: Some("custom-team-1".to_string()),
selected_team_source: Some("custom".to_string()),
selected_team_label: Some("前端联调团队".to_string()),
selected_team_description: Some("分析、实现、验证三段式推进。".to_string()),
selected_team_summary: Some("分析、实现、验证三段式推进。".to_string()),
selected_team_roles: Some(vec![
lime_agent::SessionExecutionRuntimeRecentTeamRole {
id: "explorer".to_string(),
label: "分析".to_string(),
summary: "负责定位问题与影响范围。".to_string(),
profile_id: Some("code-explorer".to_string()),
role_key: Some("explorer".to_string()),
skill_ids: vec!["repo-exploration".to_string()],
},
]),
}),
true,
)
.expect("team prompt should exist");
assert!(prompt.contains("代码排障团队"));
assert!(prompt.contains("前端联调团队"));
assert!(prompt.contains("来源:custom"));
assert!(prompt.contains("分析、实现、验证三段式推进。"));
assert!(prompt.contains("分析:负责定位问题与影响范围。"));
assert!(prompt.contains("profile: code-explorer"));
assert!(prompt.contains("roleKey: explorer"));
assert!(prompt.contains("skills: repo-exploration"));
}
#[test]
fn test_build_team_preference_system_prompt_prefers_request_metadata_over_session_recent_team_selection()
{
let prompt = build_team_preference_system_prompt(
Some(&serde_json::json!({
"harness": {
"selected_team_source": "builtin",
"selected_team_label": "请求内 Team",
"selected_team_summary": "以本次请求为准。",
}
})),
Some(&lime_agent::SessionExecutionRuntimeRecentTeamSelection {
disabled: false,
theme: Some("general".to_string()),
preferred_team_preset_id: Some("research-team".to_string()),
selected_team_id: Some("runtime-team".to_string()),
selected_team_source: Some("custom".to_string()),
selected_team_label: Some("会话 Team".to_string()),
selected_team_description: Some("旧会话描述".to_string()),
selected_team_summary: Some("旧会话摘要".to_string()),
selected_team_roles: None,
}),
true,
)
.expect("team prompt should exist");
assert!(prompt.contains("请求内 Team"));
assert!(prompt.contains("来源:builtin"));
assert!(prompt.contains("以本次请求为准。"));
assert!(!prompt.contains("会话 Team"));
assert!(!prompt.contains("旧会话摘要"));
}
#[test]
fn test_build_subagent_customization_state_applies_profile_defaults() {
let customization = build_subagent_customization_state(&AgentRuntimeSpawnSubagentRequest {
@@ -4,43 +4,52 @@ use crate::services::site_capability_service::{
build_site_result_document_body, save_site_result_to_project,
};
use crate::services::site_capability_service::{
get_site_adapter, list_site_adapters, run_site_adapter_with_optional_save,
search_site_adapters, RunSiteAdapterRequest,
get_site_adapter, list_site_adapters, recommend_site_adapters,
run_site_adapter_with_optional_save, search_site_adapters, RunSiteAdapterRequest,
};
#[cfg(test)]
use crate::services::site_capability_service::{SiteAdapterDefinition, SiteAdapterRunResult};
use crate::services::site_capability_service::{
SiteAdapterDefinition, SiteAdapterRecommendation, SiteAdapterRunResult,
};
use aster::session::{load_shared_session_runtime_snapshot, SessionRuntimeSnapshot};
use serde_json::Value;
const PROJECT_ID_ENV_KEYS: &[&str] = &["LIME_PROJECT_ID", "PROXYCAST_PROJECT_ID"];
const CONTENT_ID_ENV_KEYS: &[&str] = &["LIME_CONTENT_ID", "PROXYCAST_CONTENT_ID"];
#[derive(Debug, Clone, Copy)]
enum LimeSiteToolKind {
List,
Recommend,
Search,
Info,
Run,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LimeSiteProjectSource {
enum LimeSiteSaveTargetSource {
ExplicitProject,
ContextProject,
ExplicitContent,
ContextContent,
}
impl LimeSiteProjectSource {
impl LimeSiteSaveTargetSource {
fn as_str(self) -> &'static str {
match self {
LimeSiteProjectSource::ExplicitProject => "explicit_project",
LimeSiteProjectSource::ContextProject => "context_project",
LimeSiteSaveTargetSource::ExplicitProject => "explicit_project",
LimeSiteSaveTargetSource::ContextProject => "context_project",
LimeSiteSaveTargetSource::ExplicitContent => "explicit_content",
LimeSiteSaveTargetSource::ContextContent => "context_content",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LimeSiteProjectTarget {
project_id: String,
source: LimeSiteProjectSource,
struct LimeSiteSaveTarget {
project_id: Option<String>,
content_id: Option<String>,
source: LimeSiteSaveTargetSource,
}
#[derive(Debug, Clone)]
@@ -140,6 +149,19 @@ impl LimeSiteTool {
})
}
fn build_recommend_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "可选返回数量上限;未传时返回按浏览器上下文排序后的推荐列表"
}
},
"additionalProperties": false,
})
}
fn build_info_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
@@ -168,7 +190,7 @@ impl LimeSiteTool {
},
"profile_key": {
"type": "string",
"description": "浏览器资料 Key,可选;未传时优先复用当前 browser assist 会话"
"description": "浏览器资料 Key,可选;未传时优先复用当前 browser assist 会话,否则自动选择已连接的 existing_session 或最合适的资料"
},
"target_id": {
"type": "string",
@@ -178,13 +200,17 @@ impl LimeSiteTool {
"type": "integer",
"description": "脚本执行超时时间,毫秒"
},
"content_id": {
"type": "string",
"description": "可选内容 ID;未传时优先复用当前内容上下文,成功后优先写回当前主稿"
},
"project_id": {
"type": "string",
"description": "可选项目 ID;未传时优先复用当前项目上下文,成功后会保存为资源文档"
"description": "可选项目 ID;未传时优先复用当前项目上下文。仅当没有 content_id 时,成功后会保存为新资源文档"
},
"save_title": {
"type": "string",
"description": "可选保存标题;仅在存在保存目标时生效"
"description": "可选保存标题;仅在保存为新资源文档时生效"
}
},
"required": ["adapter_name"],
@@ -200,6 +226,14 @@ impl LimeSiteTool {
.map(ToString::to_string)
}
fn extract_content_id_from_value(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn extract_project_id_from_metadata_map(
metadata: &HashMap<String, serde_json::Value>,
) -> Option<String> {
@@ -208,6 +242,14 @@ impl LimeSiteTool {
.find_map(|key| Self::extract_project_id_from_value(metadata.get(*key)))
}
fn extract_content_id_from_metadata_map(
metadata: &HashMap<String, serde_json::Value>,
) -> Option<String> {
["content_id", "contentId"]
.iter()
.find_map(|key| Self::extract_content_id_from_value(metadata.get(*key)))
}
fn extract_project_id_from_runtime_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> Option<String> {
@@ -237,6 +279,35 @@ impl LimeSiteTool {
})
}
fn extract_content_id_from_runtime_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> Option<String> {
snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.filter_map(|turn| {
let content_id = turn.context_override.as_ref().and_then(|context| {
Self::extract_content_id_from_metadata_map(&context.metadata)
})?;
Some((turn.updated_at, content_id))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, content_id)| content_id)
.or_else(|| {
snapshot
.threads
.iter()
.filter_map(|thread| {
let content_id =
Self::extract_content_id_from_metadata_map(&thread.thread.metadata)?;
Some((thread.thread.updated_at, content_id))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, content_id)| content_id)
})
}
fn extract_project_id_from_context_environment(context: &ToolContext) -> Option<String> {
PROJECT_ID_ENV_KEYS.iter().find_map(|key| {
context
@@ -249,6 +320,18 @@ impl LimeSiteTool {
})
}
fn extract_content_id_from_context_environment(context: &ToolContext) -> Option<String> {
CONTENT_ID_ENV_KEYS.iter().find_map(|key| {
context
.environment
.get(*key)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
})
}
async fn resolve_context_project_id(context: &ToolContext) -> Option<String> {
let session_id = context.session_id.trim();
if !session_id.is_empty() {
@@ -273,38 +356,80 @@ impl LimeSiteTool {
Self::extract_project_id_from_context_environment(context)
}
async fn resolve_project_target(
async fn resolve_context_content_id(context: &ToolContext) -> Option<String> {
let session_id = context.session_id.trim();
if !session_id.is_empty() {
match load_shared_session_runtime_snapshot(session_id).await {
Ok(snapshot) => {
if let Some(content_id) =
Self::extract_content_id_from_runtime_snapshot(&snapshot)
{
return Some(content_id);
}
}
Err(error) => {
tracing::debug!(
"[AsterAgent][SiteTool] 读取 runtime snapshot 失败,跳过上下文内容解析: session_id={}, error={}",
session_id,
error
);
}
}
}
Self::extract_content_id_from_context_environment(context)
}
async fn resolve_save_target(
params: &serde_json::Value,
context: &ToolContext,
) -> Option<LimeSiteProjectTarget> {
) -> Option<LimeSiteSaveTarget> {
if let Some(content_id) = Self::extract_optional_string(params, &["content_id"]) {
return Some(LimeSiteSaveTarget {
project_id: None,
content_id: Some(content_id),
source: LimeSiteSaveTargetSource::ExplicitContent,
});
}
if let Some(project_id) = Self::extract_optional_string(params, &["project_id"]) {
return Some(LimeSiteProjectTarget {
project_id,
source: LimeSiteProjectSource::ExplicitProject,
return Some(LimeSiteSaveTarget {
project_id: Some(project_id),
content_id: None,
source: LimeSiteSaveTargetSource::ExplicitProject,
});
}
if let Some(content_id) = Self::resolve_context_content_id(context).await {
return Some(LimeSiteSaveTarget {
project_id: None,
content_id: Some(content_id),
source: LimeSiteSaveTargetSource::ContextContent,
});
}
Self::resolve_context_project_id(context)
.await
.map(|project_id| LimeSiteProjectTarget {
project_id,
source: LimeSiteProjectSource::ContextProject,
.map(|project_id| LimeSiteSaveTarget {
project_id: Some(project_id),
content_id: None,
source: LimeSiteSaveTargetSource::ContextProject,
})
}
fn apply_project_target_to_run_result(
fn apply_save_target_to_run_result(
mut result: crate::services::site_capability_service::SiteAdapterRunResult,
project_target: Option<&LimeSiteProjectTarget>,
save_target: Option<&LimeSiteSaveTarget>,
) -> crate::services::site_capability_service::SiteAdapterRunResult {
let Some(project_target) = project_target else {
let Some(save_target) = save_target else {
return result;
};
let normalized_source = project_target.source.as_str().to_string();
if result.saved_project_id.is_some() {
let normalized_source = save_target.source.as_str().to_string();
if result.saved_content.is_some() || result.saved_project_id.is_some() {
result.saved_by = Some(normalized_source.clone());
}
if result.save_skipped_project_id.is_some() {
if result.save_skipped_by.is_some() || result.save_skipped_project_id.is_some() {
result.save_skipped_by = Some(normalized_source);
}
@@ -404,6 +529,21 @@ impl Tool for LimeSiteTool {
.with_metadata("tool_family", serde_json::json!("site"))
.with_metadata("result", serde_json::json!(result)))
}
LimeSiteToolKind::Recommend => {
let limit = params.get("limit").and_then(serde_json::Value::as_u64);
let limit = limit
.and_then(|value| usize::try_from(value).ok())
.filter(|value| *value > 0);
let result = recommend_site_adapters(&self.db, limit)
.await
.map_err(ToolError::execution_failed)?;
let payload = serde_json::to_string_pretty(&result).map_err(|error| {
ToolError::execution_failed(format!("序列化站点推荐结果失败: {error}"))
})?;
Ok(ToolResult::success(payload)
.with_metadata("tool_family", serde_json::json!("site"))
.with_metadata("result", serde_json::json!(result)))
}
LimeSiteToolKind::Search => {
let query = Self::extract_required_string(&params, &["query"], "query")?;
let result = search_site_adapters(&query);
@@ -447,7 +587,7 @@ impl Tool for LimeSiteTool {
.unwrap_or_else(|| serde_json::json!({}));
let target_id = Self::extract_optional_string(&params, &["target_id"]);
let timeout_ms = params.get("timeout_ms").and_then(serde_json::Value::as_u64);
let project_target = Self::resolve_project_target(&params, context).await;
let save_target = Self::resolve_save_target(&params, context).await;
let save_title = Self::extract_optional_string(&params, &["save_title"]);
let run_request = RunSiteAdapterRequest {
adapter_name: adapter_name.clone(),
@@ -455,23 +595,27 @@ impl Tool for LimeSiteTool {
profile_key,
target_id,
timeout_ms,
project_id: project_target
content_id: save_target
.as_ref()
.map(|target| target.project_id.clone()),
.and_then(|target| target.content_id.clone()),
project_id: save_target
.as_ref()
.and_then(|target| target.project_id.clone()),
save_title,
};
let result = Self::apply_project_target_to_run_result(
let result = Self::apply_save_target_to_run_result(
run_site_adapter_with_optional_save(&self.db, run_request.clone()).await,
project_target.as_ref(),
save_target.as_ref(),
);
let browser_session = match (&result.session_id, &result.target_id) {
(Some(session_id), Some(target_id)) => Some(serde_json::json!({
"session_id": session_id,
"target_id": target_id,
let browser_session = if result.session_id.is_some() || result.target_id.is_some() {
Some(serde_json::json!({
"session_id": result.session_id,
"target_id": result.target_id,
"profile_key": result.profile_key,
})),
_ => None,
}))
} else {
None
};
let payload = serde_json::to_string_pretty(&result).map_err(|error| {
ToolError::execution_failed(format!("序列化站点执行结果失败: {error}"))
@@ -531,10 +675,22 @@ mod tests {
.and_then(serde_json::Value::as_object)
.expect("properties should exist");
assert!(properties.contains_key("content_id"));
assert!(properties.contains_key("project_id"));
assert!(properties.contains_key("save_title"));
}
#[test]
fn should_include_limit_field_in_recommend_schema() {
let schema = LimeSiteTool::build_recommend_schema();
let properties = schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("properties should exist");
assert!(properties.contains_key("limit"));
}
#[test]
fn should_build_site_result_document_body_with_sections() {
let adapter = SiteAdapterDefinition {
@@ -556,6 +712,7 @@ mod tests {
profile_key: Some("general_browser_assist".to_string()),
target_id: None,
timeout_ms: Some(20_000),
content_id: None,
project_id: None,
save_title: None,
};
@@ -576,6 +733,7 @@ mod tests {
error_code: None,
error_message: None,
auth_hint: Some("请先登录 GitHub。".to_string()),
report_hint: None,
saved_content: None,
saved_project_id: None,
saved_by: None,
@@ -624,6 +782,7 @@ mod tests {
profile_key: Some("general_browser_assist".to_string()),
target_id: Some("target-1".to_string()),
timeout_ms: Some(20_000),
content_id: None,
project_id: None,
save_title: None,
};
@@ -644,6 +803,7 @@ mod tests {
error_code: None,
error_message: None,
auth_hint: Some("请先登录 GitHub。".to_string()),
report_hint: None,
saved_content: None,
saved_project_id: None,
saved_by: None,
@@ -759,6 +919,57 @@ mod tests {
assert_eq!(project_id.as_deref(), Some("project-current"));
}
#[test]
fn should_extract_latest_content_id_from_runtime_snapshot() {
let now = Utc::now();
let mut older_turn = TurnRuntime::new(
"turn-older",
"session-1",
"thread-1",
Some("旧 turn".to_string()),
Some(TurnContextOverride {
metadata: HashMap::from([(
"content_id".to_string(),
serde_json::json!("content-older"),
)]),
..TurnContextOverride::default()
}),
);
older_turn.updated_at = now;
let mut latest_turn = TurnRuntime::new(
"turn-latest",
"session-1",
"thread-1",
Some("新 turn".to_string()),
Some(TurnContextOverride {
metadata: HashMap::from([(
"content_id".to_string(),
serde_json::json!("content-current"),
)]),
..TurnContextOverride::default()
}),
);
latest_turn.updated_at = now + ChronoDuration::seconds(5);
let mut thread =
ThreadRuntime::new("thread-1", "session-1", PathBuf::from("/tmp/site-runtime"));
thread.updated_at = latest_turn.updated_at;
let snapshot = SessionRuntimeSnapshot {
session_id: "session-1".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread,
turns: vec![older_turn, latest_turn],
items: Vec::new(),
}],
};
let content_id = LimeSiteTool::extract_content_id_from_runtime_snapshot(&snapshot);
assert_eq!(content_id.as_deref(), Some("content-current"));
}
#[test]
fn should_extract_project_id_from_thread_metadata_when_turn_metadata_missing() {
let now = Utc::now();
@@ -796,7 +1007,7 @@ mod tests {
let context =
ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("session-1");
let target = runtime.block_on(LimeSiteTool::resolve_project_target(
let target = runtime.block_on(LimeSiteTool::resolve_save_target(
&serde_json::json!({
"project_id": "project-explicit"
}),
@@ -805,9 +1016,33 @@ mod tests {
assert_eq!(
target,
Some(LimeSiteProjectTarget {
project_id: "project-explicit".to_string(),
source: LimeSiteProjectSource::ExplicitProject,
Some(LimeSiteSaveTarget {
project_id: Some("project-explicit".to_string()),
content_id: None,
source: LimeSiteSaveTargetSource::ExplicitProject,
})
);
}
#[test]
fn should_resolve_content_target_as_explicit_when_param_exists() {
let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败");
let context =
ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("session-1");
let target = runtime.block_on(LimeSiteTool::resolve_save_target(
&serde_json::json!({
"content_id": "content-explicit"
}),
&context,
));
assert_eq!(
target,
Some(LimeSiteSaveTarget {
project_id: None,
content_id: Some("content-explicit".to_string()),
source: LimeSiteSaveTargetSource::ExplicitContent,
})
);
}
@@ -822,20 +1057,81 @@ mod tests {
"project-from-env".to_string(),
);
let target = runtime.block_on(LimeSiteTool::resolve_project_target(
let target = runtime.block_on(LimeSiteTool::resolve_save_target(
&serde_json::json!({}),
&context,
));
assert_eq!(
target,
Some(LimeSiteProjectTarget {
project_id: "project-from-env".to_string(),
source: LimeSiteProjectSource::ContextProject,
Some(LimeSiteSaveTarget {
project_id: Some("project-from-env".to_string()),
content_id: None,
source: LimeSiteSaveTargetSource::ContextProject,
})
);
}
#[test]
fn should_resolve_content_target_from_context_environment_when_runtime_missing() {
let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败");
let mut context =
ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("missing");
context.environment.insert(
"LIME_CONTENT_ID".to_string(),
"content-from-env".to_string(),
);
let target = runtime.block_on(LimeSiteTool::resolve_save_target(
&serde_json::json!({}),
&context,
));
assert_eq!(
target,
Some(LimeSiteSaveTarget {
project_id: None,
content_id: Some("content-from-env".to_string()),
source: LimeSiteSaveTargetSource::ContextContent,
})
);
}
#[test]
fn should_execute_recommend_tool_and_return_result_metadata() {
let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败");
let tool = LimeSiteTool::new(
LIME_SITE_RECOMMEND_TOOL_NAME.to_string(),
"推荐站点适配器",
LimeSiteTool::build_recommend_schema(),
LimeSiteToolKind::Recommend,
setup_test_db(),
);
let context =
ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("session-1");
let result = runtime
.block_on(tool.execute(
serde_json::json!({
"limit": 1
}),
&context,
))
.expect("推荐工具应返回 ToolResult");
assert!(result.success);
let recommendations = serde_json::from_value::<Vec<SiteAdapterRecommendation>>(
result
.metadata
.get("result")
.cloned()
.expect("metadata 应包含 result"),
)
.expect("应能解析推荐结果");
assert_eq!(recommendations.len(), 1);
assert!(!recommendations[0].adapter.name.is_empty());
}
#[test]
fn should_rewrite_saved_source_as_context_project_when_result_comes_from_context() {
let result = SiteAdapterRunResult {
@@ -851,6 +1147,7 @@ mod tests {
error_code: None,
error_message: None,
auth_hint: None,
report_hint: None,
saved_content: Some(
crate::services::site_capability_service::SavedSiteAdapterContent {
content_id: "content-1".to_string(),
@@ -865,11 +1162,12 @@ mod tests {
save_error_message: None,
};
let normalized = LimeSiteTool::apply_project_target_to_run_result(
let normalized = LimeSiteTool::apply_save_target_to_run_result(
result,
Some(&LimeSiteProjectTarget {
project_id: "project-context".to_string(),
source: LimeSiteProjectSource::ContextProject,
Some(&LimeSiteSaveTarget {
project_id: Some("project-context".to_string()),
content_id: None,
source: LimeSiteSaveTargetSource::ContextProject,
}),
);
@@ -891,6 +1189,7 @@ mod tests {
error_code: Some("adapter_not_found".to_string()),
error_message: Some("未找到对应的站点适配器".to_string()),
auth_hint: None,
report_hint: None,
saved_content: None,
saved_project_id: None,
saved_by: None,
@@ -899,11 +1198,12 @@ mod tests {
save_error_message: None,
};
let normalized = LimeSiteTool::apply_project_target_to_run_result(
let normalized = LimeSiteTool::apply_save_target_to_run_result(
result,
Some(&LimeSiteProjectTarget {
project_id: "project-context".to_string(),
source: LimeSiteProjectSource::ContextProject,
Some(&LimeSiteSaveTarget {
project_id: Some("project-context".to_string()),
content_id: None,
source: LimeSiteSaveTargetSource::ContextProject,
}),
);
@@ -962,6 +1262,7 @@ mod tests {
pub(super) fn site_tool_names() -> Vec<&'static str> {
vec![
LIME_SITE_LIST_TOOL_NAME,
LIME_SITE_RECOMMEND_TOOL_NAME,
LIME_SITE_SEARCH_TOOL_NAME,
LIME_SITE_INFO_TOOL_NAME,
LIME_SITE_RUN_TOOL_NAME,
@@ -979,6 +1280,12 @@ pub(super) fn register_site_tools_to_registry(
LimeSiteTool::build_list_schema(),
LimeSiteToolKind::List,
),
(
LIME_SITE_RECOMMEND_TOOL_NAME,
"基于当前浏览器资料、已连接标签页和站点范围推荐可直接运行的 Lime 站点适配器,优先复用现有登录态。",
LimeSiteTool::build_recommend_schema(),
LimeSiteToolKind::Recommend,
),
(
LIME_SITE_SEARCH_TOOL_NAME,
"按关键词搜索 Lime 内置站点适配器。",
+18 -3
View File
@@ -4,9 +4,10 @@ use crate::services::site_adapter_registry::{
get_site_adapter_catalog_status, SiteAdapterCatalogStatus,
};
use crate::services::site_capability_service::{
get_site_adapter, list_site_adapters, run_site_adapter, run_site_adapter_with_optional_save,
save_existing_site_result_to_project, search_site_adapters, RunSiteAdapterRequest,
SaveSiteAdapterResultRequest, SavedSiteAdapterContent, SiteAdapterDefinition,
get_site_adapter, list_site_adapters, recommend_site_adapters, run_site_adapter,
run_site_adapter_with_optional_save, save_existing_site_result_to_project,
search_site_adapters, RunSiteAdapterRequest, SaveSiteAdapterResultRequest,
SavedSiteAdapterContent, SiteAdapterDefinition, SiteAdapterRecommendation,
SiteAdapterRunResult,
};
use serde::Deserialize;
@@ -28,11 +29,25 @@ pub struct SiteAdapterCatalogBootstrapRequest {
pub payload: Value,
}
#[derive(Debug, Deserialize)]
pub struct SiteAdapterRecommendRequest {
#[serde(default)]
pub limit: Option<usize>,
}
#[tauri::command]
pub fn site_list_adapters() -> Result<Vec<SiteAdapterDefinition>, String> {
Ok(list_site_adapters())
}
#[tauri::command]
pub async fn site_recommend_adapters(
db: State<'_, DbConnection>,
request: SiteAdapterRecommendRequest,
) -> Result<Vec<SiteAdapterRecommendation>, String> {
recommend_site_adapters(db.inner(), request.limit).await
}
#[tauri::command]
pub fn site_search_adapters(
request: SiteAdapterSearchRequest,
+48 -2
View File
@@ -508,6 +508,8 @@ impl BrowserRuntimeAuditRecord {
profile_key: Option<String>,
requested_backend: Option<BrowserBackendType>,
selected_backend: Option<BrowserBackendType>,
session_id: Option<String>,
target_id: Option<String>,
success: bool,
error: Option<String>,
attempts: Vec<BrowserActionAttempt>,
@@ -526,8 +528,8 @@ impl BrowserRuntimeAuditRecord {
attempts,
environment_preset_id: None,
environment_preset_name: None,
target_id: None,
session_id: None,
target_id,
session_id,
url: None,
reused: None,
open_window: None,
@@ -1678,6 +1680,8 @@ pub async fn browser_execute_action_with_manager(
profile_key.clone(),
request.backend.clone(),
result.backend.clone(),
result.session_id.clone(),
result.target_id.clone(),
true,
None,
attempts,
@@ -1709,6 +1713,8 @@ pub async fn browser_execute_action_with_manager(
profile_key.clone(),
request.backend.clone(),
None,
None,
None,
false,
Some(error),
attempts,
@@ -1737,6 +1743,8 @@ pub async fn browser_execute_action_with_manager(
profile_key,
request.backend,
None,
None,
None,
false,
result.error.clone(),
attempts,
@@ -3420,4 +3428,42 @@ mod tests {
BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear();
}
#[tokio::test]
async fn browser_runtime_audit_should_store_action_session_keys() {
BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear();
append_browser_runtime_audit(BrowserRuntimeAuditRecord::action(
"browser-action-1".to_string(),
"read_page".to_string(),
Some("general_browser_assist".to_string()),
Some(BrowserBackendType::CdpDirect),
Some(BrowserBackendType::CdpDirect),
Some("session-42".to_string()),
Some("target-42".to_string()),
true,
None,
vec![BrowserActionAttempt {
backend: BrowserBackendType::CdpDirect,
success: true,
message: "执行成功".to_string(),
}],
))
.await;
let logs = get_browser_action_audit_logs(Some(5))
.await
.expect("audit logs should be readable");
let record = logs.first().expect("action audit must exist");
assert!(matches!(record.kind, BrowserRuntimeAuditKind::Action));
assert_eq!(record.id, "browser-action-1");
assert_eq!(record.session_id.as_deref(), Some("session-42"));
assert_eq!(record.target_id.as_deref(), Some("target-42"));
assert_eq!(
record.profile_key.as_deref(),
Some("general_browser_assist")
);
BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear();
}
}
+216
View File
@@ -312,4 +312,220 @@ mod tests {
assert!(status_value["shortcut_registered"].is_boolean());
assert!(status_value["translate_shortcut_registered"].is_boolean());
}
#[tokio::test]
async fn browser_profile_commands_roundtrip() {
let state = make_test_state();
let saved_value = handle_command(
&state,
"save_browser_profile_cmd",
Some(serde_json::json!({
"request": {
"profile_key": "github-attached",
"name": "GitHub 已登录 Chrome",
"description": "复用当前 Chrome",
"site_scope": "github.com",
"launch_url": "https://github.com/",
"transport_kind": "existing_session"
}
})),
)
.await
.unwrap();
let profile_id = saved_value["id"].as_str().unwrap().to_string();
assert_eq!(saved_value["profile_key"], "github-attached");
assert_eq!(saved_value["transport_kind"], "existing_session");
let active_list = handle_command(
&state,
"list_browser_profiles_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert_eq!(active_list.as_array().unwrap().len(), 1);
let archived = handle_command(
&state,
"archive_browser_profile_cmd",
Some(serde_json::json!({
"request": {
"id": profile_id
}
})),
)
.await
.unwrap();
assert_eq!(archived, serde_json::json!(true));
let active_list_after_archive = handle_command(
&state,
"list_browser_profiles_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert!(active_list_after_archive.as_array().unwrap().is_empty());
let archived_list = handle_command(
&state,
"list_browser_profiles_cmd",
Some(serde_json::json!({
"request": {
"include_archived": true
}
})),
)
.await
.unwrap();
assert_eq!(archived_list.as_array().unwrap().len(), 1);
assert!(archived_list.as_array().unwrap()[0]["archived_at"]
.as_str()
.is_some());
let restored = handle_command(
&state,
"restore_browser_profile_cmd",
Some(serde_json::json!({
"request": {
"id": archived_list.as_array().unwrap()[0]["id"]
}
})),
)
.await
.unwrap();
assert_eq!(restored, serde_json::json!(true));
let active_list_after_restore = handle_command(
&state,
"list_browser_profiles_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert_eq!(active_list_after_restore.as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn browser_environment_preset_commands_roundtrip() {
let state = make_test_state();
let saved_value = handle_command(
&state,
"save_browser_environment_preset_cmd",
Some(serde_json::json!({
"request": {
"name": "GitHub 搜索环境",
"description": "用于仓库线索检索",
"timezone_id": "Asia/Shanghai",
"locale": "zh_CN",
"accept_language": "zh-CN,zh;q=0.9",
"viewport_width": 1440,
"viewport_height": 960,
"device_scale_factor": 1.25
}
})),
)
.await
.unwrap();
let preset_id = saved_value["id"].as_str().unwrap().to_string();
assert_eq!(saved_value["name"], "GitHub 搜索环境");
assert_eq!(saved_value["timezone_id"], "Asia/Shanghai");
let active_list = handle_command(
&state,
"list_browser_environment_presets_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert_eq!(active_list.as_array().unwrap().len(), 1);
let archived = handle_command(
&state,
"archive_browser_environment_preset_cmd",
Some(serde_json::json!({
"request": {
"id": preset_id
}
})),
)
.await
.unwrap();
assert_eq!(archived, serde_json::json!(true));
let active_list_after_archive = handle_command(
&state,
"list_browser_environment_presets_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert!(active_list_after_archive.as_array().unwrap().is_empty());
let archived_list = handle_command(
&state,
"list_browser_environment_presets_cmd",
Some(serde_json::json!({
"request": {
"include_archived": true
}
})),
)
.await
.unwrap();
assert_eq!(archived_list.as_array().unwrap().len(), 1);
assert!(archived_list.as_array().unwrap()[0]["archived_at"]
.as_str()
.is_some());
let restored = handle_command(
&state,
"restore_browser_environment_preset_cmd",
Some(serde_json::json!({
"request": {
"id": archived_list.as_array().unwrap()[0]["id"]
}
})),
)
.await
.unwrap();
assert_eq!(restored, serde_json::json!(true));
let active_list_after_restore = handle_command(
&state,
"list_browser_environment_presets_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert_eq!(active_list_after_restore.as_array().unwrap().len(), 1);
}
}
@@ -53,6 +53,131 @@ pub(super) async fn try_handle(
.await?,
)?
}
"list_browser_profiles_cmd" => {
let request: Option<crate::commands::browser_profile_cmd::ListBrowserProfilesRequest> =
parse_optional_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_profile_service::list_browser_profiles(
&conn,
request
.map(|payload| payload.include_archived)
.unwrap_or(false),
)?,
)?
}
"save_browser_profile_cmd" => {
let request: crate::commands::browser_profile_cmd::SaveBrowserProfileRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_profile_service::save_browser_profile(
&conn,
crate::services::browser_profile_service::SaveBrowserProfileInput {
id: request.id,
profile_key: request.profile_key,
name: request.name,
description: request.description,
site_scope: request.site_scope,
launch_url: request.launch_url,
transport_kind: request.transport_kind,
},
)?,
)?
}
"archive_browser_profile_cmd" => {
let request: crate::commands::browser_profile_cmd::BrowserProfileRecordRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_profile_service::archive_browser_profile(
&conn,
&request.id,
)?,
)?
}
"restore_browser_profile_cmd" => {
let request: crate::commands::browser_profile_cmd::BrowserProfileRecordRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_profile_service::restore_browser_profile(
&conn,
&request.id,
)?,
)?
}
"list_browser_environment_presets_cmd" => {
let request: Option<
crate::commands::browser_environment_cmd::ListBrowserEnvironmentPresetsRequest,
> = parse_optional_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_environment_service::list_browser_environment_presets(
&conn,
request
.map(|payload| payload.include_archived)
.unwrap_or(false),
)?,
)?
}
"save_browser_environment_preset_cmd" => {
let request: crate::commands::browser_environment_cmd::SaveBrowserEnvironmentPresetRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_environment_service::save_browser_environment_preset(
&conn,
crate::services::browser_environment_service::SaveBrowserEnvironmentPresetInput {
id: request.id,
name: request.name,
description: request.description,
proxy_server: request.proxy_server,
timezone_id: request.timezone_id,
locale: request.locale,
accept_language: request.accept_language,
geolocation_lat: request.geolocation_lat,
geolocation_lng: request.geolocation_lng,
geolocation_accuracy_m: request.geolocation_accuracy_m,
user_agent: request.user_agent,
platform: request.platform,
viewport_width: request.viewport_width,
viewport_height: request.viewport_height,
device_scale_factor: request.device_scale_factor,
},
)?,
)?
}
"archive_browser_environment_preset_cmd" => {
let request: crate::commands::browser_environment_cmd::BrowserEnvironmentPresetRecordRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_environment_service::archive_browser_environment_preset(
&conn,
&request.id,
)?,
)?
}
"restore_browser_environment_preset_cmd" => {
let request: crate::commands::browser_environment_cmd::BrowserEnvironmentPresetRecordRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_environment_service::restore_browser_environment_preset(
&conn,
&request.id,
)?,
)?
}
"launch_browser_runtime_assist" => {
let app_handle = require_app_handle(state)?;
let request: crate::commands::browser_runtime_cmd::LaunchBrowserRuntimeAssistRequest =
@@ -6,8 +6,9 @@ use crate::services::site_adapter_registry::{
get_site_adapter_catalog_status,
};
use crate::services::site_capability_service::{
get_site_adapter, list_site_adapters, run_site_adapter, run_site_adapter_with_optional_save,
save_existing_site_result_to_project, search_site_adapters,
get_site_adapter, list_site_adapters, recommend_site_adapters, run_site_adapter,
run_site_adapter_with_optional_save, save_existing_site_result_to_project,
search_site_adapters,
};
use serde_json::Value as JsonValue;
@@ -18,6 +19,12 @@ pub(super) async fn try_handle(
) -> Result<Option<JsonValue>, DynError> {
let result = match cmd {
"site_list_adapters" => serde_json::to_value(list_site_adapters())?,
"site_recommend_adapters" => {
let request: crate::commands::site_capability_cmd::SiteAdapterRecommendRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
serde_json::to_value(recommend_site_adapters(&db, request.limit).await?)?
}
"site_search_adapters" => {
let request: crate::commands::site_capability_cmd::SiteAdapterSearchRequest =
parse_request(args)?;
@@ -3,6 +3,9 @@
//! 负责在工作区内生成稳定路径、落盘 JSON 快照,并给前端 workbench
//! 提供可直接消费的 snapshot metadata。
use crate::commands::content_cmd::THEME_WORKBENCH_DOCUMENT_META_KEY;
use crate::content::{ContentManager, ContentUpdateRequest};
use crate::database::DbConnection;
use crate::services::artifact_document_validator::{
validate_or_fallback_artifact_document, validate_or_repair_artifact_document_value,
ArtifactDocumentValidationContext, ArtifactDocumentValidationOutcome,
@@ -26,6 +29,8 @@ pub struct PersistedArtifactDocument {
pub absolute_path: PathBuf,
pub serialized_document: String,
pub snapshot_metadata: Map<String, Value>,
pub theme_workbench_document_state: Map<String, Value>,
pub content_body: String,
pub title: String,
pub kind: String,
pub status: String,
@@ -268,6 +273,9 @@ pub fn persist_artifact_document_from_text(
&source_links,
version_diff.as_ref(),
);
let theme_workbench_document_state =
build_theme_workbench_document_state(&version_history, current_version.id.as_str());
let content_body = build_content_body_from_document(&enriched_document);
Ok(PersistedArtifactDocument {
artifact_id,
@@ -277,6 +285,8 @@ pub fn persist_artifact_document_from_text(
absolute_path,
serialized_document,
snapshot_metadata,
theme_workbench_document_state,
content_body,
title: outcome.title,
kind: outcome.kind,
status: outcome.status,
@@ -397,6 +407,198 @@ fn build_snapshot_metadata(
metadata
}
fn parse_rfc3339_to_timestamp_millis(value: &str) -> Option<i64> {
DateTime::parse_from_rfc3339(value)
.ok()
.map(|parsed| parsed.timestamp_millis())
}
fn resolve_topic_branch_status(status: &str) -> Option<&'static str> {
match status.trim() {
"ready" | "success" => Some("merged"),
"draft" | "streaming" | "pending" | "queued" | "running" => Some("pending"),
"failed" | "error" | "timeout" | "canceled" => Some("candidate"),
_ => None,
}
}
fn build_theme_workbench_document_state(
version_history: &[ArtifactVersionSummary],
current_version_id: &str,
) -> Map<String, Value> {
let mut state = Map::new();
state.insert(
"currentVersionId".to_string(),
Value::String(current_version_id.to_string()),
);
state.insert(
"versions".to_string(),
Value::Array(
version_history
.iter()
.rev()
.map(|version| {
let mut record = Map::new();
record.insert("id".to_string(), Value::String(version.id.clone()));
record.insert(
"createdAt".to_string(),
Value::from(
parse_rfc3339_to_timestamp_millis(version.created_at.as_str())
.unwrap_or_default(),
),
);
record.insert(
"description".to_string(),
Value::String(
version
.summary
.clone()
.unwrap_or_else(|| format!("版本 {}", version.version_no)),
),
);
Value::Object(record)
})
.collect(),
),
);
state.insert(
"versionStatusMap".to_string(),
Value::Object(
version_history
.iter()
.filter_map(|version| {
resolve_topic_branch_status(version.status.as_str())
.map(|status| (version.id.clone(), Value::String(status.to_string())))
})
.collect(),
),
);
state
}
fn normalize_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn extract_block_text(block: &Map<String, Value>) -> Option<String> {
normalize_text(block.get("markdown").and_then(Value::as_str))
.or_else(|| normalize_text(block.get("text").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("content").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("summary").and_then(Value::as_str)))
.or_else(|| {
block.get("items").and_then(Value::as_array).map(|items| {
items
.iter()
.filter_map(|item| {
if let Some(text) = item.as_str() {
return normalize_text(Some(text));
}
let item = item.as_object()?;
normalize_text(item.get("label").and_then(Value::as_str))
.or_else(|| normalize_text(item.get("text").and_then(Value::as_str)))
.or_else(|| normalize_text(item.get("title").and_then(Value::as_str)))
})
.collect::<Vec<_>>()
.join("\n")
})
})
.and_then(|value| normalize_text(Some(value.as_str())))
}
fn build_content_body_from_document(document: &Value) -> String {
let Some(record) = document.as_object() else {
return String::new();
};
let mut sections = Vec::new();
if let Some(title) = normalize_text(record.get("title").and_then(Value::as_str)) {
sections.push(format!("# {title}"));
}
if let Some(summary) = normalize_text(record.get("summary").and_then(Value::as_str)) {
sections.push(summary);
}
if let Some(blocks) = record.get("blocks").and_then(Value::as_array) {
for block in blocks.iter().filter_map(Value::as_object) {
let mut parts = Vec::new();
if let Some(title) = normalize_text(block.get("title").and_then(Value::as_str)) {
parts.push(format!("## {title}"));
}
if let Some(body) = extract_block_text(block) {
parts.push(body);
}
if !parts.is_empty() {
sections.push(parts.join("\n\n"));
}
}
}
sections.join("\n\n").trim().to_string()
}
fn extract_content_id_from_request_metadata(request_metadata: Option<&Value>) -> Option<String> {
let root = request_metadata?.as_object()?;
let harness = root
.get("harness")
.and_then(Value::as_object)
.unwrap_or(root);
["content_id", "contentId"]
.iter()
.filter_map(|key| harness.get(*key))
.find_map(Value::as_str)
.and_then(|value| normalize_text(Some(value)))
}
fn should_sync_snapshot_metadata_key_to_content(key: &str) -> bool {
key.starts_with("artifact") || matches!(key, "previewText" | "lastUpdateSource")
}
pub fn sync_persisted_artifact_document_to_content(
db: &DbConnection,
request_metadata: Option<&Value>,
persisted: &PersistedArtifactDocument,
) -> Result<(), String> {
let Some(content_id) = extract_content_id_from_request_metadata(request_metadata) else {
return Ok(());
};
let manager = ContentManager::new(db.clone());
let Some(content) = manager.get(&content_id)? else {
return Err(format!("未找到要同步的内容: {content_id}"));
};
let mut next_metadata = content
.metadata
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
for (key, value) in persisted.snapshot_metadata.iter() {
if should_sync_snapshot_metadata_key_to_content(key.as_str()) {
next_metadata.insert(key.clone(), value.clone());
}
}
next_metadata.insert(
THEME_WORKBENCH_DOCUMENT_META_KEY.to_string(),
Value::Object(persisted.theme_workbench_document_state.clone()),
);
manager.update(
&content_id,
ContentUpdateRequest {
body: (!persisted.content_body.trim().is_empty())
.then(|| persisted.content_body.clone()),
metadata: Some(Value::Object(next_metadata)),
..Default::default()
},
)?;
Ok(())
}
fn build_version_id(artifact_id: &str, version_no: usize) -> String {
format!("{artifact_id}:v{version_no}")
}
@@ -1075,6 +1277,131 @@ mod tests {
assert!(persisted_second
.serialized_document
.contains("\"currentVersionDiff\""));
assert_eq!(
persisted_second
.theme_workbench_document_state
.get("currentVersionId")
.and_then(Value::as_str),
Some("artifact-document:artifact:analysis:demo:v2")
);
assert!(persisted_second.content_body.contains("# 结构化结论"));
}
#[test]
fn sync_persisted_artifact_document_to_content_should_update_body_and_metadata() {
use crate::content::{ContentCreateRequest, ContentManager};
use crate::database::init_database;
use crate::workspace::{WorkspaceManager, WorkspaceType};
let db = init_database().expect("db should init");
let workspace_root = tempdir().expect("tempdir").keep();
let workspace = WorkspaceManager::new(db.clone())
.create_with_type(
"自动化项目".to_string(),
workspace_root.clone(),
WorkspaceType::Document,
)
.expect("workspace should create");
let manager = ContentManager::new(db.clone());
let content = manager
.create(ContentCreateRequest {
project_id: workspace.id.clone(),
title: "自动化日报".to_string(),
content_type: None,
order: None,
body: Some(String::new()),
metadata: Some(serde_json::json!({
"source": "service_skill"
})),
})
.expect("content should create");
let params = ArtifactDocumentPersistParams {
workspace_root,
workspace_id: Some(workspace.id.clone()),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
request_metadata: Some(serde_json::json!({
"artifact": {
"artifact_mode": "draft",
"artifact_stage": "stage2",
"artifact_kind": "report",
"artifact_request_id": format!("artifact:{}", content.id.clone())
},
"harness": {
"content_id": content.id.clone()
}
})),
};
let persisted = persist_artifact_document_from_text(
r#"{
"type": "artifact_document_draft",
"schemaVersion": "artifact_document.v1",
"artifactId": "artifact-document:artifact:content",
"kind": "report",
"title": "自动化日报",
"status": "ready",
"summary": "最新日报已生成",
"blocks": [
{ "id": "body-1", "type": "rich_text", "markdown": "日报正文内容" }
]
}"#,
&params,
)
.expect("persist should succeed");
sync_persisted_artifact_document_to_content(
&db,
params.request_metadata.as_ref(),
&persisted,
)
.expect("sync should succeed");
let updated = manager
.get(&content.id)
.expect("get content should succeed")
.expect("updated content should exist");
assert!(updated.body.contains("日报正文内容"));
let metadata = updated.metadata.expect("metadata should exist");
assert_eq!(
metadata
.get(THEME_WORKBENCH_DOCUMENT_META_KEY)
.and_then(Value::as_object)
.and_then(|value| value.get("currentVersionId"))
.and_then(Value::as_str),
Some(format!("artifact-document:artifact:{}:v1", content.id).as_str())
);
assert_eq!(
metadata.get("artifactKind").and_then(Value::as_str),
Some("report")
);
assert_eq!(
metadata.get("artifactRequestId").and_then(Value::as_str),
Some(format!("artifact:{}", content.id).as_str())
);
assert_eq!(
metadata
.get("artifact_paths")
.and_then(Value::as_array)
.and_then(|paths| paths.first())
.and_then(Value::as_str),
Some(
format!(
".lime/artifacts/thread-1/{}.artifact.json",
normalize_slug(format!("artifact:{}", content.id).as_str())
)
.as_str()
)
);
assert_eq!(
metadata
.get("artifactDocument")
.and_then(Value::as_object)
.and_then(|document| document.get("title"))
.and_then(Value::as_str),
Some("自动化日报")
);
}
#[test]
@@ -151,10 +151,66 @@ fn is_meaningful_artifact_value(value: Option<&Value>) -> bool {
.is_some_and(|value| !value.is_empty())
}
fn backfill_harness_string_if_missing(
request_metadata: Value,
keys: &[&str],
fallback: Option<&str>,
) -> Value {
let Some(fallback) = normalize_text(fallback) else {
return request_metadata;
};
if extract_harness_string(Some(&request_metadata), keys).is_some() {
return request_metadata;
}
let mut request_metadata = request_metadata;
let Some(root) = request_metadata.as_object_mut() else {
return request_metadata;
};
if let Some(harness) = root.get_mut("harness").and_then(Value::as_object_mut) {
harness.insert(keys[0].to_string(), Value::String(fallback));
return request_metadata;
}
root.insert(keys[0].to_string(), Value::String(fallback));
request_metadata
}
pub fn normalize_request_metadata_with_artifact_defaults(
request_metadata: Option<Value>,
theme_fallback: Option<&str>,
session_mode_fallback: Option<&str>,
gate_key_fallback: Option<&str>,
run_title_fallback: Option<&str>,
content_id_fallback: Option<&str>,
) -> Option<Value> {
let request_metadata = request_metadata?;
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["theme", "harness_theme", "harnessTheme"],
theme_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["session_mode", "sessionMode"],
session_mode_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["gate_key", "gateKey"],
gate_key_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["run_title", "runTitle", "title"],
run_title_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["content_id", "contentId"],
content_id_fallback,
);
let Some(root) = request_metadata.as_object() else {
return Some(request_metadata);
};
@@ -250,8 +306,15 @@ mod tests {
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
None,
)
.expect("normalized metadata");
assert_eq!(
normalized
@@ -302,8 +365,15 @@ mod tests {
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
None,
)
.expect("normalized metadata");
assert!(normalized.get("artifact").is_none());
}
@@ -320,8 +390,15 @@ mod tests {
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
None,
)
.expect("normalized metadata");
assert_eq!(
normalized
@@ -342,4 +419,107 @@ mod tests {
None
);
}
#[test]
fn should_backfill_content_id_before_infer_artifact_request_id() {
let metadata = json!({
"harness": {
"theme": "document",
"session_mode": "theme_workbench"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
Some("content-from-session"),
)
.expect("normalized metadata");
assert_eq!(
normalized
.pointer("/harness/content_id")
.and_then(Value::as_str),
Some("content-from-session")
);
assert_eq!(
normalized
.pointer("/artifact/artifact_request_id")
.and_then(Value::as_str),
Some("artifact:content-from-session")
);
}
#[test]
fn should_backfill_theme_and_session_mode_before_infer_artifact_defaults() {
let metadata = json!({
"harness": {
"content_id": "content-1"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
Some("document"),
Some("theme_workbench"),
None,
None,
None,
)
.expect("normalized metadata");
assert_eq!(
normalized.pointer("/harness/theme").and_then(Value::as_str),
Some("document")
);
assert_eq!(
normalized
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
);
assert_eq!(
normalized
.pointer("/artifact/artifact_request_id")
.and_then(Value::as_str),
Some("artifact:content-1")
);
}
#[test]
fn should_backfill_gate_key_and_run_title_when_missing() {
let metadata = json!({
"harness": {
"theme": "social-media",
"session_mode": "theme_workbench",
"content_id": "content-social-1"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
Some("write_mode"),
Some("社媒初稿"),
None,
)
.expect("normalized metadata");
assert_eq!(
normalized
.pointer("/harness/gate_key")
.and_then(Value::as_str),
Some("write_mode")
);
assert_eq!(
normalized
.pointer("/harness/run_title")
.and_then(Value::as_str),
Some("社媒初稿")
);
}
}
+160 -26
View File
@@ -1085,34 +1085,79 @@ pub(super) fn append_payload_tracking_metadata(metadata: &mut Map<String, Value>
Value::String(parsed_payload.kind().to_string()),
);
if let AutomationPayload::BrowserSession {
profile_id,
profile_key,
url,
environment_preset_id,
target_id,
open_window,
stream_mode,
} = parsed_payload
{
metadata.insert("profile_id".to_string(), Value::String(profile_id));
if let Some(profile_key) = profile_key {
metadata.insert("profile_key".to_string(), Value::String(profile_key));
match parsed_payload {
AutomationPayload::AgentTurn {
request_metadata,
content_id,
..
} => {
if let Some(content_id) = content_id.clone() {
metadata.insert("content_id".to_string(), Value::String(content_id));
}
let request_metadata = request_metadata.as_ref().and_then(Value::as_object);
if let Some(service_skill) = request_metadata.and_then(|value| {
value
.get("service_skill")
.or_else(|| value.get("serviceSkill"))
}) {
metadata.insert("service_skill".to_string(), service_skill.clone());
}
let harness = request_metadata
.and_then(|value| value.get("harness"))
.and_then(Value::as_object)
.map(|value| {
let mut next = value.clone();
if let Some(content_id) = content_id.as_ref() {
if !next.contains_key("content_id") && !next.contains_key("contentId") {
next.insert(
"content_id".to_string(),
Value::String(content_id.clone()),
);
}
}
Value::Object(next)
})
.or_else(|| {
content_id.map(|value| {
json!({
"content_id": value
})
})
});
if let Some(harness) = harness {
metadata.insert("harness".to_string(), harness);
}
}
if let Some(url) = url {
metadata.insert("url".to_string(), Value::String(url));
AutomationPayload::BrowserSession {
profile_id,
profile_key,
url,
environment_preset_id,
target_id,
open_window,
stream_mode,
} => {
metadata.insert("profile_id".to_string(), Value::String(profile_id));
if let Some(profile_key) = profile_key {
metadata.insert("profile_key".to_string(), Value::String(profile_key));
}
if let Some(url) = url {
metadata.insert("url".to_string(), Value::String(url));
}
if let Some(environment_preset_id) = environment_preset_id {
metadata.insert(
"environment_preset_id".to_string(),
Value::String(environment_preset_id),
);
}
if let Some(target_id) = target_id {
metadata.insert("target_id".to_string(), Value::String(target_id));
}
metadata.insert("open_window".to_string(), Value::Bool(open_window));
metadata.insert("stream_mode".to_string(), json!(stream_mode));
}
if let Some(environment_preset_id) = environment_preset_id {
metadata.insert(
"environment_preset_id".to_string(),
Value::String(environment_preset_id),
);
}
if let Some(target_id) = target_id {
metadata.insert("target_id".to_string(), Value::String(target_id));
}
metadata.insert("open_window".to_string(), Value::Bool(open_window));
metadata.insert("stream_mode".to_string(), json!(stream_mode));
}
}
@@ -1335,6 +1380,95 @@ mod tests {
);
}
#[test]
fn build_tracker_finish_metadata_should_include_agent_turn_service_skill_context() {
let job = AutomationJob {
id: "job-1".to_string(),
name: "每日趋势摘要".to_string(),
description: Some("围绕指定平台输出趋势摘要".to_string()),
enabled: true,
workspace_id: "workspace-1".to_string(),
execution_mode: AutomationExecutionMode::Skill,
schedule: TaskSchedule::Cron {
expr: "0 9 * * *".to_string(),
tz: Some("Asia/Shanghai".to_string()),
},
payload: json!({
"kind": "agent_turn",
"prompt": "[服务型技能] 每日趋势摘要",
"web_search": false,
"content_id": "content-1",
"request_metadata": {
"service_skill": {
"id": "daily-trend-briefing",
"title": "每日趋势摘要",
"runner_type": "scheduled",
"execution_location": "client_default",
"source": "cloud_catalog",
"slot_values": [
{
"key": "platform",
"label": "监测平台",
"value": "小红书"
}
],
"user_input": "关注增长最快的话题"
},
"harness": {
"theme": "social-media"
}
}
}),
delivery: DeliveryConfig::default(),
timeout_secs: None,
max_retries: 3,
next_run_at: None,
last_status: None,
last_error: None,
last_run_at: None,
last_finished_at: None,
running_started_at: None,
consecutive_failures: 0,
last_retry_count: 0,
auto_disabled_until: None,
last_delivery: None,
created_at: "2026-03-15T00:00:00Z".to_string(),
updated_at: "2026-03-15T00:00:00Z".to_string(),
};
let metadata = build_tracker_finish_metadata(
&job,
Some("session-1"),
"success",
"success",
0,
1200,
None,
);
assert_eq!(metadata.get("content_id"), Some(&json!("content-1")));
assert_eq!(
metadata.pointer("/service_skill/title"),
Some(&json!("每日趋势摘要"))
);
assert_eq!(
metadata.pointer("/service_skill/slot_values/0/label"),
Some(&json!("监测平台"))
);
assert_eq!(
metadata.pointer("/service_skill/user_input"),
Some(&json!("关注增长最快的话题"))
);
assert_eq!(
metadata.pointer("/harness/theme"),
Some(&json!("social-media"))
);
assert_eq!(
metadata.pointer("/harness/content_id"),
Some(&json!("content-1"))
);
}
#[test]
fn build_delivery_context_should_build_stable_attempt_id_without_run_id() {
let job = AutomationJob {
@@ -8,6 +8,21 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
const DEFAULT_CDP_TIMEOUT_MS: u64 = 10_000;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID: &str = "browser-environment-us-desktop";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME: &str = "美区桌面";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_DESCRIPTION: &str = "美国住宅代理 + 桌面视口";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER: &str = "http://127.0.0.1:7890";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_TIMEZONE_ID: &str = "America/Los_Angeles";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LOCALE: &str = "en-US";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_ACCEPT_LANGUAGE: &str = "en-US,en;q=0.9";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_USER_AGENT: &str = "Mozilla/5.0";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_PLATFORM: &str = "MacIntel";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_WIDTH: i64 = 1440;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_HEIGHT: i64 = 900;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_DEVICE_SCALE_FACTOR: f64 = 2.0;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LAT: f64 = 37.7749;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LNG: f64 = -122.4194;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_GEO_ACCURACY_M: f64 = 100.0;
#[derive(Debug, Clone)]
pub struct SaveBrowserEnvironmentPresetInput {
@@ -105,6 +120,37 @@ pub fn list_browser_environment_presets(
.map_err(|error| format!("读取浏览器环境预设失败: {error}"))
}
pub fn ensure_default_browser_environment_presets(conn: &Connection) -> Result<bool, String> {
let existing_presets = BrowserEnvironmentPresetDao::list(conn, true)
.map_err(|error| format!("读取浏览器环境预设失败: {error}"))?;
if !existing_presets.is_empty() {
return Ok(false);
}
save_browser_environment_preset(
conn,
SaveBrowserEnvironmentPresetInput {
id: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID.to_string()),
name: DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME.to_string(),
description: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_DESCRIPTION.to_string()),
proxy_server: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER.to_string()),
timezone_id: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_TIMEZONE_ID.to_string()),
locale: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LOCALE.to_string()),
accept_language: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_ACCEPT_LANGUAGE.to_string()),
geolocation_lat: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LAT),
geolocation_lng: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LNG),
geolocation_accuracy_m: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_GEO_ACCURACY_M),
user_agent: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_USER_AGENT.to_string()),
platform: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PLATFORM.to_string()),
viewport_width: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_WIDTH),
viewport_height: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_HEIGHT),
device_scale_factor: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_DEVICE_SCALE_FACTOR),
},
)?;
Ok(true)
}
pub fn get_browser_environment_preset(
conn: &Connection,
id: &str,
@@ -405,6 +451,14 @@ fn extract_runtime_value(response: Value) -> Option<Value> {
#[cfg(test)]
mod tests {
use super::*;
use crate::database::schema::create_tables;
use rusqlite::Connection;
fn setup_db() -> Connection {
let conn = Connection::open_in_memory().expect("创建内存数据库失败");
create_tables(&conn).expect("创建数据表失败");
conn
}
#[test]
fn should_require_complete_geolocation_pair() {
@@ -441,4 +495,54 @@ mod tests {
let error = normalize_viewport(Some(1440), None).unwrap_err();
assert!(error.contains("必须同时填写"));
}
#[test]
fn should_seed_default_environment_preset_for_empty_table() {
let conn = setup_db();
let seeded = ensure_default_browser_environment_presets(&conn).unwrap();
let presets = list_browser_environment_presets(&conn, false).unwrap();
assert!(seeded);
assert_eq!(presets.len(), 1);
assert_eq!(presets[0].id, DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID);
assert_eq!(presets[0].name, DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME);
assert_eq!(
presets[0].proxy_server.as_deref(),
Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER)
);
}
#[test]
fn should_not_seed_default_environment_preset_when_table_has_records() {
let conn = setup_db();
save_browser_environment_preset(
&conn,
SaveBrowserEnvironmentPresetInput {
id: None,
name: "日本桌面".to_string(),
description: None,
proxy_server: None,
timezone_id: Some("Asia/Tokyo".to_string()),
locale: Some("ja-JP".to_string()),
accept_language: Some("ja-JP,ja;q=0.9".to_string()),
geolocation_lat: None,
geolocation_lng: None,
geolocation_accuracy_m: None,
user_agent: None,
platform: Some("MacIntel".to_string()),
viewport_width: Some(1440),
viewport_height: Some(900),
device_scale_factor: Some(2.0),
},
)
.unwrap();
let seeded = ensure_default_browser_environment_presets(&conn).unwrap();
let presets = list_browser_environment_presets(&conn, false).unwrap();
assert!(!seeded);
assert_eq!(presets.len(), 1);
assert_eq!(presets[0].name, "日本桌面");
}
}
@@ -7,6 +7,12 @@ use lime_core::database::dao::browser_profile::{
use rusqlite::Connection;
use url::Url;
const DEFAULT_BROWSER_PROFILE_KEY: &str = "general_browser_assist";
const DEFAULT_BROWSER_PROFILE_NAME: &str = "通用浏览器资料";
const DEFAULT_BROWSER_PROFILE_DESCRIPTION: &str = "默认浏览器协助资料";
const DEFAULT_BROWSER_PROFILE_SITE_SCOPE: &str = "通用";
const DEFAULT_BROWSER_PROFILE_LAUNCH_URL: &str = "https://www.google.com/";
#[derive(Debug, Clone)]
pub struct SaveBrowserProfileInput {
pub id: Option<String>,
@@ -63,6 +69,29 @@ pub fn list_browser_profiles(
.map_err(|error| format!("读取浏览器资料失败: {error}"))
}
pub fn ensure_default_browser_profiles(conn: &Connection) -> Result<bool, String> {
let existing_profiles = BrowserProfileDao::list(conn, true)
.map_err(|error| format!("读取浏览器资料失败: {error}"))?;
if !existing_profiles.is_empty() {
return Ok(false);
}
save_browser_profile(
conn,
SaveBrowserProfileInput {
id: None,
profile_key: DEFAULT_BROWSER_PROFILE_KEY.to_string(),
name: DEFAULT_BROWSER_PROFILE_NAME.to_string(),
description: Some(DEFAULT_BROWSER_PROFILE_DESCRIPTION.to_string()),
site_scope: Some(DEFAULT_BROWSER_PROFILE_SITE_SCOPE.to_string()),
launch_url: Some(DEFAULT_BROWSER_PROFILE_LAUNCH_URL.to_string()),
transport_kind: BrowserProfileTransportKind::ManagedCdp,
},
)?;
Ok(true)
}
pub fn get_browser_profile(
conn: &Connection,
id: &str,
@@ -306,4 +335,46 @@ mod tests {
assert_eq!(saved.profile_dir, "");
assert_eq!(saved.managed_profile_dir, None);
}
#[test]
fn should_seed_default_profile_for_empty_table() {
let conn = setup_db();
let seeded = ensure_default_browser_profiles(&conn).unwrap();
let profiles = list_browser_profiles(&conn, false).unwrap();
assert!(seeded);
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].profile_key, DEFAULT_BROWSER_PROFILE_KEY);
assert_eq!(profiles[0].name, DEFAULT_BROWSER_PROFILE_NAME);
assert_eq!(
profiles[0].transport_kind,
BrowserProfileTransportKind::ManagedCdp
);
}
#[test]
fn should_not_seed_default_profile_when_table_has_records() {
let conn = setup_db();
save_browser_profile(
&conn,
SaveBrowserProfileInput {
id: None,
profile_key: "weibo_attach".to_string(),
name: "微博附着".to_string(),
description: Some("依赖当前 Chrome".to_string()),
site_scope: Some("weibo.com".to_string()),
launch_url: Some("https://weibo.com".to_string()),
transport_kind: BrowserProfileTransportKind::ExistingSession,
},
)
.unwrap();
let seeded = ensure_default_browser_profiles(&conn).unwrap();
let profiles = list_browser_profiles(&conn, false).unwrap();
assert!(!seeded);
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].profile_key, "weibo_attach");
}
}
+5
View File
@@ -30,6 +30,11 @@ pub mod memory_source_resolver_service;
pub mod novel_service;
pub mod openclaw_service;
pub mod runtime_agents_template_service;
pub mod runtime_analysis_handoff_service;
pub mod runtime_evidence_pack_service;
pub mod runtime_handoff_artifact_service;
pub mod runtime_replay_case_service;
pub mod runtime_review_decision_service;
pub mod site_adapter_registry;
pub mod site_capability_service;
pub mod sysinfo_service;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,740 @@
//! Runtime evidence pack 导出服务
//!
//! 将当前 Lime 会话的 runtime / timeline / artifact 事实,
//! 导出为最小可复盘的问题证据包。
use crate::agent::SessionDetail;
use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
use chrono::Utc;
use lime_core::database::dao::agent_timeline::AgentThreadItemPayload;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
const SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions";
const EVIDENCE_DIR_NAME: &str = "evidence";
const SUMMARY_FILE_NAME: &str = "summary.md";
const RUNTIME_FILE_NAME: &str = "runtime.json";
const TIMELINE_FILE_NAME: &str = "timeline.json";
const ARTIFACTS_FILE_NAME: &str = "artifacts.json";
const MAX_RECENT_ARTIFACTS: usize = 12;
const MAX_PREVIEW_CHARS: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeEvidenceArtifactKind {
Summary,
Runtime,
Timeline,
Artifacts,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeEvidenceArtifact {
pub kind: RuntimeEvidenceArtifactKind,
pub title: String,
pub relative_path: String,
pub absolute_path: String,
pub bytes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeEvidencePackExportResult {
pub session_id: String,
pub thread_id: String,
pub workspace_id: Option<String>,
pub workspace_root: String,
pub pack_relative_root: String,
pub pack_absolute_root: String,
pub exported_at: String,
pub thread_status: String,
pub latest_turn_status: Option<String>,
pub turn_count: usize,
pub item_count: usize,
pub pending_request_count: usize,
pub queued_turn_count: usize,
pub recent_artifact_count: usize,
pub known_gaps: Vec<String>,
pub artifacts: Vec<RuntimeEvidenceArtifact>,
}
pub fn export_runtime_evidence_pack(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
) -> Result<RuntimeEvidencePackExportResult, String> {
let session_id = detail.id.trim();
if session_id.is_empty() {
return Err("session_id 不能为空,无法导出问题证据包".to_string());
}
let thread_id = detail.thread_id.trim();
if thread_id.is_empty() {
return Err("thread_id 不能为空,无法导出问题证据包".to_string());
}
let workspace_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
let exported_at = Utc::now().to_rfc3339();
let pack_relative_root = format!("{SESSION_RELATIVE_ROOT}/{session_id}/{EVIDENCE_DIR_NAME}");
let pack_absolute_root =
workspace_root.join(pack_relative_root.replace('/', std::path::MAIN_SEPARATOR_STR));
fs::create_dir_all(&pack_absolute_root).map_err(|error| {
format!(
"创建 evidence pack 目录失败 {}: {error}",
pack_absolute_root.display()
)
})?;
let recent_artifacts = collect_recent_artifact_paths(detail);
let latest_turn_summary = collect_latest_turn_summary(detail);
let known_gaps = build_known_gaps(&recent_artifacts);
let artifacts = vec![
write_evidence_file(
&pack_absolute_root,
session_id,
SUMMARY_FILE_NAME,
RuntimeEvidenceArtifactKind::Summary,
"问题摘要",
build_summary_markdown(
detail,
thread_read,
&recent_artifacts,
latest_turn_summary.as_deref(),
&known_gaps,
exported_at.as_str(),
),
)?,
write_evidence_file(
&pack_absolute_root,
session_id,
RUNTIME_FILE_NAME,
RuntimeEvidenceArtifactKind::Runtime,
"运行时快照",
build_runtime_json(
detail,
thread_read,
workspace_root.as_path(),
&recent_artifacts,
&known_gaps,
exported_at.as_str(),
)?,
)?,
write_evidence_file(
&pack_absolute_root,
session_id,
TIMELINE_FILE_NAME,
RuntimeEvidenceArtifactKind::Timeline,
"时间线快照",
build_timeline_json(detail, exported_at.as_str())?,
)?,
write_evidence_file(
&pack_absolute_root,
session_id,
ARTIFACTS_FILE_NAME,
RuntimeEvidenceArtifactKind::Artifacts,
"产物与验证线索",
build_artifacts_json(
detail,
thread_read,
&recent_artifacts,
&known_gaps,
exported_at.as_str(),
)?,
)?,
];
Ok(RuntimeEvidencePackExportResult {
session_id: session_id.to_string(),
thread_id: thread_id.to_string(),
workspace_id: normalize_optional_text(detail.workspace_id.clone()),
workspace_root: workspace_root.to_string_lossy().to_string(),
pack_relative_root,
pack_absolute_root: pack_absolute_root.to_string_lossy().to_string(),
exported_at,
thread_status: thread_read.status.trim().to_string(),
latest_turn_status: thread_read
.diagnostics
.as_ref()
.and_then(|value| normalize_optional_text(value.latest_turn_status.clone())),
turn_count: detail.turns.len(),
item_count: detail.items.len(),
pending_request_count: thread_read.pending_requests.len(),
queued_turn_count: thread_read.queued_turns.len(),
recent_artifact_count: recent_artifacts.len(),
known_gaps,
artifacts,
})
}
fn write_evidence_file(
pack_root: &Path,
session_id: &str,
file_name: &str,
kind: RuntimeEvidenceArtifactKind,
title: &str,
content: String,
) -> Result<RuntimeEvidenceArtifact, String> {
let absolute_path = pack_root.join(file_name);
fs::write(&absolute_path, content.as_bytes()).map_err(|error| {
format!(
"写入 evidence pack 文件失败 {}: {error}",
absolute_path.display()
)
})?;
Ok(RuntimeEvidenceArtifact {
kind,
title: title.to_string(),
relative_path: format!(
"{SESSION_RELATIVE_ROOT}/{session_id}/{EVIDENCE_DIR_NAME}/{file_name}"
),
absolute_path: absolute_path.to_string_lossy().to_string(),
bytes: content.len(),
})
}
fn build_summary_markdown(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
recent_artifacts: &[String],
latest_turn_summary: Option<&str>,
known_gaps: &[String],
exported_at: &str,
) -> String {
let mut markdown = String::new();
let _ = writeln!(markdown, "# 问题证据包");
let _ = writeln!(markdown);
let _ = writeln!(
markdown,
"> 当前证据包继续沿用 Codex 的结构化交接思路,运行时事实承接 Aster 的 session / thread / diagnostics,最终制品由 Lime 落盘到工作区。"
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "- 会话:`{}`", detail.id);
let _ = writeln!(markdown, "- 线程:`{}`", detail.thread_id);
let _ = writeln!(markdown, "- 导出时间:{exported_at}");
let _ = writeln!(markdown, "- 线程状态:{}", thread_read.status);
let _ = writeln!(
markdown,
"- Pending request:{} · 排队 turn:{}",
thread_read.pending_requests.len(),
thread_read.queued_turns.len()
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 最近摘要");
let _ = writeln!(markdown);
let _ = writeln!(
markdown,
"{}",
latest_turn_summary
.unwrap_or("当前没有结构化 turn summary,请先读 runtime.json 与 timeline.json。")
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 证据概览");
let _ = writeln!(markdown);
let _ = writeln!(markdown, "- Turns:{}", detail.turns.len());
let _ = writeln!(markdown, "- Timeline items:{}", detail.items.len());
let _ = writeln!(markdown, "- 最近产物:{}", recent_artifacts.len());
if let Some(blocking_summary) = thread_read
.diagnostics
.as_ref()
.and_then(|value| value.primary_blocking_summary.clone())
{
let _ = writeln!(markdown, "- 当前主要阻塞:{blocking_summary}");
}
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 建议读取顺序");
let _ = writeln!(markdown);
let _ = writeln!(markdown, "1. 先读 `summary.md`,确认会话状态和当前阻塞。");
let _ = writeln!(
markdown,
"2. 再读 `runtime.json`,查看 pending request / queued turn / diagnostics。"
);
let _ = writeln!(
markdown,
"3. 再读 `timeline.json`,回放最近 turns 与 items。"
);
let _ = writeln!(
markdown,
"4. 最后读 `artifacts.json`,确认最近产物与当前证据缺口。"
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 已知缺口");
let _ = writeln!(markdown);
for gap in known_gaps {
let _ = writeln!(markdown, "- {gap}");
}
markdown
}
fn build_runtime_json(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
recent_artifacts: &[String],
known_gaps: &[String],
exported_at: &str,
) -> Result<String, String> {
let payload = json!({
"schemaVersion": "v1",
"source": {
"contractShape": "codex_trace_evidence_pack",
"runtimeSubstrate": "aster_session_thread_runtime",
"productSurface": "lime_workspace_evidence_pack"
},
"session": {
"sessionId": detail.id,
"threadId": detail.thread_id,
"name": detail.name,
"workspaceId": detail.workspace_id,
"workspaceRoot": workspace_root.to_string_lossy().to_string(),
"exportedAt": exported_at,
"updatedAt": detail.updated_at,
"executionStrategy": detail.execution_strategy,
"model": detail.model
},
"thread": {
"status": thread_read.status,
"activeTurnId": thread_read.active_turn_id,
"interruptState": thread_read.interrupt_state,
"latestTurnStatus": thread_read.diagnostics.as_ref().and_then(|value| value.latest_turn_status.clone()),
"pendingRequestCount": thread_read.pending_requests.len(),
"queuedTurnCount": thread_read.queued_turns.len(),
"diagnostics": {
"warningCount": thread_read.diagnostics.as_ref().map(|value| value.warning_count).unwrap_or(0),
"contextCompactionCount": thread_read.diagnostics.as_ref().map(|value| value.context_compaction_count).unwrap_or(0),
"failedToolCallCount": thread_read.diagnostics.as_ref().map(|value| value.failed_tool_call_count).unwrap_or(0),
"failedCommandCount": thread_read.diagnostics.as_ref().map(|value| value.failed_command_count).unwrap_or(0),
"primaryBlockingKind": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_kind.clone()),
"primaryBlockingSummary": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_summary.clone()),
"latestWarning": thread_read.diagnostics.as_ref().and_then(|value| value.latest_warning.as_ref().map(|warning| json!({
"code": warning.code,
"message": warning.message,
"updatedAt": warning.updated_at
}))),
"latestFailedTool": thread_read.diagnostics.as_ref().and_then(|value| value.latest_failed_tool.as_ref().map(|tool| json!({
"toolName": tool.tool_name,
"error": tool.error,
"updatedAt": tool.updated_at
}))),
"latestFailedCommand": thread_read.diagnostics.as_ref().and_then(|value| value.latest_failed_command.as_ref().map(|command| json!({
"command": command.command,
"exitCode": command.exit_code,
"error": command.error,
"updatedAt": command.updated_at
})))
}
},
"pendingRequests": thread_read.pending_requests.iter().map(|item| {
json!({
"id": item.id,
"type": item.request_type,
"status": item.status,
"title": item.title,
"turnId": item.turn_id
})
}).collect::<Vec<_>>(),
"queuedTurns": thread_read.queued_turns.iter().map(|item| {
json!({
"id": item.queued_turn_id,
"position": item.position,
"preview": item.message_preview,
"createdAt": item.created_at
})
}).collect::<Vec<_>>(),
"subagents": detail.child_subagent_sessions.iter().map(|session| {
json!({
"id": session.id,
"name": session.name,
"runtimeStatus": session.runtime_status,
"latestTurnStatus": session.latest_turn_status,
"taskSummary": session.task_summary,
"roleHint": session.role_hint,
"updatedAt": session.updated_at
})
}).collect::<Vec<_>>(),
"recentArtifacts": recent_artifacts,
"knownGaps": known_gaps
});
serde_json::to_string_pretty(&payload)
.map_err(|error| format!("序列化 runtime.json 失败: {error}"))
}
fn build_timeline_json(detail: &SessionDetail, exported_at: &str) -> Result<String, String> {
let payload = json!({
"schemaVersion": "v1",
"exportedAt": exported_at,
"turns": detail.turns.iter().map(|turn| {
json!({
"id": turn.id,
"status": serialize_enum_as_string(&turn.status, "unknown"),
"promptPreview": truncate_text(turn.prompt_text.as_str()),
"startedAt": turn.started_at,
"completedAt": turn.completed_at,
"updatedAt": turn.updated_at
})
}).collect::<Vec<_>>(),
"items": detail.items.iter().map(|item| {
let (payload_kind, payload_summary) = summarize_item_payload(&item.payload);
json!({
"id": item.id,
"turnId": item.turn_id,
"sequence": item.sequence,
"status": serialize_enum_as_string(&item.status, "unknown"),
"payloadKind": payload_kind,
"payloadSummary": payload_summary,
"updatedAt": item.updated_at
})
}).collect::<Vec<_>>()
});
serde_json::to_string_pretty(&payload)
.map_err(|error| format!("序列化 timeline.json 失败: {error}"))
}
fn build_artifacts_json(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
recent_artifacts: &[String],
known_gaps: &[String],
exported_at: &str,
) -> Result<String, String> {
let payload = json!({
"schemaVersion": "v1",
"exportedAt": exported_at,
"recentArtifacts": recent_artifacts,
"artifactCount": recent_artifacts.len(),
"verification": {
"artifactValidatorIssues": [],
"browserEvidence": [],
"guiSmoke": null
},
"requests": {
"pending": thread_read.pending_requests.iter().map(|item| {
json!({
"id": item.id,
"type": item.request_type,
"title": item.title,
"status": item.status
})
}).collect::<Vec<_>>(),
"knownGap": "provider request token / retry 摘要尚未接入当前 evidence pack"
},
"workspace": {
"workspaceId": detail.workspace_id,
"workingDir": detail.working_dir
},
"knownGaps": known_gaps
});
serde_json::to_string_pretty(&payload)
.map_err(|error| format!("序列化 artifacts.json 失败: {error}"))
}
fn build_known_gaps(recent_artifacts: &[String]) -> Vec<String> {
let mut gaps = vec![
"当前 Evidence Pack 尚未纳入 provider 请求级 token / retry / duration 摘要。".to_string(),
"当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。".to_string(),
];
if recent_artifacts.is_empty() {
gaps.push("当前未检测到最近产物路径,Artifact 证据为空。".to_string());
}
gaps
}
fn collect_latest_turn_summary(detail: &SessionDetail) -> Option<String> {
detail
.items
.iter()
.rev()
.find_map(|item| match &item.payload {
AgentThreadItemPayload::TurnSummary { text } => {
normalize_optional_text(Some(text.clone()))
}
_ => None,
})
}
fn collect_recent_artifact_paths(detail: &SessionDetail) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut paths = Vec::new();
for item in detail.items.iter().rev() {
let Some(path) = (match &item.payload {
AgentThreadItemPayload::FileArtifact { path, .. } => {
normalize_optional_text(Some(path.clone()))
}
_ => None,
}) else {
continue;
};
if seen.insert(path.clone()) {
paths.push(path);
}
if paths.len() >= MAX_RECENT_ARTIFACTS {
break;
}
}
paths
}
fn summarize_item_payload(payload: &AgentThreadItemPayload) -> (&'static str, Option<String>) {
match payload {
AgentThreadItemPayload::Plan { text } => {
("plan", normalize_optional_text(Some(truncate_text(text))))
}
AgentThreadItemPayload::TurnSummary { text } => (
"turn_summary",
normalize_optional_text(Some(truncate_text(text))),
),
AgentThreadItemPayload::FileArtifact { path, .. } => {
("file_artifact", normalize_optional_text(Some(path.clone())))
}
_ => ("other", None),
}
}
fn truncate_text(value: &str) -> String {
let normalized = value.trim();
if normalized.chars().count() <= MAX_PREVIEW_CHARS {
return normalized.to_string();
}
normalized
.chars()
.take(MAX_PREVIEW_CHARS)
.collect::<String>()
+ "..."
}
fn serialize_enum_as_string<T: Serialize>(value: &T, fallback: &str) -> String {
serde_json::to_value(value)
.ok()
.and_then(|item| item.as_str().map(str::to_string))
.unwrap_or_else(|| fallback.to_string())
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
let trimmed = value?.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::QueuedTurnSnapshot;
use lime_core::database::dao::agent_timeline::{
AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn,
AgentThreadTurnStatus,
};
use tempfile::TempDir;
fn build_detail() -> SessionDetail {
SessionDetail {
id: "session-1".to_string(),
name: "P2 evidence".to_string(),
created_at: 1,
updated_at: 2,
thread_id: "thread-1".to_string(),
model: Some("gpt-5.4".to_string()),
working_dir: Some("/tmp/workspace".to_string()),
workspace_id: Some("workspace-1".to_string()),
messages: Vec::new(),
execution_strategy: Some("react".to_string()),
execution_runtime: None,
turns: vec![AgentThreadTurn {
id: "turn-1".to_string(),
thread_id: "thread-1".to_string(),
prompt_text: "继续推进 evidence pack".to_string(),
status: AgentThreadTurnStatus::Completed,
started_at: "2026-03-27T10:00:00Z".to_string(),
completed_at: Some("2026-03-27T10:01:00Z".to_string()),
error_message: None,
created_at: "2026-03-27T10:00:00Z".to_string(),
updated_at: "2026-03-27T10:01:00Z".to_string(),
}],
items: vec![
AgentThreadItem {
id: "plan-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 1,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:05Z".to_string(),
completed_at: Some("2026-03-27T10:00:05Z".to_string()),
updated_at: "2026-03-27T10:00:05Z".to_string(),
payload: AgentThreadItemPayload::Plan {
text: "先导出 handoff,再导出 evidence pack".to_string(),
},
},
AgentThreadItem {
id: "artifact-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 2,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:20Z".to_string(),
completed_at: Some("2026-03-27T10:00:20Z".to_string()),
updated_at: "2026-03-27T10:00:20Z".to_string(),
payload: AgentThreadItemPayload::FileArtifact {
path: ".lime/artifacts/thread-1/report.md".to_string(),
source: "artifact_snapshot".to_string(),
content: None,
metadata: None,
},
},
AgentThreadItem {
id: "summary-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 3,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:30Z".to_string(),
completed_at: Some("2026-03-27T10:00:30Z".to_string()),
updated_at: "2026-03-27T10:00:30Z".to_string(),
payload: AgentThreadItemPayload::TurnSummary {
text: "已拿到 handoff 四件套,下一步补问题证据包。".to_string(),
},
},
],
todo_items: Vec::new(),
child_subagent_sessions: Vec::new(),
subagent_parent_context: None,
}
}
fn build_thread_read() -> AgentRuntimeThreadReadModel {
AgentRuntimeThreadReadModel {
thread_id: "thread-1".to_string(),
status: "running".to_string(),
active_turn_id: Some("turn-1".to_string()),
pending_requests: vec![crate::commands::aster_agent_cmd::AgentRuntimeRequestView {
id: "req-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
item_id: None,
request_type: "ask_user".to_string(),
status: "pending".to_string(),
title: Some("确认是否导出问题证据包".to_string()),
payload: None,
decision: None,
scope: None,
created_at: None,
resolved_at: None,
}],
last_outcome: None,
incidents: Vec::new(),
queued_turns: vec![QueuedTurnSnapshot {
queued_turn_id: "queued-1".to_string(),
message_preview: "继续补证据包 UI".to_string(),
message_text: "继续补证据包 UI".to_string(),
created_at: 3,
image_count: 0,
position: 1,
}],
interrupt_state: None,
updated_at: Some("2026-03-27T10:01:00Z".to_string()),
diagnostics: Some(
crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
latest_turn_status: Some("running".to_string()),
latest_turn_started_at: None,
latest_turn_completed_at: None,
latest_turn_updated_at: None,
latest_turn_elapsed_seconds: None,
latest_turn_stalled_seconds: None,
latest_turn_error_message: None,
interrupt_reason: None,
runtime_interrupt_source: None,
runtime_interrupt_requested_at: None,
runtime_interrupt_wait_seconds: None,
warning_count: 1,
context_compaction_count: 0,
failed_tool_call_count: 0,
failed_command_count: 0,
pending_request_count: 1,
oldest_pending_request_wait_seconds: None,
primary_blocking_kind: Some("pending_request".to_string()),
primary_blocking_summary: Some("等待用户确认是否导出问题证据包".to_string()),
latest_warning: Some(
crate::commands::aster_agent_cmd::AgentRuntimeDiagnosticWarningSample {
item_id: "warning-1".to_string(),
turn_id: Some("turn-1".to_string()),
code: Some("runtime.pending".to_string()),
message: "存在待处理请求".to_string(),
updated_at: "2026-03-27T10:01:00Z".to_string(),
},
),
latest_context_compaction: None,
latest_failed_tool: None,
latest_failed_command: None,
latest_pending_request: None,
},
),
}
}
#[test]
fn should_export_runtime_evidence_pack_to_workspace() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let thread_read = build_thread_read();
let result =
export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export");
assert_eq!(
result.pack_relative_root,
".lime/harness/sessions/session-1/evidence"
);
assert_eq!(result.artifacts.len(), 4);
assert_eq!(result.turn_count, 1);
assert_eq!(result.item_count, 3);
assert_eq!(result.pending_request_count, 1);
assert_eq!(result.queued_turn_count, 1);
assert_eq!(result.recent_artifact_count, 1);
assert!(!result.known_gaps.is_empty());
let summary_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/evidence/summary.md");
let runtime_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/evidence/runtime.json");
let timeline_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/evidence/timeline.json");
assert!(summary_path.exists());
assert!(runtime_path.exists());
assert!(timeline_path.exists());
let summary = fs::read_to_string(summary_path).expect("summary");
assert!(summary.contains("问题证据包"));
assert!(summary.contains("等待用户确认是否导出问题证据包"));
let runtime = fs::read_to_string(runtime_path).expect("runtime");
assert!(runtime.contains("\"sessionId\": \"session-1\""));
assert!(runtime.contains("\"pendingRequestCount\": 1"));
let timeline = fs::read_to_string(timeline_path).expect("timeline");
assert!(timeline.contains("\"payloadKind\": \"plan\""));
assert!(timeline.contains("\"status\": \"completed\""));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,625 @@
//! Runtime review decision 模板导出服务
//!
//! 将外部 Claude Code / Codex 的分析结论回挂为
//! Lime 工作区内可版本化的人工审核与决策记录模板。
//! 这条链只导出 review-decision 模板,不在 Lime 内自动批准或自动应用修复。
use crate::agent::SessionDetail;
use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
use crate::services::runtime_analysis_handoff_service::{
export_runtime_analysis_handoff, RuntimeAnalysisArtifact, RuntimeAnalysisHandoffExportResult,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
const SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions";
const REVIEW_DIR_NAME: &str = "review";
const REVIEW_DECISION_MARKDOWN_FILE_NAME: &str = "review-decision.md";
const REVIEW_DECISION_JSON_FILE_NAME: &str = "review-decision.json";
const DEFAULT_DECISION_STATUS: &str = "pending_review";
const DEFAULT_RISK_LEVEL: &str = "unknown";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeReviewDecisionArtifactKind {
ReviewDecisionMarkdown,
ReviewDecisionJson,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeReviewDecisionArtifact {
pub kind: RuntimeReviewDecisionArtifactKind,
pub title: String,
pub relative_path: String,
pub absolute_path: String,
pub bytes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeReviewDecisionTemplateExportResult {
pub session_id: String,
pub thread_id: String,
pub workspace_id: Option<String>,
pub workspace_root: String,
pub review_relative_root: String,
pub review_absolute_root: String,
pub analysis_relative_root: String,
pub analysis_absolute_root: String,
pub handoff_bundle_relative_root: String,
pub evidence_pack_relative_root: String,
pub replay_case_relative_root: String,
pub exported_at: String,
pub title: String,
pub thread_status: String,
pub latest_turn_status: Option<String>,
pub pending_request_count: usize,
pub queued_turn_count: usize,
pub default_decision_status: String,
pub review_checklist: Vec<String>,
pub analysis_artifacts: Vec<RuntimeAnalysisArtifact>,
pub artifacts: Vec<RuntimeReviewDecisionArtifact>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionDocument {
schema_version: String,
contract_shape: String,
exported_at: String,
source: ReviewDecisionSource,
review_context: ReviewDecisionContext,
decision: ReviewDecisionContent,
decision_status_options: Vec<String>,
risk_level_options: Vec<String>,
review_checklist: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionSource {
derived_from: Vec<String>,
upstream_alignment: ReviewDecisionUpstreamAlignment,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionUpstreamAlignment {
execution_environment_reference: String,
runtime_fact_source: String,
product_surface: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionContext {
session_id: String,
thread_id: String,
workspace_id: Option<String>,
title: String,
thread_status: String,
latest_turn_status: Option<String>,
pending_request_count: usize,
queued_turn_count: usize,
analysis_relative_root: String,
handoff_bundle_relative_root: String,
evidence_pack_relative_root: String,
replay_case_relative_root: String,
analysis_artifacts: Vec<ReviewDecisionArtifactReference>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionArtifactReference {
kind: String,
title: String,
relative_path: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionContent {
decision_status: String,
decision_summary: String,
chosen_fix_strategy: String,
risk_level: String,
risk_tags: Vec<String>,
human_reviewer: String,
reviewed_at: Option<String>,
followup_actions: Vec<String>,
regression_requirements: Vec<String>,
notes: String,
}
pub fn export_runtime_review_decision_template(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
let session_id = detail.id.trim();
if session_id.is_empty() {
return Err("session_id 不能为空,无法导出 review decision 模板".to_string());
}
let thread_id = detail.thread_id.trim();
if thread_id.is_empty() {
return Err("thread_id 不能为空,无法导出 review decision 模板".to_string());
}
let workspace_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
let exported_at = Utc::now().to_rfc3339();
let review_relative_root = format!("{SESSION_RELATIVE_ROOT}/{session_id}/{REVIEW_DIR_NAME}");
let review_absolute_root =
workspace_root.join(review_relative_root.replace('/', std::path::MAIN_SEPARATOR_STR));
let analysis = export_runtime_analysis_handoff(detail, thread_read, workspace_root.as_path())?;
fs::create_dir_all(&review_absolute_root).map_err(|error| {
format!(
"创建 review decision 目录失败 {}: {error}",
review_absolute_root.display()
)
})?;
let review_checklist = build_review_checklist();
let document = build_review_decision_document(&analysis, &exported_at, &review_checklist);
let markdown = build_review_decision_markdown(&document);
let json = serde_json::to_string_pretty(&document)
.map_err(|error| format!("序列化 review decision json 失败: {error}"))?;
let artifacts = vec![
write_review_decision_artifact(
RuntimeReviewDecisionArtifactKind::ReviewDecisionMarkdown,
"人工审核记录",
&review_absolute_root.join(REVIEW_DECISION_MARKDOWN_FILE_NAME),
&format!("{review_relative_root}/{REVIEW_DECISION_MARKDOWN_FILE_NAME}"),
markdown.as_bytes(),
)?,
write_review_decision_artifact(
RuntimeReviewDecisionArtifactKind::ReviewDecisionJson,
"人工审核记录 JSON",
&review_absolute_root.join(REVIEW_DECISION_JSON_FILE_NAME),
&format!("{review_relative_root}/{REVIEW_DECISION_JSON_FILE_NAME}"),
json.as_bytes(),
)?,
];
Ok(RuntimeReviewDecisionTemplateExportResult {
session_id: analysis.session_id.clone(),
thread_id: analysis.thread_id.clone(),
workspace_id: analysis.workspace_id.clone(),
workspace_root: analysis.workspace_root.clone(),
review_relative_root,
review_absolute_root: to_portable_path(&review_absolute_root.to_string_lossy()),
analysis_relative_root: analysis.analysis_relative_root.clone(),
analysis_absolute_root: analysis.analysis_absolute_root.clone(),
handoff_bundle_relative_root: analysis.handoff_bundle_relative_root.clone(),
evidence_pack_relative_root: analysis.evidence_pack_relative_root.clone(),
replay_case_relative_root: analysis.replay_case_relative_root.clone(),
exported_at,
title: analysis.title.clone(),
thread_status: analysis.thread_status.clone(),
latest_turn_status: analysis.latest_turn_status.clone(),
pending_request_count: analysis.pending_request_count,
queued_turn_count: analysis.queued_turn_count,
default_decision_status: DEFAULT_DECISION_STATUS.to_string(),
review_checklist,
analysis_artifacts: analysis.artifacts.clone(),
artifacts,
})
}
fn build_review_decision_document(
analysis: &RuntimeAnalysisHandoffExportResult,
exported_at: &str,
review_checklist: &[String],
) -> ReviewDecisionDocument {
ReviewDecisionDocument {
schema_version: "v1".to_string(),
contract_shape: "lime_review_decision_template".to_string(),
exported_at: exported_at.to_string(),
source: ReviewDecisionSource {
derived_from: vec![
"lime_external_analysis_handoff".to_string(),
"runtime_handoff_bundle".to_string(),
"runtime_evidence_pack".to_string(),
"runtime_replay_case".to_string(),
],
upstream_alignment: ReviewDecisionUpstreamAlignment {
execution_environment_reference: "codex".to_string(),
runtime_fact_source: "aster-rust".to_string(),
product_surface: "lime".to_string(),
},
},
review_context: ReviewDecisionContext {
session_id: analysis.session_id.clone(),
thread_id: analysis.thread_id.clone(),
workspace_id: analysis.workspace_id.clone(),
title: analysis.title.clone(),
thread_status: analysis.thread_status.clone(),
latest_turn_status: analysis.latest_turn_status.clone(),
pending_request_count: analysis.pending_request_count,
queued_turn_count: analysis.queued_turn_count,
analysis_relative_root: analysis.analysis_relative_root.clone(),
handoff_bundle_relative_root: analysis.handoff_bundle_relative_root.clone(),
evidence_pack_relative_root: analysis.evidence_pack_relative_root.clone(),
replay_case_relative_root: analysis.replay_case_relative_root.clone(),
analysis_artifacts: analysis
.artifacts
.iter()
.map(|artifact| ReviewDecisionArtifactReference {
kind: review_analysis_artifact_kind_key(&artifact.kind).to_string(),
title: artifact.title.clone(),
relative_path: artifact.relative_path.clone(),
})
.collect(),
},
decision: ReviewDecisionContent {
decision_status: DEFAULT_DECISION_STATUS.to_string(),
decision_summary: String::new(),
chosen_fix_strategy: String::new(),
risk_level: DEFAULT_RISK_LEVEL.to_string(),
risk_tags: Vec::new(),
human_reviewer: String::new(),
reviewed_at: None,
followup_actions: Vec::new(),
regression_requirements: Vec::new(),
notes: String::new(),
},
decision_status_options: vec![
"accepted".to_string(),
"deferred".to_string(),
"rejected".to_string(),
"needs_more_evidence".to_string(),
DEFAULT_DECISION_STATUS.to_string(),
],
risk_level_options: vec![
"low".to_string(),
"medium".to_string(),
"high".to_string(),
DEFAULT_RISK_LEVEL.to_string(),
],
review_checklist: review_checklist.to_vec(),
}
}
fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
let checklist = document
.review_checklist
.iter()
.map(|item| format!("- [ ] {item}"))
.collect::<Vec<_>>()
.join("\n");
let analysis_files = document
.review_context
.analysis_artifacts
.iter()
.map(|artifact| format!("- `{}`:`{}`", artifact.title, artifact.relative_path))
.collect::<Vec<_>>()
.join("\n");
format!(
"# Lime 人工审核与决策记录\n\n\
> 状态:`{decision_status}`\n\
> 导出时间:`{exported_at}`\n\
> 说明:这份模板用于把外部 Claude Code / Codex 的分析结论,回挂为 Lime 工作区内可版本化的人工审核记录;最终是否接受修复仍由开发者决定。\n\n\
## 1. 审核上下文\n\
- 标题:{title}\n\
- session_id:`{session_id}`\n\
- thread_id:`{thread_id}`\n\
- 线程状态:`{thread_status}`\n\
- 最新 Turn:`{latest_turn_status}`\n\
- 待处理请求:`{pending_request_count}`\n\
- 排队任务:`{queued_turn_count}`\n\
- analysis 目录:`{analysis_relative_root}`\n\
- handoff 目录:`{handoff_bundle_relative_root}`\n\
- evidence 目录:`{evidence_pack_relative_root}`\n\
- replay 目录:`{replay_case_relative_root}`\n\n\
### 关联分析文件\n\
{analysis_files}\n\n\
## 2. 上游对齐\n\
- 执行环境参照:`codex`\n\
- 运行时事实源:`aster-rust`\n\
- 产品承接面:`lime`\n\n\
## 3. 审核清单\n\
{checklist}\n\n\
## 4. 决策状态\n\
- 当前值:`{decision_status}`\n\
- 可选值:`accepted` / `deferred` / `rejected` / `needs_more_evidence`\n\n\
## 5. 决策摘要\n\
待填写。\n\n\
## 6. 采用的修复策略\n\
待填写。\n\n\
## 7. 风险等级与标签\n\
- 风险等级:`{risk_level}`\n\
- 风险标签:待填写\n\n\
## 8. 回归要求\n\
- 待填写\n\n\
## 9. 后续动作\n\
- 待填写\n\n\
## 10. 审核备注\n\
- 审核人:待填写\n\
- 审核时间:待填写\n\
- 备注:待填写\n",
decision_status = document.decision.decision_status,
exported_at = document.exported_at,
title = empty_fallback(&document.review_context.title, "未命名"),
session_id = document.review_context.session_id,
thread_id = document.review_context.thread_id,
thread_status = empty_fallback(&document.review_context.thread_status, "unknown"),
latest_turn_status = document
.review_context
.latest_turn_status
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("unknown"),
pending_request_count = document.review_context.pending_request_count,
queued_turn_count = document.review_context.queued_turn_count,
analysis_relative_root = document.review_context.analysis_relative_root,
handoff_bundle_relative_root = document.review_context.handoff_bundle_relative_root,
evidence_pack_relative_root = document.review_context.evidence_pack_relative_root,
replay_case_relative_root = document.review_context.replay_case_relative_root,
analysis_files = if analysis_files.is_empty() {
"- 待补充".to_string()
} else {
analysis_files
},
checklist = if checklist.is_empty() {
"- [ ] 待补充审核清单".to_string()
} else {
checklist
},
risk_level = document.decision.risk_level,
)
}
fn build_review_checklist() -> Vec<String> {
vec![
"先阅读 analysis-brief.md 与 analysis-context.json,再决定是否进入修复。".to_string(),
"确认根因判断引用的是现有证据,而不是外部 AI 的猜测扩写。".to_string(),
"确认修复范围仍落在 current 主链,没有把 compat / deprecated 路径重新接回主线。"
.to_string(),
"明确最小回归集合,包括 contract、GUI smoke、Replay 或其它定向验证。".to_string(),
"把最终决定记录为 accepted / deferred / rejected / needs_more_evidence 之一。".to_string(),
]
}
fn review_analysis_artifact_kind_key(
kind: &crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind,
) -> &'static str {
match kind {
crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind::AnalysisBrief => {
"analysis_brief"
}
crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind::AnalysisContext => {
"analysis_context"
}
}
}
fn write_review_decision_artifact(
kind: RuntimeReviewDecisionArtifactKind,
title: &str,
absolute_path: &Path,
relative_path: &str,
contents: &[u8],
) -> Result<RuntimeReviewDecisionArtifact, String> {
fs::write(absolute_path, contents).map_err(|error| {
format!(
"写入 review decision 文件失败 {}: {error}",
absolute_path.display()
)
})?;
Ok(RuntimeReviewDecisionArtifact {
kind,
title: title.to_string(),
relative_path: relative_path.to_string(),
absolute_path: to_portable_path(&absolute_path.to_string_lossy()),
bytes: contents.len(),
})
}
fn empty_fallback<'a>(value: &'a str, fallback: &'a str) -> &'a str {
if value.trim().is_empty() {
fallback
} else {
value
}
}
fn to_portable_path(value: &str) -> String {
value.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::QueuedTurnSnapshot;
use crate::commands::aster_agent_cmd::{
AgentRuntimeDiagnosticPendingRequestSample, AgentRuntimeRequestView,
AgentRuntimeThreadDiagnostics,
};
use lime_core::database::dao::agent_timeline::{
AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn,
AgentThreadTurnStatus,
};
use serde_json::json;
use tempfile::TempDir;
fn build_detail() -> SessionDetail {
SessionDetail {
id: "session-1".to_string(),
thread_id: "thread-1".to_string(),
workspace_id: Some("workspace-1".to_string()),
name: "Harness Review Demo".to_string(),
model: Some("gpt-5.4".to_string()),
working_dir: Some("/tmp/workspace".to_string()),
created_at: 1,
updated_at: 2,
execution_strategy: Some("react".to_string()),
messages: Vec::new(),
execution_runtime: None,
turns: vec![AgentThreadTurn {
id: "turn-1".to_string(),
thread_id: "thread-1".to_string(),
prompt_text: "请导出 review decision 模板。".to_string(),
status: AgentThreadTurnStatus::Completed,
started_at: "2026-03-27T10:00:00Z".to_string(),
completed_at: Some("2026-03-27T10:01:00Z".to_string()),
error_message: None,
created_at: "2026-03-27T10:00:00Z".to_string(),
updated_at: "2026-03-27T10:01:00Z".to_string(),
}],
items: vec![
AgentThreadItem {
id: "item-plan-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 1,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:10Z".to_string(),
completed_at: Some("2026-03-27T10:00:10Z".to_string()),
updated_at: "2026-03-27T10:00:10Z".to_string(),
payload: AgentThreadItemPayload::Plan {
text: "补 review decision 模板导出".to_string(),
},
},
AgentThreadItem {
id: "item-summary-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 2,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:01:00Z".to_string(),
completed_at: Some("2026-03-27T10:01:00Z".to_string()),
updated_at: "2026-03-27T10:01:00Z".to_string(),
payload: AgentThreadItemPayload::TurnSummary {
text: "外部分析已可导出,下一步需要固定人工审核记录。".to_string(),
},
},
],
todo_items: vec![lime_agent::SessionTodoItem {
content: "导出人工审核记录".to_string(),
status: serde_json::from_value(json!("in_progress")).expect("status"),
active_form: None,
}],
child_subagent_sessions: vec![],
subagent_parent_context: None,
}
}
fn build_thread_read() -> AgentRuntimeThreadReadModel {
AgentRuntimeThreadReadModel {
thread_id: "thread-1".to_string(),
status: "waiting_request".to_string(),
active_turn_id: Some("turn-1".to_string()),
pending_requests: vec![AgentRuntimeRequestView {
id: "req-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
item_id: Some("request-1".to_string()),
request_type: "approval_request".to_string(),
status: "pending".to_string(),
title: Some("是否接受最小修复".to_string()),
payload: None,
decision: None,
scope: None,
created_at: None,
resolved_at: None,
}],
last_outcome: None,
incidents: Vec::new(),
queued_turns: vec![QueuedTurnSnapshot {
queued_turn_id: "queued-1".to_string(),
message_preview: "继续补 review decision".to_string(),
message_text: "继续补 review decision".to_string(),
created_at: 3,
image_count: 0,
position: 1,
}],
interrupt_state: None,
updated_at: Some("2026-03-27T10:01:20Z".to_string()),
diagnostics: Some(AgentRuntimeThreadDiagnostics {
latest_turn_status: Some("action_required".to_string()),
latest_turn_started_at: None,
latest_turn_completed_at: None,
latest_turn_updated_at: None,
latest_turn_elapsed_seconds: None,
latest_turn_stalled_seconds: None,
latest_turn_error_message: None,
interrupt_reason: None,
runtime_interrupt_source: None,
runtime_interrupt_requested_at: None,
runtime_interrupt_wait_seconds: None,
warning_count: 0,
context_compaction_count: 0,
failed_tool_call_count: 0,
failed_command_count: 0,
pending_request_count: 1,
oldest_pending_request_wait_seconds: None,
primary_blocking_kind: Some("pending_request".to_string()),
primary_blocking_summary: Some("等待人工审核修复方案".to_string()),
latest_warning: None,
latest_context_compaction: None,
latest_failed_tool: None,
latest_failed_command: None,
latest_pending_request: Some(AgentRuntimeDiagnosticPendingRequestSample {
request_id: "req-1".to_string(),
turn_id: Some("turn-1".to_string()),
request_type: "approval_request".to_string(),
title: Some("是否接受最小修复".to_string()),
waited_seconds: Some(10),
created_at: None,
}),
}),
}
}
#[test]
fn should_export_runtime_review_decision_template_to_workspace() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let thread_read = build_thread_read();
let result =
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
.expect("export");
assert_eq!(
result.review_relative_root,
".lime/harness/sessions/session-1/review"
);
assert_eq!(result.default_decision_status, "pending_review");
assert_eq!(result.artifacts.len(), 2);
assert_eq!(result.analysis_artifacts.len(), 2);
assert!(!result.review_checklist.is_empty());
let markdown_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.md");
let json_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.json");
assert!(markdown_path.exists());
assert!(json_path.exists());
let markdown = fs::read_to_string(markdown_path).expect("markdown");
assert!(markdown.contains("人工审核与决策记录"));
assert!(markdown.contains("analysis-brief.md"));
assert!(markdown.contains("aster-rust"));
assert!(markdown.contains("pending_review"));
let json = fs::read_to_string(json_path).expect("json");
assert!(json.contains("\"contractShape\": \"lime_review_decision_template\""));
assert!(json.contains("\"decisionStatus\": \"pending_review\""));
assert!(json.contains("\"executionEnvironmentReference\": \"codex\""));
assert!(json.contains("\"runtimeFactSource\": \"aster-rust\""));
}
}
@@ -386,7 +386,7 @@ fn load_site_adapters_from_str(
document
.adapters
.into_iter()
.map(|entry| manifest_entry_to_spec(entry, dir, source_kind))
.map(|entry| manifest_entry_to_spec(normalize_manifest_entry(entry), dir, source_kind))
.collect()
}
@@ -429,6 +429,40 @@ fn manifest_entry_to_spec(
})
}
fn normalize_manifest_entry(mut entry: SiteAdapterManifestEntry) -> SiteAdapterManifestEntry {
if should_upgrade_legacy_github_search_entry(&entry) {
entry.entry = SiteAdapterEntryManifest::UrlTemplate {
template: "https://github.com/search?q={{query|urlencode}}&type=repositories"
.to_string(),
};
}
entry
}
fn should_upgrade_legacy_github_search_entry(entry: &SiteAdapterManifestEntry) -> bool {
if normalize_site_adapter_name(&entry.name) != "github/search" {
return false;
}
let has_query_arg = entry.args.iter().any(|arg| {
arg.name == "query" && matches!(arg.arg_type, SiteAdapterArgTypeManifest::String)
});
if !has_query_arg {
return false;
}
matches!(
&entry.entry,
SiteAdapterEntryManifest::FixedUrl { url }
if normalize_fixed_url(url) == "https://github.com/search"
)
}
fn normalize_fixed_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_ascii_lowercase()
}
fn extract_site_adapter_catalog_from_bootstrap_payload<'a>(
payload: &'a Value,
) -> Option<&'a Value> {
@@ -502,7 +536,7 @@ fn write_server_synced_catalog_to_dir(
format!("写入站点适配器脚本失败 {}: {error}", script_path.display())
})?;
adapters.push(SiteAdapterManifestEntry {
adapters.push(normalize_manifest_entry(SiteAdapterManifestEntry {
name: normalize_required_text(&entry.name, "name")?,
domain: normalize_required_text(&entry.domain, "domain")?,
description: normalize_required_text(&entry.description, "description")?,
@@ -514,7 +548,7 @@ fn write_server_synced_catalog_to_dir(
entry: entry.entry,
script_file,
source_version: normalize_optional_text(entry.source_version),
});
}));
}
let document = SiteAdapterRegistryDocument {
@@ -842,6 +876,64 @@ mod tests {
assert_eq!(adapters[0].source_version.as_deref(), Some("sync-1"));
}
#[test]
fn should_upgrade_legacy_server_synced_github_search_fixed_url_to_template() {
let temp_dir = tempdir().expect("temp dir should exist");
let dir = temp_dir.path();
fs::create_dir_all(dir.join("scripts")).expect("scripts dir should exist");
fs::write(
dir.join("index.json"),
r#"
{
"adapters": [
{
"name": "github/search",
"domain": "github.com",
"description": "server synced",
"read_only": true,
"capabilities": ["search"],
"args": [
{
"name": "query",
"description": "搜索关键词",
"required": true,
"arg_type": "string",
"example": "mcp"
}
],
"example": "github/search {\"query\":\"mcp\"}",
"entry": {
"kind": "fixed_url",
"url": "https://github.com/search"
},
"script_file": "scripts/github-search.js",
"source_version": "sync-legacy"
}
]
}
"#,
)
.expect("index should write");
fs::write(
dir.join("scripts/github-search.js"),
"async () => ({ ok: true })",
)
.expect("script should write");
let adapters = load_site_adapters_from_dir(dir, SiteAdapterSourceKind::ServerSynced)
.expect("server synced adapters should load");
let github = adapters
.iter()
.find(|adapter| adapter.name == "github/search")
.expect("github/search should exist");
let mut args = Map::new();
args.insert("query".to_string(), Value::String("mcp".to_string()));
let url = build_entry_url(github, &args).expect("entry url should build");
assert_eq!(url, "https://github.com/search?q=mcp&type=repositories");
}
#[test]
fn should_extract_site_adapter_catalog_from_nested_bootstrap_payload() {
let payload = serde_json::json!({
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "0.96.0",
"version": "0.97.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.96.0",
"version": "0.97.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -0,0 +1,382 @@
use anyhow::{anyhow, Result};
use aster::conversation::Conversation;
use aster::model::ModelConfig;
use aster::providers::api_client::{ApiClient, AuthMethod};
use aster::providers::base::Provider;
use aster::providers::openai::OpenAiProvider;
use aster::recipe::Recipe;
use aster::session::{
ChatHistoryMatch, CommitOptions, CommitReport, ExtensionData, MemoryCategory, MemoryHealth,
MemoryRecord, MemorySearchResult, MemoryStats, NoopSessionStore, Session, SessionInsights,
SessionStore, SessionType, TokenStatsUpdate, TurnOutputSchemaStrategy,
};
use async_trait::async_trait;
use chrono::Utc;
use lime_agent::{build_session_execution_runtime, SessionConfigBuilder};
use lime_lib::services::artifact_output_schema_service::merge_turn_context_with_artifact_output_schema;
use serde_json::json;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::tempdir;
use tokio::sync::RwLock;
use uuid::Uuid;
fn build_openai_provider(model_config: ModelConfig) -> Result<OpenAiProvider> {
let api_client = ApiClient::new(
"https://api.deepseek.com".to_string(),
AuthMethod::BearerToken("test-key".to_string()),
)?;
Ok(OpenAiProvider::new(api_client, model_config))
}
struct TestSessionStore {
fallback: NoopSessionStore,
sessions: RwLock<HashMap<String, Session>>,
}
impl Default for TestSessionStore {
fn default() -> Self {
Self {
fallback: NoopSessionStore,
sessions: RwLock::new(HashMap::new()),
}
}
}
impl TestSessionStore {
async fn create_user_session(&self, working_dir: PathBuf, name: &str) -> Result<Session> {
<Self as SessionStore>::create_session(
self,
working_dir,
name.to_string(),
SessionType::User,
)
.await
}
}
#[async_trait]
impl SessionStore for TestSessionStore {
async fn create_session(
&self,
working_dir: PathBuf,
name: String,
session_type: SessionType,
) -> Result<Session> {
let session = Session {
id: format!("test-session-{}", Uuid::new_v4()),
working_dir,
name,
user_set_name: false,
session_type,
created_at: Utc::now(),
updated_at: Utc::now(),
extension_data: ExtensionData::default(),
total_tokens: None,
input_tokens: None,
output_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
schedule_id: None,
recipe: None,
user_recipe_values: None,
conversation: Some(Conversation::default()),
message_count: 0,
provider_name: None,
model_config: None,
};
self.sessions
.write()
.await
.insert(session.id.clone(), session.clone());
Ok(session)
}
async fn get_session(&self, id: &str, _include_messages: bool) -> Result<Session> {
self.sessions
.read()
.await
.get(id)
.cloned()
.ok_or_else(|| anyhow!("session not found: {id}"))
}
async fn add_message(
&self,
session_id: &str,
message: &aster::conversation::message::Message,
) -> Result<()> {
self.fallback.add_message(session_id, message).await
}
async fn replace_conversation(
&self,
session_id: &str,
conversation: &Conversation,
) -> Result<()> {
self.fallback
.replace_conversation(session_id, conversation)
.await
}
async fn list_sessions(&self) -> Result<Vec<Session>> {
Ok(self.sessions.read().await.values().cloned().collect())
}
async fn list_sessions_by_types(&self, types: &[SessionType]) -> Result<Vec<Session>> {
Ok(self
.sessions
.read()
.await
.values()
.filter(|session| types.contains(&session.session_type))
.cloned()
.collect())
}
async fn delete_session(&self, id: &str) -> Result<()> {
self.sessions.write().await.remove(id);
Ok(())
}
async fn get_insights(&self) -> Result<SessionInsights> {
Ok(SessionInsights {
total_sessions: self.sessions.read().await.len(),
total_tokens: 0,
})
}
async fn export_session(&self, id: &str) -> Result<String> {
self.fallback.export_session(id).await
}
async fn import_session(&self, json: &str) -> Result<Session> {
self.fallback.import_session(json).await
}
async fn copy_session(&self, session_id: &str, new_name: String) -> Result<Session> {
self.fallback.copy_session(session_id, new_name).await
}
async fn truncate_conversation(&self, session_id: &str, timestamp: i64) -> Result<()> {
self.fallback
.truncate_conversation(session_id, timestamp)
.await
}
async fn update_session_name(
&self,
session_id: &str,
name: String,
user_set: bool,
) -> Result<()> {
self.fallback
.update_session_name(session_id, name, user_set)
.await
}
async fn update_extension_data(
&self,
session_id: &str,
extension_data: ExtensionData,
) -> Result<()> {
self.fallback
.update_extension_data(session_id, extension_data)
.await
}
async fn update_token_stats(&self, session_id: &str, stats: TokenStatsUpdate) -> Result<()> {
self.fallback.update_token_stats(session_id, stats).await
}
async fn update_provider_config(
&self,
session_id: &str,
provider_name: Option<String>,
model_config: Option<ModelConfig>,
) -> Result<()> {
let mut sessions = self.sessions.write().await;
let session = sessions
.get_mut(session_id)
.ok_or_else(|| anyhow!("session not found: {session_id}"))?;
session.provider_name = provider_name;
session.model_config = model_config;
session.updated_at = Utc::now();
Ok(())
}
async fn update_recipe(
&self,
session_id: &str,
recipe: Option<Recipe>,
user_recipe_values: Option<HashMap<String, String>>,
) -> Result<()> {
self.fallback
.update_recipe(session_id, recipe, user_recipe_values)
.await
}
async fn search_chat_history(
&self,
query: &str,
limit: Option<usize>,
after_date: Option<chrono::DateTime<chrono::Utc>>,
before_date: Option<chrono::DateTime<chrono::Utc>>,
exclude_session_id: Option<String>,
) -> Result<Vec<ChatHistoryMatch>> {
self.fallback
.search_chat_history(query, limit, after_date, before_date, exclude_session_id)
.await
}
async fn commit_session(&self, id: &str, options: CommitOptions) -> Result<CommitReport> {
self.fallback.commit_session(id, options).await
}
async fn search_memories(
&self,
query: &str,
limit: Option<usize>,
session_scope: Option<&str>,
categories: Option<Vec<MemoryCategory>>,
) -> Result<Vec<MemorySearchResult>> {
self.fallback
.search_memories(query, limit, session_scope, categories)
.await
}
async fn retrieve_context_memories(
&self,
session_id: &str,
query: &str,
limit: usize,
) -> Result<Vec<MemoryRecord>> {
self.fallback
.retrieve_context_memories(session_id, query, limit)
.await
}
async fn memory_stats(&self) -> Result<MemoryStats> {
self.fallback.memory_stats().await
}
async fn memory_health(&self) -> Result<MemoryHealth> {
self.fallback.memory_health().await
}
}
#[test]
fn deepseek_reasoner_should_not_use_openai_native_output_schema() -> Result<()> {
let deepseek_model = ModelConfig::new("deepseek-reasoner")?;
let deepseek_provider = build_openai_provider(deepseek_model.clone())?;
assert!(
!deepseek_provider.supports_native_output_schema_with_model(&deepseek_model),
"deepseek-reasoner 不应被判定为 OpenAI native output schema 模型"
);
let codex_model = ModelConfig::new("gpt-5.3-codex")?;
let codex_provider = build_openai_provider(codex_model.clone())?;
assert!(
codex_provider.supports_native_output_schema_with_model(&codex_model),
"gpt-5.3-codex 应保持 native output schema 能力"
);
Ok(())
}
#[tokio::test]
async fn artifact_runtime_should_mark_deepseek_reasoner_as_final_output_tool() -> Result<()> {
let working_dir = tempdir()?;
let store = Arc::new(TestSessionStore::default());
let session = store
.create_user_session(
working_dir.path().to_path_buf(),
"deepseek artifact runtime",
)
.await?;
let agent = aster::agents::Agent::new().with_session_store(store.clone());
let model_config = ModelConfig::new("deepseek-reasoner")?;
let provider = Arc::new(build_openai_provider(model_config.clone())?);
agent.update_provider(provider, &session.id).await?;
let request_metadata = json!({
"artifact": {
"artifact_mode": "draft",
"artifact_stage": "stage2",
"artifact_kind": "report",
"source_policy": "required"
}
});
let turn_context = merge_turn_context_with_artifact_output_schema(
Some(aster::session::TurnContextOverride {
model: Some("deepseek-reasoner".to_string()),
..aster::session::TurnContextOverride::default()
}),
Some(&request_metadata),
)
.expect("turn context");
assert!(turn_context.output_schema.is_some());
let session_config = SessionConfigBuilder::new(&session.id)
.thread_id("thread-deepseek")
.turn_id("turn-deepseek")
.turn_context(turn_context)
.build();
agent
.ensure_runtime_turn_initialized(&session_config, Some("生成 Artifact 文档".to_string()))
.await?;
let snapshot = agent.runtime_snapshot(&session.id).await?;
let turn = snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.find(|turn| turn.id == "turn-deepseek")
.expect("runtime turn");
assert_eq!(turn.status, aster::session::TurnStatus::Running);
let output_schema_runtime = turn
.output_schema_runtime
.as_ref()
.expect("output schema runtime");
assert_eq!(
output_schema_runtime.strategy,
TurnOutputSchemaStrategy::FinalOutputTool
);
assert_eq!(
output_schema_runtime.model_name.as_deref(),
Some("deepseek-reasoner")
);
let updated_session = store.get_session(&session.id, false).await?;
let execution_runtime = build_session_execution_runtime(
&session.id,
Some(&updated_session),
None,
Some(&snapshot),
Some("deepseek".to_string()),
)
.expect("execution runtime");
assert_eq!(
execution_runtime
.output_schema_runtime
.as_ref()
.map(|runtime| runtime.strategy),
Some(TurnOutputSchemaStrategy::FinalOutputTool)
);
assert_eq!(
execution_runtime.model_name.as_deref(),
Some("deepseek-reasoner")
);
assert_eq!(
execution_runtime.latest_turn_status.as_deref(),
Some("running")
);
Ok(())
}
+18 -1
View File
@@ -35,6 +35,7 @@ import { ComponentDebugOverlay } from "./components/dev";
import {
AgentPageParams,
AutomationPageParams,
BrowserRuntimePageParams,
getThemeByWorkspacePage,
getThemeWorkspacePage,
isThemeWorkspacePage,
@@ -705,9 +706,25 @@ function AppContent() {
}
if (currentPage === "browser-runtime") {
const browserRuntimeParams = pageParams as BrowserRuntimePageParams;
return (
<PageWrapper $isActive={true}>
<BrowserRuntimeWorkspace active={true} onNavigate={handleNavigate} />
<BrowserRuntimeWorkspace
active={true}
onNavigate={handleNavigate}
initialProfileKey={browserRuntimeParams.initialProfileKey}
initialSessionId={browserRuntimeParams.initialSessionId}
initialTargetId={browserRuntimeParams.initialTargetId}
currentProjectId={browserRuntimeParams.projectId}
currentContentId={browserRuntimeParams.contentId}
initialAdapterName={browserRuntimeParams.initialAdapterName}
initialArgs={browserRuntimeParams.initialArgs}
initialAutoRun={browserRuntimeParams.initialAutoRun}
initialRequireAttachedSession={
browserRuntimeParams.initialRequireAttachedSession
}
initialSaveTitle={browserRuntimeParams.initialSaveTitle}
/>
</PageWrapper>
);
}
@@ -14,6 +14,7 @@ const {
mockHomeShellExecutionStrategy,
mockHomeShellModel,
mockHomeShellProviderType,
mockHomeShellRecentExecutionRuntime,
mockListProjects,
mockSetExecutionStrategy,
mockSetModel,
@@ -148,9 +149,8 @@ const {
isRecent: false,
runnerLabel: "本地计划任务",
runnerTone: "sky",
runnerDescription:
"当前先进入工作区生成首版任务方案,后续再接本地自动化。",
actionLabel: "先做方案",
runnerDescription: "可直接创建本地定时任务,并回流到任务中心与工作区。",
actionLabel: "创建任务",
automationStatus: {
jobId: "automation-job-daily-trend",
jobName: "每日趋势摘要",
@@ -159,6 +159,56 @@ const {
detail: "下次 03/24 09:00",
},
},
{
id: "github-repo-radar",
title: "GitHub 仓库线索检索",
summary:
"复用你当前浏览器里的 GitHub 登录态,直接检索主题仓库并沉淀成结构化线索。",
category: "情报研究",
outputHint: "仓库列表 + 关键线索",
source: "cloud_catalog",
runnerType: "instant",
defaultExecutorBinding: "browser_assist",
executionLocation: "client_default",
defaultArtifactKind: "analysis",
themeTarget: "knowledge",
version: "seed-v1",
readinessRequirements: {
requiresBrowser: true,
requiresProject: true,
},
siteCapabilityBinding: {
adapterName: "github/search",
autoRun: true,
requireAttachedSession: true,
saveMode: "current_content",
slotArgMap: {
repository_query: "query",
},
fixedArgs: {
limit: 10,
},
suggestedTitleTemplate: "GitHub 仓库线索 · {{repository_query}}",
},
slotSchema: [
{
key: "repository_query",
label: "检索主题",
type: "text",
required: true,
placeholder: "例如 MCP agent browser automation",
},
],
badge: "云目录",
recentUsedAt: null,
isRecent: false,
runnerLabel: "浏览器站点执行",
runnerTone: "emerald",
runnerDescription:
"直接进入浏览器工作台,复用真实登录态执行站点脚本并沉淀结果。",
actionLabel: "启动采集",
automationStatus: null,
},
];
const mockRecordClawSolutionUsage = vi.fn();
@@ -202,6 +252,7 @@ const {
mockHomeShellProviderType: { current: "mock-provider" },
mockHomeShellModel: { current: "mock-model" },
mockHomeShellExecutionStrategy: { current: "react" },
mockHomeShellRecentExecutionRuntime: { current: null as unknown },
mockListProjects: vi.fn(async () => [
{
id: "project-1",
@@ -276,6 +327,8 @@ vi.mock("./components/EmptyState", () => ({
onSend,
onRecommendationClick,
supportingSlotOverride,
serviceSkills,
onSelectServiceSkill,
}: {
onSend: (
value: string,
@@ -284,6 +337,8 @@ vi.mock("./components/EmptyState", () => ({
) => void;
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
supportingSlotOverride?: React.ReactNode;
serviceSkills?: Array<{ id: string; title: string }>;
onSelectServiceSkill?: (skill: { id: string; title: string }) => void;
}) => (
<>
<button
@@ -305,6 +360,15 @@ vi.mock("./components/EmptyState", () => ({
>
Team 推荐
</button>
{serviceSkills?.[0] && onSelectServiceSkill ? (
<button
type="button"
data-testid="home-shell-empty-state-service-skill"
onClick={() => onSelectServiceSkill(serviceSkills[0]!)}
>
通过 @ 选择服务技能
</button>
) : null}
{supportingSlotOverride}
</>
),
@@ -353,6 +417,7 @@ vi.mock("./hooks/useHomeShellAgentPreferences", () => ({
setModel: mockSetModel,
executionStrategy: mockHomeShellExecutionStrategy.current,
setExecutionStrategy: mockSetExecutionStrategy,
recentExecutionRuntime: mockHomeShellRecentExecutionRuntime.current,
})),
}));
@@ -534,9 +599,13 @@ vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({
industry_keywords: "AI Agent,创作者工具",
schedule_time: "每天 09:00",
}
: {
reference_video: "https://example.com/video",
},
: skill.id === "github-repo-radar"
? {
repository_query: "browser assist mcp",
}
: {
reference_video: "https://example.com/video",
},
)
}
>
@@ -677,6 +746,7 @@ beforeEach(() => {
mockHomeShellProviderType.current = "mock-provider";
mockHomeShellModel.current = "mock-model";
mockHomeShellExecutionStrategy.current = "react";
mockHomeShellRecentExecutionRuntime.current = null;
mockUseClawSolutions.mockImplementation(() => ({
solutions: mockClawSolutions,
isLoading: false,
@@ -879,6 +949,53 @@ describe("AgentChatHomeShell", () => {
);
});
it("最近 session runtime 的工具偏好应先回灌首页壳,再参与 team 推荐", async () => {
mockHomeShellRecentExecutionRuntime.current = {
recent_preferences: {
webSearch: true,
thinking: true,
task: false,
subagent: false,
},
recent_team_selection: null,
};
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: true,
thinking: true,
task: false,
subagent: true,
}),
"general",
);
expect(onEnterWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
theme: "general",
}),
);
});
it("点击社媒方案时应切换到 social-media 工作区", async () => {
const onNavigate = vi.fn();
mockLoadConfiguredProviders.mockResolvedValueOnce([
@@ -1182,6 +1299,7 @@ describe("AgentChatHomeShell", () => {
contentId: "content-service-skill-1",
theme: "video",
initialCreationMode: "guided",
autoRunInitialPromptOnMount: true,
initialRequestMetadata: {
artifact: {
artifact_mode: "draft",
@@ -1206,8 +1324,99 @@ describe("AgentChatHomeShell", () => {
});
});
it("cloud_required 服务型技能应提交云端运行且不进入本地工作区", async () => {
it("通过首页输入区 @ 选择服务型技能时应打开补参弹窗", async () => {
const { container } = renderShell();
await flushEffects();
const mentionServiceSkillButton = container.querySelector(
'[data-testid="home-shell-empty-state-service-skill"]',
) as HTMLButtonElement | null;
expect(mentionServiceSkillButton).toBeTruthy();
act(() => {
mentionServiceSkillButton?.click();
});
await flushEffects();
const launchButton = container.querySelector(
'[data-testid="home-shell-service-skill-launch"]',
) as HTMLButtonElement | null;
expect(launchButton).toBeTruthy();
});
it("站点型服务技能应直接导航到浏览器工作台并预填自动执行参数", async () => {
const onNavigate = vi.fn();
const onEnterWorkspace = vi.fn();
const { container } = renderShell({
onNavigate,
onEnterWorkspace,
});
await flushEffects();
const serviceSkillButton = container.querySelector(
'[data-testid="home-shell-service-skill-github-repo-radar"]',
) as HTMLButtonElement | null;
expect(serviceSkillButton).toBeTruthy();
act(() => {
serviceSkillButton?.click();
});
await flushEffects();
const launchButton = container.querySelector(
'[data-testid="home-shell-service-skill-launch"]',
) as HTMLButtonElement | null;
expect(launchButton).toBeTruthy();
act(() => {
launchButton?.click();
});
await flushEffects();
expect(mockCreateContent).toHaveBeenCalledWith(
expect.objectContaining({
project_id: "project-1",
title: "GitHub 仓库线索检索",
content_type: "document",
}),
);
expect(onNavigate).toHaveBeenCalledWith("browser-runtime", {
projectId: "project-1",
contentId: "content-service-skill-1",
initialAdapterName: "github/search",
initialArgs: {
query: "browser assist mcp",
limit: 10,
},
initialAutoRun: true,
initialRequireAttachedSession: true,
initialSaveTitle: undefined,
});
expect(onEnterWorkspace).not.toHaveBeenCalled();
expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "github-repo-radar",
runnerType: "instant",
});
});
it("cloud_required 服务型技能成功后应回流本地工作区", async () => {
const onEnterWorkspace = vi.fn();
mockCreateServiceSkillRun.mockResolvedValue({
id: "service-skill-run-cloud-1",
status: "success",
outputSummary: "云端结果已生成",
outputText: "# 云端视频配音\n\n第一版成稿",
finishedAt: "2026-03-26T01:02:03.000Z",
});
mockUseServiceSkills.mockImplementation(() => ({
skills: [
{
@@ -1268,7 +1477,43 @@ describe("AgentChatHomeShell", () => {
"cloud-video-dubbing",
expect.stringContaining("- 参考视频链接/素材: https://example.com/video"),
);
expect(onEnterWorkspace).not.toHaveBeenCalled();
expect(mockCreateContent).toHaveBeenCalledWith(
expect.objectContaining({
project_id: "project-1",
title: "云端视频配音",
content_type: "episode",
body: "# 云端视频配音\n\n第一版成稿",
metadata: expect.objectContaining({
source: "service_skill",
serviceSkill: expect.objectContaining({
id: "cloud-video-dubbing",
executionLocation: "cloud_required",
themeTarget: "video",
}),
cloudRun: expect.objectContaining({
id: "service-skill-run-cloud-1",
status: "success",
outputSummary: "云端结果已生成",
finishedAt: "2026-03-26T01:02:03.000Z",
}),
}),
}),
);
expect(onEnterWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
contentId: "content-service-skill-1",
theme: "video",
initialCreationMode: "guided",
initialRequestMetadata: {
artifact: {
artifact_mode: "draft",
artifact_kind: "brief",
workbench_surface: "right_panel",
},
},
}),
);
expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "cloud-video-dubbing",
runnerType: "instant",
@@ -1277,7 +1522,7 @@ describe("AgentChatHomeShell", () => {
"正在提交 云端视频配音 到云端...",
);
expect(mockToastSuccess).toHaveBeenCalledWith(
"云端视频配音 云端运行完成:云端结果已生成",
"云端视频配音 云端运行完成:云端结果已生成,正在回流本地工作区。",
{
id: "toast-loading",
},
@@ -1354,6 +1599,34 @@ describe("AgentChatHomeShell", () => {
artifact_mode: "draft",
artifact_kind: "analysis",
}),
service_skill: expect.objectContaining({
id: "daily-trend-briefing",
title: "每日趋势摘要",
runner_type: "scheduled",
slot_values: [
{
key: "platform",
label: "监测平台",
value: "X / Twitter",
},
{
key: "industry_keywords",
label: "行业关键词",
value: "AI Agent,创作者工具",
},
{
key: "schedule_time",
label: "推送时间",
value: "每天 09:00",
},
],
slot_summary: [
"监测平台: X / Twitter",
"行业关键词: AI Agent,创作者工具",
"推送时间: 每天 09:00",
],
user_input: null,
}),
harness: expect.objectContaining({
theme: "social-media",
session_mode: "theme_workbench",
@@ -1381,6 +1654,7 @@ describe("AgentChatHomeShell", () => {
contentId: "content-service-skill-1",
theme: "social-media",
initialCreationMode: "guided",
autoRunInitialPromptOnMount: true,
initialRequestMetadata: {
artifact: {
artifact_mode: "draft",
+245 -14
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import { toast } from "sonner";
import { createAutomationJob } from "@/lib/api/automation";
@@ -15,13 +15,14 @@ import {
type AutomationJobDialogInitialValues,
type AutomationJobDialogSubmit,
} from "@/components/settings-v2/system/automation/AutomationJobDialog";
import type { Page, PageParams } from "@/types/page";
import type { BrowserRuntimePageParams, Page, PageParams } from "@/types/page";
import { SettingsTabs } from "@/types/settings";
import { EmptyState } from "./components/EmptyState";
import type { CreationMode } from "./components/types";
import { saveChatToolPreferences } from "./utils/chatToolPreferences";
import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations";
import { resolveClawWorkspaceProviderSelection } from "./utils/clawWorkspaceProviderSelection";
import { createChatToolPreferencesFromExecutionRuntime } from "./utils/sessionExecutionRuntime";
import { normalizeProjectId } from "./utils/topicProjectResolution";
import {
LAST_PROJECT_ID_KEY,
@@ -56,11 +57,17 @@ import {
supportsServiceSkillLocalAutomation,
} from "./service-skills/automationDraft";
import { recordServiceSkillAutomationLink } from "./service-skills/automationLinkStorage";
import { recordServiceSkillCloudRun } from "./service-skills/cloudRunStorage";
import type {
ServiceSkillHomeItem,
ServiceSkillSlotValues,
} from "./service-skills/types";
import { buildServiceSkillWorkspaceSeed } from "./service-skills/workspaceLaunch";
import {
buildServiceSkillSiteCapabilityArgs,
buildServiceSkillSiteCapabilitySaveTitle,
isServiceSkillSiteCapabilityBound,
} from "./service-skills/siteCapabilityBinding";
const PageContainer = styled.div<{ $compact?: boolean }>`
display: flex;
@@ -168,6 +175,44 @@ function getErrorMessage(error: unknown): string {
return "请稍后重试";
}
function normalizeOptionalText(value?: string | null): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim();
return normalized ? normalized : undefined;
}
function buildServiceSkillCloudResultBody(
skill: ServiceSkillHomeItem,
run: ServiceSkillRun,
): string {
return (
normalizeOptionalText(run.outputText) ||
normalizeOptionalText(run.outputSummary) ||
`# ${skill.title}\n\n云端结果已生成。`
);
}
function buildServiceSkillCloudResultMetadata(
run: ServiceSkillRun,
): Record<string, unknown> {
return {
cloudRun: {
id: run.id,
status: run.status,
executorKind: run.executorKind ?? null,
outputSummary: normalizeOptionalText(run.outputSummary) ?? null,
errorCode: run.errorCode ?? null,
errorMessage: run.errorMessage ?? null,
startedAt: run.startedAt ?? null,
finishedAt: run.finishedAt ?? null,
updatedAt: run.updatedAt ?? null,
},
};
}
function resolveFallbackProjectType(theme?: string): Project["workspaceType"] {
switch (theme) {
case "social-media":
@@ -250,15 +295,17 @@ function buildServiceSkillRunSuccessMessage(
): string {
const summary = run.outputSummary || run.outputText || run.inputSummary;
if (summary) {
return `${skill.title} 云端运行完成:${summary}`;
return `${skill.title} 云端运行完成:${summary},正在回流本地工作区。`;
}
return `${skill.title} 云端运行完成。`;
return `${skill.title} 云端运行完成,正在回流本地工作区。`;
}
interface PendingServiceSkillAutomationLaunch {
skill: ServiceSkillHomeItem;
prompt: string;
slotValues: ServiceSkillSlotValues;
userInput?: string;
usage: {
skillId: string;
runnerType: ServiceSkillHomeItem["runnerType"];
@@ -290,13 +337,18 @@ export function AgentChatHomeShell({
const [creationMode, setCreationMode] = useState<CreationMode>(
initialCreationMode ?? "guided",
);
const { chatToolPreferences, setChatToolPreferences } =
useThemeScopedChatToolPreferences(activeTheme);
const {
projectId: currentProjectId,
setProjectId: setCurrentProjectId,
rememberProjectId,
} = usePersistedProjectId(externalProjectId, LAST_PROJECT_ID_KEY);
const {
chatToolPreferences,
setChatToolPreferences,
syncChatToolPreferencesSource,
} = useThemeScopedChatToolPreferences(activeTheme, {
scopeId: currentProjectId,
});
const {
providerType,
setProviderType,
@@ -304,6 +356,7 @@ export function AgentChatHomeShell({
setModel,
executionStrategy,
setExecutionStrategy,
recentExecutionRuntime,
} = useHomeShellAgentPreferences(currentProjectId);
const projectMemory = useHomeShellProjectMemory(currentProjectId);
const { skills, skillsLoading, refreshSkills } = useHomeShellSkills();
@@ -312,7 +365,13 @@ export function AgentChatHomeShell({
selectedTeam,
setSelectedTeam: handleSelectTeam,
enableSuggestedTeam: handleEnableSuggestedTeam,
} = useSelectedTeamPreference(activeTheme);
} = useSelectedTeamPreference(activeTheme, {
runtimeSelection: recentExecutionRuntime?.recent_team_selection ?? null,
});
const runtimeChatToolPreferences = useMemo(
() => createChatToolPreferencesFromExecutionRuntime(recentExecutionRuntime),
[recentExecutionRuntime],
);
const {
solutions: clawSolutions,
isLoading: clawSolutionsLoading,
@@ -366,6 +425,10 @@ export function AgentChatHomeShell({
toast.error(`加载服务型技能失败:${serviceSkillsError}`);
}, [activeTheme, serviceSkillsError]);
useEffect(() => {
syncChatToolPreferencesSource(activeTheme, runtimeChatToolPreferences);
}, [activeTheme, runtimeChatToolPreferences, syncChatToolPreferencesSource]);
const handleRefreshSkills = useCallback(async () => {
await refreshSkills(true);
}, [refreshSkills]);
@@ -542,7 +605,14 @@ export function AgentChatHomeShell({
);
const createServiceSkillSeededContent = useCallback(
async (skill: ServiceSkillHomeItem, projectId?: string | null) => {
async (
skill: ServiceSkillHomeItem,
projectId?: string | null,
options?: {
body?: string;
metadata?: Record<string, unknown>;
},
) => {
const normalizedProjectId = normalizeProjectId(
projectId ?? currentProjectId,
);
@@ -555,17 +625,60 @@ export function AgentChatHomeShell({
return null;
}
const mergedMetadata = {
...(seed.metadata ?? {}),
...(options?.metadata ?? {}),
};
return createContent({
project_id: normalizedProjectId,
title: seed.title,
content_type: seed.contentType,
body: "",
metadata: seed.metadata,
body: options?.body ?? "",
metadata:
Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined,
});
},
[activeTheme, currentProjectId],
);
const prepareServiceSkillCloudResultWorkspacePayload = useCallback(
async (
skill: ServiceSkillHomeItem,
run: ServiceSkillRun,
): Promise<HomeShellEnterWorkspacePayload | null> => {
const normalizedProjectId = normalizeProjectId(currentProjectId);
const seed = buildServiceSkillWorkspaceSeed(
skill,
skill.themeTarget ?? activeTheme,
);
if (!normalizedProjectId || !seed) {
return null;
}
const created = await createServiceSkillSeededContent(
skill,
normalizedProjectId,
{
body: buildServiceSkillCloudResultBody(skill, run),
metadata: buildServiceSkillCloudResultMetadata(run),
},
);
if (!created) {
return null;
}
return {
contentId: created.id,
themeOverride: skill.themeTarget,
initialRequestMetadata: seed.requestMetadata,
};
},
[activeTheme, createServiceSkillSeededContent, currentProjectId],
);
const prepareServiceSkillWorkspacePayload = useCallback(
async (
skill: ServiceSkillHomeItem,
@@ -590,6 +703,7 @@ export function AgentChatHomeShell({
contentId: existingContentId,
themeOverride: skill.themeTarget,
initialRequestMetadata: seed?.requestMetadata,
autoRunInitialPromptOnMount: true,
};
}
@@ -598,6 +712,7 @@ export function AgentChatHomeShell({
prompt,
themeOverride: skill.themeTarget,
initialRequestMetadata: seed?.requestMetadata,
autoRunInitialPromptOnMount: true,
};
}
@@ -611,6 +726,7 @@ export function AgentChatHomeShell({
prompt,
themeOverride: skill.themeTarget,
initialRequestMetadata: seed.requestMetadata,
autoRunInitialPromptOnMount: true,
};
}
@@ -619,13 +735,93 @@ export function AgentChatHomeShell({
contentId: created.id,
themeOverride: skill.themeTarget,
initialRequestMetadata: seed.requestMetadata,
autoRunInitialPromptOnMount: true,
};
},
[activeTheme, createServiceSkillSeededContent, currentProjectId],
);
const handleServiceSkillBrowserRuntimeLaunch = useCallback(
async (
skill: ServiceSkillHomeItem,
slotValues: ServiceSkillSlotValues,
): Promise<void> => {
if (!isServiceSkillSiteCapabilityBound(skill)) {
return;
}
if (!onNavigate) {
toast.error("当前入口暂不支持打开浏览器工作台,请从桌面主界面重试。");
return;
}
const normalizedProjectId = normalizeProjectId(currentProjectId);
if (
skill.readinessRequirements?.requiresProject &&
!normalizedProjectId
) {
toast.error("缺少项目工作区,请先选择项目后再启动浏览器采集。");
return;
}
const binding = skill.siteCapabilityBinding;
const saveMode = binding.saveMode ?? "project_resource";
const initialArgs = buildServiceSkillSiteCapabilityArgs(
skill,
slotValues,
);
const initialSaveTitle = buildServiceSkillSiteCapabilitySaveTitle(
skill,
slotValues,
);
let contentId: string | undefined;
if (saveMode === "current_content" && normalizedProjectId) {
try {
const created = await createServiceSkillSeededContent(
skill,
normalizedProjectId,
);
contentId = created?.id ?? undefined;
} catch (error) {
toast.error(`准备浏览器采集主稿失败:${getErrorMessage(error)}`);
return;
}
}
const navigationParams: BrowserRuntimePageParams = {
projectId: normalizedProjectId ?? undefined,
contentId,
initialAdapterName: binding.adapterName,
initialArgs,
initialAutoRun: binding.autoRun ?? false,
initialRequireAttachedSession: binding.requireAttachedSession ?? false,
initialSaveTitle: contentId ? undefined : initialSaveTitle,
};
onNavigate("browser-runtime", navigationParams);
recordServiceSkillUsage({
skillId: skill.id,
runnerType: skill.runnerType,
});
setServiceSkillDialogOpen(false);
setSelectedServiceSkill(null);
},
[
createServiceSkillSeededContent,
currentProjectId,
onNavigate,
recordServiceSkillUsage,
],
);
const handleServiceSkillLaunch = useCallback(
async (skill: ServiceSkillHomeItem, slotValues: ServiceSkillSlotValues) => {
if (isServiceSkillSiteCapabilityBound(skill)) {
await handleServiceSkillBrowserRuntimeLaunch(skill, slotValues);
return;
}
const prompt = composeServiceSkillPrompt({
skill,
slotValues,
@@ -640,6 +836,7 @@ export function AgentChatHomeShell({
setSelectedServiceSkill(null);
let run = await createServiceSkillRun(skill.id, prompt);
recordServiceSkillCloudRun(skill.id, run);
recordServiceSkillUsage({
skillId: skill.id,
runnerType: skill.runnerType,
@@ -656,6 +853,7 @@ export function AgentChatHomeShell({
for (let attempt = 0; attempt < 12; attempt += 1) {
await sleep(2_000);
run = await getServiceSkillRun(run.id);
recordServiceSkillCloudRun(skill.id, run);
if (isTerminalServiceSkillRunStatus(run.status)) {
break;
}
@@ -663,9 +861,35 @@ export function AgentChatHomeShell({
}
if (run.status === "success") {
let workspacePayload: HomeShellEnterWorkspacePayload | null = null;
let workspaceErrorMessage: string | null = null;
try {
workspacePayload =
await prepareServiceSkillCloudResultWorkspacePayload(
skill,
run,
);
} catch (error) {
workspaceErrorMessage = getErrorMessage(error);
}
toast.success(buildServiceSkillRunSuccessMessage(skill, run), {
id: toastId,
});
if (workspacePayload) {
const entered = handleEnterWorkspace(workspacePayload);
if (!entered) {
toast.error(
"云端结果已生成,但进入工作区失败,请稍后手动打开。",
);
}
} else if (workspaceErrorMessage) {
toast.error(
`云端结果已生成,但回流本地工作区失败:${workspaceErrorMessage}`,
);
}
return;
}
@@ -691,9 +915,7 @@ export function AgentChatHomeShell({
}
if (skill.runnerType !== "instant") {
toast.info(
"当前先进入工作区生成首版方案,下一阶段再接本地自动化任务。",
);
toast.info("当前先进入工作区生成首版结果;如需持续运行,可继续创建本地任务。");
}
let workspacePayload: HomeShellEnterWorkspacePayload;
@@ -721,8 +943,10 @@ export function AgentChatHomeShell({
setSelectedServiceSkill(null);
},
[
handleServiceSkillBrowserRuntimeLaunch,
handleEnterWorkspace,
input,
prepareServiceSkillCloudResultWorkspacePayload,
prepareServiceSkillWorkspacePayload,
recordServiceSkillUsage,
],
@@ -746,6 +970,7 @@ export function AgentChatHomeShell({
slotValues,
userInput: input.trim() || undefined,
});
const userInput = input.trim() || undefined;
try {
let workspaces: Project[];
@@ -769,13 +994,15 @@ export function AgentChatHomeShell({
buildServiceSkillAutomationInitialValues({
skill,
slotValues,
userInput: input.trim() || undefined,
userInput,
workspaceId: normalizedProjectId,
}),
);
setPendingServiceSkillAutomation({
skill,
prompt,
slotValues,
userInput,
usage: {
skillId: skill.id,
runnerType: skill.runnerType,
@@ -823,6 +1050,8 @@ export function AgentChatHomeShell({
...request.payload,
...buildServiceSkillAutomationAgentTurnPayloadContext({
skill: pendingLaunch.skill,
slotValues: pendingLaunch.slotValues,
userInput: pendingLaunch.userInput,
contentId: automationContentId,
}),
},
@@ -1009,7 +1238,9 @@ export function AgentChatHomeShell({
}
characters={projectMemory?.characters || []}
skills={skills}
serviceSkills={activeTheme === "general" ? serviceSkills : []}
isSkillsLoading={skillsLoading}
onSelectServiceSkill={handleServiceSkillSelect}
onNavigateToSettings={() => {
onNavigate?.("settings", {
tab: SettingsTabs.Skills,
+179 -18
View File
@@ -58,6 +58,7 @@ import {
ensureWorkspaceReady,
type Project,
} from "@/lib/api/project";
import { updateAgentRuntimeSession } from "@/lib/api/agentRuntime";
import {
getProjectMemory,
type ProjectMemory,
@@ -79,11 +80,16 @@ import type { ThemeType, LayoutMode } from "@/components/content-creator/types";
import { normalizeProjectId } from "./utils/topicProjectResolution";
import { buildHarnessRequestMetadata } from "./utils/harnessRequestMetadata";
import { deriveHarnessSessionState } from "./utils/harnessState";
import { loadChatToolPreferences } from "./utils/chatToolPreferences";
import {
mergeArtifacts,
resolveDefaultArtifactViewMode,
} from "./utils/messageArtifacts";
import { createChatToolPreferencesFromExecutionRuntime } from "./utils/sessionExecutionRuntime";
import {
createChatToolPreferencesFromExecutionRuntime,
createSessionRecentPreferencesFromChatToolPreferences,
createSessionRecentTeamSelectionFromTeamDefinition,
} from "./utils/sessionExecutionRuntime";
import {
buildRealSubagentTimelineItems,
buildSyntheticSubagentTimelineItems,
@@ -93,9 +99,11 @@ import {
resolveAgentChatMode,
} from "./utils/generalAgentPrompt";
import { loadPersistedProjectId } from "./hooks/agentProjectStorage";
import { loadPersistedSessionWorkspaceId } from "./hooks/agentProjectStorage";
import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference";
import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToolPreferences";
import { useLimeSkills } from "./hooks/useLimeSkills";
import { useServiceSkills } from "./service-skills/useServiceSkills";
import { useWorkspaceProjectSelection } from "./hooks/useWorkspaceProjectSelection";
import { useBootstrapDispatchPreview } from "./hooks/useBootstrapDispatchPreview";
import { useRuntimeTeamFormation } from "./hooks/useRuntimeTeamFormation";
@@ -134,6 +142,7 @@ import { useWorkspaceWorkflowProgressSync } from "./workspace/useWorkspaceWorkfl
import { useWorkspaceCanvasLayoutRuntime } from "./workspace/useWorkspaceCanvasLayoutRuntime";
import { useWorkspaceCanvasTaskFileSync } from "./workspace/useWorkspaceCanvasTaskFileSync";
import { useWorkspaceGeneralResourceSync } from "./workspace/useWorkspaceGeneralResourceSync";
import { useWorkspaceArtifactWorkbenchActions } from "./workspace/useWorkspaceArtifactWorkbenchActions";
import { useWorkspaceImageWorkbenchActionRuntime } from "./workspace/useWorkspaceImageWorkbenchActionRuntime";
import { useWorkspaceImageWorkbenchEventRuntime } from "./workspace/useWorkspaceImageWorkbenchEventRuntime";
import { useWorkspaceRuntimeTeamDispatchPreviewRuntime } from "./workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime";
@@ -153,6 +162,7 @@ import { useWorkspaceThemeWorkbenchShellRuntime } from "./workspace/useWorkspace
import { useWorkspaceContextDetailActions } from "./workspace/useWorkspaceContextDetailActions";
import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime";
import { useWorkspaceThemeWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceThemeWorkbenchDocumentPersistenceRuntime";
import { useWorkspaceServiceSkillEntryActions } from "./workspace/useWorkspaceServiceSkillEntryActions";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
import type { ArtifactDocumentV1 } from "@/lib/artifact-document";
import type { ArtifactTimelineOpenTarget } from "./utils/artifactTimelineNavigation";
@@ -174,10 +184,11 @@ import {
projectTypeToTheme,
} from "./agentChatWorkspaceShared";
import type { AgentChatWorkspaceProps } from "./agentChatWorkspaceContract";
import { ServiceSkillLaunchDialog } from "./service-skills/ServiceSkillLaunchDialog";
import { AutomationJobDialog } from "@/components/settings-v2/system/automation/AutomationJobDialog";
const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist";
const TOPIC_PROJECT_KEY_PREFIX = "agent_session_workspace_";
export type {
AgentChatWorkspaceProps,
WorkflowProgressSnapshot,
@@ -188,6 +199,7 @@ export function AgentChatWorkspace({
projectId: externalProjectId,
contentId,
initialRequestMetadata,
autoRunInitialPromptOnMount = false,
agentEntry = "claw",
theme: initialTheme,
initialCreationMode,
@@ -233,11 +245,50 @@ export function AgentChatWorkspace({
const [creationMode, setCreationMode] = useState<CreationMode>(
initialCreationMode ?? "guided",
);
const activeSessionIdRef = useRef<string | null>(null);
const sessionRecentPreferencesBackfillKeyRef = useRef<string | null>(null);
const syncSessionRecentPreferences = useCallback(
async (
sessionId: string,
preferences: Parameters<
typeof createSessionRecentPreferencesFromChatToolPreferences
>[0],
) => {
await updateAgentRuntimeSession({
session_id: sessionId,
recent_preferences:
createSessionRecentPreferencesFromChatToolPreferences(preferences),
});
},
[],
);
const syncSessionRecentTeamSelection = useCallback(
async (
sessionId: string,
team: Parameters<
typeof createSessionRecentTeamSelectionFromTeamDefinition
>[0],
theme?: string | null,
) => {
await updateAgentRuntimeSession({
session_id: sessionId,
recent_team_selection:
createSessionRecentTeamSelectionFromTeamDefinition(team, theme),
});
},
[],
);
const {
chatToolPreferences,
setChatToolPreferences,
syncChatToolPreferencesSource,
} = useThemeScopedChatToolPreferences(activeTheme);
getSyncedSessionRecentPreferences,
} = useThemeScopedChatToolPreferences(activeTheme, {
sessionSync: {
getSessionId: () => activeSessionIdRef.current,
setSessionRecentPreferences: syncSessionRecentPreferences,
},
});
const {
projectId,
shouldDisableSessionRestore,
@@ -338,14 +389,6 @@ export function AgentChatWorkspace({
} | null>(null);
const [novelChapterListCollapsed, setNovelChapterListCollapsed] =
useState(false);
const {
selectedTeam,
setSelectedTeam: handleSelectTeam,
enableSuggestedTeam: handleEnableSuggestedTeam,
preferredTeamPresetId,
selectedTeamLabel,
selectedTeamSummary,
} = useSelectedTeamPreference(activeTheme);
useEffect(() => {
setActiveContentTarget(projectId, contentId, canvasState?.type ?? null);
@@ -506,6 +549,21 @@ export function AgentChatWorkspace({
console.warn("[AgentChatPage] 加载 skills 失败:", error);
},
});
const {
skills: serviceSkills,
isLoading: serviceSkillsLoading,
error: serviceSkillsError,
recordUsage: recordServiceSkillUsage,
} = useServiceSkills(activeTheme === "general");
useEffect(() => {
if (activeTheme !== "general" || !serviceSkillsError) {
return;
}
toast.error(`加载服务型技能失败:${serviceSkillsError}`);
}, [activeTheme, serviceSkillsError]);
const combinedSkillsLoading = skillsLoading || serviceSkillsLoading;
// Workbench Store(用于主题工作台右侧面板状态同步)
const pendingSkillKey = useWorkbenchStore((state) => state.pendingSkillKey);
@@ -1080,6 +1138,22 @@ export function AgentChatWorkspace({
},
workspaceId: projectId ?? "",
disableSessionRestore: shouldDisableSessionRestore,
getSyncedSessionRecentPreferences,
});
activeSessionIdRef.current = sessionId;
const {
selectedTeam,
setSelectedTeam: handleSelectTeam,
enableSuggestedTeam: handleEnableSuggestedTeam,
preferredTeamPresetId,
selectedTeamLabel,
selectedTeamSummary,
} = useSelectedTeamPreference(activeTheme, {
runtimeSelection: executionRuntime?.recent_team_selection ?? null,
sessionSync: {
getSessionId: () => activeSessionIdRef.current,
setSessionRecentTeamSelection: syncSessionRecentTeamSelection,
},
});
const handleOpenSubagentSession = useCallback(
(subagentSessionId: string) => {
@@ -1103,6 +1177,37 @@ export function AgentChatWorkspace({
syncChatToolPreferencesSource(activeTheme, runtimeChatToolPreferences);
}, [activeTheme, runtimeChatToolPreferences, syncChatToolPreferencesSource]);
useEffect(() => {
const trimmedSessionId = sessionId?.trim();
if (!trimmedSessionId || runtimeChatToolPreferences) {
return;
}
const fallbackPreferences = loadChatToolPreferences(activeTheme);
const backfillKey = `${trimmedSessionId}:${JSON.stringify([
fallbackPreferences.webSearch,
fallbackPreferences.thinking,
fallbackPreferences.task,
fallbackPreferences.subagent,
])}`;
if (sessionRecentPreferencesBackfillKeyRef.current === backfillKey) {
return;
}
sessionRecentPreferencesBackfillKeyRef.current = backfillKey;
void syncSessionRecentPreferences(
trimmedSessionId,
fallbackPreferences,
).catch((error) => {
console.warn("[AgentChatPage] 回填会话 recent_preferences 失败:", error);
});
}, [
activeTheme,
runtimeChatToolPreferences,
sessionId,
syncSessionRecentPreferences,
]);
const hasRealTeamGraph =
childSubagentSessions.length > 0 || Boolean(subagentParentContext);
const {
@@ -1204,7 +1309,7 @@ export function AgentChatWorkspace({
projectId: projectId ?? null,
sessionId: sessionId ?? null,
skillsCount: skills.length,
skillsLoading,
skillsLoading: combinedSkillsLoading,
topicsCount: topics.length,
workspaceHealthError,
},
@@ -1220,7 +1325,7 @@ export function AgentChatWorkspace({
projectId: projectId ?? null,
sessionId: sessionId ?? null,
skillsCount: skills.length,
skillsLoading,
skillsLoading: combinedSkillsLoading,
topicsCount: topics.length,
workspaceHealthError,
}),
@@ -1238,7 +1343,7 @@ export function AgentChatWorkspace({
projectId,
sessionId,
skills.length,
skillsLoading,
combinedSkillsLoading,
topics.length,
workspaceHealthError,
]);
@@ -1506,6 +1611,7 @@ export function AgentChatWorkspace({
selectedTeamId: selectedTeam?.id,
selectedTeamSource: selectedTeam?.source,
selectedTeamLabel,
selectedTeamDescription: selectedTeam?.description,
selectedTeamSummary,
selectedTeamRoles: selectedTeam?.roles,
}),
@@ -1520,6 +1626,7 @@ export function AgentChatWorkspace({
mappedTheme,
preferredTeamPresetId,
selectedTeam?.id,
selectedTeam?.description,
selectedTeam?.roles,
selectedTeam?.source,
selectedTeamLabel,
@@ -1621,6 +1728,7 @@ export function AgentChatWorkspace({
contentId: contentId ?? undefined,
sessionId: sessionId ?? undefined,
isThemeWorkbench,
autoRunInitialPromptOnMount,
shouldUseCompactThemeWorkbench,
messagesCount: messages.length,
initialDispatchKey,
@@ -1735,7 +1843,9 @@ export function AgentChatWorkspace({
rememberProjectId,
getRememberedProjectId,
loadTopicBoundProjectId: (topicId) =>
loadPersistedProjectId(`${TOPIC_PROJECT_KEY_PREFIX}${topicId}`),
topics.find((topic) => topic.id === topicId)?.workspaceId ||
loadPersistedSessionWorkspaceId(topicId) ||
loadPersistedProjectId(`agent_session_workspace_${topicId}`),
resetTopicLocalState,
});
@@ -2096,6 +2206,13 @@ export function AgentChatWorkspace({
},
[handleWriteFile],
);
const { renderToolbarActions: renderArtifactWorkbenchToolbarActions } =
useWorkspaceArtifactWorkbenchActions({
activeTheme,
projectId,
syncGeneralArtifactToResource,
onSaveArtifactDocument: handleSaveArtifactDocument,
});
const {
handleHarnessLoadFilePreview,
@@ -2171,6 +2288,7 @@ export function AgentChatWorkspace({
sessionId,
initialUserPrompt,
initialUserImages,
autoRunInitialPromptOnMount,
initialDispatchKey,
messagesCount: messages.length,
projectReady: Boolean(project),
@@ -2269,6 +2387,17 @@ export function AgentChatWorkspace({
setWorkspaceHealthError,
workspacePathMissing,
});
const workspaceServiceSkillEntryActions =
useWorkspaceServiceSkillEntryActions({
activeTheme,
creationMode,
projectId,
contentId,
input,
chatToolPreferences,
onNavigate: _onNavigate,
recordServiceSkillUsage,
});
const inputbarScene = useWorkspaceInputbarSceneRuntime({
setMentionedCharacters,
@@ -2318,7 +2447,10 @@ export function AgentChatWorkspace({
handleTaskFileClick,
characters: projectMemory?.characters || [],
skills,
skillsLoading,
serviceSkills: activeTheme === "general" ? serviceSkills : [],
skillsLoading: combinedSkillsLoading,
onSelectServiceSkill:
workspaceServiceSkillEntryActions.handleServiceSkillSelect,
setChatToolPreferences,
handleNavigateToSkillSettings,
handleRefreshSkills,
@@ -2375,6 +2507,7 @@ export function AgentChatWorkspace({
artifactPreviewSize,
setArtifactPreviewSize,
onSaveArtifactDocument: handleSaveArtifactDocument,
renderArtifactWorkbenchToolbarActions,
threadItems: effectiveThreadItems,
focusedBlockId: focusedArtifactBlockId,
blockFocusRequestKey: artifactBlockFocusRequestKey,
@@ -2464,7 +2597,7 @@ export function AgentChatWorkspace({
selectedText,
handleRecommendationClick,
skills,
skillsLoading,
skillsLoading: combinedSkillsLoading,
handleNavigateToSkillSettings,
handleRefreshSkills,
handleOpenBrowserAssistInCanvas,
@@ -2540,5 +2673,33 @@ export function AgentChatWorkspace({
timelineFocusRequestKey,
});
return workspaceShellSceneRuntime.shellSceneNode;
return (
<>
{workspaceShellSceneRuntime.shellSceneNode}
<ServiceSkillLaunchDialog
skill={workspaceServiceSkillEntryActions.selectedServiceSkill}
open={workspaceServiceSkillEntryActions.serviceSkillDialogOpen}
onOpenChange={
workspaceServiceSkillEntryActions.handleServiceSkillDialogOpenChange
}
onLaunch={workspaceServiceSkillEntryActions.handleServiceSkillLaunch}
onCreateAutomation={
workspaceServiceSkillEntryActions.handleServiceSkillAutomationSetup
}
/>
<AutomationJobDialog
open={workspaceServiceSkillEntryActions.automationDialogOpen}
mode="create"
workspaces={workspaceServiceSkillEntryActions.automationWorkspaces}
initialValues={
workspaceServiceSkillEntryActions.automationDialogInitialValues
}
saving={workspaceServiceSkillEntryActions.automationJobSaving}
onOpenChange={
workspaceServiceSkillEntryActions.handleAutomationDialogOpenChange
}
onSubmit={workspaceServiceSkillEntryActions.handleAutomationDialogSubmit}
/>
</>
);
}
-1
View File
@@ -21,7 +21,6 @@ AI Agent 聊天页面,支持通用对话和内容创作两种模式。集成
| --------------------------- | ---------------------------------------------------------------------------- |
| `ChatNavbar.tsx` | 顶部导航栏(模型选择、设置等) |
| `ChatSidebar.tsx` | 侧边栏(任务列表) |
| `ChatSettings.tsx` | 设置面板 |
| `MessageList.tsx` | 消息列表组件 |
| `Inputbar.tsx` | 输入栏组件 |
| `EmptyState.tsx` | 空状态引导(主题选择、模式选择) |
@@ -17,6 +17,7 @@ export interface AgentChatWorkspaceProps {
projectId?: string;
contentId?: string;
initialRequestMetadata?: Record<string, unknown>;
autoRunInitialPromptOnMount?: boolean;
agentEntry?: "new-task" | "claw";
immersiveHome?: boolean;
theme?: string;
@@ -708,9 +708,11 @@ describe("AgentThreadReliabilityPanel", () => {
expect.stringContaining("浏览器工具执行失败"),
);
expect(mockToast.success).toHaveBeenCalledWith("AI 诊断内容已复制");
expect(container.textContent).toContain("复制给 AI");
expect(container.textContent).toContain("复制原始 JSON");
expect(container.textContent).toContain("会附带诊断任务说明");
expect(container.textContent).toContain("compat 快速诊断");
expect(container.textContent).toContain("快速复制给 AI");
expect(container.textContent).toContain("复制原始 JSON(debug)");
expect(container.textContent).toContain("外部分析交接");
expect(container.textContent).toContain("analysis-brief.md / analysis-context.json");
});
it("应支持复制原始 JSON 诊断数据", async () => {
@@ -755,6 +755,12 @@ export const AgentThreadReliabilityPanel: React.FC<
线程可靠性
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge
variant="outline"
className="border-amber-200 bg-amber-50 text-amber-700"
>
compat 快速诊断
</Badge>
<Badge
variant="outline"
className={resolveToneClassName(statusTone)}
@@ -789,7 +795,7 @@ export const AgentThreadReliabilityPanel: React.FC<
data-testid="agent-thread-reliability-copy"
>
<Copy className="mr-2 h-3.5 w-3.5" />
复制给 AI
快速复制给 AI
</Button>
<Button
type="button"
@@ -800,12 +806,15 @@ export const AgentThreadReliabilityPanel: React.FC<
data-testid="agent-thread-reliability-copy-json"
>
<Copy className="mr-2 h-3.5 w-3.5" />
复制原始 JSON
复制原始 JSON(debug)
</Button>
</div>
</div>
<div className="mt-2 text-[11px] leading-5 text-muted-foreground">
“复制给 AI” 会附带诊断任务说明、运行环境、过程信号与最近消息;“复制原始 JSON” 适合程序化分析、存档或二次处理。
<div className="mt-2 rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-[11px] leading-5 text-amber-900">
当前入口属于 `compat` 线程级快速诊断,只覆盖当前 thread 的运行信号。
正式交给外部 Claude Code / Codex 分析时,请优先使用工作台“交接制品
→ 外部分析交接”的 `analysis-brief.md / analysis-context.json`
主链;这里的“快速复制给 AI”只适合临时排障,“复制原始 JSON(debug)”适合程序化分析、存档或二次处理。
</div>
<div className="mt-4 grid gap-2 md:grid-cols-3">
@@ -1,365 +0,0 @@
import React, { useState } from "react";
import styled from "styled-components";
import {
Settings2,
ChevronDown,
ChevronRight,
HelpCircle,
X,
} from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
// --- Styled Components ---
const SettingsContainer = styled.div`
width: 300px;
background-color: hsl(var(--background));
border-left: 1px solid hsl(var(--border));
display: flex;
flex-direction: column;
height: 100%;
flex-shrink: 0;
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid hsl(var(--border));
.title {
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
`;
const SectionContainer = styled.div`
/* padding: 16px; removed to move padding into content */
`;
const SectionTitle = styled.div`
font-size: 12px;
font-weight: 500;
color: hsl(var(--muted-foreground));
padding: 12px 16px;
width: 100%;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: hsl(var(--foreground));
}
`;
const SectionContent = styled(CollapsibleContent)`
padding: 0 16px 16px 16px;
`;
const SettingRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.label {
font-size: 13px;
color: hsl(var(--foreground));
display: flex;
align-items: center;
gap: 4px;
}
.desc {
font-size: 11px;
color: hsl(var(--muted-foreground));
margin-top: 2px;
}
`;
const HelpIcon = () => (
<HelpCircle size={12} className="text-muted-foreground opacity-70" />
);
interface CollapsibleSectionProps {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
children,
defaultOpen = true,
}) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<SectionContainer>
<CollapsibleTrigger asChild>
<SectionTitle>
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{title}
</SectionTitle>
</CollapsibleTrigger>
<SectionContent>{children}</SectionContent>
</SectionContainer>
</Collapsible>
);
};
interface ChatSettingsProps {
onClose: () => void;
}
export const ChatSettings: React.FC<ChatSettingsProps> = ({ onClose }) => {
// Local state for UI toggles (Mocking functional settings)
const [fontSize, setFontSize] = useState([14]);
return (
<SettingsContainer>
<Header>
<div className="title">
<Settings2 size={16} />
<span>设置</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onClose}
>
<X size={14} />
</Button>
</Header>
<ScrollArea className="flex-1">
{/* Message Settings */}
<CollapsibleSection title="消息设置">
<SettingRow>
<div className="label">显示提示词</div>
<Switch defaultChecked />
</SettingRow>
<SettingRow>
<div className="label">使用衬线字体</div>
<Switch />
</SettingRow>
<SettingRow>
<div className="label">
思考内容自动折叠
<HelpIcon />
</div>
<Switch defaultChecked />
</SettingRow>
<SettingRow>
<div className="label">显示消息大纲</div>
<Switch />
</SettingRow>
<SettingRow>
<div className="label">消息样式</div>
<Select defaultValue="simple">
<SelectTrigger className="w-[100px] h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="simple">简洁</SelectItem>
<SelectItem value="bubble">气泡</SelectItem>
</SelectContent>
</Select>
</SettingRow>
<SettingRow>
<div className="label">多模型回答样式</div>
<Select defaultValue="tag">
<SelectTrigger className="w-[100px] h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tag">标签模式</SelectItem>
<SelectItem value="split">分栏模式</SelectItem>
</SelectContent>
</Select>
</SettingRow>
<SettingRow>
<div className="label">对话导航按钮</div>
<Select defaultValue="none">
<SelectTrigger className="w-[100px] h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">不显示</SelectItem>
<SelectItem value="show">显示</SelectItem>
</SelectContent>
</Select>
</SettingRow>
<div className="mt-4 mb-2">
<div className="text-xs mb-2 flex justify-between">
<span>消息字体大小</span>
<span className="text-muted-foreground">{fontSize[0]}px</span>
</div>
<Slider
value={fontSize}
onValueChange={setFontSize}
min={12}
max={24}
step={1}
className="w-full"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-1">
<span>A</span>
<span>默认</span>
<span>A</span>
</div>
</div>
</CollapsibleSection>
<Separator />
{/* Math Settings */}
<CollapsibleSection title="数学公式设置">
<SettingRow>
<div className="label">数学公式引擎</div>
<Select defaultValue="katex">
<SelectTrigger className="w-[100px] h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="katex">KaTeX</SelectItem>
<SelectItem value="mathjax">MathJax</SelectItem>
</SelectContent>
</Select>
</SettingRow>
<SettingRow>
<div className="label">
启用 $...$
<HelpIcon />
</div>
<Switch defaultChecked />
</SettingRow>
</CollapsibleSection>
<Separator />
{/* Code Settings */}
<CollapsibleSection title="代码块设置">
<SettingRow>
<div className="label">代码风格</div>
<Select defaultValue="auto">
<SelectTrigger className="w-[100px] h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">auto</SelectItem>
<SelectItem value="dark">dark</SelectItem>
<SelectItem value="light">light</SelectItem>
</SelectContent>
</Select>
</SettingRow>
<SettingRow>
<div className="label">
花式代码块
<HelpIcon />
</div>
<Switch defaultChecked />
</SettingRow>
<SettingRow>
<div className="label">
代码执行
<HelpIcon />
</div>
<Switch />
</SettingRow>
<SettingRow>
<div className="label">代码编辑器</div>
<Switch />
</SettingRow>
<SettingRow>
<div className="label">代码显示行号</div>
<Switch />
</SettingRow>
<SettingRow>
<div className="label">代码块可折叠</div>
<Switch />
</SettingRow>
<SettingRow>
<div className="label">代码块可换行</div>
<Switch />
</SettingRow>
<SettingRow>
<div className="label">
启用预览工具
<HelpIcon />
</div>
<Switch />
</SettingRow>
</CollapsibleSection>
<Separator />
{/* Input Settings */}
<CollapsibleSection title="输入设置">
<SettingRow>
<div className="label">显示预估 Token 数</div>
<Switch />
</SettingRow>
<SettingRow>
<div className="label">长文本粘贴为文件</div>
<Switch />
</SettingRow>
<SettingRow>
<div className="label">Markdown 渲染输入消息</div>
<Switch />
</SettingRow>
<SettingRow>
<div className="label">3 个空格快速翻译</div>
<Switch />
</SettingRow>
</CollapsibleSection>
</ScrollArea>
</SettingsContainer>
);
};
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EmptyState } from "./EmptyState";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { ServiceSkillHomeItem } from "../service-skills/types";
import { composeEntryPrompt } from "../utils/entryPromptComposer";
const { mockGetConfig } = vi.hoisted(() => ({
@@ -16,7 +17,9 @@ const mockCharacterMention =
(props: {
characters?: Character[];
skills?: Skill[];
serviceSkills?: ServiceSkillHomeItem[];
onSelectSkill?: (skill: Skill) => void;
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
value: string;
onChange: (value: string) => void;
}) => React.ReactNode
@@ -52,7 +55,9 @@ vi.mock("./Inputbar/components/CharacterMention", () => ({
CharacterMention: (props: {
characters?: Character[];
skills?: Skill[];
serviceSkills?: ServiceSkillHomeItem[];
onSelectSkill?: (skill: Skill) => void;
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
value: string;
onChange: (value: string) => void;
}) => {
@@ -254,6 +259,60 @@ describe("EmptyState", () => {
expect(setInput).toHaveBeenCalledWith("@技能A");
});
it("应把服务型技能与选择回调透传给 CharacterMention", async () => {
const serviceSkills: ServiceSkillHomeItem[] = [
{
id: "daily-trend-briefing",
title: "每日趋势摘要",
summary: "围绕指定平台与关键词输出趋势摘要。",
entryHint: "把平台和关键词给我,我先整理一份趋势报告。",
aliases: ["趋势报告"],
category: "社媒运营",
outputHint: "趋势摘要 + 调度建议",
source: "cloud_catalog",
runnerType: "scheduled",
defaultExecutorBinding: "automation_job",
executionLocation: "client_default",
slotSchema: [],
surfaceScopes: ["home", "mention", "workspace"],
promptTemplateKey: "trend_briefing",
version: "seed-v1",
badge: "云目录",
recentUsedAt: null,
isRecent: false,
runnerLabel: "本地计划任务",
runnerTone: "sky",
runnerDescription:
"当前先进入工作区生成首版任务方案,后续再接本地自动化。",
actionLabel: "先做方案",
automationStatus: null,
},
];
const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>();
renderEmptyState({
input: "@",
serviceSkills,
onSelectServiceSkill,
});
await act(async () => {
await Promise.resolve();
});
const latestCall =
mockCharacterMention.mock.calls[
mockCharacterMention.mock.calls.length - 1
][0];
expect(latestCall.serviceSkills).toEqual(serviceSkills);
expect(typeof latestCall.onSelectServiceSkill).toBe("function");
act(() => {
latestCall.onSelectServiceSkill?.(serviceSkills[0]!);
});
expect(onSelectServiceSkill).toHaveBeenCalledWith(serviceSkills[0]!);
});
it("选择技能后发送应自动附加 skill 前缀,且发送后清除激活技能", async () => {
const onSend =
vi.fn<
@@ -57,6 +57,7 @@ import {
getClipboardImageCandidates,
readImageAttachment,
} from "../utils/imageAttachments";
import type { ServiceSkillHomeItem } from "../service-skills/types";
// Import Assets
import capabilitySkillsPlaceholder from "@/assets/claw-home/capability-skills-placeholder.svg";
@@ -212,8 +213,12 @@ interface EmptyStateProps {
characters?: Character[];
/** 技能列表(用于 @ 引用) */
skills?: Skill[];
/** 服务型技能列表(用于 @ 引用) */
serviceSkills?: ServiceSkillHomeItem[];
/** 技能列表加载状态 */
isSkillsLoading?: boolean;
/** 选择服务型技能回调 */
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
/** 跳转到设置页安装技能 */
onNavigateToSettings?: () => void;
/** 导入本地技能 */
@@ -402,7 +407,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
selectedText = "",
characters = [],
skills = [],
serviceSkills = [],
isSkillsLoading = false,
onSelectServiceSkill,
onNavigateToSettings,
onImportSkill,
onRefreshSkills,
@@ -1127,8 +1134,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onEntrySlotChange={handleEntrySlotChange}
characters={characters}
skills={skills}
serviceSkills={serviceSkills}
activeSkill={activeSkill}
setActiveSkill={setActiveSkill}
onSelectServiceSkill={onSelectServiceSkill}
clearActiveSkill={clearActiveSkill}
isSkillsLoading={isSkillsLoading}
onNavigateToSettings={onNavigateToSettings}
@@ -29,6 +29,20 @@ vi.mock("./Inputbar/components/TeamSelector", () => ({
),
}));
const mockSelectedTeam = {
id: "frontend-triage-team",
source: "builtin" as const,
label: "前端联调团队",
description: "分析、实现、验证三段式推进。",
roles: [
{
id: "analysis",
label: "分析",
summary: "负责拆解问题。",
},
],
};
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
@@ -136,13 +150,21 @@ function renderPanel(
function renderStatefulPanel(
props?: Partial<React.ComponentProps<typeof EmptyStateComposerPanel>>,
initialSubagentEnabled = false,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const {
subagentEnabled: _ignoredSubagentEnabled,
onSubagentEnabledChange: _ignoredOnSubagentEnabledChange,
...restProps
} = props || {};
const StatefulPanel = () => {
const [subagentEnabled, setSubagentEnabled] = React.useState(false);
const [subagentEnabled, setSubagentEnabled] = React.useState(
initialSubagentEnabled,
);
return (
<EmptyStateComposerPanel
input=""
@@ -210,7 +232,7 @@ function renderStatefulPanel(
onFileSelect={vi.fn()}
onPaste={vi.fn()}
onRemoveImage={vi.fn()}
{...props}
{...restProps}
/>
);
};
@@ -324,26 +346,45 @@ describe("EmptyStateComposerPanel", () => {
).toBeTruthy();
});
it("未开启 Team mode 时应显示显式开启按钮,并可直接启用", () => {
const onSubagentEnabledChange = vi.fn();
it("未开启 Team mode 时应只保留图标开关,不再显示重复的文字入口", () => {
const container = renderPanel({
isGeneralTheme: true,
subagentEnabled: false,
onSubagentEnabledChange,
});
expect(
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="empty-state-team-mode-enable-button"]'),
).toBeNull();
const toggleButton = container.querySelector(
'button[title="开启多代理偏好"]',
) as HTMLButtonElement | null;
expect(toggleButton).toBeTruthy();
});
it("即使已经保留 Team 方案,关闭 Team mode 后也不应显示 TeamSelector", () => {
const container = renderPanel({
isGeneralTheme: true,
subagentEnabled: false,
selectedTeam: mockSelectedTeam,
});
expect(
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeNull();
const enableButton = container.querySelector(
'[data-testid="empty-state-team-mode-enable-button"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("开启 Team");
act(() => {
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
expect(enableButton).toBeNull();
expect(
container.querySelector('button[title="开启多代理偏好"]'),
).toBeTruthy();
});
it("命中稳妥模式模型时应在首页输入区前置提示", () => {
@@ -361,11 +402,11 @@ describe("EmptyStateComposerPanel", () => {
expect(container.textContent).toContain("依次开始同类请求");
});
it("点击开启 Team 后应自动透传 Team 配置面板打开令牌", async () => {
it("点击多代理图标后应自动透传 Team 配置面板打开令牌", async () => {
const container = renderStatefulPanel();
const enableButton = container.querySelector(
'[data-testid="empty-state-team-mode-enable-button"]',
'button[title="开启多代理偏好"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
@@ -387,19 +428,61 @@ describe("EmptyStateComposerPanel", () => {
expect(teamSelector?.getAttribute("data-auto-open-token")).toBe("1");
});
it("复杂任务但未开启 Team 时,首页开启按钮应显示推荐态", () => {
it("关闭多代理偏好后应立即隐藏 TeamSelector 并回到显式开启入口", async () => {
const container = renderStatefulPanel(
{
selectedTeam: mockSelectedTeam,
},
true,
);
expect(
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeTruthy();
const toggleButton = container.querySelector(
'button[title="关闭多代理偏好"]',
) as HTMLButtonElement | null;
expect(toggleButton).toBeTruthy();
act(() => {
toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeNull();
expect(
container.querySelector('button[title="开启多代理偏好"]'),
).toBeTruthy();
});
it("复杂任务但未开启 Team 时,首页保留推荐提示但不再渲染重复入口", () => {
const container = renderPanel({
isGeneralTheme: true,
subagentEnabled: false,
input: "请拆成多个子任务分别分析、实现、验证,并最终统一回归验收",
});
const enableButton = container.querySelector(
'[data-testid="empty-state-team-mode-enable-button"]',
) as HTMLButtonElement | null;
const enableButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("启用 Team")) as
| HTMLButtonElement
| undefined;
expect(
container.querySelector('[data-testid="empty-state-team-mode-enable-button"]'),
).toBeNull();
expect(
container.querySelector('button[title="开启多代理偏好"]'),
).toBeTruthy();
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("开启 Team");
expect(enableButton?.textContent).toContain("推荐");
expect(enableButton?.textContent).toContain("启用 Team");
expect(container.textContent).toContain("当前任务更适合 Team 协作");
});
});
@@ -35,8 +35,8 @@ 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 { TeamModeEntryButton } from "./Inputbar/components/TeamModeEntryButton";
import { StableProcessingNotice } from "./StableProcessingNotice";
import type { ServiceSkillHomeItem } from "../service-skills/types";
import type { WorkspaceSettings } from "@/types/workspace";
import { CREATION_MODE_CONFIG } from "./constants";
import type {
@@ -378,8 +378,7 @@ const GridItem = styled.div<{ $active?: boolean }>`
padding: 10px;
border-radius: 8px;
border: 1px solid
${(props) =>
props.$active ? "rgba(148, 163, 184, 0.82)" : "transparent"};
${(props) => (props.$active ? "rgba(148, 163, 184, 0.82)" : "transparent")};
background-color: ${(props) =>
props.$active ? "rgba(241, 245, 249, 0.96)" : "rgba(248, 250, 252, 0.92)"};
cursor: pointer;
@@ -416,11 +415,8 @@ const EntryTaskTab = styled.button<{ $active?: boolean }>`
font-size: 12px;
border: 1px solid
${(props) =>
props.$active
? "rgba(203, 213, 225, 0.92)"
: "rgba(226, 232, 240, 0.9)"};
color: ${(props) =>
props.$active ? "#0f172a" : "#64748b"};
props.$active ? "rgba(203, 213, 225, 0.92)" : "rgba(226, 232, 240, 0.9)"};
color: ${(props) => (props.$active ? "#0f172a" : "#64748b")};
background: ${(props) =>
props.$active ? "rgba(255, 255, 255, 0.96)" : "rgba(255, 255, 255, 0.78)"};
box-shadow: ${(props) =>
@@ -501,8 +497,10 @@ interface EmptyStateComposerPanelProps {
onEntrySlotChange: (key: string, value: string) => void;
characters: Character[];
skills: Skill[];
serviceSkills?: ServiceSkillHomeItem[];
activeSkill?: Skill | null;
setActiveSkill: (skill: Skill) => void;
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
clearActiveSkill: () => void;
isSkillsLoading: boolean;
onNavigateToSettings?: () => void;
@@ -570,8 +568,10 @@ export function EmptyStateComposerPanel({
onEntrySlotChange,
characters,
skills,
serviceSkills = [],
activeSkill,
setActiveSkill,
onSelectServiceSkill,
clearActiveSkill,
isSkillsLoading,
onNavigateToSettings,
@@ -644,6 +644,7 @@ export function EmptyStateComposerPanel({
Boolean(onSubagentEnabledChange) &&
teamSuggestion.shouldSuggest &&
dismissedSuggestionKey !== suggestionKey;
const shouldShowTeamSelector = isGeneralTheme && subagentEnabled;
const handleEnableTeamSuggestion = () => {
onSubagentEnabledChange?.(true);
@@ -655,13 +656,6 @@ export function EmptyStateComposerPanel({
setDismissedSuggestionKey(suggestionKey);
};
const handleEnableTeamMode = () => {
if (!subagentEnabled && !selectedTeam) {
setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1);
}
onSubagentEnabledChange?.(true);
};
const handleToggleSubagentMode = () => {
if (!subagentEnabled && !selectedTeam) {
setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1);
@@ -740,10 +734,12 @@ export function EmptyStateComposerPanel({
<CharacterMention
characters={characters}
skills={skills}
serviceSkills={serviceSkills}
inputRef={textareaRef}
value={input}
onChange={setInput}
onSelectSkill={setActiveSkill}
onSelectServiceSkill={onSelectServiceSkill}
onNavigateToSettings={onNavigateToSettings}
/>
@@ -814,7 +810,7 @@ export function EmptyStateComposerPanel({
onRefreshSkills={onRefreshSkills}
/>
) : null}
{subagentEnabled ? (
{shouldShowTeamSelector ? (
<TeamSelector
activeTheme={activeTheme}
input={input}
@@ -828,14 +824,6 @@ export function EmptyStateComposerPanel({
onPersistCustomTeams={onPersistCustomTeams}
onSelectTeam={(team) => onSelectTeam?.(team)}
/>
) : isGeneralTheme && onSubagentEnabledChange ? (
<TeamModeEntryButton
selectedTeamLabel={selectedTeam?.label}
dataTestId="empty-state-team-mode-enable-button"
recommended={teamSuggestion.shouldSuggest}
hint={teamSuggestion.reasons[0]}
onClick={handleEnableTeamMode}
/>
) : null}
<ChatModelSelector
@@ -1109,6 +1097,7 @@ export function EmptyStateComposerPanel({
)}
onClick={handleToggleSubagentMode}
aria-pressed={subagentEnabled}
data-state={subagentEnabled ? "on" : "off"}
title={subagentEnabled ? "关闭多代理偏好" : "开启多代理偏好"}
>
<Workflow className="h-4 w-4 opacity-70" />
@@ -1177,7 +1166,9 @@ export function EmptyStateComposerPanel({
<LaunchButton
size="sm"
onClick={onSend}
disabled={!input.trim() && !isEntryTheme && pendingImages.length === 0}
disabled={
!input.trim() && !isEntryTheme && pendingImages.length === 0
}
>
开始生成
<ArrowRight className="ml-2 h-4 w-4" />
@@ -112,6 +112,7 @@ export interface EmptyStateQuickActionItem {
secondaryStatusLabel?: string;
secondaryStatusTone?: "slate" | "sky" | "emerald" | "amber";
secondaryStatusDescription?: string;
secondaryStatusActionable?: boolean;
testId?: string;
solutionId?: string;
}
@@ -257,7 +258,8 @@ export function EmptyStateQuickActions({
) : null}
{item.secondaryStatusLabel ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
{onSecondaryStatusAction ? (
{onSecondaryStatusAction &&
item.secondaryStatusActionable !== false ? (
<button
type="button"
data-testid={
@@ -5,7 +5,19 @@ import type { AgentRuntimeToolInventory } from "@/lib/api/agentRuntime";
import { HarnessStatusPanel } from "./HarnessStatusPanel";
import type { HarnessSessionState } from "../utils/harnessState";
const { mockToast } = vi.hoisted(() => ({
const {
exportAgentRuntimeAnalysisHandoffMock,
exportAgentRuntimeEvidencePackMock,
exportAgentRuntimeHandoffBundleMock,
exportAgentRuntimeReplayCaseMock,
exportAgentRuntimeReviewDecisionTemplateMock,
mockToast,
} = vi.hoisted(() => ({
exportAgentRuntimeAnalysisHandoffMock: vi.fn(),
exportAgentRuntimeEvidencePackMock: vi.fn(),
exportAgentRuntimeHandoffBundleMock: vi.fn(),
exportAgentRuntimeReplayCaseMock: vi.fn(),
exportAgentRuntimeReviewDecisionTemplateMock: vi.fn(),
mockToast: {
success: vi.fn(),
error: vi.fn(),
@@ -14,6 +26,21 @@ const { mockToast } = vi.hoisted(() => ({
},
}));
vi.mock("@/lib/api/agentRuntime", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/agentRuntime")>(
"@/lib/api/agentRuntime",
);
return {
...actual,
exportAgentRuntimeAnalysisHandoff: exportAgentRuntimeAnalysisHandoffMock,
exportAgentRuntimeEvidencePack: exportAgentRuntimeEvidencePackMock,
exportAgentRuntimeHandoffBundle: exportAgentRuntimeHandoffBundleMock,
exportAgentRuntimeReplayCase: exportAgentRuntimeReplayCaseMock,
exportAgentRuntimeReviewDecisionTemplate:
exportAgentRuntimeReviewDecisionTemplateMock,
};
});
vi.mock("sonner", () => ({
toast: mockToast,
}));
@@ -432,6 +459,460 @@ describe("HarnessStatusPanel", () => {
).not.toBeNull();
});
it("存在 sessionId 时应支持导出交接制品并展示产物列表", async () => {
exportAgentRuntimeHandoffBundleMock.mockResolvedValue({
session_id: "session-handoff-1",
thread_id: "thread-handoff-1",
workspace_id: "workspace-handoff-1",
workspace_root: "/tmp/workspace-handoff-1",
bundle_relative_root: ".lime/harness/sessions/session-handoff-1",
bundle_absolute_root:
"/tmp/workspace-handoff-1/.lime/harness/sessions/session-handoff-1",
exported_at: "2026-03-27T09:30:00.000Z",
thread_status: "running",
latest_turn_status: "queued",
pending_request_count: 1,
queued_turn_count: 2,
active_subagent_count: 1,
todo_total: 3,
todo_pending: 1,
todo_in_progress: 1,
todo_completed: 1,
artifacts: [
{
kind: "handoff",
title: "交接摘要",
relative_path: ".lime/harness/sessions/session-handoff-1/handoff.md",
absolute_path:
"/tmp/workspace-handoff-1/.lime/harness/sessions/session-handoff-1/handoff.md",
bytes: 512,
},
],
});
renderPanel({
diagnosticRuntimeContext: {
sessionId: "session-handoff-1",
workspaceId: "workspace-handoff-1",
providerType: "openai",
model: "gpt-5.4",
executionStrategy: "react",
activeTheme: "default",
selectedTeamLabel: null,
},
});
expect(document.body.textContent).toContain("交接制品");
expect(
document.body.querySelector('button[aria-label="跳转到交接制品"]'),
).not.toBeNull();
const exportButton = document.body.querySelector(
'button[aria-label="导出交接制品"]',
) as HTMLButtonElement | null;
await act(async () => {
exportButton?.click();
await Promise.resolve();
});
expect(exportAgentRuntimeHandoffBundleMock).toHaveBeenCalledWith(
"session-handoff-1",
);
expect(document.body.textContent).toContain(
".lime/harness/sessions/session-handoff-1/handoff.md",
);
expect(document.body.textContent).toContain("线程状态");
expect(document.body.textContent).toContain("处理中");
expect(document.body.textContent).toContain("排队中");
expect(mockToast.success).toHaveBeenCalledWith("已导出 1 个交接制品");
});
it("存在 sessionId 时应支持导出问题证据包并展示缺口与文件列表", async () => {
exportAgentRuntimeEvidencePackMock.mockResolvedValue({
session_id: "session-evidence-1",
thread_id: "thread-evidence-1",
workspace_id: "workspace-evidence-1",
workspace_root: "/tmp/workspace-evidence-1",
pack_relative_root: ".lime/harness/sessions/session-evidence-1/evidence",
pack_absolute_root:
"/tmp/workspace-evidence-1/.lime/harness/sessions/session-evidence-1/evidence",
exported_at: "2026-03-27T09:40:00.000Z",
thread_status: "running",
latest_turn_status: "running",
turn_count: 2,
item_count: 5,
pending_request_count: 1,
queued_turn_count: 1,
recent_artifact_count: 2,
known_gaps: [
"当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。",
],
artifacts: [
{
kind: "summary",
title: "问题摘要",
relative_path:
".lime/harness/sessions/session-evidence-1/evidence/summary.md",
absolute_path:
"/tmp/workspace-evidence-1/.lime/harness/sessions/session-evidence-1/evidence/summary.md",
bytes: 256,
},
],
});
renderPanel({
diagnosticRuntimeContext: {
sessionId: "session-evidence-1",
workspaceId: "workspace-evidence-1",
providerType: "openai",
model: "gpt-5.4",
executionStrategy: "react",
activeTheme: "default",
selectedTeamLabel: null,
},
});
const exportButton = document.body.querySelector(
'button[aria-label="导出问题证据包"]',
) as HTMLButtonElement | null;
await act(async () => {
exportButton?.click();
await Promise.resolve();
});
expect(exportAgentRuntimeEvidencePackMock).toHaveBeenCalledWith(
"session-evidence-1",
);
expect(document.body.textContent).toContain("问题证据包");
expect(document.body.textContent).toContain("当前已知缺口");
expect(document.body.textContent).toContain(
".lime/harness/sessions/session-evidence-1/evidence/summary.md",
);
expect(mockToast.success).toHaveBeenCalledWith("已导出 1 个问题证据文件");
});
it("存在 sessionId 时应支持导出外部分析交接并展示分析文件列表", async () => {
exportAgentRuntimeAnalysisHandoffMock.mockResolvedValue({
session_id: "session-analysis-1",
thread_id: "thread-analysis-1",
workspace_id: "workspace-analysis-1",
workspace_root: "/tmp/workspace-analysis-1",
analysis_relative_root:
".lime/harness/sessions/session-analysis-1/analysis",
analysis_absolute_root:
"/tmp/workspace-analysis-1/.lime/harness/sessions/session-analysis-1/analysis",
handoff_bundle_relative_root: ".lime/harness/sessions/session-analysis-1",
evidence_pack_relative_root:
".lime/harness/sessions/session-analysis-1/evidence",
replay_case_relative_root:
".lime/harness/sessions/session-analysis-1/replay",
exported_at: "2026-03-27T10:00:00.000Z",
title: "修复运行时导出交接缺口",
thread_status: "running",
latest_turn_status: "waiting_request",
pending_request_count: 1,
queued_turn_count: 0,
sanitized_workspace_root: "/workspace/lime",
copy_prompt: "# Lime 外部诊断与修复任务\n",
artifacts: [
{
kind: "analysis_brief",
title: "外部分析简报",
relative_path:
".lime/harness/sessions/session-analysis-1/analysis/analysis-brief.md",
absolute_path:
"/tmp/workspace-analysis-1/.lime/harness/sessions/session-analysis-1/analysis/analysis-brief.md",
bytes: 512,
},
{
kind: "analysis_context",
title: "外部分析上下文",
relative_path:
".lime/harness/sessions/session-analysis-1/analysis/analysis-context.json",
absolute_path:
"/tmp/workspace-analysis-1/.lime/harness/sessions/session-analysis-1/analysis/analysis-context.json",
bytes: 768,
},
],
});
renderPanel({
diagnosticRuntimeContext: {
sessionId: "session-analysis-1",
workspaceId: "workspace-analysis-1",
providerType: "openai",
model: "gpt-5.4",
executionStrategy: "react",
activeTheme: "default",
selectedTeamLabel: null,
},
});
const exportButton = document.body.querySelector(
'button[aria-label="导出外部分析交接"]',
) as HTMLButtonElement | null;
await act(async () => {
exportButton?.click();
await Promise.resolve();
});
expect(exportAgentRuntimeAnalysisHandoffMock).toHaveBeenCalledWith(
"session-analysis-1",
);
expect(document.body.textContent).toContain("外部分析交接");
expect(document.body.textContent).toContain("修复运行时导出交接缺口");
expect(document.body.textContent).toContain(
".lime/harness/sessions/session-analysis-1/analysis/analysis-brief.md",
);
expect(document.body.textContent).toContain(
".lime/harness/sessions/session-analysis-1/analysis/analysis-context.json",
);
expect(document.body.textContent).toContain("/workspace/lime");
expect(mockToast.success).toHaveBeenCalledWith("已导出 2 个外部分析文件");
});
it("存在 sessionId 时应支持导出 Replay 样本并展示关联证据与文件列表", async () => {
exportAgentRuntimeReplayCaseMock.mockResolvedValue({
session_id: "session-replay-1",
thread_id: "thread-replay-1",
workspace_id: "workspace-replay-1",
workspace_root: "/tmp/workspace-replay-1",
replay_relative_root: ".lime/harness/sessions/session-replay-1/replay",
replay_absolute_root:
"/tmp/workspace-replay-1/.lime/harness/sessions/session-replay-1/replay",
handoff_bundle_relative_root: ".lime/harness/sessions/session-replay-1",
evidence_pack_relative_root:
".lime/harness/sessions/session-replay-1/evidence",
exported_at: "2026-03-27T09:50:00.000Z",
thread_status: "waiting_request",
latest_turn_status: "completed",
pending_request_count: 1,
queued_turn_count: 1,
linked_handoff_artifact_count: 4,
linked_evidence_artifact_count: 4,
recent_artifact_count: 2,
artifacts: [
{
kind: "grader",
title: "评分说明",
relative_path:
".lime/harness/sessions/session-replay-1/replay/grader.md",
absolute_path:
"/tmp/workspace-replay-1/.lime/harness/sessions/session-replay-1/replay/grader.md",
bytes: 320,
},
],
});
renderPanel({
diagnosticRuntimeContext: {
sessionId: "session-replay-1",
workspaceId: "workspace-replay-1",
providerType: "openai",
model: "gpt-5.4",
executionStrategy: "react",
activeTheme: "default",
selectedTeamLabel: null,
},
});
const exportButton = document.body.querySelector(
'button[aria-label="导出 Replay 样本"]',
) as HTMLButtonElement | null;
await act(async () => {
exportButton?.click();
await Promise.resolve();
});
expect(exportAgentRuntimeReplayCaseMock).toHaveBeenCalledWith(
"session-replay-1",
);
expect(document.body.textContent).toContain("Replay 样本");
expect(document.body.textContent).toContain("关联证据主链");
expect(document.body.textContent).toContain(
".lime/harness/sessions/session-replay-1/replay/grader.md",
);
expect(document.body.textContent).toContain(
".lime/harness/sessions/session-replay-1/evidence",
);
expect(mockToast.success).toHaveBeenCalledWith(
"已导出 1 个 Replay 样本文件",
);
});
it("存在 sessionId 时应支持导出人工审核记录并展示审核模板与清单", async () => {
exportAgentRuntimeReviewDecisionTemplateMock.mockResolvedValue({
session_id: "session-review-1",
thread_id: "thread-review-1",
workspace_id: "workspace-review-1",
workspace_root: "/tmp/workspace-review-1",
review_relative_root: ".lime/harness/sessions/session-review-1/review",
review_absolute_root:
"/tmp/workspace-review-1/.lime/harness/sessions/session-review-1/review",
analysis_relative_root:
".lime/harness/sessions/session-review-1/analysis",
analysis_absolute_root:
"/tmp/workspace-review-1/.lime/harness/sessions/session-review-1/analysis",
handoff_bundle_relative_root: ".lime/harness/sessions/session-review-1",
evidence_pack_relative_root:
".lime/harness/sessions/session-review-1/evidence",
replay_case_relative_root:
".lime/harness/sessions/session-review-1/replay",
exported_at: "2026-03-27T10:20:00.000Z",
title: "把外部分析结论回挂为人工审核记录",
thread_status: "waiting_request",
latest_turn_status: "action_required",
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
review_checklist: [
"先阅读 analysis-brief.md 与 analysis-context.json。",
"确认最终决策由人工审核者填写。",
],
analysis_artifacts: [
{
kind: "analysis_brief",
title: "外部分析简报",
relative_path:
".lime/harness/sessions/session-review-1/analysis/analysis-brief.md",
absolute_path:
"/tmp/workspace-review-1/.lime/harness/sessions/session-review-1/analysis/analysis-brief.md",
bytes: 320,
},
],
artifacts: [
{
kind: "review_decision_markdown",
title: "人工审核记录",
relative_path:
".lime/harness/sessions/session-review-1/review/review-decision.md",
absolute_path:
"/tmp/workspace-review-1/.lime/harness/sessions/session-review-1/review/review-decision.md",
bytes: 512,
},
{
kind: "review_decision_json",
title: "人工审核记录 JSON",
relative_path:
".lime/harness/sessions/session-review-1/review/review-decision.json",
absolute_path:
"/tmp/workspace-review-1/.lime/harness/sessions/session-review-1/review/review-decision.json",
bytes: 256,
},
],
});
renderPanel({
diagnosticRuntimeContext: {
sessionId: "session-review-1",
workspaceId: "workspace-review-1",
providerType: "openai",
model: "gpt-5.4",
executionStrategy: "react",
activeTheme: "default",
selectedTeamLabel: null,
},
});
const exportButton = document.body.querySelector(
'button[aria-label="导出人工审核记录"]',
) as HTMLButtonElement | null;
await act(async () => {
exportButton?.click();
await Promise.resolve();
});
expect(exportAgentRuntimeReviewDecisionTemplateMock).toHaveBeenCalledWith(
"session-review-1",
);
expect(document.body.textContent).toContain("人工审核记录");
expect(document.body.textContent).toContain("待人工审核");
expect(document.body.textContent).toContain(
".lime/harness/sessions/session-review-1/review/review-decision.md",
);
expect(document.body.textContent).toContain(
".lime/harness/sessions/session-review-1/analysis/analysis-brief.md",
);
expect(document.body.textContent).toContain(
"确认最终决策由人工审核者填写。",
);
expect(document.body.textContent).toContain("aster-rust");
expect(mockToast.success).toHaveBeenCalledWith("已导出 2 个人工审核文件");
});
it("一键复制给 AI 在未导出时应先自动导出再复制 copy_prompt", async () => {
exportAgentRuntimeAnalysisHandoffMock.mockResolvedValue({
session_id: "session-analysis-copy-1",
thread_id: "thread-analysis-copy-1",
workspace_id: "workspace-analysis-copy-1",
workspace_root: "/tmp/workspace-analysis-copy-1",
analysis_relative_root:
".lime/harness/sessions/session-analysis-copy-1/analysis",
analysis_absolute_root:
"/tmp/workspace-analysis-copy-1/.lime/harness/sessions/session-analysis-copy-1/analysis",
handoff_bundle_relative_root:
".lime/harness/sessions/session-analysis-copy-1",
evidence_pack_relative_root:
".lime/harness/sessions/session-analysis-copy-1/evidence",
replay_case_relative_root:
".lime/harness/sessions/session-analysis-copy-1/replay",
exported_at: "2026-03-27T10:10:00.000Z",
title: "分析复制任务",
thread_status: "running",
latest_turn_status: "running",
pending_request_count: 0,
queued_turn_count: 0,
sanitized_workspace_root: "/workspace/lime",
copy_prompt: "# Lime 外部诊断与修复任务\n请直接开始诊断。\n",
artifacts: [
{
kind: "analysis_brief",
title: "外部分析简报",
relative_path:
".lime/harness/sessions/session-analysis-copy-1/analysis/analysis-brief.md",
absolute_path:
"/tmp/workspace-analysis-copy-1/.lime/harness/sessions/session-analysis-copy-1/analysis/analysis-brief.md",
bytes: 256,
},
],
});
renderPanel({
diagnosticRuntimeContext: {
sessionId: "session-analysis-copy-1",
workspaceId: "workspace-analysis-copy-1",
providerType: "openai",
model: "gpt-5.4",
executionStrategy: "react",
activeTheme: "default",
selectedTeamLabel: null,
},
});
const copyButton = document.body.querySelector(
'button[aria-label="一键复制给 AI"]',
) as HTMLButtonElement | null;
await act(async () => {
copyButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(exportAgentRuntimeAnalysisHandoffMock).toHaveBeenCalledWith(
"session-analysis-copy-1",
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
"# Lime 外部诊断与修复任务\n请直接开始诊断。\n",
);
expect(mockToast.success).toHaveBeenCalledWith("已复制 AI 诊断与修复指令");
});
it("runtimeStatus 为 failed 时应展示失败阶段与失败详情", () => {
renderPanel({
harnessState: createHarnessState({
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CharacterMention } from "./CharacterMention";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import { filterMentionableServiceSkills } from "@/components/agent/chat/service-skills/entryAdapter";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type { BuiltinInputCommand } from "./builtinCommands";
vi.mock("sonner", () => ({
@@ -35,29 +37,34 @@ vi.mock("@/components/ui/popover", () => {
sideOffset?: number;
onOpenAutoFocus?: (event: Event) => void;
}
>(({
children,
className,
style,
side,
align,
avoidCollisions,
sideOffset: _sideOffset,
onOpenAutoFocus: _onOpenAutoFocus,
...props
}, ref) => (
<div
ref={ref}
className={className}
style={style}
data-side={side}
data-align={align}
data-avoid-collisions={String(avoidCollisions)}
{...props}
>
{children}
</div>
));
>(
(
{
children,
className,
style,
side,
align,
avoidCollisions,
sideOffset: _sideOffset,
onOpenAutoFocus: _onOpenAutoFocus,
...props
},
ref,
) => (
<div
ref={ref}
className={className}
style={style}
data-side={side}
data-align={align}
data-avoid-collisions={String(avoidCollisions)}
{...props}
>
{children}
</div>
),
);
return { Popover, PopoverTrigger, PopoverContent };
});
@@ -157,19 +164,23 @@ afterEach(() => {
interface HarnessProps {
characters?: Character[];
skills?: Skill[];
serviceSkills?: ServiceSkillHomeItem[];
syncValue?: boolean;
onNavigateToSettings?: () => void;
onChangeSpy?: (value: string) => void;
onSelectBuiltinCommand?: (command: BuiltinInputCommand) => void;
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
}
const Harness: React.FC<HarnessProps> = ({
characters = [],
skills = [],
serviceSkills = [],
syncValue = true,
onNavigateToSettings,
onChangeSpy,
onSelectBuiltinCommand,
onSelectServiceSkill,
}) => {
const [value, setValue] = useState("");
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -189,6 +200,7 @@ const Harness: React.FC<HarnessProps> = ({
<CharacterMention
characters={characters}
skills={skills}
serviceSkills={serviceSkills}
inputRef={inputRef}
value={value}
onChange={(next) => {
@@ -198,6 +210,7 @@ const Harness: React.FC<HarnessProps> = ({
}
}}
onSelectBuiltinCommand={onSelectBuiltinCommand}
onSelectServiceSkill={onSelectServiceSkill}
onNavigateToSettings={onNavigateToSettings}
/>
</div>
@@ -256,10 +269,7 @@ async function typeAtAndWait(textarea: HTMLTextAreaElement) {
});
}
async function typeSlashAndWait(
textarea: HTMLTextAreaElement,
value = "/",
) {
async function typeSlashAndWait(textarea: HTMLTextAreaElement, value = "/") {
await act(async () => {
await import("./CharacterMentionPanel");
});
@@ -302,6 +312,37 @@ function createCharacter(name: string): Character {
};
}
function createServiceSkill(
overrides: Partial<ServiceSkillHomeItem> = {},
): ServiceSkillHomeItem {
return {
id: "daily-trend-briefing",
title: "每日趋势摘要",
summary: "围绕指定平台与关键词输出趋势摘要。",
entryHint: "把平台和关键词给我,我先整理一份趋势报告。",
aliases: ["趋势报告", "热点摘要"],
category: "社媒运营",
outputHint: "趋势摘要 + 调度建议",
source: "cloud_catalog",
runnerType: "scheduled",
defaultExecutorBinding: "automation_job",
executionLocation: "client_default",
slotSchema: [],
surfaceScopes: ["home", "mention", "workspace"],
promptTemplateKey: "trend_briefing",
version: "seed-v1",
badge: "云目录",
recentUsedAt: null,
isRecent: false,
runnerLabel: "本地计划任务",
runnerTone: "sky",
runnerDescription: "当前先进入工作区生成首版任务方案,后续再接本地自动化。",
actionLabel: "先做方案",
automationStatus: null,
...overrides,
};
}
describe("CharacterMention", () => {
it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", async () => {
const container = renderHarness({
@@ -329,7 +370,8 @@ describe("CharacterMention", () => {
});
it("提供 onSelectBuiltinCommand 时,选择配图命令应交给父组件接管", async () => {
const onSelectBuiltinCommand = vi.fn<(command: BuiltinInputCommand) => void>();
const onSelectBuiltinCommand =
vi.fn<(command: BuiltinInputCommand) => void>();
const container = renderHarness({
onSelectBuiltinCommand,
});
@@ -337,9 +379,9 @@ describe("CharacterMention", () => {
await typeAtAndWait(textarea);
const builtinButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("@配图"),
);
const builtinButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("@配图"));
expect(builtinButton).toBeTruthy();
act(() => {
@@ -354,6 +396,84 @@ describe("CharacterMention", () => {
);
});
it("服务技能应出现在 @ 面板里", async () => {
const container = renderHarness({
serviceSkills: [
createServiceSkill(),
createServiceSkill({
id: "carousel-post-replication",
title: "复制轮播帖",
entryHint: "拆结构并输出一版可继续改的轮播帖。",
aliases: ["轮播帖", "小红书轮播"],
runnerType: "instant",
defaultExecutorBinding: "agent_turn",
runnerLabel: "本地即时执行",
runnerTone: "emerald",
runnerDescription: "客户端起步版可直接进入工作区执行。",
actionLabel: "填写参数",
promptTemplateKey: "replication",
}),
],
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
expect(document.body.textContent).toContain("服务技能");
expect(document.body.textContent).toContain("每日趋势摘要");
expect(document.body.textContent).toContain("复制轮播帖");
});
it("服务技能过滤应支持命中别名", () => {
const filtered = filterMentionableServiceSkills(
[
createServiceSkill(),
createServiceSkill({
id: "carousel-post-replication",
title: "复制轮播帖",
aliases: ["轮播帖", "小红书轮播"],
runnerType: "instant",
defaultExecutorBinding: "agent_turn",
runnerLabel: "本地即时执行",
runnerTone: "emerald",
runnerDescription: "客户端起步版可直接进入工作区执行。",
actionLabel: "填写参数",
promptTemplateKey: "replication",
}),
],
"轮播",
);
expect(filtered).toHaveLength(1);
expect(filtered[0]?.id).toBe("carousel-post-replication");
});
it("提供 onSelectServiceSkill 时,选择服务技能应交给父组件接管", async () => {
const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>();
const onChangeSpy = vi.fn<(value: string) => void>();
const serviceSkill = createServiceSkill();
const container = renderHarness({
serviceSkills: [serviceSkill],
onSelectServiceSkill,
onChangeSpy,
});
const textarea = getTextarea(container);
await typeAtAndWait(textarea);
const serviceSkillButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("每日趋势摘要"));
expect(serviceSkillButton).toBeTruthy();
act(() => {
serviceSkillButton?.click();
});
expect(onChangeSpy).toHaveBeenCalledWith("");
expect(onSelectServiceSkill).toHaveBeenCalledWith(serviceSkill);
});
it("未提供 onSelectSkill 时,选择已安装技能应回填到输入框", async () => {
const onChangeSpy = vi.fn<(value: string) => void>();
const container = renderHarness({
@@ -364,9 +484,9 @@ describe("CharacterMention", () => {
await typeAtAndWait(textarea);
const skillButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("技能A"),
);
const skillButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("技能A"));
expect(skillButton).toBeTruthy();
act(() => {
@@ -418,9 +538,9 @@ describe("CharacterMention", () => {
await typeSlashAndWait(textarea, "/ski");
const skillButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("技能A"),
);
const skillButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("技能A"));
expect(skillButton).toBeTruthy();
act(() => {
@@ -16,8 +16,13 @@ import React, {
import { createPortal } from "react-dom";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import { filterMentionableServiceSkills } from "@/components/agent/chat/service-skills/entryAdapter";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import { toast } from "sonner";
import { filterCodexSlashCommands, type CodexSlashCommandDefinition } from "../../../commands";
import {
filterCodexSlashCommands,
type CodexSlashCommandDefinition,
} from "../../../commands";
import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload";
import {
filterBuiltinCommands,
@@ -36,6 +41,8 @@ interface CharacterMentionProps {
characters: Character[];
/** 技能列表 */
skills?: Skill[];
/** 服务型技能列表 */
serviceSkills?: ServiceSkillHomeItem[];
/** 输入框 ref */
inputRef: React.RefObject<HTMLTextAreaElement>;
/** 当前输入值 */
@@ -46,6 +53,8 @@ interface CharacterMentionProps {
onSelectCharacter?: (character: Character) => void;
/** 选择已安装技能回调 */
onSelectSkill?: (skill: Skill) => void;
/** 选择服务型技能回调 */
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
/** 选择内建命令回调 */
onSelectBuiltinCommand?: (command: BuiltinInputCommand) => void;
/** 跳转到设置页安装技能 */
@@ -116,11 +125,13 @@ function resolveActiveTrigger(
export function CharacterMention({
characters,
skills = [],
serviceSkills = [],
inputRef,
value,
onChange,
onSelectCharacter,
onSelectSkill,
onSelectServiceSkill,
onSelectBuiltinCommand,
onNavigateToSettings,
}: CharacterMentionProps) {
@@ -147,6 +158,10 @@ export function CharacterMention({
() => filterBuiltinCommands(mentionQuery),
[mentionQuery],
);
const filteredServiceSkills = useMemo(
() => filterMentionableServiceSkills(serviceSkills, mentionQuery),
[mentionQuery, serviceSkills],
);
const filteredSlashCommands = useMemo(
() => filterCodexSlashCommands(mentionQuery),
[mentionQuery],
@@ -276,7 +291,10 @@ export function CharacterMention({
if (!(target instanceof Node)) {
return;
}
if (panelRef.current?.contains(target) || inputRef.current?.contains(target)) {
if (
panelRef.current?.contains(target) ||
inputRef.current?.contains(target)
) {
return;
}
setShowMentions(false);
@@ -311,7 +329,8 @@ export function CharacterMention({
setTimeout(() => {
textarea.focus();
const newCursorPos = activeTrigger.triggerIndex + character.name.length + 2;
const newCursorPos =
activeTrigger.triggerIndex + character.name.length + 2;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
};
@@ -428,6 +447,47 @@ export function CharacterMention({
}, 0);
};
const handleSelectServiceSkill = (skill: ServiceSkillHomeItem) => {
const textarea = inputRef.current;
if (!textarea) return;
const currentValue = textarea.value || value;
const cursorPos = textarea.selectionStart ?? currentValue.length;
const textAfterCursor = currentValue.slice(cursorPos);
const activeTrigger = resolveActiveTrigger(currentValue, cursorPos);
if (!activeTrigger || activeTrigger.mode !== "mention") {
return;
}
if (onSelectServiceSkill) {
const newValue =
currentValue.slice(0, activeTrigger.triggerIndex) + textAfterCursor;
onChange(newValue.trimEnd() === "" ? "" : newValue);
setShowMentions(false);
onSelectServiceSkill(skill);
setTimeout(() => {
textarea.focus();
const newCursorPos = Math.max(0, activeTrigger.triggerIndex);
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
return;
}
const newValue =
currentValue.slice(0, activeTrigger.triggerIndex) +
`@${skill.title} ` +
textAfterCursor;
onChange(newValue);
setShowMentions(false);
setTimeout(() => {
textarea.focus();
const newCursorPos = activeTrigger.triggerIndex + skill.title.length + 2;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
};
const handleSelectSlashCommand = (command: CodexSlashCommandDefinition) => {
const textarea = inputRef.current;
if (!textarea) return;
@@ -541,12 +601,14 @@ export function CharacterMention({
mentionQuery={mentionQuery}
builtinCommands={filteredBuiltinCommands}
slashCommands={filteredSlashCommands}
mentionServiceSkills={filteredServiceSkills}
filteredCharacters={filteredCharacters}
installedSkills={installedSkills}
availableSkills={availableSkills}
commandRef={commandRef}
onQueryChange={setMentionQuery}
onSelectBuiltinCommand={handleSelectBuiltinCommand}
onSelectServiceSkill={handleSelectServiceSkill}
onSelectSlashCommand={handleSelectSlashCommand}
onSelectCharacter={handleSelectCharacter}
onSelectInstalledSkill={handleSelectInstalledSkill}
@@ -1,5 +1,11 @@
import React from "react";
import { Command as CommandIcon, ImagePlus, User, Zap } from "lucide-react";
import {
Command as CommandIcon,
ImagePlus,
Sparkles,
User,
Zap,
} from "lucide-react";
import {
Command,
CommandGroup,
@@ -9,6 +15,8 @@ import {
} from "@/components/ui/command";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import { resolveServiceSkillEntryDescription } from "@/components/agent/chat/service-skills/entryAdapter";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type { CodexSlashCommandDefinition } from "../../../commands";
import type { BuiltinInputCommand } from "./builtinCommands";
@@ -17,12 +25,14 @@ interface CharacterMentionPanelProps {
mentionQuery: string;
builtinCommands: BuiltinInputCommand[];
slashCommands: CodexSlashCommandDefinition[];
mentionServiceSkills: ServiceSkillHomeItem[];
filteredCharacters: Character[];
installedSkills: Skill[];
availableSkills: Skill[];
commandRef: React.RefObject<HTMLDivElement>;
onQueryChange: (query: string) => void;
onSelectBuiltinCommand: (command: BuiltinInputCommand) => void;
onSelectServiceSkill: (skill: ServiceSkillHomeItem) => void;
onSelectSlashCommand: (command: CodexSlashCommandDefinition) => void;
onSelectCharacter: (character: Character) => void;
onSelectInstalledSkill: (skill: Skill) => void;
@@ -35,12 +45,14 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
mentionQuery,
builtinCommands,
slashCommands,
mentionServiceSkills,
filteredCharacters,
installedSkills,
availableSkills,
commandRef,
onQueryChange,
onSelectBuiltinCommand,
onSelectServiceSkill,
onSelectSlashCommand,
onSelectCharacter,
onSelectInstalledSkill,
@@ -48,11 +60,13 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
onNavigateToSettings,
}) => {
const visibleBuiltinCommands = mode === "mention" ? builtinCommands : [];
const visibleServiceSkills = mode === "mention" ? mentionServiceSkills : [];
const visibleCharacters = mode === "mention" ? filteredCharacters : [];
const visibleSlashCommands = mode === "slash" ? slashCommands : [];
const hasFilteredResults =
visibleSlashCommands.length > 0 ||
visibleBuiltinCommands.length > 0 ||
visibleServiceSkills.length > 0 ||
visibleCharacters.length > 0 ||
installedSkills.length > 0 ||
availableSkills.length > 0;
@@ -60,14 +74,18 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
return (
<Command ref={commandRef} className="bg-background">
<CommandInput
placeholder={mode === "slash" ? "搜索命令或技能..." : "搜索角色或技能..."}
placeholder={
mode === "slash" ? "搜索命令或技能..." : "搜索角色或技能..."
}
value={mentionQuery}
onValueChange={onQueryChange}
/>
<CommandList>
{!hasFilteredResults ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
<div>{mode === "slash" ? "暂无可用命令或技能" : "暂无可用角色或技能"}</div>
<div>
{mode === "slash" ? "暂无可用命令或技能" : "暂无可用角色或技能"}
</div>
{onNavigateToSettings ? (
<button
type="button"
@@ -125,6 +143,25 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
))}
</CommandGroup>
) : null}
{visibleServiceSkills.length > 0 ? (
<CommandGroup heading="服务技能">
{visibleServiceSkills.map((skill) => (
<CommandItem
key={skill.id}
onSelect={() => onSelectServiceSkill(skill)}
className="cursor-pointer"
>
<Sparkles className="mr-2 h-4 w-4 text-emerald-600" />
<div className="flex-1">
<div className="font-medium">{skill.title}</div>
<div className="text-xs text-muted-foreground line-clamp-1">
{resolveServiceSkillEntryDescription(skill)}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
{visibleCharacters.length > 0 ? (
<CommandGroup heading="角色">
{visibleCharacters.map((character) => (
@@ -191,4 +228,4 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
</CommandList>
</Command>
);
}
};
@@ -2,6 +2,7 @@ import React, { useState } from "react";
import type { ChatInputAdapter } from "@/components/input-kit/adapters/types";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type {
AsterSessionExecutionRuntime,
QueuedTurnSnapshot,
@@ -12,7 +13,6 @@ import { InputbarCore } from "./InputbarCore";
import { SkillSelector } from "./SkillSelector";
import type { BuiltinInputCommand } from "./builtinCommands";
import { TeamSelector } from "./TeamSelector";
import { TeamModeEntryButton } from "./TeamModeEntryButton";
import { ThemeWorkbenchStatusPanel } from "./ThemeWorkbenchStatusPanel";
import { InputbarModelExtra } from "./InputbarModelExtra";
import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice";
@@ -20,7 +20,6 @@ import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySele
import { StableProcessingNotice } from "../../StableProcessingNotice";
import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt";
import type { TeamDefinition } from "../../../utils/teamDefinitions";
import { getTeamSuggestion } from "../../../utils/teamSuggestion";
import { shouldShowStableProcessingNotice } from "../../../utils/stableProcessingExperience";
import type { WorkspaceSettings } from "@/types/workspace";
import type {
@@ -37,12 +36,14 @@ interface InputbarComposerSectionProps {
inputAdapter: ChatInputAdapter;
characters: Character[];
skills: Skill[];
serviceSkills?: ServiceSkillHomeItem[];
isSkillsLoading?: boolean;
textareaRef: React.RefObject<HTMLTextAreaElement>;
input: string;
activeSkill?: Skill | null;
onSelectCharacter?: (character: Character) => void;
onSelectSkill: (skill: Skill) => void;
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
onSelectBuiltinCommand: (command: BuiltinInputCommand | null) => void;
onClearSkill?: () => void;
onNavigateToSettings?: () => void;
@@ -88,12 +89,14 @@ export const InputbarComposerSection: React.FC<
inputAdapter,
characters,
skills,
serviceSkills = [],
isSkillsLoading,
textareaRef,
input,
activeSkill,
onSelectCharacter,
onSelectSkill,
onSelectServiceSkill,
onSelectBuiltinCommand,
onClearSkill,
onNavigateToSettings,
@@ -131,15 +134,6 @@ export const InputbarComposerSection: React.FC<
>(null);
const showSkillSelector =
!isThemeWorkbenchVariant && isGeneralResearchTheme(activeTheme);
const teamSuggestion =
!activeTools["subagent_mode"] && isGeneralResearchTheme(activeTheme)
? getTeamSuggestion({
input,
activeTheme,
subagentEnabled: false,
})
: null;
const shouldRecommendTeamEntry = Boolean(teamSuggestion?.shouldSuggest);
const currentPendingImages =
(inputAdapter.state.attachments as MessageImage[] | undefined) ||
pendingImages;
@@ -177,11 +171,15 @@ export const InputbarComposerSection: React.FC<
) : null}
</>
) : undefined;
const handleEnableTeamMode = () => {
if (!selectedTeam) {
const handleToolAction = (tool: string) => {
if (
tool === "subagent_mode" &&
!activeTools["subagent_mode"] &&
!selectedTeam
) {
setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1);
}
onToolClick("subagent_mode");
onToolClick(tool);
};
if (renderThemeWorkbenchGeneratingPanel) {
@@ -210,11 +208,13 @@ export const InputbarComposerSection: React.FC<
<CharacterMention
characters={characters}
skills={skills}
serviceSkills={serviceSkills}
inputRef={textareaRef}
value={input}
onChange={inputAdapter.actions.setText}
onSelectCharacter={onSelectCharacter}
onSelectSkill={onSelectSkill}
onSelectServiceSkill={onSelectServiceSkill}
onSelectBuiltinCommand={onSelectBuiltinCommand}
onNavigateToSettings={onNavigateToSettings}
/>
@@ -226,7 +226,7 @@ export const InputbarComposerSection: React.FC<
onStop={inputAdapter.actions.stop}
isLoading={inputAdapter.state.isSending}
disabled={inputAdapter.state.disabled}
onToolClick={onToolClick}
onToolClick={handleToolAction}
activeTools={activeTools}
executionStrategy={executionStrategy}
showExecutionStrategy={false}
@@ -282,15 +282,7 @@ export const InputbarComposerSection: React.FC<
onPersistCustomTeams={onPersistCustomTeams}
onSelectTeam={(team) => onSelectTeam?.(team)}
/>
) : (
<TeamModeEntryButton
selectedTeamLabel={selectedTeam?.label}
dataTestId="team-mode-enable-button"
recommended={shouldRecommendTeamEntry}
hint={teamSuggestion?.reasons?.[0]}
onClick={handleEnableTeamMode}
/>
)
) : null
) : null}
<InputbarModelExtra
isFullscreen={isFullscreen}
@@ -1,56 +0,0 @@
import React from "react";
import { Workflow } from "lucide-react";
import { cn } from "@/lib/utils";
interface TeamModeEntryButtonProps {
selectedTeamLabel?: string | null;
onClick: () => void;
className?: string;
dataTestId?: string;
recommended?: boolean;
hint?: string;
}
export const TeamModeEntryButton: React.FC<TeamModeEntryButtonProps> = ({
selectedTeamLabel,
onClick,
className,
dataTestId,
recommended = false,
hint,
}) => {
const trimmedLabel = selectedTeamLabel?.trim() || "";
const buttonLabel = trimmedLabel ? `开启 Team · ${trimmedLabel}` : "开启 Team";
const title =
hint?.trim() ||
(recommended
? "当前任务更适合拆分协作,但仍由你手动决定是否启用 Team"
: "仅在当前任务适合拆分协作时启用 Team");
return (
<button
type="button"
data-testid={dataTestId}
onClick={onClick}
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium shadow-none transition-colors",
recommended
? "border-sky-300 bg-sky-50 text-sky-700 hover:border-sky-400 hover:bg-sky-100"
: "border-fuchsia-200 bg-fuchsia-50 text-fuchsia-700 hover:border-fuchsia-300 hover:bg-fuchsia-100",
className,
)}
title={title}
aria-label={buttonLabel}
>
<Workflow className="h-3.5 w-3.5" />
<span className="max-w-[180px] truncate">{buttonLabel}</span>
{recommended ? (
<span className="rounded-full border border-sky-200 bg-white px-1.5 py-0.5 text-[10px] font-semibold leading-none text-sky-700">
推荐
</span>
) : null}
</button>
);
};
export default TeamModeEntryButton;
@@ -5,10 +5,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Inputbar } from "./index";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
const mockCharacterMention =
vi.fn<
(props: { characters?: Character[]; skills?: Skill[] }) => React.ReactNode
(props: {
characters?: Character[];
skills?: Skill[];
serviceSkills?: ServiceSkillHomeItem[];
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
}) => React.ReactNode
>();
const mockInputbarCore = vi.fn(
(props: {
@@ -33,6 +39,16 @@ const mockInputbarCore = vi.fn(
<span data-testid="web-search-state">
{props.activeTools?.web_search ? "on" : "off"}
</span>
<button
type="button"
data-testid="toggle-subagent-mode"
onClick={() => props.onToolClick?.("subagent_mode")}
>
切换多代理
</button>
<span data-testid="subagent-state">
{props.activeTools?.subagent_mode ? "on" : "off"}
</span>
<button
type="button"
data-testid="send-btn"
@@ -62,7 +78,12 @@ vi.mock("./components/InputbarCore", () => ({
}));
vi.mock("./components/CharacterMention", () => ({
CharacterMention: (props: { characters?: Character[]; skills?: Skill[] }) => {
CharacterMention: (props: {
characters?: Character[];
skills?: Skill[];
serviceSkills?: ServiceSkillHomeItem[];
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
}) => {
mockCharacterMention(props);
return <div data-testid="character-mention-stub" />;
},
@@ -228,6 +249,34 @@ describe("Inputbar", () => {
expect(latestCall.skills).toEqual([]);
});
it("应将服务型技能目录与选择回调透传给 CharacterMention", async () => {
const serviceSkills = [
{
id: "daily-trend-briefing",
title: "每日趋势摘要",
},
] as ServiceSkillHomeItem[];
const onSelectServiceSkill = vi.fn();
renderInputbar({
serviceSkills,
onSelectServiceSkill,
});
await act(async () => {
await Promise.resolve();
});
expect(mockCharacterMention.mock.calls.length).toBeGreaterThan(0);
const latestCall =
mockCharacterMention.mock.calls[
mockCharacterMention.mock.calls.length - 1
][0];
expect(latestCall.serviceSkills).toBe(serviceSkills);
expect(latestCall.onSelectServiceSkill).toBe(onSelectServiceSkill);
});
it("存在 executionRuntime 时应展示最近执行模型与结构化输出提示", async () => {
const container = renderInputbar({
providerType: "openai",
@@ -358,9 +407,6 @@ describe("Inputbar", () => {
expect(
container.querySelector('[data-testid="team-selector-stub"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="team-mode-enable-button"]'),
).toBeTruthy();
const enabledContainer = renderInputbar({
activeTheme: "general",
@@ -379,12 +425,9 @@ describe("Inputbar", () => {
expect(
enabledContainer.querySelector('[data-testid="team-selector-stub"]'),
).toBeTruthy();
expect(
enabledContainer.querySelector('[data-testid="team-mode-enable-button"]'),
).toBeNull();
});
it("未开启 Team mode 时应显示显式开启按钮,并可直接启用", async () => {
it("未开启 Team mode 时应只保留图标开关,并可直接启用", async () => {
const onToolStatesChange = vi.fn();
const container = renderInputbar({
activeTheme: "general",
@@ -401,12 +444,15 @@ describe("Inputbar", () => {
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="team-mode-enable-button"]'),
).toBeNull();
const enableButton = container.querySelector(
'[data-testid="team-mode-enable-button"]',
'[data-testid="toggle-subagent-mode"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("开启 Team");
act(() => {
enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -420,7 +466,7 @@ describe("Inputbar", () => {
});
});
it("点击开启 Team 后应自动透传 Team 配置面板打开令牌", async () => {
it("点击多代理图标后应自动透传 Team 配置面板打开令牌", async () => {
const container = renderInputbar({
activeTheme: "general",
});
@@ -431,7 +477,7 @@ describe("Inputbar", () => {
});
const enableButton = container.querySelector(
'[data-testid="team-mode-enable-button"]',
'[data-testid="toggle-subagent-mode"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
@@ -453,7 +499,7 @@ describe("Inputbar", () => {
expect(teamSelector?.getAttribute("data-auto-open-token")).toBe("1");
});
it("复杂任务但未开启 Team 时,开启按钮应显示推荐态", async () => {
it("复杂任务但未开启 Team 时,保留推荐提示但不再渲染重复入口", async () => {
const container = renderInputbar({
activeTheme: "general",
input: "请把这个跨模块问题拆分成分析、实现、验证三个并行子任务再汇总",
@@ -469,13 +515,18 @@ describe("Inputbar", () => {
await Promise.resolve();
});
const enableButton = container.querySelector(
'[data-testid="team-mode-enable-button"]',
) as HTMLButtonElement | null;
const recommendationButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("启用 Team"));
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("开启 Team");
expect(enableButton?.textContent).toContain("推荐");
expect(
container.querySelector('[data-testid="team-mode-enable-button"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="toggle-subagent-mode"]'),
).toBeTruthy();
expect(recommendationButton).toBeTruthy();
expect(container.textContent).toContain("当前任务更适合 Team 协作");
});
it("社媒主题默认应自动注入 social_post_with_cover skill", async () => {
@@ -2,6 +2,7 @@ import React from "react";
import type { MessageImage } from "../../types";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type {
AsterSessionExecutionRuntime,
QueuedTurnSnapshot,
@@ -59,10 +60,14 @@ interface InputbarProps {
characters?: Character[];
/** 技能列表(用于 @ 引用) */
skills?: Skill[];
/** 服务型技能列表(用于 @ 引用) */
serviceSkills?: ServiceSkillHomeItem[];
/** 技能列表加载状态 */
isSkillsLoading?: boolean;
/** 选择角色回调 */
onSelectCharacter?: (character: Character) => void;
/** 选择服务型技能回调 */
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
/** 跳转到设置页安装技能 */
onNavigateToSettings?: () => void;
/** 导入本地技能 */
@@ -122,8 +127,10 @@ export const Inputbar: React.FC<InputbarProps> = ({
overlayAccessory,
characters = [],
skills = [],
serviceSkills = [],
isSkillsLoading = false,
onSelectCharacter,
onSelectServiceSkill,
onNavigateToSettings,
onImportSkill,
onRefreshSkills,
@@ -248,12 +255,14 @@ export const Inputbar: React.FC<InputbarProps> = ({
inputAdapter={inputAdapter}
characters={characters}
skills={skills}
serviceSkills={serviceSkills}
isSkillsLoading={isSkillsLoading}
textareaRef={textareaRef}
input={input}
activeSkill={activeSkill}
onSelectCharacter={onSelectCharacter}
onSelectSkill={setActiveSkill}
onSelectServiceSkill={onSelectServiceSkill}
onSelectBuiltinCommand={setActiveBuiltinCommand}
onClearSkill={clearActiveSkill}
onNavigateToSettings={onNavigateToSettings}
@@ -7,7 +7,6 @@ Agent 聊天界面的 UI 组件集合。
| 文件 | 说明 |
|------|------|
| `ChatNavbar.tsx` | 聊天顶部导航栏 |
| `ChatSettings.tsx` | 聊天设置面板 |
| `ChatSidebar.tsx` | 聊天侧边栏(会话列表) |
| `CanvasWorkbenchLayout.tsx` | 画布内工作台布局,宽屏侧栏/窄屏底部工作台均支持产物、全部文件、变更、预览与下载动作 |
| `EmptyState.tsx` | 空状态占位组件 |
@@ -69,7 +69,7 @@ export const EMPTY_STATE_ICON_TOOL_BUTTON_CLASSNAME =
const EMPTY_STATE_TOOL_TOGGLE_TONE_CLASSNAMES: Record<EmptyStateTone, string> = {
slate:
"border-slate-300 bg-slate-100 text-slate-700 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-700",
"border-slate-400 bg-slate-200 text-slate-900 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)] hover:border-slate-400 hover:bg-slate-200 hover:text-slate-900",
sky:
"border-sky-300 bg-sky-50 text-sky-700 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700",
emerald:

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