From 02ed1cd5b9316c5b7a6328f8a192a955111604c7 Mon Sep 17 00:00:00 2001 From: coso Date: Fri, 27 Mar 2026 21:46:17 +0800 Subject: [PATCH] feat: finalize latest v0.97.0 release payload --- .github/workflows/release.yml | 4 +- .gitignore | 2 + RELEASE_NOTES.md | 15 +- docs/test/README.md | 2 +- docs/test/harness-evals.md | 25 +- .../evidence-links.json | 88 +++++- .../replay/minimal-pending-request/input.json | 88 +++++- scripts/harness-analysis-brief.mjs | 51 ++++ scripts/harness-eval-history-record.mjs | 41 ++- scripts/harness-eval-runner.mjs | 140 ++++++++++ scripts/harness-eval-trend-report.mjs | 43 +++ scripts/lib/generated-slop-report-core.mjs | 188 +++++++++++++ .../lib/generated-slop-report-core.test.ts | 68 +++++ .../lib/harness-eval-history-record.test.ts | 81 +++++- .../lib/harness-review-decision-evals.test.ts | 76 ++++++ scripts/report-generated-slop.mjs | 26 +- .../runtime_analysis_handoff_service.rs | 62 +++++ .../services/runtime_evidence_pack_service.rs | 250 +++++++++++++++++- .../services/runtime_replay_case_service.rs | 19 +- .../agent/chat/AgentChatWorkspace.tsx | 1 + .../Inputbar/components/A2UIFloatingForm.tsx | 10 + .../components/InputbarOverlayShell.test.tsx | 48 ++++ .../components/InputbarOverlayShell.tsx | 14 +- .../Inputbar/hooks/useInputbarController.ts | 6 +- .../Inputbar/hooks/useStickyA2UIForm.test.tsx | 5 + .../Inputbar/hooks/useStickyA2UIForm.ts | 15 ++ .../chat/components/Inputbar/index.test.tsx | 124 +++++++-- .../agent/chat/components/Inputbar/index.tsx | 2 + .../agent/chat/utils/threadReliabilityView.ts | 4 +- .../chat/workspace/WorkspaceMainArea.tsx | 1 + .../agent/chat/workspace/WorkspaceStyles.tsx | 6 +- .../generalCanvasPreviewState.test.ts | 29 ++ .../workspace/generalCanvasPreviewState.ts | 14 + .../useWorkspaceA2UIRuntime.test.tsx | 180 +++++++++++++ .../chat/workspace/useWorkspaceA2UIRuntime.ts | 112 ++++++-- .../useWorkspaceCanvasLayoutRuntime.test.tsx | 26 ++ .../useWorkspaceCanvasLayoutRuntime.ts | 33 +++ .../useWorkspaceCanvasPreviewPresentation.tsx | 5 +- .../agent/chat/workspace/workbenchPreview.tsx | 6 +- .../LayoutTransition.test.tsx | 40 ++- .../LayoutTransition/LayoutTransition.tsx | 22 ++ src/lib/tauri-mock/core.ts | 30 ++- 42 files changed, 1913 insertions(+), 89 deletions(-) create mode 100644 src/components/agent/chat/workspace/generalCanvasPreviewState.test.ts create mode 100644 src/components/agent/chat/workspace/generalCanvasPreviewState.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.test.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c76eadf5..c0edca8a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: - platform: macos-latest target: aarch64-apple-darwin name: macOS-arm64 - - platform: macos-13 + - platform: macos-15-intel target: x86_64-apple-darwin name: macOS-x64 - platform: windows-2022 @@ -182,7 +182,7 @@ jobs: --notes-file "$NOTES_FILE" || gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null - name: Install frontend dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --no-optional - name: Normalize updater signing key if: steps.updater_mode.outputs.enabled == 'true' diff --git a/.gitignore b/.gitignore index 1a8142dc1..7ca90c1e3 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,8 @@ docs/tech/ # Issues tracking (internal use only) .issues/ +.lime/harness/ +.lime/artifacts/ # 开发环境 **/.local/ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4bf068252..609eb0724 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,8 +4,10 @@ - **Harness 导出链路补齐四类制品**:处理工作台与统一运行时接通 `handoff bundle`、`evidence pack`、`replay case`、`analysis handoff` 导出,支持 pending request 重放、外部诊断交接和问题复盘闭环 - **Replay Eval / Nightly 骨架进入 current 主线**:仓库新增 `docs/test/harness-evals.*`、固定 replay fixture、`harness-eval-runner`、`harness-eval-trend-report`、`harness-replay-promote` 与 nightly workflow,把 replay 样本、grader 合同和趋势摘要收口到统一入口 +- **Inputbar A2UI 浮层与工作台预览继续收口**:输入栏补齐 legacy 问卷折叠、结构化表单浮层、通用画布预览和 workbench stacked/split 行为,避免 A2UI 表单与画布入口互相抢占 - **Service Skill 到 Automation 的落地链路更完整**:Home Shell、Workspace 与自动化设置页现在可以直接从服务技能创建本地 automation job,保留技能与任务关联,并回填 workspace/content 上下文 - **Browser Runtime 站点采集继续收口**:站点采集工作台补齐推荐适配器、资料自动选择、目录状态展示和结果回写当前内容/项目的主链,优先复用已连接 Chrome 的真实登录态 +- **Release 流程补齐当前平台约束**:macOS x64 release runner 切换到当前 Intel 标签,并在依赖安装阶段跳过 `canvas` 这类可选原生包,降低最近 release 在 macOS/Windows 上的噪音失败与长时间等待 - **Agent Chat 提交流程与处理面板继续瘦身**:slash skill、selected team、session/runtime steady-state 与 Harness 状态面板的交互拆分重组,关键回归测试同步补齐 ### ⚠️ 兼容性说明 @@ -24,13 +26,12 @@ ### 🧪 测试 - 发布前执行:`npm run verify:app-version` -- 发布前执行:`npm run lint` -- 发布前执行:`npm run test:contracts` -- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml --all` -- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.97.0 cargo test --manifest-path src-tauri/Cargo.toml` -- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.97.0 cargo clippy --manifest-path src-tauri/Cargo.toml` -- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.97.0 npm run verify:gui-smoke -- --timeout-ms 480000` -- 格式状态:已执行 `cargo fmt --manifest-path src-tauri/Cargo.toml --all` +- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml` +- 发布前执行:`npm run build` +- 发布前执行:`npm run verify:local` +- 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml` +- 验证结果:`verify:local` 已覆盖 `npm run lint`、`npm run typecheck`、`npm test`、`npm run test:contracts`、`cargo test --manifest-path src-tauri/Cargo.toml` 与 `npm run verify:gui-smoke` +- 备注:`cargo clippy` 当前通过,但仍保留 3 条既有 warning,未在本次同版发布中额外扩范围消除 ### 📝 文档 diff --git a/docs/test/README.md b/docs/test/README.md index 8908fef86..6858c8b21 100644 --- a/docs/test/README.md +++ b/docs/test/README.md @@ -131,7 +131,7 @@ npm run harness:eval:trend ### 记录 Harness eval 历史窗口 ```bash -node scripts/harness-eval-runner.mjs --record-history-dir "./artifacts/history" --history-retain 30 +npm run harness:eval:history:record ``` ### 运行 Harness cleanup / slop 报告 diff --git a/docs/test/harness-evals.md b/docs/test/harness-evals.md index 2e260691d..8ebdabdc0 100644 --- a/docs/test/harness-evals.md +++ b/docs/test/harness-evals.md @@ -88,6 +88,7 @@ Lime 当前不直接把“真实模型重放平台”一次做完,而是先固 2. 解析固定 fixture 与工作区自动发现 case 3. 校验 replay case 最小四件套与关键 JSON 字段 4. 输出统一 JSON / Markdown 摘要,并聚合 `suite tag / failure mode / review decision status / risk level` 分布 +5. 继续聚合 `observabilitySignals`,把 `requestTelemetry / artifactValidator / browser/gui smoke` 的证据缺口也纳入 trend 与 cleanup 当前它**不直接执行真实模型重放**,而是先把“样本是否可评估、摘要是否可归档”工程化。 @@ -172,6 +173,13 @@ node scripts/harness-replay-promote.mjs \ 从 `2026-03-27` 起,这个历史窗口不再依赖 workflow 里的 `cp / ls / xargs` 拼接,而是直接由 `scripts/harness-eval-runner.mjs --record-history-dir` 负责写入和裁剪,保证本地与 nightly 走同一条跨平台主链。 +同一天新增的 `scripts/harness-eval-history-record.mjs` 又把这条主链向前推了一步: + +- 本地可以一次命令完成 `summary -> history -> trend -> cleanup` +- 本地默认把 history window 写到 `./.lime/harness/history`,并把 trend / cleanup 报表写到 `./.lime/harness/reports` +- `cleanup report` 在未显式传入 `--trend-input / --trend-history-dir` 时,会优先自动发现 `./.lime/harness/history`,再回退到 `./artifacts/history` +- 修复后的趋势积累不再只属于 nightly,开发者本地也能拿到同口径的历史窗口 + ## 常用命令 ```bash @@ -195,14 +203,17 @@ node scripts/harness-eval-runner.mjs \ --output-json "./tmp/harness-eval-summary.json" \ --output-markdown "./tmp/harness-eval-summary.md" -# 记录本地 history window,供 trend / cleanup 复用 +# 推荐的本地一体化入口:记录 history,并同时刷新 trend / cleanup +npm run harness:eval:history:record + +# 如需显式指定本地 history window node scripts/harness-eval-runner.mjs \ - --record-history-dir "./artifacts/history" \ + --record-history-dir "./.lime/harness/history" \ --history-retain 30 # 从历史 summary 目录生成趋势报告 node scripts/harness-eval-trend-report.mjs \ - --history-dir "./artifacts/history" \ + --history-dir "./.lime/harness/history" \ --output-json "./tmp/harness-eval-trend.json" \ --output-markdown "./tmp/harness-eval-trend.md" ``` @@ -218,6 +229,7 @@ Runner 摘要至少回答下面这些问题: - 哪些 case 属于什么 suite tag / failure mode - 哪些 case 默认需要人工复核 - 哪些 case 已经记录人工审核状态与风险等级 +- 哪些 case 还缺 `observabilitySummary` 或仍处于 `requestTelemetry:unlinked`、`artifactValidator:known_gap` 等证据缺口 - 工作区 replay 是否已经开始形成增量样本 如果摘要回答不了这些问题,就说明 runner 还不算进入 current 主链。 @@ -228,6 +240,7 @@ Trend 报告至少还要回答: - 哪些 suite 在 latest 里变差了 - 哪些 failure mode / suite tag 在 latest 里增长或退化了 - 哪些人工审核状态 / 风险等级在 latest 里新增、减少或发生迁移 +- 哪些 observability signal gap 在 latest 里增加、减少或仍然停留在 current 样本中 - 当前只有 trend seed,还是已经开始形成真正的历史窗口 ## 与其他事实源的关系 @@ -250,9 +263,9 @@ Trend 报告至少还要回答: `P3-6` 做完之后,下一刀优先级建议固定为: -1. 把 cleanup report 接入更稳定的历史窗口,避免长期停留在 trend seed -2. 继续补 observability 证据字段,让 grader 和 cleanup report 都能消费更多 request / timeline / artifact 关联 -3. 逐步提高 repo current 样本质量,而不是只增加数量 +1. 继续补 observability 证据字段,让 grader、analysis handoff 和 cleanup report 都能消费更多 `request / timeline / artifact` 关联 +2. 逐步提高 repo current 样本质量,而不是只增加数量 +3. 让 review-decision / 风险等级 / replay promotion 继续回挂到同一条趋势主线 4. 再考虑是否引入真实模型执行或 transcript grading ## 非目标 diff --git a/docs/test/harness-fixtures/replay/minimal-pending-request/evidence-links.json b/docs/test/harness-fixtures/replay/minimal-pending-request/evidence-links.json index 83007b1b8..f97b83e5d 100644 --- a/docs/test/harness-fixtures/replay/minimal-pending-request/evidence-links.json +++ b/docs/test/harness-fixtures/replay/minimal-pending-request/evidence-links.json @@ -21,7 +21,9 @@ "relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence", "absoluteRoot": "/workspace/lime/.lime/harness/sessions/fixture-session-minimal-pending-request/evidence", "knownGaps": [ - "当前 fixture 不包含真实浏览器快照,只验证 replay 样本结构与 grader 约定。" + "当前 Lime 虽然已有全局 RequestLog / token / retry / duration 遥测,但 RequestLog 尚未携带 session/thread/turn 关联键,Evidence Pack 只能导出 request telemetry coverage gap,不能给出精确的会话级摘要。", + "当前 Evidence Pack 尚未纳入 ArtifactDocument validator outcome 与修复问题摘要。", + "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。" ], "artifacts": [ { @@ -36,6 +38,90 @@ } ] }, + "observabilitySummary": { + "schemaVersion": "v1", + "correlation": { + "correlationKeys": [ + "session_id", + "thread_id", + "turn_id", + "pending_request_id", + "queued_turn_id", + "subagent_session_id" + ], + "sessionId": "fixture-session-minimal-pending-request", + "threadId": "fixture-thread-minimal-pending-request", + "activeTurnId": "turn-fixture-001", + "pendingRequestIds": ["approval-fixture-001"], + "queuedTurnIds": [], + "subagentSessionIds": [] + }, + "counts": { + "turnCount": 1, + "itemCount": 2, + "pendingRequestCount": 1, + "queuedTurnCount": 0, + "warningCount": 0, + "failedToolCallCount": 0, + "failedCommandCount": 0, + "subagentCount": 0, + "recentArtifactCount": 2 + }, + "latest": { + "warning": null, + "failedTool": null, + "failedCommand": null + }, + "signalCoverage": [ + { + "signal": "correlation", + "status": "exported", + "source": "runtime thread identity", + "detail": "当前证据包已导出 session/thread/turn/pending request/subagent 关联键。" + }, + { + "signal": "timeline", + "status": "exported", + "source": "timeline.json", + "detail": "当前证据包已导出最近 turn 与 item 时间线。" + }, + { + "signal": "warnings", + "status": "exported", + "source": "thread.diagnostics", + "detail": "当前线程没有 diagnostics,但 warning 通道已保留在导出结构中。" + }, + { + "signal": "requestTelemetry", + "status": "unlinked", + "source": "lime_infra.telemetry", + "detail": "Lime 已有 workspace 级 request telemetry,但当前 RequestLog 还未携带 session/thread/turn 元数据。" + }, + { + "signal": "artifactValidator", + "status": "known_gap", + "source": "artifact_document_validator", + "detail": "Artifact validator outcome 尚未回挂到当前 evidence pack。" + }, + { + "signal": "browserVerification", + "status": "known_gap", + "source": "browser runtime", + "detail": "浏览器截图、DOM 快照和交互验证结果尚未接入 evidence pack。" + }, + { + "signal": "guiSmoke", + "status": "known_gap", + "source": "verify:gui-smoke", + "detail": "GUI smoke 结果尚未作为结构化证据回挂当前会话。" + } + ], + "knownGaps": [ + "当前 Lime 虽然已有全局 RequestLog / token / retry / duration 遥测,但 RequestLog 尚未携带 session/thread/turn 关联键,Evidence Pack 只能导出 request telemetry coverage gap,不能给出精确的会话级摘要。", + "当前 Evidence Pack 尚未纳入 ArtifactDocument validator outcome 与修复问题摘要。", + "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。" + ] + }, "recentArtifacts": [ "docs/tech/harness/implementation-blueprint.md", ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md" diff --git a/docs/test/harness-fixtures/replay/minimal-pending-request/input.json b/docs/test/harness-fixtures/replay/minimal-pending-request/input.json index 7792f35ca..ca85d0bde 100644 --- a/docs/test/harness-fixtures/replay/minimal-pending-request/input.json +++ b/docs/test/harness-fixtures/replay/minimal-pending-request/input.json @@ -86,6 +86,90 @@ }, "incidents": [] }, + "observability": { + "schemaVersion": "v1", + "correlation": { + "correlationKeys": [ + "session_id", + "thread_id", + "turn_id", + "pending_request_id", + "queued_turn_id", + "subagent_session_id" + ], + "sessionId": "fixture-session-minimal-pending-request", + "threadId": "fixture-thread-minimal-pending-request", + "activeTurnId": "turn-fixture-001", + "pendingRequestIds": ["approval-fixture-001"], + "queuedTurnIds": [], + "subagentSessionIds": [] + }, + "counts": { + "turnCount": 1, + "itemCount": 2, + "pendingRequestCount": 1, + "queuedTurnCount": 0, + "warningCount": 0, + "failedToolCallCount": 0, + "failedCommandCount": 0, + "subagentCount": 0, + "recentArtifactCount": 2 + }, + "latest": { + "warning": null, + "failedTool": null, + "failedCommand": null + }, + "signalCoverage": [ + { + "signal": "correlation", + "status": "exported", + "source": "runtime thread identity", + "detail": "当前证据包已导出 session/thread/turn/pending request/subagent 关联键。" + }, + { + "signal": "timeline", + "status": "exported", + "source": "timeline.json", + "detail": "当前证据包已导出最近 turn 与 item 时间线。" + }, + { + "signal": "warnings", + "status": "exported", + "source": "thread.diagnostics", + "detail": "当前线程没有 diagnostics,但 warning 通道已保留在导出结构中。" + }, + { + "signal": "requestTelemetry", + "status": "unlinked", + "source": "lime_infra.telemetry", + "detail": "Lime 已有 workspace 级 request telemetry,但当前 RequestLog 还未携带 session/thread/turn 元数据。" + }, + { + "signal": "artifactValidator", + "status": "known_gap", + "source": "artifact_document_validator", + "detail": "Artifact validator outcome 尚未回挂到当前 evidence pack。" + }, + { + "signal": "browserVerification", + "status": "known_gap", + "source": "browser runtime", + "detail": "浏览器截图、DOM 快照和交互验证结果尚未接入 evidence pack。" + }, + { + "signal": "guiSmoke", + "status": "known_gap", + "source": "verify:gui-smoke", + "detail": "GUI smoke 结果尚未作为结构化证据回挂当前会话。" + } + ], + "knownGaps": [ + "当前 Lime 虽然已有全局 RequestLog / token / retry / duration 遥测,但 RequestLog 尚未携带 session/thread/turn 关联键,Evidence Pack 只能导出 request telemetry coverage gap,不能给出精确的会话级摘要。", + "当前 Evidence Pack 尚未纳入 ArtifactDocument validator outcome 与修复问题摘要。", + "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。" + ] + }, "linkedArtifacts": { "handoffBundle": { "relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff", @@ -95,7 +179,9 @@ "relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence", "artifactCount": 4, "knownGaps": [ - "当前 fixture 不包含真实浏览器快照,只验证 replay 样本结构与 grader 约定。" + "当前 Lime 虽然已有全局 RequestLog / token / retry / duration 遥测,但 RequestLog 尚未携带 session/thread/turn 关联键,Evidence Pack 只能导出 request telemetry coverage gap,不能给出精确的会话级摘要。", + "当前 Evidence Pack 尚未纳入 ArtifactDocument validator outcome 与修复问题摘要。", + "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。" ] } } diff --git a/scripts/harness-analysis-brief.mjs b/scripts/harness-analysis-brief.mjs index 6976dd583..008899a69 100644 --- a/scripts/harness-analysis-brief.mjs +++ b/scripts/harness-analysis-brief.mjs @@ -374,6 +374,39 @@ function buildAnalysisContext({ title, workspaceRoot, }) { + const rawObservabilitySummary = + inputPayload?.observability ?? evidenceJson?.observabilitySummary ?? null; + const observabilitySummary = sanitizeValue( + rawObservabilitySummary ?? {}, + workspaceRoot, + options.sanitizedWorkspaceRoot, + ); + const observabilityCorrelationKeys = Array.isArray( + observabilitySummary?.correlation?.correlationKeys, + ) + ? observabilitySummary.correlation.correlationKeys + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean) + : []; + const observabilityGapSignals = Array.isArray( + observabilitySummary?.signalCoverage, + ) + ? observabilitySummary.signalCoverage + .map((entry) => { + if (!entry || typeof entry !== "object") { + return ""; + } + const signal = + typeof entry.signal === "string" ? entry.signal.trim() : ""; + const status = + typeof entry.status === "string" ? entry.status.trim() : ""; + if (!signal || !status || status === "exported") { + return ""; + } + return `${signal} (${status})`; + }) + .filter(Boolean) + : []; const sanitizedInput = sanitizeValue( { session: inputPayload?.session ?? {}, @@ -505,6 +538,11 @@ function buildAnalysisContext({ ), ), }, + observability: { + summary: observabilitySummary, + correlationKeys: observabilityCorrelationKeys, + gapSignals: observabilityGapSignals, + }, readingOrder: buildReadingOrder(handoffArtifacts, evidenceArtifacts), externalAnalysisContract: buildExternalAnalysisPromptContract(), humanReviewChecklist: buildHumanReviewChecklist(inputPayload, expectedPayload), @@ -555,6 +593,19 @@ function buildAnalysisBrief(context) { `- pending request:${context.summary.pendingRequestCount}`, `- queued turn:${context.summary.queuedTurnCount}`, "", + "## 证据关联与可观测覆盖", + "", + `- 关联键:${ + context.observability.correlationKeys.length > 0 + ? context.observability.correlationKeys.join(", ") + : "无" + }`, + `- 当前缺口:${ + context.observability.gapSignals.length > 0 + ? context.observability.gapSignals.join(", ") + : "无" + }`, + "", "## 推荐读取顺序", "", ...context.readingOrder.map((entry, index) => `${index + 1}. ${entry}`), diff --git a/scripts/harness-eval-history-record.mjs b/scripts/harness-eval-history-record.mjs index 3546651e1..6261da8f6 100644 --- a/scripts/harness-eval-history-record.mjs +++ b/scripts/harness-eval-history-record.mjs @@ -16,7 +16,7 @@ function parseArgs(argv) { cleanupMarkdown: "", format: "text", help: false, - historyDir: "./artifacts/history", + historyDir: "./.lime/harness/history", outputJson: "", retain: 30, skipCleanup: false, @@ -108,10 +108,10 @@ Lime Harness Eval History Record 用法: node scripts/harness-eval-history-record.mjs node scripts/harness-eval-history-record.mjs --workspace-root "/path/to/workspace" - node scripts/harness-eval-history-record.mjs --history-dir "./artifacts/history" --output-json "./tmp/harness-history-record.json" + node scripts/harness-eval-history-record.mjs --history-dir "./.lime/harness/history" --output-json "./tmp/harness-history-record.json" 选项: - --history-dir PATH summary 历史目录,默认 ./artifacts/history + --history-dir PATH summary 历史目录,默认 ./.lime/harness/history --workspace-root PATH 生成当前 summary 时使用的工作区根目录 --retain N 历史窗口保留数量,默认 30 --trend-json PATH trend JSON 输出路径 @@ -173,6 +173,29 @@ function writeJsonFile(filePath, payload) { fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); } +function writeUniqueHistorySummary(historyDir, payload) { + ensureParentDirectory(historyDir); + const json = `${JSON.stringify(payload, null, 2)}\n`; + const baseName = `${timestampForFilename()}-harness-eval-summary`; + + for (let index = 0; index < 1000; index += 1) { + const suffix = index === 0 ? "" : `-${index}`; + const filePath = path.join(historyDir, `${baseName}${suffix}.json`); + + try { + fs.writeFileSync(filePath, json, { encoding: "utf8", flag: "wx" }); + return filePath; + } catch (error) { + if (error?.code === "EEXIST") { + continue; + } + throw error; + } + } + + throw new Error(`无法在历史目录中创建唯一 summary 文件: ${historyDir}`); +} + function trimHistoryFiles(historyDir, retain) { const files = collectHistoryFiles(historyDir).sort((left, right) => right.localeCompare(left), @@ -185,7 +208,11 @@ function trimHistoryFiles(historyDir, retain) { } function buildDefaultArtifactPaths(historyDir) { - const artifactsRoot = path.dirname(historyDir); + const historyParent = path.dirname(historyDir); + const artifactsRoot = + path.basename(historyDir) === "history" + ? path.join(historyParent, "reports") + : historyParent; return { trendJson: path.join(artifactsRoot, "harness-eval-trend.json"), trendMarkdown: path.join(artifactsRoot, "harness-eval-trend.md"), @@ -238,11 +265,7 @@ function runHistoryRecordCli() { fs.mkdirSync(historyDir, { recursive: true }); const summary = buildCurrentSummary(repoRoot, options.workspaceRoot); - const summaryFilePath = path.join( - historyDir, - `${timestampForFilename()}-harness-eval-summary.json`, - ); - writeJsonFile(summaryFilePath, summary); + const summaryFilePath = writeUniqueHistorySummary(historyDir, summary); const trimmedPaths = trimHistoryFiles(historyDir, options.retain); const historyCount = collectHistoryFiles(historyDir).length; const defaults = buildDefaultArtifactPaths(historyDir); diff --git a/scripts/harness-eval-runner.mjs b/scripts/harness-eval-runner.mjs index ceb42b2ad..fdd0aaccd 100644 --- a/scripts/harness-eval-runner.mjs +++ b/scripts/harness-eval-runner.mjs @@ -388,6 +388,79 @@ function loadReviewDecisionForCase(caseDir, inlineReviewDecision) { }; } +function normalizeObservabilityCoverage(inputPayload, evidencePayload) { + const inlineSummary = + inputPayload?.observability && + typeof inputPayload.observability === "object" && + !Array.isArray(inputPayload.observability) + ? inputPayload.observability + : evidencePayload?.observabilitySummary && + typeof evidencePayload.observabilitySummary === "object" && + !Array.isArray(evidencePayload.observabilitySummary) + ? evidencePayload.observabilitySummary + : null; + + if (!inlineSummary) { + return [ + { + signal: "observabilitySummary", + status: "missing", + }, + ]; + } + + const signalCoverage = Array.isArray(inlineSummary.signalCoverage) + ? inlineSummary.signalCoverage + : []; + + if (signalCoverage.length === 0) { + return [ + { + signal: "observabilitySummary", + status: "missing_signal_coverage", + }, + ]; + } + + const seen = new Set(); + const coverage = []; + + for (const entry of signalCoverage) { + if (!entry || typeof entry !== "object") { + continue; + } + + const signal = normalizeOptionalString(entry.signal ?? entry.name); + const status = normalizeOptionalString(entry.status); + if (!signal || !status) { + continue; + } + + const fingerprint = `${signal}:${status}`; + if (seen.has(fingerprint)) { + continue; + } + seen.add(fingerprint); + coverage.push({ + signal, + status, + source: normalizeOptionalString(entry.source), + detail: normalizeOptionalString(entry.detail), + }); + } + + if (coverage.length === 0) { + return [ + { + signal: "observabilitySummary", + status: "missing_signal_coverage", + }, + ]; + } + + return coverage; +} + function validateCaseDirectory(caseDir, caseConfig, defaults, context) { const requiredArtifacts = normalizeStringList( caseConfig.requiredArtifacts ?? defaults.requiredArtifacts, @@ -468,6 +541,16 @@ function validateCaseDirectory(caseDir, caseConfig, defaults, context) { ); reviewDecision = reviewDecisionResult.reviewDecision; issues.push(...reviewDecisionResult.issues); + const observabilityCoverage = normalizeObservabilityCoverage( + inputPayload, + evidencePayload, + ); + const observabilitySignals = observabilityCoverage.map( + (entry) => `${entry.signal}:${entry.status}`, + ); + const observabilityGapCount = observabilityCoverage.filter( + (entry) => entry.status !== "exported", + ).length; const pendingRequestCount = Array.isArray( inputPayload?.runtimeContext?.pendingRequests, @@ -520,6 +603,9 @@ function validateCaseDirectory(caseDir, caseConfig, defaults, context) { reviewRiskLevel: reviewDecision?.riskLevel ?? "", reviewHumanReviewer: reviewDecision?.humanReviewer ?? "", reviewReviewedAt: reviewDecision?.reviewedAt ?? "", + observabilityCoverage, + observabilitySignals, + observabilityGapCount, preferredMode, status: issues.length === 0 ? "ready" : "invalid", issues, @@ -584,6 +670,11 @@ function expandSuiteCases(suiteConfig, defaults, repoRoot, workspaceRoot) { reviewRiskLevel: "", reviewHumanReviewer: "", reviewReviewedAt: "", + observabilityCoverage: [ + { signal: "observabilitySummary", status: "missing" }, + ], + observabilitySignals: ["observabilitySummary:missing"], + observabilityGapCount: 1, preferredMode: "", status: "invalid", issues: [ @@ -633,6 +724,9 @@ function expandSuiteCases(suiteConfig, defaults, repoRoot, workspaceRoot) { reviewRiskLevel: "", reviewHumanReviewer: "", reviewReviewedAt: "", + observabilityCoverage: [{ signal: "observabilitySummary", status: "missing" }], + observabilitySignals: ["observabilitySummary:missing"], + observabilityGapCount: 1, preferredMode: "", status: "invalid", issues: [`不支持的 case source: ${source || "(empty)"}`], @@ -676,6 +770,9 @@ function buildSummary(manifest, suites, options) { const recordedReviewDecisionCases = allCases.filter( (entry) => entry.reviewDecisionRecorded, ); + const observabilityGapCases = allCases.filter( + (entry) => entry.observabilityGapCount > 0, + ); return { manifestVersion: String(manifest.manifestVersion ?? "unknown"), @@ -692,6 +789,7 @@ function buildSummary(manifest, suites, options) { needsHumanReviewCount: reviewCases.length, pendingRequestCaseCount: pendingCases.length, reviewDecisionRecordedCount: recordedReviewDecisionCases.length, + observabilityGapCaseCount: observabilityGapCases.length, }, breakdowns: { suiteTags: aggregateCaseBreakdown(allCases, (entry) => entry.tags), @@ -705,6 +803,10 @@ function buildSummary(manifest, suites, options) { reviewRiskLevels: aggregateCaseBreakdown(allCases, (entry) => entry.reviewRiskLevel ? [entry.reviewRiskLevel] : [], ), + observabilitySignals: aggregateCaseBreakdown( + allCases, + (entry) => entry.observabilitySignals, + ), }, suites, }; @@ -721,6 +823,7 @@ function renderText(summary) { `[harness-eval] pending-request cases: ${summary.totals.pendingRequestCaseCount}`, `[harness-eval] needs-review cases : ${summary.totals.needsHumanReviewCount}`, `[harness-eval] recorded review decisions: ${summary.totals.reviewDecisionRecordedCount}`, + `[harness-eval] observability-gap cases: ${summary.totals.observabilityGapCaseCount}`, ]; const topFailureModes = summary.breakdowns.failureModes.slice(0, 5); @@ -753,6 +856,19 @@ function renderText(summary) { } } + const topObservabilitySignals = summary.breakdowns.observabilitySignals.slice( + 0, + 5, + ); + if (topObservabilitySignals.length > 0) { + lines.push("[harness-eval] observability signals:"); + for (const entry of topObservabilitySignals) { + lines.push( + ` - ${entry.name}: case=${entry.caseCount}, ready=${entry.readyCount}, invalid=${entry.invalidCount}`, + ); + } + } + for (const suite of summary.suites) { lines.push( `[harness-eval] suite ${suite.id}: ready ${suite.stats.readyCount} / ${suite.stats.caseCount}`, @@ -772,6 +888,11 @@ function renderText(summary) { ` review: ${entry.reviewDecisionStatus} / risk=${entry.reviewRiskLevel || "unknown"}`, ); } + if (entry.observabilitySignals.length > 0) { + lines.push( + ` observability: ${entry.observabilitySignals.join(", ")}`, + ); + } for (const issue of entry.issues) { lines.push(` * ${issue}`); } @@ -795,6 +916,7 @@ function renderMarkdown(summary) { `- pending request case:${summary.totals.pendingRequestCaseCount}`, `- needs review case:${summary.totals.needsHumanReviewCount}`, `- 已记录人工审核:${summary.totals.reviewDecisionRecordedCount}`, + `- observability gap case:${summary.totals.observabilityGapCaseCount}`, "", ]; @@ -852,6 +974,19 @@ function renderMarkdown(summary) { lines.push(""); } + if (summary.breakdowns.observabilitySignals.length > 0) { + lines.push("## Observability Coverage 分布"); + lines.push(""); + lines.push("| Signal | case | ready | invalid |"); + lines.push("| --- | --- | --- | --- |"); + for (const entry of summary.breakdowns.observabilitySignals) { + lines.push( + `| ${entry.name} | ${entry.caseCount} | ${entry.readyCount} | ${entry.invalidCount} |`, + ); + } + lines.push(""); + } + for (const suite of summary.suites) { lines.push(`## ${suite.title}`); lines.push(""); @@ -885,6 +1020,11 @@ function renderMarkdown(summary) { if (entry.primaryBlockingKind) { classificationText.push(`blocking: ${entry.primaryBlockingKind}`); } + if (entry.observabilitySignals.length > 0) { + classificationText.push( + `observability: ${entry.observabilitySignals.join(", ")}`, + ); + } const reviewText = entry.reviewDecisionStatus ? [ entry.reviewDecisionStatus, diff --git a/scripts/harness-eval-trend-report.mjs b/scripts/harness-eval-trend-report.mjs index 0b4b4307f..c5951625b 100644 --- a/scripts/harness-eval-trend-report.mjs +++ b/scripts/harness-eval-trend-report.mjs @@ -324,6 +324,9 @@ function buildStatusSignals(baseline, latest, sampleCount) { const pendingDelta = normalizeNumber(latest?.totals?.pendingRequestCaseCount) - normalizeNumber(baseline?.totals?.pendingRequestCaseCount); + const observabilityGapDelta = + normalizeNumber(latest?.totals?.observabilityGapCaseCount) - + normalizeNumber(baseline?.totals?.observabilityGapCaseCount); if (invalidDelta > 0) { signals.push(`invalid case 增加 ${invalidDelta},存在回归候选。`); @@ -341,6 +344,12 @@ function buildStatusSignals(baseline, latest, sampleCount) { ); } + if (observabilityGapDelta > 0) { + signals.push( + `observability gap case 增加 ${observabilityGapDelta},需先补 evidence / analysis / replay 的证据覆盖。`, + ); + } + const failureModeDeltas = buildBreakdownDeltas( baseline, latest, @@ -419,6 +428,9 @@ function buildTrendReport(samples, repoRoot) { reviewDecisionRecordedCount: normalizeNumber(latest?.totals?.reviewDecisionRecordedCount) - normalizeNumber(baseline?.totals?.reviewDecisionRecordedCount), + observabilityGapCaseCount: + normalizeNumber(latest?.totals?.observabilityGapCaseCount) - + normalizeNumber(baseline?.totals?.observabilityGapCaseCount), readyRate: readyRateDelta, }, signals: buildStatusSignals(baseline, latest, sortedSamples.length), @@ -441,6 +453,11 @@ function buildTrendReport(samples, repoRoot) { latest, "reviewRiskLevels", ), + observabilitySignals: buildBreakdownDeltas( + baseline, + latest, + "observabilitySignals", + ), }, }; } @@ -455,6 +472,7 @@ function renderText(report) { `[harness-eval-trend] delta invalidCount: ${report.delta.invalidCount}`, `[harness-eval-trend] delta pendingRequestCaseCount: ${report.delta.pendingRequestCaseCount}`, `[harness-eval-trend] delta reviewDecisionRecordedCount: ${report.delta.reviewDecisionRecordedCount}`, + `[harness-eval-trend] delta observabilityGapCaseCount: ${report.delta.observabilityGapCaseCount}`, `[harness-eval-trend] delta readyRate: ${(report.delta.readyRate * 100).toFixed(1)}%`, ]; @@ -486,6 +504,17 @@ function renderText(report) { } } + const topObservabilityDeltas = + report.classificationDeltas.observabilitySignals.slice(0, 5); + if (topObservabilityDeltas.length > 0) { + lines.push("[harness-eval-trend] observability signal deltas:"); + for (const entry of topObservabilityDeltas) { + lines.push( + ` - ${entry.name}: delta_case=${entry.delta.caseCount}, latest_case=${entry.latest.caseCount}, latest_invalid=${entry.latest.invalidCount}`, + ); + } + } + return `${lines.join("\n")}\n`; } @@ -507,6 +536,7 @@ function renderMarkdown(report) { `- pending request case 变化:${report.delta.pendingRequestCaseCount}`, `- needs review case 变化:${report.delta.needsHumanReviewCount}`, `- 已记录人工审核变化:${report.delta.reviewDecisionRecordedCount}`, + `- observability gap case 变化:${report.delta.observabilityGapCaseCount}`, `- ready rate 变化:${(report.delta.readyRate * 100).toFixed(1)}%`, "", "## 信号", @@ -573,6 +603,19 @@ function renderMarkdown(report) { } } + if (report.classificationDeltas.observabilitySignals.length > 0) { + lines.push(""); + lines.push("## Observability Coverage 变化"); + lines.push(""); + lines.push("| Signal | baseline case | latest case | delta case | delta invalid |"); + lines.push("| --- | --- | --- | --- | --- |"); + for (const entry of report.classificationDeltas.observabilitySignals) { + lines.push( + `| ${entry.name} | ${entry.baseline.caseCount} | ${entry.latest.caseCount} | ${entry.delta.caseCount} | ${entry.delta.invalidCount} |`, + ); + } + } + lines.push(""); lines.push("## 时间线样本"); lines.push(""); diff --git a/scripts/lib/generated-slop-report-core.mjs b/scripts/lib/generated-slop-report-core.mjs index 846cb68ee..9912ae1d5 100644 --- a/scripts/lib/generated-slop-report-core.mjs +++ b/scripts/lib/generated-slop-report-core.mjs @@ -394,11 +394,119 @@ function buildTrendSummary(trendReport) { 0, latestNeedsReviewCount - latestReviewDecisionRecordedCount, ), + observabilityGapCaseDelta: normalizeNumber(delta.observabilityGapCaseCount), + latestObservabilityGapCaseCount: normalizeNumber( + latestTotals.observabilityGapCaseCount, + ), readyRateDelta: normalizeNumber(delta.readyRate), signals: Array.isArray(trendReport?.signals) ? trendReport.signals : [], }; } +function splitObservabilitySignalName(name) { + const normalized = normalizeString(name); + if (!normalized.includes(":")) { + return { + signal: normalized, + status: "", + }; + } + + const separatorIndex = normalized.indexOf(":"); + return { + signal: normalized.slice(0, separatorIndex), + status: normalized.slice(separatorIndex + 1), + }; +} + +function getObservabilityStatusWeight(status) { + switch (status) { + case "missing": + return 120; + case "missing_signal_coverage": + return 110; + case "unlinked": + return 95; + case "known_gap": + return 85; + case "partial": + return 70; + default: + return 50; + } +} + +function buildObservabilityFocusEntries(entries, sampleCount) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + + return normalizedEntries + .map((entry) => { + const latest = isObject(entry?.latest) ? entry.latest : {}; + const delta = isObject(entry?.delta) ? entry.delta : {}; + const baseline = isObject(entry?.baseline) ? entry.baseline : {}; + const parsed = splitObservabilitySignalName(entry?.name); + const positiveDeltaCase = Math.max(0, normalizeNumber(delta.caseCount)); + const latestCase = normalizeNumber(latest.caseCount); + const score = + positiveDeltaCase * 120 + + latestCase * getObservabilityStatusWeight(parsed.status); + + let state = "stable"; + if (sampleCount < 2 && latestCase > 0) { + state = "seed-risk"; + } else if (positiveDeltaCase > 0) { + state = "regressing"; + } else if (latestCase > 0) { + state = "present"; + } + + return { + name: normalizeString(entry?.name, "(unknown)"), + signal: parsed.signal || "(unknown)", + status: parsed.status || "unknown", + baseline: { + caseCount: normalizeNumber(baseline.caseCount), + readyCount: normalizeNumber(baseline.readyCount), + invalidCount: normalizeNumber(baseline.invalidCount), + pendingRequestCaseCount: normalizeNumber( + baseline.pendingRequestCaseCount, + ), + needsHumanReviewCount: normalizeNumber( + baseline.needsHumanReviewCount, + ), + }, + latest: { + caseCount: latestCase, + readyCount: normalizeNumber(latest.readyCount), + invalidCount: normalizeNumber(latest.invalidCount), + pendingRequestCaseCount: normalizeNumber( + latest.pendingRequestCaseCount, + ), + needsHumanReviewCount: normalizeNumber( + latest.needsHumanReviewCount, + ), + }, + delta: { + caseCount: normalizeNumber(delta.caseCount), + readyCount: normalizeNumber(delta.readyCount), + invalidCount: normalizeNumber(delta.invalidCount), + pendingRequestCaseCount: normalizeNumber(delta.pendingRequestCaseCount), + needsHumanReviewCount: normalizeNumber(delta.needsHumanReviewCount), + }, + state, + score, + }; + }) + .filter((entry) => entry.status && entry.status !== "exported") + .filter((entry) => entry.score > 0 || entry.latest.caseCount > 0) + .sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return left.name.localeCompare(right.name); + }); +} + function buildDocFreshnessSummary(docFreshnessReport) { const summary = isObject(docFreshnessReport?.summary) ? docFreshnessReport.summary @@ -469,6 +577,7 @@ function buildRecommendations({ focusSuiteTags, focusReviewDecisionStatuses, focusReviewRiskLevels, + focusObservabilitySignals, docFreshnessSummary, docFreshnessFocus, governanceSummary, @@ -485,6 +594,9 @@ function buildRecommendations({ const topReviewRiskLevels = focusReviewRiskLevels .slice(0, 3) .map((entry) => entry.name); + const topObservabilitySignals = focusObservabilitySignals + .slice(0, 3) + .map((entry) => `${entry.signal} (${entry.status})`); const topGovernanceSurfaceIds = governanceSurfaces .filter((entry) => entry.active && entry.classification !== "current") .slice(0, 3) @@ -606,6 +718,47 @@ function buildRecommendations({ }); } + const hasObservabilityPressure = + trendSummary.latestObservabilityGapCaseCount > 0 || + trendSummary.observabilityGapCaseDelta > 0 || + focusObservabilitySignals.some( + (entry) => entry.latest.caseCount > 0 || entry.delta.caseCount > 0, + ); + + if (hasObservabilityPressure) { + maybePushRecommendation(recommendations, { + id: "observability-evidence-follow-up", + priority: + topObservabilitySignals.some( + (entry) => + entry.includes("observabilitySummary (missing)") || + entry.includes("requestTelemetry (unlinked)"), + ) || trendSummary.observabilityGapCaseDelta > 0 + ? "P1" + : "P2", + title: "先补 observability 证据覆盖,再扩大外部分析与回归", + rationale: [ + trendSummary.latestObservabilityGapCaseCount > 0 + ? `当前仍有 ${trendSummary.latestObservabilityGapCaseCount} 个 case 带着 observability 证据缺口进入 replay/eval。` + : "当前 trend 已检测到 observability coverage 漂移,需先修证据而不是空谈根因分析。", + `当前缺口焦点:${topObservabilitySignals.join("、") || "暂无"}。这些缺口会直接降低 analysis handoff、人工审核和 cleanup report 的判断质量。`, + ], + commands: [ + "npm run harness:eval", + "npm run harness:eval:trend", + "npm run harness:cleanup-report", + ], + backlogTools: [ + "优先补 request telemetry 关联键、artifact validator outcome、browser/gui smoke 结果到 evidence pack / analysis handoff / replay。", + ], + focusFailureModes: topFailureModes, + focusSuiteTags: topSuiteTags, + focusReviewDecisionStatuses: topReviewDecisionStatuses, + focusReviewRiskLevels: topReviewRiskLevels, + focusSurfaceIds: [], + }); + } + const hasGovernancePressure = governanceSummary.activeByClassification.compat > 0 || governanceSummary.activeByClassification.deprecated > 0 || @@ -707,6 +860,10 @@ export function buildGeneratedSlopReport({ trendReport?.classificationDeltas?.reviewRiskLevels, trendSummary.sampleCount, ); + const focusObservabilitySignals = buildObservabilityFocusEntries( + trendReport?.classificationDeltas?.observabilitySignals, + trendSummary.sampleCount, + ); const governanceSurfaces = buildGovernanceSurfaceEntries(governanceReport); const governanceSummary = buildGovernanceSummary( governanceReport, @@ -718,6 +875,7 @@ export function buildGeneratedSlopReport({ focusSuiteTags, focusReviewDecisionStatuses, focusReviewRiskLevels, + focusObservabilitySignals, docFreshnessSummary, docFreshnessFocus, governanceSummary, @@ -764,12 +922,16 @@ export function buildGeneratedSlopReport({ trendSummary.reviewDecisionBacklogCount > 0 ? `仍有 ${trendSummary.reviewDecisionBacklogCount} 个需要人工审核的 case 尚未记录最终 decision。` : "当前没有待补录的人工审核 backlog。", + trendSummary.latestObservabilityGapCaseCount > 0 + ? `当前仍有 ${trendSummary.latestObservabilityGapCaseCount} 个 case 缺 observability 证据。` + : "当前没有额外的 observability 证据缺口。", ], focus: { failureModes: focusFailureModes.slice(0, 5), suiteTags: focusSuiteTags.slice(0, 5), reviewDecisionStatuses: focusReviewDecisionStatuses.slice(0, 5), reviewRiskLevels: focusReviewRiskLevels.slice(0, 5), + observabilitySignals: focusObservabilitySignals.slice(0, 5), docFreshness: docFreshnessFocus, governanceSurfaces: governanceSurfaces .filter((entry) => entry.active && entry.classification !== "current") @@ -791,6 +953,7 @@ export function renderGeneratedSlopText(report) { `[harness-cleanup] delta pending_request: ${report.summary.trend.pendingDelta}`, `[harness-cleanup] delta review_decision_recorded: ${report.summary.trend.reviewDecisionRecordedDelta}`, `[harness-cleanup] review backlog: ${report.summary.trend.reviewDecisionBacklogCount}`, + `[harness-cleanup] observability gap cases: ${report.summary.trend.latestObservabilityGapCaseCount}`, `[harness-cleanup] doc freshness issues: ${report.summary.docFreshness.issueCount}`, `[harness-cleanup] active compat/deprecated/dead-candidate: ${report.summary.governance.activeByClassification.compat}/${report.summary.governance.activeByClassification.deprecated}/${report.summary.governance.activeByClassification["dead-candidate"]}`, `[harness-cleanup] governance violations: ${report.summary.governance.violationCount}`, @@ -836,6 +999,15 @@ export function renderGeneratedSlopText(report) { } } + if (report.focus.observabilitySignals.length > 0) { + lines.push("[harness-cleanup] top observability gaps:"); + for (const entry of report.focus.observabilitySignals) { + lines.push( + ` - ${entry.signal} (${entry.status}): state=${entry.state}, latest_case=${entry.latest.caseCount}, delta_case=${entry.delta.caseCount}, score=${entry.score}`, + ); + } + } + if (report.focus.docFreshness.issueKinds.length > 0) { lines.push("[harness-cleanup] doc freshness issues:"); for (const entry of report.focus.docFreshness.issueKinds) { @@ -870,6 +1042,7 @@ export function renderGeneratedSlopMarkdown(report) { `- pending request delta:${report.summary.trend.pendingDelta}`, `- 已记录人工审核 delta:${report.summary.trend.reviewDecisionRecordedDelta}`, `- 待补录人工审核 backlog:${report.summary.trend.reviewDecisionBacklogCount}`, + `- observability gap case:${report.summary.trend.latestObservabilityGapCaseCount}`, `- doc freshness 问题数:${report.summary.docFreshness.issueCount}`, `- governance 违规数:${report.summary.governance.violationCount}`, `- compat 活跃 surface:${report.summary.governance.activeByClassification.compat}`, @@ -955,6 +1128,21 @@ export function renderGeneratedSlopMarkdown(report) { } } + if (report.focus.observabilitySignals.length > 0) { + lines.push(""); + lines.push("## Observability 证据缺口"); + lines.push(""); + lines.push( + "| Signal | 状态 | latest case | delta case | score |", + ); + lines.push("| --- | --- | --- | --- | --- |"); + for (const entry of report.focus.observabilitySignals) { + lines.push( + `| ${entry.signal} | ${entry.status} | ${entry.latest.caseCount} | ${entry.delta.caseCount} | ${entry.score} |`, + ); + } + } + if (report.recommendations.length > 0) { lines.push(""); lines.push("## 推荐动作"); diff --git a/scripts/lib/generated-slop-report-core.test.ts b/scripts/lib/generated-slop-report-core.test.ts index 01ef68ad1..3171a97e0 100644 --- a/scripts/lib/generated-slop-report-core.test.ts +++ b/scripts/lib/generated-slop-report-core.test.ts @@ -13,6 +13,7 @@ describe("generated-slop-report-core", () => { pendingRequestCaseCount: 0, needsHumanReviewCount: 0, reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 1, readyRate: 0, }, signals: ["样本数不足 2,当前仅形成 trend seed,还不能判断长期退化。"], @@ -20,12 +21,14 @@ describe("generated-slop-report-core", () => { totals: { needsHumanReviewCount: 0, reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, }, }, latest: { totals: { needsHumanReviewCount: 1, reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 1, }, }, classificationDeltas: { @@ -133,6 +136,56 @@ describe("generated-slop-report-core", () => { }, }, ], + observabilitySignals: [ + { + name: "requestTelemetry:unlinked", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + { + name: "artifactValidator:known_gap", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], }, }, governanceReport: { @@ -190,10 +243,15 @@ describe("generated-slop-report-core", () => { expect(report.summary.trend.isSeed).toBe(true); expect(report.summary.trend.reviewDecisionBacklogCount).toBe(1); + expect(report.summary.trend.latestObservabilityGapCaseCount).toBe(1); expect(report.summary.docFreshness.issueCount).toBe(0); expect(report.focus.failureModes[0].name).toBe("pending_request"); expect(report.focus.reviewDecisionStatuses[0].name).toBe("pending_review"); expect(report.focus.reviewRiskLevels[0].name).toBe("high"); + expect(report.focus.observabilitySignals[0]).toMatchObject({ + signal: "requestTelemetry", + status: "unlinked", + }); expect(report.focus.governanceSurfaces[0].id).toBe( "migration-setting-key-leak", ); @@ -202,6 +260,7 @@ describe("generated-slop-report-core", () => { "promote-high-value-replay", "replay-and-smoke-follow-up", "review-decision-follow-up", + "observability-evidence-follow-up", "governance-cleanup-priority", "doc-freshness-review", ]), @@ -215,6 +274,11 @@ describe("generated-slop-report-core", () => { report.recommendations.find((entry) => entry.id === "doc-freshness-review") ?.commands, ).toContain("npm run harness:doc-freshness"); + expect( + report.recommendations.find( + (entry) => entry.id === "observability-evidence-follow-up", + )?.priority, + ).toBe("P1"); }); it("应把 governance 违规提升为 P0 守卫动作", () => { @@ -227,6 +291,7 @@ describe("generated-slop-report-core", () => { pendingRequestCaseCount: 0, needsHumanReviewCount: 0, reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, readyRate: -0.5, }, signals: ["invalid case 增加 1,存在回归候选。"], @@ -234,12 +299,14 @@ describe("generated-slop-report-core", () => { totals: { needsHumanReviewCount: 0, reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, }, }, latest: { totals: { needsHumanReviewCount: 0, reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, }, }, classificationDeltas: { @@ -247,6 +314,7 @@ describe("generated-slop-report-core", () => { suiteTags: [], reviewDecisionStatuses: [], reviewRiskLevels: [], + observabilitySignals: [], }, }, governanceReport: { diff --git a/scripts/lib/harness-eval-history-record.test.ts b/scripts/lib/harness-eval-history-record.test.ts index 0e9c2de1c..880465bbd 100644 --- a/scripts/lib/harness-eval-history-record.test.ts +++ b/scripts/lib/harness-eval-history-record.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { afterEach, describe, expect, it } from "vitest"; const repoRoot = process.cwd(); @@ -28,6 +28,27 @@ function runNodeScript(scriptRelativePath: string, args: string[]) { return JSON.parse(output); } +function runNodeScriptAsync(scriptRelativePath: string, args: string[]) { + return new Promise((resolve, reject) => { + execFile( + process.execPath, + [path.join(repoRoot, scriptRelativePath), ...args], + { + cwd: repoRoot, + encoding: "utf8", + }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + + resolve(JSON.parse(stdout)); + }, + ); + }); +} + afterEach(() => { while (tempRoots.length > 0) { const tempRoot = tempRoots.pop(); @@ -40,7 +61,7 @@ afterEach(() => { describe("harness-eval-history-record", () => { it("应记录本地 summary 历史,并生成非 seed 的 trend / cleanup", () => { const tempRoot = createTempRoot(); - const historyDir = path.join(tempRoot, "artifacts", "history"); + const historyDir = path.join(tempRoot, ".lime", "harness", "history"); const workspaceRoot = path.join(tempRoot, "workspace"); const firstResult = runNodeScript("scripts/harness-eval-history-record.mjs", [ @@ -76,12 +97,62 @@ describe("harness-eval-history-record", () => { expect(secondResult.cleanup.trendSampleCount).toBe(2); expect(fs.existsSync(secondResult.recordedSummaryPath)).toBe(true); expect( - fs.existsSync(path.join(tempRoot, "artifacts", "harness-eval-trend.json")), + fs.existsSync( + path.join( + tempRoot, + ".lime", + "harness", + "reports", + "harness-eval-trend.json", + ), + ), ).toBe(true); expect( fs.existsSync( - path.join(tempRoot, "artifacts", "harness-cleanup-report.json"), + path.join( + tempRoot, + ".lime", + "harness", + "reports", + "harness-cleanup-report.json", + ), ), ).toBe(true); - }); + }, 30_000); + + it("并发记录 history 时不应覆盖已有样本", async () => { + const tempRoot = createTempRoot(); + const historyDir = path.join(tempRoot, ".lime", "harness", "history"); + const workspaceRoot = path.join(tempRoot, "workspace"); + + const [firstResult, secondResult] = (await Promise.all([ + runNodeScriptAsync("scripts/harness-eval-history-record.mjs", [ + "--format", + "json", + "--history-dir", + historyDir, + "--workspace-root", + workspaceRoot, + ]), + runNodeScriptAsync("scripts/harness-eval-history-record.mjs", [ + "--format", + "json", + "--history-dir", + historyDir, + "--workspace-root", + workspaceRoot, + ]), + ])) as Array<{ recordedSummaryPath: string }>; + + const historyFiles = fs + .readdirSync(historyDir) + .filter( + (entry) => + entry.endsWith("-harness-eval-summary.json") || + /-harness-eval-summary-\d+\.json$/.test(entry), + ); + + expect(historyFiles).toHaveLength(2); + expect(firstResult.recordedSummaryPath).not.toBe(secondResult.recordedSummaryPath); + }, 30_000); }); diff --git a/scripts/lib/harness-review-decision-evals.test.ts b/scripts/lib/harness-review-decision-evals.test.ts index c1146e280..b52eed0ab 100644 --- a/scripts/lib/harness-review-decision-evals.test.ts +++ b/scripts/lib/harness-review-decision-evals.test.ts @@ -101,6 +101,29 @@ function createWorkspaceSessionArtifacts(tempRoot: string, sessionId: string) { }, ], }, + observability: { + correlation: { + correlationKeys: ["session_id", "thread_id", "turn_id"], + }, + signalCoverage: [ + { + signal: "correlation", + status: "exported", + }, + { + signal: "timeline", + status: "exported", + }, + { + signal: "requestTelemetry", + status: "unlinked", + }, + { + signal: "artifactValidator", + status: "known_gap", + }, + ], + }, linkedArtifacts: { handoffBundle: { relativeRoot: `.lime/harness/sessions/${sessionId}/handoff`, @@ -133,6 +156,7 @@ function createWorkspaceSessionArtifacts(tempRoot: string, sessionId: string) { relativeRoot: `.lime/harness/sessions/${sessionId}/evidence`, absoluteRoot: evidenceRoot, }, + observabilitySummary: inputPayload.observability, }; const reviewDecisionPayload = { schemaVersion: "v1", @@ -300,6 +324,7 @@ describe("Harness review decision / eval integration", () => { ]); expect(summary.totals.reviewDecisionRecordedCount).toBe(1); + expect(summary.totals.observabilityGapCaseCount).toBe(1); expect(summary.breakdowns.reviewDecisionStatuses).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -316,10 +341,23 @@ describe("Harness review decision / eval integration", () => { }), ]), ); + expect(summary.breakdowns.observabilitySignals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "requestTelemetry:unlinked", + caseCount: 1, + }), + expect.objectContaining({ + name: "artifactValidator:known_gap", + caseCount: 1, + }), + ]), + ); expect(summary.suites[0].cases[0]).toMatchObject({ reviewDecisionStatus: "accepted", reviewRiskLevel: "high", reviewHumanReviewer: "Lime Maintainer", + observabilityGapCount: 2, }); }); @@ -337,6 +375,7 @@ describe("Harness review decision / eval integration", () => { invalidCount: 0, pendingRequestCaseCount: 0, needsHumanReviewCount: 1, + observabilityGapCaseCount: 1, }, breakdowns: { suiteTags: [], @@ -361,6 +400,16 @@ describe("Harness review decision / eval integration", () => { needsHumanReviewCount: 1, }, ], + observabilitySignals: [ + { + name: "requestTelemetry:unlinked", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 1, + }, + ], }, suites: [], }); @@ -374,6 +423,7 @@ describe("Harness review decision / eval integration", () => { invalidCount: 0, pendingRequestCaseCount: 0, needsHumanReviewCount: 0, + observabilityGapCaseCount: 1, }, breakdowns: { suiteTags: [], @@ -398,6 +448,16 @@ describe("Harness review decision / eval integration", () => { needsHumanReviewCount: 0, }, ], + observabilitySignals: [ + { + name: "artifactValidator:known_gap", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + ], }, suites: [], }); @@ -443,5 +503,21 @@ describe("Harness review decision / eval integration", () => { }), ]), ); + expect(report.classificationDeltas.observabilitySignals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "requestTelemetry:unlinked", + delta: expect.objectContaining({ + caseCount: -1, + }), + }), + expect.objectContaining({ + name: "artifactValidator:known_gap", + delta: expect.objectContaining({ + caseCount: 1, + }), + }), + ]), + ); }); }); diff --git a/scripts/report-generated-slop.mjs b/scripts/report-generated-slop.mjs index 5a07c4ce3..dfcee1487 100644 --- a/scripts/report-generated-slop.mjs +++ b/scripts/report-generated-slop.mjs @@ -144,16 +144,26 @@ function readJsonFile(filePath) { } function detectDefaultTrendHistoryDir(repoRoot) { - const candidate = resolvePath(repoRoot, "./artifacts/history"); - if (!fs.existsSync(candidate)) { - return ""; + const candidates = [ + resolvePath(repoRoot, "./.lime/harness/history"), + resolvePath(repoRoot, "./artifacts/history"), + ]; + + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) { + continue; + } + + const hasJson = fs + .readdirSync(candidate, { withFileTypes: true }) + .some((entry) => entry.isFile() && entry.name.endsWith(".json")); + + if (hasJson) { + return candidate; + } } - const hasJson = fs - .readdirSync(candidate, { withFileTypes: true }) - .some((entry) => entry.isFile() && entry.name.endsWith(".json")); - - return hasJson ? candidate : ""; + return ""; } function buildTrendReport(repoRoot, options) { diff --git a/src-tauri/src/services/runtime_analysis_handoff_service.rs b/src-tauri/src/services/runtime_analysis_handoff_service.rs index 2d6ddb95b..dab7042c8 100644 --- a/src-tauri/src/services/runtime_analysis_handoff_service.rs +++ b/src-tauri/src/services/runtime_analysis_handoff_service.rs @@ -81,6 +81,7 @@ struct AnalysisContextDocument { replay: AnalysisReplaySection, handoff: AnalysisHandoffSection, evidence: AnalysisEvidenceSection, + observability: AnalysisObservabilitySection, reading_order: Vec, external_analysis_contract: AnalysisExternalContract, human_review_checklist: Vec, @@ -146,6 +147,14 @@ struct AnalysisEvidenceSection { summary_excerpt: String, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalysisObservabilitySection { + summary: Value, + correlation_keys: Vec, + gap_signals: Vec, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct AnalysisExternalContract { @@ -216,6 +225,13 @@ pub fn export_runtime_analysis_handoff( &read_optional_text_file(&evidence_root.join("summary.md"))?, MAX_EXCERPT_CHARS, ); + let observability_summary = runtime_payload + .pointer("/observabilitySummary") + .cloned() + .unwrap_or(Value::Null); + let observability_correlation_keys = + collect_observability_correlation_keys(&observability_summary); + let observability_gap_signals = collect_observability_gap_signals(&observability_summary); let title = derive_title(&input_payload, session_id); let failure_modes = value_string_list( @@ -386,6 +402,11 @@ pub fn export_runtime_analysis_handoff( runtime: sanitize_value(runtime_payload, workspace_root.as_path()), summary_excerpt: sanitize_text(evidence_summary_excerpt, workspace_root.as_path()), }, + observability: AnalysisObservabilitySection { + summary: sanitize_value(observability_summary, workspace_root.as_path()), + correlation_keys: observability_correlation_keys.clone(), + gap_signals: observability_gap_signals.clone(), + }, reading_order: reading_order.clone(), external_analysis_contract: external_contract.clone(), human_review_checklist: review_checklist.clone(), @@ -403,6 +424,8 @@ pub fn export_runtime_analysis_handoff( &analysis_context.replay.grader_excerpt, &analysis_context.handoff.handoff_excerpt, &analysis_context.evidence.summary_excerpt, + &analysis_context.observability.correlation_keys, + &analysis_context.observability.gap_signals, ); let artifacts = vec![ @@ -474,6 +497,8 @@ fn build_analysis_brief( grader_excerpt: &str, handoff_excerpt: &str, evidence_excerpt: &str, + observability_correlation_keys: &[String], + observability_gap_signals: &[String], ) -> String { let mut lines = vec![ "# 外部分析交接简报".to_string(), @@ -522,6 +547,17 @@ fn build_analysis_brief( format!("- pending request:{}", summary.pending_request_count), format!("- queued turn:{}", summary.queued_turn_count), String::new(), + "## 证据关联与可观测覆盖".to_string(), + String::new(), + format!( + "- 关联键:{}", + join_or_fallback(observability_correlation_keys, "无") + ), + format!( + "- 当前缺口:{}", + join_or_fallback(observability_gap_signals, "无") + ), + String::new(), "## 推荐读取顺序".to_string(), String::new(), ]; @@ -917,6 +953,27 @@ fn normalize_optional_text(value: Option) -> Option { .filter(|text| !text.is_empty()) } +fn collect_observability_correlation_keys(summary: &Value) -> Vec { + summary + .pointer("/correlation/correlationKeys") + .map(value_string_list) + .unwrap_or_default() +} + +fn collect_observability_gap_signals(summary: &Value) -> Vec { + value_array(summary.pointer("/signalCoverage").unwrap_or(&Value::Null)) + .into_iter() + .filter_map(|entry| { + let signal = value_string(entry.get("signal").unwrap_or(&Value::Null))?; + let status = value_string(entry.get("status").unwrap_or(&Value::Null))?; + if status == "exported" { + return None; + } + Some(format!("{signal} ({status})")) + }) + .collect() +} + fn join_or_fallback(values: &[String], fallback: &str) -> String { if values.is_empty() { fallback.to_string() @@ -1130,12 +1187,17 @@ mod tests { let brief = fs::read_to_string(brief_path).expect("brief"); assert!(brief.contains("外部分析交接简报")); assert!(brief.contains("pending request:1")); + assert!(brief.contains("证据关联与可观测覆盖")); + assert!(brief.contains("requestTelemetry (unlinked)")); assert!(brief.contains("/workspace/lime")); let context = fs::read_to_string(context_path).expect("context"); assert!(context.contains("\"schemaVersion\": \"v1\"")); assert!(context.contains("\"contractShape\": \"lime_external_analysis_handoff\"")); assert!(context.contains("\"pendingRequestCount\": 1")); + assert!(context.contains("\"observability\"")); + assert!(context.contains("\"correlationKeys\"")); + assert!(context.contains("\"gapSignals\"")); assert!(context.contains("/workspace/lime")); assert!(!context.contains(temp_dir.path().to_string_lossy().as_ref())); } diff --git a/src-tauri/src/services/runtime_evidence_pack_service.rs b/src-tauri/src/services/runtime_evidence_pack_service.rs index d10ee4c3e..873582d82 100644 --- a/src-tauri/src/services/runtime_evidence_pack_service.rs +++ b/src-tauri/src/services/runtime_evidence_pack_service.rs @@ -8,7 +8,7 @@ use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; use chrono::Utc; use lime_core::database::dao::agent_timeline::AgentThreadItemPayload; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{json, Value}; use std::fmt::Write as _; use std::fs; use std::path::Path; @@ -95,6 +95,12 @@ pub fn export_runtime_evidence_pack( let recent_artifacts = collect_recent_artifact_paths(detail); let latest_turn_summary = collect_latest_turn_summary(detail); let known_gaps = build_known_gaps(&recent_artifacts); + let observability_summary = build_runtime_observability_summary_json( + detail, + thread_read, + &recent_artifacts, + &known_gaps, + ); let artifacts = vec![ write_evidence_file( @@ -108,6 +114,7 @@ pub fn export_runtime_evidence_pack( thread_read, &recent_artifacts, latest_turn_summary.as_deref(), + &observability_summary, &known_gaps, exported_at.as_str(), ), @@ -123,6 +130,7 @@ pub fn export_runtime_evidence_pack( thread_read, workspace_root.as_path(), &recent_artifacts, + &observability_summary, &known_gaps, exported_at.as_str(), )?, @@ -145,6 +153,7 @@ pub fn export_runtime_evidence_pack( detail, thread_read, &recent_artifacts, + &observability_summary, &known_gaps, exported_at.as_str(), )?, @@ -206,6 +215,7 @@ fn build_summary_markdown( thread_read: &AgentRuntimeThreadReadModel, recent_artifacts: &[String], latest_turn_summary: Option<&str>, + observability_summary: &Value, known_gaps: &[String], exported_at: &str, ) -> String { @@ -250,6 +260,36 @@ fn build_summary_markdown( let _ = writeln!(markdown, "- 当前主要阻塞:{blocking_summary}"); } let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 证据关联与可观测覆盖"); + let _ = writeln!(markdown); + let _ = writeln!( + markdown, + "- 关联键:{}", + observability_summary + .pointer("/correlation/correlationKeys") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(|value| format!("`{value}`")) + .collect::>() + .join("、") + }) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "当前未导出关联键".to_string()) + ); + let _ = writeln!( + markdown, + "- 当前导出信号:{}", + format_observability_signal_list(observability_summary, "exported") + ); + let _ = writeln!( + markdown, + "- 当前证据缺口:{}", + format_observability_gap_list(observability_summary) + ); + let _ = writeln!(markdown); let _ = writeln!(markdown, "## 建议读取顺序"); let _ = writeln!(markdown); let _ = writeln!(markdown, "1. 先读 `summary.md`,确认会话状态和当前阻塞。"); @@ -280,6 +320,7 @@ fn build_runtime_json( thread_read: &AgentRuntimeThreadReadModel, workspace_root: &Path, recent_artifacts: &[String], + observability_summary: &Value, known_gaps: &[String], exported_at: &str, ) -> Result { @@ -361,6 +402,7 @@ fn build_runtime_json( "updatedAt": session.updated_at }) }).collect::>(), + "observabilitySummary": observability_summary, "recentArtifacts": recent_artifacts, "knownGaps": known_gaps }); @@ -405,6 +447,7 @@ fn build_artifacts_json( detail: &SessionDetail, thread_read: &AgentRuntimeThreadReadModel, recent_artifacts: &[String], + observability_summary: &Value, known_gaps: &[String], exported_at: &str, ) -> Result { @@ -413,6 +456,7 @@ fn build_artifacts_json( "exportedAt": exported_at, "recentArtifacts": recent_artifacts, "artifactCount": recent_artifacts.len(), + "observabilitySummary": observability_summary, "verification": { "artifactValidatorIssues": [], "browserEvidence": [], @@ -442,7 +486,8 @@ fn build_artifacts_json( fn build_known_gaps(recent_artifacts: &[String]) -> Vec { let mut gaps = vec![ - "当前 Evidence Pack 尚未纳入 provider 请求级 token / retry / duration 摘要。".to_string(), + "当前 Lime 虽然已有全局 RequestLog / token / retry / duration 遥测,但 RequestLog 尚未携带 session/thread/turn 关联键,Evidence Pack 只能导出 request telemetry coverage gap,不能给出精确的会话级摘要。".to_string(), + "当前 Evidence Pack 尚未纳入 ArtifactDocument validator outcome 与修复问题摘要。".to_string(), "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。".to_string(), ]; @@ -520,6 +565,194 @@ fn truncate_text(value: &str) -> String { + "..." } +pub(crate) fn build_runtime_observability_summary_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + recent_artifacts: &[String], + known_gaps: &[String], +) -> Value { + let diagnostics = thread_read.diagnostics.as_ref(); + json!({ + "schemaVersion": "v1", + "correlation": { + "correlationKeys": [ + "session_id", + "thread_id", + "turn_id", + "pending_request_id", + "queued_turn_id", + "subagent_session_id" + ], + "sessionId": detail.id, + "threadId": detail.thread_id, + "activeTurnId": thread_read.active_turn_id, + "pendingRequestIds": thread_read.pending_requests.iter().map(|item| item.id.clone()).collect::>(), + "queuedTurnIds": thread_read.queued_turns.iter().map(|item| item.queued_turn_id.clone()).collect::>(), + "subagentSessionIds": detail.child_subagent_sessions.iter().map(|item| item.id.clone()).collect::>() + }, + "counts": { + "turnCount": detail.turns.len(), + "itemCount": detail.items.len(), + "pendingRequestCount": thread_read.pending_requests.len(), + "queuedTurnCount": thread_read.queued_turns.len(), + "warningCount": diagnostics.map(|value| value.warning_count).unwrap_or(0), + "failedToolCallCount": diagnostics.map(|value| value.failed_tool_call_count).unwrap_or(0), + "failedCommandCount": diagnostics.map(|value| value.failed_command_count).unwrap_or(0), + "subagentCount": detail.child_subagent_sessions.len(), + "recentArtifactCount": recent_artifacts.len() + }, + "latest": { + "warning": latest_warning_json(diagnostics), + "failedTool": latest_failed_tool_json(diagnostics), + "failedCommand": latest_failed_command_json(diagnostics) + }, + "signalCoverage": [ + json!({ + "signal": "correlation", + "status": "exported", + "source": "runtime thread identity", + "detail": "当前证据包已导出 session/thread/turn/pending request/subagent 关联键。" + }), + json!({ + "signal": "timeline", + "status": "exported", + "source": "timeline.json", + "detail": "当前证据包已导出最近 turn 与 item 时间线。" + }), + json!({ + "signal": "warnings", + "status": "exported", + "source": "thread.diagnostics", + "detail": if diagnostics.is_some() { + "当前证据包已导出 warning / failed tool / failed command 摘要。" + } else { + "当前线程没有 diagnostics,但 warning 通道已保留在导出结构中。" + } + }), + json!({ + "signal": "requestTelemetry", + "status": "unlinked", + "source": "lime_infra.telemetry", + "detail": "Lime 已有 workspace 级 request telemetry,但当前 RequestLog 还未携带 session/thread/turn 元数据。" + }), + json!({ + "signal": "artifactValidator", + "status": "known_gap", + "source": "artifact_document_validator", + "detail": "Artifact validator outcome 尚未回挂到当前 evidence pack。" + }), + json!({ + "signal": "browserVerification", + "status": "known_gap", + "source": "browser runtime", + "detail": "浏览器截图、DOM 快照和交互验证结果尚未接入 evidence pack。" + }), + json!({ + "signal": "guiSmoke", + "status": "known_gap", + "source": "verify:gui-smoke", + "detail": "GUI smoke 结果尚未作为结构化证据回挂当前会话。" + }) + ], + "knownGaps": known_gaps + }) +} + +fn latest_warning_json( + diagnostics: Option<&crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics>, +) -> Option { + diagnostics.and_then(|value| { + value.latest_warning.as_ref().map(|warning| { + json!({ + "code": warning.code, + "message": warning.message, + "updatedAt": warning.updated_at + }) + }) + }) +} + +fn latest_failed_tool_json( + diagnostics: Option<&crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics>, +) -> Option { + diagnostics.and_then(|value| { + value.latest_failed_tool.as_ref().map(|tool| { + json!({ + "toolName": tool.tool_name, + "error": tool.error, + "updatedAt": tool.updated_at + }) + }) + }) +} + +fn latest_failed_command_json( + diagnostics: Option<&crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics>, +) -> Option { + diagnostics.and_then(|value| { + value.latest_failed_command.as_ref().map(|command| { + json!({ + "command": command.command, + "exitCode": command.exit_code, + "error": command.error, + "updatedAt": command.updated_at + }) + }) + }) +} + +fn format_observability_signal_list(observability_summary: &Value, status: &str) -> String { + let signals = observability_summary + .pointer("/signalCoverage") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter(|value| { + value.get("status") + .and_then(Value::as_str) + .map(|value| value == status) + .unwrap_or(false) + }) + .filter_map(|value| value.get("signal").and_then(Value::as_str)) + .map(|value| format!("`{value}`")) + .collect::>() + }) + .unwrap_or_default(); + + if signals.is_empty() { + "无".to_string() + } else { + signals.join("、") + } +} + +fn format_observability_gap_list(observability_summary: &Value) -> String { + let signals = observability_summary + .pointer("/signalCoverage") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(|value| { + let signal = value.get("signal").and_then(Value::as_str)?; + let status = value.get("status").and_then(Value::as_str)?; + if status == "exported" { + return None; + } + Some(format!("`{signal}` ({status})")) + }) + .collect::>() + }) + .unwrap_or_default(); + + if signals.is_empty() { + "无".to_string() + } else { + signals.join("、") + } +} + fn serialize_enum_as_string(value: &T, fallback: &str) -> String { serde_json::to_value(value) .ok() @@ -720,21 +953,34 @@ mod tests { let timeline_path = temp_dir .path() .join(".lime/harness/sessions/session-1/evidence/timeline.json"); + let artifacts_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/artifacts.json"); assert!(summary_path.exists()); assert!(runtime_path.exists()); assert!(timeline_path.exists()); + assert!(artifacts_path.exists()); let summary = fs::read_to_string(summary_path).expect("summary"); assert!(summary.contains("问题证据包")); assert!(summary.contains("等待用户确认是否导出问题证据包")); + assert!(summary.contains("证据关联与可观测覆盖")); + assert!(summary.contains("requestTelemetry")); let runtime = fs::read_to_string(runtime_path).expect("runtime"); assert!(runtime.contains("\"sessionId\": \"session-1\"")); assert!(runtime.contains("\"pendingRequestCount\": 1")); + assert!(runtime.contains("\"observabilitySummary\"")); + assert!(runtime.contains("\"requestTelemetry\"")); + assert!(runtime.contains("\"artifactValidator\"")); let timeline = fs::read_to_string(timeline_path).expect("timeline"); assert!(timeline.contains("\"payloadKind\": \"plan\"")); assert!(timeline.contains("\"status\": \"completed\"")); + + let artifacts = fs::read_to_string(artifacts_path).expect("artifacts"); + assert!(artifacts.contains("\"observabilitySummary\"")); + assert!(artifacts.contains("\"guiSmoke\"")); } } diff --git a/src-tauri/src/services/runtime_replay_case_service.rs b/src-tauri/src/services/runtime_replay_case_service.rs index d5dd44573..b8f4fff74 100644 --- a/src-tauri/src/services/runtime_replay_case_service.rs +++ b/src-tauri/src/services/runtime_replay_case_service.rs @@ -7,7 +7,8 @@ use crate::agent::SessionDetail; use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; use crate::services::runtime_evidence_pack_service::{ - export_runtime_evidence_pack, RuntimeEvidencePackExportResult, + build_runtime_observability_summary_json, export_runtime_evidence_pack, + RuntimeEvidencePackExportResult, }; use crate::services::runtime_handoff_artifact_service::{ export_runtime_handoff_bundle, RuntimeHandoffBundleExportResult, @@ -144,6 +145,12 @@ pub fn export_runtime_replay_case( let recent_artifacts = collect_recent_artifact_paths(detail); let pending_requests = collect_pending_request_inputs(detail, thread_read); let recent_timeline = collect_recent_timeline_items(detail); + let observability_summary = build_runtime_observability_summary_json( + detail, + thread_read, + &recent_artifacts, + &evidence_pack.known_gaps, + ); let success_criteria = build_success_criteria( detail, thread_read, @@ -172,6 +179,7 @@ pub fn export_runtime_replay_case( &recent_artifacts, &recent_timeline, &pending_requests, + &observability_summary, workspace_root.as_path(), exported_at.as_str(), )?, @@ -218,6 +226,7 @@ pub fn export_runtime_replay_case( &handoff_bundle, &evidence_pack, &recent_artifacts, + &observability_summary, exported_at.as_str(), )?, )?, @@ -286,6 +295,7 @@ fn build_input_json( recent_artifacts: &[String], recent_timeline: &[ReplayTimelineItem], pending_requests: &[ReplayPendingRequestInput], + observability_summary: &Value, workspace_root: &Path, exported_at: &str, ) -> Result { @@ -359,6 +369,7 @@ fn build_input_json( "lastOutcome": &thread_read.last_outcome, "incidents": &thread_read.incidents, }, + "observability": observability_summary, "linkedArtifacts": { "handoffBundle": { "relativeRoot": handoff_bundle.bundle_relative_root.as_str(), @@ -487,6 +498,7 @@ fn build_evidence_links_json( handoff_bundle: &RuntimeHandoffBundleExportResult, evidence_pack: &RuntimeEvidencePackExportResult, recent_artifacts: &[String], + observability_summary: &Value, exported_at: &str, ) -> Result { let payload = json!({ @@ -503,6 +515,7 @@ fn build_evidence_links_json( "knownGaps": &evidence_pack.known_gaps, "artifacts": &evidence_pack.artifacts, }, + "observabilitySummary": observability_summary, "recentArtifacts": recent_artifacts, }); @@ -1245,6 +1258,8 @@ mod tests { assert!(input.contains("\"suiteTags\"")); assert!(input.contains("\"failureModes\"")); assert!(input.contains("\"pending_request\"")); + assert!(input.contains("\"observability\"")); + assert!(input.contains("\"requestTelemetry\"")); let expected = fs::read_to_string(expected_path).expect("expected"); assert!(expected.contains("不要要求与原始会话完全相同的工具调用顺序")); @@ -1257,5 +1272,7 @@ mod tests { let links = fs::read_to_string(links_path).expect("links"); assert!(links.contains("\"handoffBundle\"")); assert!(links.contains("\"evidencePack\"")); + assert!(links.contains("\"observabilitySummary\"")); + assert!(links.contains("\"artifactValidator\"")); } } diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 3ebcdabfd..f21662339 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -2075,6 +2075,7 @@ export function AgentChatWorkspace({ normalizedEntryTheme, shouldBootstrapCanvasOnEntry, canvasState, + generalCanvasState, showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard, hasCurrentCanvasArtifact: Boolean(currentCanvasArtifact), currentCanvasArtifactType: currentCanvasArtifact?.type, diff --git a/src/components/agent/chat/components/Inputbar/components/A2UIFloatingForm.tsx b/src/components/agent/chat/components/Inputbar/components/A2UIFloatingForm.tsx index eb6724499..2ad43a3c5 100644 --- a/src/components/agent/chat/components/Inputbar/components/A2UIFloatingForm.tsx +++ b/src/components/agent/chat/components/Inputbar/components/A2UIFloatingForm.tsx @@ -9,8 +9,13 @@ import { A2UITaskCard } from "../../A2UITaskCard"; interface A2UIFloatingFormProps { response: A2UIResponse; onSubmit: (formData: A2UIFormData) => void; + submitDisabled?: boolean; + isStale?: boolean; } +const STALE_STATUS_LABEL = "同步中"; +const STALE_FOOTER_TEXT = "正在同步最新上下文,表单暂时不可提交。"; + const Card = styled.div` position: relative; margin-bottom: 10px; @@ -34,14 +39,19 @@ const Card = styled.div` export function A2UIFloatingForm({ response, onSubmit, + submitDisabled = false, + isStale = false, }: A2UIFloatingFormProps) { return ( diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.test.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.test.tsx index b116b3d8a..fb4f19a7f 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { InputbarOverlayShell } from "./InputbarOverlayShell"; +import type { A2UIResponse } from "@/components/content-creator/a2ui/types"; vi.mock("../../TaskFiles", () => ({ TaskFileList: () =>
, @@ -66,6 +67,34 @@ function renderShell( return container; } +function createPendingA2UIForm(): A2UIResponse { + return { + id: "a2ui-floating-form", + version: "1.0", + root: "root", + data: {}, + components: [ + { + id: "title", + component: "Text", + text: "请确认开始方式", + variant: "body", + }, + { + id: "root", + component: "Column", + children: ["title"], + gap: 12, + align: "stretch", + }, + ], + submitAction: { + label: "继续处理", + action: { name: "submit" }, + }, + } as A2UIResponse; +} + describe("InputbarOverlayShell", () => { it("应把任务文件与额外浮层控件放进同一条透明 overlay row", () => { const container = renderShell({ @@ -102,4 +131,23 @@ describe("InputbarOverlayShell", () => { container.querySelector('[data-testid="inputbar-secondary-controls"]'), ).toBeNull(); }); + + it("A2UI 表单进入粘性保留期时应显示同步提示并禁用提交", () => { + const container = renderShell({ + taskFiles: [], + pendingA2UIForm: createPendingA2UIForm(), + pendingA2UIFormStale: true, + onA2UISubmit: vi.fn(), + }); + + const submitButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("继续处理"), + ) as HTMLButtonElement | undefined; + + expect(container.textContent).toContain("同步中"); + expect(container.textContent).toContain( + "正在同步最新上下文,表单暂时不可提交。", + ); + expect(submitButton?.disabled).toBe(true); + }); }); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx index 66f227af6..a238a35d7 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx @@ -1,7 +1,10 @@ import React, { type ChangeEvent, type RefObject } from "react"; import styled from "styled-components"; import type { TaskFile } from "../../TaskFiles"; -import type { A2UIResponse, A2UIFormData } from "@/components/content-creator/a2ui/types"; +import type { + A2UIFormData, + A2UIResponse, +} from "@/components/content-creator/a2ui/types"; import { A2UISubmissionNotice, type A2UISubmissionNoticeData, @@ -25,6 +28,7 @@ interface InputbarOverlayShellProps { submissionNotice?: A2UISubmissionNoticeData | null; isSubmissionNoticeVisible: boolean; pendingA2UIForm?: A2UIResponse | null; + pendingA2UIFormStale?: boolean; onA2UISubmit?: (formData: A2UIFormData) => void; fileInputRef: RefObject; onFileSelect: (event: ChangeEvent) => void; @@ -63,6 +67,7 @@ export const InputbarOverlayShell: React.FC = ({ submissionNotice, isSubmissionNoticeVisible, pendingA2UIForm, + pendingA2UIFormStale = false, onA2UISubmit, fileInputRef, onFileSelect, @@ -94,7 +99,12 @@ export const InputbarOverlayShell: React.FC = ({ /> ) : null} {pendingA2UIForm && onA2UISubmit ? ( - + ) : null} { await render(); expect(getValue().visibleForm).toEqual(form); + expect(getValue().isStale).toBe(false); await render({ form: null, }); expect(getValue().visibleForm).toEqual(form); + expect(getValue().isStale).toBe(true); await act(async () => { vi.advanceTimersByTime(1199); }); expect(getValue().visibleForm).toEqual(form); + expect(getValue().isStale).toBe(true); await act(async () => { vi.advanceTimersByTime(1); }); expect(getValue().visibleForm).toBeNull(); + expect(getValue().isStale).toBe(false); }); it("进入已提交提示后应立即清空旧表单,避免叠层残留", async () => { @@ -121,5 +125,6 @@ describe("useStickyA2UIForm", () => { clearImmediately: true, }); expect(getValue().visibleForm).toBeNull(); + expect(getValue().isStale).toBe(false); }); }); diff --git a/src/components/agent/chat/components/Inputbar/hooks/useStickyA2UIForm.ts b/src/components/agent/chat/components/Inputbar/hooks/useStickyA2UIForm.ts index abf6428a7..6fd9f051a 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useStickyA2UIForm.ts +++ b/src/components/agent/chat/components/Inputbar/hooks/useStickyA2UIForm.ts @@ -15,7 +15,11 @@ export function useStickyA2UIForm({ const [visibleForm, setVisibleForm] = useState( form || null, ); + const [isStale, setIsStale] = useState(false); const hideTimerRef = useRef | null>(null); + const visibleFormRef = useRef(visibleForm); + + visibleFormRef.current = visibleForm; useEffect(() => { return () => { @@ -33,16 +37,26 @@ export function useStickyA2UIForm({ if (form) { setVisibleForm(form); + setIsStale(false); return; } if (clearImmediately) { setVisibleForm(null); + setIsStale(false); return; } + if (!visibleFormRef.current) { + setVisibleForm(null); + setIsStale(false); + return; + } + + setIsStale(true); hideTimerRef.current = setTimeout(() => { setVisibleForm(null); + setIsStale(false); hideTimerRef.current = null; }, holdMs); @@ -56,5 +70,6 @@ export function useStickyA2UIForm({ return { visibleForm, + isStale, }; } diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index 5c026e48e..8ab7fa9dd 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -6,6 +6,7 @@ import { Inputbar } from "./index"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; +import type { A2UIResponse } from "@/components/content-creator/a2ui/types"; const mockCharacterMention = vi.fn< @@ -202,6 +203,7 @@ afterEach(() => { }); mounted.container.remove(); } + vi.useRealTimers(); vi.clearAllMocks(); }); @@ -221,17 +223,54 @@ function renderInputbar( skills: [], }; - act(() => { - root.render(); - }); + const render = ( + nextProps?: Partial>, + ) => { + act(() => { + root.render(); + }); + }; + + render(); mountedRoots.push({ root, container }); - return container; + return { + container, + rerender: render, + }; +} + +function createPendingA2UIForm(): A2UIResponse { + return { + id: "a2ui-pending", + version: "1.0", + root: "root", + data: {}, + components: [ + { + id: "title", + component: "Text", + text: "请确认开始方式", + variant: "body", + }, + { + id: "root", + component: "Column", + children: ["title"], + gap: 12, + align: "stretch", + }, + ], + submitAction: { + label: "继续处理", + action: { name: "submit" }, + }, + } as A2UIResponse; } describe("Inputbar", () => { it("即使角色和技能为空,也应挂载 CharacterMention", async () => { - const container = renderInputbar(); + const { container } = renderInputbar(); await act(async () => { await Promise.resolve(); }); @@ -278,7 +317,7 @@ describe("Inputbar", () => { }); it("存在 executionRuntime 时应展示最近执行模型与结构化输出提示", async () => { - const container = renderInputbar({ + const { container } = renderInputbar({ providerType: "openai", setProviderType: vi.fn(), model: "gpt-5.4-mini", @@ -353,7 +392,7 @@ describe("Inputbar", () => { it("通用聊天态复杂任务应显示 Team 建议并支持开启多代理", async () => { const onToolStatesChange = vi.fn(); - const container = renderInputbar({ + const { container } = renderInputbar({ input: "请拆分这个多代理调试任务,分别分析、实现、验证,再由主线程汇总结论。", activeTheme: "general", toolStates: { @@ -390,7 +429,7 @@ describe("Inputbar", () => { }); it("仅在 Team mode 开启时显示 TeamSelector", async () => { - const container = renderInputbar({ + const { container } = renderInputbar({ activeTheme: "general", toolStates: { webSearch: false, @@ -408,7 +447,7 @@ describe("Inputbar", () => { container.querySelector('[data-testid="team-selector-stub"]'), ).toBeNull(); - const enabledContainer = renderInputbar({ + const { container: enabledContainer } = renderInputbar({ activeTheme: "general", toolStates: { webSearch: false, @@ -429,7 +468,7 @@ describe("Inputbar", () => { it("未开启 Team mode 时应只保留图标开关,并可直接启用", async () => { const onToolStatesChange = vi.fn(); - const container = renderInputbar({ + const { container } = renderInputbar({ activeTheme: "general", toolStates: { webSearch: false, @@ -467,7 +506,7 @@ describe("Inputbar", () => { }); it("点击多代理图标后应自动透传 Team 配置面板打开令牌", async () => { - const container = renderInputbar({ + const { container } = renderInputbar({ activeTheme: "general", }); @@ -500,7 +539,7 @@ describe("Inputbar", () => { }); it("复杂任务但未开启 Team 时,保留推荐提示但不再渲染重复入口", async () => { - const container = renderInputbar({ + const { container } = renderInputbar({ activeTheme: "general", input: "请把这个跨模块问题拆分成分析、实现、验证三个并行子任务再汇总", toolStates: { @@ -531,7 +570,7 @@ describe("Inputbar", () => { it("社媒主题默认应自动注入 social_post_with_cover skill", async () => { const onSend = vi.fn(); - const container = renderInputbar({ + const { container } = renderInputbar({ activeTheme: "social-media", input: "写一篇春季上新种草文案", onSend, @@ -562,7 +601,7 @@ describe("Inputbar", () => { it("社媒主题输入 slash 命令时不应重复注入默认 skill", async () => { const onSend = vi.fn(); - const container = renderInputbar({ + const { container } = renderInputbar({ activeTheme: "social-media", input: "/custom_skill 写一篇品牌故事", onSend, @@ -618,7 +657,7 @@ describe("Inputbar", () => { }); it("主题工作台在待启动状态下不应显示闸门条", async () => { - const container = renderInputbar({ + const { container } = renderInputbar({ variant: "theme_workbench", themeWorkbenchGate: { key: "draft_start", @@ -639,7 +678,7 @@ describe("Inputbar", () => { it("主题工作台闸门快捷操作应能快速填充输入", async () => { const setInput = vi.fn(); - const container = renderInputbar({ + const { container } = renderInputbar({ variant: "theme_workbench", setInput, themeWorkbenchGate: { @@ -677,7 +716,7 @@ describe("Inputbar", () => { it("主题工作台生成中应展示任务面板并支持停止", async () => { const onStop = vi.fn(); - const container = renderInputbar({ + const { container } = renderInputbar({ variant: "theme_workbench", isLoading: true, onStop, @@ -707,7 +746,7 @@ describe("Inputbar", () => { }); it("主题工作台生成中应支持折叠与展开待办列表", async () => { - const container = renderInputbar({ + const { container } = renderInputbar({ variant: "theme_workbench", isLoading: true, workflowSteps: [ @@ -747,7 +786,7 @@ describe("Inputbar", () => { }); it("主题工作台在 auto_running 状态下应展示生成面板(不依赖 isLoading)", async () => { - const container = renderInputbar({ + const { container } = renderInputbar({ variant: "theme_workbench", isLoading: false, themeWorkbenchRunState: "auto_running", @@ -768,7 +807,7 @@ describe("Inputbar", () => { }); it("主题工作台在 await_user_decision 状态下应显示输入框", async () => { - const container = renderInputbar({ + const { container } = renderInputbar({ variant: "theme_workbench", isLoading: true, themeWorkbenchRunState: "await_user_decision", @@ -787,4 +826,49 @@ describe("Inputbar", () => { ).toBeTruthy(); expect(container.textContent).not.toContain("正在生成中"); }); + + it("pending A2UI 表单短暂消失时应保留可见,但进入不可提交的同步态", async () => { + vi.useFakeTimers(); + const pendingForm = createPendingA2UIForm(); + const { container, rerender } = renderInputbar({ + pendingA2UIForm: pendingForm, + onA2UISubmit: vi.fn(), + }); + + await act(async () => { + await Promise.resolve(); + }); + + let submitButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("继续处理"), + ) as HTMLButtonElement | undefined; + + expect(submitButton?.disabled).toBe(false); + + rerender({ + pendingA2UIForm: null, + onA2UISubmit: vi.fn(), + }); + + await act(async () => { + await Promise.resolve(); + }); + + submitButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("继续处理"), + ) as HTMLButtonElement | undefined; + + expect(container.textContent).toContain("同步中"); + expect(container.textContent).toContain( + "正在同步最新上下文,表单暂时不可提交。", + ); + expect(submitButton?.disabled).toBe(true); + + await act(async () => { + vi.advanceTimersByTime(1200); + }); + + expect(container.textContent).not.toContain("继续处理"); + vi.useRealTimers(); + }); }); diff --git a/src/components/agent/chat/components/Inputbar/index.tsx b/src/components/agent/chat/components/Inputbar/index.tsx index 3a1dcff42..6c01ad7ce 100644 --- a/src/components/agent/chat/components/Inputbar/index.tsx +++ b/src/components/agent/chat/components/Inputbar/index.tsx @@ -188,6 +188,7 @@ export const Inputbar: React.FC = ({ themeWorkbenchQueueItems, renderThemeWorkbenchGeneratingPanel, visiblePendingA2UIForm, + isPendingA2UIFormStale, visibleA2UISubmissionNotice, isA2UISubmissionNoticeVisible, activeSkill, @@ -242,6 +243,7 @@ export const Inputbar: React.FC = ({ submissionNotice={visibleA2UISubmissionNotice} isSubmissionNoticeVisible={isA2UISubmissionNoticeVisible} pendingA2UIForm={visiblePendingA2UIForm} + pendingA2UIFormStale={isPendingA2UIFormStale} onA2UISubmit={onA2UISubmit} fileInputRef={fileInputRef} onFileSelect={handleFileSelect} diff --git a/src/components/agent/chat/utils/threadReliabilityView.ts b/src/components/agent/chat/utils/threadReliabilityView.ts index d8d45cf4c..fce5fc026 100644 --- a/src/components/agent/chat/utils/threadReliabilityView.ts +++ b/src/components/agent/chat/utils/threadReliabilityView.ts @@ -83,6 +83,8 @@ const NON_BLOCKING_RUNTIME_WARNING_CODES = new Set([ "artifact_document_repaired", ]); +type RuntimeIssueThreadItem = Extract; + function normalizeText(value?: string | null): string | null { if (typeof value !== "string") { return null; @@ -535,7 +537,7 @@ function deriveFallbackIncidents( const issueItem = [...threadItems] .reverse() - .find((item) => { + .find((item): item is RuntimeIssueThreadItem => { if (item.type === "error") { return true; } diff --git a/src/components/agent/chat/workspace/WorkspaceMainArea.tsx b/src/components/agent/chat/workspace/WorkspaceMainArea.tsx index cb70626f0..d9fac0bda 100644 --- a/src/components/agent/chat/workspace/WorkspaceMainArea.tsx +++ b/src/components/agent/chat/workspace/WorkspaceMainArea.tsx @@ -53,6 +53,7 @@ export function WorkspaceMainArea({ canvasContent={canvasContent} chatPanelWidth={chatPanelWidth} chatPanelMinWidth={chatPanelMinWidth} + forceOpenChatPanel={hasPendingA2UIForm} /> {generalWorkbenchDialog} diff --git a/src/components/agent/chat/workspace/WorkspaceStyles.tsx b/src/components/agent/chat/workspace/WorkspaceStyles.tsx index 8b54e6b1b..bde92dd64 100644 --- a/src/components/agent/chat/workspace/WorkspaceStyles.tsx +++ b/src/components/agent/chat/workspace/WorkspaceStyles.tsx @@ -278,6 +278,7 @@ interface LayoutTransitionRenderGateProps { canvasContent: ReactNode; chatPanelWidth?: string; chatPanelMinWidth?: string; + forceOpenChatPanel?: boolean; } export const LayoutTransitionRenderGate = memo( @@ -287,6 +288,7 @@ export const LayoutTransitionRenderGate = memo( canvasContent, chatPanelWidth, chatPanelMinWidth, + forceOpenChatPanel = false, }: LayoutTransitionRenderGateProps) => ( ), @@ -304,7 +307,8 @@ export const LayoutTransitionRenderGate = memo( previous.chatContent === next.chatContent && previous.canvasContent === next.canvasContent && previous.chatPanelWidth === next.chatPanelWidth && - previous.chatPanelMinWidth === next.chatPanelMinWidth, + previous.chatPanelMinWidth === next.chatPanelMinWidth && + previous.forceOpenChatPanel === next.forceOpenChatPanel, ); LayoutTransitionRenderGate.displayName = "LayoutTransitionRenderGate"; diff --git a/src/components/agent/chat/workspace/generalCanvasPreviewState.test.ts b/src/components/agent/chat/workspace/generalCanvasPreviewState.test.ts new file mode 100644 index 000000000..7889cd42c --- /dev/null +++ b/src/components/agent/chat/workspace/generalCanvasPreviewState.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState"; + +describe("hasRenderableGeneralCanvasPreview", () => { + it("关闭状态或空白内容不应视为真实预览", () => { + expect( + hasRenderableGeneralCanvasPreview({ + isOpen: false, + content: "# 草稿", + }), + ).toBe(false); + + expect( + hasRenderableGeneralCanvasPreview({ + isOpen: true, + content: " ", + }), + ).toBe(false); + }); + + it("打开且有正文时应视为真实预览", () => { + expect( + hasRenderableGeneralCanvasPreview({ + isOpen: true, + content: "# 草稿\n\n这里有内容", + }), + ).toBe(true); + }); +}); diff --git a/src/components/agent/chat/workspace/generalCanvasPreviewState.ts b/src/components/agent/chat/workspace/generalCanvasPreviewState.ts new file mode 100644 index 000000000..9c35d5dbe --- /dev/null +++ b/src/components/agent/chat/workspace/generalCanvasPreviewState.ts @@ -0,0 +1,14 @@ +import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge"; + +export function hasRenderableGeneralCanvasPreview( + state: + | Pick + | null + | undefined, +): boolean { + if (!state?.isOpen) { + return false; + } + + return state.content.trim().length > 0; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.test.tsx new file mode 100644 index 000000000..cb92c460b --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.test.tsx @@ -0,0 +1,180 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useWorkspaceA2UIRuntime } from "./useWorkspaceA2UIRuntime"; +import type { Message } from "../types"; + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function createAssistantMessage(id: string, content: string): Message { + return { + id, + role: "assistant", + content, + timestamp: new Date("2026-03-27T10:00:00.000Z"), + }; +} + +function createUserMessage(id: string, content: string): Message { + return { + id, + role: "user", + content, + timestamp: new Date("2026-03-27T10:00:10.000Z"), + }; +} + +function renderHook(_initialProps: HookProps) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let latestValue: ReturnType | null = null; + + function Probe(currentProps: HookProps) { + latestValue = useWorkspaceA2UIRuntime(currentProps); + return null; + } + + const render = async (nextProps: HookProps) => { + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + render, + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.restoreAllMocks(); +}); + +describe("useWorkspaceA2UIRuntime", () => { + it("内联 A2UI 在消息内容短暂变成不完整 JSON 时应保留最后一份有效表单", async () => { + const validA2UIMessage = createAssistantMessage( + "assistant-a2ui", + [ + "```a2ui", + '{"type":"form","title":"补充信息","fields":[{"id":"answer","type":"text","label":"你的回答"}],"submitLabel":"继续处理"}', + "```", + ].join("\n"), + ); + const truncatedA2UIMessage = createAssistantMessage( + "assistant-a2ui", + ['```a2ui', '{"type":"form","title":"补充信息"'].join("\n"), + ); + const { render, getValue } = renderHook({ + messages: [validA2UIMessage], + }); + + await render({ messages: [validA2UIMessage] }); + expect(getValue().pendingA2UIForm?.components.length).toBeGreaterThan(0); + expect(getValue().a2uiSubmissionNotice).toBeNull(); + + await render({ messages: [truncatedA2UIMessage] }); + expect(getValue().pendingA2UIForm?.components.length).toBeGreaterThan(0); + expect(getValue().a2uiSubmissionNotice).toBeNull(); + }); + + it("promoted action_required 在 pending 短暂切到 queued 时应继续保留输入区 A2UI", async () => { + const pendingMessage: Message = { + id: "assistant-action", + role: "assistant", + content: "请补充执行偏好。", + timestamp: new Date("2026-03-27T10:01:00.000Z"), + actionRequests: [ + { + requestId: "req-a2ui-1", + actionType: "ask_user", + prompt: "请选择执行模式", + questions: [ + { + question: "你希望如何执行?", + header: "执行模式", + options: [{ label: "自动执行" }, { label: "手动确认" }], + }, + ], + status: "pending", + }, + ], + }; + const queuedMessage: Message = { + ...pendingMessage, + actionRequests: [ + { + ...pendingMessage.actionRequests![0], + status: "queued", + }, + ], + }; + const { render, getValue } = renderHook({ + messages: [pendingMessage], + }); + + await render({ messages: [pendingMessage] }); + expect(getValue().pendingA2UIForm?.id).toBe( + "action-request-req-a2ui-1", + ); + + await render({ messages: [queuedMessage] }); + expect(getValue().pendingA2UIForm?.id).toBe( + "action-request-req-a2ui-1", + ); + expect(getValue().a2uiSubmissionNotice).toBeNull(); + }); + + it("线程已切走且源消息消失时,应清理保留中的旧 A2UI", async () => { + const validA2UIMessage = createAssistantMessage( + "assistant-old", + [ + "```a2ui", + '{"type":"form","title":"补充信息","fields":[{"id":"answer","type":"text","label":"你的回答"}],"submitLabel":"继续处理"}', + "```", + ].join("\n"), + ); + const { render, getValue } = renderHook({ + messages: [validA2UIMessage], + }); + + await render({ messages: [validA2UIMessage] }); + expect(getValue().pendingA2UIForm?.components.length).toBeGreaterThan(0); + + await render({ + messages: [createUserMessage("user-new-thread", "这是新的会话内容")], + }); + expect(getValue().pendingA2UIForm).toBeNull(); + expect(getValue().a2uiSubmissionNotice).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts index ddc8a7665..2567a1fbb 100644 --- a/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; import type { A2UIResponse } from "@/components/content-creator/a2ui/types"; import { @@ -14,6 +14,22 @@ interface A2UISubmissionNotice { summary: string; } +type PendingA2UIResolution = + | { + form: A2UIResponse; + source: { + kind: "assistant_message" | "legacy_message"; + messageId: string; + }; + } + | { + form: A2UIResponse; + source: { + kind: "action_request"; + requestId: string; + }; + }; + interface UseWorkspaceA2UIRuntimeParams { messages: Message[]; } @@ -45,7 +61,7 @@ export function useWorkspaceA2UIRuntime({ ); }, [messages]); - const pendingMessageA2UIForm = useMemo(() => { + const pendingMessageA2UI = useMemo(() => { for (let i = messages.length - 1; i >= 0; i -= 1) { const message = messages[i]; @@ -66,7 +82,13 @@ export function useWorkspaceA2UIRuntime({ for (let j = parsed.parts.length - 1; j >= 0; j -= 1) { const part = parsed.parts[j]; if (part.type === "a2ui" && typeof part.content !== "string") { - return part.content; + return { + form: part.content, + source: { + kind: "assistant_message", + messageId: message.id, + }, + }; } } } catch { @@ -76,6 +98,7 @@ export function useWorkspaceA2UIRuntime({ return null; }, [messages]); + const pendingMessageA2UIForm = pendingMessageA2UI?.form ?? null; const pendingPromotedA2UIActionRequest = useMemo(() => { if (pendingMessageA2UIForm) { @@ -100,7 +123,7 @@ export function useWorkspaceA2UIRuntime({ return null; }, [messages, pendingMessageA2UIForm]); - const pendingLegacyQuestionnaireA2UIForm = useMemo(() => { + const pendingLegacyQuestionnaireA2UI = useMemo(() => { if (pendingMessageA2UIForm || pendingActionRequest) { return null; } @@ -120,30 +143,49 @@ export function useWorkspaceA2UIRuntime({ return null; } - return buildLegacyQuestionnaireA2UI(message.content || ""); + const form = buildLegacyQuestionnaireA2UI(message.content || ""); + if (!form) { + return null; + } + + return { + form, + source: { + kind: "legacy_message", + messageId: message.id, + }, + }; } return null; }, [messages, pendingActionRequest, pendingMessageA2UIForm]); + const pendingLegacyQuestionnaireA2UIForm = + pendingLegacyQuestionnaireA2UI?.form ?? null; - const pendingA2UIForm = useMemo(() => { - if (pendingMessageA2UIForm) { - return pendingMessageA2UIForm; + const resolvedPendingA2UI = useMemo(() => { + if (pendingMessageA2UI) { + return pendingMessageA2UI; } if (pendingPromotedA2UIActionRequest) { - return buildActionRequestA2UI(pendingPromotedA2UIActionRequest); + return { + form: buildActionRequestA2UI(pendingPromotedA2UIActionRequest), + source: { + kind: "action_request", + requestId: pendingPromotedA2UIActionRequest.requestId, + }, + }; } - return pendingLegacyQuestionnaireA2UIForm; + return pendingLegacyQuestionnaireA2UI; }, [ - pendingLegacyQuestionnaireA2UIForm, - pendingMessageA2UIForm, + pendingLegacyQuestionnaireA2UI, + pendingMessageA2UI, pendingPromotedA2UIActionRequest, ]); const a2uiSubmissionNotice = useMemo(() => { - if (pendingA2UIForm) { + if (resolvedPendingA2UI) { return null; } @@ -194,12 +236,50 @@ export function useWorkspaceA2UIRuntime({ } return null; - }, [messages, pendingA2UIForm]); + }, [messages, resolvedPendingA2UI]); + + const [retainedPendingA2UI, setRetainedPendingA2UI] = + useState(resolvedPendingA2UI); + + useEffect(() => { + if (resolvedPendingA2UI) { + setRetainedPendingA2UI(resolvedPendingA2UI); + return; + } + + if (a2uiSubmissionNotice) { + setRetainedPendingA2UI(null); + return; + } + + setRetainedPendingA2UI((previous) => { + if (!previous) { + return null; + } + + if (previous.source.kind === "action_request") { + const requestStillExists = messages.some((message) => + (message.actionRequests || []).some( + (request) => request.requestId === previous.source.requestId, + ), + ); + return requestStillExists ? previous : null; + } + + const sourceMessageStillExists = messages.some( + (message) => message.id === previous.source.messageId, + ); + return sourceMessageStillExists ? previous : null; + }); + }, [a2uiSubmissionNotice, messages, resolvedPendingA2UI]); + + const pendingA2UIForm = + resolvedPendingA2UI?.form ?? retainedPendingA2UI?.form ?? null; useEffect(() => { if ( !pendingActionRequest || - pendingA2UIForm || + resolvedPendingA2UI || !isActionRequestA2UICompatible(pendingActionRequest) ) { return; @@ -211,7 +291,7 @@ export function useWorkspaceA2UIRuntime({ prompt: pendingActionRequest.prompt, scope: pendingActionRequest.scope, }); - }, [pendingA2UIForm, pendingActionRequest]); + }, [pendingActionRequest, resolvedPendingA2UI]); return { a2uiSubmissionNotice, diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx index 4a7a99494..c04fb4dd2 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx @@ -30,6 +30,12 @@ function renderHook(props?: Partial) { normalizedEntryTheme: "general", shouldBootstrapCanvasOnEntry: false, canvasState: null, + generalCanvasState: { + isOpen: false, + contentType: "empty", + content: "", + isEditing: false, + }, showTeamWorkspaceBoard: false, hasCurrentCanvasArtifact: false, currentCanvasArtifactType: null, @@ -128,4 +134,24 @@ describe("useWorkspaceCanvasLayoutRuntime", () => { isOpen: false, }); }); + + it("general 空白画布没有真实预览目标时应回退到聊天态", async () => { + const setLayoutMode = vi.fn(); + const { render } = renderHook({ + layoutMode: "chat-canvas", + generalCanvasState: { + isOpen: true, + contentType: "markdown", + content: " ", + isEditing: false, + }, + setLayoutMode, + }); + + await render(); + + expect( + setLayoutMode.mock.calls.some((call) => call[0] === "chat"), + ).toBe(true); + }); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts index d8f79d374..0b9e8e6ab 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts @@ -5,6 +5,7 @@ import type { CanvasState as GeneralCanvasState } from "@/components/general-cha import type { LayoutMode, ThemeType } from "@/components/content-creator/types"; import { isContentCreationTheme } from "@/components/content-creator/utils/systemPrompt"; import type { CanvasWorkbenchLayoutMode } from "../components/CanvasWorkbenchLayout"; +import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState"; const FALLBACK_CANVAS_CONTENT = "# 新文档\n\n在这里开始编写内容..."; @@ -22,6 +23,7 @@ interface UseWorkspaceCanvasLayoutRuntimeParams { normalizedEntryTheme: ThemeType; shouldBootstrapCanvasOnEntry: boolean; canvasState: CanvasStateUnion | null; + generalCanvasState: GeneralCanvasState; showTeamWorkspaceBoard: boolean; hasCurrentCanvasArtifact: boolean; currentCanvasArtifactType?: string | null; @@ -55,6 +57,7 @@ export function useWorkspaceCanvasLayoutRuntime({ normalizedEntryTheme, shouldBootstrapCanvasOnEntry, canvasState, + generalCanvasState, showTeamWorkspaceBoard, hasCurrentCanvasArtifact, currentCanvasArtifactType, @@ -172,6 +175,36 @@ export function useWorkspaceCanvasLayoutRuntime({ setGeneralCanvasState, ]); + useEffect(() => { + if ( + activeTheme !== "general" || + !showChatPanel || + layoutMode === "chat" || + showTeamWorkspaceBoard || + hasCurrentCanvasArtifact || + currentImageWorkbenchActive || + isBrowserAssistCanvasVisible + ) { + return; + } + + if (hasRenderableGeneralCanvasPreview(generalCanvasState)) { + return; + } + + setLayoutMode("chat"); + }, [ + activeTheme, + currentImageWorkbenchActive, + generalCanvasState, + hasCurrentCanvasArtifact, + isBrowserAssistCanvasVisible, + layoutMode, + setLayoutMode, + showChatPanel, + showTeamWorkspaceBoard, + ]); + useEffect(() => { const shouldAutoHideTopicSidebar = showChatPanel && diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx index b688229db..283887b39 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx @@ -37,6 +37,7 @@ import { type TeamWorkbenchSurfaceProps, type UseTeamWorkbenchPresentationParams, } from "./teamWorkbenchPresentation"; +import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState"; type ArtifactPreviewBaseProps = Omit< ComponentProps, @@ -451,7 +452,7 @@ export function useWorkspaceCanvasPreviewPresentation({ return Boolean( (artifactPreview.currentCanvasArtifact && artifactPreview.displayedCanvasArtifact) || - defaultPreview.generalCanvasState.isOpen, + hasRenderableGeneralCanvasPreview(defaultPreview.generalCanvasState), ); } @@ -463,7 +464,7 @@ export function useWorkspaceCanvasPreviewPresentation({ artifactPreview.currentCanvasArtifact, artifactPreview.displayedCanvasArtifact, defaultPreview.canvasRenderTheme, - defaultPreview.generalCanvasState.isOpen, + defaultPreview.generalCanvasState, defaultPreview.resolvedCanvasState, imageWorkbench.active, loading.shouldShowCanvasLoadingState, diff --git a/src/components/agent/chat/workspace/workbenchPreview.tsx b/src/components/agent/chat/workspace/workbenchPreview.tsx index 36ed794ed..87b7de0fa 100644 --- a/src/components/agent/chat/workspace/workbenchPreview.tsx +++ b/src/components/agent/chat/workspace/workbenchPreview.tsx @@ -15,6 +15,7 @@ import type { CanvasWorkbenchDefaultPreview } from "../components/CanvasWorkbenc import { ImageWorkbenchCanvas } from "../components/ImageWorkbenchCanvas"; import { TeamWorkspaceBoard } from "../components/TeamWorkspaceBoard"; import { ArtifactWorkbenchShell } from "./ArtifactWorkbenchShell"; +import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState"; import { wrapPreviewWithWorkbenchTrigger } from "./workbenchPreviewHelpers"; import { resolveArtifactProtocolDocumentPayload } from "@/lib/artifact-protocol"; import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; @@ -237,7 +238,10 @@ export function WorkspaceLiveCanvasPreview({ } if (canvasRenderTheme === "general") { - if (generalCanvasPanelProps?.state.isOpen) { + if ( + generalCanvasPanelProps && + hasRenderableGeneralCanvasPreview(generalCanvasPanelProps.state) + ) { return ( chat
} canvasContent={
canvas
} + forceOpenChatPanel={forceOpenChatPanel} /> ); @@ -212,6 +219,37 @@ describe("LayoutTransition", () => { ).toBe("closed"); }); + it("紧凑抽屉态存在待处理 A2UI 时应自动展开聊天区", async () => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + writable: true, + value: 1080, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + writable: true, + value: 720, + }); + + const { container } = mountHarness( + LayoutHarness, + { mode: "chat-canvas", forceOpenChatPanel: true }, + mountedRoots, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="layout-chat-panel"]') + ?.getAttribute("data-overlay-state"), + ).toBe("open"); + expect( + container.querySelector('[data-testid="layout-chat-content"]'), + ).not.toBeNull(); + }); + it("画布内容为空时应退回聊天布局,避免保留空白画布列", () => { const { container } = mountHarness( EmptyCanvasLayoutHarness, diff --git a/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx b/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx index 02c8d4307..138a92f12 100644 --- a/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx +++ b/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx @@ -181,6 +181,8 @@ interface LayoutTransitionProps { chatPanelWidth?: string; /** chat-canvas 模式下聊天面板最小宽度 */ chatPanelMinWidth?: string; + /** 紧凑抽屉态下强制展开聊天区 */ + forceOpenChatPanel?: boolean; } /** @@ -197,6 +199,7 @@ export const LayoutTransition: React.FC = memo( chatPanelChrome = "panel", chatPanelWidth, chatPanelMinWidth, + forceOpenChatPanel = false, }) => { const hasCanvasContent = React.Children.count(canvasContent) > 0; const effectiveMode: LayoutMode = hasCanvasContent ? mode : "chat"; @@ -245,6 +248,25 @@ export const LayoutTransition: React.FC = memo( } }, [compactChatCanvasOverlay, effectiveMode]); + useEffect(() => { + if ( + !forceOpenChatPanel || + !compactChatCanvasOverlay || + effectiveMode !== "chat-canvas" || + compactChatPanelOpen + ) { + return; + } + + setCompactChatPanelOpen(true); + emitCompactRightPanelOpen({ source: "chat" }); + }, [ + compactChatCanvasOverlay, + compactChatPanelOpen, + effectiveMode, + forceOpenChatPanel, + ]); + useEffect(() => { if (!compactChatCanvasOverlay || effectiveMode !== "chat-canvas") { return; diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index cac4b4331..1ff658d7b 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -604,6 +604,30 @@ type MockClawSolutionSummary = { reasonCode?: string; }; +type MockReviewDecisionRequest = { + session_id?: string; + sessionId?: string; + decision_status?: string; + decisionStatus?: string; + decision_summary?: string; + decisionSummary?: string; + chosen_fix_strategy?: string; + chosenFixStrategy?: string; + risk_level?: string; + riskLevel?: string; + risk_tags?: string[]; + riskTags?: string[]; + human_reviewer?: string; + humanReviewer?: string; + reviewed_at?: string | null; + reviewedAt?: string | null; + followup_actions?: string[]; + followupActions?: string[]; + regression_requirements?: string[]; + regressionRequirements?: string[]; + notes?: string; +}; + const mockClawSolutionCatalog: MockClawSolutionSummary[] = [ { id: "web-research-brief", @@ -2066,7 +2090,11 @@ const defaultMocks: Record = { }, ], }), - agent_runtime_save_review_decision: ({ request }) => ({ + agent_runtime_save_review_decision: ({ + request, + }: { + request?: MockReviewDecisionRequest; + }) => ({ session_id: request?.session_id || request?.sessionId || "mock-session", thread_id: "mock-thread", workspace_root: "/mock/workspace",