mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v0.91.0
This commit is contained in:
@@ -5,139 +5,16 @@ use lime_core::database::dao::agent_timeline::{
|
||||
AgentThreadTurnStatus, AgentTimelineDao,
|
||||
};
|
||||
use lime_core::database::{lock_db, DbConnection};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
const PROPOSED_PLAN_OPEN: &str = "<proposed_plan>";
|
||||
const PROPOSED_PLAN_CLOSE: &str = "</proposed_plan>";
|
||||
|
||||
fn format_runtime_status_text(title: &str, detail: &str, checkpoints: &[String]) -> String {
|
||||
let mut lines = Vec::new();
|
||||
let trimmed_title = title.trim();
|
||||
if !trimmed_title.is_empty() {
|
||||
lines.push(trimmed_title.to_string());
|
||||
}
|
||||
let trimmed_detail = detail.trim();
|
||||
if !trimmed_detail.is_empty() {
|
||||
lines.push(trimmed_detail.to_string());
|
||||
}
|
||||
for checkpoint in checkpoints {
|
||||
let trimmed = checkpoint.trim();
|
||||
if !trimmed.is_empty() {
|
||||
lines.push(format!("• {trimmed}"));
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn emit_event(app: &AppHandle, event_name: &str, event: &TauriAgentEvent) {
|
||||
if let Err(error) = app.emit(event_name, event) {
|
||||
tracing::error!("[AgentTimeline] 发送事件失败: {}", error);
|
||||
}
|
||||
}
|
||||
|
||||
fn as_object(value: &Value) -> Option<&serde_json::Map<String, Value>> {
|
||||
value.as_object()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ExtractedFileArtifact {
|
||||
path: String,
|
||||
artifact_id: Option<String>,
|
||||
}
|
||||
|
||||
fn push_unique_file_path(target: &mut Vec<String>, raw: &str) {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() || target.iter().any(|item| item == trimmed) {
|
||||
return;
|
||||
}
|
||||
target.push(trimmed.to_string());
|
||||
}
|
||||
|
||||
fn collect_string_values(value: &Value) -> Vec<String> {
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![trimmed.to_string()]
|
||||
}
|
||||
}
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_file_artifacts(
|
||||
arguments: Option<&Value>,
|
||||
metadata: Option<&Value>,
|
||||
) -> Vec<ExtractedFileArtifact> {
|
||||
let mut paths = Vec::new();
|
||||
for source in [arguments, metadata] {
|
||||
let Some(object) = source.and_then(as_object) else {
|
||||
continue;
|
||||
};
|
||||
for key in [
|
||||
"path",
|
||||
"file_path",
|
||||
"filePath",
|
||||
"output_file",
|
||||
"output_path",
|
||||
"outputPath",
|
||||
"artifact_path",
|
||||
"artifact_paths",
|
||||
"absolute_path",
|
||||
"absolutePath",
|
||||
] {
|
||||
let Some(value) = object.get(key) else {
|
||||
continue;
|
||||
};
|
||||
for path in collect_string_values(value) {
|
||||
push_unique_file_path(&mut paths, path.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let metadata_object = metadata.and_then(as_object);
|
||||
let artifact_ids = metadata_object
|
||||
.and_then(|object| object.get("artifact_ids"))
|
||||
.map(collect_string_values)
|
||||
.unwrap_or_default();
|
||||
let single_artifact_id = metadata_object
|
||||
.and_then(|object| {
|
||||
object
|
||||
.get("artifact_id")
|
||||
.or_else(|| object.get("artifactId"))
|
||||
})
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
paths
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, path)| ExtractedFileArtifact {
|
||||
path,
|
||||
artifact_id: artifact_ids.get(index).cloned().or_else(|| {
|
||||
if index == 0 {
|
||||
single_artifact_id.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve_artifact_item_status(metadata: Option<&Value>) -> AgentThreadItemStatus {
|
||||
let write_phase = metadata
|
||||
.and_then(|value| value.get("writePhase"))
|
||||
@@ -165,18 +42,6 @@ fn resolve_artifact_item_source(metadata: Option<&Value>) -> String {
|
||||
.unwrap_or_else(|| "artifact_snapshot".to_string())
|
||||
}
|
||||
|
||||
fn extract_proposed_plan_block(text: &str) -> Option<String> {
|
||||
let start = text.find(PROPOSED_PLAN_OPEN)?;
|
||||
let remainder = &text[start + PROPOSED_PLAN_OPEN.len()..];
|
||||
let end = remainder.find(PROPOSED_PLAN_CLOSE)?;
|
||||
let content = remainder[..end].trim();
|
||||
if content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(content.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AgentTimelineRecorder {
|
||||
db: DbConnection,
|
||||
@@ -187,7 +52,6 @@ pub struct AgentTimelineRecorder {
|
||||
item_sequences: HashMap<String, i64>,
|
||||
item_statuses: HashMap<String, AgentThreadItemStatus>,
|
||||
plan_text: Option<String>,
|
||||
turn_summary_text: Option<String>,
|
||||
}
|
||||
|
||||
impl AgentTimelineRecorder {
|
||||
@@ -228,7 +92,6 @@ impl AgentTimelineRecorder {
|
||||
item_sequences: HashMap::new(),
|
||||
item_statuses: HashMap::new(),
|
||||
plan_text: None,
|
||||
turn_summary_text: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -265,7 +128,6 @@ impl AgentTimelineRecorder {
|
||||
item.clone(),
|
||||
TauriAgentEvent::ItemStarted { item: item.clone() },
|
||||
)?;
|
||||
self.maybe_project_plan_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::ItemUpdated { item } => {
|
||||
self.persist_runtime_item(
|
||||
@@ -274,7 +136,6 @@ impl AgentTimelineRecorder {
|
||||
item.clone(),
|
||||
TauriAgentEvent::ItemUpdated { item: item.clone() },
|
||||
)?;
|
||||
self.maybe_project_plan_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::ItemCompleted { item } => {
|
||||
self.persist_runtime_item(
|
||||
@@ -283,52 +144,9 @@ impl AgentTimelineRecorder {
|
||||
item.clone(),
|
||||
TauriAgentEvent::ItemCompleted { item: item.clone() },
|
||||
)?;
|
||||
self.maybe_project_plan_item(app, event_name, item)?;
|
||||
}
|
||||
TauriAgentEvent::RuntimeStatus { status } => {
|
||||
let text =
|
||||
format_runtime_status_text(&status.title, &status.detail, &status.checkpoints);
|
||||
if !text.is_empty() {
|
||||
self.turn_summary_text = Some(text.clone());
|
||||
let item = self.build_item(
|
||||
format!("turn_summary:{}", self.turn_id),
|
||||
AgentThreadItemStatus::InProgress,
|
||||
None,
|
||||
AgentThreadItemPayload::TurnSummary { text },
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::ToolEnd { tool_id, result } => {
|
||||
let metadata_value = result
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| serde_json::to_value(metadata).ok());
|
||||
|
||||
for artifact in extract_file_artifacts(None, metadata_value.as_ref()) {
|
||||
let artifact_path = artifact.path.clone();
|
||||
let status = resolve_artifact_item_status(metadata_value.as_ref());
|
||||
let file_item = self.build_item(
|
||||
artifact
|
||||
.artifact_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("artifact:{}:{}", tool_id, artifact_path)),
|
||||
status.clone(),
|
||||
if matches!(status, AgentThreadItemStatus::InProgress) {
|
||||
None
|
||||
} else {
|
||||
Some(Utc::now().to_rfc3339())
|
||||
},
|
||||
AgentThreadItemPayload::FileArtifact {
|
||||
path: artifact_path,
|
||||
source: "tool_result".to_string(),
|
||||
content: None,
|
||||
metadata: metadata_value.clone(),
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, file_item)?;
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::RuntimeStatus { .. } => {}
|
||||
TauriAgentEvent::ToolEnd { .. } => {}
|
||||
TauriAgentEvent::ArtifactSnapshot { artifact } => {
|
||||
let metadata_value = artifact
|
||||
.metadata
|
||||
@@ -480,18 +298,6 @@ impl AgentTimelineRecorder {
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
|
||||
if let Some(turn_summary_text) = self.turn_summary_text.clone() {
|
||||
let item = self.build_item(
|
||||
format!("turn_summary:{}", self.turn_id),
|
||||
status,
|
||||
Some(Utc::now().to_rfc3339()),
|
||||
AgentThreadItemPayload::TurnSummary {
|
||||
text: turn_summary_text,
|
||||
},
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, item)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -592,98 +398,8 @@ impl AgentTimelineRecorder {
|
||||
self.item_statuses
|
||||
.insert(item.id.clone(), item.status.clone());
|
||||
|
||||
if let AgentThreadItemPayload::AgentMessage { text, .. } = &item.payload {
|
||||
self.plan_text = extract_proposed_plan_block(text);
|
||||
if let AgentThreadItemPayload::Plan { text } = &item.payload {
|
||||
self.plan_text = Some(text.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_project_plan_item(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
item: &AgentThreadItem,
|
||||
) -> Result<(), String> {
|
||||
let AgentThreadItemPayload::AgentMessage { text, .. } = &item.payload else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(plan_text) = extract_proposed_plan_block(text) else {
|
||||
return Ok(());
|
||||
};
|
||||
self.plan_text = Some(plan_text.clone());
|
||||
let plan_item = self.build_item(
|
||||
format!("plan:{}", self.turn_id),
|
||||
item.status.clone(),
|
||||
item.completed_at.clone(),
|
||||
AgentThreadItemPayload::Plan { text: plan_text },
|
||||
);
|
||||
self.persist_and_emit_item(app, event_name, plan_item)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn complete_action_item(
|
||||
db: &DbConnection,
|
||||
request_id: &str,
|
||||
response: Option<Value>,
|
||||
) -> Result<(), String> {
|
||||
let conn = lock_db(db)?;
|
||||
let Some(mut item) = AgentTimelineDao::get_item(&conn, request_id)
|
||||
.map_err(|e| format!("读取 action item 失败: {e}"))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let payload = match item.payload {
|
||||
AgentThreadItemPayload::ApprovalRequest {
|
||||
request_id,
|
||||
action_type,
|
||||
prompt,
|
||||
tool_name,
|
||||
arguments,
|
||||
..
|
||||
} => AgentThreadItemPayload::ApprovalRequest {
|
||||
request_id,
|
||||
action_type,
|
||||
prompt,
|
||||
tool_name,
|
||||
arguments,
|
||||
response,
|
||||
},
|
||||
AgentThreadItemPayload::RequestUserInput {
|
||||
request_id,
|
||||
action_type,
|
||||
prompt,
|
||||
questions,
|
||||
..
|
||||
} => AgentThreadItemPayload::RequestUserInput {
|
||||
request_id,
|
||||
action_type,
|
||||
prompt,
|
||||
questions,
|
||||
response,
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
item.status = AgentThreadItemStatus::Completed;
|
||||
item.completed_at = Some(now.clone());
|
||||
item.updated_at = now;
|
||||
item.payload = payload;
|
||||
|
||||
AgentTimelineDao::upsert_item(&conn, &item).map_err(|e| format!("更新 action item 失败: {e}"))
|
||||
}
|
||||
|
||||
pub fn build_action_response_value(
|
||||
confirmed: bool,
|
||||
response: Option<&str>,
|
||||
user_data: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
if let Some(value) = user_data {
|
||||
return Some(value.clone());
|
||||
}
|
||||
if !confirmed {
|
||||
return Some(json!({ "confirmed": false }));
|
||||
}
|
||||
response.map(|value| Value::String(value.to_string()))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch};
|
||||
use crate::database::load_pending_general_messages;
|
||||
use chrono::{Local, TimeZone};
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashSet;
|
||||
@@ -24,15 +23,6 @@ pub fn load_memory_source_candidates(
|
||||
let mut candidates = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
load_pending_general_candidates(
|
||||
conn,
|
||||
from_timestamp,
|
||||
to_timestamp,
|
||||
limit,
|
||||
min_message_length,
|
||||
&mut candidates,
|
||||
&mut seen,
|
||||
)?;
|
||||
load_unified_general_candidates(
|
||||
conn,
|
||||
from_timestamp,
|
||||
@@ -58,33 +48,6 @@ pub fn load_memory_source_candidates(
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
fn load_pending_general_candidates(
|
||||
conn: &Connection,
|
||||
from_timestamp: Option<i64>,
|
||||
to_timestamp: Option<i64>,
|
||||
limit: usize,
|
||||
min_message_length: usize,
|
||||
candidates: &mut Vec<MemorySourceCandidate>,
|
||||
seen: &mut HashSet<String>,
|
||||
) -> Result<(), String> {
|
||||
let rows = load_pending_general_messages(conn, from_timestamp, to_timestamp, limit)
|
||||
.map_err(|e| format!("读取待迁移 general 消息失败: {e}"))?;
|
||||
|
||||
for row in rows {
|
||||
push_candidate(
|
||||
candidates,
|
||||
seen,
|
||||
row.session_id,
|
||||
row.role,
|
||||
row.content,
|
||||
normalize_timestamp(row.created_at),
|
||||
min_message_length,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_unified_general_candidates(
|
||||
conn: &Connection,
|
||||
from_timestamp: Option<i64>,
|
||||
@@ -274,7 +237,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_memory_source_candidates_merges_unified_and_legacy_without_duplicates() {
|
||||
fn load_memory_source_candidates_only_reads_unified_general_and_agent_messages() {
|
||||
let conn = Connection::open_in_memory().expect("open in memory db");
|
||||
create_test_schema(&conn);
|
||||
|
||||
@@ -299,28 +262,6 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO general_chat_sessions (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["general-migrated", "旧会话", 1_741_744_000_000i64, 1_741_744_000_000i64],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO general_chat_sessions (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["legacy-only", "旧会话2", 1_741_744_100_000i64, 1_741_744_100_000i64],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO general_chat_messages (id, session_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params!["g1", "general-migrated", "user", "这条消息已经迁移", 1_741_744_000_000i64],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO general_chat_messages (id, session_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params!["g2", "legacy-only", "assistant", "这条消息仍在旧表中", 1_741_744_100_000i64],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
@@ -349,23 +290,16 @@ mod tests {
|
||||
.iter()
|
||||
.map(|item| item.session_id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(candidates.len(), 3);
|
||||
assert_eq!(candidates.len(), 2);
|
||||
assert!(session_ids.contains(&"general-migrated"));
|
||||
assert!(session_ids.contains(&"legacy-only"));
|
||||
assert!(session_ids.contains(&"agent-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_memory_source_candidates_skips_legacy_general_after_migration_completed() {
|
||||
fn load_memory_source_candidates_ignores_pending_general_tables() {
|
||||
let conn = Connection::open_in_memory().expect("open in memory db");
|
||||
create_test_schema(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["migrated_general_chat_to_unified", "true"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO agent_sessions (id, model, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch};
|
||||
use crate::database::dao::orchestrator::OrchestratorDao;
|
||||
use crate::database::{summarize_pending_general, ConversationWindowSummary};
|
||||
use crate::database::ConversationWindowSummary;
|
||||
use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike};
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -242,16 +242,12 @@ fn summarize_general_window(
|
||||
from_timestamp_ms: Option<i64>,
|
||||
to_timestamp_ms: Option<i64>,
|
||||
) -> Result<ConversationWindowSummary, String> {
|
||||
let unified = summarize_unified_window(
|
||||
summarize_unified_window(
|
||||
conn,
|
||||
AgentModelPatternMatch::Like,
|
||||
from_timestamp_ms,
|
||||
to_timestamp_ms,
|
||||
)?;
|
||||
let pending = summarize_pending_general(conn, from_timestamp_ms, to_timestamp_ms)
|
||||
.map_err(|e| format!("查询待迁移 general 摘要失败: {e}"))?;
|
||||
|
||||
Ok(unified.merge(pending))
|
||||
)
|
||||
}
|
||||
|
||||
fn summarize_agent_window(
|
||||
@@ -298,6 +294,7 @@ fn build_conversation_stats(windows: ConversationWindowTriplet) -> ConversationS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn query_general_chat_stats(
|
||||
conn: &Connection,
|
||||
today_start: &DateTime<Local>,
|
||||
@@ -307,6 +304,7 @@ fn query_general_chat_stats(
|
||||
.map(build_conversation_stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn query_agent_chat_stats(
|
||||
conn: &Connection,
|
||||
today_start: &DateTime<Local>,
|
||||
@@ -641,7 +639,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_ignore_legacy_general_after_migration_completed() {
|
||||
fn stats_ignore_legacy_general_tables_during_runtime() {
|
||||
let conn = Connection::open_in_memory().expect("open in memory db");
|
||||
create_test_schema(&conn);
|
||||
|
||||
@@ -651,12 +649,6 @@ mod tests {
|
||||
.expect("build datetime");
|
||||
let now_ms = now.timestamp_millis();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["migrated_general_chat_to_unified", "true"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO general_chat_sessions (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["legacy-only", "旧通用会话", now_ms, now_ms],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 记忆画像提示词服务
|
||||
//! 记忆提示词装配服务
|
||||
//!
|
||||
//! 将设置页中的记忆画像(学习状态、擅长领域、解释偏好、难题偏好)
|
||||
//! 转换为可注入到系统提示词中的统一指令片段。
|
||||
//! 将设置页中的记忆画像与配置化记忆来源统一装配为可注入到 system prompt
|
||||
//! 的单一记忆指令片段,避免调用方继续各自决定拼装顺序。
|
||||
|
||||
use lime_core::config::Config;
|
||||
use std::path::Path;
|
||||
@@ -11,6 +11,26 @@ use crate::services::memory_source_resolver_service::build_memory_sources_prompt
|
||||
const MEMORY_PROFILE_PROMPT_MARKER: &str = "【用户记忆画像偏好】";
|
||||
const MEMORY_SOURCE_PROMPT_MARKER: &str = "【记忆来源补充指令】";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct MemoryPromptContext<'a> {
|
||||
pub working_dir: Option<&'a Path>,
|
||||
pub active_relative_path: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> MemoryPromptContext<'a> {
|
||||
pub fn with_working_dir(working_dir: &'a Path) -> Self {
|
||||
Self {
|
||||
working_dir: Some(working_dir),
|
||||
active_relative_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_active_relative_path(mut self, active_relative_path: Option<&'a str>) -> Self {
|
||||
self.active_relative_path = active_relative_path;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_text(input: &str) -> Option<String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -32,7 +52,7 @@ fn normalize_list(items: &[String]) -> Vec<String> {
|
||||
/// 仅在以下条件满足时返回:
|
||||
/// - 记忆功能已启用
|
||||
/// - 至少有一项画像字段有值
|
||||
pub fn build_memory_profile_prompt(config: &Config) -> Option<String> {
|
||||
fn build_memory_profile_prompt(config: &Config) -> Option<String> {
|
||||
let memory = &config.memory;
|
||||
if !memory.enabled {
|
||||
return None;
|
||||
@@ -83,59 +103,73 @@ pub fn build_memory_profile_prompt(config: &Config) -> Option<String> {
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// 合并基础系统提示词与记忆画像提示词
|
||||
///
|
||||
/// - 已包含画像标记时不会重复追加
|
||||
/// - 任一方为空时返回另一方
|
||||
pub fn merge_system_prompt_with_memory_profile(
|
||||
base_prompt: Option<String>,
|
||||
fn build_memory_sources_prompt_for_context(
|
||||
config: &Config,
|
||||
context: MemoryPromptContext<'_>,
|
||||
) -> Option<String> {
|
||||
let memory_prompt = build_memory_profile_prompt(config);
|
||||
let working_dir = context.working_dir?;
|
||||
if !config.memory.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
match (base_prompt, memory_prompt) {
|
||||
(Some(base), Some(memory)) => {
|
||||
if base.contains(MEMORY_PROFILE_PROMPT_MARKER) {
|
||||
build_memory_sources_prompt(config, working_dir, context.active_relative_path, 4000)
|
||||
}
|
||||
|
||||
fn merge_prompt_section(
|
||||
base_prompt: Option<String>,
|
||||
section_prompt: Option<String>,
|
||||
marker: &str,
|
||||
) -> Option<String> {
|
||||
match (base_prompt, section_prompt) {
|
||||
(Some(base), Some(section)) => {
|
||||
if base.contains(marker) {
|
||||
Some(base)
|
||||
} else if base.trim().is_empty() {
|
||||
Some(memory)
|
||||
Some(section)
|
||||
} else {
|
||||
Some(format!("{base}\n\n{memory}"))
|
||||
Some(format!("{base}\n\n{section}"))
|
||||
}
|
||||
}
|
||||
(Some(base), None) => Some(base),
|
||||
(None, Some(memory)) => Some(memory),
|
||||
(None, Some(section)) => Some(section),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_system_prompt_with_memory_sources(
|
||||
pub fn build_memory_prompt(config: &Config, context: MemoryPromptContext<'_>) -> Option<String> {
|
||||
let with_profile = merge_prompt_section(
|
||||
None,
|
||||
build_memory_profile_prompt(config),
|
||||
MEMORY_PROFILE_PROMPT_MARKER,
|
||||
);
|
||||
|
||||
merge_prompt_section(
|
||||
with_profile,
|
||||
build_memory_sources_prompt_for_context(config, context),
|
||||
MEMORY_SOURCE_PROMPT_MARKER,
|
||||
)
|
||||
}
|
||||
|
||||
/// 合并基础系统提示词与统一记忆提示词。
|
||||
///
|
||||
/// - 画像与来源统一在同一边界内拼装
|
||||
/// - 已包含对应 marker 时不会重复追加
|
||||
pub fn merge_system_prompt_with_memory_context(
|
||||
base_prompt: Option<String>,
|
||||
config: &Config,
|
||||
working_dir: &Path,
|
||||
active_relative_path: Option<&str>,
|
||||
context: MemoryPromptContext<'_>,
|
||||
) -> Option<String> {
|
||||
if !config.memory.enabled {
|
||||
return base_prompt;
|
||||
}
|
||||
let with_profile = merge_prompt_section(
|
||||
base_prompt,
|
||||
build_memory_profile_prompt(config),
|
||||
MEMORY_PROFILE_PROMPT_MARKER,
|
||||
);
|
||||
|
||||
let memory_sources_prompt =
|
||||
build_memory_sources_prompt(config, working_dir, active_relative_path, 4000);
|
||||
|
||||
match (base_prompt, memory_sources_prompt) {
|
||||
(Some(base), Some(source_prompt)) => {
|
||||
if base.contains(MEMORY_SOURCE_PROMPT_MARKER) {
|
||||
Some(base)
|
||||
} else if base.trim().is_empty() {
|
||||
Some(source_prompt)
|
||||
} else {
|
||||
Some(format!("{base}\n\n{source_prompt}"))
|
||||
}
|
||||
}
|
||||
(Some(base), None) => Some(base),
|
||||
(None, Some(source_prompt)) => Some(source_prompt),
|
||||
(None, None) => None,
|
||||
}
|
||||
merge_prompt_section(
|
||||
with_profile,
|
||||
build_memory_sources_prompt_for_context(config, context),
|
||||
MEMORY_SOURCE_PROMPT_MARKER,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -192,7 +226,11 @@ mod tests {
|
||||
config.memory.profile = Some(profile);
|
||||
|
||||
let base = Some("前置内容\n\n【用户记忆画像偏好】\n已有内容".to_string());
|
||||
let merged = merge_system_prompt_with_memory_profile(base.clone(), &config);
|
||||
let merged = merge_system_prompt_with_memory_context(
|
||||
base.clone(),
|
||||
&config,
|
||||
MemoryPromptContext::default(),
|
||||
);
|
||||
assert_eq!(merged, base);
|
||||
}
|
||||
|
||||
@@ -210,10 +248,40 @@ mod tests {
|
||||
config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
|
||||
config.memory.sources.project_rule_dirs = Vec::new();
|
||||
|
||||
let merged = merge_system_prompt_with_memory_sources(None, &config, tmp.path(), None)
|
||||
.expect("should build sources prompt");
|
||||
let merged = merge_system_prompt_with_memory_context(
|
||||
None,
|
||||
&config,
|
||||
MemoryPromptContext::with_working_dir(tmp.path()),
|
||||
)
|
||||
.expect("should build sources prompt");
|
||||
|
||||
assert!(merged.contains("【记忆来源补充指令】"));
|
||||
assert!(merged.contains("偏好简洁输出"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_combined_memory_prompt() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "# 项目记忆\n- 保持简洁")
|
||||
.expect("write memory file");
|
||||
|
||||
let mut config = Config::default();
|
||||
config.memory.enabled = true;
|
||||
let mut profile = config.memory.profile.clone().unwrap_or_default();
|
||||
profile.current_status = Some("高级开发者".to_string());
|
||||
config.memory.profile = Some(profile);
|
||||
config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
|
||||
config.memory.sources.project_rule_dirs = Vec::new();
|
||||
config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
|
||||
config.memory.sources.user_memory_path = Some("missing-user.md".to_string());
|
||||
|
||||
let prompt =
|
||||
build_memory_prompt(&config, MemoryPromptContext::with_working_dir(tmp.path()))
|
||||
.expect("should build combined prompt");
|
||||
|
||||
assert!(prompt.contains("【用户记忆画像偏好】"));
|
||||
assert!(prompt.contains("高级开发者"));
|
||||
assert!(prompt.contains("【记忆来源补充指令】"));
|
||||
assert!(prompt.contains("保持简洁"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,12 @@ pub struct EnvironmentDiagnostics {
|
||||
pub supplemental_search_dirs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub supplemental_command_candidates: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub git_where_candidates: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub git_supplemental_search_dirs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub git_supplemental_command_candidates: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -4089,23 +4095,30 @@ fn find_command_in_bin_dir(command_name: &str, bin_dir: &Path) -> Option<PathBuf
|
||||
))
|
||||
}
|
||||
|
||||
async fn collect_preferred_runtime_command_dirs(
|
||||
command_name: &str,
|
||||
preferred_bin_dir: &Path,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
fn collect_existing_unique_dirs<I>(candidates: I) -> Vec<PathBuf>
|
||||
where
|
||||
I: IntoIterator<Item = PathBuf>,
|
||||
{
|
||||
let mut dirs = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
let mut push_dir = |dir: PathBuf| {
|
||||
for dir in candidates {
|
||||
if dir.as_os_str().is_empty() || !dir.exists() {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
if seen.insert(dir.clone()) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
push_dir(preferred_bin_dir.to_path_buf());
|
||||
dirs
|
||||
}
|
||||
|
||||
async fn collect_preferred_runtime_command_dirs(
|
||||
command_name: &str,
|
||||
preferred_bin_dir: &Path,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
let mut candidate_dirs = vec![preferred_bin_dir.to_path_buf()];
|
||||
|
||||
if command_name == "openclaw" {
|
||||
if let Some(npm_path) = find_command_in_bin_dir("npm", preferred_bin_dir)
|
||||
@@ -4113,13 +4126,13 @@ async fn collect_preferred_runtime_command_dirs(
|
||||
{
|
||||
if let Some(prefix) = detect_npm_global_prefix(&npm_path).await {
|
||||
for dir in npm_global_command_dirs(&prefix) {
|
||||
push_dir(dir);
|
||||
candidate_dirs.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(dirs)
|
||||
Ok(collect_existing_unique_dirs(candidate_dirs))
|
||||
}
|
||||
|
||||
async fn collect_preferred_runtime_command_candidates(
|
||||
@@ -4252,9 +4265,17 @@ async fn select_command_candidate(
|
||||
return select_node_runtime_candidate(candidates).await;
|
||||
}
|
||||
|
||||
if command_name == "git" {
|
||||
return Ok(select_best_git_candidate(candidates));
|
||||
}
|
||||
|
||||
Ok(candidates.into_iter().next())
|
||||
}
|
||||
|
||||
fn select_best_git_candidate(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
select_preferred_path_candidate(candidates.clone()).or_else(|| candidates.into_iter().next())
|
||||
}
|
||||
|
||||
async fn select_preferred_runtime_candidate(
|
||||
command_name: &str,
|
||||
candidates: &[PathBuf],
|
||||
@@ -4287,68 +4308,61 @@ async fn select_preferred_runtime_candidate(
|
||||
}
|
||||
|
||||
fn find_all_commands_in_known_locations(command_name: &str) -> Vec<PathBuf> {
|
||||
let search_dirs = collect_known_command_search_dirs();
|
||||
let search_dirs = collect_known_command_search_dirs(command_name);
|
||||
find_all_commands_in_paths(command_name, &search_dirs)
|
||||
}
|
||||
|
||||
fn collect_known_command_search_dirs() -> Vec<PathBuf> {
|
||||
fn collect_known_command_search_dirs(_command_name: &str) -> Vec<PathBuf> {
|
||||
let mut search_dirs = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
let mut push_dir = |dir: PathBuf| {
|
||||
if dir.as_os_str().is_empty() || !dir.exists() {
|
||||
return;
|
||||
}
|
||||
if seen.insert(dir.clone()) {
|
||||
search_dirs.push(dir);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(path_var) = std::env::var_os("PATH") {
|
||||
for dir in std::env::split_paths(&path_var) {
|
||||
push_dir(dir);
|
||||
}
|
||||
search_dirs.extend(std::env::split_paths(&path_var));
|
||||
}
|
||||
|
||||
if let Some(home) = home_dir() {
|
||||
push_dir(home.join(".npm-global/bin"));
|
||||
push_dir(home.join(".local/bin"));
|
||||
push_dir(home.join(".bun/bin"));
|
||||
push_dir(home.join(".volta/bin"));
|
||||
push_dir(home.join(".asdf/shims"));
|
||||
push_dir(home.join(".local/share/mise/shims"));
|
||||
push_dir(home.join("Library/PhpWebStudy/env/node/bin"));
|
||||
search_dirs.extend([
|
||||
home.join(".npm-global/bin"),
|
||||
home.join(".local/bin"),
|
||||
home.join(".bun/bin"),
|
||||
home.join(".volta/bin"),
|
||||
home.join(".asdf/shims"),
|
||||
home.join(".local/share/mise/shims"),
|
||||
home.join("Library/PhpWebStudy/env/node/bin"),
|
||||
]);
|
||||
|
||||
let nvm_versions = home.join(".nvm/versions/node");
|
||||
if let Ok(entries) = std::fs::read_dir(nvm_versions) {
|
||||
for entry in entries.flatten() {
|
||||
push_dir(entry.path().join("bin"));
|
||||
search_dirs.push(entry.path().join("bin"));
|
||||
}
|
||||
}
|
||||
|
||||
let fnm_versions = home.join(".fnm/node-versions");
|
||||
if let Ok(entries) = std::fs::read_dir(fnm_versions) {
|
||||
for entry in entries.flatten() {
|
||||
push_dir(entry.path().join("installation/bin"));
|
||||
search_dirs.push(entry.path().join("installation/bin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
for dir in windows_known_command_dirs_from_env() {
|
||||
push_dir(dir);
|
||||
search_dirs.extend(windows_known_command_dirs_from_env());
|
||||
if _command_name == "git" {
|
||||
search_dirs.extend(windows_known_git_command_dirs_from_env());
|
||||
}
|
||||
}
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
push_dir(PathBuf::from("/opt/homebrew/bin"));
|
||||
push_dir(PathBuf::from("/usr/local/bin"));
|
||||
push_dir(PathBuf::from("/usr/bin"));
|
||||
push_dir(PathBuf::from("/bin"));
|
||||
search_dirs.extend([
|
||||
PathBuf::from("/opt/homebrew/bin"),
|
||||
PathBuf::from("/usr/local/bin"),
|
||||
PathBuf::from("/usr/bin"),
|
||||
PathBuf::from("/bin"),
|
||||
]);
|
||||
}
|
||||
|
||||
search_dirs
|
||||
collect_existing_unique_dirs(search_dirs)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -4386,17 +4400,64 @@ fn windows_known_command_dirs_from_env() -> Vec<PathBuf> {
|
||||
dirs
|
||||
}
|
||||
|
||||
fn find_all_commands_in_paths(command_name: &str, search_dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let candidates = [
|
||||
format!("{command_name}.exe"),
|
||||
format!("{command_name}.cmd"),
|
||||
format!("{command_name}.bat"),
|
||||
command_name.to_string(),
|
||||
];
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_git_install_dir_variants(root: PathBuf) -> Vec<PathBuf> {
|
||||
vec![
|
||||
root.join("cmd"),
|
||||
root.join("bin"),
|
||||
root.join("mingw64").join("bin"),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let candidates = [command_name.to_string()];
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_known_git_command_dirs_from_env() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
if let Some(program_files) = std::env::var_os("ProgramFiles") {
|
||||
dirs.extend(windows_git_install_dir_variants(
|
||||
PathBuf::from(program_files).join("Git"),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") {
|
||||
dirs.extend(windows_git_install_dir_variants(
|
||||
PathBuf::from(program_files_x86).join("Git"),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") {
|
||||
dirs.extend(windows_git_install_dir_variants(
|
||||
PathBuf::from(localappdata).join("Programs").join("Git"),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(home) = home_dir() {
|
||||
dirs.extend(windows_git_install_dir_variants(
|
||||
home.join("scoop").join("apps").join("git").join("current"),
|
||||
));
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
fn find_all_commands_in_paths(command_name: &str, search_dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
find_all_commands_in_paths_for(current_shell_platform(), command_name, search_dirs)
|
||||
}
|
||||
|
||||
fn find_all_commands_in_paths_for(
|
||||
platform: ShellPlatform,
|
||||
command_name: &str,
|
||||
search_dirs: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
let candidates = match platform {
|
||||
ShellPlatform::Windows => vec![
|
||||
format!("{command_name}.exe"),
|
||||
format!("{command_name}.cmd"),
|
||||
format!("{command_name}.bat"),
|
||||
command_name.to_string(),
|
||||
],
|
||||
ShellPlatform::Unix => vec![command_name.to_string()],
|
||||
};
|
||||
|
||||
let mut matches = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -4844,6 +4905,24 @@ async fn collect_environment_diagnostics() -> EnvironmentDiagnostics {
|
||||
.and_then(find_installed_openclaw_package_details)
|
||||
.map(|package| package.path.display().to_string());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let git_where_candidates = find_commands_via_where("git")
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect();
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let git_where_candidates = Vec::new();
|
||||
|
||||
let git_supplemental_search_dirs = collect_supplemental_git_search_dirs();
|
||||
let git_supplemental_command_candidates =
|
||||
find_all_commands_in_paths("git", &git_supplemental_search_dirs)
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect();
|
||||
|
||||
EnvironmentDiagnostics {
|
||||
npm_path,
|
||||
npm_global_prefix,
|
||||
@@ -4854,36 +4933,40 @@ async fn collect_environment_diagnostics() -> EnvironmentDiagnostics {
|
||||
.map(|path| path.display().to_string())
|
||||
.collect(),
|
||||
supplemental_command_candidates,
|
||||
git_where_candidates,
|
||||
git_supplemental_search_dirs: git_supplemental_search_dirs
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect(),
|
||||
git_supplemental_command_candidates,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_supplemental_openclaw_search_dirs(npm_global_prefix: Option<&str>) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
let mut push_dir = |dir: PathBuf| {
|
||||
if dir.as_os_str().is_empty() || !dir.exists() {
|
||||
return;
|
||||
}
|
||||
if seen.insert(dir.clone()) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
for dir in windows_known_command_dirs_from_env() {
|
||||
push_dir(dir);
|
||||
}
|
||||
dirs.extend(windows_known_command_dirs_from_env());
|
||||
}
|
||||
|
||||
if let Some(prefix) = npm_global_prefix {
|
||||
for dir in npm_global_command_dirs(prefix) {
|
||||
push_dir(dir);
|
||||
}
|
||||
dirs.extend(npm_global_command_dirs(prefix));
|
||||
}
|
||||
|
||||
dirs
|
||||
collect_existing_unique_dirs(dirs)
|
||||
}
|
||||
|
||||
fn collect_supplemental_git_search_dirs() -> Vec<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return collect_existing_unique_dirs(windows_known_git_command_dirs_from_env());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
async fn select_best_node_candidate(candidates: Vec<PathBuf>) -> Result<Option<PathBuf>, String> {
|
||||
@@ -5263,15 +5346,15 @@ mod tests {
|
||||
npm_global_node_modules_dirs_for, package_registry_for_package_spec,
|
||||
parse_semver_from_text, resolve_openclaw_cli_entry_from_package_manifest,
|
||||
resolve_openclaw_command_from_runtime_candidate, resolve_windows_dependency_install_plan,
|
||||
runtime_candidate_matches_install_root, sanitize_runtime_config,
|
||||
runtime_candidate_matches_install_root, sanitize_runtime_config, select_best_git_candidate,
|
||||
select_best_semver_candidate, select_gateway_start_failure_detail,
|
||||
select_openclaw_update_failure_detail, select_preferred_path_candidate,
|
||||
shell_command_escape_for, shell_npm_prefix_assignment_for, shell_path_assignment_for,
|
||||
trim_trailing_slash, windows_dependency_action_result, windows_dependency_setup_message,
|
||||
windows_install_block_result, windows_manual_install_message, DependencyKind,
|
||||
DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate,
|
||||
ResolvedOpenClawCommand, ShellPlatform, WindowsDependencyInstallPlan, NPM_MIRROR_CN,
|
||||
OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
|
||||
windows_git_install_dir_variants, windows_install_block_result,
|
||||
windows_manual_install_message, DependencyKind, DependencyStatus, EnvironmentDiagnostics,
|
||||
OpenClawRuntimeCandidate, ResolvedOpenClawCommand, ShellPlatform,
|
||||
WindowsDependencyInstallPlan, NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
|
||||
};
|
||||
use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup};
|
||||
use chrono::Utc;
|
||||
@@ -5924,6 +6007,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_candidate_selection_prefers_executable_extension() {
|
||||
let preferred = select_best_git_candidate(vec![
|
||||
PathBuf::from(r"C:\Program Files\Git\cmd\git.cmd"),
|
||||
PathBuf::from(r"C:\Program Files\Git\cmd\git.exe"),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
preferred,
|
||||
Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_git_install_dir_variants_cover_common_layouts() {
|
||||
let git_root = build_unique_temp_dir("git-layout-root");
|
||||
let cmd_dir = git_root.join("cmd");
|
||||
let bin_dir = git_root.join("bin");
|
||||
fs::create_dir_all(&cmd_dir).unwrap();
|
||||
fs::create_dir_all(&bin_dir).unwrap();
|
||||
fs::write(cmd_dir.join("git.exe"), "").unwrap();
|
||||
fs::write(bin_dir.join("git.cmd"), "").unwrap();
|
||||
|
||||
let matches = super::find_all_commands_in_paths_for(
|
||||
ShellPlatform::Windows,
|
||||
"git",
|
||||
&windows_git_install_dir_variants(git_root.clone()),
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&git_root);
|
||||
|
||||
assert_eq!(
|
||||
matches,
|
||||
vec![cmd_dir.join("git.exe"), bin_dir.join("git.cmd")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_npm_global_command_dirs_use_prefix_root() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user