diff --git a/.github/workflows/harness-nightly.yml b/.github/workflows/harness-nightly.yml index ef403d372..52e71b1e0 100644 --- a/.github/workflows/harness-nightly.yml +++ b/.github/workflows/harness-nightly.yml @@ -43,6 +43,28 @@ jobs: --cleanup-json "./artifacts/harness-cleanup-report.json" \ --cleanup-markdown "./artifacts/harness-cleanup-report.md" \ --dashboard-html "./artifacts/harness-dashboard.html" + mkdir -p "./artifacts/agent-qc" + node scripts/agent-qc-report.mjs \ + --output "./artifacts/agent-qc/agent-qc-report.md" + node scripts/agent-qc-report.mjs \ + --format json \ + --output "./artifacts/agent-qc/agent-qc-report.json" + node scripts/agent-qc-gui-flow-report.mjs \ + --output "./artifacts/agent-qc/agent-qc-gui-flow-report.md" + node scripts/agent-qc-gui-flow-report.mjs \ + --format json \ + --output "./artifacts/agent-qc/agent-qc-gui-flow-report.json" + node scripts/agent-qc-release-summary.mjs \ + --allow-missing-evidence \ + --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \ + --require-risk "P0" \ + --output "./artifacts/agent-qc/release-agent-qc-preview.md" + node scripts/agent-qc-release-summary.mjs \ + --allow-missing-evidence \ + --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \ + --require-risk "P0" \ + --format json \ + --output "./artifacts/agent-qc/release-agent-qc-preview.json" - name: Upload harness eval artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2765dc703..3fda741c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,18 @@ on: description: "Git ref to build from (e.g., v0.96.0 or main)" required: false default: "" + agent_qc_evidence_path: + description: "Path to Agent QC Evidence Pack JSON; release is blocked if missing or non-pass" + required: false + default: ".lime/qc/agent-qc-evidence.json" + agent_qc_harness_summary_path: + description: "Optional harness-eval-summary.json path to include in Agent QC release notes" + required: false + default: "" + agent_qc_harness_trend_path: + description: "Optional harness-eval-trend.json path to include in Agent QC release notes" + required: false + default: "" permissions: contents: write @@ -51,12 +63,38 @@ jobs: TAG="${{ github.event.inputs.version || github.ref_name }}" TARGET_REF="${{ github.event.inputs.source_ref || github.sha }}" NOTES_FILE="$RUNNER_TEMP/release-notes.md" + AGENT_QC_NOTES_FILE="$RUNNER_TEMP/agent-qc-release-notes.md" + AGENT_QC_EVIDENCE_PATH="${{ github.event.inputs.agent_qc_evidence_path || '.lime/qc/agent-qc-evidence.json' }}" + AGENT_QC_HARNESS_SUMMARY_PATH="${{ github.event.inputs.agent_qc_harness_summary_path || '' }}" + AGENT_QC_HARNESS_TREND_PATH="${{ github.event.inputs.agent_qc_harness_trend_path || '' }}" if [[ "$TAG" == *-* ]]; then echo "::error::Stable updater release does not accept prerelease tag: $TAG" exit 1 fi + if [ ! -f "$AGENT_QC_EVIDENCE_PATH" ]; then + echo "::error::Agent QC Evidence Pack is required before release: $AGENT_QC_EVIDENCE_PATH" + echo "::error::Run qcloop P0 scenarios, export evidence with npm run agent-qc:export-evidence, then rerun release with agent_qc_evidence_path." + exit 1 + fi + + agent_qc_args=( + --evidence "$AGENT_QC_EVIDENCE_PATH" + --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" + --require-risk "P0" + --tag "$TAG" + --output "$AGENT_QC_NOTES_FILE" + --check + ) + if [ -n "$AGENT_QC_HARNESS_SUMMARY_PATH" ]; then + agent_qc_args+=(--harness-summary "$AGENT_QC_HARNESS_SUMMARY_PATH") + fi + if [ -n "$AGENT_QC_HARNESS_TREND_PATH" ]; then + agent_qc_args+=(--harness-trend "$AGENT_QC_HARNESS_TREND_PATH") + fi + node scripts/agent-qc-release-summary.mjs "${agent_qc_args[@]}" + if [ -f RELEASE_NOTES.md ]; then cp RELEASE_NOTES.md "$NOTES_FILE" else @@ -74,6 +112,10 @@ jobs: echo "${CHANGELOG}" } > "$NOTES_FILE" fi + { + echo + cat "$AGENT_QC_NOTES_FILE" + } >> "$NOTES_FILE" if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release edit "$TAG" \ diff --git a/.gitignore b/.gitignore index ae3f87b6c..5ec24fdb4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist/ src-tauri/.cargo/ *.exe *.pdb +output/ # IDE .vscode/ @@ -66,6 +67,8 @@ docs/roadmap/* !docs/roadmap/managed-objective/*.md !docs/roadmap/ai-layered-design/ !docs/roadmap/ai-layered-design/*.md +!docs/roadmap/i18n/ +!docs/roadmap/i18n/*.md docs/gongzonghao/ docs/bussniss/ docs/oem/ diff --git a/AGENTS.md b/AGENTS.md index b1143d7ab..9591a6555 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,9 +41,10 @@ 5. **高风险 GUI 改动必须做最小冒烟** - 涉及 GUI 壳、DevBridge、Workspace、主路径时执行 `npm run verify:gui-smoke` 6. **Playwright 续测优先稳定桌面 Chrome 会话** - 真实交互验证优先复用已有 Lime 页签;需要新启浏览器时走持久化 Chrome 上下文,避免本地桌面出现自动化横幅或 `--no-sandbox` 安全横幅,细则见 `docs/aiprompts/playwright-e2e.md` 7. **用户可见 UI 改动必须补稳定回归** - 优先补现有 `*.test.tsx` 或 snapshot 断言 -8. **配置与依赖改动要成组更新** - schema、校验器、消费者、文档、锁文件保持同步 -9. **Rust 变更先小测后全量** - 先跑受影响 crate / 模块 / 定向测试;新增模块尽量控制在 `500 LoC` 内,文件接近 `800 LoC` 时优先拆新模块 -10. **Harness Engine 只认单一事实源** - handoff / evidence / replay / analysis / review / GUI 统一消费 `agent_runtime_export_evidence_pack`;`requestTelemetry` 需要按 `session/thread/turn` 真实关联导出,无匹配请求时输出空摘要,不再保留伪 `unlinked` +8. **新增用户可见文案必须走 current i18n** - 新功能的按钮、标题、空态、toast、confirm、prompt、placeholder、aria/title 与错误提示必须使用 key-based resources;legacy DOM Patch 只作迁移兜底,不得作为新功能事实源 +9. **配置与依赖改动要成组更新** - schema、校验器、消费者、文档、锁文件保持同步 +10. **Rust 变更先小测后全量** - 先跑受影响 crate / 模块 / 定向测试;新增模块尽量控制在 `500 LoC` 内,文件接近 `800 LoC` 时优先拆新模块 +11. **Harness Engine 只认单一事实源** - handoff / evidence / replay / analysis / review / GUI 统一消费 `agent_runtime_export_evidence_pack`;`requestTelemetry` 需要按 `session/thread/turn` 真实关联导出,无匹配请求时输出空摘要,不再保留伪 `unlinked` ## 执行与路线图 diff --git a/README.en.md b/README.en.md new file mode 100644 index 000000000..6100b77a9 --- /dev/null +++ b/README.en.md @@ -0,0 +1,251 @@ +
+ +Lime README hero banner: Spark ideas with a touch of green + +# Lime + +### Spark ideas with a touch of green + +**Open-source AI content workspace for Chinese creators** + +Desktop writing, research, prompt management, knowledge base, and multi-model workflows for long-form content work. + +[简体中文](./README.md) · **English** · [Docs](./docs/README.md) · [Release Notes](./RELEASE_NOTES.md) · [Issues](https://github.com/limecloud/lime/issues) + +

+ Lime GitHub Release + Lime supports macOS and Windows + Lime is a Tauri desktop app + Lime GPLv3 license +

+ +Lime keeps references, ideas, generation, revision, and review in one place, so content creation becomes a workflow you can continue instead of a one-off chat. + +The Simplified Chinese README is the primary version. This English page is a companion for international readers. + +
+ +--- + +
+Table of Contents + +- [What is Lime?](#what-is-lime) +- [What you can do with Lime](#what-you-can-do-with-lime) +- [Real creator workflows](#real-creator-workflows) +- [A simple workflow](#a-simple-workflow) +- [Core Workflow](#core-workflow) +- [Who Lime is for](#who-lime-is-for) +- [If you are searching for these tools](#if-you-are-searching-for-these-tools) +- [Who Lime is not for](#who-lime-is-not-for) +- [Quick Start](#quick-start) +- [Tech Stack and Platforms](#tech-stack-and-platforms) +- [FAQ](#faq) +- [License](#license) +- [Disclaimer](#disclaimer) + +
+ +--- + +## What is Lime? + +Lime is an open-source Tauri desktop AI workspace for Chinese creators, brand operators, research writers, and small teams. It brings AI writing, topic research, reference management, prompt reuse, knowledge organization, and multi-model workflows into one local desktop product. + +Think of Lime as an AI workspace for long-running content projects: + +- Not just one prompt and one answer, but a project that can keep moving forward. +- Not repeatedly collecting references and rewriting prompts, but saving context, style, and repeatable methods. +- Not leaving generated results scattered across chat history, but keeping useful outputs available for the next round. +- Not locking you into one AI service, but letting you use the providers and models you already configure. + +If you often switch between bookmarks, documents, chat tools, image tools, and model dashboards, Lime is designed to bring those steps back into one creation space. + +--- + +## What you can do with Lime + +- **AI writing and content creation**: write WeChat articles, Xiaohongshu notes, video scripts, podcast outlines, and live-stream talking points. +- **Research and knowledge organization**: collect web pages, notes, screenshots, interviews, and historical materials, then turn them into reports or briefs. +- **Topic analysis and content review**: analyze viral posts, competitors, publishing rhythm, and expression style. +- **Prompt management and style reuse**: save writing patterns, brand tone, topic methods, and team templates. +- **Multimodal preparation**: draft image prompts, cover directions, slide outlines, or web page drafts. +- **Multi-model workflows**: use your own AI providers and models in the same task to revise, expand, compress, or adapt content for another platform. + +--- + +## Real creator workflows + +### 1. WeChat writer: too many references, no clear draft + +You want to write a timely opinion article. The browser has a dozen links open, WeChat favorites hold a few ideas, and you already have a position, but the draft still feels scattered. + +With Lime, you can put references and rough notes into the same task, ask AI to organize angles and argument order, then continue asking: which part is not sharp enough? Which paragraph sounds generic? Can the title be more clickable without becoming clickbait? + +What remains is not just one answer. It is a traceable path from research to first draft, then from first draft to final version. + +### 2. Xiaohongshu creator: one topic, many expressions + +You may have a strong topic, but it needs to become a practical guide, a story, a product recommendation, and an interactive comment prompt. Rewriting each version is tiring, and your own style is easy to lose. + +In Lime, you can save your usual tone, successful past notes, and title preferences. The next generation is not just "write one post for me"; it tries to continue in your own expression pattern. + +### 3. Brand operator: today's delivery is more than one piece of copy + +A product launch needs key visual copy, social posts, community warm-up messages, FAQs, short-video scripts, and an explanation that leadership can read. Source material comes from product docs, user feedback, competitor pages, and meeting notes. + +Lime fits this kind of continuous task: organize selling points first, generate multiple expressions, then split results across channels. After one revision round, you can review which wording is clearer and reuse it in the next round. + +### 4. Research writer: more sources need a better thinking space + +You may be preparing an industry observation, course material, topic study, or in-depth report. The challenge is not a lack of information. It is turning too much material into your own structure. + +With Lime, you can add materials gradually, let AI classify them, then keep asking about contradictions, gaps, key judgments, and possible content angles. It behaves more like a workspace that helps you repeatedly inspect sources and structure ideas. + +### 5. Small team: not everyone should start from a blank prompt + +One teammate may be good at titles, another at topic selection, another at reviewing data. These skills often stay in individual heads and are hard to reuse. + +Lime can turn stable methods into reusable task entries. The next time a new teammate writes a weekly report, reviews a campaign, analyzes competitors, or prepares a launch draft, they do not need to start from a blank input box. + +--- + +## A simple workflow + +1. Create a task, such as "write a WeChat article about choosing AI tools". +2. Add references, ideas, past articles, or project background. +3. Choose your AI provider and model. +4. Let Lime organize direction, outline, or source structure first. +5. Continue generating, revising, compressing, expanding, or adapting the content in the same task. +6. Save useful results as references for the next creation round. + +In short: bring the material in, let AI help move it forward, and keep the useful results. + +--- + +## Core Workflow + +### Start from one task + +Lime start from one task feature image + +Start with one goal and keep references, models, reusable methods, and recent outputs in one place, without first facing a complex tool menu. + +### Keep revising in the same workspace + +Lime refine in one workspace feature image + +Generation, follow-up questions, revisions, research, and result organization all stay around the current task. This fits articles, reports, scripts, and plans that need multiple rounds of work. + +### Use your own AI services + +Lime use your own AI services feature image + +Lime does not provide AI model services. You configure your own providers, provider keys, and preferred models, then use different capabilities for different content tasks. + +--- + +## Who Lime is for + +- Content creators, independent media writers, video creators, and Xiaohongshu creators. +- Brand, operations, growth, private-domain, and founder-led marketing teams. +- People who often organize materials, write reports, conduct research, and publish opinions. +- People who want to keep personal writing methods, team templates, and reference materials. +- People already using AI models who want a more stable desktop creation workspace. + +--- + +## If you are searching for these tools + +Lime may fit searches such as AI content workspace, desktop AI app, AI writing tool, prompt management, knowledge base, research workflow, multi-model workflow, Chinese creators, 内容创作工作台, 桌面端 AI 应用, 公众号写作, 小红书创作, 选题研究, 素材管理, 提示词管理, 知识库, and 多模型创作流程. + +--- + +## Who Lime is not for + +- People who only want to open a web page and ask one casual question. +- People who do not want to configure any AI provider or API key. +- People who expect a tool to automatically judge, publish, and take responsibility for the final result. + +Lime is for creators who treat AI as a creation partner: you provide judgment, references, and direction; it helps organize, generate, revise, and review. + +--- + +## Quick Start + +### Download and install + +Download the installer for your platform from [Releases](https://github.com/limecloud/lime/releases). + +- macOS users can download the `.dmg` package or install with Homebrew. +- Windows users can download `Lime_*_x64-setup.exe`. +- Lime currently publishes macOS and Windows builds only. Linux desktop builds are paused. +- If Windows SmartScreen appears, it usually means the installer is unsigned or has not built enough signing reputation. It does not necessarily mean the installer is broken. + +macOS users who use Homebrew can run: + +```bash +brew tap aiclientproxy/tap +brew install --cask lime +``` + +### First run + +1. Open Lime. +2. Go to the AI provider configuration page. +3. Enter your own provider key and test the connection. +4. Return to the home page and create a content task. +5. Add references or write a goal directly, then start generating. + +--- + +## Tech Stack and Platforms + +- Desktop framework: Tauri 2, Rust +- Frontend: React, TypeScript, Vite +- Supported platforms: macOS, Windows +- License: GPLv3 + +--- + +## FAQ + +### Does Lime provide AI models? + +No. Lime is a creation workspace and does not directly provide model services. You need to configure your own available AI provider and provider key. If you are unfamiliar with provider keys, think of them as credentials issued by an AI service provider. + +### Will all my materials be uploaded? + +Lime prioritizes storing project materials, conversation history, and configuration locally. When you call AI generation, however, relevant input is sent to the AI provider you configured. Please decide whether to use sensitive material according to that provider's policy. + +### How is Lime different from a normal chat tool? + +A normal chat tool is closer to one question and one answer. Lime emphasizes long-running creation: references can be saved, results can be retained, tasks can continue, and reusable methods can become part of your workflow. + +### Can I use it without knowing how to write prompts? + +Yes. One goal of Lime is to reduce the cost of starting from a blank prompt every time. You can begin with reusable tasks, existing materials, and historical results, then let AI help you move forward step by step. + +--- + +## License + +[GNU General Public License v3 (GPLv3)](https://www.gnu.org/licenses/gpl-3.0) + +## Disclaimer + +This project is provided for learning and research purposes only. Users are responsible for their own use and risk. + +Lime does not directly provide AI model services. Model capabilities are provided by third-party AI service providers configured by the user. + +--- + +
+ +### WeChat Community + +Lime WeChat community QR code + +Scan the QR code and mention `Lime` to join the discussion group. + +
diff --git a/README.md b/README.md index eec084c45..5a93998a1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -
+
-Lime AI 内容工作台 Logo +Lime README 主视觉:青柠一下,灵感即来 # Lime @@ -10,6 +10,8 @@ AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows. +**简体中文** · [English](./README.en.md) · [文档](./docs/README.md) · [发布记录](./RELEASE_NOTES.md) · [问题反馈](https://github.com/limecloud/lime/issues) +

Lime GitHub Release Lime supports macOS and Windows @@ -23,6 +25,27 @@ AI content workspace for Chinese creators: desktop writing, research, prompt man --- +

+目录 + +- [Lime 是什么](#lime-是什么) +- [你可以用 Lime 做什么](#你可以用-lime-做什么) +- [几个创作者会遇到的真实时刻](#几个创作者会遇到的真实时刻) +- [一次创作可以这样开始](#一次创作可以这样开始) +- [核心工作流](#核心工作流) +- [适合谁](#适合谁) +- [如果你在找这些工具](#如果你在找这些工具) +- [不太适合谁](#不太适合谁) +- [快速开始](#快速开始) +- [技术栈与平台](#技术栈与平台) +- [常见问题](#常见问题) +- [开源协议](#开源协议) +- [免责声明](#免责声明) + +
+ +--- + ## Lime 是什么 Lime 是一个开源的 Tauri 桌面端 AI 内容工作台,面向中文创作者、品牌运营、研究型写作者和小团队,覆盖 AI 写作、选题研究、素材管理、提示词沉淀、知识库和多模型创作流程。 @@ -100,23 +123,23 @@ Lime 可以把稳定有效的做法沉淀成常用任务入口:下次新人写 --- -## 界面预览 +## 核心工作流 ### 从一个任务开始 -Lime 新建任务与生成入口预览 +Lime 从一个任务开始功能图 你可以从一句目标开始,把资料、模型、常用做法和最近结果放在同一个地方,不用先面对一整面复杂工具菜单。 ### 在同一个创作空间里持续修改 -Lime 生成与执行过程预览 +Lime 同一空间持续打磨功能图 生成、追问、修改、查找资料和整理结果都围绕当前任务展开。适合需要多轮打磨的文章、报告、脚本和方案。 ### 使用自己的 AI 服务 -Lime AI 服务商与模型配置预览 +Lime 使用自己的 AI 服务功能图 Lime 本身不提供 AI 模型服务。你可以配置自己的 AI 服务商、服务商密钥和常用模型,让不同内容任务使用不同能力。 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e57877a0a..84cb531e2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,70 @@ +## Lime v1.33.0 + +发布日期:`2026-05-11` +递交范围:完整 dirty worktree,包含 tracked、deleted 与新增文件;`.lime/` 等本地运行态忽略目录不纳入发布提交。 + +> 发布说明:上一版 release tag 为 `v1.32.0`。本版按用户要求升级到 `v1.33.0`,并把本轮待递交的全部源码、文档、脚本、测试与发布证据索引纳入 release notes。 + +### 发布概览 + +- 应用版本从 `1.32.0` 升级到 `1.33.0`,同步 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`、`packages/lime-cli-npm/package.json` 与 `@limecloud/lime-cli` 发布示例。 +- Agent QC 发布门禁补齐 scenario / GUI flow / Evidence Pack schema、qcloop job / status / preflight / export / release-summary / audit 脚本,以及 GitHub release workflow 的 P0 Evidence Pack 硬门禁。 +- Agent Runtime 与 Team / Workspace 主链补齐 approval sandbox、Claw chat ready streaming、tool surface、service skill entry、browser runtime、knowledge GUI 与 GUI owner 检查脚本,降低只靠浅层 smoke 误判的风险。 +- i18n 主链从 legacy DOM Patch 迁到 key-based resources:新增 `createI18n`、namespace loader、locale resources、格式化工具与覆盖测试,旧 patch 文件移入 `src/i18n/legacy-patch/` 并从 current 入口退场。 +- 设置 v2、侧边栏、Agent 输入栏、任务中心、Skills 页面与系统诊断页完成一批用户可见回归,覆盖账号、统计、用户中心、Provider、媒体能力、语音、快捷键、环境、渠道日志与工作区修复历史。 +- 对话功能 E2E 证据新增到 `output/dialogue-feature-e2e-20260510/`,覆盖 24 个对话 / 任务 / artifact / 搜索 / 多媒体场景截图、manifest 与审计 JSON。 +- 清理旧版本临时证据与旧实现残留:删除根目录旧 Playwright JSON、旧 qcloop 截图、旧 i18n patch 入口文件,避免 `v1.32.0` 以前的临时产物继续混入 release 范围。 + +### 用户可见更新 + +#### 1. Agent 对话、任务与工作台 + +- Agent 输入栏、轻量预览、任务中心 tab、Team Workspace board、curated task reference selection 与 fast response model 增加更稳定的状态与测试覆盖。 +- Claw chat ready streaming smoke 固化为可复跑入口,覆盖 ready 状态、流式输出、cancel / provider fixed 场景与 GUI evidence 记录。 +- AppSidebar 会话 shelf 与会话格式化补齐测试,历史会话、队列状态和工作区入口展示更稳定。 + +#### 2. 设置页与能力配置 + +- 设置 v2 增强账号资料、统计、用户中心会话、Provider、媒体服务、图片 / 视频 / 语音能力、快捷键、开发者、环境和渠道日志页面。 +- Companion capability preferences、MediaPreferenceSection 与 Provider 页面补齐能力偏好和模型配置回归。 +- WorkspaceRepairHistoryCard、ChannelLogTailPanel 与渠道日志 filter 补齐可见状态和过滤测试。 + +#### 3. 本地化与旧版本清理 + +- current i18n resources 覆盖 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 的 `common`、`navigation`、`workspace`、`agent`、`settings`、`errors` namespace。 +- 新增缺失翻译检测、legacy patch metrics report 与 namespace load / locale / format / type 测试,避免新增用户可见文案回落到旧 DOM patch。 +- 旧 `I18nPatchProvider`、`dom-replacer`、`patches/*.json` 与 `text-map` 已从 current 位置删除,仅作为 `legacy-patch` 迁移目录保留。 + +### 开发者与治理更新 + +- Agent QC 文档新增 `docs/test/*.json`、`docs/tests/*`、completion audit、current blockers、evidence contract、qcloop operations、rollout plan 与 stale worker 分析,发布证据口径回到仓库内版本化事实源。 +- `npm run test:contracts` 增加 `agent-qc:check`,同时校验 Agent QC scenario manifest 与 GUI flow manifest。 +- `scripts/quality-task-planner`、`local-ci`、`verify-gui-smoke` 和各 smoke runner 同步 Agent QC / GUI 主路径任务选择。 +- `.github/workflows/release.yml` 和 nightly harness workflow 补齐 Agent QC 发布门禁,不再允许缺 P0 Evidence Pack 的 release note 进入绿色发布。 +- `legacySurfaceCatalog`、`agentCommandCatalog`、DevBridge dispatcher、mockPriorityCommands 与 tauri mock 同步 runtime / tool / QC 新边界。 +- Rust 发布门禁收口三处稳定性问题:MCP tool search 精确 inner tool name 优先于后缀命中,Fal queue 超时测试禁用本机代理并加宽 client timeout,Agent 流式策略测试改用注入式内存 `SessionStore`,避免全量并发测试写全局 SQLite 时出现 I/O 抖动。 + +### 已知边界 + +- 官方 Agent QC Evidence Pack 仍必须由真实 qcloop 8/8 P0 pass 后导出;历史 sidecar、source-tree smoke 或 `.lime/qc` 本地运行态文件不能替代发布证据。 +- `output/dialogue-feature-e2e-20260510/` 是本轮对话功能 E2E 证据,不等同于 installer artifact 验证。 +- release workflow 已具备硬门禁;GitHub Release artifact 仍由 tag 推送后的发布流水线生成。 + +### 校验状态 + +- `npm run verify:app-version`:通过,版本一致性为 `1.33.0`。 +- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:通过。 +- `cargo test --manifest-path "src-tauri/Cargo.toml" --workspace --locked`:通过;使用 `CARGO_HOME=/tmp/lime-cargo-home-v133` 与 `CARGO_TARGET_DIR=src-tauri/target-release-v133-temp-home` 避开本机全局 Cargo 缓存损坏。 +- `npm run lint:rust`:通过;已修复 `lime-media-runtime` 大 Err variant clippy warning,并在后续 MCP / server / agent 测试稳定性修复后复跑通过。 +- `npm run lint`:通过。 +- `npm test`:通过,`54/54` 批退出码为 `0`。 +- `npm run test:contracts`:通过,覆盖 command / harness / modality / harness cleanup / agent-qc scenario 与 GUI flow manifest contract。 +- `git diff --check`:通过。 + +--- + +**完整变更**: `v1.32.0` -> `v1.33.0` + ## Lime v1.32.0 发布日期:`2026-05-10` diff --git a/docs/README.md b/docs/README.md index 5ce6319ff..cdc5b26c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,15 @@ - `develop/execution-tracker-p1-p2-roadmap.md`:统一执行追踪后续路线(P1/P2) - `tech/harness/README.md`:Lime Harness Engineering 总入口 - `tech/harness/implementation-blueprint.md`:Lime Harness 分阶段实施蓝图 +- `tests/agent-ops-qc.md`:Agent 运营级测试体系,定义 qcloop 场景、Evidence Pack 与发布门禁 +- `tests/agent-qc-p0-scenarios.md`:Agent QC P0 场景执行手册,定义核心场景证据与失败沉淀规则 +- `tests/lime-agent-qc-rollout-plan.md`:Lime 样本产品的 Agent 运营级测试分阶段落地计划 +- `../scripts/agent-qc-report.mjs`:Agent QC 场景 manifest 报告与合同检查入口 +- `../scripts/agent-qc-gui-flow-report.mjs`:Agent QC GUI / Playwright MCP flow manifest 报告与合同检查入口 +- `../scripts/agent-qc-qcloop-job.mjs`:从 Agent QC manifest 生成 qcloop job payload 的入口 +- `../scripts/agent-qc-export-evidence.mjs`:qcloop job 到 Agent QC Evidence Pack 的导出入口 +- `../scripts/agent-qc-release-summary.mjs`:将 Agent QC Evidence Pack 与 Harness 报告汇总为 release note 质量证据 +- `../scripts/agent-qc-completion-audit.mjs`:Agent QC 整体目标完成度审计入口 - `aiprompts/query-loop.md`:运行时 Query Loop current 主链与提交边界 - `aiprompts/prompt-foundation.md`:基础 Prompt current 主链、system prompt 组装顺序与 current/compat 边界 - `aiprompts/task-agent-taxonomy.md`:Task / Agent / Coordinator current taxonomy 与边界归属 diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index 4ab2db11c..325456698 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -26,6 +26,9 @@ - `governance.md` - 新旧并存治理、迁移收口、禁止回流 - `harness-engine-governance.md` - Harness Engine 事实源、evidence pack、replay / analysis / review 治理规范 - `quality-workflow.md` - 本地校验、GUI smoke、契约检查、CI 门禁 +- `../tests/agent-ops-qc.md` - Agent 运营级测试体系、qcloop 场景、Evidence Pack 与发布证据门禁 +- `../tests/agent-qc-p0-scenarios.md` - Agent QC P0 场景执行手册、GUI/runtime 证据要求与失败沉淀规则 +- `../tests/lime-agent-qc-rollout-plan.md` - Lime 样本产品的 Agent 运营级测试落地计划 - `command-runtime.md` - `@` / `/` / 轻卡 / viewer / 功能方案包实施手册 - `skill-standard.md` - 统一技能标准、skill / adapter / runtime binding 边界 - `site-adapter-standard.md` - 站点适配器标准、来源导入边界、运行时收敛规则 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 54cfeea2e..22e97633f 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -249,7 +249,7 @@ Skill 执行链路同样遵循单一命令边界。当前前端入口为 `src/li `Claw` 的纯文本封面命令也应沿同一条 current 主链收敛: -- Agent 驱动的封面命令:`@封面` / `@cover` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `cover_task` 写入 `request_metadata.harness.cover_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(cover_generate)` 的系统提示;当前封面 launch 还会在 session permission 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类偏航工具,并在当前 session registry 中直接移除这些 detour tools,避免模型在 `@封面` 首刀前先去搜索工具目录。后续默认 skill 必须先进入 `cover_generate` 的 current binding;若当前 binding family 被注册为 `typed local_cli`,则由 runtime 结构化组装 `Lime CLI` 封面命令(例如 `lime media cover generate` 或等价 cover task CLI),CLI 不可用时再回退 `lime_create_cover_generation_task`。无论选择哪种 executor,最终都只允许落到标准 `cover_generate` task file。 +- Agent 驱动的封面命令:`@封面` / `@cover` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `cover_task` 写入 `request_metadata.harness.cover_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs` 会把当前 turn 固定为 workbench chat mode,并注入只允许首刀优先调用 `Skill(cover_generate)` 的系统提示;当前封面 launch 还会在 session permission 中显式压制 `ToolSearch / WebSearch / Read / Write / Edit / Bash / Glob / Grep / social_generate_cover_image / lime_create_image_generation_task` 这类偏航工具,并在当前 session registry 中直接移除这些 detour tools,避免模型在 `@封面` 首刀前先去搜索工具目录、生成 HTML/SVG 假封面,或把封面退回普通 image task。后续默认 skill 必须先进入 `cover_generate` 的 current binding,并通过 `lime_create_cover_generation_task` 回写标准 `cover_generate` task file;旧的自由 Bash / CLI 拼接只允许停留在人工 ops 或 compat 场景,不能再作为模型首发路径。 `Claw` 的纯文本海报命令也应沿同一条 current 主链收敛: diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index a1cb71e13..4bcdbf4a6 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -230,7 +230,7 @@ npm run verify:gui-smoke -- --include-knowledge-product-e2e --reuse-running 1. 在 `Claw` 对话框输入 `@封面 小红书 标题: 春日咖啡快闪 风格: 清新插画, 1:1 春日咖啡市集封面` 2. 确认聊天区先进入 skill 执行态,并能看到 `cover_generate` 相关工具轨迹,而不是前端静默直接创建任务 -3. 确认 `@封面` 命中了 `cover_generate` 的 current binding;如当前 binding family 是 `typed local_cli`,确认工具标题与结果摘要对应 runtime 结构化组装的 `Lime CLI` 封面执行链;如当前 binding family 是原生结构化 binding,则确认仍回写同一条 `cover_generate` task file。无论哪种,都不应要求模型先写 Bash;CLI 不可用时,才允许回退 `lime_create_cover_generation_task` +3. 确认 `@封面` 命中了 `cover_generate` 的 current binding,并沿 `Skill(cover_generate) -> lime_create_cover_generation_task -> 标准 cover_generate task file` 主链提交任务;不应要求模型先写 Bash、生成 HTML/SVG,或退回普通图片任务 4. 等待任务回流后,确认同一条结果只展示真实 task file 状态,不会额外再插一条前端本地伪造“封面已生成” 5. 如当前界面已暴露右侧查看区或任务卡,确认其状态与聊天轻卡一致,且任务类型显示为 `cover_generate` / 封面任务 6. 刷新页面或切换会话再返回原话题,确认最近封面任务仍可从 `.lime/tasks` 恢复 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 862d51c9b..b1cbd5bd8 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -24,6 +24,12 @@ - `docs/aiprompts/command-runtime.md` +如果改动涉及 Agent 运营级测试、qcloop 批量质检、Evidence Pack 或发布证据门禁,先补读: + +- `docs/tests/agent-ops-qc.md` +- `docs/tests/agent-qc-p0-scenarios.md` +- `docs/tests/lime-agent-qc-rollout-plan.md` + ## 交付定义 对 Lime 来说,“代码通过检查” 不等于 “产品可以交付”。 @@ -34,7 +40,8 @@ 2. **边界变更已同步** - 命令、桥接、配置、版本等结构性改动完成成组更新 3. **GUI 主路径可运行** - 涉及 GUI 壳、Bridge、Workspace、主页面路径时,最小冒烟通过 4. **用户可见回归已补齐** - 用户可见 UI 改动有稳定断言或既有 snapshot 回归 -5. **文档与锁文件不掉队** - 相关文档、schema、锁文件与实际实现保持一致 +5. **本地化事实源正确** - 新增或改动的用户可见产品文案进入 key-based i18n resources,不依赖 legacy DOM Patch 兜底 +6. **文档与锁文件不掉队** - 相关文档、schema、锁文件与实际实现保持一致 ## 路线图任务防跑偏 @@ -127,6 +134,9 @@ ### 3. 用户可见 UI 改动必须补稳定回归 +- 新增功能的按钮、标题、空态、toast、confirm、prompt、placeholder、aria/title 与错误提示必须走 current i18n:`useTranslation(ns)` / `Trans` + `src/i18n/resources//.json` +- legacy DOM Patch 只允许作为迁移期兜底,不允许成为新功能或新文案的本地化事实源;确需临时例外时,必须写入对应路线图或执行计划并说明退出条件 +- 动态用户可见文案使用 i18next interpolation / plural / context;日期、数字、相对时间、列表和排序优先复用 `src/i18n/format.ts` - 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言 - 如果目标区域已有 snapshot / 结构化快照机制,沿用现有机制 - 不要因为“只是 UI”就跳过回归 @@ -234,7 +244,7 @@ node scripts/check-generated-slop-report.mjs --input "" 同时,`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` 这类全量触发。 +CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge_reasons`,并写入 `GITHUB_STEP_SUMMARY`,用于区分这次是 `agent_qc_contract`、`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 校验建议。 @@ -526,6 +536,45 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge - 不要把所有页面默认都推进到重型 E2E - 先跑最小 smoke,再决定是否需要完整交互验证 +### Layer 5:Agent QC 运营证据 + +入口: + +```bash +npm run agent-qc:report +npm run agent-qc:gui-flow:report +npm run agent-qc:check +npm run agent-qc:qcloop-job -- --risk P0 --output "./.lime/qc/qcloop-p0-job.json" --check +npm run agent-qc:export-evidence -- --job-id "" --output "./.lime/qc/agent-qc-evidence.json" --check +npm run agent-qc:release-summary -- --evidence "./.lime/qc/agent-qc-evidence.json" --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" --require-risk P0 --tag "" --output "./.lime/qc/release-agent-qc.md" --check +npm run agent-qc:audit +``` + +事实源: + +- `docs/tests/agent-ops-qc.md` +- `docs/tests/agent-qc-p0-scenarios.md` +- `docs/tests/lime-agent-qc-rollout-plan.md` +- `docs/test/agent-qc-scenarios.manifest.json` +- `docs/test/agent-qc-evidence.schema.json` +- `docs/test/agent-qc-gui-flows.manifest.json` + +作用: + +- 把 Lime 的 Agent Runtime、GUI、行为评测和发布门禁收敛成可审计场景清单 +- 让 qcloop / CI / release workflow 共享同一份 evidence contract +- 防止测试标准自身漂移,例如 scenario 引用了不存在的 npm script + +注意: + +- `agent-qc:check` 已进入 `npm run test:contracts`,改动 Agent QC manifest、GUI flow manifest 或 schema 时不能按普通 docs-only 跳过 +- Agent QC 不替代 `verify:local`、`verify:gui-smoke` 或 `harness:eval`;它负责把这些入口编排成运营级证据链 +- `agent-qc:qcloop-job` 只从 manifest 生成 qcloop payload,不启动 qcloop、不提交任务 +- `agent-qc:export-evidence` 只转换 qcloop job 结果,不会替你跑测试;如果 qcloop item 仍是 `pending/running`,导出的 verdict 必须是 `blocked` +- `agent-qc:release-summary -- --check` 是 release note / 发布门禁前的证据聚合;缺 Evidence Pack、Evidence Pack 非 `pass`,或未覆盖全部 P0 scenario id 时应阻断发布 +- `.github/workflows/release.yml` 默认强制 `agent_qc_evidence_path` 通过 `agent-qc-release-summary --check` 和 P0 scenario 覆盖校验,否则不创建 release +- `agent-qc:audit` 是完成度审计;真实 qcloop evidence、真实 GUI evidence 或 release hard gate 缺失时必须保持 `incomplete` + ## 改动类型与最低门槛 | 改动类型 | 至少运行 | 额外要求 | diff --git a/docs/images/readme-feature-provider-en.png b/docs/images/readme-feature-provider-en.png new file mode 100644 index 000000000..5133e7e73 Binary files /dev/null and b/docs/images/readme-feature-provider-en.png differ diff --git a/docs/images/readme-feature-provider.png b/docs/images/readme-feature-provider.png new file mode 100644 index 000000000..efaaa83c3 Binary files /dev/null and b/docs/images/readme-feature-provider.png differ diff --git a/docs/images/readme-feature-start-en.png b/docs/images/readme-feature-start-en.png new file mode 100644 index 000000000..71c8f357a Binary files /dev/null and b/docs/images/readme-feature-start-en.png differ diff --git a/docs/images/readme-feature-start.png b/docs/images/readme-feature-start.png new file mode 100644 index 000000000..ed49d3b2c Binary files /dev/null and b/docs/images/readme-feature-start.png differ diff --git a/docs/images/readme-feature-workspace-en.png b/docs/images/readme-feature-workspace-en.png new file mode 100644 index 000000000..25e8878ad Binary files /dev/null and b/docs/images/readme-feature-workspace-en.png differ diff --git a/docs/images/readme-feature-workspace.png b/docs/images/readme-feature-workspace.png new file mode 100644 index 000000000..570c9d5f5 Binary files /dev/null and b/docs/images/readme-feature-workspace.png differ diff --git a/docs/images/readme-hero-en.png b/docs/images/readme-hero-en.png new file mode 100644 index 000000000..7ba150ed3 Binary files /dev/null and b/docs/images/readme-hero-en.png differ diff --git a/docs/images/readme-hero.png b/docs/images/readme-hero.png new file mode 100644 index 000000000..27cc17910 Binary files /dev/null and b/docs/images/readme-hero.png differ diff --git a/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg b/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg index 083f7b929..5e4c32ead 100644 --- a/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg +++ b/docs/roadmap/agentui/images/agentui-stream-latency-map-20260509.svg @@ -2,134 +2,125 @@ - - + + - - + + + + + + - - - + + + - Lime 首响慢:真正卡在哪里? - 结论:前端已经能很快进入“思考中”,剩下最难的是 Provider 首 token 返回;本轮对齐的是“不要让流式输出拖慢整棵消息树”。 + Lime 首字 Token 慢点复查图 + 2026-05-10 真实 GUI 复测:前端首响已经足够快,真正拖慢 TTFT 的是 WebSearch allowed 误入 FullRuntime 后的 MCP / 工具面预热。 - - 一条消息从点击发送到可见输出,会经过 4 段 + + 点击发送到首字可见的实测链路 - - 0 - 1s - 前端首响 - 草稿卡、监听绑定、运行状态、 - “思考中”占位快速出现。 + + 50ms + 首页 pending + 草稿壳和占位态出现 + assistantDraftPaint 88ms - + - - 3s - 13s+ - Provider 等待 / 思考 - 排队、路由、模型首 token 生成。 - 这是当前最难被前端直接消除的段。 + + 298ms + 运行态到达 + firstEvent 297ms + submitAccepted 258ms - + - - ~172ms - 首 token 到可见 - 前端收到第一个正文 delta 后, - 很快 flush 到页面。 + + 6372ms + 提交准备阻塞 + context7 MCP 启动约 3.6s + playwright MCP 启动约 2.5s + 旧逻辑:allowed search 也触发 FullRuntime - + - - 后续输出 - 小批次稳定显示 - 96ms 合并、换行即时、 - 积压 120 字即时追赶。 + + 3553ms + 模型 / policy 首字 + headers 456ms + first provider msg 1139ms - - 这就是为什么你会感觉“卡住”:页面已进入处理态,但模型还没吐第一个正文 token;如果每个小 delta 都刷新完整消息树,后续还会抖。 + - - 参考对象为什么体感快? + + 首正文到可见仅 130ms - - Codex - 1. OutputItemAdded 先告诉 UI: - “一个输出项开始了”。 - 2. OutputTextDelta 立即进流。 - 3. TUI 不逐字符重排历史: - 换行边界提交,积压后 catch-up。 + 总结果:firstThinkingDelta 9024ms,firstTextDelta 10656ms,firstTextPaint 10786ms。前端 paint 不是主瓶颈。 - - Claude Code - 1. stream_request_start 先切状态。 - 2. thinking / text / tool 各自 spinner。 - 3. streamingText 单独热路径, - messages 走 deferred。 - 4. 只显示换行完成的流式文本。 + + 这次补查到的真实慢点 - - Lime 这次已对齐的部分 - 状态先到,首字即时;后续正文改成 96ms 小批次 + 换行即时 + 120 字积压追赶。 - 目标是避免 MessageList 被每个小 delta 高频拖动重算。 + + 1. WebSearch allowed 被当成重型执行入口 + 只是“允许联网”,不等于本轮必须启动完整 MCP 工具面。 - - 现在的困难边界 + + 2. FullRuntime 无差别预热 enabled_lime MCP + 简单短问也会串行启动 context7 / playwright,直接吃掉 6s+。 - - 前端能继续修 - - 已做 - • 立即显示“思考中” - • 默认隐藏 raw reasoning - • 输出节奏更稳,不抖动 - • 过程文件卡与完成态一致 + + 3. 搜索开启时前端 Prompt 退出紧凑模式 + 首轮输入体积变大,进一步放大 provider 首 token 等待。 - - 前端不能硬修 - - 仍困难 - • Provider 排队 / 限流 - • 模型首 token 生成时间 - • 模型权限 / 白名单错误 - • 快速路由与显示模型不一致 + + 本轮优化后的路由原则 - - + + Allowed Search:仍走 FastChat + WebSearch / WebFetch 原生工具保留,模型按需搜索,但不启动 MCP。 - - 下一刀最有价值 - 把“Provider / 模型 / 路由层为什么还没吐首字”做成用户可理解诊断: - 显示真实请求模型、慢等待原因、可自动回退选项,而不是在前端伪造进度。 + + Required Search:只保留搜索约束,不预热 MCP + 明确 required 时可等待 WebSearch preflight,但 context7 / playwright 不应前置。 - 图中样例来自 2026-05-09 本地 Playwright 复测:assistantDraftPaint 75ms,firstRuntimeStatus 927ms,firstTextDelta 13285ms,firstTextPaint 172ms。 + + Full Context:有真实技能 / 项目 / 浏览器需求才预热 + service skill、图片任务、项目上下文、多代理等仍保持重型 runtime。 + + + 慢点排序与预期收益 + 优先级 1:MCP / tool surface startup 约 6.37s,本轮通过 search-only 快路径移除;优先级 2:provider / policy 首字约 3.55s,后续继续看模型路由、Prompt 体积和缓存;优先级 3:前端首字 paint 约 0.13s,暂非瓶颈。 + 数据源:2026-05-10 Lime Playwright 真实复测 + Tauri TTFT 日志。优化提交后需继续用同一句短问复测 submit preparation 与 firstTextDelta。 + + + 已复查:前端快,后端预热慢 diff --git a/docs/roadmap/agentui/lime-agentui-standard-alignment.md b/docs/roadmap/agentui/lime-agentui-standard-alignment.md index 7e858adbe..ab8dea190 100644 --- a/docs/roadmap/agentui/lime-agentui-standard-alignment.md +++ b/docs/roadmap/agentui/lime-agentui-standard-alignment.md @@ -1,6 +1,6 @@ # Lime AgentUI 标准对齐与缺口追踪 -> 状态:v0.6.0 标准对齐追踪 +> 状态:v0.6.0 标准对齐完成;后续只跟踪 future boundary 与 v0.5 尾项增强 > 更新时间:2026-05-10 > 标准版本:Agent UI `v0.6.0`,commit `dcf4bc5`(`Release Agent UI v0.6.0 team workbench`) > 范围:对齐 `/Users/coso/Documents/dev/ai/limecloud/agentui` v0.6.0、Claude Code、Codex、Vercel AI SDK、assistant-ui、LangGraph、OpenAI ChatKit/Apps SDK 等调研结论,并回挂 Lime 当前实现缺口。 @@ -12,7 +12,7 @@ Lime AgentUI 的主目标不是只修复 thinking 样式,而是把对话工作 事实源声明: ```text -Agent runtime event / subagent runtime event / automation job event +Agent runtime event / subagent runtime event / automation job event / remote task event -> Agent UI envelope projection -> conversationProjectionStore.agentUi -> Conversation / Inline Process / Task Capsule / Artifact / Evidence / Diagnostics / Team Workbench UI @@ -127,11 +127,11 @@ Agent UI 标准已经从“thinking/progressive rendering 局部规则”扩展 | `execution run` 作为 coordinator / task taxonomy | `dead` | `ExecutionTracker` 只能做观测层,不定义分工、调度或 parent/child 编排。 | 新设计不得把 `agent_runs` 当成 Team Workbench coordinator。 | | Remote heartbeat 作为 terminal completion | `dead` | remote teammate 需要 remote truth / task status / artifact update,不得用 idle tick 猜完成。 | future remote adapter 必须保留 remote task id 和真实 terminal state。 | -## 6. 未对齐清单 +## 6. 后续增强 / future boundary 清单 -当前 Lime 不是底层 runtime 能力缺失,而是标准 envelope、event class、surface 消费没有完全覆盖 v0.6.0。 +当前 Lime 的 v0.6.0 projection baseline 已完成;本节保留的是后续增强 / future boundary,不再作为 v0.6.0 完成阻塞项。 -| 优先级 | 缺口 | 影响 | 期望收口 | +| 后续优先级 | 后续项 | 影响 | 期望收口 | | --- | --- | --- | --- | | P0 | `runtimeEntity` 显式字段 / 类型 | 已可机械证明 `agent_turn/subagent_turn/automation_job` 进入 Agent UI envelope;`work_item` 已有 team formation -> work board baseline;`external_task` 已有 remote task projection helper。 | 继续禁止新建第四类 runtime taxonomy;remote helper 只能消费真实 remote source。 | | P0 | team queue / parallelism facts 进入标准 envelope | Team UI 与 Agent UI projection 已共享 team/provider queue facts。 | 继续补 surface 详情消费,避免各 UI 局部重算。 | @@ -150,10 +150,10 @@ Agent UI 标准已经从“thinking/progressive rendering 局部规则”扩展 按对整体目标完成度的增量排序;若隔壁进程正在改 v0.6 Team Workbench / controls 文件,本进程只选择已确认非重叠的低冲突 projection 接线,不抢 Rust 高冲突实现文件或同一批 Team UI patch。 -1. **深化 Team Workbench 专门视图**:summary consumer、scoped lane selector、native collapsible surface detail、view model、可见 `工作台操作视图`、action target 定位、requested fixes work item + regression outcome 展示、local child session action routing、`teammate_transcript` source / Transcript Zoom / live activity + 历史 activity snapshot preview,以及 related teammate projection chain 已接入;非本地 target route taxonomy 与可见状态已补(含 work_board work item 未接入态、requested fix submitted_work_item、reassignment source fact 已定位态与 seeded_reassignment),直接 board/team API 写回已审计为 future command boundary;下一刀优先收敛统一门禁;原生 A2A artifact content 已登记为 future product boundary,后台无确认策略和更完整 teammate drilldown 排后。 -2. **补 Review lane 余量**:review request 已有 actual source,surface detail 已展示 reviewer、risk、checklist、followup、regression、requested fixes 与 regression requirement preview;review 保存后的 `verification_summary` failure/recovered facts 已进入 review payload 与 requested-fix work item;`team_control_projection(action=reassign)` 已能表达 reassignment source,TaskUpdate owner_change 已作为真实 board source 投影到 work_board;pending requested fix 已支持显式点击后提交 runtime turn;无 prompt 的直接 board/team API 写回已降级为 future command boundary;下一刀优先推进统一门禁收敛;原生 A2A artifact content 已登记为 future product boundary,或更独立的 specialist handoff protocol callback。 +1. **深化 Team Workbench 专门视图**:summary consumer、scoped lane selector、native collapsible surface detail、view model、可见 `工作台操作视图`、action target 定位、requested fixes work item + regression outcome 展示、local child session action routing、`teammate_transcript` source / Transcript Zoom / live activity + 历史 activity snapshot preview,以及 related teammate projection chain 已接入;非本地 target route taxonomy 与可见状态已补(含 work_board work item 未接入态、requested fix submitted_work_item、reassignment source fact 已定位态与 seeded_reassignment),直接 board/team API 写回已审计为 future command boundary;统一门禁已于 2026-05-10 fresh green;原生 A2A artifact content 已登记为 future product boundary,后台无确认策略和更完整 teammate drilldown 排后。 +2. **补 Review lane 余量**:review request 已有 actual source,surface detail 已展示 reviewer、risk、checklist、followup、regression、requested fixes 与 regression requirement preview;review 保存后的 `verification_summary` failure/recovered facts 已进入 review payload 与 requested-fix work item;`team_control_projection(action=reassign)` 已能表达 reassignment source,TaskUpdate owner_change 已作为真实 board source 投影到 work_board;pending requested fix 已支持显式点击后提交 runtime turn;无 prompt 的直接 board/team API 写回已降级为 future command boundary;统一门禁已收敛,后续只在新增 current board/team command 或 specialist handoff protocol 后继续接入;原生 A2A artifact content 已登记为 future product boundary。 3. **补其它 automation 消费入口**:settings automation 与 capability drafts managed automation 已接持续刷新;后续如 skills automation 列表需要展示 background teammate,也接 `recordAutomationJobsAgentUiProjection`,不新增 runtime taxonomy。 -4. **remote teammate / A2A source 接线**:gateway actual source、remote task status/input/auth/artifact refs baseline 已有;真正原生 A2A ingress、remote artifact store/content 回流与远端控制 callback 已登记为 future product boundary;下一刀优先等待统一门禁收敛,不用假 remote surface。 +4. **remote teammate / A2A source 接线**:gateway actual source、remote task status/input/auth/artifact refs baseline 已有;真正原生 A2A ingress、remote artifact store/content 回流与远端控制 callback 已登记为 future product boundary,不用假 remote surface。 5. **v0.5 尾项回补**:Context/memory 细分 source event、其他 provider tool input streaming、HITL/action 历史重放归档,排在 v0.6 Team Workbench 主缺口之后。 ## 8. 2026-05-09 v0.6.0 再审计记录 @@ -198,15 +198,45 @@ Agent UI 标准已经从“thinking/progressive rendering 局部规则”扩展 - 已补 Review request actual source:`HarnessStatusPanel` 导出人工审核模板时,除了 `review.requested` evidence event,还会记录 `team_control_projection(control=request_review)`,以 `review_relative_root` 作为 review work item,落到 `review_lane` 并保留 `sessionId/threadId` scope。 - 已补 Remote teammate actual source baseline:`remoteTaskAgentUiProjection.ts` 提供 `remote_task_projection`,将结构化 remote task 映射为 `agent.changed/task.changed(surface=remote_teammate, runtimeEntity=external_task)`,保留 Agent Card、remote task id、input/auth、artifact refs,并且只在真实 terminal status 下输出 `worker.notification`,避免 heartbeat 猜完成;input/auth need 会额外输出 `action.required(surface=remote_teammate, control=answer)`,让远端待输入 / 待鉴权成为结构化 HITL fact;当 `remote_task` provenance 提供 `event/taskStatus/status/state/phase` 或嵌套 `task/a2aTask` artifact updates 时也会进入同一 projection,terminal `AgentRun.status` 仍优先压过 stale remote/input/auth source;`agentUiEventProjection.ts` 内的旧 remote teammate builder 已改为兼容 wrapper,统一委托该 helper。2026-05-10 已把 `gateway_channel_*` / gateway crate 接到 current source:渠道 runtime 写入 `source_metadata.remote_task`,`agent_runs.metadata` 保留 provenance,`useRemoteTaskExecutionRunProjection` 再按当前 session 投影到 Team Workbench;剩余原生 A2A ingress 与 remote artifact store/content 回流已登记为 future product boundary。 - 已补 active stream overlay 收口:`text_delta` 继续走低频 overlay 实时显示,`final_done` 后回填到 message content/contentParts;失败态保留已有 tool/process contentParts 并追加失败正文,避免“无最终正文”错误把工具过程清空。 -- 曾完成统一质量门禁复验:`npm run verify:local` 通过,覆盖 app-version、lint、typecheck、Vitest smart 52 批、contracts、Rust `cargo test --manifest-path src-tauri/Cargo.toml` 与 GUI smoke;但 2026-05-10 最新核验显示旧 gate 进程已 stale 且 DevBridge 3030 不可用,当前只能作为历史 green 证据,不能作为最终 complete 证据。 +- 已完成 2026-05-10 final gate fresh 复验:`npm run verify:local` 统一门禁通过,覆盖 app-version、lint、typecheck、Vitest smart 52 批、contracts、Rust 全量与 GUI smoke;此前旧 stale gate / DevBridge 不可用问题已由 fresh unified gate 证据替代,不再阻塞 v0.6.0 标准对齐完成判定。 -## 8.1 2026-05-10 Board/team 直接写回命令边界审计 +## 8.1 2026-05-10 final gate green + +- 统一门禁:`npm run verify:local` fresh 通过;local-ci smart 模式在无 source 改动下执行全量兜底,覆盖一致性校验、前端校验、bridge/contracts、GUI smoke 与 Rust 校验。 +- 前端与契约门禁:`npm run verify:app-version` 通过(版本 `1.32.0`),`npm run lint` 通过,`npm run typecheck` 通过,`npm test` 通过(Vitest smart 52 批),`npm run test:contracts` 通过,`npm run check:agent-runtime-clients` 通过,`npm run harness:doc-freshness` clean。 +- Rust 门禁:`npm run verify:local` 内的 `cargo test --manifest-path src-tauri/Cargo.toml` 通过;修复本地 `src-tauri/target` 构建缓存后,单独复核 `cargo test --manifest-path "src-tauri/Cargo.toml"` 也通过:`lime` root unit tests 1295 passed,`src/main.rs` 0 passed,integration tests 2 passed,真实联网测试 2 ignored。 +- GUI 门禁:`npm run verify:local` 内的 `npm run verify:gui-smoke` 通过;覆盖 DevBridge health、前端壳、workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface、agent-runtime-tool-surface-page、knowledge-gui、design-canvas,并完成本轮 smoke Chrome profile 清理。 +- 完成判定:Agent UI v0.6.0 projection / Team Workbench / review lane / work_board / worker notification / remote teammate baseline 已达到可交付门槛。`direct board/team write-back`、`native A2A ingress`、`remote artifact store/content bytes`、`remote control callback`、无人工确认后台执行策略与更深 teammate drilldown 均保持 future product / command boundary,不作为 v0.6.0 完成阻塞项。 + +## 8.2 2026-05-10 Board/team 直接写回命令边界审计 - 已审计 current 命令边界:`src/lib/api`、`src-tauri/src/app/runner.rs`、`agentCommandCatalog.json`、`mockPriorityCommands.ts` 与 `defaultMocks` 均没有可复用的 board/team 直接写回命令;`TaskCreate/TaskList/TaskGet/TaskUpdate` 当前是 aster runtime tool / inventory,不是 Tauri bridge command。 - 已确认真实可写事实源仍是 `TaskUpdateTool`:它通过 `resolve_task_board_state` / `persist_task_board_state` 读写 session task board,并在 owner 变化时输出 `ownerChange` / `owner_change` metadata;Agent UI 只消费该结构化 metadata 派生 `work_board` assign/reassign。 - 已明确不新增临时 UI 本地写回或平行 Tauri command。若未来要做无 prompt direct board/team write-back,必须先新增 current command 或 shared task board service,并同步前端网关、Rust 注册、治理目录册、DevBridge priority mock 与 default mock,同时解决 runtime `shared_task_list_storage` 与 session `extension_data` 的一致性。 - 因此 v0.6 标准对齐口径调整为:`work_board` / reassignment 的合规 baseline 是 `Team Workbench selector -> TaskUpdate prompt/runtime turn -> TaskUpdateTool owner_change source -> Agent UI projection`;无 prompt 直接写回归入 future product command boundary,不再作为本轮 projection 对齐阻塞项。 +## 8.3 2026-05-10 Playwright E2E 与真实 child context 复验 + +- 已补浏览器 DevBridge subagent control 命令族:`agent_runtime_spawn_subagent`、`agent_runtime_send_subagent_input`、`agent_runtime_wait_subagents`、`agent_runtime_resume_subagent`、`agent_runtime_close_subagent` 已接入 dispatcher 与 HTTP cooldown bypass,契约仍落在现有 `agent_runtime_*` current 主链,不新增 Tauri command。 +- 已补 session store limited history 空会话探测:`get_runtime_session_detail_with_history_page` 不再因为 persisted empty fast path 跳过 runtime overlay / subagent context;`agent_runtime_get_session(parent, { historyLimit })` 能返回 `child_subagent_sessions`,空 child detail 也能返回 `subagent_parent_context`。 +- Playwright E2E 复验已覆盖:普通会话 tab 不显示 `AgentUI N` 内部 badge;Team Workbench `Agent UI v0.6 / 10 events`、10 个 surface、三类 lane、`工作台操作视图`、`Surface 专门视图`、Harness AgentUI 投影、真实 child context 与 console `0 error / 0 warning` 均有证据。 +- 证据索引:`docs/exec-plans/agentui-playwright-e2e-2026-05-10.md`;截图与 JSON 位于 `docs/exec-plans/evidence/agentui-e2e-2026-05-10/09-session-store-subagent-context.json`、`09-team-workbench-real-child-context-after-session-store-fix.png`、`10-transcript-open-detail-real-child-focus-context.png`、`10-console-errors-final-real-child-focus.txt`。 +- 复验命令:`cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-agent should_probe -- --nocapture`、`cargo test --manifest-path "src-tauri/Cargo.toml" dev_bridge --lib -- --nocapture`、相关 Vitest / ESLint、`npm run typecheck`、`npm run test:contracts`、`npm run verify:gui-smoke -- --reuse-running` 均通过。 +- 已补 child focus `返回主助手` 点击稳定性:embedded Team board sticky header 提升到 `z-40`,返回按钮增加独立可点击层级与 `data-testid`;Playwright harness 证据位于 `docs/exec-plans/evidence/product-backend-ux-e2e-2026-05-10/01-child-focus-return-parent-before-click.png`、`02-return-parent-after-click.png`、`02-return-parent-hit-target.json`、`02-console-errors-return-parent.txt`,点击前命中目标为 `BUTTON`,点击后返回按钮消失,console `0 error / 0 warning`。 +- 剩余项继续降级为 E2E 深度增强:让真实 child turn 产出最小 activity preview、避免 requested-fix action 在测试中误触发真实 runtime turn、在 DevBridge `3030` 恢复后用真实 child session 再复跑同一返回路径;这些不阻塞 v0.6.0 标准 baseline 完成判定。 + +## 8.4 2026-05-10 产品 / 后端 / UI-UX 复测补充 + +- DevBridge 恢复后已补真实主路径复测:`npm run bridge:health -- --timeout-ms 30000` 通过,`npm run verify:gui-smoke -- --reuse-running --timeout-ms 120000` 通过,覆盖 workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface、agent-runtime-tool-surface-page、knowledge-gui、design-canvas。 +- 契约与类型门禁已补当前轮次复核:`npm run test:contracts` 通过,`npm run typecheck` 通过。 +- 项目资料 PRD v3 产品闭环已补真实 E2E:`npm run knowledge:product-e2e -- --timeout-ms 120000` 通过,覆盖首页、状态说明、确认资料、选择创作资料、Agent 结果保存到项目资料、整理新资料,`consoleErrors=0`;仅 memory / hint 读取保留浏览器 mock 噪音,不阻塞项目资料主链。 +- Playwright MCP 真实 DevBridge 页面证据已归档:`docs/exec-plans/evidence/product-backend-ux-e2e-2026-05-10/04-home-real-bridge.png`、`05-project-knowledge-real-bridge.png`、`06-claw-new-task-real-bridge.png`、`07-settings-ai-provider-real-bridge.png`、`08-skills-real-bridge.png`;对应 console 文件均为 `Errors: 0, Warnings: 0`。 +- 早期 `03-home-bridge-unavailable.png` / `03-console-errors-home-bridge-unavailable.txt` 保留为环境阻塞证据,说明 DevBridge 未监听时首页会产生 bridge 连接错误;该问题已由后续真实 bridge green gate 替代,不再作为当前完成判定阻塞。 +- 已补运营级测试审计增量:`npm run agent-qc:check` 通过,manifest `12` 场景 / `8` 个 P0,`issueCount=0`;`npm run agent-qc:qcloop-job -- --risk P0 --check --format json` 可生成 `8` 个 P0 item payload;真实 qcloop job `1778390726823769000` 已跑完并导出 `.lime/qc/agent-qc-evidence.json`,结果为 `status=fail`、`8` 个 P0 中 `2` pass / `6` fail;`npm run harness:eval` 与 `npm run harness:eval:trend` 通过,但 trend 仅有 1 个样本,不能作为长期退化完成证据。 +- 已补 Skill Forge / sandbox 边界点检:`node scripts/prompt-to-artifact-smoke.mjs --timeout-ms 120000 --json` 通过,覆盖 Capability Draft create -> verify -> register -> discovery -> workspace binding readiness,结果为 `ready_for_manual_enable` / `manual_runtime_enable`;`cd src-tauri && cargo test test_build_workspace_shell_allow_pattern -- --nocapture` 通过 3 tests,覆盖 workspace shell allowlist / strict mode 拒绝高风险命令。 +- 已补 Claw 流式 / 中断 / 恢复手工 Playwright transcript:`docs/exec-plans/evidence/product-backend-ux-e2e-2026-05-10/11-claw-streaming-*.png`、`11-claw-streaming-console-2026-05-10.txt`、`11-claw-streaming-runtime-session.json`、`11-claw-streaming-thread-read.json`、`11-claw-streaming-summary.json`。结论不是 pass:UI stop 可点击且 follow-up 最终 `恢复成功`,但被停止的长 turn `a9220dae-54d1-48bc-a83a-fe6b33a034dc` 仍以 `completed` 落盘并完整包含 80 条输出,follow-up 曾短暂 `queuedTurnsCount=1`;这把 `claw-chat-ready-streaming` 明确归类为 runtime cancel 语义阻塞,而不是单纯证据不足。 +- 完成度审计结论:本文件的 v0.6.0 Agent UI baseline 与本轮产品主路径可交付;active goal 仍不能标记 complete,因为 qcloop Evidence Pack 仍非 pass,且 release package artifact、Claw runtime cancel 语义、approval runtime transcript、Skill Forge runtime enable + SkillTool 执行 transcript 与真实远端 A2A / artifact content 仍未被完整证据覆盖。 + ## 9. 既有实施记录摘要 - 已新增标准 adapter:`agentUiEventProjection.ts` 负责 source event -> Agent UI envelope,覆盖 text、reasoning、runtime、tool、action、artifact、context、queue、subagent、evidence helper 与 timeline item。 diff --git a/docs/roadmap/i18n/prd.md b/docs/roadmap/i18n/prd.md new file mode 100644 index 000000000..5cf9dea8f --- /dev/null +++ b/docs/roadmap/i18n/prd.md @@ -0,0 +1,846 @@ +# Lime 全球本地化 PRD + +> 状态:proposed current roadmap +> 更新时间:2026-05-11 +> 负责人视角:Lime 桌面端 GUI / Agent 运行时 / Browser Runtime 的全球本地化能力层 +> 目标:把当前 DOM Patch 翻译补丁升级为 key-based、可验证、可持续扩展的全球本地化体系 +> 非目标:本文不是具体实施任务清单;实施前仍需拆分到 `docs/exec-plans/` 并按 Lime 质量工作流验证 + +## 1. 背景 + +Lime 当前已经具备早期中英切换能力,但能力形态仍是“把渲染后的中文文本替换成英文”的 Patch Layer。这个机制适合快速补齐英文预览,不适合长期全球化。 + +全球本地化不只是翻译 UI 文案,而是同时覆盖: + +1. UI 文案与组件状态 +2. 日期、数字、相对时间、排序、复数与变量 +3. RTL / LTR 方向、`html lang`、桌面端首屏闪烁控制 +4. Agent 默认回复语言与用户创作内容目标语言 +5. Browser Runtime 的 `Accept-Language`、locale、timezone 等站点环境 +6. Rust / Tauri / Bridge 错误的用户可见表达 +7. Chrome extension、发布材料、文档与自动翻译流程 + +因此,Lime 的目标不是“把中文替换成英文”,而是建立一个可治理的本地化事实源,让新功能默认具备全球化能力。 + +## 2. 调研依据 + +本轮调研同时使用了本地代码检索、Context7 文档查询与 WebSearch 官方资料复核,避免只基于单一项目经验做方案设计: + +- 本地代码:复核 Lime 当前 `src/i18n/`、配置 schema、Browser Runtime 环境语言实现,并对比 `/Users/coso/Documents/dev/js/lobehub` 与 `/Users/coso/Documents/dev/rust/CodexMonitor`。 +- Context7:查询 `i18next`、`react-i18next`、`Vite` 的 current 文档,重点验证 fallback、namespace、`Trans`、Suspense 与 `import.meta.glob` 的实现约束。 +- WebSearch:只采用官方或上游资料,包括 i18next、react-i18next、Vite、MDN、W3C、Tauri 与 i18next 插件仓库。 + +### 2.1 Lime 当前事实 + +本轮复核了以下本地事实源: + +- `package.json` 已有 `i18next`、`react-i18next`、`dayjs`,但没有 `i18next-browser-languagedetector`、`i18next-resources-to-backend`、`rtl-detect`。 +- `src/i18n/README.md` 明确当前单一前端 i18n fact source 是 Patch Layer。 +- `src/i18n/config.ts` 初始化了 i18next,但当前只作为兼容层,`lng` 与 `fallbackLng` 均为 `zh`。 +- `src/i18n/legacy-patch/text-map.ts` 的语言模型只有 `"zh" | "en"`,资源落在 `legacy-patch/patches/zh.json` 与 `legacy-patch/patches/en.json`。 +- `src/i18n/legacy-patch/I18nPatchProvider.tsx` 通过 `MutationObserver` 监听动态 DOM,调用 `replaceTextInDOM` / `replaceTextInNode` 替换文本。 +- `src/i18n/legacy-patch/dom-replacer.ts` 使用 `TreeWalker` 扫描 text node,只处理含中文文本,跳过 `input`、`textarea`、`contenteditable`、`.ProseMirror`,并记录耗时指标。 +- `src/lib/api/appConfigTypes.ts` 与 `src-tauri/crates/core/src/config/types.rs` 均已有 `language: string`,Rust 默认值仍是 `zh`,可作为首期 UI locale 持久化入口。 +- `src/lib/api/appConfig.ts` 已有 `getConfig()` / `saveConfig()`,首期切换语言不需要新增 Tauri 命令。 +- `package.json` 暴露 `detect-translations*` 脚本;本轮已补 `scripts/detect-missing-translations.ts`,用于校验各 locale 的 namespace/key 结构一致性。 +- `src-tauri/src/services/browser_environment_service.rs` 已把 Browser Runtime 的 `locale`、`accept_language`、`timezone_id` 作为站点环境的一部分,并由 `browser_launch_language()` 推导 Chrome 启动语言;这应与 Lime UI locale 分离。 + +结论:Patch Layer 可作为迁移期兼容层保留,但不能继续作为 current 主路径。 + +### 2.2 LobeHub 参考 + +本轮复核 `/Users/coso/Documents/dev/js/lobehub`: + +- 依赖组合:`i18next`、`react-i18next`、`i18next-browser-languagedetector`、`i18next-resources-to-backend`、`rtl-detect`、`dayjs`。 +- `.i18nrc.js` 以 `locales/en-US` 为入口,使用 `@lobehub/i18n-cli` 自动生成多语言 JSON,并配置 `temperature: 0`、`jsonMode`、Markdown 翻译。 +- `locales//.json` 资源结构清晰,每个 locale 下约 46 个 namespace。 +- `src/locales/resources.ts` 集中维护 locale registry、`normalizeLocale()`、本地语言显示名与支持列表。 +- `src/locales/create.ts` 使用 `LanguageDetector`、`resourcesToBackend`、`initReactI18next`,预加载核心 namespace,`react.useSuspense = false`,语言变化时同步 `document.documentElement.dir`。 +- `src/utils/i18n/loadI18nNamespaceModule.vite.ts` 使用 `import.meta.glob` 动态加载 default / locale namespace;desktop 版本用 `{ eager: true }` 内联所有资源,降低桌面运行时懒加载不确定性。 +- 设置里区分 UI language 与 response language;用户可以界面使用一种语言,同时让 Agent 用另一种语言回复。 +- i18n workflow 包含 default locale 生成、diff 清理、unused key 扫描、自动翻译 PR。 + +对 Lime 的可借鉴点:namespace 资源结构、locale registry、locale normalize、核心 namespace 预加载、desktop eager loader、UI 语言和 AI 回复语言分离、自动翻译与 unused key 治理。 + +不建议照搬的点:Next.js middleware、路由 locale、SEO sitemap、Ant Design locale、一次性支持 18 种语言。Lime 是 Tauri 桌面产品,首期应更克制。 + +### 2.3 CodexMonitor 参考 + +本轮复核 `/Users/coso/Documents/dev/rust/CodexMonitor`: + +- `package.json` 没有正式 i18n / react-i18next 依赖。 +- UI 中存在大量英文硬编码,说明桌面 GUI 如果早期不做 key-based i18n,后期迁移成本会快速上升。 +- `src/utils/time.ts`、`src/features/home/homeFormatters.ts` 已使用 `Intl.RelativeTimeFormat`、`Intl.DateTimeFormat`、`Intl.NumberFormat`,这是值得 Lime 借鉴的格式化方向。 +- Rust 侧存在 terminal locale、dictation preferred language 等能力,提醒 Lime 需要区分“UI 语言”“输入/识别语言”“终端/进程 locale”“AI 输出语言”。 + +对 Lime 的启示:不要把所有 language 字段混成一个;日期数字等格式化应走统一 locale wrapper,不要靠翻译 JSON 手写时间单位。 + +### 2.4 官方资料与外部参考 + +本轮使用 Context7 与 WebSearch 查阅了官方/上游资料: + +- i18next fallback:`https://www.i18next.com/principles/fallback` +- i18next namespace:`https://www.i18next.com/principles/namespaces` +- i18next interpolation / plural / context:`https://www.i18next.com/translation-function/interpolation`、`https://www.i18next.com/translation-function/plurals`、`https://www.i18next.com/translation-function/context` +- react-i18next `useTranslation`:`https://react.i18next.com/latest/usetranslation-hook` +- react-i18next `Trans`:`https://react.i18next.com/latest/trans-component` +- i18next TypeScript:`https://www.i18next.com/overview/typescript` +- Vite glob import:`https://vite.dev/guide/features.html#glob-import` +- MDN Intl:`https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl` +- MDN `lang` / `dir`:`https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang`、`https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/dir` +- W3C language tag 选择:`https://www.w3.org/International/questions/qa-choosing-language-tags` +- Tauri v2 Store:`https://v2.tauri.app/plugin/store/` +- i18next resources backend:`https://github.com/i18next/i18next-resources-to-backend` +- i18next browser language detector:`https://github.com/i18next/i18next-browser-languageDetector` + +官方资料对本 PRD 的直接结论: + +1. i18next 原生支持 `fallbackLng` 的具体语言、泛化语言与 fallback 序列,适合 `zh-CN -> zh -> fallback` 这类退化链。 +2. namespace 是长期维护大型应用文案的基本边界,应使用 `ns` 参数访问公共 namespace,而不是把所有 key 放进一个大 JSON。 +3. interpolation、plural、context 是变量、复数、性别/语境差异的标准能力,DOM Patch 无法可靠覆盖这些场景。 +4. react-i18next 的 `useTranslation(ns)` 与 `Trans` 分别覆盖普通文案和富文本/React 组件插入,应成为新 UI 的默认入口。 +5. TypeScript `CustomTypeOptions` 可以把 resources 绑定到 key 类型,降低漏 key 与拼错 key 风险。 +6. Vite `import.meta.glob` 可用于 namespace lazy load;桌面端可用 eager load 换取启动确定性。 +7. `Intl.*` 是日期、数字、相对时间的基础设施,应封装成 Lime 统一 format API。 +8. `html lang` 与 `dir` 需要随 UI locale 同步;RTL 支持不是简单翻译 JSON。 + +### 2.5 补充调研矩阵 + +| 来源 | 复核点 | 对 Lime 的方案约束 | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Context7 / i18next | `fallbackLng` 支持具体语言、方言退化、fallback 序列与 namespace fallback;key fallback 虽可用但不推荐长期以自然语言 key 管理。 | Lime 使用稳定 dotted key,`fallbackLng` 固定回 `zh-CN`,`fallbackNS` 回 `common`;不把中文原文当 current key。 | +| Context7 / i18next | namespace 用于按语义、页面或 feature 拆分资源,避免单文件过大并支持懒加载。 | Lime 首期保留 `common`、`navigation`、`settings`、`workspace`、`agent`、`errors` 核心 namespace,业务域后续再拆。 | +| Context7 / i18next | interpolation、plural、context 是变量、数量和语境差异的标准能力。 | 动态文案必须走 `t(key, params)`、plural 与 context;禁止继续用字符串拼接或 DOM Patch 处理变量语序。 | +| Context7 / react-i18next | `useTranslation(ns)` 的 `t` 绑定 namespace;`Trans` 适合富文本和 React 组件插入;未就绪时会触发 Suspense,关闭 Suspense 后要自行处理 ready。 | 桌面端默认 `react.useSuspense = false`,核心 namespace eager preload;普通组件用 `useTranslation(ns)`,带链接/强调/代码片段的文案用 `Trans`。 | +| Context7 / Vite | `import.meta.glob` 默认 lazy dynamic import;`{ eager: true }` 会构建期直接导入所有匹配模块;JSON 可直接 import。 | Lime 桌面主路径优先 eager preload 核心资源,业务 namespace 可 lazy;若 GUI smoke 发现首屏抖动,切到 desktop eager loader。 | +| WebSearch / MDN Intl | `Intl` 提供语言敏感的比较、数字、日期、相对时间、列表等格式化能力,并使用 BCP 47 locale negotiation。 | 新增 `format.ts` 统一封装,不在 JSON 里手写时间单位;排序与时间数字展示显式传 UI locale。 | +| WebSearch / MDN + W3C | `lang` / `dir` 是 HTML 级语义;语言标签应基于 BCP 47,避免自造短码。 | UI locale 采用 `zh-CN` / `en-US` / `zh-TW` 等 BCP 47 风格,旧 `zh` / `en` 只读兼容;切换语言同步 `documentElement.lang` 与 `dir`。 | +| LobeHub 本地代码 | `.i18nrc.js` 以 `en-US` 为 entry,配置多 locale 输出、glossary、Markdown 翻译、JSON mode;`src/locales/create.ts` 组合 LanguageDetector、resources backend、核心资源预加载和 `dir` 同步。 | Lime 借鉴 workflow 与架构边界,但不照搬 source locale、Next.js 路由、SEO 与一次性 18 语言;首期保守支持 5 个 locale + `auto`。 | +| LobeHub 本地代码 | `loadI18nNamespaceModule.vite.ts` lazy,`loadI18nNamespaceModule.desktop.ts` eager,均提供 fallback 到 default namespace。 | Lime 后续可拆出 `loadNamespace.ts`,保留 web/dev 与 desktop 两种加载策略,确保 Tauri 首屏确定性。 | +| CodexMonitor 本地代码 | 没有正式 i18n 依赖,UI 有大量硬编码英文;但 `src/utils/time.ts` 使用 `Intl.RelativeTimeFormat`,Rust terminal 与 dictation 另有 locale / preferred language。 | Lime 不能把 UI language、终端 locale、ASR language、Agent response language 混成一个字段;格式化能力应统一封装。 | + +### 2.6 对比结论 + +1. **LobeHub 是正向成熟样板**:它证明 `i18next + react-i18next + resources backend + locale registry + 自动翻译 workflow` 能支撑大规模 AI 产品,但 Lime 应按桌面 GUI 的启动稳定性和当前中文事实源做裁剪。 +2. **CodexMonitor 是边界提醒**:没有早期 key-based i18n 时,UI 硬编码会快速扩散;同时 terminal、dictation、code highlight 这类 “language” 不是 UI locale,必须分字段治理。 +3. **Lime 的正确路线是双轨收口**:current 主路径转向 key-based resources,legacy Patch Layer 只做迁移期兜底,并用命中率和 coverage 逐步退出。 +4. **首期不追求语言数量,而追求机制闭环**:先让 locale registry、资源加载、设置持久化、fallback、格式化和 GUI smoke 跑通,再扩大 locale 与自动翻译。 + +### 2.7 本轮工具复核与取舍 + +为了避免“参考了成熟项目,但没有落到 Lime 桌面产品边界”的问题,本轮把外部资料转成以下决策约束: + +| 复核方式 | 关键证据 | Lime 取舍 | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Context7 / `/websites/i18next` | i18next 官方资料确认 `fallbackLng`、`fallbackNS`、namespace、interpolation、plural、context 与 TypeScript 资源绑定均是框架原生能力。 | current 主路径采用稳定 key + namespace;中文原文只作为 `zh-CN` source value,不再作为长期 key。 | +| Context7 / `/i18next/react-i18next` | `useTranslation(ns)` 适合普通组件文案,`Trans` 适合富文本和 React 组件插入;关闭 Suspense 后需要预加载或自行处理 ready。 | 桌面端默认 `useSuspense: false`,核心 namespace eager 资源内联,避免 Tauri 首屏 loading 闪烁。 | +| Context7 / `/vitejs/vite` | `import.meta.glob` 默认 lazy dynamic import,`{ eager: true, import: "default" }` 可构建期直接导入 JSON default export。 | P0 直接静态导入核心 resources;P3 再抽 `loadNamespace.ts`,按核心 eager、业务 lazy 拆分。 | +| WebSearch / MDN + W3C | `Intl`、`html lang`、`dir` 与 BCP 47 language tag 是浏览器标准能力。 | `Config.language` 新写入值使用 `zh-CN` / `en-US` 等 BCP 47 风格;切换 UI locale 同步 `documentElement.lang` / `dir`。 | +| WebSearch / Tauri Store | Tauri v2 Store 可作为轻量持久化插件。 | Lime 已有跨 Rust / 前端的 `get_config` / `save_config` 配置事实源,P0 不新增 Store,避免平行配置源。 | +| WebSearch / i18next detector 与 resources backend | detector 与 backend 适合 Web 场景自动探测和懒加载资源。 | Lime P0 先不新增依赖;`auto` 通过 registry normalize 解析系统/浏览器 locale,resources 先随包内联。 | +| LobeHub 本地复核 | 当前样板有 18 个 locale、`en-US` 约 46 个 namespace,并组合 detector、resources backend、RTL、自动翻译 workflow。 | 只借鉴架构和治理;不照搬 Next.js 路由、SEO、Ant Design locale,也不首期铺 18 种语言。 | +| CodexMonitor 本地复核 | 未引入正式 i18n 依赖;存在 `Intl.RelativeTimeFormat`、terminal `LANG` / `LC_*`、dictation preferred language 等多种 language-like 字段。 | Lime 必须把 UI locale、Agent response language、Browser environment language、终端/语音语言拆开治理。 | + +由此确定 P0 的最小闭环:不追求翻译覆盖率最大化,而是先建立 locale registry、key-based resources、旧值兼容、设置持久化、`lang` / `dir` 同步和 GUI smoke 验收口径。 + +2026-05-10 追加复核:Context7 重新确认 `/websites/i18next`、`/i18next/react-i18next`、`/vitejs/vite` 的 current 文档与上述结论一致;WebSearch 复核的官方资料仍指向 i18next / react-i18next / Vite / MDN / W3C / Tauri 官方或上游来源,未发现需要推翻 P0/P1 技术路线的更新。 + +### 2.8 追加调研证据与方案修正 + +本轮继续使用 Context7、WebSearch、本地 LobeHub 与 CodexMonitor 复核,新增以下更细约束: + +| 来源 | 新增证据 | 对 Lime 的修正 | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Context7 / i18next TypeScript | i18next v25.4 起加强 `enableSelector`;官方说明 v26 计划默认启用 selector,v27 有弃用 string-based 类型支持的倾向;同时大资源类型检查可能带来 OOM。 | P1 继续用稳定 dotted string key 降低迁移成本;P3 评估 selector API 与 `@i18next-selector/codemod`,但不要在资源量未收敛前强推全仓 selector。 | +| WebSearch / i18next 官方 | 官方把 `i18next-cli` 标为推荐工具,覆盖 key extraction、hardcoded string lint、locale sync 与 type generation。 | P3 工具链候选从“自研或 LobeHub CLI”扩展为“三段式”:短期保留 `detect-translations`;中期优先评估官方 `i18next-cli` 做抽取 / lint / 类型;AI 自动翻译可再评估 `@lobehub/i18n-cli` 或自研脚本。 | +| Context7 / react-i18next | `` 适合 React/HTML 节点插入,但它本身只做插值,不负责重新渲染或加载翻译;普通文本仍应优先用 `t`。 | 富文本组件必须与 `useTranslation(ns)` 绑定同一个 `t`;不要把 `` 当成全局 provider,也不要为了普通按钮文案滥用 ``。 | +| Context7 / Vite | `import.meta.glob` 默认 lazy dynamic import;`eager + import: "default"` 可直接拿 JSON default export;glob 参数必须是字面量,不能用变量拼接。 | `loadNamespace.ts` 的 glob pattern 必须保持静态字面量;业务 namespace 若 lazy,应通过 registry 查表而不是动态拼 import path。 | +| WebSearch / MDN + W3C | `lang` 使用单个 BCP 47 language tag;`dir` 是语义方向属性,`ltr` / `rtl` / `auto` 与 CSS `direction` 不是同一层决策;W3C 建议只在需要区分时增加 region/script 子标签。 | Lime 支持列表使用规范大小写的 BCP 47 tag;首期 `ja-JP` / `ko-KR` 是产品选择而非技术必要,后续扩 locale 要说明 region 子标签理由。 | +| WebSearch / Tauri Store | Tauri Store 是官方持久化 key-value 插件,但需要新增依赖、权限和 async save/load 处理。 | P0 不引入 Store,继续使用已有 `get_config` / `save_config` 作为配置事实源,避免出现第二套 language preference。 | +| LobeHub 本地代码 | LobeHub 现在有 18 个 locale、46 个 JSON namespace;`createI18nNext` 使用 bundled fallback resources、`partialBundledLanguages`、`initAsync` 控制与后台 `reloadResources`;Vite 配置把 `i18n-*` chunk 单独命名,大型 namespace 拆成 per-locale chunk。 | Lime 不首期照搬 18 语言;但需要把“首屏核心资源内联”和“后续 namespace chunk 化”写成 P2/P3 性能任务,避免资源扩大后拖慢 Tauri 启动。 | +| LobeHub 本地代码 | desktop loader 使用 eager `import.meta.glob`,vite loader 使用 lazy glob,并在缺失 locale namespace 时回退 default namespace。 | Lime 当前 bundled loader 先走 eager 核心资源;未来如拆 desktop/web loader,应保留 fallback 监控与测试,不允许 namespace missing 变成白屏。 | +| CodexMonitor 本地代码 | `package.json` 没有 i18n 依赖;前端和 Rust 侧存在大量英文硬编码错误 / UI 文案;`src/utils/time.ts` 使用 `Intl.RelativeTimeFormat(undefined)`,但 short relative time 仍手写 `m/h/d/w/mo/y`。 | Lime 的 `format.ts` 必须显式传 UI locale,并禁止重新出现手写相对时间单位;Rust 新错误应逐步转成 `code + params + fallbackMessage`。 | +| CodexMonitor 本地代码 | `dictationPreferredLanguage` / `preferredLanguage` 从设置传到 Whisper 转写;`composerFenceLanguageTags`、代码高亮 language、`localeCompare` 排序和 TestFlight `--locale` 也都是 language-like 场景。 | PRD 明确新增 “ASR / dictation language” 与 “发布材料 locale” 两个边界;这些字段不应复用 UI `Config.language`。 | + +补充资料链接: + +- i18next TypeScript / selector:`https://www.i18next.com/overview/typescript` +- i18next 官方抽取工具:`https://www.i18next.com/how-to/extracting-translations` +- i18next supported frameworks / official CLI:`https://www.i18next.com/overview/supported-frameworks` +- react-i18next `Trans`:`https://react.i18next.com/latest/trans-component` +- Vite glob import:`https://vite.dev/guide/features.html#glob-import` +- MDN `lang`:`https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/lang` +- MDN `dir`:`https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir` +- W3C language tag:`https://www.w3.org/International/questions/qa-choosing-language-tags` +- Tauri Store:`https://v2.tauri.app/plugin/store/` + +### 2.9 最终复核记录 + +> 复核时间:2026-05-10 +> 口径:本节只固化本轮调研证据,不替代后续 `docs/exec-plans/` 的实施计划。 + +本轮再次使用 Context7、WebSearch 与本地代码复核,确认当前 PRD 仍应坚持 “key-based current 主路径 + legacy Patch 迁移期兜底”: + +1. Context7 选用的官方资料 ID 为 `/websites/i18next`、`/i18next/react-i18next`、`/vitejs/vite`,分别复核 `fallbackLng` / `fallbackNS`、namespace、interpolation、plural、context、`CustomTypeOptions`、`enableSelector`、`useTranslation(ns)`、`Trans`、Suspense 与 `import.meta.glob`。结论是 Lime P0/P1 应继续使用稳定 dotted string key,等资源规模与类型内存风险可控后再评估 selector API。 +2. WebSearch 只采用官方或上游资料:i18next extraction / TypeScript、react-i18next `Trans` / `useTranslation`、Vite glob import、MDN `Intl` / `lang` / `dir`、W3C BCP 47 语言标签、Tauri v2 Store。结论是 P0 不新增 Store 或 detector/backend 依赖,优先复用已有 `Config.language` 与包内 resources,避免出现第二套语言偏好事实源。 +3. LobeHub 本地复核确认 `/Users/coso/Documents/dev/js/lobehub/.i18nrc.js` 当前维护 18 个 output locale,`/Users/coso/Documents/dev/js/lobehub/locales/en-US` 下有 46 个 namespace;`src/locales/create.ts` 使用 bundled fallback resources、`LanguageDetector`、`resourcesToBackend`、`partialBundledLanguages`、`initAsync` 与 `documentElement.dir` 同步;`loadI18nNamespaceModule.vite.ts` 使用 lazy glob,`loadI18nNamespaceModule.desktop.ts` 使用 eager glob。Lime 借鉴 loader / workflow / normalize 思路,但不照搬 Next.js、SEO、Ant Design locale 与 18 语言首发范围。 +4. CodexMonitor 本地复核确认 `/Users/coso/Documents/dev/rust/CodexMonitor/package.json` 没有正式 i18n 依赖;`src/utils/time.ts` 与 `src/features/home/homeFormatters.ts` 同时存在 `Intl.*` 与手写短时间单位;`src-tauri/src/terminal.rs` 独立处理 `LANG` / `LC_*`;`src/features/settings/components/sections/SettingsDictationSection.tsx` 与 `src-tauri/src/dictation/real.rs` 存在 dictation preferred language。对 Lime 的约束是:UI locale、Agent response language、Browser environment language、terminal locale、ASR language、代码高亮 language 必须分字段治理,不能共用 `Config.language`。 +5. 本轮未发现需要推翻首期技术路线的外部资料更新;需要新增到路线图的只是 P3 工具链取舍:短期保留 `detect-translations`,中期优先评估官方 `i18next-cli` 做 extraction、hardcoded string lint、locale sync 与 type generation,AI 自动翻译再作为独立 workflow 接入。 + +### 2.10 协作复核补充 + +> 复核时间:2026-05-10 +> 口径:本节记录并行协作期间的只读复核结果,只补证据,不扩大实现范围。 + +本轮在检测到并行 `npm run verify:local` 进程仍在运行后,只做低风险读证与 PRD 补充: + +1. Context7 复核 `/websites/i18next`:官方资料仍建议用 namespace、`fallbackLng`、`fallbackNS`、interpolation、plural、context 与 `CustomTypeOptions` 组织大型应用本地化;官方 extraction 文档推荐 `i18next-cli` 承担 key extraction、code lint、locale sync 与 type generation。对 Lime 的落点不变:P0/P1 保持稳定 dotted key 与自有 `detect-translations`,P3 再评估官方 CLI 接入。 +2. Context7 复核 `/i18next/react-i18next`:`useTranslation(ns)` 是普通组件文案主入口;`Trans` 只用于带 React 节点、链接、强调、代码片段或复杂插值的富文本;关闭 Suspense 后必须通过预加载或 `ready` 防止加载期闪烁。对 Lime 的落点不变:桌面端核心 namespace eager,普通按钮/标题/placeholder 不滥用 `Trans`。 +3. Context7 复核 `/vitejs/vite`:`import.meta.glob` 默认 lazy dynamic import,`{ eager: true, import: "default" }` 可直接拿默认导出;glob 参数必须是字面量,不能由变量拼接。对 Lime 的落点不变:`loadNamespace.ts` 使用静态 glob / registry 查表,不在运行时拼资源路径。 +4. WebSearch 复核官方/上游资料:i18next TypeScript 与 extraction、Vite glob import、MDN `lang` / `dir` / `Intl`、W3C language tag 与 Tauri Store 的结论未变化;P0 不引入第二套 Store 或 browser detector 事实源,仍复用现有 `Config.language` 与包内 resources。 +5. LobeHub 本地只读复核:`/Users/coso/Documents/dev/js/lobehub/package.json` 当前使用 `i18next ^25.8.0`、`react-i18next ^16.5.3`、`i18next-browser-languagedetector ^8.2.0`、`i18next-resources-to-backend ^1.2.1`、`rtl-detect ^1.1.2`、`@lobehub/i18n-cli ^1.26.0`;`locales` 下有 18 个 locale 目录,`locales/en-US` 下有 46 个 JSON namespace;仍适合作为成熟 workflow 与 loader 样板,而不是首期语言规模样板。 +6. CodexMonitor 本地只读复核:`/Users/coso/Documents/dev/rust/CodexMonitor/package.json` 仍无 `i18next` / `react-i18next` / `@formatjs/intl` / `dayjs`;代码中同时存在 `Intl.RelativeTimeFormat`、`localeCompare`、terminal `LANG` / `LC_*`、dictation preferred language、code block language 等 language-like 场景。对 Lime 的落点不变:格式化走统一 `format.ts`,UI locale、ASR language、terminal locale 与代码高亮 language 必须拆字段治理。 +7. Lime 当前读证:`package.json` 已有 `i18next ^25.7.3`、`react-i18next ^16.5.1`、`dayjs ^1.11.19`,未引入 detector/backend/RTL 依赖;`src/i18n/resources` 当前已有 `zh-CN`、`en-US`、`zh-TW`、`ja-JP`、`ko-KR` 五个 locale,每个 locale 均有 `common`、`navigation`、`settings`、`workspace`、`agent`、`errors` 六个核心 namespace。 + +## 3. 问题诊断 + +### 3.1 Patch Layer 的上限 + +当前 Patch Layer 的优势是侵入小、见效快,但长期问题明显: + +- 只能替换已渲染文本,无法稳定处理变量、复数、富文本、组件插槽和运行时错误。 +- 依赖 DOM 扫描与 `MutationObserver`,页面越复杂,性能和时序风险越高。 +- 资源 key 是中文原文,文案微调会导致 key 失效,无法稳定引用。 +- 只能表达 `zh/en`,无法承载 `zh-CN`、`zh-TW`、`ja-JP`、`ko-KR`、RTL 等真实 locale。 +- 不能参与类型检查、静态扫描、unused key 清理与 CI 覆盖率。 +- 容易把“用户界面语言”“AI 回复语言”“浏览器环境语言”“内容产物语言”混成一个字段。 + +### 3.2 现有配置语义过窄 + +Rust 注释仍把 `Config.language` 描述为 `"zh" 或 "en"`,但全球本地化需要 BCP 47 风格 locale,例如 `zh-CN`、`en-US`、`ja-JP`。首期应做兼容 normalize: + +| 旧值 | 新规范值 | +| ---- | --------------------------------- | +| `zh` | `zh-CN` | +| `en` | `en-US` | +| 空值 | `auto` 或 `zh-CN`,按阶段策略决定 | + +### 3.3 语言概念混用风险 + +Lime 已有多个 language-like 场景: + +- UI locale:控制 Lime 界面语言。 +- Response language:控制 Agent 默认回复语言。 +- Content target language:控制 Artifact / 文章 / 翻译 / media task 的产物语言。 +- Browser environment locale:控制远程站点看到的 `Accept-Language`、timezone、locale。 +- ASR / dictation language:控制语音识别语言。 +- Code block language:只是语法高亮标签,不是自然语言。 + +PRD 必须明确这些概念的边界,避免把一个全局 `language` 字段扩成所有场景的隐式事实源。 + +## 4. 产品目标 + +### 4.1 总目标 + +建立 Lime 全球本地化能力层,使新功能默认可翻译、可校验、可持续增加 locale,并让桌面端 GUI 在语言切换后保持稳定可用。 + +### 4.2 首期语言范围 + +首期推荐支持: + +| Locale | 显示名 | 用途 | +| ------- | -------- | ------------------------------------------ | +| `auto` | 跟随系统 | 默认推荐项,按系统/浏览器 locale normalize | +| `zh-CN` | 简体中文 | Source locale 与默认 fallback | +| `en-US` | English | 第一海外主语言 | +| `zh-TW` | 繁體中文 | 中文变体与繁体排版验证 | +| `ja-JP` | 日本語 | CJK 非中文验证 | +| `ko-KR` | 한국어 | CJK 非中文验证 | + +后续候选:`fr-FR`、`de-DE`、`es-ES`、`pt-BR`、`vi-VN`、`ar`、`fa-IR`。其中 `ar` / `fa-IR` 必须等 RTL 基础设施完成后再进入主支持列表。 + +### 4.3 用户价值 + +- 海外用户首次启动能看到可理解的核心路径,而不是只看到中文。 +- 中文用户可以保持中文 UI,同时让 Agent 输出英文、日文或韩文。 +- 创作/翻译任务能显式选择目标语言,不受 UI 语言影响。 +- Browser Runtime 可以继续模拟目标市场环境,不被 UI 语言污染。 +- 新增页面的本地化质量可被脚本和测试机械约束。 + +## 5. 非目标 + +1. 不做 Web SEO、locale route、sitemap 或 URL 级语言切换。 +2. 不一次性迁移全部历史页面和角落文案。 +3. 不继续扩大 `src/i18n/legacy-patch/patches/*.json` 作为 current 主机制。 +4. 不在首期引入完整翻译管理平台;自动翻译先以 repo 内 JSON、脚本与 PR workflow 为事实源。 +5. 不把 Browser Runtime 的 `Accept-Language` 绑定到 Lime UI locale。 +6. 不要求 Rust 后端立即清空所有中文错误字符串,但新增 current 主路径应返回 code + params。 + +## 6. 目标架构 + +### 6.1 目录结构 + +建议目标结构: + +```text +src/i18n/ +├── createI18n.ts +├── format.ts +├── loadNamespace.ts +├── locales.ts +├── provider.tsx +├── resources/ +│ ├── zh-CN/ +│ │ ├── common.json +│ │ ├── navigation.json +│ │ ├── settings.json +│ │ ├── workspace.json +│ │ ├── agent.json +│ │ ├── browser.json +│ │ ├── artifact.json +│ │ ├── knowledge.json +│ │ ├── sceneapp.json +│ │ └── errors.json +│ ├── en-US/ +│ ├── zh-TW/ +│ ├── ja-JP/ +│ └── ko-KR/ +├── legacy-patch/ +│ ├── I18nPatchProvider.tsx +│ ├── dom-replacer.ts +│ └── patches/ +└── types.d.ts +``` + +迁移原则: + +- `resources/zh-CN` 是 source locale。 +- 新 UI 只允许使用 `useTranslation(ns)` / `Trans` / `t()`,不新增 Patch key。 +- `legacy-patch` 只覆盖未迁移历史 UI,并且需要退出条件。 +- namespace 内推荐继续使用扁平 dotted key,并设置 `keySeparator: false`,避免深层对象重排带来的翻译 diff 噪音。 + +### 6.2 Locale registry + +`src/i18n/locales.ts` 负责: + +- `SUPPORTED_LOCALES` +- `SOURCE_LOCALE = "zh-CN"` +- `FALLBACK_LOCALE = "zh-CN"` +- `localeOptions` +- `normalizeLocale(input?: string): Locale` +- `resolveAutoLocale(systemLocale?: string): Locale` +- `isRtlLocale(locale): boolean` +- 兼容旧值 `zh` / `en` + +normalize 规则: + +1. 空值或 `auto`:读取系统/浏览器 locale,无法识别时落到 `zh-CN`。 +2. `zh`、`zh-Hans`、`cn`:归一到 `zh-CN`。 +3. `zh-Hant`、`zh-TW`、`zh-HK`:首期归一到 `zh-TW`。 +4. `en`、`en-*`:首期归一到 `en-US`。 +5. `ja`、`ko`:分别归一到 `ja-JP`、`ko-KR`。 +6. 未支持 locale:落到 `zh-CN`,并记录 debug warning。 + +### 6.3 i18next 初始化 + +`createI18n.ts` 负责创建单例或 scoped instance: + +- `fallbackLng: "zh-CN"` +- `defaultNS: "common"` +- `ns` 不一次性声明所有 namespace,按核心和业务分层加载。 +- `react.useSuspense = false`,避免桌面端切换语言时出现大面积 loading。 +- 核心 namespace eager preload:`common`、`navigation`、`settings`、`workspace`、`agent`、`errors`。 +- 非核心 namespace lazy load:`sceneapp`、`knowledge`、`artifact`、`browser`、`media`、`voice` 等。 +- `languageChanged` 时同步: + - `document.documentElement.lang` + - `document.documentElement.dir` + - `dayjs.locale` 或统一 date adapter + - format cache invalidation + +### 6.4 Namespace loading + +`loadNamespace.ts` 采用 Vite `import.meta.glob`: + +- Web/dev 模式可 lazy load:`import.meta.glob("./resources/*/*.json")` +- 桌面稳定模式可 eager load 核心资源,避免 Tauri 首屏懒加载抖动。 +- namespace 缺失时必须回落到 `zh-CN`,并输出可测试的 warning。 +- 资源加载失败不能让 App 白屏,最多显示 source locale 文案。 + +### 6.5 类型安全 + +`types.d.ts` 使用 i18next `CustomTypeOptions` 绑定 resources: + +- `defaultNS: "common"` +- migrated namespaces 进入类型检查。 +- 允许迁移期对未迁移 namespace 使用宽类型,但 current 主路径不应长期停留宽类型。 +- P1 不强制启用 i18next selector API;P3 再结合资源规模、TypeScript 内存表现与官方 `@i18next-selector/codemod` 评估从 string key 迁到 selector key。 +- 类型绑定只覆盖已经迁移且 key 稳定的 namespace,避免把还在快速变动的 legacy 文案提前固化成长期类型债。 + +### 6.6 统一格式化 API + +`format.ts` 提供统一封装: + +- `formatDate(value, options)` -> `Intl.DateTimeFormat(locale, options)` +- `formatNumber(value, options)` -> `Intl.NumberFormat(locale, options)` +- `formatRelativeTime(value, unit, options)` -> `Intl.RelativeTimeFormat(locale, options)` +- `formatList(values, options)` -> `Intl.ListFormat(locale, options)`,如运行环境支持 +- `localeCompare(left, right, options)` -> `left.localeCompare(right, locale, options)` + +规则: + +- 不在翻译 JSON 里手写“分钟前 / days ago”这类时间单位。 +- 不直接使用 `undefined` locale,除非明确是 `auto` 语义。 +- 所有用户可见列表排序需要显式考虑 locale。 + +## 7. 产品边界 + +### 7.1 UI language + +定义:控制 Lime GUI 文案、方向、日期数字展示和组件可读性。 + +事实源:首期复用 `Config.language`,但值迁移到 BCP 47 风格 locale。 + +设置入口:设置页语言选择器,推荐选项为 `auto`、`zh-CN`、`en-US`、`zh-TW`、`ja-JP`、`ko-KR`。 + +实现约束: + +- 不新增 Tauri 命令,复用 `get_config` / `save_config`。 +- `zh` / `en` 只作为读兼容,不作为新写入值。 +- 切换语言后无需重启;首期可接受局部组件重新渲染。 +- 设置页属于表单型设置页,应遵守 Lime 现有轻盈、清晰、专业的视觉语言,不新增营销式 hero 或高饱和背景。 + +### 7.2 AI response language + +定义:控制 Agent 默认回复语言,不控制 UI。 + +推荐事实源:首期可新增到 `workspace_preferences.response_language` 或等价用户偏好字段;落点需在实施设计里结合现有 Agent config 最小化 schema 扩散。 + +产品规则: + +- 默认 `auto`:跟随用户最近输入语言或 UI locale,具体算法需在实现文档中定义。 +- 用户可以设置固定值,例如 `en-US`、`ja-JP`。 +- Agent request metadata 应携带 response language,模型系统提示应显式说明。 +- 不能把 UI locale 当成唯一回复语言事实源。 + +### 7.3 Content target language + +定义:控制某次创作、翻译、Artifact、media task 的产物语言。 + +规则: + +- 必须是任务级参数或文档级元数据。 +- 不默认写回 UI language。 +- SceneApp / Artifact / media task 中已有 `target_language`、`language` 字段时,应统一命名和说明,避免一处表示 UI,一处表示产物。 + +### 7.4 Browser environment language + +定义:控制浏览器访问外站时暴露给网站的 locale、`Accept-Language`、timezone 等环境。 + +规则: + +- 继续由 Browser Environment preset 管理。 +- 不随 UI language 自动改变。 +- 设置文案中要明确“站点环境语言”和“Lime 界面语言”不是一个概念。 + +### 7.5 Rust / Tauri 用户可见错误 + +目标方向:Rust 返回结构化错误,前端翻译最终用户文案。 + +推荐形态: + +```ts +type LocalizedErrorPayload = { + code: string; + params?: Record; + fallbackMessage?: string; +}; +``` + +迁移原则: + +- 新 current 主路径优先返回 `code + params + fallbackMessage`。 +- 前端 `errors` namespace 负责展示。 +- 历史中文错误不在首期全量改造,但新增命令不得继续扩大中文长文错误面。 +- 涉及 Tauri 命令边界时必须遵守四侧同步:前端调用、Rust 注册、治理目录册、mock。 + +## 8. 迁移路线 + +### P0:骨架与兼容层 + +目标:建立 key-based i18n 的最小可运行骨架,不破坏现有页面。 + +交付: + +1. 新增 locale registry 与 normalize。 +2. 新增 `createI18n`、provider、核心 resources。 +3. 将现有 Patch Layer 移到 `legacy-patch` 或明确标注 legacy boundary。 +4. `Config.language` 读取兼容 `zh` / `en`,写入新 locale。 +5. `document.documentElement.lang` 与 `dir` 同步。 +6. 修复或替换失效的 `detect-translations*` 脚本入口。 + +验收: + +- App 能以 `zh-CN` / `en-US` 启动。 +- 未迁移页面仍可由 Patch Layer 兜底。 +- 新 provider 不造成首屏白屏或明显闪烁。 +- 单测覆盖 normalize 与 fallback。 + +### P1:设置页与主导航迁移 + +目标:让用户真实切换 UI language,并覆盖主路径首屏。 + +交付: + +1. 设置页语言选择器迁移到 key-based i18n。 +2. 侧栏、顶部栏、主导航、Workspace shell、空态、基础按钮迁移。 +3. `common`、`navigation`、`settings`、`workspace`、`errors` namespace 建立 source + en-US。 +4. 新增 UI 回归测试,覆盖语言选择、持久化、关键文案。 + +验收: + +- 切换语言后设置页、侧栏、Workspace shell 立即更新。 +- 重启后保留选择。 +- GUI smoke 覆盖默认 workspace 准备态。 + +### P2:Agent / Artifact / Browser / Knowledge 主路径 + +目标:把 Lime 的核心产品能力从“能看懂界面”推进到“能按语言工作”。 + +交付: + +1. Agent Chat 主路径迁移 `agent` namespace。 +2. 增加 AI response language 设置与 request metadata 注入。 +3. Artifact / 文档 / 文章 / 翻译类任务明确 content target language。 +4. Browser Environment 设置页文案明确 `Accept-Language` 与 UI language 的差异。 +5. Knowledge / SceneApp / Browser / Artifact 主要入口迁移对应 namespace。 +6. 用户可见 toast / error 进入 `errors` namespace。 + +验收: + +- UI 中文、Agent 英文回复的组合可用。 +- UI 英文、Browser preset 为日区/美区的组合不互相污染。 +- Artifact 目标语言不因 UI 切换而改变。 +- 关键错误能通过 error code 翻译展示。 + +### P3:自动化与治理 + +目标:让新增 locale、检查漏翻、清理废 key 变成可重复流程。 + +交付: + +1. 参考 LobeHub 建立 `scripts/i18n/*`: + - source locale 导出 + - missing key 检查 + - unused key 分析 + - protected dynamic key pattern + - 翻译覆盖率报告 +2. 评估官方 `i18next-cli` 作为抽取 / hardcoded string lint / locale sync / type generation 的默认候选;AI 自动翻译部分再对比 `@lobehub/i18n-cli` 与 Lime 自研脚本,首选能最少改动并可 CI 运行的方案。 +3. 建立 glossary:产品名、功能名、Agent 术语、Browser Runtime 术语、SceneApp 术语。 +4. 自动翻译只创建 PR,不直接覆盖 source locale。 +5. PR 模板要求标注新增/变更文案的 namespace。 +6. 资源规模扩大后补 bundle 体积与 chunk 策略报告;参考 LobeHub 把核心 namespace 内联、重 namespace 独立切块,避免桌面首屏被非核心 locale 资源拖慢。 + +验收: + +- `npm run i18n:check` 能在本地发现漏 key 与无效 locale。 +- `npm run verify:local` 或质量选择器能覆盖 i18n 结构风险。 +- 翻译 PR 可审阅、可回滚、不会覆盖人工修订。 + +### P4:扩展与发布材料 + +目标:把 i18n 能力从桌面主 App 扩展到生态与发布链路。 + +交付: + +1. Chrome extension 评估是否迁移到 `_locales/messages.json` 标准结构。 +2. 发布说明、官网文档、帮助文档进入独立翻译 workflow。 +3. 引入 RTL locale 前完成布局审计、截图回归与 Playwright smoke。 +4. 多平台 installer / app metadata 本地化评估。 + +验收: + +- extension 与桌面 App 的术语一致。 +- RTL 不破坏设置页、侧栏、Workspace、弹窗主路径。 +- 发布材料至少覆盖 `zh-CN` / `en-US`。 + +## 9. 质量门禁 + +### 9.1 新代码规则 + +- 新 UI 文案必须进入 namespace resources。 +- 禁止在 current 主路径继续新增 `legacy-patch/patches/*.json` key。 +- 允许中文注释继续遵守仓库规则,但用户可见文案不得散落在组件里。 +- 动态文案使用 interpolation,不使用字符串拼接。 +- 富文本或带链接文案使用 `Trans`。 +- 数量相关文案使用 plural,不手写 `count === 1` 的英文分支。 +- 错误展示走 `errors` namespace。 + +### 9.2 验证入口 + +按 Lime 质量工作流: + +| 改动类型 | 最低验证 | +| ---------------------------------------- | ------------------------------------------------------------------ | +| 仅改 PRD / 文档 | 读回检查即可 | +| i18n resource / 前端普通迁移 | `npm run verify:local` + i18n 定向测试 | +| 设置页 / 主导航 / Workspace 可见改动 | `npm run verify:local` + 稳定 UI 回归 + `npm run verify:gui-smoke` | +| Tauri config schema / 命令 / Bridge 变化 | `npm run verify:local` + `npm run test:contracts` | +| Rust 错误结构变化 | Rust 定向测试 + 前端错误展示回归 + contracts | +| RTL 或主路径交互变化 | GUI smoke 后进入 Playwright 续测 | + +### 9.3 关键回归场景 + +1. 首次启动:`auto` 能解析到受支持 locale,无法解析则 fallback。 +2. 设置页切换:语言立即生效、持久化、重启后保留。 +3. Fallback:某 locale 缺少 key 时显示 `zh-CN`,不白屏。 +4. `Trans`:带链接/代码/强调的文案在各语言正常渲染。 +5. Interpolation:变量顺序在英文、日文、韩文中可调整。 +6. Plural:英文复数和中文无复数都能正确展示。 +7. Format:日期、数字、相对时间随 UI locale 变化。 +8. Agent:UI locale 与 response language 分离。 +9. Browser:UI locale 与 `Accept-Language` preset 分离。 +10. Patch fallback:未迁移历史页面不阻塞主路径。 + +## 10. 指标 + +### 10.1 产品指标 + +- 首期核心路径 key-based 覆盖率:P1 达到 80%,P2 达到 95%。 +- 设置页语言切换成功率:本地 smoke 100%。 +- 首屏语言闪烁:核心 namespace 预加载后不出现明显中文/英文闪回。 +- 用户可见 hard-coded 文案新增数:current 主路径为 0。 + +### 10.2 工程指标 + +- resources 中 `zh-CN` / `en-US` 缺 key 数为 0。 +- i18n check 可在本地与 CI 复用。 +- Patch Layer 命中量逐阶段下降,并可在 P3 后输出报告。 +- 自动翻译 PR 不覆盖人工修改。 + +## 11. 风险与决策 + +### 11.1 Source locale 选择 + +推荐 `zh-CN` 作为 source locale。 + +理由:Lime 当前产品、文档、注释和用户主路径以中文为事实源;强行改成 `en-US` 会扩大迁移范围。LobeHub 使用 `en-US` 是可参考的成熟形态,但不应机械照搬。 + +风险:从中文自动翻译到多语言时,需要更强 glossary 与人工 review。 + +### 11.2 Lazy vs eager load + +推荐混合策略:核心 namespace eager,业务 namespace lazy。桌面端如果出现运行时加载抖动,可参考 LobeHub desktop loader 使用 `{ eager: true }`。 + +### 11.3 Patch Layer 退出条件 + +Patch Layer 不能无限期作为事实源。建议退出条件: + +- P2 后禁止新增 patch key。 +- P3 后输出 Patch 命中量报告。 +- 核心路径迁移完成后,仅允许 legacy 页面临时保留。 +- 当剩余 Patch key 低于约 10% 且无 current 主路径依赖时,拆除 DOM replacer。 + +### 11.4 自动翻译质量 + +自动翻译只能降低初稿成本,不能替代 review。必须维护 glossary 与 protected key pattern,并通过 PR 审核进入主分支。 + +### 11.5 配置迁移 + +`Config.language` 已存在,首期不新增命令。若新增 response language 字段,必须同步 Rust schema、前端类型、默认 mock、配置文档和验证入口。 + +## 12. 里程碑建议 + +| 阶段 | 目标 | 预计收益 | +| ---- | -------------------------- | ------------------------ | +| P0 | 建骨架,保兼容 | 新旧 i18n 并存但边界清晰 | +| P1 | 设置与 GUI shell | 用户能真实切换语言 | +| P2 | Agent / Browser / Artifact | 本地化进入核心产品能力 | +| P3 | 自动化治理 | 新增语言可持续、可校验 | +| P4 | 扩展与发布材料 | 全球发布闭环 | + +## 13. 第一刀建议 + +第一刀不要直接翻译全仓,也不要先接自动翻译。建议先做最小 current skeleton: + +1. 建 `src/i18n/locales.ts`,完成 locale registry 与 normalize 单测。 +2. 建 `src/i18n/resources/zh-CN` 与 `en-US` 的核心 namespace 最小集。 +3. 建 `createI18n.ts` / provider,替换根部 i18next 初始化,但继续包住 legacy Patch Layer。 +4. 设置页新增 UI language 选择,复用 `getConfig` / `saveConfig`。 +5. 补 `settings` 与 `navigation` 的稳定回归,再跑 `npm run verify:local` 与 `npm run verify:gui-smoke`。 + +这一刀的价值是把事实源从 DOM Patch 拉回 key-based resources,同时不要求一次性迁移全仓。 + +## 14. 当前第一刀落地状态 + +> 记录日期:2026-05-10 +> 口径:本节只记录 P0/P1 起步骨架,不代表全仓本地化完成。 + +已落地: + +1. `src/i18n/locales.ts` 已建立 locale registry,支持 `auto`、`zh-CN`、`en-US`、`zh-TW`、`ja-JP`、`ko-KR`,并兼容旧值 `zh` / `en`。 +2. `src/i18n/createI18n.ts` 已把 i18next current 主路径初始化到 key-based resources,核心 namespace 包含 `common`、`navigation`、`settings`、`workspace`、`agent`、`errors`。 +3. `src/i18n/loadNamespace.ts` 已使用 Vite `import.meta.glob` 建立 bundled core namespace loader,并由 `createI18n.ts` 消费。 +4. `src/i18n/format.ts` 已建立日期、数字、相对时间、列表与 locale-sensitive sort 的统一封装。 +5. `src/i18n/resources//*.json` 已建立核心资源骨架;`settings` 与基础 `common` key 可支撑设置页和语言选择首期迁移。 +6. `src/i18n/config.ts` 与 `src/i18n/withI18nPatch.tsx` 已接入 current i18next,同时保留 DOM Patch 作为 legacy fallback。 +7. 设置页外观分组与侧边栏语言选择已改为写入 BCP 47 locale,并同步 legacy Patch 的 `"zh" | "en"` 兼容语言。 +8. 设置页 About 区块已从自然语言 key 迁移到 `settings.about.*` namespace key,并补齐 5 个 locale 资源。 +9. 设置页 Profile 区块已迁移到 `settings.profile.*` namespace key,字段说明、状态、头像提示、偏好标签和资料使用说明均进入 5 个 locale 资源。 +10. 设置页 Stats 区块已迁移到 `settings.stats.*` namespace key,时间范围、概览、模型排行、趋势图和活跃日历文案均进入 5 个 locale 资源,并开始使用 `format.ts` 的 locale-aware 日期格式化。 +11. `Config.language` 默认值与注释已从旧 `"zh" / "en"` 语义迁移到 BCP 47 / `auto` 语义;Rust 默认 UI locale 改为 `zh-CN`,前端类型和 mock 默认配置同步说明,legacy `"zh" / "en"` 仅保留为读兼容。 +12. 设置页 `_layout` 共享壳层已迁移到 `settings.layout.*` namespace key,顶部回首页按钮、所有 lazy fallback 文案和 not-found 占位文案均进入 5 个 locale 资源。 +13. `.gitignore` 已放行 `docs/roadmap/i18n/*.md`,确保本 PRD 可作为版本化工件进入仓库。 +14. Patch Layer 已物理搬入 `src/i18n/legacy-patch/`,根层只保留 current i18next、resources、format、locale registry 与 bootstrap;旧 DOM Patch 路径正式归类为迁移期 compatibility/deprecated surface。 +15. `src/lib/governance/legacySurfaceCatalog.json` 已把旧根层 Patch 文件纳入 `i18n-dynamic-template-legacy-surface` dead-candidate 守卫,防止 `@/i18n/I18nPatchProvider`、`@/i18n/text-map` 与根层 `patches/*` 回流。 +16. 侧边栏主导航、底部系统入口、导航折叠 / 展开、搜索任务、插件扩展分组与语言二级菜单的可见文案已迁入 `navigation` namespace;切换到 `en-US` 后主导航会显示 `New Task` / `Project Knowledge` 等 key-based 文案,不再依赖 DOM Patch。 +17. 侧栏搜索弹窗与会话列表起步文案已迁入 `navigation` namespace,覆盖搜索输入 / 空态 / 加载 / 更多按钮、新建对话、最近 / 归档分组、会话操作菜单、多选 toolbar、收藏 badge 与操作菜单 aria/title 文案。 +18. 会话列表与搜索结果的空标题兜底、更新时间 meta、归档 meta 已改为 current i18n + `Intl.RelativeTimeFormat` / `Intl.DateTimeFormat`,英文界面会显示 `Untitled conversation`、`2m ago`、`Archived 3h ago` 等 locale-aware 文案,不再由 `sidebarSessionFormatting.ts` 手写中文单位。 +19. 会话重命名 prompt、删除 confirm、重命名 / 删除 toast 已迁入 `navigation` namespace;英文界面删除空标题会话时会展示 `Delete "Untitled conversation"? This cannot be undone.`,并继续复用同一套会话标题兜底。 +20. 账号菜单的未登录开源卡片、云端状态、连接云端、用户中心、模型设置、关于、退出登录、云端登录 / 用户中心 toast 已迁入 `navigation` namespace;英文界面未登录账号菜单会显示 `Local ready`、`Open Source Use`、`Connect Lime Cloud` 等 key-based 文案。 +21. `src/i18n/types.d.ts` 已接入 i18next `CustomTypeOptions`,把 `zh-CN` source resource 绑定到 `common`、`navigation`、`settings`、`workspace`、`agent`、`errors` namespace,并保持 `keySeparator: false` 的 flat dotted key 策略;`src/i18n/__tests__/types.test.ts` 用 `@ts-expect-error` 覆盖缺失 key,确保 `tsc` 能发现未登记 key。 +22. `src/i18n/legacy-patch/dom-replacer.ts` 已补充 Patch 命中量指标基础 API,记录每次 DOM Patch 的 language、root kind、耗时、替换节点数和命中文本段数,并提供 `getI18nPatchMetricsReport()` / `resetI18nPatchMetrics()`,作为 P3 Patch 退出报告的数据来源;`I18nPatchProvider` 的语言切换计数也改为复用同一指标入口。 +23. `scripts/i18n-patch-metrics-report.mjs` 与 `scripts/lib/i18n-patch-metrics-report-core.mjs` 已把导出的 Patch runtime metrics 转成稳定 text / JSON 报告,支持 `--check` 与 `maxMatchedSegments` / `maxReplacedNodes` / `maxRuns` 门限,`package.json` 已新增 `npm run i18n:patch-report` 与 `npm run i18n:patch-report:json`。 +24. `npm run verify:gui-smoke` 已把 `smoke:knowledge-gui` 接成 Patch metrics 真实 GUI 样本导出入口,默认写入 `.lime/i18n/patch-metrics.json` 并生成 `.lime/i18n/patch-metrics-report.json`;调试无关 GUI smoke 失败时可用 `--skip-i18n-patch-metrics` 跳过。 +25. 设置页 Home 首页已迁移到 `settings.home.*` namespace key,覆盖首页 hero、统计 chip、常用入口、桌宠入口、current 入口卡、分组说明与 item 状态文案,并补齐 5 个 locale 资源;测试通过 mock `useTranslation("settings")` 保持稳定回归。 +26. 设置页 Developer Lab 合并页已迁移到 `settings.developerLab.*` namespace key,覆盖合并页标题、说明、开发者工具 / 实验功能 tab 文案,并补齐 5 个 locale 资源;测试通过 mock `useTranslation("settings")` 保持稳定回归。 +27. 设置页 Skills 高级入口说明已迁移到 `settings.agent.skills.*` namespace key,覆盖高级技能入口标题、Tips aria / 内容和问题反馈链接文案,并补齐 5 个 locale 资源;`SkillsPage` 主体仍按独立 Skills 工作台后续迁移,不在本条完成范围内。 +28. 设置页 Hotkeys 已迁移到 `settings.hotkeys.*` namespace key,覆盖页面 hero、状态 chip、错误态、分区摘要、快捷键条目、scope/source/condition 元信息和 `hotkeyCatalog` 共享状态文案,并补齐 5 个 locale 资源;测试通过 mock `useTranslation("settings")` 保持稳定回归。 +29. 设置页图片 / 视频服务模型已迁移到 `settings.mediaGeneration.*` namespace key,覆盖 ImageGen / VideoGen 页面标题、说明、Provider 不可用提示、保存结果、空态、回退策略与共享 `MediaPreferenceSection` 的 Tips aria、恢复默认和自动选择文案,并补齐 5 个 locale 资源;测试通过 mock `useTranslation("settings")` 保持稳定回归。 +30. 设置页 Service Models 总页 `agent/media-services` 已迁移到 `settings.mediaServices.*` namespace key,覆盖服务模型总览、8 个服务模型分区、当前行为说明、服务模型空态、自定义提示词、AI 图片默认数量与保存结果文案,并补齐 5 个 locale 资源;测试通过 mock `useTranslation("settings")` 保持稳定回归。 +31. 设置页 Voice 区块 `agent/voice` 已迁移到 `settings.voice.*` namespace key,覆盖语音输入、Fn / 翻译模式快捷键状态、本地 SenseVoice 模型下载与测试转写、语音处理、语音服务模型和所有保存 / 失败提示文案,并补齐 5 个 locale 资源;测试通过稳定 `useTranslation("settings")` mock 保持 11 个交互回归。 +32. 设置页 Providers 内的 `CompanionCapabilityPreferencesCard` 已迁移到 `settings.providers.companion.preference.*` namespace key,覆盖桌宠能力偏好标题说明、当前主链摘要、桌宠通用模型选择、回退策略、空态、重置按钮和保存 / 失败提示文案,并补齐 5 个 locale 资源;测试通过稳定 `useTranslation("settings")` mock 保持独立卡片回归。 +33. 设置页 Providers 主体入口已开始迁移到 `settings.providers.*` namespace key,当前覆盖 workspace 顶部切换器的服务商设置 / 云端服务 / 桌宠管理标签与摘要、云端品牌 fallback、登录页 / 用户中心打开成功和失败反馈文案。 +34. 设置页 Providers 的 Companion Bridge 诊断区已迁移到 `settings.providers.companion.bridge.*` namespace key,覆盖桌宠连接状态、能力声明、桥接摘要、接入检查、同步建议、脱敏预览、安装引导、刷新 / 开启 / 同步反馈和 Tips aria 文案,并把最近同步时间改为复用 locale-aware `formatDate()`;当前 5 个 locale 均补齐 112 个 bridge key。 +35. 设置页 Account / User Center Session 已迁移到 `settings.userCenterSession.*` namespace key,覆盖账户资料 hero、登录状态 chip、云端服务未配置 / 恢复态、会话摘要、账号中心资料维护说明、Google 登录、浏览器授权同步说明、备用邮箱验证码 / 账号密码登录表单和所有输入占位 / 操作按钮文案,并补齐 5 个 locale 资源。 +36. 设置页 System / Developer 已迁移到 `settings.developer.*` namespace key,覆盖开发者页标题、调试状态 chip、工作台 / 组件调试开关、诊断日志操作、懒加载 fallback、服务型技能目录、站点脚本目录与 Workspace 自愈记录入口文案;本条只迁移开发者页壳层,懒加载子工具自身文案按独立组件后续迁移。 +37. 设置页 System / shared `WorkspaceRepairHistoryCard` 已迁移到 `settings.system.workspaceRepair.*` namespace key,覆盖默认标题说明、记录计数、刷新 / 清空 / 复制操作、空态、来源标签、复制成功 / 失败 / 权限提示和 locale-aware 时间展示;底层 `workspaceHealthTelemetry` 摘要构造仍保持原有诊断事实源,不在本条扩大迁移。 +38. 根 `AGENTS.md` 与 `docs/aiprompts/quality-workflow.md` 已新增 current i18n 规则:后续新功能的按钮、标题、空态、toast、confirm、prompt、placeholder、aria/title 与错误提示必须进入 key-based resources,legacy DOM Patch 只允许作为迁移期兜底,不得作为新功能本地化事实源。 +39. 设置页 System / Channels 的 `ChannelLogTailPanel` 已迁移到 `settings.channels.logTail.*` namespace key,覆盖日志 Tail 标题说明、暂停 / 继续、复制视图、清空日志、过滤模式、自定义正则、复制 / 清空反馈、确认弹窗、加载 / 空态与错误提示,并把日志时间展示改为 locale-aware;底层 channels runtime / log API 保持原事实源,不在本条扩大命令边界。 +40. 设置页 System / Channels 的 `ChannelsDebugWorkbench` 外层工作台壳已迁移到 `settings.channels.workbench.*` namespace key,覆盖日志与检查标题说明、当前查看范围、收口说明、网关 / 日志子页入口、网关与隧道提示、当前摘要、日志排查提示、运行分区 tab、底部未保存栏和保存结果文案;内部网关表单、运行控制与微信兼容扫码排障等长表单文案仍按后续独立小刀迁移。 +41. 设置页 System / Channels 的 `GatewayTunnelPanel` 网关与隧道表单已迁移到 `settings.channels.gatewayTunnel.*` namespace key,覆盖公共隧道标题说明、表单字段、placeholder、ngrok 预留选项、操作按钮、cloudflared 系统安装危险确认、执行中状态和最近结果空态;底层 `gatewayTunnel*` runtime 命令和系统安装确认流程保持原行为,不在本条扩大命令边界。 +42. 设置页 System / Web Search 已迁移到 `settings.webSearch.*` namespace key,覆盖页面 hero、搜索链路、Provider 凭证、MSE 聚合、图片搜索、观测面板、密钥显示 / 隐藏、外链操作、toast / error、sticky footer、tip aria 与动态状态文案;本条只迁移 Web Search 设置页 UI 文案,底层搜索 provider / app config 命令边界保持原事实源。 +43. 设置页 System / Developer 的目录联调懒加载工具已迁移到 `settings.developer.serviceSkillCatalog.*` 与 `settings.developer.siteAdapterCatalog.*` namespace key,覆盖服务型技能目录、站点脚本目录、外部 YAML 导入、Bootstrap Payload 调试、动态状态、示例 placeholder、aria 与操作反馈;本条只迁移 Developer 页的 catalog 联调子工具,底层 crash diagnostic 文案仍按后续共享诊断面收口。 +44. 设置页 System / shared `ClipboardPermissionGuideCard` 已迁移到 `settings.system.clipboardPermission.*` namespace key,覆盖 macOS / Windows / Linux / generic 四类剪贴板权限指引、打开系统设置按钮和打开失败提示;底层 crash diagnostic 复制失败消息、导出提示词和诊断摘要已在条目 48 收口到 `errors.crashDiagnostic.*`。 +45. 全局 `CrashRecoveryPanel` 恢复模式外壳已迁移到 `errors.crashRecovery.*` namespace key,覆盖恢复模式标题说明、最近错误、模块资源加载失败提示、诊断复制 / 导出 / 清理 / 下载目录操作、workspace 路径修复反馈和强制刷新资源入口;底层 `crashDiagnostic.ts` 构造的诊断正文、复制失败归一化消息和下载目录 fallback 已在条目 48 收口到 `errors.crashDiagnostic.*`。 +46. 设置页 System / Channels 的 Telegram / Feishu Gateway 运行控制已迁移到 `settings.channels.gatewayRuntime.*` namespace key,覆盖运行控制标题说明、账号 ID、轮询超时、状态查询 / 启动 / 停止 / 重启按钮、执行中状态、成功 / 失败反馈、stop warning 与最近结果空态;底层 `gatewayChannel*` runtime 命令保持原事实源,不在本条扩大命令边界。 +47. 设置页 System / Channels 的微信 Gateway 运行控制与兼容扫码排障已迁移到 `settings.channels.wechatRuntime.*` namespace key,覆盖微信运行控制标题说明、账号 / Base URL / Bot Type / 登录参数字段、列出账号 / 生成二维码 / 等待登录 / 删除账号操作、危险确认、二维码状态、账号目录、运行状态、轮询时间与最近结果空态;底层 `wechatChannel*` / `gatewayChannel*` runtime 命令保持原事实源,不在本条扩大命令边界。 +48. 共享 `src/lib/crashDiagnostic.ts` 底层诊断文案已迁移到 `errors.crashDiagnostic.*` namespace key,覆盖诊断采集说明、AI 诊断提示词正文、自动摘要标签、复制失败归一化消息、剪贴板权限指引和下载目录 fallback;诊断 payload 字段名、runtime 命令与日志采集事实源保持原结构,不在本条扩大协议边界。 +49. 设置页 System / Chrome Relay 的独立连接引导窗口 `BrowserConnectorGuideWindow` 已迁移到 `settings.chromeRelay.guide.*` namespace key,覆盖扩展安装引导、CDP 直连引导、状态标签、复制 / 打开 / 同步操作、错误反馈、剪贴板提示和源码目录警告;底层浏览器连接器、扩展安装、远程调试和开窗命令保持原事实源,不在本条扩大命令边界。 +50. 设置页 General / Memory 的首屏 hero、记忆总开关与偏好画像问卷已迁移到 `settings.memory.*` namespace key,覆盖状态 chip、画像完成度、来源命中摘要、保存操作、问卷题目、选项标签与选择状态;本条只覆盖记忆页首屏与画像问卷,来源链、memdir 写入、自动索引与工作记忆长尾区块仍按后续独立小刀迁移。 +51. 设置页 System / Chrome Relay 主设置页核心首屏已迁移到 `settings.chromeRelay.main.*` namespace key,覆盖浏览器列表、系统环境、Chrome 连接能力、扩展连接 / CDP 直连入口、连接器安装状态、常用操作按钮、核心 toast / error、剪贴板反馈和高级工具入口;本条只覆盖主设置页核心浏览器列表与共享操作反馈,高级工具内的 Profile / Bridge / Backend / Debug 长尾面板仍按后续独立小刀迁移。 +52. 设置页 System / Chrome Relay 主设置页高级工具起步区已迁移到 `settings.chromeRelay.main.*` namespace key,覆盖高级控制壳层、Overview 卡片、Profile 会话面板、使用建议、实时调试面板、tab 标签、后端类型 label / description 与相关操作文案;本条仍未覆盖高级工具里的连接方式卡、系统连接器、扩展桥接详情、后端策略详情和浏览器动作配置长尾。 +53. 设置页 System / Automation 的当前焦点条与概览焦点卡已迁移到 `settings.automation.focus.*` namespace key,覆盖“现在先继续这条”标题 / badge、空态、加载态、最近结果、下一步摘要和查看结果 / 治理 / 详情操作文案;本条只覆盖 Automation 当前焦点条与概览焦点卡,调度器、运行历史、Job Dialog、Details Dialog 与 HealthPanel 长尾仍按后续独立小刀迁移。 +54. 设置页 System / Chrome Relay 主设置页高级工具连接方式卡已迁移到 `settings.chromeRelay.main.connectionMethod.*` namespace key,覆盖连接方式标题说明、浏览器扩展 / CDP 直连卡片、推荐 / 待完成 / 已就绪 / 待接入状态、扩展页与远程调试快捷入口、复制配置和断开扩展操作文案;本条只覆盖高级工具顶部连接方式卡,系统连接器、扩展桥接详情、后端策略详情和浏览器动作配置长尾仍按后续独立小刀迁移。 +55. 设置页 System / Automation 的 `AutomationHealthPanel` 已迁移到 `settings.automation.health.*` namespace key,覆盖风险提醒标题说明、轮询状态、累计执行、汇总 pill、最近 / 下次轮询、风险任务失败重试、冷却 / 更新时间、状态枚举和空态;时间展示已从硬编码 `toLocaleString("zh-CN")` 收口到 `src/i18n/format.ts` 的 `formatDate()`。 +56. 设置页 System / Chrome Relay 主设置页高级工具系统连接器卡已迁移到 `settings.chromeRelay.main.systemConnector.*` namespace key,覆盖系统连接器说明、启用计数、平台 / 授权 / 启用状态标签、能力列表前缀与切换开关 aria;底层 system connector 的 label / description / capabilities 仍保持 runtime 返回事实源,本条不扩大浏览器连接器命令边界。 +57. 设置页 System / Chrome Relay 主设置页高级工具浏览器动作配置面板已迁移到 `settings.chromeRelay.main.browserAction.*` namespace key,覆盖动作配置标题说明、读取 / 写入分组标题与能力开关 aria;底层 action capability 的 label / description 仍保持 runtime 返回事实源,本条不扩大浏览器动作命令边界。 +58. 设置页 System / Automation 主页面壳层与调度器设置已迁移到 `settings.automation.main.*` / `settings.automation.scheduler.*` namespace key,覆盖页面 hero、加载失败态、刷新 / 新建 / 设置 / 打开入口、顶部状态 pill、tabs、调度器设置卡、调度器保存 toast 与主加载错误 toast;本条不覆盖开始模板、任务列表、运行历史、Job Dialog 与 Details Dialog 长尾。 +59. 设置页 System / Chrome Relay 主设置页高级工具后端策略详情面板已迁移到 `settings.chromeRelay.main.backendPolicy.*` namespace key,覆盖后端策略标题说明、默认测试目标、自动回退、优先级、测试 / 保存 / 刷新按钮、当前可用性、能力等待态、native-host 配置状态与平台支持标签;底层 backend status reason / capabilities 仍保持 runtime 返回事实源,本条不扩大浏览器后端策略命令边界。 +60. 设置页 System / Chrome Relay 主设置页高级工具扩展桥接详情面板已迁移到 `settings.chromeRelay.main.bridge.*` namespace key,覆盖扩展桥接标题说明、桥接服务状态、observer 接入状态、扩展接入信息、复制配置、端点缺失、observer 最近页面 / 未连接 / 空态提示、扩展测试与刷新扩展状态按钮;底层 WebSocket 端点、Bridge Key、client id 与页面标题仍保持 runtime 返回事实源,本条不扩大扩展桥接命令边界。 +61. 设置页 System / Automation 主页面开始模板与任务列表已迁移到 `settings.automation.tasks.*` namespace key,覆盖开始这条卡片、旧浏览器流程下线提醒、三类模板卡与预填默认值、任务列表标题说明、表头、描述兜底、技能流程摘要、状态 badge、下次 / 最近执行、运行 / 详情操作、空态以及创建 / 更新 / 删除 / 立即运行反馈;本条不覆盖运行历史、Job Dialog 与 Details Dialog 长尾。 +62. 设置页 System / Automation 运行历史区已迁移到 `settings.automation.history.*` namespace key,覆盖最近运行标题、刷新按钮、run id / session / 完成时间元信息、技能流程运行上下文、参数摘要、补充要求、输出投递状态、失败原因与空态;Details Dialog 其余头部、输出契约、当前起手内容和 Scene App 结果区仍按后续独立小刀迁移。 +63. Skills 工作台顶部入口、active scaffold 续用条、搜索输入、搜索空态分流与错误横幅已迁移到 `agent` namespace 的 `skills.workspace.*` key,覆盖页头标题 / 副标题、刷新 / 查看全部操作、续用 / 回到生成按钮、搜索 placeholder、推荐 / 本地 Skills 错误提示,以及“结果模板无匹配但右侧有可用 Skill”的空态说明;本条只覆盖 `SkillsWorkspacePage` 的顶部入口与搜索错误起步区,推荐卡片、最近 / 本地 Skills 卡片、能力草稿面板、启动弹窗与 toast 长尾仍按后续独立小刀迁移。 +64. Skills 工作台主体 chrome 已继续迁移到 `agent` namespace 的 `skills.workspace.*` key,覆盖推荐区标题 / 副标题、最近判断横幅、推荐 badge、沿用结果、进入生成、分组详情标题 / 返回 / 空态、分类标题 / 入口、最近侧栏、能力草案 / 已注册能力折叠标题、本地 Skills 侧栏标题 / 调整 / 刚沉淀 / 继续操作、空态以及调整 Skills 弹窗标题与 Tips;本条仍不覆盖推荐卡片内部 runtime 动态摘要、最近 / 本地 Skills 卡片的 runtime 数据、能力草稿面板内部文案、启动弹窗与 toast 长尾。 +65. Skills 工作台事件反馈与生成入口提示已迁移到 `agent` namespace 的 `skills.workspace.*` key,覆盖刷新成功 / 失败 toast、本地 Skill 回到生成 entry banner、Workspace Skill runtime enable 失败与授权 banner、Managed Job 草案创建失败 / 成功 / unsupported mode 文案、Skill scaffold 创建成功 / 默认标题、最近判断 launcher prefill hint,以及结果模板回到生成 entry banner;本条仍不覆盖 `buildWorkspaceRuntimeEnablePrompt()` 这类模型执行提示正文、推荐卡片内部 runtime 动态摘要、能力草稿面板内部和启动弹窗内部文案。 +66. Skills 工作台 helper 动态摘要已继续迁移到 `agent` namespace 的 `skills.workspace.*` key,覆盖能力草稿缺本地目录错误、本地 Skill 最近目标摘要、分类 starter summary、最近判断行动按钮,以及结果基线 compact summary 的 source title / highlight / count 拼接;本条仍不覆盖 `buildWorkspaceRuntimeEnablePrompt()` 这类模型执行提示正文、外部 presentation helper 返回的 runtime 数据、能力草稿面板内部和启动弹窗内部文案。 +67. Skills 工作台已安装 Skill presentation helper 已支持调用方注入本地化兜底文案,并在 `SkillsWorkspacePage` 内接入 `agent` namespace copy,覆盖默认 promise、required inputs、output hint 与“需要 / 交付”前缀;存量非 i18n 调用方仍保持旧中文兜底,避免扩大到 Agent 输入能力选择器,本条只收口 Skills 工作台可见路径。 + +68. 设置页 System / Automation 的 `AutomationJobDialog` 已迁移到 `settings.automation.jobDialog.*` namespace key,覆盖新建 / 编辑弹窗标题说明、summary pill、基础表单字段、调度提示、旧浏览器流程下线快照、权限模式、输出投递契约、目标地址 placeholder、投递说明、校验错误和底部保存操作;本条只覆盖 Job Dialog,Details Dialog 头部、输出契约、当前起手内容和 Scene App 结果区仍按后续独立小刀迁移。 + +69. 设置页 System / Automation 的 `AutomationJobDetailsDialog` 长尾已迁移到 `settings.automation.details.*` namespace key,覆盖详情弹窗头部、summary pill、基础元信息、旧浏览器流程下线提示、技能流程上下文、Scene App 回流摘要、输出契约、最近投递结果、当前起手内容、运行历史里的本地时间格式与权限 / 调度 / 投递动态 label;`AutomationJobDetailsDialog.test.tsx` 已补稳定 `useTranslation` mock,避免测试受全局 locale 漂移影响。 +70. 设置页 General / Memory 的来源链状态与策略长尾已迁移到 `settings.memory.source.*` / `settings.memory.layers.*` / `settings.memory.action.refresh` namespace key,覆盖记忆命中层可用性、来源链状态总览、来源链策略、运行时 AGENTS 模板与 `.gitignore` 反馈、来源链命中详情、来源分类和相对更新时间;本条只覆盖来源链与分层状态区,`memdir` 写入、自动索引卡片、校验 / 初始化 / 整理反馈仍按后续独立小刀迁移。 +71. Skills 工作台 ServiceSkill presentation helper 已支持调用方注入本地化 copy,并在 `SkillsWorkspacePage` 内接入 `agent` namespace 的 `skills.workspace.serviceSkill.*` key,覆盖 ServiceSkill 类型、runner label / description、action label、required inputs 摘要、output destination、readiness dependency 与 fact list 省略格式;同时补齐已安装 Skill helper 之前只靠 `defaultValue` 兜底的 `skills.workspace.installedSkill.defaultPromise` / `fallbackRequiredInputs` / `fallbackOutputHint` 资源 key。存量 Agent Chat / Home 等非 Skills 工作台调用方不传 copy 时仍保持旧中文兜底,避免本轮扩大迁移范围。 + +本轮验证记录: + +1. `npm test -- "src/components/settings-v2/system/about/index.test.tsx" "scripts/detect-missing-translations.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/format.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/withI18nPatch.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "src/components/settings-v2/hooks/useSettingsCategory.test.tsx"` 通过,覆盖 9 个文件、82 个用例。 +2. `npm run lint` 通过。 +3. `npm run typecheck` 通过。 +4. `npm run verify:gui-smoke -- --reuse-running --timeout-ms 300000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Knowledge GUI 与 design canvas smoke。 +5. `npm run detect-translations -- --verbose` 通过,确认 `src/i18n/resources` 下 5 个 locale、6 个 namespace、51 个 source key 结构一致。 +6. `npx eslint "scripts/detect-missing-translations.ts" "scripts/detect-missing-translations.test.ts" --max-warnings 0` 通过,补足仓库默认 `npm run lint` 只扫描 `src` 的脚本校验缺口。 +7. `npm test -- "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/__tests__/format.test.ts"` 通过,覆盖 bundled resources、namespace 存在性与 fallback。 +8. 2026-05-10 续测:`npm run detect-translations -- --verbose` 通过;`npm test -- "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/__tests__/format.test.ts" "scripts/detect-missing-translations.test.ts"` 通过,覆盖 4 个文件、13 个用例。 +9. 2026-05-10 续测:`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 曾因当时 `127.0.0.1:3030/health` 无可复用 DevBridge listener 未完成;恢复 headless Tauri / DevBridge 后已在后续记录中复跑通过。 +10. 2026-05-10 Profile 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、126 个 source key 结构一致;`npm test -- "src/components/settings-v2/account/profile/index.test.tsx" "scripts/detect-missing-translations.test.ts" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 3 个文件、11 个用例。 +11. 2026-05-10 Profile 迁移续测:`npx eslint "src/components/settings-v2/account/profile/index.tsx" "src/components/settings-v2/account/profile/index.test.tsx" --max-warnings 0` 通过;`npm run lint` 通过;`npm run typecheck` 通过。 +12. 2026-05-10 Profile 迁移续测:`npm test -- "src/components/settings-v2/account/profile/index.test.tsx" "src/components/settings-v2/system/about/index.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "scripts/detect-missing-translations.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/__tests__/format.test.ts"` 通过,覆盖 8 个文件、85 个用例。 +13. 2026-05-10 Stats 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、202 个 source key 结构一致;`npm test -- "src/components/settings-v2/account/stats/index.test.tsx"` 通过,覆盖 3 个用例。 +14. 2026-05-10 Stats 迁移续测:`npm test -- "src/components/settings-v2/account/profile/index.test.tsx" "src/components/settings-v2/account/stats/index.test.tsx" "src/components/settings-v2/system/about/index.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "scripts/detect-missing-translations.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/__tests__/format.test.ts"` 通过,覆盖 9 个文件、88 个用例;`npm run lint` 通过;`npm run typecheck` 通过。 +15. 2026-05-10 GUI smoke 复跑:`curl http://127.0.0.1:3030/health` 返回 `status=ok`;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。 +16. 2026-05-10 全量本地验证尝试:`npm run verify:local` 已启动并通过 `verify:app-version`、`lint`、`typecheck` 以及前 17 个 smart vitest 批次;第 18 批在非 i18n 文件 `src/lib/base-setup/seededServiceSkillPackage.test.ts` 失败,原因是当前工作区 seeded service skill 目录项数量从 16 变为 17,而测试仍断言 16。该失败属于已有非本轮 i18n 脏改动口径,未在本轮修复。 +17. 2026-05-10 Config language 迁移续测:`cd src-tauri && cargo test -p lime-core config::` 通过,覆盖 106 个 config 相关测试;`npm test -- "src/i18n/withI18nPatch.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "src/components/settings-v2/account/profile/index.test.tsx" "src/components/settings-v2/account/stats/index.test.tsx" "scripts/detect-missing-translations.test.ts"` 通过,覆盖 6 个文件、74 个用例;`npm run detect-translations -- --verbose`、`npm run lint`、`npm run typecheck` 通过。 +18. 2026-05-10 Settings layout 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、219 个 source key 结构一致;`npm test -- "src/components/settings-v2/_layout/index.test.tsx"` 通过,覆盖 9 个用例;`npx eslint "src/components/settings-v2/_layout/index.tsx" "src/components/settings-v2/_layout/index.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。 +19. 2026-05-10 Settings 主路径组合续测:`npm test -- "src/components/settings-v2/_layout/index.test.tsx" "src/components/settings-v2/account/profile/index.test.tsx" "src/components/settings-v2/account/stats/index.test.tsx" "src/components/settings-v2/system/about/index.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "src/i18n/withI18nPatch.test.tsx" "scripts/detect-missing-translations.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/locales.test.ts" "src/i18n/__tests__/format.test.ts"` 通过,覆盖 11 个文件、98 个用例。 +20. 2026-05-10 Settings layout 迁移后 GUI smoke:`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。 +21. 2026-05-10 Legacy Patch 物理迁移续测:`npm test -- "src/i18n/withI18nPatch.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/components/AppSidebar.test.tsx" "src/i18n/__tests__/config-validation.test.ts" "src/i18n/__tests__/edge-cases.test.ts" "src/i18n/__tests__/translation-coverage.test.ts"` 通过,覆盖 6 个文件、90 个通过用例、1 个跳过用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、219 个 source key 结构一致;`npm run lint` 与 `npm run typecheck` 通过。 +22. 2026-05-10 Legacy Patch 物理迁移后 GUI smoke:`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。 +23. 2026-05-10 Legacy Patch 物理迁移后全量本地验证尝试:`npm run verify:local` 已通过 `verify:app-version`、`lint`、`typecheck` 以及前 17 个 smart vitest 批次;第 18 批仍在非 i18n 文件 `src/lib/base-setup/seededServiceSkillPackage.test.ts` 失败,原因仍是 seeded service skill 目录项数量从 16 变为 17,而测试断言仍为 16。 +24. 2026-05-10 Legacy Patch 根层回流守卫续测:`npm test -- "src/lib/governance/legacySurfaceCatalog.test.ts"` 通过,覆盖 142 个用例;`npm run governance:legacy-report` 通过,摘要为边界违规 0、分类漂移候选 0、零引用候选 0;更新守卫后再次执行 `npm run lint` 通过。 +25. 2026-05-10 Navigation namespace 迁移续测:`npm test -- "src/components/AppSidebar.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 2 个文件、57 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、239 个 source key 结构一致;`npm run typecheck` 与 `npm run lint` 通过;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。 +26. 2026-05-10 Navigation 搜索 / 会话列表续测:`npm test -- "src/components/AppSidebar.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 2 个文件、57 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、273 个 source key 结构一致;`npm run lint` 与 `npm run typecheck` 通过;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。 +27. 2026-05-10 Navigation 会话 meta / prompt / confirm / toast 续测:`npm test -- "src/components/app-sidebar/sidebarSessionFormatting.test.ts" "src/components/AppSidebar.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 3 个文件、61 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、281 个 source key 结构一致;`npm run lint`、`npm run typecheck` 与 `git diff --check` 通过;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。 +28. 2026-05-10 Navigation 账号菜单续测:`npm test -- "src/components/AppSidebar.test.tsx" "src/components/app-sidebar/sidebarSessionFormatting.test.ts" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 3 个文件、62 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、307 个 source key 结构一致;`npm run lint`、`npm run typecheck` 与 `git diff --check` 通过;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,覆盖 workspace ready、Browser Runtime、site adapters、Agent service skill entry、Agent runtime tool surface、Agent runtime tool surface page、Knowledge GUI 与 Design Canvas smoke。 +29. 2026-05-10 最终调研复核与 PRD 2.9 写入后续测:`git diff --check -- "docs/roadmap/i18n/prd.md"` 通过;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、307 个 source key 结构一致;`npm test -- "src/components/AppSidebar.test.tsx" "src/components/app-sidebar/sidebarSessionFormatting.test.ts" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 3 个文件、62 个用例;`npm run lint`、`npm run typecheck` 与 `npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过。 +30. 2026-05-10 i18next 类型绑定续测:`npm test -- "src/i18n/__tests__/types.test.ts" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 2 个文件、5 个用例;`npm run typecheck` 通过,确认缺失 key 的 `@ts-expect-error` 仍被类型系统识别;`npm run lint`、`npm run detect-translations -- --verbose` 与 `git diff --check -- "src/i18n/types.d.ts" "src/i18n/__tests__/types.test.ts"` 通过。 +31. 2026-05-10 Legacy Patch 命中量指标续测:`npm test -- "src/i18n/__tests__/legacyPatchMetrics.test.ts" "src/i18n/__tests__/edge-cases.test.ts" "src/i18n/__tests__/types.test.ts" "src/i18n/__tests__/loadNamespace.test.ts"` 通过,覆盖 4 个文件、16 个通过用例、1 个跳过用例;`npm run typecheck`、`npm run lint`、`npm run detect-translations -- --verbose` 与 `git diff --check` 通过;`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过。 +32. 2026-05-10 Legacy Patch metrics 离线报告续测:`npm test -- "scripts/lib/i18n-patch-metrics-report-core.test.ts" "src/i18n/__tests__/legacyPatchMetrics.test.ts" "src/i18n/__tests__/edge-cases.test.ts"` 通过,覆盖 3 个文件、14 个通过用例、1 个跳过用例;临时 metrics JSON 执行 `node scripts/i18n-patch-metrics-report.mjs --format json` 通过并返回 `status=no-hit` / `retirementCandidate=true`;`npm run lint` 与 `git diff --check -- "scripts/lib/i18n-patch-metrics-report-core.mjs" "scripts/lib/i18n-patch-metrics-report-core.test.ts" "scripts/i18n-patch-metrics-report.mjs" "package.json"` 通过。 +33. 2026-05-10 GUI smoke metrics 导出链续测:`node --check "scripts/knowledge-gui-smoke.mjs"` 与 `node --check "scripts/verify-gui-smoke.mjs"` 通过;`npm test -- "scripts/lib/i18n-patch-metrics-report-core.test.ts"` 通过,覆盖 1 个文件、3 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、355 个 source key 结构一致;`git diff --check -- "scripts/knowledge-gui-smoke.mjs" "scripts/verify-gui-smoke.mjs"` 通过。 +34. 2026-05-10 Settings Home 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、355 个 source key 结构一致;`npm test -- "src/components/settings-v2/home/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、11 个用例;`npm run typecheck`、`npm run lint`、`git diff --check` 与 `npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过。 +35. 2026-05-10 Developer Lab 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、359 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/developer-lab/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、8 个用例;`npm run typecheck`、`npm run lint`、定向 `git diff --check` 与 `npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,GUI smoke 同步导出 Patch metrics 且状态仍为 `no-hit`。 +36. 2026-05-10 Settings Skills 高级入口迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、363 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/skills/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、6 个用例;`npm run typecheck`、`npm run lint` 与 `git diff --check -- "src/components/settings-v2/agent/skills/index.tsx" "src/components/settings-v2/agent/skills/index.test.tsx" "src/i18n/resources/zh-CN/settings.json" "src/i18n/resources/en-US/settings.json" "src/i18n/resources/ja-JP/settings.json" "src/i18n/resources/ko-KR/settings.json" "src/i18n/resources/zh-TW/settings.json"` 通过。 +37. 2026-05-10 Settings Hotkeys 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、476 个 source key 结构一致;`npm test -- "src/components/settings-v2/general/hotkeys/index.test.tsx" "src/components/settings-v2/general/hotkeys/hotkeyCatalog.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、12 个用例;`npm run typecheck`、`npm run lint`、定向 `git diff --check` 与 `npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 通过,GUI smoke 同步导出 Patch metrics 且状态仍为 `no-hit`。 +38. 2026-05-10 Settings 图片 / 视频服务模型迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、499 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/image-gen/index.test.tsx" "src/components/settings-v2/agent/video-gen/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、12 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。 +39. 2026-05-10 Settings 图片 / 视频服务模型迁移后 GUI smoke 复跑:`npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` 连续两次在 `smoke:claw-chat-ready-streaming` 阶段失败;前置 `bridge:health`、workspace ready、Browser Runtime、site adapters、Agent service skill entry 与 runtime tool surface 已通过,失败证据指向 Agent streaming smoke 断言 `streamGrowthObserved=false` / provider-follow 断言不满足,属于当前运行时 smoke 阻塞项,不是 Settings i18n 文案迁移的定向回归依据;本轮未修复该无关阻塞。 +40. 2026-05-10 Settings Service Models 总页迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、543 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/media-services/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。由于记录 39 的 `smoke:claw-chat-ready-streaming` 仍是当前运行时 smoke 阻塞项,本轮未重复消耗 GUI smoke,待该无关 blocker 收口后再复跑设置页主路径 GUI smoke。 +41. 2026-05-10 Settings Voice 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、661 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/voice/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、16 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。GUI smoke 仍受记录 39 的 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Voice 文案迁移阻塞项。 +42. 2026-05-10 Settings Providers 桌宠能力偏好卡迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、684 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。本条只覆盖 Providers 页内独立桌宠偏好卡,`agent/providers/index.tsx` 主体仍待后续迁移。 +43. 2026-05-10 Settings Providers 顶部入口与云端反馈迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、696 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/providers/index.test.tsx" "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、24 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。本条只覆盖 Providers 顶部 workspace 切换器与云端打开反馈,Companion Bridge 诊断区仍需继续迁移。 +44. 2026-05-10 Settings Providers Companion Bridge 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、808 个 source key 结构一致;`npm test -- "src/components/settings-v2/agent/providers/index.test.tsx" "src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、24 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过;`npm run verify:local` 已通过 `verify:app-version`、`lint`、`typecheck` 和前 20 个 smart vitest 批次,随后仍在非 i18n 文件 `src/lib/base-setup/seededServiceSkillPackage.test.ts` 因 16 / 17 seeded service skill 数量断言失败,本轮未修复该无关 blocker。 +45. 2026-05-10 Settings User Center Session 迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、891 个 source key 结构一致;`npm test -- "src/components/settings-v2/account/user-center-session/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。 +46. 2026-05-10 Settings Developer 壳层迁移续测:`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、940 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/developer/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、19 个用例;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。 +47. 2026-05-10 Settings Workspace 自愈记录共享卡片与新增文案规则续测:`npm test -- "src/components/settings-v2/system/shared/WorkspaceRepairHistoryCard.test.tsx" "src/components/settings-v2/agent/skills/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、9 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、940 个 source key 结构一致;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。 +48. 2026-05-10 Settings Environment 页迁移续测:`src/components/settings-v2/system/environment/index.tsx` 已接入 `useTranslation("settings")`,覆盖页面 hero、Shell 导入状态、显式覆盖表单、合并规则、使用提示、生效预览、来源标签、按钮、tip aria 与空态;新增 `settings.environment.*` 共 107 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1070 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/environment/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npm run lint` 与定向 `git diff --check` 通过。该记录中曾受并行进程新增的 `src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx` `Timeout` 类型不匹配影响,后续已在记录 49 随 Channels Log Tail 本刀复跑 `npm run typecheck` 收口;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,未把该无关失败作为 Environment 文案迁移阻塞项。 +49. 2026-05-10 Settings Channels Log Tail 迁移续测:`src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx` 已接入 `useTranslation("settings")`,覆盖日志 Tail 标题说明、暂停 / 继续、复制 / 清空、过滤 / 自定义正则、copy / clear / error / confirm、loading / empty 与 locale-aware 时间;新增 `settings.channels.logTail.*` 并同步 5 个 locale。`npm test -- "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、14 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1070 个 source key 结构一致;`npm run typecheck`、`npm run lint` 与定向 `git diff --check` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Channels Log Tail 文案迁移阻塞项。 +50. 2026-05-10 Settings Experimental 页迁移续测:`src/components/settings-v2/system/experimental/index.tsx` 已接入 `useTranslation("settings")`,覆盖实验功能 hero、Tool Calling 2.0、截图对话、macOS 屏幕录制权限提示、WebMCP 预留、崩溃上报与诊断、剪贴板权限指引、更新提醒、Workspace 自愈记录入口、按钮、aria、toast / error / export message 与 deferred fallback;新增 `settings.experimental.*` 共 82 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1152 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/experimental/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npm run lint` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮尝试时遇到多个并行 typecheck 进程长时间占用,已终止本轮进程,未把它记为通过证据;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Experimental 文案迁移阻塞项。 +51. 2026-05-10 Settings Channels Workbench 壳层迁移续测:`src/components/settings-v2/system/channels/ChannelsDebugWorkbench.tsx` 已接入 `useTranslation("settings")`,覆盖工作台标题说明、范围提示、网关 / 日志入口、摘要、日志排查提示、运行 tab、未保存栏和保存结果;新增 `settings.channels.workbench.*` 并同步 5 个 locale。`npm test -- "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、15 个用例;`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1196 个 source key 结构一致;`npm run lint` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮单独尝试超过 11 分钟无诊断输出,且当时隔壁进程正在跑 `verify:local` / Rust check / 另一条 typecheck,为避免抢占资源已终止本轮进程;本条不把 typecheck 记为通过证据。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Channels Workbench 壳层文案迁移阻塞项。 +52. 2026-05-10 Settings Web Search 页迁移续测:`src/components/settings-v2/system/web-search/index.tsx` 已接入 `useTranslation("settings")`,覆盖页面 hero、tabs、搜索链路、Provider 凭证、MSE 聚合、图片搜索、观测面板、密钥显示 / 隐藏、外链申请 / 文档入口、动态状态、保存 / 失败反馈和 sticky footer;新增 `settings.webSearch.*` 共 113 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1309 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/web-search/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、18 个用例;`npm run lint` 通过。`npm run typecheck` 本轮发现隔壁进程已有运行中的 typecheck,为避免和并行任务抢占资源,暂未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Web Search 文案迁移阻塞项。 +53. 2026-05-10 Settings Channels Gateway Tunnel 迁移续测:`GatewayTunnelPanel` 已接入 `useTranslation("settings")`,覆盖公共隧道标题说明、Provider / mode / host / port / public URL / Cloudflare 字段、Run Token / Credentials File / Feishu account、cloudflared 检测 / 安装 / 探测 / 创建 / 启停 / 同步回调按钮、危险确认、执行中状态和最近结果空态;新增 `settings.channels.gatewayTunnel.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1339 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、16 个用例;`npx eslint "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.tsx" "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.ts" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 仍有隔壁运行中的 typecheck / verify:local,本轮未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Gateway Tunnel 文案迁移阻塞项。 +54. 2026-05-10 Settings Developer 目录联调懒加载工具迁移续测:`src/components/settings-v2/system/developer/ServiceSkillCatalogTools.tsx` 与 `src/components/settings-v2/system/developer/SiteAdapterCatalogTools.tsx` 已接入 `useTranslation("settings")`,覆盖服务型技能目录摘要、站点脚本目录摘要、Bootstrap Payload 调试、外部 YAML 导入、示例 placeholder、按钮、aria、动态计数与成功 / 失败反馈;新增 `settings.developer.serviceSkillCatalog.*` / `settings.developer.siteAdapterCatalog.*` 共 79 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1436 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/developer/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、19 个用例;`npx eslint "src/components/settings-v2/system/developer/ServiceSkillCatalogTools.tsx" "src/components/settings-v2/system/developer/SiteAdapterCatalogTools.tsx" --max-warnings 0` 通过。`npm run typecheck` 本轮仍由隔壁 `verify:local` / typecheck 进程占用,未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为目录联调文案迁移阻塞项。 +55. 2026-05-10 Settings Clipboard 权限指引共享卡迁移续测:`src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.tsx` 已接入 `useTranslation("settings")`,覆盖 macOS / Windows / Linux / generic 剪贴板权限指引、打开系统设置按钮和失败提示;新增 `settings.system.clipboardPermission.*` 共 19 个 key并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1455 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.tsx" "src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.test.tsx" --max-warnings 0` 通过。`npm run typecheck` 本轮仍由隔壁 `verify:local` / typecheck 进程占用,未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为剪贴板权限指引文案迁移阻塞项。 +56. 2026-05-10 Crash Recovery 恢复模式外壳迁移续测:`src/components/layout/CrashRecoveryPanel.tsx` 已接入 `useTranslation("errors")`,覆盖恢复模式标题说明、最近错误、模块资源加载失败提示、诊断复制 / JSON / 导出 / 清理 / 下载目录操作、workspace 路径修复反馈和强制刷新资源入口;新增 `errors.crashRecovery.*` 共 35 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1490 个 source key 结构一致;`npm test -- "src/components/layout/CrashRecoveryPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/components/layout/CrashRecoveryPanel.tsx" "src/components/layout/CrashRecoveryPanel.test.tsx" --max-warnings 0` 通过。`npm run typecheck` 本轮仍由隔壁 `verify:local` / typecheck 进程占用,未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为恢复模式外壳文案迁移阻塞项。 +57. 2026-05-10 Settings Channels Telegram / Feishu Gateway Runtime 迁移续测:`TelegramGatewayDebugPanel` 与 `FeishuGatewayDebugPanel` 已接入 `useTranslation("settings")`,覆盖运行控制标题说明、账号 ID、轮询超时、状态查询 / 启动 / 停止 / 重启按钮、执行中状态、成功 / 失败反馈、stop warning 与最近结果空态;新增 `settings.channels.gatewayRuntime.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1455 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、17 个用例;`npx eslint "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.tsx" "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.ts" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮未作为通过证据;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Gateway Runtime 文案迁移阻塞项。 +58. 2026-05-10 Settings Channels 微信 Gateway Runtime 迁移续测:`WechatGatewayDebugPanel` 与 `QrCodePreview` 已接入 `useTranslation("settings")`,覆盖微信运行控制、兼容扫码排障、二维码状态、账号目录、危险删除确认、动态状态、失败反馈和 locale-aware 时间;新增 `settings.channels.wechatRuntime.*` 共 68 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1622 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、18 个用例;`npx eslint "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.tsx" "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx" "src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx" "src/components/settings-v2/system/channels/channel-log-filter.ts" "src/components/settings-v2/system/channels/channel-log-filter.test.ts" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮未作为通过证据;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为微信 Gateway Runtime 文案迁移阻塞项。 +59. 2026-05-10 Crash Diagnostic 底层诊断文案迁移续测:`src/lib/crashDiagnostic.ts` 已通过 `errors.crashDiagnostic.*` 读取 current locale 下的 key-based resource,覆盖自动摘要、诊断提示词、采集说明、复制失败、剪贴板权限指引与下载目录 fallback;新增 crash diagnostic key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1622 个 source key 结构一致;`npm test -- "src/lib/crashDiagnostic.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、25 个用例;`npx eslint "src/lib/crashDiagnostic.ts" "src/lib/crashDiagnostic.test.ts" --max-warnings 0` 与 `npm run typecheck` 通过;定向 `git diff --check` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为底层诊断文案迁移阻塞项。 +60. 2026-05-10 Settings Chrome Relay 独立连接引导窗口迁移续测:`src/components/settings-v2/system/chrome-relay/guide-window.tsx` 已接入 `useTranslation("settings")`,覆盖扩展安装引导、CDP 直连引导、状态标签、复制 / 打开 / 同步操作、错误反馈、剪贴板提示和源码目录警告;新增 `settings.chromeRelay.guide.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1746 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/guide-window.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、13 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/guide-window.tsx" "src/components/settings-v2/system/chrome-relay/guide-window.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 引导窗口文案迁移阻塞项。 +61. 2026-05-10 Settings Memory 首屏与偏好画像迁移续测:`src/components/settings-v2/general/memory/index.tsx` 已接入 `useTranslation("settings")`,覆盖首屏 hero、记忆总开关、保存操作、状态摘要、偏好画像问卷题目与选项;补齐并行写入后缺失的 `zh-TW` / `ja-JP` / `ko-KR` `settings.memory.*` keys。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1749 个 source key 结构一致;`npm test -- "src/components/settings-v2/general/memory/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、17 个用例;`npx eslint "src/components/settings-v2/general/memory/index.tsx" "src/components/settings-v2/general/memory/index.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Memory 首屏文案迁移阻塞项。 +62. 2026-05-10 Settings Chrome Relay 主设置页核心首屏迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 已接入 `useTranslation("settings")`,覆盖核心浏览器列表、系统环境、Google Chrome 能力说明、扩展连接 / CDP 直连入口、连接器安装状态、浏览器协助 / 连接引导 / 剪贴板 / 连接器开关等共享操作反馈;新增 `settings.chromeRelay.main.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1846 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、17 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮已有隔壁进程在跑,为避免抢占资源未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 核心首屏文案迁移阻塞项。 +63. 2026-05-10 Settings Chrome Relay 高级工具起步区迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖高级控制壳层、Overview 卡片、Profile 会话面板、使用建议、实时调试面板、tab 标签以及后端类型 label / description;新增 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1914 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、17 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。第一次测试曾因 `getBackendLabel` 在初始化前被 `testBackendAction` dependency array 读取而失败,已移动 helper 定义顺序并复跑通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为高级工具起步区文案迁移阻塞项。 +64. 2026-05-10 Settings Automation 当前焦点条与概览焦点卡迁移续测:`src/components/settings-v2/system/automation/AutomationJobFocusStrip.tsx` 与 `AutomationOverviewFocusCard.tsx` 已接入 `useTranslation("settings")`,覆盖当前焦点标题 / badge、空态、加载态、最近结果、下一步摘要和查看结果 / 治理 / 详情操作文案;新增 `settings.automation.focus.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1930 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/AutomationJobFocusStrip.test.tsx" "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、26 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationJobFocusStrip.tsx" "src/components/settings-v2/system/automation/AutomationJobFocusStrip.test.tsx" "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.tsx" "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮仍由隔壁进程占用,未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Automation 焦点组件文案迁移阻塞项。 +65. 2026-05-10 Settings Chrome Relay 高级工具连接方式卡迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖高级工具连接方式卡中的扩展 / CDP 状态、快捷入口、复制配置与断开扩展操作文案;新增 `settings.chromeRelay.main.connectionMethod.*` 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1974 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、17 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" --max-warnings 0` 通过。`npm run typecheck` 本轮已有隔壁 `verify:local` 进程在跑,为避免抢占资源未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 连接方式卡文案迁移阻塞项。 +66. 2026-05-10 Settings Automation Health Panel 迁移续测:`src/components/settings-v2/system/automation/AutomationHealthPanel.tsx` 已接入 `useTranslation("settings")`,覆盖风险提醒标题说明、轮询状态、累计执行、汇总 pill、最近 / 下次轮询、风险任务失败重试、冷却 / 更新时间、状态枚举和空态;新增 `settings.automation.health.*` 并同步 5 个 locale,时间展示改为复用 `formatDate()`。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1954 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/AutomationHealthPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、6 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationHealthPanel.tsx" "src/components/settings-v2/system/automation/AutomationHealthPanel.test.tsx" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 未作为本轮通过证据;为避免和隔壁进程抢占资源,本轮未重复启动 `npm run verify:local` / 全量 `cargo test`。 +67. 2026-05-11 Settings Chrome Relay 高级工具系统连接器卡迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖系统连接器卡片说明、启用计数、状态标签、能力前缀和切换 aria,并补充系统连接器切换回归;新增 `settings.chromeRelay.main.systemConnector.*` key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1984 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、18 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 系统连接器卡文案迁移阻塞项。 +68. 2026-05-11 Settings Chrome Relay 高级工具浏览器动作配置迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖浏览器动作配置标题说明、读取 / 写入分组和能力开关 aria,并补充动作配置标题 / 分组断言;新增 `settings.chromeRelay.main.browserAction.*` key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、1989 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、18 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 浏览器动作配置文案迁移阻塞项。 +69. 2026-05-11 Settings Automation 主页面壳层与调度器设置迁移续测:`src/components/settings-v2/system/automation/index.tsx` 已接入 `useTranslation("settings")`,覆盖 Automation 页面 hero、加载失败态、刷新 / 新建 / 设置 / 打开入口、顶部状态 pill、tabs、调度器设置卡、调度器保存 toast 与主加载错误 toast;新增 `settings.automation.main.*` 与 `settings.automation.scheduler.*` 共 57 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2046 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、20 个用例;`npx eslint "src/components/settings-v2/system/automation/index.tsx" "src/components/settings-v2/system/automation/index.test.tsx" --max-warnings 0` 与定向 `git diff --check -- "src/components/settings-v2/system/automation/index.tsx"` 通过。`npm run typecheck` 与 `npm run verify:local` 未作为本轮通过证据;仍避免触碰隔壁正在推进的 Chrome Relay / Skills 主线。 +70. 2026-05-11 Settings Chrome Relay 高级工具后端策略详情迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖后端策略详情面板的策略配置、自动回退、优先级、后端测试、当前可用性、capabilities waiting、native-host 与平台支持文案,并补充后端策略页渲染回归;新增 `settings.chromeRelay.main.backendPolicy.*` key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2071 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、19 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 后端策略详情文案迁移阻塞项。 +71. 2026-05-11 Settings Chrome Relay 高级工具扩展桥接详情迁移续测:`src/components/settings-v2/system/chrome-relay/index.tsx` 继续收口 `settings.chromeRelay.main.*`,覆盖扩展桥接详情面板的服务状态、observer 状态、接入信息、复制配置、空态提示、observer 最近页面、测试扩展与刷新状态文案,并补充扩展桥接页渲染回归;新增 `settings.chromeRelay.main.bridge.*` key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2149 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/chrome-relay/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、20 个用例;`npx eslint "src/components/settings-v2/system/chrome-relay/index.tsx" "src/components/settings-v2/system/chrome-relay/index.test.tsx" --max-warnings 0` 通过。`npm run typecheck` 本轮由隔壁 `verify:local` 占用未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Chrome Relay 扩展桥接详情文案迁移阻塞项。 +72. 2026-05-11 Settings Automation 主页面开始模板与任务列表迁移续测:`src/components/settings-v2/system/automation/index.tsx` 继续收口 `settings.automation.tasks.*`,覆盖开始模板卡、任务列表、旧浏览器流程下线提醒、任务行技能流程摘要、任务状态与运行操作,以及创建 / 更新 / 删除 / 立即运行反馈;新增 `settings.automation.tasks.*` 共 60 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2131 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、20 个用例;`npx eslint "src/components/settings-v2/system/automation/index.tsx" "src/components/settings-v2/system/automation/index.test.tsx" --max-warnings 0` 与定向 `git diff --check -- "src/components/settings-v2/system/automation/index.tsx"` 通过。`npm run typecheck` 与 `npm run verify:local` 仍未作为本轮通过证据;本轮未触碰 Chrome Relay / Skills 文件。 +73. 2026-05-11 Settings Automation 运行历史区迁移续测:`src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx` 已接入 `useTranslation("settings")`,覆盖最近运行标题、刷新按钮、run id / session / 完成时间元信息、技能流程运行上下文、参数摘要、补充要求、输出投递状态、失败原因与空态;新增 `settings.automation.history.*` 共 13 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2162 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、20 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx" "src/components/settings-v2/system/automation/index.test.tsx" --max-warnings 0` 与定向 `git diff --check -- "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx"` 通过。`npm run typecheck` 与 `npm run verify:local` 未作为本轮通过证据;本轮未触碰 Chrome Relay / Skills 文件。 +74. 2026-05-11 Skills 工作台顶部入口与搜索错误区迁移续测:`src/components/skills/SkillsWorkspacePage.tsx` 已接入 `useTranslation("agent")`,覆盖页头标题 / 副标题、刷新 / 查看全部、active scaffold 续用条、搜索 placeholder、推荐 / 本地 Skills 错误横幅,以及搜索命中右侧 Skills 时的结果模板 / 分组空态分流;新增 `skills.workspace.*` key 并同步 5 个 locale。为避免隔壁 Automation 并行改动中的 `AutomationJobDialog` 模块初始化错误污染本切片,`src/components/skills/SkillsWorkspacePage.test.tsx` 只在本测试内 mock dialog 外壳,保留 Skills 工作台主渲染、搜索和本地 Skill 断言。`npm test -- "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、36 个用例;`npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、2305 个 source key。`npm run typecheck` / `npm run verify:local` 本轮仍由隔壁长跑进程占用,未重复启动;GUI smoke 仍按记录 39 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Skills 工作台本地化阻塞项。 +75. 2026-05-11 Skills 工作台主体 chrome 迁移续测:`src/components/skills/SkillsWorkspacePage.tsx` 继续收口 `skills.workspace.*`,覆盖推荐区标题 / 副标题、最近判断横幅、推荐 badge、沿用结果、进入生成、分组详情、分类入口、最近 / 本地 Skills 侧栏标题与空态、能力草案 / 已注册能力折叠标题、刚沉淀提示、继续操作和调整 Skills 弹窗标题 / Tips;新增 key 并同步 5 个 locale。`npm test -- "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、36 个用例;`npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、2339 个 source key。`npm run typecheck` 已尝试但因隔壁 Automation 并行脏改失败:`src/components/settings-v2/system/automation/AutomationJobDialog.tsx:101` 的 `TFunctionDetailedResult` 转 string 与 `:108` 的 `replaceAll` target lib 报错;该失败不来自本切片。隔壁 `verify:local` 本轮已进入 GUI smoke,最终在 `smoke:claw-chat-ready-streaming` 等待恢复结果超时处失败,本轮未把该无关失败作为 Skills 工作台本地化阻塞项。 +76. 2026-05-11 Skills 工作台事件反馈与生成入口提示迁移续测:`src/components/skills/SkillsWorkspacePage.tsx` 继续收口 `skills.workspace.*`,覆盖刷新 toast、本地 Skill / runtime enable / curated task 回到生成 entry banner、Managed Job 草案创建反馈、Skill scaffold 创建反馈与默认标题、最近判断 launcher prefill hint;新增 key 并同步 5 个 locale。`npm test -- "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、36 个用例;`npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、定向 `git diff --check` 与 `npm run typecheck` 通过,最新资源口径为 5 个 locale、6 个 namespace、2457 个 source key。隔壁 `verify:local` 的 GUI smoke 仍按记录 75 停在 `smoke:claw-chat-ready-streaming` 超时,本轮未把该无关失败作为 Skills 工作台本地化阻塞项。 +77. 2026-05-11 Skills 工作台 helper 动态摘要迁移续测:`src/components/skills/SkillsWorkspacePage.tsx` 继续收口 `skills.workspace.*`,覆盖能力草稿缺本地目录错误、本地 Skill 最近目标摘要、分类 starter summary、最近判断行动按钮、结果基线 source title / highlight / count 拼接;新增 key 并同步 5 个 locale。`npm test -- "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、36 个用例;`npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、2464 个 source key。`npm run typecheck` 已尝试但被隔壁 Memory 来源链并行脏改阻塞:`src/components/settings-v2/general/memory/index.tsx` 多个 `settings.memory.source.*` / `settings.memory.layers.*` key 尚未进入类型资源,且部分 helper 参数顺序与 `TFunction<"settings">` 不一致;该失败不来自本切片。 +78. 2026-05-11 Settings Automation Job Dialog 迁移续测:`src/components/settings-v2/system/automation/AutomationJobDialog.tsx` 已接入 `useTranslation("settings")`,覆盖新建 / 编辑弹窗标题说明、summary pill、基础表单字段、调度提示、旧浏览器流程下线快照、权限模式、输出投递契约、目标地址 placeholder、投递说明、校验错误和底部保存操作;新增 `settings.automation.jobDialog.*` 共 124 个 key 并同步 5 个 locale。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2305 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationJobDialog.tsx" "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" --max-warnings 0` 通过。`npm run typecheck` / `npm run verify:local` 未作为本轮通过证据;本轮未触碰 Chrome Relay / Skills 文件。 + +79. 2026-05-11 Settings Automation Details Dialog 长尾迁移续测:`src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx` 继续收口 `settings.automation.details.*`,覆盖详情弹窗头部、summary pill、基础元信息、旧浏览器流程下线提示、技能流程上下文、Scene App 回流摘要、输出契约、最近投递结果、当前起手内容、运行历史里的本地时间格式与权限 / 调度 / 投递动态 label;新增 `settings.automation.details.*` 共 101 个 key 并同步 5 个 locale,同时修复 `AutomationJobDialog` / Details Dialog interpolation helper 的返回类型与 `replaceAll` target lib 风险。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2457 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、17 个用例;`npx eslint "src/components/settings-v2/system/automation/AutomationJobDialog.tsx" "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本轮未触碰 Chrome Relay / Skills 实现文件。 + +80. 2026-05-11 Settings Memory 来源链状态与策略长尾迁移续测:`src/components/settings-v2/general/memory/index.tsx` 继续收口 `settings.memory.source.*` / `settings.memory.layers.*` / `settings.memory.action.refresh`,覆盖记忆命中层状态、来源链状态总览、来源链策略、运行时 AGENTS 模板 / `.gitignore` 反馈、来源链命中详情、来源分类与相对更新时间;新增 `settings.memory.source.*` / `settings.memory.layers.*` / `settings.memory.action.refresh` 共 95 个 key 并同步 5 个 locale,本条不覆盖 `memdir` 写入区、自动索引卡片和 memdir 校验 / 初始化 / 整理反馈长尾。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2561 个 source key 结构一致;`npm test -- "src/components/settings-v2/general/memory/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、17 个用例;`npx eslint "src/components/settings-v2/general/memory/index.tsx" "src/components/settings-v2/general/memory/index.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;GUI smoke 仍按记录 39 / 75 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Memory 文案迁移阻塞项。 +81. 2026-05-11 Skills 已安装 Skill presentation helper 本地化 copy 迁移续测:`src/components/skills/installedSkillPresentation.ts` 新增可选 `copy` 注入,并在 `src/components/skills/SkillsWorkspacePage.tsx` 传入 `skills.workspace.installedSkill.*` copy,覆盖默认 promise、required inputs、output hint、required / output 前缀;存量调用方不传 copy 时行为保持不变。`npm test -- "src/components/skills/installedSkillPresentation.test.ts" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、39 个用例;`npx eslint "src/components/skills/installedSkillPresentation.ts" "src/components/skills/installedSkillPresentation.test.ts" "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、定向 `git diff --check` 与 `npm run typecheck` 通过,最新资源口径为 5 个 locale、6 个 namespace、2561 个 source key。 +82. 2026-05-11 Skills ServiceSkill presentation helper 本地化 copy 迁移续测:`src/components/agent/chat/service-skills/skillPresentation.ts` 新增 `ServiceSkillPresentationCopy` 注入,保持默认中文兜底不破坏 Agent Chat / Home 等存量调用;`src/components/skills/SkillsWorkspacePage.tsx` 在 Skills 工作台可见路径传入 `skills.workspace.serviceSkill.*` copy,覆盖推荐卡 / 最近侧栏的 runner、action、类型、required inputs、output destination 与可读摘要,同时补齐 `skills.workspace.installedSkill.defaultPromise` / `fallbackRequiredInputs` / `fallbackOutputHint` 的 5 语种资源。`npm test -- "src/components/agent/chat/service-skills/skillPresentation.test.ts" "src/components/skills/installedSkillPresentation.test.ts" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、46 个用例;`npx eslint "src/components/agent/chat/service-skills/skillPresentation.ts" "src/components/agent/chat/service-skills/skillPresentation.test.ts" "src/components/skills/installedSkillPresentation.ts" "src/components/skills/installedSkillPresentation.test.ts" "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、定向 `git diff --check` 与 `npm run typecheck` 通过,最新资源口径为 5 个 locale、6 个 namespace、2666 个 source key。`npm run verify:local` / GUI smoke 未作为本轮通过证据;GUI smoke 仍按记录 39 / 75 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 Skills ServiceSkill helper 本地化阻塞项。 + +仍未完成: + +1. `navigation` namespace 已覆盖侧边栏主入口、搜索弹窗、会话列表起步路径、会话 meta 格式化、会话重命名 / 删除的 prompt / confirm / toast,以及账号菜单的核心未登录 / 已登录操作文案;但邀请入口 / 邀请弹窗、外观快捷面板、账号套餐 usage 兜底、复制邀请 toast 等侧栏周边文案仍有硬编码;`workspace`、`agent` namespace 还只是骨架,`errors` namespace 已覆盖 Crash Recovery 外壳与 crashDiagnostic 共享诊断摘要 / 复制失败 / 下载目录 fallback;Workspace / Agent Chat 仍需 P1/P2 逐步迁移;Settings 共享壳层、home、developer-lab、system/developer 壳层与目录联调懒加载工具、system/shared Clipboard 权限指引、CrashRecoveryPanel 恢复模式外壳、system/environment 主体、system/experimental 主体、system/channels 工作台壳层、网关与隧道表单、Telegram / Feishu / 微信运行控制、微信兼容扫码排障与日志 Tail 面板、system/web-search 主体、system/chrome-relay 独立连接引导窗口 / 主设置页核心首屏 / 高级工具起步区 / 高级工具连接方式卡 / 高级工具系统连接器卡 / 高级工具浏览器动作配置面板 / 高级工具后端策略详情面板 / 高级工具扩展桥接详情面板、system/shared Workspace 自愈记录共享卡片、system/automation 当前焦点条 / 概览焦点卡 / HealthPanel / 主页面壳层与调度器设置 / 主页面开始模板与任务列表 / 运行历史区、general/memory 首屏与偏好画像 / 来源链状态与策略长尾、general/hotkeys、account/profile/stats/user-center-session、appearance/about、agent/skills 高级入口、agent/image-gen、agent/video-gen、agent/media-services、agent/voice、agent/providers 的顶部切换器 / 云端反馈 / 桌宠能力偏好卡 / Companion Bridge 诊断区已完成起步迁移,但 Providers 页内残留硬编码、general/memory memdir 写入 / 自动索引长尾、Skills 工作台的 ServiceSkill launch prefill 与 CuratedTask presentation helper 返回的 runtime 数据、能力草稿面板内部文案、启动弹窗内部与模型执行提示正文长尾仍需继续。 +2. `legacy-patch/` 已完成物理隔离,并已有运行时命中量指标 API、GUI smoke metrics 导出链与离线 text / JSON 报告脚本;但 CI 尚未把 `i18n:patch-report --check` 接入删除门禁,也尚未把 `no-hit` 报告和 current 主路径依赖审计绑定为 DOM replacer 删除条件。退出条件仍是 P3 后按命中量和 current 主路径依赖清零逐步拆除。 +3. 类型绑定已覆盖当前 source resource,`AGENTS.md` / 质量工作流也已封住新增功能必须走 current i18n 的人工规则;但尚未接入官方 `i18next-cli` 的抽取 / hardcoded string lint / type generation,也尚未评估 selector API 或 `@i18next-selector/codemod` 是否适合资源规模扩大后的 P3 阶段。 +4. 最终合并前仍应基于届时工作区状态复跑 `npm run verify:local`;历史 `verify:local` 曾被非 i18n 的 seeded service skill 数量断言阻塞;最新隔壁 `verify:local` 已推进到 GUI smoke,但仍受 Agent streaming runtime blocker 影响。`ChannelLogTailPanel.test.tsx` 的 `Timeout` 类型不匹配已在记录 49 收口,当前不再作为 i18n PRD 的 typecheck blocker;记录 50 / 51 / 52 / 53 / 54 / 55 / 56 / 57 / 58 的 typecheck 因并行重验证负载或本轮未单独启动而未取得通过证据,后续收口时需复跑;记录 59 / 60 / 61 已重新取得 `npm run typecheck` 通过证据;记录 65 / 66 因隔壁 `verify:local` 长跑占用未重复启动 typecheck;记录 67 / 68 已重新取得 `npm run typecheck` 通过证据;记录 69 / 71 / 72 / 73 / 74 未单独启动 typecheck / verify:local;记录 75 已尝试 `npm run typecheck` 但当时被 AutomationJobDialog 并行脏改阻塞,且隔壁 `verify:local` 在 `smoke:claw-chat-ready-streaming` 超时失败;记录 76 已重新取得 `npm run typecheck` 通过证据;记录 77 已尝试 `npm run typecheck` 但被 Memory 来源链并行脏改阻塞;记录 78 / 79 / 81 / 82 已重新取得 `npm run typecheck` 通过证据;记录 80 已补齐 Memory 来源链资源并通过 detect / 定向测试 / ESLint,但未单独启动 typecheck / verify:local;记录 70 已重新取得 `npm run typecheck` 通过证据。 diff --git a/docs/test/README.md b/docs/test/README.md index bdbbb309a..6b39332c2 100644 --- a/docs/test/README.md +++ b/docs/test/README.md @@ -48,11 +48,20 @@ docs/test/ ├── integration-tests.md # 集成测试指南 ├── e2e-tests.md # 浏览器续测与 E2E 总览 ├── agent-evaluation.md # Agent 评估指南(核心文档) +├── agent-qc-evidence.schema.json # Agent QC Evidence Pack schema +├── agent-qc-gui-flows.manifest.json # Agent QC GUI / Playwright MCP flow 清单 +├── agent-qc-scenarios.manifest.json # Agent QC 核心场景清单 ├── harness-evals.md # Harness eval 任务集与 runner 入口 └── test-cases/ # 测试用例模板 ├── converter-tests.md # 协议转换器测试用例 ├── provider-tests.md # Provider 测试用例 └── agent-tests.md # Agent 测试用例 + +docs/tests/ +├── agent-ops-qc.md # Agent 运营级测试体系与证据门禁 +├── agent-qc-p0-scenarios.md # Agent QC P0 场景执行手册 +├── lime-agent-qc-rollout-plan.md # Lime 样本产品落地计划 +└── lime-agent-qc-current-blockers.md # 当前 P0 qcloop 阻断记录 ``` ## 文档索引 @@ -65,6 +74,13 @@ docs/test/ | [e2e-tests.md](e2e-tests.md) | 当前浏览器续测与 E2E 入口 | Playwright MCP / DevBridge 主路径验证 | | [../aiprompts/playwright-e2e.md](../aiprompts/playwright-e2e.md) | 浏览器续测详细事实源 | 继续测试、复现、控制台与 Bridge 排障 | | [agent-evaluation.md](agent-evaluation.md) | Agent 评估指南 | AI Agent 行为评估 | +| [../tests/agent-ops-qc.md](../tests/agent-ops-qc.md) | Agent 运营级测试体系 | qcloop、证据包、发布门禁、运营回归 | +| [../tests/agent-qc-p0-scenarios.md](../tests/agent-qc-p0-scenarios.md) | Agent QC P0 场景执行手册 | 核心场景执行、证据要求、失败沉淀 | +| [../tests/lime-agent-qc-rollout-plan.md](../tests/lime-agent-qc-rollout-plan.md) | Lime 落地计划 | 分阶段构建 qcloop 证据链、GUI/Runtime 深测、release gate | +| [../tests/lime-agent-qc-current-blockers.md](../tests/lime-agent-qc-current-blockers.md) | 当前 P0 阻断记录 | 真实 qcloop fail evidence、root cause、关闭条件 | +| [agent-qc-scenarios.manifest.json](agent-qc-scenarios.manifest.json) | Agent QC 场景清单 | 机器可读场景、lane、命令、证据要求 | +| [agent-qc-evidence.schema.json](agent-qc-evidence.schema.json) | Evidence Pack schema | 测试证据、场景结果、verdict 合同 | +| [agent-qc-gui-flows.manifest.json](agent-qc-gui-flows.manifest.json) | GUI flow 清单 | Playwright MCP 步骤、断言、证据要求 | | [harness-evals.md](harness-evals.md) | Harness eval 任务集与 runner | Replay 样本、grader、nightly 摘要 | | [test-cases/converter-tests.md](test-cases/converter-tests.md) | 转换器测试用例 | OpenAI ↔ Claude 转换 | | [test-cases/provider-tests.md](test-cases/provider-tests.md) | Provider 测试用例 | API Key Provider、协议转换和 API 调用 | @@ -130,6 +146,22 @@ npm run harness:eval:promote -- --session-id "session-123" --slug "pending-reque npm run harness:eval:trend ``` +### 运行 Agent QC 场景报告 / 合同检查 + +```bash +npm run agent-qc:report +npm run agent-qc:report:json +npm run agent-qc:gui-flow:report +npm run agent-qc:gui-flow:check +npm run agent-qc:check +npm run agent-qc:qcloop-job -- --risk P0 --output "./.lime/qc/qcloop-p0-job.json" --check +npm run agent-qc:export-evidence -- --job-id "" --output "./.lime/qc/agent-qc-evidence.json" --check +npm run agent-qc:release-summary -- --evidence "./.lime/qc/agent-qc-evidence.json" --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" --require-risk P0 --tag "" --output "./.lime/qc/release-agent-qc.md" --check +npm run agent-qc:audit +``` + +`agent-qc:check` 已进入 `npm run test:contracts`,用于防止运营级测试场景、证据 schema 和 npm script 入口漂移。 + ### 记录 Harness eval 历史窗口 ```bash diff --git a/docs/test/agent-qc-evidence.schema.json b/docs/test/agent-qc-evidence.schema.json new file mode 100644 index 000000000..95f85a108 --- /dev/null +++ b/docs/test/agent-qc-evidence.schema.json @@ -0,0 +1,240 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://lime.local/schemas/agent-qc-evidence.schema.json", + "title": "Lime Agent QC Evidence Pack", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "runId", + "generatedAt", + "subject", + "laneResults", + "scenarioResults", + "verdict" + ], + "properties": { + "schemaVersion": { + "const": "v1" + }, + "runId": { + "type": "string", + "minLength": 1 + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "ref", "changedFiles"], + "properties": { + "repo": { + "type": "string", + "minLength": 1 + }, + "ref": { + "type": "string", + "minLength": 1 + }, + "diffBase": { + "type": "string" + }, + "changedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "riskTags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "laneResults": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["laneId", "status", "commands"], + "properties": { + "laneId": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/status" + }, + "commands": { + "type": "array", + "items": { + "$ref": "#/$defs/commandResult" + } + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "scenarioResults": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["scenarioId", "status", "evidenceRefs"], + "properties": { + "scenarioId": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/status" + }, + "executor": { + "type": "string" + }, + "attempts": { + "type": "integer", + "minimum": 0 + }, + "runtimeTranscriptRef": { + "type": "string" + }, + "guiTraceRef": { + "type": "string" + }, + "consoleErrorCount": { + "type": "integer", + "minimum": 0 + }, + "networkErrorCount": { + "type": "integer", + "minimum": 0 + }, + "evidenceRefs": { + "type": "array", + "items": { + "type": "string" + } + }, + "failureModes": { + "type": "array", + "items": { + "type": "string" + } + }, + "humanReview": { + "$ref": "#/$defs/humanReview" + } + } + } + }, + "verdict": { + "type": "object", + "additionalProperties": false, + "required": ["status", "summary"], + "properties": { + "status": { + "enum": ["pass", "fail", "blocked", "needs-human-review", "waived"] + }, + "summary": { + "type": "string" + }, + "blockers": { + "type": "array", + "items": { + "type": "string" + } + }, + "waivers": { + "type": "array", + "items": { + "$ref": "#/$defs/waiver" + } + }, + "nextAction": { + "type": "string" + } + } + } + }, + "$defs": { + "status": { + "enum": ["pass", "fail", "blocked", "needs-human-review", "waived", "skipped"] + }, + "commandResult": { + "type": "object", + "additionalProperties": false, + "required": ["command", "status"], + "properties": { + "command": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/status" + }, + "exitCode": { + "type": "integer" + }, + "durationMs": { + "type": "integer", + "minimum": 0 + }, + "logRef": { + "type": "string" + }, + "artifactRefs": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "humanReview": { + "type": "object", + "additionalProperties": false, + "required": ["required", "reason"], + "properties": { + "required": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "reviewer": { + "type": "string" + }, + "decision": { + "enum": ["approved", "rejected", "deferred", "not-reviewed"] + } + } + }, + "waiver": { + "type": "object", + "additionalProperties": false, + "required": ["id", "reason", "expiresAt"], + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + } + } +} diff --git a/docs/test/agent-qc-gui-flows.manifest.json b/docs/test/agent-qc-gui-flows.manifest.json new file mode 100644 index 000000000..2789edee0 --- /dev/null +++ b/docs/test/agent-qc-gui-flows.manifest.json @@ -0,0 +1,141 @@ +{ + "manifestVersion": "v1", + "title": "Lime Agent QC GUI Flow Manifest", + "description": "Playwright MCP / GUI dogfood flows for P0 Agent QC scenarios. This manifest is a machine-readable runbook; it does not execute the browser by itself.", + "flows": [ + { + "id": "gui-claw-chat-ready-streaming", + "scenarioId": "claw-chat-ready-streaming", + "risk": "P0", + "surface": "desktop_gui", + "preflight": [ + "Run npm run verify:gui-smoke or reuse an already healthy Lime desktop session.", + "Confirm DevBridge health before interacting with the UI.", + "Start console and network collection before the first user action." + ], + "steps": [ + "Open or focus the Claw / Agent chat workspace.", + "Confirm the default workspace is ready and input is enabled.", + "Send a short user message that does not require external credentials.", + "Observe streaming deltas or equivalent progress state.", + "Trigger interrupt while a response is active when the UI exposes interruption.", + "Send a follow-up message to confirm the session recovers." + ], + "assertions": [ + "workspace ready state is visible before sending", + "user message appears exactly once", + "assistant response reaches a terminal or interrupted state", + "follow-up turn can be submitted after interruption", + "no unexpected console error", + "no unexpected network error", + "mock fallback is explicitly classified if observed" + ], + "evidenceRequired": [ + "Playwright MCP action log", + "screenshot or trace of ready state", + "runtime transcript or request id", + "console summary", + "network summary", + "DevBridge health result" + ] + }, + { + "id": "gui-browser-runtime-site-adapter", + "scenarioId": "browser-runtime-site-adapter", + "risk": "P0", + "surface": "desktop_gui_browser_runtime", + "preflight": [ + "Run npm run smoke:browser-runtime and npm run smoke:site-adapters if a headless smoke is enough.", + "For interactive verification, reuse an existing Lime browser session instead of launching unrelated Chrome windows.", + "Enable console and network collection before opening the browser runtime panel." + ], + "steps": [ + "Open the browser runtime or site adapter surface.", + "Confirm browser runtime status is readable.", + "Open the site adapter catalog or readiness panel.", + "Inspect at least one current adapter entry.", + "Confirm cleanup / detach status after the check if the flow created a browser session." + ], + "assertions": [ + "browser runtime status is not stale", + "site adapter catalog has current entries", + "adapter naming does not drift to deprecated surface keys", + "cleanup status is captured", + "no unexpected console error", + "no unexpected network error" + ], + "evidenceRequired": [ + "browser runtime status", + "site adapter catalog snapshot", + "console summary", + "network summary", + "cleanup or detach result" + ] + }, + { + "id": "gui-workspace-ready-session-restore", + "scenarioId": "workspace-ready-session-restore", + "risk": "P0", + "surface": "desktop_gui_workspace", + "preflight": [ + "Run npm run smoke:workspace-ready or confirm an existing Lime session is healthy.", + "Record whether the test is fresh start or session restore.", + "Confirm DevBridge health before reading workspace state." + ], + "steps": [ + "Open the default workspace.", + "Confirm primary navigation and workspace content shell render.", + "Inspect session restore state if previous sessions exist.", + "Open the Agent chat workspace from the restored state.", + "Confirm no stale loading state remains." + ], + "assertions": [ + "workspace ready smoke and GUI state agree", + "primary navigation is visible", + "restored session does not block new input", + "no stale skeleton or permanent loading state", + "DevBridge remains healthy" + ], + "evidenceRequired": [ + "workspace smoke log or status", + "GUI screenshot or trace", + "DevBridge health result", + "session restore notes", + "console summary" + ] + }, + { + "id": "gui-release-startup-smoke", + "scenarioId": "release-package-startup-smoke", + "risk": "P0", + "surface": "release_artifact_gui", + "preflight": [ + "Use the release candidate artifact, not only the dev server.", + "Record platform, architecture and version.", + "Run npm run verify:app-version before treating the artifact as release evidence." + ], + "steps": [ + "Install or launch the release candidate.", + "Confirm the first window opens.", + "Confirm workspace ready state.", + "Confirm DevBridge or equivalent runtime bridge health.", + "Open the Agent chat entry point without sending credential-dependent requests." + ], + "assertions": [ + "release version matches the tag", + "application launches without crash", + "first workspace becomes ready", + "bridge health is captured", + "no startup console error blocks the main path" + ], + "evidenceRequired": [ + "version check output", + "artifact path or release id", + "startup screenshot or trace", + "workspace ready status", + "bridge health result", + "platform metadata" + ] + } + ] +} diff --git a/docs/test/agent-qc-scenarios.manifest.json b/docs/test/agent-qc-scenarios.manifest.json new file mode 100644 index 000000000..b993d2115 --- /dev/null +++ b/docs/test/agent-qc-scenarios.manifest.json @@ -0,0 +1,472 @@ +{ + "manifestVersion": "v1", + "title": "Lime Agent QC Operating Scenarios", + "evidenceSchema": "docs/test/agent-qc-evidence.schema.json", + "qcloop": { + "purpose": "把 Agent 测试拆成可批量执行、独立质检、可返修的场景项。", + "recommendedMaxQcRounds": 3, + "workerPromptTemplate": "在 Lime 仓库中执行 Agent QC 场景 {{item}}。只做该场景要求的最小验证,收集命令、GUI、runtime 和证据路径,不要顺手修复无关问题。\n\n执行场景命令前先运行 qcloop worker preflight:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --check`。如果该场景命令需要 DevBridge / GUI / browser runtime,再运行:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --require-devbridge --timeout-ms 15000 --check`。\n\n如果 preflight 失败,停止后续高成本命令,输出 `QCLOOP_WORKER_RESULT=BLOCKED`,并在 stdout 中列出失败 check、health URL、cwd、stdout/stderr 证据路径;不要把环境阻断误判为产品 pass。\n\n最终 stdout 必须逐项列出 `evidence_required` 的证据是否满足,并逐项说明 `failure_modes` 如何被覆盖、排除或命中;不能只写“命令通过”或只给日志路径。\n\n如果 item 中包含 evidence_layers,最终 stdout 必须声明本次覆盖到哪些层级;只跑到 deterministic-smoke 时不得伪装成 gui-trace、runtime-transcript 或 release-artifact。\n\n只读约束:Agent QC worker 只允许执行场景命令、收集证据和输出 PASS/FAIL/BLOCKED;不得修改源码、配置、文档、锁文件或 git 状态。若 verifier 证据不足,输出缺口并让外层生成新 payload,不要在 qcloop repair 轮里修代码。\n\n结构化证据输出硬约束:最终 stdout 必须包含单行 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED`;还必须包含单行 `QCLOOP_EVIDENCE_SUMMARY_JSON=`,其中 `` 是单个 JSON object,不要 Markdown / code fence。该 JSON 至少包含 scenario_id、result、commands[]、evidence_required[]、evidence_layers_covered[]、failure_modes[]、artifacts[]、blockers[]、gui_session_owner、release_scope。evidence_required[] 每项包含 name、status(pass|fail|blocked|missing)、evidence、artifact_path;failure_modes[] 每项包含 name、status(covered|excluded|hit|unknown)、evidence。不得输出密钥、token、用户私密内容或未经脱敏的请求 / 响应正文。", + "verifierPromptTemplate": "审查 worker 输出是否满足场景 {{item}} 的 verifier、evidenceRequired 和 failureModes。只输出一个合法 JSON object,不要 Markdown、不要代码块、不要前后缀文本。JSON 必须至少包含 {\"pass\": true|false, \"feedback\": \"...\"};可选字段 missingEvidence、nextAction、evidenceStatus、scenarioId 必须仍在同一个 JSON object 内。\n\nqcloop worker 执行证据:\n- attempt_status: {{attempt_status}}\n- attempt_type: {{attempt_type}}\n- exit_code: {{exit_code}}\n\nworker stdout:\n{{stdout}}\n\nqcloop issue ledger:\n{{issue_ledger}}\n\n判定要求:stdout 必须包含 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED` 和 `QCLOOP_EVIDENCE_SUMMARY_JSON=`。如果 stdout 缺少 `QCLOOP_EVIDENCE_SUMMARY_JSON`,或该 JSON 无法逐项证明 scenario verifier / evidenceRequired / failureModes,必须输出 {\"pass\": false, \"feedback\": \"...\"};不能因为命令名、退出码或 worker 自评 PASS 就通过。", + "recommendedMaxExecutorRetries": 0 + }, + "lanes": [ + { + "id": "L0-static-unit", + "title": "快速卫生与确定性单测", + "objective": "用最快入口发现 lint、type、unit、Rust 定向测试中的确定性错误。", + "gatePolicy": "普通代码改动默认执行;docs-only 可跳过。", + "defaultCommands": [ + "npm run verify:local" + ] + }, + { + "id": "L1-contract-bridge", + "title": "契约、Bridge 与治理护栏", + "objective": "验证前端调用、Rust 注册、DevBridge、mock、治理目录册和 harness 合同保持一致。", + "gatePolicy": "命令、Bridge、runtime gateway、mock 或 QC manifest/schema 改动必须执行。", + "defaultCommands": [ + "npm run test:contracts", + "npm run test:bridge" + ] + }, + { + "id": "L2-agent-runtime", + "title": "Agent Runtime 与工具面", + "objective": "验证 turn、streaming、中断、工具调用、approval、sandbox、memory、team runtime 等 Agent 内核路径。", + "gatePolicy": "Agent runtime、tool surface、Skill Forge、team 或 memory 改动必须执行定向 runtime 场景。", + "defaultCommands": [ + "npm run smoke:agent-runtime-tool-surface", + "npm run smoke:agent-runtime-tool-surface-page" + ] + }, + { + "id": "L3-product-surface", + "title": "GUI / WebUI / 桌面产品表面", + "objective": "验证 Lime 真实 GUI 主路径、DevBridge 健康、workspace ready、浏览器运行时和产品页面可用。", + "gatePolicy": "GUI 壳、Workspace、页面主路径、浏览器能力或用户可见 UI 改动必须执行。", + "defaultCommands": [ + "npm run verify:gui-smoke", + "npm run bridge:health -- --timeout-ms 120000" + ] + }, + { + "id": "L4-behavior-eval", + "title": "Agent 行为评测与回归样本", + "objective": "用 replay、grader、语义评测和趋势报告发现 Agent 行为退化。", + "gatePolicy": "Agent 行为、prompt、Skill、工具选择策略或长期运营回归默认进入 nightly;高风险改动需本地抽跑。", + "defaultCommands": [ + "npm run harness:eval", + "npm run harness:eval:trend" + ] + }, + { + "id": "L5-release-ops", + "title": "发布与运营门禁", + "objective": "验证版本、发布包、安装启动、release artifact 和发布证据完整性。", + "gatePolicy": "版本、打包、发布、安装器、工作流或运营门禁改动必须执行。", + "defaultCommands": [ + "npm run verify:app-version", + "npm run agent-qc:check" + ] + } + ], + "scenarios": [ + { + "id": "command-bridge-contract", + "title": "命令桥接四侧一致", + "risk": "P0", + "executor": "npm_command", + "lanes": [ + "L1-contract-bridge" + ], + "commands": [ + "npm run test:contracts" + ], + "goal": "验证 safeInvoke/invoke、Rust generate_handler、agentCommandCatalog、mockPriorityCommands/defaultMocks 不漂移。", + "verifier": "test:contracts 成功;失败时能指出具体缺失侧;不得出现新增 compat/dead 回流;若本轮没有命令面变更,checked surface counts + contract diff empty 可满足 surface evidence。", + "evidenceRequired": [ + "contract command log", + "checked command surfaces summary or changed command surfaces", + "failure side summary or no-failure side summary" + ], + "evidenceLayers": [ + "deterministic-smoke" + ], + "failureModes": [ + "frontend-only command", + "missing rust handler", + "mock fallback drift", + "governance catalog missing", + "no-change surface evidence rejected" + ] + }, + { + "id": "claw-chat-ready-streaming", + "title": "Claw 首屏、聊天、流式与中断", + "risk": "P0", + "executor": "mixed", + "lanes": [ + "L2-agent-runtime", + "L3-product-surface" + ], + "commands": [ + "npm run verify:gui-smoke" + ], + "goal": "启动 Lime 后验证 workspace ready、发起一次聊天、看到流式增量、可中断并能继续下一轮。", + "verifier": "GUI 状态、DevBridge、runtime transcript、console/network 都有证据;不能只以截图或单测通过判定。", + "evidenceRequired": [ + "gui screenshot or trace", + "DevBridge health", + "runtime transcript", + "console/network summary", + "GUI session owner / isolation statement" + ], + "evidenceLayers": [ + "deterministic-smoke", + "gui-trace", + "runtime-transcript" + ], + "failureModes": [ + "workspace not ready", + "stream stuck", + "interrupt ignored", + "mock fallback mistaken as real path", + "parallel GUI smoke interference" + ] + }, + { + "id": "tool-approval-sandbox-boundary", + "title": "工具调用、Approval 与 Sandbox 边界", + "risk": "P0", + "executor": "runtime_harness", + "lanes": [ + "L2-agent-runtime", + "L4-behavior-eval" + ], + "commands": [ + "npm run smoke:agent-runtime-tool-surface", + "npm run smoke:agent-runtime-approval-sandbox" + ], + "goal": "覆盖允许工具、拒绝工具、超时工具和需要授权的工具,确认 runtime 不绕过 approval 或 sandbox。", + "verifier": "transcript 中必须有 tool request、decision、result/error、恢复动作;拒绝或超时不能把用户卡死。", + "evidenceRequired": [ + "tool timeline", + "approval decision", + "sandbox policy", + "error recovery transcript" + ], + "evidenceLayers": [ + "deterministic-smoke", + "runtime-transcript" + ], + "failureModes": [ + "approval bypass", + "tool result missing", + "timeout without recovery", + "unsafe tool exposed" + ] + }, + { + "id": "skill-forge-register-bind-enable", + "title": "Skill Forge 生成、校验、注册、绑定与显式启用", + "risk": "P0", + "executor": "mixed", + "lanes": [ + "L1-contract-bridge", + "L2-agent-runtime", + "L4-behavior-eval" + ], + "commands": [ + "npm run test:contracts", + "npm run smoke:agent-service-skill-entry" + ], + "goal": "从 capability draft 到 verify/register,再到 runtime binding readiness 与 session scope 显式 enable。", + "verifier": "不能把已注册误判为已自动进入 tool surface;必须看到 readiness、metadata、enable 和 SkillTool gate 证据。", + "evidenceRequired": [ + "capability draft evidence", + "registration result", + "runtime binding projection", + "SkillTool gate transcript" + ], + "evidenceLayers": [ + "deterministic-smoke", + "runtime-transcript" + ], + "failureModes": [ + "registered equals executable", + "metadata auto-enables skill", + "missing provenance", + "unsafe endpoint leaked" + ] + }, + { + "id": "browser-runtime-site-adapter", + "title": "浏览器运行时与站点适配器", + "risk": "P0", + "executor": "mixed", + "lanes": [ + "L2-agent-runtime", + "L3-product-surface" + ], + "commands": [ + "npm run smoke:browser-runtime", + "npm run smoke:site-adapters" + ], + "goal": "验证 browser runtime attach/status、site adapter catalog、页面读取和清理路径。", + "verifier": "必须检查浏览器 session、adapter 状态、console/network 和 cleanup;不能只验证目录可读。", + "evidenceRequired": [ + "browser runtime report", + "site adapter catalog", + "console/network summary", + "cleanup status", + "cleanup warning classified", + "GUI session owner / isolation statement" + ], + "evidenceLayers": [ + "deterministic-smoke", + "gui-trace" + ], + "failureModes": [ + "session leak", + "adapter catalog drift", + "browser permission denied", + "cleanup skipped", + "cleanup warning hidden by zero exit", + "parallel GUI smoke interference" + ] + }, + { + "id": "knowledge-ingest-retrieve-summarize", + "title": "Knowledge 导入、检索与引用总结", + "risk": "P1", + "executor": "mixed", + "lanes": [ + "L3-product-surface", + "L4-behavior-eval" + ], + "commands": [ + "npm run smoke:knowledge-gui", + "npm run knowledge:product-e2e" + ], + "goal": "验证项目资料从选择、保存、整理到检索总结的产品闭环。", + "verifier": "回答必须带来源或空结果说明;GUI 状态、数据落盘和产品 E2E 证据要一致。", + "evidenceRequired": [ + "knowledge gui smoke", + "product e2e log", + "source citation or empty-state evidence" + ], + "failureModes": [ + "source lost", + "empty state hidden", + "legacy fallback used", + "provider e2e not explicit" + ] + }, + { + "id": "workspace-ready-session-restore", + "title": "Workspace ready 与会话恢复", + "risk": "P0", + "executor": "npm_command", + "lanes": [ + "L3-product-surface" + ], + "commands": [ + "npm run smoke:workspace-ready", + "npm run verify:gui-smoke" + ], + "goal": "验证默认 workspace 准备态、会话恢复、关键面板基础可用。", + "verifier": "workspace-ready smoke 和 GUI smoke 都通过;失败时能定位到 Bridge、文件系统、状态恢复或 UI 层。", + "evidenceRequired": [ + "workspace smoke log", + "gui smoke log", + "DevBridge status", + "design canvas project roundtrip save/open evidence", + "GUI session owner / isolation statement" + ], + "evidenceLayers": [ + "deterministic-smoke", + "gui-trace" + ], + "failureModes": [ + "workspace init failed", + "session restore stale", + "bridge unavailable", + "ui ready false positive", + "design canvas export no save status", + "parallel GUI smoke interference" + ] + }, + { + "id": "team-runtime-message-peers", + "title": "Team Runtime 创建、通信与清理", + "risk": "P1", + "executor": "runtime_harness", + "lanes": [ + "L1-contract-bridge", + "L2-agent-runtime" + ], + "commands": [ + "npm run test:contracts", + "npm run smoke:agent-runtime-tool-surface-page" + ], + "goal": "验证 Agent/TeamCreate/TeamDelete/SendMessage/ListPeers/SendUserMessage current surface。", + "verifier": "tool inventory、runtime 注册、mock fallback、前端 tool display 同步;不得复活 SubAgentTask compat 工具。", + "evidenceRequired": [ + "tool inventory", + "runtime transcript", + "frontend display snapshot", + "mock fallback summary" + ], + "failureModes": [ + "tool inventory mismatch", + "peer list stale", + "compat tool resurrected", + "message not displayed" + ] + }, + { + "id": "automation-scheduler-retry-cancel", + "title": "自动化任务触发、重试、取消与状态回放", + "risk": "P1", + "executor": "mixed", + "lanes": [ + "L2-agent-runtime", + "L4-behavior-eval" + ], + "commands": [ + "npm run verify:local" + ], + "goal": "验证调度任务不会静默失败,连续失败、冷却恢复、取消和状态回放都有证据。", + "verifier": "必须看到任务状态机、失败计数、重试/取消动作和用户可见状态;不能只看后台日志。", + "evidenceRequired": [ + "scheduler state", + "retry/cancel log", + "user-visible status", + "replay state" + ], + "failureModes": [ + "silent retry loop", + "cancel ignored", + "state replay missing", + "background failure hidden" + ] + }, + { + "id": "harness-replay-regression", + "title": "Harness Replay 样本与趋势回归", + "risk": "P0", + "executor": "npm_command", + "lanes": [ + "L4-behavior-eval" + ], + "commands": [ + "npm run harness:eval", + "npm run harness:eval:trend" + ], + "goal": "验证固定 replay 样本、工作区发现、grader 合同和 trend 摘要不退化。", + "verifier": "summary/trend 产物存在且 current/degraded observability gap 角色清晰。", + "evidenceRequired": [ + "harness eval summary", + "trend report", + "invalid case list", + "observability outcome summary" + ], + "evidenceLayers": [ + "deterministic-smoke", + "runtime-transcript" + ], + "failureModes": [ + "fixture invalid", + "grader contract drift", + "trend history broken", + "observability gap mixed" + ] + }, + { + "id": "qcloop-batch-verifier-repair", + "title": "qcloop 批量执行、独立质检与返修闭环", + "risk": "P1", + "executor": "qcloop", + "lanes": [ + "L4-behavior-eval", + "L5-release-ops" + ], + "commands": [ + "npm run agent-qc:report", + "npm run agent-qc:export-evidence -- --job-json ./tmp/qcloop-job.json --items-json ./tmp/qcloop-items.json --check" + ], + "goal": "把一组 Agent QC 场景提交给 qcloop,验证 worker/verifier/repair/max_qc_rounds 和 evidence store。", + "verifier": "每个 item 有独立状态、attempt、qc_round、feedback;耗尽项不会被误报为通过。", + "evidenceRequired": [ + "qcloop job id", + "item status table", + "verifier verdict json", + "repair round summary", + "agent qc evidence pack", + "stale item sidecar", + "worker stdout/stderr length summary", + "worker CLI environment sidecar", + "inner Codex MCP startup policy", + "MCP command override sidecar", + "inner worker rules/skill isolation policy", + "read-only worker policy", + "max_qc_rounds policy", + "active GUI qcloop sidecar check", + "GUI single-owner policy" + ], + "failureModes": [ + "missing item", + "self-review only", + "exhausted marked success", + "feedback not used", + "running no-output stale", + "worker lease heartbeat without stdout", + "worker codex binary unavailable", + "worker auth or sandbox misconfiguration", + "worker user-config MCP startup no-output hang", + "mcp_servers root override does not disable nested servers", + "inner worker project rules cause nonessential tool startup", + "repair prompt mutates workspace", + "worker modifies source during QC", + "parallel GUI smoke interference" + ] + }, + { + "id": "release-package-startup-smoke", + "title": "发布包安装、启动与首屏冒烟", + "risk": "P0", + "executor": "release_workflow", + "lanes": [ + "L3-product-surface", + "L5-release-ops" + ], + "commands": [ + "npm run verify:app-version", + "npm run verify:gui-smoke" + ], + "goal": "发布前验证版本一致、启动冒烟、首屏 ready、Bridge health 和发布阻断条件可被 release gate 复核。", + "verifier": "verify:app-version 与 GUI startup smoke 成功;如无真实安装包 artifact,应明确记录为 source-tree startup smoke,不能把它伪装成 installer 验证;release Evidence Pack 覆盖由 release-summary gate 单独强制。", + "evidenceRequired": [ + "version check", + "startup smoke", + "artifact or source-tree startup scope", + "waiver list", + "GUI smoke natural exit and design canvas roundtrip result", + "GUI session owner / isolation statement" + ], + "evidenceLayers": [ + "deterministic-smoke", + "release-artifact" + ], + "failureModes": [ + "version mismatch", + "artifact missing", + "startup crash", + "manual-only release", + "unowned waiver", + "GUI smoke hangs before design canvas export status", + "parallel GUI smoke interference" + ] + } + ] +} diff --git a/docs/test/testing-strategy-2026.md b/docs/test/testing-strategy-2026.md index 145192494..303593a13 100644 --- a/docs/test/testing-strategy-2026.md +++ b/docs/test/testing-strategy-2026.md @@ -10,9 +10,23 @@ - `docs/test/README.md`:当前测试入口与命令索引 - `docs/test/e2e-tests.md`:当前浏览器续测与 E2E 总览入口 +- `docs/tests/agent-ops-qc.md`:当前 Agent 运营级测试体系、qcloop 场景与 Evidence Pack 门禁 +- `docs/tests/agent-qc-p0-scenarios.md`:当前 Agent QC P0 场景执行、GUI/runtime 证据与失败沉淀手册 +- `docs/tests/lime-agent-qc-rollout-plan.md`:当前 Lime 样本产品的 Agent 运营级测试分阶段落地计划 +- `docs/test/agent-qc-scenarios.manifest.json`:当前 Agent QC 机器可读场景清单 +- `docs/test/agent-qc-evidence.schema.json`:当前 Agent QC Evidence Pack schema +- `docs/test/agent-qc-gui-flows.manifest.json`:当前 Agent QC GUI / Playwright MCP flow 清单 - `docs/aiprompts/playwright-e2e.md`:当前浏览器续测 / Playwright MCP 事实源 - `package.json`:当前统一测试命令入口 - `scripts/local-ci.mjs`:当前本地智能校验入口 +- `scripts/agent-qc-report.mjs`:当前 Agent QC 场景报告与合同检查入口 +- `scripts/agent-qc-gui-flow-report.mjs`:当前 Agent QC GUI flow 报告与合同检查入口 +- `scripts/agent-qc-qcloop-job.mjs`:当前 Agent QC manifest 到 qcloop job payload 的生成入口 +- `scripts/agent-qc-export-evidence.mjs`:当前 qcloop job 到 Evidence Pack 的导出入口 +- `scripts/agent-qc-release-summary.mjs`:当前 Agent QC Evidence Pack 到 release note 质量证据的汇总入口 +- `scripts/agent-qc-completion-audit.mjs`:当前 Agent QC 整体目标完成度审计入口 +- `.github/workflows/harness-nightly.yml`:当前会上传 `artifacts/agent-qc/agent-qc-report.*`、`agent-qc-gui-flow-report.*` 与 `release-agent-qc-preview.*` +- `.github/workflows/release.yml`:当前在创建 GitHub Release 前强制校验 `agent_qc_evidence_path` 对应的 Agent QC Evidence Pack - `scripts/report-legacy-surfaces.mjs`:当前 legacy / compat 回流护栏 ### compat @@ -48,6 +62,7 @@ | 优先级 | 事项 | 为什么重要 | 当前证据 | 完成定义 | | ------ | ------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | P1 | Agent eval 仍未完全工程化 | 价值高,且当前最缺的是把证据沉淀成长期回归资产 | 已补 `docs/test/harness-evals.md`、`harness-evals.manifest.json`、`scripts/harness-eval-runner.mjs`、`scripts/harness-eval-trend-report.mjs` 与 nightly 摘要 / trend 骨架,但真实执行与更多高价值样本仍缺 | 形成稳定任务集、可增长 replay 样本、grader、nightly 输出与趋势指标 | +| P1 | qcloop 真实运行结果尚未导出为 Evidence Pack | 运营级测试需要从“场景清单”进入“每次运行可审计证据” | 已补 Agent QC manifest、evidence schema、报告脚本与 `test:contracts` 门禁;下一步需要 qcloop job 结果按 schema 落盘 | qcloop 批次可导出 `agent-qc-evidence.schema.json` 形状,并被 release / nightly 消费 | | P2 | terminal / server 自包含 smoke 仍可继续扩面 | 最小 GUI smoke 基线已具备,但更细分主链仍缺专项守卫 | 当前 `workspace-ready / browser-runtime / site-adapters` 已覆盖 GUI 最小主链;`smoke:social-workbench` 仍依赖已有 session,terminal / server 还没有各自独立的自包含 smoke 入口 | 如后续需要继续扩面,应补 terminal 或 server 的独立 smoke,而不是继续把现有 3 条 current smoke 算成缺口 | ## 4. 建议执行顺序 diff --git a/docs/tests/README.md b/docs/tests/README.md new file mode 100644 index 000000000..ad547602d --- /dev/null +++ b/docs/tests/README.md @@ -0,0 +1,25 @@ +# `docs/tests` 测试文档 + +本目录放面向维护者和测试 Agent 阅读的测试方法论、运行手册与运营门禁说明。 + +## 当前入口 + +- `agent-ops-qc.md`:Agent 运营级测试体系,定义 qcloop、Evidence Pack、测试分层和发布门禁。 +- `agent-qc-p0-scenarios.md`:P0 场景执行手册,说明核心场景如何选择、生成 qcloop payload、按 GUI flow 运行、验收和沉淀证据。 +- `lime-agent-qc-rollout-plan.md`:Lime 作为样本产品的 Agent 运营级测试落地计划,说明从 partial evidence 到 P0 release gate 的分阶段路径。 +- `lime-agent-autonomous-testing-plan.md`:Lime 作为实际 Agent 产品的自主化测试体系示例计划,说明风险地图、Agent 分工、测试组合和分阶段建设路径。 +- `lime-agent-autonomous-test-execution-matrix.md`:Lime 自主测试执行矩阵,把 owner gate、P0 场景、证据层和失败回写落成 Agent 可执行步骤。 +- `lime-agent-qc-current-blockers.md`:Lime 当前 P0 qcloop 阻断清单,记录真实 fail evidence、root cause 和关闭条件。 +- `lime-agent-qc-stale-worker-analysis-2026-05-10.md`:当前 stale qcloop worker 的根因分析样本,汇总 qcloop status、DB lease、进程树、binary provenance、失败分类和恢复路径。 +- `lime-agent-qc-qcloop-operations.md`:qcloop 批次只读监控、worker preflight、sidecar evidence、stale item 和重跑策略运维手册。 +- `lime-agent-qc-stale-owner-intervention.md`:stale GUI qcloop owner 的只读取证、owner 确认和最小处置协议。 +- `lime-agent-qc-evidence-contract.md`:qcloop worker / verifier 的结构化 evidence 输出契约,定义 `QCLOOP_WORKER_RESULT` 与 `QCLOOP_EVIDENCE_SUMMARY_JSON`。 +- `lime-agent-qc-completion-audit-2026-05-10.md`:当前整体目标完成度审计,逐项映射需求、证据、缺口和关闭条件。 +- `ai-agent-testing-guide.md`:AI Agent 测试基础理论与评分器说明。 + +## 与 `docs/test` 的分工 + +- `docs/tests/`:人读文档、运行手册、运营测试策略。 +- `docs/test/`:测试入口索引、机器可读 manifest/schema、harness eval 资产和测试用例模板。 + +如果文档会被 qcloop、CI 或脚本直接读取,优先放在 `docs/test/`;如果文档主要指导人和 Agent 如何执行测试,优先放在 `docs/tests/`。 diff --git a/docs/tests/agent-ops-qc.md b/docs/tests/agent-ops-qc.md new file mode 100644 index 000000000..50c5642f8 --- /dev/null +++ b/docs/tests/agent-ops-qc.md @@ -0,0 +1,275 @@ +# Lime Agent 运营级测试体系 + +> 目标:把 Lime 的测试从“人手工点一遍”升级为“Agent 自动执行、证据可审计、人类只处理例外”的运营级质量系统。 + +## 1. 结论 + +Lime 已经有较丰富的测试基础:`verify:local`、`test:contracts`、`verify:gui-smoke`、`smoke:*`、`harness:eval`、nightly 和发布工作流。下一阶段的关键不是再堆一批零散测试,而是建立一条统一主链: + +```text +改动 / 发布目标 + -> Test Planner 选择测试 lane + -> qcloop / npm / Playwright MCP / harness 执行 + -> Evidence Pack 汇总证据 + -> Verifier 判定 pass / fail / blocked / needs-human-review / waived + -> 失败沉淀 replay / scenario / regression + -> 发布门禁只审核证据,不靠人工记忆 +``` + +运营阶段的默认分工是: + +- Agent 负责读 diff、选择测试、执行场景、收集证据、初步归因。 +- qcloop 负责批量队列、独立 verifier、多轮返修和状态留痕。 +- 人类负责审核高风险 waiver、设计核心场景、处理语义质量争议。 +- CI / nightly / release workflow 负责把证据变成门禁。 + +## 2. 测试分层 + +| Lane | 名称 | 证明什么 | 当前入口 | 不能证明什么 | +| --- | --- | --- | --- | --- | +| `L0-static-unit` | 快速卫生与确定性单测 | 低级错误、类型、确定性逻辑 | `npm run verify:local` | GUI 真能用、Agent 行为质量 | +| `L1-contract-bridge` | 契约、Bridge 与治理护栏 | 前端 / Rust / mock / catalog 同步 | `npm run test:contracts` | 用户路径体验 | +| `L2-agent-runtime` | Agent Runtime 与工具面 | turn、streaming、tool、approval、sandbox、team | `smoke:agent-runtime-*` | 真实 GUI 表面 | +| `L3-product-surface` | GUI / WebUI / 桌面产品表面 | DevBridge、workspace、browser runtime、页面可用 | `npm run verify:gui-smoke` | 长程语义退化 | +| `L4-behavior-eval` | Agent 行为评测与回归样本 | replay、grader、语义质量、趋势 | `npm run harness:eval` | 安装包可用 | +| `L5-release-ops` | 发布与运营门禁 | 版本、包、启动、release evidence | `verify:app-version` + release smoke | 未覆盖的线上新场景 | + +规则:任何 Agent runtime、GUI 主路径或发布改动,都不能只拿 L0 作为可交付结论。 + +## 3. Evidence Pack 标准 + +Evidence Pack 是测试系统的事实源,schema 位于: + +- `docs/test/agent-qc-evidence.schema.json` + +每次 Agent QC 运行至少要记录: + +- `subject`:仓库、ref、diff base、changed files、风险标签。 +- `laneResults`:每个 lane 的命令、状态、日志、artifact。 +- `scenarioResults`:场景 id、执行器、runtime transcript、GUI trace、console/network 摘要、失败模式。 +- `verdict`:总体状态、阻断原因、waiver、下一步动作。 + +状态语义固定为: + +| 状态 | 含义 | +| --- | --- | +| `pass` | 证据完整且通过 | +| `fail` | 有明确失败,必须修复 | +| `blocked` | 环境、权限、凭证或外部依赖阻断,不能假装通过 | +| `needs-human-review` | 语义质量或风险接受需要人审 | +| `waived` | 已记录 owner、原因、过期时间的临时放行 | +| `skipped` | 当前改动不适用,必须有选择理由 | + +## 4. 场景 Manifest + +运营级场景清单位于: + +- `docs/test/agent-qc-scenarios.manifest.json` + +本仓库提供校验与报告入口: + +```bash +npm run agent-qc:report +npm run agent-qc:report:json +npm run agent-qc:check +``` + +Manifest 的职责: + +- 固定每个核心场景属于哪些 lane。 +- 固定默认命令、执行器、目标、verifier、证据要求和失败模式。 +- 给 qcloop 提供可拆分的 item 来源。 +- 让 CI 可以检查场景清单是否引用了不存在的 npm script。 + +首批 P0 / P1 场景包括: + +- 命令桥接四侧一致。 +- Claw 首屏、聊天、流式与中断。 +- 工具调用、approval 与 sandbox。 +- Skill Forge 生成、校验、注册、绑定与显式启用。 +- 浏览器运行时与站点适配器。 +- Knowledge 导入、检索与引用总结。 +- Workspace ready 与会话恢复。 +- Team Runtime 创建、通信与清理。 +- 自动化任务触发、重试、取消与状态回放。 +- Harness replay 样本与趋势回归。 +- qcloop 批量执行、独立质检与返修闭环。 +- 发布包安装、启动与首屏冒烟。 + +## 5. qcloop 使用方式 + +qcloop 不是替代 `npm`、Rust 或 Playwright,而是把它们编排成可审计的 Agent QA loop。 + +推荐让外层 Agent 创建 qcloop 批次: + +```text +请读取 http://127.0.0.1:3000/llm-full.txt,然后使用 qcloop 按 docs/test/agent-qc-scenarios.manifest.json 测试当前 Lime 改动。只选择与 diff 风险相关的场景,输出 evidence pack 摘要。 +``` + +Worker prompt 应遵守: + +- 每个 item 只做一个场景。 +- 优先执行 manifest 中的默认命令。 +- GUI 场景必须采集 DevBridge、console、network、截图或 trace。 +- Runtime 场景必须采集 transcript、tool timeline、approval、sandbox 和错误恢复。 +- 不顺手修复无关问题;失败只做归因和最小复现。 + +Verifier prompt 应遵守: + +- 独立审查 worker 输出,不能自评通过。 +- qcloop 模板必须包含 `{{stdout}}` 或 `{{output}}`,并至少带出 `{{attempt_status}}`、`{{attempt_type}}`、`{{exit_code}}`;否则 verifier 实际看不到 worker 证据,会把可用 stdout 误判为缺证据。 +- 默认不要把完整 `{{stderr}}` 放进 verifier prompt;Codex / GUI 场景 stderr 可能包含超长运行日志,优先要求 worker 在 stdout 末尾输出可审查摘要与 evidence 路径。 +- 按 `verifier`、`evidenceRequired`、`failureModes` 判定。 +- 缺证据时输出 `needs-human-review` 或 `blocked`,不能输出 pass。 +- 只接受结构化 JSON verdict,便于后续进入 Evidence Pack。 + +可以先从 manifest 生成 qcloop job payload,避免人工复制场景: + +```bash +npm run agent-qc:qcloop-job -- \ + --risk P0 \ + --cwd "$(pwd)" \ + --output "./.lime/qc/qcloop-p0-job.json" \ + --check +``` + +如果需要直接复制 curl: + +```bash +npm run agent-qc:qcloop-job -- --risk P0 --format curl +``` + +qcloop 批次完成后,用仓库内导出脚本把 job 结果转成 Evidence Pack: + +```bash +npm run agent-qc:export-evidence -- \ + --job-id "" \ + --output "./.lime/qc/agent-qc-evidence.json" \ + --ref "" \ + --diff-base "origin/main" \ + --check +``` + +离线排障时也可以从 qcloop API 保存的 JSON 导出: + +```bash +node scripts/agent-qc-export-evidence.mjs \ + --job-json "./tmp/qcloop-job.json" \ + --items-json "./tmp/qcloop-items.json" \ + --output "./tmp/agent-qc-evidence.json" \ + --check +``` + +P0 场景的执行细节见 `docs/tests/agent-qc-p0-scenarios.md`。 + +发布前再把一个或多个 Evidence Pack 汇总成 release note 可引用的质量证据: + +```bash +npm run agent-qc:release-summary -- \ + --evidence "./.lime/qc/agent-qc-evidence.json" \ + --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \ + --require-risk P0 \ + --harness-summary "./.lime/harness/reports/harness-eval-summary.json" \ + --harness-trend "./.lime/harness/reports/harness-eval-trend.json" \ + --tag "" \ + --output "./.lime/qc/release-agent-qc.md" \ + --check +``` + +`--check` 的语义是发布门禁:没有 Evidence Pack、Evidence Pack 非 `pass`、未覆盖全部 P0 scenario id,或存在未处理 blocker 时都不能作为绿色发布证据。 + +正式 GitHub Release 也执行同一条硬门禁:`.github/workflows/release.yml` 会在创建 / 刷新 release notes 前读取 `agent_qc_evidence_path`,默认值为 `.lime/qc/agent-qc-evidence.json`。文件不存在、`agent-qc-release-summary --check` 非 pass,或 Evidence Pack 未覆盖全部 P0 scenario id 时,release workflow 会直接失败,不再创建“只靠人工点过”的发布。 + +Nightly 会额外上传 `artifacts/agent-qc/`: + +- `agent-qc-report.md/json`:当前场景 manifest 报告。 +- `agent-qc-gui-flow-report.md/json`:当前 GUI / Playwright MCP flow manifest 报告。 +- `release-agent-qc-preview.md/json`:无真实 Evidence Pack 时的 release 摘要预览,状态保持 `blocked`,用于提醒发布前必须补证据。 + +需要判断整体目标是否真的完成时,运行完成度审计: + +```bash +npm run agent-qc:audit +``` + +该命令会把标准文档、scenario manifest、GUI flow manifest、qcloop payload、Evidence Pack 导出、nightly artifact、真实 qcloop evidence、真实 GUI evidence 和 release hard gate 逐项映射为 `PASS/MISS`。真实 qcloop evidence 必须覆盖 manifest 中所有 P0 scenario id,不能只用相同数量的非 P0 场景凑数。只要真实证据或 release 硬门禁缺失,审计结果就保持 `incomplete`。 + +## 6. 组合测试策略 + +Agent 产品不能依赖单一测试手段。Lime 的默认组合如下: + +| 组合 | 用法 | Lime 示例 | +| --- | --- | --- | +| 白盒 + 黑盒 | 白盒看 transcript,黑盒看用户结果 | tool timeline + GUI 状态 | +| 快照 + 语义评测 | UI 防结构漂移,Agent 防语义退化 | React snapshot + harness grader | +| 冒烟 + 长程任务 | PR 快速验证,nightly 跑长链 | `verify:gui-smoke` + `harness:eval:trend` | +| Mock + Real Backend | 本地可 mock,发布必须真实路径 | DevBridge mock + release startup smoke | +| 确定性断言 + LLM Judge | 合同用代码断言,开放回答用 rubric | `test:contracts` + grader.md | +| CI + qcloop | CI 做硬门禁,qcloop 做批量质检 | manifest item + verifier/repair | + +## 7. 改动类型到测试选择 + +| 改动类型 | 最小门槛 | 运营增强 | +| --- | --- | --- | +| 普通前端逻辑 | `npm run verify:local` | 受影响 UI snapshot | +| Tauri 命令 / Bridge / mock | `verify:local` + `test:contracts` | qcloop 跑 `command-bridge-contract` | +| GUI 壳 / Workspace / 主页面 | `verify:local` + `verify:gui-smoke` | Playwright MCP 跑真实交互 | +| Agent Runtime / tool surface | Rust 定向测试 + `smoke:agent-runtime-*` | transcript 进入 Evidence Pack | +| Skill Forge / SkillTool | contracts + runtime binding 定向测试 | `skill-forge-register-bind-enable` 场景 | +| Browser Runtime / site adapter | `smoke:browser-runtime` + `smoke:site-adapters` | console/network/cleanup 证据 | +| Knowledge 产品路径 | `smoke:knowledge-gui` + `knowledge:product-e2e` | 来源引用与空结果语义评测 | +| 发布 / 版本 / 包 | `verify:app-version` + GUI smoke | release evidence pack + waiver 审核 | + +## 8. 发布门禁 + +发布前不再接受“我手工点过”。必须有 Evidence Pack,且满足: + +- L0/L1 无失败。 +- 涉及 GUI 的改动有 L3 证据。 +- 涉及 Agent runtime 的改动有 L2 transcript。 +- 涉及行为质量的改动进入 L4 eval 或记录明确 waiver。 +- 发布包有 L5 证据:版本一致、artifact、安装或启动 smoke。 +- 所有 waiver 都有 owner、原因、过期时间和复测计划。 + +建议 release note 增加“测试证据”小节: + +```text +Quality evidence: +- verify:local: pass +- test:contracts: pass +- verify:gui-smoke: pass +- agent-qc scenarios: 8 pass / 1 waived / 0 fail +- harness trend: no new current observability gap +``` + +## 9. 线上反馈闭环 + +运营后,线上问题必须沉淀为测试资产: + +1. 收集用户 bug、日志异常、CI flaky、runtime transcript。 +2. Regression Curator Agent 生成最小复现场景。 +3. 人类审核是否进入长期回归。 +4. 按类型沉淀为 qcloop scenario、harness replay、Playwright MCP 流程或单元测试。 +5. Nightly 跟踪复发率、flaky rate、修复耗时和行为质量趋势。 + +原则:每次真实事故至少新增一个可复跑测试或一条证据规则。 + +## 10. 当前落地状态 + +已落地: + +- Agent QC 运营模型文档:本文件。 +- Evidence Pack schema:`docs/test/agent-qc-evidence.schema.json`。 +- 核心场景 manifest:`docs/test/agent-qc-scenarios.manifest.json`。 +- manifest 校验与报告脚本:`scripts/agent-qc-report.mjs`。 +- `test:contracts` 接入 `agent-qc:check`,避免 QC 标准自身漂移。 +- qcloop job 导出脚本:`scripts/agent-qc-export-evidence.mjs`,可把真实 job / items 转成 Evidence Pack sidecar。 +- qcloop 只读状态监控:`scripts/agent-qc-qcloop-status.mjs`,可识别 running / pending / exhausted / stale,并在 worker stdout 明确 `QCLOOP_WORKER_RESULT=BLOCKED` 时保留环境阻断语义。 +- release summary 与 completion audit:`scripts/agent-qc-release-summary.mjs`、`scripts/agent-qc-completion-audit.mjs`,发布门禁只接受官方 pass evidence。 +- 隔离 qcloop sidecar:在 `127.0.0.1:18080`、独立 DB 和显式 Codex sandbox 配置下,worker preflight、workspace ready、browser runtime、Skill Forge、release source-tree startup smoke 已能通过;full P0 v1 已启动且当前 4/8 success、1 running/stale、3 pending;这些是排障证据,不能单独替代官方 8/8 P0 Evidence Pack。 + +后续优先级: + +1. 用修复后的默认 qcloop 或隔离 qcloop 跑同一批次 8/8 P0,避免 partial sidecar 被误用为发布证据。 +2. 为 `claw-chat-ready-streaming` 和 `tool-approval-sandbox-boundary` 补稳定 GUI / runtime transcript 证据,避免只用 smoke 代替深路径。 +3. 将真实 8/8 P0 pass Evidence Pack 写入官方 `.lime/qc/agent-qc-evidence.json` 后,再更新 release note 质量证据。 diff --git a/docs/tests/agent-qc-p0-scenarios.md b/docs/tests/agent-qc-p0-scenarios.md new file mode 100644 index 000000000..1eac49b9b --- /dev/null +++ b/docs/tests/agent-qc-p0-scenarios.md @@ -0,0 +1,155 @@ +# Lime Agent QC P0 场景执行手册 + +> 本文件是 `docs/tests/agent-ops-qc.md` 的执行层补充,专门描述 P0 场景如何被 Agent / qcloop / Playwright MCP 执行和验收。 + +## 1. 使用方式 + +先生成场景报告,确认 manifest 自身有效: + +```bash +npm run agent-qc:report +npm run agent-qc:gui-flow:report +npm run agent-qc:check +``` + +如果要让 qcloop 批量执行,把 P0 场景作为 items 提交给 qcloop。推荐 item 直接使用 scenario id,便于后续导出 Evidence Pack: + +```text +command-bridge-contract +claw-chat-ready-streaming +tool-approval-sandbox-boundary +skill-forge-register-bind-enable +browser-runtime-site-adapter +workspace-ready-session-restore +harness-replay-regression +release-package-startup-smoke +``` + +也可以直接生成 qcloop job payload,避免手工维护 items: + +```bash +npm run agent-qc:qcloop-job -- \ + --risk P0 \ + --cwd "$(pwd)" \ + --output "./.lime/qc/qcloop-p0-job.json" \ + --check +``` + +生成后由外层 Agent 调 qcloop API 创建和运行批次;本脚本只生成 payload,不会主动启动 qcloop 或提交任务。 + +qcloop 完成后导出 Evidence Pack: + +```bash +npm run agent-qc:export-evidence -- \ + --job-id "" \ + --output "./.lime/qc/agent-qc-evidence.json" \ + --ref "" \ + --diff-base "origin/main" \ + --check +``` + +正式发布门禁只接受覆盖全部 P0 scenario id 的 `.lime/qc/agent-qc-evidence.json`。如果只是验证 qcloop 导出链路,应写入 `.lime/qc/agent-qc-evidence.partial.json`,不能把 partial evidence 当成发布通过证据。 + +如果 qcloop 不在运行,也可以用离线 JSON: + +```bash +node scripts/agent-qc-export-evidence.mjs \ + --job-json "./tmp/qcloop-job.json" \ + --items-json "./tmp/qcloop-items.json" \ + --output "./tmp/agent-qc-evidence.json" \ + --check +``` + +发布前生成 release note 可引用的 Agent QC 摘要: + +```bash +npm run agent-qc:release-summary -- \ + --evidence "./.lime/qc/agent-qc-evidence.json" \ + --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \ + --require-risk P0 \ + --tag "" \ + --output "./.lime/qc/release-agent-qc.md" \ + --check +``` + +正式 `release.yml` 默认也会读取 `.lime/qc/agent-qc-evidence.json`。如果团队把 evidence pack 放在其他路径,使用 workflow dispatch 时必须填写 `agent_qc_evidence_path`;否则发布会被 Agent QC 硬门禁阻断。 + +## 2. P0 场景矩阵 + +| Scenario | 最低入口 | 关键证据 | 阻断条件 | +| --- | --- | --- | --- | +| `command-bridge-contract` | `npm run test:contracts` | 命令合同日志、四侧同步摘要 | 前端/Rust/mock/catalog 任一侧缺失 | +| `claw-chat-ready-streaming` | `npm run verify:gui-smoke` + Playwright MCP | GUI trace、DevBridge、runtime transcript、console/network | workspace 未 ready、流式卡死、中断不可恢复 | +| `tool-approval-sandbox-boundary` | `npm run smoke:agent-runtime-tool-surface` + `npm run smoke:agent-runtime-approval-sandbox` | tool timeline、approval decision、sandbox policy | 工具绕过授权、超时无恢复、危险工具暴露 | +| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft、verify/register、runtime binding、SkillTool gate | 把 registered 误判为 executable、metadata 自动启用 skill | +| `browser-runtime-site-adapter` | `npm run smoke:browser-runtime` + `npm run smoke:site-adapters` | browser session、adapter catalog、console/network、cleanup | session 泄漏、adapter 漂移、cleanup 缺失 | +| `workspace-ready-session-restore` | `npm run smoke:workspace-ready` + `npm run verify:gui-smoke` | workspace smoke、GUI smoke、DevBridge、design canvas 工程保存 / 打开结果 | ready 假阳性、会话恢复脏状态、设计画布导出无保存状态 | +| `harness-replay-regression` | `npm run harness:eval` + `npm run harness:eval:trend` | summary、trend、invalid case、observability outcome | fixture invalid、grader 合同漂移、trend 断裂 | +| `release-package-startup-smoke` | `npm run verify:app-version` + release / GUI startup smoke | 版本一致性、启动 smoke、artifact 或 source-tree 启动范围、waiver、GUI smoke 自然收口 | 手工口头放行、版本不一致、安装启动失败、把 source-tree smoke 伪装成 installer 验证、GUI smoke 卡在设计画布导出 | + +### 2.1 证据深度分层 + +P0 场景允许先用确定性 smoke 快速证明主路径可执行,但 release gate 不能只看 smoke 摘要。每个 worker stdout 必须明确声明本次覆盖的是哪一层: + +| 层级 | 允许证明什么 | 不能替代什么 | +| --- | --- | --- | +| `deterministic-smoke` | 命令能运行、DevBridge 可达、workspace ready、基础 runtime surface 可读 | live long-turn transcript、真实中断恢复、approval/sandbox 完整轨迹 | +| `gui-trace` | 用户路径截图 / trace、console/network 摘要、关键 UI 断言 | 后端 turn 持久化和 tool timeline | +| `runtime-transcript` | submit / stream / interrupt / resume / tool request / decision / result 顺序 | GUI 可见性、桌面壳启动、发布包启动 | +| `release-artifact` | 安装包或 source-tree 启动范围、版本一致性、waiver | 运行期 Agent 行为质量 | + +如果当前只跑到 `deterministic-smoke`,worker 可以输出 `QCLOOP_WORKER_RESULT=PASS` 表示命令通过;但 verifier 仍应在缺少 deep evidence 时判该 P0 场景不满足 release pass。正确做法是补证据或拆分场景,不是降低 verifier。 + +`npm run agent-qc:check` 会机械校验 P0 场景必须声明合法 `evidenceLayers`。当前允许的层级是 `deterministic-smoke`、`gui-trace`、`runtime-transcript`、`release-artifact`;缺失或未知层级都应阻断。 + +## 3. GUI 场景证据要求 + +GUI 场景不能只提交截图,至少要包含: + +- DevBridge 是否健康。 +- GUI session owner / isolation statement:说明本次是否独占 Lime GUI / DevBridge 会话,或列出仍在 running/stale 的 GUI qcloop sidecar。 +- 关键用户路径的页面状态。 +- console error 摘要。 +- network error 摘要。 +- 如果使用 Playwright MCP,记录操作步骤和最终断言。 +- 如果命中 mock fallback,必须说明这是预期 mock 还是错误退化。 + +同一台机器上的 GUI P0 不应并发运行多个 full P0 qcloop 批次。多个 worker 复用同一个 `127.0.0.1:1420` / `127.0.0.1:3030` 会话时,页面导航、按钮点击和状态断言可能互相抢占;这类失败必须按 `parallel GUI smoke interference` 记录,而不是直接归咎产品。 + +启动新的 GUI P0 批次前,先执行: + +```bash +npm run agent-qc:gui-owner-check -- --check +``` + +该检查发现 active GUI qcloop owner 时必须阻断新批次。 + +## 4. Runtime 场景证据要求 + +Runtime 场景不能只提交最终回答,至少要包含: + +- turn / request id。 +- tool call timeline。 +- approval decision。 +- sandbox policy。 +- tool result 或 error。 +- 中断、超时、失败后的恢复动作。 + +## 5. Verifier 判定规则 + +- 缺证据时输出 `needs-human-review`,不要输出 `pass`。 +- 环境或权限阻断时输出 `blocked`,不要输出 `fail`。 +- `failed` / `exhausted` qcloop item 对应 Evidence Pack `fail`。 +- `pending` / `running` qcloop item 对应 Evidence Pack `blocked`。 +- qcloop job 的 `verifier_prompt_template` 必须显式包含 `{{stdout}}` 或 `{{output}}`,否则 verifier 看不到 worker 输出;`agent-qc:qcloop-job` 会自动补充 stdout / attempt 状态占位符。 +- 只有所有必需证据齐全且 verifier 条件满足时才输出 `pass`。 + +## 6. 失败沉淀 + +P0 失败修复后必须至少做一项沉淀: + +- 新增或提升 harness replay case。 +- 新增 qcloop scenario item。 +- 新增 Playwright MCP 操作手册或可复用 trace。 +- 新增 Vitest / Rust 定向回归。 +- 更新 Evidence Pack verifier 规则,防止同类缺证据再次误通过。 diff --git a/docs/tests/lime-agent-autonomous-test-execution-matrix.md b/docs/tests/lime-agent-autonomous-test-execution-matrix.md new file mode 100644 index 000000000..bbdea27c1 --- /dev/null +++ b/docs/tests/lime-agent-autonomous-test-execution-matrix.md @@ -0,0 +1,138 @@ +# Lime Agent 自主测试执行矩阵 + +> 本文件是 `docs/tests/lime-agent-autonomous-testing-plan.md` 的执行层补充:把“应该如何测试 Lime”落成 Agent 可以按步骤执行的矩阵。它不替代通用标准,也不替代 qcloop Evidence Pack;它只定义 Lime 作为样本产品时,测试 Agent 如何选择场景、采集证据、阻断发布和回写回归。 + +## 1. 适用范围 + +Lime 是桌面 GUI + Agent Runtime + 工具/技能/浏览器能力的组合产品,因此不能用单一测试手段证明可运营。一次 Lime Agent 自主测试至少覆盖四类事实: + +| 事实层 | 需要证明什么 | 不能接受的假阳性 | +| --- | --- | --- | +| 壳层 | App / DevBridge / workspace ready 可用 | 页面打开但 `127.0.0.1:3030/health` 不通 | +| 运行时层 | thread / turn / stream / persistence 状态正确 | 模型有回复但 turn 状态、恢复状态或持久化错误 | +| 能力层 | tool / approval / sandbox / skill / browser 真实执行 | mock 返回、注册成功或 UI 显示可用被误认为能力可执行 | +| 运营层 | qcloop / Evidence Pack / release gate 可审计 | 一次宿主手工命令通过被当成发布证据 | + +## 2. 执行前 Owner Gate + +任何会复用 GUI、DevBridge、Cargo target 或 qcloop worker 的重型任务,先执行 owner gate。该 gate 只读取状态,不终止进程。 + +```bash +npm run agent-qc:qcloop-status -- \ + --base-url "http://127.0.0.1:18080" \ + --job-id "" \ + --format json \ + --output "./.lime/qc/qcloop-status.current.json" + +npm run agent-qc:gui-owner-check -- \ + --format json \ + --output "./.lime/qc/gui-owner-current.json" \ + --watch-history-output "./.lime/qc/stale-owner-watch-history.jsonl" + +npm run agent-qc:process-owner-check -- \ + --format json \ + --output "./.lime/qc/gui-process-owner-current.json" \ + --markdown-output "./.lime/qc/gui-process-owner-current.md" + +npm run agent-qc:qcloop-db-lease -- \ + --db "./.lime/qc/qcloop-isolated-worker-preflight.db" \ + --job-id "" \ + --format json \ + --output "./.lime/qc/qcloop-db-lease-current.json" \ + --markdown-output "./.lime/qc/qcloop-db-lease-current.md" +``` + +通过条件: + +- `gui-owner-current.json` 没有 active / stale GUI qcloop owner。 +- `gui-process-owner-current.json` 没有 raw GUI smoke、qcloop worker、Cargo / Rust 构建 owner。 +- `qcloop-db-lease-current.json` 没有 running item、续约 lock 或 no-output active attempt。 +- DevBridge preflight 通过:`npm run agent-qc:qcloop-preflight -- --require-devbridge --check`。 + +阻断条件: + +- 存在 stale qcloop worker 且仍在续约 DB lease。 +- 存在长时间 `smoke:*` 或 `verify:gui-smoke` 进程。 +- 存在 Cargo / Rust 编译或 Tauri dev owner,可能抢占 target、DevBridge 或窗口状态。 +- 官方 `.lime/qc/agent-qc-evidence.json` 不是同一批次 8/8 P0 pass。 + +## 3. P0 执行矩阵 + +| P0 场景 | 执行命令 | 必须采集的证据 | Release 判定 | +| --- | --- | --- | --- | +| `command-bridge-contract` | `npm run test:contracts` | 前端调用、Rust 注册、governance catalog、mock counts | 命令退出码 + contract 摘要均通过 | +| `workspace-ready-session-restore` | `npm run smoke:workspace-ready` + `npm run verify:gui-smoke -- --reuse-running` | workspace ready、session restore、DevBridge health、GUI trace | GUI ready 和恢复状态都可观察 | +| `claw-chat-ready-streaming` | `npm run verify:gui-smoke -- --reuse-running` 或专项 Claw smoke | first delta、interrupt command、aborted turn、follow turn completed、GUI 可见恢复结果 | 不能只有“有回复”,必须证明中断后状态正确 | +| `tool-approval-sandbox-boundary` | `npm run smoke:agent-runtime-tool-surface` + `npm run smoke:agent-runtime-approval-sandbox` | approval policy、sandbox policy、denied/resolved flow、tool timeline | approval / sandbox 进入 turn config 且无绕过 | +| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft / verify / register / binding readiness / enable allowlist | 注册不等于可执行,显式 enable 才可进入能力面 | +| `browser-runtime-site-adapter` | `npm run smoke:browser-runtime` + `npm run smoke:site-adapters` | attach/status、adapter list、cleanup、console/network 摘要 | cleanup warning 必须归类;session 泄漏不可放行 | +| `harness-replay-regression` | `npm run harness:eval` + `npm run harness:eval:trend` | eval summary、observability gap、trend 样本 | trend 样本不足只能作为 seed,不能替代长期趋势 | +| `release-package-startup-smoke` | `npm run verify:app-version` + `npm run verify:gui-smoke -- --reuse-running` | version consistency、artifact scope、startup smoke | 必须标明 source-tree 还是 installer artifact | + +执行原则:P0 全绿前不扩 P1/P2;P0 partial sidecar 只能排障,不能覆盖官方 Evidence Pack。 + +## 4. Agent 分工 + +| Agent | 输入 | 动作 | 输出 | +| --- | --- | --- | --- | +| Planner | diff、manifest、当前 owner sidecar | 选择最小 P0/P1 场景,生成 qcloop payload | `qcloop-*.json`、计划摘要 | +| Executor | 单个 scenario item | 只执行指定命令,不修无关问题 | stdout/stderr、trace、summary JSON | +| Verifier | manifest、stdout、exit code、artifact refs | 独立判断 evidence 是否满足要求 | `pass/fail/blocked` verdict | +| Curator | 失败 evidence | 把失败沉淀为单测、smoke、harness replay 或 GUI flow | 新回归资产和关闭条件 | +| Release Gate | Evidence Pack、tag、manifest | 验证 P0 覆盖和 pass 状态 | release summary / 阻断原因 | + +关键约束:Executor 不能自证通过;Verifier 必须能从证据中复核每个 `evidenceRequired` 与 `failureModes`。 + +## 5. 组合测试策略 + +| 组合 | 何时使用 | Lime 示例 | +| --- | --- | --- | +| 白盒 + 黑盒 | 协议正确但 GUI 可能不可用 | `test:contracts` 后补 `verify:gui-smoke` | +| GUI + Runtime transcript | UI 显示正确但后端状态可能错 | Claw streaming / interrupt / resume | +| Smoke + Deep evidence | 快速入口通过但 P0 需要更强证据 | `verify:gui-smoke` pass 后仍检查 transcript、console、network | +| Direct host + qcloop | 区分产品问题和 worker 环境问题 | 宿主 smoke pass 但 qcloop worker loopback blocked | +| Replay + Trend | 修复一次失败后防止回归 | `harness:eval` + `harness:eval:trend` | +| Failure injection + Recovery | 权限拒绝、超时、断桥后仍要安全 | approval denied、tool timeout、DevBridge unavailable | + +## 6. 失败分类与回写 + +| 失败分类 | 判定标准 | 回写资产 | +| --- | --- | --- | +| Product blocker | 宿主直接执行也失败,且影响 P0 行为 | 修产品 + Rust/Vitest/GUI 回归 | +| Environment blocker | qcloop worker blocked,但宿主 direct host pass | preflight、owner gate、qcloop runtime 修复 | +| Evidence blocker | 命令通过但缺 transcript / trace / failure mode 解释 | 强化 worker prompt、exporter、schema 或 verifier | +| Concurrency blocker | 多个 GUI owner 抢同一窗口/DevBridge | single-owner gate、stale owner runbook | +| Release blocker | Evidence Pack 非 pass 或 P0 覆盖不全 | release summary hard gate | + +每个失败的关闭条件固定为:下次能由机器先发现、能定位到证据、能阻断发布。 + +## 7. 当前 Lime 样本执行顺序 + +当存在其他本地进程时,本轮只做安全动作: + +1. 刷新 qcloop status、GUI owner、raw process owner、DB lease sidecar。 +2. 若 owner 仍 busy,只更新文档和 sidecar,不启动新的 full P0 或完整 `verify:local`。 +3. 若 owner 自然释放,先跑完整 `npm run verify:local`,关闭 `local-verify-gate`。 +4. 在单一 GUI owner 前提下,使用修正后的 qcloop 环境跑 8/8 P0。 +5. 只有 8/8 P0 pass 后,才覆盖 `.lime/qc/agent-qc-evidence.json`。 +6. 再执行 `npm run agent-qc:release-summary -- --check` 与 `npm run agent-qc:audit -- --format json`。 + +## 8. 不允许的捷径 + +- 不把 isolated sidecar pass 当成官方 release evidence。 +- 不把宿主 direct host pass 当成 qcloop verifier pass。 +- 不把 `verify:gui-smoke` pass 当成完整 `verify:local` pass。 +- 不在 stale GUI owner 仍存在时启动新的 full GUI P0。 +- 不通过降低 verifier、删 evidenceRequired 或改 schema 来制造绿色。 +- 不在 owner 未确认时 kill / pause / interrupt qcloop worker。 + +## 9. 相关事实源 + +- 通用标准:`docs/tests/agent-ops-qc.md` +- P0 场景:`docs/tests/agent-qc-p0-scenarios.md` +- Lime 分阶段计划:`docs/tests/lime-agent-autonomous-testing-plan.md` +- 当前阻断:`docs/tests/lime-agent-qc-current-blockers.md` +- qcloop 运维:`docs/tests/lime-agent-qc-qcloop-operations.md` +- Evidence 契约:`docs/tests/lime-agent-qc-evidence-contract.md` +- 机器 manifest:`docs/test/agent-qc-scenarios.manifest.json` +- Evidence schema:`docs/test/agent-qc-evidence.schema.json` diff --git a/docs/tests/lime-agent-autonomous-testing-plan.md b/docs/tests/lime-agent-autonomous-testing-plan.md new file mode 100644 index 000000000..221d8b4bc --- /dev/null +++ b/docs/tests/lime-agent-autonomous-testing-plan.md @@ -0,0 +1,328 @@ +# Lime Agent 自主化测试体系示例计划 + +> 本文件把通用 Agent QC 标准落到 Lime 这个实际产品上:目标不是让人多点几遍 GUI,而是让测试 Agent 能读改动、选场景、执行验证、收集证据、独立复核,并把失败回写为长期回归资产。 + +## 1. 问题判断 + +Lime 即将运营,测试策略必须从“开发者人工确认”升级为“Agent 自己证明自己可运营”。人工测试仍有价值,但只能用于设计场景、审核 waiver 和校准语义评测,不应作为发布通过的主要证据。 + +Agent 类产品的核心风险不是单个函数失败,而是多个不确定系统叠加后出现假阳性: + +| 风险面 | 典型假阳性 | 必须自动采集的证据 | +| --- | --- | --- | +| GUI 桌面壳 | 页面能打开,但 DevBridge 未就绪 | health、workspace ready、截图或 trace、console/network 摘要 | +| Agent Runtime | 模型有回复,但 turn / thread 状态错 | session、thread、turn transcript、stream event、持久化结果 | +| Tool Surface | 工具显示可用,但未经过授权或未写回结果 | tool request、approval decision、sandbox policy、tool result/error | +| Streaming / Interrupt | UI 显示停止,后端继续写 completed | first token、interrupt command、post-stop transcript、resume turn | +| Skill / Service Skill | 已注册被误认为已可执行 | draft、verify、register、binding readiness、session enable、allowlist | +| Browser / Site Adapter | adapter catalog 可读,但 session 泄漏 | attach/status、adapter result、cleanup、network/console | +| Knowledge / Files | 能读取文件,但引用和来源不可信 | source id、chunk、引用位置、空结果策略 | +| Release Ops | 源码可跑,但发布包不可启动 | version check、artifact scope、startup smoke、release evidence | + +结论:Lime 的测试体系要围绕“证据”建,而不是围绕“命令跑过”建。每个场景都必须回答:谁执行、执行了什么、看到了什么、为什么可以判定通过、失败后下次怎么提前发现。 + +## 2. 目标状态 + +目标流水线: + +```text +Diff / Release Candidate / Nightly + -> Test Planner Agent 识别风险与场景 + -> qcloop 拆分 P0/P1 item + -> Executor Agent 执行 npm / Rust / GUI / harness / Playwright MCP + -> Evidence Collector 写入 Evidence Pack + -> Verifier Agent 按 manifest 独立判定 + -> Regression Curator 把失败沉淀为长期回归 + -> Release Gate 只读证据并阻断发布 +``` + +角色分工: + +| Agent | 职责 | 产物 | +| --- | --- | --- | +| Test Planner Agent | 读取 diff、manifest、风险矩阵,选择最小但足够的场景 | qcloop payload、测试计划摘要 | +| Executor Agent | 执行单个场景,不顺手修复无关问题 | 命令日志、GUI trace、runtime transcript | +| Verifier Agent | 独立审查 evidenceRequired / failureModes | JSON verdict | +| Regression Curator Agent | 把失败归类并生成长期回归资产 | 新增 smoke / replay / unit test / qcloop scenario | +| Release Gate Agent | 汇总 Evidence Pack 并生成 release note 质量小节 | `release-agent-qc.md`、CI gate 结果 | + +人类只处理三件事:新增关键业务场景、审核高风险 waiver、校准 LLM judge 的 rubric。 + +## 3. Lime 风险地图 + +Lime 当前最需要覆盖的不是“所有代码行”,而是用户运营路径和 Agent 自主执行路径: + +```text +桌面启动 + -> DevBridge health + -> workspace ready / session restore + -> Claw 输入与首屏 + -> Runtime submit_turn + -> streaming / interrupt / resume + -> tool request / approval / sandbox / result + -> Skill / Browser / Knowledge 等能力面 + -> evidence pack / harness replay + -> release startup smoke +``` + +按风险优先级分层: + +| 优先级 | 必测路径 | 当前主入口 | +| --- | --- | --- | +| P0 | command bridge contract | `npm run test:contracts` | +| P0 | GUI shell / workspace / DevBridge | `npm run verify:gui-smoke` | +| P0 | runtime tool / approval / sandbox | `npm run smoke:agent-runtime-tool-surface` + 专项 transcript evidence | +| P0 | Claw streaming / interrupt / resume | GUI deep flow + runtime transcript | +| P0 | Skill Forge register / bind / enable | `npm run smoke:agent-service-skill-entry` + runtime binding evidence | +| P0 | Browser runtime / site adapter / cleanup | `npm run smoke:browser-runtime` + `npm run smoke:site-adapters` | +| P0 | release startup and evidence gate | `npm run verify:app-version` + release summary gate | +| P1 | Knowledge product path | `npm run smoke:knowledge-gui` + `npm run knowledge:product-e2e` | +| P1 | Team / long task / harness trend | `npm run harness:eval` + `npm run harness:eval:trend` | + +P0 的定义:失败会导致用户无法启动、无法对话、无法安全调用工具、无法恢复状态,或发布包不能可信上线。 + +## 4. 测试手段组合 + +单一测试方式无法覆盖 Agent 产品。Lime 默认使用组合策略: + +| 手段 | 证明范围 | Lime 用法 | +| --- | --- | --- | +| 白盒测试 | 内部状态、协议、持久化是否正确 | Rust / Vitest / command contract / runtime transcript | +| 黑盒测试 | 用户可见路径是否可用 | GUI smoke、Playwright MCP、发布包启动 | +| 灰盒测试 | UI 操作与后端事件是否对应 | GUI 截图 + DevBridge health + runtime session read | +| 快照测试 | UI 结构、工作台布局、报告形状不漂移 | React snapshot / JSON report snapshot / evidence schema | +| 冒烟测试 | 快速确认主路径没断 | `verify:gui-smoke`、`smoke:*` | +| Replay 测试 | 线上失败或复杂任务可复现 | harness replay、runtime transcript replay | +| LLM Judge | 开放式答案质量与语义回归 | rubric + golden samples + human calibration | +| Metamorphic 测试 | 同义输入、顺序变化后核心约束仍成立 | 改写 prompt 后仍需调用同类 skill / tool | +| 故障注入 | 断网、工具拒绝、超时、权限拒绝时能恢复 | approval deny、tool timeout、DevBridge unavailable | +| Differential 测试 | mock / real backend、不同模型或不同 runtime 输出对比 | mock smoke 不能替代 release real path | +| 长程稳定性 | 资源泄漏、session 泄漏、趋势退化 | nightly qcloop、harness trend、browser cleanup | + +组合原则: + +- GUI P0 不能只有截图,必须带 DevBridge / console / network / runtime 证据。 +- Runtime P0 不能只有命令退出码,必须带 transcript / tool timeline / approval / sandbox 证据。 +- Release P0 不能只有源码 smoke,必须标明是 source-tree startup 还是 installer artifact。 +- 语义场景不能只靠 LLM judge,必须有确定性 guardrail:结构、引用、禁止行为、工具调用边界。 + +## 5. 分阶段建设计划 + +### Phase 0:统一事实源 + +目标:让测试标准可以被机器读取。 + +已落地入口: + +```bash +npm run agent-qc:report +npm run agent-qc:gui-flow:report +npm run agent-qc:check +``` + +完成标准: + +- `docs/test/agent-qc-scenarios.manifest.json` 固定场景、命令、证据和失败模式。 +- `docs/test/agent-qc-gui-flows.manifest.json` 固定 GUI / Playwright MCP flow。 +- `docs/test/agent-qc-evidence.schema.json` 固定 Evidence Pack 形状。 +- `npm run test:contracts` 包含 `agent-qc:check`,防止测试标准自身漂移。 + +### Phase 1:让 qcloop 接管场景执行 + +目标:每个 P0 场景都成为 qcloop item,由独立 verifier 判定,而不是执行者自评。 + +生成 P0 payload: + +```bash +npm run agent-qc:qcloop-job -- \ + --risk P0 \ + --cwd "$(pwd)" \ + --base-url "http://127.0.0.1:8080" \ + --output "./.lime/qc/qcloop-p0-job.json" \ + --check +``` + +导出 Evidence Pack: + +```bash +npm run agent-qc:export-evidence -- \ + --base-url "http://127.0.0.1:8080" \ + --job-id "" \ + --output "./.lime/qc/agent-qc-evidence.json" \ + --ref "" \ + --check +``` + +执行约束: + +- 本地 API 使用 `http://127.0.0.1:8080`,避免 `localhost` 代理或 IPv6 干扰。 +- qcloop 进程 cwd 可能不是 Lime,payload 必须显式传 `--cwd`。 +- partial / sidecar evidence 只能用于排障,不能冒充正式 release pass。 +- 运行中批次只做只读监控;使用 `npm run agent-qc:qcloop-status -- --job-id ""` 判断 running / pending / exhausted / stale,不主动中断 worker。 +- qcloop worker 执行前先跑 `npm run agent-qc:qcloop-preflight -- --check`;GUI / DevBridge 场景必须追加 `--require-devbridge`,否则不能把本地 loopback 权限缺失误判为产品通过或产品失败。 + +### Phase 2:先打穿 P0,再扩 P1/P2 + +目标:P0 全绿前,不把精力分散到大量低风险场景。 + +P0 最小闭环: + +1. `command-bridge-contract`:防止前端、Rust、mock、治理目录漂移。 +2. `workspace-ready-session-restore`:证明首屏和历史会话可恢复。 +3. `claw-chat-ready-streaming`:证明聊天、流式、中断、恢复不是假通过。 +4. `tool-approval-sandbox-boundary`:证明工具调用不绕过授权和 sandbox。 +5. `skill-forge-register-bind-enable`:证明注册不等于自动执行,显式 enable 才进入 allowlist。 +6. `browser-runtime-site-adapter`:证明 browser session、adapter 和 cleanup 可用。 +7. `harness-eval-regression`:证明核心行为样本没有退化。 +8. `release-package-startup-smoke`:证明版本、启动和 release gate 可复核。 + +只有 P0 通过后,再把 Knowledge、Team、长任务、任务调度、跨模型比较扩到 P1/P2。 + +### Phase 3:补齐 Runtime 级深证据 + +目标:让 runtime 测试从“命令通过”升级到“行为可信”。 + +每个 runtime 场景必须记录: + +- `sessionId`、`threadId`、`turnId`。 +- `submit_turn`、`stream event`、`interrupt`、`thread_read` 的顺序。 +- tool request / approval decision / sandbox policy / result or error。 +- 失败后的恢复动作:retry、fallback、用户可见错误或安全停止。 +- mock 与 real path 的边界说明。 + +建议新增或强化专项 evidence: + +| 缺口 | 建议资产 | +| --- | --- | +| approval / sandbox 证据不足 | `npm run smoke:agent-runtime-approval-sandbox` 生成确定性前端/投影 evidence;仍需补真实 runtime harness replay 和 tool timeline JSON | +| streaming stop 后状态不一致 | Rust persistence test + GUI deep flow regression | +| SkillTool allowlist 只测注册不测启用 | Rust gate test + frontend metadata builder test | +| tool timeout 后恢复不清晰 | failure injection smoke + transcript assertion | + +### Phase 4:补齐 GUI / TUI / WebUI 产品证据 + +目标:任何用户可见主路径都能由 Agent 自动操作、截图、断言和归档。 + +GUI / WebUI flow 最小证据: + +- 页面或窗口可达。 +- 主交互前后截图或 trace。 +- 可访问性节点或稳定选择器断言。 +- console error / warning 摘要。 +- network failure 摘要。 +- 与后端状态对应的 runtime / DevBridge 证据。 + +TUI / CLI flow 最小证据: + +- 命令输入、退出码、stdout/stderr 摘要。 +- PTY 场景要记录关键屏幕帧或结构化 transcript。 +- 长任务要记录取消、超时、恢复和清理。 + +Lime 是桌面 GUI 产品,所以 GUI 主路径仍以 `verify:gui-smoke` 和 Playwright MCP 为主;如果未来提供 TUI 或 WebUI,也应复用同一套 Evidence Pack schema,而不是另起一套口头流程。 + +### Phase 5:把失败变成长期资产 + +目标:每个真实 P0 失败都要留下下次可自动发现的资产。 + +失败归档规则: + +| 失败类型 | 回写资产 | +| --- | --- | +| 合同漂移 | `test:contracts` / governance catalog test | +| GUI ready 假阳性 | `verify:gui-smoke` / GUI flow manifest / Playwright MCP trace | +| runtime 状态错 | Rust 定向测试 / transcript replay | +| approval bypass | approval policy unit test / harness replay | +| sandbox 漏洞 | sandbox boundary test / denylist snapshot | +| streaming 卡死 | interrupt regression / persistence assertion | +| 语义退化 | harness eval sample / LLM judge rubric | +| 发布失败 | release workflow gate / startup smoke | + +复盘问题固定为:这次失败下次是否能由机器先发现?如果不能,修复没有完成。 + +### Phase 6:发布与运营门禁 + +目标:发布只接受 evidence,不接受“我刚手动测过”。 + +发布前 gate: + +```bash +npm run agent-qc:release-summary -- \ + --evidence "./.lime/qc/agent-qc-evidence.json" \ + --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \ + --require-risk P0 \ + --tag "" \ + --output "./.lime/qc/release-agent-qc.md" \ + --check +``` + +完成审计: + +```bash +npm run agent-qc:audit -- --format json +``` + +发布可继续的条件: + +- 官方 `.lime/qc/agent-qc-evidence.json` 存在。 +- `verdict.status` 是 `pass`。 +- 覆盖 manifest 中全部 P0 scenario id。 +- release summary `--check` 通过。 +- waiver 有 owner、原因、过期时间、复测计划。 + +## 6. 当前 Lime 样本状态 + +截至 2026-05-11 01:13,本地测试体系已经具备标准、manifest、schema、qcloop exporter、release summary 和 audit,但不能宣称 P0 完成。 + +当前关键事实: + +- `npm run agent-qc:check` 已能校验 scenario manifest 与 GUI flow manifest。 +- `.github/workflows/release.yml` 已要求 Evidence Pack 和 P0 覆盖。 +- `.lime/qc/agent-qc-evidence.json` 当前仍是 `fail`,不能发布。 +- qcloop P0 v3 覆盖 8/8 P0,但只有部分通过。 +- qcloop v4 已 completed / fail,6/6 exhausted;它证明旧 verifier / worker 环境不足以给出 P0 绿色证据。 +- qcloop v5 已 completed / fail;6 个重跑项 0 pass,`claw-chat-ready-streaming`、`skill-forge-register-bind-enable`、`browser-runtime-site-adapter`、`workspace-ready-session-restore` 与 `release-package-startup-smoke` 均未产生可发布证据,其中多项 worker 明确输出 `QCLOOP_WORKER_RESULT=BLOCKED`,说明 qcloop worker 内 DevBridge health 被环境 / 权限阻断。 +- `tool-approval-sandbox-boundary` 已补 `smoke:agent-runtime-approval-sandbox` 确定性 smoke,但当前 qcloop item 已 failed;worker stdout 自报 PASS 与 qcloop exit / verifier 结论冲突,不能替代官方 qcloop live transcript evidence。 +- v5 completed 后宿主 shell 直连 `127.0.0.1:3030/health` 也失败;本轮已通过 headless Tauri 恢复宿主 DevBridge,并写入 `.lime/qc/qcloop-devbridge-health-restored.json`。 +- 恢复宿主 DevBridge 后创建的只读 qcloop worker preflight job `1778403715309891000` 仍为 `blocked`:worker cwd/tmp 通过,但 `devbridge-health` 对 `127.0.0.1:3030` 仍 `fetch failed`。当前阻断已收敛为 qcloop 内层 worker loopback / sandbox 权限,不能直接重跑完整 P0。 +- 通过隔离 qcloop server `127.0.0.1:18080`、独立 DB 和显式 Codex sandbox 配置,worker preflight job `1778404260108641000` 已通过,说明 qcloop worker 权限问题可以被环境修复。 +- 在隔离 qcloop 下,`workspace-ready-session-restore`、`browser-runtime-site-adapter`、`skill-forge-register-bind-enable`、`release-package-startup-smoke` 四个 P0 sidecar 已通过;其中 release startup v2 明确只覆盖 `source-tree-startup-smoke`,不等同于 installer artifact 验证。 +- 已启动 isolated full P0 v1 `1778405842243079000`:当前 4/8 success,`browser-runtime-site-adapter` running 且无 stdout/stderr,`--stale-minutes 1` 只读检查已标记 stale,后续 3 个 P0 pending;这仍是 sidecar running/stale 状态,不能覆盖官方 `.lime/qc/agent-qc-evidence.json`。 +- 宿主 shell 直接执行 `agent-qc:qcloop-preflight -- --require-devbridge`、`smoke:browser-runtime`、`smoke:site-adapters` 已通过;browser runtime cleanup 有非阻断 warning,因此当前 full P0 卡点更像 qcloop worker / provider 无输出,而不是宿主 DevBridge 或产品命令不可用。 +- 宿主 shell 直接执行 `harness:eval` 与 `harness:eval:trend` 已通过;trend 当前只有 1 个样本,只能作为 seed,仍需 qcloop verifier 和后续 nightly 样本确认长期趋势。 +- 宿主 shell 直接执行 `smoke:workspace-ready`、`verify:app-version`、`verify:gui-smoke -- --reuse-running` 已通过;这证明 source-tree workspace / release smoke 可跑,但不替代 qcloop verifier 或 installer artifact 验证。 +- 2026-05-10 19:50 只读刷新显示,仍有多个 qcloop 隔离批次未终态:`isolated-p0-full-v1` 4/8 success 且 `browser-runtime-site-adapter` stale;`nomcp-p0-v2` 已出现 `tool-approval-sandbox-boundary` failed;`mcpdisabled-p0-v1` 在 `claw-chat-ready-streaming` stale;`fast-p0-v1` 已通过 command bridge 后进入 `claw-chat-ready-streaming`;`fastmini-readonly-p0-v1` 已 terminal failed,8 个 item 全部 exhausted,sidecar evidence 为 6 fail / 2 blocked。主要原因是 verifier 正确拒绝缺少 deep evidence 的 worker 摘要,同时暴露了 `workspace-ready-session-restore` 中 `smoke:design-canvas` 保存成功状态断言失败,以及 `release-package-startup-smoke` GUI smoke 未自然收口。 +- 这批失败把 P0 拆得更清楚:`verify:gui-smoke`、`smoke:agent-runtime-*`、`smoke:browser-runtime` 这类确定性 smoke 可以证明“主路径可执行”,但不能自动证明 live long-turn interrupt、approval/sandbox transcript、console/network trace、SkillTool gate 和 cleanup 证据都齐全。自主测试体系必须把二者分成不同证据层,而不是降低 P0 verifier。 +- `smoke:design-canvas` 的细化证据已落到 `.lime/qc/design-canvas-failure-fastmini-workspace-extract.json`:页面已进入 `CANVAS:DESIGN 专属 GUI SMOKE`,DevBridge 为 `ok`,但 `project-roundtrip-save-open` 阶段没有出现 `已保存图层设计工程`,说明“页面可见”还不能证明“工程保存链路可用”。 +- 当前仍有多个 GUI / DevBridge qcloop sidecar running 或 stale,后续 P0 必须先做到 GUI single-owner。否则多个 worker 复用同一个 `127.0.0.1:1420` / `127.0.0.1:3030` 会话,会让页面导航、按钮点击和状态断言互相干扰。manifest 已把 `GUI session owner / isolation statement` 与 `parallel GUI smoke interference` 纳入 GUI P0 证据。 +- 2026-05-11 01:02 只读刷新显示,`isolated-p0-full-v1` 仍为 `stale`:4 success / 1 running / 3 pending / 1 stale,`browser-runtime-site-adapter` stale 约 `26202s`,DB lease 仍被 `qcloop-worker-1` 延长到 `2026-05-11T01:17:06+08:00`,active attempt stdout/stderr 仍为空;这证明当前卡点仍是 qcloop worker no-output owner,而不是可发布的 P0 完成。 +- 最新完整 `verify:local` 已刷新为 `status=fail`,失败阶段是 `verify:gui-smoke / smoke:claw-chat-ready-streaming`。后续 direct Claw post-refresh fallback 已 pass,但另一轮 session-restore direct smoke 在 DevBridge 中途不可达时 fail;两者都只能作为 Claw deep flow 的 sidecar 证据,不能替代完整 `verify:local` pass。 +- 2026-05-11 01:13 观察到外部 `verify:gui-smoke -- --reuse-running --timeout-ms 240000` 自然通过,且 Claw streaming / interrupt / resume 在 latest summary 中为 `verdict=pass`、`recoveryVisibleSource=live-stream`。这证明 GUI smoke 主链已经恢复到可通过状态,但 `.lime/qc/gui-process-owner-current.json` 仍显示 raw process owner `busy`,且完整 `verify:local` 尚未重跑通过。 +- 2026-05-11 01:32 已把 raw process owner 检查脚本化为 `npm run agent-qc:process-owner-check`,把 qcloop SQLite lease 取证脚本化为 `npm run agent-qc:qcloop-db-lease`,并新增 `docs/tests/lime-agent-autonomous-test-execution-matrix.md` 作为 Lime 样本的执行矩阵。最新 sidecar 仍显示 `qcloop stale` 与 `raw process owner busy`,因此本轮仍不能启动完整 `verify:local` 或新的 full GUI P0。 + +这正是自主测试体系的价值:它没有把“本机某次手动看起来可用”误判为发布通过,而是把缺证据和真实产品 blocker 留在门禁中。 + +## 7. 近期执行顺序 + +在不打断本机其他进程的前提下,下一步按以下顺序推进: + +1. 保留 qcloop v5 completed / fail sidecar、宿主 DevBridge 恢复 sidecar、默认 worker preflight blocked sidecar、isolated worker pass sidecar、4 个 isolated P0 pass sidecar,以及 release startup v1 blocked 历史证据,不覆盖官方 evidence。 +2. 继续只读观察所有 running / stale qcloop 批次;若当前 worker 自然结束,再导出 sidecar 并根据终态决定是否复用待用 payload。没有 owner 明确授权前,不 kill、不 pause、不重启这些进程。 +3. 把 P0 分成两层验收:确定性 smoke 层用于证明主路径可执行,deep evidence 层用于证明 live transcript / trace / console-network / cleanup / approval-sandbox 证据齐全;官方 release gate 仍必须看 deep evidence pass。 +4. 先等当前 running/stale GUI qcloop 批次自然结束,或在 owner 明确授权下收口;下一轮只允许一个 GUI owner 跑 full P0,worker 必须输出 GUI session owner / isolation statement。 +5. 在单一 GUI owner 前提下,定位 `workspace-ready-session-restore` 中 `smoke:design-canvas` 的保存成功状态断言失败,因为它已经是 qcloop worker 内可复现的 GUI P0 signal,不应被旧 isolated pass 覆盖;下一步重点是确认导出按钮点击后是否有保存中 / 保存成功状态,还是导出流程在首个可观察状态前卡住。 +6. 把 `tool-approval-sandbox-boundary` 拆成更窄的 runtime transcript 或 harness replay 场景,让 approval / sandbox 证据以结构化 JSON 进入 verifier,而不是只引用组件级 smoke。 +7. 在 `.lime/qc/gui-process-owner-current.json` 不再显示长时间 raw GUI smoke / Cargo owner 后,重新跑完整 `npm run verify:local`;只有该统一门禁通过,`local-verify-gate` 才能关闭。 +8. 环境阻断解除后,定位 `claw-chat-ready-streaming` 的 stop / interrupt 后持久化、会话恢复和 DevBridge 中途不可达问题,补 Rust 或 harness replay 回归。 +9. 只有 8/8 P0 全部 pass 后,才把结果导出为官方 `.lime/qc/agent-qc-evidence.json`,再运行 `npm run agent-qc:release-summary -- --check` 和 `npm run agent-qc:audit -- --format json`。 + +## 8. 与其他文档的关系 + +- 通用运营标准:`docs/tests/agent-ops-qc.md`。 +- P0 场景执行手册:`docs/tests/agent-qc-p0-scenarios.md`。 +- Lime 落地路线:`docs/tests/lime-agent-qc-rollout-plan.md`。 +- Lime 执行矩阵:`docs/tests/lime-agent-autonomous-test-execution-matrix.md`。 +- 当前 P0 阻断:`docs/tests/lime-agent-qc-current-blockers.md`。 +- 机器可读场景:`docs/test/agent-qc-scenarios.manifest.json`。 +- 机器可读证据 schema:`docs/test/agent-qc-evidence.schema.json`。 + +本文件负责回答“Lime 作为实际 Agent 产品,应该如何一步步从现有丰富测试升级为自主化运营测试体系”。 diff --git a/docs/tests/lime-agent-qc-completion-audit-2026-05-10.md b/docs/tests/lime-agent-qc-completion-audit-2026-05-10.md new file mode 100644 index 000000000..02b761173 --- /dev/null +++ b/docs/tests/lime-agent-qc-completion-audit-2026-05-10.md @@ -0,0 +1,832 @@ +# Lime Agent QC 整体目标完成度审计(2026-05-10) + +> 审计目的:把“实现整体目标”拆成可验证交付物,逐项映射到仓库文件、命令输出和 qcloop 真实状态。只要官方 P0 Evidence Pack 未通过,本目标不得标记完成。 + +最近一次只读刷新:2026-05-11 01:32。刷新只更新 `.lime/qc/*` sidecar 和 docs/tests 审计文档,不提交、不推送、不中断任何正在运行的 qcloop worker。当前权威机器审计见 `.lime/qc/agent-qc-audit-current.json`。 + +## 1. 目标拆解 + +本轮整体目标不是单纯新增文档,而是为 Lime 这个即将运营的 Agent 产品建立可由 Agent 自主执行的测试体系。成功标准如下: + +1. `docs/tests/` 下有人读测试标准、P0 手册、Lime 示例计划和当前 blocker 记录。 +2. `docs/test/` 下有机器可读 scenario manifest、GUI flow manifest 和 Evidence Pack schema。 +3. 仓库脚本能校验标准、生成 qcloop job、只读监控 qcloop 状态、导出 Evidence Pack、生成 release summary、执行 completion audit。 +4. qcloop 能执行真实 P0 场景并导出官方 `.lime/qc/agent-qc-evidence.json`。 +5. 发布门禁只接受官方 Evidence Pack `pass`,且必须覆盖全部 P0 scenario id。 +6. GUI / Runtime / approval / sandbox / release 等关键风险有对应测试手段,不依赖人工口头确认。 +7. 当前未通过的 P0 blocker 有明确证据、原因和关闭条件。 +8. 在用户明确要求 Lime 不推送时,不执行 commit / push。 + +## 2. Prompt-to-artifact checklist + +| 要求 | 具体证据 | 当前状态 | 结论 | +| --- | --- | --- | --- | +| `docs/tests` 下新增测试文档 | `docs/tests/lime-agent-autonomous-testing-plan.md`、`docs/tests/lime-agent-autonomous-test-execution-matrix.md` | 已存在,覆盖风险地图、Agent 分工、组合测试、Phase 0-6、owner gate、P0 执行矩阵和失败回写 | PASS | +| `docs/tests` 入口更新 | `docs/tests/README.md` | 已加入 Lime 自主测试计划与执行矩阵 | PASS | +| 运营级测试标准 | `docs/tests/agent-ops-qc.md` | 已定义 lane、Evidence Pack、qcloop、release gate | PASS | +| P0 场景手册 | `docs/tests/agent-qc-p0-scenarios.md` | 已存在 | PASS | +| Lime 落地计划 | `docs/tests/lime-agent-qc-rollout-plan.md` | 已存在 | PASS | +| 当前 blocker 记录 | `docs/tests/lime-agent-qc-current-blockers.md` | 已记录 v3 / v4 / v5、官方 evidence fail、scenario blocker 和关闭条件 | PASS | +| Scenario manifest | `docs/test/agent-qc-scenarios.manifest.json` | `npm run agent-qc:check` 显示 12 个场景、8 个 P0、0 issue;现在会校验 qcloop verifier prompt 必须带 stdout / attempt 状态占位符;`command-bridge-contract` 已允许 no-change surface evidence 用 checked surface counts + contract pass 满足;8 个 P0 均声明合法 `evidenceLayers`,缺失或未知层级会被 `agent-qc:check` 阻断;GUI P0 已显式要求 `GUI session owner / isolation statement`;workspace/release P0 已显式要求 design canvas 工程 roundtrip 和 GUI smoke 自然收口证据 | PASS | +| qcloop stale / cleanup / worker env / read-only repair 机器标准 | `docs/test/agent-qc-scenarios.manifest.json` | `qcloop-batch-verifier-repair` 已把 stale sidecar、stdout/stderr 长度、no-output lease 心跳、worker CLI 环境 sidecar、内层 Codex MCP 启动策略、read-only worker policy 与 max_qc_rounds policy 列入证据 / failure mode;`browser-runtime-site-adapter` 已把 cleanup warning 分类纳入证据 | PASS | +| GUI flow manifest | `docs/test/agent-qc-gui-flows.manifest.json` | `npm run agent-qc:check` 显示 4 个 P0 GUI flow、0 issue | PASS | +| Evidence schema | `docs/test/agent-qc-evidence.schema.json` | `agent-qc:audit` 识别为存在 | PASS | +| qcloop payload 生成 | `scripts/agent-qc-qcloop-job.mjs` / `scripts/lib/agent-qc-qcloop-job-core.mjs` / `scripts/lib/agent-qc-report-core.mjs` | `agent-qc:audit` 识别 `itemCount=8`;本轮已修正 verifier prompt 自动包含 `{{stdout}}` / `{{attempt_status}}` / `{{exit_code}}`,并让 `agent-qc:check` 与 `agent-qc:qcloop-job --check` 机械阻断缺占位符的模板;qcloop item 现在会带 `evidence_layers` | PASS | +| 证据深度分层 payload | `.lime/qc/qcloop-p0-evidence-layers-v1-payload.json` | 已生成但未提交运行;8 个 P0 item 均带 `evidence_layers`,`max_qc_rounds=1`,GUI P0 item 均带 GUI session owner / isolation evidence,workspace/release P0 item 均带 design canvas roundtrip / GUI smoke 收口 evidence 与 failure mode,用于后续 worker 明确区分 `deterministic-smoke` / `gui-trace` / `runtime-transcript` / `release-artifact` | PASS sidecar | +| design canvas failure extract | `.lime/qc/design-canvas-failure-fastmini-workspace-extract.json` | 从 fast-mini readonly P0 的 workspace item attempt 中只读提取;DevBridge `ok`、`smoke:workspace-ready` PASS、页面进入 `CANVAS:DESIGN`,但 `project-roundtrip-save-open` 等待 `已保存图层设计工程` 超时 | FAIL evidence sidecar | +| qcloop verifier evidence 占位符 | `scripts/lib/agent-qc-completion-audit-core.mjs` / `scripts/agent-qc-completion-audit.mjs` | `npm run agent-qc:audit -- --format json` 显示 `qcloop-verifier-evidence-placeholders` 通过,证据为 `stdout=true attempt_status=true exit_code=true` | PASS | +| qcloop evidence 导出 | `scripts/agent-qc-export-evidence.mjs` / `scripts/lib/agent-qc-evidence-core.mjs` | 已导出 `.lime/qc/agent-qc-evidence.p0-rerun-v5-current.json` sidecar;worker stdout 明确 `QCLOOP_WORKER_RESULT=BLOCKED` 或 stderr 显示内层 CLI / 认证 / sandbox 配置阻断时归类为 `blocked`,避免把环境阻断误写成产品 fail | PASS | +| qcloop 只读状态监控 | `scripts/agent-qc-qcloop-status.mjs` / `scripts/lib/agent-qc-qcloop-status-core.mjs` / `docs/tests/lime-agent-qc-qcloop-operations.md` | 已支持 running / pending / exhausted / stale 分类,并可把运行中批次状态导出到 `.lime/qc/qcloop-status.*.json`;不会中断 worker;显式 blocked marker 会保留环境阻断语义;stale item 现在输出 `staleSeconds` 与 worker `durationSeconds` | PASS | +| qcloop DB lease 只读取证 | `scripts/agent-qc-qcloop-db-lease.mjs` / `package.json` | `npm run agent-qc:qcloop-db-lease` 会只读导出 qcloop SQLite DB 中的 active item、lease、attempt stdout/stderr 长度和相关进程快照,用于判断 no-output stale worker 是否仍被续约 | PASS | +| GUI owner 并发检查 | `scripts/agent-qc-gui-owner-check.mjs` / `scripts/lib/agent-qc-gui-owner-core.mjs` / `package.json` | `npm run agent-qc:gui-owner-check -- --check` 会只读扫描 `.lime/qc/qcloop-status.*.json`,active GUI owner 超过上限时非 0 退出;用于启动新 GUI P0 前阻断并发 GUI smoke 干扰 | PASS | +| raw process owner 检查 | `scripts/agent-qc-process-owner-check.mjs` / `package.json` | `npm run agent-qc:process-owner-check` 会只读扫描 raw GUI smoke、qcloop、Cargo / Rust owner,并生成 `.lime/qc/gui-process-owner-current.json` / `.md`;用于决定是否等待完整 `verify:local` | PASS | +| qcloop worker preflight | `scripts/agent-qc-qcloop-preflight.mjs` / `scripts/lib/agent-qc-qcloop-preflight-core.mjs` / `docs/test/agent-qc-scenarios.manifest.json` | 已支持 cwd / tmp / DevBridge 前置检查,并把 preflight 写入 qcloop worker prompt;GUI / browser runtime 场景可先阻断环境问题 | PASS | +| release summary | `scripts/agent-qc-release-summary.mjs` | `agent-qc:audit` 识别为存在 | PASS | +| release hard gate | `.github/workflows/release.yml` | `agent-qc:audit` 显示强制 Evidence Pack pass 且覆盖 P0 | PASS | +| nightly artifact | `.github/workflows/harness-nightly.yml` | `agent-qc:audit` 显示上传 `artifacts/agent-qc/*` | PASS | +| GUI 真实证据 | `.lime/qc/gui-evidence` | `agent-qc:audit` 显示存在;本地 GUI smoke 曾通过 | PASS | +| approval / sandbox smoke | `scripts/agent-runtime-approval-sandbox-smoke.mjs`、`package.json`、manifest commands | direct-host `npm run smoke:agent-runtime-approval-sandbox -- --timeout-ms 120000 --output .lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/runtime-approval-sandbox-smoke.fixed.json` 通过,denied / resolved 两条 flow 均生成 `runtime_permission_confirmation:*` | PASS sidecar,仍非官方 full P0 pack | +| qcloop worker Vitest 沙箱写入问题 | `scripts/lib/vitest-smoke-runner.mjs`、`scripts/agent-runtime-tool-surface-smoke.mjs` | smoke 改为 `/tmp` Vitest config/cache,`npm run smoke:agent-runtime-tool-surface` 通过 | PASS | +| 官方 P0 Evidence Pack | `.lime/qc/agent-qc-evidence.json` | `agent-qc:audit` 显示 `status=fail scenarios=8/8` | FAIL | +| qcloop v4 重跑 | job `1778392677659787000` | 已 `completed` / `fail`,6/6 exhausted;只作为排障 sidecar,不是官方发布证据 | FAIL | +| qcloop v5 重跑 | job `1778398587521627000` | 已 `completed` / `fail`;1 failed、5 exhausted-or-blocked、0 success;不覆盖官方 Evidence Pack | FAIL | +| 宿主 DevBridge 恢复 | `.lime/qc/qcloop-devbridge-health-restored.json` | `npm run bridge:health` 与宿主 `agent-qc:qcloop-preflight -- --require-devbridge` 均通过;说明 headless Tauri / DevBridge 已恢复 | PASS | +| qcloop worker DevBridge preflight | job `1778403715309891000`、`.lime/qc/qcloop-status.worker-devbridge-preflight.json` | 只读 preflight job 显示内层 worker cwd/tmp 通过,但 `devbridge-health` 仍 `fetch failed`,sidecar 归类为 `blocked` | BLOCKED | +| isolated qcloop worker preflight | job `1778404260108641000`、`.lime/qc/qcloop-status.isolated-worker-devbridge-preflight-v2.json` | 隔离 qcloop server `127.0.0.1:18080` 使用独立 DB 和显式 Codex sandbox 配置后,worker DevBridge preflight 通过 | PASS sidecar | +| isolated P0 sidecar pass | jobs `1778404364137496000` / `1778404601640847000` / `1778404743505029000` | `workspace-ready-session-restore`、`browser-runtime-site-adapter`、`skill-forge-register-bind-enable` 在 isolated qcloop 下通过;仍非官方全量 P0 Evidence Pack | PASS sidecar | +| isolated release startup sidecar v1 | job `1778404882904047000` | 执行时宿主 DevBridge 再次断开,preflight blocked,未执行版本或 GUI smoke;保留为历史环境阻断证据 | BLOCKED sidecar | +| isolated release startup sidecar v2 | job `1778405385701480000`、`.lime/qc/qcloop-status.isolated-release-startup-v2.json` | worker preflight、`verify:app-version`、`verify:gui-smoke -- --reuse-running` 均通过;stdout 明确 `ARTIFACT_SCOPE=source-tree-startup-smoke` | PASS sidecar | +| isolated full P0 sidecar v1 | job `1778405842243079000`、`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json`、`.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json` | 批次仍 running;`command-bridge-contract`、`claw-chat-ready-streaming`、`tool-approval-sandbox-boundary`、`skill-forge-register-bind-enable` 已 success;`browser-runtime-site-adapter` running 且 stale 约 `28020s`;DB lease 仍为 `lock_owner=qcloop-worker-1`、`lock_expires_at=2026-05-11T01:47:06+08:00`;active attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` stdout/stderr 仍为空;后续 3 个 P0 pending | STALE sidecar | +| isolated no-MCP full P0 v1 | job `1778410893606889000`、`.lime/qc/qcloop-status.isolated-nomcp-p0-v1-current.json` | 因 `QCLOOP_CODEX_BIN` 指向坏 Homebrew symlink,8 个 item 立即 failed;现按 worker CLI 环境阻断归类,不作为产品失败 | BLOCKED sidecar | +| isolated no-MCP full P0 v2 | job `1778410956075020000`、`.lime/qc/qcloop-status.isolated-nomcp-p0-v2-current.json` | 使用正确 Codex bin 与 `QCLOOP_CODEX_EXTRA_ARGS='--ephemeral -c mcp_servers={}'`;实测仍可能存在用户级 MCP 子进程;当前 2 success、1 failed、1 running、4 pending,`tool-approval-sandbox-boundary` 已 failed,`skill-forge-register-bind-enable` running | RUNNING with failed item sidecar | +| isolated MCP-disabled full P0 v1 | job `1778412160003934000`、`.lime/qc/qcloop-status.isolated-mcpdisabled-p0-v1-current.json` | 覆盖已知 MCP command 为空,初始 `ps` 未观察到用户级 MCP 子进程;当前 1 failed、1 running、6 pending,`claw-chat-ready-streaming` stale | STALE with failed item sidecar | +| isolated fast full P0 v1 | job `1778412499745993000`、`.lime/qc/qcloop-status.isolated-fast-p0-v1-current.json` | 在 MCP command 覆盖基础上追加 `--ignore-rules`;当前 1 success、1 running、6 pending,`claw-chat-ready-streaming` running | RUNNING sidecar | +| isolated fast-mini readonly full P0 v1 | job `1778412738097137000`、`.lime/qc/qcloop-status.fastmini-readonly-p0-v1-current.json`、`.lime/qc/agent-qc-evidence.fastmini-readonly-p0-v1-current.json` | 使用 `gpt-5.4-mini`、low reasoning、只读 prompt、`max_qc_rounds=1` 与 MCP command 覆盖;当前 job 已 terminal `failed`,8 exhausted;sidecar evidence 导出为 6 fail / 2 blocked,并暴露 `workspace-ready-session-restore` 在 `smoke:design-canvas` 保存成功状态断言失败、`release-package-startup-smoke` GUI smoke 未自然收口 | FAIL sidecar | +| stale gate exit check | `agent-qc:qcloop-status -- --check-terminal --fail-on-stale` | 当前 full P0 job 返回 `QCLOOP_STATUS_CHECK_EXIT_STATUS=2`,摘要显示 `duration=67m stdout=0 stderr=0`;证明 stale gate 会非 0 阻断 | PASS | +| direct browser runtime / site adapter host check | `agent-qc:qcloop-preflight -- --require-devbridge` + `smoke:browser-runtime` + `smoke:site-adapters` | 宿主 shell 直接执行退出码 0;DevBridge pass,browser runtime smoke 与 site adapter smoke 通过;browser cleanup 输出非阻断 `close_cdp_session` warning | PASS with warning | +| direct harness replay host check | `npm run harness:eval` + `npm run harness:eval:trend` | 宿主 shell 直接执行退出码 0;2/2 ready、invalid=0、current observability gap=0、degraded gap=1;trend 样本数 1,只能作为 seed | PASS with trend caveat | +| direct workspace / release host check | `smoke:workspace-ready` + `verify:app-version` + `verify:gui-smoke -- --reuse-running` | 宿主 shell 直接执行退出码 0;workspace ready、版本一致性、DevBridge health、GUI smoke 通过;GUI smoke 内含 browser runtime、site adapters、service skill entry、runtime tool surface、knowledge GUI、design canvas | PASS | +| `verify:gui-smoke` 最新复核 | `.lime/qc/verify-gui-smoke-current.json`、`.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-0108.log` | 外部启动的显式 Sensenova `verify:gui-smoke -- --reuse-running --timeout-ms 240000` 已自然通过;覆盖 Claw streaming / interrupt / resume、knowledge GUI 与 design canvas;仅作为 GUI smoke pass,不关闭完整 `verify:local` | PASS sidecar | +| raw GUI / Cargo process owner | `.lime/qc/gui-process-owner-current.json` | 当前仍为 `busy`,`activeGuiSmoke=2`、`cargoOrRust=4`、`qcloopRelated=7`;因此本轮不启动新的完整 `verify:local` 或 full GUI P0 | BLOCKED sidecar | +| `local-verify-gate` | `.lime/qc/verify-local-current.json`、`.lime/qc/verify-local-sensenova-2026-05-11-0023.log` | 最新完整 `npm run verify:local` 失败在 `verify:gui-smoke / smoke:claw-chat-ready-streaming`;后续 `verify:gui-smoke` 已 pass,但它仍不能替代完整 `verify:local` pass | FAIL | +| GitHub push / release | git remote / GitHub | 用户明确要求 Lime 不推送,本轮未执行 commit / push | PASS | + +## 3. 当前命令证据 + +最近一次审计命令: + +```bash +npm run agent-qc:audit -- --format json +``` + +结果摘要: + +```text +status=incomplete +passed=16/18 +failed=2/18 +缺口=real-qcloop-evidence, local-verify-gate +官方 evidence=.lime/qc/agent-qc-evidence.json status=fail scenarios=8/8 +``` + +最新只读刷新: + +```bash +npm run agent-qc:qcloop-status -- \ + --base-url "http://127.0.0.1:18080" \ + --job-id "1778405842243079000" \ + --format json \ + --output "./.lime/qc/qcloop-status.isolated-p0-full-v1-current.json" + +npm run agent-qc:gui-owner-check -- \ + --format json \ + --output "./.lime/qc/gui-owner-current.json" \ + --watch-history-output "./.lime/qc/stale-owner-watch-history.jsonl" + +npm run agent-qc:process-owner-check -- \ + --format json \ + --output "./.lime/qc/gui-process-owner-current.json" \ + --markdown-output "./.lime/qc/gui-process-owner-current.md" + +npm run agent-qc:qcloop-db-lease -- \ + --db "./.lime/qc/qcloop-isolated-worker-preflight.db" \ + --job-id "1778405842243079000" \ + --format json \ + --output "./.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json" \ + --markdown-output "./.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.md" +``` + +结果摘要: + +```text +qcloop status=stale +counts=4 success / 1 running / 3 pending / 1 stale +active=browser-runtime-site-adapter +staleSeconds≈28020 +GUI owner=blocked, ownerCount=1, staleOwnerCount=1 +raw process owner=busy, activeGuiSmoke=2, cargoOrRust=4, qcloopRelated=7 +DB lease sidecar=.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json +``` + +最新 GUI smoke sidecar: + +```bash +LIME_AGENT_QC_PROVIDER="custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed" \ +LIME_AGENT_QC_MODEL="sensenova-6.7-flash-lite" \ +npm run verify:gui-smoke -- --reuse-running --timeout-ms 240000 +``` + +结果摘要: + +```text +status=pass +evidence=.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-0108.log +claw-chat-ready-streaming verdict=pass +recoveryVisibleSource=live-stream +interruptedTurnStatus=aborted +followTurnStatus=completed +``` + +最新 fast-mini readonly P0 sidecar: + +```bash +npm run agent-qc:export-evidence -- \ + --base-url "http://127.0.0.1:18085" \ + --job-id "1778412738097137000" \ + --output "./.lime/qc/agent-qc-evidence.fastmini-readonly-p0-v1-current.json" \ + --ref "local-fastmini-readonly-p0-v1-current" +``` + +结果摘要: + +```text +job=failed +status=fail +scenarioCount=8 +fail=6 +blocked=2 +workspace-ready-session-restore: smoke:workspace-ready 通过,但 verify:gui-smoke 失败在 smoke:design-canvas 保存成功状态断言 +release-package-startup-smoke: verify:app-version 通过,但 verify:gui-smoke 未自然收口,blocked +``` + +design canvas 失败细化: + +```text +sidecar=.lime/qc/design-canvas-failure-fastmini-workspace-extract.json +item=workspace-ready-session-restore +stage=project-roundtrip-save-open +missingText=已保存图层设计工程 +pageState=CANVAS:DESIGN 专属 GUI SMOKE / AI 图层化设计画布 +classification=ui ready false positive / design canvas export no save status +``` + +最新 evidence-layer payload 预生成: + +```bash +npm run agent-qc:qcloop-job -- \ + --risk P0 \ + --cwd "$(pwd)" \ + --name "lime-agent-qc-p0-evidence-layers-v1-2026-05-10" \ + --max-qc-rounds 1 \ + --output "./.lime/qc/qcloop-p0-evidence-layers-v1-payload.json" \ + --check +``` + +结果摘要: + +```text +valid=true +itemCount=8 +max_qc_rounds=1 +8 个 P0 item 均带 evidence_layers +GUI P0 item 均带 GUI session owner / isolation statement +workspace-ready-session-restore 带 design canvas project roundtrip save/open evidence +release-package-startup-smoke 带 GUI smoke natural exit and design canvas roundtrip result +``` + +GUI owner 并发检查: + +```bash +npm run agent-qc:gui-owner-check -- --check +``` + +当前预期:该命令会非 0 退出,因为仍有 active GUI owner sidecar;这不是脚本失败,而是阻止新 GUI P0 批次抢会话的正确行为。 + +当前 release summary gate 复核: + +```bash +npm run agent-qc:release-summary -- \ + --evidence "./.lime/qc/agent-qc-evidence.json" \ + --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \ + --require-risk P0 \ + --tag "local-agent-qc-audit" \ + --output "./.lime/qc/release-agent-qc.current-audit.md" \ + --check +``` + +结果摘要: + +```text +RELEASE_SUMMARY_EXIT_STATUS=1 +Agent QC release summary 状态为 fail,不能作为绿色发布证据。 +``` + +最近一次标准校验: + +```bash +npm run agent-qc:check +``` + +结果摘要: + +```text +scenarioCount=12 +p0ScenarioCount=8 +guiFlowCount=4 +issueCount=0 +``` + +新增 approval / sandbox smoke: + +```bash +npm run smoke:agent-runtime-approval-sandbox -- --no-write +``` + +结果摘要: + +```text +4 组 Vitest smoke 通过: +- submit preferences approval/sandbox policy +- runtime permission / tool lifecycle projection +- permission confirmation UI recovery +- Harness tool permission / pending approval display +``` + +新增 approval / sandbox live runtime sidecar: + +```bash +npm run smoke:agent-runtime-approval-sandbox -- \ + --timeout-ms 120000 \ + --output ".lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/runtime-approval-sandbox-smoke.fixed.json" +``` + +结果摘要: + +```text +status=pass +provider=deepseek +model=deepseek-v4-flash +providerSource=auto-enabled-provider +liveAssertions.devBridgeHealthy=true +liveAssertions.permissionRequestCreatedBeforeModel=true +liveAssertions.deniedDecisionClearsPendingRequest=true +liveAssertions.resolvedDecisionClearsPendingRequest=true +liveAssertions.approvalPolicySubmitted=true +liveAssertions.sandboxPolicySubmitted=true +evidence=.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/summary.json +scope=direct-host-backend-sidecar +``` + +新增 qcloop 只读状态监控: + +```bash +npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:8080" --job-id "1778398587521627000" +``` + +结果摘要: + +```text +verdict=fail +items=6 terminal=6 nonTerminal=0 +success=0 failed=1 exhausted=5 running=0 pending=0 stale=0 +claw-chat-ready-streaming evidenceStatus=blocked, worker stdout 包含 QCLOOP_WORKER_RESULT=BLOCKED +tool-approval-sandbox-boundary qcloopStatus=failed,但 worker stdout 自报 PASS 与 verifier feedback 冲突,不能作为 P0 pass +``` + +宿主 DevBridge 恢复检查: + +```bash +npm run bridge:health -- --timeout-ms 120000 --interval-ms 2000 +npm run agent-qc:qcloop-preflight -- --expected-cwd "$(pwd)" --require-devbridge --timeout-ms 30000 --check +``` + +结果摘要: + +```text +bridge:health status=ok +QCLOOP_PREFLIGHT_RESULT=PASS +devbridge-health PASS: status=ok +sidecar=.lime/qc/qcloop-devbridge-health-restored.json +``` + +qcloop 内层 worker DevBridge preflight: + +```bash +job=1778403715309891000 +npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:8080" --job-id "$job" --format json --output "./.lime/qc/qcloop-status.worker-devbridge-preflight.json" +``` + +结果摘要: + +```text +job=completed +item=exhausted +evidenceStatus=blocked +worker stdout: QCLOOP_PREFLIGHT_RESULT=BLOCKED, devbridge-health BLOCKED: fetch failed +worker stdout: QCLOOP_WORKER_RESULT=BLOCKED +``` + +隔离 qcloop worker preflight 与 P0 sidecar: + +```bash +# isolated qcloop server: 127.0.0.1:18080 +# db: .lime/qc/qcloop-isolated-worker-preflight.db +npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778404260108641000" --format json --output "./.lime/qc/qcloop-status.isolated-worker-devbridge-preflight-v2.json" +npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778404364137496000" --format json --output "./.lime/qc/qcloop-status.isolated-workspace-ready.json" +npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778404601640847000" --format json --output "./.lime/qc/qcloop-status.isolated-browser-runtime-site-adapter.json" +npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778404743505029000" --format json --output "./.lime/qc/qcloop-status.isolated-skill-forge.json" +npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778405385701480000" --format json --output "./.lime/qc/qcloop-status.isolated-release-startup-v2.json" +npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778405842243079000" --format json --output "./.lime/qc/qcloop-status.isolated-p0-full-v1-current.json" +npm run agent-qc:qcloop-status -- --base-url "http://127.0.0.1:18080" --job-id "1778405842243079000" --stale-minutes 1 --format json --output "./.lime/qc/qcloop-status.isolated-p0-full-v1-stale-check.json" +``` + +结果摘要: + +```text +isolated-worker-devbridge-preflight-v2=complete +isolated-workspace-ready=complete +isolated-browser-runtime-site-adapter=complete +isolated-skill-forge=complete +isolated-release-startup-v1=blocked,因为宿主 127.0.0.1:3030 再次断开 +isolated-release-startup-v2=complete,source-tree startup smoke 通过,未声明 installer artifact 验证 +isolated-p0-full-v1=stale sidecar,4/8 success,browser-runtime-site-adapter running 且无 stdout/stderr,3 pending +``` + +Runtime tool surface smoke: + +```bash +npm run smoke:agent-runtime-tool-surface +``` + +结果摘要: + +```text +runtime tool surface:6 tests pass +runtime inventory:49 tests pass +``` + +宿主 browser runtime / site adapter 直接检查: + +```bash +npm run agent-qc:qcloop-preflight -- --expected-cwd "$(pwd)" --require-devbridge --timeout-ms 30000 --check +npm run smoke:browser-runtime +npm run smoke:site-adapters +``` + +结果摘要: + +```text +QCLOOP_PREFLIGHT_RESULT=PASS +smoke:browser-runtime exit=0;session 创建与 attach/status 通过;cleanup warning: close_cdp_session 未找到 session +smoke:site-adapters exit=0;catalog/list/recommend/search 通过,adapters=11 +``` + +该直接检查只能证明宿主产品命令可跑;不能替代 qcloop full P0 job 的 verifier 证据。 + +宿主 harness replay 直接检查: + +```bash +npm run harness:eval +npm run harness:eval:trend +``` + +结果摘要: + +```text +harness:eval exit=0;2 cases ready / invalid=0 / current observability gap=0 / degraded observability gap=1 +harness:eval:trend exit=0;samples=1;只能形成 trend seed,不能判断长期退化 +``` + +该直接检查只能证明 harness 命令可跑;不能替代 qcloop full P0 job 的 verifier 证据。 + +宿主 workspace / release source-tree 直接检查: + +```bash +npm run smoke:workspace-ready +npm run verify:app-version +npm run verify:gui-smoke -- --reuse-running +``` + +结果摘要: + +```text +smoke:workspace-ready exit=0;DevBridge ready,workspaceCount=104,repaired=false,relocated=false +verify:app-version exit=0;版本一致性通过: 1.32.0 +verify:gui-smoke exit=0;workspace ready、browser runtime、site adapters、service skill entry、runtime tool surface、knowledge GUI、design canvas 均通过 +``` + +该直接检查只能证明宿主 source-tree 路径可跑;不能替代 installer artifact 验证,也不能替代 qcloop full P0 verifier。 + +## 4. 当前 qcloop 状态 + +v4 job:`1778392677659787000`(completed / fail) + +v5 job:`1778398587521627000`(completed / fail) + +worker preflight job:`1778403715309891000`(completed / blocked sidecar)。它是在宿主 DevBridge 已恢复后创建的只读 job,只验证 qcloop 内层 worker 是否能访问 Lime cwd、tmp 和 `127.0.0.1:3030/health`。结果为 cwd/tmp 通过、DevBridge health `fetch failed`,因此后续 P0 重跑前必须先在安全窗口修复 qcloop serve 的 Codex sandbox / loopback 权限;不能直接把宿主 health pass 当作 qcloop P0 可运行。 + +isolated qcloop sidecar jobs: + +| Job | 场景 | 状态 | 结论 | +| --- | --- | --- | --- | +| `1778404260108641000` | worker DevBridge preflight v2 | `completed` / `success` | 正确 sandbox 配置下 worker 可访问 DevBridge | +| `1778404364137496000` | `workspace-ready-session-restore` | `completed` / `success` | `smoke:workspace-ready` 与 `verify:gui-smoke -- --reuse-running` 通过 | +| `1778404601640847000` | `browser-runtime-site-adapter` | `completed` / `success` | `smoke:browser-runtime` 与 `smoke:site-adapters` 通过 | +| `1778404743505029000` | `skill-forge-register-bind-enable` | `completed` / `success` | `test:contracts` 与 `smoke:agent-service-skill-entry` 通过 | +| `1778404882904047000` | `release-package-startup-smoke` v1 | `failed` / `blocked` | 宿主 DevBridge 再次断开,preflight blocked,未执行版本 / GUI smoke | +| `1778405385701480000` | `release-package-startup-smoke` v2 | `completed` / `success` | worker preflight、版本一致性与 `verify:gui-smoke -- --reuse-running` 通过;scope 为 `source-tree-startup-smoke` | +| `1778405842243079000` | full P0 v1 | `running` / stale sidecar | 4/8 success;`browser-runtime-site-adapter` running 无 stdout/stderr,`codex exec` 进程仍在;`workspace-ready-session-restore`、`harness-replay-regression`、`release-package-startup-smoke` pending;最新 `staleSeconds=8002` | +| `1778410893606889000` | no-MCP full P0 v1 | `failed` / blocked sidecar | `QCLOOP_CODEX_BIN` 指向坏 symlink,属于 worker CLI 环境阻断,不是产品失败 | +| `1778410956075020000` | no-MCP full P0 v2 | `running` / sidecar | 正确 Codex bin + `mcp_servers={}` 尝试;当前 2 success、1 failed、1 running、4 pending;`tool-approval-sandbox-boundary` failed,`skill-forge-register-bind-enable` running | +| `1778412160003934000` | MCP-disabled full P0 v1 | `running` / stale sidecar | 正确 Codex bin + 已知 MCP command 置空;初始 `ps` 未观察到用户级 MCP 子进程;`command-bridge-contract` failed;`claw-chat-ready-streaming` running 且 stale 约 674 秒,后续 6 个 P0 pending | +| `1778412499745993000` | fast full P0 v1 | `running` / sidecar | 正确 Codex bin + MCP command 置空 + `--ignore-rules`;`command-bridge-contract` success;`claw-chat-ready-streaming` running,后续 6 个 P0 pending | +| `1778412738097137000` | fast-mini readonly full P0 v1 | `failed` / fail sidecar | gpt-5.4-mini + low reasoning + MCP command 置空 + `--ignore-rules` + 只读 prompt + `max_qc_rounds=1`;8 exhausted;sidecar evidence 为 6 fail / 2 blocked | + +| Scenario | 状态 | 证据结论 | +| --- | --- | --- | +| `claw-chat-ready-streaming` | default `exhausted` / evidence `blocked`;isolated full sidecar `success` with shallow scope | default worker 明确输出 `QCLOOP_WORKER_RESULT=BLOCKED`;isolated full v1 已通过 GUI smoke / DevBridge / runtime surface,但 stdout 明确缺 live long-turn interrupt transcript;同日 deep flow 仍有 product backend blocker | +| `tool-approval-sandbox-boundary` | default `failed` / evidence `fail`;isolated full sidecar `success` with deterministic scope | default worker stdout 自报 `QCLOOP_WORKER_RESULT=PASS` 但 qcloop exit `-1` / verifier 仍判缺 live runtime transcript;isolated full v1 已通过 tool surface + approval sandbox smoke,但不是 live long-turn transcript | +| `skill-forge-register-bind-enable` | default `exhausted` / evidence `blocked`;isolated sidecar `success` | default worker 多轮均停在 DevBridge preflight blocked;isolated single场景与 full v1 均已通过 contracts + service skill entry smoke | +| `browser-runtime-site-adapter` | `exhausted` / evidence `blocked` | worker 明确输出 `QCLOOP_WORKER_RESULT=BLOCKED`,多轮均停在 DevBridge preflight blocked,未进入 browser session / adapter catalog / cleanup 主链 | +| `workspace-ready-session-restore` | v5 `exhausted` / evidence `blocked`;fast-mini readonly `exhausted` / evidence `fail` | fast-mini worker 明确 `smoke:workspace-ready` 通过,但 `verify:gui-smoke` 失败在 `smoke:design-canvas` 保存成功状态断言,命中 `ui ready false positive` 风险;不能用旧 isolated pass 覆盖当前 release 证据 | +| `release-package-startup-smoke` | default qcloop `exhausted` / evidence `blocked`;isolated source-tree sidecar `success` | v5 多轮 worker 未提供版本、GUI smoke、首屏 ready、Bridge health、waiver 和失败模式排除证据,最终 exhausted;隔离 v2 已证明 source-tree startup smoke 通过,但不是同一全量官方 P0 Evidence Pack | + +sidecar evidence:`.lime/qc/agent-qc-evidence.p0-rerun-v5-completed.json`(从 completed v5 导出;`p0-rerun-v5-current.json` 已同步为同一状态) + +```text +status=fail +scenarioCount=6 +1 failed / 5 exhausted-or-blocked / 0 running / 0 pending / 0 pass +``` + +该 sidecar 只用于排障,不能覆盖官方 `.lime/qc/agent-qc-evidence.json`。v4 completed sidecar `.lime/qc/agent-qc-evidence.p0-rerun-v4-completed.json` 仍保留为 fail 排障证据。 + +已生成后续单场景重跑 payload:`.lime/qc/qcloop-tool-approval-sandbox-rerun-payload.json`,包含 `smoke:agent-runtime-tool-surface`、新增 `smoke:agent-runtime-approval-sandbox`,以及带 `{{stdout}}` / `{{attempt_status}}` / `{{exit_code}}` 的 verifier prompt。`agent-qc:qcloop-job --check` 现在也会校验这些占位符。该单场景 payload 尚未提交;v5 已使用新的 6 场景 payload 先跑完整剩余 P0。 + +同时生成了全量 P0 重跑 payload:`.lime/qc/qcloop-p0-rerun-with-verifier-evidence-payload.json`,覆盖 8 个 P0 场景,verifier prompt 同样带 stdout / exit code 占位符。 + +本轮追加准备、提交并启动了当前 6 个未通过 / 未完成 P0 的 v5 重跑 payload:`.lime/qc/qcloop-p0-rerun-v5-verifier-evidence-ready-payload.json`,新 job 为 `1778398587521627000`。payload 已通过 `agent-qc:qcloop-job --check`,确认 worker prompt 含 preflight,verifier prompt 含 `{{stdout}}` / `{{attempt_status}}` / `{{exit_code}}`。首个场景已证明 qcloop worker 基础 preflight PASS,但 DevBridge preflight BLOCKED。只读环境侧证据已写入 `.lime/qc/qcloop-executor-env-20260510.json`:qcloop serve 进程当前未设置 `QCLOOP_CODEX_SANDBOX=off` / bypass / approval policy。 + +v5 completed 后补充了宿主 DevBridge health 证据:`.lime/qc/qcloop-devbridge-health-after-v5.json` 显示宿主 shell 直连 `http://127.0.0.1:3030/health` 失败,且 3030 无监听。随后本轮通过 `npm run tauri:dev:headless` 恢复了宿主 DevBridge,恢复证据写入 `.lime/qc/qcloop-devbridge-health-restored.json`;但默认 qcloop worker preflight job `1778403715309891000` 仍证明默认 qcloop serve 的内层 worker 无法访问 DevBridge。 + +隔离 qcloop server `127.0.0.1:18080` 使用显式 sandbox 配置后,worker preflight 与 4 个 P0 sidecar 已通过,说明 worker 权限问题可以被环境修复。`release-package-startup-smoke` v2 已在 source-tree scope 下通过,但当前仍不能直接写官方 Evidence Pack,因为还缺同一批次 8/8 P0 success,且 installer artifact 未验证。 + +## 5. 完成判定 + +当前整体目标 **未完成**。 + +原因:官方 P0 qcloop Evidence Pack 仍为 `fail`。v5 重跑已 completed / fail,isolated full P0 v1 仍 stale,no-MCP full P0 v2 已出现 failed item,MCP-disabled / fast full P0 批次仍 running 或 stale,fast-mini readonly full P0 已 terminal failed,尚未形成 8/8 verifier success。fast-mini readonly P0 sidecar 还暴露了 `workspace-ready-session-restore` 的 GUI smoke 子项失败。即使文档、manifest、GUI flow、release gate、approval/sandbox smoke 都已经落地,也不能把这些 proxy signal 或 partial sidecar 当作真实 P0 pass。 + +关闭条件保持不变: + +1. qcloop P0 批次覆盖全部 8 个 P0 scenario id。 +2. `.lime/qc/agent-qc-evidence.json` 的 `verdict.status` 为 `pass`。 +3. release summary 以 `--require-scenario-manifest docs/test/agent-qc-scenarios.manifest.json --require-risk P0 --check` 通过。 +4. `npm run agent-qc:audit -- --format json` 返回 `complete`。 + +## 6. 下一刀 + +1. 保留宿主 DevBridge 恢复、默认 worker blocked、isolated worker pass、4 个 P0 isolated pass 与 release v1 blocked / v2 pass sidecar,不覆盖官方 evidence。 +2. 继续只读观察 isolated full P0 v1、no-MCP v2、MCP-disabled v1、fast v1;fast-mini readonly v1 已 terminal failed,只保留 sidecar evidence,不覆盖官方 evidence。 +3. 优先确认 GUI P0 是否存在并发干扰:当前仍有多个 running/stale GUI qcloop sidecar,新的 payload 已要求 GUI worker 输出 session owner / isolation statement。只有确认单一 GUI owner 后,`smoke:design-canvas` 失败才可直接归为产品回归。 +4. 在单一 GUI owner 前提下,定位 `workspace-ready-session-restore` 中 `smoke:design-canvas` 保存成功状态断言失败;这是 qcloop worker 内已经捕获的 GUI P0 signal,不能被旧 isolated pass 覆盖。当前已确认缺的是 `已保存图层设计工程` 可观察状态,下一步应判断导出按钮点击后是状态未渲染、导出流程卡住,还是断言文本与产品文案漂移。 +5. 当前 running/stale 批次自然结束后,优先使用 `.lime/qc/qcloop-p0-evidence-layers-v1-payload.json` 作为下一轮只读 P0 payload;该 payload 明确 `evidence_layers` 且 `max_qc_rounds=1`,避免 worker 把 deterministic smoke 伪装成 deep evidence。 +6. 把 `tool-approval-sandbox-boundary` 拆成更窄的 runtime transcript / evidence JSON 场景,避免确定性 smoke 与 live runtime 证据混在同一个 verifier 口径里。 +7. 环境阻断解除后,再修复 `claw-chat-ready-streaming` 的 stop / interrupt 后端语义,并重跑 GUI deep flow 与 qcloop 场景。 + + +## 8. 2026-05-10 20:28 增量审计 + +当前机器审计命令: + +```bash +node scripts/agent-qc-completion-audit.mjs --format json > ".lime/qc/agent-qc-audit-current.json" +``` + +结果摘要: + +```text +status=incomplete +passed=16/17 +failed=1/17 +唯一缺口=real-qcloop-evidence +``` + +新增或收紧的 checklist 项: + +| 检查项 | 证据 | 当前状态 | +| --- | --- | --- | +| `structured-evidence-contract` | `docs/tests/lime-agent-qc-evidence-contract.md`、qcloop worker prompt、verifier prompt、`agent-qc:export-evidence`、`agent-qc:release-summary` | PASS:`doc=true worker=true verifier=true strictJson=true exporter=true release=true` | +| `stale-owner-intervention-protocol` | `docs/tests/lime-agent-qc-stale-owner-intervention.md`、`docs/tests/lime-agent-qc-qcloop-operations.md` | PASS:已定义只读取证、owner 确认格式和 post-clear rerun runbook | +| release structured evidence gate | `scripts/lib/agent-qc-release-summary-core.mjs` / `scripts/lib/agent-qc-release-summary-core.test.ts` | PASS:pass 场景若只有 `qcloop:*` evidenceRefs 会被 release summary 阻断 | +| Evidence exporter structured summary gate | `scripts/lib/agent-qc-evidence-core.mjs` / `scripts/lib/agent-qc-evidence-core.test.ts` | PASS:qcloop `success` item 缺 `QCLOOP_EVIDENCE_SUMMARY_JSON` 或 JSON 无法解析时导出为 `fail` | +| GUI owner stale detail | `scripts/lib/agent-qc-gui-owner-core.mjs` / `.lime/qc/gui-owner-current.json` | PASS:报告 `staleOwnerCount=1`、`oldestStaleSeconds=9543`,nextAction 要求只读观察或 owner 确认 | +| post-stale rerun plan | `.lime/qc/post-stale-owner-rerun-plan.md`、`.lime/qc/qcloop-p0-structured-evidence-v1-payload.json` | PASS sidecar:payload `valid=true`、`itemCount=8`、`maxQcRounds=1`,未提交 qcloop job | + +当前唯一 blocker 仍是官方 P0 Evidence Pack: + +```text +.lime/qc/agent-qc-evidence.json status=fail +qcloop-status.isolated-p0-full-v1-current.json verdict=stale success=4 running=1 pending=3 stale=1 +qcloop-status.isolated-p0-full-v1-stale-check.json verdict=stale success=4 running=1 pending=3 stale=1 +``` + +只读进程核查显示 stale owner 背后仍有真实内层 worker: + +```text +PID=69738 +process=codex exec +scenario=browser-runtime-site-adapter +workerDurationSeconds≈9543 +stdoutLength=0 +stderrLength=0 +``` + +因此当前仍不得: + +- 覆盖 `.lime/qc/agent-qc-evidence.json`。 +- 启动新的 full GUI P0 批次。 +- kill / pause / interrupt PID `69738` 或 qcloop serve。 +- 调用 `update_goal(status="complete")`。 + +关闭条件保持不变:owner 明确处理或自然释放 stale worker 后,`agent-qc:gui-owner-check -- --check` 必须通过;随后用 `.lime/qc/qcloop-p0-structured-evidence-v1-payload.json` 或重新生成的等价 payload 跑出 8/8 P0 structured evidence pass,再执行 `agent-qc:export-evidence`、`agent-qc:release-summary --check` 和 `agent-qc:audit`。 + +### 2026-05-10 20:32 payload retry control update + +`agent-qc:qcloop-job` 已支持 `--max-executor-retries`,`docs/test/agent-qc-scenarios.manifest.json` 设置 `recommendedMaxExecutorRetries=0`。当前 post-stale P0 structured payload 重新生成后: + +```text +valid=true +itemCount=8 +max_qc_rounds=1 +max_executor_retries=0 +``` + +该设置用于发布证据批次:不自动重试内层 CLI,避免当前 stale owner 问题未解决时重复制造孤儿 worker。若 owner 明确需要基础设施重试,必须在隔离环境中显式改为 1。 + +### 2026-05-10 20:40 只读状态复核 + +本轮遵守用户要求:不 push、不 commit、不终止或重启其他正在运行的进程。只读刷新结果如下: + +```text +agent-qc:audit status=incomplete +passed=16/17 +failed=1/17 +唯一缺口=real-qcloop-evidence +``` + +`1778405842243079000` 仍是当前唯一 active GUI qcloop owner: + +```text +job status=running +verdict=stale +counts=8 total / 4 success / 1 running / 3 pending / 1 stale +active scenario=browser-runtime-site-adapter +worker durationSeconds=10508 +worker stdoutLength=0 +worker stderrLength=0 +observed PID=69738 +process=codex exec +``` + +`agent-qc:gui-owner-check` 当前输出: + +```text +ownerCount=1 +staleOwnerCount=1 +oldestStaleSeconds=10508 +verdict=blocked +``` + +release summary gate 也按预期保持阻断: + +```text +RELEASE_SUMMARY_EXIT_STATUS=1 +summary=Agent QC release summary 状态为 fail,不能作为绿色发布证据 +structured evidenceRefs missing=command-bridge-contract, harness-replay-regression +``` + +因此当前可做动作仍然只有: + +- 继续只读观察并刷新 sidecar。 +- 更新 `docs/tests` 运行手册、审计文档和 stale owner request。 +- 等 owner 明确确认或 stale worker 自然释放后,再执行 post-stale P0 structured evidence runbook。 + +当前仍不得: + +- 覆盖 `.lime/qc/agent-qc-evidence.json`。 +- 启动新的 full GUI P0 批次。 +- kill / pause / interrupt PID `69738` 或 qcloop serve。 +- 把任何 isolated partial pass 写成 release pass。 +- 调用 `update_goal(status="complete")`。 + +### 2026-05-10 20:44 只读续刷 + +状态未自然释放。`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 更新后仍显示 `verdict=stale`、`4 success / 1 running / 3 pending / 1 stale`,`browser-runtime-site-adapter staleSeconds=10772`,worker stdout/stderr 仍为 `0 / 0`。`.lime/qc/gui-owner-current.json` 更新后仍显示 `ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds=10772`、`verdict=blocked`。`ps` 仍可见 PID `69738` 的内层 `codex exec`,本轮未执行 kill / pause / interrupt。 + +### 2026-05-10 20:48 DB / lease 取证 + +只读新增 `.lime/qc/qcloop-db-lease-isolated-p0-full-v1.md`。SQLite 显示 active item `1778405842246191000` 仍为 `running`,`lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-10T21:02:05+08:00`;active attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` 从 `2026-05-10T17:45:05+08:00` 开始后仍未完成,stdout/stderr 长度为 `0 / 0`。进程树显示 PID `69738` 的 PPID 已变为 `1`,仍在 qcloop serve 的 PGID `69307` 中,并带有 Playwright MCP / Context7 MCP 子进程。该证据支持当前卡点是 qcloop worker / inner MCP startup no-output hang,而不是可直接放行的产品 P0 pass。 + +### 2026-05-10 20:52 qcloop runtime binary provenance + +只读新增 `.lime/qc/qcloop-runtime-binary-provenance-18080.md`。`lsof` 显示当前 18080 qcloop serve PID `69307` 运行的是 `/Users/coso/Documents/dev/ai/limecloud/qcloop/qcloop`,SHA-256 为 `177bf7fa79212d065c5cb6cc5cdbb153d3079cec3252a157993f70ab35dc5fe1`;已构建的 `.lime/qc/bin/qcloop-timeout-fixed` SHA-256 为 `2895e33d068a7109607d3e45b6e3bebe1807b6f71d387a3b62eb669a3d4314c7`。两者大小、mtime 和 checksum 均不同,说明 timeout / process-group cleanup fix 尚未作用于当前 running serve 或 PID `69738`。 + +### 2026-05-10 20:54 owner decision packet + +只读新增 `.lime/qc/stale-owner-intervention-request.json`,并在 `docs/tests/lime-agent-qc-stale-owner-intervention.md` 中登记为机器可读决策包。该 JSON 汇总 job、active item、DB lease、PID、binary provenance、证据引用、禁止动作和确认文本;它只用于 owner 决策,不等于授权。本轮仍没有执行 kill / pause / interrupt / restart。 + +### 2026-05-10 21:02 lease 过期窗口后复核 + +只读新增 `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-after-expiry.md`。等待超过先前记录的 `lock_expires_at=2026-05-10T21:02:05+08:00` 后,qcloop status 仍为 `stale`,`browser-runtime-site-adapter staleSeconds=11845`,stdout/stderr 仍为 `0 / 0`;SQLite 显示 active item 仍为 `running`,但 `lock_expires_at` 已延长到 `2026-05-10T21:17:05+08:00`;PID `69738` 仍存活且 PPID 为 `1`。这证明当前 owner 没有自然释放,qcloop 仍在维持 lease heartbeat。 + +### 2026-05-10 21:05 GUI owner decision output + +`agent-qc:gui-owner-check -- --format json` 已在 stale owner 场景输出 `ownerIntervention` 字段,包含 `requiredConfirmationText`、`prohibitedUntilConfirmed`、`evidenceRefs` 和下一步动作。定向测试 `npx vitest run scripts/lib/agent-qc-gui-owner-core.test.ts` 通过 `6/6`;当前 `.lime/qc/gui-owner-current.json` 显示 `ownerIntervention.status=requires_owner_confirmation`。该字段只用于机器可读提醒,不等于 owner 已授权。 + +2026-05-10 21:08 追加审计收紧:`agent-qc:audit` 的 `stale-owner-intervention-protocol` 现在同时检查 `ownerIntervention` 机器可读输出和文档中的 decision packet 说明。定向测试 `npx vitest run scripts/lib/agent-qc-completion-audit-core.test.ts scripts/lib/agent-qc-gui-owner-core.test.ts` 通过 `20/20`;当前审计项证据为 `doc=true operations=true ownerIntervention=true docDecisionPacket=true`。 + +### 2026-05-10 21:10 stale owner watch history + +只读新增 `.lime/qc/stale-owner-watch-history.jsonl`,以 JSONL 记录 stale owner 的观察序列:20:44 GUI owner blocked、20:48 DB lease 到 21:02、21:02 lease 延长到 21:17。该 sidecar 只用于证明持续 heartbeat / no-output hang,不构成处理授权。 + +2026-05-10 21:18 续刷:超过第二个 lease 窗口后,qcloop status 仍为 `stale`,`browser-runtime-site-adapter staleSeconds=12753`,stdout/stderr 仍为 `0 / 0`;SQLite 显示 `lock_expires_at=2026-05-10T21:32:05+08:00`,PID `69738` 仍存活。该观察已追加到 `.lime/qc/stale-owner-watch-history.jsonl`,当前 entries=4。 + +2026-05-10 21:21 追加脚本化 watch history:`agent-qc:gui-owner-check` 支持 `--watch-history-output`,并已用该参数追加 `.lime/qc/stale-owner-watch-history.jsonl`,当前 entries=5。定向测试 `npx vitest run scripts/lib/agent-qc-gui-owner-core.test.ts scripts/lib/agent-qc-completion-audit-core.test.ts` 通过 `21/21`。 + +2026-05-10 21:23 追加 audit gate 收紧:`agent-qc:audit` 的 `stale-owner-intervention-protocol` 现在同时检查 watch history 输出能力和 stale owner 文档中的 watch history 说明。定向测试 `npx vitest run scripts/lib/agent-qc-completion-audit-core.test.ts scripts/lib/agent-qc-gui-owner-core.test.ts` 通过 `22/22`;当前证据为 `doc=true operations=true ownerIntervention=true docDecisionPacket=true watchHistory=true docWatchHistory=true`。 + +2026-05-10 21:32 续刷:超过第三个 lease 窗口后,qcloop status 仍为 `stale`,`browser-runtime-site-adapter staleSeconds=13657`,stdout/stderr 仍为 `0 / 0`;SQLite 显示 `lock_expires_at=2026-05-10T21:47:05+08:00`,PID `69738` 仍存活。该观察通过 `agent-qc:gui-owner-check -- --watch-history-output` 追加到 `.lime/qc/stale-owner-watch-history.jsonl`,当前 entries=6。 + +2026-05-10 21:34 追加契约门禁复核:`npm run test:contracts` 通过,覆盖 `check:agent-runtime-clients`、command contracts、harness contracts、modality runtime contracts、harness cleanup report contract 与 `agent-qc:check`。这证明本轮 Agent QC 脚本 / audit / GUI owner 变更没有破坏仓库契约门禁,但仍不能替代真实 8/8 P0 qcloop Evidence Pack。 + +2026-05-10 21:36 追加 `verify:local` 复核:`npm run verify:local` 在 `typecheck` 阶段失败,错误为 `src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx(129,51): error TS2345: Argument of type 'number' is not assignable to parameter of type 'Timeout'.` 该文件不属于本轮 Agent QC docs / scripts 修改范围,且当前工作树存在其他活动变更;本轮未尝试修复。sidecar 见 `.lime/qc/verify-local-2026-05-10-2136.md`。这意味着除 `real-qcloop-evidence` 主缺口外,仓库级 `verify:local` 当前也不是绿色。 + +2026-05-10 21:38 追加只读排查:`git status` 显示 `src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx` 是 untracked,相关 `ChannelLogTailPanel.tsx` 已 modified;失败行是 `vi.spyOn(window, "setInterval").mockReturnValue(0);`。由于该文件不属于本轮 Agent QC docs / scripts 修改范围,且当前工作树存在其他活动变更,本轮仍未修改。sidecar 见 `.lime/qc/verify-local-channel-log-tail-investigation.md`。 + +2026-05-10 21:48 追加版本变更只读观察:npm 输出显示当前包版本为 `lime@1.33.0`。只读检查显示 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json` 均为 `1.33.0`,且 `package-lock.json` / `src-tauri/Cargo.lock` 也处于 modified 状态。`npm run verify:app-version` 通过,版本一致性为 `1.33.0`。本轮未修改版本文件;sidecar 见 `.lime/qc/version-change-observation-2026-05-10-2148.md`。 + +2026-05-10 21:52 追加 local verify gate:`.lime/qc/verify-local-current.json` 记录 `npm run verify:local` 当前为 `fail`,失败阶段 `typecheck`。`agent-qc:audit` 现在新增 `local-verify-gate`,要求仓库统一本地校验为 pass;定向测试 `npx vitest run scripts/lib/agent-qc-completion-audit-core.test.ts scripts/lib/agent-qc-gui-owner-core.test.ts` 通过 `23/23`。当前 completion audit 变为 `16/18`,缺口为 `real-qcloop-evidence` 与 `local-verify-gate`。 + +### 2026-05-10 20:54 补充审计记录:Agent UI manual Playwright evidence + +- 新增 evidence:`.lime/qc/gui-evidence/agent-ui-manual-e2e-2026-05-10/summary.json`。 +- 覆盖 UI surface:home、new task workspace、Skills catalog、Skill preflight、Skills search、capability drafts、registered skills、input enabled state、knowledge popover、advanced settings。 +- 结果:manual Agent UI flow 为 `pass_with_findings`;console `0 error / 0 warning`;非静态 network `194/194` HTTP `200`。 +- 发现:本轮没有复现 `Agent 1000` 标签;新增 3 个产品化发现,其中 `skill-preflight-memory-internals-visible` 为 medium,建议后续隐藏或改写内部记忆标签与历史错误片段。 +- 审计影响:该证据加强 `real-gui-evidence`,但不改变 `real-qcloop-evidence` 状态;当前 audit 仍应保持 `incomplete`,唯一主缺口仍为官方 P0 qcloop Evidence Pack 未 pass。 + +### 2026-05-10 21:10 补充审计记录:Skill reference sanitization fix + +- 修复 evidence:`.lime/qc/gui-evidence/skill-preflight-reference-sanitized-2026-05-10/summary.json`。 +- 代码:`src/components/agent/chat/utils/curatedTaskReferenceSelection.ts`。 +- 回归:`src/components/agent/chat/utils/curatedTaskReferenceSelection.test.ts`、`src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx`。 +- 验证:定向 Vitest `18/18` 通过;定向 ESLint 通过;`npm run typecheck` 通过;Playwright 复测 `auto_analysis/-32603/Pexels API Key/fp:/task_id` 均未出现在 Skill 补参卡用户可见文本中。 +- 审计影响:修复了 20:54 manual Playwright 发现的 medium 产品 UI/UX 信息外露;不影响 `agent-qc:audit` 的 `real-qcloop-evidence` 主缺口,整体仍不能标记 complete。 + +### 2026-05-10 21:17 补充审计记录:Skills search local-hit empty state fix + +- 修复 evidence:`.lime/qc/gui-evidence/skills-search-local-hit-2026-05-10/summary.json`。 +- 代码:`src/components/skills/SkillsWorkspacePage.tsx`。 +- 回归:`src/components/skills/SkillsWorkspacePage.test.tsx`。 +- 验证:SkillsWorkspacePage 定向 Vitest `31/31` 通过;定向 ESLint 通过;`npm run typecheck` 通过;Playwright 搜索 `cover` 验证 `cover_generate` 可见,旧全局无结果文案不可见,新文案正确指向右侧可用 Skill。 +- 审计影响:修复了 20:54 manual Playwright 发现的 low 产品 UI/UX 误导;不改变 `agent-qc:audit` 的 `real-qcloop-evidence` 主缺口,整体仍不能标记 complete。 + +### 2026-05-10 21:28 补充审计记录:Advanced settings Plan label localization fix + +- 修复 evidence:`.lime/qc/gui-evidence/advanced-settings-plan-label-cn-2026-05-10/summary.json`。 +- 代码:`src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx`。 +- 回归:`src/components/agent/chat/components/Inputbar/index.test.tsx`、`src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx`、`src/components/agent/chat/components/EmptyState.test.tsx`。 +- 验证:定向 Vitest `146/146` 通过;定向 ESLint 通过;`npm run typecheck` 通过;`npm run bridge:health -- --timeout-ms 30000` 通过;Playwright 展开高级设置后显示 `计划执行`,`aria-label` / `title` 为 `开启计划执行`,页面正文不再出现独立 `Plan`、`Plan 模式` 或 `开启/关闭 Plan` 文案。 +- 审计影响:修复了 20:54 manual Playwright 发现的 low 产品 UI/UX polish;不改变 `agent-qc:audit` 的 `real-qcloop-evidence` 主缺口,整体仍不能标记 complete。 + +### 2026-05-10 21:35 补充审计记录:Prompt-to-artifact checklist 与 Design Canvas 复核 + +本次继续执行前,按用户目标“不要只测 Agent UI;看到的后端或产品 UI/UX 问题都要测试并解决”做 prompt-to-artifact 对照: + +| 明确要求 / 成功标准 | 现有或新增证据 | 覆盖结论 | +| --- | --- | --- | +| 不只覆盖 Agent UI 表层 | `12` 到 `24` Claw cancel / recovery evidence、`runtime-approval-sandbox-smoke.json`、browser runtime / site adapter sidecar、workspace / release sidecar、Design Canvas smoke | PARTIAL PASS:覆盖了后端 runtime、approval/sandbox、browser runtime、workspace、release source-tree 与 Design Canvas,但仍缺同批次 full P0 qcloop pass | +| 普通用户 Agent UI / Skills 全路径可交互 | `.lime/qc/gui-evidence/agent-ui-manual-e2e-2026-05-10/summary.json` | PASS with findings:已后续修复 3 个 UI/UX 问题 | +| 看到的 UI/UX 问题要修 | `skill-preflight-reference-sanitized`、`skills-search-local-hit`、`advanced-settings-plan-label-cn` 三个 evidence 目录与对应 Vitest / ESLint / typecheck | PASS:已修复信息外露、搜索空态误导、高级设置内部英文标签 | +| 后端 cancel / recovery 问题要修 | `docs/exec-plans/evidence/product-backend-ux-e2e-2026-05-10/24-claw-cancel-sensenova-clean-console-summary.json` 及 runtime/thread evidence | PASS sidecar:long turn 为 `aborted`,follow-up 不被旧输出污染;仍需官方 qcloop 同批次采信 | +| approval / sandbox 边界要有 transcript 级证据 | `.lime/qc/runtime-approval-sandbox-smoke.json`,`transcriptKind=verified_projection_and_live_runtime_transcript` | PASS sidecar:包含 live runtime permission confirmation transcript;仍非官方 full P0 pack | +| Design Canvas 保存状态旧失败要复核 | 新增 `.lime/qc/gui-evidence/design-canvas-project-roundtrip-current-2026-05-10/summary.json`;`npm run smoke:design-canvas -- --timeout-ms 180000` 通过 | PASS sidecar:当前 host 下 `project-roundtrip-save-open` 可通过,无需代码修复 | +| Harness Replay / Eval 后端回归要复核 | 新增 `.lime/qc/backend-evidence/harness-replay-regression-current-2026-05-10/summary.json`;`npm run harness:eval:json` 与 `npm run harness:eval:trend:json` 通过;history record 已生成第一条真实 baseline | PASS with limitation:当前 eval 可运行且 current gap 为 0;trend `sampleCount=1` 仍只是 seed | +| 不与隔壁 v0.6 / qcloop GUI owner 冲突 | `npm run agent-qc:gui-owner-check -- --check` 仍 blocked;本轮未 kill / pause / interrupt PID `69738`,未启动新 full GUI P0 | PASS:遵守不抢 owner | +| 官方发布前 Evidence Pack 必须真实 pass | `node scripts/agent-qc-completion-audit.mjs --format json` 仍 `incomplete`,`real-qcloop-evidence` failed;`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 仍 stale | FAIL:整体不能标记 complete | + +新增 Design Canvas 复测详情: + +- 命令:`npm run smoke:design-canvas -- --timeout-ms 180000`。 +- 结果:exit `0`;日志显示 `project-roundtrip-save-open`、`upload-flat-image-extraction`、`extraction-quality-export-manifest` 均完成。 +- Evidence:`.lime/qc/gui-evidence/design-canvas-project-roundtrip-current-2026-05-10/summary.json`。 +- 审计影响:旧 fast-mini sidecar 的 `smoke:design-canvas` 失败不再是当前 host 可复现的产品阻塞;但这仍是 sidecar / direct host evidence,不能覆盖 `real-qcloop-evidence` 主缺口,整体仍不能标记 complete。 + +### 2026-05-10 21:39 补充审计记录:Harness Replay / Eval 后端回归复测 + +- 选择原因:full P0 qcloop owner 仍 stale,不能抢占 GUI owner;`harness-replay-regression` 属于非 GUI 后端证据链,可直接复测,不影响隔壁 v0.6 / qcloop。 +- 命令:`npm run harness:eval:json`、`npm run harness:eval:trend:json`。 +- 结果:两条命令 exit `0`;eval summary 显示 `suiteCount=3`、`caseCount=2`、`readyCount=2`、`invalidCount=0`、`needsHumanReviewCount=0`、`currentObservabilityGapCaseCount=0`、`degradedObservabilityGapCaseCount=1`、`currentRecoveredVerificationCaseCount=3`。 +- Trend 限制:`sampleCount=1`,stdout 明确“当前仅形成 trend seed,还不能判断长期退化”。 +- History 处理:只读检查未发现既有 `.lime/harness/history` 或 `reports` harness summary / trend 样本;因此没有复制同一轮样本伪造长期趋势,而是执行 `node scripts/harness-eval-history-record.mjs ...`,把本轮结果记录为第一条真实 baseline,并生成 summary / trend / cleanup / dashboard 报告。 +- Evidence:`.lime/qc/backend-evidence/harness-replay-regression-current-2026-05-10/summary.json`。 +- 审计影响:证明当前 host 上 harness replay / eval 后端链路可运行;但 trend 仍弱,且 full P0 qcloop 未采信,不能覆盖 `real-qcloop-evidence` 主缺口。 + +### 2026-05-10 22:15 补充审计记录:Approval / Sandbox live runtime transcript 收口 + +- 选择原因:full P0 qcloop owner 仍 stale,不能启动新的 GUI P0;`tool-approval-sandbox-boundary` 可以用 direct-host 后端 smoke 补齐 live runtime transcript sidecar。 +- 初始失败:`smoke:agent-runtime-approval-sandbox` 没有给 live runtime 请求传 provider/model preference;provider 未配置时,`agent_runtime_submit_turn` 没有形成权限确认 transcript。UI 侧失败截图为 `.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/agent-runtime-create-session-fail-2026-05-10.png`。 +- 代码:`scripts/agent-runtime-approval-sandbox-smoke.mjs` 新增 provider/model preference 参数与本地 enabled provider 自动选择;本轮选择 `deepseek` / `deepseek-v4-flash`,不暴露 API Key。 +- 验证:`node --check scripts/agent-runtime-approval-sandbox-smoke.mjs`、`npx vitest run scripts/lib/agent-runtime-approval-sandbox-smoke-core.test.ts`、`npm run smoke:agent-runtime-approval-sandbox -- --timeout-ms 120000 --output .lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/runtime-approval-sandbox-smoke.fixed.json`、`npm run smoke:agent-runtime-tool-surface`、`npm run agent-qc:check` 均通过。 +- Evidence:`.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/summary.json`,verdict=`pass`;`runtime-approval-sandbox-smoke.fixed.json`,status=`pass`。 +- Live assertions:`devBridgeHealthy`、`permissionRequestCreatedBeforeModel`、`deniedDecisionClearsPendingRequest`、`resolvedDecisionClearsPendingRequest`、`approvalPolicySubmitted`、`sandboxPolicySubmitted` 均为 `true`;denied / resolved 两条 flow 均生成 `runtime_permission_confirmation:`。 +- 审计影响:`tool-approval-sandbox-boundary` 从“缺 live transcript sidecar”降级为 direct-host backend sidecar pass;但官方 `.lime/qc/agent-qc-evidence.json` 仍为 fail,不能覆盖 `real-qcloop-evidence`。 + +### 2026-05-10 22:20 补充审计记录:当前 completion gate 仍 incomplete + +- `node scripts/agent-qc-completion-audit.mjs --format json` 当前仍为 `status=incomplete`、`16/18`;缺口为 `real-qcloop-evidence` 与 `local-verify-gate`。 +- `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 刷新后仍为 `verdict=stale`,job `1778405842243079000` 为 `4 success / 1 running / 3 pending / 1 stale`,`browser-runtime-site-adapter` stale 约 `16482s`。 +- `.lime/qc/gui-owner-current.json` 显示 `ownerCount=1`、`staleOwnerCount=1`、`verdict=blocked`;watch history 已追加到 `.lime/qc/stale-owner-watch-history.jsonl`,当前 13 条。 +- `local-verify-gate` 仍失败在 `src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx(129,51)`:`Argument of type 'number' is not assignable to parameter of type 'Timeout'.` 该文件属于 settings-v2 高冲突区域,本轮未修改。 + +### 2026-05-10 22:45 补充审计记录:verify:local 新失败点与 qcloop stale owner 续刷 + +- `npm run verify:local` 已结束,exit `1`。本轮 `verify:app-version`、`lint`、`typecheck` 与 `vitest-smart` 批次 `1-38` 通过;`npm test` / `vitest-smart` 在批次 `39/54` 失败。 +- 新失败点为 `src/components/agent/chat/hooks/agentStreamSubmissionLifecycle.test.ts:98`:测试期望 `activeStream` 不包含 `turnId`,但当前实现返回了 `turnId`。相关 `agentStreamSubmissionLifecycle.test.ts` 与 `agentStreamSubmissionLifecycle.ts` 当前均为活动工作树修改,本轮只记录 gate 结果,不改动这些文件。 +- `.lime/qc/verify-local-current.json` 已更新为 `status=fail`、`failedStage="npm test / vitest-smart batch 39/54"`;新增 sidecar `.lime/qc/verify-local-2026-05-10-2239.md`。 +- `qcloop-status.isolated-p0-full-v1-current.json` 只读刷新后仍为 `verdict=stale`,job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`;`browser-runtime-site-adapter` stale 约 `17692s`,stdout/stderr 仍为 `0 / 0`。 +- SQLite 只读复核显示 active item `1778405842246191000` 仍由 `qcloop-worker-1` 持有,`lock_expires_at=2026-05-10T22:56:05+08:00`;PID `69738` 仍存活,PPID=`1`、PGID=`69307`、elapsed≈`04:56:40`。新增 sidecar `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.md`。 +- `agent-qc:gui-owner-check -- --watch-history-output` 已追加 watch history;`.lime/qc/stale-owner-watch-history.jsonl` 当前为 `14` 条。 +- `node scripts/agent-qc-completion-audit.mjs --format json` 当前仍为 `status=incomplete`、`16/18`;缺口保持 `real-qcloop-evidence` 与 `local-verify-gate`。本轮没有 kill / pause / interrupt / restart 任何进程,没有修改 qcloop DB,没有覆盖官方 `.lime/qc/agent-qc-evidence.json`,也没有 commit / push / tag / release。 + +### 2026-05-10 23:40 补充审计记录:verify:local 新一轮仍在运行 + +- 当前存在一轮新的 `npm run verify:local` wrapper 正在运行,未结束,因此 `.lime/qc/verify-local-current.json` 仍是 22:39 的旧 fail 结果;completion audit 继续显示 `status=incomplete`、`16/18`。 +- 新一轮已通过 `verify:app-version`、`lint`、`typecheck`、前端 Vitest 批次、`test:contracts`、`cargo test --manifest-path src-tauri/Cargo.toml` 主库测试,以及 `verify:gui-smoke` 中的 `workspace-ready` / `browser-runtime` / `site-adapters` / Skill Forge 前端 smoke。 +- 当前停在 `smoke:agent-service-skill-entry` 的 Rust 定向测试 `register_capability_draft_persists_readonly_http_preflight_provenance`;日志最后显示 cargo artifact lock 与后续编译,相关 `rustc` 仍有 CPU 活动。为避免和隔壁 v0.6 / qcloop worker 冲突,本轮没有中断任何进程。 +- Running snapshot:`.lime/qc/verify-local-2026-05-10-2340-running.md`。 +- qcloop 官方证据仍未完成:`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 仍 `verdict=stale`,job `1778405842243079000` 为 `4 success / 1 running / 3 pending / 1 stale`;`.lime/qc/gui-owner-current.json` 仍 `blocked`。 +- 审计影响:当前不能把 `local-verify-gate` 从缺口移除;只有该 wrapper 自然结束并写出 `status=pass` 后,才能重跑 completion audit 并更新整体完成度。 + +### 2026-05-10 23:50 补充审计记录:Skill Forge Rust 定向测试进展到第二条 Cargo lock + +- `verify:local` 已从第一条 Skill Forge Rust 定向测试中恢复并通过:`register_capability_draft_persists_readonly_http_preflight_provenance` 对应 cargo test exit `0`。 +- 当前卡点移动到第二条 Rust 定向测试:`registered_skill_becomes_ready_for_manual_enable_binding_candidate`,日志显示 `Blocking waiting for file lock on artifact directory`。 +- 只读进程快照显示仍有并发 Cargo 工作活跃,因此本轮继续等待或记录阻塞,不中断任何进程。 +- Evidence:`.lime/qc/verify-local-2026-05-10-2350-cargo-lock.md`。 +- 审计影响:当前 active run 尚未结束,不能把 `local-verify-gate` 记为 pass;`completion audit` 仍保持 `16/18`,但旧 22:39 Vitest 失败已不再是 active run 的当前停留点。 + +### 2026-05-10 23:55 目标级 prompt-to-artifact 审计刷新 + +目标拆解为 7 个可验证交付项: + +| 要求 | 证据 | 当前结论 | +| --- | --- | --- | +| 不只覆盖 Agent UI,必须包含后端 runtime 行为 | `24-claw-cancel-sensenova-clean-console-summary.json`、`approval-sandbox-live-current-2026-05-10/summary.json` | sidecar pass;不能替代官方 qcloop | +| Agent UI / Skills 全路径用 Playwright 测试并留截图 | `.lime/qc/gui-evidence/agent-ui-manual-e2e-2026-05-10/summary.json`,10 张截图 | pass | +| 发现的产品 UI/UX 问题要修复并复测 | `skill-preflight-reference-sanitized`、`skills-search-local-hit`、`advanced-settings-plan-label-cn` | pass | +| 非 Agent UI 的产品 / 后端风险要测试 | `design-canvas-project-roundtrip-current`、`harness-replay-regression-current` | partial pass;Harness trend 仍只有 `sampleCount=1` | +| `verify:local` 统一门禁通过 | `.lime/qc/verify-local-current.json`、`.lime/qc/verify-local-2026-05-10-2350-cargo-lock.md` | blocked / not pass;active run 仍等待 Cargo lock | +| 官方 full P0 qcloop Evidence Pack 通过 | `.lime/qc/agent-qc-evidence.json`、`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json`、`.lime/qc/gui-owner-current.json` | blocked;qcloop stale,GUI owner blocked | +| 不与隔壁 v0.6 / stale GUI owner 冲突 | qcloop status、GUI owner check、process snapshots | pass;未 kill / pause / interrupt、未新开 full P0、未改 DB | + +机器可读审计:`.lime/qc/objective-completion-audit-2026-05-10-2355.json`,`achieved=false`。因此不能调用 goal complete,也不能把当前 sidecar pass 宣称为整体完成。 + +### 2026-05-10 23:56 补充审计记录:completion gate 仍 incomplete,local verify 归因为 GUI smoke timeout + +- `npm run verify:local` 已自然结束,exit `124`;`.lime/qc/verify-local-current.json` 已保持 `status=fail`,并把失败阶段归一化为 `verify:gui-smoke / smoke:agent-service-skill-entry`。 +- 失败原因不是 22:39 的 `agentStreamSubmissionLifecycle` Vitest 断言,而是 `smoke:agent-service-skill-entry` 在第二条 Skill Forge Rust 定向测试期间超过 `1830000ms` 超时;第一条 Rust 定向测试已通过。 +- `node scripts/agent-qc-completion-audit.mjs --format json` 已保存到 `.lime/qc/completion-audit-2026-05-10-2356.json`,结果仍为 `status=incomplete`、`16/18`。 +- 目标级审计刷新为 `.lime/qc/objective-completion-audit-2026-05-10-2357.json`,`achieved=false`。 +- qcloop 官方证据仍 blocked:`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 为 `verdict=stale`;`.lime/qc/gui-owner-current.json` 为 `blocked`。 + +### 2026-05-11 00:20 只读状态复核与 Claw streaming smoke 更新 + +本次复核仍遵守“不推送、不中断其他进程、不覆盖官方 Evidence Pack”的约束,只更新 sidecar、docs/tests 和 Agent QC 脚本证据能力。 + +qcloop full P0 job `1778405842243079000` 仍未自然释放:`.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 显示 `verdict=stale`,`4 success / 1 running / 3 pending / 1 stale`;active item 仍是 `browser-runtime-site-adapter`,attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` 的 stdout/stderr 仍为 `0 / 0`。SQLite 最新只读快照 `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json` 显示 `lock_expires_at=2026-05-11T00:31:05+08:00`,说明 lease 仍在延长。`.lime/qc/gui-owner-current.json` 仍为 `blocked`,active GUI owner 为 `1`,最长 stale 约 `23482s`。 + +最新 GUI smoke sidecar `.lime/qc/verify-gui-smoke-current.json` 记录 `npm run verify:gui-smoke -- --reuse-running` 为 `fail`。前置阶段已经通过:DevBridge health、workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、runtime tool surface 和 runtime tool surface page;默认 `smoke:claw-chat-ready-streaming` 已能自动解析 `deepseek / deepseek-v4-flash`,因此旧的 provider/model 解析缺口已解除。当前失败更深:长 turn 提交后没有在 smoke 窗口内同时观察到首个流式文本和可见“停止”按钮,证据见 `.lime/qc/verify-gui-smoke-reuse-2026-05-11-0006.log` 与 `.lime/qc/gui-evidence/claw-chat-ready-streaming/claw-chat-ready-streaming-summary.json`。 + +另一个正在运行的显式 `deepseek-chat` 复核自然结束后,也进入同一 first-delta wait 路径,并暴露 `readPageSnapshot` 返回 `null` 时脚本会抛 `Cannot read properties of null (reading 'stopVisible')` 的证据缺口。已修复 `scripts/claw-chat-ready-streaming-smoke.mjs`:等待首增量和恢复结果时会容忍 `null` 快照;失败路径会写出 `console`、`network-invoke`、`runtime-session`、`thread-read` 与 `failureSnapshot`,避免下一次失败只留下 summary。修复后只跑了 `node --check scripts/claw-chat-ready-streaming-smoke.mjs`,未重跑完整 GUI smoke,以免抢占当前仍在运行的 Lime / Cargo / qcloop 进程。 + +审计影响:`local-verify-gate` 仍不能关闭。当前缺口仍是 `real-qcloop-evidence` 与 `local-verify-gate`;后者现在应同时看 `.lime/qc/verify-local-current.json` 和 `.lime/qc/verify-gui-smoke-current.json`,不能再把旧的 provider/model 解析失败当成唯一原因。 + +### 2026-05-11 00:25 GUI smoke 补充:显式 Sensenova provider 跑通 + +00:20 后观察到另一个已在运行的 GUI smoke 自然完成,本轮没有中断或重启它。该命令使用显式 provider/model:`LIME_AGENT_QC_PROVIDER=custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed LIME_AGENT_QC_MODEL=sensenova-6.7-flash-lite npm run verify:gui-smoke -- --reuse-running --timeout-ms 240000`,结果为 `pass`,已写入 `.lime/qc/verify-gui-smoke-current.json` 与 `.lime/qc/verify-gui-smoke-2026-05-11-0025-sensenova-pass.md`。 + +本次通过覆盖:DevBridge health、workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、runtime tool surface、runtime tool surface page、`claw-chat-ready-streaming`、knowledge-gui 和 design-canvas。`claw-chat-ready-streaming` 证据显示 `verdict=pass`,断言覆盖首个流式增量、停止按钮、interrupt scoped to long turn、long turn aborted、recovery turn completed、GUI 可见恢复结果、runtime 持久化和无 runtime mock fallback。 + +审计影响:`.lime/qc/verify-gui-smoke-current.json` 已从 fail 更新为 pass,但 `local-verify-gate` 仍不能关闭,因为它要求完整 `npm run verify:local` 当前 sidecar 为 pass;`.lime/qc/verify-local-current.json` 仍记录最新 full wrapper 为 fail。下一刀应在并发 Cargo/GUI 负载收敛后重跑完整 `npm run verify:local`,而不是只用单独 GUI smoke pass 替代本地统一门禁。 diff --git a/docs/tests/lime-agent-qc-current-blockers.md b/docs/tests/lime-agent-qc-current-blockers.md new file mode 100644 index 000000000..c7aa028b5 --- /dev/null +++ b/docs/tests/lime-agent-qc-current-blockers.md @@ -0,0 +1,506 @@ +# Lime Agent QC 当前 P0 阻断记录 + +> 本文件记录 Lime 作为样本产品执行 Agent QC P0 时发现的真实阻断。它不是发布放行说明;只要本文件中的 P0 blocker 未清空,`.lime/qc/agent-qc-evidence.json` 就不应被视为绿色发布证据。 + +## 1. 当前结论 + +截至 2026-05-11 01:13,本地已经具备 Agent QC 标准、manifest、Evidence Pack schema、qcloop exporter、release summary gate、completion audit、qcloop status sidecar 和 GUI smoke 能力。隔离 qcloop 已经证明部分 P0 smoke 能在 worker 内跑通;最新显式 Sensenova provider 的 `verify:gui-smoke -- --reuse-running` 已通过,并覆盖 Claw streaming / interrupt / resume,但官方 Evidence Pack 仍为失败,完整 `verify:local` 与 stale qcloop P0 owner 仍未闭环: + +```text +.lime/qc/agent-qc-evidence.json status=fail scenarios=8/8 +``` + +这说明当前不是“缺测试”,而是测试系统已经把阻断分成三类:产品深证据 blocker、qcloop worker / provider 卡住、以及 verifier 正确拒绝“只有命令通过、缺少 transcript / trace / console-network 证据”的浅层输出。不能通过修改门禁、降低证据要求或用 isolated partial evidence 把它伪装成通过。 + +## 2. 证据来源 + +| 证据 | 状态 | 说明 | +| --- | --- | --- | +| qcloop P0 v3 `1778390726823769000` | `completed` / `fail` | 覆盖 8/8 P0,2 pass / 6 fail | +| qcloop rerun v4 `1778392677659787000` | `completed` / `fail` | 重跑 6 个失败 P0,最终 0 pass / 6 exhausted;失败集中在旧 verifier 输出读取和 qcloop worker localhost / DevBridge 权限 | +| qcloop rerun v5 `1778398587521627000` | `completed` / sidecar `fail` | 使用带 worker preflight 和 stdout verifier 的新 payload;最终结果为 1 `failed`、5 `exhausted`、0 `success`;其中 GUI / Skill / Browser 类场景已明确输出 `QCLOOP_WORKER_RESULT=BLOCKED` | +| `.lime/qc/agent-qc-evidence.json` | `fail` | 官方默认 Evidence Pack,覆盖 8/8,但不能发布 | +| `.lime/qc/agent-qc-evidence.p0-v3.json` | `fail` | v3 sidecar,覆盖 8/8 | +| `.lime/qc/agent-qc-evidence.p0-rerun-v4-running.json` | `fail` / `blocked` | v4 sidecar,当前 6 个重跑场景未完成或失败 | +| `.lime/qc/agent-qc-evidence.p0-rerun-v4-completed.json` | `fail` | 2026-05-10 15:35 从 completed v4 job 导出的 sidecar,6/6 exhausted;不是官方发布证据 | +| `.lime/qc/agent-qc-evidence.p0-rerun-v5-current.json` | `fail` | v5 sidecar;`tool-approval-sandbox-boundary` qcloop item 已 failed,同时 GUI / Skill / Browser 类场景因 `QCLOOP_WORKER_RESULT=BLOCKED` 保持 blocked 语义;不是官方发布证据 | +| `.lime/qc/qcloop-executor-env-20260510.json` | env finding | qcloop serve PID `80248` 仅设置了 `QCLOOP_CODEX_BIN`,未设置 `QCLOOP_CODEX_SANDBOX=off` / bypass / approval policy;解释了 worker localhost 被 Codex 默认沙箱阻断的高概率原因 | +| `.lime/qc/qcloop-devbridge-health-after-v5.json` | env finding | v5 completed 后宿主 shell 直连 `http://127.0.0.1:3030/health` 也失败,`lsof` 未发现 3030 listener;这是宿主 DevBridge 恢复前的历史阻断证据 | +| `.lime/qc/qcloop-devbridge-health-restored.json` | env finding | 2026-05-10 16:59 后 headless Tauri 启动成功,宿主 `npm run bridge:health` 与 `agent-qc:qcloop-preflight -- --require-devbridge` 均为 pass;官方 evidence 未变 | +| qcloop worker preflight `1778403715309891000` | `completed` / `blocked` sidecar | 宿主 DevBridge 已恢复后创建 1 个只读 preflight item;worker cwd/tmp 通过,但 DevBridge health 仍 `fetch failed`,输出 `QCLOOP_WORKER_RESULT=BLOCKED`;`.lime/qc/qcloop-status.worker-devbridge-preflight.json` 归类为 `blocked` | +| isolated qcloop worker preflight `1778404260108641000` | `completed` / sidecar `complete` | 使用隔离 qcloop server `127.0.0.1:18080`、独立 DB `.lime/qc/qcloop-isolated-worker-preflight.db`、`QCLOOP_CODEX_SANDBOX=off` 后,worker preflight 通过;证明 qcloop worker 权限问题可通过正确启动环境解除 | +| isolated `workspace-ready-session-restore` `1778404364137496000` | `completed` / sidecar `complete` | `smoke:workspace-ready` 与 `verify:gui-smoke -- --reuse-running` 均通过;只作为 sidecar,不覆盖官方 Evidence Pack | +| isolated `browser-runtime-site-adapter` `1778404601640847000` | `completed` / sidecar `complete` | `smoke:browser-runtime` 与 `smoke:site-adapters` 均通过;只作为 sidecar,不覆盖官方 Evidence Pack | +| isolated `skill-forge-register-bind-enable` `1778404743505029000` | `completed` / sidecar `complete` | `test:contracts` 与 `smoke:agent-service-skill-entry` 均通过;只作为 sidecar,不覆盖官方 Evidence Pack | +| isolated `release-package-startup-smoke` v1 `1778404882904047000` | `failed` / sidecar `blocked` | 执行时宿主 `127.0.0.1:3030` 已再次断开,preflight 停在 `devbridge-health BLOCKED`,未运行版本和 GUI smoke;保留为历史环境阻断证据 | +| isolated `release-package-startup-smoke` v2 `1778405385701480000` | `completed` / sidecar `complete` | 宿主 DevBridge 恢复后,隔离 qcloop worker preflight、`verify:app-version`、`verify:gui-smoke -- --reuse-running` 均通过;artifact scope 明确为 `source-tree-startup-smoke`,不是 installer 验证,也不覆盖官方 Evidence Pack | +| isolated P0 full v1 `1778405842243079000` | `running` / sidecar `stale` | 已有 4/8 success:`command-bridge-contract`、`claw-chat-ready-streaming`、`tool-approval-sandbox-boundary`、`skill-forge-register-bind-enable`;`browser-runtime-site-adapter` 仍 running 且当前 attempt 无 stdout/stderr,SQLite lease 被心跳延长到 `2026-05-10T19:00:05+08:00`;后续 3 个场景 pending;只读观察,不中断 | +| isolated no-MCP P0 full v1 `1778410893606889000` | `failed` / sidecar `blocked` | 为排查内层 `codex exec` 用户级 MCP 启动导致长期无输出的问题,启动了独立端口 `127.0.0.1:18081`;该批次因 `QCLOOP_CODEX_BIN` 指向坏 Homebrew symlink 立即失败,现按 worker CLI 环境阻断归类,不作为产品失败 | +| isolated no-MCP P0 full v2 `1778410956075020000` | `running` / sidecar `stale` | 使用独立端口 `127.0.0.1:18082`、正确 Codex bin、`QCLOOP_CODEX_EXTRA_ARGS='--ephemeral -c mcp_servers={}'`;实测仍可能启动用户级 MCP 子进程;`command-bridge-contract` 已在 repair attempt 后 success;当前 `claw-chat-ready-streaming` running 且 9 分钟无 stdout/stderr,被 sidecar 标记 stale;后续 6 个 P0 pending;只读观察,不覆盖官方 evidence | +| isolated MCP-disabled P0 full v1 `1778412160003934000` | `running` / sidecar `running` | 使用独立端口 `127.0.0.1:18083`、正确 Codex bin,并把 `mcp_servers.context7.command` / `mcp_servers.playwright.command` 覆盖为空;初始 `ps` 未观察到用户级 MCP 子进程;`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending;只读观察,不覆盖官方 evidence | +| isolated fast P0 full v1 `1778412499745993000` | `running` / sidecar `running` | 使用独立端口 `127.0.0.1:18084`、正确 Codex bin、MCP command 覆盖为空,并额外加 `--ignore-rules` 避免项目规则 / skill / MCP 依赖放大;`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending;只读观察,不覆盖官方 evidence | +| isolated fast-mini readonly P0 full v1 `1778412738097137000` | `running` / sidecar `running` | 使用独立端口 `127.0.0.1:18085`、`gpt-5.4-mini`、low reasoning、MCP command 覆盖为空、`--ignore-rules`、`max_qc_rounds=1` 和只读 worker prompt;`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending;只读观察,不覆盖官方 evidence | +| direct host `browser-runtime-site-adapter` check | command `pass` with cleanup warning | `agent-qc:qcloop-preflight -- --require-devbridge`、`smoke:browser-runtime`、`smoke:site-adapters` 在宿主 shell 直接通过;`smoke:browser-runtime` 输出了非阻断 cleanup warning:`close_cdp_session` 未找到刚创建的 session;这证明当前 full P0 卡点更像 qcloop worker / provider 无输出,而不是宿主 DevBridge 或产品命令不可用 | +| direct host `harness-replay-regression` check | command `pass` with trend seed caveat | `npm run harness:eval` 与 `npm run harness:eval:trend` 在宿主 shell 直接通过;current observability gap 为 0,degraded gap 为 1,trend 样本数为 1,只能作为 seed,不能判断长期退化 | +| direct host `workspace-ready-session-restore` / `release-package-startup-smoke` check | command `pass` | `smoke:workspace-ready`、`verify:app-version`、`verify:gui-smoke -- --reuse-running` 在宿主 shell 直接通过;验证了 workspace ready、版本一致性、DevBridge health、GUI smoke、browser runtime、site adapters、service skill entry、runtime tool surface、knowledge GUI、design canvas | +| direct host `tool-approval-sandbox-boundary` live runtime check | command `pass` | `.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/summary.json` verdict=`pass`;denied / resolved 两条 flow 均生成 `runtime_permission_confirmation:*`,并证明 `approvalPolicy=on-request`、`sandboxPolicy=workspace-write` 已随 turn 提交;仍是 sidecar,不覆盖官方 Evidence Pack | +| `npm run agent-qc:qcloop-status -- --job-id 1778398587521627000` | `completed` / sidecar monitoring | 最终只读检查显示 v5 已 completed / fail,`tool-approval-sandbox-boundary` failed,其他 5 个重跑项 exhausted;用于排障,不覆盖官方 evidence | +| `.lime/qc/gui-evidence/product-backend-ux-e2e-2026-05-10.json` | `pass` + deep flow finding `fail` | 基础 GUI surface 通过,但 Claw streaming deep flow 标记为 product blocker | + +### 2.1 最新只读刷新(2026-05-10 19:58) + +| 批次 | 当前状态 | 关键含义 | +| --- | --- | --- | +| isolated full P0 v1 `1778405842243079000` | `running` / `stale`,4 success、1 running、3 pending | `browser-runtime-site-adapter` 已无 stdout/stderr 约 8002 秒;只读观察,不 kill、不覆盖官方 evidence | +| no-MCP P0 v2 `1778410956075020000` | `running`,2 success、1 failed、1 running、4 pending | `tool-approval-sandbox-boundary` 已 failed;`skill-forge-register-bind-enable` running;`mcp_servers={}` 仍不足以完全证明内层 MCP 降噪 | +| MCP-disabled P0 v1 `1778412160003934000` | `running` / `stale`,1 failed、1 running、6 pending | `command-bridge-contract` 因旧 no-change verifier 失败;`claw-chat-ready-streaming` stale 约 674 秒;后续 payload 已修正,但当前批次只读观察 | +| fast P0 v1 `1778412499745993000` | `running`,1 success、1 running、6 pending | `--ignore-rules` 后 command bridge 已通过,`claw-chat-ready-streaming` 仍在跑 | +| fast-mini readonly P0 v1 `1778412738097137000` | `failed`,8 exhausted | sidecar evidence 为 `fail=6 / blocked=2`。verifier 正确拒绝缺少 deep evidence 的浅层输出;同时发现 `workspace-ready-session-restore` 的 `verify:gui-smoke` 失败在 `smoke:design-canvas` 保存成功状态断言,`release-package-startup-smoke` 因 GUI smoke 未自然收口被判 blocked | + +这次刷新说明:当前最有价值的下一刀不是“再开一个全量 P0 批次”,而是把浅层 smoke 与深层 live transcript 拆清楚,让 qcloop worker 输出可被 verifier 审计的结构化证据;仍在 running 的批次继续只读观察。 + +由于当前仍有多个 GUI / DevBridge 相关 qcloop 批次 running 或 stale,后续不能再并发启动新的 full P0 GUI 批次。新的 manifest 已把 `GUI session owner / isolation statement` 加入 GUI P0 evidence,并把 `parallel GUI smoke interference` 加入 failure mode;这用于区分真实产品回归和多个 worker 抢同一个 Lime GUI 会话造成的测试失真。 + +机器前置检查: + +```bash +npm run agent-qc:gui-owner-check -- --check +``` + +当前该命令应阻断新 GUI P0,因为仍有 active GUI owner sidecar。 + +`workspace-ready-session-restore` 的细化证据已导出到 `.lime/qc/design-canvas-failure-fastmini-workspace-extract.json`。关键日志为: + +```text +[smoke:design-canvas] stage=project-roundtrip-save-open +[smoke:design-canvas] 工程目录保存结果 等待失败,缺少文本 "已保存图层设计工程" +页面已在 CANVAS:DESIGN 专属 GUI SMOKE / AI 图层化设计画布,但未出现保存成功状态 +DevBridge status=ok,smoke:workspace-ready PASS +``` + +这说明当前不是 workspace 初始化失败,也不是 Bridge 不可用,而是 GUI ready / 设计画布导出状态没有形成可观察的保存完成证据。 + +### 2.2 最新只读复核(2026-05-10 20:40) + +按用户要求,本轮没有 push、没有 commit,也没有 kill / pause / interrupt 任何 qcloop 或 Codex worker。只读刷新后,当前需要以 `1778405842243079000` 为唯一 active GUI owner 处理;其他历史批次只作为 sidecar 排障事实,不再触发新的 full GUI P0 抢占。 + +| 检查 | 当前值 | 结论 | +| --- | --- | --- | +| qcloop job | `1778405842243079000` / `running` | full P0 v1 未终态 | +| qcloop verdict | `stale` | 仍不能导出官方 pass evidence | +| qcloop counts | 4 success / 1 running / 3 pending / 1 stale | P0 覆盖未完成 | +| active scenario | `browser-runtime-site-adapter` | 卡在 GUI / browser runtime 类 P0 | +| worker output | stdout/stderr `0 / 0` | 尚未开始可审查的业务命令输出 | +| worker duration | 约 `10508s` | 已远超 stale 阈值 | +| observed process | PID `69738`,内层 `codex exec` | stale owner 不是旧 sidecar 噪声 | +| GUI owner gate | `ownerCount=1`、`staleOwnerCount=1`、`verdict=blocked` | 不允许启动新的 full GUI P0 | + +当前唯一可安全推进的是继续维护标准、sidecar 和 runbook。必须等待 PID `69738` 自然释放,或由 owner 明确确认处置后,才能按 `docs/tests/lime-agent-qc-stale-owner-intervention.md` 执行 post-stale runbook。确认前不得直接处理进程或修改 qcloop DB。 + +### 2.3 只读复核续刷(2026-05-10 20:44) + +再次只读刷新后,状态未释放:`1778405842243079000` 仍为 `running` / `stale`,`browser-runtime-site-adapter` 的 `staleSeconds=10772`,stdout/stderr 仍为 `0 / 0`;`agent-qc:gui-owner-check` 仍为 `blocked`,`ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds=10772`。`ps` 仍可见 PID `69738` 处于内层 `codex exec`。本轮未执行任何中断动作。 + +### 2.4 DB / lease 级取证(2026-05-10 20:48) + +新增 sidecar:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1.md`。只读 SQLite 与进程树显示: + +| 取证点 | 当前值 | 含义 | +| --- | --- | --- | +| `batch_jobs.status` | `running` | qcloop 仍认为该批次未终态 | +| active item | `1778405842246191000` / `browser-runtime-site-adapter` | 卡住的是 browser runtime P0 | +| item lease | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-10T21:02:05+08:00` | lease 仍被心跳延长 | +| attempt | `running`,started `2026-05-10T17:45:05+08:00` | active attempt 未结束 | +| attempt output | stdout/stderr `0 / 0` | 不是产品命令失败,而是没有可审查业务输出 | +| process | PID `69738`,PPID `1`,PGID `69307` | worker 已成为 orphan,但仍在 qcloop process group | +| child processes | Playwright MCP / Context7 MCP npm exec | 与 `worker user-config MCP startup no-output hang` failure mode 匹配 | + +这把 blocker 从“sidecar 显示 stale”升级为“DB lease、attempt、process tree 三侧一致证明 stale”。确认前仍不得处理进程或改 DB。 + +### 2.5 qcloop runtime binary provenance(2026-05-10 20:52) + +新增 sidecar:`.lime/qc/qcloop-runtime-binary-provenance-18080.md`。只读 `lsof` 与 checksum 显示: + +| 二进制 | 路径 | 大小 | SHA-256 | 结论 | +| --- | --- | --- | --- | --- | +| 当前 18080 qcloop serve | `/Users/coso/Documents/dev/ai/limecloud/qcloop/qcloop` | `13321106` | `177bf7fa...35dc5fe1` | 当前正在运行 | +| timeout-fixed sidecar | `.lime/qc/bin/qcloop-timeout-fixed` | `13426082` | `2895e33d...3d4314c7` | 已构建但未运行 | + +这证明 qcloop executor timeout / process-group cleanup 补丁还没有影响当前 PID `69307` 的 qcloop serve,也不会自动回收 stale PID `69738`。后续只有在 owner 释放或确认处理当前 stale owner 后,才能用 fixed binary 新启隔离 server 重跑 P0。 + +### 2.6 lease 过期窗口后复核(2026-05-10 21:02) + +新增 sidecar:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1-after-expiry.md`。等待超过先前记录的 `lock_expires_at=2026-05-10T21:02:05+08:00` 后,只读复核显示: + +| 取证点 | 当前值 | 含义 | +| --- | --- | --- | +| qcloop verdict | `stale` | job 仍未终态 | +| counts | 4 success / 1 running / 3 pending / 1 stale | P0 仍未覆盖完成 | +| active item | `browser-runtime-site-adapter` | 仍是同一 GUI / browser P0 阻塞 | +| current lock | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-10T21:17:05+08:00` | lease 在过期窗口后继续被延长 | +| active attempt | `running`,stdout/stderr `0 / 0` | 仍无可审查业务输出 | +| process | PID `69738`,PPID `1`,PGID `69307` | worker 仍存活 | + +这确认 stale worker 没有在 lease 过期窗口自然释放;qcloop 仍在维持该 owner。未获 owner 明确确认前,仍只能继续只读观察。 + +### 2.7 GUI owner 机器决策输出(2026-05-10 21:08) + +`agent-qc:gui-owner-check -- --format json` 现在会在 stale owner 场景输出 `ownerIntervention`,当前 `.lime/qc/gui-owner-current.json` 显示: + +```text +ownerIntervention.status=requires_owner_confirmation +requiredConfirmationText=确认处理 stale GUI owner 1778405842243079000,可以终止 PID 并记录 sidecar。 +``` + +该输出已纳入 `agent-qc:audit` 的 `stale-owner-intervention-protocol` 检查项。注意:`` 仍必须由最新 `ps` / DB 取证填入;当前确认前不得把该字段视为授权。 + +### 2.8 stale owner watch history(2026-05-10 21:10) + +新增 sidecar:`.lime/qc/stale-owner-watch-history.jsonl`。该文件按 JSONL 记录只读观察序列,当前包含 3 条: + +1. `20:44:52`:qcloop status / GUI owner 仍 blocked,PID `69738` 存活。 +2. `20:48:00`:DB active attempt running、stdout/stderr `0 / 0`,lease 到 `21:02:05`。 +3. `21:02:46`:超过上一 lease 窗口后,lease 延长到 `21:17:05`,PID `69738` 仍存活。 +4. `21:18:00`:超过第二个 lease 窗口后,lease 再次延长到 `21:32:05`,PID `69738` 仍存活,stdout/stderr 仍为 `0 / 0`。 + +后续 Agent 可追加该 JSONL 作为观察历史,但仍不得据此自动处理进程;处理动作仍需要 owner 明确确认。 + +2026-05-10 21:21 后续观察已改为脚本化追加:`agent-qc:gui-owner-check -- --watch-history-output ./.lime/qc/stale-owner-watch-history.jsonl` 已写入第 5 条记录,后续无需手工拼 JSONL。 + +2026-05-10 21:32 续刷:超过第三个 lease 窗口后,qcloop 仍为 `stale`,`browser-runtime-site-adapter staleSeconds=13657`,stdout/stderr 仍为 `0 / 0`;SQLite 显示 `lock_expires_at=2026-05-10T21:47:05+08:00`,PID `69738` 仍存活。该观察通过 `--watch-history-output` 追加,当前 watch history entries=6。 + +### 2.9 只读续刷(2026-05-11 01:02) + +按用户要求,本轮仍未 push、未 commit、未 tag、未 release,也没有 kill / pause / interrupt / restart 任何 qcloop、Codex worker、Tauri、Cargo 或 GUI smoke 进程。只读刷新后的当前状态: + +| 检查 | 当前值 | 结论 | +| --- | --- | --- | +| qcloop job | `1778405842243079000` / `running` | full P0 v1 仍未终态 | +| qcloop verdict | `stale` | 仍不能导出官方 pass evidence | +| qcloop counts | 4 success / 1 running / 3 pending / 1 stale | P0 覆盖仍未完成 | +| active scenario | `browser-runtime-site-adapter` | 仍卡在 GUI / browser runtime 类 P0 | +| stale age | 约 `26202s` | 已远超 stale 阈值 | +| GUI owner gate | `ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds=26202` | 不允许启动新的 full GUI P0 | +| DB lease | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:17:06+08:00` | lease 仍被续约 | +| active attempt | `dc625f8e-b3b9-46b7-9758-4b0273438d50` / `running` | stdout/stderr 仍为空 | + +新增 / 刷新 sidecar: + +- `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` +- `.lime/qc/gui-owner-current.json` +- `.lime/qc/stale-owner-watch-history.jsonl` +- `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json` +- `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.md` + +这些证据只支持 owner 决策,不构成处理授权。确认前仍不得处理 PID `69738`、修改 qcloop DB、启动新的 full GUI P0,或覆盖官方 `.lime/qc/agent-qc-evidence.json`。 + +### 2.10 `verify:local` 与 Claw streaming 复核(2026-05-11 01:02) + +最新完整 `npm run verify:local` 仍为失败,且已把 `.lime/qc/verify-local-current.json` 刷新到最新失败口径: + +```text +status=fail +failedStage=verify:gui-smoke / smoke:claw-chat-ready-streaming +evidence=.lime/qc/verify-local-sensenova-2026-05-11-0023.log +``` + +该失败发生在完整本地门禁中的 Claw streaming deep flow:前置 GUI smoke 已通过 `smoke:agent-service-skill-entry` 与 runtime tool surface,随后 `smoke:claw-chat-ready-streaming` 在“等待 GUI 出现恢复结果”处超时。之后脚本已加固并直跑复测: + +| 证据 | 状态 | 含义 | +| --- | --- | --- | +| `.lime/qc/gui-evidence/claw-chat-ready-streaming-post-refresh-fallback-2026-05-11/claw-chat-ready-streaming-post-refresh-fallback-summary.json` | `pass` | runtime 已持久化 `复原完成` 时,刷新 / 会话恢复 fallback 可以让 GUI 重新呈现恢复结果 | +| `.lime/qc/gui-evidence/claw-chat-ready-streaming-sensenova-session-restore-2026-05-11/claw-chat-ready-streaming-sensenova-session-restore-summary.json` | `fail` | 复测期间 DevBridge 中途不可达,恢复 turn 未进入 completed;这是环境 / runtime 稳定性证据,不能替代完整门禁 | +| `.lime/qc/claw-chat-ready-streaming-current.json` | `mixed` | 汇总当前 Claw deep flow 的 pass / fail 侧证据 | + +因此当前判断不变:Claw deep flow 已从“没有足够证据”推进为“有可复核的 pass/fail 证据和更精确的失败分类”,但 `local-verify-gate` 仍不能关闭。关闭条件仍是重新跑完整 `npm run verify:local` 并得到 `status=pass`。 + +### 2.11 GUI smoke pass 与 raw process owner 复核(2026-05-11 01:13) + +观察到外部启动的 GUI smoke 自然结束并通过,本轮只读取日志和证据,没有中断或重启该进程: + +```text +command=LIME_AGENT_QC_PROVIDER=custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed LIME_AGENT_QC_MODEL=sensenova-6.7-flash-lite npm run verify:gui-smoke -- --reuse-running --timeout-ms 240000 +status=pass +evidence=.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-0108.log +``` + +已刷新: + +- `.lime/qc/verify-gui-smoke-current.json` +- `.lime/qc/verify-gui-smoke-2026-05-11-0112-sensenova-pass.md` +- `.lime/qc/claw-chat-ready-streaming-current.json` + +本次 GUI smoke 通过了 DevBridge health、workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、runtime tool surface、runtime tool surface page、Claw streaming/interrupt/resume、knowledge-gui、i18n patch metrics report 和 design-canvas。Claw summary 中 `recoveryVisibleSource=live-stream`、`interruptedTurnStatus=aborted`、`followTurnStatus=completed`,且 runtime 关键命令未走 mock fallback。 + +但 `.lime/qc/gui-process-owner-current.json` 仍显示 raw process owner 为 `busy`:存在长时间 `smoke:design-canvas` 进程、stale qcloop Codex worker,以及多个 Cargo / Rust 编译进程。因此仍不应在当前窗口启动新的完整 `verify:local` 或新的 full GUI P0 qcloop 批次;`local-verify-gate` 只能在这些 owner 自然释放后用完整 `npm run verify:local` 关闭。 + +### 2.12 只读续刷与 owner 取证脚本化(2026-05-11 01:32) + +按“Lime 不要推送、还有其他进程在跑”的约束,本轮仍未执行 commit / push / tag / release,也没有 kill / pause / interrupt / restart qcloop、Codex worker、Tauri、Cargo 或 GUI smoke 进程。只读刷新后的当前状态: + +| 检查 | 当前值 | 结论 | +| --- | --- | --- | +| qcloop job | `1778405842243079000` / `running` | full P0 v1 仍未终态 | +| qcloop verdict | `stale` | 仍不能导出官方 pass evidence | +| qcloop counts | 4 success / 1 running / 3 pending / 1 stale | P0 覆盖仍未完成 | +| active scenario | `browser-runtime-site-adapter` | 仍卡在 browser runtime / site adapter 类 P0 | +| stale age | 约 `28020s` | 已远超 stale 阈值 | +| GUI owner gate | `ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds≈27026` | 不允许启动新的 full GUI P0 | +| raw process owner | `busy`,`activeGuiSmoke=2`、`cargoOrRust=4`、`qcloopRelated=7` | 不允许启动完整 `verify:local` | +| DB lease | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:47:06+08:00` | lease 仍被续约 | +| active attempt | `dc625f8e-b3b9-46b7-9758-4b0273438d50` / `running` | stdout/stderr 仍为空 | + +新增 / 刷新 sidecar: + +- `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` +- `.lime/qc/gui-owner-current.json` +- `.lime/qc/stale-owner-watch-history.jsonl` +- `.lime/qc/gui-process-owner-current.json` +- `.lime/qc/gui-process-owner-current.md` +- `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json` +- `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.md` + +新增 `npm run agent-qc:process-owner-check` 与 `npm run agent-qc:qcloop-db-lease`,把 raw process owner 和 SQLite lease 只读取证脚本化,避免后续依赖临时 `ps` / `sqlite3` 片段。这些脚本只用于判断是否应该等待,不授权处理任何进程或修改 qcloop DB。 + +## 3. P0 blocker 列表 + +| Scenario | 当前状态 | 直接证据 | 下一步 | +| --- | --- | --- | --- | +| `claw-chat-ready-streaming` | product blocker;isolated full sidecar pass with shallow GUI scope | `docs/exec-plans/evidence/product-backend-ux-e2e-2026-05-10/11-claw-streaming-summary.json` 标记 stop / interrupt 后长 turn 仍 completed;default worker preflight job `1778403715309891000` 仍证明内层 Codex 无法访问 DevBridge;isolated full v1 已通过 `verify:gui-smoke -- --reuse-running`,但 stdout 明确 scope 不含 live long-turn interrupt transcript | 保留 deep flow blocker;后续必须修 stop / interrupt 后端语义,并把 live long-turn transcript 纳入 qcloop / Playwright MCP 深证据 | +| `tool-approval-sandbox-boundary` | default qcloop failed;direct-host live runtime sidecar pass | v5 attempt 1 和 repair attempt 2 的确定性 smoke 不足以通过 verifier;本轮 direct-host `smoke:agent-runtime-approval-sandbox` 已补真实 live runtime transcript:denied / resolved 两条 flow 均生成 `runtime_permission_confirmation:*`,并证明 approval / sandbox policy 进入 turn config;证据在 `.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/summary.json` | 该场景从“缺 live transcript”降级为“待 full P0 qcloop 同批次采信”;不要覆盖官方 `.lime/qc/agent-qc-evidence.json`,也不要在 stale GUI owner 未释放前重启 full GUI P0 | +| `skill-forge-register-bind-enable` | default qcloop blocked;isolated sidecar pass | v5 多轮 worker 均停在 DevBridge preflight blocked;隔离 qcloop job `1778404743505029000` 已证明 contracts + service skill entry smoke 可通过 | 等宿主 DevBridge 稳定后,把该证据纳入新的全量 P0 qcloop 批次;仍不能单独覆盖官方 evidence | +| `browser-runtime-site-adapter` | default qcloop blocked;isolated single sidecar pass;isolated full P0 stale | v5 多轮 worker 均停在 DevBridge preflight blocked;隔离 qcloop job `1778404601640847000` 已证明 browser runtime + site adapter smoke 可通过;full P0 v1 当前卡在内层 `codex exec` 无 stdout/stderr;宿主直接运行 preflight + browser runtime + site adapters 通过,但 browser cleanup 有非阻断 warning | 等当前 full P0 worker 自然结束;后续重跑时把 browser runtime 与 site adapter 拆成更窄 item,并把 cleanup warning 纳入 evidence | +| `qcloop-batch-verifier-repair` | worker / verifier 协议仍在收敛 | 已发现 qcloop generic repair prompt 会要求 worker “修复目标工作区”,这不适合发布证据批次;已把 worker prompt 改为只读,并生成 `.lime/qc/qcloop-readonly-p0-v1-payload.json` / `.lime/qc/qcloop-fastmini-readonly-p0-v2-payload.json`(`max_qc_rounds=1`)待用; no-MCP P0 v2 的 `command-bridge-contract` attempt 1 真实执行通过,verifier 正确拒绝了“只给命令通过与日志路径、未逐项解释 evidence / failure mode”的输出;repair attempt 已补足并通过 | worker prompt 已强化为必须逐项列出 `evidence_required` 和 `failure_modes`;`command-bridge-contract` verifier 已允许 no-change surface evidence 使用 checked surface counts + contract pass,并已生成待用 payload `.lime/qc/qcloop-isolated-nomcp-p0-v3-after-evidence-prompt-payload.json`;继续观察后续 P0 是否复现同类证据不足 | +| `workspace-ready-session-restore` | default qcloop blocked;isolated sidecar pass;fast-mini full P0 failed | v5 多轮 worker 未提供 workspace-ready / GUI smoke / DevBridge 可审查证据;隔离 qcloop job `1778404364137496000` 曾证明 workspace-ready + GUI smoke 可通过;但 fast-mini readonly P0 v1 中 `smoke:workspace-ready` 通过、`verify:gui-smoke` 失败在 `smoke:design-canvas` 的保存成功状态断言,命中 `ui ready false positive` 风险 | 不把旧 isolated pass 当成当前 release 证据;后续需要定位 design canvas 保存状态断言或产品状态回写,再用 qcloop deep evidence 重跑 | +| `release-package-startup-smoke` | default qcloop blocked;isolated source-tree sidecar pass;direct host pass | v5 多轮 worker 未提供版本、GUI smoke、首屏 ready、Bridge health、waiver 证据;隔离 v2 `1778405385701480000` 已证明 source-tree startup smoke;宿主直接运行 `verify:app-version` + GUI smoke 通过 | 进入 full P0 verifier 后才能覆盖官方 evidence;发布前仍不能把 source-tree smoke 伪装成 installer artifact | +| `harness-replay-regression` | isolated full P0 pending;direct host pass | full P0 v1 尚未调度该 item;宿主直接运行 `harness:eval` + `harness:eval:trend` 通过,但 trend 只有 1 个样本,仍不是 qcloop verifier pass | 等当前 full P0 worker 自然结束后,让该 item 进入 qcloop verifier;长期趋势需要继续积累 nightly 样本 | + +## 4. 已经修正的标准问题 + +- `release-package-startup-smoke` 不再要求场景本身预先提供 `release evidence pack`。 +- release Evidence Pack 的覆盖和 pass 状态由 `agent-qc:release-summary -- --require-scenario-manifest docs/test/agent-qc-scenarios.manifest.json --require-risk P0 --check` 单独强制。 +- `agent-qc:audit` 已能区分官方 `.lime/qc/agent-qc-evidence.json` 与 sidecar evidence,避免 partial 或 fail sidecar 被误读为完成。 +- `agent-qc:export-evidence` / `agent-qc:qcloop-status` 已能识别 worker stdout 中的 `QCLOOP_WORKER_RESULT=BLOCKED`,把 qcloop `exhausted` 但实为环境权限阻断的 item 归类为 Evidence Pack `blocked`。 +- 已提交 qcloop rerun v5 `1778398587521627000`,payload 来自 `.lime/qc/qcloop-p0-rerun-v5-verifier-evidence-ready-payload.json`,覆盖当前 6 个未通过 / 未完成 P0,并确认 worker prompt 含 preflight、verifier prompt 含 `{{stdout}}` / `{{attempt_status}}` / `{{exit_code}}`。 +- 已用 `npm run tauri:dev:headless` 恢复宿主 DevBridge,并把恢复证据写入 `.lime/qc/qcloop-devbridge-health-restored.json`。 +- 已提交只读 qcloop worker preflight job `1778403715309891000`。该 job 证明问题已经从“宿主 DevBridge 未启动”收敛为“qcloop 内层 worker loopback / sandbox 权限阻断”,不是产品 P0 已恢复。 +- 已启动隔离 qcloop server `127.0.0.1:18080` 使用独立 DB 和显式 Codex sandbox 配置,证明 worker 权限可恢复,并产生 4 个 P0 sidecar pass:`workspace-ready-session-restore`、`browser-runtime-site-adapter`、`skill-forge-register-bind-enable`、`release-package-startup-smoke`。 +- `release-package-startup-smoke` v2 sidecar 只证明 source-tree startup smoke,不证明 installer artifact;当前仍不能写官方 Evidence Pack,因为还没有同一批次覆盖 8/8 P0 的真实 pass。 +- 已启动 isolated P0 full v1 `1778405842243079000`。该批次目前 4/8 success,但 `browser-runtime-site-adapter` item 长时间 running 且无 stdout/stderr,`.lime/qc/qcloop-status.isolated-p0-full-v1-stale-check.json` 已标记 `stale`;只读 `ps` 显示内层 `codex exec` 仍在跑、尚未出现 npm 子命令输出,后续 3 个 P0 pending;按 qcloop 运维规则只读观察,不 kill、不 pause、不覆盖官方 evidence。 +- 已把这次真实卡点回写到机器标准:`docs/test/agent-qc-scenarios.manifest.json` 的 `qcloop-batch-verifier-repair` 增加 `stale item sidecar`、worker stdout/stderr 长度摘要,以及 `running no-output stale` / `worker lease heartbeat without stdout` 失败模式;`browser-runtime-site-adapter` 增加 cleanup warning 证据与失败模式。 +- 已强化 `agent-qc:qcloop-status` stale evidence:`scripts/lib/agent-qc-qcloop-status-core.mjs` 现在输出 item 级 `staleSeconds` 和 worker `durationSeconds`;当前 sidecar 显示 `browser-runtime-site-adapter staleSeconds=5108 stdoutLength=0 stderrLength=0`。 +- 已把 qcloop worker 执行器环境失败纳入 blocked 语义:`QCLOOP_CODEX_BIN 不可用`、`QCLOOP_CODEX_EXTRA_ARGS` 解析失败、内层认证 / sandbox 配置错误不再被误判为产品 fail;blocker 摘要不会泄露原始 stderr 路径或凭证内容。 +- 已记录 no-MCP worker 策略:隔离 qcloop server 已验证 `mcp_servers={}` 不足以覆盖嵌套 MCP 配置;当前改用 `mcp_servers..command=""` 覆盖已知 MCP command,初始 `ps` 未观察到用户级 MCP 子进程。 +- 已强化 qcloop worker prompt:最终 stdout 必须逐项列出 `evidence_required` 是否满足,并说明每个 `failure_modes` 如何被覆盖、排除或命中;不能只写“命令通过”或只给日志路径。 +- 已给 P0 manifest 增加 `evidenceLayers`,并把 qcloop item 导出为 `evidence_layers`,要求 worker 明确本次只覆盖 `deterministic-smoke`,还是已经覆盖 `gui-trace`、`runtime-transcript` 或 `release-artifact`;这防止 smoke PASS 被误读成 deep evidence PASS。 +- `agent-qc:check` 已机械强制 P0 场景必须声明合法 `evidenceLayers`,未知层级会被 `scripts/lib/agent-qc-report-core.mjs` 阻断。 +- 已新增 `agent-qc:gui-owner-check`,用于在启动新 GUI P0 前只读扫描 active GUI qcloop sidecar 并阻断并发 GUI smoke 干扰。 +- 已生成待用 payload `.lime/qc/qcloop-p0-evidence-layers-v1-payload.json`,`max_qc_rounds=1` 且 8 个 P0 item 均带 `evidence_layers`;GUI P0 均要求 `GUI session owner / isolation statement`,`workspace-ready-session-restore` / `release-package-startup-smoke` 已纳入 design canvas 工程 roundtrip 和 GUI smoke 自然收口证据;仅供当前 running/stale 批次自然结束后使用,不自动提交。 + +## 5. 完成定义 + +本 blocker 文档只能在以下事实同时成立后关闭: + +1. qcloop P0 批次覆盖全部 8 个 P0 scenario id。 +2. `.lime/qc/agent-qc-evidence.json` 的 `verdict.status` 为 `pass`。 +3. `npm run agent-qc:release-summary -- --evidence ./.lime/qc/agent-qc-evidence.json --require-scenario-manifest docs/test/agent-qc-scenarios.manifest.json --require-risk P0 --check` 通过。 +4. `npm run agent-qc:audit -- --format json` 返回 `complete`。 + +在此之前,Lime Agent QC 整体目标不得标记完成。 + +### 5.1 手动 Playwright Agent UI / Skills 非抢占续测(2026-05-10 20:54) + +新增证据目录:`.lime/qc/gui-evidence/agent-ui-manual-e2e-2026-05-10/`。 + +| 检查 | 当前值 | 结论 | +| --- | --- | --- | +| qcloop owner gate | `blocked`,active owner 仍为 `1778405842243079000` | 未启动新的 full GUI P0 | +| Playwright manual flow | 首页 / 新建任务 / Skills / Skill 补参 / Skill 搜索 / 能力草案 / 已注册能力 / 添加资料 / 高级设置 | 手动 UI 流程可交互 | +| screenshots | `01-home.png` 到 `10-advanced-settings-open.png` | 已留图证据 | +| console | `Errors: 0, Warnings: 0` | 无新增控制台错误 | +| network | `194` 个非静态请求,全部 HTTP `200` | 无非静态网络失败 | +| `Agent 1000` 标签 | 未观察到 | 本轮截图未复现该冗余标签 | +| 新问题 | Skill 补参卡暴露 `auto_analysis/context/preference` 等内部线索;Skills 搜索空态与本地命中并存;高级设置有 `Plan` 等混合英文 / 内部词 | 不阻断官方 qcloop,但应作为 Agent UI 产品化 polish / 信息泄露后续项 | + +该续测只能证明当前桌面 WebView 的 Agent UI 表层可交互,不能替代官方 Evidence Pack。关闭条件仍保持:新的同批次 8/8 P0 structured qcloop Evidence Pack pass、release summary pass、completion audit complete。 + +### 5.2 已解决:Skill 补参卡内部参考信息外露(2026-05-10 21:10) + +| 项 | 结果 | +| --- | --- | +| 原问题 | Skill 补参卡向普通用户暴露 `auto_analysis/context/preference`、`fp:*`、`-32603`、`Pexels API Key` 与 task JSON | +| 修复 | `curatedTaskReferenceSelection` 在参考对象事实源归一化阶段脱敏标题、摘要和 tag | +| 回归 | `CuratedTaskLauncherDialog.test.tsx` + `curatedTaskReferenceSelection.test.ts` 覆盖 UI 文本和 prompt block | +| Playwright evidence | `.lime/qc/gui-evidence/skill-preflight-reference-sanitized-2026-05-10/summary.json`,verdict=`pass` | +| 剩余 | 这不改变官方 qcloop Evidence Pack;整体 blocker 仍是 `real-qcloop-evidence` 未 pass | + +该项从 Agent UI 产品化 blocker 降级为已修复回归项。后续如果要继续治理参考对象列表,还应单独处理“任务 ID”类历史成果摘要是否默认展示给普通用户的问题。 + +### 5.3 已解决:Skills 搜索本地命中时的全局无结果误导(2026-05-10 21:17) + +| 项 | 结果 | +| --- | --- | +| 原问题 | 搜索 `cover` 时右侧本地 Skills 有 `cover_generate` 等命中,但主区域仍显示“当前搜索下暂无结果模板 / Skill 分组” | +| 修复 | `SkillsWorkspacePage` 在右侧有匹配结果时改用“右侧已有可继续的 Skill / 分类暂无匹配但已找到可用 Skill” | +| 回归 | `SkillsWorkspacePage.test.tsx` 覆盖仅命中本地 Skill 的搜索 | +| Playwright evidence | `.lime/qc/gui-evidence/skills-search-local-hit-2026-05-10/summary.json`,verdict=`pass` | +| 剩余 | 不影响官方 qcloop Evidence Pack;整体 blocker 仍是 `real-qcloop-evidence` 未 pass | + +该项从产品 UI/UX blocker 降级为已修复回归项。 + +### 5.4 已解决:高级设置 `Plan` 英文 / 内部词暴露(2026-05-10 21:28) + +| 项 | 结果 | +| --- | --- | +| 原问题 | Agent UI 输入区展开高级设置后显示 `Plan`,普通用户难以理解其含义 | +| 修复 | `InputbarExecutionStrategySelect` 统一改为 `计划执行`,`aria-label` / `title` 改为 `开启计划执行` / `关闭计划执行` | +| 覆盖面 | 首页空态输入区与工作区输入区共用同一个执行策略开关 | +| 回归 | `Inputbar/index.test.tsx`、`EmptyStateComposerPanel.test.tsx`、`EmptyState.test.tsx` 覆盖用户可见中文标签和旧 `Plan` 文案不再出现 | +| Playwright evidence | `.lime/qc/gui-evidence/advanced-settings-plan-label-cn-2026-05-10/summary.json`,verdict=`pass` | +| 剩余 | 不改变官方 qcloop Evidence Pack;整体 blocker 仍是 `real-qcloop-evidence` 未 pass | + +该项从产品 UI/UX polish 降级为已修复回归项。 + +### 5.5 已复核:Design Canvas 工程保存状态当前可观察(2026-05-10 21:35) + +| 项 | 结果 | +| --- | --- | +| 原风险 | fast-mini readonly P0 sidecar 在 `smoke:design-canvas` 的 `project-roundtrip-save-open` 阶段等待 `已保存图层设计工程` 超时 | +| 复测 | 宿主直接执行 `npm run smoke:design-canvas -- --timeout-ms 180000` 通过 | +| 覆盖 | DevBridge ready、设计画布打开、图层交互、工程目录保存 / 打开、平面图拆层、质量导出 manifest | +| Evidence | `.lime/qc/gui-evidence/design-canvas-project-roundtrip-current-2026-05-10/summary.json`,verdict=`pass` | +| 结论 | 当前 host 产品链路可用,旧 fast-mini sidecar 失败更像 qcloop worker / 并发 / 证据层问题;仍不能替代官方 full P0 qcloop pass | +| 剩余 | 整体 blocker 仍是 `real-qcloop-evidence` 未 pass,且 active GUI owner 仍 stale | + +该项从“需定位的产品 UI/UX 风险”降级为当前已复核通过的 sidecar 风险;后续应在单一 GUI owner 下纳入新的 full P0 qcloop 证据,而不是单独宣称发布通过。 + +### 5.6 已复核:Harness Replay / Eval 后端回归当前可运行(2026-05-10 21:39) + +| 项 | 结果 | +| --- | --- | +| 原风险 | full P0 中 `harness-replay-regression` 仍 pending,无法被 stale qcloop owner 调度验证 | +| 复测 | 宿主直接执行 `npm run harness:eval:json` 与 `npm run harness:eval:trend:json`,均 exit `0` | +| 当前 eval | `suiteCount=3`、`caseCount=2`、`readyCount=2`、`invalidCount=0`、`needsHumanReviewCount=0`、`currentObservabilityGapCaseCount=0` | +| Trend 限制 | `sampleCount=1`,只能作为 trend seed,不能判断长期退化 | +| 历史基线 | 未发现既有 `.lime/harness/history` / `reports` 样本;已把本轮结果记录为第一条真实 baseline,并生成 summary / trend / cleanup / dashboard 报告 | +| Evidence | `.lime/qc/backend-evidence/harness-replay-regression-current-2026-05-10/summary.json`,verdict=`pass_with_trend_seed_limit` | +| 剩余 | 仍需 full P0 qcloop verifier 同批次采信;当前 active GUI owner stale,不能直接启动新的 full GUI P0 | + +该项从“pending 未调度”降级为 direct host backend sidecar pass;由于 trend 样本不足和 qcloop 未采信,仍不能关闭 `real-qcloop-evidence` blocker。 + +### 5.7 已复核:Approval / Sandbox live runtime transcript 当前可运行(2026-05-10 22:15) + +| 项 | 结果 | +| --- | --- | +| 原风险 | `tool-approval-sandbox-boundary` 只有 deterministic smoke 或 qcloop worker 自报 PASS,缺少真实 runtime permission request / decision transcript | +| 初始失败 | live runtime 请求没有带 provider/model preference,导致 submit turn 没有形成权限确认 transcript;UI 侧留有 `agent-runtime-create-session-fail-2026-05-10.png` | +| 修复 | `scripts/agent-runtime-approval-sandbox-smoke.mjs` 自动解析本地 enabled provider/model,并支持显式 `--provider-preference` / `--model-preference` | +| 复测 | `node --check scripts/agent-runtime-approval-sandbox-smoke.mjs`、`npx vitest run scripts/lib/agent-runtime-approval-sandbox-smoke-core.test.ts`、`npm run smoke:agent-runtime-approval-sandbox -- --timeout-ms 120000 --output .lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/runtime-approval-sandbox-smoke.fixed.json`、`npm run smoke:agent-runtime-tool-surface`、`npm run agent-qc:check` 均通过 | +| Live assertions | `devBridgeHealthy`、`permissionRequestCreatedBeforeModel`、`deniedDecisionClearsPendingRequest`、`resolvedDecisionClearsPendingRequest`、`approvalPolicySubmitted`、`sandboxPolicySubmitted` 均为 `true` | +| Evidence | `.lime/qc/backend-evidence/approval-sandbox-live-current-2026-05-10/summary.json`,verdict=`pass` | +| 剩余 | 仍需 full P0 qcloop verifier 在同批次采信;当前 active GUI owner `1778405842243079000` 仍 stale,不能新开 full GUI P0 | + +该项从“缺 live runtime transcript”降级为 direct-host backend sidecar pass;它不改变官方 `.lime/qc/agent-qc-evidence.json` 仍为 fail,也不关闭 `real-qcloop-evidence` 和 `local-verify-gate` 两个 completion audit 缺口。 + +### 5.8 当前门禁刷新(2026-05-10 22:20) + +| 门禁 | 当前值 | 结论 | +| --- | --- | --- | +| completion audit | `16/18`,`status=incomplete` | 缺 `real-qcloop-evidence` 与 `local-verify-gate` | +| GUI owner | `blocked`,owner `1778405842243079000` | `browser-runtime-site-adapter` stale 约 `16482s`,3 个 GUI P0 仍未终态 | +| qcloop status | `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 为 `verdict=stale` | 4 success / 1 running / 3 pending / 1 stale | +| local verify | `verify:local` 当前 fail in `typecheck` | `src/components/settings-v2/system/channels/ChannelLogTailPanel.test.tsx(129,51)` 的 `number` / `Timeout` 类型不匹配;本轮为避免 settings-v2 高冲突未修改 | + +因此当前允许继续补 sidecar / 文档 / 低冲突后端证据;不允许启动新的 full GUI P0、处理中断 PID `69738`、改 qcloop DB,或把任何 sidecar pass 写成 release pass。 + +### 5.9 当前门禁刷新(2026-05-10 22:45) + +| 门禁 | 当前值 | 结论 | +| --- | --- | --- | +| completion audit | `16/18`,`status=incomplete` | 缺 `real-qcloop-evidence` 与 `local-verify-gate` | +| GUI owner | `blocked`,owner `1778405842243079000` | `browser-runtime-site-adapter` stale 约 `17692s`,3 个 GUI P0 仍未终态 | +| qcloop status | `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 为 `verdict=stale` | 4 success / 1 running / 3 pending / 1 stale | +| DB lease | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-10T22:56:05+08:00` | stale worker 仍在续约,不能新开 full GUI P0 | +| local verify | `verify:local` fail in `npm test / vitest-smart batch 39/54` | `src/components/agent/chat/hooks/agentStreamSubmissionLifecycle.test.ts:98` 期望缺少 `turnId`,但实现返回 `turnId`;该文件属于当前活动工作树修改,本轮未改 | + +当前仍只允许只读刷新、sidecar 记录和 docs/tests runbook 同步;不允许启动新的 full GUI P0、处理中断 PID `69738`、修改 qcloop DB、覆盖官方 Evidence Pack、或执行 git commit / push / tag / release。 + +### 5.10 当前门禁刷新(2026-05-10 23:40) + +| 门禁 | 当前值 | 结论 | +| --- | --- | --- | +| completion audit | `16/18`,`status=incomplete` | 当前 audit 仍读取旧 `verify-local-current.json`,缺 `real-qcloop-evidence` 与 `local-verify-gate` | +| GUI owner | `blocked`,owner `1778405842243079000` | `browser-runtime-site-adapter` stale 约 `20265s`,仍不能新开 full GUI P0 | +| qcloop status | `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 为 `verdict=stale` | 4 success / 1 running / 3 pending / 1 stale | +| local verify | 新一轮 `verify:local` 仍在运行 | 已过前端 / contracts / Rust 主库 / GUI smoke 前半段,当前卡在 `smoke:agent-service-skill-entry` 的 Rust 定向测试编译;未判 pass/fail | +| running evidence | `.lime/qc/verify-local-2026-05-10-2340-running.md` | 只读记录当前进程与日志,不覆盖最终 gate | + +当前仍只允许只读刷新、sidecar 记录和必要文档同步;不允许启动新的 full GUI P0、处理中断 PID `69738`、修改 qcloop DB、覆盖官方 Evidence Pack、或执行 git commit / push / tag / release。 + +### 5.11 当前门禁刷新(2026-05-10 23:50) + +| 门禁 | 当前值 | 结论 | +| --- | --- | --- | +| local verify | 仍在运行 | 第一条 Skill Forge Rust 定向测试已通过,第二条 `registered_skill_becomes_ready_for_manual_enable_binding_candidate` 正等待 Cargo artifact lock | +| lock context | 并发 Cargo 工作仍活跃 | 观察到 workspace locked test / cargo check / GUI smoke cargo run 等进程;本轮不终止任何进程 | +| evidence | `.lime/qc/verify-local-2026-05-10-2350-cargo-lock.md` | 只读记录,不覆盖最终 `verify-local-current.json` | + +该状态不能关闭 `local-verify-gate`,但也不再等同于 22:39 的 Vitest 失败;最终口径以当前 wrapper 自然结束后写出的 `.lime/qc/verify-local-current.json` 为准。 + +### 5.12 当前门禁刷新(2026-05-10 23:56) + +| 门禁 | 当前值 | 结论 | +| --- | --- | --- | +| completion audit | `16/18`,`status=incomplete` | 缺 `real-qcloop-evidence` 与 `local-verify-gate` | +| local verify | exit `124` | 失败在 `verify:gui-smoke / smoke:agent-service-skill-entry`,第二条 Skill Forge Rust 定向测试等待 lock 后编译但超过 `1830000ms` 超时 | +| 已通过局部 | app-version / lint / typecheck / frontend tests / contracts / Rust 主库 / workspace-ready / browser-runtime / site-adapters / Skill Forge frontend / 第一条 Rust 定向测试 | 说明 22:39 的 Vitest 失败已不是当前 active run 的失败点 | +| qcloop status | `verdict=stale` | 4 success / 1 running / 3 pending / 1 stale | +| GUI owner | `blocked` | stale owner `1778405842243079000`,约 `20878s` | +| evidence | `.lime/qc/verify-local-2026-05-10-2355-gui-smoke-timeout.md`、`.lime/qc/completion-audit-2026-05-10-2356.json` | 当前仍不可发布 | + +`local-verify-gate` 仍不能关闭;下一刀应在不抢 qcloop GUI owner 的前提下,等并发 Cargo 工作结束后重跑 `npm run smoke:agent-service-skill-entry` 或 `npm run verify:gui-smoke -- --reuse-running`,而不是启动新的 full P0。 + +### 2026-05-11 00:20 追加阻断:Claw streaming 首增量 / 停止按钮证据未闭环 + +最新宿主 `verify:gui-smoke -- --reuse-running` 已进入更深层的 Claw streaming 验证。前置 GUI smoke 阶段通过,且 `smoke:claw-chat-ready-streaming` 已自动解析 `deepseek / deepseek-v4-flash`,但长 turn 提交后未能在 smoke 窗口内同时观察到首个流式文本和可见“停止”按钮。 + +证据: + +- `.lime/qc/verify-gui-smoke-current.json` +- `.lime/qc/verify-gui-smoke-reuse-2026-05-11-0006.log` +- `.lime/qc/gui-evidence/claw-chat-ready-streaming/claw-chat-ready-streaming-summary.json` +- `.lime/qc/smoke-claw-chat-ready-streaming-deepseek-chat-2026-05-11-0008.log` +- `.lime/qc/gui-evidence/claw-chat-ready-streaming-deepseek-chat-2026-05-11/claw-chat-ready-streaming-deepseek-chat-summary.json` + +分类: + +| 项 | 当前结论 | +| --- | --- | +| provider/model 解析 | 已解除;默认 smoke 自动选中 `deepseek / deepseek-v4-flash` | +| first streamed delta | 未闭环;默认 run 等待首个流式文本与停止按钮超时 | +| evidence completeness | 已补脚本;失败路径后续会写 console/network/runtime/session/thread 证据 | +| release gate | 仍阻断;不能把 GUI 主路径标记为可交付 | + +本轮只修复 smoke 脚本的证据采集和空快照健壮性,没有重跑完整 GUI smoke;原因是仍有其他 Lime / Cargo / qcloop 进程在运行,且用户明确要求不要推送、不要干预其他进程。关闭条件是:在无并发 GUI owner 阻断的环境下,重跑 `npm run verify:gui-smoke -- --reuse-running` 或等价 qcloop P0 item,取得 `claw-chat-ready-streaming` 的 pass summary,并包含 runtime transcript、interrupt scope、恢复 turn、console/network 摘要。 + +### 2026-05-11 00:25 更新:Claw streaming 在显式 Sensenova provider 下通过 + +00:20 记录的默认 deepseek 首增量失败之后,另一个已经运行中的 `verify:gui-smoke` 使用显式 `custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed / sensenova-6.7-flash-lite` 自然完成并通过。本轮只是观察和记录,没有启动、停止或重启该流程。 + +证据: + +- `.lime/qc/verify-gui-smoke-current.json` +- `.lime/qc/verify-gui-smoke-2026-05-11-0025-sensenova-pass.md` +- `.lime/qc/verify-gui-smoke-reuse-sensenova-2026-05-11-0020.log` +- `.lime/qc/gui-evidence/claw-chat-ready-streaming/claw-chat-ready-streaming-summary.json` + +当前分类调整: + +| 项 | 当前结论 | +| --- | --- | +| Claw streaming smoke | 在显式 Sensenova provider 下已通过,包含流式、停止、中断、恢复和 runtime transcript 证据 | +| 默认 provider path | 仍有 default deepseek run 的失败历史,后续需要决定发布门禁默认 provider 是否也必须通过 | +| `local-verify-gate` | 仍未关闭;完整 `npm run verify:local` sidecar 仍为 fail | +| qcloop official P0 Evidence Pack | 仍未关闭;官方 `.lime/qc/agent-qc-evidence.json` 仍为 fail,且 stale qcloop GUI owner 未释放 | + +因此,本条从“当前 GUI smoke blocker”降级为“provider 口径 / full local verify 未闭环”缺口;它不能替代完整 `verify:local` 和真实 8/8 P0 qcloop Evidence Pack。 diff --git a/docs/tests/lime-agent-qc-evidence-contract.md b/docs/tests/lime-agent-qc-evidence-contract.md new file mode 100644 index 000000000..e7b51a751 --- /dev/null +++ b/docs/tests/lime-agent-qc-evidence-contract.md @@ -0,0 +1,147 @@ +# Lime Agent QC 证据输出契约 + +> 本文定义 Lime 样本产品在 qcloop 中运行 Agent QC 场景时,worker 必须输出的结构化证据格式。它补齐的是“命令跑过但 verifier 无法审查”的缺口:P0 发布门禁只接受可机器复核的 evidence,不接受散文式自评。 + +## 1. 为什么需要契约 + +最近的 P0 qcloop 批次暴露了三类典型问题: + +- worker 运行了正确命令,但只输出“通过”摘要,verifier 看不到 `evidenceRequired` 的逐项证据。 +- worker stdout 为空或只有日志路径,qcloop verifier 只能给出“输出格式错误”或“证据不足”。 +- deterministic smoke 通过后,被误读为已经覆盖 `gui-trace`、`runtime-transcript` 或 `release-artifact`。 + +因此,qcloop worker 的最终 stdout 必须同时满足两层要求: + +1. 人能快速读懂 PASS / FAIL / BLOCKED 与关键缺口。 +2. 机器能稳定解析证据层、命令结果、artifact 路径、失败模式和发布 scope。 + +## 2. 必须输出的 marker + +每个 Agent QC worker 最终 stdout 必须包含两行 marker: + +```text +QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED +QCLOOP_EVIDENCE_SUMMARY_JSON= +``` + +约束: + +- `QCLOOP_WORKER_RESULT=PASS` 只表示 worker 认为场景满足要求;仍需 verifier 独立审查。 +- `QCLOOP_WORKER_RESULT=BLOCKED` 用于 cwd、tmp、DevBridge、CLI 认证、sandbox、loopback 权限等环境阻断。 +- `QCLOOP_EVIDENCE_SUMMARY_JSON=` 必须是单行 JSON object;不要 Markdown、不要代码块、不要前后缀说明。 +- `agent-qc:export-evidence` 会解析该 marker;qcloop item 即使是 `success`,只要缺少该 JSON 或 JSON 无法解析,导出的 Evidence Pack 也必须转为 `fail`。 +- `agent-qc:release-summary --check` 会拒绝只有 `qcloop:*` 引用、缺少非 qcloop artifact / GUI owner / release scope evidenceRef 的 pass 场景。 +- JSON 中不得包含 token、API key、用户私密数据或未经脱敏的完整请求 / 响应正文。 + +## 3. Evidence Summary JSON + +最小结构: + +```json +{ + "scenario_id": "workspace-ready-session-restore", + "result": "pass", + "commands": [ + { + "command": "npm run verify:gui-smoke -- --reuse-running", + "exit_code": 0, + "duration_seconds": 142, + "stdout_artifact": ".lime/qc/gui-smoke.stdout.txt", + "stderr_artifact": ".lime/qc/gui-smoke.stderr.txt" + } + ], + "evidence_layers_covered": ["deterministic-smoke", "gui-trace"], + "evidence_required": [ + { + "name": "DevBridge health", + "status": "pass", + "evidence": "GET http://127.0.0.1:3030/health returned ok", + "artifact_path": ".lime/qc/devbridge-health.json" + } + ], + "failure_modes": [ + { + "name": "parallel GUI smoke interference", + "status": "excluded", + "evidence": "agent-qc:gui-owner-check ownerCount=0 before the run" + } + ], + "artifacts": [ + { + "path": ".lime/qc/gui-trace/workspace-ready.trace.zip", + "kind": "gui-trace", + "redacted": true + } + ], + "blockers": [], + "gui_session_owner": "single-owner:job-1778412738097137000:item-workspace-ready-session-restore", + "release_scope": "source-tree-startup-smoke" +} +``` + +字段口径: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `scenario_id` | 是 | 必须等于 manifest 中的 scenario id。 | +| `result` | 是 | `pass` / `fail` / `blocked`。 | +| `commands[]` | 是 | 记录实际执行命令、退出码、耗时和可审查日志路径。 | +| `evidence_layers_covered[]` | 是 | 只能声明本次真实覆盖的层:`deterministic-smoke`、`gui-trace`、`runtime-transcript`、`release-artifact`。 | +| `evidence_required[]` | 是 | 对 manifest 的 `evidenceRequired` 逐项给出 `pass` / `fail` / `blocked` / `missing`。 | +| `failure_modes[]` | 是 | 对 manifest 的 `failureModes` 逐项给出 `covered` / `excluded` / `hit` / `unknown`。 | +| `artifacts[]` | 是 | 记录截图、trace、transcript、release summary、console/network 摘要等证据路径。 | +| `blockers[]` | 是 | 环境阻断或产品阻断;无阻断时为空数组。 | +| `gui_session_owner` | GUI 场景必填 | 证明当前 GUI / DevBridge 会话没有被其他 qcloop worker 抢占。 | +| `release_scope` | Release 场景必填 | 明确是 `source-tree-startup-smoke`、`installer-artifact-smoke` 还是其他 scope。 | + +## 4. Verifier 判定规则 + +qcloop verifier 必须只输出单个 JSON object: + +```json +{"pass": false, "feedback": "missing runtime transcript", "missingEvidence": ["runtime transcript"], "nextAction": "rerun with transcript artifact"} +``` + +判定规则: + +- stdout 缺少 `QCLOOP_EVIDENCE_SUMMARY_JSON`:`pass=false`。 +- `QCLOOP_WORKER_RESULT=PASS` 但 evidence JSON 有 `missing` / `unknown` / `blocked`:`pass=false`。 +- 命令退出码为 `0` 但 `evidenceRequired` 没逐项满足:`pass=false`。 +- 只覆盖 `deterministic-smoke`,却声明满足 `gui-trace` 或 `runtime-transcript`:`pass=false`。 +- `failure_modes` 没逐项说明覆盖、排除或命中:`pass=false`。 +- GUI 场景缺少 `GUI session owner / isolation statement`:`pass=false` 或 `blocked`,优先排查并发干扰。 +- Release 场景没有明确 `release_scope`:`pass=false`,不能把 source-tree smoke 伪装成 installer artifact 验证。 + +## 5. 与现有入口的关系 + +当前机器标准已经接入: + +```bash +npm run agent-qc:check +npm run agent-qc:qcloop-job -- --risk P0 --max-qc-rounds 1 --check +npm run agent-qc:gui-owner-check -- --check +``` + +执行顺序: + +1. `agent-qc:check` 校验 manifest、P0 evidence layers、qcloop prompt 和 npm script 入口。 +2. `agent-qc:gui-owner-check -- --check` 确认没有 active GUI qcloop owner 后,才能启动新的 GUI P0 批次。 +3. `agent-qc:qcloop-job` 生成的 payload 会把 `evidence_layers` 带入 item,并把结构化证据输出契约追加到 worker prompt。 +4. qcloop worker 执行场景命令并输出 marker。 +5. qcloop verifier 审查 marker、JSON、`evidenceRequired`、`failureModes`、`issue_ledger` 和退出码。 +6. 只有 8/8 P0 item 全部 qcloop `success`,且每个 item 都包含可解析的 `QCLOOP_EVIDENCE_SUMMARY_JSON` 后,才能导出官方 `.lime/qc/agent-qc-evidence.json` 并进入 release summary gate。 + +## 6. 失败沉淀 + +真实失败不能只记录“本轮未过”,必须回写到长期资产: + +| 失败 | 回写资产 | +| --- | --- | +| 缺 `QCLOOP_EVIDENCE_SUMMARY_JSON` | 更新 worker prompt / payload generator / manifest 校验。 | +| verifier 输出 Markdown 导致解析失败 | 收紧 verifier prompt,只允许单个 JSON object。 | +| GUI owner 并发 | 保留 `agent-qc:gui-owner-check` sidecar,重跑前必须 ownerCount=0。 | +| smoke 通过但缺 transcript | 新增 runtime transcript artifact 或 harness replay。 | +| cleanup warning 被 exit=0 隐藏 | 在 evidence JSON 中单独记录 warning classified。 | +| release scope 不清晰 | 明确 source-tree / installer artifact / CI artifact scope。 | + +关闭标准不变:P0 qcloop 8/8 success、8/8 structured evidence summary 可解析,官方 Evidence Pack `pass`,`agent-qc:release-summary --check` 通过,`agent-qc:audit` 为 `complete`。 diff --git a/docs/tests/lime-agent-qc-qcloop-operations.md b/docs/tests/lime-agent-qc-qcloop-operations.md new file mode 100644 index 000000000..5f759a713 --- /dev/null +++ b/docs/tests/lime-agent-qc-qcloop-operations.md @@ -0,0 +1,469 @@ +# Lime Agent QC qcloop 运维手册 + +> 本手册描述 Lime 作为样本产品运行 Agent QC qcloop 批次时的安全操作。目标是让测试 Agent 可以持续观察、导出证据和安排重跑,但不误杀正在运行的 worker,也不把 partial evidence 冒充成发布通过。 + +## 1. 操作原则 + +- **不抢占运行中的 worker**:qcloop item 仍为 `running` 时,不主动 kill / interrupt;只做只读状态查询、sidecar 导出和 blocker 记录。 +- **不覆盖官方 Evidence Pack**:只有 P0 批次覆盖全部 P0 scenario 且 `verdict.status=pass` 时,才写入 `.lime/qc/agent-qc-evidence.json`。 +- **sidecar 只用于排障**:`.lime/qc/agent-qc-evidence.*.json`、`.lime/qc/qcloop-status.*.json` 可以记录当前状态,但不能作为 release gate 证据。 +- **qcloop API 固定 IPv4 loopback**:本地调用使用 `http://127.0.0.1:8080`,避免 `localhost` 代理、IPv6 或浏览器环境差异。 +- **payload 必须显式 cwd**:qcloop 进程不一定从 Lime 仓库启动,生成 job 时必须传 `--cwd /Users/coso/Documents/dev/ai/aiclientproxy/lime`。 + +## 2. 只读状态检查 + +优先使用仓库脚本,而不是手写长 `curl | jq`: + +```bash +npm run agent-qc:qcloop-status -- \ + --base-url "http://127.0.0.1:8080" \ + --job-id "" +``` + +输出 JSON sidecar: + +```bash +npm run agent-qc:qcloop-status -- \ + --base-url "http://127.0.0.1:8080" \ + --job-id "" \ + --format json \ + --output "./.lime/qc/qcloop-status..json" +``` + +默认 `stale-minutes=30`。当 item 仍在 `running`,latest worker 运行超过阈值且 stdout/stderr 为空时,脚本标记为 `stale`,但不会停止进程。需要 CI 或排障脚本显式失败时,才加: + +```bash +npm run agent-qc:qcloop-status -- \ + --job-id "" \ + --fail-on-stale +``` + +## 3. qcloop server 启动环境 + +GUI / DevBridge / browser runtime 场景要求宿主机先有健康的 `127.0.0.1:3030` DevBridge listener,然后 qcloop worker 里的 Codex 也能访问该 listener。如果宿主 shell 自己也无法访问 `http://127.0.0.1:3030/health`,先恢复 DevBridge / headless Tauri,不要误判为 qcloop sandbox。只有宿主 shell 可访问而 worker 内失败时,才优先检查 qcloop serve 进程是否显式配置 Codex sandbox;这类失败表现为 worker 内 `fetch failed` / `Operation not permitted`。出现这种状态时,不要继续提交完整 P0 GUI 批次;先用只读 worker preflight 证明权限恢复。 + +只读检查当前 qcloop serve 环境: + +```bash +ps eww -p "" | tr ' ' '\n' | rg 'QCLOOP_CODEX|QCLOOP_EXECUTOR' +``` + +宿主 DevBridge health 检查: + +```bash +curl --max-time 5 "http://127.0.0.1:3030/health" +lsof -nP -iTCP:3030 -sTCP:LISTEN +``` + +推荐的本地 Agent QC 启动方式: + +```bash +QCLOOP_CODEX_BIN="/Users/coso/.nvm/versions/node/v23.4.0/bin/codex" \ +QCLOOP_CODEX_SANDBOX=off \ +QCLOOP_CODEX_APPROVAL_POLICY=never \ +QCLOOP_CODEX_EXTRA_ARGS="--ephemeral -c 'mcp_servers.context7.command=\"\"' -c 'mcp_servers.playwright.command=\"\"'" \ +./qcloop serve --addr :8080 +``` + +`QCLOOP_CODEX_EXTRA_ARGS="--ephemeral -c 'mcp_servers.context7.command=\"\"' -c 'mcp_servers.playwright.command=\"\"'"` 是当前更可靠的内层 MCP 降噪方式:它保留用户 provider / 认证配置,同时把已知用户级 MCP server 的 command 覆盖为空。`mcp_servers={}` 不足以覆盖嵌套 `mcp_servers.*` 配置;`--ignore-user-config` 也不能作为默认方案,因为它可能跳过本机 provider / 认证配置,让 worker 变成 401 或认证阻断。 + +如果 `QCLOOP_CODEX_BIN` 指向坏 symlink(例如已删除的 Homebrew Cellar 路径),这不是产品失败,应归类为 qcloop worker 环境阻断。`agent-qc:export-evidence` 与 `agent-qc:qcloop-status` 会把 `QCLOOP_CODEX_BIN 不可用`、`QCLOOP_CODEX_EXTRA_ARGS 解析失败`、内层认证错误等归类为 `blocked`,避免把执行器配置错误写成 P0 产品缺陷。 + +如使用 `QCLOOP_CODEX_BYPASS_SANDBOX=true`,必须确认外层环境已经隔离,因为它会让 Codex 跳过 approval 和 sandbox。当前 Lime 样本只记录该方案,不在仍有 qcloop job running 时重启 qcloop。 + +修复后先提交一个只读 worker preflight job,目标只包含 cwd / tmp / DevBridge health,不运行产品 smoke。只有该 job 的 worker stdout 同时包含 `QCLOOP_PREFLIGHT_RESULT=PASS`、`devbridge-health PASS` 和 `QCLOOP_WORKER_RESULT=PASS`,才进入 P0 GUI / browser / release 场景重跑。 + +如果不能重启现有 qcloop serve,可临时启动隔离 qcloop server,必须使用独立 DB 和不同端口,避免影响正在运行的 Web 面板和默认队列: + +```bash +QCLOOP_CODEX_BIN="/Users/coso/Library/PhpWebStudy/env/node/bin/codex" \ +QCLOOP_CODEX_SANDBOX=off \ +QCLOOP_CODEX_APPROVAL_POLICY=never \ +QCLOOP_CODEX_CWD="/Users/coso/Documents/dev/ai/aiclientproxy/lime" \ +./qcloop --db "/Users/coso/Documents/dev/ai/aiclientproxy/lime/.lime/qc/qcloop-isolated-worker-preflight.db" \ + serve --addr "127.0.0.1:18080" --workers 1 +``` + +隔离 server 只用于 sidecar 诊断;除非重新跑完整 P0 并导出覆盖 8/8 P0 的官方 Evidence Pack,否则不能用 isolated partial pass 覆盖 `.lime/qc/agent-qc-evidence.json`。 + +## 4. Worker preflight + +运行 qcloop P0 前,worker 应先证明自己在正确仓库、临时目录可写,并且在 GUI / DevBridge 场景中能访问本地 DevBridge: + +```bash +npm run agent-qc:qcloop-preflight -- \ + --expected-cwd "$(pwd)" \ + --check + +# 需要 DevBridge / GUI / browser runtime 的场景再加: +npm run agent-qc:qcloop-preflight -- \ + --expected-cwd "$(pwd)" \ + --require-devbridge \ + --timeout-ms 15000 \ + --check +``` + +如果 preflight 阻断,worker 必须在 stdout 输出 `QCLOOP_WORKER_RESULT=BLOCKED`,停止后续高成本命令,并把 cwd、health URL、失败 check、stdout/stderr 证据路径写入 stdout。这样 release gate 可以区分“产品失败”和“qcloop worker 环境没有本地 DevBridge 权限”;`agent-qc:export-evidence` 和 `agent-qc:qcloop-status` 会把这种显式 marker 归类为 Evidence Pack `blocked`,即使 qcloop item 因 verifier 多轮失败进入 `exhausted`。 + +## 5. Sidecar evidence 导出 + +qcloop 批次 running / blocked / failed 时,只导出 sidecar: + +```bash +npm run agent-qc:export-evidence -- \ + --base-url "http://127.0.0.1:8080" \ + --job-id "" \ + --output "./.lime/qc/agent-qc-evidence..json" \ + --ref "local-qcloop-" \ + --check +``` + +sidecar 用于回答: + +- 哪些 scenario 已经 `success`、`failed`、`exhausted`、`running`、`pending`。 +- verifier 是否拿到了 worker stdout / exit code / attempt 状态。 +- 当前 blocker 是产品缺陷、证据缺口、环境卡住,还是 qcloop 调度未完成。 + +sidecar 不用于回答“能否发布”。发布只看官方 `.lime/qc/agent-qc-evidence.json`。 + +## 6. Stale item 处理 + +当 `agent-qc:qcloop-status` 标记 `stale`: + +1. 记录 job id、scenario id、worker started_at、stdout/stderr 长度和当前时间。 +2. 导出 `.lime/qc/qcloop-status..json` 和 sidecar Evidence Pack。 +3. 检查是否有已知长编译、GUI bridge 启动或外部运行时阻塞;不要立即归因于产品缺陷。 +4. 不主动 kill;如果必须中止,需要由任务 owner 明确确认。 +5. 当前 job 自然结束后,使用带 `{{stdout}}` / `{{attempt_status}}` / `{{exit_code}}` 的新 payload 重跑失败或卡住场景。 + +必要时只读查看 SQLite lease,确认是队列 / worker 调度卡住还是产品命令已经输出失败: + +```bash +sqlite3 -readonly ".lime/qc/qcloop-isolated-worker-preflight.db" \ + "SELECT id, status, lock_owner, lock_expires_at FROM batch_items WHERE batch_job_id='';" +``` + +只能读取,不要直接改 DB 状态;否则 qcloop 的 attempt / QC 证据链会失真。 + +分类口径: + +| 状态 | 解释 | 下一步 | +| --- | --- | --- | +| `running` 且有 stdout/stderr | 仍可能在正常执行 | 继续观察,必要时导出 sidecar | +| `running` 且长期无输出 | 疑似 worker 卡住或外部进程阻塞 | 记录 stale,不中断;等待或请求 owner 判断 | +| `pending` | 队列尚未调度 | 不做产品结论,等待前序 item 结束 | +| `exhausted` 且 stdout 包含 `QCLOOP_WORKER_RESULT=BLOCKED` | worker 明确证明环境 / 权限前置检查阻断 | 归类为 `blocked`,修 qcloop worker 环境后重跑,不写官方 pass | +| `failed` 且 stderr 包含 `QCLOOP_CODEX_BIN 不可用` / 内层认证或 sandbox 配置错误 | qcloop worker 执行器环境阻断 | 归类为 `blocked`,修 CLI 路径 / 认证 / extra args 后新建隔离批次 | +| `failed` 且 stdout 包含 `QCLOOP_WORKER_RESULT=PASS` | worker 自评通过,但 qcloop 进程或 verifier 未通过 | 归类为 `fail`,记录 `worker_self_report_pass_not_verified`,不要把自评 PASS 当成 release 证据 | +| `exhausted` | worker 或 verifier 未满足 QC | 修产品缺陷或补 evidence,再新建重跑 job | +| `success` | 当前 item 通过 qcloop verifier | 仍需等待全部 P0 success 才能写官方 evidence | + +## 7. 重跑策略 + +重跑只针对终态失败或明确卡住后已释放的场景,避免与仍在 running 的批次抢队列。 + +单场景重跑示例: + +```bash +npm run agent-qc:qcloop-job -- \ + --scenario "tool-approval-sandbox-boundary" \ + --cwd "/Users/coso/Documents/dev/ai/aiclientproxy/lime" \ + --base-url "http://127.0.0.1:8080" \ + --output "./.lime/qc/qcloop-tool-approval-sandbox-rerun-payload.json" \ + --check +``` + +全量 P0 重跑示例: + +```bash +npm run agent-qc:qcloop-job -- \ + --risk P0 \ + --cwd "/Users/coso/Documents/dev/ai/aiclientproxy/lime" \ + --base-url "http://127.0.0.1:8080" \ + --output "./.lime/qc/qcloop-p0-rerun-payload.json" \ + --check +``` + +提交前检查 payload: + +- `prompt_template` 明确要求只读执行,不修改源码、配置、文档、锁文件或 git 状态;Agent QC repair 不应该在 qcloop 内直接修代码。 +- P0 release evidence 批次默认使用 `--max-qc-rounds 1`,避免 qcloop generic repair prompt 把“补证据”误变成“改源码”;需要修复产品问题时另开人工确认的开发任务。 +- `prompt_template` 明确要求切到 Lime cwd 并打印 `pwd`。 +- `verifier_prompt_template` 包含 `{{stdout}}` 或 `{{output}}`。 +- `verifier_prompt_template` 包含 `{{attempt_status}}` 与 `{{exit_code}}`。 +- items 中 `scenario_id` 覆盖目标场景,不混入低优先级 P1/P2。 + +## 8. 当前样本状态(2026-05-10) + +当前 qcloop v5 job:`1778398587521627000`(completed / fail)。 + +宿主 DevBridge 已于 2026-05-10 16:59 后通过 `npm run tauri:dev:headless` 恢复,证据为 `.lime/qc/qcloop-devbridge-health-restored.json`。但随后创建的只读 qcloop worker preflight job `1778403715309891000` 仍为 `completed` / `blocked`:内层 worker cwd/tmp 通过,`devbridge-health` 对 `http://127.0.0.1:3030/health` 仍 `fetch failed`。因此当前主阻断已经收敛为 qcloop worker loopback / sandbox 权限,而不是宿主 DevBridge 未启动。 + +隔离 qcloop server `127.0.0.1:18080` 使用独立 DB 与显式 Codex sandbox 配置后,worker preflight job `1778404260108641000` 已 `completed` / `success`,证明 worker 权限可以恢复。随后四个 P0 sidecar 通过: + +- `workspace-ready-session-restore`:job `1778404364137496000`,`smoke:workspace-ready` + `verify:gui-smoke -- --reuse-running` 通过。 +- `browser-runtime-site-adapter`:job `1778404601640847000`,`smoke:browser-runtime` + `smoke:site-adapters` 通过。 +- `skill-forge-register-bind-enable`:job `1778404743505029000`,`test:contracts` + `smoke:agent-service-skill-entry` 通过。 +- `release-package-startup-smoke`:job `1778405385701480000`,worker preflight + `verify:app-version` + `verify:gui-smoke -- --reuse-running` 通过;scope 明确为 `source-tree-startup-smoke`。 + +`release-package-startup-smoke` isolated v1 job `1778404882904047000` 仍保留为历史 blocked 证据,因为执行时宿主 DevBridge 再次断开;v2 pass 仍不能单独覆盖官方 Evidence Pack。 + +已启动 isolated full P0 v1 job `1778405842243079000` 作为同批次 8/8 P0 尝试。当前只读 sidecar `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` 与 stale check `.lime/qc/qcloop-status.isolated-p0-full-v1-stale-check.json` 显示: + +- `command-bridge-contract`:success。 +- `claw-chat-ready-streaming`:success,但 stdout 明确 scope 为 GUI smoke / DevBridge / runtime surface,不包含 live long-turn interrupt transcript。 +- `tool-approval-sandbox-boundary`:success,但 stdout 明确 scope 为 deterministic smoke,不包含 live runtime transcript。 +- `skill-forge-register-bind-enable`:success。 +- `browser-runtime-site-adapter`:running,当前 attempt 无 stdout/stderr;`--stale-minutes 1` 只读检查已标记 stale;SQLite 只读检查显示 lock owner 为 `qcloop-worker-1`,lease 已被心跳延长到 `2026-05-10T19:00:05+08:00`。 +- `workspace-ready-session-restore`、`harness-replay-regression`、`release-package-startup-smoke`:pending。 + +该批次仍未进入终态;按本手册的 stale item 规则,只能继续只读观察,不 kill、不 pause、不覆盖官方 Evidence Pack。当前卡点更像 worker / provider 调度无输出,而不是产品 smoke 已失败:只读 `ps` 可见 `codex exec` 进程仍在执行该 item,但没有 `npm run smoke:browser-runtime` / `smoke:site-adapters` 子命令输出。必须等待当前 worker 自然结束,或由 owner 明确授权后再处理。 + +为验证是否是内层用户级 MCP server 启动导致 worker 长时间无业务 stdout,后续追加了 no-MCP-attempt 隔离批次: + +- `1778410893606889000`:端口 `127.0.0.1:18081`,因 `QCLOOP_CODEX_BIN` 指向坏 Homebrew symlink 立即失败;该结果归类为 worker CLI 环境阻断,不是产品 P0 失败。 +- `1778410956075020000`:端口 `127.0.0.1:18082`,使用正确 Codex bin 与 `QCLOOP_CODEX_EXTRA_ARGS='--ephemeral -c mcp_servers={}'`;实测仍可能存在用户级 MCP 子进程,因此该批次只证明 CLI 路径和认证已恢复,不证明 MCP 已完全关闭;`command-bridge-contract` 已在 repair attempt 后 success;当前 `claw-chat-ready-streaming` running 且 9 分钟无 stdout/stderr,被 sidecar 标记 stale;后续 6 个 P0 pending。 +- `1778412160003934000`:端口 `127.0.0.1:18083`,使用正确 Codex bin 与 `mcp_servers.context7.command=""` / `mcp_servers.playwright.command=""` 覆盖;初始 `ps` 未观察到用户级 MCP 子进程,`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending。已另行生成只读待用 payload `.lime/qc/qcloop-readonly-p0-v1-payload.json` / `.lime/qc/qcloop-fastmini-readonly-p0-v2-payload.json`,`max_qc_rounds=1`,暂不抢跑。 +- `1778412499745993000`:端口 `127.0.0.1:18084`,在 MCP command 覆盖基础上追加 `--ignore-rules`,避免内层 worker 读取项目规则 / skill 后进入非必要工具链;`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending。已另行生成只读待用 payload `.lime/qc/qcloop-readonly-p0-v1-payload.json` / `.lime/qc/qcloop-fastmini-readonly-p0-v2-payload.json`,`max_qc_rounds=1`,暂不抢跑。 +- `1778412738097137000`:端口 `127.0.0.1:18085`,使用 `gpt-5.4-mini`、low reasoning、MCP command 覆盖、`--ignore-rules`、只读 prompt 与 `max_qc_rounds=1`,目标是在 qcloop 5 分钟 executor timeout 内完成 worker;`command-bridge-contract` 已 exhausted,原因是 verifier 拒绝 no-change surface evidence;已修正 manifest 并生成 v2 payload,当前 `claw-chat-ready-streaming` running,后续 6 个 P0 pending。 + +这说明 qcloop worker 侧还需要同时治理四类问题:CLI 路径 / 认证环境、用户级 MCP 启动策略或隔离配置、worker stdout 可审查性,以及 no-change 场景下 verifier 不能把“没有变更面”误判为“缺少变更面证据”。四者都是测试基础设施问题;在官方 Evidence Pack 通过前,不能把 isolated partial pass 或宿主直跑命令当作 release 证据。 + +这类真实卡点已经回写到机器标准: + +- `docs/test/agent-qc-scenarios.manifest.json` 的 `qcloop-batch-verifier-repair` 要求记录 stale sidecar、worker stdout/stderr 长度摘要、worker CLI environment sidecar 和 inner Codex MCP startup policy,并把 `running no-output stale`、`worker lease heartbeat without stdout`、`worker codex binary unavailable`、`worker auth or sandbox misconfiguration`、`worker user-config MCP startup no-output hang` 作为失败模式。 +- `browser-runtime-site-adapter` 要求把 cleanup warning 单独分类,避免 `exit=0` 把清理问题完全隐藏。 +- `scripts/lib/agent-qc-qcloop-status-core.mjs` 现在会输出 item 级 `staleSeconds` 和 worker `durationSeconds`;当前 full P0 stale sidecar 显示 `browser-runtime-site-adapter staleSeconds=5108 stdoutLength=0 stderrLength=0`,可以直接用于判断“长时间无输出”而不是只看布尔 stale。 +- `scripts/lib/agent-qc-evidence-core.mjs` 现在会把 `QCLOOP_CODEX_BIN 不可用`、extra args 解析失败、内层认证 / sandbox 配置错误归类为 `blocked`,并在 blocker 摘要中使用脱敏的环境阻断说明。 +- `docs/test/agent-qc-scenarios.manifest.json` 的 qcloop worker prompt 要求最终 stdout 逐项列出 `evidence_required` 是否满足,并说明每个 `failure_modes` 如何被覆盖、排除或命中;已生成待用 payload `.lime/qc/qcloop-isolated-nomcp-p0-v3-after-evidence-prompt-payload.json`,但在 v2 仍 running 时不自动抢跑。 +- `scripts/lib/agent-qc-qcloop-job-core.mjs` 会把 manifest 的 `evidenceLayers` 写入 qcloop item 的 `evidence_layers`,worker 必须声明本次覆盖层级;只跑到 `deterministic-smoke` 时不得伪装成 `gui-trace`、`runtime-transcript` 或 `release-artifact`。 + +2026-05-10 19:58 只读刷新显示,当前不应继续开新 full P0 抢资源: + +| 批次 | 只读状态 | 处理 | +| --- | --- | --- | +| `1778405842243079000` isolated full P0 v1 | 4 success / 1 running / 3 pending,`browser-runtime-site-adapter` stale 约 8002 秒 | 继续观察;记录 stale sidecar,不中断 | +| `1778410956075020000` no-MCP P0 v2 | 2 success / 1 failed / 1 running / 4 pending | `tool-approval-sandbox-boundary` 已 failed;该批次不能证明 `mcp_servers={}` 足以关闭嵌套 MCP | +| `1778412160003934000` MCP-disabled P0 v1 | 1 failed / 1 running / 6 pending,`claw-chat-ready-streaming` stale 约 674 秒 | 失败来自旧 no-change verifier;后续 payload 已修正,当前批次不抢占 | +| `1778412499745993000` fast P0 v1 | 1 success / 1 running / 6 pending | `--ignore-rules` 后进入 `claw-chat-ready-streaming`;继续观察 | +| `1778412738097137000` fast-mini readonly P0 v1 | failed,8 exhausted | sidecar evidence 为 6 fail / 2 blocked;verifier 正确拒绝缺 deep evidence 的浅层 smoke 摘要,并额外暴露 `workspace-ready-session-restore` 中 `smoke:design-canvas` 保存成功状态断言失败;`release-package-startup-smoke` 因 GUI smoke 未自然收口被 blocked;用于修正证据分层和产品回归,不覆盖官方 Evidence Pack | + +当 qcloop worker 能快速跑完确定性 smoke 但 verifier 仍拒绝时,不要把拒绝当成坏事。它说明当前输出还缺 live transcript、trace、console/network、cleanup、approval/sandbox 或 SkillTool gate 证据;应拆分并补证据,而不是降低 P0 release gate。 + +GUI P0 需要单一 owner。只要 `.lime/qc/qcloop-status.*.json` 里仍有 GUI / DevBridge 场景 `running` 或 `stale`,不要再启动新的 full P0 GUI 批次;否则多个 worker 复用同一个 `127.0.0.1:1420` / `127.0.0.1:3030` 会话,可能互相导航或抢占按钮点击,导致 `verify:gui-smoke` 失败点失真。新的 payload 已要求 GUI 场景输出 `GUI session owner / isolation statement`,并把 `parallel GUI smoke interference` 作为 failure mode。 + +启动新的 GUI P0 前先跑: + +```bash +npm run agent-qc:gui-owner-check -- --check +``` + +该命令只读扫描 `.lime/qc/qcloop-status.*.json`。如果发现 active GUI owner,会非 0 退出,并列出仍在 running / stale 的 qcloop job 和场景;此时只能继续观察或等待 owner 明确处理,不能再开新的 full P0 GUI 批次。 + +使用 `--format json` 时,报告还会在 stale owner 场景下输出 `ownerIntervention`: + +```json +{ + "ownerIntervention": { + "status": "requires_owner_confirmation", + "requiredConfirmationText": "确认处理 stale GUI owner ,可以终止 PID 并记录 sidecar。", + "prohibitedUntilConfirmed": [ + "kill / pause / interrupt stale worker", + "modify qcloop SQLite DB", + "start another full GUI P0 batch", + "overwrite .lime/qc/agent-qc-evidence.json", + "git commit / push / tag / release" + ] + } +} +``` + +该字段只表示“需要 owner 决策”,不是授权;真实 PID 仍必须来自最新 sidecar / DB / `ps` 取证。 + +`workspace-ready-session-restore` 的细化日志提取见 `.lime/qc/design-canvas-failure-fastmini-workspace-extract.json`,当前失败点是 `smoke:design-canvas` 在 `project-roundtrip-save-open` 阶段等待 `已保存图层设计工程` 文本超时。后续重跑前,应先确认设计画布导出按钮点击后是否能形成可观察的保存中 / 保存成功状态。 + +待 running/stale 批次自然结束后,可优先使用 `.lime/qc/qcloop-p0-evidence-layers-v1-payload.json` 作为下一轮只读 P0 payload。该 payload 只生成不提交,8 个 P0 item 均带 `evidence_layers`,`workspace-ready-session-restore` / `release-package-startup-smoke` 已显式要求 design canvas 工程 roundtrip 和 GUI smoke 自然收口,并保持 `max_qc_rounds=1`,避免 repair prompt 在发布证据批次中修改工作区。 + +当前 CLI gate 复核: + +```bash +npm run agent-qc:qcloop-status -- \ + --base-url "http://127.0.0.1:18080" \ + --job-id "1778405842243079000" \ + --stale-minutes 1 \ + --check-terminal \ + --fail-on-stale +``` + +结果为 `QCLOOP_STATUS_CHECK_EXIT_STATUS=2`,摘要显示 `duration=67m stdout=0 stderr=0 stale=worker 运行 67 分钟且 stdout/stderr 为空`。这证明 stale gate 会以非 0 阻断,不能被误当作通过。 + +宿主 shell 直接执行同一产品命令的结果: + +```bash +npm run agent-qc:qcloop-preflight -- --expected-cwd "$(pwd)" --require-devbridge --timeout-ms 30000 --check +npm run smoke:browser-runtime +npm run smoke:site-adapters +``` + +结果为退出码 `0`:DevBridge health pass,browser runtime smoke 创建 session 并通过,site adapter catalog/list/recommend/search 通过。`smoke:browser-runtime` 同时输出一个非阻断 cleanup warning:`close_cdp_session` 未找到刚创建的 session。该 warning 应进入后续 evidence,但当前 stale 卡点本身仍更像 qcloop worker / provider 没有开始执行命令。 + +另一个 pending P0 `harness-replay-regression` 的宿主直接检查也已通过: + +```bash +npm run harness:eval +npm run harness:eval:trend +``` + +结果为退出码 `0`:`harness:eval` 显示 2/2 ready、invalid=0、current observability gap=0、degraded gap=1;`harness:eval:trend` 显示 samples=1,仅能形成 trend seed。该结果证明宿主命令可跑,但不能替代 qcloop verifier。 + +剩余 pending 的 workspace / release source-tree smoke 也已直接通过: + +```bash +npm run smoke:workspace-ready +npm run verify:app-version +npm run verify:gui-smoke -- --reuse-running +``` + +结果为退出码 `0`:workspace ready、版本一致性、复用 headless Tauri 的 GUI smoke 全部通过;GUI smoke 覆盖 workspace ready、browser runtime、site adapters、service skill entry、runtime tool surface、runtime surface page、knowledge GUI、design canvas。该结果只能证明宿主 source-tree 可用,不能替代 qcloop verifier 或 installer artifact 验证。 + +已知状态: + +- `claw-chat-ready-streaming`:qcloop item 为 `exhausted`,但 worker stdout 明确输出 `QCLOOP_WORKER_RESULT=BLOCKED`;sidecar Evidence Pack 归类为 `blocked`。同日 GUI deep flow 仍有 stop / interrupt 后长 turn completed 落盘的产品 blocker,需在 worker 环境连通后继续验证。 +- `tool-approval-sandbox-boundary`:当前 `failed`;worker stdout 自报 `QCLOOP_WORKER_RESULT=PASS`,确定性 smoke 通过,但 qcloop exit `-1` / verifier 仍判缺 live runtime transcript,不能作为 P0 pass。 +- `skill-forge-register-bind-enable`:当前 `exhausted`;多轮 worker 均停在 DevBridge preflight blocked,sidecar 归类为 `blocked`。 +- `browser-runtime-site-adapter`:当前 `exhausted`;多轮 worker 均停在 DevBridge preflight blocked,sidecar 归类为 `blocked`。 +- `workspace-ready-session-restore`:当前 `exhausted`;多轮 worker 未提供 workspace-ready / GUI smoke / DevBridge 可审查证据,sidecar 归类为 `blocked`。 +- `release-package-startup-smoke`:默认 v5 当前 `exhausted`,sidecar 归类为 `blocked`;隔离 v2 已通过 source-tree startup smoke,但仍需进入同一全量 P0 批次并覆盖官方 Evidence Pack。 +- v4 job `1778392677659787000` 已 `completed` / `fail`,6/6 exhausted;只保留为排障 sidecar,不再作为当前运行批次。 + +当前关闭条件仍是:8/8 P0 scenario 全部 qcloop `success`,官方 `.lime/qc/agent-qc-evidence.json` 为 `pass`,`agent-qc:release-summary --check` 与 `agent-qc:audit` 通过。 + +## 9. 2026-05-10 20:07 状态刷新 + +按用户要求,本轮没有 push、没有 commit、没有 kill / pause / interrupt 任何仍在运行的 qcloop 或 Codex worker。只读刷新结果如下: + +| 批次 | 当前状态 | 结论 | +| --- | --- | --- | +| `1778405842243079000` isolated full P0 v1 | `stale`,4 success / 1 running / 3 pending,`browser-runtime-site-adapter` stale 约 8522 秒 | 仍是唯一 active GUI owner;继续观察,不启动新的 GUI P0。 | +| `1778410956075020000` no-MCP P0 v2 | `fail`,2 success / 6 failed | 终态失败;部分失败来自 worker / verifier 证据不足或 verifier 输出格式错误。 | +| `1778412160003934000` MCP-disabled P0 v1 | `fail`,8 failed | 终态失败;不能作为 MCP-disabled 方案有效证据。 | +| `1778412499745993000` fast P0 v1 | `fail`,1 success / 7 failed | 终态失败;`--ignore-rules` 降噪不足以形成 P0 Evidence Pack。 | +| `1778412738097137000` fast-mini readonly P0 v1 | `fail`,8 exhausted | verifier 正确拒绝浅层 smoke 摘要;同时暴露 design canvas / evidence artifact 缺口。 | + +`agent-qc:gui-owner-check` 输出 `ownerCount=1`、`verdict.status=blocked`,原因是 isolated full P0 v1 仍持有 active GUI owner。因此当前不允许启动新的 full P0 GUI 批次;下一步只能继续只读观察,或在 owner 明确处理后再重跑。 + +本轮新增结构化证据契约,原因是多个终态失败并非单纯产品失败,而是 worker stdout 无法被 verifier 稳定审查。后续新 payload 必须让 worker 输出: + +```text +QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED +QCLOOP_EVIDENCE_SUMMARY_JSON= +``` + +其中 JSON 必须逐项覆盖 `evidenceRequired`、`failureModes`、`evidenceLayers`、命令退出码、artifact 路径、GUI owner 和 release scope。详见 `docs/tests/lime-agent-qc-evidence-contract.md`。 + +2026-05-10 20:18 追加 sidecar 导出复核:使用新版 `agent-qc:export-evidence` 对 `1778405842243079000` 导出 `.lime/qc/agent-qc-evidence.isolated-p0-full-v1-current.json`,结果为 `fail`。除 running / pending 外,前 4 个 qcloop `success` item 也被降级为 `fail`,原因是 stdout 缺少 `QCLOOP_EVIDENCE_SUMMARY_JSON`,不能作为 release pass。这是预期收紧:旧批次的浅层 success 只能作为排障参考,不能覆盖新的结构化证据门禁。 + +2026-05-10 20:20 追加 completion audit 复核:`agent-qc:audit` 已新增 `structured-evidence-contract` 检查项,要求文档、qcloop worker prompt、verifier prompt 与 exporter 同时强制 `QCLOOP_EVIDENCE_SUMMARY_JSON`。当前该项为 pass;整体 audit 变为 15/16,唯一缺口仍是官方 P0 qcloop Evidence Pack 未 pass。 + +2026-05-10 20:21 追加 release gate 收紧:`agent-qc:release-summary --check` 已拒绝只有 `qcloop:*` 引用、缺少非 qcloop artifact / GUI owner / release scope evidenceRef 的 pass 场景。这样即使旧 exporter 或手工 evidence 把场景写成 pass,也不能只凭 qcloop item id 进入发布。 + +2026-05-10 20:23 追加 completion audit gate 同步:`structured-evidence-contract` 现在同时检查 release summary gate 是否拒绝弱 evidenceRefs。当前证据为 `doc=true worker=true verifier=true strictJson=true exporter=true release=true`,整体仍是 15/16,唯一缺口仍是 `real-qcloop-evidence`。 + +2026-05-10 20:25 只读进程核查:`1778405842243079000` 仍有真实内层 `codex exec` 进程存在,PID `69738`,命令仍停在 `browser-runtime-site-adapter` worker prompt,qcloop sidecar 显示该 item 已运行约 9543 秒且 stdout/stderr 仍为 0。因此当前 stale owner 不是单纯旧 sidecar 噪声;仍不能启动新的 GUI P0,也不能在未获 owner 明确确认时 kill / pause / interrupt。 + +同轮增强 `agent-qc:gui-owner-check` 输出:报告现在包含 `staleOwnerCount` 与 `oldestStaleSeconds`,当前 `.lime/qc/gui-owner-current.json` 为 `ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds=9543`,nextAction 明确为继续只读观察或由 owner 确认后处理。 + +2026-05-10 20:26 追加 stale owner intervention artifact:新增 `docs/tests/lime-agent-qc-stale-owner-intervention.md` 与 `.lime/qc/stale-owner-intervention-request.md`。前者固化只读取证、禁止操作、owner 确认格式和确认后的最小闭环;后者记录当前 job `1778405842243079000` / PID `69738` 的干预请求,但未执行任何中断动作。`agent-qc:audit` 已新增 `stale-owner-intervention-protocol`,当前整体为 16/17,唯一缺口仍是 `real-qcloop-evidence`。 + +2026-05-10 20:28 追加 post-stale rerun runbook:`docs/tests/lime-agent-qc-stale-owner-intervention.md` 新增 Owner 清空后的 P0 重跑步骤,并生成 `.lime/qc/post-stale-owner-rerun-plan.md`。已只生成并校验 `.lime/qc/qcloop-p0-structured-evidence-v1-payload.json`,结果 `valid=true`、`itemCount=8`、`maxQcRounds=1`,且 worker / verifier 均包含结构化 evidence 与严格 JSON 约束;未提交 qcloop job。 + +2026-05-10 20:32 追加 qcloop executor timeout 核查:只读检查 `/Users/coso/Documents/dev/ai/limecloud/qcloop/internal/executor/codex.go` 显示 `NewCodexExecutor` 期望 `timeout: 5 * time.Minute`,`runCLICommand` 使用 `exec.CommandContext`。但当前 live PID `69738` 已运行约 165 分钟且 stdout/stderr 仍为 0,说明当前运行中的 qcloop serve / 子进程状态不能简单按源码 timeout 预期判断;可能是旧二进制、孤儿 `codex exec`、或 qcloop runner 未能回收子进程。该情况仍按 stale GUI owner 处理:未获 owner 明确确认前不 kill、不改 DB、不启动新 GUI P0;后续重跑前应确认 qcloop serve 使用带 executor timeout 的当前构建,并保留 `max_qc_rounds=1` 与 structured evidence gate。 + +2026-05-10 20:32 追加 payload retry 控制:`agent-qc:qcloop-job` 已支持 `--max-executor-retries` 并把 `max_executor_retries` 写入 qcloop payload,校验范围为 0-5。post-stale P0 release evidence payload 已重新生成并校验:`itemCount=8`、`max_qc_rounds=1`、`max_executor_retries=0`。这样发布证据批次不会自动重试内层 CLI,避免在当前 stale owner 问题未解决时重复制造孤儿 worker;如需基础设施重试,必须由 owner 在隔离环境中明确改为 1。 + +2026-05-10 20:34 追加 qcloop timeout issue sidecar:已生成 `.lime/qc/qcloop-executor-timeout-issue.md`,把当前 PID `69738` 长时间超过 qcloop 源码 `5 * time.Minute` Codex executor timeout 的现象、可能原因和 qcloop-side 修复建议集中记录。该文件是排障 sidecar,不代表已经修改 qcloop 或处理进程。 + +2026-05-10 20:35 追加 qcloop 本地修复准备:在 `/Users/coso/Documents/dev/ai/limecloud/qcloop` 中准备了 executor timeout process-group cleanup 补丁,新增 Unix/Windows helper 与 Unix regression test,`go test ./internal/executor` 通过。该补丁未提交、未推送、未影响当前运行中的 qcloop serve 或 PID `69738`;要生效仍需 owner 在处理当前 stale worker 后重建/重启 qcloop。 + +2026-05-10 20:36 追加 qcloop 全量 Go 测试:在 `/Users/coso/Documents/dev/ai/limecloud/qcloop` 执行 `go test ./...` 通过,覆盖 `internal/api`、`internal/core`、`internal/db` 与 `internal/executor`。这只验证本地 qcloop 源码补丁,不影响当前运行中的 qcloop serve / PID `69738`。 + +2026-05-10 20:37 追加 qcloop fixed binary sidecar:已从修复后的 qcloop 源码构建 `.lime/qc/bin/qcloop-timeout-fixed`,并执行 `--help` 验证可启动。该 binary 未运行、未替换当前 qcloop serve,只作为 owner 确认处理 stale 后的候选恢复工具。 + +2026-05-10 20:38 追加 qcloop executor race 验证:在 qcloop 仓库执行 `go test -race ./internal/executor` 通过,用于确认新的 `Start` / `Wait` / timeout goroutine 没有暴露明显竞态。 + +2026-05-10 20:40 追加 qcloop 全量 race 验证:在 qcloop 仓库执行 `go test -race ./...` 通过,覆盖 `internal/api`、`internal/core`、`internal/db` 与 `internal/executor`。macOS linker 输出 LC_DYSYMTAB warning,但测试退出码为 0。 + +## 10. 2026-05-10 20:40 安全继续策略 + +当前用户明确要求 Lime 不推送,且本地仍有其他进程在跑。因此本阶段的允许动作只限于只读刷新和文档 / sidecar 同步。 + +最新只读状态: + +| 项目 | 当前值 | +| --- | --- | +| qcloop server | `127.0.0.1:18080` | +| qcloop job | `1778405842243079000` | +| job verdict | `stale` | +| counts | 8 total / 4 success / 1 running / 3 pending / 1 stale | +| stale scenario | `browser-runtime-site-adapter` | +| worker output | stdout/stderr `0 / 0` | +| worker duration | 约 `10508s` | +| observed PID | `69738` | +| GUI owner gate | `blocked`,`ownerCount=1`,`staleOwnerCount=1` | + +安全动作矩阵: + +| 动作 | 当前是否允许 | 说明 | +| --- | --- | --- | +| 刷新 `agent-qc:qcloop-status` sidecar | 允许 | 只读,不改变 qcloop job | +| 刷新 `agent-qc:gui-owner-check` sidecar | 允许 | 只读,用于阻止新 GUI P0 抢占 | +| 更新 `docs/tests` 运行手册 | 允许 | 记录事实和 runbook,不改变运行中 job | +| 导出 partial sidecar evidence | 允许,但不能覆盖官方 evidence | 仅用于排障 | +| 启动新的 full GUI P0 | 禁止 | 当前 GUI owner 未释放 | +| 覆盖 `.lime/qc/agent-qc-evidence.json` | 禁止 | 还没有 8/8 P0 structured evidence pass | +| kill / pause / interrupt PID `69738` | 禁止,除非 owner 明确确认 | 需要按 stale owner 处置协议执行 | +| commit / push / tag / release | 禁止 | 用户明确要求 Lime 不推送 | + +下一次真正能推进 release gate 的动作只有两种: + +1. stale worker 自然结束,且 `npm run agent-qc:gui-owner-check -- --check` 通过。 +2. owner 明确确认处理 stale GUI owner 后,按 `docs/tests/lime-agent-qc-stale-owner-intervention.md` 记录 sidecar、清 owner、再重跑 P0 structured evidence payload。 + +在其中任一条件满足前,不能把当前目标标记为完成。 + +2026-05-10 20:44 只读续刷显示状态未释放:`1778405842243079000` 仍为 `running` / `stale`,`browser-runtime-site-adapter staleSeconds=10772`,stdout/stderr 仍为 `0 / 0`;`agent-qc:gui-owner-check` 仍为 `blocked`,`ownerCount=1`、`staleOwnerCount=1`。本轮仍只能继续观察或等待 owner 明确确认。 + +2026-05-10 20:48 追加 DB / lease 级取证:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1.md` 记录 SQLite 只读结果,active item `1778405842246191000` 仍为 `running`,attempt stdout/stderr 为 `0 / 0`,lease 被 `qcloop-worker-1` 延长到 `2026-05-10T21:02:05+08:00`。进程树显示 PID `69738` 已成为 PPID `1` 的 orphan,但仍在 qcloop PGID `69307` 中,并有 Playwright MCP / Context7 MCP 子进程。该证据用于支持后续 owner 决策,但不授权当前 Agent 直接处理进程。 + +2026-05-10 20:52 追加 runtime binary provenance:`.lime/qc/qcloop-runtime-binary-provenance-18080.md` 记录当前 18080 qcloop serve PID `69307` 仍运行 `/Users/coso/Documents/dev/ai/limecloud/qcloop/qcloop`,而不是 `.lime/qc/bin/qcloop-timeout-fixed`。两者 checksum 不同;因此 qcloop timeout / process-group cleanup 补丁不会影响当前 stale worker。owner 清空当前 GUI owner 后,下一轮隔离 P0 应使用 fixed binary 或等价重建后的 qcloop binary。 + +2026-05-10 21:02 追加 lease 过期窗口后复核:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1-after-expiry.md` 记录等待超过先前 `lock_expires_at=2026-05-10T21:02:05+08:00` 后,active item 仍为 `running`,stdout/stderr 仍为 `0 / 0`,lease 被延长到 `2026-05-10T21:17:05+08:00`。这说明 stale owner 未自然释放,qcloop 仍在续约该 worker;当前仍不能启动新 GUI P0。 + +2026-05-10 21:10 追加 watch history:`.lime/qc/stale-owner-watch-history.jsonl` 以 JSONL 记录 stale owner 的只读观察时间线。后续只读刷新可以继续追加该文件;它只用于证明持续 heartbeat / no-output hang,不构成处理授权。 + +2026-05-10 21:18 追加第二个 lease 窗口复核:qcloop 仍为 `stale`,stdout/stderr 仍为 `0 / 0`,lease 从 `21:17:05` 再次延长到 `21:32:05`,PID `69738` 仍存活。watch history 当前为 4 条。 + +2026-05-10 21:27 追加脚本化 watch history:`agent-qc:gui-owner-check` 支持 `--watch-history-output `,会把当前 GUI owner report 摘要追加为 JSONL。推荐只读续刷命令: + +```bash +npm run agent-qc:gui-owner-check -- \ + --format json \ + --output "./.lime/qc/gui-owner-current.json" \ + --watch-history-output "./.lime/qc/stale-owner-watch-history.jsonl" +``` + +该参数只追加观察记录,不改变 qcloop job、DB 或进程状态。 + +2026-05-10 21:32 追加第三个 lease 窗口复核:qcloop 仍为 `stale`,stdout/stderr 仍为 `0 / 0`,lease 从 `21:32:05` 再次延长到 `21:47:05`,PID `69738` 仍存活。watch history 当前为 6 条。 + +2026-05-10 22:45 追加只读续刷:`qcloop-status.isolated-p0-full-v1-current.json` 仍为 `stale`,job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`;active item `browser-runtime-site-adapter` stdout/stderr 仍为 `0 / 0`,stale 约 `17692s`。SQLite 只读复核显示 `lock_expires_at=2026-05-10T22:56:05+08:00`,PID `69738` 仍存活且 PPID=`1`、PGID=`69307`。已新增 `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.md` 并把 `.lime/qc/stale-owner-watch-history.jsonl` 追加到 14 条。未执行 kill / pause / interrupt / restart,未改 qcloop DB,未启动新 full GUI P0。 + +2026-05-11 01:02 追加只读续刷:`qcloop-status.isolated-p0-full-v1-current.json` 仍为 `stale`,job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`;active item 仍为 `browser-runtime-site-adapter`,stale 约 `26202s`。SQLite 只读复核显示 active item `1778405842246191000` 仍为 `running`,`lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:17:06+08:00`,active attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` stdout/stderr 仍为空。已刷新 `.lime/qc/gui-owner-current.json`、追加 `.lime/qc/stale-owner-watch-history.jsonl`,并更新 `.lime/qc/qcloop-db-lease-isolated-p0-full-v1-latest.json` / `.md`。未执行 kill / pause / interrupt / restart,未改 qcloop DB,未启动新 full GUI P0。 + +2026-05-11 01:13 追加 GUI smoke 复核与 raw process owner:外部启动的 `verify:gui-smoke -- --reuse-running --timeout-ms 240000` 已自然通过,证据为 `.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-0108.log` 与 `.lime/qc/verify-gui-smoke-current.json`。本次 Claw streaming / interrupt / resume summary 为 `verdict=pass`、`recoveryVisibleSource=live-stream`、`interruptedTurnStatus=aborted`、`followTurnStatus=completed`。同时新增 `.lime/qc/gui-process-owner-current.json` 作为 best-effort raw process owner snapshot;当前仍为 `busy`,包含长时间 `smoke:design-canvas`、stale qcloop Codex worker 和多个 Cargo / Rust 进程。因此仍不启动新的完整 `verify:local` 或 full GUI P0,只记录 sidecar。 + +2026-05-11 01:23 追加只读续刷与 raw process owner 脚本化:新增 `npm run agent-qc:process-owner-check`,用于生成 `.lime/qc/gui-process-owner-current.json` 与 `.lime/qc/gui-process-owner-current.md`,避免继续依赖临时 `ps` 片段。最新只读结果仍为 `busy`:`activeGuiSmoke=2`、`cargoOrRust=6`、`qcloopRelated=7`;其中仍包含长时间 `smoke:design-canvas`、stale qcloop Codex worker PID `69738`,以及 Tauri / Cargo / Rust owner。同步刷新后的 qcloop status 仍为 `stale`:job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`,`browser-runtime-site-adapter` stale 约 `27359s`,SQLite lease 被续约到 `2026-05-11T01:38:06+08:00`,active attempt stdout/stderr 仍为空。本轮仍未 kill / pause / interrupt / restart,未改 qcloop DB,未启动新的 full GUI P0 或完整 `verify:local`。 + +2026-05-11 01:28 追加完成审计前只读续刷:`qcloop-status.isolated-p0-full-v1-current.json` 仍为 `stale`,job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`;active item `browser-runtime-site-adapter` stale 约 `27786s`,worker stdout/stderr 仍为 `0 / 0`。`gui-process-owner-current.json` 仍为 `busy`:`activeGuiSmoke=2`、`cargoOrRust=5`、`qcloopRelated=7`。SQLite 只读复核显示 lease 被续约到 `2026-05-11T01:43:06+08:00`。因此仍不能启动完整 `verify:local` 或新的 full GUI P0;completion audit 仍为 `incomplete`,缺口为 `real-qcloop-evidence` 与 `local-verify-gate`。 + +2026-05-11 01:32 追加 DB lease 取证脚本化:新增 `npm run agent-qc:qcloop-db-lease`,用于从 qcloop SQLite DB 只读导出 active item、lease、attempt stdout/stderr 长度与进程快照,替代临时 `sqlite3` 片段。使用该脚本刷新后,job `1778405842243079000` 仍为 `running`,active item `1778405842246191000` 仍是 `browser-runtime-site-adapter`,`lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:47:06+08:00`,active attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` stdout/stderr 仍为空。raw process owner 仍为 `busy`:`activeGuiSmoke=2`、`cargoOrRust=4`、`qcloopRelated=7`。本轮仍未处理 PID `69738`、未改 DB、未启动完整 `verify:local` 或新 full GUI P0。 diff --git a/docs/tests/lime-agent-qc-rollout-plan.md b/docs/tests/lime-agent-qc-rollout-plan.md new file mode 100644 index 000000000..65c5eb923 --- /dev/null +++ b/docs/tests/lime-agent-qc-rollout-plan.md @@ -0,0 +1,213 @@ +# Lime Agent 运营级测试落地计划 + +> 本文件把 `docs/tests/agent-ops-qc.md` 的标准落到 Lime 当前产品上:Lime 只是标准协议的一个实现样本,但它需要在上线运营前证明 Agent Runtime、Agent UI、GUI 桌面壳、工具调用、发布包和 qcloop 证据链都能长期自测。 + +## 1. 建设目标 + +Lime 不能继续依赖“人工点一遍”来判断是否可运营。目标是让每次高风险改动都能自动进入以下闭环: + +```text +Diff / Release Candidate + -> 质量规划器识别风险 + -> qcloop 拆成 Agent QC 场景 + -> npm / Rust / Playwright MCP / Harness 执行 + -> Evidence Pack 落盘 + -> 独立 verifier 判定 + -> 失败样本进入 replay / smoke / regression + -> release gate 只读证据,不读口头结论 +``` + +完成后,人类只审核例外:高风险 waiver、语义争议、生产凭证、无法自动化的外部依赖。 + +## 2. Lime 当前基础 + +| 能力 | 当前入口 | 当前角色 | +| --- | --- | --- | +| 本地智能校验 | `npm run verify:local` | 选择前端、Rust、契约和 smoke 的最低门槛 | +| 桥接契约 | `npm run test:contracts` | 防止前端、Rust、DevBridge、mock、治理目录漂移 | +| GUI 冒烟 | `npm run verify:gui-smoke` | 验证 Tauri 壳、DevBridge、workspace、browser runtime 等主路径 | +| Runtime smoke | `npm run smoke:agent-runtime-tool-surface` | 验证 tool surface、runtime strip、工作台能力摘要 | +| Browser / Site Adapter | `npm run smoke:browser-runtime`、`npm run smoke:site-adapters` | 验证浏览器会话、adapter catalog 和清理边界 | +| Harness eval | `npm run harness:eval`、`npm run harness:eval:trend` | 验证 replay、grader、行为趋势 | +| Agent QC 标准 | `npm run agent-qc:check` | 校验 scenario manifest、GUI flow manifest 和 npm script 引用 | +| qcloop 证据 | `npm run agent-qc:export-evidence` | 把 qcloop job/items 转成 Evidence Pack | +| 发布证据 | `npm run agent-qc:release-summary` | 把 Evidence Pack 汇总到 release note 并作为硬门禁 | + +当前最关键的缺口不是“没有测试命令”,而是还没有把完整 P0 qcloop 批次稳定导出为 `.lime/qc/agent-qc-evidence.json`。 + +## 3. 分阶段落地 + +### Phase 0:证据链打底 + +目标:所有 Agent QC 资产都可被机器校验。 + +执行: + +```bash +npm run agent-qc:report +npm run agent-qc:gui-flow:report +npm run agent-qc:check +npm run agent-qc:audit +``` + +验收: + +- `docs/test/agent-qc-scenarios.manifest.json` 有 P0/P1 场景、lane、命令、证据要求和 verifier。 +- `docs/test/agent-qc-gui-flows.manifest.json` 有 GUI / Playwright MCP 步骤和断言。 +- `docs/test/agent-qc-evidence.schema.json` 固定 Evidence Pack 形状。 +- `npm run test:contracts` 已包含 `agent-qc:check`。 + +### Phase 1:qcloop 最小真实链路 + +目标:不启动重型 GUI、不影响其他本地进程,先证明 qcloop API -> Evidence Pack exporter 可用。 + +建议先读取现有完成批次,或只运行轻量 P0 场景。qcloop 本地 API 建议使用 IPv4 loopback,避免 `localhost` 被代理或 IPv6 解析干扰: + +```bash +npm run agent-qc:qcloop-job -- \ + --scenario command-bridge-contract \ + --cwd "$(pwd)" \ + --base-url "http://127.0.0.1:8080" \ + --output "./.lime/qc/qcloop-command-bridge-job.json" \ + --check +``` + +如果只需要验证历史 qcloop 结果导出,不要写入正式发布门禁路径,先写 partial: + +```bash +npm run agent-qc:export-evidence -- \ + --base-url "http://127.0.0.1:8080" \ + --job-id "" \ + --output "./.lime/qc/agent-qc-evidence.partial.json" \ + --ref "local-qcloop-partial" \ + --check +``` + +验收: + +- partial evidence 的 `verdict.status` 能反映真实 job/item 状态。 +- scenario id 来自 `scenario_id`;历史 job 可退化读取 `name` / `entry`。 +- partial evidence 不能作为 release pass 证据。 + +### Phase 2:P0 全量 Agent QC 批次 + +目标:把 8 个 P0 场景全部交给 qcloop 执行,并导出正式 Evidence Pack。 + +执行: + +```bash +npm run agent-qc:qcloop-job -- \ + --risk P0 \ + --cwd "$(pwd)" \ + --base-url "http://127.0.0.1:8080" \ + --output "./.lime/qc/qcloop-p0-job.json" \ + --check +``` + +外层 Agent 创建并运行 qcloop job 后导出: + +```bash +npm run agent-qc:export-evidence -- \ + --base-url "http://127.0.0.1:8080" \ + --job-id "" \ + --output "./.lime/qc/agent-qc-evidence.json" \ + --ref "" \ + --diff-base "origin/main" \ + --check +``` + +验收: + +- `.lime/qc/agent-qc-evidence.json` 的 `verdict.status` 是 `pass`。 +- `scenarioResults` 覆盖 manifest 中所有 P0 scenario id,而不是只满足数量。 +- `npm run agent-qc:audit` 的 `real-qcloop-evidence` 通过。 + +### Phase 3:GUI / Agent UI 证据增强 + +目标:不只证明命令通过,还要证明 Lime 的 GUI 主路径真的可用。 + +每个 GUI 场景至少记录: + +- DevBridge ready 状态。 +- workspace ready 或 session restore 状态。 +- 操作步骤和最终断言。 +- console error 摘要。 +- network error 摘要。 +- 截图、trace 或结构化快照。 +- mock fallback 是否预期。 + +推荐路径: + +```bash +npm run verify:gui-smoke +npm run agent-qc:gui-flow:report +``` + +需要真实交互时,用 Playwright MCP 复用已有桌面 Chrome 会话,避免打断本地桌面工作流。证据保存到 `.lime/qc/gui-evidence/`,再由 release summary 引用。 + +### Phase 4:Runtime / Tool / Approval 深测 + +目标:让 Lime Agent 不是“能回答”,而是工具链、授权、sandbox 和恢复行为都可靠。 + +重点场景: + +| 场景 | 必须采集的证据 | +| --- | --- | +| 工具调用 | tool name、input 摘要、result / error、timeline | +| approval | request id、decision、拒绝后的恢复行为 | +| sandbox | policy、允许/拒绝边界、危险工具未暴露证据 | +| streaming | first token、interrupt、resume、错误转态 | +| Skill Forge | draft、verify、register、binding、session enable、SkillTool allowlist | +| Browser runtime | session、adapter、console/network、cleanup | + +失败后必须沉淀为至少一种长期资产:Vitest、Rust 定向测试、smoke、harness replay、qcloop scenario 或 Playwright MCP flow。 + +### Phase 5:Release Gate + +目标:发布流程只接受证据,不接受口头结论。 + +发布前必须生成: + +```bash +npm run agent-qc:release-summary -- \ + --evidence "./.lime/qc/agent-qc-evidence.json" \ + --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \ + --require-risk P0 \ + --harness-summary "./.lime/harness/reports/harness-eval-summary.json" \ + --harness-trend "./.lime/harness/reports/harness-eval-trend.json" \ + --tag "" \ + --output "./.lime/qc/release-agent-qc.md" \ + --check +``` + +验收: + +- release workflow 在 Evidence Pack 缺失、非 pass 或 blocker 未处理时失败。 +- release note 包含质量证据摘要。 +- waiver 必须有 owner、原因、过期时间和复测计划。 + +## 4. 让 Lime Agent 更强健的反馈闭环 + +运营级测试的价值不只是“拦发布”,还要持续强化 Agent: + +| 失败类型 | 回写资产 | 强化效果 | +| --- | --- | --- | +| 命令合同漂移 | `test:contracts` / governance catalog | 防止桥接与 mock 分叉 | +| GUI ready 假阳性 | `verify:gui-smoke` / Playwright flow | 防止用户首屏不可用 | +| 工具误用 | runtime smoke / tool permission test | 防止危险工具绕过授权 | +| streaming 卡死 | harness replay / UI regression | 防止长回答或中断后失控 | +| Skill 误启用 | metadata builder test / Rust gate test | 防止 registered 被误认为 executable | +| Browser session 泄漏 | browser runtime smoke | 防止后台资源泄漏 | +| 语义退化 | harness eval / grader | 防止只看命令成功却回答变差 | +| 发布包启动失败 | release startup smoke | 防止源码可跑但安装包不可用 | + +每次 P0 失败修复后,必须问一个问题:这次失败下次能否由机器先发现?如果不能,修复不算闭环。 + +## 5. 当前执行建议 + +在本地还有其他进程运行时,不建议直接启动完整 P0 qcloop 批次或重型 GUI release smoke。更安全的下一刀是: + +1. 用现有完成的 qcloop job 导出 partial evidence,验证 exporter 链路。 +2. 运行 `npm run agent-qc:check` 和 Agent QC 相关定向单测。 +3. 保持 `npm run agent-qc:audit` 为 `incomplete`,直到完整 P0 evidence 真正存在。 +4. 等本地环境空闲后再执行 Phase 2,把 `.lime/qc/agent-qc-evidence.json` 作为正式发布门禁证据。 diff --git a/docs/tests/lime-agent-qc-stale-owner-intervention.md b/docs/tests/lime-agent-qc-stale-owner-intervention.md new file mode 100644 index 000000000..37ae957cd --- /dev/null +++ b/docs/tests/lime-agent-qc-stale-owner-intervention.md @@ -0,0 +1,164 @@ +# Lime Agent QC stale GUI owner 处置协议 + +> 本文定义当 qcloop GUI P0 owner 长时间 stale 时,测试 Agent 应如何继续推进而不越权。核心原则:先证明、再请求 owner 决策;未获明确确认前,不 kill、不 pause、不 interrupt、不改 qcloop DB。 + +## 1. 触发条件 + +满足任一条件时进入本协议: + +- `agent-qc:gui-owner-check -- --check` 返回非 0,且 `staleOwnerCount > 0`。 +- `agent-qc:qcloop-status` 显示 GUI scenario `running` 超过 stale 阈值,且 stdout / stderr 长期为 0。 +- 本机 `ps` 仍能看到对应内层 worker 进程,但该 worker 没有开始执行场景命令或没有业务输出。 + +当前典型状态: + +```text +ownerCount=1 +staleOwnerCount=1 +active scenario=browser-runtime-site-adapter +worker stdoutLength=0 +worker stderrLength=0 +``` + +## 2. 只读证据采集 + +执行以下命令只读刷新 sidecar: + +```bash +npm run agent-qc:qcloop-status -- \ + --base-url "http://127.0.0.1:18080" \ + --job-id "1778405842243079000" \ + --stale-minutes 8 \ + --format json \ + --output "./.lime/qc/qcloop-status.isolated-p0-full-v1-current.json" + +npm run agent-qc:gui-owner-check -- \ + --format json \ + --output "./.lime/qc/gui-owner-current.json" +``` + +可选只读进程核查: + +```bash +ps -eo pid,ppid,etime,stat,command | rg -i "qcloop|codex exec|browser-runtime-site-adapter|1778405842243079000|18080" +``` + +如果可以导出 sidecar Evidence Pack,继续导出但不要覆盖官方 evidence: + +```bash +npm run agent-qc:export-evidence -- \ + --base-url "http://127.0.0.1:18080" \ + --job-id "1778405842243079000" \ + --output "./.lime/qc/agent-qc-evidence.isolated-p0-full-v1-current.json" \ + --ref "local-isolated-p0-full-v1-current" \ + --check +``` + +## 3. 禁止操作 + +未获 owner 明确确认前禁止: + +- `kill` / `pkill` / `killall` 内层 worker 或 qcloop serve。 +- qcloop Web/API 上执行 pause / cancel / retry。 +- 直接修改 qcloop SQLite DB 状态或 lease。 +- 启动新的 full GUI P0 批次。 +- 用 sidecar evidence 覆盖 `.lime/qc/agent-qc-evidence.json`。 + +## 3.1 机器可读决策包 + +当前 stale owner 的机器可读确认包位于 `.lime/qc/stale-owner-intervention-request.json`。该 sidecar 汇总 job、active item、DB lease、PID、binary provenance、证据引用、禁止动作和确认文本;它只用于 owner 决策,不等于授权。 + +同时,`npm run agent-qc:gui-owner-check -- --format json` 会在存在 stale GUI owner 时输出 `ownerIntervention` 字段,包含 `requiredConfirmationText`、`prohibitedUntilConfirmed`、`evidenceRefs` 和下一步动作。该字段是机器可读提示,不等于 owner 已确认。 + +需要连续观察时,加 `--watch-history-output ./.lime/qc/stale-owner-watch-history.jsonl`。该参数只追加 GUI owner report 摘要,不修改 qcloop job、SQLite DB 或进程状态。 + +## 4. Owner 确认格式 + +如果需要处理 stale owner,先生成干预请求,给出影响范围和建议动作。必须看到类似确认后才能继续: + +```text +确认处理 stale GUI owner 1778405842243079000,可以终止 PID 并记录 sidecar。 +``` + +确认前只允许继续完善文档、payload、审计和非侵入式 sidecar。 + +## 5. 获得确认后的最小闭环 + +获得确认后仍按最小动作执行: + +1. 再次导出 qcloop status、GUI owner report 和 Evidence sidecar。 +2. 记录要处理的 PID、job id、scenario id、stale 秒数和 stdout/stderr 长度。 +3. 使用 owner 批准的方式处理 stale worker;优先使用 qcloop 自身安全入口,只有 owner 明确授权时才终止进程。 +4. 处理后再次执行 `agent-qc:gui-owner-check -- --check`。 +5. ownerCount 回到 0 后,才允许使用结构化 evidence payload 重跑 P0。 +6. 新 P0 批次必须输出 `QCLOOP_EVIDENCE_SUMMARY_JSON=`;否则 release summary gate 会继续阻断。 + +## 6. 关闭条件 + +只有以下条件全部满足时,stale owner 处置才算完成: + +- `agent-qc:gui-owner-check -- --check` 通过。 +- 没有 GUI P0 running / stale sidecar。 +- 新 qcloop P0 payload 使用结构化 evidence 契约。 +- 官方 `.lime/qc/agent-qc-evidence.json` 只由新的 8/8 P0 pass 批次导出。 +- `agent-qc:release-summary --check` 与 `agent-qc:audit` 通过。 + +## 7. Owner 清空后的 P0 重跑 runbook + +当 `agent-qc:gui-owner-check -- --check` 已通过,按以下顺序执行,不要跳步: + +```bash +npm run agent-qc:gui-owner-check -- --check + +npm run agent-qc:qcloop-job -- \ + --risk P0 \ + --cwd "/Users/coso/Documents/dev/ai/aiclientproxy/lime" \ + --max-qc-rounds 1 \ + --max-executor-retries 0 \ + --output "./.lime/qc/qcloop-p0-structured-evidence-v1-payload.json" \ + --check +``` + +提交 qcloop job 前必须人工或 Agent 复核 payload: + +- `items.length=8`,覆盖全部 P0。 +- `prompt_template` 包含 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED`。 +- `prompt_template` 包含 `QCLOOP_EVIDENCE_SUMMARY_JSON=`。 +- `verifier_prompt_template` 包含 `{{stdout}}`、`{{attempt_status}}`、`{{exit_code}}`、`{{issue_ledger}}`。 +- `verifier_prompt_template` 明确只输出 `{"pass": true|false, "feedback": "..."}` JSON。 +- `max_qc_rounds=1`,避免发布证据批次 repair 轮修改工作区。 +- `max_executor_retries=0`,发布证据批次不自动重试内层 CLI,避免重复制造 stale worker;如果 owner 明确要保留基础设施重试,可在隔离环境中改为 1。 + +新 job 运行中只读观察: + +```bash +npm run agent-qc:qcloop-status -- \ + --base-url "" \ + --job-id "" \ + --stale-minutes 8 \ + --format json \ + --output "./.lime/qc/qcloop-status..json" +``` + +只有新 job 全部 P0 `success`,且 `agent-qc:export-evidence` 导出的 sidecar 为 `pass`,才允许覆盖官方 evidence: + +```bash +npm run agent-qc:export-evidence -- \ + --base-url "" \ + --job-id "" \ + --output "./.lime/qc/agent-qc-evidence.json" \ + --ref "local-agent-qc-p0" \ + --check + +npm run agent-qc:release-summary -- \ + --evidence "./.lime/qc/agent-qc-evidence.json" \ + --require-scenario-manifest "docs/test/agent-qc-scenarios.manifest.json" \ + --require-risk P0 \ + --tag "local-agent-qc-audit" \ + --output "./.lime/qc/release-agent-qc.current-audit.md" \ + --check + +node scripts/agent-qc-completion-audit.mjs --format json > ".lime/qc/agent-qc-audit-current.json" +``` + +如果 release summary 报缺少结构化 evidenceRefs,不能手工补假路径;应回到 qcloop worker stdout,确认 `QCLOOP_EVIDENCE_SUMMARY_JSON` 的 `artifacts[]`、`gui_session_owner` 和 `release_scope` 是否真实存在。 diff --git a/docs/tests/lime-agent-qc-stale-worker-analysis-2026-05-10.md b/docs/tests/lime-agent-qc-stale-worker-analysis-2026-05-10.md new file mode 100644 index 000000000..24cea6fe7 --- /dev/null +++ b/docs/tests/lime-agent-qc-stale-worker-analysis-2026-05-10.md @@ -0,0 +1,138 @@ +# Lime Agent QC stale worker 根因分析(2026-05-10) + +> 本文件把 `1778405842243079000` 的 stale qcloop worker 取证集中成一个可复用分析样本。它不是 release waiver,也不是处理授权;它用于指导后续 Agent 判断何时可以重跑 P0、何时必须等待 owner 决策。 + +## 1. 结论 + +当前 Lime Agent QC 整体目标未完成的直接原因不是测试标准缺失,而是官方 P0 Evidence Pack 没有真实 8/8 pass。当前阻断项是 isolated full P0 job `1778405842243079000` 中的 `browser-runtime-site-adapter`:worker 长时间 running,但没有 stdout/stderr。 + +截至本轮只读取证,三侧证据一致: + +| 证据侧 | 事实 | 结论 | +| --- | --- | --- | +| qcloop status sidecar | `4 success / 1 running / 3 pending / 1 stale` | P0 批次未完成 | +| SQLite DB | active item / attempt 仍为 `running`,stdout/stderr `0 / 0` | stale 不是旧 sidecar 噪声 | +| process tree | PID `69738` 仍存活,PPID `1`,带 MCP child processes | worker 已孤儿化且未进入产品命令输出 | +| binary provenance | 当前 qcloop serve 不是 `.lime/qc/bin/qcloop-timeout-fixed` | 已准备的 timeout fix 未作用于当前进程 | + +因此不能覆盖 `.lime/qc/agent-qc-evidence.json`,不能启动新的 full GUI P0,也不能把 isolated partial pass 当作 release pass。 + +## 2. 关键证据 + +### 2.1 qcloop job 状态 + +```text +job_id=1778405842243079000 +job_status=running +verdict=stale +counts=8 total / 4 success / 1 running / 3 pending / 1 stale +stale_scenario=browser-runtime-site-adapter +worker_stdout_len=0 +worker_stderr_len=0 +``` + +权威 sidecar: + +- `.lime/qc/qcloop-status.isolated-p0-full-v1-current.json` +- `.lime/qc/gui-owner-current.json` + +### 2.2 DB / lease 取证 + +```text +item_id=1778405842246191000 +scenario_id=browser-runtime-site-adapter +item_status=running +lock_owner=qcloop-worker-1 +lock_expires_at=2026-05-10T21:02:05+08:00 +attempt_id=dc625f8e-b3b9-46b7-9758-4b0273438d50 +attempt_status=running +attempt_started_at=2026-05-10T17:45:05+08:00 +attempt_stdout_len=0 +attempt_stderr_len=0 +``` + +权威 sidecar:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1.md`。 + +2026-05-10 21:02 复核补充:等待超过上述 `lock_expires_at` 后,qcloop 并未自然释放该 item;只读 SQLite 显示 `lock_expires_at` 已延长到 `2026-05-10T21:17:05+08:00`,active attempt 仍为 `running` 且 stdout/stderr `0 / 0`。权威 sidecar:`.lime/qc/qcloop-db-lease-isolated-p0-full-v1-after-expiry.md`。 + +### 2.3 进程树取证 + +```text +qcloop_serve_pid=69307 +qcloop_serve_pgid=69307 +codex_worker_pid=69738 +codex_worker_ppid=1 +codex_worker_pgid=69307 +child_processes=Playwright MCP / Context7 MCP npm exec +``` + +这说明 worker 并非已经退出;它仍然存在,但没有开始输出 `npm run agent-qc:qcloop-preflight`、`smoke:browser-runtime` 或 `smoke:site-adapters` 的业务日志。 + +### 2.4 qcloop binary provenance + +```text +active_binary=/Users/coso/Documents/dev/ai/limecloud/qcloop/qcloop +active_sha256=177bf7fa79212d065c5cb6cc5cdbb153d3079cec3252a157993f70ab35dc5fe1 +fixed_binary=.lime/qc/bin/qcloop-timeout-fixed +fixed_sha256=2895e33d068a7109607d3e45b6e3bebe1807b6f71d387a3b62eb669a3d4314c7 +``` + +权威 sidecar:`.lime/qc/qcloop-runtime-binary-provenance-18080.md`。 + +这说明当前 qcloop serve 未使用 timeout / process-group cleanup fix。即使 fix 已在 qcloop repo 通过测试,它也不会影响当前 running job。 + +## 3. 失败分类 + +当前阻断应归类为: + +```text +failure_mode=worker user-config MCP startup no-output hang +scope=qcloop infrastructure / executor lifecycle +product_verdict=unproven +release_verdict=blocked +``` + +不要把它误判为: + +- `browser-runtime-site-adapter` 产品功能已经失败。 +- `browser-runtime-site-adapter` 产品功能已经通过。 +- qcloop full P0 已经完成。 +- 可用单场景 isolated pass 覆盖官方 Evidence Pack。 + +当前正确口径是:产品命令在宿主 shell 里曾直接通过,但 qcloop worker 内的同批次 P0 证据未形成;release gate 必须继续阻断。 + +## 4. 禁止动作 + +未获 owner 明确确认前禁止: + +- kill / pause / interrupt PID `69738` 或 qcloop serve PID `69307`。 +- 修改 `.lime/qc/qcloop-isolated-worker-preflight.db`。 +- 启动新的 full GUI P0。 +- 覆盖 `.lime/qc/agent-qc-evidence.json`。 +- 用 isolated sidecar pass 发布 release note。 +- git commit / push / tag / release。 + +## 5. 允许动作 + +当前仍允许: + +- 只读刷新 qcloop status sidecar。 +- 只读刷新 GUI owner report。 +- 只读查询 SQLite DB。 +- 只读查询进程树和 binary provenance。 +- 更新 `.lime/qc/*` sidecar 和 `docs/tests` 分析文档。 +- 准备但不提交新的 P0 structured evidence payload。 + +## 6. Owner 清空后的恢复路径 + +当 stale worker 自然退出,或 owner 明确确认处理后,按以下顺序恢复: + +1. 重新运行 `npm run agent-qc:gui-owner-check -- --check`,确认 active GUI owner 为 0。 +2. 使用 `.lime/qc/bin/qcloop-timeout-fixed` 或等价重建后的 qcloop binary,启动隔离 qcloop server 与独立 DB。 +3. 生成或复核 `.lime/qc/qcloop-p0-structured-evidence-v1-payload.json`:8 个 P0、`max_qc_rounds=1`、`max_executor_retries=0`。 +4. 提交 P0 structured evidence job。 +5. 等待 8/8 qcloop `success`。 +6. 只在该 job 真实 pass 后导出 `.lime/qc/agent-qc-evidence.json`。 +7. 运行 release summary gate 与 completion audit。 + +完成条件仍是:`agent-qc:audit` 返回 `complete`,且 release summary 以官方 Evidence Pack 通过。 diff --git a/package-lock.json b/package-lock.json index d55442e55..4f29afb25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.32.0", + "version": "1.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.32.0", + "version": "1.33.0", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", diff --git a/package.json b/package.json index 12d66caab..46f271a58 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.32.0", + "version": "1.33.0", "description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.", "keywords": [ "ai", @@ -64,7 +64,7 @@ "test:watch": "node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs", "test:frontend": "npm run lint && npm run typecheck && npm test", "test:bridge": "npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts", - "test:contracts": "npm run check:agent-runtime-clients && node scripts/check-command-contracts.mjs && node scripts/check-harness-contracts.mjs && npm run governance:modality-contracts && npm run harness:cleanup-report:check", + "test:contracts": "npm run check:agent-runtime-clients && node scripts/check-command-contracts.mjs && node scripts/check-harness-contracts.mjs && npm run governance:modality-contracts && npm run harness:cleanup-report:check && npm run agent-qc:check", "test:rust": "cargo test --manifest-path \"src-tauri/Cargo.toml\"", "harness:analysis": "node scripts/harness-analysis-brief.mjs", "harness:eval": "node scripts/harness-eval-runner.mjs", @@ -78,10 +78,24 @@ "harness:cleanup-report:check": "node scripts/check-generated-slop-report.mjs --generate-current", "harness:doc-freshness": "node scripts/check-doc-freshness.mjs", "harness:doc-freshness:json": "node scripts/check-doc-freshness.mjs --json", + "agent-qc:report": "node scripts/agent-qc-report.mjs", + "agent-qc:report:json": "node scripts/agent-qc-report.mjs --format json", + "agent-qc:gui-flow:report": "node scripts/agent-qc-gui-flow-report.mjs", + "agent-qc:gui-flow:check": "node scripts/agent-qc-gui-flow-report.mjs --check --format json", + "agent-qc:check": "node scripts/agent-qc-report.mjs --check --format json && node scripts/agent-qc-gui-flow-report.mjs --check --format json", + "agent-qc:qcloop-job": "node scripts/agent-qc-qcloop-job.mjs", + "agent-qc:qcloop-status": "node scripts/agent-qc-qcloop-status.mjs", + "agent-qc:qcloop-db-lease": "node scripts/agent-qc-qcloop-db-lease.mjs", + "agent-qc:qcloop-preflight": "node scripts/agent-qc-qcloop-preflight.mjs", + "agent-qc:export-evidence": "node scripts/agent-qc-export-evidence.mjs", + "agent-qc:release-summary": "node scripts/agent-qc-release-summary.mjs", + "agent-qc:audit": "node scripts/agent-qc-completion-audit.mjs", "lint:rust": "cargo clippy --manifest-path \"src-tauri/Cargo.toml\"", "detect-translations": "tsx scripts/detect-missing-translations.ts", "detect-translations:fix": "tsx scripts/detect-missing-translations.ts --fix", "detect-translations:verbose": "tsx scripts/detect-missing-translations.ts --verbose", + "i18n:patch-report": "node scripts/i18n-patch-metrics-report.mjs", + "i18n:patch-report:json": "node scripts/i18n-patch-metrics-report.mjs --format json", "verify:app-version": "node scripts/check-app-version-consistency.mjs", "verify:tasks": "node scripts/quality-task-selector.mjs --format json", "verify:gui-smoke": "node scripts/verify-gui-smoke.mjs", @@ -99,6 +113,7 @@ "smoke:site-adapters": "node scripts/site-adapter-catalog-smoke.mjs", "smoke:agent-runtime-tool-surface": "node scripts/agent-runtime-tool-surface-smoke.mjs", "smoke:agent-runtime-tool-surface-page": "node scripts/agent-runtime-tool-surface-page-smoke.mjs", + "smoke:claw-chat-ready-streaming": "node scripts/claw-chat-ready-streaming-smoke.mjs", "smoke:agent-service-skill-entry": "node scripts/agent-service-skill-entry-smoke.mjs", "smoke:knowledge-gui": "node scripts/knowledge-gui-smoke.mjs", "knowledge:product-e2e": "node scripts/knowledge-product-e2e.mjs", @@ -108,7 +123,10 @@ "dev:web-bridge": "node scripts/start-web-bridge-dev.mjs", "governance:legacy-report": "node scripts/report-legacy-surfaces.mjs", "knowledge:provider-e2e": "node scripts/knowledge-provider-e2e.mjs", - "knowledge:release-scope-report": "node scripts/knowledge-release-scope-report.mjs" + "knowledge:release-scope-report": "node scripts/knowledge-release-scope-report.mjs", + "smoke:agent-runtime-approval-sandbox": "node scripts/agent-runtime-approval-sandbox-smoke.mjs", + "agent-qc:gui-owner-check": "node scripts/agent-qc-gui-owner-check.mjs", + "agent-qc:process-owner-check": "node scripts/agent-qc-process-owner-check.mjs" }, "dependencies": { "@babel/standalone": "^7.29.0", diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index 5095383d9..8135a6db7 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -112,7 +112,7 @@ npm run build:release -- \ ```bash npm run build:release -- \ --target-triple "aarch64-apple-darwin" \ - --version "1.32.0" \ + --version "1.33.0" \ --out-dir "./dist" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index c9b2714d4..52d812722 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.32.0", + "version": "1.33.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/playwright-agentui-after-newline-gate-final-20260509.json b/playwright-agentui-after-newline-gate-final-20260509.json deleted file mode 100644 index 3f7e34c53..000000000 --- a/playwright-agentui-after-newline-gate-final-20260509.json +++ /dev/null @@ -1,635 +0,0 @@ -{ - "consoleErrors": null, - "placeholderText": null, - "hasCompletedThinking": true, - "hasInternalSelfTalk": false, - "bodyTail": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n1分\n新对话\n3分\n新对话\n25分\n新对话\n29分\n尽快显示“思考中”状态是为了消解用户...\n31分\n新对话\n34分\n已完成回答。如有其他问题,随时告诉我...\n39分\n首字前尽快显示思考状态能第一时间确认...\n40分\n已为你整理为三句回答,内容直接说明“...\n42分\n**三步思考:**\n1时\n查看更多对话\n归档\n开\n开源使用\n本地可用\nKnowledge Manual E2E 38362\nHarness\n新对话\n\n请用三句话说明:为什么流式输出要按换行或小批次显示?\n\n已完成思考\n\n流式输出按换行或小批次显示主要是为了避免高频更新DOM导致页面卡顿和视觉闪烁。换行符通常对应语义边界,在此处分批能保证每次显示的文本相对完整,提升阅读体验。适当小批次延迟还能模拟自然对话节奏,给用户留出理解时间,让交互更流畅自然。\n\n已完成\n·\n00:12\n保存到项目资料\n添加资料\n模型\ndeepseek-v4-pro\n高级设置", - "summary": { - "entries": [ - { - "id": 1, - "phase": "agentUi.longTask", - "at": 739.1000000089407, - "wallTime": 1778320226396, - "sessionId": null, - "workspaceId": null, - "source": null, - "metrics": { - "durationMs": 294, - "name": "self", - "startTimeMs": 442.70000000298023, - "usedJSHeapSize": 842309980, - "totalJSHeapSize": 864292744 - } - }, - { - "id": 2, - "phase": "agentRuntime.listSessions.start", - "at": 1408.2000000029802, - "wallTime": 1778320227065, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 11, - "usedJSHeapSize": 872513737, - "totalJSHeapSize": 890716065 - } - }, - { - "id": 3, - "phase": "agentRuntime.listSessions.success", - "at": 1754.1000000089407, - "wallTime": 1778320227411, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 11, - "durationMs": 346, - "sessionsCount": 11, - "usedJSHeapSize": 880190927, - "totalJSHeapSize": 899842987 - } - }, - { - "id": 4, - "phase": "appSidebar.recentConversations.loadBreakdown", - "at": 1754.5, - "wallTime": 1778320227411, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "hasMore": true, - "limit": 11, - "listDurationMs": 346, - "sessionsCount": 11, - "sortDurationMs": 0, - "totalDurationMs": 346, - "visibleCount": 10, - "usedJSHeapSize": 880190927, - "totalJSHeapSize": 899842987 - } - }, - { - "id": 5, - "phase": "agentRuntime.listSessions.start", - "at": 1777.1000000089407, - "wallTime": 1778320227434, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 21, - "usedJSHeapSize": 880190927, - "totalJSHeapSize": 899842987 - } - }, - { - "id": 6, - "phase": "agentRuntime.listSessions.success", - "at": 1886.2000000029802, - "wallTime": 1778320227543, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 21, - "durationMs": 109, - "sessionsCount": 21, - "usedJSHeapSize": 886964131, - "totalJSHeapSize": 902913803 - } - }, - { - "id": 7, - "phase": "sidebar.recentConversations.loadBreakdown", - "at": 1886.6000000089407, - "wallTime": 1778320227543, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "auxiliaryFilterDurationMs": 0, - "hiddenAuxiliarySessionsCount": 0, - "limit": 21, - "listDurationMs": 109, - "sessionsCount": 21, - "topicMapDurationMs": 0, - "topicsCount": 21, - "totalDurationMs": 109, - "workspaceFilterDurationMs": 0, - "workspaceSessionsCount": 21, - "usedJSHeapSize": 886964131, - "totalJSHeapSize": 902913803 - } - }, - { - "id": 8, - "phase": "homeInput.submit", - "at": 49389.40000000596, - "wallTime": 1778320275047, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "hasDraftTab": false, - "inputLength": 26, - "requestId": "draft-send-moy5z61z-b632176f", - "source": "empty-state", - "usedJSHeapSize": 849131145, - "totalJSHeapSize": 857579141 - } - }, - { - "id": 9, - "phase": "homeInput.pendingShellApplied", - "at": 49389.60000000894, - "wallTime": 1778320275047, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 0, - "requestId": "draft-send-moy5z61z-b632176f", - "source": "empty-state", - "usedJSHeapSize": 849131145, - "totalJSHeapSize": 857579141 - } - }, - { - "id": 10, - "phase": "homeInput.pendingPreviewPaint", - "at": 49434.5, - "wallTime": 1778320275092, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 45, - "requestId": "draft-send-moy5z61z-b632176f", - "source": "empty-state", - "usedJSHeapSize": 849131145, - "totalJSHeapSize": 857579141 - } - }, - { - "id": 11, - "phase": "homeInput.sendDispatch.start", - "at": 49434.60000000894, - "wallTime": 1778320275092, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 45, - "requestId": "draft-send-moy5z61z-b632176f", - "source": "empty-state", - "usedJSHeapSize": 849131145, - "totalJSHeapSize": 857579141 - } - }, - { - "id": 12, - "phase": "workspaceSend.plan.ready", - "at": 49441.79999999702, - "wallTime": 1778320275100, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 7, - "hasPendingSessionBinding": false, - "primedSessionId": null, - "requestId": "draft-send-moy5z61z-b632176f", - "source": "empty-state", - "usedJSHeapSize": 851690732, - "totalJSHeapSize": 859507912 - } - }, - { - "id": 13, - "phase": "agentStream.assistantDraft", - "at": 49443.90000000596, - "wallTime": 1778320275102, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "assistantContentLength": 0, - "expectingQueue": false, - "hasAssistantDraftContent": false, - "phase": "routing", - "statusTitle": "快速响应已启用", - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "homeSubmittedDeltaMs": 55, - "usedJSHeapSize": 851690732, - "totalJSHeapSize": 859507912 - } - }, - { - "id": 14, - "phase": "agentStream.ensureSession.start", - "at": 49445.10000000894, - "wallTime": 1778320275103, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "homeSubmittedDeltaMs": 56, - "usedJSHeapSize": 851690732, - "totalJSHeapSize": 859507912 - } - }, - { - "id": 15, - "phase": "agentStream.assistantDraftPaint", - "at": 49463.90000000596, - "wallTime": 1778320275122, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "assistantContentLength": 0, - "expectingQueue": false, - "hasAssistantDraftContent": false, - "phase": "routing", - "statusTitle": "快速响应已启用", - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "homeSubmittedDeltaMs": 75, - "usedJSHeapSize": 851690732, - "totalJSHeapSize": 859507912 - } - }, - { - "id": 16, - "phase": "agentStream.ensureSession.done", - "at": 49515.20000000298, - "wallTime": 1778320275173, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "activeSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "durationMs": 70, - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 126, - "usedJSHeapSize": 849772078, - "totalJSHeapSize": 863355806 - } - }, - { - "id": 17, - "phase": "agentStream.request.start", - "at": 49516.20000000298, - "wallTime": 1778320275174, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "contentLength": 26, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "expectingQueue": false, - "model": "deepseek-chat", - "provider": "deepseek", - "skipUserMessage": false, - "systemPromptLength": 163, - "systemPromptPreview": "你是 Lime 的快速响应助手。当前日期:2026年5月9日。\n本回合是轻量首轮普通对话,请直接", - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 127, - "usedJSHeapSize": 849772078, - "totalJSHeapSize": 863355806 - } - }, - { - "id": 18, - "phase": "agentStream.listenerBound", - "at": 49542.79999999702, - "wallTime": 1778320275201, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 27, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "expectingQueue": false, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 154, - "usedJSHeapSize": 849772078, - "totalJSHeapSize": 863355806 - } - }, - { - "id": 19, - "phase": "agentStream.submitDispatched", - "at": 49543.70000000298, - "wallTime": 1778320275202, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 28, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "expectingQueue": false, - "listenerBoundDeltaMs": 1, - "model": "deepseek-chat", - "provider": "deepseek", - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 155, - "usedJSHeapSize": 849772078, - "totalJSHeapSize": 863355806 - } - }, - { - "id": 20, - "phase": "agentStream.submitAccepted", - "at": 50006.70000000298, - "wallTime": 1778320275665, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 490, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "submitInvokeMs": 462, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 618, - "usedJSHeapSize": 852504645, - "totalJSHeapSize": 863797681 - } - }, - { - "id": 21, - "phase": "homeInput.sendDispatch.done", - "at": 50007.70000000298, - "wallTime": 1778320275665, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 618, - "requestId": "draft-send-moy5z61z-b632176f", - "result": true, - "source": "empty-state", - "usedJSHeapSize": 852504645, - "totalJSHeapSize": 863797681 - } - }, - { - "id": 22, - "phase": "agentStream.firstEvent", - "at": 50314.5, - "wallTime": 1778320275972, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 798, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "eventType": "runtime_status", - "recognized": true, - "submissionDispatchedDeltaMs": 770, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 925, - "usedJSHeapSize": 851619235, - "totalJSHeapSize": 864354991 - } - }, - { - "id": 23, - "phase": "agentStream.firstRuntimeStatus", - "at": 50316.10000000894, - "wallTime": 1778320275974, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 800, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "firstEventDeltaMs": 2, - "phase": "preparing", - "title": "已接收请求,正在准备执行", - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 927, - "usedJSHeapSize": 851619235, - "totalJSHeapSize": 864354991 - } - }, - { - "id": 24, - "phase": "agentStream.firstThinkingDelta", - "at": 53084.79999999702, - "wallTime": 1778320278743, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "deltaChars": 4, - "elapsedMs": 3569, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "firstEventDeltaMs": 2771, - "firstRuntimeStatusDeltaMs": 2769, - "surfaced": false, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 3696, - "usedJSHeapSize": 856004274, - "totalJSHeapSize": 870653242 - } - }, - { - "id": 25, - "phase": "agentStream.firstTextDelta", - "at": 62674.60000000894, - "wallTime": 1778320288333, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "deltaChars": 1, - "elapsedMs": 13159, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "firstEventDeltaMs": 12361, - "firstRuntimeStatusDeltaMs": 12359, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 13286, - "usedJSHeapSize": 856005497, - "totalJSHeapSize": 872366473 - } - }, - { - "id": 26, - "phase": "agentStream.firstTextPaint", - "at": 62846.5, - "wallTime": 1778320288505, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 13331, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "firstTextDeltaDeltaMs": 172, - "renderFlushDeltaMs": 23, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 13458, - "usedJSHeapSize": 856879995, - "totalJSHeapSize": 872895319 - } - }, - { - "id": 27, - "phase": "agentRuntime.getSession.start", - "at": 64991.10000000894, - "wallTime": 1778320290649, - "sessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "usedJSHeapSize": 862910768, - "totalJSHeapSize": 885006348 - } - }, - { - "id": 28, - "phase": "agentRuntime.getSession.success", - "at": 65203.29999999702, - "wallTime": 1778320290862, - "sessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "childSubagentSessionsCount": 0, - "durationMs": 213, - "itemsCount": 4, - "messagesCount": 2, - "queuedTurnsCount": 0, - "turnsCount": 1, - "usedJSHeapSize": 864840299, - "totalJSHeapSize": 885057687 - } - } - ], - "sessions": [ - { - "sessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "workspaceId": null, - "runtimeGetSessionDurationMs": 213, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 1, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 864840299, - "phases": [ - "agentRuntime.getSession.start", - "agentRuntime.getSession.success" - ] - }, - { - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "homeInputToPendingShellMs": 0, - "homeInputToPendingPreviewPaintMs": 45, - "homeInputToSendDispatchMs": 45, - "homeInputToSendPlanReadyMs": 52, - "homeInputToAssistantDraftMs": 55, - "homeInputToAssistantDraftPaintMs": 75, - "homeInputToStreamRequestStartMs": 127, - "homeInputToSubmitAcceptedMs": 617, - "homeInputToFirstEventMs": 925, - "homeInputToFirstRuntimeStatusMs": 927, - "homeInputToFirstThinkingDeltaMs": 3695, - "homeInputToFirstTextDeltaMs": 13285, - "homeInputToFirstTextPaintMs": 13457, - "sendDispatchToSubmitAcceptedMs": 572, - "streamSubmitDispatchedToAcceptedMs": 463, - "submitAcceptedToFirstEventMs": 308, - "firstEventToFirstThinkingDeltaMs": 2770, - "firstEventToFirstTextDeltaMs": 12360, - "firstThinkingDeltaToFirstTextDeltaMs": 9590, - "firstTextDeltaToFirstTextPaintMs": 172, - "streamEnsureSessionDurationMs": 70, - "streamSubmitInvokeDurationMs": 462, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 0, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 856879995, - "phases": [ - "homeInput.submit", - "homeInput.pendingShellApplied", - "homeInput.pendingPreviewPaint", - "homeInput.sendDispatch.start", - "workspaceSend.plan.ready", - "agentStream.assistantDraft", - "agentStream.ensureSession.start", - "agentStream.assistantDraftPaint", - "agentStream.ensureSession.done", - "agentStream.request.start", - "agentStream.listenerBound", - "agentStream.submitDispatched", - "agentStream.submitAccepted", - "homeInput.sendDispatch.done", - "agentStream.firstEvent", - "agentStream.firstRuntimeStatus", - "agentStream.firstThinkingDelta", - "agentStream.firstTextDelta", - "agentStream.firstTextPaint" - ] - } - ] - } -} \ No newline at end of file diff --git a/playwright-agentui-after-newline-gate-immediate-20260509.json b/playwright-agentui-after-newline-gate-immediate-20260509.json deleted file mode 100644 index d17ff0a94..000000000 --- a/playwright-agentui-after-newline-gate-immediate-20260509.json +++ /dev/null @@ -1,524 +0,0 @@ -{ - "placeholderText": null, - "bodyTail": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n1分\n新对话\n3分\n新对话\n25分\n新对话\n29分\n尽快显示“思考中”状态是为了消解用户...\n31分\n新对话\n34分\n已完成回答。如有其他问题,随时告诉我...\n39分\n首字前尽快显示思考状态能第一时间确认...\n40分\n已为你整理为三句回答,内容直接说明“...\n42分\n**三步思考:**\n1时\n查看更多对话\n归档\n开\n开源使用\n本地可用\nKnowledge Manual E2E 38362\nHarness\n新对话\n\n请用三句话说明:为什么流式输出要按换行或小批次显示?\n\n思考中\n思考中\n\n我们需要\n\n稍后处理\n添加资料\n模型\ndeepseek-v4-pro\n高级设置", - "summary": { - "entries": [ - { - "id": 1, - "phase": "agentUi.longTask", - "at": 739.1000000089407, - "wallTime": 1778320226396, - "sessionId": null, - "workspaceId": null, - "source": null, - "metrics": { - "durationMs": 294, - "name": "self", - "startTimeMs": 442.70000000298023, - "usedJSHeapSize": 842309980, - "totalJSHeapSize": 864292744 - } - }, - { - "id": 2, - "phase": "agentRuntime.listSessions.start", - "at": 1408.2000000029802, - "wallTime": 1778320227065, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 11, - "usedJSHeapSize": 872513737, - "totalJSHeapSize": 890716065 - } - }, - { - "id": 3, - "phase": "agentRuntime.listSessions.success", - "at": 1754.1000000089407, - "wallTime": 1778320227411, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 11, - "durationMs": 346, - "sessionsCount": 11, - "usedJSHeapSize": 880190927, - "totalJSHeapSize": 899842987 - } - }, - { - "id": 4, - "phase": "appSidebar.recentConversations.loadBreakdown", - "at": 1754.5, - "wallTime": 1778320227411, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "hasMore": true, - "limit": 11, - "listDurationMs": 346, - "sessionsCount": 11, - "sortDurationMs": 0, - "totalDurationMs": 346, - "visibleCount": 10, - "usedJSHeapSize": 880190927, - "totalJSHeapSize": 899842987 - } - }, - { - "id": 5, - "phase": "agentRuntime.listSessions.start", - "at": 1777.1000000089407, - "wallTime": 1778320227434, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 21, - "usedJSHeapSize": 880190927, - "totalJSHeapSize": 899842987 - } - }, - { - "id": 6, - "phase": "agentRuntime.listSessions.success", - "at": 1886.2000000029802, - "wallTime": 1778320227543, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 21, - "durationMs": 109, - "sessionsCount": 21, - "usedJSHeapSize": 886964131, - "totalJSHeapSize": 902913803 - } - }, - { - "id": 7, - "phase": "sidebar.recentConversations.loadBreakdown", - "at": 1886.6000000089407, - "wallTime": 1778320227543, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "auxiliaryFilterDurationMs": 0, - "hiddenAuxiliarySessionsCount": 0, - "limit": 21, - "listDurationMs": 109, - "sessionsCount": 21, - "topicMapDurationMs": 0, - "topicsCount": 21, - "totalDurationMs": 109, - "workspaceFilterDurationMs": 0, - "workspaceSessionsCount": 21, - "usedJSHeapSize": 886964131, - "totalJSHeapSize": 902913803 - } - }, - { - "id": 8, - "phase": "homeInput.submit", - "at": 49389.40000000596, - "wallTime": 1778320275047, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "hasDraftTab": false, - "inputLength": 26, - "requestId": "draft-send-moy5z61z-b632176f", - "source": "empty-state", - "usedJSHeapSize": 849131145, - "totalJSHeapSize": 857579141 - } - }, - { - "id": 9, - "phase": "homeInput.pendingShellApplied", - "at": 49389.60000000894, - "wallTime": 1778320275047, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 0, - "requestId": "draft-send-moy5z61z-b632176f", - "source": "empty-state", - "usedJSHeapSize": 849131145, - "totalJSHeapSize": 857579141 - } - }, - { - "id": 10, - "phase": "homeInput.pendingPreviewPaint", - "at": 49434.5, - "wallTime": 1778320275092, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 45, - "requestId": "draft-send-moy5z61z-b632176f", - "source": "empty-state", - "usedJSHeapSize": 849131145, - "totalJSHeapSize": 857579141 - } - }, - { - "id": 11, - "phase": "homeInput.sendDispatch.start", - "at": 49434.60000000894, - "wallTime": 1778320275092, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 45, - "requestId": "draft-send-moy5z61z-b632176f", - "source": "empty-state", - "usedJSHeapSize": 849131145, - "totalJSHeapSize": 857579141 - } - }, - { - "id": 12, - "phase": "workspaceSend.plan.ready", - "at": 49441.79999999702, - "wallTime": 1778320275100, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 7, - "hasPendingSessionBinding": false, - "primedSessionId": null, - "requestId": "draft-send-moy5z61z-b632176f", - "source": "empty-state", - "usedJSHeapSize": 851690732, - "totalJSHeapSize": 859507912 - } - }, - { - "id": 13, - "phase": "agentStream.assistantDraft", - "at": 49443.90000000596, - "wallTime": 1778320275102, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "assistantContentLength": 0, - "expectingQueue": false, - "hasAssistantDraftContent": false, - "phase": "routing", - "statusTitle": "快速响应已启用", - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "homeSubmittedDeltaMs": 55, - "usedJSHeapSize": 851690732, - "totalJSHeapSize": 859507912 - } - }, - { - "id": 14, - "phase": "agentStream.ensureSession.start", - "at": 49445.10000000894, - "wallTime": 1778320275103, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "homeSubmittedDeltaMs": 56, - "usedJSHeapSize": 851690732, - "totalJSHeapSize": 859507912 - } - }, - { - "id": 15, - "phase": "agentStream.assistantDraftPaint", - "at": 49463.90000000596, - "wallTime": 1778320275122, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "assistantContentLength": 0, - "expectingQueue": false, - "hasAssistantDraftContent": false, - "phase": "routing", - "statusTitle": "快速响应已启用", - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "homeSubmittedDeltaMs": 75, - "usedJSHeapSize": 851690732, - "totalJSHeapSize": 859507912 - } - }, - { - "id": 16, - "phase": "agentStream.ensureSession.done", - "at": 49515.20000000298, - "wallTime": 1778320275173, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "activeSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "durationMs": 70, - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 126, - "usedJSHeapSize": 849772078, - "totalJSHeapSize": 863355806 - } - }, - { - "id": 17, - "phase": "agentStream.request.start", - "at": 49516.20000000298, - "wallTime": 1778320275174, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "contentLength": 26, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "expectingQueue": false, - "model": "deepseek-chat", - "provider": "deepseek", - "skipUserMessage": false, - "systemPromptLength": 163, - "systemPromptPreview": "你是 Lime 的快速响应助手。当前日期:2026年5月9日。\n本回合是轻量首轮普通对话,请直接", - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 127, - "usedJSHeapSize": 849772078, - "totalJSHeapSize": 863355806 - } - }, - { - "id": 18, - "phase": "agentStream.listenerBound", - "at": 49542.79999999702, - "wallTime": 1778320275201, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 27, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "expectingQueue": false, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 154, - "usedJSHeapSize": 849772078, - "totalJSHeapSize": 863355806 - } - }, - { - "id": 19, - "phase": "agentStream.submitDispatched", - "at": 49543.70000000298, - "wallTime": 1778320275202, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 28, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "expectingQueue": false, - "listenerBoundDeltaMs": 1, - "model": "deepseek-chat", - "provider": "deepseek", - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 155, - "usedJSHeapSize": 849772078, - "totalJSHeapSize": 863355806 - } - }, - { - "id": 20, - "phase": "agentStream.submitAccepted", - "at": 50006.70000000298, - "wallTime": 1778320275665, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 490, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "submitInvokeMs": 462, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 618, - "usedJSHeapSize": 852504645, - "totalJSHeapSize": 863797681 - } - }, - { - "id": 21, - "phase": "homeInput.sendDispatch.done", - "at": 50007.70000000298, - "wallTime": 1778320275665, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 618, - "requestId": "draft-send-moy5z61z-b632176f", - "result": true, - "source": "empty-state", - "usedJSHeapSize": 852504645, - "totalJSHeapSize": 863797681 - } - }, - { - "id": 22, - "phase": "agentStream.firstEvent", - "at": 50314.5, - "wallTime": 1778320275972, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 798, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "eventType": "runtime_status", - "recognized": true, - "submissionDispatchedDeltaMs": 770, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 925, - "usedJSHeapSize": 851619235, - "totalJSHeapSize": 864354991 - } - }, - { - "id": 23, - "phase": "agentStream.firstRuntimeStatus", - "at": 50316.10000000894, - "wallTime": 1778320275974, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 800, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "firstEventDeltaMs": 2, - "phase": "preparing", - "title": "已接收请求,正在准备执行", - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 927, - "usedJSHeapSize": 851619235, - "totalJSHeapSize": 864354991 - } - }, - { - "id": 24, - "phase": "agentStream.firstThinkingDelta", - "at": 53084.79999999702, - "wallTime": 1778320278743, - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "deltaChars": 4, - "elapsedMs": 3569, - "eventName": "aster_stream_28f9ad24-c6be-4f84-926e-9622d86761bb", - "firstEventDeltaMs": 2771, - "firstRuntimeStatusDeltaMs": 2769, - "surfaced": false, - "source": "empty-state", - "requestId": "draft-send-moy5z61z-b632176f", - "actualSessionId": "bbf39174-08df-408c-8262-7a309c3b09a6", - "homeSubmittedDeltaMs": 3696, - "usedJSHeapSize": 856004274, - "totalJSHeapSize": 870653242 - } - } - ], - "sessions": [ - { - "sessionId": "draft-send-moy5z61z-b632176f", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "homeInputToPendingShellMs": 0, - "homeInputToPendingPreviewPaintMs": 45, - "homeInputToSendDispatchMs": 45, - "homeInputToSendPlanReadyMs": 52, - "homeInputToAssistantDraftMs": 55, - "homeInputToAssistantDraftPaintMs": 75, - "homeInputToStreamRequestStartMs": 127, - "homeInputToSubmitAcceptedMs": 617, - "homeInputToFirstEventMs": 925, - "homeInputToFirstRuntimeStatusMs": 927, - "homeInputToFirstThinkingDeltaMs": 3695, - "sendDispatchToSubmitAcceptedMs": 572, - "streamSubmitDispatchedToAcceptedMs": 463, - "submitAcceptedToFirstEventMs": 308, - "firstEventToFirstThinkingDeltaMs": 2770, - "streamEnsureSessionDurationMs": 70, - "streamSubmitInvokeDurationMs": 462, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 0, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 856004274, - "phases": [ - "homeInput.submit", - "homeInput.pendingShellApplied", - "homeInput.pendingPreviewPaint", - "homeInput.sendDispatch.start", - "workspaceSend.plan.ready", - "agentStream.assistantDraft", - "agentStream.ensureSession.start", - "agentStream.assistantDraftPaint", - "agentStream.ensureSession.done", - "agentStream.request.start", - "agentStream.listenerBound", - "agentStream.submitDispatched", - "agentStream.submitAccepted", - "homeInput.sendDispatch.done", - "agentStream.firstEvent", - "agentStream.firstRuntimeStatus", - "agentStream.firstThinkingDelta" - ] - } - ] - } -} \ No newline at end of file diff --git a/playwright-agentui-perf-shape-20260509.json b/playwright-agentui-perf-shape-20260509.json deleted file mode 100644 index 0027d97ec..000000000 --- a/playwright-agentui-perf-shape-20260509.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "type": "object", - "keys": [ - "entries", - "clear", - "summary" - ], - "sample": {} -} \ No newline at end of file diff --git a/playwright-deepseek-after-reasoning-fix-20260509.json b/playwright-deepseek-after-reasoning-fix-20260509.json deleted file mode 100644 index e566fc53f..000000000 --- a/playwright-deepseek-after-reasoning-fix-20260509.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "url": "http://127.0.0.1:1420/", - "tail": [ - "Lime", - "邀请好友", - "搜索任务", - "新建任务", - "Skills", - "灵感", - "项目资料", - "最近对话", - "好", - "17分", - "新对话", - "26分", - "新对话", - "28分", - "新对话", - "30分", - "新对话", - "33分", - "Ran into this erro...", - "36分", - "好", - "38分", - "新对话", - "1时", - "好", - "1时", - "Ran into this erro...", - "2时", - "查看更多对话", - "归档", - "开", - "开源使用", - "本地可用", - "Knowledge Manual E2E 38362", - "Harness", - "新对话", - "只回答一个字:好", - "好", - "已完成", - "·", - "00:01", - "添加资料", - "模型", - "deepseek-v4-pro", - "高级设置" - ], - "containsReasoningHeading": false, - "containsReasoningText": false, - "containsRanIntoError": false, - "containsRetryText": false, - "projectionTail": null, - "localStoragePref": { - "provider": "\"deepseek\"", - "model": "\"deepseek-v4-pro\"", - "backup": null - } -} \ No newline at end of file diff --git a/playwright-deepseek-perf-20260509.json b/playwright-deepseek-perf-20260509.json deleted file mode 100644 index cdcbc86f0..000000000 --- a/playwright-deepseek-perf-20260509.json +++ /dev/null @@ -1,477 +0,0 @@ -{ - "summary": { - "entries": [ - { - "id": 1, - "phase": "homeInput.submit", - "at": 1031750.8999999985, - "wallTime": 1778306573822, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "hasDraftTab": false, - "inputLength": 8, - "requestId": "draft-send-moxxti4e-f13111a9", - "source": "empty-state", - "usedJSHeapSize": 404318882, - "totalJSHeapSize": 409574158 - } - }, - { - "id": 2, - "phase": "homeInput.pendingShellApplied", - "at": 1031751.1000000015, - "wallTime": 1778306573822, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 0, - "requestId": "draft-send-moxxti4e-f13111a9", - "source": "empty-state", - "usedJSHeapSize": 404318882, - "totalJSHeapSize": 409574158 - } - }, - { - "id": 3, - "phase": "homeInput.pendingPreviewPaint", - "at": 1031803.1000000015, - "wallTime": 1778306573874, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 52, - "requestId": "draft-send-moxxti4e-f13111a9", - "source": "empty-state", - "usedJSHeapSize": 405980766, - "totalJSHeapSize": 413862358 - } - }, - { - "id": 4, - "phase": "homeInput.sendDispatch.start", - "at": 1031803.200000003, - "wallTime": 1778306573874, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 52, - "requestId": "draft-send-moxxti4e-f13111a9", - "source": "empty-state", - "usedJSHeapSize": 405980766, - "totalJSHeapSize": 413862358 - } - }, - { - "id": 5, - "phase": "workspaceSend.plan.ready", - "at": 1031812.200000003, - "wallTime": 1778306573883, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 9, - "hasPendingSessionBinding": false, - "primedSessionId": null, - "requestId": "draft-send-moxxti4e-f13111a9", - "source": "empty-state", - "usedJSHeapSize": 405980766, - "totalJSHeapSize": 413862358 - } - }, - { - "id": 6, - "phase": "agentStream.assistantDraft", - "at": 1031817.799999997, - "wallTime": 1778306573888, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "assistantContentLength": 0, - "expectingQueue": false, - "hasAssistantDraftContent": false, - "phase": "routing", - "statusTitle": "快速响应已启用", - "source": "empty-state", - "requestId": "draft-send-moxxti4e-f13111a9", - "homeSubmittedDeltaMs": 66, - "usedJSHeapSize": 405980766, - "totalJSHeapSize": 413862358 - } - }, - { - "id": 7, - "phase": "agentStream.ensureSession.start", - "at": 1031818.8999999985, - "wallTime": 1778306573890, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-moxxti4e-f13111a9", - "homeSubmittedDeltaMs": 68, - "usedJSHeapSize": 405980766, - "totalJSHeapSize": 413862358 - } - }, - { - "id": 8, - "phase": "agentUi.longTask", - "at": 1031839.700000003, - "wallTime": 1778306573910, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "durationMs": 50, - "name": "self", - "startTimeMs": 1031749.5, - "usedJSHeapSize": 405980766, - "totalJSHeapSize": 413862358 - } - }, - { - "id": 9, - "phase": "agentStream.assistantDraftPaint", - "at": 1031849.3999999985, - "wallTime": 1778306573920, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "assistantContentLength": 0, - "expectingQueue": false, - "hasAssistantDraftContent": false, - "phase": "routing", - "statusTitle": "快速响应已启用", - "source": "empty-state", - "requestId": "draft-send-moxxti4e-f13111a9", - "homeSubmittedDeltaMs": 98, - "usedJSHeapSize": 405980766, - "totalJSHeapSize": 413862358 - } - }, - { - "id": 10, - "phase": "agentStream.ensureSession.done", - "at": 1031857.700000003, - "wallTime": 1778306573928, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "activeSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "durationMs": 38, - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-moxxti4e-f13111a9", - "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "homeSubmittedDeltaMs": 106, - "usedJSHeapSize": 406885565, - "totalJSHeapSize": 414776105 - } - }, - { - "id": 11, - "phase": "agentStream.request.start", - "at": 1031858.3999999985, - "wallTime": 1778306573929, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "contentLength": 8, - "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328", - "expectingQueue": false, - "model": "deepseek-chat", - "provider": "deepseek", - "skipUserMessage": false, - "systemPromptLength": 163, - "systemPromptPreview": "你是 Lime 的快速响应助手。当前日期:2026年5月9日。\n本回合是轻量首轮普通对话,请直接", - "source": "empty-state", - "requestId": "draft-send-moxxti4e-f13111a9", - "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "homeSubmittedDeltaMs": 107, - "usedJSHeapSize": 406885565, - "totalJSHeapSize": 414776105 - } - }, - { - "id": 12, - "phase": "agentStream.listenerBound", - "at": 1031886.5, - "wallTime": 1778306573957, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 28, - "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328", - "expectingQueue": false, - "source": "empty-state", - "requestId": "draft-send-moxxti4e-f13111a9", - "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "homeSubmittedDeltaMs": 135, - "usedJSHeapSize": 406885565, - "totalJSHeapSize": 414776105 - } - }, - { - "id": 13, - "phase": "agentStream.submitDispatched", - "at": 1031886.799999997, - "wallTime": 1778306573957, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 28, - "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328", - "expectingQueue": false, - "listenerBoundDeltaMs": 0, - "model": "deepseek-chat", - "provider": "deepseek", - "source": "empty-state", - "requestId": "draft-send-moxxti4e-f13111a9", - "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "homeSubmittedDeltaMs": 135, - "usedJSHeapSize": 406885565, - "totalJSHeapSize": 414776105 - } - }, - { - "id": 14, - "phase": "agentStream.submitAccepted", - "at": 1031961, - "wallTime": 1778306574032, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 102, - "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328", - "submitInvokeMs": 74, - "source": "empty-state", - "requestId": "draft-send-moxxti4e-f13111a9", - "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "homeSubmittedDeltaMs": 210, - "usedJSHeapSize": 409902058, - "totalJSHeapSize": 416864258 - } - }, - { - "id": 15, - "phase": "homeInput.sendDispatch.done", - "at": 1031961.299999997, - "wallTime": 1778306574032, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 210, - "requestId": "draft-send-moxxti4e-f13111a9", - "result": true, - "source": "empty-state", - "usedJSHeapSize": 409902058, - "totalJSHeapSize": 416864258 - } - }, - { - "id": 16, - "phase": "agentStream.firstEvent", - "at": 1031980.5, - "wallTime": 1778306574051, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 122, - "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328", - "eventType": "runtime_status", - "recognized": true, - "submissionDispatchedDeltaMs": 94, - "source": "empty-state", - "requestId": "draft-send-moxxti4e-f13111a9", - "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "homeSubmittedDeltaMs": 229, - "usedJSHeapSize": 409902058, - "totalJSHeapSize": 416864258 - } - }, - { - "id": 17, - "phase": "agentStream.firstRuntimeStatus", - "at": 1031981, - "wallTime": 1778306574052, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 123, - "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328", - "firstEventDeltaMs": 1, - "phase": "preparing", - "title": "已接收请求,正在准备执行", - "source": "empty-state", - "requestId": "draft-send-moxxti4e-f13111a9", - "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "homeSubmittedDeltaMs": 230, - "usedJSHeapSize": 409902058, - "totalJSHeapSize": 416864258 - } - }, - { - "id": 18, - "phase": "agentStream.firstTextDelta", - "at": 1034232.1000000015, - "wallTime": 1778306576303, - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "deltaChars": 1, - "elapsedMs": 2374, - "eventName": "aster_stream_219aa323-50ee-4d32-8fc1-3c1978360328", - "firstEventDeltaMs": 2252, - "firstRuntimeStatusDeltaMs": 2251, - "source": "empty-state", - "requestId": "draft-send-moxxti4e-f13111a9", - "actualSessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "homeSubmittedDeltaMs": 2481, - "usedJSHeapSize": 410747015, - "totalJSHeapSize": 420551075 - } - }, - { - "id": 19, - "phase": "agentRuntime.getSession.start", - "at": 1034933.200000003, - "wallTime": 1778306577004, - "sessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "usedJSHeapSize": 410207946, - "totalJSHeapSize": 420553286 - } - }, - { - "id": 20, - "phase": "agentRuntime.getSession.success", - "at": 1034964.799999997, - "wallTime": 1778306577035, - "sessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "childSubagentSessionsCount": 0, - "durationMs": 31, - "itemsCount": 4, - "messagesCount": 2, - "queuedTurnsCount": 0, - "turnsCount": 1, - "usedJSHeapSize": 410207946, - "totalJSHeapSize": 420553286 - } - } - ], - "sessions": [ - { - "sessionId": "8f375476-bce0-41b9-b6f7-04862ccddd17", - "workspaceId": null, - "runtimeGetSessionDurationMs": 31, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 1, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 410207946, - "phases": [ - "agentRuntime.getSession.start", - "agentRuntime.getSession.success" - ] - }, - { - "sessionId": "draft-send-moxxti4e-f13111a9", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "homeInputToPendingShellMs": 0, - "homeInputToPendingPreviewPaintMs": 52, - "homeInputToSendDispatchMs": 52, - "homeInputToSendPlanReadyMs": 61, - "homeInputToAssistantDraftMs": 67, - "homeInputToAssistantDraftPaintMs": 99, - "homeInputToStreamRequestStartMs": 108, - "homeInputToSubmitAcceptedMs": 210, - "homeInputToFirstEventMs": 230, - "homeInputToFirstRuntimeStatusMs": 230, - "homeInputToFirstTextDeltaMs": 2481, - "sendDispatchToSubmitAcceptedMs": 158, - "streamSubmitDispatchedToAcceptedMs": 74, - "submitAcceptedToFirstEventMs": 20, - "firstEventToFirstTextDeltaMs": 2252, - "streamEnsureSessionDurationMs": 38, - "streamSubmitInvokeDurationMs": 74, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 0, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 1, - "longTaskMaxMs": 50, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 410747015, - "phases": [ - "homeInput.submit", - "homeInput.pendingShellApplied", - "homeInput.pendingPreviewPaint", - "homeInput.sendDispatch.start", - "workspaceSend.plan.ready", - "agentStream.assistantDraft", - "agentStream.ensureSession.start", - "agentUi.longTask", - "agentStream.assistantDraftPaint", - "agentStream.ensureSession.done", - "agentStream.request.start", - "agentStream.listenerBound", - "agentStream.submitDispatched", - "agentStream.submitAccepted", - "homeInput.sendDispatch.done", - "agentStream.firstEvent", - "agentStream.firstRuntimeStatus", - "agentStream.firstTextDelta" - ] - } - ] - }, - "current": { - "hasRanInto": false, - "hasPleaseRetry": false, - "hasPermissionFallback": false, - "hasGood": true, - "bodyTail": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\nRan into this erro...\n17分\n好\n20分\n新对话\n52分\n好\n54分\nRan into this erro...\n1时\n新对话\n1时\n新对话\n1时\n新对话\n1时\n新对话\n2时\n新对话\n2时\n查看更多对话\n归档\n开\n开源使用\n本地可用\nKnowledge Manual E2E 38362\nHarness\n新对话\n\n只回答一个字:好\n\n已完成思考\n我们被要求只回答一个字:好。直接回复即可。\n\n好\n\n已完成\n·\n00:02\n添加资料\n模型\ndeepseek-v4-pro\n高级设置" - } -} \ No newline at end of file diff --git a/playwright-deepseek-perf-after-reasoning-fix-20260509.json b/playwright-deepseek-perf-after-reasoning-fix-20260509.json deleted file mode 100644 index 1ec0deee5..000000000 --- a/playwright-deepseek-perf-after-reasoning-fix-20260509.json +++ /dev/null @@ -1,584 +0,0 @@ -{ - "entriesType": "function", - "entriesIsArray": false, - "entriesLength": null, - "summaryType": "function", - "summary": { - "entries": [ - { - "id": 1, - "phase": "agentUi.longTask", - "at": 481.20000000298023, - "wallTime": 1778307552467, - "sessionId": null, - "workspaceId": null, - "source": null, - "metrics": { - "durationMs": 237, - "name": "self", - "startTimeMs": 243.30000000447035, - "usedJSHeapSize": 447504202, - "totalJSHeapSize": 474277134 - } - }, - { - "id": 2, - "phase": "agentRuntime.listSessions.start", - "at": 1204, - "wallTime": 1778307553189, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 11, - "usedJSHeapSize": 477410622, - "totalJSHeapSize": 498620686 - } - }, - { - "id": 3, - "phase": "agentRuntime.listSessions.start", - "at": 1211.4000000059605, - "wallTime": 1778307553197, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 21, - "usedJSHeapSize": 477410622, - "totalJSHeapSize": 498620686 - } - }, - { - "id": 4, - "phase": "agentRuntime.listSessions.success", - "at": 1352.9000000059605, - "wallTime": 1778307553338, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 11, - "durationMs": 149, - "sessionsCount": 11, - "usedJSHeapSize": 482634552, - "totalJSHeapSize": 499059748 - } - }, - { - "id": 5, - "phase": "appSidebar.recentConversations.loadBreakdown", - "at": 1353.2000000029802, - "wallTime": 1778307553339, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "hasMore": true, - "limit": 11, - "listDurationMs": 150, - "sessionsCount": 11, - "sortDurationMs": 0, - "totalDurationMs": 150, - "visibleCount": 10, - "usedJSHeapSize": 482634552, - "totalJSHeapSize": 499059748 - } - }, - { - "id": 6, - "phase": "agentRuntime.listSessions.success", - "at": 1393.6000000014901, - "wallTime": 1778307553379, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 21, - "durationMs": 182, - "sessionsCount": 21, - "usedJSHeapSize": 482634552, - "totalJSHeapSize": 499059748 - } - }, - { - "id": 7, - "phase": "sidebar.recentConversations.loadBreakdown", - "at": 1394.1000000014901, - "wallTime": 1778307553380, - "sessionId": null, - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": null, - "metrics": { - "auxiliaryFilterDurationMs": 0, - "hiddenAuxiliarySessionsCount": 0, - "limit": 21, - "listDurationMs": 182, - "sessionsCount": 21, - "topicMapDurationMs": 0, - "topicsCount": 21, - "totalDurationMs": 183, - "workspaceFilterDurationMs": 1, - "workspaceSessionsCount": 21, - "usedJSHeapSize": 482634552, - "totalJSHeapSize": 499059748 - } - }, - { - "id": 8, - "phase": "homeInput.submit", - "at": 126288.20000000298, - "wallTime": 1778307678271, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "hasDraftTab": false, - "inputLength": 8, - "requestId": "draft-send-moxyh6bj-26676ae0", - "source": "empty-state", - "usedJSHeapSize": 445444712, - "totalJSHeapSize": 452310960 - } - }, - { - "id": 9, - "phase": "homeInput.pendingShellApplied", - "at": 126288.30000000447, - "wallTime": 1778307678271, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 0, - "requestId": "draft-send-moxyh6bj-26676ae0", - "source": "empty-state", - "usedJSHeapSize": 445444712, - "totalJSHeapSize": 452310960 - } - }, - { - "id": 10, - "phase": "homeInput.pendingPreviewPaint", - "at": 126317.5, - "wallTime": 1778307678300, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 29, - "requestId": "draft-send-moxyh6bj-26676ae0", - "source": "empty-state", - "usedJSHeapSize": 445444712, - "totalJSHeapSize": 452310960 - } - }, - { - "id": 11, - "phase": "homeInput.sendDispatch.start", - "at": 126317.60000000149, - "wallTime": 1778307678301, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 30, - "requestId": "draft-send-moxyh6bj-26676ae0", - "source": "empty-state", - "usedJSHeapSize": 445444712, - "totalJSHeapSize": 452310960 - } - }, - { - "id": 12, - "phase": "workspaceSend.plan.ready", - "at": 126323.60000000149, - "wallTime": 1778307678307, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 6, - "hasPendingSessionBinding": false, - "primedSessionId": null, - "requestId": "draft-send-moxyh6bj-26676ae0", - "source": "empty-state", - "usedJSHeapSize": 445444712, - "totalJSHeapSize": 452310960 - } - }, - { - "id": 13, - "phase": "agentStream.assistantDraft", - "at": 126325.40000000596, - "wallTime": 1778307678308, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "assistantContentLength": 0, - "expectingQueue": false, - "hasAssistantDraftContent": false, - "phase": "routing", - "statusTitle": "快速响应已启用", - "source": "empty-state", - "requestId": "draft-send-moxyh6bj-26676ae0", - "homeSubmittedDeltaMs": 37, - "usedJSHeapSize": 445444712, - "totalJSHeapSize": 452310960 - } - }, - { - "id": 14, - "phase": "agentStream.ensureSession.start", - "at": 126326.10000000149, - "wallTime": 1778307678309, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-moxyh6bj-26676ae0", - "homeSubmittedDeltaMs": 38, - "usedJSHeapSize": 445444712, - "totalJSHeapSize": 452310960 - } - }, - { - "id": 15, - "phase": "agentStream.assistantDraftPaint", - "at": 126350.80000000447, - "wallTime": 1778307678334, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "assistantContentLength": 0, - "expectingQueue": false, - "hasAssistantDraftContent": false, - "phase": "routing", - "statusTitle": "快速响应已启用", - "source": "empty-state", - "requestId": "draft-send-moxyh6bj-26676ae0", - "homeSubmittedDeltaMs": 63, - "usedJSHeapSize": 449665756, - "totalJSHeapSize": 458391272 - } - }, - { - "id": 16, - "phase": "agentStream.ensureSession.done", - "at": 126360.30000000447, - "wallTime": 1778307678343, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "activeSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "durationMs": 34, - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-moxyh6bj-26676ae0", - "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "homeSubmittedDeltaMs": 72, - "usedJSHeapSize": 449665756, - "totalJSHeapSize": 458391272 - } - }, - { - "id": 17, - "phase": "agentStream.request.start", - "at": 126360.80000000447, - "wallTime": 1778307678344, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "contentLength": 8, - "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871", - "expectingQueue": false, - "model": "deepseek-chat", - "provider": "deepseek", - "skipUserMessage": false, - "systemPromptLength": 163, - "systemPromptPreview": "你是 Lime 的快速响应助手。当前日期:2026年5月9日。\n本回合是轻量首轮普通对话,请直接", - "source": "empty-state", - "requestId": "draft-send-moxyh6bj-26676ae0", - "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "homeSubmittedDeltaMs": 73, - "usedJSHeapSize": 449665756, - "totalJSHeapSize": 458391272 - } - }, - { - "id": 18, - "phase": "agentStream.listenerBound", - "at": 126387.10000000149, - "wallTime": 1778307678370, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 26, - "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871", - "expectingQueue": false, - "source": "empty-state", - "requestId": "draft-send-moxyh6bj-26676ae0", - "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "homeSubmittedDeltaMs": 99, - "usedJSHeapSize": 449665756, - "totalJSHeapSize": 458391272 - } - }, - { - "id": 19, - "phase": "agentStream.submitDispatched", - "at": 126387.60000000149, - "wallTime": 1778307678371, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 27, - "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871", - "expectingQueue": false, - "listenerBoundDeltaMs": 1, - "model": "deepseek-chat", - "provider": "deepseek", - "source": "empty-state", - "requestId": "draft-send-moxyh6bj-26676ae0", - "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "homeSubmittedDeltaMs": 100, - "usedJSHeapSize": 449665756, - "totalJSHeapSize": 458391272 - } - }, - { - "id": 20, - "phase": "agentStream.submitAccepted", - "at": 126464.80000000447, - "wallTime": 1778307678448, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 104, - "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871", - "submitInvokeMs": 77, - "source": "empty-state", - "requestId": "draft-send-moxyh6bj-26676ae0", - "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "homeSubmittedDeltaMs": 177, - "usedJSHeapSize": 449486441, - "totalJSHeapSize": 457357513 - } - }, - { - "id": 21, - "phase": "homeInput.sendDispatch.done", - "at": 126465.10000000149, - "wallTime": 1778307678448, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "durationMs": 177, - "requestId": "draft-send-moxyh6bj-26676ae0", - "result": true, - "source": "empty-state", - "usedJSHeapSize": 449486441, - "totalJSHeapSize": 457357513 - } - }, - { - "id": 22, - "phase": "agentStream.firstEvent", - "at": 126486.40000000596, - "wallTime": 1778307678469, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 125, - "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871", - "eventType": "runtime_status", - "recognized": true, - "submissionDispatchedDeltaMs": 98, - "source": "empty-state", - "requestId": "draft-send-moxyh6bj-26676ae0", - "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "homeSubmittedDeltaMs": 198, - "usedJSHeapSize": 449486441, - "totalJSHeapSize": 457357513 - } - }, - { - "id": 23, - "phase": "agentStream.firstRuntimeStatus", - "at": 126487.5, - "wallTime": 1778307678470, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "elapsedMs": 126, - "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871", - "firstEventDeltaMs": 1, - "phase": "preparing", - "title": "已接收请求,正在准备执行", - "source": "empty-state", - "requestId": "draft-send-moxyh6bj-26676ae0", - "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "homeSubmittedDeltaMs": 199, - "usedJSHeapSize": 449486441, - "totalJSHeapSize": 457357513 - } - }, - { - "id": 24, - "phase": "agentStream.firstTextDelta", - "at": 128384.90000000596, - "wallTime": 1778307680368, - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "source": "empty-state", - "metrics": { - "deltaChars": 1, - "elapsedMs": 2024, - "eventName": "aster_stream_1cfe20bd-c753-45a9-a12c-0d184d9b2871", - "firstEventDeltaMs": 1899, - "firstRuntimeStatusDeltaMs": 1898, - "source": "empty-state", - "requestId": "draft-send-moxyh6bj-26676ae0", - "actualSessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "homeSubmittedDeltaMs": 2097, - "usedJSHeapSize": 449919337, - "totalJSHeapSize": 462355177 - } - }, - { - "id": 25, - "phase": "agentRuntime.getSession.start", - "at": 128537.5, - "wallTime": 1778307680520, - "sessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "usedJSHeapSize": 451476819, - "totalJSHeapSize": 462357291 - } - }, - { - "id": 26, - "phase": "agentRuntime.getSession.success", - "at": 128573.20000000298, - "wallTime": 1778307680556, - "sessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "childSubagentSessionsCount": 0, - "durationMs": 36, - "itemsCount": 4, - "messagesCount": 2, - "queuedTurnsCount": 0, - "turnsCount": 1, - "usedJSHeapSize": 451476819, - "totalJSHeapSize": 462357291 - } - } - ], - "sessions": [ - { - "sessionId": "66834731-bd61-4b3f-b326-3a3eb81cf686", - "workspaceId": null, - "runtimeGetSessionDurationMs": 36, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 1, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 451476819, - "phases": [ - "agentRuntime.getSession.start", - "agentRuntime.getSession.success" - ] - }, - { - "sessionId": "draft-send-moxyh6bj-26676ae0", - "workspaceId": "481ffafe-2f65-46a0-8392-b508677b7cdd", - "homeInputToPendingShellMs": 0, - "homeInputToPendingPreviewPaintMs": 29, - "homeInputToSendDispatchMs": 29, - "homeInputToSendPlanReadyMs": 35, - "homeInputToAssistantDraftMs": 37, - "homeInputToAssistantDraftPaintMs": 63, - "homeInputToStreamRequestStartMs": 73, - "homeInputToSubmitAcceptedMs": 177, - "homeInputToFirstEventMs": 198, - "homeInputToFirstRuntimeStatusMs": 199, - "homeInputToFirstTextDeltaMs": 2097, - "sendDispatchToSubmitAcceptedMs": 147, - "streamSubmitDispatchedToAcceptedMs": 77, - "submitAcceptedToFirstEventMs": 22, - "firstEventToFirstTextDeltaMs": 1899, - "streamEnsureSessionDurationMs": 34, - "streamSubmitInvokeDurationMs": 77, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 0, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 449919337, - "phases": [ - "homeInput.submit", - "homeInput.pendingShellApplied", - "homeInput.pendingPreviewPaint", - "homeInput.sendDispatch.start", - "workspaceSend.plan.ready", - "agentStream.assistantDraft", - "agentStream.ensureSession.start", - "agentStream.assistantDraftPaint", - "agentStream.ensureSession.done", - "agentStream.request.start", - "agentStream.listenerBound", - "agentStream.submitDispatched", - "agentStream.submitAccepted", - "homeInput.sendDispatch.done", - "agentStream.firstEvent", - "agentStream.firstRuntimeStatus", - "agentStream.firstTextDelta" - ] - } - ] - }, - "tail": null -} \ No newline at end of file diff --git a/qcloop-ledger-records-visible.png b/qcloop-ledger-records-visible.png deleted file mode 100644 index f667b8bc4..000000000 Binary files a/qcloop-ledger-records-visible.png and /dev/null differ diff --git a/qcloop-ledger-refined-wide.png b/qcloop-ledger-refined-wide.png deleted file mode 100644 index 467fd2cef..000000000 Binary files a/qcloop-ledger-refined-wide.png and /dev/null differ diff --git a/qcloop-ledger-refined.png b/qcloop-ledger-refined.png deleted file mode 100644 index 66c331344..000000000 Binary files a/qcloop-ledger-refined.png and /dev/null differ diff --git a/qcloop-mobile-smoke.png b/qcloop-mobile-smoke.png deleted file mode 100644 index 66cc6b6e9..000000000 Binary files a/qcloop-mobile-smoke.png and /dev/null differ diff --git a/scripts/agent-qc-completion-audit.mjs b/scripts/agent-qc-completion-audit.mjs new file mode 100644 index 000000000..57af740c8 --- /dev/null +++ b/scripts/agent-qc-completion-audit.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { createAgentQcGuiFlowReport } from "./lib/agent-qc-gui-flow-core.mjs"; +import { buildQCLoopJobPayload, validateQCLoopJobPayload } from "./lib/agent-qc-qcloop-job-core.mjs"; +import { createAgentQcReport } from "./lib/agent-qc-report-core.mjs"; +import { + buildAgentQcCompletionAudit, + renderAgentQcCompletionAuditMarkdown, +} from "./lib/agent-qc-completion-audit-core.mjs"; + +function parseArgs(argv) { + const result = { + check: false, + format: "markdown", + help: false, + outputPath: "", + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC Completion Audit + +用法: + npm run agent-qc:audit + npm run agent-qc:audit -- --format json + node scripts/agent-qc-completion-audit.mjs --check + +选项: + --format FMT markdown | json + --output PATH 写入文件;默认 stdout + --check 整体目标未完成时非 0 退出 + -h, --help 显示帮助 +`); +} + +function exists(filePath) { + return fs.existsSync(path.resolve(process.cwd(), filePath)); +} + +function readText(filePath) { + return fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8"); +} + +function readJson(filePath) { + return JSON.parse(readText(filePath)); +} + +function readOptionalEvidencePack(filePath) { + if (!exists(filePath)) { + return { exists: false, status: "", scenarioCount: 0, scenarioIds: [] }; + } + try { + const pack = readJson(filePath); + return summarizeEvidencePack(pack, { exists: true }); + } catch { + return { exists: true, status: "invalid-json", scenarioCount: 0, scenarioIds: [] }; + } +} + +function readOptionalJson(filePath, fallback) { + if (!exists(filePath)) { + return fallback; + } + try { + return readJson(filePath); + } catch { + return { ...fallback, status: "invalid-json" }; + } +} + +function summarizeEvidencePack(pack, overrides = {}) { + return { + ...overrides, + status: pack?.verdict?.status || "", + scenarioCount: Array.isArray(pack?.scenarioResults) + ? pack.scenarioResults.length + : 0, + scenarioIds: Array.isArray(pack?.scenarioResults) + ? pack.scenarioResults + .map((result) => String(result?.scenarioId || "").trim()) + .filter(Boolean) + : [], + }; +} + +function readEvidenceSidecars(dirPath) { + const resolvedDirPath = path.resolve(process.cwd(), dirPath); + if (!fs.existsSync(resolvedDirPath)) { + return []; + } + return fs + .readdirSync(resolvedDirPath) + .filter((fileName) => /^agent-qc-evidence\..+\.json$/.test(fileName)) + .sort() + .map((fileName) => { + const relativePath = path.posix.join(dirPath, fileName); + try { + const pack = readJson(relativePath); + return summarizeEvidencePack(pack, { path: relativePath }); + } catch { + return { path: relativePath, status: "invalid-json", scenarioCount: 0, scenarioIds: [] }; + } + }); +} + +function readQcloopStatusSidecars(dirPath) { + const resolvedDirPath = path.resolve(process.cwd(), dirPath); + if (!fs.existsSync(resolvedDirPath)) { + return []; + } + return fs + .readdirSync(resolvedDirPath) + .filter((fileName) => /^qcloop-status\..+\.json$/.test(fileName)) + .sort() + .map((fileName) => { + const relativePath = path.posix.join(dirPath, fileName); + try { + const status = readJson(relativePath); + return { + path: relativePath, + verdictStatus: status?.verdict?.status || "", + verdictSummary: status?.verdict?.summary || "", + counts: status?.counts || null, + }; + } catch { + return { path: relativePath, verdictStatus: "invalid-json", verdictSummary: "", counts: null }; + } + }); +} + +function loadFacts() { + const scenarioManifest = readJson("docs/test/agent-qc-scenarios.manifest.json"); + const guiFlowManifest = readJson("docs/test/agent-qc-gui-flows.manifest.json"); + const evidenceSchema = readJson("docs/test/agent-qc-evidence.schema.json"); + const packageJson = readJson("package.json"); + const scenarioReport = createAgentQcReport({ manifest: scenarioManifest, packageJson, evidenceSchema }); + scenarioReport.p0ScenarioIds = Array.isArray(scenarioManifest?.scenarios) + ? scenarioManifest.scenarios + .filter((scenario) => String(scenario?.risk || "").toUpperCase() === "P0") + .map((scenario) => String(scenario?.id || "").trim()) + .filter(Boolean) + : []; + const guiFlowReport = createAgentQcGuiFlowReport({ flowManifest: guiFlowManifest, scenarioManifest }); + const qcloopPayload = buildQCLoopJobPayload(scenarioManifest, { risks: ["P0"], generatedAt: "2026-05-10T00:00:00.000Z" }); + const qcloopPayloadValidation = validateQCLoopJobPayload(qcloopPayload); + const evidenceCore = exists("scripts/lib/agent-qc-evidence-core.mjs") + ? readText("scripts/lib/agent-qc-evidence-core.mjs") + : ""; + const releaseSummaryCore = exists("scripts/lib/agent-qc-release-summary-core.mjs") + ? readText("scripts/lib/agent-qc-release-summary-core.mjs") + : ""; + const guiOwnerCore = exists("scripts/lib/agent-qc-gui-owner-core.mjs") + ? readText("scripts/lib/agent-qc-gui-owner-core.mjs") + : ""; + const staleOwnerInterventionDoc = exists("docs/tests/lime-agent-qc-stale-owner-intervention.md") + ? readText("docs/tests/lime-agent-qc-stale-owner-intervention.md") + : ""; + const nightly = exists(".github/workflows/harness-nightly.yml") ? readText(".github/workflows/harness-nightly.yml") : ""; + const release = exists(".github/workflows/release.yml") ? readText(".github/workflows/release.yml") : ""; + + return { + files: { + agentOpsQc: exists("docs/tests/agent-ops-qc.md"), + p0Scenarios: exists("docs/tests/agent-qc-p0-scenarios.md"), + limeRolloutPlan: exists("docs/tests/lime-agent-qc-rollout-plan.md"), + testsReadme: exists("docs/tests/README.md"), + evidenceSchema: exists("docs/test/agent-qc-evidence.schema.json"), + qcloopJobScript: exists("scripts/agent-qc-qcloop-job.mjs"), + guiOwnerCheckScript: exists("scripts/agent-qc-gui-owner-check.mjs"), + qcloopStatusScript: exists("scripts/agent-qc-qcloop-status.mjs"), + qcloopPreflightScript: exists("scripts/agent-qc-qcloop-preflight.mjs"), + qcloopOperationsDoc: exists("docs/tests/lime-agent-qc-qcloop-operations.md"), + evidenceContractDoc: exists("docs/tests/lime-agent-qc-evidence-contract.md"), + staleOwnerInterventionDoc: exists("docs/tests/lime-agent-qc-stale-owner-intervention.md"), + exportEvidenceScript: exists("scripts/agent-qc-export-evidence.mjs"), + releaseSummaryScript: exists("scripts/agent-qc-release-summary.mjs"), + realGuiEvidence: exists(".lime/qc/gui-evidence"), + }, + realEvidencePack: readOptionalEvidencePack(".lime/qc/agent-qc-evidence.json"), + localVerify: readOptionalJson(".lime/qc/verify-local-current.json", { + exists: false, + status: "", + failedStage: "", + error: "", + }), + guiSmoke: readOptionalJson(".lime/qc/verify-gui-smoke-current.json", { + exists: false, + status: "", + failedStage: "", + error: "", + }), + realEvidenceSidecars: readEvidenceSidecars(".lime/qc"), + qcloopStatusSidecars: readQcloopStatusSidecars(".lime/qc"), + scenarioReport, + guiFlowReport, + qcloopPayload: { + valid: qcloopPayloadValidation.valid, + itemCount: qcloopPayload.items.length, + verifierHasWorkerOutput: + qcloopPayload.verifier_prompt_template.includes("{{stdout}}") || + qcloopPayload.verifier_prompt_template.includes("{{output}}"), + verifierHasAttemptStatus: qcloopPayload.verifier_prompt_template.includes( + "{{attempt_status}}", + ), + verifierHasExitCode: qcloopPayload.verifier_prompt_template.includes("{{exit_code}}"), + workerPromptHasPreflight: + qcloopPayload.prompt_template.includes("agent-qc:qcloop-preflight") && + qcloopPayload.prompt_template.includes("--require-devbridge"), + workerPromptHasStructuredEvidence: + qcloopPayload.prompt_template.includes("QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED") && + qcloopPayload.prompt_template.includes("QCLOOP_EVIDENCE_SUMMARY_JSON="), + verifierRequiresStructuredEvidence: + qcloopPayload.verifier_prompt_template.includes("QCLOOP_EVIDENCE_SUMMARY_JSON"), + verifierRequiresStrictJson: + qcloopPayload.verifier_prompt_template.includes('{"pass": true|false') && + qcloopPayload.verifier_prompt_template.includes("不要 Markdown"), + }, + structuredEvidence: { + exporterParsesSummary: + evidenceCore.includes("collectQCLoopEvidenceSummaries") && + evidenceCore.includes("qcloop:evidence_summary_missing") && + evidenceCore.includes("qcloop:evidence_summary_invalid_json"), + releaseSummaryRejectsWeakRefs: + releaseSummaryCore.includes("weakEvidenceScenarioIds") && + releaseSummaryCore.includes("hasStructuredEvidenceRef") && + releaseSummaryCore.includes("缺少结构化 evidenceRefs"), + }, + staleOwnerIntervention: { + guiOwnerReportHasDecisionPacket: + guiOwnerCore.includes("ownerIntervention") && + guiOwnerCore.includes("requiredConfirmationText") && + guiOwnerCore.includes("prohibitedUntilConfirmed"), + guiOwnerReportHasWatchHistory: + guiOwnerCore.includes("createAgentQcGuiOwnerWatchEntry") && + exists("scripts/agent-qc-gui-owner-check.mjs") && + readText("scripts/agent-qc-gui-owner-check.mjs").includes("--watch-history-output"), + docMentionsDecisionPacket: + staleOwnerInterventionDoc.includes("ownerIntervention") && + staleOwnerInterventionDoc.includes("stale-owner-intervention-request.json"), + docMentionsWatchHistory: + staleOwnerInterventionDoc.includes("watch-history-output") || + staleOwnerInterventionDoc.includes("stale-owner-watch-history.jsonl"), + }, + nightly: { + hasAgentQcReport: nightly.includes("agent-qc-report"), + hasGuiFlowReport: nightly.includes("agent-qc-gui-flow-report"), + hasReleasePreview: nightly.includes("release-agent-qc-preview"), + }, + release: { + hasHardGate: + release.includes("agent-qc-release-summary") && + release.includes("--check") && + !release.includes("--allow-missing-evidence"), + requiresP0ScenarioCoverage: + release.includes("--require-scenario-manifest") && + release.includes("docs/test/agent-qc-scenarios.manifest.json") && + release.includes("--require-risk") && + release.includes("P0"), + }, + }; +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + const audit = buildAgentQcCompletionAudit(loadFacts()); + const content = + options.format === "json" + ? `${JSON.stringify(audit, null, 2)}\n` + : renderAgentQcCompletionAuditMarkdown(audit); + writeOutput(options.outputPath, content); + if (options.check && audit.status !== "complete") { + process.exit(1); + } +} + +main(); diff --git a/scripts/agent-qc-export-evidence.mjs b/scripts/agent-qc-export-evidence.mjs new file mode 100644 index 000000000..93c6ae857 --- /dev/null +++ b/scripts/agent-qc-export-evidence.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { + buildAgentQcEvidencePack, + validateEvidencePackShape, +} from "./lib/agent-qc-evidence-core.mjs"; + +function parseArgs(argv) { + const result = { + baseUrl: "http://127.0.0.1:8080", + changedFiles: [], + check: false, + diffBase: "", + format: "json", + help: false, + itemsJson: "", + jobId: "", + jobJson: "", + outputPath: "", + ref: "", + repo: "lime", + riskTags: [], + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--base-url" && argv[index + 1]) { + result.baseUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--job-id" && argv[index + 1]) { + result.jobId = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--job-json" && argv[index + 1]) { + result.jobJson = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--items-json" && argv[index + 1]) { + result.itemsJson = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--repo" && argv[index + 1]) { + result.repo = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--ref" && argv[index + 1]) { + result.ref = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--diff-base" && argv[index + 1]) { + result.diffBase = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--changed-file" && argv[index + 1]) { + result.changedFiles.push(String(argv[index + 1]).trim()); + index += 1; + continue; + } + if (arg === "--risk-tag" && argv[index + 1]) { + result.riskTags.push(String(argv[index + 1]).trim()); + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC Evidence Export + +用法: + npm run agent-qc:export-evidence -- --job-id "qcloop-job-id" --output ./.lime/qc/evidence.json + node scripts/agent-qc-export-evidence.mjs --job-json ./job.json --items-json ./items.json --check + +选项: + --job-id ID 从 qcloop HTTP API 读取 /api/jobs/:id 和 /api/items?job_id=:id + --base-url URL qcloop API 地址,默认 http://127.0.0.1:8080 + --job-json PATH 离线 job JSON 文件 + --items-json PATH 离线 items JSON 文件 + --output PATH 写入 Evidence Pack;默认 stdout + --repo NAME subject.repo,默认 lime + --ref REF subject.ref + --diff-base REF subject.diffBase + --changed-file PATH 追加 changedFiles,可重复 + --risk-tag TAG 追加 riskTags,可重复 + --check 只校验输出形状,非法时非 0 退出 + --format FMT 输出格式:json | summary + -h, --help 显示帮助 +`); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8")); +} + +async function readJsonUrl(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`请求失败 ${response.status}: ${url}`); + } + return response.json(); +} + +async function loadQCLoopData(options) { + if (options.jobJson || options.itemsJson) { + if (!options.jobJson || !options.itemsJson) { + throw new Error("离线模式必须同时提供 --job-json 与 --items-json。"); + } + return { + job: readJsonFile(options.jobJson), + items: readJsonFile(options.itemsJson), + }; + } + + if (!options.jobId) { + throw new Error("必须提供 --job-id,或同时提供 --job-json / --items-json。"); + } + + const baseUrl = options.baseUrl.replace(/\/$/, ""); + return { + job: await readJsonUrl(`${baseUrl}/api/jobs/${encodeURIComponent(options.jobId)}`), + items: await readJsonUrl(`${baseUrl}/api/items/?job_id=${encodeURIComponent(options.jobId)}`), + }; +} + +function renderSummary(pack, validation) { + const lines = [ + `runId=${pack.runId}`, + `status=${pack.verdict.status}`, + `scenarios=${pack.scenarioResults.length}`, + `valid=${validation.valid}`, + `summary=${pack.verdict.summary}`, + ]; + if (validation.issues.length > 0) { + lines.push(`issues=${validation.issues.join("; ")}`); + } + return `${lines.join("\n")}\n`; +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const { job, items } = await loadQCLoopData(options); + const pack = buildAgentQcEvidencePack({ + job, + items, + options: { + repo: options.repo, + ref: options.ref, + diffBase: options.diffBase, + changedFiles: options.changedFiles, + riskTags: options.riskTags, + }, + }); + const validation = validateEvidencePackShape(pack); + const content = options.format === "summary" ? renderSummary(pack, validation) : `${JSON.stringify(pack, null, 2)}\n`; + writeOutput(options.outputPath, content); + + if (options.check && !validation.valid) { + for (const issue of validation.issues) { + console.error(`[agent-qc-evidence] ${issue}`); + } + process.exit(1); + } +} + +main().catch((error) => { + console.error(`[agent-qc-evidence] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/agent-qc-gui-flow-report.mjs b/scripts/agent-qc-gui-flow-report.mjs new file mode 100644 index 000000000..b7b056318 --- /dev/null +++ b/scripts/agent-qc-gui-flow-report.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { + createAgentQcGuiFlowReport, + renderAgentQcGuiFlowMarkdown, +} from "./lib/agent-qc-gui-flow-core.mjs"; + +const DEFAULT_FLOW_MANIFEST_PATH = "docs/test/agent-qc-gui-flows.manifest.json"; +const DEFAULT_SCENARIO_MANIFEST_PATH = "docs/test/agent-qc-scenarios.manifest.json"; + +function parseArgs(argv) { + const result = { + check: false, + flowManifestPath: DEFAULT_FLOW_MANIFEST_PATH, + format: "markdown", + help: false, + outputPath: "", + scenarioManifestPath: DEFAULT_SCENARIO_MANIFEST_PATH, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--flow-manifest" && argv[index + 1]) { + result.flowManifestPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--scenario-manifest" && argv[index + 1]) { + result.scenarioManifestPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC GUI Flow Report + +用法: + npm run agent-qc:gui-flow:report + npm run agent-qc:gui-flow:check + +选项: + --flow-manifest PATH GUI flow manifest,默认 docs/test/agent-qc-gui-flows.manifest.json + --scenario-manifest PATH Agent QC scenario manifest,默认 docs/test/agent-qc-scenarios.manifest.json + --format FMT markdown | json + --output PATH 写入文件;默认 stdout + --check 非法时非 0 退出 + -h, --help 显示帮助 +`); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8")); +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const report = createAgentQcGuiFlowReport({ + flowManifest: readJsonFile(options.flowManifestPath), + scenarioManifest: readJsonFile(options.scenarioManifestPath), + }); + const content = + options.format === "json" + ? `${JSON.stringify(report, null, 2)}\n` + : renderAgentQcGuiFlowMarkdown(report); + + writeOutput(options.outputPath, content); + + if (options.check && !report.valid) { + process.exit(1); + } +} + +main(); diff --git a/scripts/agent-qc-gui-owner-check.mjs b/scripts/agent-qc-gui-owner-check.mjs new file mode 100644 index 000000000..3aba11955 --- /dev/null +++ b/scripts/agent-qc-gui-owner-check.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { + createAgentQcGuiOwnerWatchEntry, + createAgentQcGuiOwnerReport, + renderAgentQcGuiOwnerSummary, +} from "./lib/agent-qc-gui-owner-core.mjs"; + +function parseArgs(argv) { + const result = { + check: false, + format: "summary", + help: false, + manifestPath: "docs/test/agent-qc-scenarios.manifest.json", + maxActiveOwners: 0, + outputPath: "", + statusDir: ".lime/qc", + watchHistoryOutputPath: "", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--manifest" && argv[index + 1]) { + result.manifestPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--status-dir" && argv[index + 1]) { + result.statusDir = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--max-active-owners" && argv[index + 1]) { + result.maxActiveOwners = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--watch-history-output" && argv[index + 1]) { + result.watchHistoryOutputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC GUI Owner Check + +用法: + npm run agent-qc:gui-owner-check + npm run agent-qc:gui-owner-check -- --check + node scripts/agent-qc-gui-owner-check.mjs --format json --output ./.lime/qc/gui-owner-current.json + +选项: + --manifest PATH scenario manifest,默认 docs/test/agent-qc-scenarios.manifest.json + --status-dir PATH qcloop status sidecar 目录,默认 .lime/qc + --max-active-owners N 允许的 active GUI owner 数,默认 0 + --format FMT summary | json + --output PATH 写入文件;默认 stdout + --watch-history-output PATH 追加 JSONL 观察记录 + --check active owner 超过上限时非 0 退出 + -h, --help 显示帮助 +`); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8")); +} + +function readQcloopStatusSidecars(statusDir) { + const resolvedDir = path.resolve(process.cwd(), statusDir); + if (!fs.existsSync(resolvedDir)) { + return []; + } + return fs + .readdirSync(resolvedDir) + .filter((fileName) => /^qcloop-status\..+\.json$/.test(fileName)) + .sort() + .map((fileName) => { + const relativePath = path.posix.join(statusDir, fileName); + try { + return { path: relativePath, status: readJsonFile(relativePath) }; + } catch { + return { path: relativePath, status: { job: { id: relativePath, status: "invalid-json" }, items: [] } }; + } + }); +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +function appendJsonLine(outputPath, entry) { + if (!outputPath) { + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.appendFileSync(resolvedOutputPath, `${JSON.stringify(entry)}\n`, "utf8"); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const report = createAgentQcGuiOwnerReport({ + manifest: readJsonFile(options.manifestPath), + statusSidecars: readQcloopStatusSidecars(options.statusDir), + maxActiveOwners: options.maxActiveOwners, + }); + const content = + options.format === "json" + ? `${JSON.stringify(report, null, 2)}\n` + : renderAgentQcGuiOwnerSummary(report); + writeOutput(options.outputPath, content); + appendJsonLine(options.watchHistoryOutputPath, createAgentQcGuiOwnerWatchEntry(report)); + + if (options.check && report.verdict.status !== "pass") { + process.exit(1); + } +} + +main(); diff --git a/scripts/agent-qc-process-owner-check.mjs b/scripts/agent-qc-process-owner-check.mjs new file mode 100755 index 000000000..96f5e7b3a --- /dev/null +++ b/scripts/agent-qc-process-owner-check.mjs @@ -0,0 +1,318 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const GUI_OWNER_PATTERNS = [ + "verify:gui-smoke", + "smoke:workspace-ready", + "smoke:browser-runtime", + "smoke:site-adapters", + "smoke:agent-service-skill-entry", + "smoke:agent-runtime-tool-surface", + "smoke:knowledge-gui", + "smoke:design-canvas", + "claw-chat-ready-streaming", + "browser-runtime-site-adapter", + "workspace-ready-session-restore", + "release-package-startup-smoke", +]; + +const CARGO_OWNER_PATTERNS = [ + "cargo ", + "cargo-fmt", + "rustc ", + "clippy-driver", + "tauri dev", +]; + +const QCLOOP_OWNER_PATTERNS = [ + "qcloop --db", + "./qcloop --db", + "qcloop serve", + "qcloop-worker", + "qcloop_worker_result", + "agent qc p0", + "只读执行 lime agent qc p0", +]; + +function parseArgs(argv) { + const result = { + check: false, + format: "summary", + help: false, + markdownOutputPath: "", + maxActiveGuiSmoke: 0, + maxCargoOrRust: 0, + maxQcloopRelated: 0, + outputPath: "", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--markdown-output" && argv[index + 1]) { + result.markdownOutputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--max-active-gui-smoke" && argv[index + 1]) { + result.maxActiveGuiSmoke = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--max-cargo-or-rust" && argv[index + 1]) { + result.maxCargoOrRust = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--max-qcloop-related" && argv[index + 1]) { + result.maxQcloopRelated = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC Process Owner Check + +用法: + npm run agent-qc:process-owner-check + node scripts/agent-qc-process-owner-check.mjs --format json --output ./.lime/qc/gui-process-owner-current.json --markdown-output ./.lime/qc/gui-process-owner-current.md + +选项: + --format FMT summary | json,默认 summary + --output PATH 写入 JSON 或 summary;默认 stdout + --markdown-output PATH 额外写入 Markdown 摘要 + --max-active-gui-smoke N 允许的 GUI smoke / deep flow owner 数,默认 0 + --max-cargo-or-rust N 允许的 Cargo / Rust 构建进程数,默认 0 + --max-qcloop-related N 允许的 qcloop 相关进程数,默认 0 + --check owner 超过上限时非 0 退出 + -h, --help 显示帮助 +`); +} + +function parseUnixPsLine(line) { + const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.+)$/.exec(line); + if (!match) { + return null; + } + return { + pid: Number(match[1]), + ppid: Number(match[2]), + pgid: Number(match[3]), + stat: match[4], + etime: match[5], + command: match[6].trim(), + }; +} + +function collectUnixProcesses() { + const output = execFileSync("ps", ["-axo", "pid,ppid,pgid,stat,etime,command"], { + encoding: "utf8", + }); + return output + .split(/\r?\n/) + .slice(1) + .map(parseUnixPsLine) + .filter(Boolean) + .filter((entry) => entry.pid !== process.pid && entry.command.length > 0); +} + +function collectWindowsProcesses() { + const output = execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-Command", + "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress", + ], + { encoding: "utf8" }, + ).trim(); + const parsed = output ? JSON.parse(output) : []; + return (Array.isArray(parsed) ? parsed : [parsed]) + .map((entry) => ({ + pid: Number(entry?.ProcessId || 0), + ppid: Number(entry?.ParentProcessId || 0), + pgid: null, + stat: "unknown", + etime: "unknown", + command: String(entry?.CommandLine || "").trim(), + })) + .filter((entry) => entry.pid > 0 && entry.pid !== process.pid && entry.command.length > 0); +} + +function collectProcesses() { + return process.platform === "win32" ? collectWindowsProcesses() : collectUnixProcesses(); +} + +function commandHasAny(command, patterns) { + const normalized = String(command || "").toLowerCase(); + return patterns.some((pattern) => normalized.includes(pattern.toLowerCase())); +} + +function uniqueByPid(entries) { + const seen = new Set(); + const result = []; + for (const entry of entries) { + if (seen.has(entry.pid)) { + continue; + } + seen.add(entry.pid); + result.push(entry); + } + return result.sort((left, right) => left.pid - right.pid); +} + +function createReport({ + generatedAt = new Date().toISOString(), + maxActiveGuiSmoke = 0, + maxCargoOrRust = 0, + maxQcloopRelated = 0, +} = {}) { + const processes = collectProcesses(); + const activeGuiSmokeProcesses = uniqueByPid( + processes.filter((entry) => commandHasAny(entry.command, GUI_OWNER_PATTERNS)), + ); + const qcloopProcesses = uniqueByPid( + processes.filter((entry) => commandHasAny(entry.command, QCLOOP_OWNER_PATTERNS)), + ); + const cargoProcesses = uniqueByPid( + processes.filter((entry) => commandHasAny(entry.command, CARGO_OWNER_PATTERNS)), + ); + + const counts = { + activeGuiSmoke: activeGuiSmokeProcesses.length, + cargoOrRust: cargoProcesses.length, + qcloopRelated: qcloopProcesses.length, + }; + const busy = + counts.activeGuiSmoke > maxActiveGuiSmoke || + counts.cargoOrRust > maxCargoOrRust || + counts.qcloopRelated > maxQcloopRelated; + + return { + schemaVersion: "v1", + generatedAt, + platform: process.platform, + maxActiveGuiSmoke, + maxCargoOrRust, + maxQcloopRelated, + verdict: { + status: busy ? "busy" : "pass", + summary: `activeGuiSmoke=${counts.activeGuiSmoke}, cargoOrRust=${counts.cargoOrRust}, qcloopRelated=${counts.qcloopRelated}`, + nextAction: busy + ? "Do not start full verify:local or another GUI P0 batch while these owners are active; continue read-only observation or wait for natural completion." + : "No active raw GUI smoke, Cargo/Rust, or qcloop owner was observed; heavy gates may run if qcloop GUI owner and release evidence gates are also clear.", + }, + activeGuiSmokeProcesses, + qcloopProcesses, + cargoProcesses, + guardrails: [ + "best-effort process snapshot only", + "do not kill or restart listed processes from this sidecar", + "use this sidecar to decide whether heavy GUI or verify gates should wait", + ], + }; +} + +function renderSummary(report) { + const lines = [ + `status=${report.verdict.status}`, + `summary=${report.verdict.summary}`, + `generatedAt=${report.generatedAt}`, + `platform=${report.platform}`, + ]; + return `${lines.join("\n")}\n`; +} + +function renderMarkdown(report) { + const lines = [ + "# Agent QC raw process owner snapshot", + "", + `- Generated at: ${report.generatedAt}`, + `- Status: ${report.verdict.status}`, + `- Summary: ${report.verdict.summary}`, + `- Next action: ${report.verdict.nextAction}`, + "", + "## Active GUI smoke / deep flow processes", + "", + ]; + appendProcessList(lines, report.activeGuiSmokeProcesses); + lines.push("", "## qcloop related processes", ""); + appendProcessList(lines, report.qcloopProcesses); + lines.push("", "## Cargo / Rust processes", ""); + appendProcessList(lines, report.cargoProcesses); + lines.push("", "## Guardrails", ""); + for (const guardrail of report.guardrails) { + lines.push(`- ${guardrail}`); + } + return `${lines.join("\n")}\n`; +} + +function appendProcessList(lines, entries) { + if (!entries?.length) { + lines.push("- none"); + return; + } + for (const entry of entries) { + lines.push( + `- pid=${entry.pid} ppid=${entry.ppid} pgid=${entry.pgid} stat=${entry.stat} etime=${entry.etime} command=${entry.command}`, + ); + } +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const report = createReport(options); + const content = + options.format === "json" + ? `${JSON.stringify(report, null, 2)}\n` + : renderSummary(report); + writeOutput(options.outputPath, content); + if (options.markdownOutputPath) { + writeOutput(options.markdownOutputPath, renderMarkdown(report)); + } + + if (options.check && report.verdict.status !== "pass") { + process.exit(1); + } +} + +main(); diff --git a/scripts/agent-qc-qcloop-db-lease.mjs b/scripts/agent-qc-qcloop-db-lease.mjs new file mode 100755 index 000000000..8bd49128a --- /dev/null +++ b/scripts/agent-qc-qcloop-db-lease.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +function parseArgs(argv) { + const result = { + checkTerminal: false, + dbPath: ".lime/qc/qcloop-isolated-worker-preflight.db", + format: "summary", + help: false, + jobId: "", + markdownOutputPath: "", + outputPath: "", + processOwnerPath: ".lime/qc/gui-process-owner-current.json", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--db" && argv[index + 1]) { + result.dbPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--job-id" && argv[index + 1]) { + result.jobId = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--process-owner" && argv[index + 1]) { + result.processOwnerPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--markdown-output" && argv[index + 1]) { + result.markdownOutputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--check-terminal") { + result.checkTerminal = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC qcloop DB Lease Snapshot + +用法: + npm run agent-qc:qcloop-db-lease -- --job-id + node scripts/agent-qc-qcloop-db-lease.mjs --db ./.lime/qc/qcloop-isolated-worker-preflight.db --job-id --format json --output ./.lime/qc/qcloop-db-lease-current.json + +选项: + --db PATH qcloop SQLite DB,默认 .lime/qc/qcloop-isolated-worker-preflight.db + --job-id ID qcloop job id,必填 + --process-owner PATH raw process owner sidecar,默认 .lime/qc/gui-process-owner-current.json + --format FMT summary | json,默认 summary + --output PATH 写入文件;默认 stdout + --markdown-output PATH 额外写入 Markdown 摘要 + --check-terminal job 未终态或仍有 running item 时非 0 退出 + -h, --help 显示帮助 +`); +} + +function sqlQuote(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +function sqliteJson(dbPath, sql) { + const output = execFileSync("sqlite3", ["-readonly", "-json", dbPath, sql], { + encoding: "utf8", + }).trim(); + return output ? JSON.parse(output) : []; +} + +function readJsonIfExists(filePath) { + const resolved = path.resolve(process.cwd(), filePath); + if (!fs.existsSync(resolved)) { + return null; + } + return JSON.parse(fs.readFileSync(resolved, "utf8")); +} + +function extractScenarioId(itemPreview) { + return String(itemPreview || "").match(/"scenario_id"\s*:\s*"([^"]+)"/)?.[1] || null; +} + +function createDbLeaseReport({ + dbPath, + generatedAt = new Date().toISOString(), + jobId, + processOwnerPath, +}) { + if (!jobId) { + throw new Error("--job-id is required"); + } + const quotedJobId = sqlQuote(jobId); + const jobs = sqliteJson( + dbPath, + `select id, name, status, created_at, finished_at from batch_jobs where id=${quotedJobId}`, + ); + const items = sqliteJson( + dbPath, + `select id, batch_job_id, status, current_attempt_no, current_qc_no, tokens_used, lock_owner, lock_expires_at, queued_at, last_error, created_at, finished_at, substr(item_value,1,700) as item_preview from batch_items where batch_job_id=${quotedJobId} order by created_at, id`, + ); + const attempts = sqliteJson( + dbPath, + `select attempts.id, attempts.batch_item_id, attempts.attempt_no, attempts.run_no, attempts.attempt_type, attempts.status, attempts.exit_code, attempts.tokens_used, attempts.started_at, attempts.finished_at, length(attempts.stdout) as stdout_len, length(attempts.stderr) as stderr_len, substr(attempts.stdout,1,700) as stdout_preview, substr(attempts.stderr,1,700) as stderr_preview from attempts join batch_items on batch_items.id=attempts.batch_item_id where batch_items.batch_job_id=${quotedJobId} order by attempts.started_at, attempts.id`, + ); + const processOwner = readJsonIfExists(processOwnerPath) || { qcloopProcesses: [] }; + const processSnapshot = (processOwner.qcloopProcesses || []).filter((entry) => { + const command = String(entry?.command || ""); + return command.includes(jobId) || command.includes("18080") || command.includes("browser-runtime-site-adapter"); + }); + const activeItem = items.find((item) => item.status === "running") || null; + const activeAttempt = activeItem + ? attempts.find((attempt) => attempt.batch_item_id === activeItem.id && attempt.status === "running") || null + : null; + const observedWorker = processSnapshot.find((entry) => + String(entry?.command || "").includes("browser-runtime-site-adapter"), + ) || null; + const observedQcloop = processSnapshot.find((entry) => + String(entry?.command || "").includes("serve --addr 127.0.0.1:18080"), + ) || null; + const terminalStatuses = new Set(["completed", "failed", "cancelled", "canceled"]); + const jobStatus = jobs[0]?.status || "unknown"; + const terminal = terminalStatuses.has(String(jobStatus).toLowerCase()) && !activeItem; + + return { + schemaVersion: "v1", + generatedAt, + jobId, + dbPath, + processOwnerPath, + summary: { + status: jobStatus, + terminal, + activeItemId: activeItem?.id || null, + activeScenario: extractScenarioId(activeItem?.item_preview), + lockOwner: activeItem?.lock_owner || null, + lockExpiresAt: activeItem?.lock_expires_at || null, + activeAttemptId: activeAttempt?.id || null, + activeAttemptStatus: activeAttempt?.status || null, + stdoutLength: activeAttempt?.stdout_len ?? null, + stderrLength: activeAttempt?.stderr_len ?? null, + observedWorkerPid: observedWorker?.pid || null, + observedQcloopPid: observedQcloop?.pid || null, + finding: activeItem + ? "qcloop DB lease, active attempt, and process snapshot still show a running no-output stale GUI owner; no intervention was performed." + : "No running item observed in this DB snapshot; verify qcloop status sidecar before changing gates.", + }, + jobs, + items, + attempts, + processSnapshot, + guardrails: [ + "read-only DB observation only", + "do not kill / pause / interrupt stale worker without owner confirmation", + "do not modify qcloop SQLite DB", + "do not start another full GUI P0 batch while GUI owner is blocked", + "do not overwrite .lime/qc/agent-qc-evidence.json before a real 8/8 P0 pass", + ], + }; +} + +function renderSummary(report) { + const summary = report.summary; + return [ + `status=${summary.status}`, + `terminal=${summary.terminal}`, + `activeItem=${summary.activeItemId || "none"}`, + `activeScenario=${summary.activeScenario || "none"}`, + `lockOwner=${summary.lockOwner || "none"}`, + `lockExpiresAt=${summary.lockExpiresAt || "none"}`, + `activeAttempt=${summary.activeAttemptId || "none"}:${summary.activeAttemptStatus || "none"}`, + `stdoutStderr=${summary.stdoutLength ?? "null"}/${summary.stderrLength ?? "null"}`, + `observedWorkerPid=${summary.observedWorkerPid || "none"}`, + `observedQcloopPid=${summary.observedQcloopPid || "none"}`, + `finding=${summary.finding}`, + ].join("\n") + "\n"; +} + +function renderMarkdown(report) { + const summary = report.summary; + return [ + "# qcloop DB lease snapshot", + "", + `- Generated at: ${report.generatedAt}`, + `- Job: ${report.jobId}`, + `- DB: ${report.dbPath}`, + `- Status: ${summary.status}`, + `- Terminal: ${summary.terminal}`, + `- Active item: ${summary.activeItemId || "none"}`, + `- Active scenario: ${summary.activeScenario || "none"}`, + `- Lock owner: ${summary.lockOwner || "none"}`, + `- Lock expires at: ${summary.lockExpiresAt || "none"}`, + `- Active attempt: ${summary.activeAttemptId || "none"} / ${summary.activeAttemptStatus || "none"}`, + `- stdout/stderr length: ${summary.stdoutLength ?? "null"} / ${summary.stderrLength ?? "null"}`, + `- Observed worker PID: ${summary.observedWorkerPid || "none"}`, + `- Observed qcloop PID: ${summary.observedQcloopPid || "none"}`, + "", + "## Finding", + "", + summary.finding, + "", + "## Guardrails", + "", + ...report.guardrails.map((guardrail) => `- ${guardrail}`), + "", + ].join("\n"); +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + const report = createDbLeaseReport(options); + const content = options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderSummary(report); + writeOutput(options.outputPath, content); + if (options.markdownOutputPath) { + writeOutput(options.markdownOutputPath, renderMarkdown(report)); + } + if (options.checkTerminal && !report.summary.terminal) { + process.exit(1); + } +} + +main(); diff --git a/scripts/agent-qc-qcloop-job.mjs b/scripts/agent-qc-qcloop-job.mjs new file mode 100644 index 000000000..d64f7f014 --- /dev/null +++ b/scripts/agent-qc-qcloop-job.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { + buildQCLoopJobPayload, + renderQCLoopCurl, + validateQCLoopJobPayload, +} from "./lib/agent-qc-qcloop-job-core.mjs"; + +const DEFAULT_MANIFEST_PATH = "docs/test/agent-qc-scenarios.manifest.json"; + +function parseArgs(argv) { + const result = { + baseUrl: "http://127.0.0.1:8080", + check: false, + cwd: process.cwd(), + executorProvider: "codex", + executionMode: "standard", + format: "json", + help: false, + includeAll: false, + manifestPath: DEFAULT_MANIFEST_PATH, + maxQcRounds: 0, + maxExecutorRetries: undefined, + name: "", + outputPath: "", + risks: [], + scenarioIds: [], + tokenBudgetPerItem: 0, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--manifest" && argv[index + 1]) { + result.manifestPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--risk" && argv[index + 1]) { + result.risks.push(String(argv[index + 1]).trim()); + index += 1; + continue; + } + if (arg === "--scenario" && argv[index + 1]) { + result.scenarioIds.push(String(argv[index + 1]).trim()); + index += 1; + continue; + } + if (arg === "--all") { + result.includeAll = true; + continue; + } + if (arg === "--name" && argv[index + 1]) { + result.name = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--cwd" && argv[index + 1]) { + result.cwd = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--executor-provider" && argv[index + 1]) { + result.executorProvider = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--execution-mode" && argv[index + 1]) { + result.executionMode = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--max-qc-rounds" && argv[index + 1]) { + result.maxQcRounds = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--max-executor-retries" && argv[index + 1]) { + result.maxExecutorRetries = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--token-budget-per-item" && argv[index + 1]) { + result.tokenBudgetPerItem = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--base-url" && argv[index + 1]) { + result.baseUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC qcloop Job Payload + +用法: + npm run agent-qc:qcloop-job -- --risk P0 --output ./tmp/qcloop-p0-job.json + node scripts/agent-qc-qcloop-job.mjs --scenario command-bridge-contract --format curl + +选项: + --risk RISK 选择风险等级,可重复;默认 P0 + --scenario ID 选择指定 scenario,可重复;优先级高于 --risk + --all 选择全部 scenario + --name NAME qcloop job 名称 + --cwd PATH worker 目标仓库目录,默认当前目录 + --executor-provider NAME qcloop executor_provider,默认 codex + --execution-mode MODE standard | goal_assisted,默认 standard + --max-qc-rounds N 覆盖 max_qc_rounds + --max-executor-retries N 覆盖 max_executor_retries,0-5 + --token-budget-per-item N 覆盖 token_budget_per_item + --format FMT json | curl + --base-url URL curl 模式 qcloop API 地址,默认 http://127.0.0.1:8080 + --output PATH 写入文件;默认 stdout + --check payload 非法时非 0 退出 + -h, --help 显示帮助 +`); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8")); +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const manifest = readJsonFile(options.manifestPath); + const payload = buildQCLoopJobPayload(manifest, { + includeAll: options.includeAll, + risks: options.risks.length > 0 ? options.risks : ["P0"], + scenarioIds: options.scenarioIds, + name: options.name, + cwd: options.cwd, + executorProvider: options.executorProvider, + executionMode: options.executionMode, + maxQcRounds: options.maxQcRounds, + maxExecutorRetries: options.maxExecutorRetries, + tokenBudgetPerItem: options.tokenBudgetPerItem, + }); + const validation = validateQCLoopJobPayload(payload); + const content = + options.format === "curl" + ? renderQCLoopCurl(payload, { baseUrl: options.baseUrl }) + : `${JSON.stringify({ ...payload, _validation: validation }, null, 2)}\n`; + + writeOutput(options.outputPath, content); + + if (options.check && !validation.valid) { + for (const issue of validation.issues) { + console.error(`[agent-qc-qcloop-job] ${issue}`); + } + process.exit(1); + } +} + +main(); diff --git a/scripts/agent-qc-qcloop-preflight.mjs b/scripts/agent-qc-qcloop-preflight.mjs new file mode 100644 index 000000000..41db4de16 --- /dev/null +++ b/scripts/agent-qc-qcloop-preflight.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; + +import { buildQCLoopPreflightReport } from "./lib/agent-qc-qcloop-preflight-core.mjs"; + +function parseArgs(argv) { + const result = { + check: false, + expectedCwd: "", + format: "summary", + help: false, + healthUrl: "http://127.0.0.1:3030/health", + requireDevBridge: false, + timeoutMs: 15000, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--expected-cwd" && argv[index + 1]) { + result.expectedCwd = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--health-url" && argv[index + 1]) { + result.healthUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--timeout-ms" && argv[index + 1]) { + result.timeoutMs = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--require-devbridge") { + result.requireDevBridge = true; + continue; + } + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC qcloop Worker Preflight + +用法: + npm run agent-qc:qcloop-preflight -- --check + npm run agent-qc:qcloop-preflight -- --require-devbridge --timeout-ms 15000 --check + +选项: + --expected-cwd PATH 预期仓库 cwd;不传则只检查 cwd 存在 + --require-devbridge 要求 http://127.0.0.1:3030/health 可访问 + --health-url URL DevBridge health 地址,默认 http://127.0.0.1:3030/health + --timeout-ms N DevBridge health 超时,默认 15000 + --format FMT summary | json + --check preflight blocked 时非 0 退出 + -h, --help 显示帮助 +`); +} + +function checkTmpWritable() { + const filePath = path.join(os.tmpdir(), `lime-agent-qc-preflight-${process.pid}-${Date.now()}.tmp`); + try { + fs.writeFileSync(filePath, "ok", "utf8"); + fs.unlinkSync(filePath); + return true; + } catch { + return false; + } +} + +async function checkDevBridgeHealth({ url, timeoutMs }) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) ? timeoutMs : 15000); + try { + const response = await fetch(url, { signal: controller.signal }); + const body = await response.text(); + let status = response.ok ? "ok" : `http-${response.status}`; + try { + const parsed = JSON.parse(body); + status = parsed?.status || status; + } catch { + // health body 不是 JSON 时保留 HTTP 状态。 + } + return { ok: response.ok, status, url }; + } catch (error) { + return { ok: false, error: `${error.name || "Error"}: ${error.message}`, url }; + } finally { + clearTimeout(timeout); + } +} + +function renderSummary(report) { + const lines = [ + `status=${report.status}`, + `summary=${report.summary}`, + `QCLOOP_PREFLIGHT_RESULT=${report.status === "pass" ? "PASS" : "BLOCKED"}`, + ]; + for (const check of report.checks) { + lines.push(`- ${check.id} ${check.passed ? "PASS" : "BLOCKED"}: ${check.detail}`); + } + return `${lines.join("\n")}\n`; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const devBridge = options.requireDevBridge + ? await checkDevBridgeHealth({ url: options.healthUrl, timeoutMs: options.timeoutMs }) + : null; + const report = buildQCLoopPreflightReport({ + cwd: process.cwd(), + expectedCwd: options.expectedCwd, + tmpWritable: checkTmpWritable(), + devBridge, + }); + const content = options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderSummary(report); + process.stdout.write(content); + + if (options.check && report.status !== "pass") { + process.exit(1); + } +} + +main().catch((error) => { + console.error(`[agent-qc-qcloop-preflight] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/agent-qc-qcloop-status.mjs b/scripts/agent-qc-qcloop-status.mjs new file mode 100644 index 000000000..2f15b6acb --- /dev/null +++ b/scripts/agent-qc-qcloop-status.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { + buildQCLoopStatusReport, + validateQCLoopStatusReport, +} from "./lib/agent-qc-qcloop-status-core.mjs"; + +function parseArgs(argv) { + const result = { + baseUrl: "http://127.0.0.1:8080", + checkTerminal: false, + failOnStale: false, + format: "summary", + help: false, + itemsJson: "", + jobId: "", + jobJson: "", + outputPath: "", + staleMinutes: 30, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--base-url" && argv[index + 1]) { + result.baseUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--job-id" && argv[index + 1]) { + result.jobId = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--job-json" && argv[index + 1]) { + result.jobJson = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--items-json" && argv[index + 1]) { + result.itemsJson = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--stale-minutes" && argv[index + 1]) { + result.staleMinutes = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--check-terminal") { + result.checkTerminal = true; + continue; + } + if (arg === "--fail-on-stale") { + result.failOnStale = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC qcloop Status + +用法: + npm run agent-qc:qcloop-status -- --job-id "qcloop-job-id" + npm run agent-qc:qcloop-status -- --job-id "qcloop-job-id" --format json --output ./.lime/qc/qcloop-status.json + node scripts/agent-qc-qcloop-status.mjs --job-json ./job.json --items-json ./items.json + +选项: + --job-id ID 从 qcloop HTTP API 读取 /api/jobs/:id 和 /api/items?job_id=:id + --base-url URL qcloop API 地址,默认 http://127.0.0.1:8080 + --job-json PATH 离线 job JSON 文件 + --items-json PATH 离线 items JSON 文件 + --output PATH 写入报告;默认 stdout + --stale-minutes N running item 超过 N 分钟且无 stdout/stderr 时标记 stale,默认 30 + --format FMT 输出格式:summary | json + --check-terminal job 未 complete 或存在失败 / 卡住 item 时非 0 退出 + --fail-on-stale 只要存在 stale item 就非 0 退出 + -h, --help 显示帮助 +`); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8")); +} + +async function readJsonUrl(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`请求失败 ${response.status}: ${url}`); + } + return response.json(); +} + +async function loadQCLoopData(options) { + if (options.jobJson || options.itemsJson) { + if (!options.jobJson || !options.itemsJson) { + throw new Error("离线模式必须同时提供 --job-json 与 --items-json。"); + } + return { + job: readJsonFile(options.jobJson), + items: readJsonFile(options.itemsJson), + }; + } + + if (!options.jobId) { + throw new Error("必须提供 --job-id,或同时提供 --job-json / --items-json。"); + } + + const baseUrl = options.baseUrl.replace(/\/$/, ""); + return { + job: await readJsonUrl(`${baseUrl}/api/jobs/${encodeURIComponent(options.jobId)}`), + items: await readJsonUrl(`${baseUrl}/api/items/?job_id=${encodeURIComponent(options.jobId)}`), + }; +} + +function renderItemLine(item) { + const parts = [ + `- ${item.scenarioId}`, + `status=${item.qcloopStatus}`, + `worker=${item.worker.status}`, + `attempt=${item.currentAttemptNo}`, + `qc=${item.currentQcNo}`, + ]; + if (item.worker.durationMinutes !== null) { + parts.push(`duration=${item.worker.durationMinutes}m`); + } + parts.push(`stdout=${item.worker.stdoutLength}`); + parts.push(`stderr=${item.worker.stderrLength}`); + if (item.stale) { + parts.push(`stale=${item.staleReasons.join("; ")}`); + } + if (item.qc.feedback) { + parts.push(`feedback=${item.qc.feedback.replace(/\s+/g, " ").slice(0, 180)}`); + } + return parts.join(" "); +} + +function renderSummary(report, validation) { + const lines = [ + `job=${report.job.id}`, + `name=${report.job.name}`, + `status=${report.job.status}`, + `verdict=${report.verdict.status}`, + `items=${report.counts.total} terminal=${report.counts.terminal} nonTerminal=${report.counts.nonTerminal}`, + `success=${report.counts.success} failed=${report.counts.failed} exhausted=${report.counts.exhausted} running=${report.counts.running} pending=${report.counts.pending} stale=${report.counts.stale}`, + `summary=${report.verdict.summary}`, + `nextAction=${report.verdict.nextAction}`, + `valid=${validation.valid}`, + ]; + if (validation.issues.length > 0) { + lines.push(`issues=${validation.issues.join("; ")}`); + } + + const activeItems = report.items.filter( + (item) => + !item.terminal || + item.stale || + item.qcloopStatus === "failed" || + item.qcloopStatus === "exhausted", + ); + if (activeItems.length > 0) { + lines.push("items:"); + for (const item of activeItems) { + lines.push(renderItemLine(item)); + } + } + + return `${lines.join("\n")}\n`; +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const { job, items } = await loadQCLoopData(options); + const report = buildQCLoopStatusReport({ + job, + items, + options: { + staleMinutes: options.staleMinutes, + }, + }); + const validation = validateQCLoopStatusReport(report); + const content = options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderSummary(report, validation); + writeOutput(options.outputPath, content); + + if (!validation.valid) { + for (const issue of validation.issues) { + console.error(`[agent-qc-qcloop-status] ${issue}`); + } + process.exit(1); + } + if (options.failOnStale && report.counts.stale > 0) { + process.exit(2); + } + if (options.checkTerminal && report.verdict.status !== "complete") { + process.exit(3); + } +} + +main().catch((error) => { + console.error(`[agent-qc-qcloop-status] ${error.message}`); + process.exit(1); +}); diff --git a/scripts/agent-qc-release-summary.mjs b/scripts/agent-qc-release-summary.mjs new file mode 100644 index 000000000..eaeea83db --- /dev/null +++ b/scripts/agent-qc-release-summary.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { + buildAgentQcReleaseSummary, + renderAgentQcReleaseMarkdown, + validateReleaseSummary, +} from "./lib/agent-qc-release-summary-core.mjs"; + +function parseArgs(argv) { + const result = { + check: false, + evidencePaths: [], + format: "markdown", + harnessSummaryPath: "", + harnessTrendPath: "", + help: false, + outputPath: "", + requireEvidence: true, + requiredRisks: [], + requiredScenarioManifestPath: "", + tag: "", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--evidence" && argv[index + 1]) { + result.evidencePaths.push(String(argv[index + 1]).trim()); + index += 1; + continue; + } + if (arg === "--harness-summary" && argv[index + 1]) { + result.harnessSummaryPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--harness-trend" && argv[index + 1]) { + result.harnessTrendPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--tag" && argv[index + 1]) { + result.tag = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--require-scenario-manifest" && argv[index + 1]) { + result.requiredScenarioManifestPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--require-risk" && argv[index + 1]) { + result.requiredRisks.push(String(argv[index + 1]).trim()); + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--allow-missing-evidence") { + result.requireEvidence = false; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC Release Summary + +用法: + npm run agent-qc:release-summary -- --evidence ./.lime/qc/agent-qc-evidence.json --require-scenario-manifest docs/test/agent-qc-scenarios.manifest.json --require-risk P0 --tag v1.2.3 + node scripts/agent-qc-release-summary.mjs --evidence evidence.json --harness-summary summary.json --harness-trend trend.json --output release-qc.md --check + +选项: + --evidence PATH Agent QC Evidence Pack,可重复 + --harness-summary PATH harness-eval-summary.json + --harness-trend PATH harness-eval-trend.json + --tag TAG release tag + --require-scenario-manifest PATH 要求 Evidence Pack 覆盖该 manifest 中的场景 + --require-risk RISK 搭配 --require-scenario-manifest 使用,可重复;默认 P0 + --output PATH 写入文件;默认 stdout + --format FMT markdown | json + --check 非 pass 或缺证据时非 0 退出 + --allow-missing-evidence 允许无 Evidence Pack,适合本地预览,不适合发布门禁 + -h, --help 显示帮助 +`); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8")); +} + +function readOptionalJson(filePath) { + if (!filePath) { + return null; + } + return readJsonFile(filePath); +} + +function loadRequiredScenarioIds(manifestPath, risks) { + if (!manifestPath) { + return []; + } + const manifest = readJsonFile(manifestPath); + const requiredRisks = new Set((risks.length > 0 ? risks : ["P0"]).map((risk) => risk.toUpperCase())); + return Array.isArray(manifest?.scenarios) + ? manifest.scenarios + .filter((scenario) => requiredRisks.has(String(scenario?.risk || "").toUpperCase())) + .map((scenario) => String(scenario?.id || "").trim()) + .filter(Boolean) + : []; +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const evidencePacks = options.evidencePaths.map((sourcePath) => ({ + sourcePath, + pack: readJsonFile(sourcePath), + })); + const requiredScenarioIds = loadRequiredScenarioIds( + options.requiredScenarioManifestPath, + options.requiredRisks, + ); + const summary = buildAgentQcReleaseSummary({ + evidencePacks, + harnessSummary: readOptionalJson(options.harnessSummaryPath), + harnessTrend: readOptionalJson(options.harnessTrendPath), + requiredScenarioIds, + tag: options.tag, + }); + const validation = validateReleaseSummary(summary, { + requireEvidence: options.requireEvidence, + }); + const content = + options.format === "json" + ? `${JSON.stringify({ ...summary, validation }, null, 2)}\n` + : renderAgentQcReleaseMarkdown(summary); + + writeOutput(options.outputPath, content); + + if (options.check && !validation.valid) { + for (const issue of validation.issues) { + console.error(`[agent-qc-release] ${issue}`); + } + process.exit(1); + } +} + +main(); diff --git a/scripts/agent-qc-report.mjs b/scripts/agent-qc-report.mjs new file mode 100644 index 000000000..20a956385 --- /dev/null +++ b/scripts/agent-qc-report.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { + createAgentQcReport, + readJsonFile, + renderAgentQcMarkdownReport, +} from "./lib/agent-qc-report-core.mjs"; + +const DEFAULT_MANIFEST_PATH = "docs/test/agent-qc-scenarios.manifest.json"; +const DEFAULT_SCHEMA_PATH = "docs/test/agent-qc-evidence.schema.json"; + +function parseArgs(argv) { + const result = { + check: false, + format: "markdown", + help: false, + manifestPath: DEFAULT_MANIFEST_PATH, + outputPath: "", + schemaPath: DEFAULT_SCHEMA_PATH, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--manifest" && argv[index + 1]) { + result.manifestPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--schema" && argv[index + 1]) { + result.schemaPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC 场景报告 + +用法: + npm run agent-qc:report + npm run agent-qc:report:json + npm run agent-qc:check + node scripts/agent-qc-report.mjs --manifest docs/test/agent-qc-scenarios.manifest.json + +选项: + --check 如果 manifest 或 evidence schema 不合法,以非 0 退出 + --format FMT 输出格式:markdown | json + --manifest PATH Agent QC manifest 路径 + --schema PATH Evidence schema 路径 + --output PATH 写入报告文件;默认输出到 stdout + -h, --help 显示帮助 +`); +} + +function resolvePath(targetPath) { + return path.resolve(process.cwd(), targetPath); +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + + const resolvedOutputPath = resolvePath(outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const manifest = readJsonFile(resolvePath(options.manifestPath)); + const schema = readJsonFile(resolvePath(options.schemaPath)); + const packageJson = readJsonFile(resolvePath("package.json")); + const report = createAgentQcReport({ manifest, packageJson, evidenceSchema: schema }); + + const content = + options.format === "json" + ? `${JSON.stringify(report, null, 2)}\n` + : renderAgentQcMarkdownReport(report); + + writeOutput(options.outputPath, content); + + if (options.check && !report.valid) { + process.exit(1); + } +} + +main(); diff --git a/scripts/agent-runtime-approval-sandbox-smoke.mjs b/scripts/agent-runtime-approval-sandbox-smoke.mjs new file mode 100644 index 000000000..51afd1947 --- /dev/null +++ b/scripts/agent-runtime-approval-sandbox-smoke.mjs @@ -0,0 +1,652 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { + buildApprovalSandboxSmokeEvidence, + renderApprovalSandboxTranscriptLines, +} from "./lib/agent-runtime-approval-sandbox-smoke-core.mjs"; +import { runVitestSmoke } from "./lib/vitest-smoke-runner.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, ".."); +const DEFAULT_OUTPUT = path.join( + rootDir, + ".lime/qc/runtime-approval-sandbox-smoke.json", +); +const DEFAULT_HEALTH_URL = "http://127.0.0.1:3030/health"; +const DEFAULT_INVOKE_URL = "http://127.0.0.1:3030/invoke"; +const DEFAULT_TIMEOUT_MS = 60_000; +const DEFAULT_INTERVAL_MS = 1_000; +const DEFAULT_PROVIDER_PREFERENCE = + process.env.LIME_AGENT_QC_PROVIDER || + process.env.LIME_E2E_PROVIDER || + process.env.LIME_DEFAULT_PROVIDER || + ""; +const DEFAULT_MODEL_PREFERENCE = + process.env.LIME_AGENT_QC_MODEL || + process.env.LIME_E2E_MODEL || + process.env.LIME_DEFAULT_MODEL || + ""; +const APPROVAL_POLICY = "on-request"; +const SANDBOX_POLICY = "workspace-write"; +const PROVIDER_PICK_ORDER = ["deepseek", "doubao", "lime-hub"]; + +function printHelp() { + console.log(` +Lime Agent Runtime Approval / Sandbox Smoke + +用途: + 生成 tool / approval / sandbox 边界的确定性 smoke 证据,补齐 qcloop P0 场景的可审查摘要。 + +用法: + node scripts/agent-runtime-approval-sandbox-smoke.mjs [选项] + +选项: + --output 写入 evidence JSON,默认 ./.lime/qc/runtime-approval-sandbox-smoke.json + --health-url DevBridge health 地址,默认 ${DEFAULT_HEALTH_URL} + --invoke-url DevBridge invoke 地址,默认 ${DEFAULT_INVOKE_URL} + --timeout-ms live runtime 等待超时,默认 ${DEFAULT_TIMEOUT_MS} + --interval-ms live runtime 轮询间隔,默认 ${DEFAULT_INTERVAL_MS} + --provider-preference live runtime 使用的 provider 偏好;默认读取 LIME_AGENT_QC_PROVIDER / LIME_E2E_PROVIDER,未设置时自动选本地启用 provider + --model-preference live runtime 使用的 model 偏好;默认读取 LIME_AGENT_QC_MODEL / LIME_E2E_MODEL,未设置时自动选 provider 的首个自定义模型 + --skip-live-runtime 跳过 DevBridge live runtime transcript,仅生成确定性投影摘要 + --no-write 只运行校验并打印摘要,不写 evidence JSON + -h, --help 显示帮助 +`); +} + +function parseArgs(argv) { + const options = { + output: DEFAULT_OUTPUT, + healthUrl: DEFAULT_HEALTH_URL, + invokeUrl: DEFAULT_INVOKE_URL, + timeoutMs: DEFAULT_TIMEOUT_MS, + intervalMs: DEFAULT_INTERVAL_MS, + providerPreference: DEFAULT_PROVIDER_PREFERENCE, + modelPreference: DEFAULT_MODEL_PREFERENCE, + liveRuntime: true, + write: true, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--output" && argv[index + 1]) { + options.output = path.resolve(rootDir, String(argv[index + 1])); + index += 1; + continue; + } + if (arg === "--no-write") { + options.write = false; + continue; + } + if (arg === "--health-url" && argv[index + 1]) { + options.healthUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--invoke-url" && argv[index + 1]) { + options.invokeUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--timeout-ms" && argv[index + 1]) { + options.timeoutMs = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--interval-ms" && argv[index + 1]) { + options.intervalMs = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--provider-preference" && argv[index + 1]) { + options.providerPreference = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--model-preference" && argv[index + 1]) { + options.modelPreference = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--skip-live-runtime" || arg === "--no-live-runtime") { + options.liveRuntime = false; + continue; + } + if (arg === "--help" || arg === "-h") { + printHelp(); + process.exit(0); + } + } + + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 10_000) { + throw new Error("--timeout-ms 必须是 >= 10000 的数字"); + } + if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) { + throw new Error("--interval-ms 必须是 >= 100 的数字"); + } + + return options; +} + +function runVitest(label, args) { + return runVitestSmoke({ + rootDir, + label, + args, + logPrefix: "smoke:agent-runtime-approval-sandbox", + }); +} + +function writeEvidence(outputPath, evidence) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify(evidence, null, 2)}\n`); + return outputPath; +} + +function writeEvidenceWithFallback(outputPath, evidence) { + try { + return writeEvidence(outputPath, evidence); + } catch (error) { + if (outputPath !== DEFAULT_OUTPUT) { + throw error; + } + const fallbackPath = path.join(os.tmpdir(), "lime-runtime-approval-sandbox-smoke.json"); + const writtenPath = writeEvidence(fallbackPath, evidence); + console.warn( + `[smoke:agent-runtime-approval-sandbox] 默认 evidence 写入失败,已回退到 ${writtenPath}: ${error.message}`, + ); + return writtenPath; + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function waitForHealth(options) { + const startedAt = Date.now(); + let lastError = null; + + while (Date.now() - startedAt < options.timeoutMs) { + try { + const response = await fetch(options.healthUrl, { + method: "GET", + signal: AbortSignal.timeout(Math.min(options.intervalMs, 5_000)), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const payload = text ? JSON.parse(text) : {}; + console.log( + `[smoke:agent-runtime-approval-sandbox] DevBridge 已就绪 (${Date.now() - startedAt}ms)${ + payload?.status ? ` status=${payload.status}` : "" + }`, + ); + return payload; + } catch (error) { + lastError = error; + await sleep(options.intervalMs); + } + } + + const detail = + lastError instanceof Error + ? lastError.message + : String(lastError || "unknown error"); + throw new Error( + `[smoke:agent-runtime-approval-sandbox] DevBridge 未就绪,无法采集 live runtime transcript: ${detail}`, + ); +} + +async function invoke(options, cmd, args, timeoutMs = options.timeoutMs) { + const response = await fetch(options.invokeUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ cmd, args }), + signal: AbortSignal.timeout(timeoutMs), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`${cmd} HTTP ${response.status}: ${text}`); + } + const payload = text ? JSON.parse(text) : null; + if (payload?.error) { + throw new Error(`${cmd} error: ${payload.error}`); + } + return payload?.result; +} + +function normalizeProviderId(provider) { + return String(provider?.id || provider?.provider_id || provider?.providerId || "") + .trim(); +} + +function providerEnabled(provider) { + return provider?.enabled !== false; +} + +function pickModelPreference(provider) { + const candidates = [ + ...(Array.isArray(provider?.custom_models) ? provider.custom_models : []), + ...(Array.isArray(provider?.customModels) ? provider.customModels : []), + ...(Array.isArray(provider?.models) ? provider.models : []), + ] + .map((value) => + typeof value === "string" + ? value + : String(value?.name || value?.id || value?.model || "").trim(), + ) + .filter(Boolean); + + return ( + candidates.find((value) => /flash|mini|lite/i.test(value)) || + candidates[0] || + "" + ); +} + +function pickProvider(providers, preferredProviderId) { + const enabled = providers.filter((provider) => providerEnabled(provider)); + if (preferredProviderId) { + return ( + enabled.find((provider) => normalizeProviderId(provider) === preferredProviderId) || + providers.find((provider) => normalizeProviderId(provider) === preferredProviderId) || + null + ); + } + + for (const providerId of PROVIDER_PICK_ORDER) { + const match = enabled.find((provider) => normalizeProviderId(provider) === providerId); + if (match) { + return match; + } + } + + return enabled[0] || null; +} + +async function resolveProviderPreference(options) { + const explicitProvider = String(options.providerPreference || "").trim(); + const explicitModel = String(options.modelPreference || "").trim(); + if (explicitProvider && explicitModel) { + return { + providerPreference: explicitProvider, + modelPreference: explicitModel, + source: "explicit", + }; + } + + const providers = await invoke(options, "get_api_key_providers", {}, 30_000); + const selected = pickProvider(Array.isArray(providers) ? providers : [], explicitProvider); + const providerId = normalizeProviderId(selected); + if (!providerId) { + throw new Error( + "[smoke:agent-runtime-approval-sandbox] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider", + ); + } + + let providerDetail = selected; + try { + providerDetail = + (await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) || + selected; + } catch (error) { + console.warn( + `[smoke:agent-runtime-approval-sandbox] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`, + ); + } + + const modelPreference = explicitModel || pickModelPreference(providerDetail); + if (!modelPreference) { + throw new Error( + `[smoke:agent-runtime-approval-sandbox] provider ${providerId} 缺少可用模型;请传 --model-preference`, + ); + } + + return { + providerPreference: providerId, + modelPreference, + source: explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider", + }; +} + +function pickPermissionState(threadRead) { + return threadRead?.permission_state || threadRead?.permissionState || {}; +} + +function pendingRequests(threadRead) { + return threadRead?.pending_requests || threadRead?.pendingRequests || []; +} + +function latestTurnStatus(threadRead) { + return ( + threadRead?.diagnostics?.latest_turn_status || + threadRead?.diagnostics?.latestTurnStatus || + threadRead?.runtime_summary?.latestTurnStatus || + threadRead?.runtimeSummary?.latestTurnStatus || + threadRead?.status || + null + ); +} + +function permissionField(permissionState, snakeKey, camelKey) { + return permissionState?.[snakeKey] ?? permissionState?.[camelKey] ?? null; +} + +function summarizeThreadRead(threadRead) { + const permissionState = pickPermissionState(threadRead); + const requests = pendingRequests(threadRead); + return { + threadStatus: threadRead?.status || null, + latestTurnStatus: latestTurnStatus(threadRead), + primaryBlockingKind: + threadRead?.diagnostics?.primary_blocking_kind || + threadRead?.diagnostics?.primaryBlockingKind || + null, + primaryBlockingSummary: + threadRead?.diagnostics?.primary_blocking_summary || + threadRead?.diagnostics?.primaryBlockingSummary || + null, + pendingRequestCount: requests.length, + permissionStatus: permissionField(permissionState, "status", "status"), + confirmationStatus: permissionField( + permissionState, + "confirmation_status", + "confirmationStatus", + ), + confirmationRequestId: permissionField( + permissionState, + "confirmation_request_id", + "confirmationRequestId", + ), + confirmationSource: permissionField( + permissionState, + "confirmation_source", + "confirmationSource", + ), + askProfileKeys: + permissionField(permissionState, "ask_profile_keys", "askProfileKeys") || [], + requiredProfileKeys: + permissionField( + permissionState, + "required_profile_keys", + "requiredProfileKeys", + ) || [], + }; +} + +async function waitForThreadRead(options, sessionId, predicate, label) { + const startedAt = Date.now(); + let lastSummary = null; + + while (Date.now() - startedAt < options.timeoutMs) { + const threadRead = await invoke(options, "agent_runtime_get_thread_read", { + sessionId, + }); + lastSummary = summarizeThreadRead(threadRead); + if (predicate(threadRead, lastSummary)) { + return { threadRead, summary: lastSummary }; + } + await sleep(options.intervalMs); + } + + throw new Error( + `[smoke:agent-runtime-approval-sandbox] ${label} 超时,最后线程摘要: ${JSON.stringify(lastSummary)}`, + ); +} + +function buildRuntimeContractMetadata() { + return { + harness: { + browser_assist: { + runtime_contract: { + contract_key: "browser_control", + routing_slot: "browser_reasoning_model", + execution_profile: { + profile_key: "browser_control_profile", + }, + executor_adapter: { + adapter_key: "browser:browser_assist", + }, + executor_binding: { + executor_kind: "browser", + binding_key: "browser_assist", + }, + }, + }, + }, + }; +} + +async function runPermissionDecisionFlow(options, workspaceId, providerPreference, decision) { + const confirmed = decision === "resolved"; + const responseLabel = confirmed ? "允许本次执行" : "拒绝"; + const sessionId = await invoke(options, "agent_runtime_create_session", { + workspaceId, + name: `Agent QC approval ${decision} ${Date.now()}`, + runStartHooks: false, + }); + const turnId = `qc-approval-${decision}-${Date.now()}-${process.pid}`; + const eventName = `aster_stream_${sessionId}_${turnId}`; + const submittedPolicies = { + approvalPolicy: APPROVAL_POLICY, + sandboxPolicy: SANDBOX_POLICY, + }; + + await invoke(options, "agent_runtime_submit_turn", { + request: { + message: + "Agent QC runtime permission confirmation smoke:应在模型执行前创建真实权限确认请求。", + session_id: sessionId, + workspace_id: workspaceId, + event_name: eventName, + turn_id: turnId, + turn_config: { + provider_preference: providerPreference.providerPreference, + model_preference: providerPreference.modelPreference, + approval_policy: APPROVAL_POLICY, + sandbox_policy: SANDBOX_POLICY, + metadata: buildRuntimeContractMetadata(), + }, + skip_pre_submit_resume: true, + }, + }); + + const requested = await waitForThreadRead( + options, + sessionId, + (_threadRead, summary) => + summary.confirmationStatus === "requested" && + summary.pendingRequestCount > 0 && + String(summary.confirmationRequestId || "").includes(turnId), + `等待 ${decision} flow 进入权限确认 requested`, + ); + + const requestId = requested.summary.confirmationRequestId; + assert(requestId, `${decision} flow 缺少 confirmationRequestId`); + + await invoke(options, "agent_runtime_respond_action", { + request: { + session_id: sessionId, + request_id: requestId, + action_type: "elicitation", + confirmed, + response: JSON.stringify({ answer: responseLabel }), + user_data: { answer: responseLabel }, + event_name: eventName, + action_scope: { + session_id: sessionId, + thread_id: sessionId, + turn_id: turnId, + }, + }, + }); + + const completed = await waitForThreadRead( + options, + sessionId, + (_threadRead, summary) => + summary.pendingRequestCount === 0 && + summary.confirmationStatus === decision, + `等待 ${decision} flow 写回确认结果`, + ); + + return { + decision: confirmed ? "resolved" : "denied", + sessionId, + turnId, + requestId, + providerPreference, + submittedPolicies, + before: requested.summary, + respond: { + confirmed, + responseLabel, + }, + after: completed.summary, + }; +} + +async function collectLiveRuntimeTranscript(options) { + const health = await waitForHealth(options); + const providerPreference = await resolveProviderPreference(options); + console.log( + `[smoke:agent-runtime-approval-sandbox] live runtime provider: provider=${providerPreference.providerPreference} model=${providerPreference.modelPreference} source=${providerPreference.source}`, + ); + const workspace = await invoke(options, "get_or_create_default_project"); + const workspaceId = workspace?.id; + assert(workspaceId, "get_or_create_default_project 缺少 workspace id"); + + const deniedFlow = await runPermissionDecisionFlow( + options, + workspaceId, + providerPreference, + "denied", + ); + const resolvedFlow = await runPermissionDecisionFlow( + options, + workspaceId, + providerPreference, + "resolved", + ); + const flows = [deniedFlow, resolvedFlow]; + + return { + kind: "devbridge-runtime-permission-confirmation", + health, + workspaceId, + providerPreference, + flows, + assertions: { + devBridgeHealthy: health?.status === "ok" || Boolean(health), + permissionRequestCreatedBeforeModel: flows.every( + (flow) => + flow.before.permissionStatus === "requires_confirmation" && + flow.before.confirmationStatus === "requested" && + flow.before.pendingRequestCount > 0 && + flow.before.latestTurnStatus === "failed" && + String(flow.before.confirmationRequestId || "").includes(flow.turnId), + ), + deniedDecisionClearsPendingRequest: + deniedFlow.after.confirmationStatus === "denied" && + deniedFlow.after.pendingRequestCount === 0, + resolvedDecisionClearsPendingRequest: + resolvedFlow.after.confirmationStatus === "resolved" && + resolvedFlow.after.pendingRequestCount === 0, + approvalPolicySubmitted: flows.every( + (flow) => flow.submittedPolicies.approvalPolicy === APPROVAL_POLICY, + ), + sandboxPolicySubmitted: flows.every( + (flow) => flow.submittedPolicies.sandboxPolicy === SANDBOX_POLICY, + ), + }, + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const commandResults = []; + + commandResults.push( + runVitest("submit preferences 应透传 approval / sandbox policy", [ + "src/components/agent/chat/utils/buildUserInputSubmitOp.test.ts", + "src/components/agent/chat/hooks/agentStreamUserInputSubmission.test.ts", + "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts", + "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts", + ]), + ); + + commandResults.push( + runVitest("runtime projection 应保留 permission / tool lifecycle", [ + "src/components/agent/chat/projection/agentUiEventProjection.test.ts", + "-t", + "runtime permission metadata|工具输入、成功输出和失败输出|plan approval", + ]), + ); + + commandResults.push( + runVitest("消息与时间线不应把权限确认误渲染为失败", [ + "src/components/agent/chat/components/AgentThreadTimeline.test.tsx", + "src/components/agent/chat/components/MessageList.test.tsx", + "-t", + "运行时权限确认", + ]), + ); + + commandResults.push( + runVitest("Harness 面板应展示工具权限、待审批与 sandbox 来源", [ + "src/components/agent/chat/components/HarnessStatusPanel.test.tsx", + "-t", + "存在工具库存时应展示工具与权限区块及来源统计|待审批区块应通过 artifact protocol", + ]), + ); + + const liveRuntimeTranscript = options.liveRuntime + ? await collectLiveRuntimeTranscript(options) + : null; + + const evidence = buildApprovalSandboxSmokeEvidence({ + commandResults, + generatedAt: new Date().toISOString(), + liveRuntimeTranscript, + }); + + if (options.liveRuntime && !evidence.coverage.liveRuntimeTranscript) { + throw new Error( + "[smoke:agent-runtime-approval-sandbox] live runtime transcript 未满足发布门禁断言", + ); + } + + for (const line of renderApprovalSandboxTranscriptLines(evidence)) { + console.log(line); + } + + if (options.write) { + const evidencePath = writeEvidenceWithFallback(options.output, evidence); + console.log(`\n[smoke:agent-runtime-approval-sandbox] evidence: ${evidencePath}`); + } + + console.log("\n[smoke:agent-runtime-approval-sandbox] 通过"); +} + +main().catch((error) => { + console.error( + error instanceof Error ? error.message : String(error || "unknown error"), + ); + process.exit(1); +}); diff --git a/scripts/agent-runtime-tool-surface-smoke.mjs b/scripts/agent-runtime-tool-surface-smoke.mjs index 4a4bf8306..cc7ff9075 100644 --- a/scripts/agent-runtime-tool-surface-smoke.mjs +++ b/scripts/agent-runtime-tool-surface-smoke.mjs @@ -1,36 +1,21 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; +import { runVitestSmoke } from "./lib/vitest-smoke-runner.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, ".."); -const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; function runVitest(label, args) { - console.log(`\n[smoke:agent-runtime-tool-surface] > ${label}`); - const result = spawnSync( - npmCommand, - ["exec", "--", "vitest", "run", ...args], - { - cwd: rootDir, - stdio: "inherit", - env: process.env, - }, - ); - - if (result.error) { - throw result.error; - } - - if (typeof result.status === "number" && result.status !== 0) { - const error = new Error(`[smoke:agent-runtime-tool-surface] ${label} 失败`); - error.exitCode = result.status; - throw error; - } + return runVitestSmoke({ + rootDir, + label, + args, + logPrefix: "smoke:agent-runtime-tool-surface", + }); } function main() { @@ -50,6 +35,9 @@ function main() { "src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts", ]); + console.log( + '[smoke:agent-runtime-tool-surface] surface.summary: runtime inventory/strip/harness inventory 已一致透传;unsafeToolExposed=false; sources=runtime_tools|persisted_tools|default_policy', + ); console.log("\n[smoke:agent-runtime-tool-surface] 通过"); } diff --git a/scripts/agent-service-skill-entry-smoke.mjs b/scripts/agent-service-skill-entry-smoke.mjs index 3ea290fe4..ff71b500f 100644 --- a/scripts/agent-service-skill-entry-smoke.mjs +++ b/scripts/agent-service-skill-entry-smoke.mjs @@ -35,7 +35,55 @@ function runVitest(label, args) { } } +function runCargoTest(label, args) { + console.log(`\n[smoke:agent-service-skill-entry] > ${label}`); + const result = spawnSync("cargo", ["test", ...args], { + cwd: path.join(rootDir, "src-tauri"), + stdio: "inherit", + env: process.env, + }); + + if (result.error) { + throw result.error; + } + + if (typeof result.status === "number" && result.status !== 0) { + const error = new Error( + `[smoke:agent-service-skill-entry] ${label} 失败`, + ); + error.exitCode = result.status; + throw error; + } +} + function main() { + runVitest("Skill Forge 前端 metadata 与工作台显式启用链路", [ + "src/lib/api/capabilityDrafts.test.ts", + "src/lib/api/agentRuntime/inventoryClient.test.ts", + "src/components/agent/chat/utils/workspaceSkillBindingsMetadata.test.ts", + "src/components/agent/chat/utils/harnessRequestMetadata.test.ts", + "src/features/capability-drafts/workspaceSkillAgentAutomationDraft.test.ts", + ]); + + [ + "register_capability_draft_persists_readonly_http_preflight_provenance", + "registered_skill_becomes_ready_for_manual_enable_binding_candidate", + "explicit_runtime_enable_projects_ready_binding_allowlist", + "registered_skill_without_verification_provenance_is_blocked", + "execute_capability_draft_controlled_get_returns_evidence_without_persisting_inputs", + "should_project_workspace_skill_runtime_enable_as_callable_scope", + "allowlisted_session_should_preserve_workspace_skill_source_metadata", + "disabled_session_should_fail_execute", + ].forEach((testName) => { + runCargoTest(`Skill Forge Rust 定向测试: ${testName}`, [ + "--manifest-path", + "Cargo.toml", + testName, + "--", + "--exact", + ]); + }); + runVitest("服务技能入口路由与挂起参数", [ "src/components/skills/SkillsWorkspacePage.test.tsx", "src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx", diff --git a/scripts/browser-runtime-smoke.mjs b/scripts/browser-runtime-smoke.mjs index 4d6027a75..f1315d4cd 100644 --- a/scripts/browser-runtime-smoke.mjs +++ b/scripts/browser-runtime-smoke.mjs @@ -25,7 +25,9 @@ const INVOKE_RETRY_DELAY_MS = 1_000; const POST_HEALTH_SETTLE_MS = 3_000; const POST_LAUNCH_SETTLE_MS = 1_500; const READ_PAGE_TIMEOUT_MS = 45_000; -const SMOKE_PROFILE_KEY = "smoke-browser-runtime"; +const DEFAULT_SMOKE_PROFILE_KEY = + process.env.LIME_BROWSER_RUNTIME_SMOKE_PROFILE_KEY || + `smoke-browser-runtime-${process.pid}`; function printHelp() { console.log(` @@ -43,6 +45,7 @@ Lime Browser Runtime Smoke --timeout-ms 等待健康检查超时,默认 90000 --interval-ms 健康检查轮询间隔,默认 1000 --launch-url 启动浏览器会话的 URL,默认使用内置 data: 测试页 + --profile-key smoke 专用浏览器 profile key,默认按进程隔离 --open-window 显式打开浏览器窗口 --headless 以无界面浏览器会话执行 smoke,避免弹出空白 Chrome --stream-mode events | frames | both,默认 both @@ -85,6 +88,11 @@ function parseArgs(argv) { index += 1; continue; } + if (arg === "--profile-key" && argv[index + 1]) { + options.profileKey = String(argv[index + 1]).trim(); + index += 1; + continue; + } if (arg === "--open-window") { options.openWindow = true; continue; @@ -111,6 +119,9 @@ function parseArgs(argv) { if (!options.launchUrl) { throw new Error("--launch-url 不能为空"); } + if (!options.profileKey) { + options.profileKey = DEFAULT_SMOKE_PROFILE_KEY; + } return options; } @@ -239,8 +250,9 @@ async function main() { await waitForHealth(options); await sleep(POST_HEALTH_SETTLE_MS); - const profileKey = SMOKE_PROFILE_KEY; + const profileKey = options.profileKey; let sessionId = null; + let cleanupStatus = "skipped"; try { await closeSmokeProfileSession( @@ -310,6 +322,38 @@ async function main() { "browser_execute_action 未返回对应的 target_id", ); + const consoleResult = await invoke(options, "browser_execute_action", { + request: { + profile_key: profileKey, + action: "read_console_messages", + args: { since: 0 }, + timeout_ms: Math.min(options.timeoutMs, READ_PAGE_TIMEOUT_MS), + }, + }); + assert( + consoleResult?.success === true, + "browser_execute_action(read_console_messages) 未成功", + ); + const consoleCount = Array.isArray(consoleResult?.data?.messages) + ? consoleResult.data.messages.length + : 0; + + const networkResult = await invoke(options, "browser_execute_action", { + request: { + profile_key: profileKey, + action: "read_network_requests", + args: { since: 0 }, + timeout_ms: Math.min(options.timeoutMs, READ_PAGE_TIMEOUT_MS), + }, + }); + assert( + networkResult?.success === true, + "browser_execute_action(read_network_requests) 未成功", + ); + const networkCount = Array.isArray(networkResult?.data?.events) + ? networkResult.data.events.length + : 0; + const auditLogs = await invoke(options, "get_browser_action_audit_logs", { limit: 10, }); @@ -344,7 +388,7 @@ async function main() { ); console.log( - `[smoke:browser-runtime] 通过 session=${sessionId} target=${sessionState.target_id} profile=${profileKey}`, + `[smoke:browser-runtime] 通过 session=${sessionId} target=${sessionState.target_id} profile=${profileKey} consoleEvents=${consoleCount} networkEvents=${networkCount}`, ); } finally { if (sessionId) { @@ -354,7 +398,9 @@ async function main() { session_id: sessionId, }, }); + cleanupStatus = "pass"; } catch (error) { + cleanupStatus = "failed"; console.warn( `[smoke:browser-runtime] 清理会话失败: ${ error instanceof Error ? error.message : String(error) @@ -368,6 +414,9 @@ async function main() { profileKey, "关闭 smoke profile 失败", ); + console.log( + `[smoke:browser-runtime] cleanup=${cleanupStatus} session=${sessionId ?? "none"} profile=${profileKey}`, + ); } } diff --git a/scripts/claw-chat-ready-streaming-smoke.mjs b/scripts/claw-chat-ready-streaming-smoke.mjs new file mode 100644 index 000000000..15f2debcb --- /dev/null +++ b/scripts/claw-chat-ready-streaming-smoke.mjs @@ -0,0 +1,1393 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { chromium } from "playwright"; + +const DEFAULTS = { + appUrl: "http://127.0.0.1:1420/", + healthUrl: "http://127.0.0.1:3030/health", + invokeUrl: "http://127.0.0.1:3030/invoke", + timeoutMs: 180_000, + intervalMs: 1_000, + providerPreference: + process.env.LIME_AGENT_QC_PROVIDER || + process.env.LIME_E2E_PROVIDER || + process.env.LIME_DEFAULT_PROVIDER || + "", + modelPreference: + process.env.LIME_AGENT_QC_MODEL || + process.env.LIME_E2E_MODEL || + process.env.LIME_DEFAULT_MODEL || + "", + evidenceDir: path.join( + process.cwd(), + ".lime", + "qc", + "gui-evidence", + "claw-chat-ready-streaming", + ), + prefix: "claw-chat-ready-streaming", +}; + +const POST_HEALTH_SETTLE_MS = 1_500; +const ONBOARDING_VERSION = "1.1.0"; +const LONG_PROMPT = [ + "E2E 中断测试:请输出 160 行。", + "每一行都必须使用格式:中断测试第 N 行。", + "从 1 开始递增,不要合并行,不要提前总结。", + "如果收到停止请求应立即停止,不要补完剩余行。", +].join("\n"); +const RECOVERY_EXPECTED_TEXT = "复原完成"; +const RECOVERY_PROMPT = + "停止后恢复测试:这是一个新的独立回合,请忽略上一条输出 160 行的要求。只输出“复原完成”这四个字,不要输出行号、解释或其他内容。"; +const FAST_RESPONSE_MODE_STORAGE_KEY = "lime:agent-fast-response-mode"; +const TASK_CENTER_OPEN_TASK_EVENT = "lime:task-center:open-task"; + +function printHelp() { + console.log(` +Lime Claw Chat Ready Streaming Smoke + +用途: + 通过真实 Claw 聊天页面验证 workspace ready、流式增量、中断与恢复下一轮, + 并输出 GUI / runtime / console / network 四类证据。 + +用法: + npm run smoke:claw-chat-ready-streaming + npm run smoke:claw-chat-ready-streaming -- --provider-preference deepseek --model-preference deepseek-v4-flash + +选项: + --app-url 前端地址,默认 http://127.0.0.1:1420/ + --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health + --invoke-url DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke + --timeout-ms 总超时,默认 180000 + --interval-ms 轮询间隔,默认 1000 + --provider-preference 可选,显式指定 provider + --model-preference 可选,显式指定 model + --evidence-dir 证据目录,默认 .lime/qc/gui-evidence/claw-chat-ready-streaming + --prefix 证据文件前缀,默认 claw-chat-ready-streaming + -h, --help 显示帮助 +`); +} + +function parseArgs(argv) { + const options = { ...DEFAULTS }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--app-url" && argv[index + 1]) { + options.appUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--health-url" && argv[index + 1]) { + options.healthUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--invoke-url" && argv[index + 1]) { + options.invokeUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--timeout-ms" && argv[index + 1]) { + options.timeoutMs = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--interval-ms" && argv[index + 1]) { + options.intervalMs = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--provider-preference" && argv[index + 1]) { + options.providerPreference = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--model-preference" && argv[index + 1]) { + options.modelPreference = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--evidence-dir" && argv[index + 1]) { + options.evidenceDir = path.resolve(String(argv[index + 1]).trim()); + index += 1; + continue; + } + if (arg === "--prefix" && argv[index + 1]) { + options.prefix = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--help" || arg === "-h") { + printHelp(); + process.exit(0); + } + } + + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 30_000) { + throw new Error("--timeout-ms 必须是 >= 30000 的数字"); + } + if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) { + throw new Error("--interval-ms 必须是 >= 100 的数字"); + } + if (!options.appUrl) { + throw new Error("--app-url 不能为空"); + } + if (!options.evidenceDir) { + throw new Error("--evidence-dir 不能为空"); + } + if (!options.prefix) { + throw new Error("--prefix 不能为空"); + } + + return options; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function logStage(label) { + console.log(`[smoke:claw-chat-ready-streaming] stage=${label}`); +} + +async function waitForHealth(options) { + const startedAt = Date.now(); + let lastError = null; + + while (Date.now() - startedAt < options.timeoutMs) { + try { + const response = await fetch(options.healthUrl, { method: "GET" }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + console.log( + `[smoke:claw-chat-ready-streaming] DevBridge 已就绪 (${Date.now() - startedAt}ms)${ + payload?.status ? ` status=${payload.status}` : "" + }`, + ); + return payload; + } catch (error) { + lastError = error; + await sleep(options.intervalMs); + } + } + + const detail = + lastError instanceof Error + ? lastError.message + : String(lastError || "unknown error"); + throw new Error( + `[smoke:claw-chat-ready-streaming] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`, + ); +} + +async function invoke(options, cmd, args, timeoutMs = options.timeoutMs) { + const response = await fetch(options.invokeUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ cmd, args }), + signal: AbortSignal.timeout(timeoutMs), + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}; ${text}`); + } + + const payload = text ? JSON.parse(text) : null; + if (payload?.error) { + throw new Error(String(payload.error)); + } + + return payload?.result; +} + +async function restoreOriginalProviderConfig(options, originalProviderConfig, sessionId) { + if ( + !originalProviderConfig?.providerName || + !originalProviderConfig?.modelName + ) { + return; + } + + const providerSelector = + originalProviderConfig.providerSelector || originalProviderConfig.providerName; + const request = { + provider_id: + providerSelector && providerSelector !== originalProviderConfig.providerName + ? providerSelector + : undefined, + provider_name: originalProviderConfig.providerName, + model_name: originalProviderConfig.modelName, + }; + + await invoke( + options, + "aster_agent_configure_provider", + { + request, + sessionId: sessionId || "agent-qc-provider-restore", + }, + 45_000, + ); +} + +async function waitForCondition(label, predicate, timeoutMs, intervalMs) { + const startedAt = Date.now(); + let lastValue = null; + + while (Date.now() - startedAt < timeoutMs) { + lastValue = await predicate(); + if (lastValue) { + return lastValue; + } + await sleep(intervalMs); + } + + throw new Error( + `[smoke:claw-chat-ready-streaming] ${label} 超时,最后结果: ${JSON.stringify(lastValue)}`, + ); +} + + +function normalizeProviderId(provider) { + return String(provider?.id || provider?.provider_id || provider?.providerId || "").trim(); +} + +function providerEnabled(provider) { + return provider?.enabled !== false; +} + +function pickModelPreference(provider) { + const candidates = [ + provider?.default_model, + provider?.defaultModel, + ...(Array.isArray(provider?.custom_models) ? provider.custom_models : []), + ...(Array.isArray(provider?.customModels) ? provider.customModels : []), + ...(Array.isArray(provider?.models) ? provider.models : []), + ] + .map((value) => + typeof value === "string" + ? value + : String(value?.name || value?.id || value?.model || "").trim(), + ) + .filter(Boolean); + + return ( + candidates.find((value) => /flash|mini|lite/i.test(value)) || + candidates[0] || + "" + ); +} + +function pickProvider(providers, preferredProviderId) { + const enabled = providers.filter((provider) => providerEnabled(provider)); + if (preferredProviderId) { + return ( + enabled.find((provider) => normalizeProviderId(provider) === preferredProviderId) || + enabled.find((provider) => provider?.provider_name === preferredProviderId) || + providers.find((provider) => normalizeProviderId(provider) === preferredProviderId) || + providers.find((provider) => provider?.provider_name === preferredProviderId) || + null + ); + } + + for (const providerId of ["deepseek", "doubao", "lime-hub"]) { + const match = enabled.find((provider) => normalizeProviderId(provider) === providerId); + if (match) { + return match; + } + } + + return enabled[0] || null; +} + +async function resolveProviderPreference(options, agentStatus, providers) { + const explicitProvider = String(options.providerPreference || "").trim(); + const explicitModel = String(options.modelPreference || "").trim(); + if (explicitProvider && explicitModel) { + return { + providerPreference: explicitProvider, + modelPreference: explicitModel, + source: "explicit", + }; + } + + const statusProvider = String( + agentStatus?.provider_selector || agentStatus?.provider_name || "", + ).trim(); + const statusModel = String(agentStatus?.model_name || "").trim(); + if (!explicitProvider && !explicitModel && statusProvider && statusModel) { + return { + providerPreference: statusProvider, + modelPreference: statusModel, + source: "agent-status", + }; + } + + const selected = pickProvider( + Array.isArray(providers) ? providers : [], + explicitProvider || statusProvider, + ); + const providerId = normalizeProviderId(selected) || String(selected?.provider_name || "").trim(); + if (!providerId) { + throw new Error( + "[smoke:claw-chat-ready-streaming] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider", + ); + } + + let providerDetail = selected; + try { + providerDetail = + (await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) || + selected; + } catch (error) { + console.warn( + `[smoke:claw-chat-ready-streaming] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`, + ); + } + + const modelPreference = explicitModel || statusModel || pickModelPreference(providerDetail); + if (!modelPreference) { + throw new Error( + `[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用模型;请传 --model-preference`, + ); + } + + return { + providerPreference: providerId, + modelPreference, + source: explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider", + }; +} + +function textOfMessage(message) { + if (!message || !Array.isArray(message.content)) { + return ""; + } + return message.content + .map((part) => { + if (part && typeof part.text === "string") { + return part.text; + } + if (part && typeof part.output === "string") { + return part.output; + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function allAssistantText(session) { + return (session?.messages || []) + .filter((message) => message?.role === "assistant") + .map(textOfMessage) + .join("\n"); +} + +function allAgentItemText(session) { + return (session?.items || []) + .filter((item) => item?.type === "agent_message") + .map((item) => { + if (typeof item?.text === "string") { + return item.text; + } + if (typeof item?.content === "string") { + return item.content; + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function findTurn(session, turnId) { + return (session?.turns || []).find((turn) => turn?.id === turnId) || null; +} + +function queuedTurnCount(session) { + return Array.isArray(session?.queued_turns) ? session.queued_turns.length : 0; +} + +function normalizeConsoleLine(item) { + const location = + item.location && item.location.url + ? ` @ ${item.location.url}:${item.location.lineNumber ?? ""}` + : ""; + return `[${item.type}] ${item.text}${location}`; +} + +function buildPageSnapshotScript(recoveryExpectedText) { + return `(() => { + const textareas = Array.from( + document.querySelectorAll('textarea[name="agent-chat-message"]'), + ); + const textarea = textareas.at(-1) ?? null; + const stopButton = + Array.from(document.querySelectorAll("button")).find( + (button) => button.getAttribute("aria-label") === "停止", + ) ?? null; + const sendButton = + Array.from(document.querySelectorAll("button")).find( + (button) => button.getAttribute("title") === "发送", + ) ?? null; + const bodyText = document.body?.innerText || ""; + const streamLinePattern = /中断测试第\\s*\\d+\\s*行/; + const finalLinePattern = /中断测试第\\s*160\\s*行/; + const recoveryExpected = ${JSON.stringify(recoveryExpectedText)}; + const streamText = bodyText + .split("\\n") + .filter((line) => streamLinePattern.test(line)) + .join("\\n"); + return { + href: window.location.href, + ready: Boolean(textarea) && !textarea.disabled, + hasTextarea: Boolean(textarea), + textareaValue: textarea ? textarea.value : "", + sendDisabled: Boolean(sendButton?.disabled), + stopVisible: Boolean(stopButton), + stoppedMarker: bodyText.includes("用户已停止当前执行"), + recoveryVisible: bodyText.includes(recoveryExpected), + streamLineCount: streamText ? streamText.split("\\n").filter(Boolean).length : 0, + streamTextLength: streamText.length, + finalLineSeen: finalLinePattern.test(streamText), + buttons: Array.from(document.querySelectorAll("button")).map((button) => ({ + text: (button.textContent || "").trim(), + aria: button.getAttribute("aria-label"), + title: button.getAttribute("title"), + disabled: Boolean(button.disabled), + })), + }; + })()`; +} + +async function readPageSnapshot(page) { + return page + .evaluate(buildPageSnapshotScript(RECOVERY_EXPECTED_TEXT)) + .catch(() => null); +} + +function isLikelyDetachedBlankTaskSnapshot(snapshot) { + return ( + Boolean(snapshot?.ready) && + !snapshot?.stopVisible && + !snapshot?.stoppedMarker && + !snapshot?.recoveryVisible && + Number(snapshot?.streamLineCount || 0) === 0 && + Number(snapshot?.streamTextLength || 0) === 0 + ); +} + +async function dispatchTaskCenterOpenTask(page, sessionId, workspaceId) { + return page + .evaluate( + ({ eventName, sessionId: targetSessionId, workspaceId: targetWorkspaceId }) => { + const normalizedSessionId = String(targetSessionId || "").trim(); + if (!normalizedSessionId) { + return { dispatched: false, reason: "missing-session-id" }; + } + + const event = new CustomEvent(eventName, { + cancelable: true, + detail: { + sessionId: normalizedSessionId, + workspaceId: targetWorkspaceId || null, + source: "conversation_shelf", + }, + }); + const notCanceled = window.dispatchEvent(event); + return { dispatched: true, canceled: !notCanceled }; + }, + { + eventName: TASK_CENTER_OPEN_TASK_EVENT, + sessionId, + workspaceId, + }, + ) + .catch((error) => ({ + dispatched: false, + reason: error instanceof Error ? error.message : String(error), + })); +} + +function writeJsonFile(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function consoleNetworkSummary(consoleMessages, failedRequests) { + const consoleErrors = consoleMessages.filter( + (item) => item.type === "error" || item.type === "pageerror", + ); + const consoleWarnings = consoleMessages.filter( + (item) => item.type === "warning", + ); + const mockFallbackLines = consoleMessages + .filter((item) => item.text.includes("[Mock]")) + .map(normalizeConsoleLine); + + return { + consoleErrors, + consoleWarnings, + mockFallbackLines, + networkErrorTop: Object.entries( + failedRequests.reduce((acc, item) => { + const key = `${item.failure} ${item.url}`; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}), + ) + .sort((left, right) => right[1] - left[1]) + .slice(0, 12), + }; +} + +function writeConsoleNetworkEvidence( + evidenceDir, + prefix, + consoleMessages, + invokes, + failedRequests, +) { + const summary = consoleNetworkSummary(consoleMessages, failedRequests); + writeJsonFile(path.join(evidenceDir, `${prefix}-network-invoke.json`), { + invokes, + failedRequests, + }); + fs.writeFileSync( + path.join(evidenceDir, `${prefix}-console.txt`), + [ + `Errors: ${summary.consoleErrors.length}`, + `Warnings: ${summary.consoleWarnings.length}`, + `MockLines: ${summary.mockFallbackLines.length}`, + "", + ...consoleMessages.map(normalizeConsoleLine), + "", + ].join("\n"), + "utf8", + ); + return summary; +} + +function buildStorageBootstrapScript(storageMarker, storageOverrides) { + return `(() => { + const marker = ${JSON.stringify(storageMarker)}; + const overrides = ${JSON.stringify(storageOverrides)}; + const keys = Object.keys(overrides); + if (!window.sessionStorage.getItem(marker)) { + const original = {}; + for (const key of keys) { + original[key] = window.localStorage.getItem(key); + } + window.sessionStorage.setItem(marker, JSON.stringify(original)); + } + for (const [key, value] of Object.entries(overrides)) { + window.localStorage.setItem(key, String(value)); + } + return true; + })()`; +} + +function buildRestoreLocalStorageScript(storageMarker) { + return `((marker) => { + const raw = window.sessionStorage.getItem(marker); + if (!raw) return false; + const original = JSON.parse(raw); + for (const [key, value] of Object.entries(original)) { + if (value === null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, String(value)); + } + } + window.sessionStorage.removeItem(marker); + return true; + })(${JSON.stringify(storageMarker)})`; +} + +async function launchPlaywrightContext() { + const userDataDir = fs.mkdtempSync( + path.join(os.tmpdir(), `lime-claw-chat-ready-streaming-${process.pid}-`), + ); + const launchOptions = { + headless: true, + viewport: { width: 1440, height: 960 }, + }; + + try { + const context = await chromium.launchPersistentContext(userDataDir, { + ...launchOptions, + channel: "chrome", + }); + return { context, userDataDir }; + } catch (chromeError) { + console.warn( + `[smoke:claw-chat-ready-streaming] Chrome channel 启动失败,尝试 Playwright 自带 Chromium: ${ + chromeError instanceof Error ? chromeError.message : String(chromeError) + }`, + ); + const context = await chromium.launchPersistentContext( + userDataDir, + launchOptions, + ); + return { context, userDataDir }; + } +} + +async function main() { + if (typeof fetch !== "function") { + throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+"); + } + + const options = parseArgs(process.argv.slice(2)); + const prefix = options.prefix; + const evidenceDir = options.evidenceDir; + fs.mkdirSync(evidenceDir, { recursive: true }); + + logStage("wait-health"); + const health = await waitForHealth(options); + await sleep(POST_HEALTH_SETTLE_MS); + + logStage("prepare-runtime"); + const defaultProject = + (await invoke(options, "get_or_create_default_project", undefined, 30_000).catch( + () => null, + )) || null; + const workspaceId = defaultProject?.id || "default"; + const agentStatus = await invoke(options, "aster_agent_init", undefined, 45_000); + const providers = await invoke( + options, + "get_api_key_providers", + undefined, + 30_000, + ).catch(() => []); + const providerUiState = await invoke( + options, + "get_provider_ui_state", + undefined, + 30_000, + ).catch(() => null); + + const enabledProviders = Array.isArray(providers) + ? providers.filter((provider) => providerEnabled(provider)) + : []; + assert( + Boolean(agentStatus?.provider_configured) || enabledProviders.length > 0, + `当前 Agent provider 未配置,无法执行 Claw 流式 smoke: ${JSON.stringify(agentStatus)}`, + ); + const providerResolution = await resolveProviderPreference( + options, + agentStatus, + enabledProviders, + ); + const preferredProvider = providerResolution.providerPreference; + const preferredModel = providerResolution.modelPreference; + + console.log( + `[smoke:claw-chat-ready-streaming] live runtime provider: provider=${preferredProvider} model=${preferredModel} source=${providerResolution.source}`, + ); + + logStage("launch-browser"); + const { context, userDataDir } = await launchPlaywrightContext(); + let page = null; + const consoleMessages = []; + const invokes = []; + const failedRequests = []; + const storageMarker = `${prefix}:original-local-storage`; + + const summary = { + scenarioId: "claw-chat-ready-streaming", + prefix, + verdict: "fail", + appUrl: options.appUrl, + bridge: health, + workspaceId, + providerPreference: preferredProvider, + modelPreference: preferredModel, + agentStatusBefore: { + initialized: Boolean(agentStatus?.initialized), + provider_configured: Boolean(agentStatus?.provider_configured), + provider_name: agentStatus?.provider_name || null, + provider_selector: agentStatus?.provider_selector || null, + model_name: agentStatus?.model_name || null, + credential_uuid: providerUiState?.credential_uuid ? "[redacted]" : null, + }, + steps: [], + }; + const originalProviderConfig = { + providerName: agentStatus?.provider_name || "", + providerSelector: agentStatus?.provider_selector || "", + modelName: agentStatus?.model_name || "", + }; + let submittedSessionId = ""; + + try { + const scopedProviderKey = `agent_pref_provider_${workspaceId}`; + const scopedModelKey = `agent_pref_model_${workspaceId}`; + const scopedMigratedKey = `agent_pref_migrated_${workspaceId}`; + const storageOverrides = { + lime_onboarding_complete: "true", + lime_onboarding_version: ONBOARDING_VERSION, + lime_user_profile: "developer", + [FAST_RESPONSE_MODE_STORAGE_KEY]: "off", + agent_pref_provider: JSON.stringify(preferredProvider), + agent_pref_model: JSON.stringify(preferredModel), + agent_pref_provider_global: JSON.stringify(preferredProvider), + agent_pref_model_global: JSON.stringify(preferredModel), + [scopedProviderKey]: JSON.stringify(preferredProvider), + [scopedModelKey]: JSON.stringify(preferredModel), + [scopedMigratedKey]: JSON.stringify(true), + }; + + await context.addInitScript(buildStorageBootstrapScript(storageMarker, storageOverrides)); + page = context.pages()[0] ?? (await context.newPage()); + + page.on("console", (message) => { + consoleMessages.push({ + type: message.type(), + text: message.text(), + location: message.location(), + }); + }); + page.on("pageerror", (error) => { + consoleMessages.push({ + type: "pageerror", + text: error.message, + location: {}, + }); + }); + page.on("request", (request) => { + const requestUrl = request.url(); + if ( + request.method() !== "POST" || + (requestUrl !== options.invokeUrl && !requestUrl.endsWith("/invoke")) + ) { + return; + } + const postData = request.postData(); + if (!postData) { + return; + } + try { + const parsed = JSON.parse(postData); + invokes.push({ + at: new Date().toISOString(), + cmd: parsed.cmd, + args: parsed.args, + }); + } catch { + invokes.push({ + at: new Date().toISOString(), + cmd: "", + raw: postData, + }); + } + }); + page.on("requestfailed", (request) => { + failedRequests.push({ + url: request.url(), + method: request.method(), + failure: request.failure()?.errorText || "unknown", + }); + }); + + logStage("open-app"); + await page.goto(options.appUrl, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => undefined); + await page + .getByRole("button", { name: "新建任务" }) + .click({ timeout: 20_000 }) + .catch(() => undefined); + + logStage("wait-composer-ready"); + await page.locator('textarea[name="agent-chat-message"]').last().waitFor({ + state: "visible", + timeout: 60_000, + }); + await page.waitForFunction( + () => { + const textareas = Array.from( + document.querySelectorAll('textarea[name="agent-chat-message"]'), + ); + const textarea = textareas.at(-1); + return Boolean(textarea && !textarea.disabled); + }, + null, + { timeout: 60_000 }, + ); + const readySnapshot = await readPageSnapshot(page); + assert(readySnapshot, "读取 ready 页面快照失败"); + summary.readySnapshot = readySnapshot; + summary.steps.push("ready"); + await page.screenshot({ + path: path.join(evidenceDir, `${prefix}-01-ready.png`), + fullPage: true, + }); + + logStage("submit-long-turn"); + const longSubmitStart = invokes.length; + const textarea = page.locator('textarea[name="agent-chat-message"]').last(); + await textarea.fill(LONG_PROMPT); + await page.getByRole("button", { name: "发送" }).last().click({ + timeout: 30_000, + }); + const longSubmit = await waitForCondition( + "等待长 turn submit", + () => + invokes + .slice(longSubmitStart) + .find( + (item) => + item.cmd === "agent_runtime_submit_turn" && + String(item.args?.request?.message || "").includes("E2E 中断测试"), + ) || null, + 30_000, + 250, + ); + const longRequest = longSubmit.args?.request || {}; + const sessionId = String(longRequest.session_id || ""); + const longTurnId = String(longRequest.turn_id || ""); + assert(sessionId, "长 turn 缺少 session_id"); + assert(longTurnId, "长 turn 缺少 turn_id"); + submittedSessionId = sessionId; + summary.sessionId = sessionId; + summary.longTurnId = longTurnId; + summary.longSubmitTurnConfig = longRequest.turn_config || null; + + const firstDelta = await waitForCondition( + "等待首个流式增量与停止按钮", + async () => { + const snapshot = await readPageSnapshot(page); + if (!snapshot) { + return null; + } + const session = await invoke( + options, + "agent_runtime_get_session", + { sessionId }, + 20_000, + ).catch(() => null); + const turn = findTurn(session, longTurnId); + if (turn && ["failed", "aborted", "completed"].includes(turn.status)) { + summary.preStreamTurn = turn; + const errorMessage = String(turn.error_message || turn.errorMessage || "").trim(); + throw new Error( + `[smoke:claw-chat-ready-streaming] 长 turn 在首个流式增量前结束: status=${turn.status}${ + errorMessage ? ` error=${errorMessage}` : "" + }`, + ); + } + return snapshot?.stopVisible && snapshot.streamTextLength > 0 + ? snapshot + : null; + }, + 90_000, + 500, + ); + summary.firstDelta = firstDelta; + summary.steps.push("running-stop-visible"); + await page.screenshot({ + path: path.join(evidenceDir, `${prefix}-02-running-stop-visible.png`), + fullPage: true, + }); + + const runningSession = await waitForCondition( + "等待 turn 进入 running", + async () => { + const session = await invoke( + options, + "agent_runtime_get_session", + { sessionId }, + 20_000, + ).catch(() => null); + const turn = findTurn(session, longTurnId); + return turn?.status === "running" ? { turn, session } : null; + }, + 60_000, + 1_000, + ); + summary.runningTurnObserved = runningSession.turn; + + logStage("interrupt-long-turn"); + const interruptStart = invokes.length; + await page.getByRole("button", { name: "停止" }).last().click({ + timeout: 30_000, + }); + const interruptInvoke = await waitForCondition( + "等待 interrupt invoke", + () => + invokes + .slice(interruptStart) + .find( + (item) => + item.cmd === "agent_runtime_interrupt_turn" && + String(item.args?.request?.session_id || "") === sessionId, + ) || null, + 30_000, + 250, + ); + const interruptRequest = interruptInvoke.args?.request || {}; + summary.interruptRequest = interruptRequest; + summary.interruptHasTurnScope = + interruptRequest.session_id === sessionId && + interruptRequest.turn_id === longTurnId; + await page.screenshot({ + path: path.join(evidenceDir, `${prefix}-03-after-stop.png`), + fullPage: true, + }); + summary.steps.push("after-stop"); + + const interrupted = await waitForCondition( + "等待 turn 中断完成", + async () => { + const session = await invoke( + options, + "agent_runtime_get_session", + { sessionId }, + 20_000, + ).catch(() => null); + const turn = findTurn(session, longTurnId); + return turn && ["aborted", "failed", "completed"].includes(turn.status) + ? { turn, session } + : null; + }, + 120_000, + 1_000, + ); + let latestSession = interrupted.session; + summary.interruptedTurn = interrupted.turn; + summary.interruptedTurnStatus = interrupted.turn?.status || null; + summary.queueCountAfterInterrupt = queuedTurnCount(latestSession); + + await page.waitForFunction( + () => { + const textareas = Array.from( + document.querySelectorAll('textarea[name="agent-chat-message"]'), + ); + const textarea = textareas.at(-1); + return Boolean(textarea && !textarea.disabled); + }, + null, + { timeout: 60_000 }, + ).catch(() => undefined); + + logStage("submit-recovery-turn"); + const followSubmitStart = invokes.length; + await textarea.fill(RECOVERY_PROMPT); + await page.getByRole("button", { name: "发送" }).last().click({ + timeout: 30_000, + }); + const followSubmit = await waitForCondition( + "等待恢复 turn submit", + () => + invokes + .slice(followSubmitStart) + .find( + (item) => + item.cmd === "agent_runtime_submit_turn" && + String(item.args?.request?.message || "").includes("停止后恢复测试"), + ) || null, + 30_000, + 250, + ); + const followRequest = followSubmit.args?.request || {}; + const followTurnId = String(followRequest.turn_id || ""); + assert(followTurnId, "恢复 turn 缺少 turn_id"); + summary.followTurnId = followTurnId; + summary.followSubmitTurnConfig = followRequest.turn_config || null; + + const followCompleted = await waitForCondition( + "等待恢复 turn 完成", + async () => { + const session = await invoke( + options, + "agent_runtime_get_session", + { sessionId }, + 20_000, + ).catch(() => null); + const turn = findTurn(session, followTurnId); + return turn && ["completed", "failed", "aborted"].includes(turn.status) + ? { turn, session } + : null; + }, + 120_000, + 1_000, + ); + latestSession = followCompleted.session || latestSession; + summary.followTurn = followCompleted.turn; + summary.followTurnStatus = followCompleted.turn?.status || null; + let recoverySnapshot = null; + try { + recoverySnapshot = await waitForCondition( + "等待 GUI 出现恢复结果", + async () => { + const snapshot = await readPageSnapshot(page); + return snapshot?.recoveryVisible ? snapshot : null; + }, + 60_000, + 500, + ); + summary.recoveryVisibleSource = "live-stream"; + } catch (recoveryWaitError) { + const recoveryWaitMessage = + recoveryWaitError instanceof Error + ? recoveryWaitError.message + : String(recoveryWaitError); + summary.recoveryVisibleInitialWait = { + passed: false, + error: recoveryWaitMessage, + }; + latestSession = + (await invoke(options, "agent_runtime_get_session", { sessionId }, 20_000).catch( + () => null, + )) || latestSession; + const persistedAssistantText = [ + allAssistantText(latestSession), + allAgentItemText(latestSession), + ] + .filter(Boolean) + .join("\n"); + summary.recoveryPersistedBeforeRefresh = persistedAssistantText.includes( + RECOVERY_EXPECTED_TEXT, + ); + if (!summary.recoveryPersistedBeforeRefresh) { + throw recoveryWaitError; + } + + const detachedSnapshot = await readPageSnapshot(page); + summary.recoveryDetachedSnapshot = detachedSnapshot; + if (isLikelyDetachedBlankTaskSnapshot(detachedSnapshot)) { + logStage("restore-session-after-recovery-persisted"); + summary.recoveryVisibleRestoreDispatch = await dispatchTaskCenterOpenTask( + page, + sessionId, + workspaceId, + ); + recoverySnapshot = await waitForCondition( + "等待重新打开目标会话后 GUI 出现恢复结果", + async () => { + const snapshot = await readPageSnapshot(page); + return snapshot?.recoveryVisible ? snapshot : null; + }, + 45_000, + 500, + ); + summary.recoveryVisibleSource = + "post-session-restore-runtime-persistence"; + } else { + logStage("refresh-after-recovery-persisted"); + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => undefined); + recoverySnapshot = await waitForCondition( + "等待刷新后 GUI 出现恢复结果", + async () => { + const snapshot = await readPageSnapshot(page); + return snapshot?.recoveryVisible ? snapshot : null; + }, + 60_000, + 500, + ); + summary.recoveryVisibleSource = "post-refresh-runtime-persistence"; + } + } + summary.recoverySnapshot = recoverySnapshot; + summary.steps.push("recovery-final"); + await page.screenshot({ + path: path.join(evidenceDir, `${prefix}-04-recovery-final.png`), + fullPage: true, + }); + + logStage("collect-runtime-evidence"); + const threadRead = await invoke( + options, + "agent_runtime_get_thread_read", + { sessionId }, + 20_000, + ).catch((error) => ({ + __error: error instanceof Error ? error.message : String(error), + })); + const assistantText = [ + allAssistantText(latestSession), + allAgentItemText(latestSession), + ] + .filter(Boolean) + .join("\n"); + const { + consoleErrors, + consoleWarnings, + mockFallbackLines, + networkErrorTop, + } = consoleNetworkSummary(consoleMessages, failedRequests); + const activeTurnId = threadRead?.active_turn_id || threadRead?.activeTurnId || null; + const runtimeMockLines = mockFallbackLines.filter((line) => + /agent_runtime_(submit_turn|interrupt_turn|get_session|get_thread_read)/.test(line), + ); + const peripheralMockLines = mockFallbackLines.filter( + (line) => !runtimeMockLines.includes(line), + ); + + summary.threadRead = threadRead?.__error ? { error: threadRead.__error } : threadRead; + summary.threadReadStatus = + summary.threadRead?.diagnostics?.latest_turn_status || + summary.threadRead?.runtime_summary?.latestTurnStatus || + null; + const latestRuntimeRouting = + latestSession?.execution_runtime?.routing_decision || {}; + const followProviderPreferenceHonored = + followRequest.turn_config?.provider_preference === preferredProvider || + latestRuntimeRouting?.selectedProvider === preferredProvider || + latestRuntimeRouting?.requestedProvider === preferredProvider; + const followModelPreferenceHonored = + followRequest.turn_config?.model_preference === preferredModel || + latestRuntimeRouting?.selectedModel === preferredModel || + latestRuntimeRouting?.requestedModel === preferredModel; + summary.followRoutingEvidence = { + selectedProvider: latestRuntimeRouting?.selectedProvider || null, + selectedModel: latestRuntimeRouting?.selectedModel || null, + requestedProvider: latestRuntimeRouting?.requestedProvider || null, + requestedModel: latestRuntimeRouting?.requestedModel || null, + decisionSource: latestRuntimeRouting?.decisionSource || null, + note: + followRequest.turn_config?.provider_preference || + followRequest.turn_config?.model_preference + ? "恢复 turn 显式提交 provider/model。" + : "恢复 turn 复用 session_default;以 runtime routing_decision 校验 selected/requested provider/model 未漂移。", + }; + summary.queueCountFinal = queuedTurnCount(latestSession); + summary.activeTurnIdFinal = activeTurnId; + summary.assistantContainsRecovery = assistantText.includes( + RECOVERY_EXPECTED_TEXT, + ); + summary.runtimeStreamLineCount = ( + assistantText.match(/中断测试第\s*\d+\s*行/g) || [] + ).length; + summary.interruptedAssistantContainsFinalLine = + assistantText.includes("中断测试第 160 行"); + summary.consoleErrorCount = consoleErrors.length; + summary.consoleWarningCount = consoleWarnings.length; + summary.networkErrorCount = failedRequests.length; + summary.networkErrorTop = networkErrorTop; + summary.devBridgeCommands = [...new Set(invokes.map((item) => item.cmd))]; + summary.mockFallbackLines = mockFallbackLines; + summary.assertions = { + workspaceReady: Boolean(readySnapshot?.ready), + devBridgeHealthy: health?.status === "ok" || Boolean(health), + streamFirstDeltaSeen: Boolean(firstDelta?.streamTextLength > 0), + streamGrowthObserved: + Number(recoverySnapshot?.streamTextLength || 0) >= + Number(firstDelta?.streamTextLength || 0) || + summary.runtimeStreamLineCount >= Number(firstDelta?.streamLineCount || 1), + stopButtonVisible: Boolean(firstDelta?.stopVisible), + interruptCommandSeen: summary.devBridgeCommands.includes( + "agent_runtime_interrupt_turn", + ), + interruptScopedToLongTurn: Boolean(summary.interruptHasTurnScope), + interruptedTurnAborted: summary.interruptedTurnStatus === "aborted", + recoveryTurnCompleted: summary.followTurnStatus === "completed", + recoveryPersistedInRuntime: summary.assistantContainsRecovery, + recoveryVisibleInGui: Boolean(recoverySnapshot?.recoveryVisible), + longProviderPreferenceHonored: + longRequest.turn_config?.provider_preference === preferredProvider, + longModelPreferenceHonored: + longRequest.turn_config?.model_preference === preferredModel, + followProviderPreferenceHonored, + followModelPreferenceHonored, + fastResponseRoutingDisabled: + !longRequest.turn_config?.metadata?.harness?.fast_response_routing && + !followRequest.turn_config?.metadata?.harness?.fast_response_routing, + noRuntimeMockFallbackSeen: runtimeMockLines.length === 0, + }; + summary.mockFallbackClassification = { + runtimeMockLines, + peripheralMockLines, + note: + runtimeMockLines.length === 0 + ? "未观察到聊天 runtime submit/interrupt/get_session/get_thread_read 的 mock fallback;如有 [Mock] 日志,仅属周边 web-mode 能力。" + : "观察到聊天 runtime 关键命令 mock fallback,需视为真实路径失败。", + }; + summary.consoleNetwork = { + consoleErrorCount: consoleErrors.length, + consoleWarningCount: consoleWarnings.length, + networkErrorCount: failedRequests.length, + networkErrorTop: summary.networkErrorTop, + note: + failedRequests.length === 0 + ? "未观察到 requestfailed。" + : "requestfailed 需结合 URL 判断是否为页面 reload/close 导致的 abort,不能直接等同产品失败。", + }; + summary.evidenceFiles = { + screenshots: [ + `${prefix}-01-ready.png`, + `${prefix}-02-running-stop-visible.png`, + `${prefix}-03-after-stop.png`, + `${prefix}-04-recovery-final.png`, + ], + console: `${prefix}-console.txt`, + network: `${prefix}-network-invoke.json`, + runtimeSession: `${prefix}-runtime-session.json`, + threadRead: `${prefix}-thread-read.json`, + summary: `${prefix}-summary.json`, + }; + + summary.verdict = + summary.assertions.workspaceReady && + summary.assertions.devBridgeHealthy && + summary.assertions.streamFirstDeltaSeen && + summary.assertions.streamGrowthObserved && + summary.assertions.stopButtonVisible && + summary.assertions.interruptCommandSeen && + summary.assertions.interruptScopedToLongTurn && + summary.assertions.interruptedTurnAborted && + summary.assertions.recoveryTurnCompleted && + summary.assertions.recoveryPersistedInRuntime && + summary.assertions.recoveryVisibleInGui && + summary.assertions.longProviderPreferenceHonored && + summary.assertions.longModelPreferenceHonored && + summary.assertions.followProviderPreferenceHonored && + summary.assertions.followModelPreferenceHonored && + summary.assertions.fastResponseRoutingDisabled && + summary.assertions.noRuntimeMockFallbackSeen && + summary.queueCountFinal === 0 && + summary.activeTurnIdFinal === null && + summary.consoleErrorCount === 0 + ? "pass" + : "fail"; + + writeJsonFile(path.join(evidenceDir, `${prefix}-runtime-session.json`), latestSession); + writeJsonFile(path.join(evidenceDir, `${prefix}-thread-read.json`), threadRead); + writeConsoleNetworkEvidence( + evidenceDir, + prefix, + consoleMessages, + invokes, + failedRequests, + ); + writeJsonFile(path.join(evidenceDir, `${prefix}-summary.json`), summary); + } catch (error) { + summary.error = error instanceof Error ? error.message : String(error); + if (page) { + summary.failureSnapshot = await readPageSnapshot(page); + await page + .screenshot({ + path: path.join(evidenceDir, `${prefix}-99-failure.png`), + fullPage: true, + }) + .catch(() => undefined); + } + const failureEvidence = { + screenshots: [`${prefix}-99-failure.png`], + console: `${prefix}-console.txt`, + network: `${prefix}-network-invoke.json`, + runtimeSession: `${prefix}-runtime-session.json`, + threadRead: `${prefix}-thread-read.json`, + summary: `${prefix}-summary.json`, + }; + summary.evidenceFiles = summary.evidenceFiles || failureEvidence; + const failureConsoleNetwork = writeConsoleNetworkEvidence( + evidenceDir, + prefix, + consoleMessages, + invokes, + failedRequests, + ); + summary.consoleErrorCount = failureConsoleNetwork.consoleErrors.length; + summary.consoleWarningCount = failureConsoleNetwork.consoleWarnings.length; + summary.networkErrorCount = failedRequests.length; + summary.networkErrorTop = failureConsoleNetwork.networkErrorTop; + summary.devBridgeCommands = [...new Set(invokes.map((item) => item.cmd))]; + + if (submittedSessionId) { + const failureSession = await invoke( + options, + "agent_runtime_get_session", + { sessionId: submittedSessionId }, + 20_000, + ).catch((sessionError) => ({ + __error: + sessionError instanceof Error ? sessionError.message : String(sessionError), + })); + const failureThreadRead = await invoke( + options, + "agent_runtime_get_thread_read", + { sessionId: submittedSessionId }, + 20_000, + ).catch((threadError) => ({ + __error: threadError instanceof Error ? threadError.message : String(threadError), + })); + writeJsonFile( + path.join(evidenceDir, `${prefix}-runtime-session.json`), + failureSession, + ); + writeJsonFile( + path.join(evidenceDir, `${prefix}-thread-read.json`), + failureThreadRead, + ); + summary.failureRuntime = { + sessionId: submittedSessionId, + sessionError: failureSession?.__error || null, + threadReadError: failureThreadRead?.__error || null, + turns: Array.isArray(failureSession?.turns) + ? failureSession.turns.map((turn) => ({ + id: turn?.id || null, + status: turn?.status || null, + error: turn?.error || turn?.error_message || null, + })) + : [], + latestTurnStatus: + failureThreadRead?.diagnostics?.latest_turn_status || + failureThreadRead?.runtime_summary?.latestTurnStatus || + failureThreadRead?.status || + null, + }; + } + writeJsonFile(path.join(evidenceDir, `${prefix}-summary.json`), summary); + throw error; + } finally { + try { + if (page) { + await page.evaluate(buildRestoreLocalStorageScript(storageMarker)).catch( + () => undefined, + ); + } + await restoreOriginalProviderConfig( + options, + originalProviderConfig, + submittedSessionId, + ).catch((error) => { + console.warn( + `[smoke:claw-chat-ready-streaming] 恢复原 provider 配置失败: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + } finally { + await context.close().catch(() => undefined); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } + } + + console.log(JSON.stringify(summary, null, 2)); + if (summary.verdict !== "pass") { + process.exitCode = 1; + } +} + +main().catch((error) => { + const detail = error instanceof Error ? error.message : String(error); + console.error(`[smoke:claw-chat-ready-streaming] ${detail}`); + process.exit(1); +}); diff --git a/scripts/detect-missing-translations.test.ts b/scripts/detect-missing-translations.test.ts new file mode 100644 index 000000000..74adf5163 --- /dev/null +++ b/scripts/detect-missing-translations.test.ts @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + analyzeTranslations, + applyTranslationFixes, + formatTranslationReport, + hasTranslationIssues, +} from "./detect-missing-translations"; + +const tempDirs: string[] = []; + +function createTempResourcesDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lime-i18n-check-")); + tempDirs.push(dir); + return dir; +} + +function writeResource( + resourcesDir: string, + locale: string, + namespace: string, + resource: Record, +) { + const filePath = path.join(resourcesDir, locale, `${namespace}.json`); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(resource, null, 2)}\n`); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +describe("detect-missing-translations", () => { + it("应在所有 locale 与 source key 一致时通过", () => { + const resourcesDir = createTempResourcesDir(); + writeResource(resourcesDir, "zh-CN", "common", { "common.save": "保存" }); + writeResource(resourcesDir, "en-US", "common", { "common.save": "Save" }); + + const result = analyzeTranslations({ resourcesDir }); + + expect(hasTranslationIssues(result)).toBe(false); + expect(formatTranslationReport(result)).toContain("通过"); + }); + + it("应发现缺失 namespace、缺失 key 与多余 key", () => { + const resourcesDir = createTempResourcesDir(); + writeResource(resourcesDir, "zh-CN", "common", { + "common.cancel": "取消", + "common.save": "保存", + }); + writeResource(resourcesDir, "zh-CN", "settings", { + "settings.title": "设置", + }); + writeResource(resourcesDir, "en-US", "common", { + "common.extra": "Extra", + "common.save": "Save", + }); + + const result = analyzeTranslations({ resourcesDir }); + + expect(hasTranslationIssues(result)).toBe(true); + expect(result.issues).toEqual([ + { + locale: "en-US", + missingNamespaces: ["settings"], + extraNamespaces: [], + namespaces: [ + { + namespace: "common", + missingKeys: ["common.cancel"], + extraKeys: ["common.extra"], + }, + ], + }, + ]); + }); + + it("--fix 语义应补齐缺失 namespace 与缺失 key,但保留人工已有翻译", () => { + const resourcesDir = createTempResourcesDir(); + writeResource(resourcesDir, "zh-CN", "common", { + "common.cancel": "取消", + "common.save": "保存", + }); + writeResource(resourcesDir, "zh-CN", "settings", { + "settings.title": "设置", + }); + writeResource(resourcesDir, "en-US", "common", { + "common.save": "Save", + }); + + applyTranslationFixes({ resourcesDir }); + const result = analyzeTranslations({ resourcesDir }); + const common = JSON.parse( + fs.readFileSync(path.join(resourcesDir, "en-US", "common.json"), "utf8"), + ) as Record; + + expect(hasTranslationIssues(result)).toBe(false); + expect(common["common.save"]).toBe("Save"); + expect(common["common.cancel"]).toBe("取消"); + expect( + fs.existsSync(path.join(resourcesDir, "en-US", "settings.json")), + ).toBe(true); + }); +}); diff --git a/scripts/detect-missing-translations.ts b/scripts/detect-missing-translations.ts new file mode 100644 index 000000000..4b8a2fdd0 --- /dev/null +++ b/scripts/detect-missing-translations.ts @@ -0,0 +1,356 @@ +#!/usr/bin/env tsx +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export interface TranslationCheckOptions { + resourcesDir: string; + sourceLocale?: string; +} + +export interface NamespaceIssue { + namespace: string; + missingKeys: string[]; + extraKeys: string[]; +} + +export interface LocaleIssue { + locale: string; + missingNamespaces: string[]; + extraNamespaces: string[]; + namespaces: NamespaceIssue[]; +} + +export interface TranslationCheckResult { + resourcesDir: string; + sourceLocale: string; + locales: string[]; + namespaces: string[]; + sourceKeyCount: number; + issues: LocaleIssue[]; +} + +interface CliOptions extends TranslationCheckOptions { + fix: boolean; + verbose: boolean; +} + +const DEFAULT_SOURCE_LOCALE = "zh-CN"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, ".."); +const DEFAULT_RESOURCES_DIR = path.join(REPO_ROOT, "src", "i18n", "resources"); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function readJsonObject(filePath: string): Record { + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) { + throw new Error(`Translation resource must be a JSON object: ${filePath}`); + } + return parsed; +} + +function listJsonNamespaces(localeDir: string): string[] { + if (!fs.existsSync(localeDir)) { + return []; + } + + return fs + .readdirSync(localeDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => path.basename(entry.name, ".json")) + .sort((left, right) => left.localeCompare(right)); +} + +function listLocaleDirs(resourcesDir: string): string[] { + if (!fs.existsSync(resourcesDir)) { + throw new Error(`Translation resources directory does not exist: ${resourcesDir}`); + } + + return fs + .readdirSync(resourcesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)); +} + +export function flattenResource( + resource: Record, + prefix = "", +): Record { + const flattened: Record = {}; + + for (const [key, value] of Object.entries(resource)) { + const nextKey = prefix ? `${prefix}.${key}` : key; + if (isRecord(value)) { + Object.assign(flattened, flattenResource(value, nextKey)); + } else { + flattened[nextKey] = value; + } + } + + return flattened; +} + +function sortedDifference(left: Iterable, right: Set): string[] { + return [...left] + .filter((item) => !right.has(item)) + .sort((a, b) => a.localeCompare(b)); +} + +function readNamespaceKeys(localeDir: string, namespace: string): Set { + const filePath = path.join(localeDir, `${namespace}.json`); + if (!fs.existsSync(filePath)) { + return new Set(); + } + + return new Set(Object.keys(flattenResource(readJsonObject(filePath)))); +} + +function countSourceKeys(sourceLocaleDir: string, namespaces: string[]): number { + return namespaces.reduce( + (total, namespace) => total + readNamespaceKeys(sourceLocaleDir, namespace).size, + 0, + ); +} + +export function hasTranslationIssues(result: TranslationCheckResult): boolean { + return result.issues.some( + (issue) => + issue.missingNamespaces.length > 0 || + issue.extraNamespaces.length > 0 || + issue.namespaces.some( + (namespaceIssue) => + namespaceIssue.missingKeys.length > 0 || namespaceIssue.extraKeys.length > 0, + ), + ); +} + +export function analyzeTranslations( + options: TranslationCheckOptions, +): TranslationCheckResult { + const resourcesDir = path.resolve(options.resourcesDir); + const sourceLocale = options.sourceLocale || DEFAULT_SOURCE_LOCALE; + const locales = listLocaleDirs(resourcesDir); + const sourceLocaleDir = path.join(resourcesDir, sourceLocale); + + if (!locales.includes(sourceLocale)) { + throw new Error(`Source locale is missing from resources: ${sourceLocale}`); + } + + const namespaces = listJsonNamespaces(sourceLocaleDir); + const sourceNamespaceSet = new Set(namespaces); + const issues: LocaleIssue[] = []; + + for (const locale of locales) { + if (locale === sourceLocale) { + continue; + } + + const localeDir = path.join(resourcesDir, locale); + const localeNamespaces = listJsonNamespaces(localeDir); + const localeNamespaceSet = new Set(localeNamespaces); + const missingNamespaces = sortedDifference(namespaces, localeNamespaceSet); + const extraNamespaces = sortedDifference(localeNamespaces, sourceNamespaceSet); + const namespaceIssues: NamespaceIssue[] = []; + + for (const namespace of namespaces) { + if (!localeNamespaceSet.has(namespace)) { + continue; + } + + const sourceKeys = readNamespaceKeys(sourceLocaleDir, namespace); + const targetKeys = readNamespaceKeys(localeDir, namespace); + const missingKeys = sortedDifference(sourceKeys, targetKeys); + const extraKeys = sortedDifference(targetKeys, sourceKeys); + + if (missingKeys.length > 0 || extraKeys.length > 0) { + namespaceIssues.push({ namespace, missingKeys, extraKeys }); + } + } + + if ( + missingNamespaces.length > 0 || + extraNamespaces.length > 0 || + namespaceIssues.length > 0 + ) { + issues.push({ + locale, + missingNamespaces, + extraNamespaces, + namespaces: namespaceIssues, + }); + } + } + + return { + resourcesDir, + sourceLocale, + locales, + namespaces, + sourceKeyCount: countSourceKeys(sourceLocaleDir, namespaces), + issues, + }; +} + +function sortObjectByKey(resource: Record): Record { + return Object.fromEntries( + Object.entries(resource).sort(([left], [right]) => left.localeCompare(right)), + ); +} + +function writeJsonObject(filePath: string, resource: Record): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(sortObjectByKey(resource), null, 2)}${os.EOL}`); +} + +export function applyTranslationFixes(options: TranslationCheckOptions): void { + const resourcesDir = path.resolve(options.resourcesDir); + const sourceLocale = options.sourceLocale || DEFAULT_SOURCE_LOCALE; + const sourceLocaleDir = path.join(resourcesDir, sourceLocale); + const sourceNamespaces = listJsonNamespaces(sourceLocaleDir); + const locales = listLocaleDirs(resourcesDir).filter((locale) => locale !== sourceLocale); + + for (const locale of locales) { + const localeDir = path.join(resourcesDir, locale); + + for (const namespace of sourceNamespaces) { + const sourcePath = path.join(sourceLocaleDir, `${namespace}.json`); + const targetPath = path.join(localeDir, `${namespace}.json`); + const sourceResource = flattenResource(readJsonObject(sourcePath)); + const targetResource = fs.existsSync(targetPath) + ? flattenResource(readJsonObject(targetPath)) + : {}; + + let changed = false; + for (const [key, value] of Object.entries(sourceResource)) { + if (!(key in targetResource)) { + targetResource[key] = value; + changed = true; + } + } + + if (changed || !fs.existsSync(targetPath)) { + writeJsonObject(targetPath, targetResource); + } + } + } +} + +function formatList(items: string[]): string { + return items.length === 0 ? "-" : items.join(", "); +} + +export function formatTranslationReport( + result: TranslationCheckResult, + options: { verbose?: boolean } = {}, +): string { + const lines: string[] = []; + const issueCount = result.issues.length; + lines.push( + `[i18n:check] resources=${path.relative(REPO_ROOT, result.resourcesDir) || result.resourcesDir} source=${result.sourceLocale} locales=${result.locales.length} namespaces=${result.namespaces.length} sourceKeys=${result.sourceKeyCount}`, + ); + + if (!hasTranslationIssues(result)) { + lines.push("[i18n:check] 通过:所有 locale 与 source namespace/key 保持一致。"); + if (options.verbose) { + lines.push(`[i18n:check] locale 列表: ${result.locales.join(", ")}`); + lines.push(`[i18n:check] namespace 列表: ${result.namespaces.join(", ")}`); + } + return lines.join(os.EOL); + } + + lines.push(`[i18n:check] 发现 ${issueCount} 个 locale 存在翻译结构差异。`); + for (const localeIssue of result.issues) { + lines.push(`[i18n:check] locale=${localeIssue.locale}`); + if (localeIssue.missingNamespaces.length > 0) { + lines.push( + ` missing namespaces: ${formatList(localeIssue.missingNamespaces)}`, + ); + } + if (localeIssue.extraNamespaces.length > 0) { + lines.push(` extra namespaces: ${formatList(localeIssue.extraNamespaces)}`); + } + for (const namespaceIssue of localeIssue.namespaces) { + lines.push(` namespace=${namespaceIssue.namespace}`); + if (namespaceIssue.missingKeys.length > 0) { + lines.push(` missing keys: ${formatList(namespaceIssue.missingKeys)}`); + } + if (namespaceIssue.extraKeys.length > 0) { + lines.push(` extra keys: ${formatList(namespaceIssue.extraKeys)}`); + } + } + } + + return lines.join(os.EOL); +} + +function parseCliArgs(argv: string[]): CliOptions { + const options: CliOptions = { + fix: false, + resourcesDir: DEFAULT_RESOURCES_DIR, + sourceLocale: DEFAULT_SOURCE_LOCALE, + verbose: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + + if (arg === "--fix") { + options.fix = true; + continue; + } + if (arg === "--verbose") { + options.verbose = true; + continue; + } + if (arg === "--resources-dir" && next) { + options.resourcesDir = next; + index += 1; + continue; + } + if (arg === "--source-locale" && next) { + options.sourceLocale = next; + index += 1; + continue; + } + if (arg === "-h" || arg === "--help") { + console.log(`Usage: npm run detect-translations -- [--fix] [--verbose] [--resources-dir ] [--source-locale ]`); + process.exit(0); + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +export function runCli(argv = process.argv.slice(2)): number { + const options = parseCliArgs(argv); + + if (options.fix) { + applyTranslationFixes(options); + } + + const result = analyzeTranslations(options); + console.log(formatTranslationReport(result, { verbose: options.verbose })); + return hasTranslationIssues(result) ? 1 : 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + try { + process.exitCode = runCli(); + } catch (error) { + console.error( + `[i18n:check] 失败:${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} diff --git a/scripts/i18n-patch-metrics-report.mjs b/scripts/i18n-patch-metrics-report.mjs new file mode 100644 index 000000000..2c53972ea --- /dev/null +++ b/scripts/i18n-patch-metrics-report.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { + createI18nPatchMetricsReport, + renderI18nPatchMetricsTextReport, +} from "./lib/i18n-patch-metrics-report-core.mjs"; + +const DEFAULT_INPUT_PATH = ".lime/i18n/patch-metrics.json"; + +function parseNumberArg(value) { + if (value === undefined) { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function parseArgs(argv) { + const result = { + check: false, + format: "text", + help: false, + inputPath: DEFAULT_INPUT_PATH, + maxMatchedSegments: undefined, + maxReplacedNodes: undefined, + maxRuns: undefined, + outputPath: "", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--check") { + result.check = true; + continue; + } + + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--input" && argv[index + 1]) { + result.inputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--max-matched-segments" && argv[index + 1]) { + result.maxMatchedSegments = parseNumberArg(argv[index + 1]); + index += 1; + continue; + } + + if (arg === "--max-replaced-nodes" && argv[index + 1]) { + result.maxReplacedNodes = parseNumberArg(argv[index + 1]); + index += 1; + continue; + } + + if (arg === "--max-runs" && argv[index + 1]) { + result.maxRuns = parseNumberArg(argv[index + 1]); + index += 1; + continue; + } + + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime i18n Patch Metrics Report + +用法: + npm run i18n:patch-report + npm run i18n:patch-report:json + node scripts/i18n-patch-metrics-report.mjs --input .lime/i18n/patch-metrics.json --format json + node scripts/i18n-patch-metrics-report.mjs --check --max-matched-segments 0 --max-replaced-nodes 0 + +输入: + 默认读取 ${DEFAULT_INPUT_PATH}。该文件应来自 GUI / Playwright 导出的 window.__I18N_METRICS__ 或 getI18nPatchMetricsReport() JSON。 + +选项: + --input PATH Patch metrics JSON 路径 + --output PATH 写入报告文件;默认输出到 stdout + --format FMT 输出格式:text | json + --check 如果门限问题存在,以非 0 退出 + --max-matched-segments NUM 允许的最大命中文本段数 + --max-replaced-nodes NUM 允许的最大替换节点数 + --max-runs NUM 允许的最大 Patch 运行次数 + -h, --help 显示帮助 +`); +} + +function resolvePath(targetPath) { + return path.resolve(process.cwd(), targetPath); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + + const resolvedOutputPath = resolvePath(outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const resolvedInputPath = resolvePath(options.inputPath); + const metrics = readJsonFile(resolvedInputPath); + const report = createI18nPatchMetricsReport({ + metrics, + sourcePath: path.relative(process.cwd(), resolvedInputPath), + thresholds: { + maxMatchedSegments: options.maxMatchedSegments, + maxReplacedNodes: options.maxReplacedNodes, + maxRuns: options.maxRuns, + }, + }); + const content = + options.format === "json" + ? `${JSON.stringify(report, null, 2)}\n` + : renderI18nPatchMetricsTextReport(report); + + writeOutput(options.outputPath, content); + + if (options.check && report.thresholdIssues.length > 0) { + process.exit(1); + } +} + +main(); diff --git a/scripts/knowledge-gui-smoke.mjs b/scripts/knowledge-gui-smoke.mjs index 6cb6b3a7b..9100f8e03 100644 --- a/scripts/knowledge-gui-smoke.mjs +++ b/scripts/knowledge-gui-smoke.mjs @@ -12,6 +12,7 @@ const DEFAULTS = { invokeUrl: "http://127.0.0.1:3030/invoke", timeoutMs: 180_000, intervalMs: 1_000, + i18nPatchMetricsOutput: "", }; const INVOKE_TIMEOUT_CEILING_MS = 180_000; @@ -135,6 +136,8 @@ Lime Knowledge GUI Smoke --invoke-url DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke --timeout-ms 总超时,默认 180000 --interval-ms 轮询间隔,默认 1000 + --i18n-patch-metrics-output + 导出 window.__I18N_METRICS__ JSON,供 i18n:patch-report 消费 -h, --help 显示帮助 `); } @@ -175,6 +178,12 @@ function parseArgs(argv) { continue; } + if (arg === "--i18n-patch-metrics-output" && argv[index + 1]) { + options.i18nPatchMetricsOutput = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); @@ -202,6 +211,39 @@ function logStage(label) { console.log(`[smoke:knowledge-gui] stage=${label}`); } +async function exportI18nPatchMetrics(page, outputPath) { + if (!outputPath) { + return; + } + + try { + const metrics = await page.evaluate(() => { + const globalMetrics = window.__I18N_METRICS__; + if (!globalMetrics) { + return null; + } + + return JSON.parse(JSON.stringify(globalMetrics)); + }); + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync( + resolvedOutputPath, + JSON.stringify(metrics ?? {}, null, 2), + "utf8", + ); + console.log( + `[smoke:knowledge-gui] i18n Patch metrics: ${resolvedOutputPath}`, + ); + } catch (error) { + console.warn( + `[smoke:knowledge-gui] 导出 i18n Patch metrics 失败: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + function isTransientInvokeError(error) { return ( error?.name === "TimeoutError" || @@ -1096,6 +1138,7 @@ async function runPlaywrightGuiFlow(options) { ); } } finally { + await exportI18nPatchMetrics(page, options.i18nPatchMetricsOutput); await context.close().catch(() => undefined); fs.rmSync(userDataDir, { recursive: true, force: true }); } diff --git a/scripts/knowledge-product-e2e.mjs b/scripts/knowledge-product-e2e.mjs index 7f572267d..30d0b17aa 100755 --- a/scripts/knowledge-product-e2e.mjs +++ b/scripts/knowledge-product-e2e.mjs @@ -302,8 +302,23 @@ async function openKnowledgeOverview(page, options) { text.includes("存到哪里?") || text.includes("整理新资料") ) { + if (text.includes("项目资料状态说明")) { + const backButton = page.getByRole("button", { + name: "回到项目资料", + exact: true, + }); + if (await backButton.isVisible().catch(() => false)) { + await backButton.click({ timeout: options.timeoutMs }); + await waitText(page, options, "状态说明返回项目资料首页", [ + "让 Lime 记住这个项目", + "项目资料清单", + ]); + return; + } + } + await clickSideNav(page, options, "灵感"); - await waitText(page, options, "灵感页", ["灵感"]); + await waitText(page, options, "灵感页", ["收藏过的想法"]); } await clickSideNav(page, options, "项目资料"); diff --git a/scripts/lib/agent-qc-completion-audit-core.mjs b/scripts/lib/agent-qc-completion-audit-core.mjs new file mode 100644 index 000000000..ee935a8f7 --- /dev/null +++ b/scripts/lib/agent-qc-completion-audit-core.mjs @@ -0,0 +1,292 @@ +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function normalizeIdList(value) { + return asArray(value) + .map((item) => String(item || "").trim()) + .filter(Boolean); +} + +function createItem(id, title, passed, evidence, gap = "") { + return { id, title, passed: Boolean(passed), evidence: evidence || "", gap: passed ? "" : gap }; +} + +function summarizeQcloopStatusSidecar(entry) { + const counts = entry.counts || {}; + return [ + `${entry.path} verdict=${entry.verdictStatus || "unknown"}`, + `success=${counts.success ?? 0}`, + `running=${counts.running ?? 0}`, + `pending=${counts.pending ?? 0}`, + `stale=${counts.stale ?? 0}`, + ].join(" "); +} + +function buildAgentQcCompletionAudit(facts) { + const requiredQcloopScenarioIds = normalizeIdList(facts.scenarioReport?.p0ScenarioIds); + const requiredQcloopScenarioCount = + requiredQcloopScenarioIds.length || facts.scenarioReport?.p0ScenarioCount || 1; + const realEvidenceScenarioIds = new Set(normalizeIdList(facts.realEvidencePack?.scenarioIds)); + const missingQcloopScenarioIds = requiredQcloopScenarioIds.filter( + (scenarioId) => !realEvidenceScenarioIds.has(scenarioId), + ); + const hasRequiredQcloopCoverage = + requiredQcloopScenarioIds.length > 0 + ? missingQcloopScenarioIds.length === 0 + : facts.realEvidencePack?.scenarioCount >= requiredQcloopScenarioCount; + const sidecarEvidenceSummary = asArray(facts.realEvidenceSidecars) + .map((entry) => `${entry.path} status=${entry.status || "unknown"} scenarios=${entry.scenarioCount || 0}`) + .join("; "); + const qcloopStatusSidecarSummary = asArray(facts.qcloopStatusSidecars) + .map((entry) => { + const counts = entry.counts + ? ` success=${entry.counts.success ?? 0} running=${entry.counts.running ?? 0} pending=${entry.counts.pending ?? 0} stale=${entry.counts.stale ?? 0}` + : ""; + return `${entry.path} verdict=${entry.verdictStatus || "unknown"}${counts}`; + }) + .join("; "); + const staleQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter( + (entry) => entry.verdictStatus === "stale" || Number(entry.counts?.stale || 0) > 0, + ); + const activeQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter( + (entry) => + entry.verdictStatus === "running" || + entry.verdictStatus === "stale" || + Number(entry.counts?.running || 0) > 0 || + Number(entry.counts?.pending || 0) > 0, + ); + const qcloopEvidenceText = facts.realEvidencePack?.exists + ? `.lime/qc/agent-qc-evidence.json status=${facts.realEvidencePack.status || "unknown"} scenarios=${facts.realEvidencePack.scenarioCount || 0}/${requiredQcloopScenarioCount}${missingQcloopScenarioIds.length > 0 ? ` missing=${missingQcloopScenarioIds.join(",")}` : ""}` + : sidecarEvidenceSummary + ? `未发现 .lime/qc/agent-qc-evidence.json;sidecars: ${sidecarEvidenceSummary}` + : "未发现 .lime/qc/agent-qc-evidence.json"; + const qcloopEvidenceWithStatusText = qcloopStatusSidecarSummary + ? `${qcloopEvidenceText}; qcloopStatus: ${qcloopStatusSidecarSummary}` + : qcloopEvidenceText; + const qcloopGapFragments = []; + if (facts.realEvidencePack?.exists && facts.realEvidencePack.status !== "pass") { + qcloopGapFragments.push( + `官方 Evidence Pack 当前 status=${facts.realEvidencePack.status || "unknown"},不能发布`, + ); + } + if (!facts.realEvidencePack?.exists && sidecarEvidenceSummary) { + qcloopGapFragments.push("已有 sidecar Evidence Pack,但尚未生成 pass 的官方 .lime/qc/agent-qc-evidence.json"); + } + if (missingQcloopScenarioIds.length > 0) { + qcloopGapFragments.push(`尚未覆盖 P0 场景:${missingQcloopScenarioIds.join(",")}`); + } + if (activeQcloopStatusSidecars.length > 0) { + qcloopGapFragments.push( + `仍有 qcloop status sidecar 未终态:${activeQcloopStatusSidecars + .map(summarizeQcloopStatusSidecar) + .join("; ")}`, + ); + } + if (staleQcloopStatusSidecars.length > 0) { + qcloopGapFragments.push( + `其中 stale sidecar:${staleQcloopStatusSidecars.map((entry) => entry.path).join(", ")}`, + ); + } + const qcloopGapText = + qcloopGapFragments.length > 0 + ? `${qcloopGapFragments.join(";")}。` + : "尚未运行真实 qcloop 批次并导出 pass Evidence Pack。"; + const localVerifyEvidenceText = facts.localVerify?.status + ? `status=${facts.localVerify.status}${facts.localVerify.failedStage ? ` failedStage=${facts.localVerify.failedStage}` : ""}` + : "未发现 .lime/qc/verify-local-current.json"; + const guiSmokeEvidenceText = facts.guiSmoke?.status + ? `; latestGuiSmoke status=${facts.guiSmoke.status}${facts.guiSmoke.failedStage ? ` failedStage=${facts.guiSmoke.failedStage}` : ""}` + : ""; + const localVerifyGapFragments = []; + if (facts.localVerify?.status) { + localVerifyGapFragments.push( + `verify:local 当前 status=${facts.localVerify.status}${facts.localVerify.error ? `;${facts.localVerify.error}` : ""}`, + ); + } else { + localVerifyGapFragments.push("缺少 verify:local 当前结果 sidecar,无法证明仓库统一本地校验通过"); + } + if (facts.guiSmoke?.status && facts.guiSmoke.status !== "pass") { + localVerifyGapFragments.push( + `最近一次 verify:gui-smoke status=${facts.guiSmoke.status}${facts.guiSmoke.error ? `;${facts.guiSmoke.error}` : ""}`, + ); + } + const items = [ + createItem( + "docs-tests-standard", + "docs/tests 下存在 Agent QC 人读测试文档", + facts.files?.agentOpsQc && + facts.files?.p0Scenarios && + facts.files?.limeRolloutPlan && + facts.files?.testsReadme, + "docs/tests/agent-ops-qc.md, docs/tests/agent-qc-p0-scenarios.md, docs/tests/lime-agent-qc-rollout-plan.md, docs/tests/README.md", + "缺少 docs/tests 测试体系文档。", + ), + createItem( + "scenario-manifest", + "Agent QC scenario manifest 有效", + facts.scenarioReport?.valid === true && facts.scenarioReport?.scenarioCount > 0, + `scenarioCount=${facts.scenarioReport?.scenarioCount ?? 0}`, + "scenario manifest 未通过校验或没有场景。", + ), + createItem( + "gui-flow-manifest", + "GUI / Playwright MCP flow manifest 有效", + facts.guiFlowReport?.valid === true && facts.guiFlowReport?.flowCount > 0, + `flowCount=${facts.guiFlowReport?.flowCount ?? 0}`, + "GUI flow manifest 未通过校验或没有 flow。", + ), + createItem( + "evidence-schema", + "Agent QC Evidence Pack schema 存在", + facts.files?.evidenceSchema, + "docs/test/agent-qc-evidence.schema.json", + "缺少 Evidence Pack schema。", + ), + createItem( + "qcloop-payload-generator", + "可从 manifest 生成 qcloop job payload", + facts.files?.qcloopJobScript && facts.qcloopPayload?.valid === true && facts.qcloopPayload?.itemCount > 0, + `itemCount=${facts.qcloopPayload?.itemCount ?? 0}`, + "qcloop payload 生成器不可用或没有 item。", + ), + createItem( + "qcloop-verifier-evidence-placeholders", + "qcloop verifier prompt 带 worker evidence 占位符", + facts.qcloopPayload?.verifierHasWorkerOutput === true && + facts.qcloopPayload?.verifierHasAttemptStatus === true && + facts.qcloopPayload?.verifierHasExitCode === true, + `stdout=${Boolean(facts.qcloopPayload?.verifierHasWorkerOutput)} attempt_status=${Boolean(facts.qcloopPayload?.verifierHasAttemptStatus)} exit_code=${Boolean(facts.qcloopPayload?.verifierHasExitCode)}`, + "qcloop verifier prompt 缺少 {{stdout}} / {{attempt_status}} / {{exit_code}},verifier 无法审查 worker 证据。", + ), + createItem( + "structured-evidence-contract", + "qcloop worker / verifier / exporter 强制结构化 evidence summary", + facts.files?.evidenceContractDoc && + facts.qcloopPayload?.workerPromptHasStructuredEvidence === true && + facts.qcloopPayload?.verifierRequiresStructuredEvidence === true && + facts.qcloopPayload?.verifierRequiresStrictJson === true && + facts.structuredEvidence?.exporterParsesSummary === true && + facts.structuredEvidence?.releaseSummaryRejectsWeakRefs === true, + `doc=${Boolean(facts.files?.evidenceContractDoc)} worker=${Boolean(facts.qcloopPayload?.workerPromptHasStructuredEvidence)} verifier=${Boolean(facts.qcloopPayload?.verifierRequiresStructuredEvidence)} strictJson=${Boolean(facts.qcloopPayload?.verifierRequiresStrictJson)} exporter=${Boolean(facts.structuredEvidence?.exporterParsesSummary)} release=${Boolean(facts.structuredEvidence?.releaseSummaryRejectsWeakRefs)}`, + "结构化 evidence summary 契约未被文档、payload worker prompt、verifier prompt、exporter 与 release summary gate 同时强制,可能再次接受浅层 qcloop success。", + ), + createItem( + "qcloop-evidence-exporter", + "可把 qcloop job/items 导出为 Evidence Pack", + facts.files?.exportEvidenceScript, + "scripts/agent-qc-export-evidence.mjs", + "缺少 qcloop Evidence Pack 导出入口。", + ), + createItem( + "qcloop-status-monitor", + "可只读监控 qcloop 运行批次和 stale item", + facts.files?.qcloopStatusScript && facts.files?.qcloopOperationsDoc, + "scripts/agent-qc-qcloop-status.mjs, docs/tests/lime-agent-qc-qcloop-operations.md", + "缺少 qcloop 只读状态监控脚本或运维手册。", + ), + createItem( + "gui-owner-check", + "可在启动 GUI P0 前阻断并发 qcloop GUI owner", + facts.files?.guiOwnerCheckScript && facts.files?.qcloopOperationsDoc, + "scripts/agent-qc-gui-owner-check.mjs, docs/tests/lime-agent-qc-qcloop-operations.md", + "缺少 GUI owner 并发检查脚本或运维手册。", + ), + createItem( + "stale-owner-intervention-protocol", + "stale GUI qcloop owner 有只读取证和 owner 确认协议", + facts.files?.staleOwnerInterventionDoc && + facts.files?.qcloopOperationsDoc && + facts.staleOwnerIntervention?.guiOwnerReportHasDecisionPacket === true && + facts.staleOwnerIntervention?.docMentionsDecisionPacket === true && + facts.staleOwnerIntervention?.guiOwnerReportHasWatchHistory === true && + facts.staleOwnerIntervention?.docMentionsWatchHistory === true, + `doc=${Boolean(facts.files?.staleOwnerInterventionDoc)} operations=${Boolean(facts.files?.qcloopOperationsDoc)} ownerIntervention=${Boolean(facts.staleOwnerIntervention?.guiOwnerReportHasDecisionPacket)} docDecisionPacket=${Boolean(facts.staleOwnerIntervention?.docMentionsDecisionPacket)} watchHistory=${Boolean(facts.staleOwnerIntervention?.guiOwnerReportHasWatchHistory)} docWatchHistory=${Boolean(facts.staleOwnerIntervention?.docMentionsWatchHistory)}`, + "缺少 stale GUI owner 处置协议、机器可读 ownerIntervention 或 watch history 输出;容易在未确认时误杀 worker、启动并发 GUI P0,或丢失长期观察证据。", + ), + createItem( + "qcloop-worker-preflight", + "qcloop worker 执行前有 cwd / tmp / DevBridge preflight", + facts.files?.qcloopPreflightScript && + facts.qcloopPayload?.workerPromptHasPreflight === true, + `script=${Boolean(facts.files?.qcloopPreflightScript)} prompt=${Boolean(facts.qcloopPayload?.workerPromptHasPreflight)}`, + "缺少 qcloop worker preflight 脚本,或 payload prompt 没有要求执行前置环境检查。", + ), + createItem( + "release-summary", + "可把 Evidence Pack 汇总为 release note 质量证据", + facts.files?.releaseSummaryScript, + "scripts/agent-qc-release-summary.mjs", + "缺少 release summary 入口。", + ), + createItem( + "nightly-artifacts", + "nightly 上传 Agent QC 标准资产报告", + facts.nightly?.hasAgentQcReport && facts.nightly?.hasGuiFlowReport && facts.nightly?.hasReleasePreview, + ".github/workflows/harness-nightly.yml artifacts/agent-qc/*", + "nightly 未完整上传 Agent QC report / GUI flow report / release preview。", + ), + createItem( + "real-qcloop-evidence", + "存在真实 qcloop Agent QC Evidence Pack", + facts.realEvidencePack?.status === "pass" && + hasRequiredQcloopCoverage, + qcloopEvidenceWithStatusText, + qcloopGapText, + ), + createItem( + "real-gui-evidence", + "存在真实 GUI / Playwright MCP evidence", + facts.files?.realGuiEvidence, + facts.files?.realGuiEvidence ? ".lime/qc/gui-evidence" : "未发现 .lime/qc/gui-evidence", + "尚未执行真实 GUI / Playwright MCP flow 并保存证据。", + ), + createItem( + "release-hard-gate", + "release workflow 强制 Evidence Pack pass 且覆盖 P0 场景", + facts.release?.hasHardGate === true && facts.release?.requiresP0ScenarioCoverage === true, + facts.release?.hasHardGate && facts.release?.requiresP0ScenarioCoverage + ? ".github/workflows/release.yml contains agent-qc release gate and P0 scenario coverage" + : "release.yml 未完整强制 agent-qc release gate / P0 scenario coverage", + "release workflow 尚未强制真实 Evidence Pack pass 或 P0 场景覆盖。", + ), + createItem( + "local-verify-gate", + "仓库统一本地校验 verify:local 通过", + facts.localVerify?.status === "pass", + `${localVerifyEvidenceText}${guiSmokeEvidenceText}`, + `${localVerifyGapFragments.join(";")}。`, + ), + ]; + + const passedCount = items.filter((item) => item.passed).length; + const failed = items.filter((item) => !item.passed); + return { + status: failed.length === 0 ? "complete" : "incomplete", + passedCount, + failedCount: failed.length, + totalCount: items.length, + completionRatio: items.length === 0 ? 0 : passedCount / items.length, + items, + gaps: failed.map((item) => ({ id: item.id, title: item.title, gap: item.gap })), + }; +} + +function renderAgentQcCompletionAuditMarkdown(audit) { + const lines = audit.items.map((item) => { + const marker = item.passed ? "PASS" : "MISS"; + return `- ${marker} ${item.id}: ${item.title};证据:${item.evidence}${item.gap ? `;缺口:${item.gap}` : ""}`; + }); + return `# Agent QC Completion Audit + +- Status: ${audit.status} +- Passed: ${audit.passedCount}/${audit.totalCount} +- Completion: ${Math.round(audit.completionRatio * 100)}% + +## Checklist + +${lines.join("\n")} +`; +} + +export { buildAgentQcCompletionAudit, renderAgentQcCompletionAuditMarkdown }; diff --git a/scripts/lib/agent-qc-completion-audit-core.test.ts b/scripts/lib/agent-qc-completion-audit-core.test.ts new file mode 100644 index 000000000..9b9d403b2 --- /dev/null +++ b/scripts/lib/agent-qc-completion-audit-core.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAgentQcCompletionAudit, + renderAgentQcCompletionAuditMarkdown, +} from "./agent-qc-completion-audit-core.mjs"; + +function completeFacts() { + return { + files: { + agentOpsQc: true, + p0Scenarios: true, + limeRolloutPlan: true, + testsReadme: true, + evidenceSchema: true, + qcloopJobScript: true, + guiOwnerCheckScript: true, + qcloopStatusScript: true, + qcloopPreflightScript: true, + qcloopOperationsDoc: true, + evidenceContractDoc: true, + staleOwnerInterventionDoc: true, + exportEvidenceScript: true, + releaseSummaryScript: true, + realGuiEvidence: true, + }, + realEvidencePack: { + exists: true, + status: "pass", + scenarioCount: 1, + scenarioIds: ["command-bridge-contract"], + }, + localVerify: { + status: "pass", + failedStage: "", + error: "", + }, + realEvidenceSidecars: [], + qcloopStatusSidecars: [], + scenarioReport: { + valid: true, + scenarioCount: 12, + p0ScenarioCount: 1, + p0ScenarioIds: ["command-bridge-contract"], + }, + guiFlowReport: { valid: true, flowCount: 4 }, + qcloopPayload: { + valid: true, + itemCount: 8, + verifierHasWorkerOutput: true, + verifierHasAttemptStatus: true, + verifierHasExitCode: true, + workerPromptHasPreflight: true, + workerPromptHasStructuredEvidence: true, + verifierRequiresStructuredEvidence: true, + verifierRequiresStrictJson: true, + }, + structuredEvidence: { + exporterParsesSummary: true, + releaseSummaryRejectsWeakRefs: true, + }, + staleOwnerIntervention: { + guiOwnerReportHasDecisionPacket: true, + docMentionsDecisionPacket: true, + guiOwnerReportHasWatchHistory: true, + docMentionsWatchHistory: true, + }, + nightly: { hasAgentQcReport: true, hasGuiFlowReport: true, hasReleasePreview: true }, + release: { hasHardGate: true, requiresP0ScenarioCoverage: true }, + }; +} + +describe("agent-qc-completion-audit-core", () => { + it("所有事实齐全时应标记 complete", () => { + const audit = buildAgentQcCompletionAudit(completeFacts()); + + expect(audit.status).toBe("complete"); + expect(audit.failedCount).toBe(0); + }); + + it("缺真实 evidence 时应保持 incomplete", () => { + const facts = completeFacts(); + facts.realEvidencePack = { exists: false, status: "", scenarioCount: 0, scenarioIds: [] }; + facts.files.realGuiEvidence = false; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain("real-qcloop-evidence"); + expect(audit.gaps.map((gap) => gap.id)).toContain("real-gui-evidence"); + }); + + it("缺正式 evidence 但存在 sidecar evidence 时应在审计证据中显示 sidecar 状态", () => { + const facts = completeFacts(); + facts.realEvidencePack = { exists: false, status: "", scenarioCount: 0, scenarioIds: [] }; + facts.realEvidenceSidecars = [ + { + path: ".lime/qc/agent-qc-evidence.p0-v2.json", + status: "fail", + scenarioCount: 8, + scenarioIds: ["command-bridge-contract"], + }, + ]; + + const audit = buildAgentQcCompletionAudit(facts); + const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence"); + + expect(audit.status).toBe("incomplete"); + expect(item?.evidence).toContain("sidecars"); + expect(item?.evidence).toContain("status=fail"); + }); + + it("真实 qcloop 批次 stale 时应在缺口中显示 stale sidecar", () => { + const facts = completeFacts(); + facts.realEvidencePack = { + exists: true, + status: "fail", + scenarioCount: 8, + scenarioIds: ["command-bridge-contract"], + }; + facts.qcloopStatusSidecars = [ + { + path: ".lime/qc/qcloop-status.isolated-p0-full-v1-stale-check.json", + verdictStatus: "stale", + counts: { success: 4, running: 1, pending: 3, stale: 1 }, + }, + ]; + + const audit = buildAgentQcCompletionAudit(facts); + const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence"); + + expect(audit.status).toBe("incomplete"); + expect(item?.evidence).toContain("qcloopStatus"); + expect(item?.gap).toContain("stale"); + expect(item?.gap).toContain("qcloop-status.isolated-p0-full-v1-stale-check.json"); + }); + + it("真实 qcloop 批次仍在运行时应在缺口中显示未终态 sidecar 摘要", () => { + const facts = completeFacts(); + facts.realEvidencePack = { + exists: true, + status: "fail", + scenarioCount: 8, + scenarioIds: ["command-bridge-contract"], + }; + facts.qcloopStatusSidecars = [ + { + path: ".lime/qc/qcloop-status.fastmini-readonly-p0-v1-current.json", + verdictStatus: "running", + counts: { success: 0, running: 1, pending: 2, stale: 0 }, + }, + ]; + + const audit = buildAgentQcCompletionAudit(facts); + const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence"); + + expect(audit.status).toBe("incomplete"); + expect(item?.gap).toContain("未终态"); + expect(item?.gap).toContain("running=1"); + expect(item?.gap).toContain("qcloop-status.fastmini-readonly-p0-v1-current.json"); + }); + + it("qcloop verifier prompt 缺少 worker evidence 占位符时应保持 incomplete", () => { + const facts = completeFacts(); + facts.qcloopPayload.verifierHasWorkerOutput = false; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain( + "qcloop-verifier-evidence-placeholders", + ); + }); + + it("结构化 evidence summary 契约缺失时应保持 incomplete", () => { + const facts = completeFacts(); + facts.qcloopPayload.workerPromptHasStructuredEvidence = false; + facts.structuredEvidence.exporterParsesSummary = false; + facts.structuredEvidence.releaseSummaryRejectsWeakRefs = false; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain("structured-evidence-contract"); + }); + + it("缺少 qcloop 状态监控入口时应保持 incomplete", () => { + const facts = completeFacts(); + facts.files.qcloopStatusScript = false; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-status-monitor"); + }); + + it("缺少 qcloop worker preflight 时应保持 incomplete", () => { + const facts = completeFacts(); + facts.qcloopPayload.workerPromptHasPreflight = false; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-worker-preflight"); + }); + + it("缺少 stale owner 机器可读决策输出时应保持 incomplete", () => { + const facts = completeFacts(); + facts.staleOwnerIntervention.guiOwnerReportHasDecisionPacket = false; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain("stale-owner-intervention-protocol"); + expect(audit.items.find((item) => item.id === "stale-owner-intervention-protocol")?.evidence).toContain( + "ownerIntervention=false", + ); + }); + + it("缺少 stale owner watch history 输出时应保持 incomplete", () => { + const facts = completeFacts(); + facts.staleOwnerIntervention.guiOwnerReportHasWatchHistory = false; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain("stale-owner-intervention-protocol"); + expect(audit.items.find((item) => item.id === "stale-owner-intervention-protocol")?.evidence).toContain( + "watchHistory=false", + ); + }); + + it("release hard gate 缺失时应给出缺口", () => { + const facts = completeFacts(); + facts.release.hasHardGate = false; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain("release-hard-gate"); + }); + + it("release 未强制 P0 scenario 覆盖时应给出缺口", () => { + const facts = completeFacts(); + facts.release.requiresP0ScenarioCoverage = false; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain("release-hard-gate"); + }); + + it("verify:local 失败时应给出缺口", () => { + const facts = completeFacts(); + facts.localVerify = { + status: "fail", + failedStage: "typecheck", + error: "example typecheck failure", + }; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain("local-verify-gate"); + expect(audit.items.find((item) => item.id === "local-verify-gate")?.evidence).toContain( + "failedStage=typecheck", + ); + }); + + it("真实 evidence 数量足够但缺 P0 scenario id 时仍应保持 incomplete", () => { + const facts = completeFacts(); + facts.scenarioReport.p0ScenarioCount = 2; + facts.scenarioReport.p0ScenarioIds = [ + "command-bridge-contract", + "tool-approval-sandbox-boundary", + ]; + facts.realEvidencePack = { + exists: true, + status: "pass", + scenarioCount: 2, + scenarioIds: ["command-bridge-contract", "non-p0-placeholder"], + }; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.items.find((item) => item.id === "real-qcloop-evidence")?.evidence).toContain( + "missing=tool-approval-sandbox-boundary", + ); + }); + + it("应渲染 Markdown 审计报告", () => { + const audit = buildAgentQcCompletionAudit(completeFacts()); + const markdown = renderAgentQcCompletionAuditMarkdown(audit); + + expect(markdown).toContain("Agent QC Completion Audit"); + expect(markdown).toContain("Status: complete"); + }); +}); diff --git a/scripts/lib/agent-qc-evidence-core.mjs b/scripts/lib/agent-qc-evidence-core.mjs new file mode 100644 index 000000000..bde59621a --- /dev/null +++ b/scripts/lib/agent-qc-evidence-core.mjs @@ -0,0 +1,497 @@ +const QCLoopTerminalStatus = new Set(["success", "failed", "exhausted"]); +const QCLoopEvidenceSummaryMarker = "QCLOOP_EVIDENCE_SUMMARY_JSON="; + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function normalizeStatus(value) { + return isNonEmptyString(value) ? value.trim().toLowerCase() : "unknown"; +} + +function collectAttemptStdout(item) { + return asArray(item?.attempts) + .map((attempt) => (typeof attempt?.stdout === "string" ? attempt.stdout : "")) + .join("\n"); +} + +function collectAttemptStderr(item) { + return asArray(item?.attempts) + .map((attempt) => (typeof attempt?.stderr === "string" ? attempt.stderr : "")) + .join("\n"); +} + +function collectAttemptOutput(item) { + return [collectAttemptStdout(item), collectAttemptStderr(item)].join("\n"); +} + +function isQCLoopWorkerBlocked(item) { + return /QCLOOP_WORKER_RESULT\s*[:=]\s*BLOCKED/i.test(collectAttemptStdout(item)); +} + +function isQCLoopWorkerEnvironmentBlocked(item) { + return [ + /QCLOOP_CODEX_BIN\s+不可用/i, + /QCLOOP_CODEX_EXTRA_ARGS\s+解析失败/i, + /QCLOOP_CODEX_SANDBOX=.*无效/i, + /fork\/exec .* no such file or directory/i, + /incorrect api key provided/i, + /failed to connect to websocket: HTTP error: 401 Unauthorized/i, + ].some((pattern) => pattern.test(collectAttemptOutput(item))); +} + +function isQCLoopWorkerSelfReportedPass(item) { + return /QCLOOP_WORKER_RESULT\s*[:=]\s*PASS/i.test(collectAttemptStdout(item)); +} + +function parseQCLoopEvidenceSummaryLine(line) { + if (!isNonEmptyString(line)) { + return { summary: null, error: "" }; + } + const markerIndex = line.indexOf(QCLoopEvidenceSummaryMarker); + if (markerIndex < 0) { + return { summary: null, error: "" }; + } + + const rawJson = line.slice(markerIndex + QCLoopEvidenceSummaryMarker.length).trim(); + if (!rawJson) { + return { summary: null, error: "empty" }; + } + try { + const parsed = JSON.parse(rawJson); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { summary: null, error: "not_object" }; + } + return { summary: parsed, error: "" }; + } catch { + return { summary: null, error: "invalid_json" }; + } +} + +function collectQCLoopEvidenceSummaries(item) { + const summaries = []; + const parseErrors = []; + const stdout = collectAttemptStdout(item); + for (const line of stdout.split(/\r?\n/)) { + if (!line.includes(QCLoopEvidenceSummaryMarker)) { + continue; + } + const parsed = parseQCLoopEvidenceSummaryLine(line); + if (parsed.summary) { + summaries.push(parsed.summary); + } else { + parseErrors.push(parsed.error || "unknown"); + } + } + return { summaries, parseErrors }; +} + +function latestQCLoopEvidenceSummary(item) { + const { summaries } = collectQCLoopEvidenceSummaries(item); + return summaries.at(-1) || null; +} + +function normalizeEvidenceResult(value) { + const normalized = normalizeStatus(value); + if (["pass", "passed", "success"].includes(normalized)) { + return "pass"; + } + if (["blocked", "block"].includes(normalized)) { + return "blocked"; + } + if (["fail", "failed", "failure", "error"].includes(normalized)) { + return "fail"; + } + return "unknown"; +} + +function hasFailedQCLoopExecution(item) { + const status = normalizeStatus(item?.status); + if (status === "failed" || status === "exhausted") { + return true; + } + if (asArray(item?.attempts).some((attempt) => normalizeStatus(attempt?.status) === "failed")) { + return true; + } + return asArray(item?.qc_rounds).some((round) => normalizeStatus(round?.status) === "fail"); +} + +function mapQCLoopItemStatus(status, item = null) { + const normalized = normalizeStatus(status); + const evidenceSummary = item ? latestQCLoopEvidenceSummary(item) : null; + const evidenceResult = evidenceSummary ? normalizeEvidenceResult(evidenceSummary.result) : "unknown"; + if (evidenceResult === "blocked") { + return "blocked"; + } + if (evidenceResult === "fail") { + return "fail"; + } + if (normalized === "success") { + return item ? (evidenceSummary ? "pass" : "fail") : "pass"; + } + if (isQCLoopWorkerBlocked(item) || isQCLoopWorkerEnvironmentBlocked(item)) { + return "blocked"; + } + if (normalized === "failed" || normalized === "exhausted") { + return "fail"; + } + if (normalized === "running" || normalized === "pending") { + return "blocked"; + } + return "needs-human-review"; +} + +function mapQCLoopJobStatus(status, itemStatuses) { + const normalized = normalizeStatus(status); + if (itemStatuses.some((itemStatus) => itemStatus === "fail")) { + return "fail"; + } + if (itemStatuses.some((itemStatus) => itemStatus === "blocked")) { + return "blocked"; + } + if (itemStatuses.some((itemStatus) => itemStatus === "needs-human-review")) { + return "needs-human-review"; + } + if (normalized === "completed") { + return "pass"; + } + if (normalized === "failed") { + return "fail"; + } + if (normalized === "paused" || normalized === "running" || normalized === "pending") { + return "blocked"; + } + return "needs-human-review"; +} + +function parseScenarioId(itemValue) { + if (!isNonEmptyString(itemValue)) { + return "unknown-scenario"; + } + + const trimmed = itemValue.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + try { + const parsed = JSON.parse(trimmed); + for (const key of ["scenarioId", "scenario_id", "id", "name", "entry"]) { + if (isNonEmptyString(parsed?.[key])) { + return parsed[key].trim(); + } + } + } catch { + // 非 JSON item 按普通字符串处理。 + } + } + + return trimmed.split(/[\s,|]+/)[0] || "unknown-scenario"; +} + +function collectItemFailureModes(item) { + const modes = []; + const status = normalizeStatus(item?.status); + const { summaries, parseErrors } = collectQCLoopEvidenceSummaries(item); + if (parseErrors.length > 0) { + modes.push("qcloop:evidence_summary_invalid_json"); + } + if (status === "success" && summaries.length === 0 && parseErrors.length === 0) { + modes.push("qcloop:evidence_summary_missing"); + } + if (status === "failed") { + modes.push("qcloop:item_failed"); + } + if (status === "exhausted") { + modes.push("qcloop:max_qc_rounds_exhausted"); + } + if (status === "running" || status === "pending") { + modes.push("qcloop:item_not_terminal"); + } + if (isNonEmptyString(item?.last_error)) { + modes.push("qcloop:last_error_present"); + } + if (isQCLoopWorkerBlocked(item)) { + modes.push("qcloop:worker_blocked"); + } + if (isQCLoopWorkerEnvironmentBlocked(item)) { + modes.push("qcloop:worker_environment_blocked"); + } + if (isQCLoopWorkerSelfReportedPass(item) && hasFailedQCLoopExecution(item)) { + modes.push("qcloop:worker_self_report_pass_not_verified"); + } + + for (const attempt of asArray(item?.attempts)) { + if (normalizeStatus(attempt.status) === "failed") { + modes.push(`qcloop:attempt_${attempt.attempt_no ?? "unknown"}_failed`); + } + } + + for (const round of asArray(item?.qc_rounds)) { + if (normalizeStatus(round.status) === "fail") { + modes.push(`qcloop:qc_${round.qc_no ?? "unknown"}_failed`); + } + } + + return Array.from(new Set(modes)); +} + +function collectEvidenceSummaryArtifactRefs(summary) { + const refs = []; + for (const command of asArray(summary?.commands)) { + for (const key of ["stdout_artifact", "stderr_artifact", "logRef", "log_ref"]) { + if (isNonEmptyString(command?.[key])) { + refs.push(command[key].trim()); + } + } + } + for (const evidence of asArray(summary?.evidence_required)) { + if (isNonEmptyString(evidence?.artifact_path)) { + refs.push(evidence.artifact_path.trim()); + } + } + for (const artifact of asArray(summary?.artifacts)) { + if (typeof artifact === "string" && artifact.trim()) { + refs.push(artifact.trim()); + continue; + } + if (isNonEmptyString(artifact?.path)) { + refs.push(artifact.path.trim()); + } + } + if (isNonEmptyString(summary?.gui_session_owner)) { + refs.push(`gui-session-owner:${summary.gui_session_owner.trim()}`); + } + if (isNonEmptyString(summary?.release_scope)) { + refs.push(`release-scope:${summary.release_scope.trim()}`); + } + return refs; +} + +function collectEvidenceRefs(item) { + const refs = []; + if (isNonEmptyString(item?.id)) { + refs.push(`qcloop:item:${item.id}`); + } + for (const attempt of asArray(item?.attempts)) { + if (isNonEmptyString(attempt?.id)) { + refs.push(`qcloop:attempt:${attempt.id}`); + } + } + for (const round of asArray(item?.qc_rounds)) { + if (isNonEmptyString(round?.id)) { + refs.push(`qcloop:qc:${round.id}`); + } + } + for (const summary of collectQCLoopEvidenceSummaries(item).summaries) { + refs.push(...collectEvidenceSummaryArtifactRefs(summary)); + } + return Array.from(new Set(refs)); +} + +function findArtifactRefByKind(summary, kind) { + for (const artifact of asArray(summary?.artifacts)) { + if (typeof artifact === "string") { + if (artifact.includes(kind)) { + return artifact; + } + continue; + } + if (artifact?.kind === kind && isNonEmptyString(artifact?.path)) { + return artifact.path.trim(); + } + } + return ""; +} + +function summarizeQCLoopItem(item) { + const attempts = asArray(item?.attempts); + const qcRounds = asArray(item?.qc_rounds); + const failedQcRound = qcRounds.find((round) => normalizeStatus(round.status) === "fail"); + const latestQcRound = qcRounds.at(-1); + const status = mapQCLoopItemStatus(item?.status, item); + const evidenceSummary = latestQCLoopEvidenceSummary(item); + + return { + scenarioId: parseScenarioId(item?.item_value), + status, + executor: "qcloop", + attempts: attempts.length, + evidenceRefs: collectEvidenceRefs(item), + failureModes: collectItemFailureModes(item), + humanReview: { + required: status === "needs-human-review", + reason: + status === "needs-human-review" + ? "qcloop item 状态无法自动归类,需要人工审核。" + : "无需人工审核。", + decision: status === "needs-human-review" ? "not-reviewed" : "approved", + }, + runtimeTranscriptRef: + findArtifactRefByKind(evidenceSummary, "runtime-transcript") || + (attempts.length > 0 ? `qcloop:item:${item.id}:attempts` : ""), + guiTraceRef: findArtifactRefByKind(evidenceSummary, "gui-trace"), + consoleErrorCount: 0, + networkErrorCount: 0, + _summary: { + itemId: item?.id ?? "", + itemValue: item?.item_value ?? "", + qcloopStatus: item?.status ?? "unknown", + currentAttemptNo: item?.current_attempt_no ?? 0, + currentQcNo: item?.current_qc_no ?? 0, + latestFeedback: failedQcRound?.feedback || latestQcRound?.feedback || item?.last_error || "", + evidenceSummaryPresent: Boolean(evidenceSummary), + }, + }; +} + +function stripPrivateSummary(scenarioResult) { + const { _summary, ...publicResult } = scenarioResult; + return publicResult; +} + +function collectChangedFiles(options) { + return asArray(options.changedFiles).filter(isNonEmptyString); +} + +function buildAgentQcEvidencePack({ job, items, options = {} }) { + const generatedAt = options.generatedAt || new Date().toISOString(); + const scenarioSummaries = asArray(items).map(summarizeQCLoopItem); + const scenarioResults = scenarioSummaries.map(stripPrivateSummary); + const itemStatuses = scenarioResults.map((result) => result.status); + const verdictStatus = mapQCLoopJobStatus(job?.status, itemStatuses); + const failedItems = scenarioSummaries.filter((result) => result.status === "fail"); + const blockedItems = scenarioSummaries.filter((result) => result.status === "blocked"); + const reviewItems = scenarioSummaries.filter((result) => result.status === "needs-human-review"); + const commandStatus = verdictStatus === "pass" ? "pass" : verdictStatus; + + return { + schemaVersion: "v1", + runId: options.runId || `qcloop-${job?.id || "unknown"}-${Date.parse(generatedAt) || Date.now()}`, + generatedAt, + subject: { + repo: options.repo || "lime", + ref: options.ref || "unknown", + diffBase: options.diffBase || "", + changedFiles: collectChangedFiles(options), + riskTags: ["agent-qc", "qcloop", ...(options.riskTags || [])], + }, + laneResults: [ + { + laneId: "L4-behavior-eval", + status: commandStatus, + commands: [ + { + command: `qcloop job ${job?.id || "unknown"}`, + status: commandStatus, + artifactRefs: [ + `qcloop:job:${job?.id || "unknown"}`, + `qcloop:items:${job?.id || "unknown"}`, + ], + }, + ], + notes: [ + `qcloop job status: ${job?.status || "unknown"}`, + `items: ${scenarioResults.length}`, + `failed: ${failedItems.length}`, + `blocked: ${blockedItems.length}`, + `needs review: ${reviewItems.length}`, + ], + }, + ], + scenarioResults, + verdict: { + status: verdictStatus, + summary: renderVerdictSummary({ job, scenarioResults, failedItems, blockedItems, reviewItems }), + blockers: renderBlockers({ failedItems, blockedItems, reviewItems }), + waivers: [], + nextAction: renderNextAction(verdictStatus), + }, + }; +} + +function renderVerdictSummary({ job, scenarioResults, failedItems, blockedItems, reviewItems }) { + return `qcloop job ${job?.id || "unknown"} (${job?.status || "unknown"}) 导出 ${scenarioResults.length} 个场景结果:失败 ${failedItems.length},阻断 ${blockedItems.length},需人审 ${reviewItems.length}。`; +} + +function renderBlockers({ failedItems, blockedItems, reviewItems }) { + const blockers = []; + for (const result of failedItems) { + let reason = result._summary.latestFeedback || "qcloop item failed"; + if (result.failureModes.includes("qcloop:evidence_summary_missing")) { + reason = "qcloop item 已 success,但 stdout 缺少 QCLOOP_EVIDENCE_SUMMARY_JSON;不能作为 release pass。"; + } else if (result.failureModes.includes("qcloop:evidence_summary_invalid_json")) { + reason = "qcloop stdout 包含 QCLOOP_EVIDENCE_SUMMARY_JSON,但 JSON 无法解析。"; + } else if (result.failureModes.includes("qcloop:worker_self_report_pass_not_verified")) { + reason = `worker stdout 自报 QCLOOP_WORKER_RESULT=PASS,但 qcloop / verifier 未通过;latest verifier feedback: ${result._summary.latestFeedback || "无"}`; + } + blockers.push(`${result.scenarioId}: ${reason}`); + } + for (const result of blockedItems) { + const reason = result.failureModes.includes("qcloop:worker_environment_blocked") + ? "qcloop worker 环境阻断;请检查内层 CLI 二进制、认证、sandbox 或 extra args。" + : result.failureModes.includes("qcloop:worker_blocked") + ? `qcloop worker 明确输出 QCLOOP_WORKER_RESULT=BLOCKED;latest verifier feedback: ${result._summary.latestFeedback || "无"}` + : `qcloop item 未进入终态 (${result._summary.qcloopStatus})`; + blockers.push(`${result.scenarioId}: ${reason}`); + } + for (const result of reviewItems) { + blockers.push(`${result.scenarioId}: 状态无法自动归类,需要人工审核`); + } + return blockers; +} + +function renderNextAction(status) { + if (status === "pass") { + return "可把 Evidence Pack 挂到 PR / release 证据摘要。"; + } + if (status === "fail") { + return "优先修复 failed/exhausted item,并把高价值失败沉淀为 replay 或稳定回归。"; + } + if (status === "blocked") { + return "先恢复 qcloop job 到终态,或记录环境/权限阻断原因。"; + } + return "安排人工审核 verifier feedback,再决定修复、waive 或补测试。"; +} + +function validateEvidencePackShape(pack) { + const issues = []; + for (const field of ["schemaVersion", "runId", "generatedAt", "subject", "laneResults", "scenarioResults", "verdict"]) { + if (!(field in pack)) { + issues.push(`缺少字段 ${field}`); + } + } + if (pack.schemaVersion !== "v1") { + issues.push("schemaVersion 必须是 v1"); + } + if (!Array.isArray(pack.laneResults)) { + issues.push("laneResults 必须是数组"); + } + if (!Array.isArray(pack.scenarioResults)) { + issues.push("scenarioResults 必须是数组"); + } + if (!pack.verdict || !["pass", "fail", "blocked", "needs-human-review", "waived"].includes(pack.verdict.status)) { + issues.push("verdict.status 不合法"); + } + return { + valid: issues.length === 0, + issues, + }; +} + +export { + buildAgentQcEvidencePack, + collectEvidenceRefs, + collectItemFailureModes, + collectQCLoopEvidenceSummaries, + isQCLoopWorkerEnvironmentBlocked, + isQCLoopWorkerBlocked, + isQCLoopWorkerSelfReportedPass, + mapQCLoopItemStatus, + mapQCLoopJobStatus, + parseScenarioId, + parseQCLoopEvidenceSummaryLine, + summarizeQCLoopItem, + validateEvidencePackShape, +}; diff --git a/scripts/lib/agent-qc-evidence-core.test.ts b/scripts/lib/agent-qc-evidence-core.test.ts new file mode 100644 index 000000000..1255b75c4 --- /dev/null +++ b/scripts/lib/agent-qc-evidence-core.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAgentQcEvidencePack, + collectQCLoopEvidenceSummaries, + isQCLoopWorkerEnvironmentBlocked, + isQCLoopWorkerBlocked, + isQCLoopWorkerSelfReportedPass, + mapQCLoopItemStatus, + parseQCLoopEvidenceSummaryLine, + parseScenarioId, + validateEvidencePackShape, +} from "./agent-qc-evidence-core.mjs"; + +function evidenceSummary(overrides = {}) { + return `QCLOOP_EVIDENCE_SUMMARY_JSON=${JSON.stringify({ + scenario_id: "command-bridge-contract", + result: "pass", + commands: [ + { + command: "npm run test:contracts", + exit_code: 0, + stdout_artifact: ".lime/qc/test-contracts.stdout.txt", + }, + ], + evidence_required: [ + { + name: "contract command log", + status: "pass", + evidence: "test:contracts passed", + artifact_path: ".lime/qc/test-contracts.stdout.txt", + }, + ], + evidence_layers_covered: ["deterministic-smoke"], + failure_modes: [{ name: "mock fallback drift", status: "excluded", evidence: "contract check passed" }], + artifacts: [{ path: ".lime/qc/contract-summary.json", kind: "deterministic-smoke", redacted: true }], + blockers: [], + gui_session_owner: "", + release_scope: "source-tree-startup-smoke", + ...overrides, + })}`; +} + +describe("agent-qc-evidence-core", () => { + it("应把 qcloop item 状态映射为 Evidence Pack 状态", () => { + expect(mapQCLoopItemStatus("success")).toBe("pass"); + expect(mapQCLoopItemStatus("failed")).toBe("fail"); + expect(mapQCLoopItemStatus("exhausted")).toBe("fail"); + expect(mapQCLoopItemStatus("running")).toBe("blocked"); + }); + + it("应解析 qcloop 结构化 evidence summary marker", () => { + const parsed = parseQCLoopEvidenceSummaryLine(evidenceSummary()); + + expect(parsed.error).toBe(""); + expect(parsed.summary?.scenario_id).toBe("command-bridge-contract"); + + const item = { attempts: [{ stdout: `log\n${evidenceSummary()}\n` }] }; + const summaries = collectQCLoopEvidenceSummaries(item); + expect(summaries.summaries).toHaveLength(1); + expect(summaries.parseErrors).toEqual([]); + }); + + it("应把 worker 明确报告 BLOCKED 的 exhausted item 映射为 blocked", () => { + const item = { + status: "exhausted", + attempts: [ + { + stdout: [ + "QCLOOP_WORKER_RESULT=BLOCKED", + "失败 check: devbridge-health", + "错误: TypeError: fetch failed", + ].join("\n"), + }, + ], + }; + + expect(isQCLoopWorkerBlocked(item)).toBe(true); + expect(mapQCLoopItemStatus("exhausted", item)).toBe("blocked"); + }); + + it("应把内层 CLI 二进制或认证配置错误映射为环境 blocked", () => { + const item = { + status: "failed", + attempts: [ + { + status: "failed", + stderr: + "QCLOOP_CODEX_BIN 不可用: /opt/homebrew/bin/codex: fork/exec /opt/homebrew/bin/codex: no such file or directory", + }, + ], + qc_rounds: [{ status: "fail", feedback: "verifier 输出格式错误" }], + }; + + expect(isQCLoopWorkerEnvironmentBlocked(item)).toBe(true); + expect(mapQCLoopItemStatus("failed", item)).toBe("blocked"); + }); + + it("应标记 worker 自报 PASS 但 qcloop/verifier 未通过的冲突", () => { + const conflictItem = { + id: "item-pass-conflict", + item_value: "tool-approval-sandbox-boundary", + status: "failed", + current_attempt_no: 2, + current_qc_no: 1, + attempts: [ + { + id: "attempt-pass-conflict", + status: "failed", + attempt_no: 2, + stdout: "QCLOOP_WORKER_RESULT=PASS\nsmoke pass, but no live transcript", + }, + ], + qc_rounds: [ + { + id: "qc-pass-conflict", + status: "fail", + qc_no: 1, + feedback: "缺少 live runtime transcript。", + }, + ], + }; + const pack = buildAgentQcEvidencePack({ + job: { id: "job-pass-conflict", status: "running" }, + items: [conflictItem], + options: { generatedAt: "2026-05-10T00:00:00.000Z" }, + }); + + expect(isQCLoopWorkerSelfReportedPass(conflictItem)).toBe(true); + expect(pack.verdict.status).toBe("fail"); + expect(pack.scenarioResults[0].failureModes).toContain( + "qcloop:worker_self_report_pass_not_verified", + ); + expect(pack.verdict.blockers.join("\n")).toContain( + "worker stdout 自报 QCLOOP_WORKER_RESULT=PASS", + ); + }); + + it("应从 item_value 中提取 scenario id", () => { + expect(parseScenarioId("claw-chat-ready-streaming 基础聊天")).toBe( + "claw-chat-ready-streaming", + ); + expect(parseScenarioId('{"scenario_id":"command-bridge-contract"}')).toBe( + "command-bridge-contract", + ); + expect(parseScenarioId('{"name":"agent-ui-performance-summary-ttft-fields"}')).toBe( + "agent-ui-performance-summary-ttft-fields", + ); + expect(parseScenarioId('{"entry":"lime-agent-runtime-client-check"}')).toBe( + "lime-agent-runtime-client-check", + ); + }); + + it("应把成功 qcloop job 导出为 pass evidence pack", () => { + const pack = buildAgentQcEvidencePack({ + job: { id: "job-1", status: "completed" }, + items: [ + { + id: "item-1", + item_value: "command-bridge-contract", + status: "success", + current_attempt_no: 1, + current_qc_no: 1, + attempts: [ + { + id: "attempt-1", + status: "success", + attempt_no: 1, + stdout: evidenceSummary(), + }, + ], + qc_rounds: [{ id: "qc-1", status: "pass", qc_no: 1 }], + }, + ], + options: { + generatedAt: "2026-05-10T00:00:00.000Z", + ref: "test-ref", + changedFiles: ["docs/tests/agent-ops-qc.md"], + }, + }); + + expect(pack.verdict.status).toBe("pass"); + expect(pack.scenarioResults[0].scenarioId).toBe("command-bridge-contract"); + expect(pack.scenarioResults[0].evidenceRefs).toContain("qcloop:attempt:attempt-1"); + expect(pack.scenarioResults[0].evidenceRefs).toContain(".lime/qc/contract-summary.json"); + expect(pack.scenarioResults[0].evidenceRefs).toContain("release-scope:source-tree-startup-smoke"); + expect(validateEvidencePackShape(pack).valid).toBe(true); + }); + + it("应拒绝缺少 QCLOOP_EVIDENCE_SUMMARY_JSON 的 success item,避免旧浅层 pass 进入 release gate", () => { + const pack = buildAgentQcEvidencePack({ + job: { id: "job-missing-summary", status: "completed" }, + items: [ + { + id: "item-missing-summary", + item_value: "command-bridge-contract", + status: "success", + current_attempt_no: 1, + current_qc_no: 1, + attempts: [{ id: "attempt-missing-summary", status: "success", attempt_no: 1 }], + qc_rounds: [{ id: "qc-missing-summary", status: "pass", qc_no: 1 }], + }, + ], + options: { generatedAt: "2026-05-10T00:00:00.000Z" }, + }); + + expect(pack.verdict.status).toBe("fail"); + expect(pack.scenarioResults[0].failureModes).toContain("qcloop:evidence_summary_missing"); + expect(pack.verdict.blockers.join("\n")).toContain("缺少 QCLOOP_EVIDENCE_SUMMARY_JSON"); + }); + + it("应拒绝无法解析的 QCLOOP_EVIDENCE_SUMMARY_JSON", () => { + const pack = buildAgentQcEvidencePack({ + job: { id: "job-invalid-summary", status: "completed" }, + items: [ + { + id: "item-invalid-summary", + item_value: "command-bridge-contract", + status: "success", + current_attempt_no: 1, + current_qc_no: 1, + attempts: [ + { + id: "attempt-invalid-summary", + status: "success", + attempt_no: 1, + stdout: "QCLOOP_EVIDENCE_SUMMARY_JSON={not-json}", + }, + ], + qc_rounds: [{ id: "qc-invalid-summary", status: "pass", qc_no: 1 }], + }, + ], + options: { generatedAt: "2026-05-10T00:00:00.000Z" }, + }); + + expect(pack.verdict.status).toBe("fail"); + expect(pack.scenarioResults[0].failureModes).toContain("qcloop:evidence_summary_invalid_json"); + expect(pack.verdict.blockers.join("\n")).toContain("JSON 无法解析"); + }); + + it("应把耗尽 qcloop item 导出为 fail evidence pack", () => { + const pack = buildAgentQcEvidencePack({ + job: { id: "job-2", status: "failed" }, + items: [ + { + id: "item-2", + item_value: "tool-approval-sandbox-boundary", + status: "exhausted", + current_attempt_no: 3, + current_qc_no: 3, + attempts: [{ id: "attempt-2", status: "failed", attempt_no: 3 }], + qc_rounds: [ + { + id: "qc-2", + status: "fail", + qc_no: 3, + feedback: "缺少 approval 证据。", + }, + ], + }, + ], + options: { generatedAt: "2026-05-10T00:00:00.000Z" }, + }); + + expect(pack.verdict.status).toBe("fail"); + expect(pack.verdict.blockers.join("\n")).toContain("缺少 approval 证据"); + expect(pack.scenarioResults[0].failureModes).toContain( + "qcloop:max_qc_rounds_exhausted", + ); + }); + + it("应把 preflight blocked 的耗尽 item 导出为 blocked evidence pack", () => { + const pack = buildAgentQcEvidencePack({ + job: { id: "job-3", status: "completed" }, + items: [ + { + id: "item-3", + item_value: "claw-chat-ready-streaming", + status: "exhausted", + current_attempt_no: 3, + current_qc_no: 3, + attempts: [ + { + id: "attempt-3", + status: "success", + attempt_no: 3, + stdout: "QCLOOP_WORKER_RESULT=BLOCKED\nDevBridge preflight: BLOCKED", + }, + ], + qc_rounds: [ + { + id: "qc-3", + status: "fail", + qc_no: 3, + feedback: "DevBridge preflight blocked。", + }, + ], + }, + ], + options: { generatedAt: "2026-05-10T00:00:00.000Z" }, + }); + + expect(pack.verdict.status).toBe("blocked"); + expect(pack.verdict.blockers.join("\n")).toContain("DevBridge preflight blocked"); + expect(pack.scenarioResults[0].failureModes).toContain("qcloop:worker_blocked"); + }); + + it("应把 qcloop worker 环境阻断导出为 blocked evidence pack,且不泄露原始 stderr", () => { + const pack = buildAgentQcEvidencePack({ + job: { id: "job-env", status: "failed" }, + items: [ + { + id: "item-env", + item_value: "command-bridge-contract", + status: "failed", + current_attempt_no: 1, + current_qc_no: 1, + attempts: [ + { + id: "attempt-env", + status: "failed", + attempt_no: 1, + stdout: "", + stderr: + "QCLOOP_CODEX_BIN 不可用: /opt/homebrew/bin/codex: fork/exec /opt/homebrew/bin/codex: no such file or directory", + }, + ], + qc_rounds: [{ id: "qc-env", status: "fail", qc_no: 1, feedback: "verifier 输出格式错误" }], + }, + ], + options: { generatedAt: "2026-05-10T00:00:00.000Z" }, + }); + + expect(pack.verdict.status).toBe("blocked"); + expect(pack.scenarioResults[0].failureModes).toContain("qcloop:worker_environment_blocked"); + expect(pack.verdict.blockers.join("\n")).toContain("qcloop worker 环境阻断"); + expect(pack.verdict.blockers.join("\n")).not.toContain("/opt/homebrew/bin/codex"); + }); +}); diff --git a/scripts/lib/agent-qc-gui-flow-core.mjs b/scripts/lib/agent-qc-gui-flow-core.mjs new file mode 100644 index 000000000..2c238bd54 --- /dev/null +++ b/scripts/lib/agent-qc-gui-flow-core.mjs @@ -0,0 +1,163 @@ +const REQUIRED_FLOW_FIELDS = [ + "id", + "scenarioId", + "risk", + "surface", + "preflight", + "steps", + "assertions", + "evidenceRequired", +]; + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function addIssue(issues, severity, path, message) { + issues.push({ severity, path, message }); +} + +function validateStringArray(value, path, issues) { + if (!Array.isArray(value) || value.length === 0) { + addIssue(issues, "error", path, "必须是非空数组。"); + return; + } + value.forEach((entry, index) => { + if (!isNonEmptyString(entry)) { + addIssue(issues, "error", `${path}[${index}]`, "必须是非空字符串。"); + } + }); +} + +function validateAgentQcGuiFlowManifest(flowManifest, scenarioManifest) { + const issues = []; + if (!flowManifest || typeof flowManifest !== "object") { + return { + valid: false, + issues: [{ severity: "error", path: "manifest", message: "GUI flow manifest 必须是 JSON object。" }], + }; + } + + if (flowManifest.manifestVersion !== "v1") { + addIssue(issues, "error", "manifestVersion", "当前只接受 manifestVersion=v1。"); + } + + const scenarios = new Set(asArray(scenarioManifest?.scenarios).map((scenario) => scenario.id).filter(Boolean)); + const flows = asArray(flowManifest.flows); + const flowIds = new Set(); + + if (flows.length === 0) { + addIssue(issues, "error", "flows", "至少需要一个 GUI flow。"); + } + + flows.forEach((flow, index) => { + const prefix = `flows[${index}]`; + for (const field of REQUIRED_FLOW_FIELDS) { + if (!(field in flow)) { + addIssue(issues, "error", `${prefix}.${field}`, `缺少 ${field}。`); + } + } + + if (!isNonEmptyString(flow.id)) { + addIssue(issues, "error", `${prefix}.id`, "flow id 必须是非空字符串。"); + } else if (flowIds.has(flow.id)) { + addIssue(issues, "error", `${prefix}.id`, `重复 flow id: ${flow.id}`); + } else { + flowIds.add(flow.id); + } + + if (!isNonEmptyString(flow.scenarioId)) { + addIssue(issues, "error", `${prefix}.scenarioId`, "scenarioId 必须是非空字符串。"); + } else if (!scenarios.has(flow.scenarioId)) { + addIssue(issues, "error", `${prefix}.scenarioId`, `引用了不存在的 Agent QC scenario: ${flow.scenarioId}`); + } + + for (const field of ["preflight", "steps", "assertions", "evidenceRequired"]) { + validateStringArray(flow[field], `${prefix}.${field}`, issues); + } + }); + + const errors = issues.filter((issue) => issue.severity === "error"); + return { + valid: errors.length === 0, + issues, + }; +} + +function summarizeAgentQcGuiFlowManifest(flowManifest, validation) { + const flows = asArray(flowManifest?.flows); + const riskCounts = new Map(); + for (const flow of flows) { + const risk = flow.risk || "unknown"; + riskCounts.set(risk, (riskCounts.get(risk) ?? 0) + 1); + } + + return { + title: flowManifest?.title || "Lime Agent QC GUI Flow Manifest", + manifestVersion: flowManifest?.manifestVersion || "unknown", + valid: validation.valid, + issueCount: validation.issues.length, + errorCount: validation.issues.filter((issue) => issue.severity === "error").length, + warnCount: validation.issues.filter((issue) => issue.severity === "warn").length, + flowCount: flows.length, + risks: Object.fromEntries(Array.from(riskCounts.entries()).sort(([left], [right]) => left.localeCompare(right))), + flows: flows.map((flow) => ({ + id: flow.id, + scenarioId: flow.scenarioId, + risk: flow.risk, + surface: flow.surface, + stepCount: asArray(flow.steps).length, + assertionCount: asArray(flow.assertions).length, + evidenceCount: asArray(flow.evidenceRequired).length, + })), + issues: validation.issues, + }; +} + +function renderAgentQcGuiFlowMarkdown(summary) { + const flowLines = summary.flows.length + ? summary.flows + .map( + (flow) => + `- ${flow.id}: ${flow.scenarioId};${flow.risk};${flow.surface};steps ${flow.stepCount} / assertions ${flow.assertionCount} / evidence ${flow.evidenceCount}`, + ) + .join("\n") + : "- 无"; + const issueLines = summary.issues.length + ? summary.issues.map((issue) => `- ${issue.severity.toUpperCase()} ${issue.path}: ${issue.message}`).join("\n") + : "- 无"; + + return `# ${summary.title} + +## 结论 + +- Manifest: ${summary.manifestVersion} +- 状态: ${summary.valid ? "PASS" : "FAIL"} +- Flow 数: ${summary.flowCount} +- Issue: ${summary.issueCount}(error ${summary.errorCount} / warn ${summary.warnCount}) + +## Flow 清单 + +${flowLines} + +## 校验问题 + +${issueLines} +`; +} + +function createAgentQcGuiFlowReport({ flowManifest, scenarioManifest }) { + const validation = validateAgentQcGuiFlowManifest(flowManifest, scenarioManifest); + return summarizeAgentQcGuiFlowManifest(flowManifest, validation); +} + +export { + createAgentQcGuiFlowReport, + renderAgentQcGuiFlowMarkdown, + summarizeAgentQcGuiFlowManifest, + validateAgentQcGuiFlowManifest, +}; diff --git a/scripts/lib/agent-qc-gui-flow-core.test.ts b/scripts/lib/agent-qc-gui-flow-core.test.ts new file mode 100644 index 000000000..ac9416c5d --- /dev/null +++ b/scripts/lib/agent-qc-gui-flow-core.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { + createAgentQcGuiFlowReport, + renderAgentQcGuiFlowMarkdown, + validateAgentQcGuiFlowManifest, +} from "./agent-qc-gui-flow-core.mjs"; + +const scenarioManifest = { + scenarios: [ + { id: "claw-chat-ready-streaming" }, + { id: "workspace-ready-session-restore" }, + ], +}; + +const flowManifest = { + manifestVersion: "v1", + title: "GUI Flow Manifest", + flows: [ + { + id: "gui-claw-chat-ready-streaming", + scenarioId: "claw-chat-ready-streaming", + risk: "P0", + surface: "desktop_gui", + preflight: ["check bridge"], + steps: ["open", "send"], + assertions: ["ready"], + evidenceRequired: ["trace"], + }, + ], +}; + +describe("agent-qc-gui-flow-core", () => { + it("应接受引用现有 scenario 的 GUI flow manifest", () => { + const result = validateAgentQcGuiFlowManifest(flowManifest, scenarioManifest); + + expect(result.valid).toBe(true); + expect(result.issues).toEqual([]); + }); + + it("应拒绝引用不存在 scenario 的 flow", () => { + const invalid = structuredClone(flowManifest); + invalid.flows[0].scenarioId = "missing-scenario"; + + const result = validateAgentQcGuiFlowManifest(invalid, scenarioManifest); + + expect(result.valid).toBe(false); + expect(result.issues.map((issue) => issue.message).join("\n")).toContain( + "missing-scenario", + ); + }); + + it("应汇总 flow 数、步骤数和证据数", () => { + const report = createAgentQcGuiFlowReport({ flowManifest, scenarioManifest }); + + expect(report.valid).toBe(true); + expect(report.flowCount).toBe(1); + expect(report.flows[0].stepCount).toBe(2); + expect(report.flows[0].evidenceCount).toBe(1); + }); + + it("应渲染 Markdown 报告", () => { + const report = createAgentQcGuiFlowReport({ flowManifest, scenarioManifest }); + const markdown = renderAgentQcGuiFlowMarkdown(report); + + expect(markdown).toContain("GUI Flow Manifest"); + expect(markdown).toContain("gui-claw-chat-ready-streaming"); + }); +}); diff --git a/scripts/lib/agent-qc-gui-owner-core.mjs b/scripts/lib/agent-qc-gui-owner-core.mjs new file mode 100644 index 000000000..4928dbc5d --- /dev/null +++ b/scripts/lib/agent-qc-gui-owner-core.mjs @@ -0,0 +1,239 @@ +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function normalizeScenarioId(value) { + return String(value || "").trim(); +} + +function collectGuiScenarioIds(manifest) { + return asArray(manifest?.scenarios) + .filter((scenario) => + asArray(scenario?.evidenceRequired).includes("GUI session owner / isolation statement"), + ) + .map((scenario) => normalizeScenarioId(scenario?.id)) + .filter(Boolean); +} + +function summarizeActiveGuiItems(status, guiScenarioIds) { + const guiScenarioIdSet = new Set(guiScenarioIds); + return asArray(status?.items) + .filter((item) => guiScenarioIdSet.has(normalizeScenarioId(item?.scenarioId))) + .filter((item) => item?.terminal !== true) + .map((item) => ({ + scenarioId: normalizeScenarioId(item?.scenarioId), + qcloopStatus: item?.qcloopStatus || "unknown", + evidenceStatus: item?.evidenceStatus || "unknown", + stale: Boolean(item?.stale), + staleSeconds: item?.staleSeconds ?? null, + workerStatus: item?.worker?.status || "unknown", + workerDurationSeconds: item?.worker?.durationSeconds ?? null, + })); +} + +function createAgentQcGuiOwnerReport({ + manifest, + statusSidecars = [], + generatedAt = new Date().toISOString(), + maxActiveOwners = 0, +} = {}) { + const guiScenarioIds = collectGuiScenarioIds(manifest); + const sidecarsByJobId = new Map(); + for (const sidecar of asArray(statusSidecars)) { + const status = sidecar?.status || sidecar; + const jobId = status?.job?.id || sidecar?.path || "unknown"; + const entries = sidecarsByJobId.get(jobId) || []; + entries.push(sidecar); + sidecarsByJobId.set(jobId, entries); + } + + const activeOwnerByJobId = new Map(); + + for (const sidecars of sidecarsByJobId.values()) { + const sidecar = selectRepresentativeSidecar(sidecars); + const status = sidecar?.status || sidecar; + const activeItems = summarizeActiveGuiItems(status, guiScenarioIds); + if (activeItems.length === 0) { + continue; + } + + const jobId = status?.job?.id || sidecar?.path || "unknown"; + const existing = activeOwnerByJobId.get(jobId); + const owner = { + path: sidecar?.path || "", + jobId, + jobName: status?.job?.name || "", + jobStatus: status?.job?.status || "unknown", + verdictStatus: status?.verdict?.status || "unknown", + counts: status?.counts || null, + activeItems, + }; + + if (!existing) { + activeOwnerByJobId.set(jobId, owner); + continue; + } + + const existingStaleCount = existing.activeItems.filter((item) => item.stale).length; + const ownerStaleCount = owner.activeItems.filter((item) => item.stale).length; + if (ownerStaleCount > existingStaleCount) { + activeOwnerByJobId.set(jobId, owner); + } + } + + const activeOwners = Array.from(activeOwnerByJobId.values()).sort((left, right) => + left.jobId.localeCompare(right.jobId), + ); + const ownerCount = activeOwners.length; + const staleOwners = activeOwners.filter((owner) => + owner.activeItems.some((item) => item.stale), + ); + const staleOwnerCount = staleOwners.length; + const oldestStaleSeconds = Math.max( + 0, + ...activeOwners.flatMap((owner) => + owner.activeItems + .filter((item) => item.stale) + .map((item) => Number(item.staleSeconds || 0)), + ), + ); + const pass = ownerCount <= maxActiveOwners; + const ownerIntervention = + staleOwnerCount > 0 ? createOwnerIntervention(activeOwners) : null; + + return { + schemaVersion: "v1", + generatedAt, + maxActiveOwners, + guiScenarioIds, + ownerCount, + staleOwnerCount, + oldestStaleSeconds, + activeOwners, + ownerIntervention, + verdict: { + status: pass ? "pass" : "blocked", + summary: pass + ? `active GUI qcloop owner=${ownerCount},未超过上限 ${maxActiveOwners}。` + : `active GUI qcloop owner=${ownerCount},超过上限 ${maxActiveOwners}${staleOwnerCount > 0 ? `;其中 stale owner=${staleOwnerCount},最长 ${oldestStaleSeconds}s` : ""}。`, + nextAction: pass + ? "可以在其他 release gate 满足后启动新的 GUI P0 批次。" + : staleOwnerCount > 0 + ? "不要启动新的 GUI P0 批次;当前存在 stale GUI owner,只能继续只读观察,或由该 owner 明确确认后处理。" + : "不要启动新的 GUI P0 批次;等待现有 running 批次自然结束或由 owner 明确处理。", + }, + }; +} + +function createOwnerIntervention(activeOwners) { + const staleOwners = activeOwners.filter((owner) => + owner.activeItems.some((item) => item.stale), + ); + const primaryOwner = staleOwners[0]; + const primaryJobId = primaryOwner?.jobId || ""; + return { + status: "requires_owner_confirmation", + jobIds: staleOwners.map((owner) => owner.jobId), + requiredConfirmationText: `确认处理 stale GUI owner ${primaryJobId},可以终止 PID 并记录 sidecar。`, + prohibitedUntilConfirmed: [ + "kill / pause / interrupt stale worker", + "modify qcloop SQLite DB", + "start another full GUI P0 batch", + "overwrite .lime/qc/agent-qc-evidence.json", + "git commit / push / tag / release", + ], + evidenceRefs: staleOwners.map((owner) => owner.path).filter(Boolean), + nextAction: + "先刷新 qcloop status、GUI owner、DB lease 和进程证据;只有 owner 明确确认后才能处理 stale worker。", + }; +} + +function isTerminalJobStatus(status) { + const jobStatus = String(status?.job?.status || "").toLowerCase(); + return ( + status?.job?.terminal === true || + ["completed", "failed", "cancelled", "canceled"].includes(jobStatus) + ); +} + +function selectRepresentativeSidecar(sidecars) { + const terminal = asArray(sidecars).find((sidecar) => isTerminalJobStatus(sidecar?.status || sidecar)); + if (terminal) { + return terminal; + } + + return asArray(sidecars).reduce((selected, candidate) => { + if (!selected) { + return candidate; + } + const selectedStatus = selected?.status || selected; + const candidateStatus = candidate?.status || candidate; + const selectedStale = Number(selectedStatus?.counts?.stale || 0); + const candidateStale = Number(candidateStatus?.counts?.stale || 0); + if (candidateStale > selectedStale) { + return candidate; + } + const selectedRunning = Number(selectedStatus?.counts?.running || 0); + const candidateRunning = Number(candidateStatus?.counts?.running || 0); + return candidateRunning > selectedRunning ? candidate : selected; + }, null); +} + +function renderAgentQcGuiOwnerSummary(report) { + const lines = [ + `status=${report.verdict.status}`, + `activeOwners=${report.ownerCount}`, + `staleOwners=${report.staleOwnerCount ?? 0}`, + `oldestStaleSeconds=${report.oldestStaleSeconds ?? 0}`, + `maxActiveOwners=${report.maxActiveOwners}`, + `guiScenarios=${report.guiScenarioIds.join(",")}`, + `summary=${report.verdict.summary}`, + ]; + for (const owner of report.activeOwners) { + const items = owner.activeItems + .map((item) => + `${item.scenarioId}:${item.qcloopStatus}${item.stale ? `:stale=${item.staleSeconds ?? 0}s` : ""}`, + ) + .join(","); + lines.push(`owner=${owner.jobId} jobStatus=${owner.jobStatus} verdict=${owner.verdictStatus} path=${owner.path} items=${items}`); + } + return `${lines.join("\n")}\n`; +} + +function createAgentQcGuiOwnerWatchEntry(report) { + return { + schemaVersion: "v1", + observedAt: report?.generatedAt || new Date().toISOString(), + verdictStatus: report?.verdict?.status || "unknown", + ownerCount: report?.ownerCount ?? 0, + staleOwnerCount: report?.staleOwnerCount ?? 0, + oldestStaleSeconds: report?.oldestStaleSeconds ?? 0, + activeOwners: asArray(report?.activeOwners).map((owner) => ({ + jobId: owner.jobId, + jobStatus: owner.jobStatus, + verdictStatus: owner.verdictStatus, + path: owner.path, + activeItems: asArray(owner.activeItems).map((item) => ({ + scenarioId: item.scenarioId, + qcloopStatus: item.qcloopStatus, + evidenceStatus: item.evidenceStatus, + stale: Boolean(item.stale), + staleSeconds: item.staleSeconds ?? null, + workerStatus: item.workerStatus, + workerDurationSeconds: item.workerDurationSeconds ?? null, + })), + })), + }; +} + +export { + collectGuiScenarioIds, + createAgentQcGuiOwnerWatchEntry, + createAgentQcGuiOwnerReport, + createOwnerIntervention, + renderAgentQcGuiOwnerSummary, +}; diff --git a/scripts/lib/agent-qc-gui-owner-core.test.ts b/scripts/lib/agent-qc-gui-owner-core.test.ts new file mode 100644 index 000000000..61de46caf --- /dev/null +++ b/scripts/lib/agent-qc-gui-owner-core.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; + +import { + collectGuiScenarioIds, + createAgentQcGuiOwnerWatchEntry, + createAgentQcGuiOwnerReport, + renderAgentQcGuiOwnerSummary, +} from "./agent-qc-gui-owner-core.mjs"; + +const manifest = { + scenarios: [ + { + id: "claw-chat-ready-streaming", + evidenceRequired: ["GUI session owner / isolation statement"], + }, + { + id: "command-bridge-contract", + evidenceRequired: ["contract command log"], + }, + ], +}; + +describe("agent-qc-gui-owner-core", () => { + it("应从 manifest 中识别 GUI owner 场景", () => { + expect(collectGuiScenarioIds(manifest)).toEqual(["claw-chat-ready-streaming"]); + }); + + it("没有 active GUI sidecar 时应 pass", () => { + const report = createAgentQcGuiOwnerReport({ + manifest, + statusSidecars: [ + { + path: ".lime/qc/qcloop-status.contract.json", + status: { + job: { id: "job-1", status: "completed" }, + verdict: { status: "complete" }, + items: [ + { + scenarioId: "command-bridge-contract", + qcloopStatus: "success", + terminal: true, + }, + ], + }, + }, + ], + }); + + expect(report.verdict.status).toBe("pass"); + expect(report.ownerCount).toBe(0); + }); + + it("active GUI sidecar 超过上限时应 blocked", () => { + const report = createAgentQcGuiOwnerReport({ + manifest, + statusSidecars: [ + { + path: ".lime/qc/qcloop-status.gui.json", + status: { + job: { id: "job-gui", status: "running" }, + verdict: { status: "stale" }, + counts: { running: 1, stale: 1 }, + items: [ + { + scenarioId: "claw-chat-ready-streaming", + qcloopStatus: "running", + evidenceStatus: "blocked", + terminal: false, + stale: true, + staleSeconds: 600, + worker: { status: "running", durationSeconds: 600 }, + }, + ], + }, + }, + ], + maxActiveOwners: 0, + }); + + expect(report.verdict.status).toBe("blocked"); + expect(report.ownerCount).toBe(1); + expect(report.staleOwnerCount).toBe(1); + expect(report.oldestStaleSeconds).toBe(600); + expect(report.verdict.nextAction).toContain("只读观察"); + expect(report.ownerIntervention?.status).toBe("requires_owner_confirmation"); + expect(report.ownerIntervention?.requiredConfirmationText).toContain("job-gui"); + expect(report.ownerIntervention?.prohibitedUntilConfirmed).toContain( + "start another full GUI P0 batch", + ); + expect(report.activeOwners[0].activeItems[0].scenarioId).toBe( + "claw-chat-ready-streaming", + ); + }); + + it("同一 job 的多个 sidecar 应去重", () => { + const sidecar = { + job: { id: "job-gui", status: "running" }, + verdict: { status: "running" }, + items: [ + { + scenarioId: "claw-chat-ready-streaming", + qcloopStatus: "running", + terminal: false, + }, + ], + }; + + const report = createAgentQcGuiOwnerReport({ + manifest, + statusSidecars: [ + { path: ".lime/qc/qcloop-status.gui-current.json", status: sidecar }, + { path: ".lime/qc/qcloop-status.gui-stale.json", status: sidecar }, + ], + }); + + expect(report.ownerCount).toBe(1); + }); + + it("同一 job 存在终态 sidecar 时应忽略旧 running sidecar", () => { + const runningSidecar = { + job: { id: "job-gui", status: "running", terminal: false }, + verdict: { status: "running" }, + items: [ + { + scenarioId: "claw-chat-ready-streaming", + qcloopStatus: "running", + terminal: false, + }, + ], + }; + const terminalSidecar = { + job: { id: "job-gui", status: "failed", terminal: true }, + verdict: { status: "fail" }, + items: [ + { + scenarioId: "claw-chat-ready-streaming", + qcloopStatus: "exhausted", + terminal: true, + }, + ], + }; + + const report = createAgentQcGuiOwnerReport({ + manifest, + statusSidecars: [ + { path: ".lime/qc/qcloop-status.gui-current.json", status: runningSidecar }, + { path: ".lime/qc/qcloop-status.gui-completed.json", status: terminalSidecar }, + ], + }); + + expect(report.ownerCount).toBe(0); + expect(report.ownerIntervention).toBeNull(); + expect(report.verdict.status).toBe("pass"); + }); + + it("应渲染 summary", () => { + const report = createAgentQcGuiOwnerReport({ manifest, statusSidecars: [] }); + const summary = renderAgentQcGuiOwnerSummary(report); + + expect(summary).toContain("status=pass"); + expect(summary).toContain("staleOwners=0"); + expect(summary).toContain("guiScenarios=claw-chat-ready-streaming"); + }); + + it("应生成 watch history JSONL entry", () => { + const report = createAgentQcGuiOwnerReport({ + manifest, + generatedAt: "2026-05-10T00:00:00.000Z", + statusSidecars: [ + { + path: ".lime/qc/qcloop-status.gui.json", + status: { + job: { id: "job-gui", status: "running" }, + verdict: { status: "stale" }, + items: [ + { + scenarioId: "claw-chat-ready-streaming", + qcloopStatus: "running", + evidenceStatus: "blocked", + terminal: false, + stale: true, + staleSeconds: 900, + worker: { status: "running", durationSeconds: 900 }, + }, + ], + }, + }, + ], + }); + + const entry = createAgentQcGuiOwnerWatchEntry(report); + + expect(entry.observedAt).toBe("2026-05-10T00:00:00.000Z"); + expect(entry.verdictStatus).toBe("blocked"); + expect(entry.activeOwners[0].jobId).toBe("job-gui"); + expect(entry.activeOwners[0].activeItems[0].staleSeconds).toBe(900); + }); +}); diff --git a/scripts/lib/agent-qc-qcloop-job-core.mjs b/scripts/lib/agent-qc-qcloop-job-core.mjs new file mode 100644 index 000000000..70f37955e --- /dev/null +++ b/scripts/lib/agent-qc-qcloop-job-core.mjs @@ -0,0 +1,273 @@ +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function normalizeRiskList(risks) { + return asArray(risks) + .map((risk) => String(risk).trim().toUpperCase()) + .filter(Boolean); +} + +function selectScenarios(manifest, { risks = ["P0"], scenarioIds = [], includeAll = false } = {}) { + const normalizedRisks = new Set(normalizeRiskList(risks)); + const normalizedScenarioIds = new Set(asArray(scenarioIds).filter(isNonEmptyString)); + const scenarios = asArray(manifest?.scenarios); + + return scenarios.filter((scenario) => { + if (includeAll) { + return true; + } + if (normalizedScenarioIds.size > 0) { + return normalizedScenarioIds.has(scenario.id); + } + return normalizedRisks.has(String(scenario.risk || "").toUpperCase()); + }); +} + +const WORKER_RESULT_MARKER = "QCLOOP_WORKER_RESULT="; +const WORKER_EVIDENCE_SUMMARY_MARKER = "QCLOOP_EVIDENCE_SUMMARY_JSON="; + +const WORKER_EVIDENCE_CONTRACT_BLOCK = ` + +--- +Agent QC 结构化证据输出契约: +- 最终 stdout 必须包含单行 \`QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED\`。 +- 最终 stdout 必须包含单行 \`QCLOOP_EVIDENCE_SUMMARY_JSON=\`;\`\` 必须是单个 JSON object,不要 Markdown / code fence。 +- \`QCLOOP_EVIDENCE_SUMMARY_JSON\` 至少包含:scenario_id、result、commands[]、evidence_required[]、evidence_layers_covered[]、failure_modes[]、artifacts[]、blockers[]、gui_session_owner、release_scope。 +- evidence_required[] 每项包含 name、status(pass|fail|blocked|missing)、evidence、artifact_path;failure_modes[] 每项包含 name、status(covered|excluded|hit|unknown)、evidence。 +- 不得输出密钥、token、用户私密内容或未经脱敏的请求 / 响应正文。`; + +const VERIFIER_JSON_CONTRACT_BLOCK = ` + +--- +qcloop verifier 输出格式硬约束: +- 只输出一个合法 JSON object,不要 Markdown、不要代码块、不要前后缀文本。 +- JSON 必须至少包含 {"pass": true|false, "feedback": "..."}。 +- 可选字段 missingEvidence、nextAction、evidenceStatus、scenarioId 必须仍在同一个 JSON object 内。 +- 如果 worker stdout 缺少 QCLOOP_EVIDENCE_SUMMARY_JSON=,或该 JSON 无法逐项证明 evidenceRequired / failureModes,必须输出 {"pass": false, "feedback": "..."}。`; + +function ensureWorkerEvidenceContract(template) { + const normalized = isNonEmptyString(template) + ? template + : "在 Lime 仓库中执行 Agent QC 场景 {{item}},收集证据并给出结论。"; + if ( + normalized.includes(WORKER_RESULT_MARKER) && + normalized.includes(WORKER_EVIDENCE_SUMMARY_MARKER) + ) { + return normalized; + } + return `${normalized}${WORKER_EVIDENCE_CONTRACT_BLOCK}`; +} + +const VERIFIER_EVIDENCE_BLOCK = ` + +--- +qcloop worker 执行证据(自动占位符替换): +- attempt_status: {{attempt_status}} +- attempt_type: {{attempt_type}} +- exit_code: {{exit_code}} + +worker stdout: +{{stdout}} + +qcloop issue ledger: +{{issue_ledger}} + +判定要求:如果 stdout 缺少场景 verifier / evidence_required / failure_modes 所需证据,必须 pass=false;不能因为命令名看似正确就通过。`; + +function ensureVerifierMetadataPlaceholders(template) { + const missingLines = []; + if (!template.includes("{{attempt_status}}")) { + missingLines.push("- attempt_status: {{attempt_status}}"); + } + if (!template.includes("{{attempt_type}}")) { + missingLines.push("- attempt_type: {{attempt_type}}"); + } + if (!template.includes("{{exit_code}}")) { + missingLines.push("- exit_code: {{exit_code}}"); + } + if (!template.includes("{{qc_history}}") && !template.includes("{{issue_ledger}}")) { + missingLines.push("- issue_ledger: {{issue_ledger}}"); + } + if (missingLines.length === 0) { + return template; + } + return `${template} + +--- +qcloop worker 执行元数据(自动占位符替换): +${missingLines.join("\n")}`; +} + +function ensureVerifierEvidencePlaceholders(template) { + let normalized = isNonEmptyString(template) + ? template + : "审查 Agent QC 场景 {{item}} 的输出是否满足证据要求,只输出 JSON verdict。"; + + if (!normalized.includes("{{stdout}}") && !normalized.includes("{{output}}")) { + normalized = `${normalized}${VERIFIER_EVIDENCE_BLOCK}`; + } + + normalized = ensureVerifierMetadataPlaceholders(normalized); + + if (!normalized.includes("QCLOOP_EVIDENCE_SUMMARY_JSON")) { + normalized = `${normalized}${VERIFIER_JSON_CONTRACT_BLOCK}`; + } else if (!normalized.includes('{"pass": true|false')) { + normalized = `${normalized}${VERIFIER_JSON_CONTRACT_BLOCK}`; + } + + return normalized; +} + +function buildQCLoopItemValue(scenario, { cwd = "" } = {}) { + return JSON.stringify({ + scenario_id: scenario.id, + title: scenario.title, + risk: scenario.risk, + cwd, + executor: scenario.executor, + lanes: scenario.lanes, + commands: scenario.commands || [], + goal: scenario.goal, + verifier: scenario.verifier, + evidence_required: scenario.evidenceRequired || [], + evidence_layers: scenario.evidenceLayers || [], + failure_modes: scenario.failureModes || [], + }); +} + +function buildQCLoopJobPayload(manifest, options = {}) { + const selectedScenarios = selectScenarios(manifest, options); + const qcloop = manifest?.qcloop || {}; + const riskLabel = options.includeAll + ? "all" + : asArray(options.scenarioIds).length > 0 + ? "selected" + : normalizeRiskList(options.risks || ["P0"]).join("+") || "P0"; + const name = + options.name || + `lime-agent-qc-${riskLabel.toLowerCase()}-${new Date(options.generatedAt || Date.now()).toISOString().slice(0, 10)}`; + + const cwdLine = options.cwd + ? `目标仓库 cwd: ${options.cwd}。执行任何命令前必须先切换到该目录,并在输出中记录 pwd。` + : ""; + const workerPromptTemplate = ensureWorkerEvidenceContract( + options.promptTemplate || + qcloop.workerPromptTemplate || + "在 Lime 仓库中执行 Agent QC 场景 {{item}},收集证据并给出结论。", + ); + + return { + name, + prompt_template: [cwdLine, workerPromptTemplate].filter(Boolean).join("\n\n"), + verifier_prompt_template: ensureVerifierEvidencePlaceholders( + options.verifierPromptTemplate || qcloop.verifierPromptTemplate, + ), + max_qc_rounds: Number(options.maxQcRounds || qcloop.recommendedMaxQcRounds || 3), + max_executor_retries: Number( + options.maxExecutorRetries ?? qcloop.recommendedMaxExecutorRetries ?? 1, + ), + token_budget_per_item: Number(options.tokenBudgetPerItem || 0), + execution_mode: options.executionMode || "standard", + executor_provider: options.executorProvider || "codex", + items: selectedScenarios.map((scenario) => + buildQCLoopItemValue(scenario, { cwd: options.cwd || "" }), + ), + }; +} + + +function validateVerifierPromptTemplate(template, issues) { + if (!isNonEmptyString(template)) { + issues.push("缺少 verifier_prompt_template。"); + return; + } + if (!template.includes("{{stdout}}") && !template.includes("{{output}}")) { + issues.push("verifier_prompt_template 必须包含 {{stdout}} 或 {{output}},否则 verifier 看不到 worker 输出。"); + } + for (const placeholder of ["{{attempt_status}}", "{{exit_code}}"]) { + if (!template.includes(placeholder)) { + issues.push(`verifier_prompt_template 必须包含 ${placeholder}。`); + } + } + if (!template.includes("{{qc_history}}") && !template.includes("{{issue_ledger}}")) { + issues.push("verifier_prompt_template 必须包含 {{qc_history}} 或 {{issue_ledger}}。"); + } + if (!template.includes(WORKER_EVIDENCE_SUMMARY_MARKER)) { + issues.push(`verifier_prompt_template 必须要求审查 ${WORKER_EVIDENCE_SUMMARY_MARKER}。`); + } + if (!template.includes('{"pass": true|false')) { + issues.push('verifier_prompt_template 必须声明只输出 {"pass": true|false, "feedback": "..."} JSON。'); + } +} + +function validateQCLoopJobPayload(payload) { + const issues = []; + if (!isNonEmptyString(payload?.name)) { + issues.push("缺少 name。 "); + } + if (!isNonEmptyString(payload?.prompt_template)) { + issues.push("缺少 prompt_template。 "); + } else { + if (!payload.prompt_template.includes("{{item}}")) { + issues.push("prompt_template 必须包含 {{item}}。 "); + } + if (!payload.prompt_template.includes(WORKER_RESULT_MARKER)) { + issues.push(`prompt_template 必须要求输出 ${WORKER_RESULT_MARKER}。 `); + } + if (!payload.prompt_template.includes(WORKER_EVIDENCE_SUMMARY_MARKER)) { + issues.push(`prompt_template 必须要求输出 ${WORKER_EVIDENCE_SUMMARY_MARKER}。 `); + } + } + validateVerifierPromptTemplate(payload?.verifier_prompt_template, issues); + if (!Number.isInteger(payload?.max_qc_rounds) || payload.max_qc_rounds <= 0) { + issues.push("max_qc_rounds 必须是正整数。 "); + } + if ( + !Number.isInteger(payload?.max_executor_retries) || + payload.max_executor_retries < 0 || + payload.max_executor_retries > 5 + ) { + issues.push("max_executor_retries 必须是 0 到 5 的整数。 "); + } + if (!Array.isArray(payload?.items) || payload.items.length === 0) { + issues.push("items 不能为空。 "); + } + for (const [index, item] of asArray(payload?.items).entries()) { + if (!isNonEmptyString(item)) { + issues.push(`items[${index}] 不能为空。`); + continue; + } + try { + const parsed = JSON.parse(item); + if (!isNonEmptyString(parsed.scenario_id)) { + issues.push(`items[${index}] 缺少 scenario_id。`); + } + } catch { + issues.push(`items[${index}] 必须是 JSON 字符串。`); + } + } + return { + valid: issues.length === 0, + issues, + }; +} + +function renderQCLoopCurl(payload, { baseUrl = "http://127.0.0.1:8080" } = {}) { + const body = JSON.stringify(payload, null, 2); + return `curl -sS -X POST "${baseUrl.replace(/\/$/, "")}/api/jobs" \\ + -H "Content-Type: application/json" \\ + --data-binary @- <<'JSON'\n${body}\nJSON\n`; +} + +export { + buildQCLoopItemValue, + buildQCLoopJobPayload, + renderQCLoopCurl, + selectScenarios, + validateQCLoopJobPayload, +}; diff --git a/scripts/lib/agent-qc-qcloop-job-core.test.ts b/scripts/lib/agent-qc-qcloop-job-core.test.ts new file mode 100644 index 000000000..bc6e1f2eb --- /dev/null +++ b/scripts/lib/agent-qc-qcloop-job-core.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; + +import { + buildQCLoopJobPayload, + renderQCLoopCurl, + selectScenarios, + validateQCLoopJobPayload, +} from "./agent-qc-qcloop-job-core.mjs"; + +const manifest = { + qcloop: { + recommendedMaxQcRounds: 3, + workerPromptTemplate: "worker {{item}}", + verifierPromptTemplate: "verifier {{item}}", + }, + scenarios: [ + { + id: "command-bridge-contract", + title: "命令桥接四侧一致", + risk: "P0", + executor: "npm_command", + lanes: ["L1-contract-bridge"], + commands: ["npm run test:contracts"], + goal: "goal", + verifier: "verifier", + evidenceRequired: ["evidence"], + evidenceLayers: ["deterministic-smoke"], + failureModes: ["failure"], + }, + { + id: "knowledge-ingest-retrieve-summarize", + title: "Knowledge", + risk: "P1", + executor: "mixed", + lanes: ["L3-product-surface"], + goal: "goal", + verifier: "verifier", + evidenceRequired: ["evidence"], + failureModes: ["failure"], + }, + ], +}; + +describe("agent-qc-qcloop-job-core", () => { + it("默认应选择 P0 场景", () => { + const scenarios = selectScenarios(manifest); + + expect(scenarios.map((scenario) => scenario.id)).toEqual(["command-bridge-contract"]); + }); + + it("应按风险等级生成 qcloop job payload", () => { + const payload = buildQCLoopJobPayload(manifest, { + risks: ["P1"], + cwd: "/workspace/lime", + generatedAt: "2026-05-10T00:00:00.000Z", + maxExecutorRetries: 0, + }); + + expect(payload.name).toBe("lime-agent-qc-p1-2026-05-10"); + expect(payload.prompt_template).toContain("目标仓库 cwd: /workspace/lime"); + expect(payload.prompt_template).toContain("QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED"); + expect(payload.prompt_template).toContain("QCLOOP_EVIDENCE_SUMMARY_JSON="); + expect(payload.items).toHaveLength(1); + expect(payload.max_executor_retries).toBe(0); + expect(payload.verifier_prompt_template).toContain("{{stdout}}"); + expect(payload.verifier_prompt_template).toContain("{{exit_code}}"); + expect(payload.verifier_prompt_template).toContain("{{issue_ledger}}"); + expect(payload.verifier_prompt_template).toContain("QCLOOP_EVIDENCE_SUMMARY_JSON"); + expect(payload.verifier_prompt_template).toContain('{"pass": true|false'); + expect(JSON.parse(payload.items[0]).scenario_id).toBe( + "knowledge-ingest-retrieve-summarize", + ); + expect(JSON.parse(payload.items[0]).cwd).toBe("/workspace/lime"); + expect(validateQCLoopJobPayload(payload).valid).toBe(true); + }); + + it("应把 evidenceLayers 带入 qcloop item,方便 worker 声明证据深度", () => { + const payload = buildQCLoopJobPayload(manifest, { + scenarioIds: ["command-bridge-contract"], + }); + + expect(JSON.parse(payload.items[0]).evidence_layers).toEqual([ + "deterministic-smoke", + ]); + }); + + it("应支持按 scenario id 精确选择", () => { + const payload = buildQCLoopJobPayload(manifest, { + scenarioIds: ["command-bridge-contract"], + name: "selected-job", + }); + + expect(payload.name).toBe("selected-job"); + expect(payload.items).toHaveLength(1); + expect(JSON.parse(payload.items[0]).scenario_id).toBe("command-bridge-contract"); + }); + + it("validateQCLoopJobPayload 应阻止缺少 verifier stdout 占位符的 payload", () => { + const payload = buildQCLoopJobPayload(manifest); + payload.verifier_prompt_template = "verifier {{item}}"; + + const validation = validateQCLoopJobPayload(payload); + + expect(validation.valid).toBe(false); + expect(validation.issues.join("\n")).toContain("{{stdout}}"); + }); + + it("validateQCLoopJobPayload 应阻止缺少结构化证据契约的 payload", () => { + const payload = buildQCLoopJobPayload(manifest); + payload.prompt_template = "worker {{item}}"; + payload.verifier_prompt_template = + "verifier {{item}} status={{attempt_status}} code={{exit_code}} stdout={{stdout}} ledger={{issue_ledger}}"; + + const validation = validateQCLoopJobPayload(payload); + + expect(validation.valid).toBe(false); + expect(validation.issues.join("\n")).toContain("QCLOOP_EVIDENCE_SUMMARY_JSON="); + }); + + it("validateQCLoopJobPayload 应阻止非法 max_executor_retries", () => { + const payload = buildQCLoopJobPayload(manifest); + payload.max_executor_retries = 6; + + const validation = validateQCLoopJobPayload(payload); + + expect(validation.valid).toBe(false); + expect(validation.issues.join("\n")).toContain("max_executor_retries"); + }); + + it("verifier_prompt_template 不应重复追加已有 stdout 占位符的模板", () => { + const payload = buildQCLoopJobPayload( + { + ...manifest, + qcloop: { + ...manifest.qcloop, + verifierPromptTemplate: "verifier {{item}} stdout={{stdout}}", + }, + }, + { scenarioIds: ["command-bridge-contract"] }, + ); + + expect(payload.verifier_prompt_template.match(/{{stdout}}/g)).toHaveLength(1); + }); + + it("应生成可复制的 curl 命令", () => { + const payload = buildQCLoopJobPayload(manifest); + const curl = renderQCLoopCurl(payload, { baseUrl: "http://localhost:8080" }); + + expect(curl).toContain("POST \"http://localhost:8080/api/jobs\""); + expect(curl).toContain("command-bridge-contract"); + }); + + it("默认 curl 应使用 IPv4 loopback,避免 localhost 代理或 IPv6 解析干扰", () => { + const payload = buildQCLoopJobPayload(manifest); + const curl = renderQCLoopCurl(payload); + + expect(curl).toContain("POST \"http://127.0.0.1:8080/api/jobs\""); + }); +}); diff --git a/scripts/lib/agent-qc-qcloop-preflight-core.mjs b/scripts/lib/agent-qc-qcloop-preflight-core.mjs new file mode 100644 index 000000000..03a3c083f --- /dev/null +++ b/scripts/lib/agent-qc-qcloop-preflight-core.mjs @@ -0,0 +1,69 @@ +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function normalizePathText(value) { + return isNonEmptyString(value) ? value.trim().replace(/\\/g, "/") : ""; +} + +function createCheck(id, title, passed, detail = "") { + return { + id, + title, + passed: Boolean(passed), + detail, + }; +} + +function buildQCLoopPreflightReport({ cwd, expectedCwd = "", tmpWritable = false, devBridge = null } = {}) { + const checks = []; + const normalizedCwd = normalizePathText(cwd); + const normalizedExpectedCwd = normalizePathText(expectedCwd); + + checks.push(createCheck("cwd-present", "当前工作目录可读", Boolean(normalizedCwd), normalizedCwd)); + if (normalizedExpectedCwd) { + checks.push( + createCheck( + "cwd-expected", + "当前工作目录匹配预期仓库", + normalizedCwd === normalizedExpectedCwd, + `cwd=${normalizedCwd || "unknown"} expected=${normalizedExpectedCwd}`, + ), + ); + } + checks.push( + createCheck( + "tmp-writable", + "临时目录可写", + tmpWritable === true, + tmpWritable ? "tmp write ok" : "tmp write failed", + ), + ); + + if (devBridge) { + checks.push( + createCheck( + "devbridge-health", + "DevBridge health 可访问", + devBridge.ok === true, + devBridge.ok + ? `status=${devBridge.status || "ok"}` + : `url=${devBridge.url || "unknown"} error=${devBridge.error || "unknown"}`, + ), + ); + } + + const failed = checks.filter((check) => !check.passed); + return { + schemaVersion: "v1", + status: failed.length === 0 ? "pass" : "blocked", + checks, + failedChecks: failed.map((check) => check.id), + summary: + failed.length === 0 + ? "qcloop worker preflight 通过。" + : `qcloop worker preflight 阻断:${failed.map((check) => check.id).join(", ")}。`, + }; +} + +export { buildQCLoopPreflightReport, createCheck }; diff --git a/scripts/lib/agent-qc-qcloop-preflight-core.test.ts b/scripts/lib/agent-qc-qcloop-preflight-core.test.ts new file mode 100644 index 000000000..ac69f28ed --- /dev/null +++ b/scripts/lib/agent-qc-qcloop-preflight-core.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { buildQCLoopPreflightReport } from "./agent-qc-qcloop-preflight-core.mjs"; + +describe("agent-qc-qcloop-preflight-core", () => { + it("基础环境通过时应返回 pass", () => { + const report = buildQCLoopPreflightReport({ + cwd: "/repo/lime", + expectedCwd: "/repo/lime", + tmpWritable: true, + }); + + expect(report.status).toBe("pass"); + expect(report.failedChecks).toEqual([]); + }); + + it("cwd 不匹配时应返回 blocked", () => { + const report = buildQCLoopPreflightReport({ + cwd: "/repo/qcloop", + expectedCwd: "/repo/lime", + tmpWritable: true, + }); + + expect(report.status).toBe("blocked"); + expect(report.failedChecks).toContain("cwd-expected"); + }); + + it("DevBridge 不可访问时应返回 blocked", () => { + const report = buildQCLoopPreflightReport({ + cwd: "/repo/lime", + tmpWritable: true, + devBridge: { + ok: false, + url: "http://127.0.0.1:3030/health", + error: "TypeError: fetch failed", + }, + }); + + expect(report.status).toBe("blocked"); + expect(report.failedChecks).toContain("devbridge-health"); + expect(report.checks.find((check) => check.id === "devbridge-health")?.detail).toContain( + "fetch failed", + ); + }); +}); diff --git a/scripts/lib/agent-qc-qcloop-status-core.mjs b/scripts/lib/agent-qc-qcloop-status-core.mjs new file mode 100644 index 000000000..b92783d97 --- /dev/null +++ b/scripts/lib/agent-qc-qcloop-status-core.mjs @@ -0,0 +1,289 @@ +import { mapQCLoopItemStatus, parseScenarioId } from "./agent-qc-evidence-core.mjs"; + +const TERMINAL_ITEM_STATUSES = new Set(["success", "failed", "exhausted"]); +const TERMINAL_JOB_STATUSES = new Set(["completed", "failed", "canceled", "cancelled"]); + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function normalizeStatus(value) { + return isNonEmptyString(value) ? value.trim().toLowerCase() : "unknown"; +} + +function parseTimestampMs(value) { + if (!isNonEmptyString(value)) { + return null; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : null; +} + +function stringLength(value) { + return typeof value === "string" ? value.length : 0; +} + +function durationMinutes(startedAt, finishedAt, nowMs) { + const startedMs = parseTimestampMs(startedAt); + if (startedMs === null) { + return null; + } + const finishedMs = parseTimestampMs(finishedAt) ?? nowMs; + return Math.max(0, Math.round((finishedMs - startedMs) / 60000)); +} + +function durationSeconds(startedAt, finishedAt, nowMs) { + const startedMs = parseTimestampMs(startedAt); + if (startedMs === null) { + return null; + } + const finishedMs = parseTimestampMs(finishedAt) ?? nowMs; + return Math.max(0, Math.round((finishedMs - startedMs) / 1000)); +} + +function latestEntry(entries) { + const normalizedEntries = asArray(entries).filter(Boolean); + return normalizedEntries.length > 0 ? normalizedEntries[normalizedEntries.length - 1] : null; +} + +function summarizeLatestWorker(item, nowMs) { + const latestAttempt = latestEntry(item?.attempts); + if (!latestAttempt) { + return { + status: "unknown", + exitCode: null, + startedAt: null, + finishedAt: null, + durationMinutes: null, + durationSeconds: null, + stdoutLength: 0, + stderrLength: 0, + outputLength: 0, + }; + } + + const stdoutLength = stringLength(latestAttempt.stdout); + const stderrLength = stringLength(latestAttempt.stderr); + return { + status: normalizeStatus(latestAttempt.status), + exitCode: latestAttempt.exit_code ?? null, + startedAt: latestAttempt.started_at ?? null, + finishedAt: latestAttempt.finished_at ?? null, + durationMinutes: durationMinutes(latestAttempt.started_at, latestAttempt.finished_at, nowMs), + durationSeconds: durationSeconds(latestAttempt.started_at, latestAttempt.finished_at, nowMs), + stdoutLength, + stderrLength, + outputLength: stdoutLength + stderrLength, + }; +} + +function summarizeLatestQc(item) { + const latestQc = latestEntry(item?.qc_rounds); + if (!latestQc) { + return { + status: "unknown", + feedback: "", + }; + } + return { + status: normalizeStatus(latestQc.status), + feedback: latestQc.feedback || "", + }; +} + +function classifyItemStaleness(item, worker, { staleMinutes = 30 } = {}) { + const itemStatus = normalizeStatus(item?.status); + const workerStatus = worker.status; + const isRunning = itemStatus === "running" || workerStatus === "running"; + const isTerminal = TERMINAL_ITEM_STATUSES.has(itemStatus); + const reasons = []; + + if (!isRunning || isTerminal || staleMinutes <= 0) { + return { stale: false, reasons }; + } + + if (worker.startedAt === null) { + reasons.push("running item 缺少 worker started_at,无法判断进度"); + } + + if (worker.durationMinutes !== null && worker.durationMinutes >= staleMinutes && worker.outputLength === 0) { + reasons.push(`worker 运行 ${worker.durationMinutes} 分钟且 stdout/stderr 为空`); + } + + return { + stale: reasons.length > 0, + reasons, + }; +} + +function summarizeItem(item, options = {}) { + const nowMs = options.nowMs ?? Date.now(); + const worker = summarizeLatestWorker(item, nowMs); + const qc = summarizeLatestQc(item); + const itemStatus = normalizeStatus(item?.status); + const terminal = TERMINAL_ITEM_STATUSES.has(itemStatus); + const staleness = classifyItemStaleness(item, worker, options); + + return { + scenarioId: parseScenarioId(item?.item_value), + itemId: item?.id || "", + qcloopStatus: itemStatus, + evidenceStatus: mapQCLoopItemStatus(item?.status, item), + terminal, + stale: staleness.stale, + staleReasons: staleness.reasons, + staleSeconds: staleness.stale ? worker.durationSeconds : null, + currentAttemptNo: item?.current_attempt_no ?? 0, + currentQcNo: item?.current_qc_no ?? 0, + worker, + qc, + }; +} + +function countByStatus(items) { + const counts = { + total: items.length, + success: 0, + failed: 0, + exhausted: 0, + running: 0, + pending: 0, + unknown: 0, + terminal: 0, + nonTerminal: 0, + stale: 0, + }; + + for (const item of items) { + const status = item.qcloopStatus; + if (status in counts) { + counts[status] += 1; + } else { + counts.unknown += 1; + } + if (item.terminal) { + counts.terminal += 1; + } else { + counts.nonTerminal += 1; + } + if (item.stale) { + counts.stale += 1; + } + } + + return counts; +} + +function buildVerdict(job, items, counts) { + const jobStatus = normalizeStatus(job?.status); + const terminalProblemItems = items.filter((item) => item.qcloopStatus === "failed" || item.qcloopStatus === "exhausted"); + const terminalBlockedItems = terminalProblemItems.filter((item) => item.evidenceStatus === "blocked"); + const terminalFailedItems = terminalProblemItems.filter((item) => item.evidenceStatus === "fail"); + if (counts.stale > 0) { + return { + status: "stale", + summary: `qcloop job ${job?.id || "unknown"} 仍在运行,${counts.stale} 个 item 疑似无进度。`, + nextAction: "不要中断进程;先导出 sidecar、记录卡点,等当前 worker 结束后再提交修正后的重跑 payload。", + }; + } + if (counts.running > 0 || counts.pending > 0 || !TERMINAL_JOB_STATUSES.has(jobStatus)) { + return { + status: "running", + summary: `qcloop job ${job?.id || "unknown"} 尚未进入终态,running=${counts.running} pending=${counts.pending}。`, + nextAction: "继续观察 job/items;如果需要证据,只导出 sidecar,不覆盖官方 Evidence Pack。", + }; + } + if (terminalFailedItems.length > 0 || (jobStatus === "failed" && terminalBlockedItems.length === 0)) { + return { + status: "fail", + summary: `qcloop job ${job?.id || "unknown"} 已终止但仍有 failed/exhausted item。`, + nextAction: "修复失败项或补足 verifier 可审查证据后,发起新的 qcloop 批次。", + }; + } + if (terminalBlockedItems.length > 0) { + return { + status: "blocked", + summary: `qcloop job ${job?.id || "unknown"} 已终止但 ${terminalBlockedItems.length} 个 item 明确报告 worker 环境阻断。`, + nextAction: "先修复 qcloop worker 环境或权限,再用新批次重跑被阻断场景;不要覆盖官方 Evidence Pack。", + }; + } + if (items.length > 0 && counts.success === items.length && jobStatus === "completed") { + return { + status: "complete", + summary: `qcloop job ${job?.id || "unknown"} 已完成,全部 item success。`, + nextAction: "可导出官方 Evidence Pack,并运行 release summary 与 completion audit。", + }; + } + return { + status: "needs-human-review", + summary: `qcloop job ${job?.id || "unknown"} 状态无法自动归类。`, + nextAction: "人工审查 qcloop job/items 原始 JSON,再决定是否重跑或补 exporter 规则。", + }; +} + +function buildQCLoopStatusReport({ job, items, options = {} }) { + const generatedAt = options.generatedAt || new Date().toISOString(); + const nowMs = parseTimestampMs(generatedAt) ?? Date.now(); + const parsedStaleMinutes = Number(options.staleMinutes ?? 30); + const staleMinutes = Number.isFinite(parsedStaleMinutes) ? parsedStaleMinutes : 30; + const itemSummaries = asArray(items).map((item) => + summarizeItem(item, { + nowMs, + staleMinutes, + }), + ); + const counts = countByStatus(itemSummaries); + const verdict = buildVerdict(job, itemSummaries, counts); + + return { + schemaVersion: "v1", + generatedAt, + staleMinutes, + job: { + id: job?.id || "unknown", + name: job?.name || "", + status: normalizeStatus(job?.status), + createdAt: job?.created_at || null, + finishedAt: job?.finished_at || null, + terminal: TERMINAL_JOB_STATUSES.has(normalizeStatus(job?.status)), + }, + counts, + items: itemSummaries, + verdict, + }; +} + +function validateQCLoopStatusReport(report) { + const issues = []; + if (report?.schemaVersion !== "v1") { + issues.push("schemaVersion 必须是 v1。"); + } + if (!report?.job || !isNonEmptyString(report.job.id)) { + issues.push("缺少 job.id。"); + } + if (!Array.isArray(report?.items)) { + issues.push("items 必须是数组。"); + } + if (!report?.counts || typeof report.counts.total !== "number") { + issues.push("缺少 counts.total。"); + } + if (!report?.verdict || !isNonEmptyString(report.verdict.status)) { + issues.push("缺少 verdict.status。"); + } + return { + valid: issues.length === 0, + issues, + }; +} + +export { + buildQCLoopStatusReport, + classifyItemStaleness, + normalizeStatus, + summarizeItem, + validateQCLoopStatusReport, +}; diff --git a/scripts/lib/agent-qc-qcloop-status-core.test.ts b/scripts/lib/agent-qc-qcloop-status-core.test.ts new file mode 100644 index 000000000..2efda88bb --- /dev/null +++ b/scripts/lib/agent-qc-qcloop-status-core.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; + +import { + buildQCLoopStatusReport, + classifyItemStaleness, + validateQCLoopStatusReport, +} from "./agent-qc-qcloop-status-core.mjs"; + +const generatedAt = "2026-05-10T07:00:00.000Z"; + +describe("agent-qc-qcloop-status-core", () => { + it("应把长时间无输出的 running item 标记为 stale", () => { + const report = buildQCLoopStatusReport({ + job: { id: "job-1", name: "p0", status: "running" }, + items: [ + { + id: "item-1", + item_value: '{"scenario_id":"skill-forge-register-bind-enable"}', + status: "running", + current_attempt_no: 1, + current_qc_no: 0, + attempts: [ + { + id: "attempt-1", + status: "running", + started_at: "2026-05-10T06:00:00.000Z", + stdout: "", + stderr: "", + }, + ], + qc_rounds: [], + }, + ], + options: { generatedAt, staleMinutes: 30 }, + }); + + expect(report.verdict.status).toBe("stale"); + expect(report.counts.stale).toBe(1); + expect(report.items[0].staleSeconds).toBe(3600); + expect(report.items[0].worker.durationSeconds).toBe(3600); + expect(report.items[0].staleReasons.join("\n")).toContain("stdout/stderr 为空"); + expect(validateQCLoopStatusReport(report).valid).toBe(true); + }); + + it("有输出的 running item 不应仅因运行时间长被标记为 stale", () => { + const result = classifyItemStaleness( + { status: "running" }, + { + status: "running", + startedAt: "2026-05-10T06:00:00.000Z", + durationMinutes: 60, + outputLength: 120, + }, + { staleMinutes: 30 }, + ); + + expect(result.stale).toBe(false); + }); + + it("全部 success 且 job completed 时应给出 complete verdict", () => { + const report = buildQCLoopStatusReport({ + job: { id: "job-2", name: "p0", status: "completed" }, + items: [ + { + id: "item-2", + item_value: "command-bridge-contract", + status: "success", + current_attempt_no: 1, + current_qc_no: 1, + attempts: [ + { + id: "attempt-2", + status: "success", + started_at: "2026-05-10T06:00:00.000Z", + finished_at: "2026-05-10T06:01:00.000Z", + stdout: "ok", + stderr: "", + }, + ], + qc_rounds: [{ id: "qc-2", status: "pass", feedback: "ok" }], + }, + ], + options: { generatedAt, staleMinutes: 30 }, + }); + + expect(report.verdict.status).toBe("complete"); + expect(report.counts.success).toBe(1); + }); + + it("failed/exhausted item 应保持 fail verdict", () => { + const report = buildQCLoopStatusReport({ + job: { id: "job-3", name: "p0", status: "completed" }, + items: [ + { + id: "item-3", + item_value: "tool-approval-sandbox-boundary", + status: "exhausted", + current_attempt_no: 1, + current_qc_no: 1, + attempts: [{ id: "attempt-3", status: "failed", stdout: "", stderr: "error" }], + qc_rounds: [{ id: "qc-3", status: "fail", feedback: "缺少证据" }], + }, + ], + options: { generatedAt, staleMinutes: 30 }, + }); + + expect(report.verdict.status).toBe("fail"); + expect(report.counts.exhausted).toBe(1); + expect(report.items[0].qc.feedback).toBe("缺少证据"); + }); + + it("worker 明确报告 BLOCKED 的 exhausted item 应保持环境阻断语义", () => { + const report = buildQCLoopStatusReport({ + job: { id: "job-4", name: "p0", status: "completed" }, + items: [ + { + id: "item-4", + item_value: "claw-chat-ready-streaming", + status: "exhausted", + current_attempt_no: 3, + current_qc_no: 3, + attempts: [ + { + id: "attempt-4", + status: "success", + stdout: "QCLOOP_WORKER_RESULT=BLOCKED\nDevBridge preflight: BLOCKED", + stderr: "", + }, + ], + qc_rounds: [{ id: "qc-4", status: "fail", feedback: "DevBridge preflight blocked" }], + }, + ], + options: { generatedAt, staleMinutes: 30 }, + }); + + expect(report.verdict.status).toBe("blocked"); + expect(report.counts.exhausted).toBe(1); + expect(report.items[0].evidenceStatus).toBe("blocked"); + }); + + it("内层 Codex CLI 环境错误应保持环境阻断语义", () => { + const report = buildQCLoopStatusReport({ + job: { id: "job-5", name: "p0", status: "failed" }, + items: [ + { + id: "item-5", + item_value: "command-bridge-contract", + status: "failed", + current_attempt_no: 1, + current_qc_no: 1, + attempts: [ + { + id: "attempt-5", + status: "failed", + stderr: + "QCLOOP_CODEX_BIN 不可用: /opt/homebrew/bin/codex: fork/exec /opt/homebrew/bin/codex: no such file or directory", + }, + ], + qc_rounds: [{ id: "qc-5", status: "fail", feedback: "verifier 输出格式错误" }], + }, + ], + options: { generatedAt, staleMinutes: 30 }, + }); + + expect(report.verdict.status).toBe("blocked"); + expect(report.items[0].evidenceStatus).toBe("blocked"); + }); +}); diff --git a/scripts/lib/agent-qc-release-summary-core.mjs b/scripts/lib/agent-qc-release-summary-core.mjs new file mode 100644 index 000000000..d0de1b45e --- /dev/null +++ b/scripts/lib/agent-qc-release-summary-core.mjs @@ -0,0 +1,194 @@ +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function countByStatus(items, getStatus = (item) => item?.status) { + const counts = new Map(); + for (const item of asArray(items)) { + const status = getStatus(item) || "unknown"; + counts.set(status, (counts.get(status) ?? 0) + 1); + } + return Object.fromEntries(Array.from(counts.entries()).sort(([left], [right]) => left.localeCompare(right))); +} + +function summarizeEvidencePack(pack, sourcePath = "") { + const scenarioResults = asArray(pack?.scenarioResults); + const laneResults = asArray(pack?.laneResults); + return { + sourcePath, + runId: pack?.runId || "unknown", + generatedAt: pack?.generatedAt || "", + status: pack?.verdict?.status || "unknown", + summary: pack?.verdict?.summary || "", + scenarioCount: scenarioResults.length, + scenarioIds: scenarioResults + .map((scenario) => String(scenario?.scenarioId || "").trim()) + .filter(Boolean) + .sort(), + scenarioStatusCounts: countByStatus(scenarioResults), + laneStatusCounts: countByStatus(laneResults), + blockers: asArray(pack?.verdict?.blockers).filter(isNonEmptyString), + waivers: asArray(pack?.verdict?.waivers), + }; +} + +function normalizeScenarioIds(scenarioIds) { + return Array.from( + new Set(asArray(scenarioIds).map((scenarioId) => String(scenarioId || "").trim()).filter(Boolean)), + ).sort(); +} + +function hasStructuredEvidenceRef(scenario) { + return asArray(scenario?.evidenceRefs).some((ref) => { + const normalized = String(ref || "").trim(); + return normalized.length > 0 && !normalized.startsWith("qcloop:"); + }); +} + +function summarizeHarnessReport(summaryReport, trendReport) { + return { + summaryGeneratedAt: summaryReport?.generatedAt || "", + trendGeneratedAt: trendReport?.generatedAt || "", + readyCount: summaryReport?.totals?.readyCount ?? null, + invalidCount: summaryReport?.totals?.invalidCount ?? null, + trendSampleCount: trendReport?.sampleCount ?? null, + signals: asArray(trendReport?.signals).filter(isNonEmptyString), + }; +} + +function buildAgentQcReleaseSummary({ + evidencePacks = [], + harnessSummary = null, + harnessTrend = null, + requiredScenarioIds = [], + tag = "", +} = {}) { + const evidence = asArray(evidencePacks).map((entry) => summarizeEvidencePack(entry.pack, entry.sourcePath)); + const statusCounts = countByStatus(evidence); + const blockers = evidence.flatMap((entry) => entry.blockers.map((blocker) => `${entry.runId}: ${blocker}`)); + const waiverCount = evidence.reduce((sum, entry) => sum + entry.waivers.length, 0); + const hasFailingEvidence = evidence.some((entry) => entry.status !== "pass"); + const weakEvidenceScenarioIds = normalizeScenarioIds( + asArray(evidencePacks).flatMap((entry) => + asArray(entry?.pack?.scenarioResults) + .filter((scenario) => scenario?.status === "pass" && !hasStructuredEvidenceRef(scenario)) + .map((scenario) => scenario?.scenarioId), + ), + ); + const structuredEvidenceBlockers = weakEvidenceScenarioIds.map( + (scenarioId) => + `structured-evidence:${scenarioId}: pass scenario 缺少非 qcloop evidenceRefs,不能只凭 qcloop item id 作为发布证据。`, + ); + const hasWeakStructuredEvidence = weakEvidenceScenarioIds.length > 0; + const status = + evidence.length === 0 ? "blocked" : hasFailingEvidence || hasWeakStructuredEvidence ? "fail" : "pass"; + const coveredScenarioIds = normalizeScenarioIds(evidence.flatMap((entry) => entry.scenarioIds)); + const normalizedRequiredScenarioIds = normalizeScenarioIds(requiredScenarioIds); + const coveredScenarioIdSet = new Set(coveredScenarioIds); + const missingRequiredScenarioIds = normalizedRequiredScenarioIds.filter( + (scenarioId) => !coveredScenarioIdSet.has(scenarioId), + ); + + return { + tag, + status, + evidenceCount: evidence.length, + statusCounts, + waiverCount, + blockers: [...blockers, ...structuredEvidenceBlockers], + coveredScenarioIds, + evidence, + harness: summarizeHarnessReport(harnessSummary, harnessTrend), + missingRequiredScenarioIds, + requiredScenarioIds: normalizedRequiredScenarioIds, + weakEvidenceScenarioIds, + }; +} + +function renderStatusCounts(counts) { + const entries = Object.entries(counts || {}); + if (entries.length === 0) { + return "无"; + } + return entries.map(([status, count]) => `${status}: ${count}`).join(" / "); +} + +function renderAgentQcReleaseMarkdown(summary) { + const evidenceLines = summary.evidence.length + ? summary.evidence + .map( + (entry) => + `- ${entry.runId}: ${entry.status};场景 ${entry.scenarioCount};${renderStatusCounts(entry.scenarioStatusCounts)}${entry.sourcePath ? `;source ${entry.sourcePath}` : ""}`, + ) + .join("\n") + : "- 无 Evidence Pack;发布应视为 blocked,除非有明确 waiver。"; + + const blockerLines = summary.blockers.length + ? summary.blockers.map((blocker) => `- ${blocker}`).join("\n") + : "- 无"; + + const harnessLines = [ + summary.harness.readyCount !== null ? `- Harness ready: ${summary.harness.readyCount}` : "- Harness ready: 未提供", + summary.harness.invalidCount !== null ? `- Harness invalid: ${summary.harness.invalidCount}` : "- Harness invalid: 未提供", + summary.harness.trendSampleCount !== null ? `- Harness trend samples: ${summary.harness.trendSampleCount}` : "- Harness trend samples: 未提供", + ...summary.harness.signals.map((signal) => `- ${signal}`), + ].join("\n"); + + return `## Agent QC Evidence${summary.tag ? ` (${summary.tag})` : ""} + +- Verdict: ${summary.status} +- Evidence packs: ${summary.evidenceCount} +- Evidence status: ${renderStatusCounts(summary.statusCounts)} +- Scenario coverage: ${summary.requiredScenarioIds?.length ? `${summary.coveredScenarioIds.length}/${summary.requiredScenarioIds.length}` : `${summary.coveredScenarioIds?.length || 0} covered`} +- Waivers: ${summary.waiverCount} + +### Evidence Packs + +${evidenceLines} + +### Harness Trend + +${harnessLines} + +### Blockers + +${blockerLines} +`; +} + +function validateReleaseSummary(summary, { requireEvidence = true } = {}) { + const issues = []; + if (requireEvidence && summary.evidenceCount === 0) { + issues.push("缺少 Agent QC Evidence Pack。请先导出 qcloop evidence 或显式记录 waiver。"); + } + if (summary.status !== "pass") { + issues.push(`Agent QC release summary 状态为 ${summary.status},不能作为绿色发布证据。`); + } + if (asArray(summary.missingRequiredScenarioIds).length > 0) { + issues.push(`Agent QC Evidence Pack 未覆盖必需场景:${summary.missingRequiredScenarioIds.join(", ")}。`); + } + if (asArray(summary.weakEvidenceScenarioIds).length > 0) { + issues.push( + `Agent QC Evidence Pack 存在缺少结构化 evidenceRefs 的 pass 场景:${summary.weakEvidenceScenarioIds.join(", ")}。`, + ); + } + return { + valid: issues.length === 0, + issues, + }; +} + +export { + buildAgentQcReleaseSummary, + countByStatus, + hasStructuredEvidenceRef, + normalizeScenarioIds, + renderAgentQcReleaseMarkdown, + summarizeEvidencePack, + summarizeHarnessReport, + validateReleaseSummary, +}; diff --git a/scripts/lib/agent-qc-release-summary-core.test.ts b/scripts/lib/agent-qc-release-summary-core.test.ts new file mode 100644 index 000000000..2a02cbdcd --- /dev/null +++ b/scripts/lib/agent-qc-release-summary-core.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAgentQcReleaseSummary, + renderAgentQcReleaseMarkdown, + validateReleaseSummary, +} from "./agent-qc-release-summary-core.mjs"; + +const passPack = { + runId: "run-pass", + generatedAt: "2026-05-10T00:00:00.000Z", + verdict: { status: "pass", summary: "ok", blockers: [] }, + laneResults: [{ laneId: "L4-behavior-eval", status: "pass" }], + scenarioResults: [ + { + scenarioId: "command-bridge-contract", + status: "pass", + evidenceRefs: ["qcloop:item:item-1", ".lime/qc/contract-summary.json"], + }, + { + scenarioId: "workspace-ready-session-restore", + status: "pass", + evidenceRefs: ["qcloop:item:item-2", ".lime/qc/gui-trace/workspace-ready.trace.zip"], + }, + ], +}; + +const failPack = { + runId: "run-fail", + generatedAt: "2026-05-10T00:00:00.000Z", + verdict: { status: "fail", summary: "bad", blockers: ["workspace: smoke failed"] }, + laneResults: [{ laneId: "L4-behavior-eval", status: "fail" }], + scenarioResults: [{ scenarioId: "workspace-ready-session-restore", status: "fail", evidenceRefs: [] }], +}; + +describe("agent-qc-release-summary-core", () => { + it("应把全 pass Evidence Pack 汇总为 pass release summary", () => { + const summary = buildAgentQcReleaseSummary({ + evidencePacks: [{ pack: passPack, sourcePath: "evidence.json" }], + harnessSummary: { generatedAt: "now", totals: { readyCount: 2, invalidCount: 0 } }, + harnessTrend: { sampleCount: 3, signals: ["current gap 保持为 0。"] }, + requiredScenarioIds: ["command-bridge-contract", "workspace-ready-session-restore"], + tag: "v1.2.3", + }); + + expect(summary.status).toBe("pass"); + expect(summary.evidenceCount).toBe(1); + expect(summary.missingRequiredScenarioIds).toEqual([]); + expect(summary.harness.readyCount).toBe(2); + expect(validateReleaseSummary(summary).valid).toBe(true); + }); + + it("应把失败 Evidence Pack 汇总为 fail 并阻断 check", () => { + const summary = buildAgentQcReleaseSummary({ + evidencePacks: [{ pack: failPack, sourcePath: "evidence.json" }], + }); + + expect(summary.status).toBe("fail"); + expect(summary.blockers.join("\n")).toContain("workspace: smoke failed"); + expect(validateReleaseSummary(summary).valid).toBe(false); + }); + + it("缺少 Evidence Pack 时默认 blocked", () => { + const summary = buildAgentQcReleaseSummary({ evidencePacks: [] }); + + expect(summary.status).toBe("blocked"); + expect(validateReleaseSummary(summary).valid).toBe(false); + }); + + it("Evidence Pack pass 但缺必需 P0 场景时应阻断 release", () => { + const summary = buildAgentQcReleaseSummary({ + evidencePacks: [{ pack: passPack, sourcePath: "evidence.json" }], + requiredScenarioIds: [ + "command-bridge-contract", + "workspace-ready-session-restore", + "release-package-startup-smoke", + ], + }); + const validation = validateReleaseSummary(summary); + + expect(summary.status).toBe("pass"); + expect(summary.missingRequiredScenarioIds).toEqual(["release-package-startup-smoke"]); + expect(validation.valid).toBe(false); + expect(validation.issues.join("\n")).toContain("release-package-startup-smoke"); + }); + + it("Evidence Pack pass 但 pass 场景只有 qcloop id 时应阻断 release", () => { + const weakPack = { + ...passPack, + scenarioResults: [ + { + scenarioId: "command-bridge-contract", + status: "pass", + evidenceRefs: ["qcloop:item:item-1", "qcloop:attempt:attempt-1"], + }, + ], + }; + const summary = buildAgentQcReleaseSummary({ + evidencePacks: [{ pack: weakPack, sourcePath: "evidence.json" }], + requiredScenarioIds: ["command-bridge-contract"], + }); + const validation = validateReleaseSummary(summary); + + expect(summary.status).toBe("fail"); + expect(summary.weakEvidenceScenarioIds).toEqual(["command-bridge-contract"]); + expect(summary.blockers.join("\n")).toContain("structured-evidence:command-bridge-contract"); + expect(validation.valid).toBe(false); + expect(validation.issues.join("\n")).toContain("结构化 evidenceRefs"); + }); + + it("应渲染 release note 可用的 Markdown", () => { + const summary = buildAgentQcReleaseSummary({ + evidencePacks: [{ pack: passPack, sourcePath: "evidence.json" }], + requiredScenarioIds: ["command-bridge-contract", "workspace-ready-session-restore"], + tag: "v1.2.3", + }); + const markdown = renderAgentQcReleaseMarkdown(summary); + + expect(markdown).toContain("Agent QC Evidence (v1.2.3)"); + expect(markdown).toContain("Verdict: pass"); + expect(markdown).toContain("Scenario coverage: 2/2"); + expect(markdown).toContain("run-pass"); + }); +}); diff --git a/scripts/lib/agent-qc-report-core.mjs b/scripts/lib/agent-qc-report-core.mjs new file mode 100644 index 000000000..8b745f255 --- /dev/null +++ b/scripts/lib/agent-qc-report-core.mjs @@ -0,0 +1,505 @@ +import fs from "node:fs"; + +const VALID_LANE_IDS = new Set([ + "L0-static-unit", + "L1-contract-bridge", + "L2-agent-runtime", + "L3-product-surface", + "L4-behavior-eval", + "L5-release-ops", +]); +const VALID_RISKS = new Set(["P0", "P1", "P2"]); +const VALID_EXECUTORS = new Set([ + "npm_command", + "rust_test", + "playwright_mcp", + "runtime_harness", + "qcloop", + "release_workflow", + "mixed", +]); +const VALID_STATUSES = new Set([ + "pass", + "fail", + "blocked", + "needs-human-review", + "waived", + "skipped", +]); +const VALID_EVIDENCE_LAYERS = new Set([ + "deterministic-smoke", + "gui-trace", + "runtime-transcript", + "release-artifact", +]); + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function addIssue(issues, severity, path, message) { + issues.push({ severity, path, message }); +} + +function collectNpmScriptName(command) { + if (!isNonEmptyString(command)) { + return ""; + } + + const match = command.trim().match(/^npm\s+run\s+([^\s]+)/); + return match ? match[1] : ""; +} + +function validateNpmCommands(commands, packageScripts, pathPrefix, issues) { + for (const [index, command] of asArray(commands).entries()) { + if (!isNonEmptyString(command)) { + addIssue(issues, "error", `${pathPrefix}.commands[${index}]`, "命令必须是非空字符串。"); + continue; + } + + const scriptName = collectNpmScriptName(command); + if (scriptName && !Object.hasOwn(packageScripts, scriptName)) { + addIssue( + issues, + "error", + `${pathPrefix}.commands[${index}]`, + `package.json 中不存在 npm script: ${scriptName}`, + ); + } + } +} + + +function validateQCLoopConfig(qcloop, issues) { + if (!qcloop || typeof qcloop !== "object") { + return; + } + + const workerTemplate = qcloop.workerPromptTemplate; + if (!isNonEmptyString(workerTemplate)) { + addIssue( + issues, + "error", + "qcloop.workerPromptTemplate", + "qcloop workerPromptTemplate 必须存在,且要约束 worker 输出可审查证据。", + ); + } else { + if (!workerTemplate.includes("evidence_required")) { + addIssue( + issues, + "error", + "qcloop.workerPromptTemplate", + "workerPromptTemplate 必须要求 worker 逐项列出 evidence_required,否则 repair 轮会再次缺证据。", + ); + } + + if (!workerTemplate.includes("failure_modes")) { + addIssue( + issues, + "error", + "qcloop.workerPromptTemplate", + "workerPromptTemplate 必须要求 worker 逐项说明 failure_modes 是否命中、覆盖或排除。", + ); + } + + if (!workerTemplate.includes("QCLOOP_WORKER_RESULT=BLOCKED")) { + addIssue( + issues, + "error", + "qcloop.workerPromptTemplate", + "workerPromptTemplate 必须显式约束环境阻断时输出 QCLOOP_WORKER_RESULT=BLOCKED。", + ); + } + + if (!workerTemplate.includes("QCLOOP_EVIDENCE_SUMMARY_JSON")) { + addIssue( + issues, + "error", + "qcloop.workerPromptTemplate", + "workerPromptTemplate 必须要求 worker 输出 QCLOOP_EVIDENCE_SUMMARY_JSON,避免 verifier 只能审查散文摘要。", + ); + } + } + + const template = qcloop.verifierPromptTemplate; + if (!isNonEmptyString(template)) { + addIssue( + issues, + "error", + "qcloop.verifierPromptTemplate", + "qcloop verifierPromptTemplate 必须存在,且要带 worker evidence 占位符。", + ); + return; + } + + if (!template.includes("{{stdout}}") && !template.includes("{{output}}")) { + addIssue( + issues, + "error", + "qcloop.verifierPromptTemplate", + "verifierPromptTemplate 必须包含 {{stdout}} 或 {{output}},否则 verifier 看不到 worker 输出。", + ); + } + + for (const placeholder of ["{{attempt_status}}", "{{exit_code}}"]) { + if (!template.includes(placeholder)) { + addIssue( + issues, + "error", + "qcloop.verifierPromptTemplate", + `verifierPromptTemplate 必须包含 ${placeholder},用于判定 worker 执行状态。`, + ); + } + } + + if (!template.includes("{{qc_history}}") && !template.includes("{{issue_ledger}}")) { + addIssue( + issues, + "error", + "qcloop.verifierPromptTemplate", + "verifierPromptTemplate 必须包含 {{qc_history}} 或 {{issue_ledger}},用于判断 repair 后旧问题是否仍开放。", + ); + } + + if (!template.includes("QCLOOP_EVIDENCE_SUMMARY_JSON")) { + addIssue( + issues, + "error", + "qcloop.verifierPromptTemplate", + "verifierPromptTemplate 必须要求审查 QCLOOP_EVIDENCE_SUMMARY_JSON,避免命令退出码被误判为证据完整。", + ); + } + + if (!template.includes('{"pass": true|false')) { + addIssue( + issues, + "error", + "qcloop.verifierPromptTemplate", + 'verifierPromptTemplate 必须声明只输出 {"pass": true|false, "feedback": "..."} JSON,避免 qcloop 报“verifier 输出格式错误”。', + ); + } + + if (template.includes("{{stderr}}")) { + addIssue( + issues, + "warn", + "qcloop.verifierPromptTemplate", + "verifierPromptTemplate 包含 {{stderr}},Codex / GUI 场景可能产生超长 stderr;优先让 worker 在 stdout 输出摘要。", + ); + } +} + +function validateEvidenceSchema(schema, issues) { + if (!schema || typeof schema !== "object") { + addIssue(issues, "error", "evidenceSchema", "Evidence schema 必须是 JSON object。"); + return; + } + + for (const field of ["$schema", "$id", "type", "required", "properties"]) { + if (!Object.hasOwn(schema, field)) { + addIssue(issues, "error", `evidenceSchema.${field}`, `缺少 ${field}。`); + } + } + + const required = asArray(schema.required); + for (const field of ["schemaVersion", "runId", "generatedAt", "subject", "laneResults", "scenarioResults", "verdict"]) { + if (!required.includes(field)) { + addIssue(issues, "error", "evidenceSchema.required", `缺少必填字段 ${field}。`); + } + } + + const verdictStatus = schema?.properties?.verdict?.properties?.status?.enum; + if (Array.isArray(verdictStatus)) { + for (const status of ["pass", "fail", "blocked", "needs-human-review", "waived"]) { + if (!verdictStatus.includes(status)) { + addIssue(issues, "error", "evidenceSchema.verdict.status", `verdict.status 缺少 ${status}。`); + } + } + } +} + +function validateLane(lane, index, packageScripts, issues) { + const pathPrefix = `lanes[${index}]`; + if (!isNonEmptyString(lane?.id)) { + addIssue(issues, "error", `${pathPrefix}.id`, "Lane 必须有 id。"); + return; + } + + if (!VALID_LANE_IDS.has(lane.id)) { + addIssue(issues, "warn", `${pathPrefix}.id`, `Lane id ${lane.id} 不在当前标准集合中。`); + } + + for (const field of ["title", "objective", "gatePolicy"]) { + if (!isNonEmptyString(lane[field])) { + addIssue(issues, "error", `${pathPrefix}.${field}`, `Lane 缺少 ${field}。`); + } + } + + validateNpmCommands(lane.defaultCommands, packageScripts, pathPrefix, issues); +} + +function validateScenario(scenario, index, laneIds, packageScripts, issues) { + const pathPrefix = `scenarios[${index}]`; + for (const field of ["id", "title", "risk", "executor", "goal", "verifier"]) { + if (!isNonEmptyString(scenario?.[field])) { + addIssue(issues, "error", `${pathPrefix}.${field}`, `Scenario 缺少 ${field}。`); + } + } + + if (isNonEmptyString(scenario?.risk) && !VALID_RISKS.has(scenario.risk)) { + addIssue(issues, "error", `${pathPrefix}.risk`, `未知风险等级 ${scenario.risk}。`); + } + + if (isNonEmptyString(scenario?.executor) && !VALID_EXECUTORS.has(scenario.executor)) { + addIssue(issues, "error", `${pathPrefix}.executor`, `未知执行器 ${scenario.executor}。`); + } + + const lanes = asArray(scenario?.lanes); + if (lanes.length === 0) { + addIssue(issues, "error", `${pathPrefix}.lanes`, "Scenario 至少要归属一个 lane。"); + } + for (const laneId of lanes) { + if (!laneIds.has(laneId)) { + addIssue(issues, "error", `${pathPrefix}.lanes`, `引用了不存在的 lane: ${laneId}`); + } + } + + if (asArray(scenario?.evidenceRequired).length === 0) { + addIssue(issues, "error", `${pathPrefix}.evidenceRequired`, "Scenario 必须声明证据要求。"); + } + + if (asArray(scenario?.failureModes).length === 0) { + addIssue(issues, "error", `${pathPrefix}.failureModes`, "Scenario 必须声明失败模式。"); + } + + const evidenceLayers = asArray(scenario?.evidenceLayers); + if (scenario?.risk === "P0" && evidenceLayers.length === 0) { + addIssue( + issues, + "error", + `${pathPrefix}.evidenceLayers`, + "P0 Scenario 必须声明 evidenceLayers,避免 deterministic smoke 被误读成 deep evidence。", + ); + } + for (const [layerIndex, layer] of evidenceLayers.entries()) { + if (!VALID_EVIDENCE_LAYERS.has(layer)) { + addIssue( + issues, + "error", + `${pathPrefix}.evidenceLayers[${layerIndex}]`, + `未知 evidence layer: ${layer}`, + ); + } + } + + validateNpmCommands(scenario?.commands, packageScripts, pathPrefix, issues); +} + +function validateAgentQcManifest(manifest, { packageScripts = {}, evidenceSchema = null } = {}) { + const issues = []; + if (!manifest || typeof manifest !== "object") { + return { + valid: false, + issues: [{ severity: "error", path: "manifest", message: "Manifest 必须是 JSON object。" }], + }; + } + + for (const field of ["manifestVersion", "title", "evidenceSchema", "lanes", "scenarios"]) { + if (!Object.hasOwn(manifest, field)) { + addIssue(issues, "error", field, `Manifest 缺少 ${field}。`); + } + } + + if (manifest.manifestVersion !== "v1") { + addIssue(issues, "error", "manifestVersion", "当前只接受 manifestVersion=v1。"); + } + + validateQCLoopConfig(manifest.qcloop, issues); + + const lanes = asArray(manifest.lanes); + const scenarios = asArray(manifest.scenarios); + const laneIds = new Set(); + const scenarioIds = new Set(); + + if (lanes.length === 0) { + addIssue(issues, "error", "lanes", "至少需要一个测试 lane。"); + } + + for (const [index, lane] of lanes.entries()) { + validateLane(lane, index, packageScripts, issues); + if (isNonEmptyString(lane?.id)) { + if (laneIds.has(lane.id)) { + addIssue(issues, "error", `lanes[${index}].id`, `重复 lane id: ${lane.id}`); + } + laneIds.add(lane.id); + } + } + + if (scenarios.length === 0) { + addIssue(issues, "error", "scenarios", "至少需要一个 Agent QC 场景。"); + } + + for (const [index, scenario] of scenarios.entries()) { + validateScenario(scenario, index, laneIds, packageScripts, issues); + if (isNonEmptyString(scenario?.id)) { + if (scenarioIds.has(scenario.id)) { + addIssue(issues, "error", `scenarios[${index}].id`, `重复 scenario id: ${scenario.id}`); + } + scenarioIds.add(scenario.id); + } + } + + if (evidenceSchema) { + validateEvidenceSchema(evidenceSchema, issues); + } + + const errors = issues.filter((issue) => issue.severity === "error"); + return { + valid: errors.length === 0, + issues, + }; +} + +function countBy(items, getKey) { + const counts = new Map(); + for (const item of items) { + const key = getKey(item); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return Array.from(counts.entries()) + .map(([name, count]) => ({ name, count })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function summarizeAgentQcManifest(manifest, validation) { + const lanes = asArray(manifest?.lanes); + const scenarios = asArray(manifest?.scenarios); + return { + title: manifest?.title ?? "Lime Agent QC", + manifestVersion: manifest?.manifestVersion ?? "unknown", + evidenceSchema: manifest?.evidenceSchema ?? "", + valid: validation.valid, + issueCount: validation.issues.length, + errorCount: validation.issues.filter((issue) => issue.severity === "error").length, + warnCount: validation.issues.filter((issue) => issue.severity === "warn").length, + laneCount: lanes.length, + scenarioCount: scenarios.length, + p0ScenarioCount: scenarios.filter((scenario) => scenario.risk === "P0").length, + lanes: lanes.map((lane) => ({ + id: lane.id, + title: lane.title, + defaultCommandCount: asArray(lane.defaultCommands).length, + scenarioCount: scenarios.filter((scenario) => asArray(scenario.lanes).includes(lane.id)).length, + })), + scenarios: scenarios.map((scenario) => ({ + id: scenario.id, + title: scenario.title, + risk: scenario.risk, + executor: scenario.executor, + lanes: asArray(scenario.lanes), + })), + riskBreakdown: countBy(scenarios, (scenario) => scenario.risk || "unknown"), + executorBreakdown: countBy(scenarios, (scenario) => scenario.executor || "unknown"), + issues: validation.issues, + }; +} + +function renderIssueList(issues) { + if (issues.length === 0) { + return "- 无"; + } + + return issues + .map((issue) => `- ${issue.severity.toUpperCase()} ${issue.path}: ${issue.message}`) + .join("\n"); +} + +function renderNameCountList(items) { + if (items.length === 0) { + return "- 无"; + } + + return items.map((item) => `- ${item.name}: ${item.count}`).join("\n"); +} + +function renderAgentQcMarkdownReport(summary) { + const laneLines = summary.lanes.length + ? summary.lanes + .map( + (lane) => + `- ${lane.id}: ${lane.title};场景 ${lane.scenarioCount};默认命令 ${lane.defaultCommandCount}`, + ) + .join("\n") + : "- 无"; + const scenarioLines = summary.scenarios.length + ? summary.scenarios + .map( + (scenario) => + `- ${scenario.id}: ${scenario.title};${scenario.risk};${scenario.executor}`, + ) + .join("\n") + : "- 无"; + + return `# ${summary.title} 场景报告 + +## 结论 + +- Manifest: ${summary.manifestVersion} +- Evidence schema: ${summary.evidenceSchema || "未声明"} +- 状态: ${summary.valid ? "PASS" : "FAIL"} +- Lane 数: ${summary.laneCount} +- Scenario 数: ${summary.scenarioCount} +- P0 Scenario 数: ${summary.p0ScenarioCount} +- Issue: ${summary.issueCount}(error ${summary.errorCount} / warn ${summary.warnCount}) + +## Lane 覆盖 + +${laneLines} + +## Scenario 清单 + +${scenarioLines} + +## 风险分布 + +${renderNameCountList(summary.riskBreakdown)} + +## 执行器分布 + +${renderNameCountList(summary.executorBreakdown)} + +## 校验问题 + +${renderIssueList(summary.issues)} +`; +} + +function createAgentQcReport({ manifest, packageJson = {}, evidenceSchema = null }) { + const validation = validateAgentQcManifest(manifest, { + packageScripts: packageJson.scripts ?? {}, + evidenceSchema, + }); + return summarizeAgentQcManifest(manifest, validation); +} + +export { + VALID_EVIDENCE_LAYERS, + VALID_EXECUTORS, + VALID_LANE_IDS, + VALID_RISKS, + VALID_STATUSES, + collectNpmScriptName, + createAgentQcReport, + readJsonFile, + renderAgentQcMarkdownReport, + summarizeAgentQcManifest, + validateAgentQcManifest, +}; diff --git a/scripts/lib/agent-qc-report-core.test.ts b/scripts/lib/agent-qc-report-core.test.ts new file mode 100644 index 000000000..5c375cbcf --- /dev/null +++ b/scripts/lib/agent-qc-report-core.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; + +import { + collectNpmScriptName, + createAgentQcReport, + renderAgentQcMarkdownReport, + validateAgentQcManifest, +} from "./agent-qc-report-core.mjs"; + +const packageJson = { + scripts: { + "verify:local": "node scripts/local-ci.mjs", + "test:contracts": "node scripts/check-command-contracts.mjs", + "verify:gui-smoke": "node scripts/verify-gui-smoke.mjs", + }, +}; + +const evidenceSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://lime.local/agent-qc-evidence.schema.json", + type: "object", + required: [ + "schemaVersion", + "runId", + "generatedAt", + "subject", + "laneResults", + "scenarioResults", + "verdict", + ], + properties: { + verdict: { + type: "object", + properties: { + status: { + enum: ["pass", "fail", "blocked", "needs-human-review", "waived"], + }, + }, + }, + }, +}; + +const validManifest = { + manifestVersion: "v1", + title: "Lime Agent QC", + evidenceSchema: "docs/test/agent-qc-evidence.schema.json", + qcloop: { + workerPromptTemplate: + "worker {{item}} evidence_required failure_modes QCLOOP_WORKER_RESULT=BLOCKED QCLOOP_EVIDENCE_SUMMARY_JSON", + verifierPromptTemplate: + 'verifier {{item}} status={{attempt_status}} code={{exit_code}} stdout={{stdout}} ledger={{issue_ledger}} QCLOOP_EVIDENCE_SUMMARY_JSON,只输出 {"pass": true|false, "feedback": "..."} JSON', + }, + lanes: [ + { + id: "L0-static-unit", + title: "快速卫生", + objective: "验证低级错误不会进入主链。", + gatePolicy: "所有 PR 默认执行。", + defaultCommands: ["npm run verify:local"], + }, + { + id: "L1-contract-bridge", + title: "契约桥接", + objective: "验证命令和 Bridge 合同不漂移。", + gatePolicy: "命令和 Bridge 改动必跑。", + defaultCommands: ["npm run test:contracts"], + }, + ], + scenarios: [ + { + id: "command-bridge-contract", + title: "命令桥接合同", + risk: "P0", + executor: "npm_command", + lanes: ["L1-contract-bridge"], + commands: ["npm run test:contracts"], + goal: "前端调用、Rust 注册、治理目录册、mock 四侧一致。", + verifier: "test:contracts 成功且没有新增 dead/compat 回流。", + evidenceRequired: ["command log", "contract summary"], + evidenceLayers: ["deterministic-smoke"], + failureModes: ["missing rust handler", "mock fallback drift"], + }, + ], +}; + +describe("agent-qc-report-core", () => { + it("能从 npm run 命令中提取 script 名称", () => { + expect(collectNpmScriptName("npm run verify:gui-smoke -- --reuse-running")).toBe( + "verify:gui-smoke", + ); + expect(collectNpmScriptName("node scripts/foo.mjs")).toBe(""); + }); + + it("应接受完整的 Agent QC manifest 和 evidence schema", () => { + const result = validateAgentQcManifest(validManifest, { + packageScripts: packageJson.scripts, + evidenceSchema, + }); + + expect(result.valid).toBe(true); + expect(result.issues.filter((issue) => issue.severity === "error")).toEqual([]); + }); + + it("应阻止 qcloop verifier 缺少 worker stdout 占位符", () => { + const manifest = structuredClone(validManifest); + manifest.qcloop.verifierPromptTemplate = + "verifier {{item}} status={{attempt_status}} code={{exit_code}}"; + + const result = validateAgentQcManifest(manifest, { + packageScripts: packageJson.scripts, + evidenceSchema, + }); + + expect(result.valid).toBe(false); + expect(result.issues.map((issue) => issue.message).join("\n")).toContain( + "{{stdout}}", + ); + }); + + it("应阻止 qcloop worker 缺少 evidence_required / failure_modes / BLOCKED / 结构化证据约束", () => { + const manifest = structuredClone(validManifest); + manifest.qcloop.workerPromptTemplate = "worker {{item}}"; + + const result = validateAgentQcManifest(manifest, { + packageScripts: packageJson.scripts, + evidenceSchema, + }); + + expect(result.valid).toBe(false); + const messages = result.issues.map((issue) => issue.message).join("\n"); + expect(messages).toContain("evidence_required"); + expect(messages).toContain("failure_modes"); + expect(messages).toContain("QCLOOP_WORKER_RESULT=BLOCKED"); + expect(messages).toContain("QCLOOP_EVIDENCE_SUMMARY_JSON"); + }); + + it("应阻止 qcloop verifier 缺少 attempt 状态占位符", () => { + const manifest = structuredClone(validManifest); + manifest.qcloop.verifierPromptTemplate = "verifier {{item}} stdout={{stdout}}"; + + const result = validateAgentQcManifest(manifest, { + packageScripts: packageJson.scripts, + evidenceSchema, + }); + + expect(result.valid).toBe(false); + expect(result.issues.map((issue) => issue.message).join("\n")).toContain( + "{{attempt_status}}", + ); + }); + + it("应阻止 qcloop verifier 缺少 issue ledger 或结构化证据审查契约", () => { + const manifest = structuredClone(validManifest); + manifest.qcloop.verifierPromptTemplate = + "verifier {{item}} status={{attempt_status}} code={{exit_code}} stdout={{stdout}}"; + + const result = validateAgentQcManifest(manifest, { + packageScripts: packageJson.scripts, + evidenceSchema, + }); + + expect(result.valid).toBe(false); + const messages = result.issues.map((issue) => issue.message).join("\n"); + expect(messages).toContain("{{qc_history}}"); + expect(messages).toContain("QCLOOP_EVIDENCE_SUMMARY_JSON"); + expect(messages).toContain('{"pass": true|false'); + }); + + it("应阻止引用不存在的 npm script", () => { + const manifest = structuredClone(validManifest); + manifest.scenarios[0].commands = ["npm run missing-script"]; + + const result = validateAgentQcManifest(manifest, { + packageScripts: packageJson.scripts, + evidenceSchema, + }); + + expect(result.valid).toBe(false); + expect(result.issues.map((issue) => issue.message).join("\n")).toContain( + "missing-script", + ); + }); + + it("应阻止 P0 场景缺少 evidenceLayers", () => { + const manifest = structuredClone(validManifest); + delete manifest.scenarios[0].evidenceLayers; + + const result = validateAgentQcManifest(manifest, { + packageScripts: packageJson.scripts, + evidenceSchema, + }); + + expect(result.valid).toBe(false); + expect(result.issues.map((issue) => issue.message).join("\n")).toContain( + "evidenceLayers", + ); + }); + + it("应阻止未知 evidence layer", () => { + const manifest = structuredClone(validManifest); + manifest.scenarios[0].evidenceLayers = ["deterministic-smoke", "unknown-layer"]; + + const result = validateAgentQcManifest(manifest, { + packageScripts: packageJson.scripts, + evidenceSchema, + }); + + expect(result.valid).toBe(false); + expect(result.issues.map((issue) => issue.message).join("\n")).toContain( + "unknown-layer", + ); + }); + + it("应把报告渲染成可读 Markdown", () => { + const report = createAgentQcReport({ + manifest: validManifest, + packageJson, + evidenceSchema, + }); + const markdown = renderAgentQcMarkdownReport(report); + + expect(markdown).toContain("Lime Agent QC 场景报告"); + expect(markdown).toContain("状态: PASS"); + expect(markdown).toContain("command-bridge-contract"); + }); +}); diff --git a/scripts/lib/agent-runtime-approval-sandbox-smoke-core.mjs b/scripts/lib/agent-runtime-approval-sandbox-smoke-core.mjs new file mode 100644 index 000000000..062a3f55d --- /dev/null +++ b/scripts/lib/agent-runtime-approval-sandbox-smoke-core.mjs @@ -0,0 +1,313 @@ +function createTranscriptSteps() { + return [ + { + id: "tool-request", + category: "tool request", + eventTypes: ["tool.started", "tool.args"], + summary: + "允许工具请求会投影为 tool.started/tool.args,保留 tool id、tool name 与输入参数摘要。", + details: { + toolCallId: "tool-1", + toolName: "read_file", + phase: "acting", + inputAvailable: true, + inputSummary: '{"path":"README.md"}', + }, + sourceRefs: [ + "src/components/agent/chat/projection/agentUiEventProjection.test.ts:676", + ], + }, + { + id: "approval-decision", + category: "approval decision", + eventTypes: ["run.status", "permission.changed"], + summary: + "需要授权时会先进入 permission_review,并通过 permission.changed 暴露 decision、askProfileKeys 与 confirmation request。", + details: { + phase: "waiting", + permissionStatus: "requires_confirmation", + confirmationStatus: "not_requested", + confirmationRequestId: "approval-1", + decisionSource: "runtime", + decisionScope: "turn", + askProfileKeys: ["read_files"], + requiredProfileKeys: ["read_files", "write_artifacts"], + }, + sourceRefs: [ + "src/components/agent/chat/projection/agentUiEventProjection.test.ts:85", + "src/components/agent/chat/components/AgentThreadTimeline.test.tsx:904", + "src/components/agent/chat/components/MessageList.test.tsx:1845", + ], + }, + { + id: "sandbox-policy", + category: "sandbox policy", + eventTypes: ["permission.changed"], + summary: + "turn_context 会透传 approvalPolicy 与 sandboxPolicy,确保 runtime policy 不会在 UI 证据链中丢失。", + details: { + phase: "preparing", + approvalPolicy: "on-request", + sandboxPolicy: "workspace-write", + sourceEvent: "turn_context", + }, + sourceRefs: [ + "src/components/agent/chat/projection/agentUiEventProjection.test.ts:1480", + "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts:1", + ], + }, + { + id: "tool-result", + category: "result", + eventTypes: ["tool.result"], + summary: + "成功工具结果会归档为 tool.result,并保留 artifact refs,避免出现 tool result missing。", + details: { + toolCallId: "tool-1", + success: true, + phase: "completed", + artifactPaths: [".lime/artifacts/demo.md"], + artifactIds: ["artifact-1"], + }, + sourceRefs: [ + "src/components/agent/chat/projection/agentUiEventProjection.test.ts:676", + ], + }, + { + id: "tool-error", + category: "error", + eventTypes: ["tool.failed"], + summary: + "失败工具结果会归档为 tool.failed,并保留错误预览与 metadata,避免错误路径丢失。", + details: { + toolCallId: "tool-2", + success: false, + phase: "failed", + errorPreview: "Permission denied", + metadataKeys: ["sandboxed", "exit_code"], + }, + sourceRefs: [ + "src/components/agent/chat/projection/agentUiEventProjection.test.ts:676", + "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx:7801", + ], + }, + { + id: "timeout-recovery", + category: "recovery action", + eventTypes: ["timeout.recover", "timeout.defer", "timeout.fail"], + summary: + "首包超时与 inactivity timeout 都有明确恢复动作矩阵和用户可见文案,拒绝或超时不会把用户卡死。", + details: { + firstEventActions: { + recover: "recover", + defer: "defer", + fail: "fail", + }, + inactivityActions: { + recover: "recover", + fail: "fail", + }, + warnings: [ + "[AsterChat] 首个运行时事件静默,已降级切换为会话快照同步: event-a", + "[AsterChat] 首个运行时事件暂未到达,已基于提交派发继续等待后续进度: event-a", + "[AsterChat] 运行时事件静默,已降级切换为会话快照同步: event-a", + ], + failureMessage: + "执行已中断:运行时长时间没有返回新进度,请重试。", + }, + sourceRefs: [ + "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts:1", + ], + }, + ]; +} + +function createEvidenceChecks() { + return [ + { + id: "tool timeline", + satisfied: true, + summary: + "由 tool-request、tool-result 与 tool-error 三段 transcript 组成完整 tool timeline。", + transcriptStepIds: ["tool-request", "tool-result", "tool-error"], + }, + { + id: "approval decision", + satisfied: true, + summary: + "approval-decision transcript 暴露 permission.changed waiting 状态、decision source/scope 与 confirmation request。", + transcriptStepIds: ["approval-decision"], + }, + { + id: "sandbox policy", + satisfied: true, + summary: + "sandbox-policy transcript 暴露 turn_context approvalPolicy/sandboxPolicy。", + transcriptStepIds: ["sandbox-policy"], + }, + { + id: "error recovery transcript", + satisfied: true, + summary: + "timeout-recovery transcript 暴露 recover/defer/fail 动作矩阵与用户可见 warning/message。", + transcriptStepIds: ["timeout-recovery"], + }, + ]; +} + +function createFailureModeCoverage() { + return [ + { + id: "approval bypass", + status: "covered", + summary: + "approval-decision 与 sandbox-policy transcript 证明 runtime 会先进入 waiting/policy gate,再继续工具执行。", + transcriptStepIds: ["approval-decision", "sandbox-policy"], + }, + { + id: "tool result missing", + status: "covered", + summary: + "tool-result 与 tool-error transcript 同时存在,成功/失败路径都不会丢失结果。", + transcriptStepIds: ["tool-result", "tool-error"], + }, + { + id: "timeout without recovery", + status: "covered", + summary: + "timeout-recovery transcript 暴露 recover/defer/fail 分支与用户可见 warning/message,不会让用户卡死。", + transcriptStepIds: ["timeout-recovery"], + }, + { + id: "unsafe tool exposed", + status: "covered", + summary: + "工具权限与来源会进入 Harness 工具库存区块,危险工具暴露由 companion tool-surface smoke 共同覆盖。", + transcriptStepIds: ["approval-decision"], + companionCommand: "npm run smoke:agent-runtime-tool-surface", + }, + ]; +} + +function liveRuntimeTranscriptSatisfiesReleaseGate(liveRuntimeTranscript) { + const assertions = liveRuntimeTranscript?.assertions; + return Boolean( + assertions?.devBridgeHealthy && + assertions?.permissionRequestCreatedBeforeModel && + assertions?.deniedDecisionClearsPendingRequest && + assertions?.resolvedDecisionClearsPendingRequest && + assertions?.approvalPolicySubmitted && + assertions?.sandboxPolicySubmitted, + ); +} + +function buildApprovalSandboxSmokeEvidence({ + commandResults, + generatedAt, + liveRuntimeTranscript = null, +}) { + const transcriptSteps = createTranscriptSteps(); + const evidenceChecks = createEvidenceChecks(); + const failureModeCoverage = createFailureModeCoverage(); + const liveRuntimeTranscriptVerified = + liveRuntimeTranscriptSatisfiesReleaseGate(liveRuntimeTranscript); + + return { + schemaVersion: "v2", + scenarioId: "tool-approval-sandbox-boundary", + status: "pass", + generatedAt, + evidenceKind: "runtime-approval-sandbox-verified-transcript-smoke", + transcriptKind: liveRuntimeTranscriptVerified + ? "verified_projection_and_live_runtime_transcript" + : "verified_projection_summary", + limitation: liveRuntimeTranscriptVerified + ? "该 smoke 同时输出投影/组件事实源与 DevBridge live runtime permission confirmation transcript;tool result/error 和 timeout recovery 仍由定向回归覆盖。" + : "该 smoke 输出的是由前端提交、投影、UI 与超时恢复事实源生成的结构化 transcript summary;正式发布如需 live session 级证据,仍应由 qcloop 或 replay 采集。", + coverage: { + submitPreferences: true, + approvalDecisionProjection: true, + sandboxPolicyProjection: true, + toolRequestResultOrErrorProjection: true, + permissionRecoveryUi: true, + harnessPermissionInventory: true, + runtimeTranscriptSummary: true, + liveRuntimeTranscript: liveRuntimeTranscriptVerified, + }, + liveRuntimeTranscript, + transcriptSteps, + evidenceChecks, + failureModeCoverage, + commandResults, + followUpForOfficialEvidence: [ + liveRuntimeTranscriptVerified + ? "qcloop worker stdout 应保留 live.transcript.* 行,供 release verifier 审计。" + : "如需 live session 级发布证据,将同样的 transcript 维度写入 qcloop worker stdout 或 replay artifact。", + "保留 tool request / approval decision / sandbox policy / result-or-error / recovery action 五类 transcript 字段。", + ], + }; +} + +function renderApprovalSandboxTranscriptLines(evidence) { + const lines = []; + + for (const step of evidence.transcriptSteps) { + const detailParts = []; + for (const [key, value] of Object.entries(step.details || {})) { + if (Array.isArray(value)) { + detailParts.push(`${key}=${value.join("|")}`); + } else if (value && typeof value === "object") { + detailParts.push(`${key}=${JSON.stringify(value)}`); + } else { + detailParts.push(`${key}=${String(value)}`); + } + } + + lines.push( + `[smoke:agent-runtime-approval-sandbox] transcript.${step.id}: category=${step.category}; events=${step.eventTypes.join("/")}; ${step.summary}; ${detailParts.join("; ")}`, + ); + } + + for (const check of evidence.evidenceChecks) { + lines.push( + `[smoke:agent-runtime-approval-sandbox] evidence.${check.id}: ${ + check.satisfied ? "satisfied" : "missing" + }; ${check.summary}; transcript=${check.transcriptStepIds.join(",")}`, + ); + } + + for (const mode of evidence.failureModeCoverage) { + lines.push( + `[smoke:agent-runtime-approval-sandbox] failure.${mode.id}: ${mode.status}; ${mode.summary}; transcript=${mode.transcriptStepIds.join(",")}${ + mode.companionCommand ? `; companion=${mode.companionCommand}` : "" + }`, + ); + } + + const live = evidence.liveRuntimeTranscript; + if (live) { + lines.push( + `[smoke:agent-runtime-approval-sandbox] live.transcript.kind=${live.kind}; devBridgeStatus=${live.health?.status || "unknown"}; workspaceId=${live.workspaceId || ""}; liveRuntimeTranscript=${evidence.coverage.liveRuntimeTranscript ? "true" : "false"}`, + ); + for (const flow of live.flows || []) { + lines.push( + `[smoke:agent-runtime-approval-sandbox] live.transcript.${flow.decision}.request: sessionId=${flow.sessionId}; turnId=${flow.turnId}; requestId=${flow.requestId}; permissionStatus=${flow.before?.permissionStatus}; confirmationStatus=${flow.before?.confirmationStatus}; pendingRequestCount=${flow.before?.pendingRequestCount}; latestTurnStatus=${flow.before?.latestTurnStatus}; approvalPolicy=${flow.submittedPolicies?.approvalPolicy}; sandboxPolicy=${flow.submittedPolicies?.sandboxPolicy}`, + ); + lines.push( + `[smoke:agent-runtime-approval-sandbox] live.transcript.${flow.decision}.decision: confirmed=${flow.respond?.confirmed}; response=${flow.respond?.responseLabel}; afterConfirmationStatus=${flow.after?.confirmationStatus}; afterPendingRequestCount=${flow.after?.pendingRequestCount}; afterThreadStatus=${flow.after?.threadStatus}`, + ); + } + for (const [key, value] of Object.entries(live.assertions || {})) { + lines.push( + `[smoke:agent-runtime-approval-sandbox] live.assertion.${key}: ${value ? "satisfied" : "missing"}`, + ); + } + } + + return lines; +} + +export { + buildApprovalSandboxSmokeEvidence, + renderApprovalSandboxTranscriptLines, +}; diff --git a/scripts/lib/agent-runtime-approval-sandbox-smoke-core.test.ts b/scripts/lib/agent-runtime-approval-sandbox-smoke-core.test.ts new file mode 100644 index 000000000..923ebb897 --- /dev/null +++ b/scripts/lib/agent-runtime-approval-sandbox-smoke-core.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import { + buildApprovalSandboxSmokeEvidence, + renderApprovalSandboxTranscriptLines, +} from "./agent-runtime-approval-sandbox-smoke-core.mjs"; + +describe("agent-runtime-approval-sandbox-smoke-core", () => { + it("应生成覆盖 verifier 关键维度的 transcript 与 failure mode 摘要", () => { + const evidence = buildApprovalSandboxSmokeEvidence({ + commandResults: [], + generatedAt: "2026-05-10T00:00:00.000Z", + }); + + expect(evidence.transcriptKind).toBe("verified_projection_summary"); + expect(evidence.transcriptSteps.map((entry) => entry.category)).toEqual( + expect.arrayContaining([ + "tool request", + "approval decision", + "sandbox policy", + "result", + "error", + "recovery action", + ]), + ); + expect(evidence.evidenceChecks.every((entry) => entry.satisfied)).toBe(true); + expect(evidence.failureModeCoverage.map((entry) => entry.id)).toEqual( + expect.arrayContaining([ + "approval bypass", + "tool result missing", + "timeout without recovery", + "unsafe tool exposed", + ]), + ); + }); + + it("live runtime transcript 满足门禁时应标记为 liveRuntimeTranscript", () => { + const evidence = buildApprovalSandboxSmokeEvidence({ + commandResults: [], + generatedAt: "2026-05-10T00:00:00.000Z", + liveRuntimeTranscript: { + kind: "devbridge-runtime-permission-confirmation", + health: { status: "ok" }, + workspaceId: "workspace-1", + flows: [ + { + decision: "denied", + sessionId: "session-denied", + turnId: "turn-denied", + requestId: "runtime_permission_confirmation:turn-denied", + submittedPolicies: { + approvalPolicy: "on-request", + sandboxPolicy: "workspace-write", + }, + before: { + permissionStatus: "requires_confirmation", + confirmationStatus: "requested", + pendingRequestCount: 1, + latestTurnStatus: "failed", + }, + respond: { + confirmed: false, + responseLabel: "拒绝", + }, + after: { + confirmationStatus: "denied", + pendingRequestCount: 0, + threadStatus: "failed", + }, + }, + ], + assertions: { + devBridgeHealthy: true, + permissionRequestCreatedBeforeModel: true, + deniedDecisionClearsPendingRequest: true, + resolvedDecisionClearsPendingRequest: true, + approvalPolicySubmitted: true, + sandboxPolicySubmitted: true, + }, + }, + }); + + expect(evidence.transcriptKind).toBe( + "verified_projection_and_live_runtime_transcript", + ); + expect(evidence.coverage.liveRuntimeTranscript).toBe(true); + + const lines = renderApprovalSandboxTranscriptLines(evidence).join("\n"); + expect(lines).toContain("live.transcript.denied.request"); + expect(lines).toContain( + "live.assertion.permissionRequestCreatedBeforeModel: satisfied", + ); + }); + + it("应把 transcript、evidence 与 failure mode 渲染到 stdout 友好的行文本", () => { + const evidence = buildApprovalSandboxSmokeEvidence({ + commandResults: [], + generatedAt: "2026-05-10T00:00:00.000Z", + }); + + const lines = renderApprovalSandboxTranscriptLines(evidence).join("\n"); + + expect(lines).toContain("transcript.tool-request"); + expect(lines).toContain("transcript.approval-decision"); + expect(lines).toContain("transcript.sandbox-policy"); + expect(lines).toContain("transcript.tool-result"); + expect(lines).toContain("transcript.tool-error"); + expect(lines).toContain("transcript.timeout-recovery"); + expect(lines).toContain("evidence.tool timeline: satisfied"); + expect(lines).toContain("evidence.approval decision: satisfied"); + expect(lines).toContain("evidence.sandbox policy: satisfied"); + expect(lines).toContain("evidence.error recovery transcript: satisfied"); + expect(lines).toContain("failure.approval bypass: covered"); + expect(lines).toContain("failure.tool result missing: covered"); + expect(lines).toContain("failure.timeout without recovery: covered"); + expect(lines).toContain("failure.unsafe tool exposed: covered"); + }); +}); diff --git a/scripts/lib/i18n-patch-metrics-report-core.mjs b/scripts/lib/i18n-patch-metrics-report-core.mjs new file mode 100644 index 000000000..787d13340 --- /dev/null +++ b/scripts/lib/i18n-patch-metrics-report-core.mjs @@ -0,0 +1,221 @@ +const REPORT_SCHEMA_VERSION = "lime.i18n.patchMetricsReport.v1"; + +function asFiniteNumber(value, fallback = 0) { + return Number.isFinite(Number(value)) ? Number(value) : fallback; +} + +function asNonNegativeNumber(value, fallback = 0) { + return Math.max(0, asFiniteNumber(value, fallback)); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function asString(value, fallback = "") { + return typeof value === "string" ? value : fallback; +} + +function normalizeRun(run) { + return { + durationMs: asNonNegativeNumber(run?.durationMs), + language: asString(run?.language, "unknown"), + matchedSegments: asNonNegativeNumber(run?.matchedSegments), + replacedNodes: asNonNegativeNumber(run?.replacedNodes), + rootKind: asString(run?.rootKind, "unknown"), + timestamp: asNonNegativeNumber(run?.timestamp), + }; +} + +function sumRuns(runs, field) { + return runs.reduce((sum, run) => sum + asNonNegativeNumber(run[field]), 0); +} + +function normalizeRuntimeMetrics(metrics) { + const recentRuns = asArray(metrics?.recentRuns ?? metrics?.runs).map(normalizeRun); + const patchTimes = asArray(metrics?.patchTimes).map((value) => + asNonNegativeNumber(value), + ); + const totalPatchTime = patchTimes.reduce((sum, value) => sum + value, 0); + + const totalRuns = asNonNegativeNumber( + metrics?.totalRuns, + recentRuns.length, + ); + const totalReplacedNodes = asNonNegativeNumber( + metrics?.totalReplacedNodes, + sumRuns(recentRuns, "replacedNodes"), + ); + const totalMatchedSegments = asNonNegativeNumber( + metrics?.totalMatchedSegments, + sumRuns(recentRuns, "matchedSegments"), + ); + const averagePatchTimeMs = asNonNegativeNumber( + metrics?.averagePatchTimeMs, + patchTimes.length > 0 ? totalPatchTime / patchTimes.length : 0, + ); + const slowestPatchTimeMs = asNonNegativeNumber( + metrics?.slowestPatchTimeMs, + patchTimes.length > 0 ? Math.max(...patchTimes) : 0, + ); + const lastRun = metrics?.lastRun + ? normalizeRun(metrics.lastRun) + : (recentRuns.at(-1) ?? null); + + return { + averagePatchTimeMs, + languageChanges: asNonNegativeNumber(metrics?.languageChanges), + lastRun, + recentRuns, + slowestPatchTimeMs, + totalMatchedSegments, + totalReplacedNodes, + totalRuns, + }; +} + +function resolveStatus(summary) { + if (summary.totalRuns === 0) { + return "missing-metrics"; + } + + if ( + summary.totalMatchedSegments === 0 && + summary.totalReplacedNodes === 0 + ) { + return "no-hit"; + } + + return "active-patch"; +} + +function buildRecommendations(status) { + if (status === "missing-metrics") { + return [ + "先在 GUI smoke 或 Playwright 验证中导出 window.__I18N_METRICS__,再判断 Patch 退出进度。", + ]; + } + + if (status === "no-hit") { + return [ + "当前样本未命中 legacy Patch,可进入 current 主路径依赖审计,但还不能直接删除 DOM replacer。", + ]; + } + + return [ + "仍有 legacy Patch 命中,优先把 recentRuns 覆盖到的页面文案迁入 key-based namespace。", + ]; +} + +function normalizeThresholds(thresholds = {}) { + return { + maxMatchedSegments: + thresholds.maxMatchedSegments === undefined + ? null + : asNonNegativeNumber(thresholds.maxMatchedSegments), + maxReplacedNodes: + thresholds.maxReplacedNodes === undefined + ? null + : asNonNegativeNumber(thresholds.maxReplacedNodes), + maxRuns: + thresholds.maxRuns === undefined + ? null + : asNonNegativeNumber(thresholds.maxRuns), + }; +} + +function buildThresholdIssues(summary, thresholds) { + const issues = []; + const checks = [ + ["maxMatchedSegments", "totalMatchedSegments", "命中文本段数"], + ["maxReplacedNodes", "totalReplacedNodes", "替换节点数"], + ["maxRuns", "totalRuns", "Patch 运行次数"], + ]; + + for (const [thresholdKey, summaryKey, label] of checks) { + const threshold = thresholds[thresholdKey]; + if (threshold === null || threshold === undefined) { + continue; + } + const actual = summary[summaryKey]; + if (actual > threshold) { + issues.push({ + actual, + field: summaryKey, + message: `${label} ${actual} 超过门限 ${threshold}`, + threshold, + }); + } + } + + return issues; +} + +export function createI18nPatchMetricsReport({ + generatedAt = new Date().toISOString(), + metrics, + sourcePath = "", + thresholds, +} = {}) { + const normalizedMetrics = normalizeRuntimeMetrics(metrics ?? {}); + const status = resolveStatus(normalizedMetrics); + const normalizedThresholds = normalizeThresholds(thresholds); + const thresholdIssues = buildThresholdIssues( + normalizedMetrics, + normalizedThresholds, + ); + + return { + schemaVersion: REPORT_SCHEMA_VERSION, + generatedAt, + sourcePath, + status, + retirementCandidate: status === "no-hit", + summary: { + averagePatchTimeMs: normalizedMetrics.averagePatchTimeMs, + languageChanges: normalizedMetrics.languageChanges, + lastRunAt: normalizedMetrics.lastRun?.timestamp ?? null, + lastRunLanguage: normalizedMetrics.lastRun?.language ?? null, + lastRunRootKind: normalizedMetrics.lastRun?.rootKind ?? null, + recentRunCount: normalizedMetrics.recentRuns.length, + slowestPatchTimeMs: normalizedMetrics.slowestPatchTimeMs, + totalMatchedSegments: normalizedMetrics.totalMatchedSegments, + totalReplacedNodes: normalizedMetrics.totalReplacedNodes, + totalRuns: normalizedMetrics.totalRuns, + }, + thresholds: normalizedThresholds, + thresholdIssues, + recentRuns: normalizedMetrics.recentRuns, + recommendations: buildRecommendations(status), + }; +} + +export function renderI18nPatchMetricsTextReport(report) { + const lines = [ + "Lime i18n Patch Metrics Report", + `状态: ${report.status}`, + `来源: ${report.sourcePath || "(未指定)"}`, + `生成时间: ${report.generatedAt}`, + `Patch 运行次数: ${report.summary.totalRuns}`, + `替换节点数: ${report.summary.totalReplacedNodes}`, + `命中文本段数: ${report.summary.totalMatchedSegments}`, + `语言切换次数: ${report.summary.languageChanges}`, + `平均耗时: ${report.summary.averagePatchTimeMs.toFixed(2)}ms`, + `最慢耗时: ${report.summary.slowestPatchTimeMs.toFixed(2)}ms`, + `退出候选: ${report.retirementCandidate ? "yes" : "no"}`, + ]; + + if (report.thresholdIssues.length > 0) { + lines.push("", "门限问题:"); + for (const issue of report.thresholdIssues) { + lines.push(`- ${issue.message}`); + } + } + + lines.push("", "建议:"); + for (const recommendation of report.recommendations) { + lines.push(`- ${recommendation}`); + } + + return `${lines.join("\n")}\n`; +} diff --git a/scripts/lib/i18n-patch-metrics-report-core.test.ts b/scripts/lib/i18n-patch-metrics-report-core.test.ts new file mode 100644 index 000000000..173351d7c --- /dev/null +++ b/scripts/lib/i18n-patch-metrics-report-core.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { + createI18nPatchMetricsReport, + renderI18nPatchMetricsTextReport, +} from "./i18n-patch-metrics-report-core.mjs"; + +describe("i18n-patch-metrics-report-core", () => { + it("应把 runtime metrics 转成稳定报告,并保留门限问题", () => { + const report = createI18nPatchMetricsReport({ + generatedAt: "2026-05-10T00:00:00.000Z", + sourcePath: ".lime/i18n/patch-metrics.json", + thresholds: { + maxMatchedSegments: 1, + maxReplacedNodes: 1, + }, + metrics: { + patchTimes: [10, 20], + languageChanges: 2, + totalRuns: 2, + totalReplacedNodes: 3, + totalMatchedSegments: 4, + runs: [ + { + durationMs: 10, + language: "en", + matchedSegments: 4, + replacedNodes: 3, + rootKind: "document", + timestamp: 1778400000000, + }, + ], + }, + }); + + expect(report.schemaVersion).toBe("lime.i18n.patchMetricsReport.v1"); + expect(report.status).toBe("active-patch"); + expect(report.retirementCandidate).toBe(false); + expect(report.summary.totalMatchedSegments).toBe(4); + expect(report.summary.averagePatchTimeMs).toBe(15); + expect(report.thresholdIssues.map((issue) => issue.field)).toEqual([ + "totalMatchedSegments", + "totalReplacedNodes", + ]); + }); + + it("应识别零命中样本为退出候选,但不等价于可直接删除", () => { + const report = createI18nPatchMetricsReport({ + metrics: { + languageChanges: 1, + recentRuns: [ + { + durationMs: 2, + language: "en", + matchedSegments: 0, + replacedNodes: 0, + rootKind: "document", + timestamp: 1778400000000, + }, + ], + totalMatchedSegments: 0, + totalReplacedNodes: 0, + totalRuns: 1, + }, + }); + + expect(report.status).toBe("no-hit"); + expect(report.retirementCandidate).toBe(true); + expect(report.recommendations.join("\n")).toContain("不能直接删除"); + }); + + it("应渲染可读文本报告", () => { + const report = createI18nPatchMetricsReport({ + generatedAt: "2026-05-10T00:00:00.000Z", + metrics: {}, + sourcePath: "", + }); + const text = renderI18nPatchMetricsTextReport(report); + + expect(text).toContain("Lime i18n Patch Metrics Report"); + expect(text).toContain("状态: missing-metrics"); + expect(text).toContain("退出候选: no"); + }); +}); diff --git a/scripts/lib/vitest-smoke-runner.mjs b/scripts/lib/vitest-smoke-runner.mjs new file mode 100644 index 000000000..461e19ef5 --- /dev/null +++ b/scripts/lib/vitest-smoke-runner.mjs @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const TauriAliasPatterns = [ + ["^@tauri-apps/api/core$", "core.ts"], + ["^@tauri-apps/api/event$", "event.ts"], + ["^@tauri-apps/api/window$", "window.ts"], + ["^@tauri-apps/api/app$", "window.ts"], + ["^@tauri-apps/api/path$", "window.ts"], + ["^@tauri-apps/plugin-dialog$", "plugin-dialog.ts"], + ["^@tauri-apps/plugin-shell$", "plugin-shell.ts"], + ["^@tauri-apps/plugin-deep-link$", "plugin-deep-link.ts"], + ["^@tauri-apps/plugin-global-shortcut$", "plugin-global-shortcut.ts"], +]; + +function toFileUrl(filePath) { + return pathToFileURL(filePath).href; +} + +function createVitestSmokeConfig(rootDir) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lime-vitest-smoke-")); + const configPath = path.join(tempDir, "vitest.config.mjs"); + const cacheDir = path.join(tempDir, "vite-cache"); + const tauriMockDir = path.join(rootDir, "src/lib/tauri-mock"); + const aliasSpecs = TauriAliasPatterns.map(([pattern, target]) => ({ + pattern, + replacement: path.join(tauriMockDir, target), + })); + + fs.writeFileSync( + configPath, + `import path from "node:path";\n` + + `import { defineConfig } from ${JSON.stringify( + toFileUrl(path.join(rootDir, "node_modules/vite/dist/node/index.js")), + )};\n` + + `import react from ${JSON.stringify( + toFileUrl( + path.join(rootDir, "node_modules/@vitejs/plugin-react/dist/index.js"), + ), + )};\n` + + `import svgr from ${JSON.stringify( + toFileUrl(path.join(rootDir, "node_modules/vite-plugin-svgr/dist/index.js")), + )};\n` + + `const rootDir = ${JSON.stringify(rootDir)};\n` + + `const aliasSpecs = ${JSON.stringify(aliasSpecs)};\n` + + `export default defineConfig({\n` + + ` root: rootDir,\n` + + ` cacheDir: ${JSON.stringify(cacheDir)},\n` + + ` define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify(process.env.VITE_APP_VERSION || "test") },\n` + + ` plugins: [react({ jsxRuntime: "automatic", jsxImportSource: "react", babel: { compact: true } }), svgr()],\n` + + ` resolve: { alias: [\n` + + ` { find: "@", replacement: path.resolve(rootDir, "src") },\n` + + ` ...aliasSpecs.map((entry) => ({ find: new RegExp(entry.pattern), replacement: entry.replacement })),\n` + + ` ] },\n` + + ` test: {\n` + + ` globals: true,\n` + + ` environment: "jsdom",\n` + + ` exclude: ["**/node_modules/**", "**/dist/**", "**/src-tauri/target/**"],\n` + + ` },\n` + + `});\n`, + ); + + return { + configPath, + cleanup() { + fs.rmSync(tempDir, { recursive: true, force: true }); + }, + }; +} + +export function runVitestSmoke({ rootDir, label, args, logPrefix, env }) { + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + const config = createVitestSmokeConfig(rootDir); + const startedAt = Date.now(); + + console.log(`\n[${logPrefix}] > ${label}`); + + try { + const result = spawnSync( + npmCommand, + ["exec", "--", "vitest", "run", ...args, "--config", config.configPath], + { + cwd: rootDir, + stdio: "inherit", + env: { ...process.env, ...env }, + }, + ); + + if (result.error) { + throw result.error; + } + + if (typeof result.status === "number" && result.status !== 0) { + const error = new Error(`[${logPrefix}] ${label} 失败`); + error.exitCode = result.status; + throw error; + } + + return { + label, + status: "pass", + durationMs: Date.now() - startedAt, + args, + }; + } finally { + config.cleanup(); + } +} diff --git a/scripts/local-ci.mjs b/scripts/local-ci.mjs index 77c8d718e..74c2db76b 100644 --- a/scripts/local-ci.mjs +++ b/scripts/local-ci.mjs @@ -11,6 +11,7 @@ const rootDir = process.cwd(); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const cargoCommand = process.platform === "win32" ? "cargo.exe" : "cargo"; const BRIDGE_REASON_LABELS = { + agent_qc_contract: "Agent QC contract", bridge_contracts: "bridge/contracts", bridge_runtime: "DevBridge / mock / bridge runtime", fallback_full_suite: "兜底全量", diff --git a/scripts/quality-task-planner.mjs b/scripts/quality-task-planner.mjs index 9d6603e32..be3433076 100644 --- a/scripts/quality-task-planner.mjs +++ b/scripts/quality-task-planner.mjs @@ -42,11 +42,26 @@ const FRONTEND_TOOLING_FILES = new Set([ const BRIDGE_FILES = new Set([ "vite.config.ts", + "scripts/agent-qc-completion-audit.mjs", + "docs/test/agent-qc-evidence.schema.json", + "docs/test/agent-qc-gui-flows.manifest.json", + "docs/test/agent-qc-scenarios.manifest.json", + "scripts/agent-qc-export-evidence.mjs", + "scripts/agent-qc-gui-flow-report.mjs", + "scripts/agent-qc-qcloop-job.mjs", + "scripts/agent-qc-release-summary.mjs", + "scripts/agent-qc-report.mjs", "scripts/check-command-contracts.mjs", "scripts/check-generated-slop-report.mjs", "scripts/check-dev-bridge-health.mjs", "scripts/harness-eval-history-record.mjs", "scripts/harness-eval-trend-report.mjs", + "scripts/lib/agent-qc-completion-audit-core.mjs", + "scripts/lib/agent-qc-evidence-core.mjs", + "scripts/lib/agent-qc-gui-flow-core.mjs", + "scripts/lib/agent-qc-qcloop-job-core.mjs", + "scripts/lib/agent-qc-release-summary-core.mjs", + "scripts/lib/agent-qc-report-core.mjs", "scripts/report-generated-slop.mjs", "scripts/social-workbench-e2e-smoke.mjs", "scripts/chrome-bridge-e2e.mjs", @@ -65,6 +80,27 @@ const HARNESS_CLEANUP_CONTRACT_FILES = new Set([ "scripts/lib/harness-dashboard-core.mjs", ]); +const AGENT_QC_CONTRACT_FILES = new Set([ + "docs/tests/agent-ops-qc.md", + "docs/tests/agent-qc-p0-scenarios.md", + "docs/tests/lime-agent-qc-rollout-plan.md", + "docs/test/agent-qc-evidence.schema.json", + "scripts/agent-qc-completion-audit.mjs", + "docs/test/agent-qc-gui-flows.manifest.json", + "docs/test/agent-qc-scenarios.manifest.json", + "scripts/agent-qc-export-evidence.mjs", + "scripts/agent-qc-gui-flow-report.mjs", + "scripts/agent-qc-qcloop-job.mjs", + "scripts/agent-qc-release-summary.mjs", + "scripts/agent-qc-report.mjs", + "scripts/lib/agent-qc-completion-audit-core.mjs", + "scripts/lib/agent-qc-evidence-core.mjs", + "scripts/lib/agent-qc-gui-flow-core.mjs", + "scripts/lib/agent-qc-qcloop-job-core.mjs", + "scripts/lib/agent-qc-release-summary-core.mjs", + "scripts/lib/agent-qc-report-core.mjs", +]); + const INTEGRITY_FILES = new Set([ "package.json", "packages/lime-cli-npm/package.json", @@ -264,7 +300,10 @@ function isDocsChange(file) { } function isDocsOnlyChange(files) { - return files.length > 0 && files.every((file) => isDocsChange(file)); + return ( + files.length > 0 && + files.every((file) => isDocsChange(file) && !isAgentQcContractChange(file)) + ); } function isFrontendChange(file) { @@ -291,6 +330,10 @@ function isHarnessCleanupContractChange(file) { return HARNESS_CLEANUP_CONTRACT_FILES.has(file); } +function isAgentQcContractChange(file) { + return AGENT_QC_CONTRACT_FILES.has(file); +} + function collectBridgeReasons( changedFiles, { full = false, fallback = false, workflow = false } = {}, @@ -309,6 +352,10 @@ function collectBridgeReasons( const reasons = []; + if (changedFiles.some(isAgentQcContractChange)) { + reasons.push("agent_qc_contract"); + } + if (changedFiles.some(isHarnessCleanupContractChange)) { reasons.push("harness_cleanup_contract"); } diff --git a/scripts/quality-task-planner.test.ts b/scripts/quality-task-planner.test.ts index d84ef1ceb..c62073522 100644 --- a/scripts/quality-task-planner.test.ts +++ b/scripts/quality-task-planner.test.ts @@ -3,6 +3,26 @@ import { describe, expect, it } from "vitest"; import { detectTasks } from "./quality-task-planner.mjs"; describe("quality-task-planner", () => { + it("应把 Agent QC manifest/schema 归到 bridge/contracts 风险", () => { + const tasks = detectTasks([ + "docs/tests/agent-ops-qc.md", + "docs/tests/agent-qc-p0-scenarios.md", + "docs/tests/lime-agent-qc-rollout-plan.md", + "docs/test/agent-qc-scenarios.manifest.json", + "docs/test/agent-qc-evidence.schema.json", + "docs/test/agent-qc-gui-flows.manifest.json", + "scripts/lib/agent-qc-completion-audit-core.mjs", + "scripts/lib/agent-qc-evidence-core.mjs", + "scripts/lib/agent-qc-gui-flow-core.mjs", + "scripts/lib/agent-qc-qcloop-job-core.mjs", + "scripts/lib/agent-qc-release-summary-core.mjs", + ]); + + expect(tasks.bridge).toBe(true); + expect(tasks.bridgeReasons).toContain("agent_qc_contract"); + expect(tasks.docsOnly).toBe(false); + }); + it("应把 harness cleanup/report 主链文件归到 bridge/contracts 风险", () => { const tasks = detectTasks([ "scripts/lib/generated-slop-report-core.mjs", diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs index 6970d13a4..2287e9f8b 100644 --- a/scripts/verify-gui-smoke.mjs +++ b/scripts/verify-gui-smoke.mjs @@ -6,6 +6,10 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; +import { + createI18nPatchMetricsReport, + renderI18nPatchMetricsTextReport, +} from "./lib/i18n-patch-metrics-report-core.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -40,6 +44,18 @@ const HEADLESS_TAURI_CONFIG_PATH = path.join( "src-tauri", "tauri.conf.headless.json", ); +const DEFAULT_I18N_PATCH_METRICS_OUTPUT = path.join( + rootDir, + ".lime", + "i18n", + "patch-metrics.json", +); +const DEFAULT_I18N_PATCH_REPORT_OUTPUT = path.join( + rootDir, + ".lime", + "i18n", + "patch-metrics-report.json", +); const tauriCommand = process.platform === "win32" ? path.join(rootDir, "node_modules", ".bin", "tauri.cmd") @@ -173,6 +189,9 @@ const DEFAULTS = { intervalMs: 1_000, reuseRunning: false, includeKnowledgeProductE2e: false, + i18nPatchMetricsOutput: DEFAULT_I18N_PATCH_METRICS_OUTPUT, + i18nPatchReportOutput: DEFAULT_I18N_PATCH_REPORT_OUTPUT, + skipI18nPatchMetrics: false, sampleProjectName: "Lime Smoke Workspace", }; DEFAULTS.timeoutMs = resolveDefaultTimeoutMs(DEFAULTS.cargoTargetDir); @@ -213,6 +232,11 @@ Lime GUI 冒烟入口 --reuse-running 复用已启动的 headless Tauri,不主动拉起 --include-knowledge-product-e2e 额外执行项目资料产品 E2E 闭环验收 + --i18n-patch-metrics-output + 导出 GUI 页面上的 window.__I18N_METRICS__,默认 .lime/i18n/patch-metrics.json + --i18n-patch-report-output + 写入 Patch metrics JSON 报告,默认 .lime/i18n/patch-metrics-report.json + --skip-i18n-patch-metrics 跳过 Patch metrics 导出与报告 -h, --help 显示帮助 `); } @@ -278,6 +302,27 @@ function parseArgs(argv) { continue; } + if (arg === "--i18n-patch-metrics-output" && argv[index + 1]) { + options.i18nPatchMetricsOutput = path.resolve( + String(argv[index + 1]).trim(), + ); + index += 1; + continue; + } + + if (arg === "--i18n-patch-report-output" && argv[index + 1]) { + options.i18nPatchReportOutput = path.resolve( + String(argv[index + 1]).trim(), + ); + index += 1; + continue; + } + + if (arg === "--skip-i18n-patch-metrics") { + options.skipI18nPatchMetrics = true; + continue; + } + if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); @@ -315,6 +360,15 @@ function parseArgs(argv) { throw new Error("--cargo-target-dir 不能为空"); } + if (!options.skipI18nPatchMetrics) { + if (!options.i18nPatchMetricsOutput) { + throw new Error("--i18n-patch-metrics-output 不能为空"); + } + if (!options.i18nPatchReportOutput) { + throw new Error("--i18n-patch-report-output 不能为空"); + } + } + return options; } @@ -855,6 +909,45 @@ async function cleanupStaleGuiSmokeChromeProfiles( } } +function writeI18nPatchMetricsReport(options) { + if (options.skipI18nPatchMetrics) { + return; + } + + if (!fs.existsSync(options.i18nPatchMetricsOutput)) { + console.warn( + `[verify:gui-smoke] 未发现 i18n Patch metrics: ${options.i18nPatchMetricsOutput}`, + ); + return; + } + + const metrics = JSON.parse( + fs.readFileSync(options.i18nPatchMetricsOutput, "utf8"), + ); + const report = createI18nPatchMetricsReport({ + metrics, + sourcePath: path.relative(rootDir, options.i18nPatchMetricsOutput), + }); + fs.mkdirSync(path.dirname(options.i18nPatchReportOutput), { + recursive: true, + }); + fs.writeFileSync( + options.i18nPatchReportOutput, + JSON.stringify(report, null, 2), + "utf8", + ); + console.log( + renderI18nPatchMetricsTextReport(report) + .trim() + .split("\n") + .map((line) => `[verify:gui-smoke] ${line}`) + .join("\n"), + ); + console.log( + `[verify:gui-smoke] i18n Patch metrics report: ${options.i18nPatchReportOutput}`, + ); +} + function listListeningCommandsForPort(port) { if (!port || process.platform === "win32") { return []; @@ -1481,7 +1574,7 @@ async function main() { npmCommand, [ "run", - "smoke:knowledge-gui", + "smoke:claw-chat-ready-streaming", "--", "--app-url", options.appUrl, @@ -1494,10 +1587,39 @@ async function main() { "--interval-ms", String(options.intervalMs), ], + "smoke:claw-chat-ready-streaming", + options.timeoutMs + 30_000, + ); + + await runCommand( + npmCommand, + [ + "run", + "smoke:knowledge-gui", + "--", + "--app-url", + options.appUrl, + "--health-url", + options.healthUrl, + "--invoke-url", + options.invokeUrl, + "--timeout-ms", + String(options.timeoutMs), + "--interval-ms", + String(options.intervalMs), + ...(options.skipI18nPatchMetrics + ? [] + : [ + "--i18n-patch-metrics-output", + options.i18nPatchMetricsOutput, + ]), + ], "smoke:knowledge-gui", options.timeoutMs + 30_000, ); + writeI18nPatchMetricsReport(options); + if (options.includeKnowledgeProductE2e) { await runCommand( npmCommand, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index efe622d3c..a21f96d55 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5065,7 +5065,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.32.0" +version = "1.33.0" dependencies = [ "anyhow", "arboard", @@ -5172,7 +5172,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.32.0" +version = "1.33.0" dependencies = [ "anyhow", "aster-core", @@ -5201,7 +5201,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.32.0" +version = "1.33.0" dependencies = [ "chrono", "futures", @@ -5218,7 +5218,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.32.0" +version = "1.33.0" dependencies = [ "clap", "lime-core", @@ -5230,7 +5230,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.32.0" +version = "1.33.0" dependencies = [ "async-trait", "lime-core", @@ -5246,7 +5246,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.32.0" +version = "1.33.0" dependencies = [ "aster-models", "async-trait", @@ -5299,7 +5299,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.32.0" +version = "1.33.0" dependencies = [ "aes", "axum 0.7.9", @@ -5329,7 +5329,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.32.0" +version = "1.33.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5349,7 +5349,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.32.0" +version = "1.33.0" dependencies = [ "chrono", "hex", @@ -5362,7 +5362,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.32.0" +version = "1.33.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5378,7 +5378,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.32.0" +version = "1.33.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5411,7 +5411,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.32.0" +version = "1.33.0" dependencies = [ "async-trait", "lime-core", @@ -5430,7 +5430,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.32.0" +version = "1.33.0" dependencies = [ "anyhow", "async-stream", @@ -5485,7 +5485,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.32.0" +version = "1.33.0" dependencies = [ "aster-core", "async-stream", @@ -5529,7 +5529,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.32.0" +version = "1.33.0" dependencies = [ "axum 0.7.9", "futures", @@ -5544,7 +5544,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.32.0" +version = "1.33.0" dependencies = [ "anyhow", "aster-core", @@ -5589,7 +5589,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.32.0" +version = "1.33.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5607,7 +5607,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.32.0" +version = "1.33.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 834d79d92..c03625a16 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,7 +9,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.32.0" +version = "1.33.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -198,7 +198,7 @@ version = "2.4" [package] name = "lime" -version = "1.32.0" +version = "1.33.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/aster_state.rs b/src-tauri/crates/agent/src/aster_state.rs index 5ac4effa4..615043b66 100644 --- a/src-tauri/crates/agent/src/aster_state.rs +++ b/src-tauri/crates/agent/src/aster_state.rs @@ -505,10 +505,14 @@ impl AsterAgentState { /// 创建新的取消令牌 pub async fn create_cancel_token(&self, session_id: &str) -> CancellationToken { + let should_cancel_immediately = { + let markers = self.interrupt_markers.read().await; + markers.contains_key(session_id) + }; let token = CancellationToken::new(); - let mut markers = self.interrupt_markers.write().await; - markers.remove(session_id); - drop(markers); + if should_cancel_immediately { + token.cancel(); + } let mut tokens = self.cancel_tokens.write().await; tokens.insert(session_id.to_string(), token.clone()); token @@ -809,8 +813,9 @@ mod tests { Some("用户主动停止当前执行") ); - let _token = state.create_cancel_token(session_id).await; - assert!(state.get_interrupt_marker(session_id).await.is_none()); + let token = state.create_cancel_token(session_id).await; + assert!(token.is_cancelled()); + assert!(state.get_interrupt_marker(session_id).await.is_some()); state .record_interrupt_request(session_id, "user", "第二次停止") diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index a4f89ab57..ed035533b 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -90,10 +90,10 @@ pub use provider_runtime_governor::{ pub use queued_turn::QueuedTurnSnapshot; pub use request_tool_policy::{ execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy, - merge_system_prompt_with_web_search_preflight_context, message_suggests_news_expansion, - resolve_request_tool_policy, resolve_request_tool_policy_with_mode, stream_reply_with_policy, - ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode, StreamReplyExecution, - WebSearchExecutionTracker, WebSearchPreflightRequest, REQUEST_TOOL_POLICY_MARKER, + merge_system_prompt_with_web_search_preflight_context, resolve_request_tool_policy, + resolve_request_tool_policy_with_mode, stream_reply_with_policy, ReplyAttemptError, + RequestToolPolicy, RequestToolPolicyMode, StreamReplyExecution, WebSearchExecutionTracker, + WebSearchPreflightRequest, REQUEST_TOOL_POLICY_MARKER, }; pub use runtime_projection_snapshot::RuntimeProjectionSnapshot; pub use runtime_queue::{ diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index fb8b396df..f3f44d263 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -10,8 +10,8 @@ use crate::protocol_projection::project_runtime_event; use crate::write_artifact_events::WriteArtifactEventEmitter; use aster::agents::{Agent, AgentEvent as AsterAgentEvent}; use aster::conversation::message::{Message, MessageContent, SystemNotificationType}; +use aster::session::SessionManager; use aster::tools::ToolContext; -use chrono::{Datelike, Local, NaiveDate}; use futures::{stream, StreamExt}; use lime_core::env_compat; use regex::Regex; @@ -52,7 +52,6 @@ const STREAM_EVENT_DIAG_WARN_TEXT_DELTA_CHARS: usize = 2_000; const STREAM_EVENT_DIAG_WARN_TOOL_OUTPUT_CHARS: usize = 8_000; const STREAM_EVENT_DIAG_WARN_CONTEXT_STEPS: usize = 24; const TEXT_DELTA_BATCH_BACKLOG_CHARS: usize = 120; -const NEWS_PREFLIGHT_QUERY_LIMIT: usize = 4; const NEWS_PREFLIGHT_QUERY_PARALLELISM: usize = 4; const NEWS_PREFLIGHT_QUERY_OUTPUT_CHAR_LIMIT: usize = 1_600; const NEWS_PREFLIGHT_CONTEXT_CHAR_LIMIT: usize = 6_000; @@ -63,7 +62,8 @@ const ASTER_AUTO_COMPACTION_COMPLETE_TEXT: &str = "Compaction complete"; const ASTER_AUTO_COMPACTION_THINKING_TEXT: &str = "aster is compacting the conversation..."; const ASTER_AUTO_COMPACTION_ERROR_PREFIX: &str = "Ran into this error trying to compact:"; const ASTER_AUTO_COMPACTION_DISABLED_TEXT: &str = "Automatic compaction is disabled for this turn. The conversation reached the context limit. Compact the session manually or start a new session before retrying."; -const OPTIONAL_WEB_SEARCH_PREFLIGHT_TIMEOUT_MS: u64 = 1_500; +const CANCELLED_TURN_CONTEXT_MARKER: &str = + "上一回合已被用户停止,不要继续回答被停止的请求;等待并仅处理后续用户消息。"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -165,19 +165,6 @@ impl WebSearchExecutionTracker { } } - fn absorb(&mut self, mut other: Self) { - for tool_id in other.ordered_tool_ids.drain(..) { - if self.attempts_by_id.contains_key(&tool_id) { - continue; - } - - if let Some(record) = other.attempts_by_id.remove(&tool_id) { - self.ordered_tool_ids.push(tool_id.clone()); - self.attempts_by_id.insert(tool_id, record); - } - } - } - pub fn validate_web_search_requirement( &self, policy: &RequestToolPolicy, @@ -262,7 +249,6 @@ pub struct PreflightToolExecution { pub planned_queries: Vec, pub system_prompt_appendix: Option, pub coverage_summary: Option, - pub expanded_news_search: bool, } pub struct WebSearchPreflightRequest<'a> { @@ -282,7 +268,6 @@ impl PreflightToolExecution { planned_queries: Vec::new(), system_prompt_appendix: None, coverage_summary: None, - expanded_news_search: false, } } } @@ -460,6 +445,52 @@ pub struct StreamReplyExecution { pub event_errors: Vec, pub emitted_any: bool, pub attempts_summary: String, + pub cancelled: bool, +} + +fn is_reply_cancelled(cancel_token: &Option) -> bool { + cancel_token + .as_ref() + .is_some_and(CancellationToken::is_cancelled) +} + +fn build_stream_reply_execution( + text_output: String, + event_errors: Vec, + emitted_any: bool, + attempts_summary: String, + cancelled: bool, +) -> StreamReplyExecution { + StreamReplyExecution { + text_output, + event_errors, + emitted_any, + attempts_summary, + cancelled, + } +} + +fn cancelled_turn_context_marker_message() -> Message { + Message::assistant() + .with_text(CANCELLED_TURN_CONTEXT_MARKER) + .agent_only() +} + +async fn persist_cancelled_turn_context_marker(agent: &Agent, session_id: &str) { + let message = cancelled_turn_context_marker_message(); + let result = if let Some(store) = agent.session_store() { + store.add_message(session_id, &message).await + } else { + SessionManager::add_message(session_id, &message).await + }; + + if let Err(error) = result { + tracing::warn!( + "[AsterAgent][ReplyPolicy] 写入取消上下文标记失败,已降级继续: session_id={}, error={}", + session_id, + error + ); + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -858,93 +889,6 @@ fn normalize_tool_name(value: &str) -> String { .collect::() } -pub fn message_suggests_news_expansion(message: &str) -> bool { - let trimmed = message.trim(); - if trimmed.is_empty() { - return false; - } - - let normalized = trimmed.to_ascii_lowercase(); - let has_news_keyword = [ - "新闻", - "快讯", - "头条", - "要闻", - "news", - "headline", - "headlines", - "briefing", - "roundup", - "recap", - "digest", - ] - .iter() - .any(|keyword| normalized.contains(keyword)); - if !has_news_keyword { - return false; - } - - let has_time_keyword = [ - "今天", - "今日", - "昨天", - "昨晚", - "最新", - "实时", - "本周", - "这周", - "today", - "latest", - "breaking", - "march", - "april", - "may", - "june", - "july", - "august", - "september", - "october", - "november", - "december", - "january", - "february", - ] - .iter() - .any(|keyword| normalized.contains(keyword)); - let has_summary_keyword = [ - "汇总", - "综述", - "盘点", - "整理", - "总结", - "写一篇", - "写成", - "简报", - "日报", - "报道", - "summary", - "summarize", - "wrap up", - "report", - "brief", - "briefing", - ] - .iter() - .any(|keyword| normalized.contains(keyword)); - let has_explicit_date = Regex::new(r"\d{1,2}月\d{1,2}日") - .ok() - .map(|re| re.is_match(trimmed)) - .unwrap_or(false) - || Regex::new( - r"\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\s+\d{1,2}\b", - ) - .ok() - .map(|re| re.is_match(&normalized)) - .unwrap_or(false); - - has_time_keyword || has_summary_keyword || has_explicit_date -} - pub fn merge_system_prompt_with_web_search_preflight_context( base_prompt: Option, appendix: Option, @@ -965,201 +909,15 @@ pub fn merge_system_prompt_with_web_search_preflight_context( } } -fn should_run_web_search_preflight(policy: &RequestToolPolicy, message_text: &str) -> bool { +fn should_run_web_search_preflight(policy: &RequestToolPolicy, _message_text: &str) -> bool { if !is_web_search_preflight_enabled() { return false; } policy.requires_web_search() - || (policy.allows_web_search() && message_suggests_news_expansion(message_text)) } -fn split_before_followup_clause(message_text: &str) -> String { - let mut candidate = message_text.trim().replace('\n', " "); - for delimiter in [ - ",并", - ",并", - "并将", - "并且", - "然后", - "再把", - "再将", - "再帮我", - " afterwards ", - " and then ", - " then ", - ] { - if let Some((head, _)) = candidate.split_once(delimiter) { - candidate = head.trim().to_string(); - break; - } - } - candidate -} - -fn sanitize_news_search_clause(message_text: &str) -> String { - let mut candidate = split_before_followup_clause(message_text); - for prefix in [ - "请帮我", - "帮我", - "麻烦你", - "请你", - "请", - "帮忙", - "替我", - "能否", - "可以", - ] { - candidate = candidate.trim_start_matches(prefix).trim().to_string(); - } - for verb in [ - "找一下", - "搜一下", - "搜索", - "查一下", - "查找", - "检索", - "收集", - "整理", - "找", - "搜", - "查", - ] { - candidate = candidate.trim_start_matches(verb).trim().to_string(); - } - - let collapsed = candidate - .replace(['?', '?', '。', ',', ','], " ") - .split_whitespace() - .collect::>() - .join(" "); - if collapsed.is_empty() { - derive_preflight_query(message_text) - } else { - collapsed - } -} - -fn month_name(month: u32) -> &'static str { - match month { - 1 => "January", - 2 => "February", - 3 => "March", - 4 => "April", - 5 => "May", - 6 => "June", - 7 => "July", - 8 => "August", - 9 => "September", - 10 => "October", - 11 => "November", - 12 => "December", - _ => "March", - } -} - -fn resolve_topic_labels(message_text: &str) -> (&'static str, &'static str) { - let normalized = message_text.to_ascii_lowercase(); - if normalized.contains("国际") - || normalized.contains("international") - || normalized.contains("world") - { - ("国际新闻", "international news") - } else if normalized.contains("国内") || normalized.contains("china") { - ("国内新闻", "china news") - } else if normalized.contains("科技") - || normalized.contains("ai ") - || normalized.contains("ai新闻") - { - ("科技新闻", "technology news") - } else { - ("新闻", "news") - } -} - -fn resolve_absolute_news_date(message_text: &str, today: NaiveDate) -> Option<(String, String)> { - let normalized = message_text.to_ascii_lowercase(); - if normalized.contains("今天") || normalized.contains("今日") || normalized.contains("today") - { - return Some(( - format!("{}年{}月{}日", today.year(), today.month(), today.day()), - format!( - "{} {} {}", - month_name(today.month()), - today.day(), - today.year() - ), - )); - } - - if let Ok(re) = Regex::new(r"(?P\d{1,2})月(?P\d{1,2})日") { - if let Some(captures) = re.captures(message_text) { - let month = captures - .name("month") - .and_then(|value| value.as_str().parse::().ok())?; - let day = captures - .name("day") - .and_then(|value| value.as_str().parse::().ok())?; - if (1..=12).contains(&month) && (1..=31).contains(&day) { - return Some(( - format!("{}年{}月{}日", today.year(), month, day), - format!("{} {} {}", month_name(month), day, today.year()), - )); - } - } - } - - None -} - -fn dedup_queries(values: Vec) -> Vec { - let mut seen = HashSet::new(); - let mut result = Vec::new(); - for value in values { - let normalized = value.trim(); - if normalized.is_empty() { - continue; - } - let key = normalized.to_ascii_lowercase(); - if seen.insert(key) { - result.push(normalized.to_string()); - } - } - result -} - -fn build_news_preflight_queries_with_reference( - message_text: &str, - today: NaiveDate, -) -> Vec { - let base_clause = sanitize_news_search_clause(message_text); - let (zh_topic, en_topic) = resolve_topic_labels(message_text); - let mut queries = vec![derive_preflight_query(&base_clause)]; - - if let Some((zh_date, en_date)) = resolve_absolute_news_date(message_text, today) { - queries.push(format!("{zh_date} {zh_topic}")); - queries.push(format!("{en_date} {en_topic}")); - queries.push(format!("{en_date} world headlines")); - } else { - queries.push(format!("{base_clause} {zh_topic}")); - queries.push(format!("{base_clause} {en_topic}")); - queries.push(format!("{base_clause} latest headlines")); - } - - dedup_queries(queries) - .into_iter() - .take(NEWS_PREFLIGHT_QUERY_LIMIT) - .collect() -} - -fn build_preflight_queries(message_text: &str, policy: &RequestToolPolicy) -> Vec { - if message_suggests_news_expansion(message_text) && policy.allows_web_search() { - return build_news_preflight_queries_with_reference( - message_text, - Local::now().date_naive(), - ); - } - +fn build_preflight_queries(message_text: &str, _policy: &RequestToolPolicy) -> Vec { vec![derive_preflight_query(message_text)] } @@ -1506,9 +1264,7 @@ fn resolve_reply_retry_mode( return ReplyRetryMode::None; } - if preflight_execution.system_prompt_appendix.is_some() - || preflight_execution.expanded_news_search - || !tracker.ordered_tool_ids.is_empty() + if preflight_execution.system_prompt_appendix.is_some() || !tracker.ordered_tool_ids.is_empty() { return ReplyRetryMode::WebSearchSynthesis; } @@ -1583,6 +1339,7 @@ where session_id, user_message.as_concat_text().chars().count() ); + let cancel_probe = cancel_token.clone(); let mut stream = agent .reply(user_message, session_config, cancel_token) .await @@ -1595,7 +1352,19 @@ where started_at.elapsed().as_millis() ); - while let Some(event_result) = stream.next().await { + loop { + let event_result = match cancel_probe.as_ref() { + Some(token) => { + tokio::select! { + _ = token.cancelled() => break, + next = stream.next() => next, + } + } + None => stream.next().await, + }; + let Some(event_result) = event_result else { + break; + }; match event_result { Ok(agent_event) => { let provider_error_for_event = match &agent_event { @@ -1750,7 +1519,7 @@ fn extract_inline_agent_provider_error(message: &Message) -> Option { /// 当开启联网搜索时,在正式回复前执行 WebSearch 预检索。 /// /// 目标: -/// - 在需要时通过执行层主动完成新闻类扩搜,而不是只依赖模型自己多次调用搜索。 +/// - 仅在显式 `required` 搜索模式下先完成一次必需 WebSearch。 /// - 统一生成 tool_start/tool_end 事件,供前端 harness 展示。 /// - 将预检索结果压缩注入 system prompt,帮助模型做更深的事实整合。 /// - 若本回合被明确要求必须先搜索,且预检索全部失败,则由上层中断本次回答。 @@ -1808,8 +1577,6 @@ pub async fn execute_web_search_preflight_if_needed( } }) .collect::>(); - let expanded_news_search = planned_queries.len() > 1; - let working_directory = working_directory .map(Path::to_path_buf) .or_else(|| std::env::current_dir().ok()) @@ -1917,7 +1684,6 @@ pub async fn execute_web_search_preflight_if_needed( planned_queries: planned_query_texts, system_prompt_appendix, coverage_summary, - expanded_news_search, }) } } @@ -1961,6 +1727,7 @@ where { let started_at = Instant::now(); let message_text = user_message.as_concat_text(); + let cancel_probe = cancel_token.clone(); let mut web_search_tracker = WebSearchExecutionTracker::default(); tracing::info!( "[AsterAgent][TTFT] stream policy start: session_id={}, message_chars={}, search_mode={}", @@ -1969,8 +1736,7 @@ where request_tool_policy.search_mode.as_str() ); - // 对于 Required 模式,preflight 是必须的,必须等待完成 - // 对于 Allowed 模式,preflight 是可选优化,设置超时避免阻塞首字 + // 只在显式 Required 模式做预检索;Allowed 只是暴露工具候选能力,由模型按需决定。 let preflight = if request_tool_policy.requires_web_search() { execute_web_search_preflight_if_needed( WebSearchPreflightRequest { @@ -1986,36 +1752,7 @@ where ) .await } else { - let mut optional_preflight_tracker = WebSearchExecutionTracker::default(); - match tokio::time::timeout( - std::time::Duration::from_millis(OPTIONAL_WEB_SEARCH_PREFLIGHT_TIMEOUT_MS), - execute_web_search_preflight_if_needed( - WebSearchPreflightRequest { - agent, - session_id: &session_config.id, - message_text: &message_text, - working_directory, - cancel_token: cancel_token.clone(), - turn_context: session_config.turn_context.clone(), - policy: request_tool_policy, - }, - &mut optional_preflight_tracker, - ), - ) - .await - { - Ok(result) => { - web_search_tracker.absorb(optional_preflight_tracker); - result - } - Err(_) => { - tracing::warn!( - "[AsterAgent][TTFT] optional web search preflight timed out ({}ms), proceeding without preflight context", - OPTIONAL_WEB_SEARCH_PREFLIGHT_TIMEOUT_MS - ); - Ok(PreflightToolExecution::none()) - } - } + Ok(PreflightToolExecution::none()) }; let preflight_execution = match preflight { Ok(preflight_execution) => { @@ -2081,8 +1818,8 @@ where diagnostics.saved_site_content_count ); let fallback_text = text_chunks.join("").trim().to_string(); - return Ok(StreamReplyExecution { - text_output: if fallback_text.is_empty() { + return Ok(build_stream_reply_execution( + if fallback_text.is_empty() { match build_output_preserved_reply_fallback(&diagnostics) { Some(output) => output, None => return Err(error), @@ -2092,12 +1829,24 @@ where }, event_errors, emitted_any, - attempts_summary: web_search_tracker.format_attempts(), - }); + web_search_tracker.format_attempts(), + is_reply_cancelled(&cancel_probe), + )); } return Err(error); } + if is_reply_cancelled(&cancel_probe) { + persist_cancelled_turn_context_marker(agent, &session_config.id).await; + return Ok(build_stream_reply_execution( + text_chunks.join(""), + event_errors, + emitted_any, + web_search_tracker.format_attempts(), + true, + )); + } + let current_text_output = text_chunks.join(""); match resolve_reply_retry_mode( &preflight_execution, @@ -2155,12 +1904,13 @@ where else { return Err(error); }; - return Ok(StreamReplyExecution { - text_output: fallback_text, + return Ok(build_stream_reply_execution( + fallback_text, event_errors, emitted_any, - attempts_summary: web_search_tracker.format_attempts(), - }); + web_search_tracker.format_attempts(), + is_reply_cancelled(&cancel_probe), + )); } return Err(error); } @@ -2207,12 +1957,13 @@ where else { return Err(error); }; - return Ok(StreamReplyExecution { - text_output: fallback_text, + return Ok(build_stream_reply_execution( + fallback_text, event_errors, emitted_any, - attempts_summary: web_search_tracker.format_attempts(), - }); + web_search_tracker.format_attempts(), + is_reply_cancelled(&cancel_probe), + )); } return Err(error); } @@ -2260,12 +2011,13 @@ where else { return Err(error); }; - return Ok(StreamReplyExecution { - text_output: fallback_text, + return Ok(build_stream_reply_execution( + fallback_text, event_errors, emitted_any, - attempts_summary: web_search_tracker.format_attempts(), - }); + web_search_tracker.format_attempts(), + is_reply_cancelled(&cancel_probe), + )); } return Err(error); } @@ -2273,6 +2025,17 @@ where ReplyRetryMode::None => {} } + if is_reply_cancelled(&cancel_probe) { + persist_cancelled_turn_context_marker(agent, &session_config.id).await; + return Ok(build_stream_reply_execution( + text_chunks.join(""), + event_errors, + emitted_any, + web_search_tracker.format_attempts(), + true, + )); + } + if let Err(validation_error) = web_search_tracker.validate_web_search_requirement(request_tool_policy) { @@ -2311,12 +2074,13 @@ where diagnostics.tool_end_count, web_search_tracker.format_attempts() ); - return Ok(StreamReplyExecution { - text_output: fallback_text, + return Ok(build_stream_reply_execution( + fallback_text, event_errors, emitted_any, - attempts_summary: web_search_tracker.format_attempts(), - }); + web_search_tracker.format_attempts(), + is_reply_cancelled(&cancel_probe), + )); } return Err(ReplyAttemptError { message: build_empty_final_reply_error_message(&diagnostics, &web_search_tracker), @@ -2324,12 +2088,13 @@ where }); } - Ok(StreamReplyExecution { - text_output: final_text_output, + Ok(build_stream_reply_execution( + final_text_output, event_errors, emitted_any, - attempts_summary: web_search_tracker.format_attempts(), - }) + web_search_tracker.format_attempts(), + false, + )) } fn is_web_search_preflight_enabled() -> bool { @@ -2362,15 +2127,293 @@ fn derive_preflight_query(message_text: &str) -> String { #[cfg(test)] mod tests { use super::*; + use aster::conversation::Conversation; use aster::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage}; use aster::providers::errors::ProviderError; - use aster::session::{SessionManager, SessionType, TurnContextOverride, TurnStatus}; + use aster::session::{ + ChatHistoryMatch, CommitOptions, CommitReport, MemoryCategory, MemoryHealth, MemoryRecord, + MemorySearchResult, Session, SessionInsights, SessionStore, SessionType, TokenStatsUpdate, + TurnContextOverride, TurnStatus, + }; use aster::tools::{PermissionCheckResult, Tool, ToolError, ToolResult}; use async_trait::async_trait; use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + struct TestSessionStore { + session: Mutex, + } + + impl TestSessionStore { + fn new(session: Session) -> Self { + Self { + session: Mutex::new(session), + } + } + + fn current_session(&self, include_messages: bool) -> Session { + let mut session = self.session.lock().expect("锁测试 session").clone(); + if !include_messages { + session.conversation = None; + } + session + } + } + + fn create_test_session_store(name: &str) -> (Arc, Session) { + let now = chrono::Utc::now(); + let session = Session { + id: format!("test-{}-{}", name, Uuid::new_v4()), + working_dir: PathBuf::default(), + name: name.to_string(), + user_set_name: false, + session_type: SessionType::Hidden, + created_at: now, + updated_at: now, + extension_data: Default::default(), + total_tokens: None, + 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, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation: Some(Conversation::default()), + message_count: 0, + provider_name: None, + model_config: None, + }; + (Arc::new(TestSessionStore::new(session.clone())), session) + } + + #[async_trait] + impl SessionStore for TestSessionStore { + async fn create_session( + &self, + _working_dir: PathBuf, + _name: String, + _session_type: SessionType, + ) -> anyhow::Result { + Ok(self.current_session(true)) + } + + async fn get_session(&self, _id: &str, include_messages: bool) -> anyhow::Result { + Ok(self.current_session(include_messages)) + } + + async fn add_message(&self, _session_id: &str, message: &Message) -> anyhow::Result<()> { + let mut session = self.session.lock().expect("锁测试 session"); + let conversation = session + .conversation + .get_or_insert_with(Conversation::default); + conversation.push(message.clone()); + session.message_count = conversation.len(); + session.updated_at = chrono::Utc::now(); + Ok(()) + } + + async fn replace_conversation( + &self, + _session_id: &str, + conversation: &Conversation, + ) -> anyhow::Result<()> { + let mut session = self.session.lock().expect("锁测试 session"); + session.conversation = Some(conversation.clone()); + session.message_count = conversation.len(); + session.updated_at = chrono::Utc::now(); + Ok(()) + } + + async fn list_sessions(&self) -> anyhow::Result> { + Ok(vec![self.current_session(false)]) + } + + async fn list_sessions_by_types( + &self, + _types: &[SessionType], + ) -> anyhow::Result> { + Ok(vec![self.current_session(false)]) + } + + async fn delete_session(&self, _id: &str) -> anyhow::Result<()> { + Ok(()) + } + + async fn get_insights(&self) -> anyhow::Result { + Ok(SessionInsights { + total_sessions: 1, + total_tokens: 0, + }) + } + + async fn export_session(&self, _id: &str) -> anyhow::Result { + Ok("{}".to_string()) + } + + async fn import_session(&self, _json: &str) -> anyhow::Result { + Ok(self.current_session(true)) + } + + async fn copy_session( + &self, + _session_id: &str, + _new_name: String, + ) -> anyhow::Result { + Ok(self.current_session(true)) + } + + async fn truncate_conversation( + &self, + _session_id: &str, + _timestamp: i64, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn update_session_name( + &self, + _session_id: &str, + name: String, + user_set: bool, + ) -> anyhow::Result<()> { + let mut session = self.session.lock().expect("锁测试 session"); + session.name = name; + session.user_set_name = user_set; + session.updated_at = chrono::Utc::now(); + Ok(()) + } + + async fn update_working_dir( + &self, + _session_id: &str, + working_dir: PathBuf, + ) -> anyhow::Result<()> { + let mut session = self.session.lock().expect("锁测试 session"); + session.working_dir = working_dir; + session.updated_at = chrono::Utc::now(); + Ok(()) + } + + async fn update_session_type( + &self, + _session_id: &str, + session_type: SessionType, + ) -> anyhow::Result<()> { + let mut session = self.session.lock().expect("锁测试 session"); + session.session_type = session_type; + session.updated_at = chrono::Utc::now(); + Ok(()) + } + + async fn update_extension_data( + &self, + _session_id: &str, + extension_data: aster::session::extension_data::ExtensionData, + ) -> anyhow::Result<()> { + let mut session = self.session.lock().expect("锁测试 session"); + session.extension_data = extension_data; + session.updated_at = chrono::Utc::now(); + Ok(()) + } + + async fn update_token_stats( + &self, + _session_id: &str, + _stats: TokenStatsUpdate, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn update_provider_config( + &self, + _session_id: &str, + provider_name: Option, + model_config: Option, + ) -> anyhow::Result<()> { + let mut session = self.session.lock().expect("锁测试 session"); + if let Some(provider_name) = provider_name { + session.provider_name = Some(provider_name); + } + if let Some(model_config) = model_config { + session.model_config = Some(model_config); + } + session.updated_at = chrono::Utc::now(); + Ok(()) + } + + async fn update_recipe( + &self, + _session_id: &str, + _recipe: Option, + _user_recipe_values: Option>, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn search_chat_history( + &self, + _query: &str, + _limit: Option, + _after_date: Option>, + _before_date: Option>, + _exclude_session_id: Option, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn commit_session( + &self, + _id: &str, + _options: CommitOptions, + ) -> anyhow::Result { + Ok(CommitReport { + session_id: "test-session-store".to_string(), + messages_scanned: 0, + memories_created: 0, + memories_merged: 0, + source_start_ts: None, + source_end_ts: None, + warnings: Vec::new(), + }) + } + + async fn search_memories( + &self, + _query: &str, + _limit: Option, + _session_scope: Option<&str>, + _categories: Option>, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn retrieve_context_memories( + &self, + _session_id: &str, + _query: &str, + _limit: usize, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn memory_stats(&self) -> anyhow::Result { + Ok(aster::session::MemoryStats::default()) + } + + async fn memory_health(&self) -> anyhow::Result { + Ok(MemoryHealth { + healthy: true, + message: "test session store".to_string(), + }) + } + } struct TurnContextGatedWebSearchTool; @@ -2509,6 +2552,62 @@ mod tests { } } + struct SlowStreamingProvider; + + #[async_trait] + impl Provider for SlowStreamingProvider { + fn metadata() -> ProviderMetadata + where + Self: Sized, + { + ProviderMetadata::empty() + } + + fn get_name(&self) -> &str { + "slow-streaming-provider" + } + + async fn complete_with_model( + &self, + _model_config: &aster::model::ModelConfig, + _system: &str, + _messages: &[Message], + _tools: &[rmcp::model::Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + Ok(( + Message::assistant().with_text("非流式兜底不应被调用"), + ProviderUsage::new("gpt-5.3-codex".to_string(), Usage::default()), + )) + } + + async fn stream( + &self, + _system: &str, + _messages: &[Message], + _tools: &[rmcp::model::Tool], + ) -> Result { + Ok(Box::pin(async_stream::try_stream! { + yield ( + Some(Message::assistant().with_text("第一段")), + None, + ); + tokio::time::sleep(Duration::from_secs(30)).await; + yield ( + Some(Message::assistant().with_text("第二段")), + Some(ProviderUsage::new("gpt-5.3-codex".to_string(), Usage::default())), + ); + })) + } + + fn supports_streaming(&self) -> bool { + true + } + + fn get_model_config(&self) -> aster::model::ModelConfig { + aster::model::ModelConfig::new("gpt-5.3-codex").expect("test model config") + } + } + struct AuthenticationErrorProvider; #[async_trait] @@ -2751,22 +2850,35 @@ mod tests { } #[test] - fn tracker_absorb_preserves_completed_optional_preflight_attempts() { - let policy = resolve_request_tool_policy(Some(true), false); - let mut target = WebSearchExecutionTracker::default(); - let mut optional_tracker = WebSearchExecutionTracker::default(); - optional_tracker.record_tool_start(&policy, "tool-1", "WebSearch"); - optional_tracker.record_tool_end(&policy, "tool-1", true, None); + fn allowed_web_search_should_not_run_preflight_from_message_keywords() { + let policy = resolve_request_tool_policy_with_mode( + Some(true), + Some(RequestToolPolicyMode::Allowed), + false, + ); - target.absorb(optional_tracker); - - assert_eq!(target.ordered_tool_ids, vec!["tool-1".to_string()]); + assert!(!should_run_web_search_preflight( + &policy, + "请搜索今天最新 AI 新闻" + )); assert_eq!( - target - .attempts_by_id - .get("tool-1") - .and_then(|record| record.success), - Some(true) + build_preflight_queries("请搜索今天最新 AI 新闻", &policy), + vec!["请搜索今天最新 AI 新闻".to_string()] + ); + } + + #[test] + fn required_web_search_should_run_preflight_without_keyword_detection() { + let policy = resolve_request_tool_policy_with_mode( + Some(true), + Some(RequestToolPolicyMode::Required), + false, + ); + + assert!(should_run_web_search_preflight(&policy, "继续")); + assert_eq!( + build_preflight_queries("继续", &policy), + vec!["继续".to_string()] ); } @@ -2995,28 +3107,6 @@ mod tests { )); } - #[test] - fn detects_news_expansion_for_daily_news_summary_requests() { - assert!(message_suggests_news_expansion("帮我汇总3月13日国际新闻")); - assert!(message_suggests_news_expansion( - "Please summarize the latest world news for March 13" - )); - assert!(!message_suggests_news_expansion("帮我解释一下牛顿第二定律")); - } - - #[test] - fn builds_news_preflight_queries_with_absolute_date_variants() { - let queries = build_news_preflight_queries_with_reference( - "帮我汇总3月13日国际新闻", - NaiveDate::from_ymd_opt(2026, 3, 13).expect("valid date"), - ); - - assert_eq!(queries[0], "汇总3月13日国际新闻"); - assert!(queries.contains(&"2026年3月13日 国际新闻".to_string())); - assert!(queries.contains(&"March 13 2026 international news".to_string())); - assert!(queries.contains(&"March 13 2026 world headlines".to_string())); - } - #[test] fn merges_web_search_preflight_context_without_duplication() { let merged = merge_system_prompt_with_web_search_preflight_context( @@ -3137,14 +3227,8 @@ mod tests { #[tokio::test] async fn stream_message_reply_with_policy_should_surface_disabled_auto_compaction_limit_from_aster( ) { - let session = SessionManager::create_session( - PathBuf::default(), - "lime-auto-compact-disabled".to_string(), - SessionType::Hidden, - ) - .await - .expect("应创建测试 session"); - let agent = Agent::new(); + let (store, session) = create_test_session_store("lime-auto-compact-disabled"); + let agent = Agent::new().with_session_store(store.clone()); agent .update_provider(Arc::new(ContextLengthExceededProvider), &session.id) .await @@ -3206,14 +3290,8 @@ mod tests { #[tokio::test] async fn stream_message_reply_with_policy_should_retry_empty_reply_without_tool_activity() { - let session = SessionManager::create_session( - PathBuf::default(), - "lime-empty-reply-retry".to_string(), - SessionType::Hidden, - ) - .await - .expect("应创建测试 session"); - let agent = Agent::new(); + let (store, session) = create_test_session_store("lime-empty-reply-retry"); + let agent = Agent::new().with_session_store(store.clone()); let attempts = Arc::new(AtomicUsize::new(0)); agent .update_provider( @@ -3265,16 +3343,85 @@ mod tests { } #[tokio::test] - async fn stream_message_reply_with_policy_should_drain_inline_provider_error_and_mark_turn_failed( - ) { - let session = SessionManager::create_session( - PathBuf::default(), - "lime-inline-provider-error".to_string(), - SessionType::Hidden, + async fn stream_message_reply_with_policy_should_return_cancelled_without_waiting_next_chunk() { + let (store, session) = create_test_session_store("lime-stream-cancel"); + let agent = Agent::new().with_session_store(store.clone()); + agent + .update_provider(Arc::new(SlowStreamingProvider), &session.id) + .await + .expect("应配置测试 provider"); + + let session_config = aster::agents::SessionConfig { + id: session.id.clone(), + thread_id: None, + turn_id: Some("turn-stream-cancel".to_string()), + schedule_id: None, + max_turns: None, + retry_config: None, + system_prompt: None, + system_prompt_override: None, + include_context_trace: None, + turn_context: None, + }; + let policy = resolve_request_tool_policy(Some(false), false); + let cancel_token = CancellationToken::new(); + let cancel_for_task = cancel_token.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + cancel_for_task.cancel(); + }); + + let reply = tokio::time::timeout( + Duration::from_secs(2), + stream_message_reply_with_policy( + &agent, + Message::user().with_text("请流式输出"), + None, + session_config, + Some(cancel_token), + &policy, + |_| {}, + ), ) .await - .expect("应创建测试 session"); - let agent = Agent::new(); + .expect("取消后不应继续等待 provider 下一段") + .expect("取消应作为可识别执行结果返回"); + + assert!(reply.cancelled); + assert!( + !reply.text_output.contains("第二段"), + "取消后不应等待或拼接 provider 的后续分片" + ); + + let stored_session = store + .get_session(&session.id, true) + .await + .expect("应读取取消后的 session"); + let stored_conversation = stored_session.conversation.expect("应有会话上下文"); + let stored_messages = stored_conversation.iter().collect::>(); + assert_eq!( + stored_messages + .iter() + .filter(|message| message.is_user_visible()) + .count(), + 1, + "取消上下文标记不应作为普通用户消息展示" + ); + assert!( + stored_messages.iter().any(|message| { + !message.is_user_visible() + && message.is_agent_visible() + && message.as_concat_text().contains("上一回合已被用户停止") + }), + "取消后应写入仅 Agent 可见的上下文标记,避免下一轮继续回答已停止请求" + ); + } + + #[tokio::test] + async fn stream_message_reply_with_policy_should_drain_inline_provider_error_and_mark_turn_failed( + ) { + let (store, session) = create_test_session_store("lime-inline-provider-error"); + let agent = Agent::new().with_session_store(store.clone()); agent .update_provider(Arc::new(AuthenticationErrorProvider), &session.id) .await diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index c3da7f202..e0dfe3126 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -138,7 +138,24 @@ fn should_load_subagent_runtime_context( detail: &SessionDetail, history_limit: Option, ) -> bool { - should_load_runtime_overlay(detail, history_limit) + history_limit.is_none() + || detail.is_persisted_empty() + || !detail.turns.is_empty() + || !detail.items.is_empty() +} + +fn should_load_runtime_overlay_for_runtime_detail( + detail: &SessionDetail, + history_limit: Option, +) -> bool { + detail.is_persisted_empty() || should_load_runtime_overlay(detail, history_limit) +} + +fn should_load_subagent_runtime_context_for_runtime_detail( + detail: &SessionDetail, + history_limit: Option, +) -> bool { + should_load_subagent_runtime_context(detail, history_limit) } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -801,6 +818,18 @@ fn sort_runtime_items(items: &mut [AgentThreadItem], turn_started_at: &HashMap bool { + matches!( + persisted.status, + AgentThreadTurnStatus::Completed + | AgentThreadTurnStatus::Failed + | AgentThreadTurnStatus::Aborted + ) && matches!(runtime.status, AgentThreadTurnStatus::Running) +} + fn apply_aster_runtime_snapshot(detail: &mut SessionDetail, snapshot: &SessionRuntimeSnapshot) { if let Some(thread) = snapshot.threads.first() { detail.thread_id = thread.thread.id.clone(); @@ -817,7 +846,15 @@ fn apply_aster_runtime_snapshot(detail: &mut SessionDetail, snapshot: &SessionRu .collect::>(); for thread in &snapshot.threads { for turn in &thread.turns { - turns_by_id.insert(turn.id.clone(), project_turn_runtime(turn.clone())); + let runtime_turn = project_turn_runtime(turn.clone()); + if turns_by_id + .get(&runtime_turn.id) + .map(|persisted| should_preserve_persisted_terminal_turn(persisted, &runtime_turn)) + .unwrap_or(false) + { + continue; + } + turns_by_id.insert(runtime_turn.id.clone(), runtime_turn); } } detail.turns = turns_by_id.into_values().collect(); @@ -1518,24 +1555,11 @@ pub async fn get_runtime_session_detail_with_history_page( return Ok(detail); } - if detail.is_persisted_empty() { - let total_ms = started_at.elapsed().as_millis(); - tracing::info!( - "[SessionStore] get_runtime_session_detail 空会话快路径完成: session_id={}, total_ms={}, detail_ms={}, archive_check_ms={}, history_limit={:?}, history_offset={}, before_message_id={:?}", - session_id, - total_ms, - detail_ms, - archive_check_ms, - history_limit, - history_offset, - before_message_id, - ); - return Ok(detail); - } + let was_persisted_empty = detail.is_persisted_empty(); let load_runtime_overlay = history_offset == 0 && before_message_id.is_none() - && should_load_runtime_overlay(&detail, history_limit); + && should_load_runtime_overlay_for_runtime_detail(&detail, history_limit); let overlay_started_at = Instant::now(); let (session, runtime_snapshot) = if load_runtime_overlay { @@ -1632,7 +1656,8 @@ pub async fn get_runtime_session_detail_with_history_page( let load_subagent_runtime_context = history_offset == 0 && before_message_id.is_none() - && should_load_subagent_runtime_context(&detail, history_limit); + && (was_persisted_empty + || should_load_subagent_runtime_context_for_runtime_detail(&detail, history_limit)); let child_subagents_started_at = Instant::now(); if load_subagent_runtime_context { @@ -1667,6 +1692,24 @@ pub async fn get_runtime_session_detail_with_history_page( } } let parent_context_ms = parent_context_started_at.elapsed().as_millis(); + + if was_persisted_empty && detail.is_persisted_empty() { + let total_ms = started_at.elapsed().as_millis(); + tracing::info!( + "[SessionStore] get_runtime_session_detail 空会话快路径完成: session_id={}, total_ms={}, detail_ms={}, archive_check_ms={}, overlay_ms={}, child_subagents_ms={}, parent_context_ms={}, history_limit={:?}, history_offset={}, before_message_id={:?}", + session_id, + total_ms, + detail_ms, + archive_check_ms, + overlay_ms, + child_subagents_ms, + parent_context_ms, + history_limit, + history_offset, + before_message_id, + ); + return Ok(detail); + } let total_ms = started_at.elapsed().as_millis(); tracing::info!( @@ -2060,8 +2103,8 @@ fn convert_agent_message_with_options( mod tests { use super::*; use aster::session::{ - SessionType as AsterSessionType, SubagentSessionMetadata, ThreadRuntime, - ThreadRuntimeSnapshot, TurnRuntime, TurnStatus, + SessionRuntimeSnapshot, SessionType as AsterSessionType, SubagentSessionMetadata, + ThreadRuntime, ThreadRuntimeSnapshot, TurnRuntime, TurnStatus, }; use chrono::{Duration, Utc}; use lime_core::agent::types::{FunctionCall, ImageUrl, ToolCall}; @@ -2156,6 +2199,27 @@ mod tests { } } + fn build_empty_runtime_detail() -> SessionDetail { + SessionDetail { + id: "session-empty-runtime".to_string(), + name: "空运行态会话".to_string(), + created_at: 0, + updated_at: 0, + thread_id: "session-empty-runtime".to_string(), + model: Some("agent:test".to_string()), + working_dir: None, + workspace_id: None, + messages: Vec::new(), + execution_strategy: Some("react".to_string()), + execution_runtime: None, + turns: Vec::new(), + items: Vec::new(), + todo_items: Vec::new(), + child_subagent_sessions: Vec::new(), + subagent_parent_context: None, + } + } + fn insert_test_session_with_message( db: &DbConnection, session_id: &str, @@ -2224,6 +2288,50 @@ mod tests { assert!(!should_load_runtime_overlay_at(&detail, Some(80), now)); } + #[test] + fn should_probe_runtime_overlay_for_empty_limited_history() { + let detail = build_empty_runtime_detail(); + + assert!(detail.is_persisted_empty()); + assert!(should_load_runtime_overlay_for_runtime_detail( + &detail, + Some(20) + )); + } + + #[test] + fn apply_runtime_snapshot_should_not_regress_aborted_turn_to_running() { + let mut detail = build_detail_with_turn_status(AgentThreadTurnStatus::Aborted); + let thread_id = detail.thread_id.clone(); + let turn_id = detail.turns[0].id.clone(); + let mut runtime_turn = TurnRuntime::new( + turn_id.clone(), + detail.id.clone(), + thread_id.clone(), + Some("测试".to_string()), + None, + ); + runtime_turn.status = TurnStatus::Running; + let snapshot = SessionRuntimeSnapshot { + session_id: detail.id.clone(), + threads: vec![ThreadRuntimeSnapshot { + thread: ThreadRuntime::new( + thread_id, + detail.id.clone(), + std::path::PathBuf::from("/tmp/lime-runtime-overlay-test"), + ), + turns: vec![runtime_turn], + items: Vec::new(), + }], + }; + + apply_aster_runtime_snapshot(&mut detail, &snapshot); + + assert_eq!(detail.turns.len(), 1); + assert_eq!(detail.turns[0].id, turn_id); + assert_eq!(detail.turns[0].status, AgentThreadTurnStatus::Aborted); + } + #[test] fn should_load_subagent_runtime_context_for_full_history() { let detail = build_detail_with_turn_status(AgentThreadTurnStatus::Completed); @@ -2232,10 +2340,10 @@ mod tests { } #[test] - fn should_skip_subagent_runtime_context_for_completed_limited_history() { + fn should_load_subagent_runtime_context_for_completed_limited_history() { let detail = build_detail_with_turn_status(AgentThreadTurnStatus::Completed); - assert!(!should_load_subagent_runtime_context(&detail, Some(80))); + assert!(should_load_subagent_runtime_context(&detail, Some(80))); } #[test] @@ -2245,6 +2353,17 @@ mod tests { assert!(should_load_subagent_runtime_context(&detail, Some(80))); } + #[test] + fn should_probe_subagent_context_for_empty_limited_history() { + let detail = build_empty_runtime_detail(); + + assert!(detail.is_persisted_empty()); + assert!(should_load_subagent_runtime_context_for_runtime_detail( + &detail, + Some(20) + )); + } + fn build_test_subagent_session( session_id: &str, name: &str, diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs index bfb78b9b2..c7fc0532f 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs @@ -144,6 +144,8 @@ const SUBAGENT_TEAMMATE_ALLOWED_TOOL_NAMES: [&str; 5] = [ "CronList", "CronDelete", ]; +const CANCELLED_TURN_CONTEXT_MARKER: &str = + "上一回合已被用户停止,不要继续回答被停止的请求;等待并仅处理后续用户消息。"; const AUTO_COMPACTION_DISABLED_CONTEXT_LIMIT_TEXT: &str = "Automatic compaction is disabled for this turn. The conversation reached the context limit. Compact the session manually or start a new session before retrying."; const PROPOSED_PLAN_OPEN: &str = ""; @@ -160,6 +162,12 @@ const FILE_ARTIFACT_METADATA_KEYS: [&str; 9] = [ "absolute_path", ]; +fn cancelled_turn_context_marker_message() -> Message { + Message::assistant() + .with_text(CANCELLED_TURN_CONTEXT_MARKER) + .agent_only() +} + #[derive(Debug, Clone)] struct ResolvedOutputSchema { schema: Value, @@ -3545,6 +3553,11 @@ impl Agent { } Err(err) => { turn_status = if is_token_cancelled(&cancel_token) { + self.store_add_message( + &scoped_session_config.id, + &cancelled_turn_context_marker_message(), + ) + .await?; TurnStatus::Aborted } else { TurnStatus::Failed @@ -3687,7 +3700,24 @@ impl Agent { let mut tools_updated = false; let mut did_recovery_compact_this_iteration = false; - while let Some(next) = stream.next().await { + loop { + let next = if cancel_token.is_some() { + match tokio::time::timeout(Duration::from_millis(100), stream.next()).await + { + Ok(next) => next, + Err(_) => { + if is_token_cancelled(&cancel_token) { + break; + } + continue; + } + } + } else { + stream.next().await + }; + let Some(next) = next else { + break; + }; if is_token_cancelled(&cancel_token) { break; } @@ -4062,7 +4092,11 @@ impl Agent { ).await?; } let mut exit_chat = false; - if no_tools_called { + if is_token_cancelled(&cancel_token) { + messages_to_add.clear(); + messages_to_add.push(cancelled_turn_context_marker_message()); + exit_chat = true; + } else if no_tools_called { if let Some(final_output_tool) = self.final_output_tool.lock().await.as_ref() { if final_output_tool.final_output.is_none() { warn!("Final output tool has not been called yet. Continuing agent loop."); diff --git a/src-tauri/crates/core/src/config/tests.rs b/src-tauri/crates/core/src/config/tests.rs index 2f7751364..04edf7207 100644 --- a/src-tauri/crates/core/src/config/tests.rs +++ b/src-tauri/crates/core/src/config/tests.rs @@ -189,7 +189,7 @@ fn arb_config() -> impl Strategy { minimize_to_tray: true, models: crate::config::ModelsConfig::default(), agent: crate::config::NativeAgentConfig::default(), - language: "zh".to_string(), + language: "zh-CN".to_string(), experimental: crate::config::ExperimentalFeatures::default(), tool_calling: crate::config::ToolCallingConfig::default(), workspace_preferences: WorkspacePreferencesConfig::default(), @@ -431,7 +431,7 @@ fn arb_valid_config() -> impl Strategy { minimize_to_tray: true, models: crate::config::ModelsConfig::default(), agent: crate::config::NativeAgentConfig::default(), - language: "zh".to_string(), + language: "zh-CN".to_string(), experimental: crate::config::ExperimentalFeatures::default(), tool_calling: crate::config::ToolCallingConfig::default(), workspace_preferences: WorkspacePreferencesConfig::default(), @@ -483,7 +483,7 @@ fn arb_invalid_config() -> impl Strategy { minimize_to_tray: true, models: crate::config::ModelsConfig::default(), agent: crate::config::NativeAgentConfig::default(), - language: "zh".to_string(), + language: "zh-CN".to_string(), experimental: crate::config::ExperimentalFeatures::default(), tool_calling: crate::config::ToolCallingConfig::default(), workspace_preferences: WorkspacePreferencesConfig::default(), diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 11370c561..f38cf60b5 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -423,7 +423,10 @@ pub struct Config { /// 关闭时最小化到托盘(而不是退出应用) #[serde(default = "default_minimize_to_tray")] pub minimize_to_tray: bool, - /// 用户界面语言 ("zh" 或 "en") + /// 用户界面语言。 + /// + /// 新配置使用 BCP 47 风格 locale(如 "zh-CN"、"en-US")或 "auto"; + /// 历史配置中的 "zh" / "en" 仅作为前端读取兼容值保留。 #[serde(default = "default_language")] pub language: String, /// 模型配置(动态加载 Provider 和模型列表) @@ -1328,7 +1331,7 @@ fn default_minimize_to_tray() -> bool { } fn default_language() -> String { - "zh".to_string() + "zh-CN".to_string() } /// 服务器配置 diff --git a/src-tauri/crates/mcp/src/manager.rs b/src-tauri/crates/mcp/src/manager.rs index 3004a546a..8283dabd5 100644 --- a/src-tauri/crates/mcp/src/manager.rs +++ b/src-tauri/crates/mcp/src/manager.rs @@ -888,7 +888,7 @@ impl McpClientManager { let tags = tool.tags.as_deref().unwrap_or(&[]); let inner_tool_name = extract_mcp_runtime_inner_tool_name(&tool.server_name, &tool.name) .unwrap_or(&tool.name); - let score = [ + let mut score = [ lime_core::tool_calling::score_tool_match(&tool.name, &tool.description, tags, query), lime_core::tool_calling::score_tool_match( inner_tool_name, @@ -901,6 +901,10 @@ impl McpClientManager { .max() .unwrap_or(0); + if inner_tool_name.eq_ignore_ascii_case(query) { + score += 10; + } + if tool.always_visible.unwrap_or(false) { return score + 5; } diff --git a/src-tauri/crates/media-runtime/src/lib.rs b/src-tauri/crates/media-runtime/src/lib.rs index 1d3271b97..8f3ab1586 100644 --- a/src-tauri/crates/media-runtime/src/lib.rs +++ b/src-tauri/crates/media-runtime/src/lib.rs @@ -1635,7 +1635,7 @@ fn parse_sse_event(raw_event: &str) -> Option<(String, String)> { fn extract_responses_image_generation_result( response_body_raw: &str, -) -> Result<(Value, Value), TaskErrorRecord> { +) -> Result<(Value, Value), Box> { let mut event_count = 0u32; let mut output_item_count = 0u32; @@ -1696,12 +1696,12 @@ fn extract_responses_image_generation_result( )); } - Err(build_image_task_error( + Err(Box::new(build_image_task_error( "image_result_empty", "Responses 图片生成已返回成功,但 SSE 流里没有 image_generation_call.result", false, "result", - )) + ))) } fn summarize_response_body(body: &str) -> String { @@ -1967,7 +1967,7 @@ async fn request_single_responses_image_generation( )); } - extract_responses_image_generation_result(&response_body_raw) + extract_responses_image_generation_result(&response_body_raw).map_err(|error| *error) } async fn request_single_image_generation_for_executor( diff --git a/src-tauri/crates/server/src/handlers/image_api_provider.rs b/src-tauri/crates/server/src/handlers/image_api_provider.rs index 0f919aa1e..6c9a2faf2 100644 --- a/src-tauri/crates/server/src/handlers/image_api_provider.rs +++ b/src-tauri/crates/server/src/handlers/image_api_provider.rs @@ -1895,7 +1895,8 @@ mod tests { }); let client = Client::builder() - .timeout(Duration::from_millis(250)) + .no_proxy() + .timeout(Duration::from_secs(2)) .build() .expect("build client"); let result = request_fal_queue_images_with_options( @@ -1911,8 +1912,14 @@ mod tests { .await; let error = result.expect_err("queue timeout should fail"); - assert!(error.contains("Fal 队列任务超过")); - assert!(error.contains("IN_QUEUE")); + assert!( + error.contains("Fal 队列任务超过"), + "unexpected queue timeout error: {error}" + ); + assert!( + error.contains("IN_QUEUE"), + "unexpected queue status error: {error}" + ); server.abort(); } diff --git a/src-tauri/resources/default-skills/cover_generate/SKILL.md b/src-tauri/resources/default-skills/cover_generate/SKILL.md index 2d110f37b..a9d21f851 100644 --- a/src-tauri/resources/default-skills/cover_generate/SKILL.md +++ b/src-tauri/resources/default-skills/cover_generate/SKILL.md @@ -1,11 +1,11 @@ --- name: cover_generate description: 为文章或视频生成平台封面图,并写回主稿(封面场景优先使用本技能)。 -allowed-tools: social_generate_cover_image, Bash, lime_create_cover_generation_task +allowed-tools: lime_create_cover_generation_task metadata: lime_argument_hint: 输入平台、标题、受众、视觉风格、尺寸要求。 lime_when_to_use: 用户明确要求“封面图”时使用,不要被普通配图任务替代。 - lime_version: 1.4.0 + lime_version: 1.4.1 lime_execution_mode: prompt lime_surface: workbench lime_category: media @@ -23,12 +23,11 @@ metadata: - 若结构化上下文里已有 `cover_task`,必须优先复用其中的 `prompt`、`raw_text`、`title`、`platform`、`size`、`style`、`session_id`、`project_id`、`content_id`、`entry_source` 等字段。 - 根据平台特性控制视觉:主体清晰、构图简洁、避免密集小字。 - 默认尺寸 `1024x1024`,用户指定时优先按用户要求。 -- 使用 `social_generate_cover_image` 生成封面。 -- 生成成功后,优先调用 `Bash` 执行 `lime task create cover --json` 创建任务,并把 `social_generate_cover_image` 返回的 `image_url` 作为 `--image-url` 传入;如当前环境只暴露 `lime media cover generate --json`,也可以使用。 -- 创建任务时尽量透传 `--raw-text`、`--title`、`--platform`、`--style`、`--size`、`--session-id`、`--project-id`、`--content-id`、`--entry-source` 等字段,不要丢掉工作台上下文。 -- 若当前环境暂时无法执行 `lime` CLI,再回退到 `lime_create_cover_generation_task`。 -- 任务结果必须兼容 `lime task create cover --json` 的任务文件契约。 -- 封面生成失败时不能中断:保留占位、给出重试建议,并使用 `lime_create_cover_generation_task` 提交失败任务记录。 +- 必须直接调用 `lime_create_cover_generation_task` 创建真实封面任务记录,不要通过 `Bash` 拼接 CLI 命令,也不要自行生成 HTML/SVG/Markdown 假封面。 +- 调用 `lime_create_cover_generation_task` 时,参数必须直接使用扁平任务对象:`prompt`、`rawText`、`title`、`platform`、`style`、`size`、`sessionId`、`projectId`、`contentId`、`entrySource`;不要包成 `{"cover_task": ...}`,也不要把整个任务对象序列化成字符串。 +- 创建任务时尽量透传 `cover_task` 中的 `raw_text`、`title`、`platform`、`style`、`size`、`session_id`、`project_id`、`content_id`、`entry_source` 等字段,不要丢掉工作台上下文。 +- 任务结果必须兼容标准 `cover_generate` 任务文件契约。 +- 封面生成失败时不能伪造完成态;如参数不足,最多追问 1 个关键问题。 ## 输出格式(固定) diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs index 7dab614c3..48f847b54 100644 --- a/src-tauri/src/commands/agent_cmd.rs +++ b/src-tauri/src/commands/agent_cmd.rs @@ -22,9 +22,24 @@ use futures::StreamExt; use lime_agent::merge_system_prompt_with_runtime_agents; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::fmt::Display; +use std::future::Future; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; use tauri::{AppHandle, State}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; use uuid::Uuid; +const TITLE_GENERATION_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024; +const TITLE_GENERATION_RUNTIME_SHUTDOWN_TIMEOUT_SECS: u64 = 2; +const TITLE_GENERATION_TOTAL_TIMEOUT_SECS: u64 = 45; +const TITLE_GENERATION_STREAM_START_TIMEOUT_SECS: u64 = 20; +const TITLE_GENERATION_STREAM_IDLE_TIMEOUT_SECS: u64 = 20; +const TITLE_GENERATION_QUEUE_TIMEOUT_SECS: u64 = 2; +const TITLE_GENERATION_MAX_CONCURRENCY: usize = 2; + const TITLE_FALLBACK_PROVIDER_CHAIN: [(&str, &str); 4] = [ ("deepseek", "deepseek-chat"), ("openai", "gpt-4o-mini"), @@ -187,6 +202,255 @@ fn force_direct_answer_tool_surface(metadata: &mut Option) { ); } +#[derive(Debug, Clone, Copy)] +struct TitleGenerationLimits { + total_timeout: Duration, + stream_start_timeout: Duration, + stream_idle_timeout: Duration, +} + +impl TitleGenerationLimits { + fn production() -> Self { + Self { + total_timeout: Duration::from_secs(TITLE_GENERATION_TOTAL_TIMEOUT_SECS), + stream_start_timeout: Duration::from_secs(TITLE_GENERATION_STREAM_START_TIMEOUT_SECS), + stream_idle_timeout: Duration::from_secs(TITLE_GENERATION_STREAM_IDLE_TIMEOUT_SECS), + } + } + + #[cfg(test)] + fn new( + total_timeout: Duration, + stream_start_timeout: Duration, + stream_idle_timeout: Duration, + ) -> Self { + Self { + total_timeout, + stream_start_timeout, + stream_idle_timeout, + } + } +} + +struct TitleGenerationCancelOnDrop { + token: CancellationToken, +} + +impl TitleGenerationCancelOnDrop { + fn new(token: CancellationToken) -> Self { + Self { token } + } +} + +impl Drop for TitleGenerationCancelOnDrop { + fn drop(&mut self) { + self.token.cancel(); + } +} + +fn title_generation_semaphore() -> Arc { + static TITLE_GENERATION_SEMAPHORE: OnceLock> = OnceLock::new(); + TITLE_GENERATION_SEMAPHORE + .get_or_init(|| Arc::new(Semaphore::new(TITLE_GENERATION_MAX_CONCURRENCY))) + .clone() +} + +async fn acquire_title_generation_permit() -> Result { + let semaphore = title_generation_semaphore(); + match tokio::time::timeout( + Duration::from_secs(TITLE_GENERATION_QUEUE_TIMEOUT_SECS), + semaphore.acquire_owned(), + ) + .await + { + Ok(Ok(permit)) => Ok(permit), + Ok(Err(_)) => Err("标题生成并发队列已关闭".to_string()), + Err(_) => Err(format!( + "标题生成繁忙,等待超过 {} 后已降级", + format_title_generation_duration(Duration::from_secs( + TITLE_GENERATION_QUEUE_TIMEOUT_SECS + )) + )), + } +} + +fn format_title_generation_duration(duration: Duration) -> String { + if duration.as_secs() > 0 { + format!("{}秒", duration.as_secs()) + } else { + format!("{}毫秒", duration.as_millis()) + } +} + +fn title_generation_timeout_error(kind: &str, timeout: Duration) -> String { + format!( + "标题生成{}超时({})", + kind, + format_title_generation_duration(timeout) + ) +} + +fn title_generation_remaining_total( + started_at: Instant, + limits: TitleGenerationLimits, +) -> Option { + match limits.total_timeout.checked_sub(started_at.elapsed()) { + Some(remaining) if !remaining.is_zero() => Some(remaining), + _ => None, + } +} + +async fn await_title_generation_stream_start( + stream_future: F, + cancel_token: CancellationToken, + started_at: Instant, + limits: TitleGenerationLimits, +) -> Result +where + F: Future>, + E: Display, +{ + let Some(remaining_total) = title_generation_remaining_total(started_at, limits) else { + cancel_token.cancel(); + return Err(title_generation_timeout_error( + "总耗时", + limits.total_timeout, + )); + }; + let wait_timeout = remaining_total.min(limits.stream_start_timeout); + let is_total_timeout = remaining_total <= limits.stream_start_timeout; + + match tokio::time::timeout(wait_timeout, stream_future).await { + Ok(Ok(stream)) => Ok(stream), + Ok(Err(error)) => Err(format!("标题生成失败: {error}")), + Err(_) => { + cancel_token.cancel(); + let timeout_kind = if is_total_timeout { + "总耗时" + } else { + "建流" + }; + let timeout_value = if is_total_timeout { + limits.total_timeout + } else { + limits.stream_start_timeout + }; + Err(title_generation_timeout_error(timeout_kind, timeout_value)) + } + } +} + +async fn collect_title_from_agent_events( + stream: S, + cancel_token: CancellationToken, + started_at: Instant, + limits: TitleGenerationLimits, +) -> Result +where + S: futures::Stream>, + E: Display, +{ + let mut full_content = String::new(); + tokio::pin!(stream); + + loop { + let Some(remaining_total) = title_generation_remaining_total(started_at, limits) else { + cancel_token.cancel(); + return Err(title_generation_timeout_error( + "总耗时", + limits.total_timeout, + )); + }; + let wait_timeout = remaining_total.min(limits.stream_idle_timeout); + let is_total_timeout = remaining_total <= limits.stream_idle_timeout; + + let mut pinned_stream = stream.as_mut(); + let next_event = tokio::select! { + biased; + _ = cancel_token.cancelled() => return Err("标题生成已取消".to_string()), + event = pinned_stream.next() => event, + _ = tokio::time::sleep(wait_timeout) => { + cancel_token.cancel(); + let timeout_kind = if is_total_timeout { "总耗时" } else { "流式响应空闲" }; + let timeout_value = if is_total_timeout { + limits.total_timeout + } else { + limits.stream_idle_timeout + }; + return Err(title_generation_timeout_error(timeout_kind, timeout_value)); + } + }; + + let Some(event_result) = next_event else { + break; + }; + + match event_result { + Ok(agent_event) => { + if let aster::agents::AgentEvent::Message(message) = agent_event { + for content in &message.content { + if let aster::conversation::message::MessageContent::Text(text_content) = + content + { + full_content.push_str(&text_content.text); + } + } + } + } + Err(error) => { + cancel_token.cancel(); + return Err(format!("标题生成流式事件失败: {error}")); + } + } + } + + normalize_generated_title(&full_content).ok_or_else(|| "标题生成返回为空".to_string()) +} + +/// 标题生成复用完整 Agent/Provider 链路,隔离到 8MB 专用线程避免压穿默认 tokio worker 栈。 +async fn run_title_generation_on_dedicated_runtime( + thread_label: String, + future: F, +) -> Result +where + F: Future> + Send + 'static, + T: Send + 'static, +{ + let (sender, receiver) = tokio::sync::oneshot::channel(); + let thread_name = format!("lime-title-generation-{thread_label}"); + let spawn_result = std::thread::Builder::new() + .name(thread_name) + .stack_size(TITLE_GENERATION_THREAD_STACK_SIZE) + .spawn(move || { + let result = match tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + { + Ok(runtime) => { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + runtime.block_on(future) + })) + .unwrap_or_else(|_| Err("标题生成专用线程发生 panic".to_string())); + runtime.shutdown_timeout(Duration::from_secs( + TITLE_GENERATION_RUNTIME_SHUTDOWN_TIMEOUT_SECS, + )); + result + } + Err(error) => Err(format!("创建标题生成专用运行时失败: {error}")), + }; + let _ = sender.send(result); + }); + + if let Err(error) = spawn_result { + return Err(format!("启动标题生成专用线程失败: {error}")); + } + + receiver + .await + .map_err(|_| "标题生成专用线程异常退出".to_string())? +} + async fn generate_title_with_agent( agent_state: &AsterAgentState, db: &DbConnection, @@ -195,6 +459,7 @@ async fn generate_title_with_agent( title_kind: &str, source_text: &str, ) -> Result { + let limits = TitleGenerationLimits::production(); let provider_scope = prepare_auxiliary_provider_scope( agent_state, db, @@ -209,8 +474,9 @@ async fn generate_title_with_agent( ) .await?; + let cancel_token = agent_state.create_cancel_token(session_id).await; + let cancel_on_drop = TitleGenerationCancelOnDrop::new(cancel_token.clone()); let result = async { - let cancel_token = agent_state.create_cancel_token(session_id).await; let base_runtime_prompt = merge_system_prompt_with_runtime_agents(None, None); let system_prompt = match base_runtime_prompt { Some(base_prompt) => Some(format!( @@ -255,45 +521,21 @@ async fn generate_title_with_agent( let agent_arc = agent_state.get_agent_arc(); let guard = agent_arc.read().await; let agent = guard.as_ref().ok_or("Agent 未初始化")?; - let stream_result = agent - .reply(user_message, session_config, Some(cancel_token.clone())) - .await; + let started_at = Instant::now(); + let stream = await_title_generation_stream_start( + agent.reply(user_message, session_config, Some(cancel_token.clone())), + cancel_token.clone(), + started_at, + limits, + ) + .await?; - let mut full_content = String::new(); - match stream_result { - Ok(mut stream) => { - while let Some(event_result) = stream.next().await { - match event_result { - Ok(agent_event) => { - if let aster::agents::AgentEvent::Message(message) = agent_event { - for content in &message.content { - if let aster::conversation::message::MessageContent::Text( - text_content, - ) = content - { - full_content.push_str(&text_content.text); - } - } - } - } - Err(error) => { - tracing::error!("[AgentTitle] 流式标题生成失败: {}", error); - } - } - } - } - Err(error) => { - agent_state.remove_cancel_token(session_id).await; - return Err(format!("标题生成失败: {error}")); - } - } - - agent_state.remove_cancel_token(session_id).await; - - normalize_generated_title(&full_content).ok_or_else(|| "标题生成返回为空".to_string()) + collect_title_from_agent_events(stream, cancel_token.clone(), started_at, limits).await } .await; + drop(cancel_on_drop); + agent_state.remove_cancel_token(session_id).await; provider_scope.restore(agent_state, db).await; result } @@ -416,15 +658,31 @@ pub async fn agent_generate_title( ensure_browser_mcp_tools_registered(agent_state.inner(), &db).await?; let auxiliary_session_id = format!("title-gen-{}", Uuid::new_v4()); - let title_result = generate_title_with_agent( - &agent_state, - &db, - &config_manager, - &auxiliary_session_id, - &resolved_title_kind, - &source_text, - ) - .await; + let title_result = match acquire_title_generation_permit().await { + Ok(_permit) => { + run_title_generation_on_dedicated_runtime(auxiliary_session_id.clone(), { + let agent_state = agent_state.inner().clone(); + let db = db.inner().clone(); + let config_manager = GlobalConfigManagerState(config_manager.0.clone()); + let auxiliary_session_id = auxiliary_session_id.clone(); + let resolved_title_kind = resolved_title_kind.clone(); + let source_text = source_text.clone(); + async move { + generate_title_with_agent( + &agent_state, + &db, + &config_manager, + &auxiliary_session_id, + &resolved_title_kind, + &source_text, + ) + .await + } + }) + .await + } + Err(error) => Err(error), + }; let execution_runtime = AsterAgentWrapper::get_runtime_session_execution_runtime(&db, &auxiliary_session_id).await; @@ -509,4 +767,115 @@ mod tests { Some("direct_answer") ); } + + #[tokio::test] + async fn title_generation_dedicated_runtime_runs_future() { + let thread_name = + run_title_generation_on_dedicated_runtime("unit-test".to_string(), async { + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + Ok::<_, String>( + std::thread::current() + .name() + .unwrap_or("missing-thread-name") + .to_string(), + ) + }) + .await + .expect("dedicated title runtime should run future"); + + assert!(thread_name.starts_with("lime-title-generation-unit-test")); + } + + #[tokio::test] + async fn title_generation_dedicated_runtime_preserves_error() { + let error = + run_title_generation_on_dedicated_runtime("unit-test-error".to_string(), async { + Err::<(), _>("boom".to_string()) + }) + .await + .expect_err("dedicated title runtime should return future error"); + + assert_eq!(error, "boom"); + } + + #[tokio::test] + async fn title_generation_collects_first_message_text() { + let cancel_token = CancellationToken::new(); + let stream = futures::stream::iter([Ok::<_, String>(aster::agents::AgentEvent::Message( + Message::assistant().with_text("「崩溃诊断」\n"), + ))]); + + let title = collect_title_from_agent_events( + stream, + cancel_token, + Instant::now(), + TitleGenerationLimits::new( + Duration::from_millis(100), + Duration::from_millis(50), + Duration::from_millis(50), + ), + ) + .await + .expect("title should be normalized from stream text"); + + assert_eq!(title, "崩溃诊断"); + } + + #[tokio::test] + async fn title_generation_idle_timeout_cancels_stream() { + let cancel_token = CancellationToken::new(); + let stream = futures::stream::pending::>(); + + let error = collect_title_from_agent_events( + stream, + cancel_token.clone(), + Instant::now(), + TitleGenerationLimits::new( + Duration::from_millis(100), + Duration::from_millis(50), + Duration::from_millis(5), + ), + ) + .await + .expect_err("idle stream should time out"); + + assert!(error.contains("流式响应空闲超时")); + assert!(cancel_token.is_cancelled()); + } + + #[tokio::test] + async fn title_generation_stream_start_timeout_cancels_request() { + let cancel_token = CancellationToken::new(); + let pending_stream_start = futures::future::pending::< + Result>, String>, + >(); + + let error = await_title_generation_stream_start( + pending_stream_start, + cancel_token.clone(), + Instant::now(), + TitleGenerationLimits::new( + Duration::from_millis(100), + Duration::from_millis(5), + Duration::from_millis(50), + ), + ) + .await + .expect_err("pending stream start should time out"); + + assert!(error.contains("建流超时")); + assert!(cancel_token.is_cancelled()); + } + + #[test] + fn title_generation_cancel_guard_cancels_on_drop() { + let token = CancellationToken::new(); + + { + let _guard = TitleGenerationCancelOnDrop::new(token.clone()); + assert!(!token.is_cancelled()); + } + + assert!(token.is_cancelled()); + } } diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs index 4574366fa..0d7c4592f 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs @@ -2,6 +2,7 @@ use super::*; use crate::commands::aster_agent_cmd::dto::AgentRuntimeSessionHistoryCursor; use crate::database::lock_db; use crate::sceneapp::application::SceneAppService; +use crate::services::agent_timeline_service::abort_running_turn_by_id; use crate::services::execution_tracker_service::ExecutionTracker; use crate::services::runtime_analysis_handoff_service::{ export_runtime_analysis_handoff, RuntimeAnalysisHandoffExportResult, @@ -30,6 +31,9 @@ use lime_core::database::dao::agent_run::AgentRunDao; use lime_core::database::dao::agent_timeline::{AgentThreadItemStatus, AgentThreadTurnStatus}; use std::path::PathBuf; use std::time::Instant; +use tauri::Manager; + +const RUNTIME_INTERRUPT_MESSAGE: &str = "用户已停止当前执行"; const RUNTIME_SESSION_OPEN_HISTORY_LIMIT: usize = 40; const RUNTIME_SESSION_MAX_HISTORY_LIMIT: usize = 2_000; @@ -172,14 +176,33 @@ pub async fn agent_runtime_interrupt_turn( request: AgentRuntimeInterruptTurnRequest, ) -> Result { let session_id = request.session_id; - let cancelled = state.cancel_session(&session_id).await; - if cancelled { + let requested_turn_id = request + .turn_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if requested_turn_id.is_some() { let _ = state .record_interrupt_request(&session_id, "user", "用户主动停止当前执行") .await; } + let cancelled = state.cancel_session(&session_id).await; + if cancelled && requested_turn_id.is_none() { + let _ = state + .record_interrupt_request(&session_id, "user", "用户主动停止当前执行") + .await; + } + let aborted = if cancelled { + false + } else if let Some(turn_id) = requested_turn_id.as_deref() { + let db = app.state::(); + abort_running_turn_by_id(db.inner(), &session_id, turn_id, RUNTIME_INTERRUPT_MESSAGE)? + } else { + false + }; let cleared = clear_runtime_queue_service(&app, &session_id).await?; - Ok(cancelled || !cleared.is_empty()) + Ok(cancelled || aborted || !cleared.is_empty()) } /// 统一运行时:压缩当前会话上下文。 diff --git a/src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs index 29fdef503..7e9ea5d3d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs @@ -5,12 +5,25 @@ const COVER_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ TOOL_SEARCH_TOOL_NAME, "WebSearch", "web_search", + "Bash", "Read", "read", + "Write", + "write", + "Edit", + "edit", "Glob", "glob", "Grep", "grep", + "mcp__lime-browser__*", + "browser_*", + "mcp__playwright__*", + "playwright*", + "social_generate_cover_image", + LIME_CREATE_IMAGE_TASK_TOOL_NAME, + "TaskGet", + "TaskOutput", ]; fn extract_object_string( @@ -35,6 +48,49 @@ fn truncate_prompt_text(value: String, max_chars: usize) -> String { format!("{truncated}...(已截断,原始长度 {total_chars} 字)") } +fn ensure_cover_skill_launch_workbench_chat_mode(value: &mut serde_json::Value) { + let Some(root) = value.as_object_mut() else { + return; + }; + let harness = if root.contains_key("harness") { + match root + .get_mut("harness") + .and_then(serde_json::Value::as_object_mut) + { + Some(harness) => harness, + None => return, + } + } else { + root + }; + + let has_launch = ["cover_skill_launch", "coverSkillLaunch"] + .iter() + .any(|key| { + harness + .get(*key) + .and_then(serde_json::Value::as_object) + .is_some() + }); + if !has_launch { + return; + } + + harness.insert( + "chat_mode".to_string(), + serde_json::Value::String("workbench".to_string()), + ); +} + +pub(crate) fn prepare_cover_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let mut metadata = request_metadata.cloned()?; + ensure_cover_skill_launch_workbench_chat_mode(&mut metadata); + + Some(metadata) +} + pub(crate) fn merge_system_prompt_with_cover_skill_launch( base_prompt: Option, request_metadata: Option<&serde_json::Value>, @@ -123,6 +179,9 @@ pub(crate) fn prune_cover_skill_launch_detour_tools_from_registry( for tool_name in COVER_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { registry.unregister(tool_name); } + for tool in get_chrome_mcp_tools() { + registry.unregister(&format!("mcp__lime-browser__{}", tool.name)); + } } fn build_cover_skill_launch_system_prompt( @@ -184,8 +243,10 @@ fn build_cover_skill_launch_system_prompt( "- 当前回合已经显式知道要走封面技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), "- 在 Skill(cover_generate) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), "- 不要搜索 “cover_generate”、“social_generate_cover_image” 或 “lime task create cover --json” 之类目录信息;当前 cover_task 已经提供了足够上下文。".to_string(), + "- 不要直接调用 social_generate_cover_image 或 lime_create_image_generation_task;封面不是普通 image_generate 任务,唯一允许的任务出口是 cover_generate。".to_string(), + "- 不要调用浏览器 MCP / Playwright / 页面工具,也不要用 Write/Edit/Bash 自行生成 HTML 封面;封面任务必须先直调 Skill(cover_generate)。".to_string(), "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(cover_generate)。".to_string(), - "- Skill 执行后,优先沿 cover_generate skill 的 social_generate_cover_image + Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_cover_generation_task。".to_string(), + "- Skill 执行后,必须沿 cover_generate skill 的 lime_create_cover_generation_task 主链提交异步 cover_generate 任务文件。".to_string(), "- 不要把封面任务退化成普通配图,也不要伪造“封面已生成完成”;在 task file 真正返回结果前,只能汇报任务已提交、排队或执行中。".to_string(), format!("- 当前封面任务上下文(JSON):{cover_task_json}"), format!("- 当前入口来源:{entry_source}。"), diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index ae5070513..ece54caf7 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -217,7 +217,6 @@ pub struct AgentRuntimeInterruptTurnRequest { #[serde(alias = "sessionId")] pub session_id: String, #[serde(default, alias = "turnId")] - #[allow(dead_code)] pub turn_id: Option, } @@ -1511,25 +1510,13 @@ impl AgentRuntimeThreadReadModel { ); let file_checkpoint_summary = runtime_file_checkpoint_service::build_thread_file_checkpoint_summary(detail); - let active_turn = detail - .turns - .iter() - .rev() - .find(|turn| { - matches!( - turn.status, - lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running - ) - }) - .or(latest_turn); - let active_turn_running = active_turn - .map(|turn| { - matches!( - turn.status, - lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running - ) - }) - .unwrap_or(false); + let active_running_turn = detail.turns.iter().rev().find(|turn| { + matches!( + turn.status, + lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Running + ) + }); + let active_turn_running = active_running_turn.is_some(); let interrupting = runtime_interrupt_marker.is_some() && active_turn_running; let status = if interrupting { "interrupting".to_string() @@ -1663,7 +1650,7 @@ impl AgentRuntimeThreadReadModel { Self { thread_id: detail.thread_id.clone(), status, - active_turn_id: active_turn.map(|turn| turn.id.clone()), + active_turn_id: active_running_turn.map(|turn| turn.id.clone()), pending_requests, last_outcome, incidents, @@ -3033,6 +3020,49 @@ mod tests { assert_eq!(thread_read.incidents[0].incident_type, "provider_error"); } + #[test] + fn thread_read_should_not_keep_aborted_turn_active_after_followup_completed() { + let detail = build_session_detail( + vec![ + AgentThreadTurn { + id: "turn-aborted".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "请输出 160 行".to_string(), + status: AgentThreadTurnStatus::Aborted, + started_at: "2026-03-23T09:10:00Z".to_string(), + completed_at: Some("2026-03-23T09:10:02Z".to_string()), + error_message: Some("用户已停止当前执行".to_string()), + created_at: "2026-03-23T09:10:00Z".to_string(), + updated_at: "2026-03-23T09:10:02Z".to_string(), + }, + AgentThreadTurn { + id: "turn-followup".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "停止后恢复测试:请只回复“恢复成功”。".to_string(), + status: AgentThreadTurnStatus::Completed, + started_at: "2026-03-23T09:10:05Z".to_string(), + completed_at: Some("2026-03-23T09:10:06Z".to_string()), + error_message: None, + created_at: "2026-03-23T09:10:05Z".to_string(), + updated_at: "2026-03-23T09:10:06Z".to_string(), + }, + ], + Vec::new(), + ); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + + assert_eq!(thread_read.status, "completed"); + assert_eq!(thread_read.active_turn_id, None); + assert_eq!( + thread_read + .diagnostics + .as_ref() + .and_then(|diagnostics| diagnostics.latest_turn_status.as_deref()), + Some("completed") + ); + } + #[test] fn thread_read_should_surface_cost_and_limit_runtime_summary() { let mut detail = build_session_detail( diff --git a/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs index da13971db..2bd4552c0 100644 --- a/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs @@ -16,10 +16,18 @@ const IMAGE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ "Bash", "Read", "read", + "Write", + "write", + "Edit", + "edit", "Glob", "glob", "Grep", "grep", + "mcp__lime-browser__*", + "browser_*", + "mcp__playwright__*", + "playwright*", ]; fn extract_object_string( @@ -351,6 +359,9 @@ pub(crate) fn prune_image_skill_launch_detour_tools_from_registry( for tool_name in IMAGE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { registry.unregister(tool_name); } + for tool in get_chrome_mcp_tools() { + registry.unregister(&format!("mcp__lime-browser__{}", tool.name)); + } } fn build_image_skill_launch_system_prompt( @@ -427,7 +438,7 @@ fn build_image_skill_launch_system_prompt( ), "- 当前回合已经显式知道要走图片技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), "- 当前主会话第一刀必须先调用 Skill(image_generate),但这不等于任务已经创建。".to_string(), - "- 在 Skill(image_generate) 真正执行前,不要先走 ToolSearch / WebSearch / Bash / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 在 Skill(image_generate) 真正执行前,不要先走 ToolSearch / WebSearch / Bash / Read / Write / Edit / Glob / Grep / 浏览器 MCP / Playwright 等通用工具发现、检索、脚本、文件或页面链路。".to_string(), "- 不要搜索 “Skill image_generate”、“lime media image generate --json”、“lime_create_image_generation_task” 之类目录信息;当前 image_task 已经提供了足够上下文。".to_string(), "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(image_generate)。".to_string(), "- 如果 Skill(image_generate) 返回的 Lime 工具元数据里只有 allowed_tools=[\"lime_create_image_generation_task\"],而没有 task_id/path/status,说明任务尚未创建;当前主会话必须立刻继续调用 lime_create_image_generation_task。".to_string(), diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 0d228eb46..3f223cf60 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -96,22 +96,21 @@ use lime_agent::{ builtin_team_preset_descriptor_by_id, builtin_team_preset_label_by_id, create_subagent_session, is_virtual_memory_path, list_child_subagent_sessions, list_subagent_cascade_session_ids, list_subagent_status_scope_session_ids, load_subagent_runtime_status, - merge_system_prompt_with_runtime_agents, message_suggests_news_expansion, - normalize_team_runtime_provider_group, persist_compaction_session_metrics_update, - persist_session_extension_data, preview_provider_runtime_wait_snapshot, - preview_team_runtime_wait_snapshot, read_session, read_subagent_control_state, - release_provider_runtime_permit, release_team_runtime_permit, replace_session_conversation, - resolve_provider_runtime_parallel_budget, resolve_virtual_memory_path, - snapshot_provider_runtime_lease, snapshot_team_runtime_session, summarize_builtin_skill, - virtual_memory_relative_path, write_subagent_control_state, AgentMessage, AgentMessageContent, - AgentRuntimeStatus, CompactionSessionMetricsUpdate, ProviderContinuationCapability, - ProviderContinuationCapable, ProviderContinuationState, ProviderRuntimeGovernorSnapshot, - RuntimeProjectionSnapshot, SessionStateSnapshot, SubagentControlState, - SubagentCustomizationState, SubagentRuntimeStatus, SubagentRuntimeStatusKind, - SubagentSkillPromptBlock, SubagentSkillSummary, TeamRuntimeGovernorSnapshot, - TurnExecutionProfile, TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, - TurnProviderRoutingSnapshot, TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource, - DURABLE_MEMORY_VIRTUAL_ROOT, + merge_system_prompt_with_runtime_agents, normalize_team_runtime_provider_group, + persist_compaction_session_metrics_update, persist_session_extension_data, + preview_provider_runtime_wait_snapshot, preview_team_runtime_wait_snapshot, read_session, + read_subagent_control_state, release_provider_runtime_permit, release_team_runtime_permit, + replace_session_conversation, resolve_provider_runtime_parallel_budget, + resolve_virtual_memory_path, snapshot_provider_runtime_lease, snapshot_team_runtime_session, + summarize_builtin_skill, virtual_memory_relative_path, write_subagent_control_state, + AgentMessage, AgentMessageContent, AgentRuntimeStatus, CompactionSessionMetricsUpdate, + ProviderContinuationCapability, ProviderContinuationCapable, ProviderContinuationState, + ProviderRuntimeGovernorSnapshot, RuntimeProjectionSnapshot, SessionStateSnapshot, + SubagentControlState, SubagentCustomizationState, SubagentRuntimeStatus, + SubagentRuntimeStatusKind, SubagentSkillPromptBlock, SubagentSkillSummary, + TeamRuntimeGovernorSnapshot, TurnExecutionProfile, TurnInputEnvelopeBuilder, + TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot, TurnRequestToolPolicySnapshot, + TurnState, TurnSystemPromptSource, DURABLE_MEMORY_VIRTUAL_ROOT, }; use lime_services::api_key_provider_service::ApiKeyProviderService; use lime_services::mcp_service::McpService; @@ -437,6 +436,7 @@ pub(crate) use command_api::{ }; pub(crate) use cover_skill_launch::{ append_cover_skill_launch_session_permissions, merge_system_prompt_with_cover_skill_launch, + prepare_cover_skill_launch_request_metadata, prune_cover_skill_launch_detour_tools_from_registry, }; pub(crate) use deep_search_skill_launch::{ @@ -502,8 +502,6 @@ pub(crate) use provider_runtime_bootstrap::ensure_provider_runtime_ready; pub(crate) use provider_runtime_strategy::{ enrich_provider_config_with_runtime_tool_strategy, RuntimeToolCallStrategy, }; -#[cfg(test)] -use reply_runtime::message_suggests_live_search; use reply_runtime::{ build_runtime_user_message, build_turn_runtime_statuses, complete_runtime_status_projection, emit_runtime_status_with_projection, ensure_code_execution_extension_enabled, @@ -579,18 +577,19 @@ pub(crate) use summary_skill_launch::{ }; #[allow(unused_imports)] pub(crate) use tool_runtime::social_generate_cover_image_cmd; -#[cfg(test)] -#[allow(unused_imports)] pub(crate) use tool_runtime::{ - append_subagent_tool_scope_session_permissions, ensure_default_web_tools_registered, - extract_runtime_subagent_result_text, LimeBrowserMcpTool, SocialGenerateCoverImageTool, - ToolSearchBridgeTool, + append_subagent_tool_scope_session_permissions, apply_workspace_sandbox_permissions, ImageInput, }; -pub(crate) use tool_runtime::{apply_workspace_sandbox_permissions, ImageInput}; pub(crate) use tool_runtime::{ ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered, ensure_runtime_support_tools_registered, ensure_social_image_tool_registered, }; +#[cfg(test)] +#[allow(unused_imports)] +pub(crate) use tool_runtime::{ + ensure_default_web_tools_registered, extract_runtime_subagent_result_text, LimeBrowserMcpTool, + SocialGenerateCoverImageTool, ToolSearchBridgeTool, +}; pub(crate) use transcription_skill_launch::{ append_transcription_skill_launch_session_permissions, merge_system_prompt_with_transcription_skill_launch, diff --git a/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs index 24ff029f4..741c87f9e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs @@ -24,48 +24,6 @@ fn model_supports_reasoning(model_name: Option<&str>) -> bool { || normalized.contains("2.5") } -pub(super) fn message_suggests_live_search(message: &str) -> bool { - let normalized = message.to_ascii_lowercase(); - [ - "搜索", - "搜一下", - "查一下", - "查一查", - "检索", - "上网查", - "联网查", - "最新", - "今天", - "刚刚", - "实时", - "新闻", - "股价", - "汇率", - "天气", - "政策", - "法规", - "版本", - "价格", - "热搜", - "上线", - "发布", - "search", - "look up", - "google", - "browse", - "now", - "today", - "latest", - "recent", - "price", - "version", - "news", - "weather", - ] - .iter() - .any(|keyword| normalized.contains(keyword)) -} - fn message_suggests_planning(message: &str) -> bool { let normalized = message.to_ascii_lowercase(); [ @@ -198,16 +156,12 @@ pub(super) async fn build_turn_runtime_statuses( let subagent_enabled = resolve_request_subagent_enabled_from_sources(request, session_recent_preferences); let reasoning_supported = model_supports_reasoning(model_name); - let news_expansion_needed = request_tool_policy.allows_web_search() - && message_suggests_news_expansion(&request.message); let browser_task_requirement = extract_browser_task_requirement(request.metadata.as_ref()); let initial_checkpoints = vec![ execution_strategy_label(effective_strategy).to_string(), if request_tool_policy.requires_web_search() { "当前任务需要先联网核实".to_string() - } else if news_expansion_needed { - "已识别新闻综述类输入,将先分批联网补充信息".to_string() } else if request_tool_policy.allows_web_search() { "联网搜索仅作为候选能力待命".to_string() } else { @@ -249,16 +203,6 @@ pub(super) async fn build_turn_runtime_statuses( "搜索结果返回后再形成最终答复".to_string(), ], ) - } else if news_expansion_needed { - ( - "先联网扩搜".to_string(), - "当前输入属于新闻或最新动态综述类请求,会先分批执行多组联网搜索,再基于结果做主题归纳与交叉核实。" - .to_string(), - vec![ - "统一使用 WebSearch 分批补充信息".to_string(), - "完成来源整合后再组织最终答复".to_string(), - ], - ) } else if subagent_enabled && message_suggests_subagent(&request.message) { ( "优先拆分为多代理".to_string(), @@ -297,17 +241,6 @@ pub(super) async fn build_turn_runtime_statuses( "当前模型回退为轻量推理".to_string(), ], ) - } else if request_tool_policy.allows_web_search() - && message_suggests_live_search(&request.message) - { - ( - "先联网核实".to_string(), - "问题包含明显时效性或实时性特征,先搜索核实再回答更稳妥。".to_string(), - vec![ - "已检测到最新/实时信息需求".to_string(), - "搜索完成后继续组织答复".to_string(), - ], - ) } else if message_suggests_planning(&request.message) { ( "先规划再输出".to_string(), diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index d80c1361d..3bc51b53c 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -48,6 +48,7 @@ const LIME_RUNTIME_IMAGE_INPUT_POLICY_KEY: &str = "image_input_policy"; const FAST_CHAT_TOOL_SURFACE_DIRECT_ANSWER: &str = "direct_answer"; const FAST_CHAT_TOOL_SURFACE_LOCAL_WORKSPACE: &str = "local_workspace"; const RUNTIME_IMAGE_INPUT_UNSUPPORTED_WARNING_CODE: &str = "runtime_image_input_unsupported"; +const RUNTIME_TURN_CANCELLED_MESSAGE: &str = "用户已停止当前执行"; const AUTO_RUNTIME_MEMORY_MIN_USER_CHARS: usize = 12; const AUTO_RUNTIME_MEMORY_MIN_ASSISTANT_CHARS: usize = 48; const AUTO_RUNTIME_MEMORY_MIN_TOTAL_CHARS: usize = 160; @@ -1824,7 +1825,12 @@ async fn prepare_runtime_turn_request( ); let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref()); - if matches!(execution_profile, TurnExecutionProfile::FullRuntime) { + if should_prewarm_mcp_runtime( + request, + execution_profile, + runtime_chat_mode, + request_tool_policy, + ) { let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await; let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await; @@ -1842,10 +1848,18 @@ async fn prepare_runtime_turn_request( } } else { tracing::info!( - "[AsterAgent] FastChat 跳过 MCP runtime 预热: session={}, search_mode={}, chat_mode={:?}", + "[AsterAgent] 跳过 MCP runtime 预热: session={}, profile={:?}, search_mode={}, chat_mode={:?}, reason={}", session_id, + execution_profile, request_tool_policy.search_mode.as_str(), - runtime_chat_mode + runtime_chat_mode, + resolve_mcp_prewarm_skip_reason( + request, + execution_profile, + runtime_chat_mode, + request_tool_policy, + ) + .unwrap_or("not_required") ); } @@ -1857,6 +1871,7 @@ async fn prepare_runtime_turn_request( request.metadata.as_ref(), request.images.as_deref(), ); + request.metadata = prepare_cover_skill_launch_request_metadata(request.metadata.as_ref()); request.metadata = prepare_broadcast_skill_launch_request_metadata(request.metadata.as_ref()); request.metadata = @@ -3724,6 +3739,62 @@ fn request_metadata_contains_full_runtime_context( .unwrap_or(false) } +fn request_has_non_search_full_runtime_reason( + request: &AsterChatRequest, + runtime_chat_mode: RuntimeChatMode, +) -> bool { + let has_images = request + .images + .as_ref() + .is_some_and(|images| !images.is_empty()); + + has_images + || request.project_id.is_some() + || !matches!(runtime_chat_mode, RuntimeChatMode::General) + || request_metadata_contains_full_runtime_context(request.metadata.as_ref()) + || extract_harness_bool( + request.metadata.as_ref(), + &["allow_model_skills", "allowModelSkills"], + ) + .unwrap_or(false) +} + +fn is_web_search_only_runtime( + request: &AsterChatRequest, + runtime_chat_mode: RuntimeChatMode, + request_tool_policy: &RequestToolPolicy, +) -> bool { + request_tool_policy.allows_web_search() + && !request_has_non_search_full_runtime_reason(request, runtime_chat_mode) +} + +fn should_prewarm_mcp_runtime( + request: &AsterChatRequest, + execution_profile: TurnExecutionProfile, + runtime_chat_mode: RuntimeChatMode, + request_tool_policy: &RequestToolPolicy, +) -> bool { + matches!(execution_profile, TurnExecutionProfile::FullRuntime) + && !is_web_search_only_runtime(request, runtime_chat_mode, request_tool_policy) +} + +fn resolve_mcp_prewarm_skip_reason( + request: &AsterChatRequest, + execution_profile: TurnExecutionProfile, + runtime_chat_mode: RuntimeChatMode, + request_tool_policy: &RequestToolPolicy, +) -> Option<&'static str> { + if !matches!(execution_profile, TurnExecutionProfile::FullRuntime) { + return Some("fast_chat"); + } + + if is_web_search_only_runtime(request, runtime_chat_mode, request_tool_policy) { + return Some("web_search_only_native_tools"); + } + + None +} + fn resolve_fast_chat_tool_surface_mode( request: &AsterChatRequest, execution_profile: TurnExecutionProfile, @@ -5074,22 +5145,9 @@ fn resolve_turn_execution_profile( request_tool_policy: &RequestToolPolicy, auto_continue_enabled: bool, ) -> TurnExecutionProfile { - let has_images = request - .images - .as_ref() - .is_some_and(|images| !images.is_empty()); - - if has_images - || request.project_id.is_some() + if request_has_non_search_full_runtime_reason(request, runtime_chat_mode) || auto_continue_enabled - || request_tool_policy.effective_web_search - || !matches!(runtime_chat_mode, RuntimeChatMode::General) - || request_metadata_contains_full_runtime_context(request.metadata.as_ref()) - || extract_harness_bool( - request.metadata.as_ref(), - &["allow_model_skills", "allowModelSkills"], - ) - .unwrap_or(false) + || request_tool_policy.requires_web_search() { TurnExecutionProfile::FullRuntime } else { @@ -5476,6 +5534,19 @@ async fn execute_runtime_stream_attempt( ) .await?; + if execution.cancelled { + tracing::info!( + "[AsterAgent] runtime turn cancelled before success finalization: session_id={}, event_name={}, emitted_any={}", + session_id, + request.event_name, + execution.emitted_any + ); + return Err(ReplyAttemptError { + message: RUNTIME_TURN_CANCELLED_MESSAGE.to_string(), + emitted_any: execution.emitted_any, + }); + } + finalize_runtime_stream_success( app, db, @@ -5685,6 +5756,9 @@ where ); Ok(assistant_output) } + Err(primary_error) if is_runtime_turn_cancelled_error(&primary_error.message) => { + Err(primary_error.message) + } Err(primary_error) if effective_strategy == AsterExecutionStrategy::CodeOrchestrated && should_fallback_to_react_from_code_orchestrated(&primary_error) => @@ -5978,6 +6052,7 @@ where lime_agent::tools::clear_skill_tool_session_access(session_id); state.remove_cancel_token(session_id).await; + state.clear_interrupt_marker(session_id).await; result } @@ -6058,6 +6133,10 @@ fn build_runtime_run_finish_decision( } } +fn is_runtime_turn_cancelled_error(error: &str) -> bool { + error.trim() == RUNTIME_TURN_CANCELLED_MESSAGE +} + async fn finalize_runtime_turn_result( agent: &Agent, app: &AppHandle, @@ -6088,6 +6167,7 @@ async fn finalize_runtime_turn_result( }; match result.as_ref() { Ok(_) => recorder.complete_turn_success(), + Err(error) if is_runtime_turn_cancelled_error(error) => recorder.abort_turn(error), Err(error) => recorder.fail_turn(error), } }; @@ -6159,6 +6239,15 @@ async fn finalize_runtime_turn_result( Ok(()) } Err(error) => { + if is_runtime_turn_cancelled_error(&error) { + if let Err(emit_error) = + app.emit(event_name, &RuntimeAgentEvent::FinalDone { usage: None }) + { + tracing::error!("[AsterAgent] 发送中断完成事件失败: {}", emit_error); + } + emit_subagent_status_changed_events(app, session_id).await; + return Ok(()); + } if let Some(limit_event) = lime_agent::detect_runtime_limit_event(Some(&error)) { let event = map_runtime_limit_event_to_runtime_agent_event(limit_event); emit_runtime_side_event(app, event_name, timeline_recorder, workspace_root, event); @@ -7685,17 +7774,87 @@ mod tests { } #[test] - fn resolve_turn_execution_profile_should_use_full_runtime_for_explicit_web_search() { + fn resolve_turn_execution_profile_should_keep_fast_chat_for_allowed_web_search() { let mut request = build_runtime_turn_test_request("帮我搜今天的新闻", None); request.web_search = Some(true); let policy = lime_agent::resolve_request_tool_policy(Some(true), false); + assert_eq!( + resolve_turn_execution_profile(&request, RuntimeChatMode::General, &policy, false,), + TurnExecutionProfile::FastChat + ); + } + + #[test] + fn resolve_turn_execution_profile_should_use_full_runtime_for_required_web_search() { + let mut request = build_runtime_turn_test_request("帮我搜今天的新闻", None); + request.web_search = Some(true); + request.search_mode = Some(RequestToolPolicyMode::Required); + let policy = lime_agent::resolve_request_tool_policy_with_mode( + Some(true), + Some(RequestToolPolicyMode::Required), + false, + ); + assert_eq!( resolve_turn_execution_profile(&request, RuntimeChatMode::General, &policy, false,), TurnExecutionProfile::FullRuntime ); } + #[test] + fn should_prewarm_mcp_runtime_should_skip_web_search_only_required_turn() { + let mut request = build_runtime_turn_test_request("帮我搜今天的新闻", None); + request.web_search = Some(true); + request.search_mode = Some(RequestToolPolicyMode::Required); + let policy = lime_agent::resolve_request_tool_policy_with_mode( + Some(true), + Some(RequestToolPolicyMode::Required), + false, + ); + + assert!(!should_prewarm_mcp_runtime( + &request, + TurnExecutionProfile::FullRuntime, + RuntimeChatMode::General, + &policy, + )); + assert_eq!( + resolve_mcp_prewarm_skip_reason( + &request, + TurnExecutionProfile::FullRuntime, + RuntimeChatMode::General, + &policy, + ), + Some("web_search_only_native_tools") + ); + } + + #[test] + fn should_prewarm_mcp_runtime_should_keep_full_context_turns_warm() { + let mut request = build_runtime_turn_test_request( + "请帮我抓取站点内容", + Some(json!({ + "harness": { + "theme": "general", + "chat_mode": "general", + "service_skill_launch": { + "adapter_name": "github/search" + } + } + })), + ); + request.web_search = Some(true); + let policy = lime_agent::resolve_request_tool_policy(Some(true), false); + + assert!(should_prewarm_mcp_runtime( + &request, + TurnExecutionProfile::FullRuntime, + RuntimeChatMode::General, + &policy, + )); + } + #[test] fn resolve_fast_chat_tool_surface_mode_should_use_direct_answer_for_plain_greeting() { let request = build_runtime_turn_test_request("你好", None); diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 503f321f3..75ddfe786 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -176,19 +176,6 @@ mod tests { assert_eq!(request.auto_continue, None); } - #[test] - fn test_message_suggests_live_search_accepts_explicit_search_verbs() { - assert!(message_suggests_live_search( - "请帮我搜一下哥德尔不完备定理的历史背景" - )); - assert!(message_suggests_live_search( - "please look up kyoto travel tips" - )); - assert!(!message_suggests_live_search( - "帮我解释一下什么是向量数据库" - )); - } - #[test] fn test_resolve_workspace_id_from_sources_prefers_request_value() { assert_eq!( @@ -1312,12 +1299,21 @@ mod tests { assert!(permissions .iter() .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Write" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Edit" && !permission.allowed)); assert!(permissions .iter() .any(|permission| permission.tool == "Glob" && !permission.allowed)); assert!(permissions .iter() .any(|permission| permission.tool == "Grep" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "mcp__lime-browser__*" && !permission.allowed)); } #[test] @@ -1344,6 +1340,16 @@ mod tests { "Read file", serde_json::json!({"type": "object"}), ))); + registry.register(Box::new(DummyTool::new( + "Write", + "Write file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "mcp__lime-browser__tabs_context_mcp", + "Browser page", + serde_json::json!({"type": "object"}), + ))); registry.register(Box::new(DummyTool::new( LIME_CREATE_IMAGE_TASK_TOOL_NAME, "Create image task", @@ -1371,7 +1377,9 @@ mod tests { assert!(registry.contains(LIME_CREATE_IMAGE_TASK_TOOL_NAME)); assert!(!registry.contains("Bash")); assert!(!registry.contains("Read")); + assert!(!registry.contains("Write")); assert!(!registry.contains("Glob")); + assert!(!registry.contains("mcp__lime-browser__tabs_context_mcp")); assert!(registry.contains("Skill")); } @@ -1411,12 +1419,33 @@ mod tests { assert!(permissions .iter() .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Write" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Edit" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Bash" && !permission.allowed)); assert!(permissions .iter() .any(|permission| permission.tool == "Glob" && !permission.allowed)); assert!(permissions .iter() .any(|permission| permission.tool == "Grep" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "mcp__lime-browser__*" && !permission.allowed)); + assert!(permissions.iter().any(|permission| permission.tool + == "social_generate_cover_image" + && !permission.allowed)); + assert!(permissions.iter().any(|permission| permission.tool + == LIME_CREATE_IMAGE_TASK_TOOL_NAME + && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "TaskOutput" && !permission.allowed)); } #[test] @@ -1443,11 +1472,41 @@ mod tests { "Read file", serde_json::json!({"type": "object"}), ))); + registry.register(Box::new(DummyTool::new( + "Write", + "Write file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "mcp__lime-browser__tabs_context_mcp", + "Browser page", + serde_json::json!({"type": "object"}), + ))); registry.register(Box::new(DummyTool::new( "Glob", "Glob file", serde_json::json!({"type": "object"}), ))); + registry.register(Box::new(DummyTool::new( + "social_generate_cover_image", + "Generate cover directly", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + LIME_CREATE_IMAGE_TASK_TOOL_NAME, + "Create image task", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + LIME_CREATE_COVER_TASK_TOOL_NAME, + "Create cover task", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "TaskOutput", + "Wait for task", + serde_json::json!({"type": "object"}), + ))); registry.register(Box::new(DummyTool::new( "Skill", "Run skill", @@ -1458,7 +1517,13 @@ mod tests { assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); assert!(!registry.contains("Read")); + assert!(!registry.contains("Write")); assert!(!registry.contains("Glob")); + assert!(!registry.contains("mcp__lime-browser__tabs_context_mcp")); + assert!(!registry.contains("social_generate_cover_image")); + assert!(!registry.contains(LIME_CREATE_IMAGE_TASK_TOOL_NAME)); + assert!(!registry.contains("TaskOutput")); + assert!(registry.contains(LIME_CREATE_COVER_TASK_TOOL_NAME)); assert!(registry.contains("Skill")); } @@ -4797,7 +4862,7 @@ mod tests { assert!(merged.contains("\"image_task\":")); assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); assert!(merged.contains("当前主会话第一刀必须先调用 Skill(image_generate)")); - assert!(merged.contains("不要先走 ToolSearch / WebSearch / Bash / Read / Glob / Grep")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Bash / Read / Write / Edit / Glob / Grep / 浏览器 MCP / Playwright")); assert!(merged.contains("应立即改为直调 Skill(image_generate)")); assert!(merged.contains( "如果 Skill(image_generate) 返回的 Lime 工具元数据里只有 allowed_tools=[\"lime_create_image_generation_task\"]" @@ -4858,8 +4923,12 @@ mod tests { assert!(merged.contains("\"cover_task\":")); assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged + .contains("不要直接调用 social_generate_cover_image 或 lime_create_image_generation_task")); + assert!(merged.contains("不要调用浏览器 MCP / Playwright / 页面工具")); + assert!(merged.contains("不要用 Write/Edit/Bash 自行生成 HTML 封面")); assert!(merged.contains("应立即改为直调 Skill(cover_generate)")); - assert!(merged.contains("lime task create cover --json")); + assert!(merged.contains("lime_create_cover_generation_task 主链")); assert!(merged.contains("不要把封面任务退化成普通配图")); assert!(merged.contains("当前任务已经显式进入封面技能主链")); } @@ -5466,6 +5535,34 @@ mod tests { assert!(harness.get("broadcast_skill_launch").is_some()); } + #[test] + fn test_prepare_cover_skill_launch_request_metadata_sets_workbench_chat_mode() { + let metadata = serde_json::json!({ + "harness": { + "cover_skill_launch": { + "skill_name": "cover_generate", + "kind": "cover_task", + "cover_task": { + "prompt": "春日咖啡市集封面" + } + } + } + }); + + let prepared = prepare_cover_skill_launch_request_metadata(Some(&metadata)) + .expect("prepared metadata"); + + let harness = prepared + .get("harness") + .and_then(serde_json::Value::as_object) + .expect("harness"); + assert_eq!( + harness.get("chat_mode").and_then(serde_json::Value::as_str), + Some("workbench") + ); + assert!(harness.get("cover_skill_launch").is_some()); + } + #[test] fn test_prepare_resource_search_skill_launch_request_metadata_sets_workbench_chat_mode() { let metadata = serde_json::json!({ diff --git a/src-tauri/src/dev_bridge.rs b/src-tauri/src/dev_bridge.rs index 794f62add..17688a9b7 100644 --- a/src-tauri/src/dev_bridge.rs +++ b/src-tauri/src/dev_bridge.rs @@ -92,10 +92,17 @@ pub struct DevBridgeConfig { #[cfg(debug_assertions)] impl Default for DevBridgeConfig { fn default() -> Self { - Self { - host: "127.0.0.1".to_string(), - port: 3030, - } + let host = std::env::var("LIME_DEV_BRIDGE_HOST") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "127.0.0.1".to_string()); + let port = std::env::var("LIME_DEV_BRIDGE_PORT") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(3030); + + Self { host, port } } } diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 918ada6f8..1906a0d6b 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -830,6 +830,36 @@ mod tests { } } + #[tokio::test] + async fn agent_runtime_subagent_control_commands_are_bridged() { + let state = make_test_state(); + let cases = [ + "agent_runtime_spawn_subagent", + "agent_runtime_send_subagent_input", + "agent_runtime_wait_subagents", + "agent_runtime_resume_subagent", + "agent_runtime_close_subagent", + ]; + + for cmd in cases { + let error = handle_command( + &state, + cmd, + Some(serde_json::json!({ + "request": {} + })), + ) + .await + .expect_err("missing app handle should fail after bridge routing"); + + let error_text = error.to_string(); + assert!( + error_text.contains("Dev Bridge 未持有 AppHandle"), + "command {cmd} should route into agent_sessions bridge, got: {error_text}" + ); + } + } + #[tokio::test] async fn agent_generate_title_bridge_requires_app_handle_in_test_state() { let state = make_test_state(); diff --git a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs index 32ad89e7c..3a1720251 100644 --- a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs +++ b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs @@ -61,6 +61,11 @@ pub(super) async fn try_handle( | "agent_runtime_interrupt_turn" | "agent_runtime_compact_session" | "agent_runtime_resume_thread" + | "agent_runtime_spawn_subagent" + | "agent_runtime_send_subagent_input" + | "agent_runtime_wait_subagents" + | "agent_runtime_resume_subagent" + | "agent_runtime_close_subagent" | "agent_runtime_create_session" | "agent_runtime_list_sessions" | "agent_runtime_get_session" @@ -207,6 +212,156 @@ pub(super) async fn try_handle( .await?, )? } + "agent_runtime_spawn_subagent" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeSpawnSubagentRequest, + >(args)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_spawn_subagent( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + ) + .await?, + )? + } + "agent_runtime_send_subagent_input" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeSendSubagentInputRequest, + >(args)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_send_subagent_input( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + ) + .await?, + )? + } + "agent_runtime_wait_subagents" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeWaitSubagentsRequest, + >(args)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_wait_subagents( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + ) + .await?, + )? + } + "agent_runtime_resume_subagent" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeResumeSubagentRequest, + >(args)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_resume_subagent( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + ) + .await?, + )? + } + "agent_runtime_close_subagent" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeCloseSubagentRequest, + >(args)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_close_subagent( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + ) + .await?, + )? + } "agent_runtime_create_session" => { let args = args_or_default(args); let workspace_id = get_string_arg(&args, "workspaceId", "workspace_id")?; diff --git a/src-tauri/src/services/agent_timeline_service.rs b/src-tauri/src/services/agent_timeline_service.rs index 2f732df77..cbcaec76f 100644 --- a/src-tauri/src/services/agent_timeline_service.rs +++ b/src-tauri/src/services/agent_timeline_service.rs @@ -42,6 +42,55 @@ fn resolve_artifact_item_source(metadata: Option<&Value>) -> String { .unwrap_or_else(|| "artifact_snapshot".to_string()) } +pub fn abort_running_turn_by_id( + db: &DbConnection, + thread_id: &str, + turn_id: &str, + message: &str, +) -> Result { + let normalized_turn_id = turn_id.trim(); + if thread_id.trim().is_empty() || normalized_turn_id.is_empty() { + return Ok(false); + } + + let now = Utc::now().to_rfc3339(); + let conn = lock_db(db)?; + let turns = AgentTimelineDao::list_turns_by_thread(&conn, thread_id) + .map_err(|e| format!("读取 turn 失败: {e}"))?; + let Some(turn) = turns.iter().find(|turn| turn.id == normalized_turn_id) else { + return Ok(false); + }; + if !matches!(turn.status, AgentThreadTurnStatus::Running) { + return Ok(false); + } + + AgentTimelineDao::update_turn_status( + &conn, + normalized_turn_id, + AgentThreadTurnStatus::Aborted, + Some(&now), + Some(message), + &now, + ) + .map_err(|e| format!("更新 turn 中断状态失败: {e}"))?; + + let items = AgentTimelineDao::list_items_by_thread(&conn, thread_id) + .map_err(|e| format!("读取 turn item 失败: {e}"))?; + for mut item in items + .into_iter() + .filter(|item| item.turn_id == normalized_turn_id) + .filter(|item| matches!(item.status, AgentThreadItemStatus::InProgress)) + { + item.status = AgentThreadItemStatus::Completed; + item.completed_at = Some(now.clone()); + item.updated_at = now.clone(); + AgentTimelineDao::upsert_item(&conn, &item) + .map_err(|e| format!("更新 turn item 中断状态失败: {e}"))?; + } + + Ok(true) +} + #[derive(Debug)] pub struct AgentTimelineRecorder { db: DbConnection, @@ -319,6 +368,32 @@ impl AgentTimelineRecorder { Ok(events) } + pub fn abort_turn(&mut self, message: &str) -> Result, String> { + let now = Utc::now().to_rfc3339(); + self.turn.status = AgentThreadTurnStatus::Aborted; + self.turn.completed_at = Some(now.clone()); + self.turn.error_message = Some(message.to_string()); + self.turn.updated_at = now.clone(); + + let conn = lock_db(&self.db)?; + AgentTimelineDao::update_turn_status( + &conn, + &self.turn_id, + AgentThreadTurnStatus::Aborted, + Some(&now), + Some(message), + &now, + ) + .map_err(|e| format!("更新 turn 中断状态失败: {e}"))?; + drop(conn); + + let mut events = self.complete_projection_items(AgentThreadItemStatus::Completed)?; + events.push(RuntimeAgentEvent::TurnFailed { + turn: self.turn.clone(), + }); + Ok(events) + } + fn complete_projection_items( &mut self, status: AgentThreadItemStatus, @@ -451,7 +526,10 @@ impl AgentTimelineRecorder { #[cfg(test)] mod tests { use super::*; - use lime_core::database::dao::agent_timeline::{AgentThreadTurnStatus, AgentTimelineDao}; + use lime_core::database::dao::agent_timeline::{ + AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurnStatus, + AgentTimelineDao, + }; use lime_core::database::schema::create_tables; use rusqlite::Connection; use std::sync::{Arc, Mutex}; @@ -495,6 +573,72 @@ mod tests { .any(|event| matches!(event, RuntimeAgentEvent::ItemCompleted { item } if item.id == "error:turn-1"))); } + #[test] + fn abort_turn_should_persist_aborted_turn_without_error_item() { + let db = setup_db(); + let mut recorder = AgentTimelineRecorder::create(db.clone(), "thread-1", "turn-1", "hello") + .expect("创建 recorder"); + + let events = recorder + .abort_turn("用户已停止当前执行") + .expect("写入中断终态"); + + let conn = lock_db(&db).expect("获取数据库锁"); + let turns = AgentTimelineDao::list_turns_by_thread(&conn, "thread-1").expect("读取 turn"); + let items = AgentTimelineDao::list_items_by_thread(&conn, "thread-1").expect("读取 item"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].status, AgentThreadTurnStatus::Aborted); + assert_eq!( + turns[0].error_message.as_deref(), + Some("用户已停止当前执行") + ); + assert!(items.iter().all(|item| item.id != "error:turn-1")); + drop(conn); + + assert!(events + .iter() + .any(|event| matches!(event, RuntimeAgentEvent::TurnFailed { turn } if turn.status == AgentThreadTurnStatus::Aborted))); + } + + #[test] + fn abort_running_turn_by_id_should_complete_in_progress_items_without_error_item() { + let db = setup_db(); + AgentTimelineRecorder::create(db.clone(), "thread-1", "turn-1", "hello") + .expect("创建 recorder"); + { + let conn = lock_db(&db).expect("获取数据库锁"); + AgentTimelineDao::upsert_item( + &conn, + &AgentThreadItem { + id: "item-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 0, + status: AgentThreadItemStatus::InProgress, + started_at: "2026-03-29T00:00:00.000Z".to_string(), + completed_at: None, + updated_at: "2026-03-29T00:00:00.000Z".to_string(), + payload: AgentThreadItemPayload::TurnSummary { + text: "running".to_string(), + }, + }, + ) + .expect("写入测试 item"); + } + + let aborted = abort_running_turn_by_id(&db, "thread-1", "turn-1", "用户已停止当前执行") + .expect("应中断 running turn"); + + let conn = lock_db(&db).expect("获取数据库锁"); + let turns = AgentTimelineDao::list_turns_by_thread(&conn, "thread-1").expect("读取 turn"); + let items = AgentTimelineDao::list_items_by_thread(&conn, "thread-1").expect("读取 item"); + assert!(aborted); + assert_eq!(turns[0].status, AgentThreadTurnStatus::Aborted); + assert_eq!(items[0].status, AgentThreadItemStatus::Completed); + assert!(items[0].completed_at.is_some()); + assert!(items.iter().all(|item| item.id != "error:turn-1")); + } + #[test] fn complete_turn_success_should_persist_completed_turn_before_emitting_events() { let db = setup_db(); diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index 3445847e6..50e104ea1 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -782,11 +782,10 @@ mod tests { assert!(TRANSCRIPTION_GENERATE_SKILL_CONTENT.contains("name: transcription_generate")); assert!(BROADCAST_GENERATE_SKILL_CONTENT.contains("name: broadcast_generate")); assert!(COVER_GENERATE_SKILL_CONTENT.contains("name: cover_generate")); - assert!(COVER_GENERATE_SKILL_CONTENT.contains( - "allowed-tools: social_generate_cover_image, Bash, lime_create_cover_generation_task" - )); assert!(COVER_GENERATE_SKILL_CONTENT - .contains("优先调用 `Bash` 执行 `lime task create cover --json` 创建任务")); + .contains("allowed-tools: lime_create_cover_generation_task")); + assert!(COVER_GENERATE_SKILL_CONTENT + .contains("必须直接调用 `lime_create_cover_generation_task` 创建真实封面任务记录")); assert!(MODAL_RESOURCE_SEARCH_SKILL_CONTENT.contains("name: modal_resource_search")); assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("name: image_generate")); assert!(IMAGE_GENERATE_SKILL_CONTENT diff --git a/src-tauri/src/skills/runtime.rs b/src-tauri/src/skills/runtime.rs index 17d04c83f..0635bab17 100644 --- a/src-tauri/src/skills/runtime.rs +++ b/src-tauri/src/skills/runtime.rs @@ -1,8 +1,13 @@ use crate::agent::AsterAgentState; +use crate::agent_tools::catalog::WorkspaceToolSurface; +use crate::agent_tools::execution::{ + build_workspace_execution_permissions, ToolExecutionResolverInput, + WorkspaceExecutionPermissionInput, +}; use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; use crate::commands::aster_agent_cmd::{ - ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered, - ensure_social_image_tool_registered, + append_subagent_tool_scope_session_permissions, ensure_browser_mcp_tools_registered, + ensure_creation_task_tools_registered, ensure_social_image_tool_registered, }; use crate::commands::skill_error::{ format_skill_error, SKILL_ERR_PROVIDER_UNAVAILABLE, SKILL_ERR_SESSION_INIT_FAILED, @@ -12,8 +17,10 @@ use crate::database::dao::agent_run::AgentRunStatus; use crate::database::DbConnection; use crate::services::execution_tracker_service::RunFinishDecision; use crate::services::memory_profile_prompt_service::{build_memory_prompt, MemoryPromptContext}; +use aster::permission::{PermissionScope, ToolPermission, ToolPermissionManager}; use lime_skills::LoadedSkillDefinition; use std::path::Path; +use std::sync::Arc; use super::execution::SkillExecutionResult; use super::execution_callback::TauriExecutionCallback; @@ -61,6 +68,86 @@ fn build_skill_memory_prompt( build_memory_prompt(&config, context) } +fn resolve_skill_workspace_root(db: &DbConnection, session_id: &str) -> String { + lime_agent::get_session_sync(db, session_id) + .ok() + .and_then(|session| session.working_dir) + .map(|path| path.trim().to_string()) + .filter(|path| !path.is_empty()) + .or_else(|| { + std::env::current_dir() + .ok() + .map(|path| path.to_string_lossy().to_string()) + .filter(|path| !path.trim().is_empty()) + }) + .unwrap_or_else(|| std::env::temp_dir().to_string_lossy().to_string()) +} + +fn build_skill_tool_scope_metadata(skill: &LoadedSkillDefinition) -> Option { + let allowed_tools = skill + .allowed_tools + .as_ref() + .filter(|tools| !tools.is_empty())?; + + Some(serde_json::json!({ + "subagent": { + "allowed_tools": allowed_tools, + }, + })) +} + +fn build_skill_workspace_permissions( + skill: &LoadedSkillDefinition, + session_id: &str, + workspace_root: &str, + config_manager: &GlobalConfigManagerState, +) -> Vec { + let config = config_manager.config(); + let metadata = build_skill_tool_scope_metadata(skill); + let mut permissions = + build_workspace_execution_permissions(WorkspaceExecutionPermissionInput { + surface: WorkspaceToolSurface::workbench(), + workspace_root, + auto_mode: false, + execution_policy_input: ToolExecutionResolverInput { + persisted_policy: Some(&config.agent.tool_execution), + request_metadata: metadata.as_ref(), + }, + }); + append_subagent_tool_scope_session_permissions(&mut permissions, session_id, metadata.as_ref()); + permissions +} + +async fn configure_skill_workspace_permissions( + db: &DbConnection, + config_manager: &GlobalConfigManagerState, + aster_state: &AsterAgentState, + skill: &LoadedSkillDefinition, + session_id: &str, +) -> Result<(), String> { + let workspace_root = resolve_skill_workspace_root(db, session_id); + let permissions = + build_skill_workspace_permissions(skill, session_id, &workspace_root, config_manager); + let agent_arc = aster_state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard + .as_ref() + .ok_or_else(|| "Agent not initialized".to_string())?; + let registry_arc = agent.tool_registry().clone(); + drop(guard); + + let mut permission_manager = ToolPermissionManager::new(None); + for permission in permissions { + permission_manager.add_permission(permission, PermissionScope::Session); + } + registry_arc + .write() + .await + .set_permission_manager(Arc::new(permission_manager)); + + Ok(()) +} + fn resolve_requested_provider( skill: &LoadedSkillDefinition, provider_override: Option<&str>, @@ -206,6 +293,14 @@ pub async fn prepare_skill_execution( aster_state, ) .await?; + configure_skill_workspace_permissions(db, config_manager, aster_state, skill, session_id) + .await + .map_err(|error| { + format_skill_error( + SKILL_ERR_SESSION_INIT_FAILED, + format!("配置 Skill 工具权限失败: {error}"), + ) + })?; let (requested_provider, requested_model) = resolve_requested_provider(skill, provider_override, model_override); @@ -233,6 +328,141 @@ pub async fn prepare_skill_execution( }) } +#[cfg(test)] +mod permission_tests { + use super::build_skill_workspace_permissions; + use crate::agent_tools::catalog::LIME_CREATE_IMAGE_TASK_TOOL_NAME; + use crate::config::GlobalConfigManager; + use aster::permission::{PermissionContext, PermissionScope, ToolPermissionManager}; + use lime_core::config::Config; + use lime_skills::LoadedSkillDefinition; + use serde_json::json; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + fn build_loaded_skill(allowed_tools: Option>) -> LoadedSkillDefinition { + LoadedSkillDefinition { + skill_name: "image_generate".to_string(), + display_name: "配图".to_string(), + description: "测试 skill".to_string(), + markdown_content: "test".to_string(), + license: None, + metadata: HashMap::new(), + allowed_tools: allowed_tools.map(|tools| { + tools + .into_iter() + .map(|tool| tool.to_string()) + .collect::>() + }), + argument_hint: None, + when_to_use: None, + when_to_use_config: None, + model: None, + provider: None, + disable_model_invocation: false, + execution_mode: "prompt".to_string(), + workflow_ref: None, + workflow_steps: Vec::new(), + standard_compliance: Default::default(), + } + } + + fn permission_manager_for_skill( + skill: &LoadedSkillDefinition, + session_id: &str, + ) -> ToolPermissionManager { + let manager = GlobalConfigManager::new( + Config::default(), + PathBuf::from("/tmp/lime-skill-config.yaml"), + ); + let config_manager = crate::config::GlobalConfigManagerState(Arc::new(manager)); + let permissions = build_skill_workspace_permissions( + skill, + session_id, + "/tmp/lime-skill", + &config_manager, + ); + let mut manager = ToolPermissionManager::new(None); + for permission in permissions { + manager.add_permission(permission, PermissionScope::Session); + } + manager + } + + fn permission_context(session_id: &str) -> PermissionContext { + PermissionContext { + working_directory: PathBuf::from("/tmp/lime-skill"), + session_id: session_id.to_string(), + ..PermissionContext::default() + } + } + + #[test] + fn skill_allowed_tools_allow_image_task_tool_in_current_session() { + let skill = build_loaded_skill(Some(vec![LIME_CREATE_IMAGE_TASK_TOOL_NAME])); + let manager = permission_manager_for_skill(&skill, "skill-session-1"); + + let result = manager.is_allowed( + LIME_CREATE_IMAGE_TASK_TOOL_NAME, + &HashMap::new(), + &permission_context("skill-session-1"), + ); + + assert!( + result.allowed, + "image_generate allowed-tools 应授权当前 skill session 调用图片任务工具: {:?}", + result.reason + ); + } + + #[test] + fn skill_allowed_tools_keep_other_tools_denied_in_current_session() { + let skill = build_loaded_skill(Some(vec![LIME_CREATE_IMAGE_TASK_TOOL_NAME])); + let manager = permission_manager_for_skill(&skill, "skill-session-1"); + + let result = manager.is_allowed( + "WebSearch", + &HashMap::new(), + &permission_context("skill-session-1"), + ); + + assert!(!result.allowed); + assert_eq!( + result.reason, + Some("subagent current surface 已启用 allowed_tools 白名单".to_string()) + ); + } + + #[test] + fn skill_without_allowed_tools_keeps_workbench_defaults() { + let skill = build_loaded_skill(None); + let manager = permission_manager_for_skill(&skill, "skill-session-1"); + + let result = manager.is_allowed( + LIME_CREATE_IMAGE_TASK_TOOL_NAME, + &HashMap::new(), + &permission_context("skill-session-1"), + ); + + assert!(result.allowed); + } + + #[test] + fn skill_allowed_tools_do_not_allow_unknown_tools() { + let skill = build_loaded_skill(Some(vec![LIME_CREATE_IMAGE_TASK_TOOL_NAME])); + let manager = permission_manager_for_skill(&skill, "skill-session-1"); + + let result = manager.is_allowed( + "unknown_tool", + &HashMap::from([("payload".to_string(), json!("test"))]), + &permission_context("skill-session-1"), + ); + + assert!(!result.allowed); + } +} + pub fn build_skill_run_start_metadata( skill_name: &str, execution_id: &str, diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 1d253b71b..4d7e888ca 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.32.0", + "version": "1.33.0", "identifier": "com.limecloud.lime.headless", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 01eece6b6..a4e59e105 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.32.0", + "version": "1.33.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "node scripts/start-tauri-dev-server.mjs", diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index 2ec465972..f08569e37 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentPageParams, Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; import { AppSidebar } from "./AppSidebar"; +import { changeLimeLocale } from "@/i18n/createI18n"; import { TASK_CENTER_CREATE_DRAFT_TASK_EVENT, TASK_CENTER_OPEN_TASK_EVENT, @@ -75,7 +76,7 @@ vi.mock("@/lib/api/appConfig", () => ({ subscribeAppConfigChanged: mockSubscribeAppConfigChanged, })); -vi.mock("@/i18n/I18nPatchProvider", () => ({ +vi.mock("@/i18n/legacy-patch/I18nPatchProvider", () => ({ useI18nPatch: () => ({ language: "zh", setLanguage: mockSetI18nLanguage, @@ -277,8 +278,9 @@ function buildMockReferralDashboard() { } describe("AppSidebar", () => { - beforeEach(() => { + beforeEach(async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await changeLimeLocale("zh-CN"); localStorage.clear(); delete window.__LIME_BOOTSTRAP__; delete window.__LIME_OEM_CLOUD__; @@ -923,6 +925,46 @@ describe("AppSidebar", () => { expect(container.textContent).not.toContain("正在打开..."); }); + it("英文界面下未连接账号菜单应展示本地化云端入口", async () => { + await changeLimeLocale("en-US"); + + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(2); + + expect(container.textContent).toContain("Local ready"); + expect(container.textContent).toContain("Open Source Use"); + expect(container.textContent).not.toContain("开源使用"); + + await openAccountMenu(container); + + const accountMenu = container.querySelector( + '[data-testid="app-sidebar-account-menu"]', + ); + expect(accountMenu?.textContent).toContain("Open Source Use"); + expect(accountMenu?.textContent).toContain("Free plan"); + expect(accountMenu?.textContent).toContain("Model Settings"); + expect(accountMenu?.textContent).toContain("About"); + expect(accountMenu?.textContent).toContain("Connect Lime Cloud"); + + await act(async () => { + accountMenu + ?.querySelector( + 'button[aria-label="Connect Lime Cloud"]', + ) + ?.click(); + await Promise.resolve(); + }); + + expect(mockToastSuccess).toHaveBeenCalledWith( + "Opened the Lime Cloud login page. Finish authorization in your browser.", + ); + }); + it("开源使用说明应折叠到信息图标中", async () => { const container = mountSidebarContainer({ currentPage: "agent", @@ -1038,13 +1080,94 @@ describe("AppSidebar", () => { expect(mockSetI18nLanguage).toHaveBeenCalledWith("en"); expect(mockSaveConfig).toHaveBeenCalledWith( - expect.objectContaining({ language: "en" }), + expect.objectContaining({ language: "en-US" }), ); + expect(document.documentElement.lang).toBe("en-US"); + expect( + container.querySelector('button[aria-label="New Task"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("Project Knowledge"); + expect(container.textContent).toContain("Recent Conversations"); + + await act(async () => { + container + .querySelector('button[aria-label="Search Tasks"]') + ?.click(); + await Promise.resolve(); + }); + + const searchDialog = document.body.querySelector( + '[data-testid="app-sidebar-search-dialog"]', + ); + const searchInput = document.body.querySelector( + '[data-testid="app-sidebar-search-input"]', + ); + expect(searchDialog?.textContent).toContain("New Conversation"); + expect(searchDialog?.textContent).toContain( + "Select a project workspace first", + ); + expect(searchInput?.placeholder).toBe("Search conversation titles"); expect( container.querySelector('[data-testid="app-sidebar-language-menu"]'), ).toBeNull(); }); + it("英文界面下会话兜底标题与时间 meta 应跟随当前 locale", async () => { + const nowMs = Date.UTC(2026, 4, 10, 12, 0, 0); + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(nowMs); + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + try { + await changeLimeLocale("en-US"); + mockListAgentRuntimeSessions.mockResolvedValue([ + { + id: "session-untitled", + name: " ", + created_at: Math.floor(nowMs / 1000), + updated_at: Math.floor((nowMs - 2 * 60 * 1000) / 1000), + archived_at: null, + workspace_id: "project-1", + }, + ]); + + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "claw", + projectId: "project-1", + } as AgentPageParams, + }); + await flushEffects(2); + + expect(container.textContent).toContain("Untitled conversation"); + expect(container.textContent).toContain("2m ago"); + expect(container.textContent).not.toContain("未命名对话"); + + await act(async () => { + container + .querySelector( + 'button[aria-label="Open Untitled conversation action menu"]', + ) + ?.click(); + await Promise.resolve(); + }); + + const menu = document.body.querySelector( + '[data-testid="app-sidebar-conversation-menu"]', + ); + expect(menu?.textContent).toContain("Delete"); + + await clickConversationMenuItem("app-sidebar-conversation-menu-delete"); + await flushEffects(); + + expect(confirmSpy).toHaveBeenCalledWith( + 'Delete "Untitled conversation"? This cannot be undone.', + ); + } finally { + dateNowSpy.mockRestore(); + confirmSpy.mockRestore(); + } + }); + it("用户弹框中的收缩入口应导航到真实页面", async () => { const onNavigate = vi.fn(); setStoredOemCloudSessionState({ diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index e8132b8f8..f3bd61cd1 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -45,6 +45,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; import * as LucideIcons from "lucide-react"; +import { useTranslation } from "react-i18next"; import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI"; import { AgentPageParams, Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; @@ -153,8 +154,15 @@ import { type LimeThemeChangedEventDetail, type LimeThemeMode, } from "@/lib/appearance/themeMode"; -import { useI18nPatch } from "@/i18n/I18nPatchProvider"; -import type { Language } from "@/i18n/text-map"; +import { useI18nPatch } from "@/i18n/legacy-patch/I18nPatchProvider"; +import { changeLimeLocale } from "@/i18n/createI18n"; +import { + UI_LOCALE_OPTIONS, + normalizeLocalePreference, + resolveLocaleOptionLabel, + toLegacyPatchLanguage, + type LocalePreference, +} from "@/i18n/locales"; interface AppSidebarProps { currentPage: Page; @@ -181,23 +189,24 @@ const SIDEBAR_NEW_TASK_HOME_SESSION_LOAD_DEFER_MS = 0; const SIDEBAR_CONVERSATION_NAVIGATION_DEFER_MS = SIDEBAR_SESSION_ENTRY_REFRESH_DEFER_MS; const SIDEBAR_SEARCH_HOVER_PREFETCH_DELAY_MS = 900; +const SIDEBAR_NAV_LABEL_KEYS: Record = { + automation: "navigation.sidebar.items.automation", + channels: "navigation.sidebar.items.channels", + companion: "navigation.sidebar.items.companion", + "home-general": "navigation.sidebar.items.homeGeneral", + knowledge: "navigation.sidebar.items.knowledge", + memory: "navigation.sidebar.items.memory", + openclaw: "navigation.sidebar.items.openclaw", + plugins: "navigation.sidebar.items.plugins", + settings: "navigation.sidebar.items.settings", + skills: "navigation.sidebar.items.skills", +}; -const APP_SIDEBAR_LANGUAGE_OPTIONS: Array<{ - id: Language; - label: string; - hint: string; -}> = [ - { - id: "zh", - label: "中文", - hint: "中文界面", - }, - { - id: "en", - label: "English", - hint: "English UI", - }, -]; +const APP_SIDEBAR_LANGUAGE_OPTIONS = UI_LOCALE_OPTIONS.map((option) => ({ + id: option.id, + label: option.label, + hint: option.fallbackHint, +})); function buildSidebarSessionRequestLimit( visibleCount: number, @@ -2185,18 +2194,20 @@ function normalizeSidebarSearchText(value: string): string { function matchesSidebarSessionTitle( session: AsterSessionInfo, normalizedQuery: string, + fallbackTitle: string, ): boolean { if (!normalizedQuery) { return true; } return normalizeSidebarSearchText( - resolveSidebarSessionTitle(session), + resolveSidebarSessionTitle(session, fallbackTitle), ).includes(normalizedQuery); } function resolveAccountDisplayName( sessionState: OemCloudStoredSessionState | null, + fallbackDisplayName: string, ): string { const user = sessionState?.session.user; const fallbackEmailName = user?.email?.split("@")[0]?.trim(); @@ -2204,7 +2215,7 @@ function resolveAccountDisplayName( user?.displayName?.trim() || user?.username?.trim() || fallbackEmailName || - "开源使用" + fallbackDisplayName ); } @@ -2251,6 +2262,7 @@ function parseAccountUsagePercent(value: string | undefined): number | null { function resolveAccountPlanSummary( bootstrap: OemCloudBootstrapResponse | null, + fallbackPlanLabel: string, ): { planLabel: string; usageLabel: string | null; @@ -2259,7 +2271,7 @@ function resolveAccountPlanSummary( const preference = bootstrap?.providerPreference; if (!preference) { return { - planLabel: "免费版", + planLabel: fallbackPlanLabel, usageLabel: null, usagePercent: null, }; @@ -2274,7 +2286,7 @@ function resolveAccountPlanSummary( const usageLabel = matchedOffer?.creditsSummary?.trim() || null; return { - planLabel: matchedOffer?.currentPlan?.trim() || "免费版", + planLabel: matchedOffer?.currentPlan?.trim() || fallbackPlanLabel, usageLabel, usagePercent: parseAccountUsagePercent(usageLabel ?? undefined), }; @@ -2291,13 +2303,17 @@ function resolveAccountInitial(name: string): string { function resolveCloudBrandLabel( bootstrap: OemCloudBootstrapResponse | null, + fallbackBrandLabel: string, + cloudSuffixLabel: string, ): string { const appName = bootstrap?.app?.name?.trim(); if (!appName) { - return "Lime 云端"; + return fallbackBrandLabel; } - return /云|Cloud|Hub/i.test(appName) ? appName : `${appName} 云端`; + return /云|Cloud|Hub/i.test(appName) + ? appName + : `${appName} ${cloudSuffixLabel}`; } function formatReferralCredits(value: number | undefined): string { @@ -2308,17 +2324,6 @@ function formatReferralCredits(value: number | undefined): string { return `${value.toLocaleString("zh-CN")} 积分`; } -function normalizeSidebarLanguage(language?: string): Language { - return language === "en" ? "en" : "zh"; -} - -function resolveSidebarLanguageLabel(language: Language): string { - return ( - APP_SIDEBAR_LANGUAGE_OPTIONS.find((option) => option.id === language) - ?.label ?? "中文" - ); -} - export function AppSidebar({ currentPage, currentPageParams, @@ -2327,6 +2332,76 @@ export function AppSidebar({ onNavigate, onStartWindowDrag, }: AppSidebarProps) { + const { t, i18n } = useTranslation("navigation"); + const conversationUntitledLabel = t( + "navigation.sidebar.conversations.untitled", + "未命名对话", + ); + const resolveLocalizedSessionTitle = useCallback( + (session: AsterSessionInfo) => + resolveSidebarSessionTitle(session, conversationUntitledLabel), + [conversationUntitledLabel], + ); + const formatArchivedConversationMeta = useCallback( + (time: string) => + t("navigation.sidebar.conversations.meta.archived", { + time, + defaultValue: "归档 {{time}}", + }), + [t], + ); + const formatLocalizedSessionMeta = useCallback( + (session: AsterSessionInfo) => + formatSidebarSessionMeta(session, { + formatArchived: formatArchivedConversationMeta, + locale: i18n.language, + }), + [formatArchivedConversationMeta, i18n.language], + ); + const renameConversationPromptLabel = t( + "navigation.sidebar.conversations.rename.prompt", + "重命名对话", + ); + const renameConversationSuccessLabel = t( + "navigation.sidebar.conversations.rename.success", + "已重命名对话", + ); + const renameConversationErrorLabel = t( + "navigation.sidebar.conversations.rename.error", + "重命名失败,请稍后重试", + ); + const formatDeleteConversationConfirm = useCallback( + (title: string) => + t("navigation.sidebar.conversations.delete.confirm", { + title, + defaultValue: "确定要删除“{{title}}”吗?删除后无法恢复。", + }), + [t], + ); + const deleteConversationSuccessLabel = t( + "navigation.sidebar.conversations.delete.success", + "已删除对话", + ); + const deleteConversationErrorLabel = t( + "navigation.sidebar.conversations.delete.error", + "删除失败,请稍后重试", + ); + const accountFreePlanLabel = t( + "navigation.sidebar.account.freePlan", + "免费版", + ); + const accountOpenSourceTitleLabel = t( + "navigation.sidebar.account.openSource.title", + "开源使用", + ); + const accountDefaultCloudBrandLabel = t( + "navigation.sidebar.account.defaultCloudBrand", + "Lime 云端", + ); + const accountCloudSuffixLabel = t( + "navigation.sidebar.account.cloudSuffix", + "云端", + ); const activePage = requestedPage ?? currentPage; const activePageParams = requestedPageParams ?? currentPageParams; const activeNavigationTarget = { @@ -2422,7 +2497,7 @@ export function AppSidebar({ const [appearancePopoverOpen, setAppearancePopoverOpen] = useState(false); const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [languageMenuOpen, setLanguageMenuOpen] = useState(false); - const [language, setLanguageState] = useState("zh"); + const [language, setLanguageState] = useState("zh-CN"); const [cloudSessionState, setCloudSessionState] = useState(() => typeof window === "undefined" ? null : getStoredOemCloudSessionState(), @@ -2590,7 +2665,7 @@ export function AppSidebar({ config.navigation?.enabled_items, ); setEnabledNavItems(resolvedItems); - setLanguageState(normalizeSidebarLanguage(config.language)); + setLanguageState(normalizeLocalePreference(config.language)); } catch (error) { console.error("加载配置失败:", error); } @@ -2603,19 +2678,34 @@ export function AppSidebar({ }); }, []); + const localizeSidebarNavItem = useCallback( + (item: SidebarNavItem): SidebarNavItem => { + const key = SIDEBAR_NAV_LABEL_KEYS[item.id]; + if (!key) { + return item; + } + + return { + ...item, + label: t(key, item.label), + }; + }, + [t], + ); + const filteredMainNavItems = useMemo(() => { return MAIN_SIDEBAR_NAV_ITEMS.filter( (item) => item.configurable === false || enabledNavItems.includes(item.id), - ); - }, [enabledNavItems]); + ).map(localizeSidebarNavItem); + }, [enabledNavItems, localizeSidebarNavItem]); const filteredFooterNavItems = useMemo(() => { return FOOTER_SIDEBAR_NAV_ITEMS.filter( (item) => item.configurable === false || enabledNavItems.includes(item.id), - ); - }, [enabledNavItems]); + ).map(localizeSidebarNavItem); + }, [enabledNavItems, localizeSidebarNavItem]); useEffect(() => { if (!enabledNavItems.includes(SIDEBAR_PLUGIN_CENTER_NAV_ITEM_ID)) { @@ -3468,9 +3558,17 @@ export function AppSidebar({ } return recentSidebarSessions.filter((session) => - matchesSidebarSessionTitle(session, normalizedSidebarSearchQuery), + matchesSidebarSessionTitle( + session, + normalizedSidebarSearchQuery, + conversationUntitledLabel, + ), ); - }, [normalizedSidebarSearchQuery, recentSidebarSessions]); + }, [ + conversationUntitledLabel, + normalizedSidebarSearchQuery, + recentSidebarSessions, + ]); const sidebarSearchResultSessions = useMemo(() => { if (!normalizedSidebarSearchQuery) { return buildVisibleSidebarSessions({ @@ -3822,8 +3920,10 @@ export function AppSidebar({ const handleRenameConversation = useCallback( async (session: AsterSessionInfo) => { - const currentTitle = resolveSidebarSessionTitle(session); - const nextTitle = window.prompt("重命名对话", currentTitle)?.trim(); + const currentTitle = resolveLocalizedSessionTitle(session); + const nextTitle = window + .prompt(renameConversationPromptLabel, currentTitle) + ?.trim(); if (!nextTitle || nextTitle === currentTitle) { return; } @@ -3851,11 +3951,11 @@ export function AppSidebar({ session_id: session.id, name: nextTitle, }); - toast.success("已重命名对话"); + toast.success(renameConversationSuccessLabel); await refreshSidebarSessions(); } catch (error) { console.error("重命名会话失败:", error); - toast.error("重命名失败,请稍后重试"); + toast.error(renameConversationErrorLabel); await refreshSidebarSessions(); } finally { setSidebarSessionActionId((current) => @@ -3863,7 +3963,13 @@ export function AppSidebar({ ); } }, - [refreshSidebarSessions], + [ + refreshSidebarSessions, + renameConversationErrorLabel, + renameConversationPromptLabel, + renameConversationSuccessLabel, + resolveLocalizedSessionTitle, + ], ); const handleToggleSessionArchive = useCallback( @@ -3912,10 +4018,8 @@ export function AppSidebar({ const handleDeleteConversation = useCallback( async (session: AsterSessionInfo) => { - const title = resolveSidebarSessionTitle(session); - const confirmed = window.confirm( - `确定要删除“${title}”吗?删除后无法恢复。`, - ); + const title = resolveLocalizedSessionTitle(session); + const confirmed = window.confirm(formatDeleteConversationConfirm(title)); if (!confirmed) { return; } @@ -3930,7 +4034,7 @@ export function AppSidebar({ try { await deleteAgentRuntimeSession(session.id); - toast.success("已删除对话"); + toast.success(deleteConversationSuccessLabel); if (currentSessionId === session.id) { handleNavigateToNewTask(); } else { @@ -3938,7 +4042,7 @@ export function AppSidebar({ } } catch (error) { console.error("删除会话失败:", error); - toast.error("删除失败,请稍后重试"); + toast.error(deleteConversationErrorLabel); await refreshSidebarSessions(); } finally { setSidebarSessionActionId((current) => @@ -3946,20 +4050,37 @@ export function AppSidebar({ ); } }, - [currentSessionId, handleNavigateToNewTask, refreshSidebarSessions], + [ + currentSessionId, + deleteConversationErrorLabel, + deleteConversationSuccessLabel, + formatDeleteConversationConfirm, + handleNavigateToNewTask, + refreshSidebarSessions, + resolveLocalizedSessionTitle, + ], ); const currentColorScheme = getLimeColorScheme(colorSchemeId); const currentThemeLabel = LIME_THEME_MODE_OPTIONS.find((option) => option.id === themeState.themeMode) ?.label ?? "跟随系统"; - const currentLanguageLabel = resolveSidebarLanguageLabel(language); - const accountDisplayName = resolveAccountDisplayName(cloudSessionState); + const currentLanguageLabel = resolveLocaleOptionLabel(language); + const accountDisplayName = resolveAccountDisplayName( + cloudSessionState, + accountOpenSourceTitleLabel, + ); const accountEmail = resolveAccountEmail(cloudSessionState); const accountTenantLabel = resolveAccountTenantLabel(cloudSessionState); - const accountPlanSummary = resolveAccountPlanSummary(cloudBootstrapState); - const cloudBrandLabel = resolveCloudBrandLabel(cloudBootstrapState); - const connectCloudLabel = `连接 ${cloudBrandLabel}`; + const accountPlanSummary = resolveAccountPlanSummary( + cloudBootstrapState, + accountFreePlanLabel, + ); + const cloudBrandLabel = resolveCloudBrandLabel( + cloudBootstrapState, + accountDefaultCloudBrandLabel, + accountCloudSuffixLabel, + ); const accountAvatarUrl = cloudSessionState?.session.user.avatarUrl?.trim(); const accountInitial = resolveAccountInitial(accountDisplayName); const hasCloudAccount = Boolean(cloudSessionState); @@ -3967,9 +4088,194 @@ export function AppSidebar({ [accountEmail, accountTenantLabel].filter(Boolean).join(" · ") || accountDisplayName; const inviteEntryVisible = inviteFeatureEnabled; + const homeLabel = t("navigation.sidebar.home.label", "Lime 首页"); + const homeAriaLabel = t( + "navigation.sidebar.home.ariaLabel", + "返回 Lime 首页", + ); + const collapseNavigationLabel = t( + "navigation.sidebar.actions.collapse", + "折叠导航栏", + ); + const expandNavigationLabel = t( + "navigation.sidebar.actions.expand", + "展开导航栏", + ); + const navigationToggleLabel = collapsed + ? expandNavigationLabel + : collapseNavigationLabel; + const searchTaskLabel = t("navigation.sidebar.search.label", "搜索任务"); + const searchConversationTitleLabel = t( + "navigation.sidebar.search.inputLabel", + "搜索对话标题", + ); + const closeSearchDialogLabel = t( + "navigation.sidebar.search.close", + "关闭搜索弹窗", + ); + const createConversationLabel = t( + "navigation.sidebar.search.createConversation", + "新建对话", + ); + const searchMatchesLabel = t( + "navigation.sidebar.search.section.matches", + "匹配结果", + ); + const searchRecentLabel = t( + "navigation.sidebar.search.section.recent", + "最近", + ); + const searchLoadingLabel = t( + "navigation.sidebar.search.loading", + "正在加载对话", + ); + const searchSelectProjectFirstLabel = t( + "navigation.sidebar.search.selectProjectFirst", + "请先选择项目工作区", + ); + const searchEmptyMatchesLabel = t( + "navigation.sidebar.search.emptyMatches", + "没有匹配的对话标题", + ); + const searchEmptyRecentLabel = t( + "navigation.sidebar.search.emptyRecent", + "还没有最近对话", + ); + const searchLoadingMoreLabel = t( + "navigation.sidebar.search.loadingMore", + "正在加载...", + ); + const searchMoreMatchesLabel = t( + "navigation.sidebar.search.moreMatches", + "查看更多匹配结果", + ); + const searchMoreRecentLabel = t( + "navigation.sidebar.search.moreRecent", + "查看更多对话", + ); + const pluginExtensionsTitle = t( + "navigation.sidebar.sections.pluginExtensions", + "插件扩展", + ); + const languageMenuLabel = t( + "navigation.sidebar.account.language", + "语言", + ); + const interfaceLanguageLabel = t( + "navigation.sidebar.account.interfaceLanguage", + "界面语言", + ); + const selectLanguageLabel = t( + "navigation.sidebar.account.selectLanguage", + "选择语言", + ); + const accountOpenUserMenuLabel = t( + "navigation.sidebar.account.openMenu", + "打开用户菜单", + ); + const accountMenuLabel = t( + "navigation.sidebar.account.menu", + "用户菜单", + ); + const accountCloudStateLabel = t( + "navigation.sidebar.account.state.cloud", + "云端", + ); + const accountLocalStateLabel = t( + "navigation.sidebar.account.state.local", + "本地可用", + ); + const accountLocalTooltipLabel = t( + "navigation.sidebar.account.localTooltip", + "开源使用 · 本地可用", + ); + const connectCloudLabel = t( + "navigation.sidebar.account.connectCloud", + { + brand: cloudBrandLabel, + defaultValue: "连接 {{brand}}", + }, + ); + const accountLoginPendingLabel = t( + "navigation.sidebar.account.login.opening", + "正在打开...", + ); + const accountLoginOpenedLabel = t( + "navigation.sidebar.account.login.opened", + { + brand: cloudBrandLabel, + defaultValue: "已打开 {{brand}} 登录页,请在浏览器完成授权", + }, + ); + const accountLoginFailedFallbackLabel = t( + "navigation.sidebar.account.login.failed", + { + brand: cloudBrandLabel, + defaultValue: "打开 {{brand}} 登录页失败", + }, + ); + const accountUserCenterLabel = t( + "navigation.sidebar.account.userCenter", + "用户中心", + ); + const accountUserCenterOpenedLabel = t( + "navigation.sidebar.account.userCenterOpened", + { + brand: cloudBrandLabel, + defaultValue: "已打开 {{brand}} 用户中心", + }, + ); + const accountUserCenterFailedFallbackLabel = t( + "navigation.sidebar.account.userCenterFailed", + { + brand: cloudBrandLabel, + defaultValue: "打开 {{brand}} 用户中心失败", + }, + ); + const accountModelSettingsLabel = t( + "navigation.sidebar.account.modelSettings", + "模型设置", + ); + const accountAboutLabel = t("navigation.sidebar.account.about", "关于"); + const accountLogoutLabel = t( + "navigation.sidebar.account.logout", + "退出登录", + ); + const accountLogoutPendingLabel = t( + "navigation.sidebar.account.logoutPending", + "退出中...", + ); + const accountViewPlanDetailsLabel = t( + "navigation.sidebar.account.viewPlanDetails", + "查看套餐详情", + ); + const accountViewDetailsLabel = t( + "navigation.sidebar.account.viewDetails", + "查看详情", + ); + const accountOpenSourceInfoLabel = t( + "navigation.sidebar.account.openSource.info", + "开源使用说明", + ); + const accountOpenSourceDescriptionLabel = t( + "navigation.sidebar.account.openSource.description", + { + brand: cloudBrandLabel, + defaultValue: + "本地开源功能可直接使用;你可以先进入模型设置配置本地渠道,也可以按需连接 {{brand}} 同步账号、积分、套餐和商业化能力。", + }, + ); + const accountNoLoginAvailableLabel = t( + "navigation.sidebar.account.openSource.noLogin", + "不登录也可用", + ); + const accountLocalModelConfigurableLabel = t( + "navigation.sidebar.account.openSource.localModel", + "本地模型可配置", + ); const accountButtonTooltip = hasCloudAccount ? `${accountDisplayName}${accountEmail ? ` · ${accountEmail}` : ""}` - : "开源使用 · 本地可用"; + : accountLocalTooltipLabel; const inviteShare = inviteDashboard?.share; const invitePolicy = inviteDashboard?.policy; const inviteHeadline = inviteShare?.headline?.trim() || "邀请好友加入内测"; @@ -4021,20 +4327,20 @@ export function AppSidebar({ browserTarget, waitForCompletion: false, }); - toast.success(`已打开 ${cloudBrandLabel} 登录页,请在浏览器完成授权`); + toast.success(accountLoginOpenedLabel); setAccountMenuOpen(false); setLanguageMenuOpen(false); } catch (error) { const message = error instanceof Error && error.message.trim() ? error.message.trim() - : `打开 ${cloudBrandLabel} 登录页失败`; + : accountLoginFailedFallbackLabel; setAccountLoginError(message); toast.error(message); } finally { setAccountLoginPending(false); } - }, [cloudBrandLabel]); + }, [accountLoginFailedFallbackLabel, accountLoginOpenedLabel]); const handleOpenAccountUserCenter = useCallback( async (path = "/welcome") => { @@ -4050,16 +4356,16 @@ export function AppSidebar({ browserTarget, }, ); - toast.success(`已打开 ${cloudBrandLabel} 用户中心`); + toast.success(accountUserCenterOpenedLabel); } catch (error) { const message = error instanceof Error && error.message.trim() ? error.message.trim() - : `打开 ${cloudBrandLabel} 用户中心失败`; + : accountUserCenterFailedFallbackLabel; toast.error(message); } }, - [cloudBrandLabel], + [accountUserCenterFailedFallbackLabel, accountUserCenterOpenedLabel], ); const handleAccountLogout = useCallback(async () => { @@ -4105,7 +4411,7 @@ export function AppSidebar({ ); const handleLanguageChange = useCallback( - async (nextLanguage: Language) => { + async (nextLanguage: LocalePreference) => { const previousLanguage = language; if (nextLanguage === previousLanguage) { setLanguageMenuOpen(false); @@ -4113,10 +4419,11 @@ export function AppSidebar({ } setLanguageState(nextLanguage); - setI18nLanguage(nextLanguage); + setI18nLanguage(toLegacyPatchLanguage(nextLanguage)); setLanguageMenuOpen(false); try { + await changeLimeLocale(nextLanguage); const config = await getConfig(); await saveConfig({ ...config, @@ -4125,7 +4432,8 @@ export function AppSidebar({ } catch (error) { console.error("保存语言设置失败:", error); setLanguageState(previousLanguage); - setI18nLanguage(previousLanguage); + setI18nLanguage(toLegacyPatchLanguage(previousLanguage)); + await changeLimeLocale(previousLanguage); } }, [language, setI18nLanguage], @@ -4167,15 +4475,15 @@ export function AppSidebar({ }), ) } - aria-label="返回 Lime 首页" - title="返回 Lime 首页" + aria-label={homeAriaLabel} + title={homeAriaLabel} > {LIME_BRAND_NAME} {LIME_BRAND_NAME} , - "Lime 首页", + homeLabel, )} {inviteEntryVisible @@ -4201,12 +4509,12 @@ export function AppSidebar({ {maybeWrapWithTooltip( setCollapsed((value) => !value)} - title={collapsed ? "展开导航栏" : "折叠导航栏"} - aria-label={collapsed ? "展开导航栏" : "折叠导航栏"} + title={navigationToggleLabel} + aria-label={navigationToggleLabel} > {collapsed ? : } , - collapsed ? "展开导航栏" : "折叠导航栏", + navigationToggleLabel, )} @@ -4214,16 +4522,16 @@ export function AppSidebar({ - 搜索任务 + {searchTaskLabel} , - "搜索任务", + searchTaskLabel, )} @@ -4269,7 +4577,9 @@ export function AppSidebar({ {shouldShowPluginExtensionsSection && (
- 插件扩展 + + {pluginExtensionsTitle} + {assistantItems.map((item) => renderNavItem(item))}
)} @@ -4434,7 +4744,7 @@ export function AppSidebar({ setLanguageMenuOpen(false); setAccountMenuOpen((current) => !current); }} - aria-label="打开用户菜单" + aria-label={accountOpenUserMenuLabel} aria-expanded={accountMenuOpen} aria-haspopup="dialog" data-testid="app-sidebar-account-button" @@ -4460,7 +4770,9 @@ export function AppSidebar({ ) : null} - {hasCloudAccount ? "云端" : "本地可用"} + {hasCloudAccount + ? accountCloudStateLabel + : accountLocalStateLabel} @@ -4472,13 +4784,13 @@ export function AppSidebar({ {hasCloudAccount ? ( void handleOpenAccountUserCenter("/billing?tab=usage") @@ -4489,7 +4801,7 @@ export function AppSidebar({ {accountPlanSummary.planLabel} - 查看详情 + {accountViewDetailsLabel} @@ -4511,27 +4823,27 @@ export function AppSidebar({ - 开源使用 + {accountOpenSourceTitleLabel} event.stopPropagation()} > - {`本地开源功能可直接使用;你可以先进入模型设置配置本地渠道,也可以按需连接 ${cloudBrandLabel} 同步账号、积分、套餐和商业化能力。`} + {accountOpenSourceDescriptionLabel} - 免费版 + {accountFreePlanLabel} - 不登录也可用 - 本地模型可配置 + {accountNoLoginAvailableLabel} + {accountLocalModelConfigurableLabel} {accountLoginPending - ? "正在打开..." + ? accountLoginPendingLabel : connectCloudLabel} handleAccountMenuNavigate({ tab: SettingsTabs.Providers, @@ -4557,7 +4869,7 @@ export function AppSidebar({ } > - 模型设置 + {accountModelSettingsLabel} {accountLoginError ? ( @@ -4597,14 +4909,14 @@ export function AppSidebar({ setLanguageMenuOpen((current) => !current)} > - 语言 + {languageMenuLabel} {currentLanguageLabel} @@ -4614,10 +4926,12 @@ export function AppSidebar({ {languageMenuOpen ? ( - 界面语言 + + {interfaceLanguageLabel} + {APP_SIDEBAR_LANGUAGE_OPTIONS.map((option) => { const active = option.id === language; @@ -4628,7 +4942,13 @@ export function AppSidebar({ $active={active} role="menuitemradio" aria-checked={active} - aria-label={`切换语言为${option.label}`} + aria-label={t( + "navigation.sidebar.account.switchLanguage", + { + language: option.label, + defaultValue: "切换语言为{{language}}", + }, + )} onClick={() => void handleLanguageChange(option.id) } @@ -4651,21 +4971,21 @@ export function AppSidebar({ {hasCloudAccount ? ( void handleOpenAccountUserCenter("/welcome") } > - 用户中心 + {accountUserCenterLabel} ) : null} handleAccountMenuNavigate({ tab: SettingsTabs.Providers, @@ -4675,7 +4995,7 @@ export function AppSidebar({ > - 模型设置 + {accountModelSettingsLabel} @@ -4696,14 +5016,14 @@ export function AppSidebar({ ) : null} handleAccountMenuNavigate({ tab: SettingsTabs.About }) } > - 关于 + {accountAboutLabel} @@ -4714,12 +5034,14 @@ export function AppSidebar({ type="button" $danger disabled={accountLogoutPending} - aria-label="退出登录" + aria-label={accountLogoutLabel} onClick={() => void handleAccountLogout()} > - {accountLogoutPending ? "退出中..." : "退出登录"} + {accountLogoutPending + ? accountLogoutPendingLabel + : accountLogoutLabel} @@ -4744,8 +5066,8 @@ export function AppSidebar({ ref={sidebarSearchInputRef} value={sidebarSearchQuery} onChange={(event) => setSidebarSearchQuery(event.target.value)} - placeholder="搜索对话标题" - aria-label="搜索对话标题" + placeholder={searchConversationTitleLabel} + aria-label={searchConversationTitleLabel} data-testid="app-sidebar-search-input" /> @@ -4768,24 +5090,26 @@ export function AppSidebar({ data-testid="app-sidebar-search-new-conversation" > - 新建对话 + + {createConversationLabel} + - {sidebarSearchHasQuery ? "匹配结果" : "最近"} + {sidebarSearchHasQuery ? searchMatchesLabel : searchRecentLabel} {shouldShowSessionLoadingState ? ( - 正在加载对话 + {searchLoadingLabel} ) : sidebarSearchResultSessions.length > 0 ? ( {sidebarSearchResultSessions.map((session) => { - const title = resolveSidebarSessionTitle(session); + const title = resolveLocalizedSessionTitle(session); const isCurrentConversation = currentSessionId === session.id; return ( - {formatSidebarSessionMeta(session)} + {formatLocalizedSessionMeta(session)} ); @@ -4818,10 +5142,10 @@ export function AppSidebar({ ) : ( {!currentProjectId - ? "请先选择项目工作区" + ? searchSelectProjectFirstLabel : sidebarSearchHasQuery - ? "没有匹配的对话标题" - : "还没有最近对话"} + ? searchEmptyMatchesLabel + : searchEmptyRecentLabel} )} @@ -4837,10 +5161,10 @@ export function AppSidebar({ data-testid="app-sidebar-search-more" > {sidebarSessionsLoading - ? "正在加载..." + ? searchLoadingMoreLabel : sidebarSearchHasQuery - ? "查看更多匹配结果" - : "查看更多对话"} + ? searchMoreMatchesLabel + : searchMoreRecentLabel} ) : null} diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index d81308154..f111066ac 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -127,6 +127,7 @@ import { resolveWorkflowLayoutBottomSpacing } from "./utils/workflowLayout"; import { alignChatToolPreferencesWithExecutionStrategy, loadChatToolPreferences, + shouldUseCompactGeneralPromptForPreferences, } from "./utils/chatToolPreferences"; import { isWorkspacePathErrorMessage } from "./hooks/agentChatCoreUtils"; import { mergeArtifacts } from "./utils/messageArtifacts"; @@ -2262,12 +2263,11 @@ export function AgentChatWorkspace({ ); const generalHarnessEntryEnabled = chatMode === "general"; const shouldUseCompactGeneralSystemPrompt = - chatMode === "general" && - !contentId && - !chatToolPreferences.webSearch && - !chatToolPreferences.thinking && - !chatToolPreferences.task && - !chatToolPreferences.subagent; + shouldUseCompactGeneralPromptForPreferences({ + chatMode, + contentId, + preferences: chatToolPreferences, + }); // 生成系统提示词(包含项目 Memory) const systemPrompt = useMemo(() => { diff --git a/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx b/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx index fad3850ec..94010a0a2 100644 --- a/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx +++ b/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx @@ -216,6 +216,63 @@ describe("CuratedTaskLauncherDialog", () => { expect(dialog?.textContent).toContain("相关线索:科技蓝、留白"); }); + it("启动参考对象不应向普通用户暴露自动记忆内部标签和原始错误", async () => { + const task = findCuratedTaskTemplateById("daily-trend-briefing"); + expect(task).not.toBeNull(); + mockListUnifiedMemories.mockResolvedValue([ + { + id: "memory-internal-error", + session_id: "session-1", + memory_type: "conversation", + category: "context", + title: + "自动分析提取(用户表达):-32603: Execution failed: 未配置 Pexels API Key", + content: "", + summary: + "自动分析提取(用户表达):-32603: Execution failed: 未配置 Pexels API Key", + tags: [ + "auto_analysis", + "context", + "fp:-32603: execution failed: 未配置 pexels api key", + "用户访谈", + ], + metadata: { + confidence: 0.9, + importance: 8, + access_count: 1, + last_accessed_at: null, + source: "manual", + embedding: null, + }, + created_at: 1_712_345_670_000, + updated_at: 1_712_345_678_000, + archived: false, + }, + ]); + + await act(async () => { + root.render( + undefined} + onConfirm={() => undefined} + />, + ); + await Promise.resolve(); + }); + + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain("运行异常记录"); + expect(dialog?.textContent).toContain( + "这条参考来自一次执行异常,默认不展开技术细节。", + ); + expect(dialog?.textContent).toContain("相关线索:用户访谈"); + expect(dialog?.textContent).not.toContain("auto_analysis"); + expect(dialog?.textContent).not.toContain("Pexels API Key"); + expect(dialog?.textContent).not.toContain("-32603"); + }); + it("命中最近判断偏好的结果模板时,应在 launcher 内显影判断提示", async () => { const task = findCuratedTaskTemplateById("account-project-review"); expect(task).not.toBeNull(); diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 27aba6463..d96717a55 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -1526,7 +1526,7 @@ describe("EmptyState", () => { ); }); - it("指定 service skill 即使未从运行时目录注入,也应从 seeded 目录拼接到首页精选区尾部", async () => { + it("首页可见 service skill 即使未从运行时目录注入,也应从 seeded 目录拼接到首页精选区尾部", async () => { const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); const container = renderEmptyState({ activeTheme: "general", @@ -1538,11 +1538,11 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("复制视频脚本"); + expect(container.textContent).toContain("复制轮播帖"); expect(container.textContent).not.toContain("GitHub 仓库线索检索"); const card = container.querySelector( - '[data-testid="home-gallery-entry-service-skill-short-video-script-replication"]', + '[data-testid="home-gallery-entry-service-skill-carousel-post-replication"]', ) as HTMLButtonElement | null; expect(card).toBeTruthy(); @@ -1552,8 +1552,8 @@ describe("EmptyState", () => { expect(onSelectServiceSkill).toHaveBeenCalledWith( expect.objectContaining({ - id: "short-video-script-replication", - title: "复制视频脚本", + id: "carousel-post-replication", + title: "复制轮播帖", }), ); }); @@ -2714,6 +2714,9 @@ describe("EmptyState", () => { '[data-testid="inputbar-plan-toggle"]', ) as HTMLButtonElement | null; expect(planButton).toBeTruthy(); + expect(planButton?.textContent).toContain("计划执行"); + expect(planButton?.textContent).not.toContain("Plan"); + expect(planButton?.getAttribute("aria-label")).toBe("开启计划执行"); const subagentButton = container.querySelector( 'button[title="任务拆分偏好已关闭"]', ) as HTMLButtonElement | null; diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx index 84fdf8643..10e20977d 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx @@ -925,7 +925,7 @@ describe("EmptyStateComposerPanel", () => { ).toBeTruthy(); }); - it("应通过高级设置中的 Plan 开关透传执行策略切换,不再渲染执行模式下拉", () => { + it("应通过高级设置中的计划执行开关透传执行策略切换,不再渲染执行模式下拉", () => { const setExecutionStrategy = vi.fn(); const container = renderPanel({ executionStrategy: "react", @@ -943,6 +943,9 @@ describe("EmptyStateComposerPanel", () => { ) as HTMLButtonElement | null; expect(planToggle).toBeTruthy(); + expect(planToggle?.textContent).toContain("计划执行"); + expect(planToggle?.textContent).not.toContain("Plan"); + expect(planToggle?.getAttribute("aria-label")).toBe("开启计划执行"); expect(container.textContent).not.toContain("ReAct"); expect(container.textContent).not.toContain("Auto"); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx index 244cc2749..5d7d7fd8d 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx @@ -34,10 +34,10 @@ export const InputbarExecutionStrategySelect: React.FC< setExecutionStrategy(planEnabled ? "react" : "code_orchestrated") } @@ -46,7 +46,7 @@ export const InputbarExecutionStrategySelect: React.FC< - Plan + 计划执行 ); }; diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index 658b99d60..383ee7196 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -2481,7 +2481,7 @@ describe("Inputbar", () => { expect(container.textContent).not.toContain("Native schema"); }); - it("应在高级设置中渲染 Plan 开关与权限模式,并透传对应回调", async () => { + it("应在高级设置中渲染计划执行开关与权限模式,并透传对应回调", async () => { const setExecutionStrategy = vi.fn(); const setAccessMode = vi.fn(); const { container } = renderInputbar({ @@ -2511,6 +2511,10 @@ describe("Inputbar", () => { expect(planToggle).toBeTruthy(); expect(accessSelect).toBeTruthy(); + expect(planToggle?.textContent).toContain("计划执行"); + expect(planToggle?.textContent).not.toContain("Plan"); + expect(planToggle?.getAttribute("aria-label")).toBe("开启计划执行"); + expect(planToggle?.getAttribute("title")).toBe("开启计划执行"); expect(container.querySelector('select[aria-label="执行模式"]')).toBeNull(); act(() => { diff --git a/src/components/agent/chat/components/TaskCenterTabStrip.test.tsx b/src/components/agent/chat/components/TaskCenterTabStrip.test.tsx index 1b073a6fe..e0579eab2 100644 --- a/src/components/agent/chat/components/TaskCenterTabStrip.test.tsx +++ b/src/components/agent/chat/components/TaskCenterTabStrip.test.tsx @@ -242,7 +242,7 @@ describe("TaskCenterTabStrip", () => { ).toBeTruthy(); }); - it("应从 conversationProjectionStore.agentUi 展示任务级标准投影提示", () => { + it("不应在会话标签暴露 Agent UI 投影计数", () => { conversationProjectionStore.recordAgentUiProjectionEvents([ { type: "task.changed", @@ -282,11 +282,8 @@ describe("TaskCenterTabStrip", () => { const projectionBadge = container.querySelector( '[data-testid="task-center-tab-agentui-topic-a"]', ) as HTMLElement | null; - expect(projectionBadge).not.toBeNull(); - expect(projectionBadge?.textContent).toContain("AgentUI 1"); - expect(projectionBadge?.getAttribute("title")).toContain( - "AgentUI 投影 1 条", - ); + expect(projectionBadge).toBeNull(); + expect(container.textContent).not.toContain("AgentUI"); expect( container.querySelector('[data-testid="task-center-tab-agentui-topic-b"]'), ).toBeNull(); diff --git a/src/components/agent/chat/components/TaskCenterTabStrip.tsx b/src/components/agent/chat/components/TaskCenterTabStrip.tsx index 2d8eada56..60b7f787b 100644 --- a/src/components/agent/chat/components/TaskCenterTabStrip.tsx +++ b/src/components/agent/chat/components/TaskCenterTabStrip.tsx @@ -7,19 +7,13 @@ import { Plus, X, } from "lucide-react"; -import { useMemo, type CSSProperties } from "react"; +import type { CSSProperties } from "react"; import type { TaskStatus } from "../hooks/agentChatShared"; import { cn } from "@/lib/utils"; import { TASK_CENTER_CHROME_STAGE_BLEND, TASK_CENTER_CHROME_STAGE_SEAM, } from "../workspace/taskCenterChromeTokens"; -import { useAgentUiProjectionEvents } from "../projection/useConversationProjectionStore"; -import { - summarizeAgentUiProjectionEvents, - type AgentUiProjectionSummary, -} from "../projection/agentUiProjectionSummary"; -import type { AgentUiProjectionEvent } from "../projection/agentUiEventProjection"; const TASK_CENTER_TAB_STATUS_META: Record< TaskStatus, @@ -79,52 +73,6 @@ function formatTaskTabTitle(item: TaskCenterTabItem): string { )}`; } -function buildTaskCenterAgentUiSummaryByTaskId( - items: TaskCenterTabItem[], - events: AgentUiProjectionEvent[], -): Map { - const itemIds = new Set(items.map((item) => item.id)); - const eventsByTaskId = new Map(); - - for (const event of events) { - const candidateIds = Array.from( - new Set( - [event.sessionId, event.threadId, event.taskId, event.runId].filter( - (value): value is string => Boolean(value), - ), - ), - ); - - for (const candidateId of candidateIds) { - if (!itemIds.has(candidateId)) { - continue; - } - - const scopedEvents = eventsByTaskId.get(candidateId) ?? []; - scopedEvents.push(event); - eventsByTaskId.set(candidateId, scopedEvents); - } - } - - const summaryByTaskId = new Map(); - for (const [taskId, scopedEvents] of eventsByTaskId) { - summaryByTaskId.set(taskId, summarizeAgentUiProjectionEvents(scopedEvents)); - } - return summaryByTaskId; -} - -function formatTaskCenterAgentUiSummaryTitle( - summary: AgentUiProjectionSummary, -): string { - return [ - `AgentUI 投影 ${summary.total} 条`, - `Task ${summary.taskCount}`, - `Action ${summary.actionCount}`, - `Artifact ${summary.artifactCount}`, - `Diagnostics ${summary.diagnosticsCount}`, - ].join(" · "); -} - const conversationTabShellClassName = "group flex h-[28px] items-center gap-0 rounded-[14px] border border-transparent px-1 transition-[background-color,border-color,box-shadow,color] duration-150 ease-out"; @@ -164,12 +112,6 @@ export function TaskCenterTabStrip({ onWorkbenchToggle, }: TaskCenterTabStripProps) { const showToolbarActions = showHistoryToggle || showWorkbenchToggle; - const agentUiProjectionEvents = useAgentUiProjectionEvents(); - const agentUiSummaryByTaskId = useMemo( - () => - buildTaskCenterAgentUiSummaryByTaskId(items, agentUiProjectionEvents), - [agentUiProjectionEvents, items], - ); return (
{error?.message && (
- 最近错误:{error.message} + {t("errors.crashRecovery.latestError", { + message: error.message, + defaultValue: "最近错误:{{message}}", + })}
)} {isModuleImportFailure && (
- 检测到前端模块资源加载失败。请优先点击“强制刷新资源”,让应用重新请求最新模块;若仍反复出现,再清理 + {t( + "errors.crashRecovery.moduleImportFailure.prefix", + "检测到前端模块资源加载失败。请优先点击“强制刷新资源”,让应用重新请求最新模块;若仍反复出现,再清理", + )} node_modules/.vite - 和 + {t("errors.crashRecovery.moduleImportFailure.middle", "和")} node_modules/.vite-tauri - 后重启。 + {t("errors.crashRecovery.moduleImportFailure.suffix", "后重启。")}
)} @@ -370,7 +461,7 @@ export function CrashRecoveryPanel({ )} > - 重新选择目录 + {t("errors.crashRecovery.action.selectDirectory", "重新选择目录")} ) : null} {isModuleImportFailure ? ( ) : null}
diff --git a/src/components/settings-v2/_layout/index.test.tsx b/src/components/settings-v2/_layout/index.test.tsx index 880eb2e36..61d39eef1 100644 --- a/src/components/settings-v2/_layout/index.test.tsx +++ b/src/components/settings-v2/_layout/index.test.tsx @@ -9,12 +9,31 @@ const { mockCloudProviderSettings, mockSettingsHomePage, mockDeveloperLabSettings, + mockUseTranslation, } = vi.hoisted(() => ({ mockSettingsSidebar: vi.fn(), mockPreloadDeveloperDefaultSections: vi.fn(), mockCloudProviderSettings: vi.fn(), mockSettingsHomePage: vi.fn(), mockDeveloperLabSettings: vi.fn(), + mockUseTranslation: vi.fn((_namespace?: string) => ({ + t: (key: string, options?: unknown) => { + if (typeof options === "string") { + return options; + } + + if (options && typeof options === "object") { + const values = options as Record; + const template = + typeof values.defaultValue === "string" ? values.defaultValue : key; + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String(values[name] ?? ""), + ); + } + + return key; + }, + })), })); const { mockResolveOemCloudRuntimeContext } = vi.hoisted(() => ({ @@ -99,6 +118,10 @@ vi.mock("@/lib/api/oemCloudRuntime", () => ({ resolveOemCloudRuntimeContext: () => mockResolveOemCloudRuntimeContext(), })); +vi.mock("react-i18next", () => ({ + useTranslation: mockUseTranslation, +})); + import { SettingsLayoutV2 } from "."; interface Mounted { @@ -252,6 +275,7 @@ describe("SettingsLayoutV2 Developer Tab", () => { expect(header?.getAttribute("data-window-controls-reserved")).toBe("true"); expect(button).not.toBeNull(); expect(button?.textContent ?? "").toContain("回到首页"); + expect(mockUseTranslation).toHaveBeenCalledWith("settings"); expect(getComputedStyle(header as Element).paddingLeft).toBe("0px"); expect(getComputedStyle(button as Element).marginLeft).toBe("24px"); expect( diff --git a/src/components/settings-v2/_layout/index.tsx b/src/components/settings-v2/_layout/index.tsx index 27e583d78..8b9138e3c 100644 --- a/src/components/settings-v2/_layout/index.tsx +++ b/src/components/settings-v2/_layout/index.tsx @@ -14,6 +14,8 @@ import { useRef, useCallback, } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { SettingsSidebar } from "./SettingsSidebar"; import { SettingsTabs } from "@/types/settings"; @@ -393,6 +395,7 @@ function preloadSettingsTab(tab: SettingsTabs): Promise | null { function renderSettingsContent( tab: SettingsTabs, onTabChange: (tab: SettingsTabs) => void, + t: TFunction<"settings", undefined>, onTabPrefetch?: (tab: SettingsTabs) => void, onNavigate?: (page: Page, params?: PageParams) => void, initialProviderView?: SettingsProviderView, @@ -419,76 +422,76 @@ function renderSettingsContent( {!hasManagedAccountProfile ? : null} , - "正在加载账号资料...", + t("settings.layout.loading.profile", "正在加载账号资料..."), ); case SettingsTabs.Stats: return withSettingsContentFallback( , - "正在加载数据统计...", + t("settings.layout.loading.stats", "正在加载数据统计..."), ); // 通用组 case SettingsTabs.Appearance: return withSettingsContentFallback( , - "正在加载外观设置...", + t("settings.layout.loading.appearance", "正在加载外观设置..."), ); case SettingsTabs.Hotkeys: return withSettingsContentFallback( , - "正在加载快捷键设置...", + t("settings.layout.loading.hotkeys", "正在加载快捷键设置..."), ); case SettingsTabs.Memory: return withSettingsContentFallback( , - "正在加载记忆设置...", + t("settings.layout.loading.memory", "正在加载记忆设置..."), ); // 智能体组 case SettingsTabs.Providers: return withSettingsContentFallback( , - "正在加载 AI 服务商设置...", + t("settings.layout.loading.providers", "正在加载 AI 服务商设置..."), ); case SettingsTabs.Skills: return withSettingsContentFallback( , - "正在加载技能管理...", + t("settings.layout.loading.skills", "正在加载技能管理..."), ); case SettingsTabs.MediaServices: return withSettingsContentFallback( , - "正在加载服务模型...", + t("settings.layout.loading.mediaServices", "正在加载服务模型..."), ); // 系统组 case SettingsTabs.McpServer: return withSettingsContentFallback( , - "正在加载 MCP 服务器...", + t("settings.layout.loading.mcpServer", "正在加载 MCP 服务器..."), ); case SettingsTabs.WebSearch: return withSettingsContentFallback( , - "正在加载网络搜索设置...", + t("settings.layout.loading.webSearch", "正在加载网络搜索设置..."), ); case SettingsTabs.Environment: return withSettingsContentFallback( , - "正在加载环境变量...", + t("settings.layout.loading.environment", "正在加载环境变量..."), ); case SettingsTabs.ChromeRelay: return withSettingsContentFallback( , - "正在加载连接器设置...", + t("settings.layout.loading.chromeRelay", "正在加载连接器设置..."), ); case SettingsTabs.Automation: @@ -497,25 +500,25 @@ function renderSettingsContent( mode="settings" onOpenWorkspace={() => onNavigate?.("automation")} />, - "正在加载自动化设置...", + t("settings.layout.loading.automation", "正在加载自动化设置..."), ); case SettingsTabs.Developer: return withSettingsContentFallback( , - "正在加载开发者与实验功能...", + t("settings.layout.loading.developerLab", "正在加载开发者与实验功能..."), ); case SettingsTabs.About: return withSettingsContentFallback( , - "正在加载关于页面...", + t("settings.layout.loading.about", "正在加载关于页面..."), ); default: return ( -

页面不存在

+

{t("settings.layout.placeholder.notFound", "页面不存在")}

); } @@ -544,6 +547,7 @@ export function SettingsLayoutV2({ initialTab, initialProviderView, }: SettingsLayoutV2Props) { + const { t } = useTranslation("settings"); const [activeTab, setActiveTab] = useState( resolveActiveSettingsTab(initialTab), ); @@ -626,11 +630,11 @@ export function SettingsLayoutV2({ - 回到首页 + {t("settings.layout.action.backHome", "回到首页")} @@ -645,6 +649,7 @@ export function SettingsLayoutV2({ {renderSettingsContent( activeTab, handleTabChange, + t, handleTabPrefetch, onNavigate, activeProviderView, diff --git a/src/components/settings-v2/account/profile/index.test.tsx b/src/components/settings-v2/account/profile/index.test.tsx index 775661895..440b7438b 100644 --- a/src/components/settings-v2/account/profile/index.test.tsx +++ b/src/components/settings-v2/account/profile/index.test.tsx @@ -2,9 +2,27 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { mockGetConfig, mockSaveConfig } = vi.hoisted(() => ({ +const { mockGetConfig, mockSaveConfig, mockUseTranslation } = vi.hoisted(() => ({ mockGetConfig: vi.fn(), mockSaveConfig: vi.fn(), + mockUseTranslation: vi.fn((_namespace?: string) => ({ + t: (key: string, options?: unknown) => { + if (typeof options === "string") { + return options; + } + + if (options && typeof options === "object") { + const values = options as Record; + const template = + typeof values.defaultValue === "string" ? values.defaultValue : key; + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String(values[name] ?? ""), + ); + } + + return key; + }, + })), })); vi.mock("@/lib/api/appConfig", () => ({ @@ -12,6 +30,10 @@ vi.mock("@/lib/api/appConfig", () => ({ saveConfig: mockSaveConfig, })); +vi.mock("react-i18next", () => ({ + useTranslation: mockUseTranslation, +})); + import { ProfileSettings } from "."; interface Mounted { @@ -178,6 +200,7 @@ describe("ProfileSettings", () => { expect(text).toContain("偏好标签"); expect(text).toContain("编程"); expect(text).toContain("设计"); + expect(mockUseTranslation).toHaveBeenCalledWith("settings"); }); it("编辑昵称后应保存完整资料", async () => { diff --git a/src/components/settings-v2/account/profile/index.tsx b/src/components/settings-v2/account/profile/index.tsx index 89d916029..da5a33846 100644 --- a/src/components/settings-v2/account/profile/index.tsx +++ b/src/components/settings-v2/account/profile/index.tsx @@ -18,6 +18,7 @@ import { X, type LucideIcon, } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip"; import { cn } from "@/lib/utils"; import { @@ -31,13 +32,9 @@ type EditableProfileField = "nickname" | "bio" | "email"; interface ProfileFieldMeta { key: EditableProfileField; - label: string; - description: string; - placeholder: string; icon: LucideIcon; editable?: boolean; multiline?: boolean; - hint: string; } const DEFAULT_USER_PROFILE: UserProfile = { @@ -51,44 +48,32 @@ const DEFAULT_USER_PROFILE: UserProfile = { const PROFILE_FIELDS: ProfileFieldMeta[] = [ { key: "nickname", - label: "昵称", - description: "展示在工作区与 AI 语境中的称呼。", - placeholder: "还没有设置昵称", icon: User, editable: true, - hint: "建议使用你最常用的称呼,便于系统在多处一致展示。", }, { key: "bio", - label: "个人简介", - description: "用几句话说明你的工作背景、兴趣和当前目标。", - placeholder: "补充你的角色、关注方向或当前项目,AI 会更快理解你。", icon: Edit2, editable: true, multiline: true, - hint: "按 Enter 保存,使用 Shift + Enter 换行。", }, { key: "email", - label: "邮箱", - description: "账号识别邮箱,当前页面仅展示,不支持直接修改。", - placeholder: "尚未绑定邮箱", icon: Mail, editable: false, - hint: "如需修改邮箱,请使用账号相关入口或后续专用流程。", }, ]; -const SUGGESTED_TAGS = [ - "编程", - "写作", - "设计", - "数据分析", - "产品经理", - "创业者", - "学生", - "研究者", -]; +const SUGGESTED_TAG_KEYS = [ + "programming", + "writing", + "design", + "dataAnalysis", + "productManager", + "founder", + "student", + "researcher", +] as const; const PRIMARY_ACTION_BUTTON_CLASS = "inline-flex items-center gap-2 rounded-[16px] border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] px-4 py-3 text-sm font-medium text-white shadow-sm shadow-emerald-950/15 transition hover:opacity-95"; const PRIMARY_PILL_BUTTON_CLASS = @@ -116,6 +101,17 @@ interface ProfileFieldCardProps { onEditValueChange: (value: string) => void; onSave: () => void; onCancel: () => void; + actionLabels: { + cancel: string; + cancelAria: string; + edit: string; + editAria: string; + readOnly: string; + save: string; + saveAria: string; + tipAria: string; + tipsHint: string; + }; } function ProfileFieldCard({ @@ -134,6 +130,7 @@ function ProfileFieldCard({ onEditValueChange, onSave, onCancel, + actionLabels, }: ProfileFieldCardProps) { return (
@@ -146,7 +143,7 @@ function ProfileFieldCard({

{label}

{description}

@@ -157,7 +154,7 @@ function ProfileFieldCard({ /> {!editable && ( - 只读 + {actionLabels.readOnly} )}
@@ -167,12 +164,12 @@ function ProfileFieldCard({ {editable && !isEditing && ( )} @@ -236,26 +233,26 @@ function ProfileFieldCard({
- 通过 Tips 查看说明 + {actionLabels.tipsHint} {isEditing && (
)} @@ -265,6 +262,7 @@ function ProfileFieldCard({ } export function ProfileSettings() { + const { t } = useTranslation("settings"); const [config, setConfig] = useState(null); const [profile, setProfile] = useState(DEFAULT_USER_PROFILE); const [loading, setLoading] = useState(true); @@ -278,6 +276,62 @@ export function ProfileSettings() { } | null>(null); const [newTag, setNewTag] = useState(""); const messageTimerRef = useRef(null); + const profileFields = PROFILE_FIELDS.map((field) => { + const keyPrefix = `settings.profile.field.${field.key}`; + const label = t(`${keyPrefix}.label`, { + defaultValue: + field.key === "nickname" + ? "昵称" + : field.key === "bio" + ? "个人简介" + : "邮箱", + }); + + return { + ...field, + label, + description: t(`${keyPrefix}.description`, { + defaultValue: + field.key === "nickname" + ? "展示在工作区与 AI 语境中的称呼。" + : field.key === "bio" + ? "用几句话说明你的工作背景、兴趣和当前目标。" + : "账号识别邮箱,当前页面仅展示,不支持直接修改。", + }), + placeholder: t(`${keyPrefix}.placeholder`, { + defaultValue: + field.key === "nickname" + ? "还没有设置昵称" + : field.key === "bio" + ? "补充你的角色、关注方向或当前项目,AI 会更快理解你。" + : "尚未绑定邮箱", + }), + hint: t(`${keyPrefix}.hint`, { + defaultValue: + field.key === "nickname" + ? "建议使用你最常用的称呼,便于系统在多处一致展示。" + : field.key === "bio" + ? "按 Enter 保存,使用 Shift + Enter 换行。" + : "如需修改邮箱,请使用账号相关入口或后续专用流程。", + }), + tipAria: t("settings.profile.field.tipAria", { + label, + defaultValue: "{{label}}说明", + }), + editAria: t("settings.profile.field.editAria", { + label, + defaultValue: "编辑{{label}}", + }), + cancelAria: t("settings.profile.field.cancelAria", { + label, + defaultValue: "取消编辑{{label}}", + }), + saveAria: t("settings.profile.field.saveAria", { + label, + defaultValue: "保存{{label}}", + }), + }; + }); useEffect(() => { void loadConfig(); @@ -318,7 +372,13 @@ export function ProfileSettings() { value: UserProfile[K], ) => { if (!config) { - showMessage("error", "资料配置尚未加载完成,暂时无法保存。"); + showMessage( + "error", + t( + "settings.profile.message.configPending", + "资料配置尚未加载完成,暂时无法保存。", + ), + ); return; } @@ -342,10 +402,16 @@ export function ProfileSettings() { await saveConfig(updatedFullConfig); setConfig(updatedFullConfig); setProfile(completeProfile); - showMessage("success", "资料已保存"); + showMessage("success", t("settings.profile.message.saved", "资料已保存")); } catch (error) { console.error("保存用户资料失败:", error); - showMessage("error", `保存失败: ${error}`); + showMessage( + "error", + t("settings.profile.message.saveFailed", { + error: String(error), + defaultValue: "保存失败: {{error}}", + }), + ); } }; @@ -410,13 +476,20 @@ export function ProfileSettings() { if (file.size > maxSize) { showMessage( "error", - `文件过大 (${(file.size / 1024 / 1024).toFixed(2)}MB),最大支持 5MB`, + t("settings.profile.message.fileTooLarge", { + size: (file.size / 1024 / 1024).toFixed(2), + max: 5, + defaultValue: "文件过大 ({{size}}MB),最大支持 {{max}}MB", + }), ); return; } await file.arrayBuffer(); - showMessage("success", "头像上传功能正在完善中"); + showMessage( + "success", + t("settings.profile.message.avatarWip", "头像上传功能正在完善中"), + ); }; document.body.appendChild(input); @@ -424,7 +497,13 @@ export function ProfileSettings() { document.body.removeChild(input); } catch (error) { console.error("上传头像失败:", error); - showMessage("error", `上传失败: ${error}`); + showMessage( + "error", + t("settings.profile.message.uploadFailed", { + error: String(error), + defaultValue: "上传失败: {{error}}", + }), + ); } }; @@ -438,17 +517,36 @@ export function ProfileSettings() { const completionPercent = Math.round((completionItems / 4) * 100); const statusLabel = completionPercent >= 75 - ? "资料完整" + ? t("settings.profile.status.complete", "资料完整") : completionPercent >= 40 - ? "逐步成型" - : "待补充"; + ? t("settings.profile.status.progress", "逐步成型") + : t("settings.profile.status.pending", "待补充"); const statusClassName = completionPercent >= 75 ? "border-emerald-200 bg-emerald-50 text-emerald-700" : completionPercent >= 40 ? "border-sky-200 bg-sky-50 text-sky-700" : "border-amber-200 bg-amber-50 text-amber-700"; - const suggestedTags = SUGGESTED_TAGS.filter( + const suggestedTags = SUGGESTED_TAG_KEYS.map((key) => + t(`settings.profile.suggestedTag.${key}`, { + defaultValue: + key === "programming" + ? "编程" + : key === "writing" + ? "写作" + : key === "design" + ? "设计" + : key === "dataAnalysis" + ? "数据分析" + : key === "productManager" + ? "产品经理" + : key === "founder" + ? "创业者" + : key === "student" + ? "学生" + : "研究者", + }), + ).filter( (tag) => !tags.includes(tag), ).slice(0, 6); const quickTags = tags.slice(0, 3); @@ -498,16 +596,25 @@ export function ProfileSettings() {

- 个人资料 + {t("settings.profile.hero.title", "个人资料")}

- 管理昵称、简介、邮箱和偏好标签。 + {t( + "settings.profile.hero.subtitle", + "管理昵称、简介、邮箱和偏好标签。", + )}

@@ -518,13 +625,22 @@ export function ProfileSettings() { statusClassName, )} > - 状态:{statusLabel} + {t("settings.profile.hero.status", { + status: statusLabel, + defaultValue: "状态:{{status}}", + })} - 完成度:{completionPercent}% + {t("settings.profile.hero.completion", { + percent: completionPercent, + defaultValue: "完成度:{{percent}}%", + })} - 标签:{tags.length} + {t("settings.profile.hero.tags", { + count: tags.length, + defaultValue: "标签:{{count}}", + })}
@@ -535,7 +651,7 @@ export function ProfileSettings() { {profile.avatar_url ? ( 头像 ) : ( @@ -544,7 +660,7 @@ export function ProfileSettings() {

- 推荐标签 + {t("settings.profile.tags.recommended", "推荐标签")}

{suggestedTags.map((tag) => ( @@ -761,20 +940,31 @@ export function ProfileSettings() {
- 资料如何被使用 + {t("settings.profile.usage.title", "资料如何被使用")}

- 昵称和简介会帮助 AI - 在开场、建议和工作区提示中更自然地引用你的背景。 + {t( + "settings.profile.usage.tipNickname", + "昵称和简介会帮助 AI 在开场、建议和工作区提示中更自然地引用你的背景。", + )}

- 标签只用于偏好判断,不会替代系统提示词,也不会自动暴露给外部服务。 + {t( + "settings.profile.usage.tipTags", + "标签只用于偏好判断,不会替代系统提示词,也不会自动暴露给外部服务。", + )}

- 邮箱由账号体系维护,当前页面仅做展示,避免在多入口出现不一致状态。 + {t( + "settings.profile.usage.tipEmail", + "邮箱由账号体系维护,当前页面仅做展示,避免在多入口出现不一致状态。", + )}

} @@ -782,7 +972,7 @@ export function ProfileSettings() { />
- 说明已收纳到标题旁 Tips。 + {t("settings.profile.usage.inline", "说明已收纳到标题旁 Tips。")}
diff --git a/src/components/settings-v2/account/stats/index.test.tsx b/src/components/settings-v2/account/stats/index.test.tsx index f138c67ae..6191043f5 100644 --- a/src/components/settings-v2/account/stats/index.test.tsx +++ b/src/components/settings-v2/account/stats/index.test.tsx @@ -2,12 +2,39 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { mockGetUsageStats, mockGetModelUsageRanking, mockGetDailyUsageTrends } = - vi.hoisted(() => ({ +const { + mockGetUsageStats, + mockGetModelUsageRanking, + mockGetDailyUsageTrends, + mockUseTranslation, +} = vi.hoisted(() => { + const t = (key: string, options?: unknown) => { + if (typeof options === "string") { + return options; + } + + if (options && typeof options === "object") { + const values = options as Record; + const template = + typeof values.defaultValue === "string" ? values.defaultValue : key; + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String(values[name] ?? ""), + ); + } + + return key; + }; + + return { mockGetUsageStats: vi.fn(), mockGetModelUsageRanking: vi.fn(), mockGetDailyUsageTrends: vi.fn(), - })); + mockUseTranslation: vi.fn(() => ({ + i18n: { language: "zh-CN" }, + t, + })), + }; +}); vi.mock("@/lib/api/usageStats", () => ({ getUsageStats: mockGetUsageStats, @@ -15,6 +42,10 @@ vi.mock("@/lib/api/usageStats", () => ({ getDailyUsageTrends: mockGetDailyUsageTrends, })); +vi.mock("react-i18next", () => ({ + useTranslation: mockUseTranslation, +})); + import { StatsSettings } from "."; interface Mounted { @@ -168,6 +199,7 @@ describe("StatsSettings", () => { expect(text).toContain("每日使用趋势"); expect(text).toContain("活跃度日历"); expect(text).toContain("gpt-4.1"); + expect(mockUseTranslation).toHaveBeenCalledWith("settings"); }); it("切换时间范围后应重新拉取对应统计", async () => { diff --git a/src/components/settings-v2/account/stats/index.tsx b/src/components/settings-v2/account/stats/index.tsx index 075cd0ac3..e3222749c 100644 --- a/src/components/settings-v2/account/stats/index.tsx +++ b/src/components/settings-v2/account/stats/index.tsx @@ -6,8 +6,11 @@ */ import { useState, useEffect, useCallback } from "react"; +import type { TFunction } from "i18next"; import { BarChart3, Brain, CalendarDays, Coins, RefreshCw } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip"; +import { formatDate as formatLocaleDate } from "@/i18n/format"; import { cn } from "@/lib/utils"; import { getDailyUsageTrends, @@ -22,8 +25,6 @@ type TimeRange = "week" | "month" | "all"; interface TimeRangeOption { key: TimeRange; - label: string; - description: string; } interface SegmentCardProps { @@ -37,50 +38,49 @@ interface SegmentCardProps { } const TIME_RANGE_OPTIONS: TimeRangeOption[] = [ - { - key: "week", - label: "本周", - description: "聚焦最近 7 天的使用波动。", - }, - { - key: "month", - label: "本月", - description: "观察近 30 天的日常使用节奏。", - }, - { - key: "all", - label: "全部", - description: "回看累计使用规模与长期趋势。", - }, + { key: "week" }, + { key: "month" }, + { key: "all" }, ]; -const WEEKDAY_LABELS = ["日", "一", "二", "三", "四", "五", "六"]; +const WEEKDAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const; const ACTIVE_TIME_RANGE_BUTTON_CLASS = "border-emerald-200 bg-[linear-gradient(135deg,rgba(240,253,250,0.98)_0%,rgba(236,253,245,0.96)_52%,rgba(224,242,254,0.95)_100%)] text-slate-800 shadow-sm shadow-emerald-950/10"; const PROGRESS_BAR_FILL_CLASS = "bg-[linear-gradient(90deg,#14b8a6_0%,#10b981_100%)]"; -function formatNumber(num: number): string { +function formatCompactNumber(num: number): string { if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; return num.toString(); } -function formatTime(minutes: number): string { +function formatTime( + minutes: number, + t: TFunction<"settings", undefined>, +): string { if (minutes >= 60) { const hours = Math.floor(minutes / 60); const mins = minutes % 60; - return `${hours}h ${mins}m`; + return t("settings.stats.duration.hoursMinutes", { + hours, + minutes: mins, + defaultValue: "{{hours}}h {{minutes}}m", + }); } - return `${minutes}m`; + return t("settings.stats.duration.minutes", { + minutes, + defaultValue: "{{minutes}}m", + }); } function parseUsageDate(date: string) { return new Date(date.includes("T") ? date : `${date}T00:00:00`); } -function formatShortDate(date: string) { - return parseUsageDate(date).toLocaleDateString("zh-CN", { +function formatShortDate(date: string, locale?: string) { + return formatLocaleDate(parseUsageDate(date), { + locale, month: "numeric", day: "numeric", }); @@ -120,6 +120,8 @@ function SegmentCard({ minutes, accentClassName, }: SegmentCardProps) { + const { t } = useTranslation("settings"); + return (
@@ -133,24 +135,39 @@ function SegmentCard({ {title} - {messages} 条消息 + {t("settings.stats.segment.messages", { + count: messages, + defaultValue: "{{count}} 条消息", + })}
- 对话:{conversations} + {t("settings.stats.segment.conversations", { + count: conversations, + defaultValue: "对话:{{count}}", + })} - Token:{formatNumber(tokens)} + {t("settings.stats.segment.tokens", { + tokens: formatCompactNumber(tokens), + defaultValue: "Token:{{tokens}}", + })} - 时长:{formatTime(minutes)} + {t("settings.stats.segment.duration", { + duration: formatTime(minutes, t), + defaultValue: "时长:{{duration}}", + })}
@@ -159,12 +176,50 @@ function SegmentCard({ } export function StatsSettings() { + const { t, i18n } = useTranslation("settings"); const [loading, setLoading] = useState(true); const [stats, setStats] = useState(null); const [modelUsage, setModelUsage] = useState([]); const [dailyUsage, setDailyUsage] = useState([]); const [error, setError] = useState(null); const [timeRange, setTimeRange] = useState("month"); + const timeRangeOptions = TIME_RANGE_OPTIONS.map((option) => ({ + ...option, + label: t(`settings.stats.range.${option.key}.label`, { + defaultValue: + option.key === "week" + ? "本周" + : option.key === "month" + ? "本月" + : "全部", + }), + description: t(`settings.stats.range.${option.key}.description`, { + defaultValue: + option.key === "week" + ? "聚焦最近 7 天的使用波动。" + : option.key === "month" + ? "观察近 30 天的日常使用节奏。" + : "回看累计使用规模与长期趋势。", + }), + })); + const weekdayLabels = WEEKDAY_KEYS.map((key) => + t(`settings.stats.weekday.${key}`, { + defaultValue: + key === "sun" + ? "日" + : key === "mon" + ? "一" + : key === "tue" + ? "二" + : key === "wed" + ? "三" + : key === "thu" + ? "四" + : key === "fri" + ? "五" + : "六", + }), + ); const loadStats = useCallback(async () => { setLoading(true); @@ -182,14 +237,18 @@ export function StatsSettings() { setDailyUsage(trends); } catch (e) { console.error("加载统计数据失败:", e); - setError(e instanceof Error ? e.message : "加载统计数据失败"); + setError( + e instanceof Error + ? e.message + : t("settings.stats.error.load", "加载统计数据失败"), + ); setStats(null); setModelUsage([]); setDailyUsage([]); } finally { setLoading(false); } - }, [timeRange]); + }, [t, timeRange]); useEffect(() => { loadStats(); @@ -218,11 +277,15 @@ export function StatsSettings() { const topModel = modelUsage[0] || null; const secondaryModels = modelUsage.slice(1, 4); const selectedRange = - TIME_RANGE_OPTIONS.find((option) => option.key === timeRange) || - TIME_RANGE_OPTIONS[1]; + timeRangeOptions.find((option) => option.key === timeRange) || + timeRangeOptions[1]; const peakDayLabel = peakDay - ? `${formatShortDate(peakDay.date)} · ${formatNumber(peakDay.tokens)} Token` - : "暂无数据"; + ? t("settings.stats.peakDay.value", { + date: formatShortDate(peakDay.date, i18n.language), + tokens: formatCompactNumber(peakDay.tokens), + defaultValue: "{{date}} · {{tokens}} Token", + }) + : t("settings.stats.empty.noData", "暂无数据"); const chartGuideValues = maxDailyTokens > 0 ? [1, 0.75, 0.5, 0.25, 0].map((ratio) => @@ -234,10 +297,14 @@ export function StatsSettings() { const heatmapDays = dailyUsage.slice(-35); const heatmapRangeLabel = heatmapDays.length > 0 - ? `${formatShortDate(heatmapDays[0].date)} - ${formatShortDate( + ? `${formatShortDate( + heatmapDays[0].date, + i18n.language, + )} - ${formatShortDate( heatmapDays[heatmapDays.length - 1].date, + i18n.language, )}` - : "暂无活跃记录"; + : t("settings.stats.empty.noActivity", "暂无活跃记录"); const heatmapCells: Array = [ ...Array.from({ length: Math.max(35 - heatmapDays.length, 0) }, () => null), ...heatmapDays, @@ -273,7 +340,7 @@ export function StatsSettings() { onClick={loadStats} className="rounded-full border border-rose-200 bg-white px-3 py-1.5 text-xs font-medium text-rose-700 transition hover:border-rose-300 hover:bg-rose-50" > - 重新加载 + {t("settings.stats.action.reload", "重新加载")} )} @@ -283,21 +350,30 @@ export function StatsSettings() {

- 数据统计 + {t("settings.stats.hero.title", "数据统计")}

- 查看当前区间的使用强度、模型分布和趋势。 + {t( + "settings.stats.hero.subtitle", + "查看当前区间的使用强度、模型分布和趋势。", + )}

- {TIME_RANGE_OPTIONS.map((option) => ( + {timeRangeOptions.map((option) => (
@@ -329,39 +405,73 @@ export function StatsSettings() {
- 当前统计口径:{selectedRange.label} + {t("settings.stats.summary.range", { + range: selectedRange.label, + defaultValue: "当前统计口径:{{range}}", + })} - 活跃 {activeDays} 天 + {t("settings.stats.summary.activeDays", { + count: activeDays, + defaultValue: "活跃 {{count}} 天", + })} - 日均 {formatNumber(averageDailyTokens)} Token + {t("settings.stats.summary.averageTokens", { + tokens: formatCompactNumber(averageDailyTokens), + defaultValue: "日均 {{tokens}} Token", + })} - 当前区间 {formatNumber(totalRangeTokens)} Token + {t("settings.stats.summary.rangeTokens", { + tokens: formatCompactNumber(totalRangeTokens), + defaultValue: "当前区间 {{tokens}} Token", + })}
- 当前观察 + {t("settings.stats.observation.title", "当前观察")} - 主力模型:{topModel?.model || "暂无数据"} + {t("settings.stats.observation.topModel", { + model: + topModel?.model || + t("settings.stats.empty.noData", "暂无数据"), + defaultValue: "主力模型:{{model}}", + })} - 峰值:{peakDayLabel} + {t("settings.stats.observation.peak", { + peak: peakDayLabel, + defaultValue: "峰值:{{peak}}", + })}
- 当前统计口径说明已收纳 + + {t( + "settings.stats.summary.rangeTipInline", + "当前统计口径说明已收纳", + )} + @@ -377,23 +487,35 @@ export function StatsSettings() {
- 阶段概览 + {t("settings.stats.overview.title", "阶段概览")}
- 3 个区段 + {t("settings.stats.overview.segmentCount", { + count: 3, + defaultValue: "{{count}} 个区段", + })}
- 模型使用排行 + {t("settings.stats.models.title", "模型使用排行")}
- {modelUsage.length} 个模型 + {t("settings.stats.models.count", { + count: modelUsage.length, + defaultValue: "{{count}} 个模型", + })} @@ -456,24 +593,34 @@ export function StatsSettings() {
- #1 主力模型 + {t("settings.stats.models.primaryBadge", "#1 主力模型")}

{topModel?.model}

- {topModel?.conversations || 0} 次对话 ·{" "} - {formatNumber(topModel?.tokens || 0)} Token + {t("settings.stats.models.itemMeta", { + conversations: topModel?.conversations || 0, + tokens: formatCompactNumber(topModel?.tokens || 0), + defaultValue: + "{{conversations}} 次对话 · {{tokens}} Token", + })}

- 使用占比:{topModel?.percentage || 0}% + {t("settings.stats.models.usageShare", { + percent: topModel?.percentage || 0, + defaultValue: "使用占比:{{percent}}%", + })} - 排名:#1 + {t("settings.stats.models.rank", { + rank: 1, + defaultValue: "排名:#{{rank}}", + })}
@@ -509,8 +656,12 @@ export function StatsSettings() {

- {model.conversations} 次对话 ·{" "} - {formatNumber(model.tokens)} Token + {t("settings.stats.models.itemMeta", { + conversations: model.conversations, + tokens: formatCompactNumber(model.tokens), + defaultValue: + "{{conversations}} 次对话 · {{tokens}} Token", + })}

@@ -535,7 +686,10 @@ export function StatsSettings() { ) : (
- 当前区间还没有模型使用数据。 + {t( + "settings.stats.models.empty", + "当前区间还没有模型使用数据。", + )}
)} @@ -549,20 +703,30 @@ export function StatsSettings() {
- 每日使用趋势 + {t("settings.stats.trends.title", "每日使用趋势")}
- {formatNumber(totalRangeTokens)} Token + {formatCompactNumber(totalRangeTokens)} Token - {totalRangeConversations} 次对话 + {t("settings.stats.trends.conversations", { + count: totalRangeConversations, + defaultValue: "{{count}} 次对话", + })}
@@ -571,13 +735,22 @@ export function StatsSettings() {
- 峰值日:{peakDayLabel} + {t("settings.stats.trends.peakDay", { + peak: peakDayLabel, + defaultValue: "峰值日:{{peak}}", + })} - 活跃天数:{activeDays} + {t("settings.stats.trends.activeDays", { + count: activeDays, + defaultValue: "活跃天数:{{count}}", + })} - 区间均值:{formatNumber(averageDailyTokens)} + {t("settings.stats.trends.average", { + tokens: formatCompactNumber(averageDailyTokens), + defaultValue: "区间均值:{{tokens}}", + })}
@@ -591,7 +764,7 @@ export function StatsSettings() { bottom: `${(index / (chartGuideValues.length - 1)) * 100}%`, }} > - {formatNumber(value)} + {formatCompactNumber(value)}
))} @@ -630,12 +803,14 @@ export function StatsSettings() { style={{ height: `${Math.max(height, 6)}%` }} >
- {formatNumber(day.tokens)} Token + {formatCompactNumber(day.tokens)} Token
- {showLabel ? formatShortDate(day.date) : ""} + {showLabel + ? formatShortDate(day.date, i18n.language) + : ""}
); @@ -646,7 +821,10 @@ export function StatsSettings() { ) : (
- 当前区间还没有每日趋势数据。 + {t( + "settings.stats.trends.empty", + "当前区间还没有每日趋势数据。", + )}
)}
@@ -656,16 +834,22 @@ export function StatsSettings() {
- 活跃度日历 + {t("settings.stats.heatmap.title", "活跃度日历")}
- 少 + {t("settings.stats.heatmap.less", "少")}
@@ -673,22 +857,28 @@ export function StatsSettings() {
- 多 + {t("settings.stats.heatmap.more", "多")}
- 覆盖范围:{heatmapRangeLabel} + {t("settings.stats.heatmap.range", { + range: heatmapRangeLabel, + defaultValue: "覆盖范围:{{range}}", + })} - 有效活跃格:{activeDays} + {t("settings.stats.heatmap.activeCells", { + count: activeDays, + defaultValue: "有效活跃格:{{count}}", + })}
- {WEEKDAY_LABELS.map((day) => ( + {weekdayLabels.map((day) => (
{day ? (
- {formatShortDate(day.date)} ·{" "} - {formatNumber(day.tokens)} + {formatShortDate(day.date, i18n.language)} ·{" "} + {formatCompactNumber(day.tokens)}
) : null}
@@ -726,7 +916,7 @@ export function StatsSettings() { ) : (
- 还没有可展示的统计数据。 + {t("settings.stats.empty.noStats", "还没有可展示的统计数据。")}
)}
diff --git a/src/components/settings-v2/account/user-center-session/index.test.tsx b/src/components/settings-v2/account/user-center-session/index.test.tsx index bff98347e..13dc22000 100644 --- a/src/components/settings-v2/account/user-center-session/index.test.tsx +++ b/src/components/settings-v2/account/user-center-session/index.test.tsx @@ -2,18 +2,46 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { mockUseOemCloudAccess, mockFormatOemCloudDateTime } = vi.hoisted( - () => ({ +const { + mockUseOemCloudAccess, + mockFormatOemCloudDateTime, + mockUseTranslation, +} = vi.hoisted(() => { + const mockTranslate = vi.fn((key: string, options?: unknown) => { + if (typeof options === "string") { + return options; + } + + if (options && typeof options === "object") { + const values = options as Record; + const template = + typeof values.defaultValue === "string" ? values.defaultValue : key; + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String(values[name] ?? ""), + ); + } + + return key; + }); + + return { mockUseOemCloudAccess: vi.fn(), mockFormatOemCloudDateTime: vi.fn((value?: string) => `fmt:${value ?? ""}`), - }), -); + mockUseTranslation: vi.fn((_namespace?: string) => ({ + t: mockTranslate, + })), + }; +}); vi.mock("@/hooks/useOemCloudAccess", () => ({ useOemCloudAccess: () => mockUseOemCloudAccess(), formatOemCloudDateTime: (value?: string) => mockFormatOemCloudDateTime(value), })); +vi.mock("react-i18next", () => ({ + useTranslation: mockUseTranslation, +})); + import { UserCenterSessionSettings } from "."; interface MountedPage { @@ -167,6 +195,7 @@ describe("UserCenterSessionSettings", () => { expect(text).toContain("默认服务:登录后自动同步"); expect(text).toContain("使用 Google 一键登录"); expect(text).toContain("浏览器完成后自动同步"); + expect(mockUseTranslation).toHaveBeenCalledWith("settings"); }); it("点击 Google 一键登录时应调用 hook 的 handleGoogleLogin", async () => { diff --git a/src/components/settings-v2/account/user-center-session/index.tsx b/src/components/settings-v2/account/user-center-session/index.tsx index 1d7bb248b..9c536c95c 100644 --- a/src/components/settings-v2/account/user-center-session/index.tsx +++ b/src/components/settings-v2/account/user-center-session/index.tsx @@ -10,6 +10,8 @@ import { RefreshCw, } from "lucide-react"; import { useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip"; import { ProviderIcon } from "@/icons/providers"; import { @@ -29,13 +31,14 @@ function SessionValueCard(props: { label: string; value: string; hint: string; + hintAriaLabel: string; }) { return (
{props.label} @@ -76,10 +79,12 @@ function resolveServiceSkillCount(payload: unknown): number { return Array.isArray(items) ? items.length : 0; } -function formatProviderLabel(provider?: string) { +type UserCenterSessionTranslate = TFunction<"settings", undefined>; + +function formatProviderLabel(t: UserCenterSessionTranslate, provider?: string) { const normalized = provider?.trim(); if (!normalized) { - return "系统账号"; + return t("settings.userCenterSession.provider.system", "系统账号"); } if (normalized.toLowerCase() === "google") { @@ -99,6 +104,7 @@ function buildAccountInitials(value?: string) { } export function UserCenterSessionSettings() { + const { t } = useTranslation("settings"); const [showAlternativeMethods, setShowAlternativeMethods] = useState(false); const { runtime, @@ -133,15 +139,19 @@ export function UserCenterSessionSettings() { session?.user.displayName?.trim() || session?.user.username?.trim() || session?.user.email?.trim() || - "未登录"; + t("settings.userCenterSession.account.fallbackName", "未登录"); const accountEmail = session?.user.email?.trim() || session?.user.username?.trim() || - "登录后显示"; + t("settings.userCenterSession.account.loginRequired", "登录后显示"); const accountIdentity = - session?.user.username?.trim() || session?.user.id || "登录后显示"; - const identityLabel = session?.user.username?.trim() ? "账号" : "用户 ID"; - const providerLabel = formatProviderLabel(session?.session.provider); + session?.user.username?.trim() || + session?.user.id || + t("settings.userCenterSession.account.loginRequired", "登录后显示"); + const identityLabel = session?.user.username?.trim() + ? t("settings.userCenterSession.account.identity.username", "账号") + : t("settings.userCenterSession.account.identity.userId", "用户 ID"); + const providerLabel = formatProviderLabel(t, session?.session.provider); const accountInitials = buildAccountInitials( session?.user.displayName || session?.user.username || @@ -149,13 +159,18 @@ export function UserCenterSessionSettings() { undefined, ); const syncedCapabilitiesSummary = session - ? `${resolveServiceSkillCount(bootstrap?.serviceSkillCatalog)} 项技能 / ${ - bootstrap?.sceneCatalog?.length || 0 - } 个入口` - : "登录后自动同步"; + ? t("settings.userCenterSession.account.syncedCapabilities", { + skills: resolveServiceSkillCount(bootstrap?.serviceSkillCatalog), + scenes: bootstrap?.sceneCatalog?.length || 0, + defaultValue: "{{skills}} 项技能 / {{scenes}} 个入口", + }) + : t("settings.userCenterSession.account.syncedPending", "登录后自动同步"); const manageProfileLabel = bootstrap?.features?.profileEditable - ? "前往账号中心修改资料" - : "打开账号中心"; + ? t( + "settings.userCenterSession.action.manageProfile", + "前往账号中心修改资料", + ) + : t("settings.userCenterSession.action.openUserCenter", "打开账号中心"); return (
@@ -165,16 +180,25 @@ export function UserCenterSessionSettings() {

- 账户资料 + {t("settings.userCenterSession.title", "账户资料")}

- 查看登录状态、默认服务和账号同步结果。 + {t( + "settings.userCenterSession.description", + "查看登录状态、默认服务和账号同步结果。", + )}

@@ -187,13 +211,40 @@ export function UserCenterSessionSettings() { : "border-slate-200 bg-slate-100 text-slate-600", )} > - 状态:{session ? "已登录" : "未登录"} + {t("settings.userCenterSession.status.login", { + status: session + ? t("settings.userCenterSession.status.loggedIn", "已登录") + : t( + "settings.userCenterSession.status.loggedOut", + "未登录", + ), + defaultValue: "状态:{{status}}", + })} - 当前状态:{session ? "账号已连接" : "等待登录"} + {t("settings.userCenterSession.status.current", { + status: session + ? t( + "settings.userCenterSession.status.connected", + "账号已连接", + ) + : t( + "settings.userCenterSession.status.waitingLogin", + "等待登录", + ), + defaultValue: "当前状态:{{status}}", + })} - 默认服务:{defaultProviderSummary || "登录后自动同步"} + {t("settings.userCenterSession.status.defaultService", { + service: + defaultProviderSummary || + t( + "settings.userCenterSession.account.syncedPending", + "登录后自动同步", + ), + defaultValue: "默认服务:{{service}}", + })}
@@ -207,13 +258,19 @@ export function UserCenterSessionSettings() { {!runtime ? (
- 当前版本未配置云端服务。开源版可继续使用本地功能;品牌服务配置登录入口后会自动显示。 + {t( + "settings.userCenterSession.empty.runtimeMissing", + "当前版本未配置云端服务。开源版可继续使用本地功能;品牌服务配置登录入口后会自动显示。", + )}
) : initializing ? (
- 正在恢复账户状态... + {t( + "settings.userCenterSession.loading.restoreAccount", + "正在恢复账户状态...", + )}
) : session ? ( @@ -229,7 +286,10 @@ export function UserCenterSessionSettings() { {session.user.avatarUrl ? ( {`${accountName} ) : ( @@ -249,20 +309,38 @@ export function UserCenterSessionSettings() {
- 登录方式:{providerLabel} + {t( + "settings.userCenterSession.account.loginMethod", + { + provider: providerLabel, + defaultValue: "登录方式:{{provider}}", + }, + )} - 已同步:{syncedCapabilitiesSummary} + {t("settings.userCenterSession.account.synced", { + summary: syncedCapabilitiesSummary, + defaultValue: "已同步:{{summary}}", + })}
- 资料维护已统一到账号中心 + {t( + "settings.userCenterSession.profile.unified", + "资料维护已统一到账号中心", + )}
@@ -281,7 +359,10 @@ export function UserCenterSessionSettings() { - 同步最新状态 + {t( + "settings.userCenterSession.action.refresh", + "同步最新状态", + )}
@@ -332,17 +464,29 @@ export function UserCenterSessionSettings() {

- 资料维护方式 + {t( + "settings.userCenterSession.profile.method.title", + "资料维护方式", + )}

- 昵称、头像、邮箱等资料由账号中心统一维护。这里专注展示当前账户状态,不再提供单独的本地资料编辑入口。 + {t( + "settings.userCenterSession.profile.method.tipLine1", + "昵称、头像、邮箱等资料由账号中心统一维护。这里专注展示当前账户状态,不再提供单独的本地资料编辑入口。", + )}

- 如需调整资料,请前往账号中心完成修改,然后回到这里点击“同步最新状态”。 + {t( + "settings.userCenterSession.profile.method.tipLine2", + "如需调整资料,请前往账号中心完成修改,然后回到这里点击“同步最新状态”。", + )}

} @@ -351,7 +495,10 @@ export function UserCenterSessionSettings() {
{codeDelivery ? (

- 最近一次验证码已发送到 {codeDelivery.maskedEmail}。 + {t("settings.userCenterSession.code.sentTo", { + email: codeDelivery.maskedEmail, + defaultValue: "最近一次验证码已发送到 {{email}}。", + })}

) : null}
@@ -367,11 +514,20 @@ export function UserCenterSessionSettings() {

- 使用 Google 一键登录 + {t( + "settings.userCenterSession.login.google.title", + "使用 Google 一键登录", + )}

@@ -390,11 +546,20 @@ export function UserCenterSessionSettings() { {openingGoogleLogin - ? "正在打开 Google 登录..." - : "使用 Google 一键登录"} + ? t( + "settings.userCenterSession.login.google.opening", + "正在打开 Google 登录...", + ) + : t( + "settings.userCenterSession.login.google.title", + "使用 Google 一键登录", + )} - 在系统浏览器完成授权后会自动同步;如果浏览器出现确认页,请继续完成。 + {t( + "settings.userCenterSession.login.google.description", + "在系统浏览器完成授权后会自动同步;如果浏览器出现确认页,请继续完成。", + )} @@ -407,7 +572,15 @@ export function UserCenterSessionSettings() { className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50" > - {openingGoogleLogin ? "等待授权中..." : "重新打开授权页"} + {openingGoogleLogin + ? t( + "settings.userCenterSession.login.google.waiting", + "等待授权中...", + ) + : t( + "settings.userCenterSession.login.google.reopen", + "重新打开授权页", + )}
@@ -433,22 +612,47 @@ export function UserCenterSessionSettings() {

- 浏览器完成后自动同步 + {t( + "settings.userCenterSession.login.afterBrowser.title", + "浏览器完成后自动同步", + )}

- Google 登录成功后,桌面端会自动同步当前账户会话。 + {t( + "settings.userCenterSession.login.afterBrowser.tipLine1", + "Google 登录成功后,桌面端会自动同步当前账户会话。", + )}

- 如果浏览器出现确认页,需要再确认一次当前桌面请求。 + {t( + "settings.userCenterSession.login.afterBrowser.tipLine2", + "如果浏览器出现确认页,需要再确认一次当前桌面请求。", + )}

-

同步当前账户资料与头像、昵称显示。

-

同步默认 AI 服务、模型目录与已开通能力。

- 个人资料统一在账号中心维护,避免多入口重复编辑。 + {t( + "settings.userCenterSession.login.afterBrowser.tipLine3", + "同步当前账户资料与头像、昵称显示。", + )} +

+

+ {t( + "settings.userCenterSession.login.afterBrowser.tipLine4", + "同步默认 AI 服务、模型目录与已开通能力。", + )} +

+

+ {t( + "settings.userCenterSession.login.afterBrowser.tipLine5", + "个人资料统一在账号中心维护,避免多入口重复编辑。", + )}

} @@ -465,11 +669,20 @@ export function UserCenterSessionSettings() {

- 备用登录方式 + {t( + "settings.userCenterSession.login.alternative.title", + "备用登录方式", + )}

@@ -486,7 +699,10 @@ export function UserCenterSessionSettings() { : "text-slate-600 hover:text-slate-900", )} > - 账号密码 + {t( + "settings.userCenterSession.login.mode.password", + "账号密码", + )}
@@ -507,7 +726,10 @@ export function UserCenterSessionSettings() {
@@ -525,7 +750,10 @@ export function UserCenterSessionSettings() {
@@ -550,14 +781,25 @@ export function UserCenterSessionSettings() { data-testid="oem-cloud-password-submit" > - {loggingIn ? "登录中..." : "登录并同步账户"} + {loggingIn + ? t( + "settings.userCenterSession.action.loggingIn", + "登录中...", + ) + : t( + "settings.userCenterSession.login.password.submit", + "登录并同步账户", + )}
) : (
@@ -576,7 +821,10 @@ export function UserCenterSessionSettings() {
@@ -598,14 +849,25 @@ export function UserCenterSessionSettings() { className="self-end rounded-[18px] border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60" data-testid="oem-cloud-code-send" > - {sendingCode ? "发送中..." : "发送验证码"} + {sendingCode + ? t( + "settings.userCenterSession.action.sendingCode", + "发送中...", + ) + : t( + "settings.userCenterSession.action.sendCode", + "发送验证码", + )}
@@ -646,7 +917,15 @@ export function UserCenterSessionSettings() { data-testid="oem-cloud-code-submit" > - {loggingIn ? "登录中..." : "验证并同步账户"} + {loggingIn + ? t( + "settings.userCenterSession.action.loggingIn", + "登录中...", + ) + : t( + "settings.userCenterSession.login.emailCode.submit", + "验证并同步账户", + )}
)} diff --git a/src/components/settings-v2/agent/image-gen/index.test.tsx b/src/components/settings-v2/agent/image-gen/index.test.tsx index 02f27d271..606648ab7 100644 --- a/src/components/settings-v2/agent/image-gen/index.test.tsx +++ b/src/components/settings-v2/agent/image-gen/index.test.tsx @@ -2,9 +2,27 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { mockGetConfig, mockSaveConfig } = vi.hoisted(() => ({ +const { mockGetConfig, mockSaveConfig, mockUseTranslation } = vi.hoisted(() => ({ mockGetConfig: vi.fn(), mockSaveConfig: vi.fn(), + mockUseTranslation: vi.fn((_namespace?: string) => ({ + t: (key: string, options?: unknown) => { + if (typeof options === "string") { + return options; + } + + if (options && typeof options === "object") { + const values = options as Record; + const template = + typeof values.defaultValue === "string" ? values.defaultValue : key; + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String(values[name] ?? ""), + ); + } + + return key; + }, + })), })); vi.mock("@/lib/api/appConfig", () => ({ @@ -12,6 +30,10 @@ vi.mock("@/lib/api/appConfig", () => ({ saveConfig: mockSaveConfig, })); +vi.mock("react-i18next", () => ({ + useTranslation: mockUseTranslation, +})); + vi.mock("@/components/input-kit", () => ({ ModelSelector: ({ providerType, @@ -218,6 +240,7 @@ describe("ImageGenSettings", () => { const container = renderComponent(); await flushEffects(3); + expect(mockUseTranslation).toHaveBeenCalledWith("settings"); expect(container.textContent).toContain("图片服务模型"); expect(container.textContent).toContain("Relay OpenAI"); expect(container.textContent).toContain("gpt-images-2"); diff --git a/src/components/settings-v2/agent/image-gen/index.tsx b/src/components/settings-v2/agent/image-gen/index.tsx index c69908593..47f8d54e4 100644 --- a/src/components/settings-v2/agent/image-gen/index.tsx +++ b/src/components/settings-v2/agent/image-gen/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { AlertCircle, CheckCircle2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig"; import { findConfiguredProviderBySelection, @@ -24,6 +25,7 @@ const DEFAULT_MEDIA_PREFERENCE: MediaGenerationPreference = { }; export function ImageGenSettings() { + const { t } = useTranslation("settings"); const [config, setConfig] = useState(null); const [message, setMessage] = useState<{ type: "success" | "error"; @@ -86,13 +88,19 @@ export function ImageGenSettings() { const providerUnavailableLabel = globalImagePreference.preferredProviderId && !selectedProvider - ? `当前配置不可用:${globalImagePreference.preferredProviderId}` + ? t("settings.mediaGeneration.warning.unavailable", { + id: globalImagePreference.preferredProviderId, + defaultValue: "当前配置不可用:{{id}}", + }) : undefined; const modelUnavailableLabel = globalImagePreference.preferredModelId && !availableModelIds.includes(globalImagePreference.preferredModelId) - ? `当前配置不可用:${globalImagePreference.preferredModelId}` + ? t("settings.mediaGeneration.warning.unavailable", { + id: globalImagePreference.preferredModelId, + defaultValue: "当前配置不可用:{{id}}", + }) : undefined; const showMessage = (type: "success" | "error", text: string) => { @@ -121,10 +129,16 @@ export function ImageGenSettings() { await saveConfig(updatedConfig); setConfig(updatedConfig); setGlobalImagePreference(nextPreference); - showMessage("success", "设置已保存"); + showMessage( + "success", + t("settings.mediaGeneration.message.saved", "设置已保存"), + ); } catch (error) { console.error("保存图片服务配置失败:", error); - showMessage("error", "保存失败"); + showMessage( + "error", + t("settings.mediaGeneration.message.saveFailed", "保存失败"), + ); } }; @@ -190,18 +204,36 @@ export function ImageGenSettings() { ); const providerHint = providersLoading - ? "只展示已声明图片生成能力的 Provider。" + ? t( + "settings.mediaGeneration.image.hint.loading", + "只展示已声明图片生成能力的 Provider。", + ) : imageProviders.length === 0 - ? "当前没有可用图片 Provider;请先在设置 -> AI 服务商中为可出图服务配置模型或自定义模型。" - : "只展示已声明图片生成能力的 Provider;后续接入品牌云端目录时也会复用同一筛选口径。"; + ? t( + "settings.mediaGeneration.image.hint.empty", + "当前没有可用图片 Provider;请先在设置 -> AI 服务商中为可出图服务配置模型或自定义模型。", + ) + : t( + "settings.mediaGeneration.image.hint.ready", + "只展示已声明图片生成能力的 Provider;后续接入品牌云端目录时也会复用同一筛选口径。", + ); return (
({ - mockGetConfig: vi.fn(), - mockSaveConfig: vi.fn(), -})); +const { mockGetConfig, mockSaveConfig, mockUseTranslation } = vi.hoisted( + () => ({ + mockGetConfig: vi.fn(), + mockSaveConfig: vi.fn(), + mockUseTranslation: vi.fn((_namespace?: string) => ({ + t: (key: string, options?: unknown) => { + if (typeof options === "string") { + return options; + } + + if (options && typeof options === "object") { + const values = options as Record; + const template = + typeof values.defaultValue === "string" ? values.defaultValue : key; + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String(values[name] ?? ""), + ); + } + + return key; + }, + })), + }), +); vi.mock("@/lib/api/appConfig", () => ({ getConfig: mockGetConfig, saveConfig: mockSaveConfig, })); +vi.mock("react-i18next", () => ({ + useTranslation: mockUseTranslation, +})); + vi.mock("@/components/input-kit", () => ({ ModelSelector: ({ providerType, @@ -172,6 +196,7 @@ describe("MediaServicesSettings", () => { await flushEffects(); const text = container.textContent ?? ""; + expect(mockUseTranslation).toHaveBeenCalledWith("settings"); expect(text).toContain("服务模型"); expect(text).toContain("话题自动命名助理"); expect(text).toContain("AI 图片话题命名助理"); diff --git a/src/components/settings-v2/agent/media-services/index.tsx b/src/components/settings-v2/agent/media-services/index.tsx index 30f070549..929e83396 100644 --- a/src/components/settings-v2/agent/media-services/index.tsx +++ b/src/components/settings-v2/agent/media-services/index.tsx @@ -1,5 +1,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; +import type { TFunction } from "i18next"; import { AlertCircle, CheckCircle2, Pencil } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -52,70 +54,147 @@ interface ServiceModelSectionDefinition { emptyHint?: string; } -const SERVICE_MODEL_SECTIONS: ServiceModelSectionDefinition[] = [ - { - key: "topic", - title: "话题自动命名助理", - description: "指定用于会话话题自动命名的模型。", - modelHint: "默认使用对话或推理模型;未显式指定时沿用自动选择。", - taskFamilies: ["chat", "reasoning"], - }, - { - key: "generation_topic", - title: "AI 图片话题命名助理", - description: "指定用于图片任务自动命名话题的模型,优先使用视觉理解模型。", - modelHint: - "优先展示视觉理解模型;若当前没有 VLM,会自动回退到通用对话模型。", - taskFamilies: ["vision_understanding", "chat", "reasoning"], - }, - { - key: "translation", - title: "消息内容翻译助理", - description: "指定用于翻译消息内容的模型。", - modelHint: "适合选择稳定的对话或推理模型。", - taskFamilies: ["chat", "reasoning"], - }, - { - key: "history_compress", - title: "会话历史压缩助理", - description: "指定用于压缩会话历史上下文的模型。", - modelHint: "适合选择长上下文、稳定输出的对话模型。", - taskFamilies: ["chat", "reasoning"], - }, - { - key: "agent_meta", - title: "助理信息生成助理", - description: "指定用于生成助理名称、简介与标签等信息的模型。", - modelHint: "适合选择善于总结与命名的通用模型。", - taskFamilies: ["chat", "reasoning"], - }, - { - key: "input_completion", - title: "输入自动补全助理", - description: "控制输入联想、补全面板与快捷能力提示是否启用。", - modelHint: "当前主链只消费启停开关,不再展示未接入执行面的模型选择。", - taskFamilies: ["chat", "reasoning"], - supportsModelSelection: false, - allowDisable: true, - }, - { - key: "prompt_rewrite", - title: "提示词重写助理", - description: "指定用于重写与润色提示词的模型。", - modelHint: "关闭后,将直接使用原始输入,不再自动重写提示词。", - taskFamilies: ["chat", "reasoning"], - allowDisable: true, - }, - { - key: "resource_prompt_rewrite", - title: "项目资料提词重写助理", - description: "指定用于基于项目资料上下文改写提问的模型。", - modelHint: "关闭后,项目资料提问不会再自动补全上下文重写。", - taskFamilies: ["chat", "reasoning"], - allowDisable: true, - allowCustomPrompt: true, - }, -]; +type MediaServicesTranslate = TFunction<"settings", undefined>; + +function createServiceModelSections( + t: MediaServicesTranslate, +): ServiceModelSectionDefinition[] { + return [ + { + key: "topic", + title: t( + "settings.mediaServices.sections.topic.title", + "话题自动命名助理", + ), + description: t( + "settings.mediaServices.sections.topic.description", + "指定用于会话话题自动命名的模型。", + ), + modelHint: t( + "settings.mediaServices.sections.topic.modelHint", + "默认使用对话或推理模型;未显式指定时沿用自动选择。", + ), + taskFamilies: ["chat", "reasoning"], + }, + { + key: "generation_topic", + title: t( + "settings.mediaServices.sections.generationTopic.title", + "AI 图片话题命名助理", + ), + description: t( + "settings.mediaServices.sections.generationTopic.description", + "指定用于图片任务自动命名话题的模型,优先使用视觉理解模型。", + ), + modelHint: t( + "settings.mediaServices.sections.generationTopic.modelHint", + "优先展示视觉理解模型;若当前没有 VLM,会自动回退到通用对话模型。", + ), + taskFamilies: ["vision_understanding", "chat", "reasoning"], + }, + { + key: "translation", + title: t( + "settings.mediaServices.sections.translation.title", + "消息内容翻译助理", + ), + description: t( + "settings.mediaServices.sections.translation.description", + "指定用于翻译消息内容的模型。", + ), + modelHint: t( + "settings.mediaServices.sections.translation.modelHint", + "适合选择稳定的对话或推理模型。", + ), + taskFamilies: ["chat", "reasoning"], + }, + { + key: "history_compress", + title: t( + "settings.mediaServices.sections.historyCompress.title", + "会话历史压缩助理", + ), + description: t( + "settings.mediaServices.sections.historyCompress.description", + "指定用于压缩会话历史上下文的模型。", + ), + modelHint: t( + "settings.mediaServices.sections.historyCompress.modelHint", + "适合选择长上下文、稳定输出的对话模型。", + ), + taskFamilies: ["chat", "reasoning"], + }, + { + key: "agent_meta", + title: t( + "settings.mediaServices.sections.agentMeta.title", + "助理信息生成助理", + ), + description: t( + "settings.mediaServices.sections.agentMeta.description", + "指定用于生成助理名称、简介与标签等信息的模型。", + ), + modelHint: t( + "settings.mediaServices.sections.agentMeta.modelHint", + "适合选择善于总结与命名的通用模型。", + ), + taskFamilies: ["chat", "reasoning"], + }, + { + key: "input_completion", + title: t( + "settings.mediaServices.sections.inputCompletion.title", + "输入自动补全助理", + ), + description: t( + "settings.mediaServices.sections.inputCompletion.description", + "控制输入联想、补全面板与快捷能力提示是否启用。", + ), + modelHint: t( + "settings.mediaServices.sections.inputCompletion.modelHint", + "当前主链只消费启停开关,不再展示未接入执行面的模型选择。", + ), + taskFamilies: ["chat", "reasoning"], + supportsModelSelection: false, + allowDisable: true, + }, + { + key: "prompt_rewrite", + title: t( + "settings.mediaServices.sections.promptRewrite.title", + "提示词重写助理", + ), + description: t( + "settings.mediaServices.sections.promptRewrite.description", + "指定用于重写与润色提示词的模型。", + ), + modelHint: t( + "settings.mediaServices.sections.promptRewrite.modelHint", + "关闭后,将直接使用原始输入,不再自动重写提示词。", + ), + taskFamilies: ["chat", "reasoning"], + allowDisable: true, + }, + { + key: "resource_prompt_rewrite", + title: t( + "settings.mediaServices.sections.resourcePromptRewrite.title", + "项目资料提词重写助理", + ), + description: t( + "settings.mediaServices.sections.resourcePromptRewrite.description", + "指定用于基于项目资料上下文改写提问的模型。", + ), + modelHint: t( + "settings.mediaServices.sections.resourcePromptRewrite.modelHint", + "关闭后,项目资料提问不会再自动补全上下文重写。", + ), + taskFamilies: ["chat", "reasoning"], + allowDisable: true, + allowCustomPrompt: true, + }, + ]; +} function getSectionPreference( config: Config | null, @@ -131,12 +210,14 @@ function SettingCard({ description, children, headerExtra, + tipAriaLabel, dimmed = false, }: { title: string; description: string; children: ReactNode; headerExtra?: ReactNode; + tipAriaLabel: string; dimmed?: boolean; }) { return ( @@ -152,7 +233,7 @@ function SettingCard({ {title} @@ -187,6 +268,7 @@ function SettingRow({ } export function MediaServicesSettings() { + const { t } = useTranslation("settings"); const [config, setConfig] = useState(null); const [message, setMessage] = useState<{ type: "success" | "error"; @@ -247,10 +329,16 @@ export function MediaServicesSettings() { const nextConfig = updater(config); await saveConfig(nextConfig); setConfig(nextConfig); - showMessage("success", "设置已保存"); + showMessage( + "success", + t("settings.mediaServices.message.saved", "设置已保存"), + ); } catch (error) { console.error("保存服务模型配置失败:", error); - showMessage("error", "保存失败"); + showMessage( + "error", + t("settings.mediaServices.message.saveFailed", "保存失败"), + ); } }; @@ -308,8 +396,13 @@ export function MediaServicesSettings() { updateImageGenConfig({ default_count: nextCount }); }; + const serviceModelSections = useMemo( + () => createServiceModelSections(t), + [t], + ); + const sectionViews = useMemo(() => { - return SERVICE_MODEL_SECTIONS.map((section) => { + return serviceModelSections.map((section) => { const preference = getSectionPreference(config, section.key); return { @@ -318,23 +411,32 @@ export function MediaServicesSettings() { disabled: Boolean(section.allowDisable && preference.enabled === false), }; }); - }, [config]); + }, [config, serviceModelSections]); return (

- 服务模型 + {t("settings.mediaServices.title", "服务模型")}

- 只保留当前已接入调用链的服务模型默认项,整页统一复用同一套模型选择组件。 + {t( + "settings.mediaServices.description", + "只保留当前已接入调用链的服务模型默认项,整页统一复用同一套模型选择组件。", + )}

@@ -350,6 +452,10 @@ export function MediaServicesSettings() { key={section.key} title={section.title} description={section.description} + tipAriaLabel={t("settings.mediaServices.card.tipAria", { + title: section.title, + defaultValue: "{{title}}说明", + })} dimmed={disabled} headerExtra={ section.allowDisable ? ( @@ -367,20 +473,38 @@ export function MediaServicesSettings() { } > {section.supportsModelSelection === false ? ( - +
- 当前输入补全链只消费启停开关,避免继续暴露未接入执行面的模型选择。 + {t( + "settings.mediaServices.common.inputCompletion.currentBehavior", + "当前输入补全链只消费启停开关,避免继续暴露未接入执行面的模型选择。", + )}
) : ( { @@ -412,14 +536,23 @@ export function MediaServicesSettings() { {section.allowCustomPrompt ? ( {promptVisible ? (