mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.97.0
This commit is contained in:
@@ -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 }}
|
||||
@@ -45,6 +45,7 @@ docs/roadmap/*
|
||||
docs/gongzonghao/
|
||||
docs/bussniss/
|
||||
docs/oem/
|
||||
docs/tech/
|
||||
|
||||
# Issues tracking (internal use only)
|
||||
.issues/
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 与前端
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,18 @@
|
||||
|
||||
其余实现必须被明确归类。
|
||||
|
||||
## 路线图任务防跑偏
|
||||
|
||||
如果用户明确绑定了某份路线图,尤其是要求“按顺序继续”“对齐目标”“先完成主线”,治理动作必须服从路线图主线,而不是反过来主导路线图。
|
||||
|
||||
执行时额外遵守:
|
||||
|
||||
1. 先重述当前路线图的 **主目标 / 当前阶段 / 下一刀**
|
||||
2. 只有当 dead / compat / deprecated surface **直接阻碍主线收口** 时,才优先做治理减法
|
||||
3. 不要把“还能删一点旧代码”误当成“继续推进目标”
|
||||
4. 连续两轮主要都在删零引用或补文档时,必须重新打开路线图,改选尚未完成的主链项
|
||||
5. 汇报治理结果时,必须补一句“这一步如何服务路线图主线”;如果说不出来,就说明这一步不该先做
|
||||
|
||||
## 分类语言
|
||||
|
||||
治理默认使用这四类:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 / 续测文档分两层:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 约定。"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. 当前建议
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Generated
+17
-17
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
// 服务状态
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
/// 自动续写参数
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(¶ms, &["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(¶ms, &["target_id"]);
|
||||
let timeout_ms = params.get("timeout_ms").and_then(serde_json::Value::as_u64);
|
||||
let project_target = Self::resolve_project_target(¶ms, context).await;
|
||||
let save_target = Self::resolve_save_target(¶ms, context).await;
|
||||
let save_title = Self::extract_optional_string(¶ms, &["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 内置站点适配器。",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "日报正文内容" }
|
||||
]
|
||||
}"#,
|
||||
¶ms,
|
||||
)
|
||||
.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("社媒初稿")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
+157
-37
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+16
-24
@@ -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
Reference in New Issue
Block a user