release: v1.10.0

This commit is contained in:
coso
2026-04-13 13:07:08 +08:00
parent 0f15b8a71e
commit bf03e44ac7
273 changed files with 12344 additions and 26363 deletions
+46 -12
View File
@@ -224,9 +224,11 @@ jobs:
FINAL_STATUS="success"
FAILURE_REASON=""
NEXT_ACTION="No extra action required."
if [[ "${DOCS_ONLY}" == 'true' ]]; then
SUMMARY_NOTE="Only docs/markdown changes detected -> quality checks not required."
NEXT_ACTION="No action required for docs-only changes."
else
SUMMARY_NOTE="Quality checks evaluated against changed areas."
@@ -257,26 +259,58 @@ jobs:
FAILURE_REASON="gui smoke failed"
fi
fi
if [[ -n "${FAILURE_REASON}" ]]; then
if [[ "${FAILURE_REASON}" == "integrity failed" ]]; then
NEXT_ACTION='Run `npm run verify:app-version` and sync package / Cargo / Tauri version files.'
elif [[ "${FAILURE_REASON}" == "frontend failed" ]]; then
NEXT_ACTION='Run `npm run verify:local` (or `npm run lint && npm run typecheck && npm test`) to reproduce the frontend failure locally.'
elif [[ "${FAILURE_REASON}" == "bridge/contracts failed" ]]; then
if [[ ",${BRIDGE_REASONS}," == *",harness_cleanup_contract,"* ]]; then
NEXT_ACTION='Run `npm run harness:cleanup-report:check` first, then `npm run test:contracts`.'
elif [[ ",${BRIDGE_REASONS}," == *",bridge_runtime,"* ]]; then
NEXT_ACTION='Run `npm run test:bridge` first, then `npm run test:contracts`.'
else
NEXT_ACTION='Run `npm run verify:local` or `npm run test:bridge && npm run test:contracts` to reproduce the bridge/contracts failure.'
fi
elif [[ "${FAILURE_REASON}" == "gui smoke failed" ]]; then
NEXT_ACTION='Run `npm run verify:gui-smoke -- --timeout-ms 480000` and inspect DevBridge / headless Tauri readiness.'
fi
fi
fi
{
echo "## Quality Summary"
echo
echo "| Item | Value |"
echo "| --- | --- |"
echo "| changed_count | ${CHANGED_COUNT} |"
echo "| docs_only | ${DOCS_ONLY} |"
echo "| bridge_reasons | ${BRIDGE_REASONS_DISPLAY} |"
echo "| integrity | ${INTEGRITY_RESULT} |"
echo "| frontend | ${FRONTEND_RESULT} |"
echo "| bridge_contracts | ${BRIDGE_RESULT} |"
echo "| gui_smoke | ${GUI_SMOKE_RESULT} |"
echo "| final_status | ${FINAL_STATUS} |"
echo "**Final status:** ${FINAL_STATUS}"
echo
echo "${SUMMARY_NOTE}"
echo "### Scope"
echo
echo "- changed_count: ${CHANGED_COUNT}"
echo "- docs_only: ${DOCS_ONLY}"
echo "- bridge_reasons: ${BRIDGE_REASONS_DISPLAY}"
echo
echo "### Required Gates"
echo
echo "| Gate | Required | Result |"
echo "| --- | --- | --- |"
echo "| integrity | ${INTEGRITY_REQUIRED} | ${INTEGRITY_RESULT} |"
echo "| frontend | ${FRONTEND_REQUIRED} | ${FRONTEND_RESULT} |"
echo "| bridge_contracts | ${BRIDGE_REQUIRED} | ${BRIDGE_RESULT} |"
echo "| gui_smoke | ${GUI_SMOKE_REQUIRED} | ${GUI_SMOKE_RESULT} |"
echo
echo "### Notes"
echo
echo "- ${SUMMARY_NOTE}"
echo
echo "### Recommended Next Action"
echo
echo "- ${NEXT_ACTION}"
if [[ -n "${FAILURE_REASON}" ]]; then
echo
echo "Failure reason: ${FAILURE_REASON}"
echo "### Failure"
echo
echo "- ${FAILURE_REASON}"
fi
} >> "$GITHUB_STEP_SUMMARY"
+4 -1
View File
@@ -44,6 +44,9 @@ docs/roadmap/*
!docs/roadmap/artifacts/architecture-blueprint.md
!docs/roadmap/artifacts/framework-boundary.md
!docs/roadmap/artifacts/system-prompt-and-schema-contract.md
!docs/roadmap/harness-engine/
!docs/roadmap/harness-engine/README.md
!docs/roadmap/harness-engine/diagrams.md
docs/gongzonghao/
docs/bussniss/
docs/oem/
@@ -101,4 +104,4 @@ governance/
!src/lib/governance/*.mjs
!src/lib/governance/*.test.ts
src-tauri/crates/aster-rust/target/
src-tauri/crates/aster-rust/target/
+23 -22
View File
@@ -1,38 +1,39 @@
## Lime v1.9.0
## Lime v1.10.0
### ✨ 主要更新
- 本次 `v1.9.0` 已收口当前工作区全部改动,核心集中在 Agent 聊天工作台、General Workbench、Service Skill、Team Workspace、Artifact / Timeline 展示与输入发送主链
- `src/components/agent/**`、`src/components/workspace/**`、`src/lib/api/**`、`src/features/browser-runtime/**`、`src/components/settings-v2/**` 一批界面、运行时与回归测试已一并进入本次发布
- 浏览器运行时、现有会话桥接、工具展示、团队协作、项目选择、技能目录、工作台工具命令与内容同步相关边界已同步更新
- 工程文档 `docs/aiprompts/commands.md`、`playwright-e2e.md`、`quality-workflow.md` 已随当前实现一起更新
- 本次 `v1.10.0` 重点把 Harness Engine 的验证事实源进一步收口到同一条主链:`evidence / analysis / review / dashboard / cleanup` 现在共享同一套 verification facts 语义,前端 review 与 evidence 展示也开始复用统一的验证结果区块
- Agent 工作台继续围绕 General Workbench、Harness 状态、Tool Search / Tool Call、Inline Process Step、Message List 与 Review Decision 做交互收敛,工作区输入发送与场景运行时同步补齐了一批回归测试
- 资源工作台补上图片资源工作台与分类浏览能力,Provider Pool 同步把 Prompt Cache 认知前置到配置 UI,`anthropic-compatible` 渠道与官方兼容 Host 的展示口径进一步统一
- 仓库治理继续做减法:独立 `terminal / tools / image-gen / video` 页面面已下线,只保留当前主路径需要的运行时与 API 能力,侧边栏与旧页面残留同步清退
- `docs/roadmap/harness-engine/`、`docs/aiprompts/quality-workflow.md`、`docs/aiprompts/terminal.md`、`docs/aiprompts/providers.md` 等文档已按当前实现刷新,长期路线图与工程边界描述同步更新
### 🔗 依赖与版本同步
### 🔗 版本与发布同步
- `aster-core` / `aster-models` 已内置到 `src-tauri/crates/aster-rust/`,不再依赖外部仓库本地 override
- 应用与 CLI 发布版本提升到 `1.9.0`
- 应用版本入口已对齐到 `1.9.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`
- `packages/lime-cli-npm/package.json`、README 发布示例与本地 `package-lock.json` 已同步到 `1.9.0`
- `src-tauri/Cargo.lock` 已在本轮 Rust 校验后同步更新到当前 workspace 状态
### ⚠️ 发布说明
- 本次发布 tag 为 `v1.9.0`
- 本次发布以当前工作区完整改动为准,不复用旧 tag
- 当前 release note 已按这次完整发布内容刷新
- 应用、Rust workspace 与 CLI npm wrapper 版本已统一提升到 `1.10.0`
- 应用版本入口已对齐到 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`
- `package-lock.json`、`src-tauri/Cargo.lock`、`packages/lime-cli-npm/package.json` 与 CLI README 示例已同步到当前版本
- 本次发布目标 tag 为 `v1.10.0`
### 🧪 已执行校验
- `npm run verify:app-version`
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`
- `npm test -- src/components/settings-v2/system/about/index.test.tsx`
- `cargo test --manifest-path "src-tauri/Cargo.toml"`
- `cargo clippy --manifest-path "src-tauri/Cargo.toml"`
- `cargo clippy --manifest-path "src-tauri/Cargo.toml"`:通过,当前包含 1 条 `clippy::if_same_then_else` 告警,位置在 `src-tauri/crates/core/src/models/provider_pool_model.rs`
- `npm run lint`
### 📝 文档同步
### ⏳ 待执行发布动作
- 发布说明已更新为当前这次完整的 `v1.9.0` 内容,可直接作为 GitHub Release note 使用
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`
- 创建并推送 `v1.10.0` tag
- 推送当前分支到 GitHub
### 📝 发布说明
- 本次发布说明按当前工作区完整改动刷新,重点覆盖 Harness Engine 验证闭环、Agent Workspace 交互收口、资源工作台与 Provider 配置体验,以及旧页面面的治理减法
- 由于 `cargo fmt --all` 和 `git tag / git push` 具有批量改写或发布风险,当前 release note 已明确把它们标记为待执行动作;完成后可直接作为 GitHub Release note 使用
---
**完整变更**: `v1.8.0` -> `v1.9.0`
**完整变更**: `v1.9.0` -> `v1.10.0`
+1
View File
@@ -68,6 +68,7 @@
同理,聊天运行时初始化的 `aster_agent_init` 在浏览器 DevBridge 模式下也不能再被放进 `mockPriorityCommands`。只要桥接在线,它就必须优先读取后端真实 `provider_name / model_name`,让聊天入口拿到当前运行时模型。
进一步地,围绕运行时模型解析的真相命令:`aster_agent_init`、`get_default_provider`、`get_provider_pool_overview`、`get_api_key_providers`、`get_model_registry`、`get_provider_alias_config`、`fetch_provider_models_auto`、`get_model_registry_provider_ids`,在浏览器 DevBridge 模式下如果桥接失败,必须直接抛错,不能再通过 `safeInvoke` 静默退回 mock;否则前端会把“后端未连上 / 命令失败”误显示成假的 Provider / 模型列表。
同时要明确,`aster_agent_init` 只负责初始化 Agent,并不保证已经完成 Provider 配置;当它未返回 `provider_name / model_name` 时,前端不得把本地硬编码默认值当作真实模型,而应继续回退到 `get_default_provider` + 已配置 Provider/模型注册表解析链,拿到当前工作区真正可用的 `provider/model`。
同一条约束也适用于 Prompt Cache 能力判断:运行时与前端都不得因为某个自定义 Provider “长得像 Anthropic 协议”就推断它支持官方 Anthropic Automatic Prompt Caching。当前事实源必须继续按 ProviderType 判断:`anthropic` 走自动缓存能力,`anthropic-compatible` 只保留显式 `cache_control` 语义;若上游没有实现 Automatic Prompt Cache,`cached_input_tokens` 为空不能直接归因到 Lime 没发字段。
文档导出链路同样遵循这条路径。当前主入口为 `src/lib/api/document-export.ts`,统一承接:
+1
View File
@@ -213,6 +213,7 @@ lime/
### 7. 多 Provider 与兼容层
- OAuth 与 API Key Provider 并存
- 凭证池、模型路由、协议兼容与 HTTP Server 作为底层支撑
- Prompt Cache 等运行时能力按 ProviderType 判断;`anthropic-compatible` 只表示 Anthropic wire format 兼容,不等于自动 Prompt Cache 能力
### 8. 本地优先与可扩展
- 桌面应用、本地工作区、插件与外部工具扩展
+7 -4
View File
@@ -140,10 +140,13 @@ npm run test:contracts
1. 进入 `设置 -> AI 服务商`
2. 确认默认落在 `服务商设置`,左侧能看到 Provider 列表,右侧是当前 Provider 配置
3. 确认首屏不会默认混入 OEM Offer、套餐或云端模型目录
4. 点击 `云端服务`
5. 确认 OEM 会话、Offer 卡片、默认来源和模型目录改为在该页单独展示
6. 如当前环境故意破坏了 `models/index.json`,确认 Provider 模型区会提示“模型真相源异常”,而不是静默显示空态
3. 如果列表中存在 `anthropic-compatible` Provider,确认左侧会展示 `显式缓存` badge,而不是暗示自动 Prompt Cache
4. 点进该 Provider 后,确认右侧头部仍展示 `显式缓存` badge
5. 进入编辑区后,确认 `Provider 类型 / API Host` 附近会提示“Anthropic 兼容不等于自动 Prompt Cache,需要显式 cache_control”
6. 确认首屏不会默认混入 OEM Offer、套餐或云端模型目录
7. 点击 `云端服务`
8. 确认 OEM 会话、Offer 卡片、默认来源和模型目录改为在该页单独展示
9. 如当前环境故意破坏了 `models/index.json`,确认 Provider 模型区会提示“模型真相源异常”,而不是静默显示空态
### 社媒内容工作流
+11 -4
View File
@@ -128,22 +128,29 @@ anthropic-version: 2023-06-01
## Prompt Cache 能力边界
Lime 当前把 Prompt Cache 能力视为 **Provider 类型能力**,而不是“请求长得像哪家协议”:
Lime 当前把 Prompt Cache 能力视为 **Provider 显式声明优先、类型默认兜底**,而不是“请求长得像哪家协议”:
- `anthropic` / `claude` / `claude-oauth`:声明为 `automatic`
- `anthropic-compatible`:声明为 `explicit_only`
- `anthropic` / `claude` / `claude-oauth`:默认 `automatic`
- `anthropic-compatible`:默认 `explicit_only`,但自定义 Provider 可显式声明为 `automatic`
- 其它 Provider:默认 `not_applicable`
前台提示层额外保留一个**已知官方 Host 例外**:
- 对 `https://open.bigmodel.cn/api/anthropic` 这类智谱官方 Anthropic 兼容 Host,Lime 前台不再把它误报成“仅显式缓存”
- 这只影响 UI 提示与 badge 收口,不代表 Lime 会把该 Host 直接等同于 Anthropic `cache_control` 自动注入语义
这条事实源当前收敛在:
- 前端:`src/lib/model/providerPromptCacheSupport.ts`
- 后端:Provider 类型与运行时能力判断链
- 模型注册表映射:只负责 provider/model 目录归一,不参与 Prompt Cache 能力推断
需要特别注意:
1. `anthropic-compatible` 只表示接入方兼容 Anthropic wire format,不等于上游已经实现 Anthropic Automatic Prompt Caching
2. Lime 不会因为某个自定义渠道“长得像 Anthropic”就默认把它当成官方 Anthropic 自动缓存能力
3. 对 `anthropic-compatible` 渠道,Lime 只保留显式 `cache_control` 语义;如果上游没有实现 Automatic Prompt Cache,`cached_input_tokens` 为空不能直接归因到 Lime 没发字段
3. 对自定义 `anthropic-compatible` 渠道,只有在上游明确声明支持 Automatic Prompt Cache 时才应配置为 `automatic`
4. 若未声明自动缓存,Lime 只保留显式 `cache_control` 语义;如果上游没有实现 Automatic Prompt Cache,`cached_input_tokens` 为空不能直接归因到 Lime 没发字段
排查这类问题时,优先确认三件事:
+9
View File
@@ -96,6 +96,7 @@
- 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言
- 如果目标区域已有 snapshot / 结构化快照机制,沿用现有机制
- 不要因为“只是 UI”就跳过回归
- 如果改动涉及 Provider 类型切换、Prompt Cache 提示或模型/协议能力认知,至少补到“列表扫描态、详情头部、创建/编辑入口、聊天发送前或结果解释”中的实际受影响落点,避免同一语义只在单点出现
### 4. 配置与依赖改动必须成组提交
@@ -198,6 +199,8 @@ node scripts/check-generated-slop-report.mjs --input "<cleanup-json>"
同时,`scripts/report-generated-slop.mjs`、`scripts/check-generated-slop-report.mjs`、`scripts/harness-eval-history-record.mjs`、`scripts/harness-eval-trend-report.mjs`、`scripts/lib/generated-slop-report-core.mjs`、`scripts/lib/harness-dashboard-core.mjs` 这条 harness cleanup/report 主链,在 `verify:local` 的 smart 模式里默认也按 bridge/contracts 风险处理。
本地 `verify:local` 输出里如果看到 `bridge 校验(harness cleanup contract)`,说明命中的就是这条 cleanup/report 契约门禁,而不是普通 DevBridge 变更。
CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge_reasons`,并写入 `GITHUB_STEP_SUMMARY`,用于区分这次是 `harness_cleanup_contract`、`bridge_runtime`,还是 `workflow_full_suite` / `fallback_full_suite` 这类全量触发。
结果摘要默认按 `Scope / Required Gates / Notes / Recommended Next Action / Failure` 分段,优先让人一眼看清“为什么触发”“哪些门禁必跑”“最终为什么失败”,以及失败后本地最应该先跑哪条命令。
如果命中的是 `harness_cleanup_contract`,推荐动作应优先指向 `npm run harness:cleanup-report:check`,而不是只给一条泛化的 bridge 校验建议。
作用:
@@ -460,6 +463,12 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge
- 资源索引损坏时,GUI 会明确提示“模型真相源异常”
- 不会再静默回退数据库或把错误伪装成空模型列表
如果本轮修改了 Provider 类型与 Prompt Cache 能力边界,还应额外确认:
- `anthropic-compatible` 不会再被 UI 或运行时误显示成“自动 Prompt Cache”
- Provider Pool 的列表、详情、创建和编辑入口中,受影响落点会继续提示“显式 cache_control”
- 聊天侧 `ModelSelector / Inputbar / MessageList / TokenUsageDisplay` 与 Provider Pool 的口径保持一致
### Layer 4:交互型 E2E
入口:
+9 -33
View File
@@ -1,8 +1,8 @@
# 内置终端
# 终端底层能力
## 概述
内置终端模块提供 PTY 管理和会话管理功能。
Lime 仍保留终端底层能力,用于复用运行时、诊断与会话管理;独立前端 `terminal / sysinfo / files / web` 页面已经下线,不再保留 `src/components/terminal/` 页面模块。
## 目录结构
@@ -13,9 +13,11 @@ src-tauri/src/terminal/
├── session.rs # 会话管理
└── commands.rs # 终端命令
src/components/terminal/
├── Terminal.tsx # 终端组件
└── TerminalTabs.tsx # 多标签管理
src/lib/api/terminal.ts
src/lib/terminal/
├── store/ # 终端状态与输入态
├── stickers/ # 终端贴纸状态
└── vdom/ # VDOM 状态与类型
```
## PTY 管理
@@ -49,35 +51,9 @@ impl PtyManager {
}
```
## 前端组件
## 前端边界
```tsx
// src/components/terminal/Terminal.tsx
export function Terminal({ sessionId }: { sessionId: string }) {
const termRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerm>();
useEffect(() => {
const xterm = new XTerm();
xterm.open(termRef.current!);
xtermRef.current = xterm;
// 监听输出
listen(`terminal-output-${sessionId}`, (event) => {
xterm.write(event.payload);
});
// 发送输入
xterm.onData((data) => {
invoke('terminal_write', { sessionId, data });
});
return () => xterm.dispose();
}, [sessionId]);
return <div ref={termRef} className="h-full" />;
}
```
前端当前只允许通过 `src/lib/api/terminal.ts` 和 `src/lib/terminal/*` 复用终端会话、事件和状态能力,不再新增独立页面壳。
## Tauri 命令
@@ -59,7 +59,7 @@ Lime 当前仅提供 macOS 与 Windows 桌面端安装包,Linux 版本已暂
启动 Lime 后,你应该看到:
1. 主窗口正常打开
2. 左侧出现主要入口(AI Agent、项目、资源、图片生成等)
2. 左侧出现主要入口(AI Agent、项目、资源、设置等)
3. 可以进入设置页并看到版本信息
## 常见安装问题
+3 -3
View File
@@ -26,7 +26,7 @@ navigation:
1. 用一句话描述你的目标
2. 让 Agent 先给结构,再生成首稿
3. 如需视觉内容,进入图片生成功能继续产出与迭代
3. 如需视觉内容,在 AI Agent 中用 `@素材` 搜图或触发图片生成,再把结果沉淀到资源库
## 步骤 3:沉淀到资源库
@@ -51,7 +51,7 @@ navigation:
### 我可以直接改图吗?
可以。上传参考图后,若所选模型支持编辑接口,会自动走编辑链路。
可以。先在资源库上传参考图,再从 Claw 发起图片任务;若所选模型支持编辑接口,会自动走对应链路。
### 我还需要排查底层协议怎么办?
@@ -61,4 +61,4 @@ navigation:
- [首页与工作台](/user-guide/dashboard) - 理解核心导航
- [资源库](/user-guide/resources) - 管理创作资产
- [图片生成与编辑](/user-guide/image-generation) - 深入图片链路
- [图片生成与素材链路](/user-guide/image-generation) - 理解 Claw 与资源页如何协同
+2 -3
View File
@@ -15,15 +15,14 @@ navigation:
- **AI Agent**:对话、任务推进、内容初稿
- **项目**:按创作目标管理长期内容
- **资源**:统一查看文档、图片、语音、视频
- **图片生成**:生成图片、参考图编辑、结果回流资源库
- **设置**:调整主题、模块开关、连接与高级选项
## 推荐工作方式
1. 先在项目中选择一个主题方向
2. 在 AI Agent 中完成结构和首稿
3. 需要视觉时进入图片生成
4. 回到资源库统一管理结果
3. 需要视觉时在 AI Agent 中使用 `@素材` 或图片任务
4. 回到资源库统一管理本地图片、图库素材与回流结果
## 主题方向
+2 -2
View File
@@ -23,7 +23,7 @@ navigation:
你可以按使用习惯定制入口:
- 启用或停用工作区主题(如社媒、短视频、小说)
- 启用或停用导航模块(如 AI Agent、项目、图片生成、终端、工具、插件)
- 启用或停用导航模块(如 AI Agent、项目、资源、设置、插件)
这样可以让侧边栏更聚焦,减少干扰。
@@ -49,7 +49,7 @@ navigation:
### 个人创作者
- 保留:AI Agent、项目、资源、图片生成
- 保留:AI Agent、项目、资源
- 关闭:暂时不用的高级模块
- 目的:让工作台聚焦在“日常产出”
+8 -2
View File
@@ -41,9 +41,15 @@ navigation:
## 资源与创作联动
### 从图片生成回流资源库
### 从 Claw 与图片任务回流资源库
在图片生成页选择目标资源库后,成功生成的图片可自动写入当前项目。
在 Claw 中发起图片生成任务,或从图片任务结果执行入库后,成功生成的图片会自动写入当前项目。
资料库的图片视图还统一承接:
- 本地图片上传
- 我的图片库浏览
- 选图后插入当前画布
### 从资源继续对话创作
@@ -1,55 +1,76 @@
---
title: 图片生成与编辑
description: 通过文本与参考图完成图片生成、编辑与资产沉淀
title: 图片生成与素材链路
description: 通过 Claw 与资源库完成搜图、图片生成、编辑与资产沉淀
navigation:
icon: i-heroicons-photo
---
# 图片生成与编辑
# 图片生成与素材链路
图片生成页用于完成从“文字描述”到“可用图片素材”的全过程。
Lime 不再把图片能力拆成独立页面。
现在的事实源是:
- Claw:负责联网搜图、图片生成、参考图编辑和任务推进
- 资源库:负责本地图片上传、我的图片库浏览、结果沉淀和插图复用
- 设置:负责图片模型、Provider 与联网搜图 Key 配置
## 基本流程
1. 选择模型与参数(尺寸、比例、数量)
2. 输入提示词
3. 可选上传参考图
4. 生成后选图并沉淀到资源库
1. 在 AI Agent 中明确视觉目标
2. 需要找参考图时,用 Claw `@素材` 进行联网搜图
3. 需要生成或编辑图片时,在 Claw 发起对应图片任务
4. 结果自动或手动沉淀到资源库
5. 在资源库图片视图继续筛选、上传、插图或复用
## 参考图与编辑
## 联网搜图与生成
### 上传参考图
### 联网搜图
可上传参考图作为创作输入,帮助模型更贴近目标风格或构图。
当你需要灵感图、风格参考或可复用素材时:
### 编辑链路
- 在 Claw 中使用 `@素材`
- 联网图片搜索结果会以任务结果或素材候选的形式返回
- 选中的图片可以继续进入正文插图、封面或图片任务链路
当模型支持图片编辑接口时,系统会优先尝试编辑端点;
若不可用,会自动回退到可用生成端点,尽量保障出图成功率。
### 图片生成与编辑
## 历史记录
当你已经明确提示词或参考图后:
历史区域会保存你的生成记录,支持:
- 在 Claw 发起图片生成任务
- 如模型支持参考图编辑,系统会优先走编辑链路
- 若某条接口不可用,运行时会回退到可用生成链路,尽量保障出图成功率
- 查看单张或批次结果
- 重新选择目标图继续迭代
- 将历史结果补录到资源库
## 本地图片与我的图片库
本地图片与历史沉淀图片已经统一收口到资源库图片视图,你可以在这里:
- 上传本地图片
- 浏览“我的图片库”
- 选图后直接插入当前画布
- 在当前项目下统一管理图片资产
## 与资源库联动
### 目标资源库
### 结果回流
生成前可指定目标资源库(项目),用于自动沉淀图片资产。
图片任务会根据当前项目和资源库选择自动回流;如果需要,也可以在结果完成后再手动入库。
### 补录历史
### 插图复用
如果历史图片尚未入库,可使用“补录历史到资源库”进行批量回填。
进入资源库的图片可以直接被当前画布复用,不需要再回到旧图片页面挑选。
## 配置入口
相关配置分布在两个位置:
- 图片模型与默认策略:设置中的媒体服务配置
- 联网图片搜索 Key:设置中的网络搜索配置
## 实用建议
### 先定方向再出图
先在 AI Agent 里明确画面目标,再进入图片生成功能,会减少无效尝试。
先在 AI Agent 里明确画面目标,再决定是 `@素材` 搜图还是直接发起图片任务,会减少无效尝试。
### 一次只改一个变量
@@ -57,4 +78,4 @@ navigation:
### 把可用版本及时入库
选中可用图片后尽快入库,方便后续在资源页检索和复用。
选中可用图片后尽快入库,方便后续在资源页检索、插图和复用。
@@ -48,8 +48,8 @@ navigation:
### 处理建议
- 回到图片生成页确认目标资源库
- 对历史结果执行“补录到资源库”
- 先确认当前项目与资源库选择一致
- 回看 Claw 中对应图片任务是否已经成功并完成入库
- 回资源页切换“全部”核对总量
## 生成失败或超时
+2 -2
View File
@@ -7,7 +7,7 @@ navigation: false
# Lime 文档中心
Lime 是创作类 AI Agent 平台。
你可以在同一个工作台里完成对话、创作、图片生成、项目沉淀与资源复用。
你可以在同一个工作台里完成对话、创作、Claw 素材与图片任务、项目沉淀和资源复用。
## 从这里开始
@@ -34,7 +34,7 @@ Lime 是创作类 AI Agent 平台。
- [首页与工作台](/user-guide/dashboard)
- [资源库](/user-guide/resources)
- [运行时 AGENTS 规则](/user-guide/runtime-agents)
- [图片生成与编辑](/user-guide/image-generation)
- [图片生成与素材链路](/user-guide/image-generation)
- [设置](/user-guide/settings)
- [插件中心](/user-guide/plugins)
+395
View File
@@ -0,0 +1,395 @@
# Lime Harness Engine 对照路线图与长期检查表
> 状态:进行中,P0 已完成首刀收口
> 更新时间:2026-04-13
> 对照基线:LangChain 博文《The Anatomy of an Agent Harness》
> 目标:把 Lime 当前已经具备的 Harness 能力、真实缺口、后续建设优先级和长期复查口径收敛到一份可执行文档,而不是继续停留在抽象口号层。
配套图纸:
- `docs/roadmap/harness-engine/diagrams.md`
## 1. 先给结论
按 LangChain 这篇文章的标准看,Lime **已经不是“只有模型壳”的产品**,而是已经具备较完整 Harness 底座的 Agent 工作台。
但更准确的判断不是“已经完全成熟”,而是:
- **底座型 Harness:已基本成形**
- **闭环型 Harness:仍是部分完成**
- **长时自治型 Harness Engine:还没有完全收口**
一句话总结:
**Lime 当前最大的短板,不是“没有工具”或“没有运行时”,而是“证据闭环、长期执行闭环、动态装配闭环还不够强”。**
---
## 2. 本文使用的判断标准
LangChain 这篇文章把 Harness 定义为:
> 模型之外的一切代码、配置、执行环境、工具、约束、状态与编排逻辑。
因此本文不只看 prompt,也不只看 tool 数量,而是按下面这些维度对 Lime 做判断:
1. 系统提示词与规则注入
2. 文件系统与 durable state
3. Bash / code execution
4. sandbox / approval / execution policy
5. tools / skills / MCP / browser runtime
6. memory / search / AGENTS 注入
7. context rot 治理
8. long-horizon execution
9. verification / replay / review / evidence
10. just-in-time tool/context assembly
11. trace-driven harness self-improvement
---
## 3. Lime 当前总判断
### 3.1 已经成立的部分
Lime 当前已经明确具备以下 Harness 基础设施:
- system prompt 与 memory prompt 注入
- workspace / filesystem / artifact 持久化边界
- bash 与通用代码执行入口
- sandbox / approval / restriction profile
- skills / MCP / browser / workspace tools
- 子代理委派、handoff、evidence、replay 基础链
- context compaction、tool output compression、tool io offload
这说明 Lime 的主问题已经不是“缺零件”,而是“怎样把这些零件收敛成更强的闭环”。
### 3.2 仍然偏弱的部分
Lime 当前仍然缺少下面三类关键闭环:
1. **证据闭环不够强**
`runtime -> evidence -> verification outcome -> review -> regression -> promote`
这条链已经有雏形,但还没有形成默认强约束。
2. **长时执行闭环不够强**
当前已有 queue / resume / provider continuation / auto continue / subagent,但还没有把“任务未完成时必须继续推进到完成标准”变成统一的 runtime 纪律。
3. **动态装配闭环不够强**
当前有 catalog、surface、skill progressive disclosure,但 tool/context 仍偏“预配置”,而不是更强的 per-turn JIT 组装。
---
## 4. 对照矩阵
| LangChain Harness 能力 | Lime 当前状态 | 当前事实源 | 结论 |
| ------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| System Prompts / 规则注入 | 已落地 | `src-tauri/src/services/memory_profile_prompt_service.rs`、`src-tauri/src/services/memory_source_resolver_service.rs` | Lime 已把 profile、memory source、project rule 注入到 system prompt 主链,不是裸 prompt 模式 |
| Filesystem / Durable Storage | 已落地 | `docs/aiprompts/overview.md`、`src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs` | Workspace、artifact、项目目录、文件工具都已经进入主链 |
| Bash / Code Execution | 已落地 | `src-tauri/src/agent_tools/catalog.rs`、`src-tauri/src/agent_tools/execution.rs` | Lime 已具备通用执行能力,不依赖“预先定义完所有工具” |
| Sandbox / Approval / Policy | 已落地 | `src-tauri/src/agent_tools/execution.rs`、`docs/aiprompts/commands.md` | restriction profile、sandbox profile、warning policy 都已进入 runtime 主链 |
| Tools / Skills / MCP / Browser | 已落地 | `docs/aiprompts/skill-standard.md`、`docs/aiprompts/command-runtime.md`、`src-tauri/src/agent_tools/catalog.rs` | Lime 已有较完整 capability surface,不是单一 chat tool 模型 |
| Memory / Search / AGENTS 注入 | 已落地 | `src-tauri/src/services/memory_source_resolver_service.rs`、`docs/aiprompts/overview.md` | 记忆与规则文件已进入生产链,Web/MCP/search 也已存在 |
| Context Rot 治理 | 部分落地 | `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`、`src-tauri/crates/aster-rust/crates/aster/src/context_mgmt/mod.rs`、`src-tauri/crates/agent/src/tool_io_offload.rs`、`src-tauri/crates/aster-rust/crates/aster/src/context/compressor.rs` | 已有 compact、tool output compression、tool offload,但产品侧可见性与默认治理还不够强 |
| Long-Horizon Execution | 部分落地 | `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`、`src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs`、`src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs` | 已有 auto continue、provider continuation、subagent、queue/resume,但还没形成统一 completion loop |
| Verification / Replay / Review | 部分落地 | `docs/aiprompts/harness-engine-governance.md`、`src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`、`src/lib/agentRuntime/harnessVerificationPresentation.ts`、`src-tauri/src/services/runtime_review_decision_service.rs` | evidence / replay / analysis / review 已成链;前端 verification 展示语义已收敛到共享 helper,review template 也开始直接携带同一份 structured verification summary,但验证结果尚未成为所有后续动作的默认硬约束 |
| Just-in-Time Tool / Context Assembly | 待加强 | `src-tauri/src/agent_tools/catalog.rs`、`docs/aiprompts/skill-standard.md`、`docs/aiprompts/command-runtime.md` | 已有 surface/profile/skill progressive disclosure,但仍偏静态 catalog,不够按任务即时裁剪 |
| Trace-Driven Harness Self-Improvement | 待建设 | `docs/aiprompts/harness-engine-governance.md`、现有 evidence/replay/export 主链 | 已经具备取证底座,但还未形成“基于 trace 自动发现缺口并推进治理”的稳定平台能力 |
---
## 5. 关键事实源与它们分别证明了什么
### 5.1 Prompt / Memory / Rules
- `src-tauri/src/services/memory_profile_prompt_service.rs`
证明 Lime 已把用户画像与 memory prompt 合并进 system prompt,而不是只靠前端临时拼接。
- `src-tauri/src/services/memory_source_resolver_service.rs`
证明 Lime 已支持 user memory、durable memory、project rule、多层目录记忆来源解析。
### 5.2 Workspace / Tool Surface / Execution
- `src-tauri/src/agent_tools/catalog.rs`
证明 Lime 已有 tool catalog、surface profile、capability、lifecycle、permission plane 这些 Harness 级抽象。
- `src-tauri/src/agent_tools/execution.rs`
证明 Lime 已把 warning policy、restriction profile、sandbox profile 做成统一执行策略,而不是 scattered 规则。
- `src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs`
证明 workspace tool 不只是文件读写,还承担 output summary、metadata、observability 编码职责。
### 5.3 Skills / Scene / Browser / MCP
- `docs/aiprompts/skill-standard.md`
证明 Lime 对 skill 的理解已经是 bundle,而不是单一 Markdown 提示词。
- `docs/aiprompts/command-runtime.md`
证明 Lime 已把 `@`、`/`、`scene`、`ServiceSkill`、tool/runtime binding 做成明确产品主链。
- `docs/aiprompts/overview.md`
证明 browser runtime、plugin、MCP、terminal、artifact、workspace 都已进入总架构。
### 5.4 Context Rot / Offload / Continuation
- `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`
证明 Lime 已有 `agent_runtime_compact_session`、resume thread、thread read model、evidence export 这类 runtime 操作主链。
- `src-tauri/crates/aster-rust/crates/aster/src/context_mgmt/mod.rs`
证明 Aster 已有 continuation message 与 compact 后续写逻辑,Lime 不是完全没有 continuation。
- `src-tauri/crates/aster-rust/crates/aster/src/context/compressor.rs`
证明 tool output 已有 head/tail compression。
- `src-tauri/crates/agent/src/tool_io_offload.rs`
证明 Lime 已有通用 tool arguments/results offload、preview、eviction policy 与 `offload_file` 协议。
- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`
证明 Lime 已有 auto continue、provider continuation state 恢复与 runtime 级 continuation 配置。
### 5.5 Evidence / Replay / Review / UI
- `docs/aiprompts/harness-engine-governance.md`
证明 Lime 已明确要求 evidence pack 作为事实源,replay / analysis / review / UI 都应复用它。
- `src/lib/agentRuntime/harnessVerificationPresentation.ts`
证明前端 verification label / variant / description 已开始从 `HarnessStatusPanel` 本地解释收敛到共享 helper,GUI 消费层不再各自维护一套语义。
- `src-tauri/src/services/runtime_review_decision_service.rs`
证明 review decision 模板不再只携带 failure / recovered 文本列表,而开始直接透传 structured verification summary,review 消费层可以继续复用 evidence 同一份事实。
- `src/components/agent/chat/components/HarnessStatusPanel.tsx`
证明前端已经能消费 evidence pack,并开始在 evidence / review 两个消费面直接复用共享 verification presentation helper;但展示仍偏状态卡,不是完整治理闭环。
---
## 6. 当前进度看板
### 6.1 按能力维度统计
- 已落地:6 项
- 部分落地:3 项
- 待加强:1 项
- 待建设:1 项
### 6.2 按建设层次统计
| 层次 | 当前状态 | 说明 |
| ------------ | -------- | ------------------------------------------------------------------------------------ |
| 底座层 | 高 | prompt、memory、workspace、tool、sandbox、subagent、artifact 都已进入现役主链 |
| 运行时治理层 | 中高 | catalog、execution policy、compact、offload、evidence 已存在,但默认动作链还不够统一 |
| 闭环验证层 | 中 | replay / review / evidence 已有,verification outcome 到修复决策还不够强绑定 |
| 长时自治层 | 中 | continuation / queue / resume / subagent 已有,但 completion loop 仍偏弱 |
| 自我改进层 | 低 | 已能导出 trace 和证据,但还没有稳定的 trace-driven governance 平台 |
### 6.3 本文建议的总体评级
- **当前阶段评级:B**
- **更准确描述:Harness 底座较强,闭环能力中等,自治能力未完全收口**
### 6.4 本轮已落地
- `agent_runtime_export_evidence_pack` 现已把 `observabilitySummary` 直接返回到前端消费层,而不再只埋在导出文件里。
- `observabilitySummary.verificationSummary` 现已补充显式 outcome,以及失败 / 恢复焦点列表。
- `HarnessStatusPanel` 现已开始直接展示验证结果、失败焦点和恢复结果,不再只显示 `known_gaps`。
- `analysis handoff` 现已开始显式携带 verification failure / recovered outcomes,外部诊断不再只看到 gap signals。
- `review decision` 模板现已复用 analysis-context 里的 verification failure / recovered outcomes,人工审核不再只靠简报文字猜测。
- `runtime_review_decision_service` 现已补上定向回归测试,覆盖“非空 recovered outcomes 从 evidence / analysis 透传到 review decision”的主链守卫,避免 review 层退回空结果假绿。
- `harness-eval-runner` 现已把 `currentRecoveredObservabilityVerificationOutcomes` 与 `currentRecoveredVerificationCaseCount` 作为 summary 一级事实导出,trend / cleanup / dashboard 不再只能从 `currentObservabilityVerificationOutcomes` 二次筛 recovered。
- `harness-eval-history-record` 现已优先复用 `summary.breakdowns/totals` 与 `trend.classificationDeltas/latest.totals` 里的 verification facts 来写入 failure / recovered 摘要,只把 cleanup 保留为兼容兜底,不再让历史记录层反向依赖 cleanup 作为事实源。
- `scripts/lib/harness-verification-facts.mjs` 现已成为 cleanup core / history record / dashboard 共用的 verification role 判定边界,`blocking_failure / advisory_failure / recovered` 不再在多个脚本里各自维护一套常量与判断。
- `harness-dashboard-core` 现已优先直接消费 `trend.classificationDeltas/latest.totals` 与 `summary.breakdowns` 来渲染 verification 统计卡和 focus table,只把 cleanup 保留给 recommendations / governance / doc freshness 这些真正属于 cleanup 的派生面。
- `generated-slop-report-core` 现已把 verification focus 选择、current/degraded/recovered 切分和 summary 组合收回 `harness-verification-facts` 共享 helper,不再在 cleanup core 内部重复维护一套“从 trend classification deltas 推导 verification 视图”的本地逻辑。
- `generated-slop-report-core` 中原本私有的 verification follow-up 规则,现也已收回 `scripts/lib/harness-verification-facts.mjs` 共享 helper;cleanup recommendation 只负责编排 P0/P1/P2 动作,不再自己维护 `guiSmoke/browserVerification/artifactValidator` 的补证据与回归语义。
- `harness-eval-history-record` 现在也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享推导来生成 failure focus、current recovered baseline 和 case counts;history-record 不再自己维护一套 failure/recovered 焦点挑选与 cleanup fallback 计数逻辑。
- `harness-dashboard-core` 现在也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享推导来生成 verification focus rows、current recovered baseline 与说明文案;dashboard 不再自己维护一套“trend / summary / cleanup 三选一”的 verification 视图拼装逻辑。
- `generated-slop-report-core` 的 signals / text output 现在也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享 compact formatter;cleanup report 不再自己手写 `signal (outcome)` 标签格式,避免 recommendation、signals、dashboard 三处名称再度漂移。
- `generated-slop-report-core` 的 verification summary signals 现在也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享 summarizer;cleanup 不再自己维护 failure / advisory / recovered / degraded baseline 的摘要句式,避免 signals、review 口径和后续展示再次漂移。
- `generated-slop-report-core` 的 recommendation rationale 里涉及 verification 的 blocking / advisory / recovered 摘要片段,现也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享 builder;cleanup recommendation 不再自己维护 verification 解释句模板。
- `generated-slop-report-core` 的 `observability-evidence-follow-up` 里原本混合 verification / observability 的 rationale 与 backlog 文案,现也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享 builder;cleanup recommendation 进一步退回“只编排、不解释”的消费层角色。
- `src/lib/agentRuntime/harnessVerificationPresentation.ts` 现已成为前端 verification label / badge / description 的共享展示边界,`HarnessStatusPanel` 不再自己维护 `blocking_failure / advisory_failure / recovered` 的中文文案与说明句式。
- `analysis-brief.md` 现已直接从 `observability.summary.verificationSummary` 生成紧凑的结构化验证摘要,外部 AI 先读 brief 时就能看到 `Artifact / Browser / GUI Smoke` 的同源 outcome 与统计,不必等到再下钻 `analysis-context.json`。
- `runtime_review_decision_service` 现已把 `analysis-context.json` 中的 structured verification summary 一并透传到 review template / review-decision.json,review 面不再只剩 failure / recovered 文本列表。
- `runtime_review_decision_service` 现已开始基于 `verification_summary + failure/recovered outcomes` 预填 review template 的默认 `followup_actions / regression_requirements`;阻塞失败会直接回挂到 replay / evidence / browser / GUI smoke 等默认动作,而不是继续留空等人工从零编排。
- `runtime_review_decision_service` 现已把 review template 默认动作进一步收口到 verification facts 共享语义:Artifact / Browser / GUI Smoke 的 follow-up 与 regression requirement 现在直接镜像 cleanup helper 的动作链,review 不再继续维护另一套手写句式。
- 前端 tauri mock、API 归一化测试与 `HarnessStatusPanel` 现也已对齐这组 facts-based 默认动作,浏览器 mock / 本地 UI 回归不再停留在“review 模板始终空白动作”的旧语义。
- `HarnessStatusPanel` 的 review decision 区块现已与 evidence pack 区块复用同一段 verification summary 展示,不再在 review 面再维护一套独立的 verification UI 解释。
- `RuntimeReviewDecisionDialog` 现已直接复用同一份 `HarnessVerificationSummarySection`,reviewer 在真正填写审核结论时看到的 verification facts 与 evidence / review 面板保持同源,不再在对话框里丢失事实基线。
- `review-decision.md` 现已直接从 `verification_summary` 生成紧凑的结构化验证摘要,人工审核产物本身也能看到 `Artifact / Browser / GUI Smoke` 的同源 outcome 与统计,不再只剩 failure / recovered 文本列表。
这意味着 Phase A 已从“只有 evidence 文件里有事实”推进到“evidence、analysis、review、GUI 展示开始共享同一份 verification facts”。
---
## 7. 最关键的缺口,不要再发散
### 缺口 1:Verification 还没有真正控制后续动作
当前 Lime 已有:
- evidence pack
- replay case
- analysis handoff
- review decision template
- GUI smoke / contracts / quality workflow
但仍缺:
- 统一的 verification outcome 模型,直接控制 review / promote / cleanup 优先级
- 失败后默认回挂到“补验证 / 重放 / 修复 / 再验证”的固定动作链
- promote / queue continuation / runtime action executor 还没有直接消费这组 outcome,verification 仍未真正成为统一动作调度器
这意味着 Lime 已经能“看见问题”,但还没有完全做到“看见问题以后所有后续动作都按同一事实推进”。
### 缺口 2:Long-horizon completion loop 还不够硬
当前 Lime 已有:
- queue / resume
- subagent runtime
- provider continuation
- auto continue
- compact / overflow recovery
但仍缺:
- 明确的 completion goal 与 exit criteria
- 更强的“未完成不得退出”统一 runtime 纪律
- 长任务中计划、验证、恢复、交接的标准化闭环
这意味着 Lime 现在更像“支持长任务”,还不完全像“强约束地把长任务做完”。
### 缺口 3:Tool / Context 仍偏静态装配
当前 Lime 已有:
- tool surface profile
- skill progressive disclosure
- command runtime 场景分型
- scene / ServiceSkill / browser assist 等收口规则
但仍缺:
- per-turn 动态组装工具面
- 基于任务类型裁剪 detour tools 的统一机制
- evidence 驱动的动态上下文注入,而不是更多静态预配
这意味着 Lime 已经知道“哪些能力存在”,但还没有稳定做到“当前任务只拿到真正需要的那一组能力和上下文”。
---
## 8. 接下来只优先做这 3 件事
### P0:把 Verification Outcome 提升成 Harness 一级事实
目标:
- 让 `evidence pack -> replay -> analysis -> review -> cleanup -> dashboard -> UI` 全部消费同一份 verification outcome
最低动作:
- 统一 verification outcome 字段,不允许下游自己再拼第二套真假判断
- 区分 `current gap`、`degraded gap`、`not_applicable`
- 让 review / promote / cleanup 的推荐动作只基于同一份 outcome 计算
完成标准:
- 同一线程的 failure / recovered / advisory 状态,在 evidence、review、cleanup、UI 中不再出现语义漂移
### P1:把 Long-Horizon 执行从“支持”升级为“约束”
目标:
- 让 Lime 对复杂任务不只是“可以继续”,而是“默认会继续直到满足完成标准”
最低动作:
- 给复杂任务补 completion goal / done criteria
- 把 auto continue、provider continuation、queue resume、subagent handoff 接成统一策略
- 让中断、恢复、交接、继续执行都能回挂到同一条 runtime 事实链
完成标准:
- 长任务出现暂停、压缩、续跑、交接时,仍能在同一 session 语义内解释“还差什么、为什么继续、何时结束”
### P2:把 Tool / Context 装配从 catalog 驱动升级为 task 驱动
目标:
- 让 runtime 在发起 turn 时更像“装配能力包”,而不是“打开一个大工具箱”
最低动作:
- 按任务类型定义基础 tool surface 模板
- 对图片、浏览器、站点、分析、转写、研究等场景,建立 detour tool 剔除规则
- 把 skill、memory、browser preload、verification context 统一成更强的 JIT 注入模型
完成标准:
- 当前任务不再默认暴露明显无关的 tool,且上下文噪音可被稳定压低
---
## 9. 分阶段演进路线
| 阶段 | 目标 | 状态 | 备注 |
| ------- | ----------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Phase A | Harness 事实源收敛 | 进行中 | `evidence pack / replay / analysis / review` 已成链;verification summary 已回挂到前端导出结果、状态面板与 review template,`HarnessStatusPanel` 的 evidence / review verification 展示语义也已收敛到 `src/lib/agentRuntime/harnessVerificationPresentation.ts`,review decision 已补 structured summary 透传,eval runner 也已导出 current recovered verification 一级 breakdown |
| Phase B | Context rot 治理产品化 | 进行中 | compact、compression、offload 已有;下一步是让 UI、review、自动动作全部懂这些信号 |
| Phase C | Long-horizon 执行约束化 | 未完成 | continuation 能力存在,但 completion loop 还没成为平台纪律 |
| Phase D | JIT 装配与场景裁剪 | 未完成 | 现阶段仍偏 static catalog + 手工场景约束 |
| Phase E | Trace-driven self-improvement | 未完成 | 目前取证能力具备,但还没形成长期治理平台 |
---
## 10. 长期检查表
这部分不是“建议”,而是后续每轮治理都应该复查的口径。
### 10.1 每次改 Harness Runtime 都检查
1. 是否继续只有一个事实源,还是又在 UI / analysis / replay 里拼了第二套真相?
2. 新增信号是否区分了 `exported / not_applicable / degraded / missing`?
3. 新增能力是否落在 `current` 主链,而不是又扩了一条 compat 旁路?
4. prompt、tool、sandbox、runtime metadata、UI 展示是否仍是同一条 contract?
### 10.2 每周检查
1. evidence pack 与 review template 是否存在字段漂移
2. known gaps 是否还在错误地把 `not_applicable` 当缺口
3. `output_truncated` 与 `offload_file` 是否能在前端稳定消费
4. context compaction 是否仍能在 thread read / replay / analysis 中一致呈现
5. 子代理、queue、resume、handoff 是否仍按同一 session 语义工作
### 10.3 每月检查
1. 哪些工具在当前任务中是长期噪音源,应该被 JIT 剔除
2. 哪些验证已经真实发生,哪些只是文档里提到但没进入 evidence 主链
3. 哪些 replay case 无法稳定复现,需要补环境、artifact 或 telemetry
4. 哪些 compat / deprecated surface 仍在偷偷长新逻辑
5. 哪些 HarnessStatusPanel、cleanup report、dashboard 文案与后端事实不一致
### 10.4 每季度检查
1. 长任务完成率是否提高,而不是只提高“能力数量”
2. 验证失败后是否更快回挂到补证据、补回放、补修复、补回归
3. 工具面是否比上季度更轻,而不是更重
4. 取证与治理链是否减少了人工判断分歧
5. 是否还在新增并行事实源、旁路协议、临时兼容层
---
## 11. 平台治理红线
后续只要出现下面任一情况,都应视为 Harness Engine 治理倒退:
1. 在 `analysis / replay / review / UI` 各自重新拼装第二套 runtime 真相
2. 为了图省事,把所有线程都写成同一种 known gap 模板
3. 在 `compat / deprecated` 路径继续长新功能
4. 为了“多给模型一点能力”,默认暴露更多无关工具和上下文
5. verification 没真实发生,却在证据层假装发生过
6. evidence 已经修正,展示层和治理层仍沿用旧字段、旧语义
---
## 12. 对 Lime 的最终定位
Lime 后续不应该把自己建设成“更多工具的聊天壳”,而应该明确建设成:
**一个以 workspace、artifact、verification、evidence、review 和长期治理为中心的 Harness Engine 平台。**
换句话说,Lime 的长期竞争力不在“会不会调模型”,而在:
- 是否能把模型接入稳定的执行环境
- 是否能把任务过程沉淀成可追溯证据
- 是否能把失败变成可修复、可回放、可治理的工程对象
- 是否能在长期演进中减少而不是放大系统熵
这才是 Lime 后续对齐 Claude Code / Codex / LangChain Harness 思路时,真正应该抓住的主线。
+146
View File
@@ -0,0 +1,146 @@
# Lime Harness Engine 架构图与流程图
> 状态:进行中
> 更新时间:2026-04-13
> 作用:把 Harness Engine 的关键结构、时序和治理闭环画成可复查的图,而不是只靠长文描述。
## 1. 总体架构图
```mermaid
flowchart TB
User[用户 / 人工审核] --> UI[前端工作台 UI]
UI --> RuntimeAPI[agent_runtime_* 命令边界]
RuntimeAPI --> Runtime[Aster / Lime Runtime]
Runtime --> Prompt[System Prompt / Memory Prompt]
Runtime --> ToolSurface[Tool Surface / Skills / MCP / Browser]
Runtime --> Policy[Sandbox / Approval / Restriction Policy]
Runtime --> Session[Session / Thread / Queue / Resume / Continuation]
Runtime --> Workspace[Workspace / Filesystem / Artifact]
Prompt --> Memory[AGENTS / Project Rules / Durable Memory]
ToolSurface --> Exec[Bash / File Tools / Browser Tools / Subagent Tools]
Workspace --> Artifact[Artifact / Timeline / Runtime Snapshot]
Session --> Evidence[Evidence Pack]
Artifact --> Evidence
Runtime --> Evidence
Evidence --> Replay[Replay Case]
Evidence --> Analysis[Analysis Handoff]
Evidence --> Review[Review Decision]
Evidence --> Dashboard[Cleanup / Dashboard / Trend]
Evidence --> StatusPanel[HarnessStatusPanel]
Replay --> Governance[治理与回归决策]
Analysis --> Governance
Review --> Governance
Dashboard --> Governance
StatusPanel --> Governance
```
## 2. 运行时与证据导出时序图
```mermaid
sequenceDiagram
participant U as 用户
participant F as 前端工作台
participant C as agent_runtime_submit_turn
participant R as Lime / Aster Runtime
participant T as Tools / Skills / Browser / Bash
participant W as Workspace / Artifact
participant E as agent_runtime_export_evidence_pack
participant P as HarnessStatusPanel
U->>F: 发送任务
F->>C: submit_turn(request_metadata + turn_config)
C->>R: 创建 / 恢复当前 turn
R->>T: 调用 tools / skills / browser / subagent
T-->>R: 返回输出 / metadata / offload / errors
R->>W: 写入 artifact / timeline / runtime state
R-->>F: 流式状态 / item / summary
U->>F: 导出问题证据包
F->>E: export_evidence_pack(session_id)
E->>R: 读取 session detail / thread read
E->>W: 汇总 runtime.json / timeline.json / artifacts.json / summary.md
E-->>F: 返回 evidence pack + observability summary + verification summary
F->>P: 渲染 known gaps / verification outcomes / focus lists
P-->>U: 展示证据事实与治理焦点
```
## 3. Evidence 驱动治理闭环
```mermaid
flowchart LR
A[Runtime Thread / Session] --> B[Evidence Pack]
B --> C[Observability Summary]
C --> D[Verification Outcomes]
D --> E[HarnessStatusPanel]
D --> F[Replay Case]
D --> G[Analysis Handoff]
D --> H[Review Decision]
H --> I[修复实现]
I --> J[回归验证]
J --> B
```
## 4. 长时任务执行闭环
```mermaid
flowchart TD
Start[用户任务进入主会话] --> Plan[计划 / Todo / Scene Binding]
Plan --> Execute[主代理执行]
Execute --> Tools[Tools / Skills / Browser / Bash]
Tools --> Check{是否完成?}
Check -- 否 --> Continue[Auto Continue / Provider Continuation / Queue Resume]
Continue --> Compact[必要时 Compact / Offload / Context Recovery]
Compact --> Execute
Check -- 需要拆分 --> Subagent[Spawn Subagent / Team Runtime]
Subagent --> Execute
Check -- 是 --> Verify[Verification / Replay / Review]
Verify --> Done[形成交付物与证据]
```
## 5. 事实源分层图
```mermaid
flowchart TB
RuntimeFact[Runtime Thread / Session / Timeline]
EvidencePack[Evidence Pack]
Derived[Replay / Analysis / Review / Dashboard]
View[UI / Prompt Copy / Status Cards]
RuntimeFact --> EvidencePack
EvidencePack --> Derived
EvidencePack --> View
Derived --> View
View -.禁止反向定义事实.-> EvidencePack
Derived -.禁止旁路重建真相.-> RuntimeFact
```
## 6. 当前最关键的治理关注点
### 6.1 已经成形的图上主链
- `User -> UI -> agent_runtime_* -> Runtime -> Tools / Workspace -> Evidence`
- `Evidence -> Replay / Analysis / Review / StatusPanel`
- `Continuation / Compact / Offload / Resume`
### 6.2 仍需继续加强的图上闭环
- `Verification Outcomes -> Review / Cleanup / Dashboard` 还要更强一致
- `是否完成 -> Continue / Compact / Resume` 还没完全约束化
- `任务类型 -> JIT Tool / Context Assembly` 还没完全平台化
## 7. 后续补图原则
后续如果 Harness Engine 再新增图纸,遵守三条规则:
1. 只画 current 主链,不为 compat / deprecated 画主图。
2. 图中节点必须能对应到仓库真实模块、命令或文档,不画空概念。
3. 如果实现已经改变事实源或时序,优先更新图,而不是只改 README 文案。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lime",
"version": "1.9.0",
"version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lime",
"version": "1.9.0",
"version": "1.10.0",
"dependencies": {
"@babel/standalone": "^7.29.0",
"@fabianlars/tauri-plugin-oauth": "^2",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "1.9.0",
"version": "1.10.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
+1 -1
View File
@@ -112,7 +112,7 @@ npm run build:release -- \
```bash
npm run build:release -- \
--target-triple "aarch64-apple-darwin" \
--version "1.9.0" \
--version "1.10.0" \
--out-dir "./dist"
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@limecloud/lime-cli",
"version": "1.9.0",
"version": "1.10.0",
"description": "Lime 官方任务 CLI",
"bin": {
"lime": "scripts/run.js"
+42 -157
View File
@@ -7,16 +7,13 @@ import process from "node:process";
import { fileURLToPath } from "node:url";
import { renderHarnessDashboardHtml } from "./lib/harness-dashboard-core.mjs";
import {
deriveVerificationHistoryRecordFacts as deriveSharedVerificationHistoryRecordFacts,
} from "./lib/harness-verification-facts.mjs";
const RUNNER_PATH = "scripts/harness-eval-runner.mjs";
const TREND_PATH = "scripts/harness-eval-trend-report.mjs";
const CLEANUP_PATH = "scripts/report-generated-slop.mjs";
const RECOVERED_VERIFICATION_OUTCOMES = new Set([
"repaired",
"success",
"passed",
"clean",
]);
function parseArgs(argv) {
const result = {
@@ -257,6 +254,14 @@ function writeUniqueHistorySummary(historyDir, payload) {
throw new Error(`无法在历史目录中创建唯一 summary 文件: ${historyDir}`);
}
function normalizeString(value) {
return typeof value === "string" ? value.trim() : "";
}
function normalizeNumber(value) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function trimHistoryFiles(historyDir, retain) {
const files = collectHistoryFiles(historyDir).sort((left, right) =>
right.localeCompare(left),
@@ -285,145 +290,24 @@ function buildDefaultArtifactPaths(historyDir) {
};
}
function toVerificationFailureOutcomeFocus(cleanupReport) {
const currentEntries = Array.isArray(
cleanupReport?.focus?.currentObservabilityVerificationOutcomes,
)
? cleanupReport.focus.currentObservabilityVerificationOutcomes
: [];
const fallbackEntries = Array.isArray(
cleanupReport?.focus?.observabilityVerificationOutcomes,
)
? cleanupReport.focus.observabilityVerificationOutcomes
: [];
const entries =
currentEntries.length > 0 ? currentEntries : fallbackEntries;
return entries
.map((entry) => {
const signal = typeof entry?.signal === "string" ? entry.signal.trim() : "";
const outcome =
typeof entry?.outcome === "string" ? entry.outcome.trim() : "";
return signal && outcome ? `${signal}:${outcome}` : "";
})
.filter(Boolean);
}
function toCurrentRecoveredBaselineFocus(cleanupReport) {
const explicitRecoveredEntries = Array.isArray(
cleanupReport?.focus?.currentRecoveredObservabilityVerificationOutcomes,
)
? cleanupReport.focus.currentRecoveredObservabilityVerificationOutcomes
: [];
const currentEntries = Array.isArray(
cleanupReport?.focus?.currentObservabilityVerificationOutcomes,
)
? cleanupReport.focus.currentObservabilityVerificationOutcomes
: [];
const fallbackEntries = Array.isArray(
cleanupReport?.focus?.observabilityVerificationOutcomes,
)
? cleanupReport.focus.observabilityVerificationOutcomes
: [];
const entries =
explicitRecoveredEntries.length > 0
? explicitRecoveredEntries
: currentEntries.length > 0
? currentEntries
: fallbackEntries;
return entries
.filter((entry) =>
RECOVERED_VERIFICATION_OUTCOMES.has(
typeof entry?.outcome === "string" ? entry.outcome.trim() : "",
),
)
.map((entry) => {
const signal = typeof entry?.signal === "string" ? entry.signal.trim() : "";
const outcome =
typeof entry?.outcome === "string" ? entry.outcome.trim() : "";
return signal && outcome ? `${signal}:${outcome}` : "";
})
.filter(Boolean);
}
function toVerificationOutcomeCounts(cleanupReport) {
const summary =
cleanupReport &&
typeof cleanupReport === "object" &&
cleanupReport.summary &&
cleanupReport.summary.verificationOutcomes &&
typeof cleanupReport.summary.verificationOutcomes === "object"
? cleanupReport.summary.verificationOutcomes
: {};
const currentSummary =
summary &&
typeof summary.current === "object" &&
!Array.isArray(summary.current)
? summary.current
: {};
const degradedSummary =
summary &&
typeof summary.degraded === "object" &&
!Array.isArray(summary.degraded)
? summary.degraded
: {};
return {
failureCaseCount:
typeof summary.failureCaseCount === "number" &&
Number.isFinite(summary.failureCaseCount)
? summary.failureCaseCount
: 0,
blockingFailureCaseCount:
typeof currentSummary.blockingFailureCaseCount === "number" &&
Number.isFinite(currentSummary.blockingFailureCaseCount)
? currentSummary.blockingFailureCaseCount
: 0,
advisoryFailureCaseCount:
typeof currentSummary.advisoryFailureCaseCount === "number" &&
Number.isFinite(currentSummary.advisoryFailureCaseCount)
? currentSummary.advisoryFailureCaseCount
: 0,
recoveredCaseCount:
typeof summary.recoveredCaseCount === "number" &&
Number.isFinite(summary.recoveredCaseCount)
? summary.recoveredCaseCount
: 0,
currentRecoveredCaseCount:
typeof currentSummary.recoveredCaseCount === "number" &&
Number.isFinite(currentSummary.recoveredCaseCount)
? currentSummary.recoveredCaseCount
: 0,
degradedBlockingFailureCaseCount:
typeof degradedSummary.blockingFailureCaseCount === "number" &&
Number.isFinite(degradedSummary.blockingFailureCaseCount)
? degradedSummary.blockingFailureCaseCount
: 0,
};
}
function toTrendCurrentRecoveredBaselineFocus(trendReport) {
const entries = Array.isArray(
trendReport?.classificationDeltas?.currentRecoveredObservabilityVerificationOutcomes,
)
? trendReport.classificationDeltas.currentRecoveredObservabilityVerificationOutcomes
: [];
return deriveSharedVerificationHistoryRecordFacts({
summary: null,
trendReport,
cleanupReport: null,
}).currentRecoveredBaselineFocus.slice(0, 3);
}
return entries
.filter((entry) => {
const latestCaseCount =
typeof entry?.latest?.caseCount === "number" &&
Number.isFinite(entry.latest.caseCount)
? entry.latest.caseCount
: 0;
return latestCaseCount > 0;
})
.map((entry) =>
typeof entry?.name === "string" ? entry.name.trim() : "",
)
.filter(Boolean)
.slice(0, 3);
export function deriveHistoryRecordVerificationFacts({
summary,
trendReport,
cleanupReport,
}) {
return deriveSharedVerificationHistoryRecordFacts({
summary,
trendReport,
cleanupReport,
});
}
function renderOutput(result, format) {
@@ -660,32 +544,33 @@ function runHistoryRecordCli() {
},
);
cleanupReport = JSON.parse(cleanupOutput);
const verificationFailureOutcomeFocus =
toVerificationFailureOutcomeFocus(cleanupReport);
const currentRecoveredBaselineFocus =
toCurrentRecoveredBaselineFocus(cleanupReport);
const verificationOutcomeCounts =
toVerificationOutcomeCounts(cleanupReport);
const verificationFacts = deriveHistoryRecordVerificationFacts({
summary,
trendReport,
cleanupReport,
});
result.cleanup = {
trendSampleCount: cleanupReport.summary?.trend?.sampleCount ?? 0,
currentObservabilityGapCaseCount:
cleanupReport.summary?.trend?.latestCurrentObservabilityGapCaseCount ?? 0,
degradedObservabilityGapCaseCount:
cleanupReport.summary?.trend?.latestDegradedObservabilityGapCaseCount ?? 0,
verificationFailureOutcomeFocus,
verificationFailureOutcomeFocus:
verificationFacts.verificationFailureOutcomeFocus,
verificationFailureCaseCount:
verificationOutcomeCounts.failureCaseCount,
verificationFacts.verificationOutcomeCounts.failureCaseCount,
verificationBlockingFailureCaseCount:
verificationOutcomeCounts.blockingFailureCaseCount,
verificationFacts.verificationOutcomeCounts.blockingFailureCaseCount,
verificationAdvisoryFailureCaseCount:
verificationOutcomeCounts.advisoryFailureCaseCount,
verificationFacts.verificationOutcomeCounts.advisoryFailureCaseCount,
verificationDegradedBlockingFailureCaseCount:
verificationOutcomeCounts.degradedBlockingFailureCaseCount,
verificationFacts.verificationOutcomeCounts.degradedBlockingFailureCaseCount,
verificationRecoveredCaseCount:
verificationOutcomeCounts.recoveredCaseCount,
verificationFacts.verificationOutcomeCounts.recoveredCaseCount,
currentVerificationRecoveredCaseCount:
verificationOutcomeCounts.currentRecoveredCaseCount,
currentRecoveredBaselineFocus,
verificationFacts.verificationOutcomeCounts.currentRecoveredCaseCount,
currentRecoveredBaselineFocus:
verificationFacts.currentRecoveredBaselineFocus,
outputJsonPath: cleanupJsonPath,
outputMarkdownPath: cleanupMarkdownPath,
};
+60 -1
View File
@@ -23,6 +23,12 @@ const REVIEW_DECISION_RISK_LEVEL_SET = new Set([
"unknown",
]);
const OBSERVABILITY_GAP_SUITE_TAG = "observability-gap";
const RECOVERED_VERIFICATION_OUTCOMES = new Set([
"artifactValidator:repaired",
"browserVerification:success",
"guiSmoke:passed",
"guiSmoke:clean",
]);
function parseArgs(argv) {
const result = {
@@ -193,6 +199,12 @@ function aggregateCaseBreakdown(cases, selector) {
});
}
function collectRecoveredVerificationOutcomes(outcomes) {
return mergeUniqueStrings(outcomes).filter((entry) =>
RECOVERED_VERIFICATION_OUTCOMES.has(entry),
);
}
function isDegradedObservabilityGapCase(entry) {
return (
entry.observabilityGapCount > 0 &&
@@ -891,6 +903,22 @@ function buildSummary(manifest, suites, options) {
const degradedObservabilityGapCases = observabilityGapCases.filter((entry) =>
isDegradedObservabilityGapCase(entry),
);
const currentObservabilityDiagnosticCases = allCases.filter((entry) =>
isCurrentObservabilityDiagnosticCase(entry),
);
const currentRecoveredObservabilityVerificationOutcomes =
aggregateCaseBreakdown(
currentObservabilityDiagnosticCases,
(entry) =>
collectRecoveredVerificationOutcomes(
entry.observabilityVerificationOutcomes,
),
);
const currentRecoveredVerificationCaseCount =
currentRecoveredObservabilityVerificationOutcomes.reduce(
(total, entry) => total + entry.caseCount,
0,
);
return {
manifestVersion: String(manifest.manifestVersion ?? "unknown"),
@@ -910,6 +938,7 @@ function buildSummary(manifest, suites, options) {
observabilityGapCaseCount: observabilityGapCases.length,
currentObservabilityGapCaseCount: currentObservabilityGapCases.length,
degradedObservabilityGapCaseCount: degradedObservabilityGapCases.length,
currentRecoveredVerificationCaseCount,
},
breakdowns: {
suiteTags: aggregateCaseBreakdown(allCases, (entry) => entry.tags),
@@ -932,9 +961,10 @@ function buildSummary(manifest, suites, options) {
(entry) => entry.observabilityVerificationOutcomes,
),
currentObservabilityVerificationOutcomes: aggregateCaseBreakdown(
allCases.filter((entry) => isCurrentObservabilityDiagnosticCase(entry)),
currentObservabilityDiagnosticCases,
(entry) => entry.observabilityVerificationOutcomes,
),
currentRecoveredObservabilityVerificationOutcomes,
degradedObservabilityVerificationOutcomes: aggregateCaseBreakdown(
allCases.filter((entry) => isDegradedObservabilityDiagnosticCase(entry)),
(entry) => entry.observabilityVerificationOutcomes,
@@ -958,6 +988,7 @@ function renderText(summary) {
`[harness-eval] observability-gap cases: ${summary.totals.observabilityGapCaseCount}`,
`[harness-eval] current observability-gap cases: ${summary.totals.currentObservabilityGapCaseCount}`,
`[harness-eval] degraded observability-gap cases: ${summary.totals.degradedObservabilityGapCaseCount}`,
`[harness-eval] current recovered verification cases: ${summary.totals.currentRecoveredVerificationCaseCount}`,
];
const topFailureModes = summary.breakdowns.failureModes.slice(0, 5);
@@ -1014,6 +1045,20 @@ function renderText(summary) {
}
}
const topCurrentRecoveredVerificationOutcomes =
summary.breakdowns.currentRecoveredObservabilityVerificationOutcomes.slice(
0,
5,
);
if (topCurrentRecoveredVerificationOutcomes.length > 0) {
lines.push("[harness-eval] current recovered verification outcomes:");
for (const entry of topCurrentRecoveredVerificationOutcomes) {
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}`,
@@ -1069,6 +1114,7 @@ function renderMarkdown(summary) {
`- observability gap case:${summary.totals.observabilityGapCaseCount}`,
`- current observability gap case:${summary.totals.currentObservabilityGapCaseCount}`,
`- degraded observability gap case:${summary.totals.degradedObservabilityGapCaseCount}`,
`- current recovered verification case:${summary.totals.currentRecoveredVerificationCaseCount}`,
"",
];
@@ -1152,6 +1198,19 @@ function renderMarkdown(summary) {
lines.push("");
}
if (summary.breakdowns.currentRecoveredObservabilityVerificationOutcomes.length > 0) {
lines.push("## Current Recovered Verification Outcome 分布");
lines.push("");
lines.push("| Outcome | case | ready | invalid |");
lines.push("| --- | --- | --- | --- |");
for (const entry of summary.breakdowns.currentRecoveredObservabilityVerificationOutcomes) {
lines.push(
`| ${entry.name} | ${entry.caseCount} | ${entry.readyCount} | ${entry.invalidCount} |`,
);
}
lines.push("");
}
for (const suite of summary.suites) {
lines.push(`## ${suite.title}`);
lines.push("");
+50 -21
View File
@@ -256,10 +256,8 @@ function buildNormalizedTotals(summary) {
const rawTotals =
summary?.totals && typeof summary.totals === "object" ? summary.totals : {};
const gapTotals = buildObservabilityGapTotals(summary);
const currentVerificationOutcomeEntries = getBreakdownEntries(
summary,
"currentObservabilityVerificationOutcomes",
);
const currentRecoveredVerificationEntries =
getCurrentRecoveredVerificationEntries(summary);
return {
suiteCount: normalizeNumber(rawTotals.suiteCount),
caseCount: normalizeNumber(rawTotals.caseCount),
@@ -274,11 +272,10 @@ function buildNormalizedTotals(summary) {
currentObservabilityGapCaseCount: gapTotals.current,
degradedObservabilityGapCaseCount: gapTotals.degraded,
currentRecoveredVerificationCaseCount:
currentVerificationOutcomeEntries.length > 0
? getBreakdownCaseCount(
summary,
"currentObservabilityVerificationOutcomes",
RECOVERED_VERIFICATION_OUTCOMES,
currentRecoveredVerificationEntries.length > 0
? currentRecoveredVerificationEntries.reduce(
(total, entry) => total + normalizeNumber(entry?.caseCount),
0,
)
: normalizeNumber(rawTotals.currentRecoveredVerificationCaseCount),
};
@@ -326,6 +323,21 @@ function getBreakdownMap(summary, key) {
);
}
function getCurrentRecoveredVerificationEntries(summary) {
const explicitEntries = getBreakdownEntries(
summary,
"currentRecoveredObservabilityVerificationOutcomes",
);
if (explicitEntries.length > 0) {
return explicitEntries;
}
return getBreakdownEntries(
summary,
"currentObservabilityVerificationOutcomes",
).filter((entry) => RECOVERED_VERIFICATION_OUTCOMES.has(entry.name));
}
function buildSuiteDeltas(baseline, latest) {
const baselineSuites = getSuiteMap(baseline);
const latestSuites = getSuiteMap(latest);
@@ -428,6 +440,32 @@ function buildFilteredBreakdownDeltas(baseline, latest, key, predicate) {
);
}
function buildCurrentRecoveredVerificationDeltas(baseline, latest) {
const baselineExplicitEntries = getBreakdownEntries(
baseline,
"currentRecoveredObservabilityVerificationOutcomes",
);
const latestExplicitEntries = getBreakdownEntries(
latest,
"currentRecoveredObservabilityVerificationOutcomes",
);
if (baselineExplicitEntries.length > 0 || latestExplicitEntries.length > 0) {
return buildBreakdownDeltas(
baseline,
latest,
"currentRecoveredObservabilityVerificationOutcomes",
);
}
return buildFilteredBreakdownDeltas(
baseline,
latest,
"currentObservabilityVerificationOutcomes",
(entry) => RECOVERED_VERIFICATION_OUTCOMES.has(entry.name),
);
}
function buildStatusSignals(baseline, latest, sampleCount) {
const signals = [];
@@ -507,12 +545,8 @@ function buildStatusSignals(baseline, latest, sampleCount) {
);
}
const currentRecoveredVerificationDeltas = buildFilteredBreakdownDeltas(
baseline,
latest,
"currentObservabilityVerificationOutcomes",
(entry) => RECOVERED_VERIFICATION_OUTCOMES.has(entry.name),
);
const currentRecoveredVerificationDeltas =
buildCurrentRecoveredVerificationDeltas(baseline, latest);
for (const entry of currentRecoveredVerificationDeltas.filter(
(candidate) => candidate.delta.caseCount < 0,
)) {
@@ -631,12 +665,7 @@ function buildTrendReport(samples, repoRoot) {
"observabilityVerificationOutcomes",
),
currentRecoveredObservabilityVerificationOutcomes:
buildFilteredBreakdownDeltas(
baseline,
latest,
"currentObservabilityVerificationOutcomes",
(entry) => RECOVERED_VERIFICATION_OUTCOMES.has(entry.name),
),
buildCurrentRecoveredVerificationDeltas(baseline, latest),
currentObservabilityVerificationOutcomes: buildBreakdownDeltas(
baseline,
latest,
+95 -636
View File
@@ -4,6 +4,22 @@ import {
getTextCountStatus,
getTextStatus,
} from "./legacy-surface-report-summary.mjs";
import {
buildAdvisoryVerificationRecommendationRationale,
buildBlockingVerificationRecommendationRationale,
buildObservabilityRecommendationBacklog,
buildObservabilityRecommendationRationale,
buildVerificationOutcomeSignalMessages,
buildRecoveredVerificationRecommendationRationale,
buildAdvisoryVerificationFollowUp,
buildBlockingVerificationFollowUp,
buildRecoveredVerificationFollowUp,
buildVerificationOutcomeSummary,
deriveVerificationOutcomePresentationFromTrend,
formatVerificationOutcomeCompactLabel,
formatVerificationOutcomeCompactLabels,
getVerificationOutcomeRole,
} from "./harness-verification-facts.mjs";
const PRIORITY_RANK = {
P0: 0,
@@ -544,277 +560,6 @@ function buildObservabilityFocusEntries(entries, sampleCount) {
});
}
const OBSERVABILITY_VERIFICATION_FAILURE_OUTCOMES = new Set([
"issues_present",
"fallback_used",
"failure",
"unknown",
"failed",
]);
const OBSERVABILITY_VERIFICATION_RECOVERED_OUTCOMES = new Set([
"repaired",
"success",
"passed",
"clean",
]);
function isObservabilityVerificationFailureOutcome(outcome) {
return OBSERVABILITY_VERIFICATION_FAILURE_OUTCOMES.has(
normalizeString(outcome),
);
}
function isObservabilityVerificationRecoveredOutcome(outcome) {
return OBSERVABILITY_VERIFICATION_RECOVERED_OUTCOMES.has(
normalizeString(outcome),
);
}
const BLOCKING_VERIFICATION_FAILURES = new Set([
"browserVerification:failure",
"guiSmoke:failed",
]);
function getObservabilityVerificationOutcomeRole(signal, outcome) {
const normalizedSignal = normalizeString(signal);
const normalizedOutcome = normalizeString(outcome);
const fingerprint = `${normalizedSignal}:${normalizedOutcome}`;
if (BLOCKING_VERIFICATION_FAILURES.has(fingerprint)) {
return "blocking_failure";
}
if (isObservabilityVerificationFailureOutcome(normalizedOutcome)) {
return "advisory_failure";
}
if (isObservabilityVerificationRecoveredOutcome(normalizedOutcome)) {
return "recovered";
}
return "other";
}
function getObservabilityVerificationOutcomeWeight(outcome) {
switch (normalizeString(outcome)) {
case "failed":
return 140;
case "failure":
return 130;
case "unknown":
return 115;
case "fallback_used":
return 110;
case "issues_present":
return 100;
case "repaired":
return 70;
default:
return 0;
}
}
function buildObservabilityVerificationFocusEntries(entries, sampleCount) {
const normalizedEntries = Array.isArray(entries) ? entries : [];
return normalizedEntries
.map((entry) => {
const latest = isObject(entry?.latest) ? entry.latest : {};
const delta = isObject(entry?.delta) ? entry.delta : {};
const baseline = isObject(entry?.baseline) ? entry.baseline : {};
const parsed = splitObservabilitySignalName(entry?.name);
const positiveDeltaCase = Math.max(0, normalizeNumber(delta.caseCount));
const latestCase = normalizeNumber(latest.caseCount);
const weight = getObservabilityVerificationOutcomeWeight(parsed.status);
const score = positiveDeltaCase * 140 + latestCase * weight;
let state = "stable";
if (sampleCount < 2 && latestCase > 0 && weight > 0) {
state = "seed-risk";
} else if (positiveDeltaCase > 0 && weight > 0) {
state = "regressing";
} else if (latestCase > 0 && weight > 0) {
state = "present";
}
return {
name: normalizeString(entry?.name, "(unknown)"),
signal: parsed.signal || "(unknown)",
outcome: parsed.status || "unknown",
baseline: {
caseCount: normalizeNumber(baseline.caseCount),
readyCount: normalizeNumber(baseline.readyCount),
invalidCount: normalizeNumber(baseline.invalidCount),
pendingRequestCaseCount: normalizeNumber(
baseline.pendingRequestCaseCount,
),
needsHumanReviewCount: normalizeNumber(
baseline.needsHumanReviewCount,
),
},
latest: {
caseCount: latestCase,
readyCount: normalizeNumber(latest.readyCount),
invalidCount: normalizeNumber(latest.invalidCount),
pendingRequestCaseCount: normalizeNumber(
latest.pendingRequestCaseCount,
),
needsHumanReviewCount: normalizeNumber(
latest.needsHumanReviewCount,
),
},
delta: {
caseCount: normalizeNumber(delta.caseCount),
readyCount: normalizeNumber(delta.readyCount),
invalidCount: normalizeNumber(delta.invalidCount),
pendingRequestCaseCount: normalizeNumber(delta.pendingRequestCaseCount),
needsHumanReviewCount: normalizeNumber(delta.needsHumanReviewCount),
},
state,
score,
};
})
.filter(
(entry) =>
entry.score > 0 ||
(entry.latest.caseCount > 0 &&
isObservabilityVerificationFailureOutcome(entry.outcome)),
)
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.name.localeCompare(right.name);
});
}
function buildVerificationOutcomeEntriesFromDeltas(entries) {
const normalizedEntries = Array.isArray(entries) ? entries : [];
return normalizedEntries
.map((entry) => {
const latest = isObject(entry?.latest) ? entry.latest : {};
const delta = isObject(entry?.delta) ? entry.delta : {};
const baseline = isObject(entry?.baseline) ? entry.baseline : {};
const parsed = splitObservabilitySignalName(entry?.name);
const latestCase = normalizeNumber(latest.caseCount);
const deltaCase = normalizeNumber(delta.caseCount);
let state = "stable";
if (deltaCase > 0) {
state = "expanding";
} else if (deltaCase < 0) {
state = "shrinking";
} else if (latestCase > 0) {
state = "present";
}
return {
name: normalizeString(entry?.name, "(unknown)"),
signal: parsed.signal || "(unknown)",
outcome: parsed.status || "unknown",
baseline: {
caseCount: normalizeNumber(baseline.caseCount),
readyCount: normalizeNumber(baseline.readyCount),
invalidCount: normalizeNumber(baseline.invalidCount),
pendingRequestCaseCount: normalizeNumber(
baseline.pendingRequestCaseCount,
),
needsHumanReviewCount: normalizeNumber(
baseline.needsHumanReviewCount,
),
},
latest: {
caseCount: latestCase,
readyCount: normalizeNumber(latest.readyCount),
invalidCount: normalizeNumber(latest.invalidCount),
pendingRequestCaseCount: normalizeNumber(
latest.pendingRequestCaseCount,
),
needsHumanReviewCount: normalizeNumber(
latest.needsHumanReviewCount,
),
},
delta: {
caseCount: deltaCase,
readyCount: normalizeNumber(delta.readyCount),
invalidCount: normalizeNumber(delta.invalidCount),
pendingRequestCaseCount: normalizeNumber(delta.pendingRequestCaseCount),
needsHumanReviewCount: normalizeNumber(delta.needsHumanReviewCount),
},
state,
score: latestCase * 10 + Math.abs(deltaCase) * 5,
};
})
.filter((entry) => entry.latest.caseCount > 0 || entry.delta.caseCount !== 0)
.sort((left, right) => {
if (right.latest.caseCount !== left.latest.caseCount) {
return right.latest.caseCount - left.latest.caseCount;
}
if (Math.abs(right.delta.caseCount) !== Math.abs(left.delta.caseCount)) {
return Math.abs(right.delta.caseCount) - Math.abs(left.delta.caseCount);
}
return left.name.localeCompare(right.name);
});
}
function buildVerificationOutcomeSummary(focusEntries) {
const entries = Array.isArray(focusEntries) ? focusEntries : [];
const blockingFailureEntries = entries.filter(
(entry) =>
getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) ===
"blocking_failure",
);
const advisoryFailureEntries = entries.filter(
(entry) =>
getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) ===
"advisory_failure",
);
const failureEntries = [...blockingFailureEntries, ...advisoryFailureEntries];
const recoveredEntries = entries.filter(
(entry) =>
getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) ===
"recovered",
);
return {
focusCount: entries.length,
failureFocusCount: failureEntries.length,
recoveredFocusCount: recoveredEntries.length,
blockingFailureFocusCount: blockingFailureEntries.length,
advisoryFailureFocusCount: advisoryFailureEntries.length,
failureCaseCount: failureEntries.reduce(
(total, entry) => total + normalizeNumber(entry?.latest?.caseCount),
0,
),
blockingFailureCaseCount: blockingFailureEntries.reduce(
(total, entry) => total + normalizeNumber(entry?.latest?.caseCount),
0,
),
advisoryFailureCaseCount: advisoryFailureEntries.reduce(
(total, entry) => total + normalizeNumber(entry?.latest?.caseCount),
0,
),
recoveredCaseCount: recoveredEntries.reduce(
(total, entry) => total + normalizeNumber(entry?.latest?.caseCount),
0,
),
topFailureOutcomes: failureEntries
.slice(0, 3)
.map((entry) => `${entry.signal}:${entry.outcome}`),
topBlockingFailureOutcomes: blockingFailureEntries
.slice(0, 3)
.map((entry) => `${entry.signal}:${entry.outcome}`),
topAdvisoryFailureOutcomes: advisoryFailureEntries
.slice(0, 3)
.map((entry) => `${entry.signal}:${entry.outcome}`),
topRecoveredOutcomes: recoveredEntries
.slice(0, 3)
.map((entry) => `${entry.signal}:${entry.outcome}`),
};
}
function buildDocFreshnessSummary(docFreshnessReport) {
const summary = isObject(docFreshnessReport?.summary)
? docFreshnessReport.summary
@@ -993,8 +738,8 @@ export function assertGeneratedSlopReportContract(report) {
if (
verificationFailureFocus.some(
(entry) =>
getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) ===
"recovered",
getVerificationOutcomeRole(entry?.signal, entry?.outcome) ===
"recovered",
)
) {
throw new Error(
@@ -1005,183 +750,6 @@ export function assertGeneratedSlopReportContract(report) {
return report;
}
function hasObservabilityVerificationOutcome(entries, signal, outcome) {
const normalizedEntries = Array.isArray(entries) ? entries : [];
const normalizedSignal = normalizeString(signal);
const normalizedOutcome = normalizeString(outcome);
return normalizedEntries.some(
(entry) =>
normalizeString(entry?.signal) === normalizedSignal &&
normalizeString(entry?.outcome) === normalizedOutcome,
);
}
function buildCurrentBlockingVerificationFollowUp(
focusCurrentObservabilityVerificationOutcomes,
) {
const hasCurrentGuiSmokeFailure = hasObservabilityVerificationOutcome(
focusCurrentObservabilityVerificationOutcomes,
"guiSmoke",
"failed",
);
const hasCurrentBrowserVerificationFailure =
hasObservabilityVerificationOutcome(
focusCurrentObservabilityVerificationOutcomes,
"browserVerification",
"failure",
);
const commands = ["npm run harness:eval", "npm run harness:eval:trend"];
const backlogTools = [];
const rationale = [];
if (hasCurrentGuiSmokeFailure) {
commands.push("npm run verify:gui-smoke");
rationale.push(
"current 样本已出现 guiSmoke:failed,先恢复 GUI 壳 / DevBridge / Workspace 主路径的最小可启动性。",
);
backlogTools.push(
"优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。",
);
}
if (hasCurrentBrowserVerificationFailure) {
rationale.push(
"current 样本已出现 browserVerification:failure,应先回看 browser replay / verification 失败样本,把失败断言回挂到受影响主路径。",
);
backlogTools.push(
"回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。",
);
}
if (backlogTools.length === 0) {
backlogTools.push("按受影响主路径追加 `npm run verify:gui-smoke` 或专项 smoke");
}
return {
commands: dedupeNonEmptyStrings(commands),
backlogTools: dedupeNonEmptyStrings(backlogTools),
rationale: dedupeNonEmptyStrings(rationale),
};
}
function buildCurrentAdvisoryVerificationFollowUp(
focusCurrentObservabilityVerificationOutcomes,
) {
const hasArtifactValidatorIssuesPresent = hasObservabilityVerificationOutcome(
focusCurrentObservabilityVerificationOutcomes,
"artifactValidator",
"issues_present",
);
const hasArtifactValidatorFallbackUsed = hasObservabilityVerificationOutcome(
focusCurrentObservabilityVerificationOutcomes,
"artifactValidator",
"fallback_used",
);
const hasBrowserVerificationUnknown = hasObservabilityVerificationOutcome(
focusCurrentObservabilityVerificationOutcomes,
"browserVerification",
"unknown",
);
const rationale = [];
const backlogTools = [];
if (hasArtifactValidatorIssuesPresent) {
rationale.push(
"current 样本已出现 artifactValidator:issues_present,应先回看 validator issue 明细,再收敛 artifact 导出字段。",
);
backlogTools.push(
"回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。",
);
}
if (hasArtifactValidatorFallbackUsed) {
rationale.push(
"current 样本已出现 artifactValidator:fallback_used,说明 artifact 主路径仍不稳定,不能继续依赖 fallback 充当事实。",
);
backlogTools.push(
"补齐 artifact 主路径导出与修复链,减少 fallback_used 持续留在 current 样本。",
);
}
if (hasBrowserVerificationUnknown) {
rationale.push(
"current 样本已出现 browserVerification:unknown,需要先把浏览器验证结果收敛成明确 outcome,再继续扩大分析。",
);
backlogTools.push(
"回看 browser verification 导出链,确保 evidence pack / replay / analysis handoff 写出明确 success 或 failure,而不是 unknown。",
);
}
if (backlogTools.length === 0) {
backlogTools.push(
"先对齐 current verification outcome 到 artifact/browser/gui 主路径,再继续补 observability 证据。",
);
}
return {
rationale: dedupeNonEmptyStrings(rationale),
backlogTools: dedupeNonEmptyStrings(backlogTools),
};
}
function buildCurrentRecoveredVerificationFollowUp(
focusCurrentRecoveredObservabilityVerificationOutcomes,
) {
const hasArtifactValidatorRepaired = hasObservabilityVerificationOutcome(
focusCurrentRecoveredObservabilityVerificationOutcomes,
"artifactValidator",
"repaired",
);
const hasBrowserVerificationSuccess = hasObservabilityVerificationOutcome(
focusCurrentRecoveredObservabilityVerificationOutcomes,
"browserVerification",
"success",
);
const hasGuiSmokePassed = hasObservabilityVerificationOutcome(
focusCurrentRecoveredObservabilityVerificationOutcomes,
"guiSmoke",
"passed",
);
const commands = ["npm run harness:eval", "npm run harness:eval:trend"];
const rationale = [];
const backlogTools = [];
if (hasArtifactValidatorRepaired) {
rationale.push(
"current 样本已出现 artifactValidator:repaired,说明 artifact 修复链已经回到可复用的主路径。",
);
backlogTools.push(
"在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。",
);
}
if (hasBrowserVerificationSuccess) {
rationale.push(
"current 样本已出现 browserVerification:success,可把浏览器验证成功样本固化成主路径正向基线。",
);
backlogTools.push(
"把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。",
);
}
if (hasGuiSmokePassed) {
commands.push("npm run verify:gui-smoke");
rationale.push(
"current 样本已出现 guiSmoke:passed,可继续把 GUI smoke 通过链路当成桌面主路径的正向守卫。",
);
backlogTools.push(
"主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。",
);
}
return {
commands: dedupeNonEmptyStrings(commands),
rationale: dedupeNonEmptyStrings(rationale),
backlogTools: dedupeNonEmptyStrings(backlogTools),
};
}
function buildRecommendations({
trendSummary,
verificationOutcomeSummary,
@@ -1213,21 +781,25 @@ function buildRecommendations({
const topObservabilitySignals = focusObservabilitySignals
.slice(0, 3)
.map((entry) => `${entry.signal} (${entry.status})`);
const topVerificationFailureOutcomes = focusVerificationFailureOutcomes
.slice(0, 3)
.map((entry) => `${entry.signal} (${entry.outcome})`);
const topVerificationFailureOutcomes = formatVerificationOutcomeCompactLabels(
focusVerificationFailureOutcomes,
3,
);
const topCurrentVerificationFailureOutcomes =
focusCurrentObservabilityVerificationOutcomes
.slice(0, 3)
.map((entry) => `${entry.signal} (${entry.outcome})`);
formatVerificationOutcomeCompactLabels(
focusCurrentObservabilityVerificationOutcomes,
3,
);
const topCurrentRecoveredVerificationOutcomes =
focusCurrentRecoveredObservabilityVerificationOutcomes
.slice(0, 3)
.map((entry) => `${entry.signal} (${entry.outcome})`);
formatVerificationOutcomeCompactLabels(
focusCurrentRecoveredObservabilityVerificationOutcomes,
3,
);
const topDegradedVerificationFailureOutcomes =
focusDegradedObservabilityVerificationOutcomes
.slice(0, 3)
.map((entry) => `${entry.signal} (${entry.outcome})`);
formatVerificationOutcomeCompactLabels(
focusDegradedObservabilityVerificationOutcomes,
3,
);
const topRecommendedVerificationFailureOutcomes =
topCurrentVerificationFailureOutcomes.length > 0
? topCurrentVerificationFailureOutcomes
@@ -1241,15 +813,15 @@ function buildRecommendations({
const degradedVerificationSummary =
verificationOutcomeSummary?.degraded ?? buildVerificationOutcomeSummary([]);
const currentBlockingVerificationFollowUp =
buildCurrentBlockingVerificationFollowUp(
buildBlockingVerificationFollowUp(
focusCurrentObservabilityVerificationOutcomes,
);
const currentAdvisoryVerificationFollowUp =
buildCurrentAdvisoryVerificationFollowUp(
buildAdvisoryVerificationFollowUp(
focusCurrentObservabilityVerificationOutcomes,
);
const currentRecoveredVerificationFollowUp =
buildCurrentRecoveredVerificationFollowUp(
buildRecoveredVerificationFollowUp(
focusCurrentRecoveredObservabilityVerificationOutcomes,
);
@@ -1314,16 +886,13 @@ function buildRecommendations({
rationale: [
`当前 failure mode 焦点:${topFailureModes.join("、") || "暂无"}。`,
"先用 replay / eval 固化失败,再按受影响主路径补最小 smoke,而不是直接凭印象清理。",
topCurrentVerificationFailureOutcomes.length > 0
? `当前 current verification failure outcome 焦点:${topCurrentVerificationFailureOutcomes.join("、")}。`
: "当前没有额外的 verification failure outcome 焦点。",
...buildBlockingVerificationRecommendationRationale({
topCurrentVerificationFailureOutcomes:
focusCurrentObservabilityVerificationOutcomes,
currentVerificationSummary,
degradedVerificationSummary,
}),
...currentBlockingVerificationFollowUp.rationale,
currentVerificationSummary.blockingFailureCaseCount > 0
? `其中 current blocking verification failure 共 ${currentVerificationSummary.blockingFailureCaseCount} 个 case:${currentVerificationSummary.topBlockingFailureOutcomes.join("、") || "暂无"}。`
: "当前没有额外的 blocking verification failure。",
degradedVerificationSummary.blockingFailureCaseCount > 0
? `另有 ${degradedVerificationSummary.blockingFailureCaseCount} 个 degraded blocking verification failure 样本作为诊断基线,不直接抬高主线优先级。`
: "当前没有额外的 degraded blocking verification baseline。",
],
commands: currentBlockingVerificationFollowUp.commands,
backlogTools: currentBlockingVerificationFollowUp.backlogTools,
@@ -1409,38 +978,27 @@ function buildRecommendations({
: "P2",
title: "先补 observability 证据覆盖,再扩大外部分析与回归",
rationale: [
trendSummary.latestCurrentObservabilityGapCaseCount > 0
? `当前仍有 ${trendSummary.latestCurrentObservabilityGapCaseCount} 个 current case 带着 observability 证据缺口进入 replay/eval。`
: "当前 trend 已检测到 observability coverage 漂移,需先修证据而不是空谈根因分析。",
trendSummary.latestDegradedObservabilityGapCaseCount > 0
? `另有 ${trendSummary.latestDegradedObservabilityGapCaseCount} 个 degraded gap 样本作为诊断基线保留,它们不应直接被当成主线回归。`
: "当前没有额外保留的 degraded observability gap 样本。",
`当前缺口焦点:${topObservabilitySignals.join("、") || "暂无"}。这些缺口会直接降低 analysis handoff、人工审核和 cleanup report 的判断质量。`,
topCurrentVerificationFailureOutcomes.length > 0
? `当前 current verification failure outcome 焦点:${topCurrentVerificationFailureOutcomes.join("、")}。可用它们直接定位先补 artifact/browser/gui 哪一层。`
: "当前没有额外的 verification failure outcome 焦点。",
...buildObservabilityRecommendationRationale({
trendSummary,
topObservabilitySignals,
topCurrentVerificationFailureOutcomes:
focusCurrentObservabilityVerificationOutcomes,
topDegradedVerificationFailureOutcomes:
focusDegradedObservabilityVerificationOutcomes,
currentVerificationSummary,
}),
...currentAdvisoryVerificationFollowUp.rationale,
currentVerificationSummary.advisoryFailureCaseCount > 0
? `当前 current advisory verification failure 共 ${currentVerificationSummary.advisoryFailureCaseCount} 个 case:${currentVerificationSummary.topAdvisoryFailureOutcomes.join("、") || "暂无"}。`
: "当前没有额外的 advisory verification failure。",
topDegradedVerificationFailureOutcomes.length > 0
? `当前保留的 degraded verification baseline:${topDegradedVerificationFailureOutcomes.join("、")}。`
: "当前没有额外的 degraded verification baseline。",
],
commands: [
"npm run harness:eval",
"npm run harness:eval:trend",
"npm run harness:cleanup-report",
],
backlogTools: [
"优先补 request telemetry 关联键、artifact validator outcome、browser/gui smoke 结果到 evidence pack / analysis handoff / replay。",
...(topCurrentVerificationFailureOutcomes.length > 0
? [
`先对齐 current verification failure outcome:${topCurrentVerificationFailureOutcomes.join("、")}。`,
]
: []),
...currentAdvisoryVerificationFollowUp.backlogTools,
],
backlogTools: buildObservabilityRecommendationBacklog({
topCurrentVerificationFailureOutcomes:
focusCurrentObservabilityVerificationOutcomes,
advisoryFollowUpBacklogTools: currentAdvisoryVerificationFollowUp.backlogTools,
}),
focusFailureModes: topFailureModes,
focusSuiteTags: topSuiteTags,
focusReviewDecisionStatuses: topReviewDecisionStatuses,
@@ -1493,9 +1051,11 @@ function buildRecommendations({
: "P3",
title: "把 recovered verification outcome 固化成 current 正向基线",
rationale: [
topCurrentRecoveredVerificationOutcomes.length > 0
? `当前 current recovered outcome 焦点:${topCurrentRecoveredVerificationOutcomes.join("、")}。`
: `当前 current recovered outcome 共 ${currentVerificationSummary.recoveredCaseCount} 个 case。`,
...buildRecoveredVerificationRecommendationRationale({
topCurrentRecoveredVerificationOutcomes:
focusCurrentRecoveredObservabilityVerificationOutcomes,
currentVerificationSummary,
}),
...currentRecoveredVerificationFollowUp.rationale,
"恢复成功的 outcome 不应只停留在统计卡里,还应继续回挂到 replay / smoke / evidence 主链,作为后续回退判断的正向对照。",
],
@@ -1590,117 +1150,31 @@ export function buildGeneratedSlopReport({
trendReport?.classificationDeltas?.observabilitySignals,
trendSummary.sampleCount,
);
const rawObservabilityVerificationOutcomes =
buildObservabilityVerificationFocusEntries(
trendReport?.classificationDeltas?.observabilityVerificationOutcomes,
trendSummary.sampleCount,
);
const explicitRecoveredObservabilityVerificationOutcomes =
buildVerificationOutcomeEntriesFromDeltas(
trendReport?.classificationDeltas?.observabilityVerificationOutcomes,
).filter(
(entry) =>
getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) ===
"recovered",
);
const focusVerificationFailureOutcomes =
rawObservabilityVerificationOutcomes.filter(
(entry) =>
getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) !==
"recovered",
);
const rawCurrentObservabilityVerificationOutcomes =
buildObservabilityVerificationFocusEntries(
trendReport?.classificationDeltas?.currentObservabilityVerificationOutcomes,
trendSummary.sampleCount,
);
const explicitCurrentRecoveredObservabilityVerificationOutcomes =
buildVerificationOutcomeEntriesFromDeltas(
trendReport?.classificationDeltas?.currentRecoveredObservabilityVerificationOutcomes,
);
const focusCurrentObservabilityVerificationOutcomes =
rawCurrentObservabilityVerificationOutcomes.filter(
(entry) =>
getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) !==
"recovered",
);
const focusCurrentRecoveredObservabilityVerificationOutcomes =
explicitCurrentRecoveredObservabilityVerificationOutcomes.length > 0
? explicitCurrentRecoveredObservabilityVerificationOutcomes
: rawCurrentObservabilityVerificationOutcomes.filter(
(entry) =>
getObservabilityVerificationOutcomeRole(
entry?.signal,
entry?.outcome,
) === "recovered",
);
const rawDegradedObservabilityVerificationOutcomes =
buildObservabilityVerificationFocusEntries(
trendReport?.classificationDeltas?.degradedObservabilityVerificationOutcomes,
trendSummary.sampleCount,
);
const focusDegradedObservabilityVerificationOutcomes =
rawDegradedObservabilityVerificationOutcomes.filter(
(entry) =>
getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) !==
"recovered",
);
const verificationPresentation = deriveVerificationOutcomePresentationFromTrend({
trendReport,
sampleCount: trendSummary.sampleCount,
});
const mergedVerificationFailureOutcomes =
focusVerificationFailureOutcomes.length > 0
? focusVerificationFailureOutcomes
: [
...focusCurrentObservabilityVerificationOutcomes,
...focusDegradedObservabilityVerificationOutcomes,
].sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.name.localeCompare(right.name);
});
const verificationFailureSummary = buildVerificationOutcomeSummary(
mergedVerificationFailureOutcomes,
);
const recoveredVerificationSummary = buildVerificationOutcomeSummary(
explicitRecoveredObservabilityVerificationOutcomes.length > 0
? explicitRecoveredObservabilityVerificationOutcomes
: mergedVerificationFailureOutcomes.filter(
(entry) =>
getObservabilityVerificationOutcomeRole(
entry?.signal,
entry?.outcome,
) === "recovered",
),
);
const verificationOutcomeSummary = {
...verificationFailureSummary,
recoveredFocusCount: recoveredVerificationSummary.recoveredFocusCount,
recoveredCaseCount: recoveredVerificationSummary.recoveredCaseCount,
topRecoveredOutcomes: recoveredVerificationSummary.topRecoveredOutcomes,
};
const currentVerificationFailureSummary = buildVerificationOutcomeSummary(
focusCurrentObservabilityVerificationOutcomes,
);
const currentRecoveredVerificationSummary = buildVerificationOutcomeSummary(
focusCurrentRecoveredObservabilityVerificationOutcomes,
);
const currentVerificationOutcomeSummary = {
...currentVerificationFailureSummary,
recoveredFocusCount: currentRecoveredVerificationSummary.recoveredFocusCount,
recoveredCaseCount: currentRecoveredVerificationSummary.recoveredCaseCount,
topRecoveredOutcomes: currentRecoveredVerificationSummary.topRecoveredOutcomes,
};
const degradedVerificationOutcomeSummary = buildVerificationOutcomeSummary(
focusDegradedObservabilityVerificationOutcomes,
);
verificationPresentation.mergedVerificationFailureOutcomes;
const focusVerificationFailureOutcomes = mergedVerificationFailureOutcomes;
const focusCurrentObservabilityVerificationOutcomes =
verificationPresentation.focusCurrentVerificationFailureOutcomes;
const focusCurrentRecoveredObservabilityVerificationOutcomes =
verificationPresentation.focusCurrentRecoveredVerificationOutcomes;
const focusDegradedObservabilityVerificationOutcomes =
verificationPresentation.focusDegradedVerificationFailureOutcomes;
const combinedVerificationOutcomeSummary =
verificationPresentation.verificationOutcomeSummary;
const verificationOutcomeSummary = combinedVerificationOutcomeSummary;
const currentVerificationOutcomeSummary =
combinedVerificationOutcomeSummary.current;
const degradedVerificationOutcomeSummary =
combinedVerificationOutcomeSummary.degraded;
const currentRecoveredVerificationOutcomes =
focusCurrentRecoveredObservabilityVerificationOutcomes
.slice(0, 3)
.map((entry) => `${entry.signal} (${entry.outcome})`);
const combinedVerificationOutcomeSummary = {
...verificationOutcomeSummary,
current: currentVerificationOutcomeSummary,
degraded: degradedVerificationOutcomeSummary,
};
formatVerificationOutcomeCompactLabels(
focusCurrentRecoveredObservabilityVerificationOutcomes,
3,
);
const governanceSurfaces = buildGovernanceSurfaceEntries(governanceReport);
const governanceSummary = buildGovernanceSummary(
governanceReport,
@@ -1771,30 +1245,15 @@ export function buildGeneratedSlopReport({
trendSummary.latestDegradedObservabilityGapCaseCount > 0
? `当前保留 ${trendSummary.latestDegradedObservabilityGapCaseCount} 个 degraded observability gap 样本作为诊断基线。`
: "当前没有额外保留的 degraded observability gap 样本。",
focusVerificationFailureOutcomes.length > 0
? `当前 verification failure outcome 焦点:${focusVerificationFailureOutcomes
.slice(0, 3)
.map((entry) => `${entry.signal} (${entry.outcome})`)
.join("、")}。`
: "当前没有额外的 verification failure outcome 焦点。",
verificationOutcomeSummary.failureCaseCount > 0
? `当前 verification failure 聚焦 ${verificationOutcomeSummary.failureFocusCount} 类 outcome,共 ${verificationOutcomeSummary.failureCaseCount} 个 case。`
: "当前没有额外的 verification failure case。",
currentVerificationOutcomeSummary.blockingFailureCaseCount > 0
? `当前 current 样本里有 ${currentVerificationOutcomeSummary.blockingFailureCaseCount} 个 blocking verification failure。`
: "当前没有额外的 blocking verification failure。",
currentVerificationOutcomeSummary.advisoryFailureCaseCount > 0
? `当前 current 样本里有 ${currentVerificationOutcomeSummary.advisoryFailureCaseCount} 个 advisory verification failure。`
: "当前没有额外的 advisory verification failure。",
currentVerificationOutcomeSummary.recoveredCaseCount > 0
? `当前 current recovered verification baseline:${currentRecoveredVerificationOutcomes.join("、") || "暂无"}。`
: "当前没有额外的 current recovered verification baseline。",
degradedVerificationOutcomeSummary.blockingFailureCaseCount > 0
? `当前保留 ${degradedVerificationOutcomeSummary.blockingFailureCaseCount} 个 degraded blocking verification failure 样本作为诊断基线。`
: "当前没有额外的 degraded blocking verification baseline。",
verificationOutcomeSummary.recoveredCaseCount > 0
? `当前 verification recovered 聚焦 ${verificationOutcomeSummary.recoveredFocusCount} 类 outcome,共 ${verificationOutcomeSummary.recoveredCaseCount} 个 case。`
: "当前没有额外的 verification recovered case。",
...buildVerificationOutcomeSignalMessages({
focusVerificationFailureOutcomes,
verificationOutcomeSummary,
currentVerificationOutcomeSummary,
degradedVerificationOutcomeSummary,
currentRecoveredVerificationOutcomes:
focusCurrentRecoveredObservabilityVerificationOutcomes,
labelLimit: 3,
}),
],
focus: {
failureModes: focusFailureModes.slice(0, 5),
@@ -1899,7 +1358,7 @@ export function renderGeneratedSlopText(report) {
lines.push("[harness-cleanup] top observability verification outcomes:");
for (const entry of report.focus.observabilityVerificationOutcomes) {
lines.push(
` - ${entry.signal} (${entry.outcome}): state=${entry.state}, latest_case=${entry.latest.caseCount}, delta_case=${entry.delta.caseCount}, score=${entry.score}`,
` - ${formatVerificationOutcomeCompactLabel(entry)}: state=${entry.state}, latest_case=${entry.latest.caseCount}, delta_case=${entry.delta.caseCount}, score=${entry.score}`,
);
}
}
+46 -111
View File
@@ -1,3 +1,8 @@
import {
describeVerificationOutcome,
deriveVerificationDashboardPresentation,
} from "./harness-verification-facts.mjs";
function normalizeNumber(value) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
@@ -141,49 +146,39 @@ function renderRecommendationList(recommendations) {
.join("");
}
function describeVerificationOutcome(entry) {
const signal = normalizeString(entry?.signal, "unknown");
const outcome = normalizeString(entry?.outcome, "unknown");
if (signal === "artifactValidator" && outcome === "issues_present") {
return "当前 evidence 已记录 artifact 校验问题,优先回看 validator issue 明细。";
}
if (signal === "artifactValidator" && outcome === "fallback_used") {
return "当前 artifact 导出仍触发 fallback,说明产物结构或修复链未完全稳定。";
}
if (signal === "browserVerification" && outcome === "failure") {
return "浏览器验证已有明确失败结果,优先回挂到 replay 或 smoke 断言。";
}
if (signal === "browserVerification" && outcome === "unknown") {
return "浏览器验证结果仍不明确,需要先补 outcome 再继续扩分析。";
}
if (signal === "guiSmoke" && outcome === "failed") {
return "GUI smoke 已明确失败,应优先收敛到受影响主路径。";
}
if (signal === "guiSmoke" && outcome === "passed") {
return "GUI smoke 已通过,可继续把注意力放回 gap 与其它失败面。";
}
if (signal === "artifactValidator" && outcome === "repaired") {
return "artifact validator 已执行修复,可结合 issues/fallback 判断是否还需继续治理。";
}
if (signal === "browserVerification" && outcome === "success") {
return "浏览器验证已有成功样本,可作为 current 主线路径的正向基线。";
function deriveTrendSummary(trendReport, cleanupReport) {
const cleanupTrendSummary =
cleanupReport &&
typeof cleanupReport === "object" &&
cleanupReport.summary &&
cleanupReport.summary.trend
? cleanupReport.summary.trend
: null;
if (cleanupTrendSummary) {
return cleanupTrendSummary;
}
return "当前 verification outcome 已进入 cleanup 主线,可直接据此定位先修哪层。";
}
const latestTotals =
trendReport && typeof trendReport === "object" && trendReport.latest?.totals
? trendReport.latest.totals
: {};
const delta = trendReport && typeof trendReport === "object" ? trendReport.delta : {};
const RECOVERED_VERIFICATION_OUTCOMES = new Set([
"repaired",
"success",
"passed",
"clean",
]);
function isRecoveredVerificationOutcome(entry) {
return RECOVERED_VERIFICATION_OUTCOMES.has(
normalizeString(entry?.outcome, "unknown"),
);
return {
sampleCount: normalizeNumber(trendReport?.sampleCount),
latestCurrentObservabilityGapCaseCount: normalizeNumber(
latestTotals.currentObservabilityGapCaseCount,
),
latestDegradedObservabilityGapCaseCount: normalizeNumber(
latestTotals.degradedObservabilityGapCaseCount,
),
currentObservabilityGapCaseDelta: normalizeNumber(
delta?.currentObservabilityGapCaseCount,
),
degradedObservabilityGapCaseDelta: normalizeNumber(
delta?.degradedObservabilityGapCaseCount,
),
};
}
function renderFocusTable(title, entries, columns) {
@@ -240,13 +235,7 @@ export function renderHarnessDashboardHtml({
summaryReport && typeof summaryReport === "object" && summaryReport.totals
? summaryReport.totals
: {};
const trendSummary =
cleanupReport &&
typeof cleanupReport === "object" &&
cleanupReport.summary &&
cleanupReport.summary.trend
? cleanupReport.summary.trend
: {};
const trendSummary = deriveTrendSummary(trendReport, cleanupReport);
const governanceSummary =
cleanupReport &&
typeof cleanupReport === "object" &&
@@ -254,13 +243,12 @@ export function renderHarnessDashboardHtml({
cleanupReport.summary.governance
? cleanupReport.summary.governance
: {};
const verificationSummary =
cleanupReport &&
typeof cleanupReport === "object" &&
cleanupReport.summary &&
cleanupReport.summary.verificationOutcomes
? cleanupReport.summary.verificationOutcomes
: {};
const verificationPresentation = deriveVerificationDashboardPresentation({
summaryReport,
trendReport,
cleanupReport,
});
const verificationSummary = verificationPresentation.verificationSummary;
const currentVerificationSummary =
verificationSummary &&
typeof verificationSummary.current === "object" &&
@@ -281,64 +269,11 @@ export function renderHarnessDashboardHtml({
? cleanupReport.recommendations
: [];
const sampleRows = Array.isArray(trendReport?.samples) ? trendReport.samples : [];
const currentVerificationFocusRows = Array.isArray(
cleanupReport?.focus?.currentObservabilityVerificationOutcomes,
)
? cleanupReport.focus.currentObservabilityVerificationOutcomes.map((entry) => ({
...entry,
role: "current",
}))
: [];
const degradedVerificationFocusRows = Array.isArray(
cleanupReport?.focus?.degradedObservabilityVerificationOutcomes,
)
? cleanupReport.focus.degradedObservabilityVerificationOutcomes.map(
(entry) => ({
...entry,
role: "degraded",
}),
)
: [];
const fallbackVerificationFocusRows = Array.isArray(
cleanupReport?.focus?.observabilityVerificationOutcomes,
)
? cleanupReport.focus.observabilityVerificationOutcomes.map((entry) => ({
...entry,
role: "mixed",
}))
: [];
const explicitCurrentRecoveredVerificationRows = Array.isArray(
cleanupReport?.focus?.currentRecoveredObservabilityVerificationOutcomes,
)
? cleanupReport.focus.currentRecoveredObservabilityVerificationOutcomes.map(
(entry) => ({
...entry,
role: "current",
}),
)
: [];
const verificationFocusRows =
currentVerificationFocusRows.length > 0 ||
degradedVerificationFocusRows.length > 0
? [...currentVerificationFocusRows, ...degradedVerificationFocusRows]
: fallbackVerificationFocusRows;
const verificationFocusRows = verificationPresentation.verificationFocusRows;
const currentRecoveredVerificationRows =
explicitCurrentRecoveredVerificationRows.length > 0
? explicitCurrentRecoveredVerificationRows
: currentVerificationFocusRows.length > 0
? currentVerificationFocusRows.filter((entry) =>
isRecoveredVerificationOutcome(entry),
)
: fallbackVerificationFocusRows.filter((entry) =>
isRecoveredVerificationOutcome(entry),
);
const currentRecoveredVerificationSummary = currentRecoveredVerificationRows
.slice(0, 3)
.map(
(entry) =>
`${normalizeString(entry?.signal, "-")} (${normalizeString(entry?.outcome, "-")})`,
)
.join("、");
verificationPresentation.currentRecoveredRows;
const currentRecoveredVerificationSummary =
verificationPresentation.currentRecoveredSummaryLabel;
return `<!doctype html>
<html lang="zh-CN">
+135
View File
@@ -3,6 +3,141 @@ import { describe, expect, it } from "vitest";
import { renderHarnessDashboardHtml } from "./harness-dashboard-core.mjs";
describe("harness-dashboard-core", () => {
it("应优先使用 trend 与 summary 的 verification facts,而不是 cleanup 渲染面", () => {
const html = renderHarnessDashboardHtml({
title: "Harness Engine Dashboard",
summaryReport: {
generatedAt: "2026-04-12T08:00:00.000Z",
totals: {
readyCount: 1,
invalidCount: 0,
},
breakdowns: {
observabilityVerificationOutcomes: [
{ name: "guiSmoke:failed", caseCount: 1 },
{ name: "browserVerification:success", caseCount: 1 },
],
currentObservabilityVerificationOutcomes: [
{ name: "guiSmoke:failed", caseCount: 1 },
{ name: "browserVerification:success", caseCount: 1 },
],
currentRecoveredObservabilityVerificationOutcomes: [
{ name: "browserVerification:success", caseCount: 1 },
],
degradedObservabilityVerificationOutcomes: [],
},
},
trendReport: {
generatedAt: "2026-04-12T08:01:00.000Z",
sampleCount: 2,
delta: {
currentObservabilityGapCaseCount: 0,
degradedObservabilityGapCaseCount: 0,
},
latest: {
totals: {
currentObservabilityGapCaseCount: 0,
degradedObservabilityGapCaseCount: 0,
currentRecoveredVerificationCaseCount: 1,
},
},
signals: ["current gap 保持为 0。"],
samples: [],
classificationDeltas: {
observabilityVerificationOutcomes: [
{
name: "guiSmoke:failed",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
{
name: "browserVerification:success",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
currentObservabilityVerificationOutcomes: [
{
name: "guiSmoke:failed",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
{
name: "browserVerification:success",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
currentRecoveredObservabilityVerificationOutcomes: [
{
name: "browserVerification:success",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
degradedObservabilityVerificationOutcomes: [],
},
},
cleanupReport: {
generatedAt: "2026-04-12T08:02:00.000Z",
signals: [],
recommendations: [],
focus: {
currentObservabilityVerificationOutcomes: [
{
signal: "artifactValidator",
outcome: "fallback_used",
state: "regressing",
latest: { caseCount: 2 },
delta: { caseCount: 2 },
},
],
currentRecoveredObservabilityVerificationOutcomes: [
{
signal: "artifactValidator",
outcome: "repaired",
state: "expanding",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
degradedObservabilityVerificationOutcomes: [],
},
summary: {
trend: {
sampleCount: 2,
latestCurrentObservabilityGapCaseCount: 0,
latestDegradedObservabilityGapCaseCount: 0,
currentObservabilityGapCaseDelta: 0,
degradedObservabilityGapCaseDelta: 0,
},
verificationOutcomes: {
recoveredCaseCount: 0,
current: {
blockingFailureCaseCount: 0,
advisoryFailureCaseCount: 2,
recoveredCaseCount: 0,
},
degraded: {
blockingFailureCaseCount: 0,
advisoryFailureCaseCount: 0,
},
},
governance: {
violationCount: 0,
},
},
},
});
expect(html).toMatch(/Current Blocking<\/span>\s*<strong>1<\/strong>/);
expect(html).toMatch(/Current Recovered<\/span>\s*<strong>1<\/strong>/);
expect(html).toContain("browserVerification (success)");
expect(html).toContain("GUI smoke 已明确失败");
expect(html).not.toContain("artifactValidator (repaired)、");
expect(html).not.toContain("fallback_used");
});
it("应把 summary、trend、cleanup 渲染成单一事实源 dashboard", () => {
const html = renderHarnessDashboardHtml({
title: "Harness Engine Dashboard",
@@ -4,6 +4,8 @@ import path from "node:path";
import { execFile, execFileSync } from "node:child_process";
import { afterEach, describe, expect, it } from "vitest";
import { deriveHistoryRecordVerificationFacts } from "../harness-eval-history-record.mjs";
const repoRoot = process.cwd();
const tempRoots: string[] = [];
@@ -59,6 +61,140 @@ afterEach(() => {
});
describe("harness-eval-history-record", () => {
it("应优先使用 summary 与 trend 的 verification facts,而不是 cleanup 反算结果", () => {
const summary = {
totals: {
currentRecoveredVerificationCaseCount: 2,
},
breakdowns: {
observabilityVerificationOutcomes: [
{ name: "guiSmoke:failed", caseCount: 2 },
{ name: "artifactValidator:issues_present", caseCount: 1 },
{ name: "browserVerification:success", caseCount: 1 },
{ name: "guiSmoke:passed", caseCount: 1 },
],
currentObservabilityVerificationOutcomes: [
{ name: "guiSmoke:failed", caseCount: 2 },
{ name: "artifactValidator:issues_present", caseCount: 1 },
{ name: "browserVerification:success", caseCount: 1 },
{ name: "guiSmoke:passed", caseCount: 1 },
],
currentRecoveredObservabilityVerificationOutcomes: [
{ name: "browserVerification:success", caseCount: 1 },
{ name: "guiSmoke:passed", caseCount: 1 },
],
degradedObservabilityVerificationOutcomes: [
{ name: "browserVerification:failure", caseCount: 1 },
],
},
};
const trendReport = {
latest: {
totals: {
currentRecoveredVerificationCaseCount: 2,
},
},
classificationDeltas: {
observabilityVerificationOutcomes: [
{
name: "guiSmoke:failed",
latest: { caseCount: 2 },
delta: { caseCount: 2 },
},
{
name: "artifactValidator:issues_present",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
currentObservabilityVerificationOutcomes: [
{
name: "guiSmoke:failed",
latest: { caseCount: 2 },
delta: { caseCount: 2 },
},
{
name: "artifactValidator:issues_present",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
{
name: "browserVerification:success",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
{
name: "guiSmoke:passed",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
currentRecoveredObservabilityVerificationOutcomes: [
{
name: "browserVerification:success",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
{
name: "guiSmoke:passed",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
},
};
const cleanupReport = {
summary: {
verificationOutcomes: {
failureCaseCount: 99,
recoveredCaseCount: 0,
current: {
blockingFailureCaseCount: 88,
advisoryFailureCaseCount: 77,
recoveredCaseCount: 0,
},
degraded: {
blockingFailureCaseCount: 66,
},
},
},
focus: {
observabilityVerificationOutcomes: [
{ signal: "artifactValidator", outcome: "fallback_used" },
],
currentObservabilityVerificationOutcomes: [
{ signal: "artifactValidator", outcome: "fallback_used" },
],
currentRecoveredObservabilityVerificationOutcomes: [
{ signal: "artifactValidator", outcome: "repaired" },
],
},
};
const result = deriveHistoryRecordVerificationFacts({
summary,
trendReport,
cleanupReport,
});
expect(result.verificationFailureOutcomeFocus).toEqual([
"guiSmoke:failed",
"artifactValidator:issues_present",
]);
expect(result.currentRecoveredBaselineFocus).toEqual([
"browserVerification:success",
"guiSmoke:passed",
]);
expect(result.verificationOutcomeCounts).toEqual({
failureCaseCount: 3,
blockingFailureCaseCount: 2,
advisoryFailureCaseCount: 1,
recoveredCaseCount: 2,
currentRecoveredCaseCount: 2,
degradedBlockingFailureCaseCount: 1,
});
});
it("默认入口应产出完整 harness artifact 套件", () => {
const tempRoot = createTempRoot();
const historyDir = path.join(tempRoot, ".lime", "harness", "history");
@@ -59,6 +59,7 @@ describe("Harness repo fixtures", () => {
expect(summary.totals.observabilityGapCaseCount).toBe(1);
expect(summary.totals.currentObservabilityGapCaseCount).toBe(0);
expect(summary.totals.degradedObservabilityGapCaseCount).toBe(1);
expect(summary.totals.currentRecoveredVerificationCaseCount).toBe(3);
const currentCase = repoFixtureSuite.cases.find(
(entry: { caseId: string }) =>
@@ -148,6 +149,20 @@ describe("Harness repo fixtures", () => {
"browserVerification:success",
);
const currentRecoveredVerificationOutcomeBreakdownNames =
summary.breakdowns.currentRecoveredObservabilityVerificationOutcomes.map(
(entry: { name: string }) => entry.name,
);
expect(currentRecoveredVerificationOutcomeBreakdownNames).toContain(
"artifactValidator:repaired",
);
expect(currentRecoveredVerificationOutcomeBreakdownNames).toContain(
"browserVerification:success",
);
expect(currentRecoveredVerificationOutcomeBreakdownNames).toContain(
"guiSmoke:passed",
);
expect(summary.breakdowns.degradedObservabilityVerificationOutcomes).toEqual(
[],
);
@@ -350,6 +350,7 @@ describe("Harness review decision / eval integration", () => {
expect(summary.totals.observabilityGapCaseCount).toBe(1);
expect(summary.totals.currentObservabilityGapCaseCount).toBe(1);
expect(summary.totals.degradedObservabilityGapCaseCount).toBe(0);
expect(summary.totals.currentRecoveredVerificationCaseCount).toBe(3);
expect(summary.breakdowns.reviewDecisionStatuses).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -402,6 +403,24 @@ describe("Harness review decision / eval integration", () => {
}),
]),
);
expect(
summary.breakdowns.currentRecoveredObservabilityVerificationOutcomes,
).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "artifactValidator:repaired",
caseCount: 1,
}),
expect.objectContaining({
name: "browserVerification:success",
caseCount: 1,
}),
expect.objectContaining({
name: "guiSmoke:passed",
caseCount: 1,
}),
]),
);
expect(summary.breakdowns.degradedObservabilityVerificationOutcomes).toEqual(
[],
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,524 @@
import { describe, expect, it } from "vitest";
import {
buildAdvisoryVerificationFollowUp,
buildAdvisoryVerificationRecommendationRationale,
buildBlockingVerificationFollowUp,
buildBlockingVerificationRecommendationRationale,
buildObservabilityRecommendationBacklog,
buildObservabilityRecommendationRationale,
buildVerificationOutcomeSignalMessages,
buildRecoveredVerificationRecommendationRationale,
deriveVerificationDashboardPresentation,
describeVerificationOutcome,
formatVerificationOutcomeCompactLabels,
buildRecoveredVerificationFollowUp,
buildVerificationOutcomeEntriesFromBreakdowns,
buildVerificationOutcomeEntriesFromDeltas,
buildVerificationFocusEntriesFromDeltas,
buildVerificationOutcomeSummary,
deriveVerificationOutcomePresentationFromTrend,
formatVerificationOutcomeCompactLabel,
getVerificationOutcomeRole,
getVerificationOutcomeWeight,
hasVerificationOutcome,
isVerificationFailureOutcome,
isVerificationRecoveredOutcome,
splitVerificationOutcomeName,
} from "./harness-verification-facts.mjs";
describe("harness-verification-facts", () => {
it("应统一解析 outcome 名称、角色与权重", () => {
expect(splitVerificationOutcomeName("browserVerification:failure")).toEqual({
name: "browserVerification:failure",
signal: "browserVerification",
outcome: "failure",
});
expect(splitVerificationOutcomeName("guiSmoke")).toEqual({
name: "guiSmoke",
signal: "guiSmoke",
outcome: "",
});
expect(getVerificationOutcomeRole("browserVerification", "failure")).toBe(
"blocking_failure",
);
expect(getVerificationOutcomeRole("artifactValidator", "issues_present")).toBe(
"advisory_failure",
);
expect(getVerificationOutcomeRole("browserVerification", "success")).toBe(
"recovered",
);
expect(getVerificationOutcomeRole("other", "noop")).toBe("other");
expect(isVerificationFailureOutcome("fallback_used")).toBe(true);
expect(isVerificationRecoveredOutcome("repaired")).toBe(true);
expect(getVerificationOutcomeWeight("failed")).toBe(140);
expect(getVerificationOutcomeWeight("repaired")).toBe(70);
expect(getVerificationOutcomeWeight("noop")).toBe(0);
});
it("应统一从 delta 与 breakdown 派生 verification entries 与 summary", () => {
const deltaEntries = buildVerificationOutcomeEntriesFromDeltas([
{
name: "guiSmoke:failed",
latest: { caseCount: 2 },
delta: { caseCount: 1 },
},
{
name: "browserVerification:success",
latest: { caseCount: 1 },
delta: { caseCount: 0 },
},
]);
const breakdownEntries = buildVerificationOutcomeEntriesFromBreakdowns([
{
name: "artifactValidator:issues_present",
caseCount: 1,
},
]);
expect(deltaEntries.map((entry) => entry.name)).toEqual([
"guiSmoke:failed",
"browserVerification:success",
]);
expect(breakdownEntries[0]).toMatchObject({
signal: "artifactValidator",
outcome: "issues_present",
latest: { caseCount: 1 },
delta: { caseCount: 0 },
});
expect(
buildVerificationOutcomeSummary([...deltaEntries, ...breakdownEntries]),
).toMatchObject({
failureCaseCount: 3,
blockingFailureCaseCount: 2,
advisoryFailureCaseCount: 1,
recoveredCaseCount: 1,
topBlockingFailureOutcomes: ["guiSmoke:failed"],
topRecoveredOutcomes: ["browserVerification:success"],
});
});
it("应从 trend classification deltas 派生 cleanup 可复用的 verification presentation", () => {
const presentation = deriveVerificationOutcomePresentationFromTrend({
sampleCount: 2,
trendReport: {
classificationDeltas: {
observabilityVerificationOutcomes: [
{
name: "guiSmoke:failed",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
{
name: "artifactValidator:repaired",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
currentObservabilityVerificationOutcomes: [
{
name: "guiSmoke:failed",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
{
name: "artifactValidator:repaired",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
currentRecoveredObservabilityVerificationOutcomes: [
{
name: "artifactValidator:repaired",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
degradedObservabilityVerificationOutcomes: [
{
name: "browserVerification:failure",
latest: { caseCount: 1 },
delta: { caseCount: 0 },
},
],
},
},
});
expect(
buildVerificationFocusEntriesFromDeltas([
{
name: "guiSmoke:failed",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
], 2)[0],
).toMatchObject({
signal: "guiSmoke",
outcome: "failed",
state: "regressing",
});
expect(
presentation.focusCurrentVerificationFailureOutcomes.map(
(entry) => entry.name,
),
).toEqual(["guiSmoke:failed"]);
expect(
presentation.focusCurrentRecoveredVerificationOutcomes.map(
(entry) => entry.name,
),
).toEqual(["artifactValidator:repaired"]);
expect(
presentation.focusDegradedVerificationFailureOutcomes.map(
(entry) => entry.name,
),
).toEqual(["browserVerification:failure"]);
expect(presentation.verificationOutcomeSummary).toMatchObject({
failureCaseCount: 1,
recoveredCaseCount: 1,
current: {
blockingFailureCaseCount: 1,
recoveredCaseCount: 1,
},
degraded: {
blockingFailureCaseCount: 1,
},
});
});
it("应统一生成 blocking/advisory/recovered follow-up 建议", () => {
const blockingEntries = [
{
signal: "guiSmoke",
outcome: "failed",
},
{
signal: "browserVerification",
outcome: "failure",
},
];
const advisoryEntries = [
{
signal: "artifactValidator",
outcome: "issues_present",
},
{
signal: "browserVerification",
outcome: "unknown",
},
];
const recoveredEntries = [
{
signal: "artifactValidator",
outcome: "repaired",
},
{
signal: "browserVerification",
outcome: "success",
},
{
signal: "guiSmoke",
outcome: "passed",
},
];
expect(
hasVerificationOutcome(blockingEntries, "guiSmoke", "failed"),
).toBe(true);
expect(
hasVerificationOutcome(blockingEntries, "guiSmoke", "passed"),
).toBe(false);
expect(buildBlockingVerificationFollowUp(blockingEntries)).toMatchObject({
commands: [
"npm run harness:eval",
"npm run harness:eval:trend",
"npm run verify:gui-smoke",
],
backlogTools: expect.arrayContaining([
"优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。",
"回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。",
]),
rationale: expect.arrayContaining([
"current 样本已出现 guiSmoke:failed,先恢复 GUI 壳 / DevBridge / Workspace 主路径的最小可启动性。",
"current 样本已出现 browserVerification:failure,应先回看 browser replay / verification 失败样本,把失败断言回挂到受影响主路径。",
]),
});
expect(buildAdvisoryVerificationFollowUp(advisoryEntries)).toMatchObject({
backlogTools: expect.arrayContaining([
"回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。",
"回看 browser verification 导出链,确保 evidence pack / replay / analysis handoff 写出明确 success 或 failure,而不是 unknown。",
]),
rationale: expect.arrayContaining([
"current 样本已出现 artifactValidator:issues_present,应先回看 validator issue 明细,再收敛 artifact 导出字段。",
"current 样本已出现 browserVerification:unknown,需要先把浏览器验证结果收敛成明确 outcome,再继续扩大分析。",
]),
});
expect(buildRecoveredVerificationFollowUp(recoveredEntries)).toMatchObject({
commands: [
"npm run harness:eval",
"npm run harness:eval:trend",
"npm run verify:gui-smoke",
],
backlogTools: expect.arrayContaining([
"在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。",
"把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。",
"主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。",
]),
rationale: expect.arrayContaining([
"current 样本已出现 artifactValidator:repaired,说明 artifact 修复链已经回到可复用的主路径。",
"current 样本已出现 browserVerification:success,可把浏览器验证成功样本固化成主路径正向基线。",
"current 样本已出现 guiSmoke:passed,可继续把 GUI smoke 通过链路当成桌面主路径的正向守卫。",
]),
});
});
it("应统一为 dashboard 派生 verification presentation 与说明文案", () => {
const presentation = deriveVerificationDashboardPresentation({
summaryReport: {
breakdowns: {
observabilityVerificationOutcomes: [
{ name: "guiSmoke:failed", caseCount: 1 },
{ name: "browserVerification:success", caseCount: 1 },
],
currentObservabilityVerificationOutcomes: [
{ name: "guiSmoke:failed", caseCount: 1 },
{ name: "browserVerification:success", caseCount: 1 },
],
currentRecoveredObservabilityVerificationOutcomes: [
{ name: "browserVerification:success", caseCount: 1 },
],
},
},
trendReport: {
classificationDeltas: {
observabilityVerificationOutcomes: [
{
name: "guiSmoke:failed",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
{
name: "browserVerification:success",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
currentObservabilityVerificationOutcomes: [
{
name: "guiSmoke:failed",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
{
name: "browserVerification:success",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
currentRecoveredObservabilityVerificationOutcomes: [
{
name: "browserVerification:success",
latest: { caseCount: 1 },
delta: { caseCount: 1 },
},
],
degradedObservabilityVerificationOutcomes: [],
},
},
cleanupReport: {
focus: {
currentObservabilityVerificationOutcomes: [
{
signal: "artifactValidator",
outcome: "fallback_used",
latest: { caseCount: 2 },
delta: { caseCount: 2 },
},
],
},
summary: {
verificationOutcomes: {
current: {
advisoryFailureCaseCount: 2,
},
},
},
},
});
expect(presentation.verificationSummary).toMatchObject({
current: {
blockingFailureCaseCount: 1,
recoveredCaseCount: 1,
},
});
expect(
presentation.verificationFocusRows.map((entry) => entry.name),
).toEqual(["guiSmoke:failed"]);
expect(
presentation.currentRecoveredRows.map((entry) => entry.name),
).toEqual(["browserVerification:success"]);
expect(presentation.currentRecoveredSummaryLabel).toBe(
"browserVerification (success)",
);
expect(
formatVerificationOutcomeCompactLabel("artifactValidator:repaired"),
).toBe("artifactValidator (repaired)");
expect(
formatVerificationOutcomeCompactLabels(
[
{ signal: "artifactValidator", outcome: "repaired" },
"browserVerification:success",
],
2,
),
).toEqual([
"artifactValidator (repaired)",
"browserVerification (success)",
]);
expect(
describeVerificationOutcome({
signal: "browserVerification",
outcome: "success",
}),
).toContain("浏览器验证已有成功样本");
});
it("应统一生成 verification summary signal 文案", () => {
expect(
buildVerificationOutcomeSignalMessages({
focusVerificationFailureOutcomes: [
{ signal: "browserVerification", outcome: "failure" },
],
verificationOutcomeSummary: {
failureFocusCount: 1,
failureCaseCount: 1,
recoveredFocusCount: 1,
recoveredCaseCount: 1,
},
currentVerificationOutcomeSummary: {
blockingFailureCaseCount: 1,
advisoryFailureCaseCount: 0,
recoveredCaseCount: 1,
},
degradedVerificationOutcomeSummary: {
blockingFailureCaseCount: 0,
},
currentRecoveredVerificationOutcomes: [
{ signal: "artifactValidator", outcome: "repaired" },
],
}),
).toEqual([
"当前 verification failure outcome 焦点:browserVerification (failure)。",
"当前 verification failure 聚焦 1 类 outcome,共 1 个 case。",
"当前 current 样本里有 1 个 blocking verification failure。",
"当前没有额外的 advisory verification failure。",
"当前 current recovered verification baseline:artifactValidator (repaired)。",
"当前没有额外的 degraded blocking verification baseline。",
"当前 verification recovered 聚焦 1 类 outcome,共 1 个 case。",
]);
});
it("应统一生成 recommendation 用的 verification rationale 片段", () => {
expect(
buildBlockingVerificationRecommendationRationale({
topCurrentVerificationFailureOutcomes: [
{ signal: "browserVerification", outcome: "failure" },
],
currentVerificationSummary: {
blockingFailureCaseCount: 1,
topBlockingFailureOutcomes: ["browserVerification:failure"],
},
degradedVerificationSummary: {
blockingFailureCaseCount: 0,
},
}),
).toEqual([
"当前 current verification failure outcome 焦点:browserVerification (failure)。",
"其中 current blocking verification failure 共 1 个 case:browserVerification:failure。",
"当前没有额外的 degraded blocking verification baseline。",
]);
expect(
buildAdvisoryVerificationRecommendationRationale({
topCurrentVerificationFailureOutcomes: [
{ signal: "artifactValidator", outcome: "issues_present" },
],
topDegradedVerificationFailureOutcomes: [
{ signal: "guiSmoke", outcome: "failed" },
],
currentVerificationSummary: {
advisoryFailureCaseCount: 1,
topAdvisoryFailureOutcomes: ["artifactValidator:issues_present"],
},
}),
).toEqual([
"当前 current verification failure outcome 焦点:artifactValidator (issues_present)。可用它们直接定位先补 artifact/browser/gui 哪一层。",
"当前 current advisory verification failure 共 1 个 case:artifactValidator:issues_present。",
"当前保留的 degraded verification baseline:guiSmoke (failed)。",
]);
expect(
buildRecoveredVerificationRecommendationRationale({
topCurrentRecoveredVerificationOutcomes: [
{ signal: "artifactValidator", outcome: "repaired" },
],
currentVerificationSummary: {
recoveredCaseCount: 1,
},
}),
).toEqual([
"当前 current recovered outcome 焦点:artifactValidator (repaired)。",
]);
});
it("应统一生成 observability recommendation 的混合文案与待办", () => {
expect(
buildObservabilityRecommendationRationale({
trendSummary: {
latestCurrentObservabilityGapCaseCount: 1,
latestDegradedObservabilityGapCaseCount: 0,
},
topObservabilitySignals: ["requestTelemetry (known_gap)"],
topCurrentVerificationFailureOutcomes: [
{ signal: "artifactValidator", outcome: "issues_present" },
],
topDegradedVerificationFailureOutcomes: [
{ signal: "guiSmoke", outcome: "failed" },
],
currentVerificationSummary: {
advisoryFailureCaseCount: 1,
topAdvisoryFailureOutcomes: ["artifactValidator:issues_present"],
},
}),
).toEqual([
"当前仍有 1 个 current case 带着 observability 证据缺口进入 replay/eval。",
"当前没有额外保留的 degraded observability gap 样本。",
"当前缺口焦点:requestTelemetry (known_gap)。这些缺口会直接降低 analysis handoff、人工审核和 cleanup report 的判断质量。",
"当前 current verification failure outcome 焦点:artifactValidator (issues_present)。可用它们直接定位先补 artifact/browser/gui 哪一层。",
"当前 current advisory verification failure 共 1 个 case:artifactValidator:issues_present。",
"当前保留的 degraded verification baseline:guiSmoke (failed)。",
]);
expect(
buildObservabilityRecommendationBacklog({
topCurrentVerificationFailureOutcomes: [
{ signal: "artifactValidator", outcome: "issues_present" },
],
advisoryFollowUpBacklogTools: [
"回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。",
],
}),
).toEqual([
"优先补 request telemetry 关联键、artifact validator outcome、browser/gui smoke 结果到 evidence pack / analysis handoff / replay。",
"先对齐 current verification failure outcome:artifactValidator (issues_present)。",
"回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。",
]);
});
});
+19 -19
View File
@@ -5101,7 +5101,7 @@ dependencies = [
[[package]]
name = "lime"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"anyhow",
"arboard",
@@ -5206,7 +5206,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5235,7 +5235,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"chrono",
"futures",
@@ -5252,7 +5252,7 @@ dependencies = [
[[package]]
name = "lime-cli"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"clap",
"lime-core",
@@ -5264,7 +5264,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5280,7 +5280,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5320,7 +5320,7 @@ dependencies = [
[[package]]
name = "lime-credential"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5355,7 +5355,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"aes",
"axum 0.7.9",
@@ -5385,7 +5385,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5405,7 +5405,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5421,7 +5421,7 @@ dependencies = [
[[package]]
name = "lime-media-runtime"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -5452,7 +5452,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5471,7 +5471,7 @@ dependencies = [
[[package]]
name = "lime-providers"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5526,7 +5526,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5571,7 +5571,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5586,7 +5586,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5628,7 +5628,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "lime-terminal"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5673,7 +5673,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "1.9.0"
version = "1.10.0"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -2
View File
@@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"]
resolver = "2"
[workspace.package]
version = "1.9.0"
version = "1.10.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -189,7 +189,7 @@ version = "2.4"
[package]
name = "lime"
version = "1.9.0"
version = "1.10.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+2
View File
@@ -44,6 +44,8 @@ pub struct AgentTokenUsage {
pub output_tokens: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cached_input_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_tokens: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+22 -8
View File
@@ -1001,6 +1001,10 @@ fn resolve_runtime_usage_from_aster_session(
.cached_input_tokens
.filter(|value| *value >= 0)
.map(|value| value as u32),
cache_creation_input_tokens: session
.cache_creation_input_tokens
.filter(|value| *value >= 0)
.map(|value| value as u32),
})
}
_ => None,
@@ -1066,6 +1070,7 @@ pub async fn get_runtime_session_detail(
usage.input_tokens,
usage.output_tokens,
usage.cached_input_tokens,
usage.cache_creation_input_tokens,
)
{
tracing::warn!(
@@ -1379,6 +1384,7 @@ fn convert_agent_message(
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_input_tokens: usage.cached_input_tokens,
cache_creation_input_tokens: usage.cache_creation_input_tokens,
}),
};
@@ -1958,6 +1964,7 @@ mod tests {
id: "session-usage-fallback".to_string(),
input_tokens: Some(3_833),
output_tokens: Some(615),
cache_creation_input_tokens: Some(144),
..AsterSession::default()
};
@@ -1965,15 +1972,20 @@ mod tests {
apply_runtime_usage_fallback_to_latest_assistant_message(&mut messages, &session);
assert_eq!(
applied.map(|usage| (usage.input_tokens, usage.output_tokens)),
Some((3_833, 615))
applied.map(|usage| (
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
)),
Some((3_833, 615, Some(144)))
);
assert_eq!(
messages[1]
.usage
.as_ref()
.map(|usage| (usage.input_tokens, usage.output_tokens)),
Some((3_833, 615))
messages[1].usage.as_ref().map(|usage| (
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
)),
Some((3_833, 615, Some(144)))
);
}
@@ -1990,6 +2002,7 @@ mod tests {
input_tokens: 20_480,
output_tokens: 10_240,
cached_input_tokens: Some(8_192),
cache_creation_input_tokens: Some(1_024),
}),
}];
let session = AsterSession {
@@ -2008,8 +2021,9 @@ mod tests {
usage.input_tokens,
usage.output_tokens,
usage.cached_input_tokens,
usage.cache_creation_input_tokens,
)),
Some((20_480, 10_240, Some(8_192)))
Some((20_480, 10_240, Some(8_192), Some(1_024)))
);
}
@@ -8,6 +8,7 @@ pub struct CompactionSessionMetricsUpdate {
pub schedule_id: Option<String>,
pub current_window_tokens: i32,
pub cached_input_tokens: Option<i32>,
pub cache_creation_input_tokens: Option<i32>,
pub accumulated_total_tokens: Option<i32>,
pub accumulated_input_tokens: Option<i32>,
pub accumulated_output_tokens: Option<i32>,
@@ -59,6 +60,7 @@ pub async fn persist_compaction_session_metrics_update(
.input_tokens(Some(update.current_window_tokens))
.output_tokens(Some(0))
.cached_input_tokens(update.cached_input_tokens)
.cache_creation_input_tokens(update.cache_creation_input_tokens)
.accumulated_total_tokens(update.accumulated_total_tokens)
.accumulated_input_tokens(update.accumulated_input_tokens)
.accumulated_output_tokens(update.accumulated_output_tokens)
@@ -4630,6 +4630,7 @@ mod tests {
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
@@ -4686,6 +4687,7 @@ mod tests {
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
@@ -4739,6 +4741,7 @@ mod tests {
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
@@ -4801,6 +4804,7 @@ mod tests {
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
@@ -4870,6 +4874,7 @@ mod tests {
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
@@ -132,6 +132,7 @@ impl Agent {
input_tokens: Some(0),
output_tokens: Some(0),
cached_input_tokens: Some(0),
cache_creation_input_tokens: Some(0),
accumulated_total: None,
accumulated_input: None,
accumulated_output: None,
@@ -143,6 +144,7 @@ impl Agent {
.total_tokens(Some(0))
.input_tokens(Some(0))
.output_tokens(Some(0))
.cache_creation_input_tokens(Some(0))
.apply()
.await?;
}
@@ -443,6 +443,11 @@ impl Agent {
} else {
usage.usage.cached_input_tokens
};
let current_cache_creation_input = if is_compaction_usage {
Some(0)
} else {
usage.usage.cache_creation_input_tokens
};
if let Some(store) = session_store {
store
@@ -454,6 +459,7 @@ impl Agent {
input_tokens: current_input,
output_tokens: current_output,
cached_input_tokens: current_cached_input,
cache_creation_input_tokens: current_cache_creation_input,
accumulated_total,
accumulated_input,
accumulated_output,
@@ -467,6 +473,7 @@ impl Agent {
.input_tokens(current_input)
.output_tokens(current_output)
.cached_input_tokens(current_cached_input)
.cache_creation_input_tokens(current_cache_creation_input)
.accumulated_total_tokens(accumulated_total)
.accumulated_input_tokens(accumulated_input)
.accumulated_output_tokens(accumulated_output)
@@ -279,6 +279,7 @@ pub struct Usage {
pub output_tokens: Option<i32>,
pub total_tokens: Option<i32>,
pub cached_input_tokens: Option<i32>,
pub cache_creation_input_tokens: Option<i32>,
}
fn sum_optionals<T>(a: Option<T>, b: Option<T>) -> Option<T>
@@ -306,6 +307,10 @@ impl Add for Usage {
self.cached_input_tokens,
other.cached_input_tokens,
))
.with_cache_creation_input_tokens(sum_optionals(
self.cache_creation_input_tokens,
other.cache_creation_input_tokens,
))
}
}
@@ -337,6 +342,7 @@ impl Usage {
output_tokens,
total_tokens: calculated_total,
cached_input_tokens: None,
cache_creation_input_tokens: None,
}
}
@@ -344,6 +350,14 @@ impl Usage {
self.cached_input_tokens = cached_input_tokens;
self
}
pub fn with_cache_creation_input_tokens(
mut self,
cache_creation_input_tokens: Option<i32>,
) -> Self {
self.cache_creation_input_tokens = cache_creation_input_tokens;
self
}
}
use async_trait::async_trait;
@@ -331,7 +331,8 @@ pub fn get_usage(data: &Value) -> Result<Usage> {
Some(output_tokens_i32),
Some(total_tokens_i32),
)
.with_cached_input_tokens(Some(cache_read_tokens.min(i32::MAX as u64) as i32)))
.with_cached_input_tokens(Some(cache_read_tokens.min(i32::MAX as u64) as i32))
.with_cache_creation_input_tokens(Some(cache_creation_tokens.min(i32::MAX as u64) as i32)))
} else if data.as_object().is_some() {
// Check if the data itself is the usage object (for message_delta events that might have usage at top level)
let input_tokens = data
@@ -375,7 +376,10 @@ pub fn get_usage(data: &Value) -> Result<Usage> {
Some(output_tokens_i32),
Some(total_tokens_i32),
)
.with_cached_input_tokens(Some(cache_read_tokens.min(i32::MAX as u64) as i32)))
.with_cached_input_tokens(Some(cache_read_tokens.min(i32::MAX as u64) as i32))
.with_cache_creation_input_tokens(Some(
cache_creation_tokens.min(i32::MAX as u64) as i32,
)))
} else {
tracing::debug!("🔍 Anthropic no token data found in object");
Ok(Usage::new(None, None, None))
@@ -661,16 +665,21 @@ where
.usage
.cached_input_tokens
.or(delta_usage.cached_input_tokens);
let merged_cache_creation = existing_usage
.usage
.cache_creation_input_tokens
.or(delta_usage.cache_creation_input_tokens);
let merged_usage = crate::providers::base::Usage::new(
merged_input,
merged_output,
merged_total,
)
.with_cached_input_tokens(merged_cached);
.with_cached_input_tokens(merged_cached)
.with_cache_creation_input_tokens(merged_cache_creation);
final_usage = Some(crate::providers::base::ProviderUsage::new(existing_usage.model.clone(), merged_usage));
tracing::debug!("🔍 Anthropic MERGED usage: input_tokens={:?}, output_tokens={:?}, total_tokens={:?}, cached_input_tokens={:?}",
merged_input, merged_output, merged_total, merged_cached);
tracing::debug!("🔍 Anthropic MERGED usage: input_tokens={:?}, output_tokens={:?}, total_tokens={:?}, cached_input_tokens={:?}, cache_creation_input_tokens={:?}",
merged_input, merged_output, merged_total, merged_cached, merged_cache_creation);
} else {
// No existing usage, just use delta usage
let model = event.data.get("model")
@@ -761,6 +770,7 @@ mod tests {
assert_eq!(usage.output_tokens, Some(15));
assert_eq!(usage.total_tokens, Some(39)); // 24 + 15
assert_eq!(usage.cached_input_tokens, Some(0));
assert_eq!(usage.cache_creation_input_tokens, Some(12));
Ok(())
}
@@ -805,6 +815,7 @@ mod tests {
assert_eq!(usage.output_tokens, Some(20));
assert_eq!(usage.total_tokens, Some(50)); // 30 + 20
assert_eq!(usage.cached_input_tokens, Some(0));
assert_eq!(usage.cache_creation_input_tokens, Some(15));
Ok(())
}
@@ -880,6 +891,7 @@ mod tests {
assert_eq!(usage.output_tokens, Some(45));
assert_eq!(usage.total_tokens, Some(55));
assert_eq!(usage.cached_input_tokens, Some(0));
assert_eq!(usage.cache_creation_input_tokens, Some(0));
Ok(())
}
@@ -1022,6 +1034,7 @@ mod tests {
assert_eq!(usage.output_tokens, Some(50));
assert_eq!(usage.total_tokens, Some(15057)); // 15007 + 50
assert_eq!(usage.cached_input_tokens, Some(5000));
assert_eq!(usage.cache_creation_input_tokens, Some(10000));
Ok(())
}
@@ -27,7 +27,7 @@ use tokio::sync::OnceCell;
use tracing::{info, warn};
use utoipa::ToSchema;
pub const CURRENT_SCHEMA_VERSION: i32 = 8;
pub const CURRENT_SCHEMA_VERSION: i32 = 9;
pub const SESSIONS_FOLDER: &str = "sessions";
pub const DB_NAME: &str = "sessions.db";
const AUTO_SESSION_NAME_PLACEHOLDERS: &[&str] = &[
@@ -97,6 +97,7 @@ pub struct Session {
pub input_tokens: Option<i32>,
pub output_tokens: Option<i32>,
pub cached_input_tokens: Option<i32>,
pub cache_creation_input_tokens: Option<i32>,
pub accumulated_total_tokens: Option<i32>,
pub accumulated_input_tokens: Option<i32>,
pub accumulated_output_tokens: Option<i32>,
@@ -120,6 +121,7 @@ pub struct SessionUpdateBuilder {
input_tokens: Option<Option<i32>>,
output_tokens: Option<Option<i32>>,
cached_input_tokens: Option<Option<i32>>,
cache_creation_input_tokens: Option<Option<i32>>,
accumulated_total_tokens: Option<Option<i32>>,
accumulated_input_tokens: Option<Option<i32>>,
accumulated_output_tokens: Option<Option<i32>>,
@@ -150,6 +152,7 @@ impl SessionUpdateBuilder {
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
@@ -214,6 +217,11 @@ impl SessionUpdateBuilder {
self
}
pub fn cache_creation_input_tokens(mut self, tokens: Option<i32>) -> Self {
self.cache_creation_input_tokens = Some(tokens);
self
}
pub fn accumulated_total_tokens(mut self, tokens: Option<i32>) -> Self {
self.accumulated_total_tokens = Some(tokens);
self
@@ -487,6 +495,7 @@ impl SessionManager {
input_tokens,
output_tokens,
cached_input_tokens,
cache_creation_input_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
@@ -513,6 +522,7 @@ impl SessionManager {
|| input_tokens.is_some()
|| output_tokens.is_some()
|| cached_input_tokens.is_some()
|| cache_creation_input_tokens.is_some()
|| accumulated_total_tokens.is_some()
|| accumulated_input_tokens.is_some()
|| accumulated_output_tokens.is_some()
@@ -527,6 +537,7 @@ impl SessionManager {
input_tokens: input_tokens.flatten(),
output_tokens: output_tokens.flatten(),
cached_input_tokens: cached_input_tokens.flatten(),
cache_creation_input_tokens: cache_creation_input_tokens.flatten(),
accumulated_total: accumulated_total_tokens.flatten(),
accumulated_input: accumulated_input_tokens.flatten(),
accumulated_output: accumulated_output_tokens.flatten(),
@@ -595,6 +606,7 @@ impl Default for Session {
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
@@ -691,6 +703,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session {
input_tokens: row.try_get("input_tokens")?,
output_tokens: row.try_get("output_tokens")?,
cached_input_tokens: row.try_get("cached_input_tokens").ok().flatten(),
cache_creation_input_tokens: row.try_get("cache_creation_input_tokens").ok().flatten(),
accumulated_total_tokens: row.try_get("accumulated_total_tokens")?,
accumulated_input_tokens: row.try_get("accumulated_input_tokens")?,
accumulated_output_tokens: row.try_get("accumulated_output_tokens")?,
@@ -784,6 +797,7 @@ impl SessionStorage {
input_tokens INTEGER,
output_tokens INTEGER,
cached_input_tokens INTEGER,
cache_creation_input_tokens INTEGER,
accumulated_total_tokens INTEGER,
accumulated_input_tokens INTEGER,
accumulated_output_tokens INTEGER,
@@ -1020,11 +1034,11 @@ impl SessionStorage {
r#"
INSERT INTO sessions (
id, name, user_set_name, session_type, working_dir, created_at, updated_at, extension_data,
total_tokens, input_tokens, output_tokens, cached_input_tokens,
total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json, user_recipe_values_json,
provider_name, model_config_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&session.id)
@@ -1039,6 +1053,7 @@ impl SessionStorage {
.bind(session.input_tokens)
.bind(session.output_tokens)
.bind(session.cached_input_tokens)
.bind(session.cache_creation_input_tokens)
.bind(session.accumulated_total_tokens)
.bind(session.accumulated_input_tokens)
.bind(session.accumulated_output_tokens)
@@ -1343,6 +1358,15 @@ impl SessionStorage {
.execute(&self.pool)
.await?;
}
9 => {
sqlx::query(
r#"
ALTER TABLE sessions ADD COLUMN cache_creation_input_tokens INTEGER
"#,
)
.execute(&self.pool)
.await?;
}
_ => {
anyhow::bail!("Unknown migration version: {}", version);
}
@@ -1395,7 +1419,7 @@ impl SessionStorage {
let mut session = sqlx::query_as::<_, Session>(
r#"
SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data,
total_tokens, input_tokens, output_tokens, cached_input_tokens,
total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json, user_recipe_values_json,
provider_name, model_config_json
@@ -1451,6 +1475,10 @@ impl SessionStorage {
add_update!(builder.input_tokens, "input_tokens");
add_update!(builder.output_tokens, "output_tokens");
add_update!(builder.cached_input_tokens, "cached_input_tokens");
add_update!(
builder.cache_creation_input_tokens,
"cache_creation_input_tokens"
);
add_update!(builder.accumulated_total_tokens, "accumulated_total_tokens");
add_update!(builder.accumulated_input_tokens, "accumulated_input_tokens");
add_update!(
@@ -1499,6 +1527,9 @@ impl SessionStorage {
if let Some(cit) = builder.cached_input_tokens {
q = q.bind(cit);
}
if let Some(cache_creation_input_tokens) = builder.cache_creation_input_tokens {
q = q.bind(cache_creation_input_tokens);
}
if let Some(att) = builder.accumulated_total_tokens {
q = q.bind(att);
}
@@ -1642,7 +1673,7 @@ impl SessionStorage {
let query = format!(
r#"
SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data,
s.total_tokens, s.input_tokens, s.output_tokens, s.cached_input_tokens,
s.total_tokens, s.input_tokens, s.output_tokens, s.cached_input_tokens, s.cache_creation_input_tokens,
s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens,
s.schedule_id, s.recipe_json, s.user_recipe_values_json,
s.provider_name, s.model_config_json,
@@ -1743,6 +1774,7 @@ impl SessionStorage {
.input_tokens(import.input_tokens)
.output_tokens(import.output_tokens)
.cached_input_tokens(import.cached_input_tokens)
.cache_creation_input_tokens(import.cache_creation_input_tokens)
.accumulated_total_tokens(import.accumulated_total_tokens)
.accumulated_input_tokens(import.accumulated_input_tokens)
.accumulated_output_tokens(import.accumulated_output_tokens)
@@ -160,6 +160,7 @@ pub struct TokenStatsUpdate {
pub input_tokens: Option<i32>,
pub output_tokens: Option<i32>,
pub cached_input_tokens: Option<i32>,
pub cache_creation_input_tokens: Option<i32>,
pub accumulated_total: Option<i32>,
pub accumulated_input: Option<i32>,
pub accumulated_output: Option<i32>,
@@ -191,6 +192,7 @@ impl SessionStore for NoopSessionStore {
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
+12
View File
@@ -449,6 +449,9 @@ pub struct TokenUsage {
/// 命中的缓存输入 token 数
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cached_input_tokens: Option<u32>,
/// 写入缓存的输入 token 数
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_tokens: Option<u32>,
}
impl TokenUsage {
@@ -458,6 +461,7 @@ impl TokenUsage {
input_tokens,
output_tokens,
cached_input_tokens: None,
cache_creation_input_tokens: None,
}
}
@@ -466,6 +470,14 @@ impl TokenUsage {
self
}
pub fn with_cache_creation_input_tokens(
mut self,
cache_creation_input_tokens: Option<u32>,
) -> Self {
self.cache_creation_input_tokens = cache_creation_input_tokens;
self
}
/// 计算总 token 数
pub fn total(&self) -> u32 {
self.input_tokens + self.output_tokens
@@ -220,6 +220,7 @@ pub fn update_latest_assistant_message_usage(
input_tokens: u32,
output_tokens: u32,
cached_input_tokens: Option<u32>,
cache_creation_input_tokens: Option<u32>,
) -> Result<bool, String> {
AgentDao::update_latest_assistant_message_usage(
conn,
@@ -227,6 +228,7 @@ pub fn update_latest_assistant_message_usage(
input_tokens,
output_tokens,
cached_input_tokens,
cache_creation_input_tokens,
)
.map_err(|error| format!("更新最新 assistant 消息 usage 失败: {error}"))
}
+31 -11
View File
@@ -967,9 +967,10 @@ impl AgentDao {
reasoning_content,
input_tokens,
output_tokens,
cached_input_tokens
cached_input_tokens,
cache_creation_input_tokens
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
session_id,
message.role,
@@ -984,6 +985,10 @@ impl AgentDao {
.usage
.as_ref()
.and_then(|usage| usage.cached_input_tokens),
message
.usage
.as_ref()
.and_then(|usage| usage.cache_creation_input_tokens),
],
)?;
@@ -1003,7 +1008,7 @@ impl AgentDao {
) -> Result<Vec<AgentMessage>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT role, content_json, timestamp, tool_calls_json, tool_call_id, reasoning_content,
input_tokens, output_tokens, cached_input_tokens
input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens
FROM agent_messages WHERE session_id = ? ORDER BY id ASC",
)?;
@@ -1017,6 +1022,7 @@ impl AgentDao {
let input_tokens: Option<u32> = row.get(6)?;
let output_tokens: Option<u32> = row.get(7)?;
let cached_input_tokens: Option<u32> = row.get(8)?;
let cache_creation_input_tokens: Option<u32> = row.get(9)?;
// 解析 JSON - 支持多种格式
// 1. Aster 格式: [{"Text":"..."}, {"Text":"..."}]
@@ -1036,7 +1042,8 @@ impl AgentDao {
usage: match (input_tokens, output_tokens) {
(Some(input_tokens), Some(output_tokens)) => Some(
crate::agent::types::TokenUsage::new(input_tokens, output_tokens)
.with_cached_input_tokens(cached_input_tokens),
.with_cached_input_tokens(cached_input_tokens)
.with_cache_creation_input_tokens(cache_creation_input_tokens),
),
_ => None,
},
@@ -1052,17 +1059,24 @@ impl AgentDao {
input_tokens: u32,
output_tokens: u32,
cached_input_tokens: Option<u32>,
cache_creation_input_tokens: Option<u32>,
) -> Result<bool, rusqlite::Error> {
let rows = conn.execute(
"UPDATE agent_messages
SET input_tokens = ?1, output_tokens = ?2, cached_input_tokens = ?3
SET input_tokens = ?1, output_tokens = ?2, cached_input_tokens = ?3, cache_creation_input_tokens = ?4
WHERE id = (
SELECT id FROM agent_messages
WHERE session_id = ?4 AND role = 'assistant'
WHERE session_id = ?5 AND role = 'assistant'
ORDER BY id DESC
LIMIT 1
)",
params![input_tokens, output_tokens, cached_input_tokens, session_id],
params![
input_tokens,
output_tokens,
cached_input_tokens,
cache_creation_input_tokens,
session_id
],
)?;
Ok(rows > 0)
@@ -1217,7 +1231,8 @@ mod tests {
reasoning_content TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
cached_input_tokens INTEGER
cached_input_tokens INTEGER,
cache_creation_input_tokens INTEGER
);
",
)
@@ -1561,7 +1576,8 @@ mod tests {
reasoning_content: Some("先分析参数,再继续请求".to_string()),
usage: Some(
crate::agent::types::TokenUsage::new(1200, 300)
.with_cached_input_tokens(Some(900)),
.with_cached_input_tokens(Some(900))
.with_cache_creation_input_tokens(Some(300)),
),
},
)
@@ -1576,7 +1592,9 @@ mod tests {
assert_eq!(
messages[0].usage,
Some(
crate::agent::types::TokenUsage::new(1200, 300).with_cached_input_tokens(Some(900)),
crate::agent::types::TokenUsage::new(1200, 300)
.with_cached_input_tokens(Some(900))
.with_cache_creation_input_tokens(Some(300)),
)
);
}
@@ -1621,6 +1639,7 @@ mod tests {
2048,
512,
Some(1536),
Some(256),
)
.unwrap();
assert!(updated);
@@ -1632,7 +1651,8 @@ mod tests {
messages[2].usage,
Some(
crate::agent::types::TokenUsage::new(2048, 512)
.with_cached_input_tokens(Some(1536)),
.with_cached_input_tokens(Some(1536))
.with_cache_creation_input_tokens(Some(256)),
)
);
}
@@ -5,6 +5,7 @@
//! **Feature: provider-ui-refactor**
//! **Validates: Requirements 9.1**
use crate::provider_prompt_cache_support::is_known_automatic_anthropic_compatible_host;
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
@@ -34,6 +35,14 @@ pub enum ApiProviderType {
Gateway,
}
/// API Key Provider 声明的 Prompt Cache 模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiProviderPromptCacheMode {
Automatic,
ExplicitOnly,
}
/// Provider 协议族
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderProtocolFamily {
@@ -159,6 +168,35 @@ impl ApiProviderType {
pub const fn supports_anthropic_prompt_cache(&self) -> bool {
matches!(self, ApiProviderType::Anthropic)
}
pub const fn default_prompt_cache_mode(&self) -> Option<ApiProviderPromptCacheMode> {
match self {
ApiProviderType::Anthropic => Some(ApiProviderPromptCacheMode::Automatic),
ApiProviderType::AnthropicCompatible => Some(ApiProviderPromptCacheMode::ExplicitOnly),
_ => None,
}
}
}
impl std::fmt::Display for ApiProviderPromptCacheMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApiProviderPromptCacheMode::Automatic => write!(f, "automatic"),
ApiProviderPromptCacheMode::ExplicitOnly => write!(f, "explicit_only"),
}
}
}
impl std::str::FromStr for ApiProviderPromptCacheMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"automatic" => Ok(Self::Automatic),
"explicit_only" | "explicit-only" => Ok(Self::ExplicitOnly),
_ => Err(format!("Unknown prompt cache mode: {s}")),
}
}
}
impl std::fmt::Display for ApiProviderType {
@@ -183,7 +221,11 @@ impl std::fmt::Display for ApiProviderType {
#[cfg(test)]
mod tests {
use super::{ApiProviderType, ProviderProtocolFamily};
use super::{
infer_managed_prompt_cache_mode, ApiKeyProvider, ApiProviderPromptCacheMode,
ApiProviderType, ProviderGroup, ProviderProtocolFamily,
};
use chrono::Utc;
#[test]
fn test_runtime_spec_anthropic_compatible() {
@@ -214,6 +256,64 @@ mod tests {
assert!(!ApiProviderType::Openai.supports_anthropic_prompt_cache());
}
#[test]
fn test_default_prompt_cache_mode() {
assert_eq!(
ApiProviderType::Anthropic.default_prompt_cache_mode(),
Some(ApiProviderPromptCacheMode::Automatic)
);
assert_eq!(
ApiProviderType::AnthropicCompatible.default_prompt_cache_mode(),
Some(ApiProviderPromptCacheMode::ExplicitOnly)
);
assert_eq!(ApiProviderType::Openai.default_prompt_cache_mode(), None);
}
#[test]
fn test_known_official_anthropic_compatible_hosts_default_to_automatic() {
let hosts = [
"https://open.bigmodel.cn/api/anthropic",
"https://api.moonshot.cn/anthropic",
"https://api.minimaxi.com/anthropic",
"https://token-plan-cn.xiaomimimo.com/anthropic",
];
for host in hosts {
assert_eq!(
infer_managed_prompt_cache_mode(ApiProviderType::AnthropicCompatible, host),
Some(ApiProviderPromptCacheMode::Automatic),
"expected host to resolve automatic prompt cache: {host}"
);
}
}
#[test]
fn test_effective_prompt_cache_mode_prefers_known_host_inference() {
let provider = ApiKeyProvider {
id: "custom-provider".to_string(),
name: "Official Anthropic-Compatible".to_string(),
provider_type: ApiProviderType::AnthropicCompatible,
api_host: "https://api.minimaxi.com/anthropic".to_string(),
is_system: false,
group: ProviderGroup::Custom,
enabled: true,
sort_order: 9999,
api_version: None,
project: None,
location: None,
region: None,
custom_models: Vec::new(),
prompt_cache_mode: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
assert_eq!(
provider.effective_prompt_cache_mode(),
Some(ApiProviderPromptCacheMode::Automatic)
);
}
#[test]
fn test_runtime_spec_contract_matrix() {
let cases = [
@@ -459,10 +559,50 @@ pub struct ApiKeyProvider {
/// 用于不支持 /models 接口的 Provider(如智谱)
#[serde(default)]
pub custom_models: Vec<String>,
/// Provider 显式声明的 Prompt Cache 模式(仅在需要覆盖类型默认值时设置)
#[serde(default)]
pub prompt_cache_mode: Option<ApiProviderPromptCacheMode>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn infer_managed_prompt_cache_mode(
provider_type: ApiProviderType,
api_host: &str,
) -> Option<ApiProviderPromptCacheMode> {
if provider_type == ApiProviderType::AnthropicCompatible
&& is_known_automatic_anthropic_compatible_host(Some(api_host))
{
return Some(ApiProviderPromptCacheMode::Automatic);
}
None
}
impl ApiKeyProvider {
pub fn effective_prompt_cache_mode(&self) -> Option<ApiProviderPromptCacheMode> {
if let Some(managed_mode) =
infer_managed_prompt_cache_mode(self.provider_type, &self.api_host)
{
return Some(managed_mode);
}
match self.provider_type {
ApiProviderType::AnthropicCompatible => self
.prompt_cache_mode
.or_else(|| self.provider_type.default_prompt_cache_mode()),
_ => self.provider_type.default_prompt_cache_mode(),
}
}
pub fn supports_automatic_prompt_cache(&self) -> bool {
matches!(
self.effective_prompt_cache_mode(),
Some(ApiProviderPromptCacheMode::Automatic)
)
}
}
/// API Key 条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKeyEntry {
@@ -499,7 +639,8 @@ impl ApiKeyProviderDao {
pub fn get_all_providers(conn: &Connection) -> Result<Vec<ApiKeyProvider>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order,
api_version, project, location, region, custom_models, created_at, updated_at
api_version, project, location, region, custom_models, prompt_cache_mode,
created_at, updated_at
FROM api_key_providers
ORDER BY sort_order ASC, created_at ASC",
)?;
@@ -519,7 +660,8 @@ impl ApiKeyProviderDao {
) -> Result<Option<ApiKeyProvider>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order,
api_version, project, location, region, custom_models, created_at, updated_at
api_version, project, location, region, custom_models, prompt_cache_mode,
created_at, updated_at
FROM api_key_providers
WHERE id = ?1",
)?;
@@ -539,7 +681,8 @@ impl ApiKeyProviderDao {
) -> Result<Vec<ApiKeyProvider>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order,
api_version, project, location, region, custom_models, created_at, updated_at
api_version, project, location, region, custom_models, prompt_cache_mode,
created_at, updated_at
FROM api_key_providers
WHERE group_name = ?1
ORDER BY sort_order ASC, created_at ASC",
@@ -563,12 +706,13 @@ impl ApiKeyProviderDao {
} else {
Some(serde_json::to_string(&provider.custom_models).unwrap_or_default())
};
let prompt_cache_mode = provider.prompt_cache_mode.map(|value| value.to_string());
conn.execute(
"INSERT INTO api_key_providers
(id, name, type, api_host, is_system, group_name, enabled, sort_order,
api_version, project, location, region, custom_models, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
api_version, project, location, region, custom_models, prompt_cache_mode, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![
provider.id,
provider.name,
@@ -583,6 +727,7 @@ impl ApiKeyProviderDao {
provider.location,
provider.region,
custom_models_json,
prompt_cache_mode,
provider.created_at.to_rfc3339(),
provider.updated_at.to_rfc3339(),
],
@@ -600,12 +745,13 @@ impl ApiKeyProviderDao {
} else {
Some(serde_json::to_string(&provider.custom_models).unwrap_or_default())
};
let prompt_cache_mode = provider.prompt_cache_mode.map(|value| value.to_string());
conn.execute(
"UPDATE api_key_providers SET
name = ?2, type = ?3, api_host = ?4, is_system = ?5, group_name = ?6,
enabled = ?7, sort_order = ?8, api_version = ?9, project = ?10,
location = ?11, region = ?12, custom_models = ?13, updated_at = ?14
location = ?11, region = ?12, custom_models = ?13, prompt_cache_mode = ?14, updated_at = ?15
WHERE id = ?1",
params![
provider.id,
@@ -621,6 +767,7 @@ impl ApiKeyProviderDao {
provider.location,
provider.region,
custom_models_json,
prompt_cache_mode,
provider.updated_at.to_rfc3339(),
],
)?;
@@ -659,8 +806,9 @@ impl ApiKeyProviderDao {
let location: Option<String> = row.get(10)?;
let region: Option<String> = row.get(11)?;
let custom_models_json: Option<String> = row.get(12)?;
let created_at_str: String = row.get(13)?;
let updated_at_str: String = row.get(14)?;
let prompt_cache_mode_str: Option<String> = row.get(13)?;
let created_at_str: String = row.get(14)?;
let updated_at_str: String = row.get(15)?;
let provider_type: ApiProviderType = type_str.parse().unwrap_or(ApiProviderType::Openai);
let group: ProviderGroup = group_str.parse().unwrap_or(ProviderGroup::Custom);
@@ -676,6 +824,8 @@ impl ApiKeyProviderDao {
let custom_models: Vec<String> = custom_models_json
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default();
let prompt_cache_mode = prompt_cache_mode_str
.and_then(|value| value.parse::<ApiProviderPromptCacheMode>().ok());
Ok(ApiKeyProvider {
id,
@@ -691,6 +841,7 @@ impl ApiKeyProviderDao {
location,
region,
custom_models,
prompt_cache_mode,
created_at,
updated_at,
})
@@ -752,7 +903,7 @@ impl ApiKeyProviderDao {
k.usage_count, k.error_count, k.last_used_at, k.created_at,
p.id, p.name, p.type, p.api_host, p.is_system, p.group_name, p.enabled,
p.sort_order, p.api_version, p.project, p.location, p.region,
p.custom_models, p.created_at, p.updated_at
p.custom_models, p.prompt_cache_mode, p.created_at, p.updated_at
FROM api_keys k
JOIN api_key_providers p ON k.provider_id = p.id
WHERE p.type = ?1 AND k.enabled = 1 AND p.enabled = 1
@@ -786,8 +937,9 @@ impl ApiKeyProviderDao {
// 解析 Provider
let custom_models_json: Option<String> = row.get(21)?;
let provider_created_at_str: String = row.get(22)?;
let provider_updated_at_str: String = row.get(23)?;
let prompt_cache_mode_str: Option<String> = row.get(22)?;
let provider_created_at_str: String = row.get(23)?;
let provider_updated_at_str: String = row.get(24)?;
let provider_created_at = DateTime::parse_from_rfc3339(&provider_created_at_str)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
@@ -799,6 +951,8 @@ impl ApiKeyProviderDao {
let custom_models: Vec<String> = custom_models_json
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default();
let prompt_cache_mode = prompt_cache_mode_str
.and_then(|value| value.parse::<ApiProviderPromptCacheMode>().ok());
let provider = ApiKeyProvider {
id: row.get(9)?,
@@ -820,6 +974,7 @@ impl ApiKeyProviderDao {
location: row.get(19)?,
region: row.get(20)?,
custom_models,
prompt_cache_mode,
created_at: provider_created_at,
updated_at: provider_updated_at,
};
@@ -368,6 +368,7 @@ impl ProviderPoolDao {
cached_token: None, // 从 get_token_cache 单独获取
source,
proxy_url,
prompt_cache_mode_override: None,
})
}
@@ -387,7 +387,8 @@ mod tests {
updated_at TEXT NOT NULL,
working_dir TEXT,
execution_strategy TEXT NOT NULL DEFAULT 'react',
cached_input_tokens INTEGER
cached_input_tokens INTEGER,
cache_creation_input_tokens INTEGER
);
CREATE TABLE agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -400,7 +401,8 @@ mod tests {
reasoning_content TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
cached_input_tokens INTEGER
cached_input_tokens INTEGER,
cache_creation_input_tokens INTEGER
);
",
)
@@ -425,7 +427,8 @@ mod tests {
updated_at TEXT NOT NULL,
working_dir TEXT,
execution_strategy TEXT NOT NULL DEFAULT 'react',
cached_input_tokens INTEGER
cached_input_tokens INTEGER,
cache_creation_input_tokens INTEGER
);
CREATE TABLE agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -438,7 +441,8 @@ mod tests {
reasoning_content TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
cached_input_tokens INTEGER
cached_input_tokens INTEGER,
cache_creation_input_tokens INTEGER
);
",
)
@@ -76,6 +76,7 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
location TEXT,
region TEXT,
custom_models TEXT,
prompt_cache_mode TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
@@ -87,6 +88,10 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
"ALTER TABLE api_key_providers ADD COLUMN custom_models TEXT",
[],
);
let _ = conn.execute(
"ALTER TABLE api_key_providers ADD COLUMN prompt_cache_mode TEXT",
[],
);
// 创建 api_key_providers 索引
conn.execute(
@@ -539,6 +544,7 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
input_tokens INTEGER,
output_tokens INTEGER,
cached_input_tokens INTEGER,
cache_creation_input_tokens INTEGER,
accumulated_total_tokens INTEGER,
accumulated_input_tokens INTEGER,
accumulated_output_tokens INTEGER,
@@ -590,6 +596,10 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
"ALTER TABLE agent_sessions ADD COLUMN cached_input_tokens INTEGER",
[],
);
let _ = conn.execute(
"ALTER TABLE agent_sessions ADD COLUMN cache_creation_input_tokens INTEGER",
[],
);
let _ = conn.execute(
"ALTER TABLE agent_sessions ADD COLUMN accumulated_total_tokens INTEGER",
[],
@@ -632,6 +642,7 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
input_tokens INTEGER,
output_tokens INTEGER,
cached_input_tokens INTEGER,
cache_creation_input_tokens INTEGER,
FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE
)",
[],
@@ -653,6 +664,10 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
"ALTER TABLE agent_messages ADD COLUMN cached_input_tokens INTEGER",
[],
);
let _ = conn.execute(
"ALTER TABLE agent_messages ADD COLUMN cache_creation_input_tokens INTEGER",
[],
);
// 创建 agent_messages 索引
conn.execute(
@@ -888,6 +888,7 @@ pub fn to_api_key_provider(def: &SystemProviderDef) -> ApiKeyProvider {
location: None,
region: None,
custom_models: Vec::new(),
prompt_cache_mode: None,
created_at: now,
updated_at: now,
}
+1
View File
@@ -52,6 +52,7 @@ pub mod credential;
// 请求处理器核心类型(context, error)
pub mod processor;
pub mod provider_prompt_cache_support;
// WebSocket 核心类型
pub mod websocket;
@@ -2,6 +2,7 @@
//!
//! 支持多凭证池管理,包括健康检测、负载均衡、故障转移等功能。
use crate::provider_prompt_cache_support::is_known_automatic_anthropic_compatible_host;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -29,6 +30,19 @@ pub enum CredentialSource {
/// 所有 Provider 类型定义已统一到 lib.rs 中的 ProviderType。
pub type PoolProviderType = super::provider_type::ProviderType;
/// Provider 声明的 Prompt Cache 模式。
///
/// 说明:
/// - 这是“上游已声明的缓存能力”,不是模型目录或协议族映射;
/// - 对普通 Provider 可为空,运行时会按 ProviderType 走默认语义;
/// - 对自定义 `anthropic-compatible` Provider,可用来覆盖默认的 `explicit_only`。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderPromptCacheMode {
Automatic,
ExplicitOnly,
}
/// 凭证数据,根据 Provider 类型不同而不同
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -232,6 +246,9 @@ pub struct ProviderCredential {
pub source: CredentialSource,
/// 代理 URL(可覆盖全局代理设置)
pub proxy_url: Option<String>,
/// Prompt Cache 模式覆盖(仅在上游显式声明时设置)
#[serde(default)]
pub prompt_cache_mode_override: Option<ProviderPromptCacheMode>,
}
fn default_true() -> bool {
@@ -265,9 +282,29 @@ impl ProviderCredential {
cached_token: None,
source: CredentialSource::Manual,
proxy_url: None,
prompt_cache_mode_override: None,
}
}
/// 解析当前凭证应采用的 Prompt Cache 模式。
pub fn effective_prompt_cache_mode(&self) -> Option<ProviderPromptCacheMode> {
self.prompt_cache_mode_override.or_else(|| {
if self.provider_type.supports_anthropic_prompt_cache() {
Some(ProviderPromptCacheMode::Automatic)
} else if matches!(self.provider_type, PoolProviderType::AnthropicCompatible)
&& is_known_automatic_anthropic_compatible_host(
get_base_url(&self.credential).as_deref(),
)
{
Some(ProviderPromptCacheMode::Automatic)
} else if matches!(self.provider_type, PoolProviderType::AnthropicCompatible) {
Some(ProviderPromptCacheMode::ExplicitOnly)
} else {
None
}
})
}
/// 创建带来源的新凭证
pub fn new_with_source(
provider_type: PoolProviderType,
@@ -743,6 +780,43 @@ mod tests {
assert!(!pattern_matches("gemini-*-pro", "gemini-2.5-flash"));
}
#[test]
fn test_effective_prompt_cache_mode_uses_known_official_host() {
let cred = ProviderCredential {
uuid: "test-uuid".to_string(),
provider_type: PoolProviderType::AnthropicCompatible,
credential: CredentialData::ClaudeKey {
api_key: "test-key".to_string(),
base_url: Some("https://token-plan-cn.xiaomimimo.com/anthropic".to_string()),
},
name: None,
is_healthy: true,
is_disabled: false,
check_health: true,
check_model_name: None,
not_supported_models: vec![],
supported_models: vec![],
usage_count: 0,
error_count: 0,
last_used: None,
last_error_time: None,
last_error_message: None,
last_health_check_time: None,
last_health_check_model: None,
created_at: Utc::now(),
updated_at: Utc::now(),
cached_token: None,
source: CredentialSource::Manual,
proxy_url: None,
prompt_cache_mode_override: None,
};
assert_eq!(
cred.effective_prompt_cache_mode(),
Some(ProviderPromptCacheMode::Automatic)
);
}
#[test]
fn test_supports_model_not_supported_models() {
let cred = ProviderCredential {
@@ -770,6 +844,7 @@ mod tests {
cached_token: None,
source: CredentialSource::Manual,
proxy_url: None,
prompt_cache_mode_override: None,
};
assert!(!cred.supports_model("claude-opus"));
@@ -805,6 +880,7 @@ mod tests {
cached_token: None,
source: CredentialSource::Manual,
proxy_url: None,
prompt_cache_mode_override: None,
};
// Exact match exclusion
@@ -842,6 +918,7 @@ mod tests {
cached_token: None,
source: CredentialSource::Manual,
proxy_url: None,
prompt_cache_mode_override: None,
};
// Prefix wildcard exclusion
@@ -883,6 +960,7 @@ mod tests {
cached_token: None,
source: CredentialSource::Manual,
proxy_url: None,
prompt_cache_mode_override: None,
};
// Contains wildcard exclusion
@@ -921,6 +999,7 @@ mod tests {
cached_token: None,
source: CredentialSource::Manual,
proxy_url: None,
prompt_cache_mode_override: None,
};
// Excluded by not_supported_models (exact match)
@@ -960,6 +1039,7 @@ mod tests {
cached_token: None,
source: CredentialSource::Manual,
proxy_url: None,
prompt_cache_mode_override: None,
};
// All models should be supported since not_supported_models is empty
@@ -0,0 +1,75 @@
use serde::Deserialize;
use std::sync::OnceLock;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PromptCacheCatalog {
#[serde(default)]
automatic_anthropic_compatible_hosts: Vec<PromptCacheHostRule>,
}
#[derive(Debug, Deserialize)]
struct PromptCacheHostRule {
contains: String,
}
fn normalize_api_host(value: &str) -> String {
value
.trim()
.to_lowercase()
.trim_end_matches('/')
.to_string()
}
fn load_prompt_cache_catalog() -> &'static PromptCacheCatalog {
static CATALOG: OnceLock<PromptCacheCatalog> = OnceLock::new();
CATALOG.get_or_init(|| {
serde_json::from_str(include_str!(
"../../../../src/lib/model/anthropicCompatiblePromptCacheCatalog.json"
))
.expect("prompt cache catalog should be valid json")
})
}
pub fn is_known_automatic_anthropic_compatible_host(api_host: Option<&str>) -> bool {
let normalized_api_host = normalize_api_host(api_host.unwrap_or_default());
if normalized_api_host.is_empty() {
return false;
}
load_prompt_cache_catalog()
.automatic_anthropic_compatible_hosts
.iter()
.map(|rule| rule.contains.trim().to_lowercase())
.any(|needle| normalized_api_host.contains(&needle))
}
#[cfg(test)]
mod tests {
use super::is_known_automatic_anthropic_compatible_host;
#[test]
fn known_official_anthropic_compatible_hosts_should_match() {
let hosts = [
"https://open.bigmodel.cn/api/anthropic",
"https://api.moonshot.cn/anthropic",
"https://api.minimaxi.com/anthropic",
"https://token-plan-cn.xiaomimimo.com/anthropic",
];
for host in hosts {
assert!(
is_known_automatic_anthropic_compatible_host(Some(host)),
"expected host to be treated as automatic prompt cache: {host}"
);
}
}
#[test]
fn unknown_host_should_not_match() {
assert!(!is_known_automatic_anthropic_compatible_host(Some(
"https://example.com/anthropic"
)));
}
}
@@ -548,7 +548,10 @@ pub async fn call_provider_anthropic(
CredentialData::ClaudeKey { api_key, base_url } => {
// 打印 Claude 代理 URL 用于调试
let actual_base_url = base_url.as_deref().unwrap_or("https://api.anthropic.com");
let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() {
let prompt_cache_mode = if matches!(
credential.effective_prompt_cache_mode(),
Some(lime_core::models::ProviderPromptCacheMode::Automatic)
) {
PromptCacheMode::Automatic
} else {
PromptCacheMode::ExplicitOnly
@@ -1680,7 +1683,10 @@ pub async fn call_provider_openai(
&credential.uuid[..8],
request.stream
);
let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() {
let prompt_cache_mode = if matches!(
credential.effective_prompt_cache_mode(),
Some(lime_core::models::ProviderPromptCacheMode::Automatic)
) {
PromptCacheMode::Automatic
} else {
PromptCacheMode::ExplicitOnly
@@ -671,7 +671,10 @@ pub async fn call_provider_openai_for_ws(
actual_base_url,
&credential.uuid[..8]
);
let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() {
let prompt_cache_mode = if matches!(
credential.effective_prompt_cache_mode(),
Some(lime_core::models::ProviderPromptCacheMode::Automatic)
) {
PromptCacheMode::Automatic
} else {
PromptCacheMode::ExplicitOnly
@@ -821,7 +824,10 @@ pub async fn call_provider_anthropic_for_ws(
actual_base_url,
&credential.uuid[..8]
);
let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() {
let prompt_cache_mode = if matches!(
credential.effective_prompt_cache_mode(),
Some(lime_core::models::ProviderPromptCacheMode::Automatic)
) {
PromptCacheMode::Automatic
} else {
PromptCacheMode::ExplicitOnly
@@ -9,12 +9,15 @@ use crate::provider_type_mapping::pool_provider_type_to_api_type;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use chrono::Utc;
use lime_core::database::dao::api_key_provider::{
ApiKeyEntry, ApiKeyProvider, ApiKeyProviderDao, ApiProviderType, ProviderGroup,
ProviderWithKeys,
ApiKeyEntry, ApiKeyProvider, ApiKeyProviderDao, ApiProviderPromptCacheMode, ApiProviderType,
ProviderGroup, ProviderWithKeys,
};
use lime_core::database::system_providers::{get_system_providers, to_api_key_provider};
use lime_core::database::DbConnection;
use lime_core::models::{CredentialData, CredentialSource, PoolProviderType, ProviderCredential};
use lime_core::models::{
CredentialData, CredentialSource, PoolProviderType, ProviderCredential, ProviderPromptCacheMode,
};
use lime_core::provider_prompt_cache_support::is_known_automatic_anthropic_compatible_host;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
@@ -44,7 +47,9 @@ mod tests {
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use chrono::Utc;
use lime_core::database::dao::api_key_provider::ApiProviderType;
use lime_core::database::dao::api_key_provider::{ApiKeyEntry, ApiKeyProviderDao};
use lime_core::database::dao::api_key_provider::{
ApiKeyEntry, ApiKeyProviderDao, ApiProviderPromptCacheMode,
};
use lime_core::database::{init_database, migration, schema, DbConnection};
use rusqlite::Connection;
use rusqlite::OptionalExtension;
@@ -225,6 +230,7 @@ data: [DONE]\n";
None,
None,
None,
None,
)
.expect("更新系统 Provider 类型失败");
@@ -238,6 +244,88 @@ data: [DONE]\n";
assert_eq!(persisted.provider.provider_type, ApiProviderType::Openai);
}
#[test]
fn test_add_custom_provider_should_force_known_anthropic_compatible_host_to_automatic() {
let db = init_test_database();
let service = ApiKeyProviderService::new();
let provider = service
.add_custom_provider(
&db,
"MiMo Anthropic".to_string(),
ApiProviderType::AnthropicCompatible,
"https://token-plan-cn.xiaomimimo.com/anthropic".to_string(),
None,
None,
None,
None,
Some(ApiProviderPromptCacheMode::ExplicitOnly),
)
.expect("创建自定义 Provider 失败");
assert_eq!(
provider.prompt_cache_mode,
Some(ApiProviderPromptCacheMode::Automatic)
);
let conn = db.lock().expect("获取数据库锁失败");
let persisted = ApiKeyProviderDao::get_provider_by_id(&conn, &provider.id)
.expect("读取 Provider 失败")
.expect("Provider 应存在");
assert_eq!(
persisted.prompt_cache_mode,
Some(ApiProviderPromptCacheMode::Automatic)
);
}
#[test]
fn test_update_provider_should_force_known_anthropic_compatible_host_to_automatic() {
let db = init_test_database();
let service = ApiKeyProviderService::new();
let provider = service
.add_custom_provider(
&db,
"Unknown Anthropic".to_string(),
ApiProviderType::AnthropicCompatible,
"https://example.com/anthropic".to_string(),
None,
None,
None,
None,
Some(ApiProviderPromptCacheMode::ExplicitOnly),
)
.expect("创建初始 Provider 失败");
assert_eq!(
provider.prompt_cache_mode,
Some(ApiProviderPromptCacheMode::ExplicitOnly)
);
let updated = service
.update_provider(
&db,
&provider.id,
None,
None,
Some("https://api.minimaxi.com/anthropic".to_string()),
None,
None,
None,
None,
None,
None,
Some(ApiProviderPromptCacheMode::ExplicitOnly),
None,
)
.expect("更新 Provider 失败");
assert_eq!(
updated.prompt_cache_mode,
Some(ApiProviderPromptCacheMode::Automatic)
);
}
#[test]
fn test_parse_openai_responses_content_prefers_output_text() {
let body = serde_json::json!({
@@ -409,6 +497,7 @@ data: [DONE]\n";
None,
None,
None,
None,
)
.expect("创建 Provider 失败");
service
@@ -462,6 +551,7 @@ data: [DONE]\n";
None,
None,
None,
None,
)
.expect("创建 Provider 失败");
service
@@ -717,6 +807,32 @@ impl ApiKeyProviderService {
}
}
fn normalize_custom_prompt_cache_mode(
provider_type: ApiProviderType,
api_host: &str,
prompt_cache_mode: Option<ApiProviderPromptCacheMode>,
) -> Option<ApiProviderPromptCacheMode> {
match provider_type {
ApiProviderType::AnthropicCompatible => {
if is_known_automatic_anthropic_compatible_host(Some(api_host)) {
Some(ApiProviderPromptCacheMode::Automatic)
} else {
Some(prompt_cache_mode.unwrap_or(ApiProviderPromptCacheMode::ExplicitOnly))
}
}
_ => None,
}
}
fn to_credential_prompt_cache_mode(
mode: ApiProviderPromptCacheMode,
) -> ProviderPromptCacheMode {
match mode {
ApiProviderPromptCacheMode::Automatic => ProviderPromptCacheMode::Automatic,
ApiProviderPromptCacheMode::ExplicitOnly => ProviderPromptCacheMode::ExplicitOnly,
}
}
fn decrypt_api_key_entry_with_migration(
&self,
conn: &rusqlite::Connection,
@@ -842,7 +958,7 @@ impl ApiKeyProviderService {
&provider.api_host,
&test_model,
&prompt,
provider.provider_type.supports_anthropic_prompt_cache(),
provider.supports_automatic_prompt_cache(),
)
.await
}
@@ -1374,9 +1490,12 @@ impl ApiKeyProviderService {
project: Option<String>,
location: Option<String>,
region: Option<String>,
prompt_cache_mode: Option<ApiProviderPromptCacheMode>,
) -> Result<ApiKeyProvider, String> {
let now = Utc::now();
let id = format!("custom-{}", uuid::Uuid::new_v4());
let normalized_prompt_cache_mode =
Self::normalize_custom_prompt_cache_mode(provider_type, &api_host, prompt_cache_mode);
let provider = ApiKeyProvider {
id: id.clone(),
@@ -1392,6 +1511,7 @@ impl ApiKeyProviderService {
location,
region,
custom_models: Vec::new(),
prompt_cache_mode: normalized_prompt_cache_mode,
created_at: now,
updated_at: now,
};
@@ -1416,6 +1536,7 @@ impl ApiKeyProviderService {
project: Option<String>,
location: Option<String>,
region: Option<String>,
prompt_cache_mode: Option<ApiProviderPromptCacheMode>,
custom_models: Option<Vec<String>>,
) -> Result<ApiKeyProvider, String> {
let conn = lime_core::database::lock_db(db)?;
@@ -1454,6 +1575,11 @@ impl ApiKeyProviderService {
if let Some(models) = custom_models {
provider.custom_models = models;
}
provider.prompt_cache_mode = Self::normalize_custom_prompt_cache_mode(
provider.provider_type,
&provider.api_host,
prompt_cache_mode.or(provider.prompt_cache_mode),
);
provider.updated_at = Utc::now();
ApiKeyProviderDao::update_provider(&conn, &provider).map_err(|e| e.to_string())?;
@@ -2173,7 +2299,7 @@ impl ApiKeyProviderService {
.test_claude_key_compatibility(
&api_key,
&provider.api_host,
provider.provider_type.supports_anthropic_prompt_cache(),
provider.supports_automatic_prompt_cache(),
)
.await
{
@@ -2292,6 +2418,9 @@ impl ApiKeyProviderService {
cached_token: None,
source: CredentialSource::Imported,
proxy_url: None,
prompt_cache_mode_override: provider
.effective_prompt_cache_mode()
.map(Self::to_credential_prompt_cache_mode),
})
}
@@ -2350,6 +2479,9 @@ impl ApiKeyProviderService {
cached_token: None,
source: CredentialSource::Imported, // 标记为导入来源
proxy_url: None,
prompt_cache_mode_override: provider
.effective_prompt_cache_mode()
.map(Self::to_credential_prompt_cache_mode),
})
}
@@ -2418,7 +2550,7 @@ impl ApiKeyProviderService {
&api_key,
&provider.api_host,
&test_model,
provider.provider_type.supports_anthropic_prompt_cache(),
provider.supports_automatic_prompt_cache(),
)
.await
{
@@ -265,6 +265,7 @@ impl SessionStore for LimeSessionStore {
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
@@ -307,7 +308,7 @@ impl SessionStore for LimeSessionStore {
.prepare(
"SELECT id, model, system_prompt, title, created_at, updated_at, working_dir,
session_type, user_set_name, extension_data_json,
total_tokens, input_tokens, output_tokens, cached_input_tokens,
total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json, user_recipe_values_json,
provider_name, model_config_json
@@ -335,11 +336,12 @@ impl SessionStore for LimeSessionStore {
row.get::<_, Option<i32>>(14)?,
row.get::<_, Option<i32>>(15)?,
row.get::<_, Option<i32>>(16)?,
row.get::<_, Option<String>>(17)?,
row.get::<_, Option<i32>>(17)?,
row.get::<_, Option<String>>(18)?,
row.get::<_, Option<String>>(19)?,
row.get::<_, Option<String>>(20)?,
row.get::<_, Option<String>>(21)?,
row.get::<_, Option<String>>(22)?,
))
})
.map_err(|e| anyhow!("会话不存在: {e}"))?;
@@ -359,6 +361,7 @@ impl SessionStore for LimeSessionStore {
input_tokens,
output_tokens,
cached_input_tokens,
cache_creation_input_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
@@ -396,6 +399,7 @@ impl SessionStore for LimeSessionStore {
input_tokens,
output_tokens,
cached_input_tokens,
cache_creation_input_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
@@ -553,7 +557,7 @@ impl SessionStore for LimeSessionStore {
let mut stmt = conn.prepare(
"SELECT id, model, system_prompt, title, created_at, updated_at, working_dir,
session_type, user_set_name, extension_data_json,
total_tokens, input_tokens, output_tokens, cached_input_tokens,
total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json, user_recipe_values_json,
provider_name, model_config_json
@@ -575,14 +579,15 @@ impl SessionStore for LimeSessionStore {
let input_tokens: Option<i32> = row.get(11)?;
let output_tokens: Option<i32> = row.get(12)?;
let cached_input_tokens: Option<i32> = row.get(13)?;
let accumulated_total_tokens: Option<i32> = row.get(14)?;
let accumulated_input_tokens: Option<i32> = row.get(15)?;
let accumulated_output_tokens: Option<i32> = row.get(16)?;
let schedule_id: Option<String> = row.get(17)?;
let recipe_json: Option<String> = row.get(18)?;
let user_recipe_values_json: Option<String> = row.get(19)?;
let provider_name: Option<String> = row.get(20)?;
let model_config_json: Option<String> = row.get(21)?;
let cache_creation_input_tokens: Option<i32> = row.get(14)?;
let accumulated_total_tokens: Option<i32> = row.get(15)?;
let accumulated_input_tokens: Option<i32> = row.get(16)?;
let accumulated_output_tokens: Option<i32> = row.get(17)?;
let schedule_id: Option<String> = row.get(18)?;
let recipe_json: Option<String> = row.get(19)?;
let user_recipe_values_json: Option<String> = row.get(20)?;
let provider_name: Option<String> = row.get(21)?;
let model_config_json: Option<String> = row.get(22)?;
Ok((
id,
@@ -598,6 +603,7 @@ impl SessionStore for LimeSessionStore {
input_tokens,
output_tokens,
cached_input_tokens,
cache_creation_input_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
@@ -624,6 +630,7 @@ impl SessionStore for LimeSessionStore {
input_tokens,
output_tokens,
cached_input_tokens,
cache_creation_input_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
@@ -657,6 +664,7 @@ impl SessionStore for LimeSessionStore {
input_tokens,
output_tokens,
cached_input_tokens,
cache_creation_input_tokens,
accumulated_total_tokens,
accumulated_input_tokens,
accumulated_output_tokens,
@@ -745,6 +753,7 @@ impl SessionStore for LimeSessionStore {
input_tokens: session.input_tokens,
output_tokens: session.output_tokens,
cached_input_tokens: session.cached_input_tokens,
cache_creation_input_tokens: session.cache_creation_input_tokens,
accumulated_total: session.accumulated_total_tokens,
accumulated_input: session.accumulated_input_tokens,
accumulated_output: session.accumulated_output_tokens,
@@ -797,6 +806,7 @@ impl SessionStore for LimeSessionStore {
input_tokens: original.input_tokens,
output_tokens: original.output_tokens,
cached_input_tokens: original.cached_input_tokens,
cache_creation_input_tokens: original.cache_creation_input_tokens,
accumulated_total: original.accumulated_total_tokens,
accumulated_input: original.accumulated_input_tokens,
accumulated_output: original.accumulated_output_tokens,
@@ -895,17 +905,19 @@ impl SessionStore for LimeSessionStore {
input_tokens = COALESCE(?2, input_tokens),
output_tokens = COALESCE(?3, output_tokens),
cached_input_tokens = COALESCE(?4, cached_input_tokens),
accumulated_total_tokens = COALESCE(?5, accumulated_total_tokens),
accumulated_input_tokens = COALESCE(?6, accumulated_input_tokens),
accumulated_output_tokens = COALESCE(?7, accumulated_output_tokens),
schedule_id = COALESCE(?8, schedule_id),
updated_at = ?9
WHERE id = ?10",
cache_creation_input_tokens = COALESCE(?5, cache_creation_input_tokens),
accumulated_total_tokens = COALESCE(?6, accumulated_total_tokens),
accumulated_input_tokens = COALESCE(?7, accumulated_input_tokens),
accumulated_output_tokens = COALESCE(?8, accumulated_output_tokens),
schedule_id = COALESCE(?9, schedule_id),
updated_at = ?10
WHERE id = ?11",
rusqlite::params![
stats.total_tokens,
stats.input_tokens,
stats.output_tokens,
stats.cached_input_tokens,
stats.cache_creation_input_tokens,
stats.accumulated_total,
stats.accumulated_input,
stats.accumulated_output,
@@ -928,6 +940,9 @@ impl SessionStore for LimeSessionStore {
if let Some(cached_input_tokens) = stats.cached_input_tokens {
session.cached_input_tokens = Some(cached_input_tokens);
}
if let Some(cache_creation_input_tokens) = stats.cache_creation_input_tokens {
session.cache_creation_input_tokens = Some(cache_creation_input_tokens);
}
if let Some(accumulated_total) = stats.accumulated_total {
session.accumulated_total_tokens = Some(accumulated_total);
}
@@ -1366,6 +1381,7 @@ mod tests {
input_tokens: Some(60),
output_tokens: Some(40),
cached_input_tokens: Some(24),
cache_creation_input_tokens: Some(12),
accumulated_total: Some(300),
accumulated_input: Some(180),
accumulated_output: Some(120),
@@ -1417,6 +1433,7 @@ mod tests {
assert_eq!(loaded.session_type, SessionType::SubAgent);
assert_eq!(loaded.total_tokens, Some(100));
assert_eq!(loaded.cached_input_tokens, Some(24));
assert_eq!(loaded.cache_creation_input_tokens, Some(12));
assert_eq!(loaded.accumulated_total_tokens, Some(300));
assert_eq!(loaded.schedule_id.as_deref(), Some("job-1"));
assert_eq!(loaded.provider_name.as_deref(), Some("openai"));
@@ -1632,6 +1649,7 @@ mod tests {
input_tokens: Some(60),
output_tokens: Some(40),
cached_input_tokens: Some(24),
cache_creation_input_tokens: Some(12),
accumulated_total: Some(300),
accumulated_input: Some(180),
accumulated_output: Some(120),
@@ -1649,6 +1667,7 @@ mod tests {
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
accumulated_total: None,
accumulated_input: None,
accumulated_output: None,
@@ -1667,6 +1686,7 @@ mod tests {
assert_eq!(loaded.input_tokens, Some(60));
assert_eq!(loaded.output_tokens, Some(40));
assert_eq!(loaded.cached_input_tokens, Some(24));
assert_eq!(loaded.cache_creation_input_tokens, Some(12));
assert_eq!(loaded.accumulated_total_tokens, Some(300));
assert_eq!(loaded.accumulated_input_tokens, Some(180));
assert_eq!(loaded.accumulated_output_tokens, Some(120));
@@ -1693,6 +1713,7 @@ mod tests {
input_tokens: Some(60),
output_tokens: Some(40),
cached_input_tokens: Some(24),
cache_creation_input_tokens: Some(12),
accumulated_total: Some(300),
accumulated_input: Some(180),
accumulated_output: Some(120),
@@ -1710,6 +1731,7 @@ mod tests {
input_tokens: Some(0),
output_tokens: Some(0),
cached_input_tokens: Some(0),
cache_creation_input_tokens: Some(0),
accumulated_total: None,
accumulated_input: None,
accumulated_output: None,
@@ -1728,6 +1750,7 @@ mod tests {
assert_eq!(loaded.input_tokens, Some(0));
assert_eq!(loaded.output_tokens, Some(0));
assert_eq!(loaded.cached_input_tokens, Some(0));
assert_eq!(loaded.cache_creation_input_tokens, Some(0));
assert_eq!(loaded.accumulated_total_tokens, Some(300));
assert_eq!(loaded.accumulated_input_tokens, Some(180));
assert_eq!(loaded.accumulated_output_tokens, Some(120));
@@ -163,6 +163,35 @@ fn format_shell_env_line(key: &str, value: &str, syntax: ShellConfigSyntax) -> S
}
}
#[cfg(test)]
fn parse_shell_env_line(line: &str) -> Option<(String, String)> {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("export ") {
let (key, value) = rest.split_once('=')?;
let unquoted = value
.trim()
.strip_prefix('"')?
.strip_suffix('"')?
.replace("\\\"", "\"")
.replace("\\\\", "\\");
return Some((key.trim().to_string(), unquoted));
}
if let Some(rest) = trimmed.strip_prefix("$env:") {
let (key, value) = rest.split_once('=')?;
let unquoted = value
.trim()
.strip_prefix('"')?
.strip_suffix('"')?
.replace("`\"", "\"")
.replace("``", "`");
return Some((key.trim().to_string(), unquoted));
}
None
}
/// 将环境变量写入 shell 配置文件
/// 使用标记块管理,避免重复添加
///
@@ -132,7 +132,10 @@ impl LimeLlmProvider {
self.call_claude_api(
api_key,
base_url.as_deref(),
if credential.provider_type.supports_anthropic_prompt_cache() {
if matches!(
credential.effective_prompt_cache_mode(),
Some(lime_core::models::ProviderPromptCacheMode::Automatic)
) {
PromptCacheMode::Automatic
} else {
PromptCacheMode::ExplicitOnly
@@ -158,7 +161,10 @@ impl LimeLlmProvider {
self.call_claude_api(
api_key,
base_url.as_deref(),
if credential.provider_type.supports_anthropic_prompt_cache() {
if matches!(
credential.effective_prompt_cache_mode(),
Some(lime_core::models::ProviderPromptCacheMode::Automatic)
) {
PromptCacheMode::Automatic
} else {
PromptCacheMode::ExplicitOnly
+18 -1
View File
@@ -6,7 +6,7 @@
//! **Validates: Requirements 9.1**
use crate::database::dao::api_key_provider::{
ApiKeyEntry, ApiKeyProvider, ApiProviderType, ProviderWithKeys,
ApiKeyEntry, ApiKeyProvider, ApiProviderPromptCacheMode, ApiProviderType, ProviderWithKeys,
};
use crate::database::system_providers::get_system_providers;
use crate::database::DbConnection;
@@ -35,6 +35,7 @@ pub struct AddCustomProviderRequest {
pub project: Option<String>,
pub location: Option<String>,
pub region: Option<String>,
pub prompt_cache_mode: Option<String>,
}
/// 更新 Provider 请求
@@ -51,6 +52,7 @@ pub struct UpdateProviderRequest {
pub project: Option<String>,
pub location: Option<String>,
pub region: Option<String>,
pub prompt_cache_mode: Option<String>,
/// 自定义模型列表
pub custom_models: Option<Vec<String>>,
}
@@ -81,6 +83,8 @@ pub struct ProviderDisplay {
pub region: Option<String>,
/// 自定义模型列表
pub custom_models: Vec<String>,
/// 当前 Provider 声明的 Prompt Cache 模式(前端优先使用该值,不再只按 type 猜)
pub prompt_cache_mode: Option<String>,
pub api_key_count: usize,
pub created_at: String,
pub updated_at: String,
@@ -156,6 +160,9 @@ fn provider_to_display(provider: &ApiKeyProvider, api_key_count: usize) -> Provi
location: provider.location.clone(),
region: provider.region.clone(),
custom_models: provider.custom_models.clone(),
prompt_cache_mode: provider
.effective_prompt_cache_mode()
.map(|mode| mode.to_string()),
api_key_count,
created_at: provider.created_at.to_rfc3339(),
updated_at: provider.updated_at.to_rfc3339(),
@@ -306,6 +313,11 @@ pub fn add_custom_api_key_provider(
request.project,
request.location,
request.region,
request
.prompt_cache_mode
.map(|mode| mode.parse::<ApiProviderPromptCacheMode>())
.transpose()
.map_err(|e: String| format!("无效的 Prompt Cache 模式: {e}"))?,
)?;
Ok(provider_to_display(&provider, 0))
@@ -338,6 +350,11 @@ pub fn update_api_key_provider(
request.project,
request.location,
request.region,
request
.prompt_cache_mode
.map(|mode| mode.parse::<ApiProviderPromptCacheMode>())
.transpose()
.map_err(|e: String| format!("无效的 Prompt Cache 模式: {e}"))?,
request.custom_models,
)?;
@@ -2049,6 +2049,10 @@ fn resolve_runtime_message_usage_from_session(
.cached_input_tokens
.filter(|value| *value >= 0)
.map(|value| value as u32),
cache_creation_input_tokens: session
.cache_creation_input_tokens
.filter(|value| *value >= 0)
.map(|value| value as u32),
})
}
_ => None,
@@ -2082,6 +2086,7 @@ fn persist_latest_assistant_message_usage(
usage.input_tokens,
usage.output_tokens,
usage.cached_input_tokens,
usage.cache_creation_input_tokens,
)?;
Ok(())
}
@@ -2110,6 +2115,11 @@ fn build_compaction_session_metrics_update(
} else {
Some(0)
};
let cache_creation_input_tokens = if usage.usage.output_tokens.is_some() {
usage.usage.cache_creation_input_tokens
} else {
Some(0)
};
let current_window_tokens = usage
.usage
@@ -2121,6 +2131,7 @@ fn build_compaction_session_metrics_update(
schedule_id,
current_window_tokens,
cached_input_tokens,
cache_creation_input_tokens,
accumulated_total_tokens: accumulated_total,
accumulated_input_tokens: accumulated_input,
accumulated_output_tokens: accumulated_output,
@@ -3482,6 +3493,7 @@ mod tests {
.input_tokens(Some(60))
.output_tokens(Some(30))
.cached_input_tokens(Some(12))
.cache_creation_input_tokens(Some(4))
.accumulated_total_tokens(Some(300))
.accumulated_input_tokens(Some(200))
.accumulated_output_tokens(Some(100))
@@ -3494,7 +3506,9 @@ mod tests {
let usage = ProviderUsage::new(
"gpt-4.1".to_string(),
Usage::new(Some(120), Some(45), Some(165)).with_cached_input_tokens(Some(90)),
Usage::new(Some(120), Some(45), Some(165))
.with_cached_input_tokens(Some(90))
.with_cache_creation_input_tokens(Some(30)),
);
update_compaction_session_metrics(&session_config, &usage)
@@ -3510,6 +3524,7 @@ mod tests {
assert_eq!(updated.input_tokens, Some(45));
assert_eq!(updated.output_tokens, Some(0));
assert_eq!(updated.cached_input_tokens, Some(90));
assert_eq!(updated.cache_creation_input_tokens, Some(30));
assert_eq!(updated.accumulated_total_tokens, Some(465));
assert_eq!(updated.accumulated_input_tokens, Some(320));
assert_eq!(updated.accumulated_output_tokens, Some(145));
@@ -3538,6 +3553,7 @@ mod tests {
.input_tokens(Some(120))
.output_tokens(Some(60))
.cached_input_tokens(Some(24))
.cache_creation_input_tokens(Some(8))
.accumulated_total_tokens(Some(700))
.accumulated_input_tokens(Some(500))
.accumulated_output_tokens(Some(200))
@@ -3563,6 +3579,7 @@ mod tests {
assert_eq!(updated.input_tokens, Some(0));
assert_eq!(updated.output_tokens, Some(0));
assert_eq!(updated.cached_input_tokens, Some(0));
assert_eq!(updated.cache_creation_input_tokens, Some(0));
assert_eq!(updated.accumulated_total_tokens, Some(700));
assert_eq!(updated.accumulated_input_tokens, Some(500));
assert_eq!(updated.accumulated_output_tokens, Some(200));
@@ -3591,6 +3608,7 @@ mod tests {
.input_tokens(Some(10))
.output_tokens(Some(10))
.cached_input_tokens(Some(6))
.cache_creation_input_tokens(Some(2))
.accumulated_total_tokens(Some(200))
.accumulated_input_tokens(Some(120))
.accumulated_output_tokens(Some(80))
@@ -3601,7 +3619,9 @@ mod tests {
let session_config = SessionConfigBuilder::new(&session.id).build();
let usage = ProviderUsage::new(
"gpt-4.1".to_string(),
Usage::new(Some(30), Some(15), Some(45)).with_cached_input_tokens(Some(18)),
Usage::new(Some(30), Some(15), Some(45))
.with_cached_input_tokens(Some(18))
.with_cache_creation_input_tokens(Some(6)),
);
update_compaction_session_metrics(&session_config, &usage)
@@ -3617,6 +3637,7 @@ mod tests {
assert_eq!(updated.input_tokens, Some(15));
assert_eq!(updated.output_tokens, Some(0));
assert_eq!(updated.cached_input_tokens, Some(18));
assert_eq!(updated.cache_creation_input_tokens, Some(6));
assert_eq!(updated.accumulated_total_tokens, Some(245));
assert_eq!(updated.accumulated_input_tokens, Some(150));
assert_eq!(updated.accumulated_output_tokens, Some(95));
@@ -3642,6 +3663,7 @@ mod tests {
.input_tokens(Some(204))
.output_tokens(Some(88))
.cached_input_tokens(Some(160))
.cache_creation_input_tokens(Some(48))
.apply()
.await
.expect("写入 usage 失败");
@@ -3654,8 +3676,9 @@ mod tests {
value.input_tokens,
value.output_tokens,
value.cached_input_tokens,
value.cache_creation_input_tokens,
)),
Some((204, 88, Some(160)))
Some((204, 88, Some(160), Some(48)))
);
}
other => panic!("收到意外事件: {:?}", other),
+1
View File
@@ -253,6 +253,7 @@ pub async fn save_relay_api_key(
None, // project
None, // location
None, // region
None, // prompt_cache_mode
)
.map_err(|e| ConnectError {
code: "CREATE_PROVIDER_FAILED".to_string(),
@@ -60,6 +60,10 @@ fn api_key_provider_with_keys_to_display(
location: provider_with_keys.provider.location.clone(),
region: provider_with_keys.provider.region.clone(),
custom_models: provider_with_keys.provider.custom_models.clone(),
prompt_cache_mode: provider_with_keys
.provider
.effective_prompt_cache_mode()
.map(|mode| mode.to_string()),
api_key_count: provider_with_keys.api_keys.len(),
created_at: provider_with_keys.provider.created_at.to_rfc3339(),
updated_at: provider_with_keys.provider.updated_at.to_rfc3339(),
@@ -42,6 +42,7 @@ fn build_provider(provider_type: ApiProviderType, api_host: &str) -> ApiKeyProvi
project: None,
location: None,
region: None,
prompt_cache_mode: None,
custom_models: Vec::new(),
created_at: Utc::now(),
updated_at: Utc::now(),
@@ -153,6 +153,8 @@ struct AnalysisObservabilitySection {
summary: Value,
correlation_keys: Vec<String>,
gap_signals: Vec<String>,
verification_failure_outcomes: Vec<String>,
verification_recovered_outcomes: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
@@ -232,6 +234,14 @@ pub fn export_runtime_analysis_handoff(
let observability_correlation_keys =
collect_observability_correlation_keys(&observability_summary);
let observability_gap_signals = collect_observability_gap_signals(&observability_summary);
let observability_verification_failure_outcomes = collect_observability_verification_outcomes(
&observability_summary,
"/verificationSummary/focusVerificationFailureOutcomes",
);
let observability_verification_recovered_outcomes = collect_observability_verification_outcomes(
&observability_summary,
"/verificationSummary/focusVerificationRecoveredOutcomes",
);
let title = derive_title(&input_payload, session_id);
let failure_modes = value_string_list(
@@ -406,6 +416,8 @@ pub fn export_runtime_analysis_handoff(
summary: sanitize_value(observability_summary, workspace_root.as_path()),
correlation_keys: observability_correlation_keys.clone(),
gap_signals: observability_gap_signals.clone(),
verification_failure_outcomes: observability_verification_failure_outcomes.clone(),
verification_recovered_outcomes: observability_verification_recovered_outcomes.clone(),
},
reading_order: reading_order.clone(),
external_analysis_contract: external_contract.clone(),
@@ -416,6 +428,7 @@ pub fn export_runtime_analysis_handoff(
&title,
&exported_at,
&summary,
&analysis_context.observability.summary,
&replay_refs,
&handoff_refs,
&evidence_refs,
@@ -426,6 +439,10 @@ pub fn export_runtime_analysis_handoff(
&analysis_context.evidence.summary_excerpt,
&analysis_context.observability.correlation_keys,
&analysis_context.observability.gap_signals,
&analysis_context.observability.verification_failure_outcomes,
&analysis_context
.observability
.verification_recovered_outcomes,
);
let artifacts = vec![
@@ -489,6 +506,7 @@ fn build_analysis_brief(
title: &str,
exported_at: &str,
summary: &AnalysisContextSummary,
observability_summary: &Value,
replay_refs: &[AnalysisArtifactReference],
handoff_refs: &[AnalysisArtifactReference],
evidence_refs: &[AnalysisArtifactReference],
@@ -499,6 +517,8 @@ fn build_analysis_brief(
evidence_excerpt: &str,
observability_correlation_keys: &[String],
observability_gap_signals: &[String],
verification_failure_outcomes: &[String],
verification_recovered_outcomes: &[String],
) -> String {
let mut lines = vec![
"# 外部分析交接简报".to_string(),
@@ -557,10 +577,26 @@ fn build_analysis_brief(
"- 当前缺口:{}",
join_or_fallback(observability_gap_signals, "无")
),
"- 结构化验证摘要:".to_string(),
];
lines.extend(
render_observability_verification_summary_lines(observability_summary)
.into_iter()
.map(|line| format!(" {line}")),
);
lines.extend([
format!(
"- 验证失败焦点:{}",
join_or_fallback(verification_failure_outcomes, "无")
),
format!(
"- 已恢复结果:{}",
join_or_fallback(verification_recovered_outcomes, "无")
),
String::new(),
"## 推荐读取顺序".to_string(),
String::new(),
];
]);
for (index, item) in reading_order.iter().enumerate() {
lines.push(format!("{}. {}", index + 1, item));
@@ -974,6 +1010,159 @@ fn collect_observability_gap_signals(summary: &Value) -> Vec<String> {
.collect()
}
fn collect_observability_verification_outcomes(summary: &Value, pointer: &str) -> Vec<String> {
summary
.pointer(pointer)
.map(value_string_list)
.unwrap_or_default()
}
fn render_observability_verification_summary_lines(summary: &Value) -> Vec<String> {
let verification_summary = summary
.get("verificationSummary")
.or_else(|| summary.get("verification_summary"));
let Some(verification_summary) = verification_summary else {
return vec!["- 当前没有结构化验证摘要。".to_string()];
};
let mut lines = Vec::new();
if let Some(artifact_validator) = summary_object_field(
verification_summary,
"artifactValidator",
"artifact_validator",
) {
lines.push(format!(
"- Artifact 校验:`{}`|{}",
format_verification_outcome_label(summary_string_field(
artifact_validator,
"outcome",
"outcome",
)),
describe_artifact_validator_summary(artifact_validator),
));
}
if let Some(browser_verification) = summary_object_field(
verification_summary,
"browserVerification",
"browser_verification",
) {
lines.push(format!(
"- 浏览器验证:`{}`|{}",
format_verification_outcome_label(summary_string_field(
browser_verification,
"outcome",
"outcome",
)),
describe_browser_verification_summary(browser_verification),
));
}
if let Some(gui_smoke) = summary_object_field(verification_summary, "guiSmoke", "gui_smoke") {
lines.push(format!(
"- GUI Smoke:`{}`|{}",
format_verification_outcome_label(summary_string_field(
gui_smoke, "outcome", "outcome",
)),
describe_gui_smoke_summary(gui_smoke),
));
}
if lines.is_empty() {
vec!["- 当前没有结构化验证摘要。".to_string()]
} else {
lines
}
}
fn summary_object_field<'a>(
summary: &'a Value,
camel_case: &str,
snake_case: &str,
) -> Option<&'a Value> {
summary
.get(camel_case)
.or_else(|| summary.get(snake_case))
.filter(|value| value.is_object())
}
fn summary_string_field<'a>(
summary: &'a Value,
camel_case: &str,
snake_case: &str,
) -> Option<&'a str> {
summary
.get(camel_case)
.or_else(|| summary.get(snake_case))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn summary_u64_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option<u64> {
summary
.get(camel_case)
.or_else(|| summary.get(snake_case))
.and_then(Value::as_u64)
}
fn summary_bool_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option<bool> {
summary
.get(camel_case)
.or_else(|| summary.get(snake_case))
.and_then(Value::as_bool)
}
fn format_verification_outcome_label(value: Option<&str>) -> &'static str {
match value {
Some("success") => "通过",
Some("blocking_failure") => "阻塞失败",
Some("advisory_failure") => "提示失败",
Some("recovered") => "已恢复",
_ => "未定",
}
}
fn describe_artifact_validator_summary(summary: &Value) -> String {
if summary_bool_field(summary, "applicable", "applicable") == Some(false) {
return "当前没有适用的 Artifact 校验。".to_string();
}
format!(
"记录 {} · issues {} · repaired {} · fallback {}",
summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0),
summary_u64_field(summary, "issueCount", "issue_count").unwrap_or(0),
summary_u64_field(summary, "repairedCount", "repaired_count").unwrap_or(0),
summary_u64_field(summary, "fallbackUsedCount", "fallback_used_count").unwrap_or(0),
)
}
fn describe_browser_verification_summary(summary: &Value) -> String {
format!(
"记录 {} · 成功 {} · 失败 {} · 未判定 {}",
summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0),
summary_u64_field(summary, "successCount", "success_count").unwrap_or(0),
summary_u64_field(summary, "failureCount", "failure_count").unwrap_or(0),
summary_u64_field(summary, "unknownCount", "unknown_count").unwrap_or(0),
)
}
fn describe_gui_smoke_summary(summary: &Value) -> String {
let status = summary_string_field(summary, "status", "status").unwrap_or("未知");
let exit_code = summary_u64_field(summary, "exitCode", "exit_code")
.map(|value| value.to_string())
.unwrap_or_else(|| "未知".to_string());
let passed = summary_bool_field(summary, "passed", "passed").unwrap_or(false);
format!(
"状态 {} · exit {} · {}",
status,
exit_code,
if passed { "已通过" } else { "未通过" }
)
}
fn join_or_fallback(values: &[String], fallback: &str) -> String {
if values.is_empty() {
fallback.to_string()
@@ -1183,6 +1372,94 @@ mod tests {
.expect("write request log");
}
fn seed_recovered_verification(detail: &mut SessionDetail, root: &Path) {
let artifact_relative_path = ".lime/artifacts/thread-1/report.artifact.json";
let artifact_absolute_path =
root.join(artifact_relative_path.replace('/', std::path::MAIN_SEPARATOR_STR));
fs::create_dir_all(
artifact_absolute_path
.parent()
.expect("artifact path should have parent"),
)
.expect("create artifact dir");
fs::write(
&artifact_absolute_path,
serde_json::to_string_pretty(&json!({
"schemaVersion": crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION,
"title": "Harness Evidence",
"kind": "analysis",
"status": "ready",
"blocks": [
{
"id": "block-1",
"type": "rich_text",
"content": "test"
}
],
"metadata": {
"artifactValidationIssues": ["title 缺失或为空,已使用兜底标题。"],
"artifactValidationRepaired": true,
"artifactFallbackUsed": false
}
}))
.expect("serialize artifact document"),
)
.expect("write artifact document");
detail.items.push(AgentThreadItem {
id: "artifact-verification-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 4,
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::FileArtifact {
path: artifact_relative_path.to_string(),
source: "artifact_snapshot".to_string(),
content: None,
metadata: None,
},
});
detail.items.push(AgentThreadItem {
id: "browser-tool-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 5,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:40Z".to_string(),
completed_at: Some("2026-03-27T10:00:40Z".to_string()),
updated_at: "2026-03-27T10:00:40Z".to_string(),
payload: AgentThreadItemPayload::ToolCall {
tool_name: "browser_snapshot".to_string(),
arguments: None,
output: None,
success: Some(true),
error: None,
metadata: None,
},
});
detail.items.push(AgentThreadItem {
id: "gui-smoke-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 6,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:50Z".to_string(),
completed_at: Some("2026-03-27T10:00:50Z".to_string()),
updated_at: "2026-03-27T10:00:50Z".to_string(),
payload: AgentThreadItemPayload::CommandExecution {
command: "npm run verify:gui-smoke".to_string(),
cwd: root.to_string_lossy().to_string(),
aggregated_output: Some("GUI smoke finished successfully".to_string()),
exit_code: Some(0),
error: None,
},
});
}
#[test]
fn should_export_runtime_analysis_handoff_to_workspace() {
let temp_dir = TempDir::new().expect("temp dir");
@@ -1218,6 +1495,10 @@ mod tests {
assert!(brief.contains("pending request:1"));
assert!(brief.contains("证据关联与可观测覆盖"));
assert!(brief.contains("requestTelemetry"));
assert!(brief.contains("结构化验证摘要"));
assert!(brief.contains("当前没有结构化验证摘要"));
assert!(brief.contains("验证失败焦点:无"));
assert!(brief.contains("已恢复结果:无"));
assert!(!brief.contains("requestTelemetry (unlinked)"));
assert!(brief.contains("/workspace/lime"));
@@ -1228,8 +1509,41 @@ mod tests {
assert!(context.contains("\"observability\""));
assert!(context.contains("\"correlationKeys\""));
assert!(context.contains("\"gapSignals\""));
assert!(context.contains("\"verificationFailureOutcomes\": []"));
assert!(context.contains("\"verificationRecoveredOutcomes\": []"));
assert!(context.contains("\"matchedRequestCount\": 1"));
assert!(context.contains("/workspace/lime"));
assert!(!context.contains(temp_dir.path().to_string_lossy().as_ref()));
}
#[test]
fn should_include_structured_verification_summary_in_analysis_brief_when_available() {
let temp_dir = TempDir::new().expect("temp dir");
let mut detail = build_detail();
let thread_read = build_thread_read();
write_request_telemetry_fixture(temp_dir.path());
seed_recovered_verification(&mut detail, temp_dir.path());
export_runtime_analysis_handoff(&detail, &thread_read, temp_dir.path()).expect("export");
let brief_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/analysis/analysis-brief.md");
let context_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/analysis/analysis-context.json");
let brief = fs::read_to_string(brief_path).expect("brief");
assert!(brief.contains("结构化验证摘要"));
assert!(brief.contains("Artifact 校验:`已恢复`"));
assert!(brief.contains("记录 1 · issues 1 · repaired 1 · fallback 0"));
assert!(brief.contains("浏览器验证:`通过`"));
assert!(brief.contains("GUI Smoke:`通过`"));
assert!(brief.contains("已恢复结果:Artifact 校验已恢复 1 个产物,fallback 0 次。"));
let context = fs::read_to_string(context_path).expect("context");
assert!(context.contains("\"verificationSummary\": {"));
assert!(context.contains("\"verificationRecoveredOutcomes\": ["));
assert!(context.contains("\"outcome\": \"recovered\""));
}
}
@@ -46,7 +46,7 @@ pub struct RuntimeEvidenceArtifact {
pub bytes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeEvidencePackExportResult {
pub session_id: String,
@@ -64,9 +64,29 @@ pub struct RuntimeEvidencePackExportResult {
pub queued_turn_count: usize,
pub recent_artifact_count: usize,
pub known_gaps: Vec<String>,
pub observability_summary: Value,
pub artifacts: Vec<RuntimeEvidenceArtifact>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuntimeVerificationOutcome {
Success,
BlockingFailure,
AdvisoryFailure,
Recovered,
}
impl RuntimeVerificationOutcome {
const fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::BlockingFailure => "blocking_failure",
Self::AdvisoryFailure => "advisory_failure",
Self::Recovered => "recovered",
}
}
}
#[derive(Debug, Clone, PartialEq)]
struct RuntimeRecentArtifact {
path: String,
@@ -240,6 +260,7 @@ pub fn export_runtime_evidence_pack(
queued_turn_count: thread_read.queued_turns.len(),
recent_artifact_count: recent_artifact_paths.len(),
known_gaps,
observability_summary,
artifacts,
})
}
@@ -1138,6 +1159,9 @@ fn build_observability_verification_summary_json(
verification: &RuntimeEvidenceVerificationSummary,
) -> Option<Value> {
let mut payload = Map::new();
let mut blocking_failure = Vec::new();
let mut advisory_failure = Vec::new();
let mut recovered = Vec::new();
if verification.artifact_validator.applicable {
let issue_count = verification
@@ -1174,15 +1198,41 @@ fn build_observability_verification_summary_json(
.unwrap_or(false)
})
.count();
let record_count = verification.artifact_validator.records.len();
let outcome = if issue_count == 0 {
if repaired_count > 0 || fallback_used_count > 0 {
RuntimeVerificationOutcome::Recovered
} else {
RuntimeVerificationOutcome::Success
}
} else if record_count > 0 && repaired_count == record_count {
RuntimeVerificationOutcome::Recovered
} else {
RuntimeVerificationOutcome::BlockingFailure
};
match outcome {
RuntimeVerificationOutcome::BlockingFailure => blocking_failure.push(format!(
"Artifact 校验存在 {} 条未恢复 issues。",
issue_count
)),
RuntimeVerificationOutcome::Recovered => recovered.push(format!(
"Artifact 校验已恢复 {} 个产物,fallback {} 次。",
repaired_count, fallback_used_count
)),
RuntimeVerificationOutcome::Success => {}
RuntimeVerificationOutcome::AdvisoryFailure => {}
}
payload.insert(
"artifactValidator".to_string(),
json!({
"applicable": true,
"recordCount": verification.artifact_validator.records.len(),
"recordCount": record_count,
"issueCount": issue_count,
"repairedCount": repaired_count,
"fallbackUsedCount": fallback_used_count
"fallbackUsedCount": fallback_used_count,
"outcome": outcome.as_str()
}),
);
}
@@ -1210,6 +1260,24 @@ fn build_observability_verification_summary_json(
None => unknown_count += 1,
}
}
let outcome = if failure_count > 0 {
RuntimeVerificationOutcome::BlockingFailure
} else if unknown_count > 0 {
RuntimeVerificationOutcome::AdvisoryFailure
} else {
RuntimeVerificationOutcome::Success
};
match outcome {
RuntimeVerificationOutcome::BlockingFailure => {
blocking_failure.push(format!("浏览器验证存在 {} 条失败线索。", failure_count))
}
RuntimeVerificationOutcome::AdvisoryFailure => {
advisory_failure.push(format!("浏览器验证仍有 {} 条未判定线索。", unknown_count))
}
RuntimeVerificationOutcome::Success => {}
RuntimeVerificationOutcome::Recovered => {}
}
payload.insert(
"browserVerification".to_string(),
@@ -1218,7 +1286,8 @@ fn build_observability_verification_summary_json(
"successCount": success_count,
"failureCount": failure_count,
"unknownCount": unknown_count,
"latestUpdatedAt": latest_updated_at
"latestUpdatedAt": latest_updated_at,
"outcome": outcome.as_str()
}),
);
}
@@ -1231,6 +1300,18 @@ fn build_observability_verification_summary_json(
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
let passed = exit_code == Some(0) && !has_error;
let outcome = if passed {
RuntimeVerificationOutcome::Success
} else {
RuntimeVerificationOutcome::BlockingFailure
};
if !passed {
let exit_code_text = exit_code
.map(|value| value.to_string())
.unwrap_or_else(|| "未知".to_string());
blocking_failure.push(format!("GUI smoke 未通过,exit_code={}。", exit_code_text));
}
payload.insert(
"guiSmoke".to_string(),
@@ -1239,11 +1320,35 @@ fn build_observability_verification_summary_json(
"exitCode": exit_code,
"passed": passed,
"updatedAt": gui_smoke.get("updatedAt").cloned().unwrap_or(Value::Null),
"hasOutputPreview": gui_smoke.get("outputPreview").is_some()
"hasOutputPreview": gui_smoke.get("outputPreview").is_some(),
"outcome": outcome.as_str()
}),
);
}
if !blocking_failure.is_empty() || !advisory_failure.is_empty() || !recovered.is_empty() {
payload.insert(
"observabilityVerificationOutcomes".to_string(),
json!({
"blockingFailure": blocking_failure,
"advisoryFailure": advisory_failure,
"recovered": recovered
}),
);
payload.insert(
"focusVerificationFailureOutcomes".to_string(),
json!(blocking_failure
.iter()
.chain(advisory_failure.iter())
.cloned()
.collect::<Vec<_>>()),
);
payload.insert(
"focusVerificationRecoveredOutcomes".to_string(),
json!(recovered),
);
}
(!payload.is_empty()).then(|| Value::Object(payload))
}
@@ -1731,6 +1836,13 @@ mod tests {
assert_eq!(result.queued_turn_count, 1);
assert_eq!(result.recent_artifact_count, 1);
assert!(result.known_gaps.is_empty());
assert_eq!(
result
.observability_summary
.get("schemaVersion")
.and_then(Value::as_str),
Some("v1")
);
let summary_path = temp_dir
.path()
@@ -1869,6 +1981,10 @@ mod tests {
.known_gaps
.iter()
.all(|gap| !gap.contains("ArtifactDocument")));
assert!(result
.observability_summary
.get("verificationSummary")
.is_some());
let runtime_path = temp_dir
.path()
@@ -1888,6 +2004,9 @@ mod tests {
assert!(runtime.contains("\"repairedCount\": 1"));
assert!(runtime.contains("\"successCount\": 1"));
assert!(runtime.contains("\"passed\": true"));
assert!(runtime.contains("\"outcome\": \"recovered\""));
assert!(runtime.contains("\"outcome\": \"success\""));
assert!(runtime.contains("\"focusVerificationRecoveredOutcomes\""));
let artifacts = fs::read_to_string(artifacts_path).expect("artifacts");
assert!(artifacts.contains("\"verification\""));
@@ -11,6 +11,7 @@ use crate::services::runtime_analysis_handoff_service::{
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::path::Path;
@@ -59,6 +60,7 @@ pub struct RuntimeReviewDecisionTemplateExportResult {
pub pending_request_count: usize,
pub queued_turn_count: usize,
pub default_decision_status: String,
pub verification_summary: Option<Value>,
pub decision: RuntimeReviewDecisionContent,
pub decision_status_options: Vec<String>,
pub risk_level_options: Vec<String>,
@@ -112,6 +114,9 @@ struct ReviewDecisionContext {
evidence_pack_relative_root: String,
replay_case_relative_root: String,
analysis_artifacts: Vec<ReviewDecisionArtifactReference>,
verification_summary: Option<Value>,
verification_failure_outcomes: Vec<String>,
verification_recovered_outcomes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -188,9 +193,15 @@ fn sync_runtime_review_decision(
})?;
let review_checklist = build_review_checklist();
let verification_context = load_analysis_verification_context(&analysis)?;
let existing_decision = load_existing_review_decision_document(&review_absolute_root)?
.map(|document| document.decision);
let mut document = build_review_decision_document(&analysis, &exported_at, &review_checklist);
let mut document = build_review_decision_document(
&analysis,
&exported_at,
&review_checklist,
&verification_context,
);
let decision = decision_override
.or(existing_decision)
.unwrap_or_else(|| document.decision.clone());
@@ -235,6 +246,7 @@ fn sync_runtime_review_decision(
pending_request_count: analysis.pending_request_count,
queued_turn_count: analysis.queued_turn_count,
default_decision_status: DEFAULT_DECISION_STATUS.to_string(),
verification_summary: document.review_context.verification_summary.clone(),
decision: document.decision,
decision_status_options: document.decision_status_options,
risk_level_options: document.risk_level_options,
@@ -248,7 +260,10 @@ fn build_review_decision_document(
analysis: &RuntimeAnalysisHandoffExportResult,
exported_at: &str,
review_checklist: &[String],
verification_context: &ReviewDecisionVerificationContext,
) -> ReviewDecisionDocument {
let suggested_actions = build_review_decision_suggested_actions(verification_context);
ReviewDecisionDocument {
schema_version: "v1".to_string(),
contract_shape: "lime_review_decision_template".to_string(),
@@ -288,6 +303,9 @@ fn build_review_decision_document(
relative_path: artifact.relative_path.clone(),
})
.collect(),
verification_summary: verification_context.summary.clone(),
verification_failure_outcomes: verification_context.failure_outcomes.clone(),
verification_recovered_outcomes: verification_context.recovered_outcomes.clone(),
},
decision: RuntimeReviewDecisionContent {
decision_status: DEFAULT_DECISION_STATUS.to_string(),
@@ -297,8 +315,8 @@ fn build_review_decision_document(
risk_tags: Vec::new(),
human_reviewer: String::new(),
reviewed_at: None,
followup_actions: Vec::new(),
regression_requirements: Vec::new(),
followup_actions: suggested_actions.followup_actions,
regression_requirements: suggested_actions.regression_requirements,
notes: String::new(),
},
decision_status_options: build_decision_status_options(),
@@ -321,6 +339,16 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
.map(|artifact| format!("- `{}`:`{}`", artifact.title, artifact.relative_path))
.collect::<Vec<_>>()
.join("\n");
let verification_summary =
format_markdown_verification_summary(document.review_context.verification_summary.as_ref());
let verification_failure_outcomes = format_markdown_list(
&document.review_context.verification_failure_outcomes,
"- 无",
);
let verification_recovered_outcomes = format_markdown_list(
&document.review_context.verification_recovered_outcomes,
"- 无",
);
let decision_status_options = document
.decision_status_options
.iter()
@@ -362,21 +390,28 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
- 产品承接面:`lime`\n\n\
## 3. 审核清单\n\
{checklist}\n\n\
## 4. 决策状态\n\
## 4. 结构化验证摘要\n\
{verification_summary}\n\n\
## 5. 验证焦点\n\
- 阻塞 / 提示失败:\n\
{verification_failure_outcomes}\n\n\
- 已恢复结果:\n\
{verification_recovered_outcomes}\n\n\
## 6. 决策状态\n\
- 当前值:`{decision_status}`\n\
- 可选值:{decision_status_options}\n\n\
## 5. 决策摘要\n\
## 7. 决策摘要\n\
{decision_summary}\n\n\
## 6. 采用的修复策略\n\
## 8. 采用的修复策略\n\
{chosen_fix_strategy}\n\n\
## 7. 风险等级与标签\n\
## 9. 风险等级与标签\n\
- 风险等级:`{risk_level}`\n\
- 风险标签:{risk_tags}\n\n\
## 8. 回归要求\n\
## 10. 回归要求\n\
{regression_requirements}\n\n\
## 9. 后续动作\n\
## 11. 后续动作\n\
{followup_actions}\n\n\
## 10. 审核备注\n\
## 12. 审核备注\n\
- 审核人:{human_reviewer}\n\
- 审核时间:{reviewed_at}\n\
- 备注:\n{notes}\n",
@@ -408,6 +443,9 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
} else {
checklist
},
verification_summary = verification_summary,
verification_failure_outcomes = verification_failure_outcomes,
verification_recovered_outcomes = verification_recovered_outcomes,
decision_status_options = if decision_status_options.is_empty() {
format!("`{DEFAULT_DECISION_STATUS}`")
} else {
@@ -434,6 +472,8 @@ fn build_review_checklist() -> Vec<String> {
vec![
"先阅读 analysis-brief.md 与 analysis-context.json,再决定是否进入修复。".to_string(),
"确认根因判断引用的是现有证据,而不是外部 AI 的猜测扩写。".to_string(),
"优先核对 verification failure / recovered outcomes,再决定是接受、延后还是补充证据。"
.to_string(),
"确认修复范围仍落在 current 主链,没有把 compat / deprecated 路径重新接回主线。"
.to_string(),
"明确最小回归集合,包括 contract、GUI smoke、Replay 或其它定向验证。".to_string(),
@@ -441,6 +481,79 @@ fn build_review_checklist() -> Vec<String> {
]
}
#[derive(Debug, Clone, Default)]
struct ReviewDecisionVerificationContext {
summary: Option<Value>,
failure_outcomes: Vec<String>,
recovered_outcomes: Vec<String>,
}
#[derive(Debug, Clone, Default)]
struct ReviewDecisionSuggestedActions {
followup_actions: Vec<String>,
regression_requirements: Vec<String>,
}
const REVIEW_VERIFICATION_COMMAND_EVAL: &str = "npm run harness:eval";
const REVIEW_VERIFICATION_COMMAND_TREND: &str = "npm run harness:eval:trend";
const REVIEW_VERIFICATION_COMMAND_GUI_SMOKE: &str = "npm run verify:gui-smoke";
fn load_analysis_verification_context(
analysis: &RuntimeAnalysisHandoffExportResult,
) -> Result<ReviewDecisionVerificationContext, String> {
let analysis_context_relative_path = analysis
.artifacts
.iter()
.find(|artifact| {
matches!(
artifact.kind,
crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind::AnalysisContext
)
})
.map(|artifact| artifact.relative_path.clone());
let Some(relative_path) = analysis_context_relative_path else {
return Ok(ReviewDecisionVerificationContext::default());
};
let absolute_path = Path::new(&analysis.workspace_root)
.join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR));
if !absolute_path.exists() {
return Ok(ReviewDecisionVerificationContext::default());
}
let contents = fs::read_to_string(&absolute_path).map_err(|error| {
format!(
"读取 analysis context 失败 {}: {error}",
absolute_path.display()
)
})?;
let payload = serde_json::from_str::<Value>(&contents).map_err(|error| {
format!(
"解析 analysis context 失败 {}: {error}",
absolute_path.display()
)
})?;
Ok(ReviewDecisionVerificationContext {
summary: payload
.pointer("/observability/summary/verificationSummary")
.cloned()
.or_else(|| {
payload
.pointer("/observability/summary/verification_summary")
.cloned()
}),
failure_outcomes: payload
.pointer("/observability/verificationFailureOutcomes")
.map(value_string_list)
.unwrap_or_default(),
recovered_outcomes: payload
.pointer("/observability/verificationRecoveredOutcomes")
.map(value_string_list)
.unwrap_or_default(),
})
}
fn review_analysis_artifact_kind_key(
kind: &crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind,
) -> &'static str {
@@ -600,6 +713,309 @@ fn normalize_string_list(values: &[String]) -> Vec<String> {
.collect()
}
fn build_review_decision_suggested_actions(
verification_context: &ReviewDecisionVerificationContext,
) -> ReviewDecisionSuggestedActions {
let mut suggested_actions = ReviewDecisionSuggestedActions::default();
if let Some(summary) = verification_context.summary.as_ref() {
if let Some(artifact_validator) =
summary_object_field(summary, "artifactValidator", "artifact_validator")
{
let artifact_outcome = summary_string_field(artifact_validator, "outcome", "outcome");
let artifact_issue_count =
summary_u64_field(artifact_validator, "issueCount", "issue_count").unwrap_or(0);
let artifact_fallback_count = summary_u64_field(
artifact_validator,
"fallbackUsedCount",
"fallback_used_count",
)
.unwrap_or(0);
if matches!(
artifact_outcome,
Some("blocking_failure" | "advisory_failure")
) {
if artifact_issue_count > 0 {
push_unique_string(
&mut suggested_actions.followup_actions,
"回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。",
);
}
if artifact_fallback_count > 0 {
push_unique_string(
&mut suggested_actions.followup_actions,
"补齐 artifact 主路径导出与修复链,减少 fallback_used 持续留在 current 样本。",
);
}
}
if matches!(artifact_outcome, Some("recovered")) {
push_review_verification_eval_commands(
&mut suggested_actions.regression_requirements,
);
push_unique_string(
&mut suggested_actions.followup_actions,
"在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。",
);
}
}
if let Some(browser_verification) =
summary_object_field(summary, "browserVerification", "browser_verification")
{
let browser_outcome = summary_string_field(browser_verification, "outcome", "outcome");
if matches!(browser_outcome, Some("blocking_failure")) {
push_review_verification_eval_commands(
&mut suggested_actions.regression_requirements,
);
push_unique_string(
&mut suggested_actions.followup_actions,
"回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。",
);
}
if matches!(browser_outcome, Some("advisory_failure")) {
push_unique_string(
&mut suggested_actions.followup_actions,
"回看 browser verification 导出链,确保 evidence pack / replay / analysis handoff 写出明确 success 或 failure,而不是 unknown。",
);
}
if matches!(browser_outcome, Some("success" | "recovered")) {
push_review_verification_eval_commands(
&mut suggested_actions.regression_requirements,
);
push_unique_string(
&mut suggested_actions.followup_actions,
"把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。",
);
}
}
if let Some(gui_smoke) = summary_object_field(summary, "guiSmoke", "gui_smoke") {
let gui_smoke_outcome = summary_string_field(gui_smoke, "outcome", "outcome");
if matches!(gui_smoke_outcome, Some("blocking_failure")) {
push_review_verification_eval_commands(
&mut suggested_actions.regression_requirements,
);
push_unique_string(
&mut suggested_actions.regression_requirements,
REVIEW_VERIFICATION_COMMAND_GUI_SMOKE,
);
push_unique_string(
&mut suggested_actions.followup_actions,
"优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。",
);
}
if matches!(gui_smoke_outcome, Some("success" | "recovered")) {
push_review_verification_eval_commands(
&mut suggested_actions.regression_requirements,
);
push_unique_string(
&mut suggested_actions.regression_requirements,
REVIEW_VERIFICATION_COMMAND_GUI_SMOKE,
);
push_unique_string(
&mut suggested_actions.followup_actions,
"主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。",
);
}
}
}
if suggested_actions.followup_actions.is_empty()
&& !verification_context.failure_outcomes.is_empty()
{
push_unique_string(
&mut suggested_actions.followup_actions,
"先对照 analysis-context.json / evidence/runtime.json 核对当前验证失败焦点,再决定是继续修复还是补证据。",
);
push_unique_string(
&mut suggested_actions.regression_requirements,
"按 replay case 复现问题并确认修复后行为与预期一致。",
);
}
if suggested_actions.followup_actions.is_empty()
&& !verification_context.recovered_outcomes.is_empty()
{
push_unique_string(
&mut suggested_actions.followup_actions,
"把 recovered outcome 回挂到 replay / smoke / evidence 主链,避免后续审核再次把已恢复结果误判成当前阻塞。",
);
}
suggested_actions
}
fn push_review_verification_eval_commands(target: &mut Vec<String>) {
push_unique_string(target, REVIEW_VERIFICATION_COMMAND_EVAL);
push_unique_string(target, REVIEW_VERIFICATION_COMMAND_TREND);
}
fn value_string_list(value: &Value) -> Vec<String> {
value
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.collect()
}
fn push_unique_string(target: &mut Vec<String>, value: &str) {
let normalized = value.trim();
if normalized.is_empty() || target.iter().any(|item| item == normalized) {
return;
}
target.push(normalized.to_string());
}
fn format_markdown_verification_summary(summary: Option<&Value>) -> String {
let Some(summary) = summary else {
return "- 当前没有结构化验证摘要。".to_string();
};
let mut lines = Vec::new();
if let Some(artifact_validator) =
summary_object_field(summary, "artifactValidator", "artifact_validator")
{
lines.push(format!(
"- Artifact 校验:`{}`|{}",
format_verification_outcome_label(summary_string_field(
artifact_validator,
"outcome",
"outcome",
)),
describe_artifact_validator_summary(artifact_validator),
));
}
if let Some(browser_verification) =
summary_object_field(summary, "browserVerification", "browser_verification")
{
lines.push(format!(
"- 浏览器验证:`{}`|{}",
format_verification_outcome_label(summary_string_field(
browser_verification,
"outcome",
"outcome",
)),
describe_browser_verification_summary(browser_verification),
));
}
if let Some(gui_smoke) = summary_object_field(summary, "guiSmoke", "gui_smoke") {
lines.push(format!(
"- GUI Smoke:`{}`|{}",
format_verification_outcome_label(summary_string_field(
gui_smoke, "outcome", "outcome",
)),
describe_gui_smoke_summary(gui_smoke),
));
}
if lines.is_empty() {
"- 当前没有结构化验证摘要。".to_string()
} else {
lines.join("\n")
}
}
fn summary_object_field<'a>(
summary: &'a Value,
camel_case: &str,
snake_case: &str,
) -> Option<&'a Value> {
summary
.get(camel_case)
.or_else(|| summary.get(snake_case))
.filter(|value| value.is_object())
}
fn summary_string_field<'a>(
summary: &'a Value,
camel_case: &str,
snake_case: &str,
) -> Option<&'a str> {
summary
.get(camel_case)
.or_else(|| summary.get(snake_case))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn summary_u64_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option<u64> {
summary
.get(camel_case)
.or_else(|| summary.get(snake_case))
.and_then(Value::as_u64)
}
fn summary_bool_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option<bool> {
summary
.get(camel_case)
.or_else(|| summary.get(snake_case))
.and_then(Value::as_bool)
}
fn format_verification_outcome_label(value: Option<&str>) -> &'static str {
match value {
Some("success") => "通过",
Some("blocking_failure") => "阻塞失败",
Some("advisory_failure") => "提示失败",
Some("recovered") => "已恢复",
_ => "未定",
}
}
fn describe_artifact_validator_summary(summary: &Value) -> String {
if summary_bool_field(summary, "applicable", "applicable") == Some(false) {
return "当前没有适用的 Artifact 校验。".to_string();
}
format!(
"记录 {} · issues {} · repaired {} · fallback {}",
summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0),
summary_u64_field(summary, "issueCount", "issue_count").unwrap_or(0),
summary_u64_field(summary, "repairedCount", "repaired_count").unwrap_or(0),
summary_u64_field(summary, "fallbackUsedCount", "fallback_used_count").unwrap_or(0),
)
}
fn describe_browser_verification_summary(summary: &Value) -> String {
format!(
"记录 {} · 成功 {} · 失败 {} · 未判定 {}",
summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0),
summary_u64_field(summary, "successCount", "success_count").unwrap_or(0),
summary_u64_field(summary, "failureCount", "failure_count").unwrap_or(0),
summary_u64_field(summary, "unknownCount", "unknown_count").unwrap_or(0),
)
}
fn describe_gui_smoke_summary(summary: &Value) -> String {
let status = summary_string_field(summary, "status", "status").unwrap_or("未知");
let exit_code = summary_u64_field(summary, "exitCode", "exit_code")
.map(|value| value.to_string())
.unwrap_or_else(|| "未知".to_string());
let passed = summary_bool_field(summary, "passed", "passed").unwrap_or(false);
format!(
"状态 {} · exit {} · {}",
status,
exit_code,
if passed { "已通过" } else { "未通过" }
)
}
fn format_markdown_text_block(value: &str, placeholder: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -790,6 +1206,182 @@ mod tests {
}
}
fn seed_recovered_verification(detail: &mut SessionDetail, root: &std::path::Path) {
let artifact_relative_path = ".lime/artifacts/thread-1/report.artifact.json";
let artifact_absolute_path =
root.join(artifact_relative_path.replace('/', std::path::MAIN_SEPARATOR_STR));
fs::create_dir_all(
artifact_absolute_path
.parent()
.expect("artifact path should have parent"),
)
.expect("create artifact dir");
fs::write(
&artifact_absolute_path,
serde_json::to_string_pretty(&json!({
"schemaVersion": crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION,
"title": "Harness Evidence",
"kind": "analysis",
"status": "ready",
"blocks": [
{
"id": "block-1",
"type": "rich_text",
"content": "test"
}
],
"metadata": {
"artifactValidationIssues": ["title 缺失或为空,已使用兜底标题。"],
"artifactValidationRepaired": true,
"artifactFallbackUsed": false
}
}))
.expect("serialize artifact document"),
)
.expect("write artifact document");
detail.items.push(AgentThreadItem {
id: "artifact-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: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: artifact_relative_path.to_string(),
source: "artifact_snapshot".to_string(),
content: None,
metadata: None,
},
});
detail.items.push(AgentThreadItem {
id: "browser-tool-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 4,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:40Z".to_string(),
completed_at: Some("2026-03-27T10:00:40Z".to_string()),
updated_at: "2026-03-27T10:00:40Z".to_string(),
payload: AgentThreadItemPayload::ToolCall {
tool_name: "browser_snapshot".to_string(),
arguments: None,
output: None,
success: Some(true),
error: None,
metadata: None,
},
});
detail.items.push(AgentThreadItem {
id: "gui-smoke-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 5,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:50Z".to_string(),
completed_at: Some("2026-03-27T10:00:50Z".to_string()),
updated_at: "2026-03-27T10:00:50Z".to_string(),
payload: AgentThreadItemPayload::CommandExecution {
command: "npm run verify:gui-smoke".to_string(),
cwd: root.to_string_lossy().to_string(),
aggregated_output: Some("GUI smoke finished successfully".to_string()),
exit_code: Some(0),
error: None,
},
});
}
fn seed_blocking_verification(detail: &mut SessionDetail, root: &std::path::Path) {
let artifact_relative_path = ".lime/artifacts/thread-1/report-blocking.artifact.json";
let artifact_absolute_path =
root.join(artifact_relative_path.replace('/', std::path::MAIN_SEPARATOR_STR));
fs::create_dir_all(
artifact_absolute_path
.parent()
.expect("artifact path should have parent"),
)
.expect("create artifact dir");
fs::write(
&artifact_absolute_path,
serde_json::to_string_pretty(&json!({
"schemaVersion": crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION,
"title": "Harness Evidence Blocking",
"kind": "analysis",
"status": "ready",
"blocks": [
{
"id": "block-1",
"type": "rich_text",
"content": "test"
}
],
"metadata": {
"artifactValidationIssues": ["title 缺失或为空。"],
"artifactValidationRepaired": false,
"artifactFallbackUsed": false
}
}))
.expect("serialize artifact document"),
)
.expect("write artifact document");
detail.items.push(AgentThreadItem {
id: "artifact-blocking-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: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: artifact_relative_path.to_string(),
source: "artifact_snapshot".to_string(),
content: None,
metadata: None,
},
});
detail.items.push(AgentThreadItem {
id: "browser-tool-blocking-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 4,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:40Z".to_string(),
completed_at: Some("2026-03-27T10:00:40Z".to_string()),
updated_at: "2026-03-27T10:00:40Z".to_string(),
payload: AgentThreadItemPayload::ToolCall {
tool_name: "browser_snapshot".to_string(),
arguments: None,
output: None,
success: Some(false),
error: Some("browser step failed".to_string()),
metadata: None,
},
});
detail.items.push(AgentThreadItem {
id: "gui-smoke-blocking-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 5,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:50Z".to_string(),
completed_at: Some("2026-03-27T10:00:50Z".to_string()),
updated_at: "2026-03-27T10:00:50Z".to_string(),
payload: AgentThreadItemPayload::CommandExecution {
command: "npm run verify:gui-smoke".to_string(),
cwd: root.to_string_lossy().to_string(),
aggregated_output: Some("GUI smoke failed".to_string()),
exit_code: Some(1),
error: Some("smoke failed".to_string()),
},
});
}
#[test]
fn should_export_runtime_review_decision_template_to_workspace() {
let temp_dir = TempDir::new().expect("temp dir");
@@ -808,6 +1400,7 @@ mod tests {
assert_eq!(result.artifacts.len(), 2);
assert_eq!(result.analysis_artifacts.len(), 2);
assert!(!result.review_checklist.is_empty());
assert!(result.verification_summary.is_none());
let markdown_path = temp_dir
.path()
@@ -824,12 +1417,120 @@ mod tests {
assert!(markdown.contains("analysis-brief.md"));
assert!(markdown.contains("aster-rust"));
assert!(markdown.contains("pending_review"));
assert!(markdown.contains("结构化验证摘要"));
assert!(markdown.contains("当前没有结构化验证摘要"));
assert!(markdown.contains("阻塞 / 提示失败"));
assert!(markdown.contains("已恢复结果"));
assert!(markdown.contains("- 无"));
assert!(result.decision.followup_actions.is_empty());
assert!(result.decision.regression_requirements.is_empty());
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\""));
assert!(json.contains("\"verificationSummary\": null"));
assert!(json.contains("\"verificationFailureOutcomes\": []"));
assert!(json.contains("\"verificationRecoveredOutcomes\": []"));
}
#[test]
fn should_include_verification_outcomes_in_review_decision_when_available() {
let temp_dir = TempDir::new().expect("temp dir");
let mut detail = build_detail();
let thread_read = build_thread_read();
seed_recovered_verification(&mut detail, temp_dir.path());
let result =
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
.expect("export");
assert!(result.verification_summary.is_some());
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");
let markdown = fs::read_to_string(markdown_path).expect("markdown");
assert!(markdown.contains("结构化验证摘要"));
assert!(markdown.contains("Artifact 校验:`已恢复`"));
assert!(markdown.contains("记录 1 · issues 1 · repaired 1 · fallback 0"));
assert!(markdown.contains("Artifact 校验已恢复 1 个产物,fallback 0 次。"));
assert!(markdown.contains("浏览器验证:`通过`"));
assert!(markdown.contains("GUI Smoke:`通过`"));
assert!(markdown.contains("- 无"));
assert_eq!(
result.decision.followup_actions,
vec![
"在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。"
.to_string(),
"把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。"
.to_string(),
"主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。"
.to_string(),
]
);
assert_eq!(
result.decision.regression_requirements,
vec![
"npm run harness:eval".to_string(),
"npm run harness:eval:trend".to_string(),
"npm run verify:gui-smoke".to_string(),
]
);
let json = fs::read_to_string(json_path).expect("json");
assert!(json.contains("\"verificationSummary\": {"));
assert!(json.contains("\"verificationFailureOutcomes\": []"));
assert!(json.contains(
"\"verificationRecoveredOutcomes\": [\n \"Artifact 校验已恢复 1 个产物,fallback 0 次。\"\n ]"
));
assert!(json.contains("\"outcome\": \"recovered\""));
}
#[test]
fn should_seed_followup_actions_from_blocking_verification_outcomes() {
let temp_dir = TempDir::new().expect("temp dir");
let mut detail = build_detail();
let thread_read = build_thread_read();
seed_blocking_verification(&mut detail, temp_dir.path());
let result =
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
.expect("export");
let markdown_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.md");
let markdown = fs::read_to_string(markdown_path).expect("markdown");
assert_eq!(
result.decision.followup_actions,
vec![
"回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。"
.to_string(),
"回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。"
.to_string(),
"优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。"
.to_string(),
]
);
assert_eq!(
result.decision.regression_requirements,
vec![
"npm run harness:eval".to_string(),
"npm run harness:eval:trend".to_string(),
"npm run verify:gui-smoke".to_string(),
]
);
assert!(markdown.contains(
"回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。"
));
assert!(markdown.contains("npm run verify:gui-smoke"));
}
#[test]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.9.0",
"version": "1.10.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev:web-bridge",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.9.0",
"version": "1.10.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -77,6 +77,7 @@ impl SessionStore for TestSessionStore {
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_creation_input_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
-13
View File
@@ -29,10 +29,6 @@ vi.mock("./channels/ImConfigPage", () => ({
ImConfigPage: () => <div data-testid="im-config-page" />,
}));
vi.mock("./workspace/video/VideoPage", () => ({
VideoPage: () => <div data-testid="video-page" />,
}));
vi.mock("./settings-v2", () => ({
SettingsPageV2: () => <div data-testid="settings-page" />,
}));
@@ -218,15 +214,6 @@ describe("AppPageContent", () => {
).not.toBeNull();
});
it("video 页面应渲染现役视频工作台入口", async () => {
const container = renderContent("video");
await flushEffects();
expect(
container.querySelector('[data-testid="video-page"]'),
).not.toBeNull();
});
it("settings 页面应渲染设置页入口", async () => {
const container = renderContent("settings");
await flushEffects();
-100
View File
@@ -26,15 +26,6 @@ const PageWrapper = styled.div<{ $isActive: boolean }>`
display: ${(props) => (props.$isActive ? "block" : "none")};
`;
const FullscreenWrapper = styled.div<{ $isActive: boolean }>`
flex: 1;
min-height: 0;
overflow: hidden;
display: ${(props) => (props.$isActive ? "flex" : "none")};
flex-direction: column;
position: relative;
`;
const columnPageStyle = {
flex: 1,
minHeight: 0,
@@ -42,11 +33,6 @@ const columnPageStyle = {
flexDirection: "column",
} as const;
const ToolsPage = lazy(() =>
import("./tools/ToolsPage").then((module) => ({
default: module.ToolsPage,
})),
);
const ResourcesPage = lazy(() =>
import("./resources").then((module) => ({
default: module.ResourcesPage,
@@ -62,16 +48,6 @@ const PluginsPage = lazy(() =>
default: module.PluginsPage,
})),
);
const ImageGenPage = lazy(() =>
import("./image-gen").then((module) => ({
default: module.ImageGenPage,
})),
);
const VideoPage = lazy(() =>
import("./workspace/video/VideoPage").then((module) => ({
default: module.VideoPage,
})),
);
const AutomationPage = lazy(() =>
import("./automation").then((module) => ({
default: module.AutomationPage,
@@ -97,26 +73,6 @@ const BrowserRuntimeWorkspace = lazy(() =>
default: module.BrowserRuntimeWorkspace,
})),
);
const TerminalWorkspace = lazy(() =>
import("./terminal").then((module) => ({
default: module.TerminalWorkspace,
})),
);
const SysinfoView = lazy(() =>
import("./terminal").then((module) => ({
default: module.SysinfoView,
})),
);
const FileBrowserView = lazy(() =>
import("./terminal").then((module) => ({
default: module.FileBrowserView,
})),
);
const WebView = lazy(() =>
import("./terminal").then((module) => ({
default: module.WebView,
})),
);
const AgentChatPage = lazy(() =>
import("./agent/chat").then((module) => ({
default: module.AgentChatPage,
@@ -136,22 +92,6 @@ export function AppPageContent({
onNavigate,
onAgentHasMessagesChange,
}: AppPageContentProps) {
if (currentPage === "image-gen") {
return (
<div style={columnPageStyle}>
<ImageGenPage onNavigate={onNavigate} />
</div>
);
}
if (currentPage === "video") {
return (
<div style={columnPageStyle}>
<VideoPage />
</div>
);
}
if (currentPage === "automation") {
return (
<div style={columnPageStyle}>
@@ -215,38 +155,6 @@ export function AppPageContent({
);
}
if (currentPage === "terminal") {
return (
<div style={columnPageStyle}>
<TerminalWorkspace onNavigate={onNavigate} isActive />
</div>
);
}
if (currentPage === "sysinfo") {
return (
<FullscreenWrapper $isActive={true}>
<SysinfoView />
</FullscreenWrapper>
);
}
if (currentPage === "files") {
return (
<FullscreenWrapper $isActive={true}>
<FileBrowserView />
</FullscreenWrapper>
);
}
if (currentPage === "web") {
return (
<FullscreenWrapper $isActive={true}>
<WebView />
</FullscreenWrapper>
);
}
if (currentPage === "resources") {
return (
<div style={columnPageStyle}>
@@ -255,14 +163,6 @@ export function AppPageContent({
);
}
if (currentPage === "tools") {
return (
<PageWrapper $isActive={true}>
<ToolsPage onNavigate={onNavigate} />
</PageWrapper>
);
}
if (currentPage === "browser-runtime") {
const browserRuntimeParams = pageParams as BrowserRuntimePageParams;
-2
View File
@@ -19,8 +19,6 @@ React 组件层,包含 UI 组件和业务组件。
- `smart-input/` - 截图/语音浮窗共享组件(当前仅保留快捷键设置)
- `settings-v2/` - 设置页面组件(当前主实现)
- `skills/` - 技能管理组件
- `terminal/` - 内置终端组件(使用 Tauri Commands)
- `tools/` - 工具页面组件
- `widgets/` - 右侧小部件栏组件(移植自 Waveterm)
- `ui/` - 通用 UI 组件(按钮、输入框等)
- `websocket/` - WebSocket 管理组件
+519 -98
View File
@@ -62,6 +62,7 @@ import {
getOrCreateDefaultProject,
type Project,
} from "@/lib/api/project";
import { executionRunGetGeneralWorkbenchState } from "@/lib/api/executionRun";
import {
cancelMediaTaskArtifact,
createImageGenerationTaskArtifact,
@@ -127,11 +128,8 @@ import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToo
import { useLimeSkills } from "./hooks/useLimeSkills";
import { useServiceSkills } from "./service-skills/useServiceSkills";
import { useWorkspaceProjectSelection } from "./hooks/useWorkspaceProjectSelection";
import { useBootstrapDispatchPreview } from "./hooks/useBootstrapDispatchPreview";
import type { HandleSendOptions } from "./hooks/handleSendTypes";
import { useRuntimeTeamFormation } from "./hooks/useRuntimeTeamFormation";
import { useGeneralWorkbenchEntryPrompt } from "./hooks/useGeneralWorkbenchEntryPrompt";
import { useGeneralWorkbenchEntryPromptActions } from "./hooks/useGeneralWorkbenchEntryPromptActions";
import { useGeneralWorkbenchSendBoundary } from "./hooks/useGeneralWorkbenchSendBoundary";
import { mergeThreadItems } from "./utils/threadTimelineView";
import { openCanvasForReason } from "./workspace/canvasOpenPolicy";
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
@@ -170,12 +168,19 @@ import { useWorkspaceVideoTaskActionRuntime } from "./workspace/useWorkspaceVide
import { useWorkspaceSessionRestore } from "./workspace/useWorkspaceSessionRestore";
import { useWorkspaceResetRuntime } from "./workspace/useWorkspaceResetRuntime";
import { useWorkspaceSendActions } from "./workspace/useWorkspaceSendActions";
import {
buildGeneralWorkbenchSendBoundaryState,
buildGeneralWorkbenchResumePromptFromRunState,
buildInitialDispatchKey,
type GeneralWorkbenchEntryPromptState,
type GeneralWorkbenchSendBoundaryState,
type InitialDispatchPreviewSnapshot,
} from "./workspace/workspaceSendHelpers";
import { useWorkspaceTeamSessionControlRuntime } from "./workspace/useWorkspaceTeamSessionControlRuntime";
import { useWorkspaceGeneralWorkbenchScaffoldRuntime } from "./workspace/useWorkspaceGeneralWorkbenchScaffoldRuntime";
import { useWorkspaceTopicSwitch } from "./workspace/useWorkspaceTopicSwitch";
import { useWorkspaceA2UIRuntime } from "./workspace/useWorkspaceA2UIRuntime";
import { useWorkspaceSceneGateRuntime } from "./workspace/useWorkspaceSceneGateRuntime";
import { useWorkspaceAutoGuideRuntime } from "./workspace/useWorkspaceAutoGuideRuntime";
import { useWorkspaceGeneralWorkbenchSidebarRuntime } from "./workspace/useWorkspaceGeneralWorkbenchSidebarRuntime";
import { useWorkspaceGeneralWorkbenchRuntime } from "./workspace/useWorkspaceGeneralWorkbenchRuntime";
import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime";
@@ -204,6 +209,7 @@ import { resolveSiteSavedContentTargetFromRunResult } from "./utils/siteToolResu
import type { ArtifactDocumentV1 } from "@/lib/artifact-document";
import type { ArtifactTimelineOpenTarget } from "./utils/artifactTimelineNavigation";
import { createUnifiedMemory } from "@/lib/api/unifiedMemory";
import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt";
import {
createInitialSessionImageWorkbenchState,
type SessionImageWorkbenchState,
@@ -212,6 +218,7 @@ import {
SOCIAL_ARTICLE_SKILL_KEY,
GENERAL_WORKBENCH_HISTORY_PAGE_SIZE,
applyBackendGeneralWorkbenchDocumentState,
isCanvasStateEmpty,
isCorruptedGeneralWorkbenchDocumentContent,
isSyncContentEmpty,
readPersistedGeneralWorkbenchDocument,
@@ -2483,41 +2490,224 @@ export function AgentChatWorkspace({
// 用于追踪是否已触发过 AI 引导
const hasTriggeredGuide = useRef(false);
const consumedInitialPromptRef = useRef<string | null>(null);
const {
const consumedInitialPromptKey = consumedInitialPromptRef.current;
const [bootstrapDispatchSnapshot, setBootstrapDispatchSnapshot] =
useState<InitialDispatchPreviewSnapshot | null>(null);
const [generalWorkbenchEntryPrompt, setGeneralWorkbenchEntryPrompt] =
useState<GeneralWorkbenchEntryPromptState | null>(null);
const [generalWorkbenchEntryCheckPending, setGeneralWorkbenchEntryCheckPending] =
useState(false);
const hydratedPromptSignatureRef = useRef<string | null>(null);
const dismissedPromptSignatureRef = useRef<string | null>(null);
const initialDispatchKey = useMemo(
() => buildInitialDispatchKey(initialUserPrompt, initialUserImages),
[initialUserImages, initialUserPrompt],
);
useEffect(() => {
if (!initialDispatchKey) {
return;
}
setBootstrapDispatchSnapshot({
key: initialDispatchKey,
prompt: initialUserPrompt,
images: initialUserImages || [],
});
}, [initialDispatchKey, initialUserImages, initialUserPrompt]);
useEffect(() => {
if (messages.length > 0) {
setBootstrapDispatchSnapshot(null);
return;
}
if (!initialDispatchKey && !isSending && queuedTurns.length === 0) {
setBootstrapDispatchSnapshot(null);
}
}, [initialDispatchKey, isSending, messages.length, queuedTurns.length]);
const activeBootstrapDispatch = useMemo(() => {
if (
initialDispatchKey &&
((initialUserPrompt || "").trim() || (initialUserImages || []).length > 0)
) {
return {
key: initialDispatchKey,
prompt: initialUserPrompt,
images: initialUserImages || [],
};
}
return bootstrapDispatchSnapshot;
}, [
bootstrapDispatchSnapshot,
initialDispatchKey,
isBootstrapDispatchPending,
bootstrapDispatchPreviewMessages,
} = useBootstrapDispatchPreview({
initialUserPrompt,
initialUserImages,
messagesCount: messages.length,
isSending,
queuedTurnCount: queuedTurns.length,
consumedInitialPromptKey: consumedInitialPromptRef.current,
shouldUseCompactGeneralWorkbench,
});
const {
generalWorkbenchEntryPrompt,
generalWorkbenchEntryCheckPending,
clearGeneralWorkbenchEntryPrompt,
dismissGeneralWorkbenchEntryPrompt,
} = useGeneralWorkbenchEntryPrompt({
activeTheme,
contentId: contentId ?? undefined,
sessionId: sessionId ?? undefined,
isThemeWorkbench,
initialUserPrompt,
]);
const isBootstrapDispatchPending =
activeBootstrapDispatch !== null &&
consumedInitialPromptKey !== activeBootstrapDispatch.key;
const bootstrapDispatchPreview =
!shouldUseCompactGeneralWorkbench &&
activeBootstrapDispatch &&
messages.length === 0 &&
(isSending || queuedTurns.length > 0)
? activeBootstrapDispatch
: null;
useEffect(() => {
hydratedPromptSignatureRef.current = null;
dismissedPromptSignatureRef.current = null;
setGeneralWorkbenchEntryPrompt(null);
setGeneralWorkbenchEntryCheckPending(false);
}, [activeTheme, contentId, initialDispatchKey]);
useEffect(() => {
if (shouldUseCompactGeneralWorkbench) {
return;
}
const pendingInitialPrompt = (initialUserPrompt || "").trim();
const pendingInitialImages = initialUserImages || [];
if (
!isThemeWorkbench ||
autoRunInitialPromptOnMount ||
!contentId ||
!initialDispatchKey ||
!pendingInitialPrompt ||
pendingInitialImages.length > 0 ||
messages.length > 0
) {
return;
}
if (
consumedInitialPromptKey === initialDispatchKey ||
hydratedPromptSignatureRef.current === initialDispatchKey
) {
return;
}
hydratedPromptSignatureRef.current = initialDispatchKey;
hasTriggeredGuide.current = true;
setInput((previous) => previous.trim() || pendingInitialPrompt);
setGeneralWorkbenchEntryPrompt({
kind: "initial_prompt",
signature: initialDispatchKey,
title: "已恢复待执行创作意图",
description: "进入页面后不会自动开始生成,确认后再继续。",
actionLabel: "继续生成",
prompt: pendingInitialPrompt,
});
}, [
autoRunInitialPromptOnMount,
shouldUseCompactGeneralWorkbench,
messagesCount: messages.length,
consumedInitialPromptKey,
contentId,
initialDispatchKey,
initialUserPrompt,
initialUserImages,
consumedInitialPromptKey: consumedInitialPromptRef.current,
onHydrateInitialPrompt: useCallback((prompt: string) => {
hasTriggeredGuide.current = true;
setInput((previous) => previous.trim() || prompt);
}, []),
});
initialUserPrompt,
isThemeWorkbench,
messages.length,
setInput,
shouldUseCompactGeneralWorkbench,
]);
useEffect(() => {
if (shouldUseCompactGeneralWorkbench) {
setGeneralWorkbenchEntryCheckPending(false);
return;
}
if (
!isThemeWorkbench ||
!contentId ||
!sessionId ||
messages.length > 0 ||
Boolean(initialDispatchKey)
) {
setGeneralWorkbenchEntryCheckPending(false);
return;
}
let disposed = false;
setGeneralWorkbenchEntryCheckPending(true);
void (async () => {
try {
const backendState = await executionRunGetGeneralWorkbenchState(
sessionId,
3,
).catch(() => null);
if (disposed) {
return;
}
const nextPrompt =
buildGeneralWorkbenchResumePromptFromRunState(backendState);
if (!nextPrompt) {
setGeneralWorkbenchEntryPrompt((current) =>
current?.kind === "resume" ? null : current,
);
return;
}
if (dismissedPromptSignatureRef.current === nextPrompt.signature) {
return;
}
setGeneralWorkbenchEntryPrompt((current) =>
current?.kind === "initial_prompt" ? current : nextPrompt,
);
} finally {
if (!disposed) {
setGeneralWorkbenchEntryCheckPending(false);
}
}
})();
return () => {
disposed = true;
};
}, [
contentId,
initialDispatchKey,
isThemeWorkbench,
messages.length,
sessionId,
shouldUseCompactGeneralWorkbench,
]);
const clearGeneralWorkbenchEntryPrompt = useCallback(() => {
setGeneralWorkbenchEntryPrompt(null);
}, []);
const dismissGeneralWorkbenchEntryPrompt = useCallback(
(options?: {
consumeInitialPrompt?: boolean;
onConsumeInitialPrompt?: () => void;
}) => {
setGeneralWorkbenchEntryPrompt((current) => {
if (!current) {
return current;
}
if (
current.kind === "initial_prompt" &&
options?.consumeInitialPrompt &&
initialDispatchKey
) {
options.onConsumeInitialPrompt?.();
} else {
dismissedPromptSignatureRef.current = current.signature;
}
return null;
});
},
[initialDispatchKey],
);
const consumeInitialPrompt = useCallback(
(dispatchKey: string | null) => {
consumedInitialPromptRef.current = dispatchKey;
@@ -2532,22 +2722,57 @@ export function AgentChatWorkspace({
hasTriggeredGuide.current = false;
consumedInitialPromptRef.current = null;
}, []);
const {
resolveSendBoundary,
finalizeAfterSendSuccess,
rollbackAfterSendFailure,
} = useGeneralWorkbenchSendBoundary({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey: consumedInitialPromptRef.current,
initialUserImages,
mappedTheme,
socialArticleSkillKey: SOCIAL_ARTICLE_SKILL_KEY,
onConsumeInitialPrompt: consumeInitialPrompt,
onResetConsumedInitialPrompt: resetConsumedInitialPrompt,
onClearEntryPrompt: clearGeneralWorkbenchEntryPrompt,
});
const resolveSendBoundary = useCallback(
({
sourceText,
sendOptions,
}: {
sourceText: string;
sendOptions?: HandleSendOptions;
}): GeneralWorkbenchSendBoundaryState =>
buildGeneralWorkbenchSendBoundaryState({
isThemeWorkbench,
contentId,
initialDispatchKey,
consumedInitialPromptKey,
initialUserImages,
mappedTheme,
socialArticleSkillKey: SOCIAL_ARTICLE_SKILL_KEY,
sourceText,
sendOptions,
}),
[
contentId,
consumedInitialPromptKey,
initialDispatchKey,
initialUserImages,
isThemeWorkbench,
mappedTheme,
],
);
const finalizeAfterSendSuccess = useCallback(
(boundary: GeneralWorkbenchSendBoundaryState) => {
if (
boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt &&
initialDispatchKey
) {
consumeInitialPrompt(initialDispatchKey);
}
if (boundary.shouldDismissGeneralWorkbenchEntryPrompt) {
clearGeneralWorkbenchEntryPrompt();
}
},
[clearGeneralWorkbenchEntryPrompt, consumeInitialPrompt, initialDispatchKey],
);
const rollbackAfterSendFailure = useCallback(
(boundary: GeneralWorkbenchSendBoundaryState) => {
if (boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt) {
resetConsumedInitialPrompt();
}
},
[resetConsumedInitialPrompt],
);
const { resetRestoredSessionState } = useWorkspaceSessionRestore({
sessionId,
sessionMeta,
@@ -2698,7 +2923,7 @@ export function AgentChatWorkspace({
browserAssistAutoLaunch: browserAssistRequestAutoLaunch,
workspaceRequestMetadataBase: initialRequestMetadata,
messages,
bootstrapDispatchPreviewMessages,
bootstrapDispatchPreview,
sendMessage,
resolveSendBoundary,
finalizeAfterSendSuccess,
@@ -2752,31 +2977,51 @@ export function AgentChatWorkspace({
submitImageWorkbenchAgentCommandRef.current =
submitImageWorkbenchAgentCommand;
const {
handleContinueGeneralWorkbenchEntryPrompt,
handleRestartGeneralWorkbenchEntryPrompt,
} = useGeneralWorkbenchEntryPromptActions({
generalWorkbenchEntryPrompt,
input,
initialDispatchKey,
onContinuePrompt: async (promptToSend) => {
await handleSendRef.current(
[],
webSearchPreferenceRef.current,
effectiveChatToolPreferences.thinking,
promptToSend,
);
},
dismissGeneralWorkbenchEntryPrompt,
onConsumeInitialPrompt: (dispatchKey) => {
consumedInitialPromptRef.current = dispatchKey;
onInitialUserPromptConsumed?.();
},
onInputChange: setInput,
onRequirePrompt: () => {
const handleContinueGeneralWorkbenchEntryPrompt = useCallback(async () => {
if (!generalWorkbenchEntryPrompt) {
return;
}
const promptToSend =
input.trim() || generalWorkbenchEntryPrompt.prompt.trim();
if (!promptToSend) {
toast.info("请先补充要继续执行的内容");
},
});
return;
}
await handleSendRef.current(
[],
webSearchPreferenceRef.current,
effectiveChatToolPreferences.thinking,
promptToSend,
);
}, [
effectiveChatToolPreferences.thinking,
generalWorkbenchEntryPrompt,
handleSendRef,
input,
webSearchPreferenceRef,
]);
const handleRestartGeneralWorkbenchEntryPrompt = useCallback(() => {
if (!generalWorkbenchEntryPrompt) {
return;
}
dismissGeneralWorkbenchEntryPrompt({
consumeInitialPrompt:
generalWorkbenchEntryPrompt.kind === "initial_prompt",
onConsumeInitialPrompt: () => {
consumeInitialPrompt(initialDispatchKey);
},
});
setInput("");
}, [
consumeInitialPrompt,
dismissGeneralWorkbenchEntryPrompt,
generalWorkbenchEntryPrompt,
initialDispatchKey,
setInput,
]);
const {
handleDocumentThinkingEnabledChange,
handleDocumentAutoContinueRun,
@@ -3446,35 +3691,211 @@ export function AgentChatWorkspace({
setFocusedTimelineItemId(normalizedItemId);
setTimelineFocusRequestKey((current) => current + 1);
}, []);
const triggerAIGuideRef = useRef(triggerAIGuide);
triggerAIGuideRef.current = triggerAIGuide;
useWorkspaceAutoGuideRuntime({
contentId,
sessionId,
initialUserPrompt,
initialUserImages,
initialAutoSendRequestMetadata,
useEffect(() => {
if (shouldUseCompactGeneralWorkbench) {
return;
}
const canvasEmpty = isCanvasStateEmpty(canvasState);
const pendingInitialPrompt = (initialUserPrompt || "").trim();
const pendingInitialImages = initialUserImages || [];
const defaultGuidePrompt =
contentId && canvasEmpty && !isThemeWorkbench
? getDefaultGuidePromptByTheme(mappedTheme)
: undefined;
if (
!contentId ||
messages.length > 0 ||
!project ||
!systemPrompt ||
isSending ||
!canvasEmpty
) {
return;
}
if (!initialDispatchKey && generalWorkbenchEntryCheckPending) {
return;
}
if (initialDispatchKey) {
if (
isThemeWorkbench &&
pendingInitialImages.length === 0 &&
!autoRunInitialPromptOnMount
) {
return;
}
if (consumedInitialPromptRef.current === initialDispatchKey) {
return;
}
let disposed = false;
consumedInitialPromptRef.current = initialDispatchKey;
hasTriggeredGuide.current = true;
if (import.meta.env.MODE !== "test") {
console.log("[AgentChatPage] 自动发送首条创作意图消息");
}
void (async () => {
const started = await handleSend(
pendingInitialImages,
effectiveChatToolPreferences.webSearch,
effectiveChatToolPreferences.thinking,
pendingInitialPrompt,
undefined,
undefined,
initialAutoSendRequestMetadata
? {
requestMetadata: initialAutoSendRequestMetadata,
}
: undefined,
);
if (disposed) {
return;
}
if (!started) {
consumedInitialPromptRef.current = null;
return;
}
onInitialUserPromptConsumed?.();
})();
return () => {
disposed = true;
};
}
if (hasTriggeredGuide.current) {
return;
}
if (generalWorkbenchEntryPrompt?.kind === "resume") {
return;
}
if (defaultGuidePrompt) {
hasTriggeredGuide.current = true;
setInput((previous) => previous.trim() || defaultGuidePrompt);
return;
}
if (isThemeWorkbench) {
if (shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt) {
return;
}
hasTriggeredGuide.current = true;
if (import.meta.env.MODE !== "test") {
console.log("[AgentChatPage] 工作区上下文:触发 AI 引导");
}
triggerAIGuideRef.current();
return;
}
hasTriggeredGuide.current = true;
if (import.meta.env.MODE !== "test") {
console.log("[AgentChatPage] 自动触发 AI 创作引导");
}
triggerAIGuideRef.current();
}, [
autoRunInitialPromptOnMount,
initialDispatchKey,
messagesCount: messages.length,
projectReady: Boolean(project),
systemPromptReady: Boolean(systemPrompt),
isSending,
canvasState,
isThemeWorkbench,
mappedTheme,
shouldUseCompactGeneralWorkbench,
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt:
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt,
contentId,
generalWorkbenchEntryCheckPending,
generalWorkbenchEntryPrompt,
chatToolPreferences: effectiveChatToolPreferences,
setInput,
handleSend,
triggerAIGuide,
initialAutoSendRequestMetadata,
initialDispatchKey,
initialUserImages,
initialUserPrompt,
isSending,
isThemeWorkbench,
mappedTheme,
messages.length,
onInitialUserPromptConsumed,
hasTriggeredGuideRef: hasTriggeredGuide,
consumedInitialPromptRef,
});
project,
setInput,
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt,
shouldUseCompactGeneralWorkbench,
systemPrompt,
effectiveChatToolPreferences.thinking,
effectiveChatToolPreferences.webSearch,
]);
useEffect(() => {
const pendingInitialPrompt = (initialUserPrompt || "").trim();
const pendingInitialImages = initialUserImages || [];
if (
shouldUseCompactGeneralWorkbench ||
!initialDispatchKey ||
contentId ||
!sessionId ||
messages.length > 0 ||
isSending
) {
return;
}
if (consumedInitialPromptRef.current === initialDispatchKey) {
return;
}
let disposed = false;
consumedInitialPromptRef.current = initialDispatchKey;
void (async () => {
const started = await handleSend(
pendingInitialImages,
effectiveChatToolPreferences.webSearch,
effectiveChatToolPreferences.thinking,
pendingInitialPrompt,
undefined,
undefined,
initialAutoSendRequestMetadata
? {
requestMetadata: initialAutoSendRequestMetadata,
}
: undefined,
);
if (disposed) {
return;
}
if (!started) {
consumedInitialPromptRef.current = null;
return;
}
onInitialUserPromptConsumed?.();
})();
return () => {
disposed = true;
};
}, [
contentId,
handleSend,
initialAutoSendRequestMetadata,
initialDispatchKey,
initialUserImages,
initialUserPrompt,
isSending,
messages.length,
onInitialUserPromptConsumed,
sessionId,
shouldUseCompactGeneralWorkbench,
effectiveChatToolPreferences.thinking,
effectiveChatToolPreferences.webSearch,
]);
useEffect(() => {
hasTriggeredGuide.current = false;
consumedInitialPromptRef.current = null;
}, [contentId]);
useWorkspaceImageWorkbenchEventRuntime({
canvasState,
@@ -1,119 +0,0 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GeneralWorkbenchEntryPromptAccessory } from "./GeneralWorkbenchEntryPromptAccessory";
import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt";
interface MountedHarness {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: MountedHarness[] = [];
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function renderAccessory(
props?: Partial<ComponentProps<typeof GeneralWorkbenchEntryPromptAccessory>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultPrompt: GeneralWorkbenchEntryPromptState = {
kind: "initial_prompt",
signature: "dispatch-1",
title: "已恢复待执行创作意图",
description: "进入页面后不会自动开始生成,确认后再继续。",
actionLabel: "继续生成",
prompt: "请先生成主稿",
};
const defaultProps: ComponentProps<
typeof GeneralWorkbenchEntryPromptAccessory
> = {
prompt: defaultPrompt,
onRestart: vi.fn(),
onContinue: vi.fn(async () => undefined),
};
act(() => {
root.render(
<GeneralWorkbenchEntryPromptAccessory {...defaultProps} {...props} />,
);
});
mountedRoots.push({ container, root });
return {
container,
props: {
...defaultProps,
...props,
},
};
}
describe("GeneralWorkbenchEntryPromptAccessory", () => {
it("应渲染提示文案与操作按钮", () => {
const { container } = renderAccessory();
expect(
container.querySelector('[data-testid="theme-workbench-entry-prompt"]')
?.textContent,
).toContain("已恢复待执行创作意图");
expect(container.textContent).toContain("进入页面后不会自动开始生成");
expect(container.textContent).toContain("继续生成");
expect(container.textContent).toContain("重新开始");
});
it("应分发继续与重启动作", async () => {
const onRestart = vi.fn();
const onContinue = vi.fn(async () => undefined);
const { container } = renderAccessory({
onRestart,
onContinue,
});
const restartButton = container.querySelector<HTMLButtonElement>(
'[data-testid="theme-workbench-entry-restart"]',
);
const continueButton = container.querySelector<HTMLButtonElement>(
'[data-testid="theme-workbench-entry-continue"]',
);
if (!restartButton || !continueButton) {
throw new Error("未找到通用工作台入口提示操作按钮");
}
act(() => {
restartButton.click();
});
expect(onRestart).toHaveBeenCalledTimes(1);
await act(async () => {
continueButton.click();
});
expect(onContinue).toHaveBeenCalledTimes(1);
});
});
@@ -1,140 +0,0 @@
import { memo } from "react";
import { Info } from "lucide-react";
import styled from "styled-components";
import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt";
interface GeneralWorkbenchEntryPromptAccessoryProps {
prompt: GeneralWorkbenchEntryPromptState;
onRestart: () => void;
onContinue: () => Promise<void> | void;
}
const GeneralWorkbenchEntryPromptCard = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
min-width: min(360px, calc(100vw - 48px));
max-width: min(420px, calc(100vw - 48px));
padding: 12px 14px;
border-radius: 18px;
border: 1px solid rgba(191, 219, 254, 0.92);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(239, 246, 255, 0.96) 100%
);
color: #0f172a;
box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.26);
`;
const GeneralWorkbenchEntryPromptHeader = styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
`;
const GeneralWorkbenchEntryPromptTitleWrap = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
`;
const GeneralWorkbenchEntryPromptTitle = styled.span`
font-size: 13px;
font-weight: 700;
line-height: 1.4;
`;
const GeneralWorkbenchEntryPromptDescription = styled.span`
font-size: 12px;
line-height: 1.5;
color: #475569;
`;
const GeneralWorkbenchEntryPromptActions = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
`;
const GeneralWorkbenchEntryPromptButton = styled.button<{
$variant?: "primary" | "ghost";
}>`
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 88px;
height: 32px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid
${({ $variant }) =>
$variant === "ghost"
? "rgba(191, 219, 254, 0.92)"
: "rgba(59, 130, 246, 0.94)"};
background: ${({ $variant }) =>
$variant === "ghost"
? "rgba(255, 255, 255, 0.92)"
: "linear-gradient(180deg, rgba(59,130,246,0.96) 0%, rgba(37,99,235,0.96) 100%)"};
color: ${({ $variant }) => ($variant === "ghost" ? "#1e293b" : "#eff6ff")};
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.16s ease,
box-shadow 0.2s ease,
background 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px -18px rgba(37, 99, 235, 0.46);
background: ${({ $variant }) =>
$variant === "ghost"
? "rgba(239, 246, 255, 0.98)"
: "linear-gradient(180deg, rgba(37,99,235,0.98) 0%, rgba(29,78,216,0.98) 100%)"};
}
`;
export const GeneralWorkbenchEntryPromptAccessory = memo(
function GeneralWorkbenchEntryPromptAccessory({
prompt,
onRestart,
onContinue,
}: GeneralWorkbenchEntryPromptAccessoryProps) {
return (
<GeneralWorkbenchEntryPromptCard data-testid="theme-workbench-entry-prompt">
<GeneralWorkbenchEntryPromptHeader>
<Info className="mt-0.5 h-4 w-4 shrink-0 text-sky-600" />
<GeneralWorkbenchEntryPromptTitleWrap>
<GeneralWorkbenchEntryPromptTitle>
{prompt.title}
</GeneralWorkbenchEntryPromptTitle>
<GeneralWorkbenchEntryPromptDescription>
{prompt.description}
</GeneralWorkbenchEntryPromptDescription>
</GeneralWorkbenchEntryPromptTitleWrap>
</GeneralWorkbenchEntryPromptHeader>
<GeneralWorkbenchEntryPromptActions>
<GeneralWorkbenchEntryPromptButton
type="button"
$variant="ghost"
data-testid="theme-workbench-entry-restart"
onClick={onRestart}
>
重新开始
</GeneralWorkbenchEntryPromptButton>
<GeneralWorkbenchEntryPromptButton
type="button"
data-testid="theme-workbench-entry-continue"
onClick={() => {
void onContinue();
}}
>
{prompt.actionLabel}
</GeneralWorkbenchEntryPromptButton>
</GeneralWorkbenchEntryPromptActions>
</GeneralWorkbenchEntryPromptCard>
);
},
);
@@ -201,9 +201,11 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("任务工作台");
expect(container.textContent).toContain("聚焦当前任务、后续节点与相关版本。");
expect(container.textContent).toContain("任务视图");
expect(container.textContent).toContain("当前任务");
expect(container.textContent).toContain("当前焦点");
expect(container.textContent).toContain("撰写主稿");
expect(container.textContent).toContain("后续任务");
expect(container.textContent).toContain("已完成 1/4");
expect(container.textContent).toContain("已完成 1 项");
expect(container.textContent).toMatch(/相关分支|相关版本/);
const stepNodes = Array.from(
@@ -216,15 +218,15 @@ describe("GeneralWorkbenchSidebar", () => {
'[data-testid="workflow-sidebar-branch-section"]',
) as HTMLElement | null;
expect(stepNodes).toHaveLength(4);
expect(stepNodes).toHaveLength(2);
expect(stepNodes.map((node) => node.getAttribute("data-status"))).toEqual([
"active",
"error",
"pending",
"completed",
]);
expect(taskSection).toBeTruthy();
expect(branchSection).toBeTruthy();
expect(taskSection?.textContent).toContain("当前焦点");
expect(taskSection?.textContent).toContain("后续任务");
const taskSectionOrder =
taskSection && branchSection
? taskSection.compareDocumentPosition(branchSection) &
@@ -449,7 +451,7 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("1. 提炼内容主线");
expect(container.textContent).toContain("2. 生成封面提示词");
expect(container.textContent).toContain("允许工具");
expect(container.textContent).toContain("文件读取");
expect(container.textContent).toContain("查看文件");
expect(container.textContent).toContain("图片生成");
expect(container.textContent).toContain("适用场景");
expect(container.textContent).toContain(
@@ -583,7 +585,7 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(container.textContent).toContain("文件读取");
expect(container.textContent).toContain("查看文件");
expect(container.textContent).toContain("文件不存在");
expect(container.textContent).not.toContain("执行技能 社媒主稿与封面");
});
@@ -644,7 +646,7 @@ describe("GeneralWorkbenchSidebar", () => {
}
expect(container.textContent).toContain("页面打开");
expect(container.textContent).toContain("任务输出");
expect(container.textContent).toContain("查看任务结果");
expect(container.textContent).toContain("用户确认");
expect(container.textContent).not.toContain("网络检索");
expect(container.textContent).not.toContain("执行命令");
@@ -766,6 +768,19 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(container.querySelector("button[aria-label='切换相关记录']")).toBeTruthy();
expect(container.querySelector("button[aria-label='删除分支']")).toBeNull();
expect(container.textContent).toContain("当前焦点落在");
const branchToggle = container.querySelector(
"button[aria-label='切换相关记录']",
) as HTMLButtonElement | null;
if (branchToggle) {
act(() => {
branchToggle.click();
});
}
const mergeButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "采纳",
);
@@ -796,6 +811,16 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("相关版本");
expect(container.textContent).toContain("新增版本");
expect(container.textContent).toContain("当前焦点落在");
const branchToggle = container.querySelector(
"button[aria-label='切换相关记录']",
) as HTMLButtonElement | null;
if (branchToggle) {
act(() => {
branchToggle.click();
});
}
const setMainButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "设为主稿",
@@ -820,6 +845,9 @@ describe("GeneralWorkbenchSidebar", () => {
workflowTab.click();
});
}
expect(container.textContent).toContain("最近一组:content_post_with_cover");
const activityToggle = container.querySelector(
"button[aria-label='切换活动日志']",
) as HTMLButtonElement | null;
@@ -829,9 +857,10 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(container.textContent).toContain("闸门:写作闸门");
expect(container.textContent).toContain("来源:skill");
expect(container.textContent).toContain("运行:run-abcd…");
expect(container.textContent).toContain("过程记录");
expect(container.textContent).toContain("写作闸门");
expect(container.textContent).toContain("技能");
expect(container.textContent).toContain("查看运行 run-abcd…");
});
it("活动日志应按运行维度分组展示步骤", () => {
@@ -873,6 +902,7 @@ describe("GeneralWorkbenchSidebar", () => {
workflowTab.click();
});
}
expect(container.textContent).toContain("最近一组:research_topic");
const activityToggle = container.querySelector(
"button[aria-label='切换活动日志']",
@@ -885,12 +915,17 @@ describe("GeneralWorkbenchSidebar", () => {
expect(container.textContent).toContain("research_topic");
expect(container.textContent).toContain("write_file");
expect(container.textContent).toContain("技能:research_topic");
expect(container.textContent).toContain("修改:content-posts/research.md");
expect(container.textContent).toContain('输入:{"topic":"AI"}');
expect(container.textContent).toContain("输出:已完成选题调研");
expect(container.textContent).toContain("技能");
expect(container.textContent).toContain("content-posts/research.md");
expect(container.textContent).toContain('{"topic":"AI"}');
expect(container.textContent).toContain("已完成选题调研");
expect(
container.querySelector(
'button[aria-label="定位活动产物路径-content-posts/research.md"]',
),
).toBeNull();
const runButtons = Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent === "运行:rungrp01",
(button) => button.textContent === "查看运行 rungrp01",
);
expect(runButtons.length).toBe(1);
});
@@ -917,7 +952,7 @@ describe("GeneralWorkbenchSidebar", () => {
}
const runButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("运行:run-abcd…"),
(button) => button.textContent?.includes("查看运行 run-abcd…"),
);
expect(runButton).toBeTruthy();
if (runButton) {
@@ -966,12 +1001,12 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(container.textContent).toContain("运行详情");
expect(container.textContent).toContain("ID:run-detail-1");
expect(container.textContent).toContain("状态:处理中");
expect(container.textContent).toContain("当前查看运行");
expect(container.textContent).toContain("运行ID:run-detail-1");
expect(container.textContent).toContain("处理中");
});
it("运行详情应支持复制运行ID与元数据", async () => {
it("运行详情应支持复制运行ID与原始记录", async () => {
const { container } = renderSidebar({
activeRunDetail: {
id: "run-copy-1",
@@ -1012,7 +1047,7 @@ describe("GeneralWorkbenchSidebar", () => {
"button[aria-label='复制运行ID']",
) as HTMLButtonElement | null;
const copyMetadataButton = container.querySelector(
"button[aria-label='复制运行元数据']",
"button[aria-label='复制原始记录']",
) as HTMLButtonElement | null;
expect(copyIdButton).toBeTruthy();
@@ -1084,12 +1119,10 @@ describe("GeneralWorkbenchSidebar", () => {
}
expect(container.textContent).toContain(
"工作流:social_content_pipeline_v1",
"工作流 social_content_pipeline_v1",
);
expect(container.textContent).toContain("执行ID:exec-artifact-1");
expect(container.textContent).toContain("版本ID:ver-artifact-1");
expect(container.textContent).toContain(
"阶段:选题闸门 → 写作闸门 → 发布闸门",
"选题闸门 → 写作闸门 → 发布闸门",
);
expect(container.textContent).toContain("content-posts/demo.md");
expect(container.textContent).toContain(
@@ -1161,8 +1194,6 @@ describe("GeneralWorkbenchSidebar", () => {
timeLabel: "11:20",
applyTarget: "主稿内容",
contextIds: ["material:1"],
runId: "run-artifact-group-1",
executionId: "exec-artifact-group-1",
sessionId: "session-group",
artifactPaths: ["content-posts/group.md"],
gateKey: "write_mode",
@@ -1263,7 +1294,9 @@ describe("GeneralWorkbenchSidebar", () => {
});
}
expect(container.textContent).toContain("任务提交");
expect(container.textContent).toContain("任务记录");
expect(container.textContent).toContain("最近一次:排版优化");
expect(container.textContent).toContain("共 3 条任务记录,按 2 类归档。");
const toggleCreationTasksButton = container.querySelector(
"button[aria-label='切换任务提交记录']",
) as HTMLButtonElement | null;
@@ -1275,7 +1308,7 @@ describe("GeneralWorkbenchSidebar", () => {
}
expect(container.textContent).toContain("配图生成");
expect(container.textContent).toContain("排版优化");
expect(container.textContent).toContain("本组 2 条");
expect(container.textContent).toContain("2 条记录");
const copyAbsolutePathButton = container.querySelector(
'button[aria-label="复制任务文件绝对路径-task-image-1"]',
File diff suppressed because it is too large Load Diff
@@ -582,6 +582,50 @@ describe("HarnessStatusPanel", () => {
known_gaps: [
"当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。",
],
observability_summary: {
schema_version: "v1",
known_gaps: [
"当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。",
],
signal_coverage: [
{
signal: "correlation",
status: "exported",
source: "runtime thread identity",
detail: "已导出关联键。",
},
{
signal: "artifactValidator",
status: "exported",
source: "artifact_document_validator",
detail: "已导出 Artifact 校验结果。",
},
],
verification_summary: {
artifact_validator: {
applicable: true,
record_count: 1,
issue_count: 2,
repaired_count: 1,
fallback_used_count: 0,
outcome: "blocking_failure",
},
browser_verification: {
record_count: 2,
success_count: 1,
failure_count: 1,
unknown_count: 0,
outcome: "blocking_failure",
},
focus_verification_failure_outcomes: [
"Artifact 校验存在 2 条未恢复 issues。",
"浏览器验证存在 1 条失败线索。",
],
focus_verification_recovered_outcomes: [
"Artifact 校验已恢复 1 个产物,fallback 0 次。",
],
},
},
artifacts: [
{
kind: "summary",
@@ -620,6 +664,10 @@ describe("HarnessStatusPanel", () => {
"session-evidence-1",
);
expect(document.body.textContent).toContain("问题证据包");
expect(document.body.textContent).toContain("验证结果");
expect(document.body.textContent).toContain("阻塞失败");
expect(document.body.textContent).toContain("验证失败焦点");
expect(document.body.textContent).toContain("已恢复结果");
expect(document.body.textContent).toContain("当前已知缺口");
expect(document.body.textContent).toContain(
".lime/harness/sessions/session-evidence-1/evidence/summary.md",
@@ -875,6 +923,22 @@ describe("HarnessStatusPanel", () => {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
verification_summary: {
artifact_validator: {
applicable: true,
record_count: 1,
issue_count: 2,
repaired_count: 1,
fallback_used_count: 0,
outcome: "blocking_failure",
},
focus_verification_failure_outcomes: [
"Artifact 校验存在 2 条未恢复 issues。",
],
focus_verification_recovered_outcomes: [
"Artifact 校验已恢复 1 个产物,fallback 0 次。",
],
},
decision: {
decision_status: "pending_review",
decision_summary: "",
@@ -883,8 +947,14 @@ describe("HarnessStatusPanel", () => {
risk_tags: [],
human_reviewer: "",
reviewed_at: undefined,
followup_actions: [],
regression_requirements: [],
followup_actions: [
"先对照 analysis-context.json / evidence/runtime.json 核对当前验证失败焦点,再决定是继续修复还是补证据。",
"复查 Artifact 校验相关产物,确认 issues / repaired / fallback 状态与最终结论一致。",
],
regression_requirements: [
"按 replay case 复现问题并确认修复后行为与预期一致。",
"重新导出 evidence pack,确认 Artifact 校验摘要已更新。",
],
notes: "",
},
decision_status_options: [
@@ -967,6 +1037,17 @@ describe("HarnessStatusPanel", () => {
expect(document.body.textContent).toContain(
"确认最终决策由人工审核者填写。",
);
expect(document.body.textContent).toContain("验证结果");
expect(document.body.textContent).toContain("阻塞失败");
expect(document.body.textContent).toContain(
"Artifact 校验存在 2 条未恢复 issues。",
);
expect(document.body.textContent).toContain(
"先对照 analysis-context.json / evidence/runtime.json 核对当前验证失败焦点",
);
expect(document.body.textContent).toContain(
"重新导出 evidence pack,确认 Artifact 校验摘要已更新。",
);
expect(document.body.textContent).toContain("aster-rust");
expect(mockToast.success).toHaveBeenCalledWith("已导出 2 个人工审核文件");
});
@@ -996,6 +1077,20 @@ describe("HarnessStatusPanel", () => {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
verification_summary: {
artifact_validator: {
applicable: true,
record_count: 1,
issue_count: 1,
repaired_count: 0,
fallback_used_count: 0,
outcome: "blocking_failure",
},
focus_verification_failure_outcomes: [
"Artifact 校验存在 1 条未恢复 issue。",
],
focus_verification_recovered_outcomes: [],
},
decision: {
decision_status: "pending_review",
decision_summary: "",
@@ -1064,6 +1159,20 @@ describe("HarnessStatusPanel", () => {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
verification_summary: {
artifact_validator: {
applicable: true,
record_count: 1,
issue_count: 0,
repaired_count: 1,
fallback_used_count: 0,
outcome: "recovered",
},
focus_verification_failure_outcomes: [],
focus_verification_recovered_outcomes: [
"Artifact 校验已恢复 1 个产物,fallback 0 次。",
],
},
decision: {
decision_status: "accepted",
decision_summary: "确认最小修复可以接受。",
@@ -1131,6 +1240,16 @@ describe("HarnessStatusPanel", () => {
await Promise.resolve();
});
const reviewDialog = document.body.querySelector(
'[role="dialog"]',
) as HTMLDivElement | null;
expect(reviewDialog?.textContent).toContain("验证结果");
expect(reviewDialog?.textContent).toContain("阻塞失败");
expect(reviewDialog?.textContent).toContain(
"Artifact 校验存在 1 条未恢复 issue。",
);
const statusSelect = document.body.querySelector(
'select[aria-label="决策状态"]',
) as HTMLSelectElement | null;
@@ -112,6 +112,7 @@ import { resolveTeamWorkspaceStableProcessingLabel } from "../utils/teamWorkspac
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
import type { TeamMemorySnapshot } from "@/lib/teamMemorySync";
import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel";
import { HarnessVerificationSummarySection } from "./HarnessVerificationSummarySection";
import { RuntimeReviewDecisionDialog } from "./RuntimeReviewDecisionDialog";
interface HarnessEnvironmentSummary {
@@ -2149,10 +2150,7 @@ export function HarnessStatusPanel({
) {
sections.push({ key: "plan", label: "规划状态" });
}
if (
realTeamSummary.total > 0 ||
harnessState.delegatedTasks.length > 0
) {
if (realTeamSummary.total > 0 || harnessState.delegatedTasks.length > 0) {
sections.push({ key: "delegation", label: "子任务" });
}
if (harnessState.latestContextTrace.length > 0) {
@@ -2787,7 +2785,8 @@ export function HarnessStatusPanel({
>
{runtimeTaskPresentation.stepStatus === "error" ? (
<AlertCircle className="h-4 w-4" />
) : runtimeTaskPresentation.stepStatus === "skipped" ? (
) : runtimeTaskPresentation.stepStatus ===
"skipped" ? (
<Clock3 className="h-4 w-4" />
) : (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -2905,7 +2904,9 @@ export function HarnessStatusPanel({
</div>
<Badge
variant={
isCurrentCheckpoint ? "secondary" : "outline"
isCurrentCheckpoint
? "secondary"
: "outline"
}
>
{isCurrentCheckpoint ? "当前" : "已记录"}
@@ -3198,30 +3199,49 @@ export function HarnessStatusPanel({
{evidencePack ? (
<div className="mt-3 space-y-3">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<InventoryStatCard
title="线程状态"
value={formatHandoffStatusLabel(
evidencePack.thread_status,
)}
hint={`最近导出 ${formatIsoDateTime(evidencePack.exported_at)}`}
/>
<InventoryStatCard
title="时间线"
value={`${evidencePack.turn_count} / ${evidencePack.item_count}`}
hint="turns / items"
/>
<InventoryStatCard
title="阻塞线索"
value={`${evidencePack.pending_request_count} / ${evidencePack.queued_turn_count}`}
hint="pending request / queued turn"
/>
<InventoryStatCard
title="已知缺口"
value={`${evidencePack.known_gaps.length}`}
hint={`最近产物 ${evidencePack.recent_artifact_count} 个`}
/>
</div>
{(() => {
const verificationSummary =
evidencePack.observability_summary
?.verification_summary;
const failureFocus =
verificationSummary?.focus_verification_failure_outcomes ??
[];
const exportedSignals =
evidencePack.observability_summary?.signal_coverage.filter(
(entry) => entry.status === "exported",
).length ?? 0;
return (
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<InventoryStatCard
title="线程状态"
value={formatHandoffStatusLabel(
evidencePack.thread_status,
)}
hint={`最近导出 ${formatIsoDateTime(evidencePack.exported_at)}`}
/>
<InventoryStatCard
title="时间线"
value={`${evidencePack.turn_count} / ${evidencePack.item_count}`}
hint="turns / items"
/>
<InventoryStatCard
title="阻塞线索"
value={`${evidencePack.pending_request_count} / ${evidencePack.queued_turn_count}`}
hint="pending request / queued turn"
/>
<InventoryStatCard
title="已知缺口"
value={`${evidencePack.known_gaps.length}`}
hint={
verificationSummary
? `验证焦点 ${failureFocus.length} · 已导出信号 ${exportedSignals}`
: `最近产物 ${evidencePack.recent_artifact_count} 个`
}
/>
</div>
);
})()}
<div className="rounded-xl border border-border bg-background p-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
@@ -3246,6 +3266,16 @@ export function HarnessStatusPanel({
</div>
</div>
{evidencePack.observability_summary
?.verification_summary ? (
<HarnessVerificationSummarySection
summary={
evidencePack.observability_summary
.verification_summary
}
/>
) : null}
{evidencePack.known_gaps.length > 0 ? (
<div className="rounded-xl border border-amber-200 bg-amber-50/80 p-3">
<div className="text-sm font-medium text-amber-900">
@@ -3644,8 +3674,8 @@ export function HarnessStatusPanel({
<span>外部分析交接</span>
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
把 handoff / evidence / replay 主链重新包装成外部
AI 可直接消费的分析交接;复制后可直接粘贴给 AI,
把 handoff / evidence / replay 主链重新包装成外部 AI
可直接消费的分析交接;复制后可直接粘贴给 AI,
不需要你再手写补充 prompt。
</div>
</div>
@@ -3907,8 +3937,9 @@ export function HarnessStatusPanel({
<span>人工审核记录</span>
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
把外部 AI 的分析结论回挂为
`review-decision.md/json` 模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是 Lime 自动闭环。
把外部 AI 的分析结论回挂为 `review-decision.md/json`
模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是
Lime 自动闭环。
</div>
</div>
<div className="flex flex-wrap gap-2">
@@ -4034,6 +4065,14 @@ export function HarnessStatusPanel({
</div>
</div>
{reviewDecisionTemplate.verification_summary ? (
<HarnessVerificationSummarySection
summary={
reviewDecisionTemplate.verification_summary
}
/>
) : null}
<div className="rounded-xl border border-border bg-background p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-foreground">
@@ -0,0 +1,63 @@
import type { AgentRuntimeEvidenceVerificationSummary } from "@/lib/api/agentRuntime";
import { buildHarnessEvidenceVerificationCardPresentations } from "@/lib/agentRuntime/harnessVerificationPresentation";
import { Badge } from "@/components/ui/badge";
import { ShieldAlert } from "lucide-react";
export function HarnessVerificationSummarySection({
summary,
}: {
summary: AgentRuntimeEvidenceVerificationSummary;
}) {
return (
<div className="rounded-xl border border-border bg-background p-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ShieldAlert className="h-4 w-4 text-emerald-600" />
<span>验证结果</span>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-3">
{buildHarnessEvidenceVerificationCardPresentations(summary).map(
(card) => (
<div
key={card.key}
className="rounded-lg border border-border/70 bg-muted/20 p-3"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-foreground">
{card.title}
</span>
<Badge variant={card.badge.variant}>{card.badge.label}</Badge>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{card.description}
</div>
</div>
),
)}
</div>
{summary.focus_verification_failure_outcomes.length > 0 ? (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50/80 p-3">
<div className="text-sm font-medium text-amber-900">验证失败焦点</div>
<div className="mt-2 space-y-1 text-xs text-amber-800">
{summary.focus_verification_failure_outcomes.map((outcome, index) => (
<div key={`${outcome}-${index}`}>{outcome}</div>
))}
</div>
</div>
) : null}
{summary.focus_verification_recovered_outcomes.length > 0 ? (
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50/80 p-3">
<div className="text-sm font-medium text-emerald-900">已恢复结果</div>
<div className="mt-2 space-y-1 text-xs text-emerald-800">
{summary.focus_verification_recovered_outcomes.map(
(outcome, index) => (
<div key={`${outcome}-${index}`}>{outcome}</div>
),
)}
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,278 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InlineToolProcessStep } from "./InlineToolProcessStep";
import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol";
vi.mock("@tauri-apps/plugin-shell", () => ({
open: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./MarkdownRenderer", () => ({
MarkdownRenderer: ({ content }: { content: string }) => (
<div data-testid="markdown-renderer">{content}</div>
),
}));
interface RenderResult {
container: HTMLDivElement;
root: Root;
}
interface RenderOptions {
isMessageStreaming?: boolean;
onOpenSavedSiteContent?: (target: unknown) => void;
}
const mountedRoots: RenderResult[] = [];
function renderTool(
toolCall: ToolCallState,
options?: RenderOptions,
): RenderResult {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<InlineToolProcessStep
toolCall={toolCall}
isMessageStreaming={options?.isMessageStreaming}
onOpenSavedSiteContent={options?.onOpenSavedSiteContent}
/>,
);
});
const rendered = { container, root };
mountedRoots.push(rendered);
return rendered;
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
describe("InlineToolProcessStep", () => {
it("ToolSearch 在流式阶段应保持结构化预览,不自动展开原始 JSON", () => {
const { container } = renderTool(
{
id: "tool-search-streaming-1",
name: "ToolSearch",
arguments: JSON.stringify({ query: "select:Read,Write" }),
status: "completed",
result: {
success: true,
output: JSON.stringify({
query: "select:Read,Write",
count: 2,
notes: [],
tools: [{ name: "Read" }, { name: "Write" }],
}),
},
startTime: new Date("2026-04-13T10:00:00.000Z"),
endTime: new Date("2026-04-13T10:00:01.000Z"),
},
{ isMessageStreaming: true },
);
expect(container.textContent).toContain("找到工具 2 个");
expect(container.textContent).not.toContain("查询:");
expect(container.textContent).not.toContain("select:Read,Write");
expect(
container.querySelector('[data-testid="inline-tool-process-tool-search-result"]'),
).toBeNull();
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
expect(container.textContent).not.toContain('"tools"');
});
it("ToolSearch 展开后应展示结构化工具摘要,而不是原始 JSON", () => {
const { container } = renderTool({
id: "tool-search-1",
name: "ToolSearch",
arguments: JSON.stringify({ query: "select:Read,Write" }),
status: "completed",
result: {
success: true,
output: JSON.stringify({
query: "select:Read,Write",
count: 2,
notes: [],
tools: [
{
name: "Read",
source: "native_registry",
description: "Read a file from disk",
always_visible: true,
},
{
name: "Write",
source: "native_registry",
description: "Write content to a file",
always_visible: true,
},
],
}),
},
startTime: new Date("2026-04-13T10:10:00.000Z"),
endTime: new Date("2026-04-13T10:10:01.000Z"),
});
act(() => {
const toggle = container.querySelector(
'button[title="展开过程详情"]',
) as HTMLButtonElement | null;
toggle?.click();
});
expect(
container.querySelector('[data-testid="inline-tool-process-tool-search-result"]'),
).not.toBeNull();
expect(container.textContent).toContain("找到工具:2 个");
expect(container.textContent).toContain("查看文件");
expect(container.textContent).toContain("保存文件");
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
expect(container.textContent).not.toContain('"always_visible":true');
expect(container.textContent).not.toContain("Read a file from disk");
expect(container.textContent).not.toContain("查询:select:Read,Write");
expect(container.textContent).not.toContain("原生工具");
expect(container.textContent).not.toContain("默认可见");
});
it("WebSearch 展开后应优先展示搜索结果列表", () => {
const { container } = renderTool({
id: "tool-search-web-1",
name: "WebSearch",
arguments: JSON.stringify({ query: "AI Agent 最新热点" }),
status: "completed",
result: {
success: true,
output: [
"Xinhua world news summary at 0030 GMT, March 13",
"https://example.com/xinhua",
"全球要闻摘要,覆盖国际局势与市场动态。",
"",
"Friday morning news: March 13, 2026 | WORLD - wng.org",
"https://example.com/wng",
"补充国际动态与区域冲突更新。",
].join("\n"),
},
startTime: new Date("2026-04-13T10:20:00.000Z"),
endTime: new Date("2026-04-13T10:20:01.000Z"),
});
act(() => {
const toggle = container.querySelector(
'button[title="展开过程详情"]',
) as HTMLButtonElement | null;
toggle?.click();
});
expect(
document.body.querySelector(
'[aria-label="预览搜索结果:Xinhua world news summary at 0030 GMT, March 13"]',
),
).not.toBeNull();
expect(container.textContent).toContain(
"Friday morning news: March 13, 2026 | WORLD - wng.org",
);
expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull();
});
it("完成态过程卡不应重复展示执行完成与原始工具名", () => {
const { container } = renderTool({
id: "tool-inline-ask-user-1",
name: "AskUserQuestion",
arguments: JSON.stringify({ question: "需要继续吗?" }),
status: "completed",
result: {
success: true,
output: "用户已确认继续。",
},
startTime: new Date("2026-04-13T10:30:00.000Z"),
endTime: new Date("2026-04-13T10:30:01.000Z"),
});
expect(container.textContent).toContain("已收集 需要继续吗?");
expect(container.textContent).not.toContain("执行完成");
expect(container.textContent).not.toContain("Ask User Question");
});
it("站点导出按钮副文案应优先展示短文件名", () => {
const onOpenSavedSiteContent = vi.fn();
const { container } = renderTool(
{
id: "tool-inline-site-run-1",
name: "lime_site_run",
arguments: JSON.stringify({
adapter_name: "x/article",
args: { url: "https://x.com/google/article/1" },
}),
status: "completed",
result: {
success: true,
output: "ok",
metadata: {
tool_family: "site",
saved_content: {
content_id: "content-inline-site-1",
project_id: "project-inline-site-1",
title: "Google Cloud 周报",
markdown_relative_path:
"exports/social-article/google-cloud/index.md",
image_count: 3,
},
saved_by: "context_project",
},
},
startTime: new Date("2026-04-13T10:40:00.000Z"),
endTime: new Date("2026-04-13T10:40:01.000Z"),
},
{ onOpenSavedSiteContent },
);
expect(container.textContent).toContain("已保存到当前项目:Google Cloud 周报");
expect(container.textContent).toContain("已导出 Markdown 文稿");
expect(container.textContent).toContain("附带图片 3 张");
const openButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("在下方预览导出 Markdown"),
) as HTMLButtonElement | undefined;
expect(openButton).toBeDefined();
expect(openButton?.textContent).toContain("index.md");
expect(openButton?.textContent).not.toContain(
"exports/social-article/google-cloud/index.md",
);
act(() => {
openButton?.click();
});
expect(onOpenSavedSiteContent).toHaveBeenCalledWith({
projectId: "project-inline-site-1",
contentId: "content-inline-site-1",
title: "Google Cloud 周报",
preferredTarget: "project_file",
projectFile: {
relativePath: "exports/social-article/google-cloud/index.md",
},
});
});
});
@@ -1,7 +1,10 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { open as openExternal } from "@tauri-apps/plugin-shell";
import { ChevronDown, ExternalLink, FileText, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { MarkdownRenderer } from "./MarkdownRenderer";
import { SearchResultPreviewList } from "./SearchResultPreviewList";
import { ToolSearchSummaryPanel } from "./ToolSearchSummaryPanel";
import {
extractLimeToolMetadataBlock,
normalizeToolResultImages,
@@ -11,18 +14,26 @@ import type { SiteSavedContentTarget } from "../types";
import {
buildToolHeadline,
getToolDisplayInfo,
humanizeToolName,
normalizeToolNameKey,
parseToolCallArguments,
resolveToolFilePath,
resolveToolPrimarySubject,
} from "../utils/toolDisplayInfo";
import {
isUnifiedWebSearchToolName,
resolveSearchResultPreviewItemsFromText,
} from "../utils/searchResultPreview";
import {
normalizeSiteToolResultSummary,
resolveSiteAdapterSourceLabel,
resolveSiteProjectTargetLabel,
resolveSiteSavedContentTargetDisplayName,
resolveSiteSavedContentTargetRelativePath,
resolveSiteProjectSourceLabel,
resolveSiteSavedContentTargetFromMetadata,
} from "../utils/siteToolResultSummary";
import {
normalizeToolSearchResultSummary,
resolveUserFacingToolSearchItemLabel,
} from "../utils/toolSearchResultSummary";
interface InlineToolProcessStepProps {
toolCall: ToolCallState;
@@ -68,6 +79,34 @@ function summarizeResultText(value: string): string | null {
return `${singleLine.slice(0, 180).trim()}...`;
}
function summarizeToolSearchPreview(value: ReturnType<
typeof normalizeToolSearchResultSummary
>): string | null {
if (!value) {
return null;
}
const toolNames = value.tools
.slice(0, 2)
.map((item) => resolveUserFacingToolSearchItemLabel(item.name))
.filter(Boolean);
const prefix = `找到工具 ${value.count} 个`;
if (toolNames.length === 0) {
return prefix;
}
return `${prefix} · ${toolNames.join(" · ")}`;
}
function summarizeSearchResultPreview(resultCount: number): string | null {
if (resultCount <= 0) {
return null;
}
return `找到 ${resultCount} 条搜索结果`;
}
function buildSiteNoticeLines(toolCall: ToolCallState): string[] {
const summary = normalizeSiteToolResultSummary(toolCall.result?.metadata);
if (!summary) {
@@ -77,52 +116,35 @@ function buildSiteNoticeLines(toolCall: ToolCallState): string[] {
const lines: string[] = [];
const savedProjectId =
summary.savedProjectId || summary.savedContent?.projectId || "";
const savedSourceLabel = resolveSiteProjectSourceLabel(summary.savedBy || "");
const savedProjectTarget = resolveSiteProjectTargetLabel({
source: summary.savedBy,
projectId: savedProjectId || undefined,
});
if (summary.savedContent?.title) {
let line = `已保存:${summary.savedContent.title}`;
if (savedProjectId) {
line += ` · 项目 ${savedProjectId}`;
}
if (savedSourceLabel) {
line += ` · ${savedSourceLabel}`;
}
lines.push(line);
lines.push(`已保存到${savedProjectTarget}:${summary.savedContent.title}`);
}
if (summary.savedContent?.markdownRelativePath) {
lines.push(`Markdown:${summary.savedContent.markdownRelativePath}`);
lines.push("已导出 Markdown 文稿");
}
if (typeof summary.savedContent?.imageCount === "number") {
const imageDir = summary.savedContent.imagesRelativeDir;
lines.push(
`图片:${summary.savedContent.imageCount} 张${
imageDir ? ` · ${imageDir}` : ""
}`,
);
lines.push(`附带图片 ${summary.savedContent.imageCount} 张`);
}
if (summary.saveSkippedProjectId) {
const skippedSourceLabel = resolveSiteProjectSourceLabel(
summary.saveSkippedBy || "",
);
let line = `未写入项目 ${summary.saveSkippedProjectId}`;
if (skippedSourceLabel) {
line += ` · ${skippedSourceLabel}`;
}
lines.push(line);
const skippedProjectTarget = resolveSiteProjectTargetLabel({
source: summary.saveSkippedBy,
projectId: summary.saveSkippedProjectId,
});
lines.push(`未保存到${skippedProjectTarget}`);
}
if (summary.saveErrorMessage) {
lines.push(`自动保存失败:${summary.saveErrorMessage}`);
}
const adapterSourceLabel = resolveSiteAdapterSourceLabel(summary);
if (adapterSourceLabel) {
lines.push(`脚本来源:${adapterSourceLabel}`);
}
return lines;
}
@@ -167,15 +189,6 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
}),
[subject, toolCall.name, toolDisplay],
);
const rawToolNameLabel = useMemo(() => {
if (
toolDisplay.family === "generic" &&
toolDisplay.label !== humanizeToolName(toolCall.name)
) {
return humanizeToolName(toolCall.name);
}
return null;
}, [toolCall.name, toolDisplay.family, toolDisplay.label]);
const resultText = useMemo(() => {
const rawText = toolCall.result?.error || toolCall.result?.output || "";
return extractLimeToolMetadataBlock(rawText).text.trim();
@@ -188,12 +201,38 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
() => normalizeToolResultImages(toolCall.result?.images, resultText) || [],
[resultText, toolCall.result?.images],
);
const isToolSearch = useMemo(
() => normalizeToolNameKey(toolCall.name) === "toolsearch",
[toolCall.name],
);
const toolSearchSummary = useMemo(
() => (isToolSearch ? normalizeToolSearchResultSummary(resultText) : null),
[isToolSearch, resultText],
);
const searchResultItems = useMemo(() => {
if (!isUnifiedWebSearchToolName(toolCall.name)) {
return [];
}
return resolveSearchResultPreviewItemsFromText(resultText);
}, [resultText, toolCall.name]);
const structuredResultPreview = useMemo(() => {
if (toolSearchSummary) {
return summarizeToolSearchPreview(toolSearchSummary);
}
if (searchResultItems.length > 0) {
return summarizeSearchResultPreview(searchResultItems.length);
}
return resultPreview;
}, [resultPreview, searchResultItems.length, toolSearchSummary]);
const savedSiteContentTarget = useMemo(
() => resolveSiteSavedContentTargetFromMetadata(toolCall.result?.metadata),
[toolCall.result?.metadata],
);
const savedSiteContentRelativePath = useMemo(
() => resolveSiteSavedContentTargetRelativePath(savedSiteContentTarget),
const savedSiteContentDisplayName = useMemo(
() =>
resolveSiteSavedContentTargetDisplayName(savedSiteContentTarget) ||
resolveSiteSavedContentTargetRelativePath(savedSiteContentTarget),
[savedSiteContentTarget],
);
const siteNoticeLines = useMemo(
@@ -209,31 +248,46 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
const hasDetails =
Boolean(resultText) ||
resultImages.length > 0 ||
searchResultItems.length > 0 ||
Boolean(toolSearchSummary) ||
siteNoticeLines.length > 0 ||
Boolean(savedSiteContentTarget) ||
Boolean(skillTitle && skillTitle !== subject);
const handleOpenExternalUrl = useCallback(async (url: string) => {
try {
await openExternal(url);
} catch {
if (typeof window !== "undefined" && typeof window.open === "function") {
window.open(url, "_blank");
}
}
}, []);
useEffect(() => {
if (
toolCall.status === "running" ||
isMessageStreaming ||
siteNoticeLines.length > 0
) {
if (toolCall.status === "running" || siteNoticeLines.length > 0) {
setExpanded(true);
return;
}
if (isMessageStreaming && !toolSearchSummary) {
setExpanded(true);
}
}, [isMessageStreaming, siteNoticeLines.length, toolCall.status]);
const statusLabel =
toolCall.status === "running"
? "执行中"
: toolCall.status === "failed"
? "执行失败"
: "执行完成";
}, [
isMessageStreaming,
siteNoticeLines.length,
toolCall.status,
toolSearchSummary,
]);
const detailBadges = [
isPreload ? "系统预执行" : null,
skillTitle && skillTitle !== subject ? `技能:${skillTitle}` : null,
statusLabel,
toolCall.status === "running"
? "执行中"
: toolCall.status === "failed"
? "执行失败"
: null,
].filter((value): value is string => Boolean(value));
return (
@@ -287,14 +341,9 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
<span key={badge}>{badge}</span>
))}
</div>
{rawToolNameLabel ? (
<div className="mt-0.5 truncate text-[11px] leading-5 text-slate-400">
{rawToolNameLabel}
</div>
) : null}
{!expanded && resultPreview ? (
{!expanded && structuredResultPreview ? (
<div className="mt-1 text-xs leading-5 text-slate-600">
{resultPreview}
{structuredResultPreview}
</div>
) : null}
</button>
@@ -361,9 +410,9 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
? "在下方预览导出 Markdown"
: "打开已保存内容"}
</span>
{savedSiteContentRelativePath ? (
{savedSiteContentDisplayName ? (
<span className="block truncate text-[11px] leading-5 text-emerald-700/80">
{savedSiteContentRelativePath}
{savedSiteContentDisplayName}
</span>
) : null}
</span>
@@ -372,7 +421,24 @@ export const InlineToolProcessStep: React.FC<InlineToolProcessStepProps> = ({
</div>
) : null}
{resultText ? (
{toolSearchSummary ? (
<ToolSearchSummaryPanel
summary={toolSearchSummary}
testId="inline-tool-process-tool-search-result"
/>
) : null}
{!toolSearchSummary && searchResultItems.length > 0 ? (
<SearchResultPreviewList
items={searchResultItems}
onOpenUrl={handleOpenExternalUrl}
popoverSide="bottom"
popoverAlign="start"
className="max-w-2xl"
/>
) : null}
{!toolSearchSummary && searchResultItems.length === 0 && resultText ? (
<div className="text-sm leading-6 text-slate-700">
<MarkdownRenderer content={resultText} />
</div>
@@ -14,7 +14,6 @@ import type { BuiltinInputCommand } from "../../../skill-selection/builtinComman
import { TeamSelector } from "./TeamSelector";
import { InputbarWorkflowStatusPanel } from "./InputbarWorkflowStatusPanel";
import { InputbarModelExtra } from "./InputbarModelExtra";
import { InputbarPromptCacheNotice } from "./InputbarPromptCacheNotice";
import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice";
import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect";
import { InputbarAccessModeSelect } from "./InputbarAccessModeSelect";
@@ -159,14 +158,10 @@ export const InputbarComposerSection: React.FC<
currentPendingImages.length > 0 &&
Boolean(resolvedProviderType?.trim()) &&
Boolean(resolvedModel?.trim());
const shouldShowPromptCacheNotice = Boolean(resolvedProviderType?.trim());
const resolvedTopExtra =
topExtra || shouldShowPromptCacheNotice || shouldShowVisionNotice ? (
topExtra || shouldShowVisionNotice ? (
<>
{topExtra}
{shouldShowPromptCacheNotice && resolvedProviderType ? (
<InputbarPromptCacheNotice providerType={resolvedProviderType} />
) : null}
{shouldShowVisionNotice && resolvedProviderType && resolvedModel ? (
<InputbarVisionCapabilityNotice
providerType={resolvedProviderType}
@@ -139,12 +139,6 @@ vi.mock("../ChatModelSelector", () => ({
ChatModelSelector: () => <div data-testid="model-selector" />,
}));
vi.mock("./components/InputbarPromptCacheNotice", () => ({
InputbarPromptCacheNotice: (props: { providerType: string }) => (
<div data-testid="inputbar-prompt-cache-warning">{props.providerType}</div>
),
}));
vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(async () => []),
}));
@@ -828,7 +822,7 @@ describe("Inputbar", () => {
expect(latestCall.leftExtra).toBeDefined();
});
it("已选择 Provider 时应将 prompt cache 提示组件挂到输入区顶部", async () => {
it("已选择 Provider 时不应再将 prompt cache 提示组件常驻挂到输入区顶部", async () => {
const { container } = renderInputbar({
providerType: "custom-provider-id",
setProviderType: vi.fn(),
@@ -843,8 +837,7 @@ describe("Inputbar", () => {
expect(
container.querySelector('[data-testid="inputbar-prompt-cache-warning"]'),
).toBeTruthy();
expect(container.textContent).toContain("custom-provider-id");
).toBeNull();
});
it("任务中心工作区应使用继续推进型输入提示", async () => {
@@ -367,6 +367,57 @@ describe("MessageList", () => {
);
});
it("anthropic-compatible 自定义 Provider 存在缓存写入时不应再透传自动缓存提示", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-cache-write",
role: "assistant",
content: "本轮已完成。",
timestamp: now,
usage: {
input_tokens: 1_500,
output_tokens: 500,
cached_input_tokens: 0,
cache_creation_input_tokens: 256,
},
},
];
mockUseConfiguredProviders.mockImplementation(() => ({
providers: [
{
key: "custom-provider-id",
label: "Kimi Anthropic",
registryId: "custom-provider-id",
type: "anthropic-compatible",
providerId: "custom-provider-id",
},
],
loading: false,
}));
mockFindConfiguredProviderBySelection.mockImplementation(
(
providers: MockConfiguredProvider[],
selection?: string | null,
): MockConfiguredProvider | null =>
Array.isArray(providers)
? (providers.find((provider) => provider.key === selection) ?? null)
: null,
);
const container = render(messages, {
providerType: "custom-provider-id",
});
expect(container.textContent).not.toContain("未声明自动缓存");
expect(mockTokenUsageDisplay).toHaveBeenCalledWith(
expect.objectContaining({
promptCacheNotice: undefined,
}),
);
});
it("图片任务消息卡应在聊天区渲染预览并支持展开图片画布", () => {
const now = new Date();
const messages: Message[] = [
@@ -163,6 +163,16 @@ interface MessageListProps {
providerType?: string;
}
function resolvePromptCacheActivity(usage?: {
cached_input_tokens?: number;
cache_creation_input_tokens?: number;
}): number {
return (
Math.max(0, usage?.cached_input_tokens ?? 0) +
Math.max(0, usage?.cache_creation_input_tokens ?? 0)
);
}
function isDeferredTimelineItem(item: AgentThreadItem): boolean {
return item.type === "file_artifact" || item.type === "turn_summary";
}
@@ -426,7 +436,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
msg.role === "assistant" &&
!msg.isThinking &&
msg.usage &&
(msg.usage.cached_input_tokens ?? 0) <= 0,
resolvePromptCacheActivity(msg.usage) <= 0,
),
),
[messages, providerType],
@@ -886,7 +896,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
<TokenUsageDisplay
usage={msg.usage}
promptCacheNotice={
(msg.usage.cached_input_tokens ?? 0) <= 0
resolvePromptCacheActivity(msg.usage) <= 0
? promptCacheNotice
: undefined
}
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { HarnessVerificationSummarySection } from "./HarnessVerificationSummarySection";
interface RuntimeReviewDecisionDialogProps {
open: boolean;
@@ -194,6 +195,12 @@ export function RuntimeReviewDecisionDialog({
{template && formState ? (
<div className="max-h-[70vh] space-y-5 overflow-y-auto px-6 py-5">
{template.verification_summary ? (
<HarnessVerificationSummarySection
summary={template.verification_summary}
/>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label
@@ -168,6 +168,40 @@ function createSavedSiteMetadata(): AgentToolResultMetadata {
}
describe("StreamingRenderer", () => {
it("交错内容应隐藏紧邻工具调用的调度自述", () => {
const { container } = renderHarness({
content: "",
contentParts: [
{
type: "text",
text: "ToolSearch 只返回了元数据,让我直接调用 WebSearch 进行多组检索。",
},
{
type: "tool_use",
toolCall: {
id: "tool-narration-hidden",
name: "WebSearch",
arguments: JSON.stringify({ query: "latest openai api" }),
status: "completed",
result: { success: true, output: "ok" },
startTime: new Date("2026-04-01T10:00:00.000Z"),
endTime: new Date("2026-04-01T10:00:01.000Z"),
},
},
{
type: "text",
text: "已经整理出 3 个可信来源。",
},
],
});
expect(container.textContent).not.toContain("只返回了元数据");
expect(
container.querySelector('[data-testid="inline-tool-process-step"]'),
).toBeTruthy();
expect(container.textContent).toContain("已经整理出 3 个可信来源。");
});
it("应过滤 assistant 正文中的工具协议残留", () => {
const { container } = renderHarness({
content:
@@ -76,7 +76,7 @@ describe("TokenUsageDisplay", () => {
expect(container.textContent).toContain("1.2M tokens");
});
it("存在缓存命中时应附带展示 cached token", () => {
it("存在缓存命中时应在总 token 旁边展示缓存 token", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
@@ -95,7 +95,82 @@ describe("TokenUsageDisplay", () => {
});
expect(container.textContent).toContain("31.0K tokens");
expect(container.textContent).toContain("命中缓存 8.0K");
expect(container.textContent).toContain("缓存 8.0K");
});
it("存在缓存写入时应展示缓存总量与读写拆分", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
mountedRoots.push({ container, root });
act(() => {
root.render(
<TokenUsageDisplay
usage={{
input_tokens: 12_000,
output_tokens: 19_000,
cached_input_tokens: 8_000,
cache_creation_input_tokens: 1_200,
}}
/>,
);
});
expect(container.textContent).toContain("31.0K tokens");
expect(container.textContent).toContain("缓存 9.2K");
expect(container.textContent).toContain("读 8.0K / 写 1.2K");
});
it("存在缓存字段但未命中时也应在总 token 旁边展示缓存 token", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
mountedRoots.push({ container, root });
act(() => {
root.render(
<TokenUsageDisplay
usage={{
input_tokens: 12_000,
output_tokens: 19_000,
cached_input_tokens: 0,
}}
/>,
);
});
expect(container.textContent).toContain("31.0K tokens");
expect(container.textContent).toContain("缓存 0");
});
it("仅存在缓存写入时应抑制黄色提示并展示缓存写入", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
mountedRoots.push({ container, root });
act(() => {
root.render(
<TokenUsageDisplay
usage={{
input_tokens: 12_000,
output_tokens: 19_000,
cached_input_tokens: 0,
cache_creation_input_tokens: 1_200,
}}
promptCacheNotice={{
label: "未声明自动缓存",
detail:
"当前 Provider 未声明支持自动 Prompt Cache;如需复用前缀,请使用显式 cache_control 标记。",
}}
/>,
);
});
expect(container.textContent).toContain("缓存 1.2K");
expect(container.textContent).toContain("读 0 / 写 1.2K");
expect(container.textContent).not.toContain("未声明自动缓存");
});
it("未启用自动缓存时应展示轻量诊断提示", () => {
@@ -73,6 +73,34 @@ function formatCompactTokenCount(value: number): string {
return normalized.toLocaleString();
}
function resolvePromptCacheMetaText(usage: TokenUsage): string | null {
const hasCachedRead = Number.isFinite(usage.cached_input_tokens);
const hasCacheCreation = Number.isFinite(usage.cache_creation_input_tokens);
if (!hasCachedRead && !hasCacheCreation) {
return null;
}
const cachedRead = Math.max(0, usage.cached_input_tokens ?? 0);
const cacheCreation = Math.max(0, usage.cache_creation_input_tokens ?? 0);
const totalCached = cachedRead + cacheCreation;
if (totalCached <= 0) {
return "· 缓存 0";
}
if (hasCacheCreation) {
if (hasCachedRead) {
return `· 缓存 ${formatCompactTokenCount(totalCached)}(读 ${formatCompactTokenCount(
cachedRead,
)} / 写 ${formatCompactTokenCount(cacheCreation)})`;
}
return `· 缓存写 ${formatCompactTokenCount(cacheCreation)}`;
}
return `· 缓存 ${formatCompactTokenCount(cachedRead)}`;
}
/**
* Token 使用量显示组件
*
@@ -84,9 +112,12 @@ export const TokenUsageDisplay: React.FC<TokenUsageDisplayProps> = ({
promptCacheNotice,
}) => {
const total = usage.input_tokens + usage.output_tokens;
const cachedInput = Math.max(0, usage.cached_input_tokens ?? 0);
const totalPromptCacheTokens =
Math.max(0, usage.cached_input_tokens ?? 0) +
Math.max(0, usage.cache_creation_input_tokens ?? 0);
const promptCacheMetaText = resolvePromptCacheMetaText(usage);
const missingPromptCacheNotice =
cachedInput > 0 ? null : (promptCacheNotice ?? null);
totalPromptCacheTokens > 0 ? null : (promptCacheNotice ?? null);
return (
<UsageContainer
@@ -95,9 +126,7 @@ export const TokenUsageDisplay: React.FC<TokenUsageDisplayProps> = ({
>
<UsageIcon />
<UsageText>{formatCompactTokenCount(total)} tokens</UsageText>
{cachedInput > 0 ? (
<UsageMeta>{`· 命中缓存 ${formatCompactTokenCount(cachedInput)}`}</UsageMeta>
) : null}
{promptCacheMetaText ? <UsageMeta>{promptCacheMetaText}</UsageMeta> : null}
{missingPromptCacheNotice ? (
<UsageMeta data-testid="token-usage-prompt-cache-notice">
{`· ${missingPromptCacheNotice.label}`}
@@ -82,7 +82,7 @@ describe("ToolCallDisplay", () => {
expect(
container.querySelector('[data-testid="tool-call-rendered-result"]'),
).toBeNull();
expect(document.body.textContent).toContain("查看原始输出");
expect(document.body.textContent).toContain("查看文本详情");
const firstSearchResult = document.body.querySelector(
'[aria-label="预览搜索结果:Xinhua world news summary at 0030 GMT, March 13"]',
@@ -104,12 +104,12 @@ describe("ToolCallDisplay", () => {
act(() => {
const rawToggle = document.body.querySelector(
'button[aria-label="查看搜索原始输出"]',
'button[aria-label="查看搜索文本详情"]',
) as HTMLButtonElement | null;
rawToggle?.click();
});
expect(document.body.textContent).toContain("收起原始输出");
expect(document.body.textContent).toContain("收起文本详情");
expect(
container.querySelector('[data-testid="tool-call-rendered-result"]'),
).not.toBeNull();
@@ -167,7 +167,7 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain(
"本次检索未返回可解析链接,请稍后重试。",
);
expect(container.textContent).not.toContain("查看原始输出");
expect(container.textContent).not.toContain("查看文本详情");
});
it("连续多次 WebSearch 应在对话区按搜索批次分组展示", () => {
@@ -214,8 +214,8 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain(
"搜索 March 13 2026 world headlines",
);
expect(container.textContent).toContain("中文日期检索");
expect(container.textContent).toContain("头条检索");
expect(container.textContent).not.toContain("中文日期检索");
expect(container.textContent).not.toContain("头条检索");
});
it("连续完成的命令工具应聚合成一个 work group", () => {
@@ -256,7 +256,7 @@ describe("ToolCallDisplay", () => {
'[data-testid="tool-call-work-group"]',
);
expect(groups).toHaveLength(1);
expect(container.textContent).toContain("已执行 2 条命令");
expect(container.textContent).toContain("已运行 2 条命令");
expect(container.textContent).toContain("2");
expect(container.textContent).toContain("pwd");
expect(container.textContent).toContain("ls -la");
@@ -268,8 +268,8 @@ describe("ToolCallDisplay", () => {
groupToggle?.click();
});
expect(container.textContent).toContain("执行 pwd");
expect(container.textContent).toContain("执行 ls -la");
expect(container.textContent).toContain("运行 pwd");
expect(container.textContent).toContain("运行 ls -la");
expect(container.textContent).not.toContain("pwd · ls -la");
});
@@ -299,8 +299,11 @@ describe("ToolCallDisplay", () => {
toggle?.click();
});
expect(container.textContent).toContain("已执行 ls -la");
expect(container.textContent).not.toContain("已执行已执行");
expect(container.textContent).toContain("已运行 ls -la");
expect(container.textContent).not.toContain("已运行已运行");
expect(container.textContent).not.toContain("退出码 0");
expect(container.textContent).not.toContain("stdout 24");
expect(container.textContent).not.toContain("stderr 0");
expect(
container.querySelector('[data-testid="tool-call-rendered-result"]'),
).toBeTruthy();
@@ -308,6 +311,61 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain("复制");
});
it("结果区应压缩内部元信息与长路径提示", () => {
const { container } = renderTool({
id: "tool-exec-render-2",
name: "bash",
arguments: JSON.stringify({ command: "generate-report" }),
status: "failed",
result: {
success: false,
output: "报告生成失败,请检查参数后重试。",
metadata: {
exit_code: 2,
lime_offloaded: true,
output_truncated: true,
output_file: "exports/reports/final-result.md",
},
},
startTime: new Date("2026-03-20T12:12:00.000Z"),
endTime: new Date("2026-03-20T12:12:01.000Z"),
});
act(() => {
const toggle = container.querySelector(
'button[title="查看结果"]',
) as HTMLButtonElement | null;
toggle?.click();
});
expect(container.textContent).toContain("内容较长,已省略部分文本");
expect(container.textContent).toContain("命令返回错误");
expect(container.textContent).toContain("结果文件: final-result.md");
expect(container.textContent).not.toContain("完整输出已转存");
expect(container.textContent).not.toContain("输出已截断");
expect(container.textContent).not.toContain("输出文件:");
expect(container.textContent).not.toContain("退出码 2");
expect(container.textContent).not.toContain("exports/reports/final-result.md");
});
it("正式工具卡不应额外展示原始工具名", () => {
const { container } = renderTool({
id: "tool-ask-user-1",
name: "AskUserQuestion",
arguments: JSON.stringify({ question: "需要继续吗?" }),
status: "completed",
result: {
success: true,
output: "用户已确认继续。",
},
startTime: new Date("2026-04-13T10:31:00.000Z"),
endTime: new Date("2026-04-13T10:31:01.000Z"),
});
expect(container.textContent).toContain("已收集 需要继续吗?");
expect(container.textContent).not.toContain("Ask User Question");
});
it("站点能力工具结果应展示自动保存结果与脚本来源", () => {
const { container } = renderTool({
id: "tool-site-run-1",
@@ -357,20 +415,18 @@ describe("ToolCallDisplay", () => {
});
expect(container.textContent).toContain(
"结果已自动保存到项目 project-1:GitHub MCP 搜索结果 · 来自当前项目上下文",
"结果已自动保存到当前项目:GitHub MCP 搜索结果",
);
expect(container.textContent).toContain(
"项目目录:/Users/coso/Library/Application Support/lime/projects/project-1",
"已导出 Markdown 文稿",
);
expect(container.textContent).toContain(
"Markdown 文件:exports/x-article-export/github-mcp/index.md",
);
expect(container.textContent).toContain(
"图片资源:7 张 · exports/x-article-export/github-mcp/images",
);
expect(container.textContent).toContain(
"脚本来源:服务端脚本 · 2026-03-25",
"附带图片 7 张",
);
expect(container.textContent).not.toContain("exports/x-article-export/github-mcp/index.md");
expect(container.textContent).not.toContain("exports/x-article-export/github-mcp/images");
expect(container.textContent).not.toContain("项目目录:");
expect(container.textContent).not.toContain("脚本来源:");
});
it("站点能力工具结果应支持直接打开已保存内容", () => {
@@ -544,11 +600,14 @@ describe("ToolCallDisplay", () => {
expect(
container.querySelector('[data-testid="tool-call-tool-search-result"]'),
).not.toBeNull();
expect(container.textContent).toContain("匹配工具:2 个");
expect(container.textContent).toContain("Read");
expect(container.textContent).toContain("Write");
expect(container.textContent).toContain("原生工具");
expect(container.textContent).toContain("找到工具:2 个");
expect(container.textContent).toContain("查看文件");
expect(container.textContent).toContain("保存文件");
expect(container.textContent).not.toContain("Read a file from disk");
expect(container.textContent).not.toContain('"always_visible":true');
expect(container.textContent).not.toContain("查询:select:Read,Write");
expect(container.textContent).not.toContain("原生工具");
expect(container.textContent).not.toContain("默认可见");
expect(
container.querySelector('[data-testid="tool-call-rendered-result"]'),
).toBeNull();
@@ -589,7 +648,7 @@ describe("ToolCallDisplay", () => {
expect(
container.querySelector('[data-testid="tool-call-tool-search-result"]'),
).toBeNull();
expect(container.textContent).not.toContain("匹配工具:2 个");
expect(container.textContent).not.toContain("找到工具:2 个");
expect(container.textContent).not.toContain("Read a file from disk");
});
@@ -626,7 +685,7 @@ describe("ToolCallDisplay", () => {
});
expect(container.textContent).toContain(
"执行失败,未保存到项目 project-2 · 来自当前项目上下文",
"执行失败,未保存到当前项目",
);
expect(container.textContent).toContain("自动保存失败:数据库写入失败");
});
@@ -756,13 +815,13 @@ describe("ToolCallDisplay", () => {
expect(container.textContent).toContain("已打开 https://example.com/docs");
expect(container.textContent).toContain("子任务处理中 修复登录页");
expect(container.textContent).toContain("已读取输出 video-task-1");
expect(container.textContent).toContain("已查看结果 video-task-1");
expect(container.textContent).toContain("已加载技能 lime-governance");
expect(container.textContent).toContain("已列出 src/**/*.tsx");
expect(container.textContent).toContain("已找到 src/**/*.tsx");
expect(container.textContent).toContain("等待输入 需要继续吗?");
expect(container.textContent).toContain("已发送");
expect(container.textContent).toContain("修复已完成");
expect(container.textContent).toContain("已列出 当前团队");
expect(container.textContent).toContain("已查看 当前团队");
expect(container.textContent).toContain("已创建 当前团队");
expect(container.textContent).toContain("已删除 当前团队");
expect(container.textContent).toContain("已处理 remote-1");
@@ -787,6 +846,6 @@ describe("ToolCallDisplay", () => {
endTime: new Date("2026-03-25T09:00:01.000Z"),
});
expect(container.textContent).toContain("已写入 final.md");
expect(container.textContent).toContain("已保存 final.md");
});
});
@@ -22,6 +22,7 @@ import type {
import type { SiteSavedContentTarget } from "../types";
import { MarkdownRenderer } from "./MarkdownRenderer";
import { SearchResultPreviewList } from "./SearchResultPreviewList";
import { ToolSearchSummaryPanel } from "./ToolSearchSummaryPanel";
import {
isUnifiedWebSearchToolName,
resolveSearchResultPreviewItemsFromText,
@@ -29,18 +30,11 @@ import {
import { extractLimeToolMetadataBlock } from "../hooks/agentChatToolResult";
import {
normalizeSiteToolResultSummary,
resolveSiteAdapterSourceLabel,
resolveSiteProjectSourceLabel,
resolveSiteProjectTargetLabel,
resolveSiteSavedContentTargetFromMetadata,
} from "../utils/siteToolResultSummary";
import {
classifySearchQuerySemantic,
summarizeSearchQuerySemantics,
} from "../utils/searchQueryGrouping";
import {
normalizeToolSearchResultSummary,
resolveToolSearchItemSourceLabel,
resolveToolSearchItemStatusLabel,
} from "../utils/toolSearchResultSummary";
import type { ToolCallArgumentValue } from "../utils/toolDisplayInfo";
import {
@@ -49,7 +43,6 @@ import {
buildToolHeadline as buildToolHeadlineFromInfo,
extractSearchQueryLabel as extractSearchQueryLabelFromInfo,
getToolDisplayInfo as getToolDisplayInfoFromInfo,
humanizeToolName as humanizeToolNameFromInfo,
normalizeToolNameKey as normalizeToolNameKeyFromInfo,
parseToolCallArguments as parseToolCallArgumentsFromInfo,
resolveToolFilePath as resolveToolFilePathFromInfo,
@@ -171,6 +164,17 @@ const buildRenderedToolResultContent = (params: {
return content;
};
function resolveUserFacingPathName(path: string | null | undefined): string | null {
const trimmed = path?.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/\\/g, "/");
const segments = normalized.split("/").filter(Boolean);
return segments.at(-1) || trimmed;
}
const isGroupableToolCall = (toolCall: ToolCallState): boolean => {
if (isUnifiedWebSearchToolName(toolCall.name)) {
return true;
@@ -567,46 +571,21 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
if (!resultMetadata) return [];
const items: string[] = [];
if (resultMetadata.lime_offloaded === true) {
items.push("完整输出已转存");
if (
resultMetadata.lime_offloaded === true ||
resultMetadata.output_truncated === true
) {
items.push("内容较长,已省略部分文本");
}
if (typeof resultMetadata.exit_code === "number") {
items.push(`退出码 ${resultMetadata.exit_code}`);
}
if (typeof resultMetadata.stdout_length === "number") {
items.push(`stdout ${resultMetadata.stdout_length}`);
}
if (typeof resultMetadata.stderr_length === "number") {
items.push(`stderr ${resultMetadata.stderr_length}`);
}
if (typeof resultMetadata.sandboxed === "boolean") {
items.push(resultMetadata.sandboxed ? "已隔离执行" : "普通执行");
}
if (resultMetadata.output_truncated === true) {
items.push("输出已截断");
}
if (typeof resultMetadata.offload_original_chars === "number") {
items.push(`原始 ${resultMetadata.offload_original_chars} 字符`);
}
if (typeof resultMetadata.offload_original_tokens === "number") {
items.push(`约 ${resultMetadata.offload_original_tokens} tokens`);
}
if (typeof resultMetadata.offload_trigger === "string") {
const triggerLabel =
resultMetadata.offload_trigger === "history_context_pressure"
? "上下文压力触发"
: resultMetadata.offload_trigger === "token_limit_before_evict"
? "token 阈值触发"
: resultMetadata.offload_trigger === "payload_bytes"
? "字节阈值触发"
: resultMetadata.offload_trigger === "payload_chars"
? "字符阈值触发"
: resultMetadata.offload_trigger;
items.push(triggerLabel);
if (
typeof resultMetadata.exit_code === "number" &&
(isFailed || resultMetadata.exit_code !== 0)
) {
items.push("命令返回错误");
}
return items;
}, [resultMetadata]);
}, [isFailed, resultMetadata]);
const siteResultNotices = useMemo(() => {
if (!siteResultSummary) return [] as ToolResultNotice[];
@@ -614,17 +593,13 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
const savedProjectId =
siteResultSummary.savedProjectId ||
siteResultSummary.savedContent?.projectId;
const savedSourceLabel = resolveSiteProjectSourceLabel(
siteResultSummary.savedBy,
);
const savedProjectTarget = resolveSiteProjectTargetLabel({
source: siteResultSummary.savedBy,
projectId: savedProjectId,
});
if (siteResultSummary.savedContent?.title) {
let text = `结果已自动保存${
savedProjectId ? `到项目 ${savedProjectId}` : ""
}:${siteResultSummary.savedContent.title}`;
if (savedSourceLabel) {
text = `${text} · ${savedSourceLabel}`;
}
const text = `结果已自动保存到${savedProjectTarget}:${siteResultSummary.savedContent.title}`;
notices.push({
key: "site-save-success",
text,
@@ -632,44 +607,31 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
});
}
if (siteResultSummary.savedContent?.projectRootPath) {
notices.push({
key: "site-save-project-root",
text: `项目目录:${siteResultSummary.savedContent.projectRootPath}`,
tone: "neutral",
});
}
if (siteResultSummary.savedContent?.markdownRelativePath) {
notices.push({
key: "site-save-markdown-path",
text: `Markdown 文件:${siteResultSummary.savedContent.markdownRelativePath}`,
text: "已导出 Markdown 文稿",
tone: "neutral",
});
}
if (typeof siteResultSummary.savedContent?.imageCount === "number") {
const imageDir = siteResultSummary.savedContent.imagesRelativeDir;
notices.push({
key: "site-save-images",
text: `图片资源:${siteResultSummary.savedContent.imageCount} 张${
imageDir ? ` · ${imageDir}` : ""
}`,
text: `附带图片 ${siteResultSummary.savedContent.imageCount} 张`,
tone: "neutral",
});
}
if (siteResultSummary.saveSkippedProjectId) {
let text =
const skippedProjectTarget = resolveSiteProjectTargetLabel({
source: siteResultSummary.saveSkippedBy,
projectId: siteResultSummary.saveSkippedProjectId,
});
const text =
toolCall.status === "failed"
? `执行失败,未保存到项目 ${siteResultSummary.saveSkippedProjectId}`
: `本次结果未保存到项目 ${siteResultSummary.saveSkippedProjectId}`;
const skippedSourceLabel = resolveSiteProjectSourceLabel(
siteResultSummary.saveSkippedBy,
);
if (skippedSourceLabel) {
text = `${text} · ${skippedSourceLabel}`;
}
? `执行失败,未保存到${skippedProjectTarget}`
: `本次结果未保存到${skippedProjectTarget}`;
notices.push({
key: "site-save-skipped",
text,
@@ -684,16 +646,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
tone: "error",
});
}
const adapterSourceLabel = resolveSiteAdapterSourceLabel(siteResultSummary);
if (adapterSourceLabel) {
notices.push({
key: "site-adapter-source",
text: `脚本来源:${adapterSourceLabel}`,
tone: "neutral",
});
}
return notices;
}, [siteResultSummary, toolCall.status]);
const resultPath = useMemo(() => {
@@ -702,24 +654,30 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
typeof resultMetadata.offload_file === "string" &&
resultMetadata.offload_file.trim()
) {
const fullPath = resultMetadata.offload_file.trim();
return {
label: "转存文件",
value: resultMetadata.offload_file.trim(),
label: "结果文件",
value: fullPath,
displayValue: resolveUserFacingPathName(fullPath) || fullPath,
};
}
if (
typeof resultMetadata.output_file === "string" &&
resultMetadata.output_file.trim()
) {
const fullPath = resultMetadata.output_file.trim();
return {
label: "输出文件",
value: resultMetadata.output_file.trim(),
label: "结果文件",
value: fullPath,
displayValue: resolveUserFacingPathName(fullPath) || fullPath,
};
}
if (typeof resultMetadata.path === "string" && resultMetadata.path.trim()) {
const fullPath = resultMetadata.path.trim();
return {
label: "产物路径",
value: resultMetadata.path.trim(),
label: "结果文件",
value: fullPath,
displayValue: resolveUserFacingPathName(fullPath) || fullPath,
};
}
return undefined;
@@ -751,12 +709,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
() => buildGroupedChildLineFromInfo(toolCall),
[toolCall],
);
const shouldShowRawToolName = useMemo(
() =>
toolDisplay.family === "generic" &&
toolDisplay.label !== humanizeToolNameFromInfo(toolCall.name),
[toolCall.name, toolDisplay.family, toolDisplay.label],
);
const searchResultItems = useMemo(() => {
if (!isUnifiedWebSearchToolName(toolCall.name)) {
return [];
@@ -764,11 +716,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
return resolveSearchResultPreviewItemsFromText(toolCall.result?.output);
}, [toolCall.name, toolCall.result?.output]);
const searchSemantic = useMemo(
() =>
classifySearchQuerySemantic(extractSearchQueryLabelFromInfo(toolCall)),
[toolCall],
);
const hasResultImages = resultImages.length > 0;
const hasSearchResults = searchResultItems.length > 0;
const isToolSearch = useMemo(
@@ -865,11 +812,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
<div className="truncate text-sm text-slate-700">
{groupedChildLine}
</div>
{shouldShowRawToolName ? (
<div className="mt-0.5 truncate text-xs text-slate-500">
{humanizeToolNameFromInfo(toolCall.name)}
</div>
) : null}
</div>
<div className="ml-auto flex items-center gap-1 pt-0.5">
{openableFilePath && onFileClick && (
@@ -920,11 +862,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
<div className="truncate text-sm text-slate-900">
{toolHeadline}
</div>
{shouldShowRawToolName ? (
<div className="mt-0.5 truncate text-xs text-slate-500">
{humanizeToolNameFromInfo(toolCall.name)}
</div>
) : null}
</div>
<div className="ml-auto flex items-center gap-1 pt-0.5">
@@ -979,9 +916,6 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
{hasSearchResults && isExpanded && (
<div className="mb-2 ml-6 mt-1.5">
<div className="mb-2 flex flex-wrap gap-2 text-[11px] text-slate-500">
<span>{searchSemantic.label}</span>
</div>
<SearchResultPreviewList
items={searchResultItems}
onOpenUrl={handleOpenExternalUrl}
@@ -995,14 +929,14 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
className="rounded-md px-2 py-1 text-[11px] text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700"
aria-label={
showRawSearchResultOutput
? "收起搜索原始输出"
: "查看搜索原始输出"
? "收起搜索文本详情"
: "查看搜索文本详情"
}
onClick={() =>
setShowRawSearchResultOutput((current) => !current)
}
>
{showRawSearchResultOutput ? "收起原始输出" : "查看原始输出"}
{showRawSearchResultOutput ? "收起文本详情" : "查看文本详情"}
</button>
</div>
) : null}
@@ -1011,60 +945,10 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
{toolSearchSummary && isExpanded ? (
<div
className="mb-2 ml-6 mt-1.5 space-y-2"
className="mb-2 ml-6 mt-1.5"
data-testid="tool-call-tool-search-result"
>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
<span>匹配工具:{toolSearchSummary.count} 个</span>
{toolSearchSummary.query ? (
<span className="break-all">查询:{toolSearchSummary.query}</span>
) : null}
{typeof toolSearchSummary.totalDeferredTools === "number" ? (
<span>Deferred 总数:{toolSearchSummary.totalDeferredTools}</span>
) : null}
</div>
{toolSearchSummary.notes.length > 0 ? (
<div className="space-y-1 text-[11px] text-amber-700">
{toolSearchSummary.notes.map((note, index) => (
<div key={`${note}-${index}`}>{note}</div>
))}
</div>
) : null}
{toolSearchSummary.tools.length > 0 ? (
<div className="space-y-2">
{toolSearchSummary.tools.map((item) => {
const sourceLabel = resolveToolSearchItemSourceLabel(item);
const statusLabel = resolveToolSearchItemStatusLabel(item);
return (
<div
key={item.name}
className="rounded-[14px] border border-slate-200 bg-white p-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-slate-900">
{item.name}
</span>
{sourceLabel ? (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
{sourceLabel}
</span>
) : null}
{statusLabel ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">
{statusLabel}
</span>
) : null}
</div>
{item.description ? (
<div className="mt-1 text-[11px] leading-5 text-slate-500">
{item.description}
</div>
) : null}
</div>
);
})}
</div>
) : null}
<ToolSearchSummaryPanel summary={toolSearchSummary} />
</div>
) : null}
@@ -1109,8 +993,11 @@ export const ToolCallDisplay: React.FC<ToolCallDisplayProps> = ({
</div>
) : null}
{resultPath ? (
<div className="break-all text-[11px] text-slate-500">
{resultPath.label}: {resultPath.value}
<div
className="break-all text-[11px] text-slate-500"
title={resultPath.value}
>
{resultPath.label}: {resultPath.displayValue}
</div>
) : null}
<div
@@ -1355,9 +1242,6 @@ function SearchToolCallGroup({
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
}) {
const [expanded, setExpanded] = useState(true);
const semanticSummaries = summarizeSearchQuerySemantics(
toolCalls.map(extractSearchQueryLabelFromInfo),
);
const headline = buildToolGroupHeadlineFromInfo(toolCalls);
const queryPreview = toolCalls
.slice(0, 2)
@@ -1392,15 +1276,6 @@ function SearchToolCallGroup({
)}
/>
</button>
{semanticSummaries.length > 0 ? (
<div className="ml-6 flex flex-wrap gap-x-3 gap-y-1 pb-1 text-[11px] text-slate-500">
{semanticSummaries.map((item) => (
<span key={item.key}>
{item.label} {item.count}
</span>
))}
</div>
) : null}
{expanded ? (
<div className="ml-6 space-y-1">
{toolCalls.map((toolCall, index) => (
@@ -0,0 +1,105 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ToolSearchSummaryPanel } from "./ToolSearchSummaryPanel";
interface RenderResult {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: RenderResult[] = [];
function renderPanel(
summary: ComponentProps<typeof ToolSearchSummaryPanel>["summary"],
): RenderResult {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(<ToolSearchSummaryPanel summary={summary} />);
});
const rendered = { container, root };
mountedRoots.push(rendered);
return rendered;
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
});
describe("ToolSearchSummaryPanel", () => {
it("应优先展示用户能看懂的工具标签,并隐藏内部状态标签", () => {
const { container } = renderPanel({
query: "browser click",
count: 3,
notes: ["未命中任何 deferred 工具"],
tools: [
{
name: "Read",
source: "native_registry",
alwaysVisible: true,
},
{
name: "mcp__playwright__browser_click",
source: "extension",
extensionName: "mcp__playwright",
status: "deferred",
deferredLoading: true,
},
{
name: "WebSearch",
source: "native_registry",
status: "loaded",
},
],
});
expect(container.textContent).toContain("找到工具:3 个");
expect(container.textContent).toContain("查询:browser click");
expect(container.textContent).toContain("查看文件");
expect(container.textContent).toContain("页面点击");
expect(container.textContent).toContain("搜索网页");
expect(container.textContent).toContain("没有找到更多匹配工具");
expect(container.textContent).not.toContain("Read");
expect(container.textContent).not.toContain("mcp__playwright__browser_click");
expect(container.textContent).not.toContain("WebSearch");
expect(container.textContent).not.toContain("来源:");
expect(container.textContent).not.toContain("状态:");
expect(container.textContent).not.toContain("原生工具");
expect(container.textContent).not.toContain("扩展工具");
expect(container.textContent).not.toContain("已加载");
expect(container.textContent).not.toContain("默认可见");
expect(container.textContent).not.toContain("待加载");
});
it("内部筛选语法查询不应直接展示给用户", () => {
const { container } = renderPanel({
query: "select:Read,Write",
count: 2,
notes: [],
tools: [{ name: "Read" }, { name: "Write" }],
});
expect(container.textContent).toContain("找到工具:2 个");
expect(container.textContent).not.toContain("查询:");
expect(container.textContent).not.toContain("select:Read,Write");
});
});
@@ -0,0 +1,88 @@
import {
type ToolSearchResultSummary,
resolveUserFacingToolSearchItemLabel,
} from "../utils/toolSearchResultSummary";
interface ToolSearchSummaryPanelProps {
summary: ToolSearchResultSummary;
testId?: string;
}
function shouldShowUserFacingQuery(query: string | undefined): boolean {
const normalized = query?.trim();
if (!normalized) {
return false;
}
return !/^(?:select|tool|tools|name|tag):/i.test(normalized);
}
function resolveUserFacingToolSearchNote(note: string): string | null {
const trimmed = note.trim();
if (!trimmed) {
return null;
}
if (/未命中.*deferred/i.test(trimmed)) {
return "没有找到更多匹配工具";
}
if (
/(?:always[_\s-]?visible|native[_\s-]?registry|extension[_\s-]?name|total[_\s-]?deferred|caller)/i.test(
trimmed,
)
) {
return null;
}
return trimmed.replace(/\bdeferred\b/gi, "更多").trim();
}
export function ToolSearchSummaryPanel({
summary,
testId,
}: ToolSearchSummaryPanelProps) {
const userFacingNotes = summary.notes
.map((note) => resolveUserFacingToolSearchNote(note))
.filter((note): note is string => Boolean(note));
return (
<div className="space-y-2" data-testid={testId}>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
<span>找到工具:{summary.count} 个</span>
{shouldShowUserFacingQuery(summary.query) ? (
<span className="break-all">查询:{summary.query}</span>
) : null}
</div>
{userFacingNotes.length > 0 ? (
<div className="space-y-1 text-[11px] text-amber-700">
{userFacingNotes.map((note, index) => (
<div key={`${note}-${index}`}>{note}</div>
))}
</div>
) : null}
{summary.tools.length > 0 ? (
<div className="flex flex-wrap gap-2">
{summary.tools.map((item) => {
const label = resolveUserFacingToolSearchItemLabel(item.name);
const rawName = item.name.trim();
return (
<div
key={item.name}
title={label !== rawName ? rawName : undefined}
className="inline-flex min-h-9 items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-700"
>
<span className="font-medium text-slate-900">{label}</span>
</div>
);
})}
</div>
) : null}
</div>
);
}
export default ToolSearchSummaryPanel;
@@ -58,6 +58,8 @@ export function buildGeneralWorkbenchWorkflowPanelProps({
onAddImage,
onImportDocument,
creationTaskEventsCount,
showBranchRecords: workflowPanelState.showBranchRecords,
onToggleBranchRecords: workflowPanelState.toggleBranchRecords,
showCreationTasks: workflowPanelState.showCreationTasks,
onToggleCreationTasks: workflowPanelState.toggleCreationTasks,
groupedCreationTaskEvents: workflowPanelState.groupedCreationTaskEvents,

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