feat: finalize latest v0.97.0 release payload

This commit is contained in:
coso
2026-03-27 21:46:17 +08:00
parent dedf46c4be
commit 02ed1cd5b9
42 changed files with 1913 additions and 89 deletions
+2 -2
View File
@@ -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'
+2
View File
@@ -49,6 +49,8 @@ docs/tech/
# Issues tracking (internal use only)
.issues/
.lime/harness/
.lime/artifacts/
# 开发环境
**/.local/
+8 -7
View File
@@ -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,未在本次同版发布中额外扩范围消除
### 📝 文档
+1 -1
View File
@@ -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 报告
+19 -6
View File
@@ -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
## 非目标
@@ -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"
@@ -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 验证结果。"
]
}
}
+51
View File
@@ -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}`),
+32 -9
View File
@@ -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);
+140
View File
@@ -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,
+43
View File
@@ -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("");
+188
View File
@@ -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("## 推荐动作");
@@ -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: {
@@ -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<unknown>((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);
});
@@ -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,
}),
}),
]),
);
});
});
+18 -8
View File
@@ -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) {
@@ -81,6 +81,7 @@ struct AnalysisContextDocument {
replay: AnalysisReplaySection,
handoff: AnalysisHandoffSection,
evidence: AnalysisEvidenceSection,
observability: AnalysisObservabilitySection,
reading_order: Vec<String>,
external_analysis_contract: AnalysisExternalContract,
human_review_checklist: Vec<String>,
@@ -146,6 +147,14 @@ struct AnalysisEvidenceSection {
summary_excerpt: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AnalysisObservabilitySection {
summary: Value,
correlation_keys: Vec<String>,
gap_signals: Vec<String>,
}
#[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<String>) -> Option<String> {
.filter(|text| !text.is_empty())
}
fn collect_observability_correlation_keys(summary: &Value) -> Vec<String> {
summary
.pointer("/correlation/correlationKeys")
.map(value_string_list)
.unwrap_or_default()
}
fn collect_observability_gap_signals(summary: &Value) -> Vec<String> {
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()));
}
@@ -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::<Vec<_>>()
.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<String, String> {
@@ -361,6 +402,7 @@ fn build_runtime_json(
"updatedAt": session.updated_at
})
}).collect::<Vec<_>>(),
"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<String, String> {
@@ -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<String> {
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::<Vec<_>>(),
"queuedTurnIds": thread_read.queued_turns.iter().map(|item| item.queued_turn_id.clone()).collect::<Vec<_>>(),
"subagentSessionIds": detail.child_subagent_sessions.iter().map(|item| item.id.clone()).collect::<Vec<_>>()
},
"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<Value> {
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<Value> {
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<Value> {
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::<Vec<_>>()
})
.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::<Vec<_>>()
})
.unwrap_or_default();
if signals.is_empty() {
"无".to_string()
} else {
signals.join("、")
}
}
fn serialize_enum_as_string<T: Serialize>(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\""));
}
}
@@ -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<String, String> {
@@ -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<String, String> {
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\""));
}
}
@@ -2075,6 +2075,7 @@ export function AgentChatWorkspace({
normalizedEntryTheme,
shouldBootstrapCanvasOnEntry,
canvasState,
generalCanvasState,
showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard,
hasCurrentCanvasArtifact: Boolean(currentCanvasArtifact),
currentCanvasArtifactType: currentCanvasArtifact?.type,
@@ -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 (
<Card>
<A2UITaskCard
response={response}
onSubmit={onSubmit}
submitDisabled={submitDisabled}
compact={true}
preset={CHAT_FLOATING_A2UI_TASK_CARD_PRESET}
statusLabel={isStale ? STALE_STATUS_LABEL : undefined}
footerText={isStale ? STALE_FOOTER_TEXT : undefined}
className="m-0"
/>
</Card>
@@ -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: () => <div data-testid="task-file-list" />,
@@ -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);
});
});
@@ -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<HTMLInputElement>;
onFileSelect: (event: ChangeEvent<HTMLInputElement>) => void;
@@ -63,6 +67,7 @@ export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
submissionNotice,
isSubmissionNoticeVisible,
pendingA2UIForm,
pendingA2UIFormStale = false,
onA2UISubmit,
fileInputRef,
onFileSelect,
@@ -94,7 +99,12 @@ export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
/>
) : null}
{pendingA2UIForm && onA2UISubmit ? (
<A2UIFloatingForm response={pendingA2UIForm} onSubmit={onA2UISubmit} />
<A2UIFloatingForm
response={pendingA2UIForm}
onSubmit={onA2UISubmit}
submitDisabled={pendingA2UIFormStale}
isStale={pendingA2UIFormStale}
/>
) : null}
<input
ref={fileInputRef}
@@ -181,7 +181,10 @@ export function useInputbarController({
a2uiSubmissionNotice,
});
const { visibleForm: visiblePendingA2UIForm } = useStickyA2UIForm({
const {
visibleForm: visiblePendingA2UIForm,
isStale: isPendingA2UIFormStale,
} = useStickyA2UIForm({
form: pendingA2UIForm,
clearImmediately: Boolean(a2uiSubmissionNotice),
});
@@ -272,6 +275,7 @@ export function useInputbarController({
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
visiblePendingA2UIForm,
isPendingA2UIFormStale,
visibleA2UISubmissionNotice,
isA2UISubmissionNoticeVisible,
activeSkill,
@@ -89,21 +89,25 @@ describe("useStickyA2UIForm", () => {
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);
});
});
@@ -15,7 +15,11 @@ export function useStickyA2UIForm({
const [visibleForm, setVisibleForm] = useState<A2UIResponse | null>(
form || null,
);
const [isStale, setIsStale] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const visibleFormRef = useRef<A2UIResponse | null>(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,
};
}
@@ -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(<Inputbar {...defaultProps} {...props} />);
});
const render = (
nextProps?: Partial<React.ComponentProps<typeof Inputbar>>,
) => {
act(() => {
root.render(<Inputbar {...defaultProps} {...props} {...nextProps} />);
});
};
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();
});
});
@@ -188,6 +188,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
visiblePendingA2UIForm,
isPendingA2UIFormStale,
visibleA2UISubmissionNotice,
isA2UISubmissionNoticeVisible,
activeSkill,
@@ -242,6 +243,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
submissionNotice={visibleA2UISubmissionNotice}
isSubmissionNoticeVisible={isA2UISubmissionNoticeVisible}
pendingA2UIForm={visiblePendingA2UIForm}
pendingA2UIFormStale={isPendingA2UIFormStale}
onA2UISubmit={onA2UISubmit}
fileInputRef={fileInputRef}
onFileSelect={handleFileSelect}
@@ -83,6 +83,8 @@ const NON_BLOCKING_RUNTIME_WARNING_CODES = new Set([
"artifact_document_repaired",
]);
type RuntimeIssueThreadItem = Extract<AgentThreadItem, { type: "error" | "warning" }>;
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;
}
@@ -53,6 +53,7 @@ export function WorkspaceMainArea({
canvasContent={canvasContent}
chatPanelWidth={chatPanelWidth}
chatPanelMinWidth={chatPanelMinWidth}
forceOpenChatPanel={hasPendingA2UIForm}
/>
</ThemeWorkbenchLayoutShell>
{generalWorkbenchDialog}
@@ -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) => (
<ThemeWorkbenchCanvasHost>
<LayoutTransition
@@ -296,6 +298,7 @@ export const LayoutTransitionRenderGate = memo(
chatPanelChrome="plain"
chatPanelWidth={chatPanelWidth}
chatPanelMinWidth={chatPanelMinWidth}
forceOpenChatPanel={forceOpenChatPanel}
/>
</ThemeWorkbenchCanvasHost>
),
@@ -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";
@@ -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);
});
});
@@ -0,0 +1,14 @@
import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge";
export function hasRenderableGeneralCanvasPreview(
state:
| Pick<GeneralCanvasState, "isOpen" | "content">
| null
| undefined,
): boolean {
if (!state?.isOpen) {
return false;
}
return state.content.trim().length > 0;
}
@@ -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<typeof useWorkspaceA2UIRuntime>[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<typeof useWorkspaceA2UIRuntime> | null = null;
function Probe(currentProps: HookProps) {
latestValue = useWorkspaceA2UIRuntime(currentProps);
return null;
}
const render = async (nextProps: HookProps) => {
await act(async () => {
root.render(<Probe {...nextProps} />);
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();
});
});
@@ -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<A2UIResponse | null>(() => {
const pendingMessageA2UI = useMemo<PendingA2UIResolution | null>(() => {
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<ActionRequired | null>(() => {
if (pendingMessageA2UIForm) {
@@ -100,7 +123,7 @@ export function useWorkspaceA2UIRuntime({
return null;
}, [messages, pendingMessageA2UIForm]);
const pendingLegacyQuestionnaireA2UIForm = useMemo<A2UIResponse | null>(() => {
const pendingLegacyQuestionnaireA2UI = useMemo<PendingA2UIResolution | null>(() => {
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<A2UIResponse | null>(() => {
if (pendingMessageA2UIForm) {
return pendingMessageA2UIForm;
const resolvedPendingA2UI = useMemo<PendingA2UIResolution | null>(() => {
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<A2UISubmissionNotice | null>(() => {
if (pendingA2UIForm) {
if (resolvedPendingA2UI) {
return null;
}
@@ -194,12 +236,50 @@ export function useWorkspaceA2UIRuntime({
}
return null;
}, [messages, pendingA2UIForm]);
}, [messages, resolvedPendingA2UI]);
const [retainedPendingA2UI, setRetainedPendingA2UI] =
useState<PendingA2UIResolution | null>(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,
@@ -30,6 +30,12 @@ function renderHook(props?: Partial<HookProps>) {
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);
});
});
@@ -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 &&
@@ -37,6 +37,7 @@ import {
type TeamWorkbenchSurfaceProps,
type UseTeamWorkbenchPresentationParams,
} from "./teamWorkbenchPresentation";
import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState";
type ArtifactPreviewBaseProps = Omit<
ComponentProps<typeof ArtifactWorkbenchPreview>,
@@ -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,
@@ -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 (
<GeneralCanvasPanel
{...generalCanvasPanelProps}
@@ -18,13 +18,20 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function LayoutHarness({ mode }: { mode: LayoutMode }) {
function LayoutHarness({
mode,
forceOpenChatPanel = false,
}: {
mode: LayoutMode;
forceOpenChatPanel?: boolean;
}) {
return (
<div style={{ width: "1200px", height: "720px" }}>
<LayoutTransition
mode={mode}
chatContent={<div data-testid="layout-chat-content">chat</div>}
canvasContent={<div data-testid="layout-canvas-content">canvas</div>}
forceOpenChatPanel={forceOpenChatPanel}
/>
</div>
);
@@ -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<HTMLElement>('[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,
@@ -181,6 +181,8 @@ interface LayoutTransitionProps {
chatPanelWidth?: string;
/** chat-canvas 模式下聊天面板最小宽度 */
chatPanelMinWidth?: string;
/** 紧凑抽屉态下强制展开聊天区 */
forceOpenChatPanel?: boolean;
}
/**
@@ -197,6 +199,7 @@ export const LayoutTransition: React.FC<LayoutTransitionProps> = 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<LayoutTransitionProps> = 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;
+29 -1
View File
@@ -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<string, any> = {
},
],
}),
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",