mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.10.0
This commit is contained in:
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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`,统一承接:
|
||||
|
||||
|
||||
@@ -213,6 +213,7 @@ lime/
|
||||
### 7. 多 Provider 与兼容层
|
||||
- OAuth 与 API Key Provider 并存
|
||||
- 凭证池、模型路由、协议兼容与 HTTP Server 作为底层支撑
|
||||
- Prompt Cache 等运行时能力按 ProviderType 判断;`anthropic-compatible` 只表示 Anthropic wire format 兼容,不等于自动 Prompt Cache 能力
|
||||
|
||||
### 8. 本地优先与可扩展
|
||||
- 桌面应用、本地工作区、插件与外部工具扩展
|
||||
|
||||
@@ -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 模型区会提示“模型真相源异常”,而不是静默显示空态
|
||||
|
||||
### 社媒内容工作流
|
||||
|
||||
|
||||
@@ -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 没发字段
|
||||
|
||||
排查这类问题时,优先确认三件事:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
入口:
|
||||
|
||||
@@ -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. 可以进入设置页并看到版本信息
|
||||
|
||||
## 常见安装问题
|
||||
|
||||
@@ -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 与资源页如何协同
|
||||
|
||||
@@ -15,15 +15,14 @@ navigation:
|
||||
- **AI Agent**:对话、任务推进、内容初稿
|
||||
- **项目**:按创作目标管理长期内容
|
||||
- **资源**:统一查看文档、图片、语音、视频
|
||||
- **图片生成**:生成图片、参考图编辑、结果回流资源库
|
||||
- **设置**:调整主题、模块开关、连接与高级选项
|
||||
|
||||
## 推荐工作方式
|
||||
|
||||
1. 先在项目中选择一个主题方向
|
||||
2. 在 AI Agent 中完成结构和首稿
|
||||
3. 需要视觉时进入图片生成
|
||||
4. 回到资源库统一管理结果
|
||||
3. 需要视觉时在 AI Agent 中使用 `@素材` 或图片任务
|
||||
4. 回到资源库统一管理本地图片、图库素材与回流结果
|
||||
|
||||
## 主题方向
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ navigation:
|
||||
你可以按使用习惯定制入口:
|
||||
|
||||
- 启用或停用工作区主题(如社媒、短视频、小说)
|
||||
- 启用或停用导航模块(如 AI Agent、项目、图片生成、终端、工具、插件)
|
||||
- 启用或停用导航模块(如 AI Agent、项目、资源、设置、插件)
|
||||
|
||||
这样可以让侧边栏更聚焦,减少干扰。
|
||||
|
||||
@@ -49,7 +49,7 @@ navigation:
|
||||
|
||||
### 个人创作者
|
||||
|
||||
- 保留:AI Agent、项目、资源、图片生成
|
||||
- 保留:AI Agent、项目、资源
|
||||
- 关闭:暂时不用的高级模块
|
||||
- 目的:让工作台聚焦在“日常产出”
|
||||
|
||||
|
||||
@@ -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 中对应图片任务是否已经成功并完成入库
|
||||
- 回资源页切换“全部”核对总量
|
||||
|
||||
## 生成失败或超时
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 思路时,真正应该抓住的主线。
|
||||
@@ -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 文案。
|
||||
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"description": "Lime 官方任务 CLI",
|
||||
"bin": {
|
||||
"lime": "scripts/run.js"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 字段。",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Generated
+19
-19
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ React 组件层,包含 UI 组件和业务组件。
|
||||
- `smart-input/` - 截图/语音浮窗共享组件(当前仅保留快捷键设置)
|
||||
- `settings-v2/` - 设置页面组件(当前主实现)
|
||||
- `skills/` - 技能管理组件
|
||||
- `terminal/` - 内置终端组件(使用 Tauri Commands)
|
||||
- `tools/` - 工具页面组件
|
||||
- `widgets/` - 右侧小部件栏组件(移植自 Waveterm)
|
||||
- `ui/` - 通用 UI 组件(按钮、输入框等)
|
||||
- `websocket/` - WebSocket 管理组件
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-6
@@ -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
Reference in New Issue
Block a user