mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.12.0
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1128,6 +1128,11 @@ pub(super) fn append_payload_tracking_metadata(metadata: &mut Map<String, Value>
|
||||
}) {
|
||||
metadata.insert("service_skill".to_string(), service_skill.clone());
|
||||
}
|
||||
if let Some(sceneapp) = request_metadata
|
||||
.and_then(|value| value.get("sceneapp").or_else(|| value.get("sceneApp")))
|
||||
{
|
||||
metadata.insert("sceneapp".to_string(), sceneapp.clone());
|
||||
}
|
||||
|
||||
let harness = request_metadata
|
||||
.and_then(|value| value.get("harness"))
|
||||
|
||||
@@ -64,6 +64,10 @@ impl ExecutionTracker {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn db(&self) -> &DbConnection {
|
||||
&self.db
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
&self,
|
||||
source: RunSource,
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
//!
|
||||
//! 将配置中的记忆来源(AGENTS、规则、自动记忆等)统一解析为可观察结果与可注入提示词片段。
|
||||
|
||||
use crate::services::auto_memory_service::{get_auto_memory_index, resolve_auto_memory_root};
|
||||
use crate::services::auto_memory_service::{
|
||||
get_auto_memory_index, infer_memdir_memory_type, resolve_auto_memory_root, MemdirMemoryType,
|
||||
};
|
||||
use crate::services::memory_import_parser_service::{parse_memory_file, MemoryImportParseOptions};
|
||||
use crate::services::memory_rules_loader_service::load_rules;
|
||||
use chrono::{DateTime, Utc};
|
||||
use lime_agent::{
|
||||
resolve_durable_memory_root, to_virtual_memory_path, DURABLE_MEMORY_VIRTUAL_ROOT,
|
||||
};
|
||||
@@ -19,12 +22,26 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
const DURABLE_MEMORY_MAX_DEPTH: usize = 4;
|
||||
const DURABLE_MEMORY_MAX_FILES: usize = 64;
|
||||
const AUTO_MEMORY_LINKED_ITEM_LIMIT: usize = 8;
|
||||
const AUTO_MEMORY_LINKED_ITEM_LINE_LIMIT: usize = 40;
|
||||
|
||||
/// 单个来源解析结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EffectiveMemorySource {
|
||||
/// 来源类型:managed_policy/project/user/local/rule/auto_memory/additional
|
||||
pub kind: String,
|
||||
/// 归属来源桶:managed/user/project/local/rules/auto/durable/additional
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_bucket: Option<String>,
|
||||
/// 当前命中的 provider 标识;memdir 主链会显式写入 `memdir`
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<String>,
|
||||
/// memdir 条目类型
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory_type: Option<MemdirMemoryType>,
|
||||
/// 最近更新时间
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<i64>,
|
||||
/// 来源路径
|
||||
pub path: String,
|
||||
/// 文件或目录是否存在
|
||||
@@ -322,6 +339,67 @@ pub fn build_memory_sources_prompt_with_options(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_effective_memory_source(
|
||||
kind: &str,
|
||||
path: String,
|
||||
exists: bool,
|
||||
loaded: bool,
|
||||
line_count: u32,
|
||||
import_count: u32,
|
||||
warnings: Vec<String>,
|
||||
preview: Option<String>,
|
||||
updated_at: Option<i64>,
|
||||
memory_type: Option<MemdirMemoryType>,
|
||||
) -> EffectiveMemorySource {
|
||||
EffectiveMemorySource {
|
||||
kind: kind.to_string(),
|
||||
source_bucket: source_bucket_for_kind(kind).map(str::to_string),
|
||||
provider: provider_for_kind(kind).map(str::to_string),
|
||||
memory_type,
|
||||
updated_at,
|
||||
path,
|
||||
exists,
|
||||
loaded,
|
||||
line_count,
|
||||
import_count,
|
||||
warnings,
|
||||
preview,
|
||||
}
|
||||
}
|
||||
|
||||
fn source_bucket_for_kind(kind: &str) -> Option<&'static str> {
|
||||
match kind {
|
||||
"managed_policy" => Some("managed"),
|
||||
"user_memory" => Some("user"),
|
||||
"project_memory" | "workspace_agents" => Some("project"),
|
||||
"project_local" => Some("local"),
|
||||
"project_rule" | "project_rules" => Some("rules"),
|
||||
"auto_memory" | "auto_memory_item" => Some("auto"),
|
||||
"durable_memory" => Some("durable"),
|
||||
"additional_memory" => Some("additional"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_for_kind(kind: &str) -> Option<&'static str> {
|
||||
match kind {
|
||||
"auto_memory" | "auto_memory_item" => Some("memdir"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_type_for_kind(kind: &str, path: &str) -> Option<MemdirMemoryType> {
|
||||
match kind {
|
||||
"auto_memory" | "auto_memory_item" => infer_memdir_memory_type(path),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_path_updated_at(path: &Path) -> Option<i64> {
|
||||
let modified = fs::metadata(path).ok()?.modified().ok()?;
|
||||
Some(DateTime::<Utc>::from(modified).timestamp_millis())
|
||||
}
|
||||
|
||||
fn runtime_agent_overlap_paths(working_dir: &Path) -> HashSet<PathBuf> {
|
||||
let mut paths = HashSet::new();
|
||||
paths.insert(normalize_path(&app_paths::best_effort_user_memory_path()));
|
||||
@@ -387,16 +465,18 @@ fn resolve_file_source_with_display_path(
|
||||
if !include_missing {
|
||||
return;
|
||||
}
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: kind.to_string(),
|
||||
path: display_path,
|
||||
exists: false,
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
import_count: 0,
|
||||
warnings: Vec::new(),
|
||||
preview: None,
|
||||
});
|
||||
output.push(build_effective_memory_source(
|
||||
kind,
|
||||
display_path,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -416,16 +496,18 @@ fn resolve_file_source_with_display_path(
|
||||
0
|
||||
};
|
||||
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: kind.to_string(),
|
||||
path: display_path.clone(),
|
||||
exists: true,
|
||||
output.push(build_effective_memory_source(
|
||||
kind,
|
||||
display_path.clone(),
|
||||
true,
|
||||
loaded,
|
||||
line_count,
|
||||
import_count: parsed.imported_files.len() as u32,
|
||||
warnings: parsed.warnings.clone(),
|
||||
parsed.imported_files.len() as u32,
|
||||
parsed.warnings.clone(),
|
||||
preview,
|
||||
});
|
||||
read_path_updated_at(&normalized),
|
||||
memory_type_for_kind(kind, display_path.as_str()),
|
||||
));
|
||||
|
||||
if loaded {
|
||||
prompt_segments.push(format!("### {} ({})\n{}", kind, display_path, content));
|
||||
@@ -437,16 +519,18 @@ fn resolve_file_source_with_display_path(
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: kind.to_string(),
|
||||
path: display_path,
|
||||
exists: true,
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
import_count: 0,
|
||||
warnings: vec![err],
|
||||
preview: None,
|
||||
});
|
||||
output.push(build_effective_memory_source(
|
||||
kind,
|
||||
display_path,
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
vec![err],
|
||||
None,
|
||||
read_path_updated_at(&normalized),
|
||||
memory_type_for_kind(kind, normalized.to_string_lossy().as_ref()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -462,16 +546,18 @@ fn resolve_durable_memory_sources(
|
||||
let root = match resolve_durable_memory_root() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: "durable_memory".to_string(),
|
||||
path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
|
||||
exists: false,
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
import_count: 0,
|
||||
warnings: vec![format!("解析 durable memory 根目录失败: {err}")],
|
||||
preview: None,
|
||||
});
|
||||
output.push(build_effective_memory_source(
|
||||
"durable_memory",
|
||||
DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
vec![format!("解析 durable memory 根目录失败: {err}")],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -483,16 +569,18 @@ fn resolve_durable_memory_sources(
|
||||
) {
|
||||
Ok(files) => files,
|
||||
Err(err) => {
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: "durable_memory".to_string(),
|
||||
path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
|
||||
exists: root.exists(),
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
import_count: 0,
|
||||
warnings: vec![format!("扫描 durable memory 文件失败: {err}")],
|
||||
preview: None,
|
||||
});
|
||||
output.push(build_effective_memory_source(
|
||||
"durable_memory",
|
||||
DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
|
||||
root.exists(),
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
vec![format!("扫描 durable memory 文件失败: {err}")],
|
||||
None,
|
||||
read_path_updated_at(&root),
|
||||
None,
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -503,16 +591,18 @@ fn resolve_durable_memory_sources(
|
||||
} else {
|
||||
vec!["记忆功能已关闭".to_string()]
|
||||
};
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: "durable_memory".to_string(),
|
||||
path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
|
||||
exists: root.exists(),
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
import_count: 0,
|
||||
output.push(build_effective_memory_source(
|
||||
"durable_memory",
|
||||
DURABLE_MEMORY_VIRTUAL_ROOT.to_string(),
|
||||
root.exists(),
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
warnings,
|
||||
preview: None,
|
||||
});
|
||||
None,
|
||||
read_path_updated_at(&root),
|
||||
None,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -554,16 +644,18 @@ fn resolve_rule_sources(
|
||||
if !include_missing {
|
||||
return;
|
||||
}
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: "project_rules".to_string(),
|
||||
path: normalized.to_string_lossy().to_string(),
|
||||
exists: false,
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
import_count: 0,
|
||||
warnings: Vec::new(),
|
||||
preview: None,
|
||||
});
|
||||
output.push(build_effective_memory_source(
|
||||
"project_rules",
|
||||
normalized.to_string_lossy().to_string(),
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -572,16 +664,18 @@ fn resolve_rule_sources(
|
||||
if !include_missing {
|
||||
return;
|
||||
}
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: "project_rules".to_string(),
|
||||
path: normalized.to_string_lossy().to_string(),
|
||||
exists: true,
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
import_count: 0,
|
||||
warnings: vec!["规则目录存在,但未发现可用规则".to_string()],
|
||||
preview: None,
|
||||
});
|
||||
output.push(build_effective_memory_source(
|
||||
"project_rules",
|
||||
normalized.to_string_lossy().to_string(),
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
vec!["规则目录存在,但未发现可用规则".to_string()],
|
||||
None,
|
||||
read_path_updated_at(&normalized),
|
||||
None,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -599,24 +693,26 @@ fn resolve_rule_sources(
|
||||
rule.path_patterns.join(", ")
|
||||
));
|
||||
}
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: "project_rule".to_string(),
|
||||
path: normalized_rule.to_string_lossy().to_string(),
|
||||
exists: true,
|
||||
output.push(build_effective_memory_source(
|
||||
"project_rule",
|
||||
normalized_rule.to_string_lossy().to_string(),
|
||||
true,
|
||||
loaded,
|
||||
line_count: if loaded {
|
||||
if loaded {
|
||||
rule.content.lines().count() as u32
|
||||
} else {
|
||||
0
|
||||
},
|
||||
import_count: 0,
|
||||
0,
|
||||
warnings,
|
||||
preview: if loaded {
|
||||
if loaded {
|
||||
Some(clip_text(&rule.content, 300))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
read_path_updated_at(&normalized_rule),
|
||||
None,
|
||||
));
|
||||
|
||||
if loaded {
|
||||
prompt_segments.push(format!(
|
||||
@@ -649,7 +745,8 @@ fn resolve_auto_memory_source(
|
||||
} else {
|
||||
entry_name
|
||||
};
|
||||
let entry_path = normalize_path(&auto_root.join(entry_name));
|
||||
let entry_display_path = auto_root.join(entry_name);
|
||||
let entry_path = normalize_path(&entry_display_path);
|
||||
if !seen.insert(entry_path.clone()) {
|
||||
return;
|
||||
}
|
||||
@@ -658,53 +755,155 @@ fn resolve_auto_memory_source(
|
||||
match index {
|
||||
Ok(idx) => {
|
||||
let loaded = idx.entry_exists && !idx.preview_lines.is_empty();
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: "auto_memory".to_string(),
|
||||
path: entry_path.to_string_lossy().to_string(),
|
||||
exists: idx.entry_exists,
|
||||
output.push(build_effective_memory_source(
|
||||
"auto_memory",
|
||||
entry_display_path.to_string_lossy().to_string(),
|
||||
idx.entry_exists,
|
||||
loaded,
|
||||
line_count: idx.total_lines,
|
||||
import_count: idx.items.len() as u32,
|
||||
warnings: if !memory_config.auto.enabled {
|
||||
idx.total_lines,
|
||||
idx.items.len() as u32,
|
||||
if !memory_config.auto.enabled {
|
||||
vec!["自动记忆已关闭".to_string()]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
preview: if loaded {
|
||||
if loaded {
|
||||
Some(clip_text(&idx.preview_lines.join("\n"), 300))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
read_path_updated_at(&entry_path),
|
||||
None,
|
||||
));
|
||||
|
||||
if loaded {
|
||||
prompt_segments.push(format!(
|
||||
"### auto_memory ({})\n{}",
|
||||
entry_path.display(),
|
||||
entry_display_path.display(),
|
||||
idx.preview_lines.join("\n")
|
||||
));
|
||||
prompt_sources.push(MemoryPromptSegment {
|
||||
title: "auto_memory".to_string(),
|
||||
path: entry_path.to_string_lossy().to_string(),
|
||||
path: entry_display_path.to_string_lossy().to_string(),
|
||||
content: idx.preview_lines.join("\n"),
|
||||
});
|
||||
|
||||
let sorted_items = sort_auto_memory_prompt_items(&idx.items);
|
||||
for item in sorted_items.iter().take(AUTO_MEMORY_LINKED_ITEM_LIMIT) {
|
||||
let item_display_path = auto_root.join(&item.relative_path);
|
||||
let item_path = normalize_path(&item_display_path);
|
||||
if !seen.insert(item_path.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(content) = fs::read_to_string(&item_path) else {
|
||||
output.push(build_effective_memory_source(
|
||||
"auto_memory_item",
|
||||
item_display_path.to_string_lossy().to_string(),
|
||||
item.exists,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
vec![format!(
|
||||
"读取 memdir 条目失败: {}",
|
||||
item_display_path.display()
|
||||
)],
|
||||
None,
|
||||
item.updated_at,
|
||||
item.memory_type,
|
||||
));
|
||||
continue;
|
||||
};
|
||||
|
||||
let preview_lines = content
|
||||
.lines()
|
||||
.take(AUTO_MEMORY_LINKED_ITEM_LINE_LIMIT)
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let item_loaded = !preview_lines.is_empty();
|
||||
|
||||
output.push(build_effective_memory_source(
|
||||
"auto_memory_item",
|
||||
item_display_path.to_string_lossy().to_string(),
|
||||
item.exists,
|
||||
item_loaded,
|
||||
content.lines().count() as u32,
|
||||
0,
|
||||
Vec::new(),
|
||||
if item_loaded {
|
||||
Some(clip_text(&preview_lines.join("\n"), 300))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
item.updated_at.or_else(|| read_path_updated_at(&item_path)),
|
||||
item.memory_type,
|
||||
));
|
||||
|
||||
if item_loaded {
|
||||
prompt_segments.push(format!(
|
||||
"### auto_memory_item ({})\n{}",
|
||||
item_display_path.display(),
|
||||
preview_lines.join("\n")
|
||||
));
|
||||
prompt_sources.push(MemoryPromptSegment {
|
||||
title: format!("auto_memory_item: {}", item.title),
|
||||
path: item_display_path.to_string_lossy().to_string(),
|
||||
content: preview_lines.join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
output.push(EffectiveMemorySource {
|
||||
kind: "auto_memory".to_string(),
|
||||
path: entry_path.to_string_lossy().to_string(),
|
||||
exists: entry_path.exists(),
|
||||
loaded: false,
|
||||
line_count: 0,
|
||||
import_count: 0,
|
||||
warnings: vec![err],
|
||||
preview: None,
|
||||
});
|
||||
output.push(build_effective_memory_source(
|
||||
"auto_memory",
|
||||
entry_display_path.to_string_lossy().to_string(),
|
||||
entry_path.exists(),
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
vec![err],
|
||||
None,
|
||||
read_path_updated_at(&entry_path),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_auto_memory_prompt_items(
|
||||
items: &[crate::services::auto_memory_service::AutoMemoryIndexItem],
|
||||
) -> Vec<crate::services::auto_memory_service::AutoMemoryIndexItem> {
|
||||
let mut sorted = items.to_vec();
|
||||
sorted.sort_by(|left, right| {
|
||||
auto_memory_prompt_item_rank(left)
|
||||
.cmp(&auto_memory_prompt_item_rank(right))
|
||||
.then_with(|| {
|
||||
right
|
||||
.updated_at
|
||||
.unwrap_or_default()
|
||||
.cmp(&left.updated_at.unwrap_or_default())
|
||||
})
|
||||
.then_with(|| left.relative_path.cmp(&right.relative_path))
|
||||
});
|
||||
sorted
|
||||
}
|
||||
|
||||
fn auto_memory_prompt_item_rank(
|
||||
item: &crate::services::auto_memory_service::AutoMemoryIndexItem,
|
||||
) -> usize {
|
||||
let is_readme = item.relative_path.ends_with("/README.md")
|
||||
|| item.relative_path.ends_with("/README.markdown")
|
||||
|| item.relative_path.ends_with("/README.mdx")
|
||||
|| item.relative_path.eq_ignore_ascii_case("README.md");
|
||||
match (item.memory_type.is_some(), is_readme) {
|
||||
(true, false) => 0,
|
||||
(true, true) => 1,
|
||||
(false, false) => 2,
|
||||
(false, true) => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_durable_memory_files(
|
||||
root: &Path,
|
||||
max_depth: usize,
|
||||
@@ -1059,6 +1258,106 @@ mod tests {
|
||||
.any(|segment| segment.contains("/memories/MEMORY.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_resolve_memdir_topic_items_with_provider_metadata() {
|
||||
let _env_lock = durable_memory_env_lock().lock().expect("lock env");
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let empty_durable_root = tmp.path().join("durable-empty");
|
||||
fs::create_dir_all(&empty_durable_root).expect("create empty durable root");
|
||||
let _durable_guard = DurableMemoryEnvGuard::set(&empty_durable_root);
|
||||
|
||||
let mut cfg = Config::default();
|
||||
cfg.memory.enabled = true;
|
||||
cfg.memory.auto.enabled = true;
|
||||
cfg.memory.auto.root_dir = Some(tmp.path().join("memdir").to_string_lossy().to_string());
|
||||
cfg.memory.auto.entrypoint = "MEMORY.md".to_string();
|
||||
cfg.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
|
||||
cfg.memory.sources.user_memory_path = Some("missing-user.md".to_string());
|
||||
cfg.memory.sources.project_memory_paths = Vec::new();
|
||||
cfg.memory.sources.project_rule_dirs = Vec::new();
|
||||
|
||||
crate::services::auto_memory_service::scaffold_memdir(&cfg.memory, tmp.path(), false)
|
||||
.expect("scaffold memdir");
|
||||
crate::services::auto_memory_service::update_auto_memory_note(
|
||||
&cfg.memory,
|
||||
tmp.path(),
|
||||
"Why:\n- 团队多次确认 pnpm only 可以避免锁文件漂移。\n\nHow to apply:\n- 涉及依赖安装时默认使用 pnpm,并保留锁文件。",
|
||||
Some("workflow"),
|
||||
Some(MemdirMemoryType::Feedback),
|
||||
)
|
||||
.expect("write feedback note");
|
||||
|
||||
let resolved = resolve_effective_sources(&cfg, tmp.path(), None);
|
||||
let auto_root = tmp.path().join("memdir");
|
||||
let entry_path = auto_root.join("MEMORY.md").to_string_lossy().to_string();
|
||||
let topic_path = auto_root
|
||||
.join("feedback/workflow.md")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
assert!(resolved.response.sources.iter().any(|source| {
|
||||
source.kind == "auto_memory"
|
||||
&& source.source_bucket.as_deref() == Some("auto")
|
||||
&& source.provider.as_deref() == Some("memdir")
|
||||
&& source.updated_at.is_some()
|
||||
&& source.path == entry_path
|
||||
}));
|
||||
|
||||
assert!(resolved.response.sources.iter().any(|source| {
|
||||
source.kind == "auto_memory_item"
|
||||
&& source.path == topic_path
|
||||
&& source.loaded
|
||||
&& source.source_bucket.as_deref() == Some("auto")
|
||||
&& source.provider.as_deref() == Some("memdir")
|
||||
&& source.memory_type == Some(MemdirMemoryType::Feedback)
|
||||
&& source.updated_at.is_some()
|
||||
}));
|
||||
|
||||
assert!(resolved
|
||||
.prompt_segments
|
||||
.iter()
|
||||
.any(|segment| segment.contains("pnpm only")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_prioritize_specific_memdir_topic_before_type_readme_in_prompt_sources() {
|
||||
let _env_lock = durable_memory_env_lock().lock().expect("lock env");
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let empty_durable_root = tmp.path().join("durable-empty");
|
||||
fs::create_dir_all(&empty_durable_root).expect("create empty durable root");
|
||||
let _durable_guard = DurableMemoryEnvGuard::set(&empty_durable_root);
|
||||
|
||||
let mut cfg = Config::default();
|
||||
cfg.memory.enabled = true;
|
||||
cfg.memory.auto.enabled = true;
|
||||
cfg.memory.auto.root_dir = Some(tmp.path().join("memdir").to_string_lossy().to_string());
|
||||
cfg.memory.auto.entrypoint = "MEMORY.md".to_string();
|
||||
cfg.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
|
||||
cfg.memory.sources.user_memory_path = Some("missing-user.md".to_string());
|
||||
cfg.memory.sources.project_memory_paths = Vec::new();
|
||||
cfg.memory.sources.project_rule_dirs = Vec::new();
|
||||
|
||||
crate::services::auto_memory_service::scaffold_memdir(&cfg.memory, tmp.path(), false)
|
||||
.expect("scaffold memdir");
|
||||
crate::services::auto_memory_service::update_auto_memory_note(
|
||||
&cfg.memory,
|
||||
tmp.path(),
|
||||
"Why:\n- 团队多次确认 pnpm only 可以避免锁文件漂移。\n\nHow to apply:\n- 涉及依赖安装时默认使用 pnpm,并保留锁文件。",
|
||||
Some("workflow"),
|
||||
Some(MemdirMemoryType::Feedback),
|
||||
)
|
||||
.expect("write feedback note");
|
||||
|
||||
let resolved = resolve_effective_sources(&cfg, tmp.path(), None);
|
||||
let first_item = resolved
|
||||
.prompt_sources
|
||||
.iter()
|
||||
.find(|source| source.title.starts_with("auto_memory_item:"))
|
||||
.expect("find first auto memory item");
|
||||
|
||||
assert!(first_item.path.ends_with("feedback/workflow.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_local_instruction_path_should_not_walk_ancestors() {
|
||||
let _env_lock = durable_memory_env_lock().lock().expect("lock env");
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod openclaw_service;
|
||||
pub mod runtime_agents_template_service;
|
||||
pub mod runtime_analysis_handoff_service;
|
||||
pub mod runtime_evidence_pack_service;
|
||||
pub mod runtime_file_checkpoint_service;
|
||||
pub mod runtime_handoff_artifact_service;
|
||||
pub mod runtime_replay_case_service;
|
||||
pub mod runtime_review_decision_service;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Runtime analysis handoff 导出服务
|
||||
//!
|
||||
//! 将 handoff bundle / evidence pack / replay case 重新包装成
|
||||
//! 外部 Claude Code / Codex 更容易直接消费的 analysis handoff。
|
||||
//! 外部代码助手 / Codex 更容易直接消费的 analysis handoff。
|
||||
//! 这条链仍然只负责导出证据与现成提示词,不在 Lime 内自动分析或自动修复。
|
||||
|
||||
use crate::agent::SessionDetail;
|
||||
@@ -742,7 +742,7 @@ fn build_reading_order() -> Vec<String> {
|
||||
|
||||
fn build_external_analysis_contract() -> AnalysisExternalContract {
|
||||
AnalysisExternalContract {
|
||||
audience: "Claude Code / Codex".to_string(),
|
||||
audience: "外部代码助手 / Codex".to_string(),
|
||||
task: "基于 Lime 导出的结构化证据做问题分析与修复建议,不直接代替团队做最终决策。"
|
||||
.to_string(),
|
||||
required_sections: vec![
|
||||
@@ -1309,6 +1309,7 @@ mod tests {
|
||||
interrupt_state: None,
|
||||
updated_at: Some("2026-03-27T10:01:20Z".to_string()),
|
||||
latest_compaction_boundary: None,
|
||||
file_checkpoint_summary: None,
|
||||
diagnostics: Some(AgentRuntimeThreadDiagnostics {
|
||||
latest_turn_status: Some("action_required".to_string()),
|
||||
latest_turn_started_at: None,
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
|
||||
use crate::agent::SessionDetail;
|
||||
use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION;
|
||||
use crate::services::runtime_file_checkpoint_service::list_file_checkpoints;
|
||||
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
|
||||
use crate::workspace::WorkspaceManager;
|
||||
use chrono::Utc;
|
||||
use lime_core::database::dao::agent_timeline::AgentThreadItemPayload;
|
||||
use lime_infra::telemetry::RequestLog;
|
||||
@@ -68,6 +72,19 @@ pub struct RuntimeEvidencePackExportResult {
|
||||
pub artifacts: Vec<RuntimeEvidenceArtifact>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct RuntimeEvidenceSceneAppSnapshot {
|
||||
pub recent_artifact_paths: Vec<String>,
|
||||
pub workspace_root: Option<String>,
|
||||
pub known_gaps: Vec<String>,
|
||||
pub verification_failure_outcomes: Vec<String>,
|
||||
pub request_telemetry_available: bool,
|
||||
pub request_telemetry_matched_count: usize,
|
||||
pub artifact_validator_applicable: bool,
|
||||
pub artifact_validator_issue_count: usize,
|
||||
pub artifact_validator_recovered_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RuntimeVerificationOutcome {
|
||||
Success,
|
||||
@@ -160,10 +177,11 @@ pub fn export_runtime_evidence_pack(
|
||||
.iter()
|
||||
.map(|artifact| artifact.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let file_checkpoints = list_file_checkpoints(detail);
|
||||
let latest_turn_summary = collect_latest_turn_summary(detail);
|
||||
let request_telemetry = collect_request_telemetry(detail, workspace_root.as_path());
|
||||
let verification =
|
||||
collect_runtime_verification(detail, workspace_root.as_path(), &recent_artifacts);
|
||||
collect_runtime_verification(detail, Some(workspace_root.as_path()), &recent_artifacts);
|
||||
let signal_coverage = build_signal_coverage(
|
||||
thread_read,
|
||||
&recent_artifacts,
|
||||
@@ -209,6 +227,7 @@ pub fn export_runtime_evidence_pack(
|
||||
thread_read,
|
||||
workspace_root.as_path(),
|
||||
&recent_artifact_paths,
|
||||
&file_checkpoints.checkpoints,
|
||||
&observability_summary,
|
||||
&known_gaps,
|
||||
exported_at.as_str(),
|
||||
@@ -232,6 +251,7 @@ pub fn export_runtime_evidence_pack(
|
||||
detail,
|
||||
thread_read,
|
||||
&recent_artifact_paths,
|
||||
&file_checkpoints.checkpoints,
|
||||
&observability_summary,
|
||||
&request_telemetry,
|
||||
&verification,
|
||||
@@ -265,6 +285,93 @@ pub fn export_runtime_evidence_pack(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_runtime_export_workspace_root(
|
||||
db: &DbConnection,
|
||||
detail: &SessionDetail,
|
||||
) -> Result<PathBuf, String> {
|
||||
if let Some(workspace_id) = detail
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let manager = WorkspaceManager::new(db.clone());
|
||||
let workspace_id = workspace_id.to_string();
|
||||
let workspace = manager
|
||||
.get(&workspace_id)
|
||||
.map_err(|error| format!("读取 workspace 失败: {error}"))?
|
||||
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
|
||||
let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?;
|
||||
return Ok(ensured.root_path);
|
||||
}
|
||||
|
||||
if let Some(working_dir) = detail
|
||||
.working_dir
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Ok(PathBuf::from(working_dir));
|
||||
}
|
||||
|
||||
Err("当前会话缺少 workspace / working_dir,无法导出运行时制品".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_evidence_sceneapp_snapshot(
|
||||
detail: &SessionDetail,
|
||||
thread_read: &AgentRuntimeThreadReadModel,
|
||||
workspace_root: Option<&Path>,
|
||||
) -> RuntimeEvidenceSceneAppSnapshot {
|
||||
let recent_artifacts = collect_recent_artifacts(detail);
|
||||
let request_telemetry = workspace_root
|
||||
.map(|root| collect_request_telemetry(detail, root))
|
||||
.unwrap_or_default();
|
||||
let verification = collect_runtime_verification(detail, workspace_root, &recent_artifacts);
|
||||
let signal_coverage = build_signal_coverage(
|
||||
thread_read,
|
||||
&recent_artifacts,
|
||||
&request_telemetry,
|
||||
&verification,
|
||||
);
|
||||
let known_gaps = build_known_gaps(&recent_artifacts, &signal_coverage);
|
||||
|
||||
RuntimeEvidenceSceneAppSnapshot {
|
||||
recent_artifact_paths: recent_artifacts
|
||||
.into_iter()
|
||||
.map(|artifact| artifact.path)
|
||||
.collect(),
|
||||
workspace_root: workspace_root.map(|path| path.to_string_lossy().to_string()),
|
||||
known_gaps,
|
||||
verification_failure_outcomes: extract_verification_failure_outcomes(&verification),
|
||||
request_telemetry_available: !request_telemetry.searched_roots.is_empty(),
|
||||
request_telemetry_matched_count: request_telemetry.matched_request_count,
|
||||
artifact_validator_applicable: verification.artifact_validator.applicable,
|
||||
artifact_validator_issue_count: verification
|
||||
.artifact_validator
|
||||
.records
|
||||
.iter()
|
||||
.map(|record| {
|
||||
record
|
||||
.get("issues")
|
||||
.and_then(Value::as_array)
|
||||
.map(|issues| issues.len())
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.sum(),
|
||||
artifact_validator_recovered_count: verification
|
||||
.artifact_validator
|
||||
.records
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
record
|
||||
.get("repaired")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_evidence_file(
|
||||
pack_root: &Path,
|
||||
session_id: &str,
|
||||
@@ -402,6 +509,7 @@ fn build_runtime_json(
|
||||
thread_read: &AgentRuntimeThreadReadModel,
|
||||
workspace_root: &Path,
|
||||
recent_artifacts: &[String],
|
||||
file_checkpoints: &[crate::commands::aster_agent_cmd::AgentRuntimeFileCheckpointSummary],
|
||||
observability_summary: &Value,
|
||||
known_gaps: &[String],
|
||||
exported_at: &str,
|
||||
@@ -486,6 +594,8 @@ fn build_runtime_json(
|
||||
}).collect::<Vec<_>>(),
|
||||
"observabilitySummary": observability_summary,
|
||||
"recentArtifacts": recent_artifacts,
|
||||
"fileCheckpointCount": file_checkpoints.len(),
|
||||
"fileCheckpoints": file_checkpoints,
|
||||
"knownGaps": known_gaps
|
||||
});
|
||||
|
||||
@@ -529,6 +639,7 @@ fn build_artifacts_json(
|
||||
detail: &SessionDetail,
|
||||
thread_read: &AgentRuntimeThreadReadModel,
|
||||
recent_artifacts: &[String],
|
||||
file_checkpoints: &[crate::commands::aster_agent_cmd::AgentRuntimeFileCheckpointSummary],
|
||||
observability_summary: &Value,
|
||||
request_telemetry: &RuntimeRequestTelemetrySummary,
|
||||
verification: &RuntimeEvidenceVerificationSummary,
|
||||
@@ -540,6 +651,8 @@ fn build_artifacts_json(
|
||||
"exportedAt": exported_at,
|
||||
"recentArtifacts": recent_artifacts,
|
||||
"artifactCount": recent_artifacts.len(),
|
||||
"fileCheckpointCount": file_checkpoints.len(),
|
||||
"fileCheckpoints": file_checkpoints,
|
||||
"observabilitySummary": observability_summary,
|
||||
"requests": {
|
||||
"pending": thread_read.pending_requests.iter().map(|item| {
|
||||
@@ -891,7 +1004,7 @@ fn build_runtime_observability_summary_json(
|
||||
|
||||
fn collect_runtime_verification(
|
||||
detail: &SessionDetail,
|
||||
workspace_root: &Path,
|
||||
workspace_root: Option<&Path>,
|
||||
recent_artifacts: &[RuntimeRecentArtifact],
|
||||
) -> RuntimeEvidenceVerificationSummary {
|
||||
RuntimeEvidenceVerificationSummary {
|
||||
@@ -902,7 +1015,7 @@ fn collect_runtime_verification(
|
||||
}
|
||||
|
||||
fn collect_artifact_validator_summary(
|
||||
workspace_root: &Path,
|
||||
workspace_root: Option<&Path>,
|
||||
recent_artifacts: &[RuntimeRecentArtifact],
|
||||
) -> RuntimeArtifactValidatorSummary {
|
||||
let mut summary = RuntimeArtifactValidatorSummary::default();
|
||||
@@ -920,10 +1033,12 @@ fn collect_artifact_validator_summary(
|
||||
|
||||
if artifact.path.ends_with(".artifact.json") {
|
||||
applicable = true;
|
||||
let absolute_path = resolve_workspace_path(workspace_root, artifact.path.as_str());
|
||||
if let Ok(raw) = fs::read_to_string(&absolute_path) {
|
||||
if let Ok(document) = serde_json::from_str::<Value>(raw.as_str()) {
|
||||
candidates.push(document);
|
||||
if let Some(workspace_root) = workspace_root {
|
||||
let absolute_path = resolve_workspace_path(workspace_root, artifact.path.as_str());
|
||||
if let Ok(raw) = fs::read_to_string(&absolute_path) {
|
||||
if let Ok(document) = serde_json::from_str::<Value>(raw.as_str()) {
|
||||
candidates.push(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -943,6 +1058,25 @@ fn collect_artifact_validator_summary(
|
||||
summary
|
||||
}
|
||||
|
||||
fn extract_verification_failure_outcomes(
|
||||
verification: &RuntimeEvidenceVerificationSummary,
|
||||
) -> Vec<String> {
|
||||
build_observability_verification_summary_json(verification)
|
||||
.and_then(|summary| {
|
||||
summary
|
||||
.get("focusVerificationFailureOutcomes")
|
||||
.and_then(Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn collect_browser_evidence(detail: &SessionDetail) -> Vec<Value> {
|
||||
let mut evidence = Vec::new();
|
||||
|
||||
@@ -1723,6 +1857,7 @@ mod tests {
|
||||
interrupt_state: None,
|
||||
updated_at: Some("2026-03-27T10:01:00Z".to_string()),
|
||||
latest_compaction_boundary: None,
|
||||
file_checkpoint_summary: None,
|
||||
diagnostics: Some(
|
||||
crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
|
||||
latest_turn_status: Some("running".to_string()),
|
||||
@@ -1875,6 +2010,10 @@ mod tests {
|
||||
assert!(runtime.contains("\"sessionId\": \"session-1\""));
|
||||
assert!(runtime.contains("\"pendingRequestCount\": 1"));
|
||||
assert!(runtime.contains("\"observabilitySummary\""));
|
||||
assert!(runtime.contains("\"fileCheckpointCount\": 1"));
|
||||
assert!(runtime.contains("\"fileCheckpoints\""));
|
||||
assert!(runtime.contains("\"checkpoint_id\": \"artifact-1\""));
|
||||
assert!(runtime.contains("\"path\": \".lime/artifacts/thread-1/report.md\""));
|
||||
assert!(runtime.contains("\"requestTelemetry\""));
|
||||
assert!(runtime.contains("\"matchedRequestCount\": 1"));
|
||||
assert!(!runtime.contains("\"verificationSummary\""));
|
||||
@@ -1889,6 +2028,9 @@ mod tests {
|
||||
let artifacts = fs::read_to_string(artifacts_path).expect("artifacts");
|
||||
assert!(artifacts.contains("\"observabilitySummary\""));
|
||||
assert!(artifacts.contains("\"telemetry\""));
|
||||
assert!(artifacts.contains("\"fileCheckpointCount\": 1"));
|
||||
assert!(artifacts.contains("\"fileCheckpoints\""));
|
||||
assert!(artifacts.contains("\"checkpoint_id\": \"artifact-1\""));
|
||||
assert!(artifacts.contains("\"matchedRequestCount\": 1"));
|
||||
assert!(!artifacts.contains("\"verification\""));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
//! Runtime file checkpoint 服务
|
||||
//!
|
||||
//! 基于当前 SessionDetail -> FileArtifact -> artifact sidecar 主链,
|
||||
//! 提供文件快照摘要、详情与 diff 读取能力。
|
||||
|
||||
use crate::agent::SessionDetail;
|
||||
use crate::commands::aster_agent_cmd::{
|
||||
AgentRuntimeFileCheckpointDetail, AgentRuntimeFileCheckpointDiffResult,
|
||||
AgentRuntimeFileCheckpointListResult, AgentRuntimeFileCheckpointSummary,
|
||||
AgentRuntimeFileCheckpointThreadSummary,
|
||||
};
|
||||
use lime_core::database::dao::agent_timeline::{AgentThreadItem, AgentThreadItemPayload};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RuntimeFileCheckpointRecord {
|
||||
summary: AgentRuntimeFileCheckpointSummary,
|
||||
content: Option<String>,
|
||||
metadata: Option<Value>,
|
||||
}
|
||||
|
||||
pub fn build_thread_file_checkpoint_summary(
|
||||
detail: &SessionDetail,
|
||||
) -> Option<AgentRuntimeFileCheckpointThreadSummary> {
|
||||
let checkpoints = collect_file_checkpoint_records(detail);
|
||||
if checkpoints.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AgentRuntimeFileCheckpointThreadSummary {
|
||||
count: checkpoints.len(),
|
||||
latest_checkpoint: checkpoints.first().map(|record| record.summary.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_file_checkpoints(detail: &SessionDetail) -> AgentRuntimeFileCheckpointListResult {
|
||||
let checkpoints = collect_file_checkpoint_records(detail)
|
||||
.into_iter()
|
||||
.map(|record| record.summary)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
AgentRuntimeFileCheckpointListResult {
|
||||
session_id: detail.id.clone(),
|
||||
thread_id: detail.thread_id.clone(),
|
||||
checkpoint_count: checkpoints.len(),
|
||||
checkpoints,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_file_checkpoint(
|
||||
detail: &SessionDetail,
|
||||
workspace_root: &Path,
|
||||
checkpoint_id: &str,
|
||||
) -> Result<AgentRuntimeFileCheckpointDetail, String> {
|
||||
let record = find_file_checkpoint_record(detail, checkpoint_id)?;
|
||||
let metadata = record.metadata.as_ref();
|
||||
let snapshot_path = extract_snapshot_path(metadata, record.summary.path.as_str())
|
||||
.unwrap_or_else(|| record.summary.path.clone());
|
||||
let checkpoint_document = read_json_relative(workspace_root, snapshot_path.as_str())
|
||||
.or_else(|| extract_artifact_document(metadata));
|
||||
let live_document =
|
||||
read_json_relative(workspace_root, record.summary.path.as_str()).or_else(|| {
|
||||
if snapshot_path == record.summary.path {
|
||||
checkpoint_document.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(AgentRuntimeFileCheckpointDetail {
|
||||
session_id: detail.id.clone(),
|
||||
thread_id: detail.thread_id.clone(),
|
||||
checkpoint: record.summary.clone(),
|
||||
live_path: record.summary.path.clone(),
|
||||
snapshot_path,
|
||||
checkpoint_document,
|
||||
live_document,
|
||||
version_history: extract_version_history(metadata),
|
||||
validation_issues: extract_validation_issues(metadata),
|
||||
metadata: record.metadata.clone(),
|
||||
content: record.content.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn diff_file_checkpoint(
|
||||
detail: &SessionDetail,
|
||||
checkpoint_id: &str,
|
||||
) -> Result<AgentRuntimeFileCheckpointDiffResult, String> {
|
||||
let record = find_file_checkpoint_record(detail, checkpoint_id)?;
|
||||
let metadata = record.metadata.as_ref();
|
||||
let current_version_id = record.summary.version_id.clone();
|
||||
let current_version_no = record.summary.version_no;
|
||||
|
||||
Ok(AgentRuntimeFileCheckpointDiffResult {
|
||||
session_id: detail.id.clone(),
|
||||
thread_id: detail.thread_id.clone(),
|
||||
checkpoint: record.summary.clone(),
|
||||
current_version_id,
|
||||
previous_version_id: extract_previous_version_id(metadata, current_version_no),
|
||||
diff: extract_version_diff(metadata),
|
||||
})
|
||||
}
|
||||
|
||||
fn find_file_checkpoint_record(
|
||||
detail: &SessionDetail,
|
||||
checkpoint_id: &str,
|
||||
) -> Result<RuntimeFileCheckpointRecord, String> {
|
||||
let normalized_checkpoint_id = checkpoint_id.trim();
|
||||
if normalized_checkpoint_id.is_empty() {
|
||||
return Err("checkpoint_id 不能为空".to_string());
|
||||
}
|
||||
|
||||
collect_file_checkpoint_records(detail)
|
||||
.into_iter()
|
||||
.find(|record| record.summary.checkpoint_id == normalized_checkpoint_id)
|
||||
.ok_or_else(|| format!("未找到文件快照: {normalized_checkpoint_id}"))
|
||||
}
|
||||
|
||||
fn collect_file_checkpoint_records(detail: &SessionDetail) -> Vec<RuntimeFileCheckpointRecord> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut checkpoints = Vec::new();
|
||||
|
||||
for item in detail.items.iter().rev() {
|
||||
let Some(record) = checkpoint_record_from_item(item) else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(record.summary.checkpoint_id.clone()) {
|
||||
checkpoints.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
checkpoints.sort_by(|left, right| {
|
||||
right
|
||||
.summary
|
||||
.updated_at
|
||||
.cmp(&left.summary.updated_at)
|
||||
.then_with(|| left.summary.path.cmp(&right.summary.path))
|
||||
});
|
||||
checkpoints
|
||||
}
|
||||
|
||||
fn checkpoint_record_from_item(item: &AgentThreadItem) -> Option<RuntimeFileCheckpointRecord> {
|
||||
let AgentThreadItemPayload::FileArtifact {
|
||||
path,
|
||||
source,
|
||||
content,
|
||||
metadata,
|
||||
} = &item.payload
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let normalized_path = normalize_optional_text(path.clone())?;
|
||||
let normalized_source =
|
||||
normalize_optional_text(source.clone()).unwrap_or_else(|| "runtime".to_string());
|
||||
let metadata_ref = metadata.as_ref();
|
||||
let preview_text = extract_preview_text(metadata_ref, content.as_deref());
|
||||
let version_no = extract_version_no(metadata_ref);
|
||||
|
||||
Some(RuntimeFileCheckpointRecord {
|
||||
summary: AgentRuntimeFileCheckpointSummary {
|
||||
checkpoint_id: item.id.clone(),
|
||||
turn_id: item.turn_id.clone(),
|
||||
path: normalized_path.clone(),
|
||||
source: normalized_source,
|
||||
updated_at: item.updated_at.clone(),
|
||||
version_no,
|
||||
version_id: extract_version_id(metadata_ref),
|
||||
request_id: extract_request_id(metadata_ref),
|
||||
title: extract_title(metadata_ref),
|
||||
kind: extract_kind(metadata_ref),
|
||||
status: extract_status(metadata_ref),
|
||||
preview_text,
|
||||
snapshot_path: extract_snapshot_path(metadata_ref, normalized_path.as_str()),
|
||||
validation_issue_count: extract_validation_issues(metadata_ref).len(),
|
||||
},
|
||||
content: content.clone(),
|
||||
metadata: metadata.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: String) -> Option<String> {
|
||||
let trimmed = value.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_object(metadata: Option<&Value>) -> Option<&Map<String, Value>> {
|
||||
metadata?.as_object()
|
||||
}
|
||||
|
||||
fn metadata_string(metadata: Option<&Value>, key: &str) -> Option<String> {
|
||||
metadata_object(metadata)?
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn nested_metadata_string(metadata: Option<&Value>, parent_key: &str, key: &str) -> Option<String> {
|
||||
metadata_object(metadata)?
|
||||
.get(parent_key)
|
||||
.and_then(Value::as_object)?
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn metadata_u32(metadata: Option<&Value>, key: &str) -> Option<u32> {
|
||||
metadata_object(metadata)?
|
||||
.get(key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
}
|
||||
|
||||
fn nested_metadata_u32(metadata: Option<&Value>, parent_key: &str, key: &str) -> Option<u32> {
|
||||
metadata_object(metadata)?
|
||||
.get(parent_key)
|
||||
.and_then(Value::as_object)?
|
||||
.get(key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
}
|
||||
|
||||
fn extract_version_no(metadata: Option<&Value>) -> Option<u32> {
|
||||
metadata_u32(metadata, "artifactVersionNo")
|
||||
.or_else(|| nested_metadata_u32(metadata, "artifactVersion", "versionNo"))
|
||||
}
|
||||
|
||||
fn extract_version_id(metadata: Option<&Value>) -> Option<String> {
|
||||
metadata_string(metadata, "artifactVersionId")
|
||||
.or_else(|| nested_metadata_string(metadata, "artifactVersion", "id"))
|
||||
}
|
||||
|
||||
fn extract_request_id(metadata: Option<&Value>) -> Option<String> {
|
||||
metadata_string(metadata, "artifactRequestId")
|
||||
}
|
||||
|
||||
fn extract_title(metadata: Option<&Value>) -> Option<String> {
|
||||
metadata_string(metadata, "artifactTitle")
|
||||
.or_else(|| nested_metadata_string(metadata, "artifactVersion", "title"))
|
||||
.or_else(|| nested_metadata_string(metadata, "artifactDocument", "title"))
|
||||
}
|
||||
|
||||
fn extract_kind(metadata: Option<&Value>) -> Option<String> {
|
||||
metadata_string(metadata, "artifactKind")
|
||||
.or_else(|| nested_metadata_string(metadata, "artifactVersion", "kind"))
|
||||
.or_else(|| nested_metadata_string(metadata, "artifactDocument", "kind"))
|
||||
}
|
||||
|
||||
fn extract_status(metadata: Option<&Value>) -> Option<String> {
|
||||
metadata_string(metadata, "artifactStatus")
|
||||
.or_else(|| nested_metadata_string(metadata, "artifactVersion", "status"))
|
||||
.or_else(|| nested_metadata_string(metadata, "artifactDocument", "status"))
|
||||
}
|
||||
|
||||
fn extract_snapshot_path(metadata: Option<&Value>, fallback_path: &str) -> Option<String> {
|
||||
nested_metadata_string(metadata, "artifactVersion", "snapshotPath")
|
||||
.or_else(|| metadata_string(metadata, "artifact_path"))
|
||||
.or_else(|| normalize_optional_text(fallback_path.to_string()))
|
||||
}
|
||||
|
||||
fn extract_preview_text(metadata: Option<&Value>, content: Option<&str>) -> Option<String> {
|
||||
metadata_string(metadata, "previewText")
|
||||
.or_else(|| nested_metadata_string(metadata, "artifactDocument", "summary"))
|
||||
.or_else(|| nested_metadata_string(metadata, "artifactDocument", "title"))
|
||||
.or_else(|| {
|
||||
content
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(truncate_text)
|
||||
})
|
||||
}
|
||||
|
||||
fn truncate_text(value: &str) -> String {
|
||||
const MAX_PREVIEW_CHARS: usize = 240;
|
||||
let normalized = value
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let mut chars = normalized.chars();
|
||||
let prefix: String = chars.by_ref().take(MAX_PREVIEW_CHARS).collect();
|
||||
if chars.next().is_some() {
|
||||
format!("{prefix}...")
|
||||
} else {
|
||||
prefix
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_validation_issues(metadata: Option<&Value>) -> Vec<String> {
|
||||
metadata_object(metadata)
|
||||
.and_then(|record| record.get("artifactValidationIssues"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn extract_artifact_document(metadata: Option<&Value>) -> Option<Value> {
|
||||
metadata_object(metadata)?.get("artifactDocument").cloned()
|
||||
}
|
||||
|
||||
fn extract_version_history(metadata: Option<&Value>) -> Vec<Value> {
|
||||
metadata_object(metadata)
|
||||
.and_then(|record| record.get("artifactVersions"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn extract_previous_version_id(
|
||||
metadata: Option<&Value>,
|
||||
current_version_no: Option<u32>,
|
||||
) -> Option<String> {
|
||||
let current_version_no = current_version_no?;
|
||||
let previous_version_no = current_version_no.checked_sub(1)?;
|
||||
|
||||
extract_version_history(metadata)
|
||||
.into_iter()
|
||||
.find_map(|value| {
|
||||
let record = value.as_object()?;
|
||||
let version_no = record.get("versionNo").and_then(Value::as_u64)?;
|
||||
if version_no != u64::from(previous_version_no) {
|
||||
return None;
|
||||
}
|
||||
record
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_version_diff(metadata: Option<&Value>) -> Option<Value> {
|
||||
metadata_object(metadata)?
|
||||
.get("artifactVersionDiff")
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn read_json_relative(workspace_root: &Path, relative_path: &str) -> Option<Value> {
|
||||
let normalized = relative_path.trim();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = workspace_root.join(normalized.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
let raw = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str::<Value>(&raw).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lime_core::database::dao::agent_timeline::{AgentThreadItemStatus, AgentThreadTurn};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn build_detail(item: AgentThreadItem) -> SessionDetail {
|
||||
SessionDetail {
|
||||
id: "session-1".to_string(),
|
||||
name: "测试会话".to_string(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
thread_id: "thread-1".to_string(),
|
||||
model: None,
|
||||
working_dir: None,
|
||||
workspace_id: Some("workspace-1".to_string()),
|
||||
messages: Vec::new(),
|
||||
execution_strategy: None,
|
||||
execution_runtime: None,
|
||||
turns: vec![AgentThreadTurn {
|
||||
id: "turn-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
prompt_text: "生成 artifact".to_string(),
|
||||
status: lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Completed,
|
||||
started_at: "2026-04-15T00:00:00Z".to_string(),
|
||||
completed_at: Some("2026-04-15T00:00:01Z".to_string()),
|
||||
error_message: None,
|
||||
created_at: "2026-04-15T00:00:00Z".to_string(),
|
||||
updated_at: "2026-04-15T00:00:01Z".to_string(),
|
||||
}],
|
||||
items: vec![item],
|
||||
todo_items: Vec::new(),
|
||||
child_subagent_sessions: Vec::new(),
|
||||
subagent_parent_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_item(metadata: Option<Value>) -> AgentThreadItem {
|
||||
AgentThreadItem {
|
||||
id: "artifact-document:req-1".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
sequence: 1,
|
||||
status: AgentThreadItemStatus::Completed,
|
||||
started_at: "2026-04-15T00:00:00Z".to_string(),
|
||||
completed_at: Some("2026-04-15T00:00:01Z".to_string()),
|
||||
updated_at: "2026-04-15T00:00:01Z".to_string(),
|
||||
payload: AgentThreadItemPayload::FileArtifact {
|
||||
path: ".lime/artifacts/thread-1/demo.artifact.json".to_string(),
|
||||
source: "artifact_document_service".to_string(),
|
||||
content: Some("# Demo".to_string()),
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_thread_summary_should_extract_latest_checkpoint_metadata() {
|
||||
let detail = build_detail(build_item(Some(serde_json::json!({
|
||||
"artifactVersionNo": 3,
|
||||
"artifactVersionId": "artifact-document:req-1:v3",
|
||||
"artifactRequestId": "req-1",
|
||||
"artifactTitle": "Demo",
|
||||
"artifactKind": "analysis",
|
||||
"artifactStatus": "ready",
|
||||
"previewText": "最新版本摘要",
|
||||
"artifactVersion": {
|
||||
"snapshotPath": ".lime/artifacts/thread-1/versions/demo/v0003.artifact.json"
|
||||
},
|
||||
"artifactValidationIssues": ["missing-source"]
|
||||
}))));
|
||||
|
||||
let summary = build_thread_file_checkpoint_summary(&detail).expect("summary should exist");
|
||||
|
||||
assert_eq!(summary.count, 1);
|
||||
let latest = summary.latest_checkpoint.expect("latest checkpoint");
|
||||
assert_eq!(latest.checkpoint_id, "artifact-document:req-1");
|
||||
assert_eq!(latest.version_no, Some(3));
|
||||
assert_eq!(
|
||||
latest.version_id.as_deref(),
|
||||
Some("artifact-document:req-1:v3")
|
||||
);
|
||||
assert_eq!(latest.request_id.as_deref(), Some("req-1"));
|
||||
assert_eq!(latest.preview_text.as_deref(), Some("最新版本摘要"));
|
||||
assert_eq!(latest.validation_issue_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_file_checkpoint_should_read_snapshot_and_live_documents() {
|
||||
let temp_dir = tempdir().expect("temp dir");
|
||||
let workspace_root = temp_dir.path();
|
||||
let live_path = workspace_root.join(".lime/artifacts/thread-1/demo.artifact.json");
|
||||
let snapshot_path =
|
||||
workspace_root.join(".lime/artifacts/thread-1/versions/demo/v0002.artifact.json");
|
||||
fs::create_dir_all(live_path.parent().expect("live parent")).expect("live dir");
|
||||
fs::create_dir_all(snapshot_path.parent().expect("snapshot parent")).expect("snapshot dir");
|
||||
fs::write(
|
||||
&live_path,
|
||||
serde_json::json!({
|
||||
"title": "当前版本",
|
||||
"summary": "current"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write live");
|
||||
fs::write(
|
||||
&snapshot_path,
|
||||
serde_json::json!({
|
||||
"title": "版本 2",
|
||||
"summary": "snapshot"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write snapshot");
|
||||
|
||||
let detail = build_detail(build_item(Some(serde_json::json!({
|
||||
"artifactVersionNo": 2,
|
||||
"artifactVersionId": "artifact-document:req-1:v2",
|
||||
"artifactVersion": {
|
||||
"id": "artifact-document:req-1:v2",
|
||||
"versionNo": 2,
|
||||
"snapshotPath": ".lime/artifacts/thread-1/versions/demo/v0002.artifact.json"
|
||||
},
|
||||
"artifactVersions": [
|
||||
{ "id": "artifact-document:req-1:v1", "versionNo": 1 },
|
||||
{ "id": "artifact-document:req-1:v2", "versionNo": 2 }
|
||||
],
|
||||
"artifactValidationIssues": ["warning-a"]
|
||||
}))));
|
||||
|
||||
let detail_result = get_file_checkpoint(&detail, workspace_root, "artifact-document:req-1")
|
||||
.expect("detail");
|
||||
|
||||
assert_eq!(
|
||||
detail_result.snapshot_path,
|
||||
".lime/artifacts/thread-1/versions/demo/v0002.artifact.json"
|
||||
);
|
||||
assert_eq!(detail_result.version_history.len(), 2);
|
||||
assert_eq!(
|
||||
detail_result.validation_issues,
|
||||
vec!["warning-a".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
detail_result
|
||||
.checkpoint_document
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("title"))
|
||||
.and_then(Value::as_str),
|
||||
Some("版本 2")
|
||||
);
|
||||
assert_eq!(
|
||||
detail_result
|
||||
.live_document
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("title"))
|
||||
.and_then(Value::as_str),
|
||||
Some("当前版本")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_file_checkpoint_should_return_metadata_diff_and_previous_version() {
|
||||
let detail = build_detail(build_item(Some(serde_json::json!({
|
||||
"artifactVersionNo": 4,
|
||||
"artifactVersionId": "artifact-document:req-1:v4",
|
||||
"artifactVersions": [
|
||||
{ "id": "artifact-document:req-1:v3", "versionNo": 3 },
|
||||
{ "id": "artifact-document:req-1:v4", "versionNo": 4 }
|
||||
],
|
||||
"artifactVersionDiff": {
|
||||
"summary": "更新结论段与证据链接"
|
||||
}
|
||||
}))));
|
||||
|
||||
let diff =
|
||||
diff_file_checkpoint(&detail, "artifact-document:req-1").expect("diff should exist");
|
||||
|
||||
assert_eq!(
|
||||
diff.previous_version_id.as_deref(),
|
||||
Some("artifact-document:req-1:v3")
|
||||
);
|
||||
assert_eq!(
|
||||
diff.diff
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("summary"))
|
||||
.and_then(Value::as_str),
|
||||
Some("更新结论段与证据链接")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -967,6 +967,7 @@ mod tests {
|
||||
interrupt_state: None,
|
||||
updated_at: Some("2026-03-27T10:01:00Z".to_string()),
|
||||
latest_compaction_boundary: None,
|
||||
file_checkpoint_summary: None,
|
||||
diagnostics: Some(crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
|
||||
latest_turn_status: Some("completed".to_string()),
|
||||
latest_turn_started_at: None,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
|
||||
use crate::services::runtime_evidence_pack_service::{
|
||||
export_runtime_evidence_pack, RuntimeEvidencePackExportResult,
|
||||
};
|
||||
use crate::services::runtime_file_checkpoint_service::list_file_checkpoints;
|
||||
use crate::services::runtime_handoff_artifact_service::{
|
||||
export_runtime_handoff_bundle, RuntimeHandoffBundleExportResult,
|
||||
};
|
||||
@@ -142,6 +143,7 @@ pub fn export_runtime_replay_case(
|
||||
.and_then(|turn| normalize_optional_text(Some(turn.prompt_text.clone())))
|
||||
});
|
||||
let recent_artifacts = collect_recent_artifact_paths(detail);
|
||||
let file_checkpoints = list_file_checkpoints(detail);
|
||||
let pending_requests = collect_pending_request_inputs(detail, thread_read);
|
||||
let recent_timeline = collect_recent_timeline_items(detail);
|
||||
let observability_summary = read_observability_summary_from_evidence_pack(&evidence_pack)?;
|
||||
@@ -171,6 +173,7 @@ pub fn export_runtime_replay_case(
|
||||
latest_plan.as_deref(),
|
||||
latest_turn_summary.as_deref(),
|
||||
&recent_artifacts,
|
||||
&file_checkpoints.checkpoints,
|
||||
&recent_timeline,
|
||||
&pending_requests,
|
||||
&observability_summary,
|
||||
@@ -310,6 +313,7 @@ fn build_input_json(
|
||||
latest_plan: Option<&str>,
|
||||
latest_turn_summary: Option<&str>,
|
||||
recent_artifacts: &[String],
|
||||
file_checkpoints: &[crate::commands::aster_agent_cmd::AgentRuntimeFileCheckpointSummary],
|
||||
recent_timeline: &[ReplayTimelineItem],
|
||||
pending_requests: &[ReplayPendingRequestInput],
|
||||
observability_summary: &Value,
|
||||
@@ -382,6 +386,8 @@ fn build_input_json(
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
"recentArtifacts": recent_artifacts,
|
||||
"fileCheckpointCount": file_checkpoints.len(),
|
||||
"fileCheckpoints": file_checkpoints,
|
||||
"recentTimeline": recent_timeline,
|
||||
"lastOutcome": &thread_read.last_outcome,
|
||||
"incidents": &thread_read.incidents,
|
||||
@@ -1182,6 +1188,7 @@ mod tests {
|
||||
interrupt_state: None,
|
||||
updated_at: Some("2026-03-27T10:02:00Z".to_string()),
|
||||
latest_compaction_boundary: None,
|
||||
file_checkpoint_summary: None,
|
||||
diagnostics: Some(crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
|
||||
latest_turn_status: Some("completed".to_string()),
|
||||
latest_turn_started_at: None,
|
||||
@@ -1272,6 +1279,10 @@ mod tests {
|
||||
));
|
||||
assert!(input.contains("\"requestId\": \"req-1\""));
|
||||
assert!(input.contains("\"recentArtifacts\""));
|
||||
assert!(input.contains("\"fileCheckpointCount\": 1"));
|
||||
assert!(input.contains("\"fileCheckpoints\""));
|
||||
assert!(input.contains("\"checkpoint_id\": \"artifact-1\""));
|
||||
assert!(input.contains("\"path\": \".lime/artifacts/thread-1/report.md\""));
|
||||
assert!(input.contains("\"classification\""));
|
||||
assert!(input.contains("\"suiteTags\""));
|
||||
assert!(input.contains("\"failureModes\""));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Runtime review decision 模板导出与保存服务
|
||||
//!
|
||||
//! 将外部 Claude Code / Codex 的分析结论回挂为
|
||||
//! 将外部代码助手 / Codex 的分析结论回挂为
|
||||
//! Lime 工作区内可版本化的人工审核与决策记录模板。
|
||||
//! 这条链只导出与保存 review-decision,不在 Lime 内自动批准或自动应用修复。
|
||||
|
||||
@@ -369,7 +369,7 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
|
||||
"# Lime 人工审核与决策记录\n\n\
|
||||
> 状态:`{decision_status}`\n\
|
||||
> 导出时间:`{exported_at}`\n\
|
||||
> 说明:这份模板用于把外部 Claude Code / Codex 的分析结论,回挂为 Lime 工作区内可版本化的人工审核记录;最终是否接受修复仍由开发者决定。\n\n\
|
||||
> 说明:这份模板用于把外部代码助手 / Codex 的分析结论,回挂为 Lime 工作区内可版本化的人工审核记录;最终是否接受修复仍由开发者决定。\n\n\
|
||||
## 1. 审核上下文\n\
|
||||
- 标题:{title}\n\
|
||||
- session_id:`{session_id}`\n\
|
||||
@@ -1170,6 +1170,7 @@ mod tests {
|
||||
interrupt_state: None,
|
||||
updated_at: Some("2026-03-27T10:01:20Z".to_string()),
|
||||
latest_compaction_boundary: None,
|
||||
file_checkpoint_summary: None,
|
||||
diagnostics: Some(AgentRuntimeThreadDiagnostics {
|
||||
latest_turn_status: Some("action_required".to_string()),
|
||||
latest_turn_started_at: None,
|
||||
|
||||
Reference in New Issue
Block a user