feat: finalize latest v0.97.0 updates

This commit is contained in:
coso
2026-03-27 19:22:46 +08:00
parent b06c4a0408
commit 91dc5dd1ca
64 changed files with 9744 additions and 2399 deletions
@@ -11,6 +11,7 @@ const {
exportAgentRuntimeHandoffBundleMock,
exportAgentRuntimeReplayCaseMock,
exportAgentRuntimeReviewDecisionTemplateMock,
saveAgentRuntimeReviewDecisionMock,
mockToast,
} = vi.hoisted(() => ({
exportAgentRuntimeAnalysisHandoffMock: vi.fn(),
@@ -18,6 +19,7 @@ const {
exportAgentRuntimeHandoffBundleMock: vi.fn(),
exportAgentRuntimeReplayCaseMock: vi.fn(),
exportAgentRuntimeReviewDecisionTemplateMock: vi.fn(),
saveAgentRuntimeReviewDecisionMock: vi.fn(),
mockToast: {
success: vi.fn(),
error: vi.fn(),
@@ -38,6 +40,7 @@ vi.mock("@/lib/api/agentRuntime", async () => {
exportAgentRuntimeReplayCase: exportAgentRuntimeReplayCaseMock,
exportAgentRuntimeReviewDecisionTemplate:
exportAgentRuntimeReviewDecisionTemplateMock,
saveAgentRuntimeReviewDecision: saveAgentRuntimeReviewDecisionMock,
};
});
@@ -121,6 +124,29 @@ function renderPanel(
return rendered;
}
function setInputValue(
input: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
value: string,
) {
const prototype =
input instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: input instanceof HTMLSelectElement
? HTMLSelectElement.prototype
: HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
descriptor?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
function findButtonByText(text: string): HTMLButtonElement | null {
return Array.from(document.body.querySelectorAll("button")).find(
(button): button is HTMLButtonElement =>
button.textContent?.trim() === text,
) as HTMLButtonElement | null;
}
function createToolInventory(): AgentRuntimeToolInventory {
return {
request: {
@@ -744,6 +770,76 @@ describe("HarnessStatusPanel", () => {
);
});
it("复制回归命令在未导出时应先自动导出 Replay 样本,再复制 promote / eval / trend 命令", async () => {
exportAgentRuntimeReplayCaseMock.mockResolvedValue({
session_id: "session-replay-copy-1",
thread_id: "thread-replay-copy-1",
workspace_id: "workspace-replay-copy-1",
workspace_root: "/tmp/workspace-replay-copy-1",
replay_relative_root:
".lime/harness/sessions/session-replay-copy-1/replay",
replay_absolute_root:
"/tmp/workspace-replay-copy-1/.lime/harness/sessions/session-replay-copy-1/replay",
handoff_bundle_relative_root:
".lime/harness/sessions/session-replay-copy-1",
evidence_pack_relative_root:
".lime/harness/sessions/session-replay-copy-1/evidence",
exported_at: "2026-03-27T10:12:00.000Z",
thread_status: "waiting_request",
latest_turn_status: "completed",
pending_request_count: 0,
queued_turn_count: 0,
linked_handoff_artifact_count: 4,
linked_evidence_artifact_count: 4,
recent_artifact_count: 2,
artifacts: [
{
kind: "grader",
title: "评分说明",
relative_path:
".lime/harness/sessions/session-replay-copy-1/replay/grader.md",
absolute_path:
"/tmp/workspace-replay-copy-1/.lime/harness/sessions/session-replay-copy-1/replay/grader.md",
bytes: 320,
},
],
});
renderPanel({
diagnosticRuntimeContext: {
sessionId: "session-replay-copy-1",
workspaceId: "workspace-replay-copy-1",
providerType: "openai",
model: "gpt-5.4",
executionStrategy: "react",
activeTheme: "default",
selectedTeamLabel: null,
},
});
const copyButton = document.body.querySelector(
'button[aria-label="复制回归沉淀与验证命令"]',
) as HTMLButtonElement | null;
await act(async () => {
copyButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(exportAgentRuntimeReplayCaseMock).toHaveBeenCalledWith(
"session-replay-copy-1",
);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
'npm run harness:eval:promote -- --session-id "session-replay-copy-1" --slug "session-replay-copy-1" --title "Replay case session-replay-copy-1"\n' +
"npm run harness:eval\n" +
"npm run harness:eval:trend\n",
);
expect(mockToast.success).toHaveBeenCalledWith(
"已复制回归沉淀、验证与趋势命令",
);
});
it("存在 sessionId 时应支持导出人工审核记录并展示审核模板与清单", async () => {
exportAgentRuntimeReviewDecisionTemplateMock.mockResolvedValue({
session_id: "session-review-1",
@@ -769,6 +865,26 @@ describe("HarnessStatusPanel", () => {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
decision: {
decision_status: "pending_review",
decision_summary: "",
chosen_fix_strategy: "",
risk_level: "unknown",
risk_tags: [],
human_reviewer: "",
reviewed_at: undefined,
followup_actions: [],
regression_requirements: [],
notes: "",
},
decision_status_options: [
"accepted",
"deferred",
"rejected",
"needs_more_evidence",
"pending_review",
],
risk_level_options: ["low", "medium", "high", "unknown"],
review_checklist: [
"先阅读 analysis-brief.md 与 analysis-context.json。",
"确认最终决策由人工审核者填写。",
@@ -845,6 +961,256 @@ describe("HarnessStatusPanel", () => {
expect(mockToast.success).toHaveBeenCalledWith("已导出 2 个人工审核文件");
});
it("应支持填写并保存人工审核结果", async () => {
exportAgentRuntimeReviewDecisionTemplateMock.mockResolvedValue({
session_id: "session-review-2",
thread_id: "thread-review-2",
workspace_id: "workspace-review-2",
workspace_root: "/tmp/workspace-review-2",
review_relative_root: ".lime/harness/sessions/session-review-2/review",
review_absolute_root:
"/tmp/workspace-review-2/.lime/harness/sessions/session-review-2/review",
analysis_relative_root:
".lime/harness/sessions/session-review-2/analysis",
analysis_absolute_root:
"/tmp/workspace-review-2/.lime/harness/sessions/session-review-2/analysis",
handoff_bundle_relative_root: ".lime/harness/sessions/session-review-2",
evidence_pack_relative_root:
".lime/harness/sessions/session-review-2/evidence",
replay_case_relative_root:
".lime/harness/sessions/session-review-2/replay",
exported_at: "2026-03-27T10:28:00.000Z",
title: "把外部分析结论回挂为人工审核记录",
thread_status: "waiting_request",
latest_turn_status: "action_required",
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
decision: {
decision_status: "pending_review",
decision_summary: "",
chosen_fix_strategy: "",
risk_level: "unknown",
risk_tags: [],
human_reviewer: "",
reviewed_at: undefined,
followup_actions: [],
regression_requirements: [],
notes: "",
},
decision_status_options: [
"accepted",
"deferred",
"rejected",
"needs_more_evidence",
"pending_review",
],
risk_level_options: ["low", "medium", "high", "unknown"],
review_checklist: ["先阅读 analysis-brief.md 与 analysis-context.json。"],
analysis_artifacts: [
{
kind: "analysis_brief",
title: "外部分析简报",
relative_path:
".lime/harness/sessions/session-review-2/analysis/analysis-brief.md",
absolute_path:
"/tmp/workspace-review-2/.lime/harness/sessions/session-review-2/analysis/analysis-brief.md",
bytes: 320,
},
],
artifacts: [
{
kind: "review_decision_markdown",
title: "人工审核记录",
relative_path:
".lime/harness/sessions/session-review-2/review/review-decision.md",
absolute_path:
"/tmp/workspace-review-2/.lime/harness/sessions/session-review-2/review/review-decision.md",
bytes: 512,
},
],
});
saveAgentRuntimeReviewDecisionMock.mockResolvedValue({
session_id: "session-review-2",
thread_id: "thread-review-2",
workspace_id: "workspace-review-2",
workspace_root: "/tmp/workspace-review-2",
review_relative_root: ".lime/harness/sessions/session-review-2/review",
review_absolute_root:
"/tmp/workspace-review-2/.lime/harness/sessions/session-review-2/review",
analysis_relative_root:
".lime/harness/sessions/session-review-2/analysis",
analysis_absolute_root:
"/tmp/workspace-review-2/.lime/harness/sessions/session-review-2/analysis",
handoff_bundle_relative_root: ".lime/harness/sessions/session-review-2",
evidence_pack_relative_root:
".lime/harness/sessions/session-review-2/evidence",
replay_case_relative_root:
".lime/harness/sessions/session-review-2/replay",
exported_at: "2026-03-27T10:32:00.000Z",
title: "把外部分析结论回挂为人工审核记录",
thread_status: "waiting_request",
latest_turn_status: "action_required",
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
decision: {
decision_status: "accepted",
decision_summary: "确认最小修复可以接受。",
chosen_fix_strategy: "先补 runtime save,再补 UI 回归。",
risk_level: "medium",
risk_tags: ["runtime", "ui"],
human_reviewer: "Lime Maintainer",
reviewed_at: "2026-03-27T10:32:00.000Z",
followup_actions: ["补充 HarnessStatusPanel 测试"],
regression_requirements: ["npm run test:contracts", "Rust 定向测试"],
notes: "保持 review decision 主链单一。",
},
decision_status_options: [
"accepted",
"deferred",
"rejected",
"needs_more_evidence",
"pending_review",
],
risk_level_options: ["low", "medium", "high", "unknown"],
review_checklist: ["先阅读 analysis-brief.md 与 analysis-context.json。"],
analysis_artifacts: [
{
kind: "analysis_brief",
title: "外部分析简报",
relative_path:
".lime/harness/sessions/session-review-2/analysis/analysis-brief.md",
absolute_path:
"/tmp/workspace-review-2/.lime/harness/sessions/session-review-2/analysis/analysis-brief.md",
bytes: 320,
},
],
artifacts: [
{
kind: "review_decision_markdown",
title: "人工审核记录",
relative_path:
".lime/harness/sessions/session-review-2/review/review-decision.md",
absolute_path:
"/tmp/workspace-review-2/.lime/harness/sessions/session-review-2/review/review-decision.md",
bytes: 512,
},
],
});
renderPanel({
diagnosticRuntimeContext: {
sessionId: "session-review-2",
workspaceId: "workspace-review-2",
providerType: "openai",
model: "gpt-5.4",
executionStrategy: "react",
activeTheme: "default",
selectedTeamLabel: null,
},
});
const fillButton = document.body.querySelector(
'button[aria-label="填写人工审核结果"]',
) as HTMLButtonElement | null;
await act(async () => {
fillButton?.click();
await Promise.resolve();
await Promise.resolve();
});
const statusSelect = document.body.querySelector(
'select[aria-label="决策状态"]',
) as HTMLSelectElement | null;
const riskSelect = document.body.querySelector(
'select[aria-label="风险等级"]',
) as HTMLSelectElement | null;
const reviewerInput = document.body.querySelector(
'input[aria-label="审核人"]',
) as HTMLInputElement | null;
const riskTagsInput = document.body.querySelector(
'input[aria-label="风险标签"]',
) as HTMLInputElement | null;
const summaryTextarea = document.body.querySelector(
'textarea[aria-label="决策摘要"]',
) as HTMLTextAreaElement | null;
const strategyTextarea = document.body.querySelector(
'textarea[aria-label="采用的修复策略"]',
) as HTMLTextAreaElement | null;
const regressionsTextarea = document.body.querySelector(
'textarea[aria-label="回归要求"]',
) as HTMLTextAreaElement | null;
const followupsTextarea = document.body.querySelector(
'textarea[aria-label="后续动作"]',
) as HTMLTextAreaElement | null;
const notesTextarea = document.body.querySelector(
'textarea[aria-label="审核备注"]',
) as HTMLTextAreaElement | null;
await act(async () => {
if (statusSelect) {
setInputValue(statusSelect, "accepted");
}
if (riskSelect) {
setInputValue(riskSelect, "medium");
}
if (reviewerInput) {
setInputValue(reviewerInput, "Lime Maintainer");
}
if (riskTagsInput) {
setInputValue(riskTagsInput, "runtime, ui");
}
if (summaryTextarea) {
setInputValue(summaryTextarea, "确认最小修复可以接受。");
}
if (strategyTextarea) {
setInputValue(strategyTextarea, "先补 runtime save,再补 UI 回归。");
}
if (regressionsTextarea) {
setInputValue(regressionsTextarea, "npm run test:contracts\nRust 定向测试");
}
if (followupsTextarea) {
setInputValue(followupsTextarea, "补充 HarnessStatusPanel 测试");
}
if (notesTextarea) {
setInputValue(notesTextarea, "保持 review decision 主链单一。");
}
await Promise.resolve();
});
const saveButton = findButtonByText("保存审核结果");
await act(async () => {
saveButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(exportAgentRuntimeReviewDecisionTemplateMock).toHaveBeenCalledWith(
"session-review-2",
);
expect(saveAgentRuntimeReviewDecisionMock).toHaveBeenCalledWith({
session_id: "session-review-2",
decision_status: "accepted",
decision_summary: "确认最小修复可以接受。",
chosen_fix_strategy: "先补 runtime save,再补 UI 回归。",
risk_level: "medium",
risk_tags: ["runtime", "ui"],
human_reviewer: "Lime Maintainer",
reviewed_at: undefined,
followup_actions: ["补充 HarnessStatusPanel 测试"],
regression_requirements: ["npm run test:contracts", "Rust 定向测试"],
notes: "保持 review decision 主链单一。",
});
expect(document.body.textContent).toContain("当前人工审核结论");
expect(document.body.textContent).toContain("确认最小修复可以接受。");
expect(document.body.textContent).toContain("Lime Maintainer");
expect(document.body.textContent).toContain("补充 HarnessStatusPanel 测试");
expect(mockToast.success).toHaveBeenCalledWith("已保存人工审核结果");
});
it("一键复制给 AI 在未导出时应先自动导出再复制 copy_prompt", async () => {
exportAgentRuntimeAnalysisHandoffMock.mockResolvedValue({
session_id: "session-analysis-copy-1",
@@ -38,6 +38,7 @@ import type {
AgentRuntimeAnalysisHandoff,
AgentRuntimeEvidencePack,
AgentRuntimeHandoffBundle,
AgentRuntimeSaveReviewDecisionRequest,
AgentRuntimeReplayCase,
AgentRuntimeReviewDecisionTemplate,
AgentRuntimeToolInventory,
@@ -54,6 +55,7 @@ import {
exportAgentRuntimeHandoffBundle,
exportAgentRuntimeReplayCase,
exportAgentRuntimeReviewDecisionTemplate,
saveAgentRuntimeReviewDecision,
} from "@/lib/api/agentRuntime";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -104,6 +106,7 @@ import { resolveTeamWorkspaceStableProcessingLabel } from "../utils/teamWorkspac
import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime";
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel";
import { RuntimeReviewDecisionDialog } from "./RuntimeReviewDecisionDialog";
interface HarnessEnvironmentSummary {
skillsCount: number;
@@ -524,6 +527,77 @@ function formatReviewDecisionStatusLabel(status?: string): string {
}
}
function formatReviewDecisionRiskLevelLabel(riskLevel?: string): string {
switch (riskLevel?.trim()) {
case "low":
return "低";
case "medium":
return "中";
case "high":
return "高";
case "unknown":
return "未定";
default:
return riskLevel?.trim() || "未知";
}
}
function slugifyHarnessCase(value: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || "replay-case";
}
function quoteShellArg(value: string): string {
return JSON.stringify(value);
}
function buildReplayPromotionContext(params: {
replayCase: AgentRuntimeReplayCase;
analysisTitle?: string | null;
reviewTitle?: string | null;
}) {
const titleSource =
params.reviewTitle?.trim() ||
params.analysisTitle?.trim() ||
`Replay case ${params.replayCase.session_id}`;
const slugSource =
params.reviewTitle?.trim() ||
params.analysisTitle?.trim() ||
params.replayCase.session_id;
return {
suiteId: "repo-promoted-replays",
title: titleSource,
slug: slugifyHarnessCase(slugSource),
};
}
function buildReplayPromotionCommand(params: {
replayCase: AgentRuntimeReplayCase;
analysisTitle?: string | null;
reviewTitle?: string | null;
}): string {
const context = buildReplayPromotionContext(params);
return [
"npm run harness:eval:promote --",
`--session-id ${quoteShellArg(params.replayCase.session_id)}`,
`--slug ${quoteShellArg(context.slug)}`,
`--title ${quoteShellArg(context.title)}`,
].join(" ");
}
function buildReplayEvalCommand(): string {
return "npm run harness:eval";
}
function buildReplayTrendCommand(): string {
return "npm run harness:eval:trend";
}
function describeAction(action: HarnessFileAction): string {
switch (action) {
case "read":
@@ -1599,7 +1673,10 @@ export function HarnessStatusPanel({
);
const [reviewDecisionTemplate, setReviewDecisionTemplate] =
useState<AgentRuntimeReviewDecisionTemplate | null>(null);
const [reviewDecisionEditorOpen, setReviewDecisionEditorOpen] =
useState(false);
const [reviewDecisionExporting, setReviewDecisionExporting] = useState(false);
const [reviewDecisionSaving, setReviewDecisionSaving] = useState(false);
const [reviewDecisionExportError, setReviewDecisionExportError] = useState<
string | null
>(null);
@@ -1623,8 +1700,10 @@ export function HarnessStatusPanel({
setAnalysisExportError(null);
setAnalysisExporting(false);
setReviewDecisionTemplate(null);
setReviewDecisionEditorOpen(false);
setReviewDecisionExportError(null);
setReviewDecisionExporting(false);
setReviewDecisionSaving(false);
}, [currentSessionId]);
const registerSectionRef = useCallback(
@@ -1689,7 +1768,7 @@ export function HarnessStatusPanel({
const handleExportReplayCase = useCallback(async () => {
if (!currentSessionId) {
toast.error("当前没有可导出的会话上下文");
return;
return null;
}
setReplayExporting(true);
@@ -1698,11 +1777,13 @@ export function HarnessStatusPanel({
const replay = await exportAgentRuntimeReplayCase(currentSessionId);
setReplayCase(replay);
toast.success(`已导出 ${replay.artifacts.length} 个 Replay 样本文件`);
return replay;
} catch (error) {
const message =
error instanceof Error ? error.message : "导出 Replay 样本失败";
setReplayExportError(message);
toast.error(message);
return null;
} finally {
setReplayExporting(false);
}
@@ -1754,6 +1835,44 @@ export function HarnessStatusPanel({
}
}, [analysisHandoff, handleExportAnalysisHandoff]);
const handleCopyReplayPromotionCommand = useCallback(async () => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
toast.error("当前环境不支持剪贴板复制");
return;
}
const replay = replayCase || (await handleExportReplayCase());
if (!replay) {
return;
}
const promoteCommand = buildReplayPromotionCommand({
replayCase: replay,
analysisTitle: analysisHandoff?.title,
reviewTitle: reviewDecisionTemplate?.title,
});
const evalCommand = buildReplayEvalCommand();
const trendCommand = buildReplayTrendCommand();
try {
await navigator.clipboard.writeText(
`${promoteCommand}\n${evalCommand}\n${trendCommand}\n`,
);
toast.success("已复制回归沉淀、验证与趋势命令");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "复制回归沉淀、验证与趋势命令失败",
);
}
}, [
analysisHandoff?.title,
handleExportReplayCase,
replayCase,
reviewDecisionTemplate?.title,
]);
const handleExportReviewDecisionTemplate = useCallback(async () => {
if (!currentSessionId) {
toast.error("当前没有可导出的会话上下文");
@@ -1779,6 +1898,38 @@ export function HarnessStatusPanel({
}
}, [currentSessionId]);
const handleOpenReviewDecisionEditor = useCallback(async () => {
const template =
reviewDecisionTemplate || (await handleExportReviewDecisionTemplate());
if (!template) {
return;
}
setReviewDecisionTemplate(template);
setReviewDecisionEditorOpen(true);
}, [handleExportReviewDecisionTemplate, reviewDecisionTemplate]);
const handleSaveReviewDecision = useCallback(
async (request: AgentRuntimeSaveReviewDecisionRequest) => {
setReviewDecisionSaving(true);
setReviewDecisionExportError(null);
try {
const template = await saveAgentRuntimeReviewDecision(request);
setReviewDecisionTemplate(template);
setReviewDecisionEditorOpen(false);
toast.success("已保存人工审核结果");
} catch (error) {
const message =
error instanceof Error ? error.message : "保存人工审核结果失败";
setReviewDecisionExportError(message);
toast.error(message);
} finally {
setReviewDecisionSaving(false);
}
},
[],
);
const hasToolInventorySection =
toolInventoryLoading ||
Boolean(toolInventoryError) ||
@@ -3123,6 +3274,24 @@ export function HarnessStatusPanel({
? "刷新 Replay 样本"
: "导出 Replay 样本"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="gap-2"
aria-label="复制回归沉淀与验证命令"
disabled={replayExporting}
onClick={() =>
void handleCopyReplayPromotionCommand()
}
>
{replayExporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Copy className="h-4 w-4" />
)}
复制回归命令
</Button>
{replayCase ? (
<Button
type="button"
@@ -3219,6 +3388,49 @@ export function HarnessStatusPanel({
</div>
</div>
<div className="rounded-xl border border-sky-200 bg-sky-50/80 p-3">
<div className="flex items-center gap-2 text-sm font-medium text-sky-950">
<Workflow className="h-4 w-4 text-sky-700" />
<span>回归资产沉淀</span>
</div>
<div className="mt-2 text-xs leading-5 text-sky-900">
这一步直接复用仓库已有的 `harness:eval:promote` 与
`harness:eval` 主命令,把当前 replay case
提升为仓库 current
样本,并立即跑一次统一摘要验证;
点击“复制回归命令”后不需要你再手写参数。
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<InventoryStatCard
title="目标 Suite"
value={
buildReplayPromotionContext({
replayCase,
analysisTitle: analysisHandoff?.title,
reviewTitle: reviewDecisionTemplate?.title,
}).suiteId
}
hint="仓库 current 样本集"
/>
<InventoryStatCard
title="建议 Slug"
value={
buildReplayPromotionContext({
replayCase,
analysisTitle: analysisHandoff?.title,
reviewTitle: reviewDecisionTemplate?.title,
}).slug
}
hint="promotion 目录名"
/>
<InventoryStatCard
title="后续验证"
value="eval + trend"
hint="统一摘要与趋势入口"
/>
</div>
</div>
<div className="space-y-3">
{replayCase.artifacts.map((artifact) => {
const sizeLabel = formatSize(artifact.bytes);
@@ -3615,6 +3827,22 @@ export function HarnessStatusPanel({
? "刷新人工审核记录"
: "导出人工审核记录"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="gap-2"
aria-label="填写人工审核结果"
disabled={
reviewDecisionExporting || reviewDecisionSaving
}
onClick={() =>
void handleOpenReviewDecisionEditor()
}
>
<ShieldAlert className="h-4 w-4" />
填写人工审核结果
</Button>
{reviewDecisionTemplate ? (
<Button
type="button"
@@ -3643,13 +3871,15 @@ export function HarnessStatusPanel({
{reviewDecisionTemplate ? (
<div className="mt-3 space-y-3">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
<InventoryStatCard
title="默认状态"
title="当前状态"
value={formatReviewDecisionStatusLabel(
reviewDecisionTemplate.default_decision_status,
reviewDecisionTemplate.decision
.decision_status ||
reviewDecisionTemplate.default_decision_status,
)}
hint={`最近导出 ${formatIsoDateTime(reviewDecisionTemplate.exported_at)}`}
hint={`最近写入 ${formatIsoDateTime(reviewDecisionTemplate.exported_at)}`}
/>
<InventoryStatCard
title="线程状态"
@@ -3658,6 +3888,20 @@ export function HarnessStatusPanel({
)}
hint={`待处理请求 ${reviewDecisionTemplate.pending_request_count} · 排队 ${reviewDecisionTemplate.queued_turn_count}`}
/>
<InventoryStatCard
title="风险等级"
value={formatReviewDecisionRiskLevelLabel(
reviewDecisionTemplate.decision.risk_level,
)}
hint={
reviewDecisionTemplate.decision.risk_tags
.length > 0
? reviewDecisionTemplate.decision.risk_tags.join(
" / ",
)
: "尚未填写风险标签"
}
/>
<InventoryStatCard
title="分析文件"
value={`${reviewDecisionTemplate.analysis_artifacts.length}`}
@@ -3683,6 +3927,116 @@ export function HarnessStatusPanel({
</div>
</div>
<div className="rounded-xl border border-border bg-background p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-foreground">
当前人工审核结论
</div>
<Badge variant="outline">
{formatReviewDecisionStatusLabel(
reviewDecisionTemplate.decision
.decision_status ||
reviewDecisionTemplate.default_decision_status,
)}
</Badge>
</div>
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-2 xl:grid-cols-3">
<div>
审核人:
<span className="ml-1 text-foreground">
{reviewDecisionTemplate.decision
.human_reviewer || "待填写"}
</span>
</div>
<div>
审核时间:
<span className="ml-1 text-foreground">
{reviewDecisionTemplate.decision.reviewed_at
? formatIsoDateTime(
reviewDecisionTemplate.decision
.reviewed_at,
)
: "待填写"}
</span>
</div>
<div>
风险等级:
<span className="ml-1 text-foreground">
{formatReviewDecisionRiskLevelLabel(
reviewDecisionTemplate.decision.risk_level,
)}
</span>
</div>
</div>
<div className="mt-3 space-y-3 text-xs leading-5 text-muted-foreground">
<div>
<div className="font-medium text-foreground">
决策摘要
</div>
<div className="mt-1 whitespace-pre-wrap">
{reviewDecisionTemplate.decision
.decision_summary || "尚未填写决策摘要。"}
</div>
</div>
<div>
<div className="font-medium text-foreground">
采用的修复策略
</div>
<div className="mt-1 whitespace-pre-wrap">
{reviewDecisionTemplate.decision
.chosen_fix_strategy ||
"尚未填写修复策略。"}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<div className="font-medium text-foreground">
回归要求
</div>
<div className="mt-1 space-y-1">
{reviewDecisionTemplate.decision
.regression_requirements.length > 0 ? (
reviewDecisionTemplate.decision.regression_requirements.map(
(item) => (
<div key={item}>- {item}</div>
),
)
) : (
<div>尚未填写回归要求。</div>
)}
</div>
</div>
<div>
<div className="font-medium text-foreground">
后续动作
</div>
<div className="mt-1 space-y-1">
{reviewDecisionTemplate.decision
.followup_actions.length > 0 ? (
reviewDecisionTemplate.decision.followup_actions.map(
(item) => (
<div key={item}>- {item}</div>
),
)
) : (
<div>尚未填写后续动作。</div>
)}
</div>
</div>
</div>
{reviewDecisionTemplate.decision.notes ? (
<div>
<div className="font-medium text-foreground">
审核备注
</div>
<div className="mt-1 whitespace-pre-wrap">
{reviewDecisionTemplate.decision.notes}
</div>
</div>
) : null}
</div>
</div>
<div className="rounded-xl border border-border bg-background p-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<FolderOpen className="h-4 w-4 text-muted-foreground" />
@@ -5441,6 +5795,14 @@ export function HarnessStatusPanel({
) : null}
</div>
<RuntimeReviewDecisionDialog
open={reviewDecisionEditorOpen}
template={reviewDecisionTemplate}
saving={reviewDecisionSaving}
onOpenChange={setReviewDecisionEditorOpen}
onSave={handleSaveReviewDecision}
/>
<Dialog
open={previewDialog.open}
onOpenChange={(open) =>
@@ -8,6 +8,7 @@ import { useImageAttachments } from "./useImageAttachments";
import { useInputbarAdapter } from "./useInputbarAdapter";
import { useInputbarDisplayState } from "./useInputbarDisplayState";
import { useInputbarSend } from "./useInputbarSend";
import { useStickyA2UIForm } from "./useStickyA2UIForm";
import {
useInputbarToolState,
type InputbarToolStates,
@@ -180,6 +181,11 @@ export function useInputbarController({
a2uiSubmissionNotice,
});
const { visibleForm: visiblePendingA2UIForm } = useStickyA2UIForm({
form: pendingA2UIForm,
clearImmediately: Boolean(a2uiSubmissionNotice),
});
const [dismissedTeamSuggestionKey, setDismissedTeamSuggestionKey] = useState<
string | null
>(null);
@@ -265,6 +271,7 @@ export function useInputbarController({
themeWorkbenchQuickActions,
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
visiblePendingA2UIForm,
visibleA2UISubmissionNotice,
isA2UISubmissionNoticeVisible,
activeSkill,
@@ -0,0 +1,125 @@
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 type { A2UIResponse } from "@/components/content-creator/a2ui/types";
import { useStickyA2UIForm } from "./useStickyA2UIForm";
type HookProps = Parameters<typeof useStickyA2UIForm>[0];
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
function createA2UIForm(id: string): A2UIResponse {
return {
id,
version: "1.0",
data: {},
components: [],
} as unknown as A2UIResponse;
}
function renderHook(props?: Partial<HookProps>) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let latestValue: ReturnType<typeof useStickyA2UIForm> | null = null;
const defaultProps: HookProps = {
form: null,
clearImmediately: false,
holdMs: 1200,
};
function Probe(currentProps: HookProps) {
latestValue = useStickyA2UIForm(currentProps);
return null;
}
const render = async (nextProps?: Partial<HookProps>) => {
await act(async () => {
root.render(<Probe {...defaultProps} {...props} {...nextProps} />);
await Promise.resolve();
});
};
mountedRoots.push({ root, container });
return {
render,
getValue: () => {
if (!latestValue) {
throw new Error("hook 尚未初始化");
}
return latestValue;
},
};
}
beforeEach(() => {
vi.useFakeTimers();
(
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.useRealTimers();
vi.restoreAllMocks();
});
describe("useStickyA2UIForm", () => {
it("pending 表单短暂消失时应先保留,避免输入区闪烁", async () => {
const form = createA2UIForm("a2ui-pending");
const { render, getValue } = renderHook({
form,
holdMs: 1200,
});
await render();
expect(getValue().visibleForm).toEqual(form);
await render({
form: null,
});
expect(getValue().visibleForm).toEqual(form);
await act(async () => {
vi.advanceTimersByTime(1199);
});
expect(getValue().visibleForm).toEqual(form);
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(getValue().visibleForm).toBeNull();
});
it("进入已提交提示后应立即清空旧表单,避免叠层残留", async () => {
const form = createA2UIForm("a2ui-submitted");
const { render, getValue } = renderHook({
form,
holdMs: 1200,
});
await render();
expect(getValue().visibleForm).toEqual(form);
await render({
form: null,
clearImmediately: true,
});
expect(getValue().visibleForm).toBeNull();
});
});
@@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from "react";
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
interface UseStickyA2UIFormParams {
form?: A2UIResponse | null;
clearImmediately?: boolean;
holdMs?: number;
}
export function useStickyA2UIForm({
form,
clearImmediately = false,
holdMs = 1200,
}: UseStickyA2UIFormParams) {
const [visibleForm, setVisibleForm] = useState<A2UIResponse | null>(
form || null,
);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
}
};
}, []);
useEffect(() => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
if (form) {
setVisibleForm(form);
return;
}
if (clearImmediately) {
setVisibleForm(null);
return;
}
hideTimerRef.current = setTimeout(() => {
setVisibleForm(null);
hideTimerRef.current = null;
}, holdMs);
return () => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
};
}, [clearImmediately, form, holdMs]);
return {
visibleForm,
};
}
@@ -187,6 +187,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
themeWorkbenchQuickActions,
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
visiblePendingA2UIForm,
visibleA2UISubmissionNotice,
isA2UISubmissionNoticeVisible,
activeSkill,
@@ -240,7 +241,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
overlayAccessory={overlayAccessory}
submissionNotice={visibleA2UISubmissionNotice}
isSubmissionNoticeVisible={isA2UISubmissionNoticeVisible}
pendingA2UIForm={pendingA2UIForm}
pendingA2UIForm={visiblePendingA2UIForm}
onA2UISubmit={onA2UISubmit}
fileInputRef={fileInputRef}
onFileSelect={handleFileSelect}
@@ -0,0 +1,471 @@
import { useEffect, useState } from "react";
import type {
AgentRuntimeReviewDecisionRiskLevel,
AgentRuntimeReviewDecisionStatus,
AgentRuntimeReviewDecisionTemplate,
AgentRuntimeSaveReviewDecisionRequest,
} from "@/lib/api/agentRuntime";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
interface RuntimeReviewDecisionDialogProps {
open: boolean;
template: AgentRuntimeReviewDecisionTemplate | null;
saving: boolean;
onOpenChange: (open: boolean) => void;
onSave: (request: AgentRuntimeSaveReviewDecisionRequest) => Promise<void>;
}
interface ReviewDecisionFormState {
decision_status: AgentRuntimeReviewDecisionStatus;
decision_summary: string;
chosen_fix_strategy: string;
risk_level: AgentRuntimeReviewDecisionRiskLevel;
risk_tags_text: string;
human_reviewer: string;
reviewed_at?: string;
followup_actions_text: string;
regression_requirements_text: string;
notes: string;
}
const DEFAULT_STATUS_OPTIONS: AgentRuntimeReviewDecisionStatus[] = [
"accepted",
"deferred",
"rejected",
"needs_more_evidence",
"pending_review",
];
const DEFAULT_RISK_LEVEL_OPTIONS: AgentRuntimeReviewDecisionRiskLevel[] = [
"low",
"medium",
"high",
"unknown",
];
const selectClassName =
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/30";
function formatStatusLabel(status: AgentRuntimeReviewDecisionStatus): string {
switch (status) {
case "accepted":
return "接受";
case "deferred":
return "延后";
case "rejected":
return "拒绝";
case "needs_more_evidence":
return "需要更多证据";
case "pending_review":
return "待人工审核";
default:
return status;
}
}
function formatRiskLevelLabel(
riskLevel: AgentRuntimeReviewDecisionRiskLevel,
): string {
switch (riskLevel) {
case "low":
return "低";
case "medium":
return "中";
case "high":
return "高";
case "unknown":
return "未定";
default:
return riskLevel;
}
}
function createFormState(
template: AgentRuntimeReviewDecisionTemplate,
): ReviewDecisionFormState {
return {
decision_status: template.decision.decision_status,
decision_summary: template.decision.decision_summary,
chosen_fix_strategy: template.decision.chosen_fix_strategy,
risk_level: template.decision.risk_level,
risk_tags_text: template.decision.risk_tags.join(", "),
human_reviewer: template.decision.human_reviewer,
reviewed_at: template.decision.reviewed_at,
followup_actions_text: template.decision.followup_actions.join("\n"),
regression_requirements_text:
template.decision.regression_requirements.join("\n"),
notes: template.decision.notes,
};
}
function splitLines(value: string): string[] {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
function splitCommaSeparated(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export function RuntimeReviewDecisionDialog({
open,
template,
saving,
onOpenChange,
onSave,
}: RuntimeReviewDecisionDialogProps) {
const [formState, setFormState] = useState<ReviewDecisionFormState | null>(
template ? createFormState(template) : null,
);
useEffect(() => {
if (!template) {
setFormState(null);
return;
}
if (open) {
setFormState(createFormState(template));
}
}, [open, template]);
const statusOptions =
template?.decision_status_options.length
? template.decision_status_options
: DEFAULT_STATUS_OPTIONS;
const riskLevelOptions =
template?.risk_level_options.length
? template.risk_level_options
: DEFAULT_RISK_LEVEL_OPTIONS;
const handleSave = async () => {
if (!template || !formState) {
return;
}
await onSave({
session_id: template.session_id,
decision_status: formState.decision_status,
decision_summary: formState.decision_summary,
chosen_fix_strategy: formState.chosen_fix_strategy,
risk_level: formState.risk_level,
risk_tags: splitCommaSeparated(formState.risk_tags_text),
human_reviewer: formState.human_reviewer,
reviewed_at: formState.reviewed_at,
followup_actions: splitLines(formState.followup_actions_text),
regression_requirements: splitLines(
formState.regression_requirements_text,
),
notes: formState.notes,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent maxWidth="max-w-3xl" className="p-0">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>填写人工审核结果</DialogTitle>
<DialogDescription className="space-y-1 text-xs leading-5">
<span className="block">
当前会话会把最终审核结果写回
`review-decision.md/json`,继续沿用现有 analysis / handoff /
evidence / replay 主链。
</span>
{template ? (
<span className="block font-mono text-[11px] text-muted-foreground">
{template.review_relative_root}
</span>
) : null}
</DialogDescription>
</DialogHeader>
{template && formState ? (
<div className="max-h-[70vh] space-y-5 overflow-y-auto px-6 py-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label
htmlFor="review-decision-status"
className="text-xs font-medium text-foreground"
>
决策状态
</label>
<select
id="review-decision-status"
aria-label="决策状态"
className={selectClassName}
value={formState.decision_status}
onChange={(event) =>
setFormState((current) =>
current
? {
...current,
decision_status: event.target
.value as AgentRuntimeReviewDecisionStatus,
}
: current,
)
}
>
{statusOptions.map((status) => (
<option key={status} value={status}>
{formatStatusLabel(status)}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label
htmlFor="review-risk-level"
className="text-xs font-medium text-foreground"
>
风险等级
</label>
<select
id="review-risk-level"
aria-label="风险等级"
className={selectClassName}
value={formState.risk_level}
onChange={(event) =>
setFormState((current) =>
current
? {
...current,
risk_level: event.target
.value as AgentRuntimeReviewDecisionRiskLevel,
}
: current,
)
}
>
{riskLevelOptions.map((riskLevel) => (
<option key={riskLevel} value={riskLevel}>
{formatRiskLevelLabel(riskLevel)}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label
htmlFor="review-human-reviewer"
className="text-xs font-medium text-foreground"
>
审核人
</label>
<Input
id="review-human-reviewer"
aria-label="审核人"
value={formState.human_reviewer}
onChange={(event) =>
setFormState((current) =>
current
? {
...current,
human_reviewer: event.target.value,
}
: current,
)
}
placeholder="例如:Lime Maintainer"
/>
</div>
<div className="space-y-2">
<label
htmlFor="review-risk-tags"
className="text-xs font-medium text-foreground"
>
风险标签
</label>
<Input
id="review-risk-tags"
aria-label="风险标签"
value={formState.risk_tags_text}
onChange={(event) =>
setFormState((current) =>
current
? {
...current,
risk_tags_text: event.target.value,
}
: current,
)
}
placeholder="用英文逗号分隔,例如:runtime, ui"
/>
</div>
</div>
<div className="space-y-2">
<label
htmlFor="review-decision-summary"
className="text-xs font-medium text-foreground"
>
决策摘要
</label>
<Textarea
id="review-decision-summary"
aria-label="决策摘要"
value={formState.decision_summary}
onChange={(event) =>
setFormState((current) =>
current
? {
...current,
decision_summary: event.target.value,
}
: current,
)
}
rows={4}
placeholder="说明为什么接受、延后、拒绝,或为什么还需要更多证据。"
/>
</div>
<div className="space-y-2">
<label
htmlFor="review-fix-strategy"
className="text-xs font-medium text-foreground"
>
采用的修复策略
</label>
<Textarea
id="review-fix-strategy"
aria-label="采用的修复策略"
value={formState.chosen_fix_strategy}
onChange={(event) =>
setFormState((current) =>
current
? {
...current,
chosen_fix_strategy: event.target.value,
}
: current,
)
}
rows={4}
placeholder="记录最终采用的最小修复方案,以及为什么不继续扩散范围。"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label
htmlFor="review-regressions"
className="text-xs font-medium text-foreground"
>
回归要求
</label>
<Textarea
id="review-regressions"
aria-label="回归要求"
value={formState.regression_requirements_text}
onChange={(event) =>
setFormState((current) =>
current
? {
...current,
regression_requirements_text: event.target.value,
}
: current,
)
}
rows={5}
placeholder="每行一条,例如:npm run test:contracts"
/>
</div>
<div className="space-y-2">
<label
htmlFor="review-followups"
className="text-xs font-medium text-foreground"
>
后续动作
</label>
<Textarea
id="review-followups"
aria-label="后续动作"
value={formState.followup_actions_text}
onChange={(event) =>
setFormState((current) =>
current
? {
...current,
followup_actions_text: event.target.value,
}
: current,
)
}
rows={5}
placeholder="每行一条,例如:补充 HarnessStatusPanel 回归"
/>
</div>
</div>
<div className="space-y-2">
<label
htmlFor="review-notes"
className="text-xs font-medium text-foreground"
>
审核备注
</label>
<Textarea
id="review-notes"
aria-label="审核备注"
value={formState.notes}
onChange={(event) =>
setFormState((current) =>
current
? {
...current,
notes: event.target.value,
}
: current,
)
}
rows={4}
placeholder="补充未决风险、边界判断或暂不处理的原因。"
/>
</div>
</div>
) : null}
<DialogFooter className="border-t px-6 py-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
取消
</Button>
<Button
type="button"
onClick={() => void handleSave()}
disabled={!template || !formState || saving}
>
{saving ? "保存中..." : "保存审核结果"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -34,6 +34,7 @@ import {
buildFailedAgentRuntimeStatus,
formatAgentRuntimeStatusSummary,
} from "../utils/agentRuntimeStatus";
import { resolveRuntimeWarningToastPresentation } from "./runtimeWarningPresentation";
import {
applyModelChangeExecutionRuntime,
applyTurnContextExecutionRuntime,
@@ -527,7 +528,25 @@ export function handleTurnStreamEvent({
const warningKey = `${activeSessionId}:${data.code || data.message}`;
if (!warnedKeysRef.current.has(warningKey)) {
warnedKeysRef.current.add(warningKey);
toast.warning(data.message);
const presentation = resolveRuntimeWarningToastPresentation({
code: data.code,
message: data.message,
});
if (!presentation.shouldToast) {
break;
}
switch (presentation.level) {
case "info":
toast.info(presentation.message);
break;
case "error":
toast.error(presentation.message);
break;
case "warning":
default:
toast.warning(presentation.message);
break;
}
}
break;
}
@@ -0,0 +1,52 @@
export const ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE =
"artifact_document_repaired";
export const ARTIFACT_DOCUMENT_FAILED_WARNING_CODE =
"artifact_document_failed";
export const ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE =
"artifact_document_persist_failed";
export type RuntimeWarningToastLevel = "info" | "warning" | "error";
export interface RuntimeWarningToastPresentation {
level: RuntimeWarningToastLevel;
message: string;
shouldToast: boolean;
}
export function resolveRuntimeWarningToastPresentation(params: {
code?: string | null;
message?: string | null;
}): RuntimeWarningToastPresentation {
const code = typeof params.code === "string" ? params.code.trim() : "";
const message =
typeof params.message === "string" && params.message.trim()
? params.message.trim()
: "收到运行提醒";
switch (code) {
case ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE:
return {
level: "info",
message: "已整理为可继续编辑的文稿,可继续查看或补全结构。",
shouldToast: false,
};
case ARTIFACT_DOCUMENT_FAILED_WARNING_CODE:
return {
level: "warning",
message: "结构化文稿未完整生成,已保留一份可继续编辑的恢复稿。",
shouldToast: true,
};
case ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE:
return {
level: "warning",
message: "文稿未能保存到工作区,当前结果仍保留在对话中。",
shouldToast: true,
};
default:
return {
level: "warning",
message,
shouldToast: true,
};
}
}
@@ -39,6 +39,7 @@ import {
import { dispatchPreparedAgentStreamSend } from "./agentStreamPreparedSendDispatch";
import type { AgentStreamPreparedSendEnv } from "./agentStreamPreparedSendEnv";
import { prepareAgentStreamUserInputSend } from "./agentStreamUserInputSendPreparation";
import { resolveRuntimeWarningToastPresentation } from "./runtimeWarningPresentation";
function appendThinkingToParts(
parts: NonNullable<Message["contentParts"]>,
@@ -448,7 +449,25 @@ export function useAgentStream(options: UseAgentStreamOptions) {
const warningKey = `${activeSessionId}:${data.code || data.message}`;
if (!warnedKeysRef.current.has(warningKey)) {
warnedKeysRef.current.add(warningKey);
toast.warning(data.message);
const presentation = resolveRuntimeWarningToastPresentation({
code: data.code,
message: data.message,
});
if (!presentation.shouldToast) {
break;
}
switch (presentation.level) {
case "info":
toast.info(presentation.message);
break;
case "error":
toast.error(presentation.message);
break;
case "warning":
default:
toast.warning(presentation.message);
break;
}
}
break;
}
@@ -2016,6 +2016,41 @@ describe("useAsterAgentChat thread timeline", () => {
harness.unmount();
}
});
it("Artifact 恢复提示不应打断为全局 toast", async () => {
const workspaceId = "ws-artifact-warning-tone";
seedSession(workspaceId, "session-artifact-warning-tone");
const harness = mountHook(workspaceId);
const stream = captureTurnStream();
try {
await flushEffects();
await act(async () => {
await harness
.getValue()
.sendMessage("帮我整理成结构化文稿", [], false, false, false, "react");
});
act(() => {
stream.emit({
type: "warning",
code: "artifact_document_repaired",
message: "ArtifactDocument 已落盘: 已根据正文整理出一份可继续编辑的草稿。",
});
stream.emit({
type: "final_done",
});
});
expect(mockToast.info).not.toHaveBeenCalled();
expect(mockToast.warning).not.toHaveBeenCalledWith(
expect.stringContaining("ArtifactDocument"),
);
} finally {
harness.unmount();
}
});
});
describe("useAsterAgentChat runtime routing", () => {
@@ -212,4 +212,48 @@ describe("buildThreadReliabilityView", () => {
"正在停止当前执行,请等待运行时回填最终状态",
);
});
it("应忽略 Artifact 自动恢复 warning,不把它升级为活跃 incident", () => {
const view = buildThreadReliabilityView({
threadRead: {
thread_id: "thread-1",
status: "completed",
active_turn_id: "turn-5",
pending_requests: [],
incidents: [],
},
turns: [
{
id: "turn-5",
thread_id: "thread-1",
prompt_text: "整理结构化文稿",
status: "completed",
created_at: "2026-03-23T09:58:00Z",
started_at: "2026-03-23T09:58:00Z",
updated_at: "2026-03-23T09:59:00Z",
completed_at: "2026-03-23T09:59:00Z",
},
],
currentTurnId: "turn-5",
threadItems: [
{
id: "warning-artifact-repaired",
thread_id: "thread-1",
turn_id: "turn-5",
sequence: 3,
status: "completed",
started_at: "2026-03-23T09:58:30Z",
completed_at: "2026-03-23T09:58:30Z",
updated_at: "2026-03-23T09:58:30Z",
type: "warning",
code: "artifact_document_repaired",
message: "ArtifactDocument 已落盘: 已根据正文整理出一份可继续编辑的草稿。",
},
],
});
expect(view.activeIncidentCount).toBe(0);
expect(view.incidents).toEqual([]);
expect(view.summary).not.toContain("警告");
});
});
@@ -79,6 +79,10 @@ interface BuildThreadReliabilityViewParams {
queuedTurns?: QueuedTurnSnapshot[];
}
const NON_BLOCKING_RUNTIME_WARNING_CODES = new Set([
"artifact_document_repaired",
]);
function normalizeText(value?: string | null): string | null {
if (typeof value !== "string") {
return null;
@@ -531,7 +535,16 @@ function deriveFallbackIncidents(
const issueItem = [...threadItems]
.reverse()
.find((item) => item.type === "warning" || item.type === "error");
.find((item) => {
if (item.type === "error") {
return true;
}
if (item.type !== "warning") {
return false;
}
const code = normalizeText(item.code);
return !code || !NON_BLOCKING_RUNTIME_WARNING_CODES.has(code);
});
if (!issueItem) {
return [];
@@ -72,8 +72,9 @@ const mountedShells: MountedShell[] = [];
function createArtifactDocumentArtifact(
options: {
status?: "ready" | "archived";
currentVersionStatus?: "ready" | "archived";
status?: "ready" | "draft" | "failed" | "archived";
currentVersionStatus?: "ready" | "draft" | "failed" | "archived";
meta?: Record<string, unknown>;
} = {},
): Artifact {
const status = options.status || "ready";
@@ -157,6 +158,7 @@ function createArtifactDocumentArtifact(
filePath: ".lime/artifacts/thread-1/board-review.artifact.json",
filename: "board-review.artifact.json",
language: "json",
...options.meta,
},
position: { start: 0, end: content.length },
createdAt: 1,
@@ -346,6 +348,156 @@ describe("ArtifactWorkbenchShell", () => {
expect(container.textContent).toContain("block hero-1");
});
it("恢复为草稿时应展示低压状态说明", async () => {
const container = renderShell(
createArtifactDocumentArtifact({
status: "draft",
currentVersionStatus: "draft",
meta: {
artifactFallbackUsed: true,
artifactValidationRepaired: true,
artifactValidationIssues: [
"模型未返回合法的 ArtifactDocument JSON,已按 Markdown 正文自动恢复为可渲染文档。",
],
},
}),
{
onSaveArtifactDocument: vi.fn().mockResolvedValue(undefined),
},
);
await act(async () => {
await Promise.resolve();
});
const overviewTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("概览"),
);
expect(overviewTrigger).not.toBeUndefined();
await act(async () => {
overviewTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(container.textContent).toContain("已整理为可继续编辑的草稿");
expect(container.textContent).toContain(
"系统已先把可用正文整理成恢复稿。你可以直接继续编辑,确认内容顺畅后,再手动标记为可阅读。",
);
expect(container.textContent).toContain("恢复稿");
expect(
container.querySelector('[data-testid="artifact-recovery-continue-editing"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="artifact-recovery-mark-ready"]'),
).not.toBeNull();
});
it("恢复稿可从概览直接切到编辑态", async () => {
const container = renderShell(
createArtifactDocumentArtifact({
status: "draft",
currentVersionStatus: "draft",
meta: {
artifactFallbackUsed: true,
artifactValidationIssues: [
"模型未返回合法的 ArtifactDocument JSON,已按 Markdown 正文自动恢复为可渲染文档。",
],
},
}),
{
onSaveArtifactDocument: vi.fn().mockResolvedValue(undefined),
},
);
await act(async () => {
await Promise.resolve();
});
const overviewTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("概览"),
);
expect(overviewTrigger).not.toBeUndefined();
await act(async () => {
overviewTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const continueEditingButton = container.querySelector(
'[data-testid="artifact-recovery-continue-editing"]',
) as HTMLButtonElement | null;
expect(continueEditingButton).not.toBeNull();
await act(async () => {
continueEditingButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
await Promise.resolve();
});
expect(container.textContent).toContain("正文块 1");
});
it("恢复稿应支持标记为可阅读并沿保存链回写 ready 状态", async () => {
const handleSaveArtifactDocument = vi.fn().mockResolvedValue(undefined);
const container = renderShell(
createArtifactDocumentArtifact({
status: "draft",
currentVersionStatus: "draft",
meta: {
artifactFallbackUsed: true,
artifactValidationIssues: [
"模型未返回合法的 ArtifactDocument JSON,已按 Markdown 正文自动恢复为可渲染文档。",
],
},
}),
{
onSaveArtifactDocument: handleSaveArtifactDocument,
},
);
await act(async () => {
await Promise.resolve();
});
const overviewTrigger = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("概览"),
);
expect(overviewTrigger).not.toBeUndefined();
await act(async () => {
overviewTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
const markReadyButton = container.querySelector(
'[data-testid="artifact-recovery-mark-ready"]',
) as HTMLButtonElement | null;
expect(markReadyButton).not.toBeNull();
await act(async () => {
markReadyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(handleSaveArtifactDocument).toHaveBeenCalledTimes(1);
expect(handleSaveArtifactDocument).toHaveBeenCalledWith(
expect.objectContaining({ id: "artifact-1" }),
expect.objectContaining({
status: "ready",
metadata: expect.objectContaining({
versionHistory: expect.arrayContaining([
expect.objectContaining({
id: "artifact-document:demo:v2",
status: "ready",
}),
]),
}),
}),
);
});
it("应支持从来源项与差异项跳转到对应 block", async () => {
const container = renderShell(createArtifactDocumentArtifact());
@@ -12,6 +12,7 @@ import {
FileClock,
FileStack,
GitCompare,
Info,
Link2,
Save,
ScrollText,
@@ -40,6 +41,7 @@ import {
import { resolveArtifactProtocolDocumentPayload } from "@/lib/artifact-protocol";
import type { Artifact } from "@/lib/artifact/types";
import { buildCanvasWorkbenchDiff } from "@/components/agent/chat/utils/canvasWorkbenchDiff";
import { updateArtifactDocumentStatus } from "./artifactWorkbenchActions";
import type { AgentThreadItem } from "../types";
import {
buildArtifactTimelineLinkIndex,
@@ -122,6 +124,10 @@ function normalizeStringArray(value: unknown): string[] {
.filter((item): item is string => Boolean(item));
}
function normalizeBoolean(value: unknown): boolean {
return value === true;
}
function formatVersionDate(value?: string): string | undefined {
if (!value) {
return undefined;
@@ -257,6 +263,66 @@ function getDiffBadgeClassName(changeType: string): string {
}
}
interface ArtifactRecoveryPresentation {
kind: "recovered_draft" | "recovered_failed" | "repaired_structure";
tone: "info" | "warning";
title: string;
detail: string;
badgeLabel: string;
}
function resolveArtifactRecoveryPresentation(
artifact: Artifact,
document: ArtifactDocumentV1,
): ArtifactRecoveryPresentation | null {
const issues = normalizeStringArray(artifact.meta.artifactValidationIssues);
const fallbackUsed = normalizeBoolean(artifact.meta.artifactFallbackUsed);
const repaired = normalizeBoolean(artifact.meta.artifactValidationRepaired);
const recoveredFromMarkdown = issues.some((issue) =>
issue.includes("Markdown 正文自动恢复"),
);
const recoveredFromTruncatedJson = issues.some((issue) =>
issue.includes("不完整的 ArtifactDocument JSON"),
);
if (document.status === "failed") {
return {
kind: "recovered_failed",
tone: "warning",
badgeLabel: "恢复稿",
title: "已保留恢复稿",
detail:
"模型这次没有完整生成结构化文稿,正文已经保留在工作台里。建议先继续编辑补齐,再作为正式文稿使用。",
};
}
if (
document.status === "draft" &&
(fallbackUsed || recoveredFromMarkdown)
) {
return {
kind: "recovered_draft",
tone: "info",
badgeLabel: "恢复稿",
title: "已整理为可继续编辑的草稿",
detail:
"系统已先把可用正文整理成恢复稿。你可以直接继续编辑,确认内容顺畅后,再手动标记为可阅读。",
};
}
if (repaired || recoveredFromTruncatedJson) {
return {
kind: "repaired_structure",
tone: "info",
badgeLabel: "已补全",
title: "文稿结构已自动补全",
detail: "系统已补齐中断的结构化内容,当前文稿可以继续预览和编辑。",
};
}
return null;
}
function resolveRichTextContent(block: ArtifactDocumentBlock): string {
return (
normalizeText(block.markdown) ||
@@ -491,51 +557,162 @@ const OverviewPanel: React.FC<{
currentVersion: ArtifactDocumentVersionSummary | null;
versionHistory: ArtifactDocumentVersionSummary[];
sourceLinks: ArtifactDocumentSourceLink[];
}> = memo(({ document, currentVersion, versionHistory, sourceLinks }) => (
<div className="space-y-4">
<section className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{getKindLabel(document.kind)}</Badge>
<Badge variant={document.status === "failed" ? "destructive" : "outline"}>
{getStatusLabel(document.status)}
</Badge>
{currentVersion ? (
<Badge variant="outline">v{currentVersion.versionNo}</Badge>
recoveryPresentation: ArtifactRecoveryPresentation | null;
canEditDocument: boolean;
canMarkAsReady: boolean;
isUpdatingRecoveryState: boolean;
recoveryActionError: string | null;
onContinueEditing: () => void;
onMarkAsReady: () => void;
}> = memo(
({
document,
currentVersion,
versionHistory,
sourceLinks,
recoveryPresentation,
canEditDocument,
canMarkAsReady,
isUpdatingRecoveryState,
recoveryActionError,
onContinueEditing,
onMarkAsReady,
}) => {
return (
<div className="space-y-4">
<section className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{getKindLabel(document.kind)}</Badge>
<Badge
variant={document.status === "failed" ? "destructive" : "outline"}
>
{getStatusLabel(document.status)}
</Badge>
{recoveryPresentation ? (
<Badge
variant="outline"
className={cn(
recoveryPresentation.tone === "warning"
? "border-amber-200 bg-amber-50 text-amber-700"
: "border-sky-200 bg-sky-50 text-sky-700",
)}
>
{recoveryPresentation.badgeLabel}
</Badge>
) : null}
{currentVersion ? (
<Badge variant="outline">v{currentVersion.versionNo}</Badge>
) : null}
</div>
{document.summary ? (
<p className="mt-3 text-sm leading-6 text-slate-600">{document.summary}</p>
) : null}
</div>
{document.summary ? (
<p className="mt-3 text-sm leading-6 text-slate-600">{document.summary}</p>
) : null}
</section>
</section>
<section className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Blocks</div>
<div className="mt-2 text-2xl font-semibold text-slate-950">
{document.blocks.length}
{recoveryPresentation ? (
<section
data-testid="artifact-recovery-notice"
className={cn(
"rounded-2xl border px-4 py-3",
recoveryPresentation.tone === "warning"
? "border-amber-200 bg-amber-50 text-amber-950"
: "border-sky-200 bg-sky-50 text-sky-950",
)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border",
recoveryPresentation.tone === "warning"
? "border-amber-200 bg-white text-amber-700"
: "border-sky-200 bg-white text-sky-700",
)}
>
<Info className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-medium">
{recoveryPresentation.title}
</div>
<p className="mt-1 text-sm leading-6 opacity-90">
{recoveryPresentation.detail}
</p>
{canEditDocument ||
(recoveryPresentation.kind === "recovered_draft" &&
canMarkAsReady) ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{canEditDocument ? (
<button
data-testid="artifact-recovery-continue-editing"
type="button"
className="inline-flex items-center gap-2 rounded-full border border-white/70 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-200 hover:text-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onContinueEditing}
disabled={isUpdatingRecoveryState}
>
<FilePenLine className="h-4 w-4" />
继续编辑
</button>
) : null}
{recoveryPresentation.kind === "recovered_draft" &&
canMarkAsReady ? (
<button
data-testid="artifact-recovery-mark-ready"
type="button"
className="inline-flex items-center gap-2 rounded-full bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-400"
onClick={onMarkAsReady}
disabled={isUpdatingRecoveryState}
>
<Save className="h-4 w-4" />
{isUpdatingRecoveryState ? "处理中" : "标记为可阅读"}
</button>
) : null}
</div>
) : null}
{recoveryActionError ? (
<p className="mt-3 text-sm text-rose-700">{recoveryActionError}</p>
) : null}
</div>
</div>
</section>
) : null}
<section className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">
Blocks
</div>
<div className="mt-2 text-2xl font-semibold text-slate-950">
{document.blocks.length}
</div>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Sources</div>
<div className="mt-2 text-2xl font-semibold text-slate-950">
{document.sources.length}
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">
Sources
</div>
<div className="mt-2 text-2xl font-semibold text-slate-950">
{document.sources.length}
</div>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Links</div>
<div className="mt-2 text-2xl font-semibold text-slate-950">
{sourceLinks.length}
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">
Links
</div>
<div className="mt-2 text-2xl font-semibold text-slate-950">
{sourceLinks.length}
</div>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Versions</div>
<div className="mt-2 text-2xl font-semibold text-slate-950">
{versionHistory.length || (currentVersion ? 1 : 0)}
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">
Versions
</div>
<div className="mt-2 text-2xl font-semibold text-slate-950">
{versionHistory.length || (currentVersion ? 1 : 0)}
</div>
</div>
</div>
</section>
</div>
));
</section>
</div>
);
});
OverviewPanel.displayName = "OverviewPanel";
const SourcesPanel: React.FC<{
@@ -1315,6 +1492,13 @@ export const ArtifactWorkbenchShell: React.FC<ArtifactWorkbenchShellProps> = mem
: {},
[artifact, document, threadItems],
);
const recoveryPresentation = useMemo(
() =>
document
? resolveArtifactRecoveryPresentation(artifact, document)
: null,
[artifact, document],
);
const canEditDocument = Boolean(
document &&
document.status !== "archived" &&
@@ -1337,7 +1521,11 @@ export const ArtifactWorkbenchShell: React.FC<ArtifactWorkbenchShellProps> = mem
Record<string, EditableArtifactBlockDraft>
>({});
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isUpdatingRecoveryState, setIsUpdatingRecoveryState] = useState(false);
const [editSaveError, setEditSaveError] = useState<string | null>(null);
const [recoveryActionError, setRecoveryActionError] = useState<string | null>(
null,
);
const [lastSavedAt, setLastSavedAt] = useState<string | null>(null);
useEffect(() => {
@@ -1358,6 +1546,10 @@ export const ArtifactWorkbenchShell: React.FC<ArtifactWorkbenchShellProps> = mem
);
}, [editableBlocks]);
useEffect(() => {
setRecoveryActionError(null);
}, [document?.artifactId, document?.status, currentVersion?.id]);
useEffect(() => {
if (
selectedEditBlockId &&
@@ -1493,6 +1685,43 @@ export const ArtifactWorkbenchShell: React.FC<ArtifactWorkbenchShellProps> = mem
],
);
const handleContinueEditing = useCallback(() => {
if (!canEditDocument) {
return;
}
setRecoveryActionError(null);
setInspectorTab("edit");
setSelectedEditBlockId((current) => current ?? editableBlocks[0]?.blockId ?? null);
}, [canEditDocument, editableBlocks]);
const handleMarkAsReady = useCallback(async () => {
if (
!document ||
!onSaveArtifactDocument ||
recoveryPresentation?.kind !== "recovered_draft"
) {
return;
}
setRecoveryActionError(null);
setIsUpdatingRecoveryState(true);
try {
await onSaveArtifactDocument(
artifact,
updateArtifactDocumentStatus(document, "ready"),
);
setLastSavedAt(new Date().toISOString());
} catch (error) {
setRecoveryActionError(
error instanceof Error ? error.message : "标记为可阅读失败",
);
} finally {
setIsUpdatingRecoveryState(false);
}
}, [artifact, document, onSaveArtifactDocument, recoveryPresentation]);
return (
<div
data-testid="artifact-workbench-shell"
@@ -1596,6 +1825,18 @@ export const ArtifactWorkbenchShell: React.FC<ArtifactWorkbenchShellProps> = mem
currentVersion={currentVersion}
versionHistory={versionHistory}
sourceLinks={sourceLinks}
recoveryPresentation={recoveryPresentation}
canEditDocument={canEditDocument}
canMarkAsReady={Boolean(
onSaveArtifactDocument &&
recoveryPresentation?.kind === "recovered_draft",
)}
isUpdatingRecoveryState={isUpdatingRecoveryState}
recoveryActionError={recoveryActionError}
onContinueEditing={handleContinueEditing}
onMarkAsReady={() => {
void handleMarkAsReady();
}}
/>
) : (
<EmptyInspectorState
@@ -0,0 +1,131 @@
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 type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge";
import { useWorkspaceCanvasLayoutRuntime } from "./useWorkspaceCanvasLayoutRuntime";
type HookProps = Parameters<typeof useWorkspaceCanvasLayoutRuntime>[0];
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
function renderHook(props?: Partial<HookProps>) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let latestValue: ReturnType<typeof useWorkspaceCanvasLayoutRuntime> | null =
null;
const defaultProps: HookProps = {
activeTheme: "general",
isThemeWorkbench: false,
layoutMode: "chat-canvas",
showChatPanel: true,
showSidebar: true,
defaultTopicSidebarVisible: true,
hasMessages: true,
canvasWorkbenchLayoutMode: "split",
autoCollapsedTopicSidebarRef: { current: false },
mappedTheme: "general",
normalizedEntryTheme: "general",
shouldBootstrapCanvasOnEntry: false,
canvasState: null,
showTeamWorkspaceBoard: false,
hasCurrentCanvasArtifact: false,
currentCanvasArtifactType: null,
currentImageWorkbenchActive: false,
isBrowserAssistCanvasVisible: false,
onHasMessagesChange: vi.fn(),
dismissActiveTeamWorkbenchAutoOpen: vi.fn(),
suppressGeneralCanvasArtifactAutoOpen: vi.fn(),
suppressBrowserAssistCanvasAutoOpen: vi.fn(),
setShowSidebar: vi.fn(),
setLayoutMode: vi.fn(),
setGeneralCanvasState: vi.fn(),
setCanvasState: vi.fn(),
setCanvasWorkbenchLayoutMode: vi.fn(),
setNovelChapterListCollapsed: vi.fn(),
};
function Probe(currentProps: HookProps) {
latestValue = useWorkspaceCanvasLayoutRuntime(currentProps);
return null;
}
const render = async (nextProps?: Partial<HookProps>) => {
await act(async () => {
root.render(<Probe {...defaultProps} {...props} {...nextProps} />);
await Promise.resolve();
});
};
mountedRoots.push({ root, container });
return {
render,
getValue: () => {
if (!latestValue) {
throw new Error("hook 尚未初始化");
}
return latestValue;
},
defaultProps,
};
}
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("useWorkspaceCanvasLayoutRuntime", () => {
it("进入真实预览目标时应自动收起通用空画布,但保留已有内容", async () => {
const setGeneralCanvasState = vi.fn();
const { render } = renderHook({
setGeneralCanvasState,
});
await render();
expect(setGeneralCanvasState).not.toHaveBeenCalled();
await render({
hasCurrentCanvasArtifact: true,
});
expect(setGeneralCanvasState).toHaveBeenCalledTimes(1);
const updater = setGeneralCanvasState.mock.calls[0]?.[0] as
| ((previous: GeneralCanvasState) => GeneralCanvasState)
| undefined;
expect(typeof updater).toBe("function");
const previousState: GeneralCanvasState = {
isOpen: true,
contentType: "markdown",
content: "# 临时草稿\n\n保留这段内容",
isEditing: false,
};
const nextState = updater?.(previousState);
expect(nextState).toEqual({
...previousState,
isOpen: false,
});
});
});
@@ -148,6 +148,30 @@ export function useWorkspaceCanvasLayoutRuntime({
setCanvasWorkbenchLayoutMode,
]);
useEffect(() => {
if (activeTheme !== "general") {
return;
}
if (
!hasCurrentCanvasArtifact &&
!currentImageWorkbenchActive &&
!isBrowserAssistCanvasVisible
) {
return;
}
setGeneralCanvasState((previous) =>
previous.isOpen ? { ...previous, isOpen: false } : previous,
);
}, [
activeTheme,
currentImageWorkbenchActive,
hasCurrentCanvasArtifact,
isBrowserAssistCanvasVisible,
setGeneralCanvasState,
]);
useEffect(() => {
const shouldAutoHideTopicSidebar =
showChatPanel &&
+144
View File
@@ -17,6 +17,7 @@ import {
exportAgentRuntimeHandoffBundle,
exportAgentRuntimeReplayCase,
exportAgentRuntimeReviewDecisionTemplate,
saveAgentRuntimeReviewDecision,
getAsterAgentStatus,
generateAgentRuntimeSessionTitle,
getAgentRuntimeSession,
@@ -860,6 +861,26 @@ describe("Agent API 治理护栏", () => {
pendingRequestCount: 1,
queuedTurnCount: 0,
defaultDecisionStatus: "pending_review",
decision: {
decisionStatus: "pending_review",
decisionSummary: "",
chosenFixStrategy: "",
riskLevel: "unknown",
riskTags: [],
humanReviewer: "",
reviewedAt: null,
followupActions: [],
regressionRequirements: [],
notes: "",
},
decisionStatusOptions: [
"accepted",
"deferred",
"rejected",
"needs_more_evidence",
"pending_review",
],
riskLevelOptions: ["low", "medium", "high", "unknown"],
reviewChecklist: ["先阅读 analysis-brief.md"],
analysisArtifacts: [
{
@@ -890,6 +911,12 @@ describe("Agent API 治理护栏", () => {
).resolves.toMatchObject({
session_id: "session-runtime-4b",
default_decision_status: "pending_review",
decision: expect.objectContaining({
decision_status: "pending_review",
risk_level: "unknown",
}),
decision_status_options: expect.arrayContaining(["accepted"]),
risk_level_options: expect.arrayContaining(["medium"]),
review_checklist: ["先阅读 analysis-brief.md"],
analysis_artifacts: [
expect.objectContaining({
@@ -915,6 +942,123 @@ describe("Agent API 治理护栏", () => {
);
});
it("saveAgentRuntimeReviewDecision 应走统一 review decision 保存命令并归一化返回结构", async () => {
mockSafeInvoke.mockResolvedValueOnce({
sessionId: "session-runtime-4c",
threadId: "thread-runtime-4c",
workspaceRoot: "/tmp/workspace-4c",
reviewRelativeRoot: ".lime/harness/sessions/session-runtime-4c/review",
reviewAbsoluteRoot:
"/tmp/workspace-4c/.lime/harness/sessions/session-runtime-4c/review",
analysisRelativeRoot:
".lime/harness/sessions/session-runtime-4c/analysis",
analysisAbsoluteRoot:
"/tmp/workspace-4c/.lime/harness/sessions/session-runtime-4c/analysis",
handoffBundleRelativeRoot: ".lime/harness/sessions/session-runtime-4c",
evidencePackRelativeRoot:
".lime/harness/sessions/session-runtime-4c/evidence",
replayCaseRelativeRoot:
".lime/harness/sessions/session-runtime-4c/replay",
exportedAt: "2026-03-27T10:25:00Z",
title: "保存人工审核结论",
threadStatus: "waiting_request",
latestTurnStatus: "action_required",
pendingRequestCount: 1,
queuedTurnCount: 0,
defaultDecisionStatus: "pending_review",
decision: {
decisionStatus: "accepted",
decisionSummary: "确认最小修复可接受。",
chosenFixStrategy: "先收口 runtime 命令,再补 UI 回归。",
riskLevel: "medium",
riskTags: ["runtime", "ui"],
humanReviewer: "Lime Maintainer",
reviewedAt: "2026-03-27T10:25:00Z",
followupActions: ["补充 HarnessStatusPanel 测试"],
regressionRequirements: ["npm run test:contracts"],
notes: "保持 review decision 主链单一。",
},
decisionStatusOptions: [
"accepted",
"deferred",
"rejected",
"needs_more_evidence",
"pending_review",
],
riskLevelOptions: ["low", "medium", "high", "unknown"],
reviewChecklist: ["先阅读 analysis-brief.md"],
analysisArtifacts: [
{
kind: "analysis_brief",
title: "外部分析简报",
relativePath:
".lime/harness/sessions/session-runtime-4c/analysis/analysis-brief.md",
absolutePath:
"/tmp/workspace-4c/.lime/harness/sessions/session-runtime-4c/analysis/analysis-brief.md",
bytes: 320,
},
],
artifacts: [
{
kind: "review_decision_markdown",
title: "人工审核记录",
relativePath:
".lime/harness/sessions/session-runtime-4c/review/review-decision.md",
absolutePath:
"/tmp/workspace-4c/.lime/harness/sessions/session-runtime-4c/review/review-decision.md",
bytes: 512,
},
],
});
await expect(
saveAgentRuntimeReviewDecision({
session_id: "session-runtime-4c",
decision_status: "accepted",
decision_summary: "确认最小修复可接受。",
chosen_fix_strategy: "先收口 runtime 命令,再补 UI 回归。",
risk_level: "medium",
risk_tags: ["runtime", "ui"],
human_reviewer: "Lime Maintainer",
reviewed_at: "2026-03-27T10:25:00Z",
followup_actions: ["补充 HarnessStatusPanel 测试"],
regression_requirements: ["npm run test:contracts"],
notes: "保持 review decision 主链单一。",
}),
).resolves.toMatchObject({
session_id: "session-runtime-4c",
decision: expect.objectContaining({
decision_status: "accepted",
risk_level: "medium",
risk_tags: ["runtime", "ui"],
}),
artifacts: [
expect.objectContaining({
kind: "review_decision_markdown",
}),
],
});
expect(mockSafeInvoke).toHaveBeenCalledWith(
"agent_runtime_save_review_decision",
{
request: {
session_id: "session-runtime-4c",
decision_status: "accepted",
decision_summary: "确认最小修复可接受。",
chosen_fix_strategy: "先收口 runtime 命令,再补 UI 回归。",
risk_level: "medium",
risk_tags: ["runtime", "ui"],
human_reviewer: "Lime Maintainer",
reviewed_at: "2026-03-27T10:25:00Z",
followup_actions: ["补充 HarnessStatusPanel 测试"],
regression_requirements: ["npm run test:contracts"],
notes: "保持 review decision 主链单一。",
},
},
);
});
it("getAgentRuntimeToolInventory 应走统一 runtime inventory 命令", async () => {
mockSafeInvoke.mockResolvedValueOnce({
request: {
+125
View File
@@ -477,6 +477,19 @@ export type AgentRuntimeReviewDecisionArtifactKind =
| "review_decision_markdown"
| "review_decision_json";
export type AgentRuntimeReviewDecisionStatus =
| "accepted"
| "deferred"
| "rejected"
| "needs_more_evidence"
| "pending_review";
export type AgentRuntimeReviewDecisionRiskLevel =
| "low"
| "medium"
| "high"
| "unknown";
export interface AgentRuntimeReviewDecisionArtifact {
kind: AgentRuntimeReviewDecisionArtifactKind;
title: string;
@@ -485,6 +498,24 @@ export interface AgentRuntimeReviewDecisionArtifact {
bytes: number;
}
export interface AgentRuntimeReviewDecision {
decision_status: AgentRuntimeReviewDecisionStatus;
decision_summary: string;
chosen_fix_strategy: string;
risk_level: AgentRuntimeReviewDecisionRiskLevel;
risk_tags: string[];
human_reviewer: string;
reviewed_at?: string;
followup_actions: string[];
regression_requirements: string[];
notes: string;
}
export interface AgentRuntimeSaveReviewDecisionRequest
extends AgentRuntimeReviewDecision {
session_id: string;
}
export interface AgentRuntimeReviewDecisionTemplate {
session_id: string;
thread_id: string;
@@ -504,6 +535,9 @@ export interface AgentRuntimeReviewDecisionTemplate {
pending_request_count: number;
queued_turn_count: number;
default_decision_status: string;
decision: AgentRuntimeReviewDecision;
decision_status_options: AgentRuntimeReviewDecisionStatus[];
risk_level_options: AgentRuntimeReviewDecisionRiskLevel[];
review_checklist: string[];
analysis_artifacts: AgentRuntimeAnalysisArtifact[];
artifacts: AgentRuntimeReviewDecisionArtifact[];
@@ -657,6 +691,76 @@ function normalizeReviewDecisionArtifact(
};
}
function normalizeReviewDecisionStatus(
value: string,
): AgentRuntimeReviewDecisionStatus {
switch (value) {
case "accepted":
case "deferred":
case "rejected":
case "needs_more_evidence":
case "pending_review":
return value;
default:
return "pending_review";
}
}
function normalizeReviewDecisionRiskLevel(
value: string,
): AgentRuntimeReviewDecisionRiskLevel {
switch (value) {
case "low":
case "medium":
case "high":
case "unknown":
return value;
default:
return "unknown";
}
}
function normalizeReviewDecision(value: unknown): AgentRuntimeReviewDecision {
const record = isRecord(value) ? value : {};
return {
decision_status: normalizeReviewDecisionStatus(
readStringField(record, "decisionStatus", "decision_status"),
),
decision_summary: readStringField(
record,
"decisionSummary",
"decision_summary",
),
chosen_fix_strategy: readStringField(
record,
"chosenFixStrategy",
"chosen_fix_strategy",
),
risk_level: normalizeReviewDecisionRiskLevel(
readStringField(record, "riskLevel", "risk_level"),
),
risk_tags: readStringListField(record, "riskTags", "risk_tags"),
human_reviewer: readStringField(
record,
"humanReviewer",
"human_reviewer",
),
reviewed_at: readOptionalStringField(record, "reviewedAt", "reviewed_at"),
followup_actions: readStringListField(
record,
"followupActions",
"followup_actions",
),
regression_requirements: readStringListField(
record,
"regressionRequirements",
"regression_requirements",
),
notes: readStringField(record, "notes"),
};
}
function normalizeReviewDecisionTemplate(
value: unknown,
): AgentRuntimeReviewDecisionTemplate {
@@ -735,6 +839,17 @@ function normalizeReviewDecisionTemplate(
"defaultDecisionStatus",
"default_decision_status",
),
decision: normalizeReviewDecision(record.decision),
decision_status_options: readStringListField(
record,
"decisionStatusOptions",
"decision_status_options",
).map((status) => normalizeReviewDecisionStatus(status)),
risk_level_options: readStringListField(
record,
"riskLevelOptions",
"risk_level_options",
).map((riskLevel) => normalizeReviewDecisionRiskLevel(riskLevel)),
review_checklist: readStringListField(
record,
"reviewChecklist",
@@ -1284,6 +1399,16 @@ export async function exportAgentRuntimeReviewDecisionTemplate(
);
}
export async function saveAgentRuntimeReviewDecision(
request: AgentRuntimeSaveReviewDecisionRequest,
): Promise<AgentRuntimeReviewDecisionTemplate> {
return normalizeReviewDecisionTemplate(
await safeInvoke("agent_runtime_save_review_decision", {
request,
}),
);
}
export async function exportAgentRuntimeEvidencePack(
sessionId: string,
): Promise<AgentRuntimeEvidencePack> {
@@ -11,6 +11,7 @@ const mockPriorityCommands = new Set<string>([
"agent_runtime_export_handoff_bundle",
"agent_runtime_export_evidence_pack",
"agent_runtime_export_review_decision_template",
"agent_runtime_save_review_decision",
"agent_runtime_export_replay_case",
"agent_runtime_get_tool_inventory",
"connection_list",
+2 -81
View File
@@ -21,6 +21,7 @@
"agent_runtime_export_handoff_bundle",
"agent_runtime_export_evidence_pack",
"agent_runtime_export_review_decision_template",
"agent_runtime_save_review_decision",
"agent_runtime_export_replay_case",
"agent_runtime_get_tool_inventory",
"agent_runtime_spawn_subagent",
@@ -69,85 +70,5 @@
"getAsterSession": "getAgentRuntimeSession",
"deleteAsterSession": "deleteAgentRuntimeSession",
"renameAsterSession": "updateAgentRuntimeSession"
},
"legacyCommandSurfaceMonitors": [
{
"id": "agent-create-session-compat-command",
"classification": "dead-candidate",
"description": "已零引用的旧 agent_create_session 命令边界",
"commands": ["agent_create_session"],
"allowedPaths": []
},
{
"id": "agent-session-message-legacy-command",
"classification": "dead-candidate",
"description": "已零引用的旧 agent session message 命令边界",
"commands": ["agent_get_session_messages"],
"allowedPaths": []
},
{
"id": "agent-session-compat-commands",
"classification": "dead-candidate",
"description": "已零引用的旧 agent session compat 命令边界",
"commands": [
"agent_list_sessions",
"agent_get_session",
"agent_delete_session",
"agent_rename_session"
],
"allowedPaths": []
},
{
"id": "agent-terminal-tool-callback-commands",
"classification": "dead-candidate",
"description": "已下线的 TerminalTool / TermScrollbackTool 回调命令边界",
"commands": [
"agent_terminal_command_response",
"agent_term_scrollback_response"
],
"allowedPaths": []
}
],
"legacyHelperSurfaceMonitors": [
{
"id": "agent-legacy-session-api-helpers",
"classification": "dead-candidate",
"description": "已零引用的旧 Agent session compat helper 回流",
"helpers": [
"createAgentSession",
"listAgentSessions",
"getAgentSession",
"getAgentSessionMessages",
"renameAgentSession",
"deleteAgentSession",
"generateAgentTitle"
],
"allowedPaths": []
},
{
"id": "agent-legacy-stream-action-helpers",
"classification": "dead-candidate",
"description": "已零引用的旧 Aster stream/action helper 回流",
"helpers": [
"sendAsterMessageStream",
"confirmAsterAction",
"submitAsterElicitationResponse",
"stopAsterSession"
],
"allowedPaths": []
},
{
"id": "aster-session-helper-direct-usage",
"classification": "dead-candidate",
"description": "已零引用的前端 direct Aster session helper 回流",
"helpers": [
"createAsterSession",
"listAsterSessions",
"getAsterSession",
"deleteAsterSession",
"renameAsterSession"
],
"allowedPaths": []
}
]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import agentCommandCatalog from "./agentCommandCatalog.json";
import legacySurfaceCatalogJson from "./legacySurfaceCatalog.json";
describe("legacySurfaceCatalog", () => {
it("应提供完整且无重复的治理扫描目录册", () => {
const catalog = legacySurfaceCatalogJson;
const groups = [
catalog.imports,
catalog.commands,
catalog.frontendText,
catalog.rustText,
catalog.rustTextCounts,
];
expect(groups.every(Array.isArray)).toBe(true);
expect(catalog.imports.length).toBeGreaterThan(0);
expect(catalog.commands.length).toBeGreaterThan(0);
expect(catalog.frontendText.length).toBeGreaterThan(0);
expect(catalog.rustText.length).toBeGreaterThan(0);
expect(catalog.rustTextCounts.length).toBeGreaterThan(0);
const ids = groups.flat().map((monitor) => monitor.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("命令目录册不应继续携带 legacy surface 扫描数据", () => {
expect("legacyCommandSurfaceMonitors" in agentCommandCatalog).toBe(false);
expect("legacyHelperSurfaceMonitors" in agentCommandCatalog).toBe(false);
});
});
+109
View File
@@ -2010,6 +2010,115 @@ const defaultMocks: Record<string, any> = {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
decision: {
decision_status: "pending_review",
decision_summary: "",
chosen_fix_strategy: "",
risk_level: "unknown",
risk_tags: [],
human_reviewer: "",
reviewed_at: null,
followup_actions: [],
regression_requirements: [],
notes: "",
},
decision_status_options: [
"accepted",
"deferred",
"rejected",
"needs_more_evidence",
"pending_review",
],
risk_level_options: ["low", "medium", "high", "unknown"],
review_checklist: [
"先阅读 analysis-brief.md 与 analysis-context.json。",
"确认最终决策由人工审核者填写。",
],
analysis_artifacts: [
{
kind: "analysis_brief",
title: "外部分析简报",
relative_path:
".lime/harness/sessions/mock-session/analysis/analysis-brief.md",
absolute_path:
"/mock/workspace/.lime/harness/sessions/mock-session/analysis/analysis-brief.md",
bytes: 512,
},
],
artifacts: [
{
kind: "review_decision_markdown",
title: "人工审核记录",
relative_path:
".lime/harness/sessions/mock-session/review/review-decision.md",
absolute_path:
"/mock/workspace/.lime/harness/sessions/mock-session/review/review-decision.md",
bytes: 512,
},
{
kind: "review_decision_json",
title: "人工审核记录 JSON",
relative_path:
".lime/harness/sessions/mock-session/review/review-decision.json",
absolute_path:
"/mock/workspace/.lime/harness/sessions/mock-session/review/review-decision.json",
bytes: 768,
},
],
}),
agent_runtime_save_review_decision: ({ request }) => ({
session_id: request?.session_id || request?.sessionId || "mock-session",
thread_id: "mock-thread",
workspace_root: "/mock/workspace",
review_relative_root: ".lime/harness/sessions/mock-session/review",
review_absolute_root:
"/mock/workspace/.lime/harness/sessions/mock-session/review",
analysis_relative_root: ".lime/harness/sessions/mock-session/analysis",
analysis_absolute_root:
"/mock/workspace/.lime/harness/sessions/mock-session/analysis",
handoff_bundle_relative_root: ".lime/harness/sessions/mock-session",
evidence_pack_relative_root: ".lime/harness/sessions/mock-session/evidence",
replay_case_relative_root: ".lime/harness/sessions/mock-session/replay",
exported_at: "2026-03-27T00:07:00Z",
title: "记录外部分析后的人工审核结论",
thread_status: "waiting_request",
latest_turn_status: "action_required",
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
decision: {
decision_status:
request?.decision_status ||
request?.decisionStatus ||
"pending_review",
decision_summary:
request?.decision_summary || request?.decisionSummary || "",
chosen_fix_strategy:
request?.chosen_fix_strategy || request?.chosenFixStrategy || "",
risk_level: request?.risk_level || request?.riskLevel || "unknown",
risk_tags: request?.risk_tags || request?.riskTags || [],
human_reviewer:
request?.human_reviewer || request?.humanReviewer || "",
reviewed_at:
request?.reviewed_at ||
request?.reviewedAt ||
"2026-03-27T00:07:00Z",
followup_actions:
request?.followup_actions || request?.followupActions || [],
regression_requirements:
request?.regression_requirements ||
request?.regressionRequirements ||
[],
notes: request?.notes || "",
},
decision_status_options: [
"accepted",
"deferred",
"rejected",
"needs_more_evidence",
"pending_review",
],
risk_level_options: ["low", "medium", "high", "unknown"],
review_checklist: [
"先阅读 analysis-brief.md 与 analysis-context.json。",
"确认最终决策由人工审核者填写。",