mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: finalize latest v0.97.0 updates
This commit is contained in:
@@ -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 &&
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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。",
|
||||
"确认最终决策由人工审核者填写。",
|
||||
|
||||
Reference in New Issue
Block a user