mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.10.0
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
//! **Validates: Requirements 9.1**
|
||||
|
||||
use crate::database::dao::api_key_provider::{
|
||||
ApiKeyEntry, ApiKeyProvider, ApiProviderType, ProviderWithKeys,
|
||||
ApiKeyEntry, ApiKeyProvider, ApiProviderPromptCacheMode, ApiProviderType, ProviderWithKeys,
|
||||
};
|
||||
use crate::database::system_providers::get_system_providers;
|
||||
use crate::database::DbConnection;
|
||||
@@ -35,6 +35,7 @@ pub struct AddCustomProviderRequest {
|
||||
pub project: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub region: Option<String>,
|
||||
pub prompt_cache_mode: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新 Provider 请求
|
||||
@@ -51,6 +52,7 @@ pub struct UpdateProviderRequest {
|
||||
pub project: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub region: Option<String>,
|
||||
pub prompt_cache_mode: Option<String>,
|
||||
/// 自定义模型列表
|
||||
pub custom_models: Option<Vec<String>>,
|
||||
}
|
||||
@@ -81,6 +83,8 @@ pub struct ProviderDisplay {
|
||||
pub region: Option<String>,
|
||||
/// 自定义模型列表
|
||||
pub custom_models: Vec<String>,
|
||||
/// 当前 Provider 声明的 Prompt Cache 模式(前端优先使用该值,不再只按 type 猜)
|
||||
pub prompt_cache_mode: Option<String>,
|
||||
pub api_key_count: usize,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
@@ -156,6 +160,9 @@ fn provider_to_display(provider: &ApiKeyProvider, api_key_count: usize) -> Provi
|
||||
location: provider.location.clone(),
|
||||
region: provider.region.clone(),
|
||||
custom_models: provider.custom_models.clone(),
|
||||
prompt_cache_mode: provider
|
||||
.effective_prompt_cache_mode()
|
||||
.map(|mode| mode.to_string()),
|
||||
api_key_count,
|
||||
created_at: provider.created_at.to_rfc3339(),
|
||||
updated_at: provider.updated_at.to_rfc3339(),
|
||||
@@ -306,6 +313,11 @@ pub fn add_custom_api_key_provider(
|
||||
request.project,
|
||||
request.location,
|
||||
request.region,
|
||||
request
|
||||
.prompt_cache_mode
|
||||
.map(|mode| mode.parse::<ApiProviderPromptCacheMode>())
|
||||
.transpose()
|
||||
.map_err(|e: String| format!("无效的 Prompt Cache 模式: {e}"))?,
|
||||
)?;
|
||||
|
||||
Ok(provider_to_display(&provider, 0))
|
||||
@@ -338,6 +350,11 @@ pub fn update_api_key_provider(
|
||||
request.project,
|
||||
request.location,
|
||||
request.region,
|
||||
request
|
||||
.prompt_cache_mode
|
||||
.map(|mode| mode.parse::<ApiProviderPromptCacheMode>())
|
||||
.transpose()
|
||||
.map_err(|e: String| format!("无效的 Prompt Cache 模式: {e}"))?,
|
||||
request.custom_models,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -2049,6 +2049,10 @@ fn resolve_runtime_message_usage_from_session(
|
||||
.cached_input_tokens
|
||||
.filter(|value| *value >= 0)
|
||||
.map(|value| value as u32),
|
||||
cache_creation_input_tokens: session
|
||||
.cache_creation_input_tokens
|
||||
.filter(|value| *value >= 0)
|
||||
.map(|value| value as u32),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
@@ -2082,6 +2086,7 @@ fn persist_latest_assistant_message_usage(
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cached_input_tokens,
|
||||
usage.cache_creation_input_tokens,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2110,6 +2115,11 @@ fn build_compaction_session_metrics_update(
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
let cache_creation_input_tokens = if usage.usage.output_tokens.is_some() {
|
||||
usage.usage.cache_creation_input_tokens
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
|
||||
let current_window_tokens = usage
|
||||
.usage
|
||||
@@ -2121,6 +2131,7 @@ fn build_compaction_session_metrics_update(
|
||||
schedule_id,
|
||||
current_window_tokens,
|
||||
cached_input_tokens,
|
||||
cache_creation_input_tokens,
|
||||
accumulated_total_tokens: accumulated_total,
|
||||
accumulated_input_tokens: accumulated_input,
|
||||
accumulated_output_tokens: accumulated_output,
|
||||
@@ -3482,6 +3493,7 @@ mod tests {
|
||||
.input_tokens(Some(60))
|
||||
.output_tokens(Some(30))
|
||||
.cached_input_tokens(Some(12))
|
||||
.cache_creation_input_tokens(Some(4))
|
||||
.accumulated_total_tokens(Some(300))
|
||||
.accumulated_input_tokens(Some(200))
|
||||
.accumulated_output_tokens(Some(100))
|
||||
@@ -3494,7 +3506,9 @@ mod tests {
|
||||
|
||||
let usage = ProviderUsage::new(
|
||||
"gpt-4.1".to_string(),
|
||||
Usage::new(Some(120), Some(45), Some(165)).with_cached_input_tokens(Some(90)),
|
||||
Usage::new(Some(120), Some(45), Some(165))
|
||||
.with_cached_input_tokens(Some(90))
|
||||
.with_cache_creation_input_tokens(Some(30)),
|
||||
);
|
||||
|
||||
update_compaction_session_metrics(&session_config, &usage)
|
||||
@@ -3510,6 +3524,7 @@ mod tests {
|
||||
assert_eq!(updated.input_tokens, Some(45));
|
||||
assert_eq!(updated.output_tokens, Some(0));
|
||||
assert_eq!(updated.cached_input_tokens, Some(90));
|
||||
assert_eq!(updated.cache_creation_input_tokens, Some(30));
|
||||
assert_eq!(updated.accumulated_total_tokens, Some(465));
|
||||
assert_eq!(updated.accumulated_input_tokens, Some(320));
|
||||
assert_eq!(updated.accumulated_output_tokens, Some(145));
|
||||
@@ -3538,6 +3553,7 @@ mod tests {
|
||||
.input_tokens(Some(120))
|
||||
.output_tokens(Some(60))
|
||||
.cached_input_tokens(Some(24))
|
||||
.cache_creation_input_tokens(Some(8))
|
||||
.accumulated_total_tokens(Some(700))
|
||||
.accumulated_input_tokens(Some(500))
|
||||
.accumulated_output_tokens(Some(200))
|
||||
@@ -3563,6 +3579,7 @@ mod tests {
|
||||
assert_eq!(updated.input_tokens, Some(0));
|
||||
assert_eq!(updated.output_tokens, Some(0));
|
||||
assert_eq!(updated.cached_input_tokens, Some(0));
|
||||
assert_eq!(updated.cache_creation_input_tokens, Some(0));
|
||||
assert_eq!(updated.accumulated_total_tokens, Some(700));
|
||||
assert_eq!(updated.accumulated_input_tokens, Some(500));
|
||||
assert_eq!(updated.accumulated_output_tokens, Some(200));
|
||||
@@ -3591,6 +3608,7 @@ mod tests {
|
||||
.input_tokens(Some(10))
|
||||
.output_tokens(Some(10))
|
||||
.cached_input_tokens(Some(6))
|
||||
.cache_creation_input_tokens(Some(2))
|
||||
.accumulated_total_tokens(Some(200))
|
||||
.accumulated_input_tokens(Some(120))
|
||||
.accumulated_output_tokens(Some(80))
|
||||
@@ -3601,7 +3619,9 @@ mod tests {
|
||||
let session_config = SessionConfigBuilder::new(&session.id).build();
|
||||
let usage = ProviderUsage::new(
|
||||
"gpt-4.1".to_string(),
|
||||
Usage::new(Some(30), Some(15), Some(45)).with_cached_input_tokens(Some(18)),
|
||||
Usage::new(Some(30), Some(15), Some(45))
|
||||
.with_cached_input_tokens(Some(18))
|
||||
.with_cache_creation_input_tokens(Some(6)),
|
||||
);
|
||||
|
||||
update_compaction_session_metrics(&session_config, &usage)
|
||||
@@ -3617,6 +3637,7 @@ mod tests {
|
||||
assert_eq!(updated.input_tokens, Some(15));
|
||||
assert_eq!(updated.output_tokens, Some(0));
|
||||
assert_eq!(updated.cached_input_tokens, Some(18));
|
||||
assert_eq!(updated.cache_creation_input_tokens, Some(6));
|
||||
assert_eq!(updated.accumulated_total_tokens, Some(245));
|
||||
assert_eq!(updated.accumulated_input_tokens, Some(150));
|
||||
assert_eq!(updated.accumulated_output_tokens, Some(95));
|
||||
@@ -3642,6 +3663,7 @@ mod tests {
|
||||
.input_tokens(Some(204))
|
||||
.output_tokens(Some(88))
|
||||
.cached_input_tokens(Some(160))
|
||||
.cache_creation_input_tokens(Some(48))
|
||||
.apply()
|
||||
.await
|
||||
.expect("写入 usage 失败");
|
||||
@@ -3654,8 +3676,9 @@ mod tests {
|
||||
value.input_tokens,
|
||||
value.output_tokens,
|
||||
value.cached_input_tokens,
|
||||
value.cache_creation_input_tokens,
|
||||
)),
|
||||
Some((204, 88, Some(160)))
|
||||
Some((204, 88, Some(160), Some(48)))
|
||||
);
|
||||
}
|
||||
other => panic!("收到意外事件: {:?}", other),
|
||||
|
||||
@@ -253,6 +253,7 @@ pub async fn save_relay_api_key(
|
||||
None, // project
|
||||
None, // location
|
||||
None, // region
|
||||
None, // prompt_cache_mode
|
||||
)
|
||||
.map_err(|e| ConnectError {
|
||||
code: "CREATE_PROVIDER_FAILED".to_string(),
|
||||
|
||||
@@ -60,6 +60,10 @@ fn api_key_provider_with_keys_to_display(
|
||||
location: provider_with_keys.provider.location.clone(),
|
||||
region: provider_with_keys.provider.region.clone(),
|
||||
custom_models: provider_with_keys.provider.custom_models.clone(),
|
||||
prompt_cache_mode: provider_with_keys
|
||||
.provider
|
||||
.effective_prompt_cache_mode()
|
||||
.map(|mode| mode.to_string()),
|
||||
api_key_count: provider_with_keys.api_keys.len(),
|
||||
created_at: provider_with_keys.provider.created_at.to_rfc3339(),
|
||||
updated_at: provider_with_keys.provider.updated_at.to_rfc3339(),
|
||||
|
||||
@@ -42,6 +42,7 @@ fn build_provider(provider_type: ApiProviderType, api_host: &str) -> ApiKeyProvi
|
||||
project: None,
|
||||
location: None,
|
||||
region: None,
|
||||
prompt_cache_mode: None,
|
||||
custom_models: Vec::new(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
|
||||
@@ -153,6 +153,8 @@ struct AnalysisObservabilitySection {
|
||||
summary: Value,
|
||||
correlation_keys: Vec<String>,
|
||||
gap_signals: Vec<String>,
|
||||
verification_failure_outcomes: Vec<String>,
|
||||
verification_recovered_outcomes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -232,6 +234,14 @@ pub fn export_runtime_analysis_handoff(
|
||||
let observability_correlation_keys =
|
||||
collect_observability_correlation_keys(&observability_summary);
|
||||
let observability_gap_signals = collect_observability_gap_signals(&observability_summary);
|
||||
let observability_verification_failure_outcomes = collect_observability_verification_outcomes(
|
||||
&observability_summary,
|
||||
"/verificationSummary/focusVerificationFailureOutcomes",
|
||||
);
|
||||
let observability_verification_recovered_outcomes = collect_observability_verification_outcomes(
|
||||
&observability_summary,
|
||||
"/verificationSummary/focusVerificationRecoveredOutcomes",
|
||||
);
|
||||
|
||||
let title = derive_title(&input_payload, session_id);
|
||||
let failure_modes = value_string_list(
|
||||
@@ -406,6 +416,8 @@ pub fn export_runtime_analysis_handoff(
|
||||
summary: sanitize_value(observability_summary, workspace_root.as_path()),
|
||||
correlation_keys: observability_correlation_keys.clone(),
|
||||
gap_signals: observability_gap_signals.clone(),
|
||||
verification_failure_outcomes: observability_verification_failure_outcomes.clone(),
|
||||
verification_recovered_outcomes: observability_verification_recovered_outcomes.clone(),
|
||||
},
|
||||
reading_order: reading_order.clone(),
|
||||
external_analysis_contract: external_contract.clone(),
|
||||
@@ -416,6 +428,7 @@ pub fn export_runtime_analysis_handoff(
|
||||
&title,
|
||||
&exported_at,
|
||||
&summary,
|
||||
&analysis_context.observability.summary,
|
||||
&replay_refs,
|
||||
&handoff_refs,
|
||||
&evidence_refs,
|
||||
@@ -426,6 +439,10 @@ pub fn export_runtime_analysis_handoff(
|
||||
&analysis_context.evidence.summary_excerpt,
|
||||
&analysis_context.observability.correlation_keys,
|
||||
&analysis_context.observability.gap_signals,
|
||||
&analysis_context.observability.verification_failure_outcomes,
|
||||
&analysis_context
|
||||
.observability
|
||||
.verification_recovered_outcomes,
|
||||
);
|
||||
|
||||
let artifacts = vec![
|
||||
@@ -489,6 +506,7 @@ fn build_analysis_brief(
|
||||
title: &str,
|
||||
exported_at: &str,
|
||||
summary: &AnalysisContextSummary,
|
||||
observability_summary: &Value,
|
||||
replay_refs: &[AnalysisArtifactReference],
|
||||
handoff_refs: &[AnalysisArtifactReference],
|
||||
evidence_refs: &[AnalysisArtifactReference],
|
||||
@@ -499,6 +517,8 @@ fn build_analysis_brief(
|
||||
evidence_excerpt: &str,
|
||||
observability_correlation_keys: &[String],
|
||||
observability_gap_signals: &[String],
|
||||
verification_failure_outcomes: &[String],
|
||||
verification_recovered_outcomes: &[String],
|
||||
) -> String {
|
||||
let mut lines = vec![
|
||||
"# 外部分析交接简报".to_string(),
|
||||
@@ -557,10 +577,26 @@ fn build_analysis_brief(
|
||||
"- 当前缺口:{}",
|
||||
join_or_fallback(observability_gap_signals, "无")
|
||||
),
|
||||
"- 结构化验证摘要:".to_string(),
|
||||
];
|
||||
lines.extend(
|
||||
render_observability_verification_summary_lines(observability_summary)
|
||||
.into_iter()
|
||||
.map(|line| format!(" {line}")),
|
||||
);
|
||||
lines.extend([
|
||||
format!(
|
||||
"- 验证失败焦点:{}",
|
||||
join_or_fallback(verification_failure_outcomes, "无")
|
||||
),
|
||||
format!(
|
||||
"- 已恢复结果:{}",
|
||||
join_or_fallback(verification_recovered_outcomes, "无")
|
||||
),
|
||||
String::new(),
|
||||
"## 推荐读取顺序".to_string(),
|
||||
String::new(),
|
||||
];
|
||||
]);
|
||||
|
||||
for (index, item) in reading_order.iter().enumerate() {
|
||||
lines.push(format!("{}. {}", index + 1, item));
|
||||
@@ -974,6 +1010,159 @@ fn collect_observability_gap_signals(summary: &Value) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_observability_verification_outcomes(summary: &Value, pointer: &str) -> Vec<String> {
|
||||
summary
|
||||
.pointer(pointer)
|
||||
.map(value_string_list)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn render_observability_verification_summary_lines(summary: &Value) -> Vec<String> {
|
||||
let verification_summary = summary
|
||||
.get("verificationSummary")
|
||||
.or_else(|| summary.get("verification_summary"));
|
||||
let Some(verification_summary) = verification_summary else {
|
||||
return vec!["- 当前没有结构化验证摘要。".to_string()];
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if let Some(artifact_validator) = summary_object_field(
|
||||
verification_summary,
|
||||
"artifactValidator",
|
||||
"artifact_validator",
|
||||
) {
|
||||
lines.push(format!(
|
||||
"- Artifact 校验:`{}`|{}",
|
||||
format_verification_outcome_label(summary_string_field(
|
||||
artifact_validator,
|
||||
"outcome",
|
||||
"outcome",
|
||||
)),
|
||||
describe_artifact_validator_summary(artifact_validator),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(browser_verification) = summary_object_field(
|
||||
verification_summary,
|
||||
"browserVerification",
|
||||
"browser_verification",
|
||||
) {
|
||||
lines.push(format!(
|
||||
"- 浏览器验证:`{}`|{}",
|
||||
format_verification_outcome_label(summary_string_field(
|
||||
browser_verification,
|
||||
"outcome",
|
||||
"outcome",
|
||||
)),
|
||||
describe_browser_verification_summary(browser_verification),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(gui_smoke) = summary_object_field(verification_summary, "guiSmoke", "gui_smoke") {
|
||||
lines.push(format!(
|
||||
"- GUI Smoke:`{}`|{}",
|
||||
format_verification_outcome_label(summary_string_field(
|
||||
gui_smoke, "outcome", "outcome",
|
||||
)),
|
||||
describe_gui_smoke_summary(gui_smoke),
|
||||
));
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
vec!["- 当前没有结构化验证摘要。".to_string()]
|
||||
} else {
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_object_field<'a>(
|
||||
summary: &'a Value,
|
||||
camel_case: &str,
|
||||
snake_case: &str,
|
||||
) -> Option<&'a Value> {
|
||||
summary
|
||||
.get(camel_case)
|
||||
.or_else(|| summary.get(snake_case))
|
||||
.filter(|value| value.is_object())
|
||||
}
|
||||
|
||||
fn summary_string_field<'a>(
|
||||
summary: &'a Value,
|
||||
camel_case: &str,
|
||||
snake_case: &str,
|
||||
) -> Option<&'a str> {
|
||||
summary
|
||||
.get(camel_case)
|
||||
.or_else(|| summary.get(snake_case))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn summary_u64_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option<u64> {
|
||||
summary
|
||||
.get(camel_case)
|
||||
.or_else(|| summary.get(snake_case))
|
||||
.and_then(Value::as_u64)
|
||||
}
|
||||
|
||||
fn summary_bool_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option<bool> {
|
||||
summary
|
||||
.get(camel_case)
|
||||
.or_else(|| summary.get(snake_case))
|
||||
.and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
fn format_verification_outcome_label(value: Option<&str>) -> &'static str {
|
||||
match value {
|
||||
Some("success") => "通过",
|
||||
Some("blocking_failure") => "阻塞失败",
|
||||
Some("advisory_failure") => "提示失败",
|
||||
Some("recovered") => "已恢复",
|
||||
_ => "未定",
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_artifact_validator_summary(summary: &Value) -> String {
|
||||
if summary_bool_field(summary, "applicable", "applicable") == Some(false) {
|
||||
return "当前没有适用的 Artifact 校验。".to_string();
|
||||
}
|
||||
|
||||
format!(
|
||||
"记录 {} · issues {} · repaired {} · fallback {}",
|
||||
summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "issueCount", "issue_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "repairedCount", "repaired_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "fallbackUsedCount", "fallback_used_count").unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
fn describe_browser_verification_summary(summary: &Value) -> String {
|
||||
format!(
|
||||
"记录 {} · 成功 {} · 失败 {} · 未判定 {}",
|
||||
summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "successCount", "success_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "failureCount", "failure_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "unknownCount", "unknown_count").unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
fn describe_gui_smoke_summary(summary: &Value) -> String {
|
||||
let status = summary_string_field(summary, "status", "status").unwrap_or("未知");
|
||||
let exit_code = summary_u64_field(summary, "exitCode", "exit_code")
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "未知".to_string());
|
||||
let passed = summary_bool_field(summary, "passed", "passed").unwrap_or(false);
|
||||
|
||||
format!(
|
||||
"状态 {} · exit {} · {}",
|
||||
status,
|
||||
exit_code,
|
||||
if passed { "已通过" } else { "未通过" }
|
||||
)
|
||||
}
|
||||
|
||||
fn join_or_fallback(values: &[String], fallback: &str) -> String {
|
||||
if values.is_empty() {
|
||||
fallback.to_string()
|
||||
@@ -1183,6 +1372,94 @@ mod tests {
|
||||
.expect("write request log");
|
||||
}
|
||||
|
||||
fn seed_recovered_verification(detail: &mut SessionDetail, root: &Path) {
|
||||
let artifact_relative_path = ".lime/artifacts/thread-1/report.artifact.json";
|
||||
let artifact_absolute_path =
|
||||
root.join(artifact_relative_path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
|
||||
fs::create_dir_all(
|
||||
artifact_absolute_path
|
||||
.parent()
|
||||
.expect("artifact path should have parent"),
|
||||
)
|
||||
.expect("create artifact dir");
|
||||
fs::write(
|
||||
&artifact_absolute_path,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"schemaVersion": crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION,
|
||||
"title": "Harness Evidence",
|
||||
"kind": "analysis",
|
||||
"status": "ready",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "block-1",
|
||||
"type": "rich_text",
|
||||
"content": "test"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"artifactValidationIssues": ["title 缺失或为空,已使用兜底标题。"],
|
||||
"artifactValidationRepaired": true,
|
||||
"artifactFallbackUsed": false
|
||||
}
|
||||
}))
|
||||
.expect("serialize artifact document"),
|
||||
)
|
||||
.expect("write artifact document");
|
||||
|
||||
detail.items.push(AgentThreadItem {
|
||||
id: "artifact-verification-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
sequence: 4,
|
||||
status: AgentThreadItemStatus::Completed,
|
||||
started_at: "2026-03-27T10:00:30Z".to_string(),
|
||||
completed_at: Some("2026-03-27T10:00:30Z".to_string()),
|
||||
updated_at: "2026-03-27T10:00:30Z".to_string(),
|
||||
payload: AgentThreadItemPayload::FileArtifact {
|
||||
path: artifact_relative_path.to_string(),
|
||||
source: "artifact_snapshot".to_string(),
|
||||
content: None,
|
||||
metadata: None,
|
||||
},
|
||||
});
|
||||
detail.items.push(AgentThreadItem {
|
||||
id: "browser-tool-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
sequence: 5,
|
||||
status: AgentThreadItemStatus::Completed,
|
||||
started_at: "2026-03-27T10:00:40Z".to_string(),
|
||||
completed_at: Some("2026-03-27T10:00:40Z".to_string()),
|
||||
updated_at: "2026-03-27T10:00:40Z".to_string(),
|
||||
payload: AgentThreadItemPayload::ToolCall {
|
||||
tool_name: "browser_snapshot".to_string(),
|
||||
arguments: None,
|
||||
output: None,
|
||||
success: Some(true),
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
});
|
||||
detail.items.push(AgentThreadItem {
|
||||
id: "gui-smoke-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
sequence: 6,
|
||||
status: AgentThreadItemStatus::Completed,
|
||||
started_at: "2026-03-27T10:00:50Z".to_string(),
|
||||
completed_at: Some("2026-03-27T10:00:50Z".to_string()),
|
||||
updated_at: "2026-03-27T10:00:50Z".to_string(),
|
||||
payload: AgentThreadItemPayload::CommandExecution {
|
||||
command: "npm run verify:gui-smoke".to_string(),
|
||||
cwd: root.to_string_lossy().to_string(),
|
||||
aggregated_output: Some("GUI smoke finished successfully".to_string()),
|
||||
exit_code: Some(0),
|
||||
error: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_export_runtime_analysis_handoff_to_workspace() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
@@ -1218,6 +1495,10 @@ mod tests {
|
||||
assert!(brief.contains("pending request:1"));
|
||||
assert!(brief.contains("证据关联与可观测覆盖"));
|
||||
assert!(brief.contains("requestTelemetry"));
|
||||
assert!(brief.contains("结构化验证摘要"));
|
||||
assert!(brief.contains("当前没有结构化验证摘要"));
|
||||
assert!(brief.contains("验证失败焦点:无"));
|
||||
assert!(brief.contains("已恢复结果:无"));
|
||||
assert!(!brief.contains("requestTelemetry (unlinked)"));
|
||||
assert!(brief.contains("/workspace/lime"));
|
||||
|
||||
@@ -1228,8 +1509,41 @@ mod tests {
|
||||
assert!(context.contains("\"observability\""));
|
||||
assert!(context.contains("\"correlationKeys\""));
|
||||
assert!(context.contains("\"gapSignals\""));
|
||||
assert!(context.contains("\"verificationFailureOutcomes\": []"));
|
||||
assert!(context.contains("\"verificationRecoveredOutcomes\": []"));
|
||||
assert!(context.contains("\"matchedRequestCount\": 1"));
|
||||
assert!(context.contains("/workspace/lime"));
|
||||
assert!(!context.contains(temp_dir.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_include_structured_verification_summary_in_analysis_brief_when_available() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let mut detail = build_detail();
|
||||
let thread_read = build_thread_read();
|
||||
write_request_telemetry_fixture(temp_dir.path());
|
||||
seed_recovered_verification(&mut detail, temp_dir.path());
|
||||
|
||||
export_runtime_analysis_handoff(&detail, &thread_read, temp_dir.path()).expect("export");
|
||||
|
||||
let brief_path = temp_dir
|
||||
.path()
|
||||
.join(".lime/harness/sessions/session-1/analysis/analysis-brief.md");
|
||||
let context_path = temp_dir
|
||||
.path()
|
||||
.join(".lime/harness/sessions/session-1/analysis/analysis-context.json");
|
||||
|
||||
let brief = fs::read_to_string(brief_path).expect("brief");
|
||||
assert!(brief.contains("结构化验证摘要"));
|
||||
assert!(brief.contains("Artifact 校验:`已恢复`"));
|
||||
assert!(brief.contains("记录 1 · issues 1 · repaired 1 · fallback 0"));
|
||||
assert!(brief.contains("浏览器验证:`通过`"));
|
||||
assert!(brief.contains("GUI Smoke:`通过`"));
|
||||
assert!(brief.contains("已恢复结果:Artifact 校验已恢复 1 个产物,fallback 0 次。"));
|
||||
|
||||
let context = fs::read_to_string(context_path).expect("context");
|
||||
assert!(context.contains("\"verificationSummary\": {"));
|
||||
assert!(context.contains("\"verificationRecoveredOutcomes\": ["));
|
||||
assert!(context.contains("\"outcome\": \"recovered\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ pub struct RuntimeEvidenceArtifact {
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeEvidencePackExportResult {
|
||||
pub session_id: String,
|
||||
@@ -64,9 +64,29 @@ pub struct RuntimeEvidencePackExportResult {
|
||||
pub queued_turn_count: usize,
|
||||
pub recent_artifact_count: usize,
|
||||
pub known_gaps: Vec<String>,
|
||||
pub observability_summary: Value,
|
||||
pub artifacts: Vec<RuntimeEvidenceArtifact>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RuntimeVerificationOutcome {
|
||||
Success,
|
||||
BlockingFailure,
|
||||
AdvisoryFailure,
|
||||
Recovered,
|
||||
}
|
||||
|
||||
impl RuntimeVerificationOutcome {
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Success => "success",
|
||||
Self::BlockingFailure => "blocking_failure",
|
||||
Self::AdvisoryFailure => "advisory_failure",
|
||||
Self::Recovered => "recovered",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct RuntimeRecentArtifact {
|
||||
path: String,
|
||||
@@ -240,6 +260,7 @@ pub fn export_runtime_evidence_pack(
|
||||
queued_turn_count: thread_read.queued_turns.len(),
|
||||
recent_artifact_count: recent_artifact_paths.len(),
|
||||
known_gaps,
|
||||
observability_summary,
|
||||
artifacts,
|
||||
})
|
||||
}
|
||||
@@ -1138,6 +1159,9 @@ fn build_observability_verification_summary_json(
|
||||
verification: &RuntimeEvidenceVerificationSummary,
|
||||
) -> Option<Value> {
|
||||
let mut payload = Map::new();
|
||||
let mut blocking_failure = Vec::new();
|
||||
let mut advisory_failure = Vec::new();
|
||||
let mut recovered = Vec::new();
|
||||
|
||||
if verification.artifact_validator.applicable {
|
||||
let issue_count = verification
|
||||
@@ -1174,15 +1198,41 @@ fn build_observability_verification_summary_json(
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
let record_count = verification.artifact_validator.records.len();
|
||||
let outcome = if issue_count == 0 {
|
||||
if repaired_count > 0 || fallback_used_count > 0 {
|
||||
RuntimeVerificationOutcome::Recovered
|
||||
} else {
|
||||
RuntimeVerificationOutcome::Success
|
||||
}
|
||||
} else if record_count > 0 && repaired_count == record_count {
|
||||
RuntimeVerificationOutcome::Recovered
|
||||
} else {
|
||||
RuntimeVerificationOutcome::BlockingFailure
|
||||
};
|
||||
|
||||
match outcome {
|
||||
RuntimeVerificationOutcome::BlockingFailure => blocking_failure.push(format!(
|
||||
"Artifact 校验存在 {} 条未恢复 issues。",
|
||||
issue_count
|
||||
)),
|
||||
RuntimeVerificationOutcome::Recovered => recovered.push(format!(
|
||||
"Artifact 校验已恢复 {} 个产物,fallback {} 次。",
|
||||
repaired_count, fallback_used_count
|
||||
)),
|
||||
RuntimeVerificationOutcome::Success => {}
|
||||
RuntimeVerificationOutcome::AdvisoryFailure => {}
|
||||
}
|
||||
|
||||
payload.insert(
|
||||
"artifactValidator".to_string(),
|
||||
json!({
|
||||
"applicable": true,
|
||||
"recordCount": verification.artifact_validator.records.len(),
|
||||
"recordCount": record_count,
|
||||
"issueCount": issue_count,
|
||||
"repairedCount": repaired_count,
|
||||
"fallbackUsedCount": fallback_used_count
|
||||
"fallbackUsedCount": fallback_used_count,
|
||||
"outcome": outcome.as_str()
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1210,6 +1260,24 @@ fn build_observability_verification_summary_json(
|
||||
None => unknown_count += 1,
|
||||
}
|
||||
}
|
||||
let outcome = if failure_count > 0 {
|
||||
RuntimeVerificationOutcome::BlockingFailure
|
||||
} else if unknown_count > 0 {
|
||||
RuntimeVerificationOutcome::AdvisoryFailure
|
||||
} else {
|
||||
RuntimeVerificationOutcome::Success
|
||||
};
|
||||
|
||||
match outcome {
|
||||
RuntimeVerificationOutcome::BlockingFailure => {
|
||||
blocking_failure.push(format!("浏览器验证存在 {} 条失败线索。", failure_count))
|
||||
}
|
||||
RuntimeVerificationOutcome::AdvisoryFailure => {
|
||||
advisory_failure.push(format!("浏览器验证仍有 {} 条未判定线索。", unknown_count))
|
||||
}
|
||||
RuntimeVerificationOutcome::Success => {}
|
||||
RuntimeVerificationOutcome::Recovered => {}
|
||||
}
|
||||
|
||||
payload.insert(
|
||||
"browserVerification".to_string(),
|
||||
@@ -1218,7 +1286,8 @@ fn build_observability_verification_summary_json(
|
||||
"successCount": success_count,
|
||||
"failureCount": failure_count,
|
||||
"unknownCount": unknown_count,
|
||||
"latestUpdatedAt": latest_updated_at
|
||||
"latestUpdatedAt": latest_updated_at,
|
||||
"outcome": outcome.as_str()
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1231,6 +1300,18 @@ fn build_observability_verification_summary_json(
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
let passed = exit_code == Some(0) && !has_error;
|
||||
let outcome = if passed {
|
||||
RuntimeVerificationOutcome::Success
|
||||
} else {
|
||||
RuntimeVerificationOutcome::BlockingFailure
|
||||
};
|
||||
|
||||
if !passed {
|
||||
let exit_code_text = exit_code
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "未知".to_string());
|
||||
blocking_failure.push(format!("GUI smoke 未通过,exit_code={}。", exit_code_text));
|
||||
}
|
||||
|
||||
payload.insert(
|
||||
"guiSmoke".to_string(),
|
||||
@@ -1239,11 +1320,35 @@ fn build_observability_verification_summary_json(
|
||||
"exitCode": exit_code,
|
||||
"passed": passed,
|
||||
"updatedAt": gui_smoke.get("updatedAt").cloned().unwrap_or(Value::Null),
|
||||
"hasOutputPreview": gui_smoke.get("outputPreview").is_some()
|
||||
"hasOutputPreview": gui_smoke.get("outputPreview").is_some(),
|
||||
"outcome": outcome.as_str()
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if !blocking_failure.is_empty() || !advisory_failure.is_empty() || !recovered.is_empty() {
|
||||
payload.insert(
|
||||
"observabilityVerificationOutcomes".to_string(),
|
||||
json!({
|
||||
"blockingFailure": blocking_failure,
|
||||
"advisoryFailure": advisory_failure,
|
||||
"recovered": recovered
|
||||
}),
|
||||
);
|
||||
payload.insert(
|
||||
"focusVerificationFailureOutcomes".to_string(),
|
||||
json!(blocking_failure
|
||||
.iter()
|
||||
.chain(advisory_failure.iter())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()),
|
||||
);
|
||||
payload.insert(
|
||||
"focusVerificationRecoveredOutcomes".to_string(),
|
||||
json!(recovered),
|
||||
);
|
||||
}
|
||||
|
||||
(!payload.is_empty()).then(|| Value::Object(payload))
|
||||
}
|
||||
|
||||
@@ -1731,6 +1836,13 @@ mod tests {
|
||||
assert_eq!(result.queued_turn_count, 1);
|
||||
assert_eq!(result.recent_artifact_count, 1);
|
||||
assert!(result.known_gaps.is_empty());
|
||||
assert_eq!(
|
||||
result
|
||||
.observability_summary
|
||||
.get("schemaVersion")
|
||||
.and_then(Value::as_str),
|
||||
Some("v1")
|
||||
);
|
||||
|
||||
let summary_path = temp_dir
|
||||
.path()
|
||||
@@ -1869,6 +1981,10 @@ mod tests {
|
||||
.known_gaps
|
||||
.iter()
|
||||
.all(|gap| !gap.contains("ArtifactDocument")));
|
||||
assert!(result
|
||||
.observability_summary
|
||||
.get("verificationSummary")
|
||||
.is_some());
|
||||
|
||||
let runtime_path = temp_dir
|
||||
.path()
|
||||
@@ -1888,6 +2004,9 @@ mod tests {
|
||||
assert!(runtime.contains("\"repairedCount\": 1"));
|
||||
assert!(runtime.contains("\"successCount\": 1"));
|
||||
assert!(runtime.contains("\"passed\": true"));
|
||||
assert!(runtime.contains("\"outcome\": \"recovered\""));
|
||||
assert!(runtime.contains("\"outcome\": \"success\""));
|
||||
assert!(runtime.contains("\"focusVerificationRecoveredOutcomes\""));
|
||||
|
||||
let artifacts = fs::read_to_string(artifacts_path).expect("artifacts");
|
||||
assert!(artifacts.contains("\"verification\""));
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::services::runtime_analysis_handoff_service::{
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -59,6 +60,7 @@ pub struct RuntimeReviewDecisionTemplateExportResult {
|
||||
pub pending_request_count: usize,
|
||||
pub queued_turn_count: usize,
|
||||
pub default_decision_status: String,
|
||||
pub verification_summary: Option<Value>,
|
||||
pub decision: RuntimeReviewDecisionContent,
|
||||
pub decision_status_options: Vec<String>,
|
||||
pub risk_level_options: Vec<String>,
|
||||
@@ -112,6 +114,9 @@ struct ReviewDecisionContext {
|
||||
evidence_pack_relative_root: String,
|
||||
replay_case_relative_root: String,
|
||||
analysis_artifacts: Vec<ReviewDecisionArtifactReference>,
|
||||
verification_summary: Option<Value>,
|
||||
verification_failure_outcomes: Vec<String>,
|
||||
verification_recovered_outcomes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -188,9 +193,15 @@ fn sync_runtime_review_decision(
|
||||
})?;
|
||||
|
||||
let review_checklist = build_review_checklist();
|
||||
let verification_context = load_analysis_verification_context(&analysis)?;
|
||||
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 mut document = build_review_decision_document(
|
||||
&analysis,
|
||||
&exported_at,
|
||||
&review_checklist,
|
||||
&verification_context,
|
||||
);
|
||||
let decision = decision_override
|
||||
.or(existing_decision)
|
||||
.unwrap_or_else(|| document.decision.clone());
|
||||
@@ -235,6 +246,7 @@ fn sync_runtime_review_decision(
|
||||
pending_request_count: analysis.pending_request_count,
|
||||
queued_turn_count: analysis.queued_turn_count,
|
||||
default_decision_status: DEFAULT_DECISION_STATUS.to_string(),
|
||||
verification_summary: document.review_context.verification_summary.clone(),
|
||||
decision: document.decision,
|
||||
decision_status_options: document.decision_status_options,
|
||||
risk_level_options: document.risk_level_options,
|
||||
@@ -248,7 +260,10 @@ fn build_review_decision_document(
|
||||
analysis: &RuntimeAnalysisHandoffExportResult,
|
||||
exported_at: &str,
|
||||
review_checklist: &[String],
|
||||
verification_context: &ReviewDecisionVerificationContext,
|
||||
) -> ReviewDecisionDocument {
|
||||
let suggested_actions = build_review_decision_suggested_actions(verification_context);
|
||||
|
||||
ReviewDecisionDocument {
|
||||
schema_version: "v1".to_string(),
|
||||
contract_shape: "lime_review_decision_template".to_string(),
|
||||
@@ -288,6 +303,9 @@ fn build_review_decision_document(
|
||||
relative_path: artifact.relative_path.clone(),
|
||||
})
|
||||
.collect(),
|
||||
verification_summary: verification_context.summary.clone(),
|
||||
verification_failure_outcomes: verification_context.failure_outcomes.clone(),
|
||||
verification_recovered_outcomes: verification_context.recovered_outcomes.clone(),
|
||||
},
|
||||
decision: RuntimeReviewDecisionContent {
|
||||
decision_status: DEFAULT_DECISION_STATUS.to_string(),
|
||||
@@ -297,8 +315,8 @@ fn build_review_decision_document(
|
||||
risk_tags: Vec::new(),
|
||||
human_reviewer: String::new(),
|
||||
reviewed_at: None,
|
||||
followup_actions: Vec::new(),
|
||||
regression_requirements: Vec::new(),
|
||||
followup_actions: suggested_actions.followup_actions,
|
||||
regression_requirements: suggested_actions.regression_requirements,
|
||||
notes: String::new(),
|
||||
},
|
||||
decision_status_options: build_decision_status_options(),
|
||||
@@ -321,6 +339,16 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
|
||||
.map(|artifact| format!("- `{}`:`{}`", artifact.title, artifact.relative_path))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let verification_summary =
|
||||
format_markdown_verification_summary(document.review_context.verification_summary.as_ref());
|
||||
let verification_failure_outcomes = format_markdown_list(
|
||||
&document.review_context.verification_failure_outcomes,
|
||||
"- 无",
|
||||
);
|
||||
let verification_recovered_outcomes = format_markdown_list(
|
||||
&document.review_context.verification_recovered_outcomes,
|
||||
"- 无",
|
||||
);
|
||||
let decision_status_options = document
|
||||
.decision_status_options
|
||||
.iter()
|
||||
@@ -362,21 +390,28 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
|
||||
- 产品承接面:`lime`\n\n\
|
||||
## 3. 审核清单\n\
|
||||
{checklist}\n\n\
|
||||
## 4. 决策状态\n\
|
||||
## 4. 结构化验证摘要\n\
|
||||
{verification_summary}\n\n\
|
||||
## 5. 验证焦点\n\
|
||||
- 阻塞 / 提示失败:\n\
|
||||
{verification_failure_outcomes}\n\n\
|
||||
- 已恢复结果:\n\
|
||||
{verification_recovered_outcomes}\n\n\
|
||||
## 6. 决策状态\n\
|
||||
- 当前值:`{decision_status}`\n\
|
||||
- 可选值:{decision_status_options}\n\n\
|
||||
## 5. 决策摘要\n\
|
||||
## 7. 决策摘要\n\
|
||||
{decision_summary}\n\n\
|
||||
## 6. 采用的修复策略\n\
|
||||
## 8. 采用的修复策略\n\
|
||||
{chosen_fix_strategy}\n\n\
|
||||
## 7. 风险等级与标签\n\
|
||||
## 9. 风险等级与标签\n\
|
||||
- 风险等级:`{risk_level}`\n\
|
||||
- 风险标签:{risk_tags}\n\n\
|
||||
## 8. 回归要求\n\
|
||||
## 10. 回归要求\n\
|
||||
{regression_requirements}\n\n\
|
||||
## 9. 后续动作\n\
|
||||
## 11. 后续动作\n\
|
||||
{followup_actions}\n\n\
|
||||
## 10. 审核备注\n\
|
||||
## 12. 审核备注\n\
|
||||
- 审核人:{human_reviewer}\n\
|
||||
- 审核时间:{reviewed_at}\n\
|
||||
- 备注:\n{notes}\n",
|
||||
@@ -408,6 +443,9 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
|
||||
} else {
|
||||
checklist
|
||||
},
|
||||
verification_summary = verification_summary,
|
||||
verification_failure_outcomes = verification_failure_outcomes,
|
||||
verification_recovered_outcomes = verification_recovered_outcomes,
|
||||
decision_status_options = if decision_status_options.is_empty() {
|
||||
format!("`{DEFAULT_DECISION_STATUS}`")
|
||||
} else {
|
||||
@@ -434,6 +472,8 @@ fn build_review_checklist() -> Vec<String> {
|
||||
vec![
|
||||
"先阅读 analysis-brief.md 与 analysis-context.json,再决定是否进入修复。".to_string(),
|
||||
"确认根因判断引用的是现有证据,而不是外部 AI 的猜测扩写。".to_string(),
|
||||
"优先核对 verification failure / recovered outcomes,再决定是接受、延后还是补充证据。"
|
||||
.to_string(),
|
||||
"确认修复范围仍落在 current 主链,没有把 compat / deprecated 路径重新接回主线。"
|
||||
.to_string(),
|
||||
"明确最小回归集合,包括 contract、GUI smoke、Replay 或其它定向验证。".to_string(),
|
||||
@@ -441,6 +481,79 @@ fn build_review_checklist() -> Vec<String> {
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ReviewDecisionVerificationContext {
|
||||
summary: Option<Value>,
|
||||
failure_outcomes: Vec<String>,
|
||||
recovered_outcomes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ReviewDecisionSuggestedActions {
|
||||
followup_actions: Vec<String>,
|
||||
regression_requirements: Vec<String>,
|
||||
}
|
||||
|
||||
const REVIEW_VERIFICATION_COMMAND_EVAL: &str = "npm run harness:eval";
|
||||
const REVIEW_VERIFICATION_COMMAND_TREND: &str = "npm run harness:eval:trend";
|
||||
const REVIEW_VERIFICATION_COMMAND_GUI_SMOKE: &str = "npm run verify:gui-smoke";
|
||||
|
||||
fn load_analysis_verification_context(
|
||||
analysis: &RuntimeAnalysisHandoffExportResult,
|
||||
) -> Result<ReviewDecisionVerificationContext, String> {
|
||||
let analysis_context_relative_path = analysis
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|artifact| {
|
||||
matches!(
|
||||
artifact.kind,
|
||||
crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind::AnalysisContext
|
||||
)
|
||||
})
|
||||
.map(|artifact| artifact.relative_path.clone());
|
||||
let Some(relative_path) = analysis_context_relative_path else {
|
||||
return Ok(ReviewDecisionVerificationContext::default());
|
||||
};
|
||||
|
||||
let absolute_path = Path::new(&analysis.workspace_root)
|
||||
.join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
if !absolute_path.exists() {
|
||||
return Ok(ReviewDecisionVerificationContext::default());
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(&absolute_path).map_err(|error| {
|
||||
format!(
|
||||
"读取 analysis context 失败 {}: {error}",
|
||||
absolute_path.display()
|
||||
)
|
||||
})?;
|
||||
let payload = serde_json::from_str::<Value>(&contents).map_err(|error| {
|
||||
format!(
|
||||
"解析 analysis context 失败 {}: {error}",
|
||||
absolute_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(ReviewDecisionVerificationContext {
|
||||
summary: payload
|
||||
.pointer("/observability/summary/verificationSummary")
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
payload
|
||||
.pointer("/observability/summary/verification_summary")
|
||||
.cloned()
|
||||
}),
|
||||
failure_outcomes: payload
|
||||
.pointer("/observability/verificationFailureOutcomes")
|
||||
.map(value_string_list)
|
||||
.unwrap_or_default(),
|
||||
recovered_outcomes: payload
|
||||
.pointer("/observability/verificationRecoveredOutcomes")
|
||||
.map(value_string_list)
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn review_analysis_artifact_kind_key(
|
||||
kind: &crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind,
|
||||
) -> &'static str {
|
||||
@@ -600,6 +713,309 @@ fn normalize_string_list(values: &[String]) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_review_decision_suggested_actions(
|
||||
verification_context: &ReviewDecisionVerificationContext,
|
||||
) -> ReviewDecisionSuggestedActions {
|
||||
let mut suggested_actions = ReviewDecisionSuggestedActions::default();
|
||||
|
||||
if let Some(summary) = verification_context.summary.as_ref() {
|
||||
if let Some(artifact_validator) =
|
||||
summary_object_field(summary, "artifactValidator", "artifact_validator")
|
||||
{
|
||||
let artifact_outcome = summary_string_field(artifact_validator, "outcome", "outcome");
|
||||
let artifact_issue_count =
|
||||
summary_u64_field(artifact_validator, "issueCount", "issue_count").unwrap_or(0);
|
||||
let artifact_fallback_count = summary_u64_field(
|
||||
artifact_validator,
|
||||
"fallbackUsedCount",
|
||||
"fallback_used_count",
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if matches!(
|
||||
artifact_outcome,
|
||||
Some("blocking_failure" | "advisory_failure")
|
||||
) {
|
||||
if artifact_issue_count > 0 {
|
||||
push_unique_string(
|
||||
&mut suggested_actions.followup_actions,
|
||||
"回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。",
|
||||
);
|
||||
}
|
||||
if artifact_fallback_count > 0 {
|
||||
push_unique_string(
|
||||
&mut suggested_actions.followup_actions,
|
||||
"补齐 artifact 主路径导出与修复链,减少 fallback_used 持续留在 current 样本。",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(artifact_outcome, Some("recovered")) {
|
||||
push_review_verification_eval_commands(
|
||||
&mut suggested_actions.regression_requirements,
|
||||
);
|
||||
push_unique_string(
|
||||
&mut suggested_actions.followup_actions,
|
||||
"在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(browser_verification) =
|
||||
summary_object_field(summary, "browserVerification", "browser_verification")
|
||||
{
|
||||
let browser_outcome = summary_string_field(browser_verification, "outcome", "outcome");
|
||||
|
||||
if matches!(browser_outcome, Some("blocking_failure")) {
|
||||
push_review_verification_eval_commands(
|
||||
&mut suggested_actions.regression_requirements,
|
||||
);
|
||||
push_unique_string(
|
||||
&mut suggested_actions.followup_actions,
|
||||
"回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。",
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(browser_outcome, Some("advisory_failure")) {
|
||||
push_unique_string(
|
||||
&mut suggested_actions.followup_actions,
|
||||
"回看 browser verification 导出链,确保 evidence pack / replay / analysis handoff 写出明确 success 或 failure,而不是 unknown。",
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(browser_outcome, Some("success" | "recovered")) {
|
||||
push_review_verification_eval_commands(
|
||||
&mut suggested_actions.regression_requirements,
|
||||
);
|
||||
push_unique_string(
|
||||
&mut suggested_actions.followup_actions,
|
||||
"把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(gui_smoke) = summary_object_field(summary, "guiSmoke", "gui_smoke") {
|
||||
let gui_smoke_outcome = summary_string_field(gui_smoke, "outcome", "outcome");
|
||||
|
||||
if matches!(gui_smoke_outcome, Some("blocking_failure")) {
|
||||
push_review_verification_eval_commands(
|
||||
&mut suggested_actions.regression_requirements,
|
||||
);
|
||||
push_unique_string(
|
||||
&mut suggested_actions.regression_requirements,
|
||||
REVIEW_VERIFICATION_COMMAND_GUI_SMOKE,
|
||||
);
|
||||
push_unique_string(
|
||||
&mut suggested_actions.followup_actions,
|
||||
"优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。",
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(gui_smoke_outcome, Some("success" | "recovered")) {
|
||||
push_review_verification_eval_commands(
|
||||
&mut suggested_actions.regression_requirements,
|
||||
);
|
||||
push_unique_string(
|
||||
&mut suggested_actions.regression_requirements,
|
||||
REVIEW_VERIFICATION_COMMAND_GUI_SMOKE,
|
||||
);
|
||||
push_unique_string(
|
||||
&mut suggested_actions.followup_actions,
|
||||
"主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if suggested_actions.followup_actions.is_empty()
|
||||
&& !verification_context.failure_outcomes.is_empty()
|
||||
{
|
||||
push_unique_string(
|
||||
&mut suggested_actions.followup_actions,
|
||||
"先对照 analysis-context.json / evidence/runtime.json 核对当前验证失败焦点,再决定是继续修复还是补证据。",
|
||||
);
|
||||
push_unique_string(
|
||||
&mut suggested_actions.regression_requirements,
|
||||
"按 replay case 复现问题并确认修复后行为与预期一致。",
|
||||
);
|
||||
}
|
||||
|
||||
if suggested_actions.followup_actions.is_empty()
|
||||
&& !verification_context.recovered_outcomes.is_empty()
|
||||
{
|
||||
push_unique_string(
|
||||
&mut suggested_actions.followup_actions,
|
||||
"把 recovered outcome 回挂到 replay / smoke / evidence 主链,避免后续审核再次把已恢复结果误判成当前阻塞。",
|
||||
);
|
||||
}
|
||||
|
||||
suggested_actions
|
||||
}
|
||||
|
||||
fn push_review_verification_eval_commands(target: &mut Vec<String>) {
|
||||
push_unique_string(target, REVIEW_VERIFICATION_COMMAND_EVAL);
|
||||
push_unique_string(target, REVIEW_VERIFICATION_COMMAND_TREND);
|
||||
}
|
||||
|
||||
fn value_string_list(value: &Value) -> Vec<String> {
|
||||
value
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn push_unique_string(target: &mut Vec<String>, value: &str) {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() || target.iter().any(|item| item == normalized) {
|
||||
return;
|
||||
}
|
||||
target.push(normalized.to_string());
|
||||
}
|
||||
|
||||
fn format_markdown_verification_summary(summary: Option<&Value>) -> String {
|
||||
let Some(summary) = summary else {
|
||||
return "- 当前没有结构化验证摘要。".to_string();
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if let Some(artifact_validator) =
|
||||
summary_object_field(summary, "artifactValidator", "artifact_validator")
|
||||
{
|
||||
lines.push(format!(
|
||||
"- Artifact 校验:`{}`|{}",
|
||||
format_verification_outcome_label(summary_string_field(
|
||||
artifact_validator,
|
||||
"outcome",
|
||||
"outcome",
|
||||
)),
|
||||
describe_artifact_validator_summary(artifact_validator),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(browser_verification) =
|
||||
summary_object_field(summary, "browserVerification", "browser_verification")
|
||||
{
|
||||
lines.push(format!(
|
||||
"- 浏览器验证:`{}`|{}",
|
||||
format_verification_outcome_label(summary_string_field(
|
||||
browser_verification,
|
||||
"outcome",
|
||||
"outcome",
|
||||
)),
|
||||
describe_browser_verification_summary(browser_verification),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(gui_smoke) = summary_object_field(summary, "guiSmoke", "gui_smoke") {
|
||||
lines.push(format!(
|
||||
"- GUI Smoke:`{}`|{}",
|
||||
format_verification_outcome_label(summary_string_field(
|
||||
gui_smoke, "outcome", "outcome",
|
||||
)),
|
||||
describe_gui_smoke_summary(gui_smoke),
|
||||
));
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
"- 当前没有结构化验证摘要。".to_string()
|
||||
} else {
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_object_field<'a>(
|
||||
summary: &'a Value,
|
||||
camel_case: &str,
|
||||
snake_case: &str,
|
||||
) -> Option<&'a Value> {
|
||||
summary
|
||||
.get(camel_case)
|
||||
.or_else(|| summary.get(snake_case))
|
||||
.filter(|value| value.is_object())
|
||||
}
|
||||
|
||||
fn summary_string_field<'a>(
|
||||
summary: &'a Value,
|
||||
camel_case: &str,
|
||||
snake_case: &str,
|
||||
) -> Option<&'a str> {
|
||||
summary
|
||||
.get(camel_case)
|
||||
.or_else(|| summary.get(snake_case))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn summary_u64_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option<u64> {
|
||||
summary
|
||||
.get(camel_case)
|
||||
.or_else(|| summary.get(snake_case))
|
||||
.and_then(Value::as_u64)
|
||||
}
|
||||
|
||||
fn summary_bool_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option<bool> {
|
||||
summary
|
||||
.get(camel_case)
|
||||
.or_else(|| summary.get(snake_case))
|
||||
.and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
fn format_verification_outcome_label(value: Option<&str>) -> &'static str {
|
||||
match value {
|
||||
Some("success") => "通过",
|
||||
Some("blocking_failure") => "阻塞失败",
|
||||
Some("advisory_failure") => "提示失败",
|
||||
Some("recovered") => "已恢复",
|
||||
_ => "未定",
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_artifact_validator_summary(summary: &Value) -> String {
|
||||
if summary_bool_field(summary, "applicable", "applicable") == Some(false) {
|
||||
return "当前没有适用的 Artifact 校验。".to_string();
|
||||
}
|
||||
|
||||
format!(
|
||||
"记录 {} · issues {} · repaired {} · fallback {}",
|
||||
summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "issueCount", "issue_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "repairedCount", "repaired_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "fallbackUsedCount", "fallback_used_count").unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
fn describe_browser_verification_summary(summary: &Value) -> String {
|
||||
format!(
|
||||
"记录 {} · 成功 {} · 失败 {} · 未判定 {}",
|
||||
summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "successCount", "success_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "failureCount", "failure_count").unwrap_or(0),
|
||||
summary_u64_field(summary, "unknownCount", "unknown_count").unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
fn describe_gui_smoke_summary(summary: &Value) -> String {
|
||||
let status = summary_string_field(summary, "status", "status").unwrap_or("未知");
|
||||
let exit_code = summary_u64_field(summary, "exitCode", "exit_code")
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "未知".to_string());
|
||||
let passed = summary_bool_field(summary, "passed", "passed").unwrap_or(false);
|
||||
|
||||
format!(
|
||||
"状态 {} · exit {} · {}",
|
||||
status,
|
||||
exit_code,
|
||||
if passed { "已通过" } else { "未通过" }
|
||||
)
|
||||
}
|
||||
|
||||
fn format_markdown_text_block(value: &str, placeholder: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -790,6 +1206,182 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_recovered_verification(detail: &mut SessionDetail, root: &std::path::Path) {
|
||||
let artifact_relative_path = ".lime/artifacts/thread-1/report.artifact.json";
|
||||
let artifact_absolute_path =
|
||||
root.join(artifact_relative_path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
|
||||
fs::create_dir_all(
|
||||
artifact_absolute_path
|
||||
.parent()
|
||||
.expect("artifact path should have parent"),
|
||||
)
|
||||
.expect("create artifact dir");
|
||||
fs::write(
|
||||
&artifact_absolute_path,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"schemaVersion": crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION,
|
||||
"title": "Harness Evidence",
|
||||
"kind": "analysis",
|
||||
"status": "ready",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "block-1",
|
||||
"type": "rich_text",
|
||||
"content": "test"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"artifactValidationIssues": ["title 缺失或为空,已使用兜底标题。"],
|
||||
"artifactValidationRepaired": true,
|
||||
"artifactFallbackUsed": false
|
||||
}
|
||||
}))
|
||||
.expect("serialize artifact document"),
|
||||
)
|
||||
.expect("write artifact document");
|
||||
|
||||
detail.items.push(AgentThreadItem {
|
||||
id: "artifact-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
sequence: 3,
|
||||
status: AgentThreadItemStatus::Completed,
|
||||
started_at: "2026-03-27T10:00:20Z".to_string(),
|
||||
completed_at: Some("2026-03-27T10:00:20Z".to_string()),
|
||||
updated_at: "2026-03-27T10:00:20Z".to_string(),
|
||||
payload: AgentThreadItemPayload::FileArtifact {
|
||||
path: artifact_relative_path.to_string(),
|
||||
source: "artifact_snapshot".to_string(),
|
||||
content: None,
|
||||
metadata: None,
|
||||
},
|
||||
});
|
||||
detail.items.push(AgentThreadItem {
|
||||
id: "browser-tool-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
sequence: 4,
|
||||
status: AgentThreadItemStatus::Completed,
|
||||
started_at: "2026-03-27T10:00:40Z".to_string(),
|
||||
completed_at: Some("2026-03-27T10:00:40Z".to_string()),
|
||||
updated_at: "2026-03-27T10:00:40Z".to_string(),
|
||||
payload: AgentThreadItemPayload::ToolCall {
|
||||
tool_name: "browser_snapshot".to_string(),
|
||||
arguments: None,
|
||||
output: None,
|
||||
success: Some(true),
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
});
|
||||
detail.items.push(AgentThreadItem {
|
||||
id: "gui-smoke-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
sequence: 5,
|
||||
status: AgentThreadItemStatus::Completed,
|
||||
started_at: "2026-03-27T10:00:50Z".to_string(),
|
||||
completed_at: Some("2026-03-27T10:00:50Z".to_string()),
|
||||
updated_at: "2026-03-27T10:00:50Z".to_string(),
|
||||
payload: AgentThreadItemPayload::CommandExecution {
|
||||
command: "npm run verify:gui-smoke".to_string(),
|
||||
cwd: root.to_string_lossy().to_string(),
|
||||
aggregated_output: Some("GUI smoke finished successfully".to_string()),
|
||||
exit_code: Some(0),
|
||||
error: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn seed_blocking_verification(detail: &mut SessionDetail, root: &std::path::Path) {
|
||||
let artifact_relative_path = ".lime/artifacts/thread-1/report-blocking.artifact.json";
|
||||
let artifact_absolute_path =
|
||||
root.join(artifact_relative_path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
|
||||
fs::create_dir_all(
|
||||
artifact_absolute_path
|
||||
.parent()
|
||||
.expect("artifact path should have parent"),
|
||||
)
|
||||
.expect("create artifact dir");
|
||||
fs::write(
|
||||
&artifact_absolute_path,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"schemaVersion": crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION,
|
||||
"title": "Harness Evidence Blocking",
|
||||
"kind": "analysis",
|
||||
"status": "ready",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "block-1",
|
||||
"type": "rich_text",
|
||||
"content": "test"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"artifactValidationIssues": ["title 缺失或为空。"],
|
||||
"artifactValidationRepaired": false,
|
||||
"artifactFallbackUsed": false
|
||||
}
|
||||
}))
|
||||
.expect("serialize artifact document"),
|
||||
)
|
||||
.expect("write artifact document");
|
||||
|
||||
detail.items.push(AgentThreadItem {
|
||||
id: "artifact-blocking-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
sequence: 3,
|
||||
status: AgentThreadItemStatus::Completed,
|
||||
started_at: "2026-03-27T10:00:20Z".to_string(),
|
||||
completed_at: Some("2026-03-27T10:00:20Z".to_string()),
|
||||
updated_at: "2026-03-27T10:00:20Z".to_string(),
|
||||
payload: AgentThreadItemPayload::FileArtifact {
|
||||
path: artifact_relative_path.to_string(),
|
||||
source: "artifact_snapshot".to_string(),
|
||||
content: None,
|
||||
metadata: None,
|
||||
},
|
||||
});
|
||||
detail.items.push(AgentThreadItem {
|
||||
id: "browser-tool-blocking-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
sequence: 4,
|
||||
status: AgentThreadItemStatus::Completed,
|
||||
started_at: "2026-03-27T10:00:40Z".to_string(),
|
||||
completed_at: Some("2026-03-27T10:00:40Z".to_string()),
|
||||
updated_at: "2026-03-27T10:00:40Z".to_string(),
|
||||
payload: AgentThreadItemPayload::ToolCall {
|
||||
tool_name: "browser_snapshot".to_string(),
|
||||
arguments: None,
|
||||
output: None,
|
||||
success: Some(false),
|
||||
error: Some("browser step failed".to_string()),
|
||||
metadata: None,
|
||||
},
|
||||
});
|
||||
detail.items.push(AgentThreadItem {
|
||||
id: "gui-smoke-blocking-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
sequence: 5,
|
||||
status: AgentThreadItemStatus::Completed,
|
||||
started_at: "2026-03-27T10:00:50Z".to_string(),
|
||||
completed_at: Some("2026-03-27T10:00:50Z".to_string()),
|
||||
updated_at: "2026-03-27T10:00:50Z".to_string(),
|
||||
payload: AgentThreadItemPayload::CommandExecution {
|
||||
command: "npm run verify:gui-smoke".to_string(),
|
||||
cwd: root.to_string_lossy().to_string(),
|
||||
aggregated_output: Some("GUI smoke failed".to_string()),
|
||||
exit_code: Some(1),
|
||||
error: Some("smoke failed".to_string()),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_export_runtime_review_decision_template_to_workspace() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
@@ -808,6 +1400,7 @@ mod tests {
|
||||
assert_eq!(result.artifacts.len(), 2);
|
||||
assert_eq!(result.analysis_artifacts.len(), 2);
|
||||
assert!(!result.review_checklist.is_empty());
|
||||
assert!(result.verification_summary.is_none());
|
||||
|
||||
let markdown_path = temp_dir
|
||||
.path()
|
||||
@@ -824,12 +1417,120 @@ mod tests {
|
||||
assert!(markdown.contains("analysis-brief.md"));
|
||||
assert!(markdown.contains("aster-rust"));
|
||||
assert!(markdown.contains("pending_review"));
|
||||
assert!(markdown.contains("结构化验证摘要"));
|
||||
assert!(markdown.contains("当前没有结构化验证摘要"));
|
||||
assert!(markdown.contains("阻塞 / 提示失败"));
|
||||
assert!(markdown.contains("已恢复结果"));
|
||||
assert!(markdown.contains("- 无"));
|
||||
assert!(result.decision.followup_actions.is_empty());
|
||||
assert!(result.decision.regression_requirements.is_empty());
|
||||
|
||||
let json = fs::read_to_string(json_path).expect("json");
|
||||
assert!(json.contains("\"contractShape\": \"lime_review_decision_template\""));
|
||||
assert!(json.contains("\"decisionStatus\": \"pending_review\""));
|
||||
assert!(json.contains("\"executionEnvironmentReference\": \"codex\""));
|
||||
assert!(json.contains("\"runtimeFactSource\": \"aster-rust\""));
|
||||
assert!(json.contains("\"verificationSummary\": null"));
|
||||
assert!(json.contains("\"verificationFailureOutcomes\": []"));
|
||||
assert!(json.contains("\"verificationRecoveredOutcomes\": []"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_include_verification_outcomes_in_review_decision_when_available() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let mut detail = build_detail();
|
||||
let thread_read = build_thread_read();
|
||||
seed_recovered_verification(&mut detail, temp_dir.path());
|
||||
|
||||
let result =
|
||||
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
|
||||
.expect("export");
|
||||
|
||||
assert!(result.verification_summary.is_some());
|
||||
|
||||
let markdown_path = temp_dir
|
||||
.path()
|
||||
.join(".lime/harness/sessions/session-1/review/review-decision.md");
|
||||
let json_path = temp_dir
|
||||
.path()
|
||||
.join(".lime/harness/sessions/session-1/review/review-decision.json");
|
||||
|
||||
let markdown = fs::read_to_string(markdown_path).expect("markdown");
|
||||
assert!(markdown.contains("结构化验证摘要"));
|
||||
assert!(markdown.contains("Artifact 校验:`已恢复`"));
|
||||
assert!(markdown.contains("记录 1 · issues 1 · repaired 1 · fallback 0"));
|
||||
assert!(markdown.contains("Artifact 校验已恢复 1 个产物,fallback 0 次。"));
|
||||
assert!(markdown.contains("浏览器验证:`通过`"));
|
||||
assert!(markdown.contains("GUI Smoke:`通过`"));
|
||||
assert!(markdown.contains("- 无"));
|
||||
assert_eq!(
|
||||
result.decision.followup_actions,
|
||||
vec![
|
||||
"在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。"
|
||||
.to_string(),
|
||||
"把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。"
|
||||
.to_string(),
|
||||
"主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。"
|
||||
.to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
result.decision.regression_requirements,
|
||||
vec![
|
||||
"npm run harness:eval".to_string(),
|
||||
"npm run harness:eval:trend".to_string(),
|
||||
"npm run verify:gui-smoke".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
let json = fs::read_to_string(json_path).expect("json");
|
||||
assert!(json.contains("\"verificationSummary\": {"));
|
||||
assert!(json.contains("\"verificationFailureOutcomes\": []"));
|
||||
assert!(json.contains(
|
||||
"\"verificationRecoveredOutcomes\": [\n \"Artifact 校验已恢复 1 个产物,fallback 0 次。\"\n ]"
|
||||
));
|
||||
assert!(json.contains("\"outcome\": \"recovered\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_seed_followup_actions_from_blocking_verification_outcomes() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let mut detail = build_detail();
|
||||
let thread_read = build_thread_read();
|
||||
seed_blocking_verification(&mut detail, temp_dir.path());
|
||||
|
||||
let result =
|
||||
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
|
||||
.expect("export");
|
||||
|
||||
let markdown_path = temp_dir
|
||||
.path()
|
||||
.join(".lime/harness/sessions/session-1/review/review-decision.md");
|
||||
let markdown = fs::read_to_string(markdown_path).expect("markdown");
|
||||
|
||||
assert_eq!(
|
||||
result.decision.followup_actions,
|
||||
vec![
|
||||
"回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。"
|
||||
.to_string(),
|
||||
"回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。"
|
||||
.to_string(),
|
||||
"优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。"
|
||||
.to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
result.decision.regression_requirements,
|
||||
vec![
|
||||
"npm run harness:eval".to_string(),
|
||||
"npm run harness:eval:trend".to_string(),
|
||||
"npm run verify:gui-smoke".to_string(),
|
||||
]
|
||||
);
|
||||
assert!(markdown.contains(
|
||||
"回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。"
|
||||
));
|
||||
assert!(markdown.contains("npm run verify:gui-smoke"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user