release: v1.10.0

This commit is contained in:
coso
2026-04-13 13:07:08 +08:00
parent 0f15b8a71e
commit bf03e44ac7
273 changed files with 12344 additions and 26363 deletions
+18 -1
View File
@@ -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),
+1
View File
@@ -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]