mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: finalize latest v0.97.0 release payload
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -49,6 +49,8 @@ docs/tech/
|
||||
|
||||
# Issues tracking (internal use only)
|
||||
.issues/
|
||||
.lime/harness/
|
||||
.lime/artifacts/
|
||||
|
||||
# 开发环境
|
||||
**/.local/
|
||||
|
||||
+8
-7
@@ -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
@@ -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 报告
|
||||
|
||||
@@ -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 验证结果。"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
+48
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user