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:
@@ -1454,6 +1454,7 @@ pub fn run() {
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_handoff_bundle,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_evidence_pack,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_review_decision_template,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_save_review_decision,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_replay_case,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_tool_inventory,
|
||||
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_spawn_subagent,
|
||||
|
||||
@@ -52,7 +52,8 @@ pub(crate) use runtime_api::{
|
||||
agent_runtime_export_replay_case, agent_runtime_get_session, agent_runtime_get_thread_read,
|
||||
agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn,
|
||||
agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn,
|
||||
agent_runtime_replay_request, agent_runtime_resume_thread, agent_runtime_submit_turn,
|
||||
agent_runtime_replay_request, agent_runtime_resume_thread, agent_runtime_save_review_decision,
|
||||
agent_runtime_submit_turn,
|
||||
};
|
||||
pub(crate) use session_api::{
|
||||
agent_runtime_create_session, agent_runtime_list_sessions, agent_runtime_update_session,
|
||||
|
||||
@@ -12,7 +12,8 @@ use crate::services::runtime_replay_case_service::{
|
||||
export_runtime_replay_case, RuntimeReplayCaseExportResult,
|
||||
};
|
||||
use crate::services::runtime_review_decision_service::{
|
||||
export_runtime_review_decision_template, RuntimeReviewDecisionTemplateExportResult,
|
||||
export_runtime_review_decision_template, save_runtime_review_decision,
|
||||
RuntimeReviewDecisionContent, RuntimeReviewDecisionTemplateExportResult,
|
||||
};
|
||||
use crate::services::thread_reliability_projection_service::sync_thread_reliability_projection;
|
||||
use std::path::PathBuf;
|
||||
@@ -454,6 +455,54 @@ pub async fn agent_runtime_export_review_decision_template(
|
||||
)
|
||||
}
|
||||
|
||||
/// 统一运行时:保存当前会话的人工审核结果。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_save_review_decision(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimeSaveReviewDecisionRequest,
|
||||
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
|
||||
let session_id = request.session_id.trim().to_string();
|
||||
tracing::info!("[AsterAgent] 保存 review decision: {}", session_id);
|
||||
let context = load_runtime_export_context(
|
||||
&app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
&session_id,
|
||||
"保存 review decision",
|
||||
)
|
||||
.await?;
|
||||
|
||||
save_runtime_review_decision(
|
||||
&context.detail,
|
||||
&context.thread_read,
|
||||
&context.workspace_root,
|
||||
RuntimeReviewDecisionContent {
|
||||
decision_status: request.decision_status,
|
||||
decision_summary: request.decision_summary,
|
||||
chosen_fix_strategy: request.chosen_fix_strategy,
|
||||
risk_level: request.risk_level,
|
||||
risk_tags: request.risk_tags,
|
||||
human_reviewer: request.human_reviewer,
|
||||
reviewed_at: request.reviewed_at,
|
||||
followup_actions: request.followup_actions,
|
||||
regression_requirements: request.regression_requirements,
|
||||
notes: request.notes,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 统一运行时:导出当前会话的 replay case。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_export_replay_case(
|
||||
|
||||
@@ -1601,6 +1601,32 @@ pub struct AgentRuntimeUpdateSessionRequest {
|
||||
pub recent_team_selection: Option<lime_agent::SessionExecutionRuntimeRecentTeamSelection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimeSaveReviewDecisionRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
#[serde(default, alias = "decisionStatus")]
|
||||
pub decision_status: String,
|
||||
#[serde(default, alias = "decisionSummary")]
|
||||
pub decision_summary: String,
|
||||
#[serde(default, alias = "chosenFixStrategy")]
|
||||
pub chosen_fix_strategy: String,
|
||||
#[serde(default, alias = "riskLevel")]
|
||||
pub risk_level: String,
|
||||
#[serde(default, alias = "riskTags")]
|
||||
pub risk_tags: Vec<String>,
|
||||
#[serde(default, alias = "humanReviewer")]
|
||||
pub human_reviewer: String,
|
||||
#[serde(default, alias = "reviewedAt")]
|
||||
pub reviewed_at: Option<String>,
|
||||
#[serde(default, alias = "followupActions")]
|
||||
pub followup_actions: Vec<String>,
|
||||
#[serde(default, alias = "regressionRequirements")]
|
||||
pub regression_requirements: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// 自动续写参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AutoContinuePayload {
|
||||
|
||||
@@ -306,10 +306,10 @@ pub(crate) use command_api::{
|
||||
agent_runtime_get_session, agent_runtime_get_thread_read, agent_runtime_get_tool_inventory,
|
||||
agent_runtime_interrupt_turn, agent_runtime_list_sessions, agent_runtime_promote_queued_turn,
|
||||
agent_runtime_remove_queued_turn, agent_runtime_replay_request, agent_runtime_resume_subagent,
|
||||
agent_runtime_resume_thread, agent_runtime_send_subagent_input, agent_runtime_spawn_subagent,
|
||||
agent_runtime_submit_turn, agent_runtime_update_session, agent_runtime_wait_subagents,
|
||||
aster_agent_configure_from_pool, aster_agent_configure_provider, aster_agent_init,
|
||||
aster_agent_reset, aster_agent_status,
|
||||
agent_runtime_resume_thread, agent_runtime_save_review_decision,
|
||||
agent_runtime_send_subagent_input, agent_runtime_spawn_subagent, agent_runtime_submit_turn,
|
||||
agent_runtime_update_session, agent_runtime_wait_subagents, aster_agent_configure_from_pool,
|
||||
aster_agent_configure_provider, aster_agent_init, aster_agent_reset, aster_agent_status,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use dto::{
|
||||
@@ -322,8 +322,9 @@ pub(crate) use dto::{
|
||||
AgentRuntimeReplayRequestRequest, AgentRuntimeReplayedActionRequiredView,
|
||||
AgentRuntimeRequestView, AgentRuntimeRespondActionRequest, AgentRuntimeResumeSubagentRequest,
|
||||
AgentRuntimeResumeSubagentResponse, AgentRuntimeResumeThreadRequest,
|
||||
AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse,
|
||||
AgentRuntimeSessionDetail, AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse,
|
||||
AgentRuntimeSaveReviewDecisionRequest, AgentRuntimeSendSubagentInputRequest,
|
||||
AgentRuntimeSendSubagentInputResponse, AgentRuntimeSessionDetail,
|
||||
AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse,
|
||||
AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadDiagnostics, AgentRuntimeThreadReadModel,
|
||||
AgentRuntimeToolInventoryRequest, AgentRuntimeUpdateSessionRequest,
|
||||
AgentRuntimeWaitSubagentsRequest, AgentRuntimeWaitSubagentsResponse, AsterAgentStatus,
|
||||
|
||||
@@ -31,19 +31,31 @@ fn emit_runtime_side_event(
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_artifact_document_issues(issues: &[String]) -> String {
|
||||
let parts = issues
|
||||
.iter()
|
||||
.map(|issue| issue.trim())
|
||||
.filter(|issue| !issue.is_empty())
|
||||
.take(3)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if parts.is_empty() {
|
||||
"结构已按最小可用方案落盘。".to_string()
|
||||
} else {
|
||||
parts.join(";")
|
||||
fn build_artifact_document_warning_message(
|
||||
status: &str,
|
||||
fallback_used: bool,
|
||||
issues: &[String],
|
||||
) -> String {
|
||||
if status == "failed" {
|
||||
return "结构化文稿未完整生成,已保留一份可继续编辑的恢复稿。".to_string();
|
||||
}
|
||||
|
||||
if fallback_used
|
||||
|| issues
|
||||
.iter()
|
||||
.any(|issue| issue.contains("Markdown 正文自动恢复"))
|
||||
{
|
||||
return "已根据正文整理出一份可继续编辑的草稿。".to_string();
|
||||
}
|
||||
|
||||
if issues
|
||||
.iter()
|
||||
.any(|issue| issue.contains("不完整的 ArtifactDocument JSON"))
|
||||
{
|
||||
return "已补全文稿结构,可继续查看和编辑。".to_string();
|
||||
}
|
||||
|
||||
"已整理为可继续编辑的文稿。".to_string()
|
||||
}
|
||||
|
||||
fn merge_turn_context_with_artifact_output_schema(
|
||||
@@ -213,15 +225,19 @@ fn maybe_persist_artifact_document_after_stream(
|
||||
let (code, prefix) = if persisted.status == "failed" {
|
||||
(
|
||||
ARTIFACT_DOCUMENT_FAILED_WARNING_CODE,
|
||||
"ArtifactDocument 未通过完整校验,已以失败态文档落盘",
|
||||
"ArtifactDocument 已落盘",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE,
|
||||
"ArtifactDocument 已自动修复后落盘",
|
||||
"ArtifactDocument 已落盘",
|
||||
)
|
||||
};
|
||||
let detail = summarize_artifact_document_issues(&persisted.issues);
|
||||
let detail = build_artifact_document_warning_message(
|
||||
persisted.status.as_str(),
|
||||
persisted.fallback_used,
|
||||
&persisted.issues,
|
||||
);
|
||||
emit_runtime_side_event(
|
||||
app,
|
||||
event_name,
|
||||
|
||||
@@ -34,6 +34,10 @@ const ARTIFACT_BLOCK_TYPE_VALUES: &[&str] = &[
|
||||
"divider",
|
||||
];
|
||||
const MAX_BLOCK_COUNT: usize = 40;
|
||||
const MARKDOWN_RECOVERY_REASON: &str =
|
||||
"模型未返回合法的 ArtifactDocument JSON,已按 Markdown 正文自动恢复为可渲染文档。";
|
||||
const TRUNCATED_JSON_RECOVERY_REASON: &str =
|
||||
"检测到不完整的 ArtifactDocument JSON,已做闭合修复后继续校验。";
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ArtifactDocumentValidationContext {
|
||||
@@ -64,7 +68,18 @@ pub fn validate_or_fallback_artifact_document(
|
||||
raw_text: &str,
|
||||
context: &ArtifactDocumentValidationContext,
|
||||
) -> ArtifactDocumentValidationOutcome {
|
||||
let Some(candidate) = extract_artifact_document_candidate(raw_text) else {
|
||||
let Some((candidate, repaired_truncated_json)) = extract_artifact_document_candidate(raw_text)
|
||||
else {
|
||||
if let Some(recovered_candidate) = build_markdown_recovery_candidate(raw_text, context) {
|
||||
let mut outcome =
|
||||
validate_or_repair_artifact_document_value(&recovered_candidate, raw_text, context);
|
||||
outcome.repaired = true;
|
||||
outcome.fallback_used = true;
|
||||
outcome
|
||||
.issues
|
||||
.insert(0, MARKDOWN_RECOVERY_REASON.to_string());
|
||||
return outcome;
|
||||
}
|
||||
return build_failed_fallback_document(
|
||||
raw_text,
|
||||
"模型未返回合法的 ArtifactDocument JSON,已回退为失败态文档。",
|
||||
@@ -72,7 +87,14 @@ pub fn validate_or_fallback_artifact_document(
|
||||
);
|
||||
};
|
||||
|
||||
validate_or_repair_artifact_document_value(&candidate, raw_text, context)
|
||||
let mut outcome = validate_or_repair_artifact_document_value(&candidate, raw_text, context);
|
||||
if repaired_truncated_json {
|
||||
outcome.repaired = true;
|
||||
outcome
|
||||
.issues
|
||||
.insert(0, TRUNCATED_JSON_RECOVERY_REASON.to_string());
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
pub fn validate_or_repair_artifact_document_value(
|
||||
@@ -303,7 +325,53 @@ fn build_failed_fallback_document(
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_artifact_document_candidate(raw_text: &str) -> Option<Value> {
|
||||
fn build_markdown_recovery_candidate(
|
||||
raw_text: &str,
|
||||
context: &ArtifactDocumentValidationContext,
|
||||
) -> Option<Value> {
|
||||
let trimmed = raw_text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (heading_title, markdown_body) = extract_markdown_heading_and_body(trimmed);
|
||||
let title = normalize_title(
|
||||
heading_title
|
||||
.or_else(|| context.title_hint.clone())
|
||||
.or_else(|| extract_first_non_empty_line(trimmed)),
|
||||
);
|
||||
let body = if markdown_body.trim().is_empty() {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
markdown_body
|
||||
};
|
||||
let kind = normalize_enum(context.kind_hint.clone(), ARTIFACT_KIND_VALUES, "analysis");
|
||||
let mut document = Map::new();
|
||||
document.insert(
|
||||
"schemaVersion".to_string(),
|
||||
Value::String(ARTIFACT_DOCUMENT_SCHEMA_VERSION.to_string()),
|
||||
);
|
||||
document.insert("kind".to_string(), Value::String(kind));
|
||||
document.insert("title".to_string(), Value::String(title));
|
||||
document.insert("status".to_string(), Value::String("draft".to_string()));
|
||||
document.insert("language".to_string(), Value::String("zh-CN".to_string()));
|
||||
if let Some(summary) = extract_markdown_summary(body.as_str()) {
|
||||
document.insert("summary".to_string(), Value::String(summary));
|
||||
}
|
||||
document.insert(
|
||||
"blocks".to_string(),
|
||||
Value::Array(vec![Value::Object(build_fallback_rich_text_block(
|
||||
"block-1",
|
||||
body.as_str(),
|
||||
Some("markdown_recovery"),
|
||||
))]),
|
||||
);
|
||||
document.insert("sources".to_string(), Value::Array(Vec::new()));
|
||||
document.insert("metadata".to_string(), Value::Object(Map::new()));
|
||||
Some(Value::Object(document))
|
||||
}
|
||||
|
||||
fn extract_artifact_document_candidate(raw_text: &str) -> Option<(Value, bool)> {
|
||||
let trimmed = raw_text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
@@ -314,6 +382,7 @@ fn extract_artifact_document_candidate(raw_text: &str) -> Option<Value> {
|
||||
strip_outer_code_fence(trimmed),
|
||||
extract_first_fenced_payload(trimmed).unwrap_or_default(),
|
||||
extract_braced_json_candidate(trimmed).unwrap_or_default(),
|
||||
extract_unclosed_json_candidate(trimmed).unwrap_or_default(),
|
||||
];
|
||||
|
||||
for candidate in candidates {
|
||||
@@ -321,17 +390,24 @@ fn extract_artifact_document_candidate(raw_text: &str) -> Option<Value> {
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(parsed) = serde_json::from_str::<Value>(normalized) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(document) = unwrap_artifact_document_envelope(&parsed) {
|
||||
return Some(document.clone());
|
||||
if let Some(document) = parse_artifact_document_candidate(normalized) {
|
||||
return Some((document, false));
|
||||
}
|
||||
if let Some(repaired) = repair_json_candidate(normalized) {
|
||||
if let Some(document) = parse_artifact_document_candidate(repaired.as_str()) {
|
||||
return Some((document, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_artifact_document_candidate(candidate: &str) -> Option<Value> {
|
||||
let parsed = serde_json::from_str::<Value>(candidate).ok()?;
|
||||
unwrap_artifact_document_envelope(&parsed).cloned()
|
||||
}
|
||||
|
||||
fn unwrap_artifact_document_envelope(value: &Value) -> Option<&Value> {
|
||||
let record = value.as_object()?;
|
||||
if is_artifact_document_record(record) {
|
||||
@@ -396,6 +472,79 @@ fn extract_braced_json_candidate(raw: &str) -> Option<String> {
|
||||
Some(raw[start..=end].trim().to_string())
|
||||
}
|
||||
|
||||
fn extract_unclosed_json_candidate(raw: &str) -> Option<String> {
|
||||
let start = raw.find('{').or_else(|| raw.find('['))?;
|
||||
Some(raw[start..].trim().to_string())
|
||||
}
|
||||
|
||||
fn repair_json_candidate(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut repaired = String::new();
|
||||
let mut expected_closers = Vec::new();
|
||||
let mut in_string = false;
|
||||
let mut escaping = false;
|
||||
|
||||
for ch in trimmed.chars() {
|
||||
repaired.push(ch);
|
||||
if in_string {
|
||||
if escaping {
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'\\' => escaping = true,
|
||||
'"' => in_string = false,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'"' => in_string = true,
|
||||
'{' => expected_closers.push('}'),
|
||||
'[' => expected_closers.push(']'),
|
||||
'}' | ']' => {
|
||||
let expected = expected_closers.pop()?;
|
||||
if ch != expected {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if in_string {
|
||||
repaired.push('"');
|
||||
}
|
||||
repaired = repair_json_tail(repaired);
|
||||
while let Some(closer) = expected_closers.pop() {
|
||||
repaired = repair_json_tail(repaired);
|
||||
repaired.push(closer);
|
||||
}
|
||||
Some(repaired)
|
||||
}
|
||||
|
||||
fn repair_json_tail(mut value: String) -> String {
|
||||
loop {
|
||||
let trimmed_len = value.trim_end().len();
|
||||
value.truncate(trimmed_len);
|
||||
if value.ends_with(':') {
|
||||
value.push_str(" null");
|
||||
break;
|
||||
}
|
||||
if value.ends_with(',') {
|
||||
value.pop();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn normalize_sources(
|
||||
value: Option<&Value>,
|
||||
repaired: &mut bool,
|
||||
@@ -841,6 +990,102 @@ fn build_fallback_markdown(raw_text: &str, reason: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_markdown_heading_and_body(raw_text: &str) -> (Option<String>, String) {
|
||||
let lines = raw_text.lines().collect::<Vec<_>>();
|
||||
let first_content_index = lines
|
||||
.iter()
|
||||
.position(|line| !line.trim().is_empty())
|
||||
.unwrap_or(0);
|
||||
let first_line = lines
|
||||
.get(first_content_index)
|
||||
.map(|line| line.trim())
|
||||
.unwrap_or_default();
|
||||
let Some(heading) = first_line.strip_prefix('#') else {
|
||||
return (None, raw_text.trim().to_string());
|
||||
};
|
||||
let title = normalize_text(Some(heading.trim_start_matches('#').trim()));
|
||||
if title.is_none() {
|
||||
return (None, raw_text.trim().to_string());
|
||||
}
|
||||
|
||||
let mut body_start = first_content_index + 1;
|
||||
while body_start < lines.len() && lines[body_start].trim().is_empty() {
|
||||
body_start += 1;
|
||||
}
|
||||
let body = lines[body_start..].join("\n").trim().to_string();
|
||||
(title, body)
|
||||
}
|
||||
|
||||
fn extract_first_non_empty_line(raw_text: &str) -> Option<String> {
|
||||
raw_text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn extract_markdown_summary(markdown: &str) -> Option<String> {
|
||||
let mut in_code_block = false;
|
||||
let mut parts = Vec::new();
|
||||
|
||||
for line in markdown.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("```") {
|
||||
in_code_block = !in_code_block;
|
||||
continue;
|
||||
}
|
||||
if in_code_block || trimmed.is_empty() {
|
||||
if !parts.is_empty() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalized = strip_markdown_summary_prefix(trimmed);
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
parts.push(normalized);
|
||||
if parts.len() >= 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(truncate_text(parts.join(" ").as_str(), 180))
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_markdown_summary_prefix(line: &str) -> String {
|
||||
let trimmed = line.trim();
|
||||
let without_bullet = trimmed
|
||||
.strip_prefix("- ")
|
||||
.or_else(|| trimmed.strip_prefix("* "))
|
||||
.or_else(|| trimmed.strip_prefix("> "))
|
||||
.unwrap_or(trimmed)
|
||||
.trim();
|
||||
let without_ordered = without_bullet
|
||||
.find(". ")
|
||||
.and_then(|index| {
|
||||
if without_bullet[..index]
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_digit())
|
||||
{
|
||||
without_bullet.get(index + 2..)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(without_bullet)
|
||||
.trim();
|
||||
without_ordered.to_string()
|
||||
}
|
||||
|
||||
fn normalize_title(value: Option<String>) -> String {
|
||||
value
|
||||
.map(|title| truncate_text(&title, 120))
|
||||
@@ -1132,4 +1377,53 @@ mod tests {
|
||||
== Some("rich_text")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_or_fallback_should_repair_truncated_json_before_markdown_recovery() {
|
||||
let mut context = base_context();
|
||||
context.source_policy = Some("none".to_string());
|
||||
|
||||
let outcome = validate_or_fallback_artifact_document(
|
||||
"{\n \"schemaVersion\": \"artifact_document.v1\",\n \"kind\": \"analysis\",\n \"title\": \"结构化报告\",\n \"status\": \"ready\",\n \"blocks\": [\n { \"type\": \"hero_summary\", \"summary\": \"摘要\" }\n ],\n \"sources\": [],\n \"metadata\": {\n \"theme\": \"knowledge\"\n }\n",
|
||||
&context,
|
||||
);
|
||||
|
||||
assert_eq!(outcome.status, "ready");
|
||||
assert!(!outcome.fallback_used);
|
||||
assert!(outcome.repaired);
|
||||
assert!(outcome
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.contains("不完整的 ArtifactDocument JSON")));
|
||||
assert_eq!(outcome.title, "结构化报告");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_or_fallback_should_recover_markdown_when_sources_not_required() {
|
||||
let mut context = base_context();
|
||||
context.source_policy = Some("none".to_string());
|
||||
context.title_hint = None;
|
||||
|
||||
let outcome = validate_or_fallback_artifact_document(
|
||||
"# 前端概念方案\n\n我将为你整理一份通用的前端概念方案框架。\n\n## 信息架构\n- 页面结构\n",
|
||||
&context,
|
||||
);
|
||||
|
||||
assert_eq!(outcome.status, "draft");
|
||||
assert!(outcome.fallback_used);
|
||||
assert_eq!(outcome.title, "前端概念方案");
|
||||
assert!(outcome.repaired);
|
||||
assert!(outcome
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.contains("Markdown 正文自动恢复")));
|
||||
assert_eq!(
|
||||
outcome.document.get("status").and_then(Value::as_str),
|
||||
Some("draft")
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.document.get("summary").and_then(Value::as_str),
|
||||
Some("我将为你整理一份通用的前端概念方案框架。")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Runtime review decision 模板导出服务
|
||||
//! Runtime review decision 模板导出与保存服务
|
||||
//!
|
||||
//! 将外部 Claude Code / Codex 的分析结论回挂为
|
||||
//! Lime 工作区内可版本化的人工审核与决策记录模板。
|
||||
//! 这条链只导出 review-decision 模板,不在 Lime 内自动批准或自动应用修复。
|
||||
//! 这条链只导出与保存 review-decision,不在 Lime 内自动批准或自动应用修复。
|
||||
|
||||
use crate::agent::SessionDetail;
|
||||
use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
|
||||
@@ -59,12 +59,15 @@ pub struct RuntimeReviewDecisionTemplateExportResult {
|
||||
pub pending_request_count: usize,
|
||||
pub queued_turn_count: usize,
|
||||
pub default_decision_status: String,
|
||||
pub decision: RuntimeReviewDecisionContent,
|
||||
pub decision_status_options: Vec<String>,
|
||||
pub risk_level_options: Vec<String>,
|
||||
pub review_checklist: Vec<String>,
|
||||
pub analysis_artifacts: Vec<RuntimeAnalysisArtifact>,
|
||||
pub artifacts: Vec<RuntimeReviewDecisionArtifact>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReviewDecisionDocument {
|
||||
schema_version: String,
|
||||
@@ -72,20 +75,20 @@ struct ReviewDecisionDocument {
|
||||
exported_at: String,
|
||||
source: ReviewDecisionSource,
|
||||
review_context: ReviewDecisionContext,
|
||||
decision: ReviewDecisionContent,
|
||||
decision: RuntimeReviewDecisionContent,
|
||||
decision_status_options: Vec<String>,
|
||||
risk_level_options: Vec<String>,
|
||||
review_checklist: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReviewDecisionSource {
|
||||
derived_from: Vec<String>,
|
||||
upstream_alignment: ReviewDecisionUpstreamAlignment,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReviewDecisionUpstreamAlignment {
|
||||
execution_environment_reference: String,
|
||||
@@ -93,7 +96,7 @@ struct ReviewDecisionUpstreamAlignment {
|
||||
product_surface: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReviewDecisionContext {
|
||||
session_id: String,
|
||||
@@ -111,7 +114,7 @@ struct ReviewDecisionContext {
|
||||
analysis_artifacts: Vec<ReviewDecisionArtifactReference>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReviewDecisionArtifactReference {
|
||||
kind: String,
|
||||
@@ -119,25 +122,43 @@ struct ReviewDecisionArtifactReference {
|
||||
relative_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ReviewDecisionContent {
|
||||
decision_status: String,
|
||||
decision_summary: String,
|
||||
chosen_fix_strategy: String,
|
||||
risk_level: String,
|
||||
risk_tags: Vec<String>,
|
||||
human_reviewer: String,
|
||||
reviewed_at: Option<String>,
|
||||
followup_actions: Vec<String>,
|
||||
regression_requirements: Vec<String>,
|
||||
notes: String,
|
||||
pub struct RuntimeReviewDecisionContent {
|
||||
pub decision_status: String,
|
||||
pub decision_summary: String,
|
||||
pub chosen_fix_strategy: String,
|
||||
pub risk_level: String,
|
||||
pub risk_tags: Vec<String>,
|
||||
pub human_reviewer: String,
|
||||
pub reviewed_at: Option<String>,
|
||||
pub followup_actions: Vec<String>,
|
||||
pub regression_requirements: Vec<String>,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
pub fn export_runtime_review_decision_template(
|
||||
detail: &SessionDetail,
|
||||
thread_read: &AgentRuntimeThreadReadModel,
|
||||
workspace_root: &Path,
|
||||
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
|
||||
sync_runtime_review_decision(detail, thread_read, workspace_root, None)
|
||||
}
|
||||
|
||||
pub fn save_runtime_review_decision(
|
||||
detail: &SessionDetail,
|
||||
thread_read: &AgentRuntimeThreadReadModel,
|
||||
workspace_root: &Path,
|
||||
decision: RuntimeReviewDecisionContent,
|
||||
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
|
||||
sync_runtime_review_decision(detail, thread_read, workspace_root, Some(decision))
|
||||
}
|
||||
|
||||
fn sync_runtime_review_decision(
|
||||
detail: &SessionDetail,
|
||||
thread_read: &AgentRuntimeThreadReadModel,
|
||||
workspace_root: &Path,
|
||||
decision_override: Option<RuntimeReviewDecisionContent>,
|
||||
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
|
||||
let session_id = detail.id.trim();
|
||||
if session_id.is_empty() {
|
||||
@@ -167,7 +188,13 @@ pub fn export_runtime_review_decision_template(
|
||||
})?;
|
||||
|
||||
let review_checklist = build_review_checklist();
|
||||
let document = build_review_decision_document(&analysis, &exported_at, &review_checklist);
|
||||
let existing_decision = load_existing_review_decision_document(&review_absolute_root)?
|
||||
.map(|document| document.decision);
|
||||
let mut document = build_review_decision_document(&analysis, &exported_at, &review_checklist);
|
||||
let decision = decision_override
|
||||
.or(existing_decision)
|
||||
.unwrap_or_else(|| document.decision.clone());
|
||||
document.decision = normalize_review_decision_content(decision, &exported_at);
|
||||
let markdown = build_review_decision_markdown(&document);
|
||||
let json = serde_json::to_string_pretty(&document)
|
||||
.map_err(|error| format!("序列化 review decision json 失败: {error}"))?;
|
||||
@@ -208,6 +235,9 @@ pub fn export_runtime_review_decision_template(
|
||||
pending_request_count: analysis.pending_request_count,
|
||||
queued_turn_count: analysis.queued_turn_count,
|
||||
default_decision_status: DEFAULT_DECISION_STATUS.to_string(),
|
||||
decision: document.decision,
|
||||
decision_status_options: document.decision_status_options,
|
||||
risk_level_options: document.risk_level_options,
|
||||
review_checklist,
|
||||
analysis_artifacts: analysis.artifacts.clone(),
|
||||
artifacts,
|
||||
@@ -259,7 +289,7 @@ fn build_review_decision_document(
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
decision: ReviewDecisionContent {
|
||||
decision: RuntimeReviewDecisionContent {
|
||||
decision_status: DEFAULT_DECISION_STATUS.to_string(),
|
||||
decision_summary: String::new(),
|
||||
chosen_fix_strategy: String::new(),
|
||||
@@ -271,19 +301,8 @@ fn build_review_decision_document(
|
||||
regression_requirements: Vec::new(),
|
||||
notes: String::new(),
|
||||
},
|
||||
decision_status_options: vec![
|
||||
"accepted".to_string(),
|
||||
"deferred".to_string(),
|
||||
"rejected".to_string(),
|
||||
"needs_more_evidence".to_string(),
|
||||
DEFAULT_DECISION_STATUS.to_string(),
|
||||
],
|
||||
risk_level_options: vec![
|
||||
"low".to_string(),
|
||||
"medium".to_string(),
|
||||
"high".to_string(),
|
||||
DEFAULT_RISK_LEVEL.to_string(),
|
||||
],
|
||||
decision_status_options: build_decision_status_options(),
|
||||
risk_level_options: build_risk_level_options(),
|
||||
review_checklist: review_checklist.to_vec(),
|
||||
}
|
||||
}
|
||||
@@ -302,6 +321,21 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
|
||||
.map(|artifact| format!("- `{}`:`{}`", artifact.title, artifact.relative_path))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let decision_status_options = document
|
||||
.decision_status_options
|
||||
.iter()
|
||||
.map(|status| format!("`{status}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
let risk_tags = format_markdown_inline_list(&document.decision.risk_tags, "待填写");
|
||||
let regression_requirements =
|
||||
format_markdown_list(&document.decision.regression_requirements, "- 待填写");
|
||||
let followup_actions = format_markdown_list(&document.decision.followup_actions, "- 待填写");
|
||||
let decision_summary =
|
||||
format_markdown_text_block(&document.decision.decision_summary, "待填写。");
|
||||
let chosen_fix_strategy =
|
||||
format_markdown_text_block(&document.decision.chosen_fix_strategy, "待填写。");
|
||||
let notes = format_markdown_text_block(&document.decision.notes, "待填写。");
|
||||
|
||||
format!(
|
||||
"# Lime 人工审核与决策记录\n\n\
|
||||
@@ -330,22 +364,22 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
|
||||
{checklist}\n\n\
|
||||
## 4. 决策状态\n\
|
||||
- 当前值:`{decision_status}`\n\
|
||||
- 可选值:`accepted` / `deferred` / `rejected` / `needs_more_evidence`\n\n\
|
||||
- 可选值:{decision_status_options}\n\n\
|
||||
## 5. 决策摘要\n\
|
||||
待填写。\n\n\
|
||||
{decision_summary}\n\n\
|
||||
## 6. 采用的修复策略\n\
|
||||
待填写。\n\n\
|
||||
{chosen_fix_strategy}\n\n\
|
||||
## 7. 风险等级与标签\n\
|
||||
- 风险等级:`{risk_level}`\n\
|
||||
- 风险标签:待填写\n\n\
|
||||
- 风险标签:{risk_tags}\n\n\
|
||||
## 8. 回归要求\n\
|
||||
- 待填写\n\n\
|
||||
{regression_requirements}\n\n\
|
||||
## 9. 后续动作\n\
|
||||
- 待填写\n\n\
|
||||
{followup_actions}\n\n\
|
||||
## 10. 审核备注\n\
|
||||
- 审核人:待填写\n\
|
||||
- 审核时间:待填写\n\
|
||||
- 备注:待填写\n",
|
||||
- 审核人:{human_reviewer}\n\
|
||||
- 审核时间:{reviewed_at}\n\
|
||||
- 备注:\n{notes}\n",
|
||||
decision_status = document.decision.decision_status,
|
||||
exported_at = document.exported_at,
|
||||
title = empty_fallback(&document.review_context.title, "未命名"),
|
||||
@@ -374,7 +408,25 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
|
||||
} else {
|
||||
checklist
|
||||
},
|
||||
decision_status_options = if decision_status_options.is_empty() {
|
||||
format!("`{DEFAULT_DECISION_STATUS}`")
|
||||
} else {
|
||||
decision_status_options
|
||||
},
|
||||
decision_summary = decision_summary,
|
||||
chosen_fix_strategy = chosen_fix_strategy,
|
||||
risk_level = document.decision.risk_level,
|
||||
risk_tags = risk_tags,
|
||||
regression_requirements = regression_requirements,
|
||||
followup_actions = followup_actions,
|
||||
human_reviewer = empty_fallback(&document.decision.human_reviewer, "待填写"),
|
||||
reviewed_at = document
|
||||
.decision
|
||||
.reviewed_at
|
||||
.as_deref()
|
||||
.filter(|value: &&str| !value.trim().is_empty())
|
||||
.unwrap_or("待填写"),
|
||||
notes = notes,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -425,6 +477,162 @@ fn write_review_decision_artifact(
|
||||
})
|
||||
}
|
||||
|
||||
fn load_existing_review_decision_document(
|
||||
review_absolute_root: &Path,
|
||||
) -> Result<Option<ReviewDecisionDocument>, String> {
|
||||
let json_path = review_absolute_root.join(REVIEW_DECISION_JSON_FILE_NAME);
|
||||
if !json_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(&json_path).map_err(|error| {
|
||||
format!(
|
||||
"读取已有 review decision json 失败 {}: {error}",
|
||||
json_path.display()
|
||||
)
|
||||
})?;
|
||||
let document = serde_json::from_str::<ReviewDecisionDocument>(&contents).map_err(|error| {
|
||||
format!(
|
||||
"解析已有 review decision json 失败 {}: {error}",
|
||||
json_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(Some(document))
|
||||
}
|
||||
|
||||
fn normalize_review_decision_content(
|
||||
decision: RuntimeReviewDecisionContent,
|
||||
exported_at: &str,
|
||||
) -> RuntimeReviewDecisionContent {
|
||||
let decision_status = normalize_review_decision_status(&decision.decision_status);
|
||||
let decision_summary = normalize_string(&decision.decision_summary);
|
||||
let chosen_fix_strategy = normalize_string(&decision.chosen_fix_strategy);
|
||||
let risk_level = normalize_review_risk_level(&decision.risk_level);
|
||||
let risk_tags = normalize_string_list(&decision.risk_tags);
|
||||
let human_reviewer = normalize_string(&decision.human_reviewer);
|
||||
let followup_actions = normalize_string_list(&decision.followup_actions);
|
||||
let regression_requirements = normalize_string_list(&decision.regression_requirements);
|
||||
let notes = normalize_string(&decision.notes);
|
||||
let reviewed_at = normalize_optional_string(decision.reviewed_at.as_deref()).or_else(|| {
|
||||
let has_review_content = decision_status != DEFAULT_DECISION_STATUS
|
||||
|| !decision_summary.is_empty()
|
||||
|| !chosen_fix_strategy.is_empty()
|
||||
|| risk_level != DEFAULT_RISK_LEVEL
|
||||
|| !risk_tags.is_empty()
|
||||
|| !human_reviewer.is_empty()
|
||||
|| !followup_actions.is_empty()
|
||||
|| !regression_requirements.is_empty()
|
||||
|| !notes.is_empty();
|
||||
has_review_content.then(|| exported_at.to_string())
|
||||
});
|
||||
|
||||
RuntimeReviewDecisionContent {
|
||||
decision_status,
|
||||
decision_summary,
|
||||
chosen_fix_strategy,
|
||||
risk_level,
|
||||
risk_tags,
|
||||
human_reviewer,
|
||||
reviewed_at,
|
||||
followup_actions,
|
||||
regression_requirements,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_decision_status_options() -> Vec<String> {
|
||||
vec![
|
||||
"accepted".to_string(),
|
||||
"deferred".to_string(),
|
||||
"rejected".to_string(),
|
||||
"needs_more_evidence".to_string(),
|
||||
DEFAULT_DECISION_STATUS.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_risk_level_options() -> Vec<String> {
|
||||
vec![
|
||||
"low".to_string(),
|
||||
"medium".to_string(),
|
||||
"high".to_string(),
|
||||
DEFAULT_RISK_LEVEL.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn normalize_review_decision_status(value: &str) -> String {
|
||||
match value.trim() {
|
||||
"accepted" => "accepted".to_string(),
|
||||
"deferred" => "deferred".to_string(),
|
||||
"rejected" => "rejected".to_string(),
|
||||
"needs_more_evidence" => "needs_more_evidence".to_string(),
|
||||
"pending_review" => "pending_review".to_string(),
|
||||
_ => DEFAULT_DECISION_STATUS.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_review_risk_level(value: &str) -> String {
|
||||
match value.trim() {
|
||||
"low" => "low".to_string(),
|
||||
"medium" => "medium".to_string(),
|
||||
"high" => "high".to_string(),
|
||||
"unknown" => "unknown".to_string(),
|
||||
_ => DEFAULT_RISK_LEVEL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_string(value: &str) -> String {
|
||||
value.trim().to_string()
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn normalize_string_list(values: &[String]) -> Vec<String> {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format_markdown_text_block(value: &str, placeholder: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
placeholder.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_markdown_list(values: &[String], placeholder: &str) -> String {
|
||||
if values.is_empty() {
|
||||
placeholder.to_string()
|
||||
} else {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| format!("- {value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_markdown_inline_list(values: &[String], placeholder: &str) -> String {
|
||||
if values.is_empty() {
|
||||
placeholder.to_string()
|
||||
} else {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| format!("`{value}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_fallback<'a>(value: &'a str, fallback: &'a str) -> &'a str {
|
||||
if value.trim().is_empty() {
|
||||
fallback
|
||||
@@ -622,4 +830,64 @@ mod tests {
|
||||
assert!(json.contains("\"executionEnvironmentReference\": \"codex\""));
|
||||
assert!(json.contains("\"runtimeFactSource\": \"aster-rust\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_save_runtime_review_decision_and_keep_it_on_reexport() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let detail = build_detail();
|
||||
let thread_read = build_thread_read();
|
||||
|
||||
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
|
||||
.expect("export");
|
||||
|
||||
let saved = save_runtime_review_decision(
|
||||
&detail,
|
||||
&thread_read,
|
||||
temp_dir.path(),
|
||||
RuntimeReviewDecisionContent {
|
||||
decision_status: "accepted".to_string(),
|
||||
decision_summary: "确认最小修复落在 current 主链。".to_string(),
|
||||
chosen_fix_strategy: "先补 runtime save 命令,再补 Harness UI。".to_string(),
|
||||
risk_level: "medium".to_string(),
|
||||
risk_tags: vec!["runtime".to_string(), "harness".to_string()],
|
||||
human_reviewer: "Lime Maintainer".to_string(),
|
||||
reviewed_at: Some("2026-03-27T10:30:00Z".to_string()),
|
||||
followup_actions: vec!["补 UI 回归".to_string()],
|
||||
regression_requirements: vec![
|
||||
"npm run test:contracts".to_string(),
|
||||
"Rust 定向测试".to_string(),
|
||||
],
|
||||
notes: "不要把 compat 命令重新接回主线。".to_string(),
|
||||
},
|
||||
)
|
||||
.expect("save");
|
||||
|
||||
assert_eq!(saved.decision.decision_status, "accepted");
|
||||
assert_eq!(saved.decision.risk_level, "medium");
|
||||
assert_eq!(saved.decision.risk_tags, vec!["runtime", "harness"]);
|
||||
assert_eq!(saved.decision.human_reviewer, "Lime Maintainer");
|
||||
|
||||
let json_path = temp_dir
|
||||
.path()
|
||||
.join(".lime/harness/sessions/session-1/review/review-decision.json");
|
||||
let markdown_path = temp_dir
|
||||
.path()
|
||||
.join(".lime/harness/sessions/session-1/review/review-decision.md");
|
||||
|
||||
let json = fs::read_to_string(&json_path).expect("json");
|
||||
assert!(json.contains("\"decisionStatus\": \"accepted\""));
|
||||
assert!(json.contains("\"humanReviewer\": \"Lime Maintainer\""));
|
||||
assert!(json.contains("\"regressionRequirements\": ["));
|
||||
|
||||
let markdown = fs::read_to_string(&markdown_path).expect("markdown");
|
||||
assert!(markdown.contains("确认最小修复落在 current 主链。"));
|
||||
assert!(markdown.contains("补 UI 回归"));
|
||||
assert!(markdown.contains("Lime Maintainer"));
|
||||
|
||||
let reexported =
|
||||
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
|
||||
.expect("re-export");
|
||||
assert_eq!(reexported.decision.decision_status, "accepted");
|
||||
assert_eq!(reexported.decision.human_reviewer, "Lime Maintainer");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user