chore: release v0.97.0

This commit is contained in:
coso
2026-03-27 14:35:56 +08:00
parent e380c98649
commit 26e7c60fdd
234 changed files with 36543 additions and 3657 deletions
+17 -17
View File
@@ -5101,7 +5101,7 @@ dependencies = [
[[package]]
name = "lime"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"anyhow",
"arboard",
@@ -5205,7 +5205,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5234,7 +5234,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"chrono",
"futures",
@@ -5251,7 +5251,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5267,7 +5267,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5307,7 +5307,7 @@ dependencies = [
[[package]]
name = "lime-credential"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5342,7 +5342,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"aes",
"axum 0.7.9",
@@ -5372,7 +5372,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5392,7 +5392,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5424,7 +5424,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5443,7 +5443,7 @@ dependencies = [
[[package]]
name = "lime-providers"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5498,7 +5498,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5558,7 +5558,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5600,7 +5600,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5618,7 +5618,7 @@ dependencies = [
[[package]]
name = "lime-terminal"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5645,7 +5645,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "0.96.0"
version = "0.97.0"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -2
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.96.0"
version = "0.97.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -192,7 +192,7 @@ version = "2.4"
[package]
name = "lime"
version = "0.96.0"
version = "0.97.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+7 -2
View File
@@ -102,7 +102,11 @@ pub use runtime_queue::{
RuntimeQueueExecutor,
};
pub use session_execution_runtime::{
build_session_execution_runtime, SessionExecutionRuntime, SessionExecutionRuntimeSource,
build_session_execution_runtime, extract_recent_content_id_from_runtime_snapshot,
persist_session_recent_preferences, persist_session_recent_team_selection,
SessionExecutionRuntime, SessionExecutionRuntimePreferences,
SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection,
SessionExecutionRuntimeSource,
};
pub use session_query::{
collect_subagent_cascade_session_ids, list_child_subagent_sessions,
@@ -115,7 +119,8 @@ pub use session_store::{
list_title_preview_messages_sync, rename_session_sync, update_session_execution_strategy_sync,
update_session_provider_config_sync, update_session_working_dir_sync,
ChildSubagentRuntimeStatus, ChildSubagentSession, PersistedSessionMetadata, SessionDetail,
SessionInfo, SessionTitlePreviewMessage, SessionTodoItem, SubagentParentContext,
SessionInfo, SessionTitlePreviewMessage, SessionTodoItem, SessionTodoStatus,
SubagentParentContext,
};
pub use session_update::{
create_subagent_session, persist_compaction_session_metrics_update,
@@ -1,3 +1,6 @@
use crate::session_query::read_session;
use crate::session_update::persist_session_extension_data;
use aster::session::extension_data::{ExtensionData, ExtensionState};
use aster::session::{Session, SessionRuntimeSnapshot, TurnOutputSchemaRuntime, TurnStatus};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -29,6 +32,165 @@ pub struct SessionExecutionRuntimePreferences {
pub subagent: bool,
}
impl ExtensionState for SessionExecutionRuntimePreferences {
const EXTENSION_NAME: &'static str = "lime_recent_preferences";
const VERSION: &'static str = "v0";
}
impl SessionExecutionRuntimePreferences {
fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
<Self as ExtensionState>::from_extension_data(extension_data)
}
fn from_session(session: &Session) -> Option<Self> {
Self::from_extension_data(&session.extension_data)
}
fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> {
<Self as ExtensionState>::to_extension_data(self, extension_data)
.map_err(|error| error.to_string())
}
fn into_updated_extension_data(self, session: &Session) -> Result<ExtensionData, String> {
let mut extension_data = session.extension_data.clone();
self.to_extension_data(&mut extension_data)?;
Ok(extension_data)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SessionExecutionRuntimeRecentTeamRole {
#[serde(default)]
pub id: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub summary: String,
#[serde(default, alias = "profile_id")]
pub profile_id: Option<String>,
#[serde(default, alias = "role_key")]
pub role_key: Option<String>,
#[serde(default, alias = "skill_ids")]
pub skill_ids: Vec<String>,
}
impl SessionExecutionRuntimeRecentTeamRole {
fn normalize(self) -> Option<Self> {
let id = self.id.trim().to_string();
let label = self.label.trim().to_string();
let summary = self.summary.trim().to_string();
if label.is_empty() && summary.is_empty() {
return None;
}
let skill_ids = self
.skill_ids
.into_iter()
.filter_map(|skill_id| normalize_optional_text(Some(skill_id)))
.collect();
Some(Self {
id,
label,
summary,
profile_id: normalize_optional_text(self.profile_id),
role_key: normalize_optional_text(self.role_key),
skill_ids,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SessionExecutionRuntimeRecentTeamSelection {
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub theme: Option<String>,
#[serde(default, alias = "preferred_team_preset_id")]
pub preferred_team_preset_id: Option<String>,
#[serde(default, alias = "selected_team_id")]
pub selected_team_id: Option<String>,
#[serde(default, alias = "selected_team_source")]
pub selected_team_source: Option<String>,
#[serde(default, alias = "selected_team_label")]
pub selected_team_label: Option<String>,
#[serde(default, alias = "selected_team_description")]
pub selected_team_description: Option<String>,
#[serde(default, alias = "selected_team_summary")]
pub selected_team_summary: Option<String>,
#[serde(default, alias = "selected_team_roles")]
pub selected_team_roles: Option<Vec<SessionExecutionRuntimeRecentTeamRole>>,
}
impl ExtensionState for SessionExecutionRuntimeRecentTeamSelection {
const EXTENSION_NAME: &'static str = "lime_recent_team_selection";
const VERSION: &'static str = "v0";
}
impl SessionExecutionRuntimeRecentTeamSelection {
fn normalize(self) -> Option<Self> {
let selected_team_roles = self
.selected_team_roles
.map(|roles| {
roles
.into_iter()
.filter_map(SessionExecutionRuntimeRecentTeamRole::normalize)
.collect::<Vec<_>>()
})
.filter(|roles| !roles.is_empty());
let normalized = Self {
disabled: self.disabled,
theme: normalize_optional_text(self.theme),
preferred_team_preset_id: normalize_optional_text(self.preferred_team_preset_id),
selected_team_id: normalize_optional_text(self.selected_team_id),
selected_team_source: normalize_optional_text(self.selected_team_source),
selected_team_label: normalize_optional_text(self.selected_team_label),
selected_team_description: normalize_optional_text(self.selected_team_description),
selected_team_summary: normalize_optional_text(self.selected_team_summary),
selected_team_roles,
};
if normalized.disabled {
return Some(normalized);
}
if normalized.preferred_team_preset_id.is_none()
&& normalized.selected_team_id.is_none()
&& normalized.selected_team_source.is_none()
&& normalized.selected_team_label.is_none()
&& normalized.selected_team_description.is_none()
&& normalized.selected_team_summary.is_none()
&& normalized.selected_team_roles.is_none()
{
return None;
}
Some(normalized)
}
fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
<Self as ExtensionState>::from_extension_data(extension_data).and_then(Self::normalize)
}
fn from_session(session: &Session) -> Option<Self> {
Self::from_extension_data(&session.extension_data)
}
fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> {
<Self as ExtensionState>::to_extension_data(self, extension_data)
.map_err(|error| error.to_string())
}
fn into_updated_extension_data(self, session: &Session) -> Result<ExtensionData, String> {
let mut extension_data = session.extension_data.clone();
self.to_extension_data(&mut extension_data)?;
Ok(extension_data)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionExecutionRuntime {
pub session_id: String,
@@ -51,6 +213,27 @@ pub struct SessionExecutionRuntime {
pub latest_turn_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_preferences: Option<SessionExecutionRuntimePreferences>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_team_selection: Option<SessionExecutionRuntimeRecentTeamSelection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_session_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_gate_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_run_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_content_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct RecentHarnessContext {
theme: Option<String>,
session_mode: Option<String>,
gate_key: Option<String>,
run_title: Option<String>,
content_id: Option<String>,
}
fn resolve_session_model_name(session: &Session) -> Option<String> {
@@ -80,6 +263,46 @@ fn extract_bool_from_metadata(
.find_map(|key| extract_bool_from_value(metadata.get(*key)))
}
fn extract_text_from_value(value: Option<&Value>) -> Option<String> {
normalize_optional_text(value.and_then(Value::as_str).map(ToString::to_string))
}
fn extract_text_from_object(
object: &serde_json::Map<String, Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.find_map(|key| extract_text_from_value(object.get(*key)))
}
fn extract_text_from_metadata(
metadata: &std::collections::HashMap<String, Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.find_map(|key| extract_text_from_value(metadata.get(*key)))
}
fn extract_array_from_object(
object: &serde_json::Map<String, Value>,
keys: &[&str],
) -> Option<Vec<Value>> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(Value::as_array)
.cloned()
}
fn extract_array_from_metadata(
metadata: &std::collections::HashMap<String, Value>,
keys: &[&str],
) -> Option<Vec<Value>> {
keys.iter()
.filter_map(|key| metadata.get(*key))
.find_map(Value::as_array)
.cloned()
}
fn extract_recent_preferences_from_metadata(
metadata: &std::collections::HashMap<String, Value>,
) -> Option<SessionExecutionRuntimePreferences> {
@@ -118,6 +341,170 @@ fn extract_recent_preferences_from_metadata(
})
}
fn extract_recent_team_roles_from_values(
values: Vec<Value>,
) -> Option<Vec<SessionExecutionRuntimeRecentTeamRole>> {
let roles = values
.into_iter()
.filter_map(|value| {
serde_json::from_value::<SessionExecutionRuntimeRecentTeamRole>(value).ok()
})
.filter_map(SessionExecutionRuntimeRecentTeamRole::normalize)
.collect::<Vec<_>>();
if roles.is_empty() {
None
} else {
Some(roles)
}
}
fn extract_recent_team_selection_from_metadata(
metadata: &std::collections::HashMap<String, Value>,
) -> Option<SessionExecutionRuntimeRecentTeamSelection> {
let harness = metadata.get("harness").and_then(Value::as_object);
let resolve_text = |keys: &[&str]| -> Option<String> {
harness
.and_then(|value| extract_text_from_object(value, keys))
.or_else(|| extract_text_from_metadata(metadata, keys))
};
let resolve_bool = |keys: &[&str]| -> Option<bool> {
harness
.and_then(|value| extract_bool_from_object(value, keys))
.or_else(|| extract_bool_from_metadata(metadata, keys))
};
let resolve_array = |keys: &[&str]| -> Option<Vec<Value>> {
harness
.and_then(|value| extract_array_from_object(value, keys))
.or_else(|| extract_array_from_metadata(metadata, keys))
};
SessionExecutionRuntimeRecentTeamSelection {
disabled: resolve_bool(&["selected_team_disabled", "selectedTeamDisabled"])
.unwrap_or(false),
theme: resolve_text(&["theme", "harness_theme", "harnessTheme"]),
preferred_team_preset_id: resolve_text(&[
"preferred_team_preset_id",
"preferredTeamPresetId",
]),
selected_team_id: resolve_text(&["selected_team_id", "selectedTeamId"]),
selected_team_source: resolve_text(&["selected_team_source", "selectedTeamSource"]),
selected_team_label: resolve_text(&["selected_team_label", "selectedTeamLabel"]),
selected_team_description: resolve_text(&[
"selected_team_description",
"selectedTeamDescription",
]),
selected_team_summary: resolve_text(&["selected_team_summary", "selectedTeamSummary"]),
selected_team_roles: resolve_array(&["selected_team_roles", "selectedTeamRoles"])
.and_then(extract_recent_team_roles_from_values),
}
.normalize()
}
fn extract_recent_harness_context_from_metadata(
metadata: &std::collections::HashMap<String, Value>,
) -> RecentHarnessContext {
let harness = metadata.get("harness").and_then(Value::as_object);
let resolve_text = |keys: &[&str]| -> Option<String> {
harness
.and_then(|value| extract_text_from_object(value, keys))
.or_else(|| extract_text_from_metadata(metadata, keys))
};
RecentHarnessContext {
theme: resolve_text(&["theme", "harness_theme", "harnessTheme"]),
session_mode: resolve_text(&["session_mode", "sessionMode"]),
gate_key: resolve_text(&["gate_key", "gateKey"]),
run_title: resolve_text(&["run_title", "runTitle", "title"]),
content_id: resolve_text(&["content_id", "contentId"]),
}
}
fn extract_recent_harness_context_from_runtime_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> RecentHarnessContext {
let from_turn = snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.filter_map(|turn| {
let context = turn
.context_override
.as_ref()
.map(|value| extract_recent_harness_context_from_metadata(&value.metadata))?;
Some((turn.updated_at, context))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, context)| context)
.unwrap_or_default();
if from_turn.theme.is_some()
&& from_turn.session_mode.is_some()
&& from_turn.gate_key.is_some()
&& from_turn.run_title.is_some()
&& from_turn.content_id.is_some()
{
return from_turn;
}
let from_thread = snapshot
.threads
.iter()
.filter_map(|thread| {
let context = extract_recent_harness_context_from_metadata(&thread.thread.metadata);
if context.theme.is_none()
&& context.session_mode.is_none()
&& context.content_id.is_none()
{
return None;
}
Some((thread.thread.updated_at, context))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, context)| context)
.unwrap_or_default();
RecentHarnessContext {
theme: from_turn.theme.or(from_thread.theme),
session_mode: from_turn.session_mode.or(from_thread.session_mode),
gate_key: from_turn.gate_key.or(from_thread.gate_key),
run_title: from_turn.run_title.or(from_thread.run_title),
content_id: from_turn.content_id.or(from_thread.content_id),
}
}
pub fn extract_recent_content_id_from_runtime_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> Option<String> {
extract_recent_harness_context_from_runtime_snapshot(snapshot).content_id
}
pub async fn persist_session_recent_preferences(
session_id: &str,
preferences: SessionExecutionRuntimePreferences,
) -> Result<(), String> {
let session = read_session(session_id, false, "读取会话 recent_preferences 失败").await?;
let extension_data = preferences.into_updated_extension_data(&session)?;
persist_session_extension_data(session_id, extension_data, "持久化会话 recent_preferences")
.await?;
Ok(())
}
pub async fn persist_session_recent_team_selection(
session_id: &str,
recent_team_selection: SessionExecutionRuntimeRecentTeamSelection,
) -> Result<(), String> {
let session = read_session(session_id, false, "读取会话 recent_team_selection 失败").await?;
let extension_data = recent_team_selection.into_updated_extension_data(&session)?;
persist_session_extension_data(
session_id,
extension_data,
"持久化会话 recent_team_selection",
)
.await?;
Ok(())
}
fn resolve_latest_turn(snapshot: &SessionRuntimeSnapshot) -> Option<&aster::session::TurnRuntime> {
snapshot
.threads
@@ -161,33 +548,62 @@ pub fn build_session_execution_runtime(
latest_turn_id: None,
latest_turn_status: None,
recent_preferences: None,
recent_team_selection: None,
recent_theme: None,
recent_session_mode: None,
recent_gate_key: None,
recent_run_title: None,
recent_content_id: None,
};
if let Some(latest_turn) = snapshot.and_then(resolve_latest_turn) {
runtime.latest_turn_id = Some(latest_turn.id.clone());
runtime.latest_turn_status = Some(map_turn_status(latest_turn.status));
runtime.output_schema_runtime = latest_turn.output_schema_runtime.clone();
runtime.model_name = latest_turn
.output_schema_runtime
.as_ref()
.and_then(|value| normalize_optional_text(value.model_name.clone()))
.or_else(|| {
latest_turn
.context_override
.as_ref()
.and_then(|value| normalize_optional_text(value.model.clone()))
})
.or(runtime.model_name);
runtime.provider_name = latest_turn
.output_schema_runtime
.as_ref()
.and_then(|value| normalize_optional_text(value.provider_name.clone()))
.or(runtime.provider_name);
runtime.recent_preferences = latest_turn
.context_override
.as_ref()
.and_then(|value| extract_recent_preferences_from_metadata(&value.metadata));
runtime.source = SessionExecutionRuntimeSource::RuntimeSnapshot;
if let Some(snapshot) = snapshot {
let recent_harness_context = extract_recent_harness_context_from_runtime_snapshot(snapshot);
runtime.recent_theme = recent_harness_context.theme;
runtime.recent_session_mode = recent_harness_context.session_mode;
runtime.recent_gate_key = recent_harness_context.gate_key;
runtime.recent_run_title = recent_harness_context.run_title;
runtime.recent_content_id = recent_harness_context.content_id;
if let Some(latest_turn) = resolve_latest_turn(snapshot) {
runtime.latest_turn_id = Some(latest_turn.id.clone());
runtime.latest_turn_status = Some(map_turn_status(latest_turn.status));
runtime.output_schema_runtime = latest_turn.output_schema_runtime.clone();
runtime.model_name = latest_turn
.output_schema_runtime
.as_ref()
.and_then(|value| normalize_optional_text(value.model_name.clone()))
.or_else(|| {
latest_turn
.context_override
.as_ref()
.and_then(|value| normalize_optional_text(value.model.clone()))
})
.or(runtime.model_name);
runtime.provider_name = latest_turn
.output_schema_runtime
.as_ref()
.and_then(|value| normalize_optional_text(value.provider_name.clone()))
.or(runtime.provider_name);
runtime.recent_preferences = latest_turn
.context_override
.as_ref()
.and_then(|value| extract_recent_preferences_from_metadata(&value.metadata));
runtime.recent_team_selection = latest_turn
.context_override
.as_ref()
.and_then(|value| extract_recent_team_selection_from_metadata(&value.metadata));
runtime.source = SessionExecutionRuntimeSource::RuntimeSnapshot;
}
}
if runtime.recent_preferences.is_none() {
runtime.recent_preferences =
session.and_then(SessionExecutionRuntimePreferences::from_session);
}
if runtime.recent_team_selection.is_none() {
runtime.recent_team_selection =
session.and_then(SessionExecutionRuntimeRecentTeamSelection::from_session);
}
if runtime.provider_selector.is_none()
@@ -195,6 +611,12 @@ pub fn build_session_execution_runtime(
&& runtime.model_name.is_none()
&& runtime.output_schema_runtime.is_none()
&& runtime.recent_preferences.is_none()
&& runtime.recent_team_selection.is_none()
&& runtime.recent_theme.is_none()
&& runtime.recent_session_mode.is_none()
&& runtime.recent_gate_key.is_none()
&& runtime.recent_run_title.is_none()
&& runtime.recent_content_id.is_none()
{
return None;
}
@@ -206,6 +628,7 @@ pub fn build_session_execution_runtime(
mod tests {
use super::{
build_session_execution_runtime, SessionExecutionRuntimePreferences,
SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection,
SessionExecutionRuntimeSource,
};
use aster::model::ModelConfig;
@@ -389,4 +812,385 @@ mod tests {
})
);
}
#[test]
fn keeps_recent_team_selection_from_latest_turn_metadata() {
let now = Utc::now();
let latest_turn = TurnRuntime {
id: "turn-team".to_string(),
session_id: "session-5".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("hello".to_string()),
error_message: None,
context_override: Some(TurnContextOverride {
metadata: std::collections::HashMap::from([(
"harness".to_string(),
json!({
"theme": "general",
"preferred_team_preset_id": "code-triage-team",
"selected_team_id": "custom-team-1",
"selected_team_source": "custom",
"selected_team_label": "前端联调团队",
"selected_team_description": "分析、实现、验证三段式推进。",
"selected_team_summary": "分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。",
"selected_team_roles": [
{
"id": "explorer",
"label": "分析",
"summary": "负责定位问题与影响范围。",
"profile_id": "code-explorer",
"role_key": "explorer",
"skill_ids": ["repo-exploration"]
}
]
}),
)]),
..TurnContextOverride::default()
}),
output_schema_runtime: None,
created_at: now - Duration::seconds(10),
started_at: Some(now - Duration::seconds(10)),
completed_at: Some(now - Duration::seconds(1)),
updated_at: now,
};
let snapshot = SessionRuntimeSnapshot {
session_id: "session-5".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread: ThreadRuntime::new(
"thread-1",
"session-5",
PathBuf::from("/tmp/workspace"),
),
turns: vec![latest_turn],
items: Vec::new(),
}],
};
let runtime =
build_session_execution_runtime("session-5", None, None, Some(&snapshot), None)
.expect("runtime");
assert_eq!(
runtime.recent_team_selection,
Some(SessionExecutionRuntimeRecentTeamSelection {
disabled: false,
theme: Some("general".to_string()),
preferred_team_preset_id: Some("code-triage-team".to_string()),
selected_team_id: Some("custom-team-1".to_string()),
selected_team_source: Some("custom".to_string()),
selected_team_label: Some("前端联调团队".to_string()),
selected_team_description: Some("分析、实现、验证三段式推进。".to_string()),
selected_team_summary: Some(
"分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。"
.to_string(),
),
selected_team_roles: Some(vec![SessionExecutionRuntimeRecentTeamRole {
id: "explorer".to_string(),
label: "分析".to_string(),
summary: "负责定位问题与影响范围。".to_string(),
profile_id: Some("code-explorer".to_string()),
role_key: Some("explorer".to_string()),
skill_ids: vec!["repo-exploration".to_string()],
}]),
})
);
}
#[test]
fn keeps_recent_content_id_from_latest_turn_metadata() {
let now = Utc::now();
let latest_turn = TurnRuntime {
id: "turn-content".to_string(),
session_id: "session-content".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("hello".to_string()),
error_message: None,
context_override: Some(TurnContextOverride {
metadata: std::collections::HashMap::from([(
"harness".to_string(),
json!({
"content_id": "content-current"
}),
)]),
..TurnContextOverride::default()
}),
output_schema_runtime: None,
created_at: now - Duration::seconds(10),
started_at: Some(now - Duration::seconds(10)),
completed_at: Some(now - Duration::seconds(1)),
updated_at: now,
};
let snapshot = SessionRuntimeSnapshot {
session_id: "session-content".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread: ThreadRuntime::new(
"thread-1",
"session-content",
PathBuf::from("/tmp/workspace"),
),
turns: vec![latest_turn],
items: Vec::new(),
}],
};
let runtime =
build_session_execution_runtime("session-content", None, None, Some(&snapshot), None)
.expect("runtime");
assert_eq!(
runtime.source,
SessionExecutionRuntimeSource::RuntimeSnapshot
);
assert_eq!(
runtime.recent_content_id.as_deref(),
Some("content-current")
);
}
#[test]
fn keeps_recent_theme_and_session_mode_from_latest_turn_metadata() {
let now = Utc::now();
let latest_turn = TurnRuntime {
id: "turn-harness".to_string(),
session_id: "session-harness".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("hello".to_string()),
error_message: None,
context_override: Some(TurnContextOverride {
metadata: std::collections::HashMap::from([(
"harness".to_string(),
json!({
"theme": "social-media",
"session_mode": "theme_workbench",
"gate_key": "write_mode",
"run_title": "社媒初稿",
"content_id": "content-current"
}),
)]),
..TurnContextOverride::default()
}),
output_schema_runtime: None,
created_at: now - Duration::seconds(10),
started_at: Some(now - Duration::seconds(10)),
completed_at: Some(now - Duration::seconds(1)),
updated_at: now,
};
let snapshot = SessionRuntimeSnapshot {
session_id: "session-harness".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread: ThreadRuntime::new(
"thread-1",
"session-harness",
PathBuf::from("/tmp/workspace"),
),
turns: vec![latest_turn],
items: Vec::new(),
}],
};
let runtime =
build_session_execution_runtime("session-harness", None, None, Some(&snapshot), None)
.expect("runtime");
assert_eq!(runtime.recent_theme.as_deref(), Some("social-media"));
assert_eq!(
runtime.recent_session_mode.as_deref(),
Some("theme_workbench")
);
assert_eq!(runtime.recent_gate_key.as_deref(), Some("write_mode"));
assert_eq!(runtime.recent_run_title.as_deref(), Some("社媒初稿"));
assert_eq!(
runtime.recent_content_id.as_deref(),
Some("content-current")
);
}
#[test]
fn falls_back_to_thread_metadata_recent_content_id() {
let now = Utc::now();
let latest_turn = TurnRuntime {
id: "turn-without-content".to_string(),
session_id: "session-thread-content".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("hello".to_string()),
error_message: None,
context_override: Some(TurnContextOverride::default()),
output_schema_runtime: None,
created_at: now - Duration::seconds(10),
started_at: Some(now - Duration::seconds(10)),
completed_at: Some(now - Duration::seconds(1)),
updated_at: now,
};
let mut thread = ThreadRuntime::new(
"thread-1",
"session-thread-content",
PathBuf::from("/tmp/workspace"),
);
thread
.metadata
.insert("content_id".to_string(), json!("content-from-thread"));
thread.updated_at = now;
let snapshot = SessionRuntimeSnapshot {
session_id: "session-thread-content".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread,
turns: vec![latest_turn],
items: Vec::new(),
}],
};
let runtime = build_session_execution_runtime(
"session-thread-content",
None,
None,
Some(&snapshot),
None,
)
.expect("runtime");
assert_eq!(
runtime.recent_content_id.as_deref(),
Some("content-from-thread")
);
}
#[test]
fn falls_back_to_thread_metadata_recent_theme_and_session_mode() {
let now = Utc::now();
let latest_turn = TurnRuntime {
id: "turn-without-harness".to_string(),
session_id: "session-thread-harness".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("hello".to_string()),
error_message: None,
context_override: Some(TurnContextOverride::default()),
output_schema_runtime: None,
created_at: now - Duration::seconds(10),
started_at: Some(now - Duration::seconds(10)),
completed_at: Some(now - Duration::seconds(1)),
updated_at: now,
};
let mut thread = ThreadRuntime::new(
"thread-1",
"session-thread-harness",
PathBuf::from("/tmp/workspace"),
);
thread
.metadata
.insert("theme".to_string(), json!("document"));
thread
.metadata
.insert("session_mode".to_string(), json!("theme_workbench"));
thread
.metadata
.insert("gate_key".to_string(), json!("publish_confirm"));
thread
.metadata
.insert("run_title".to_string(), json!("发布确认"));
thread
.metadata
.insert("content_id".to_string(), json!("content-from-thread"));
thread.updated_at = now;
let snapshot = SessionRuntimeSnapshot {
session_id: "session-thread-harness".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread,
turns: vec![latest_turn],
items: Vec::new(),
}],
};
let runtime = build_session_execution_runtime(
"session-thread-harness",
None,
None,
Some(&snapshot),
None,
)
.expect("runtime");
assert_eq!(runtime.recent_theme.as_deref(), Some("document"));
assert_eq!(
runtime.recent_session_mode.as_deref(),
Some("theme_workbench")
);
assert_eq!(runtime.recent_gate_key.as_deref(), Some("publish_confirm"));
assert_eq!(runtime.recent_run_title.as_deref(), Some("发布确认"));
assert_eq!(
runtime.recent_content_id.as_deref(),
Some("content-from-thread")
);
}
#[test]
fn falls_back_to_session_extension_data_recent_preferences() {
let mut session = Session::default();
session.id = "session-4".to_string();
session.extension_data = SessionExecutionRuntimePreferences {
web_search: false,
thinking: true,
task: true,
subagent: false,
}
.into_updated_extension_data(&Session::default())
.expect("extension data");
let runtime =
build_session_execution_runtime("session-4", Some(&session), None, None, None)
.expect("runtime");
assert_eq!(runtime.source, SessionExecutionRuntimeSource::Session);
assert_eq!(
runtime.recent_preferences,
Some(SessionExecutionRuntimePreferences {
web_search: false,
thinking: true,
task: true,
subagent: false,
})
);
}
#[test]
fn falls_back_to_session_extension_data_recent_team_selection() {
let mut session = Session::default();
session.id = "session-6".to_string();
session.extension_data = SessionExecutionRuntimeRecentTeamSelection {
disabled: true,
theme: Some("general".to_string()),
preferred_team_preset_id: None,
selected_team_id: None,
selected_team_source: None,
selected_team_label: None,
selected_team_description: None,
selected_team_summary: None,
selected_team_roles: None,
}
.into_updated_extension_data(&Session::default())
.expect("extension data");
let runtime =
build_session_execution_runtime("session-6", Some(&session), None, None, None)
.expect("runtime");
assert_eq!(
runtime.recent_team_selection,
Some(SessionExecutionRuntimeRecentTeamSelection {
disabled: true,
theme: Some("general".to_string()),
preferred_team_preset_id: None,
selected_team_id: None,
selected_team_source: None,
selected_team_label: None,
selected_team_description: None,
selected_team_summary: None,
selected_team_roles: None,
})
);
}
}
+15 -5
View File
@@ -55,6 +55,7 @@ async fn navigate(session: &CdpSessionHandle, args: &Value) -> Result<Value, Str
}
let url = get_string_arg(args, &["url"]).ok_or_else(|| "navigate 需要提供 url".to_string())?;
let wait_timeout_ms = get_u64_arg(args, &["timeout_ms"]).unwrap_or(DEFAULT_ACTION_TIMEOUT_MS);
let command_timeout_ms = resolve_navigation_command_timeout_ms(wait_timeout_ms);
let previous_url = session
.state()
.await
@@ -64,11 +65,7 @@ async fn navigate(session: &CdpSessionHandle, args: &Value) -> Result<Value, Str
.filter(|value| !value.trim().is_empty());
let mut event_rx = session.subscribe();
let response = session
.send_command(
"Page.navigate",
json!({ "url": url }),
DEFAULT_ACTION_TIMEOUT_MS,
)
.send_command("Page.navigate", json!({ "url": url }), command_timeout_ms)
.await?;
if let Some(error_text) = response
.get("errorText")
@@ -416,10 +413,23 @@ fn get_u64_arg(args: &Value, keys: &[&str]) -> Option<u64> {
.find_map(|key| args.get(*key).and_then(Value::as_u64))
}
fn resolve_navigation_command_timeout_ms(wait_timeout_ms: u64) -> u64 {
wait_timeout_ms.max(DEFAULT_ACTION_TIMEOUT_MS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_keep_navigation_command_timeout_at_least_default() {
assert_eq!(
resolve_navigation_command_timeout_ms(5_000),
DEFAULT_ACTION_TIMEOUT_MS
);
assert_eq!(resolve_navigation_command_timeout_ms(20_000), 20_000);
}
#[test]
fn should_accept_exact_expected_url_even_when_previous_matches() {
assert!(should_accept_navigation_page(
+23
View File
@@ -924,6 +924,16 @@ impl Default for ScreenshotChatConfig {
}
}
/// WebMCP 预留配置
///
/// 当前仅作为实验开关预留,不参与实际执行链。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct WebMcpConfig {
/// 是否允许未来接入 WebMCP 实验能力
#[serde(default)]
pub enabled: bool,
}
/// 实验室功能配置
///
/// 管理所有实验性功能的开关和配置
@@ -932,6 +942,9 @@ pub struct ExperimentalFeatures {
/// 截图对话功能配置
#[serde(default)]
pub screenshot_chat: ScreenshotChatConfig,
/// WebMCP 预留配置
#[serde(default)]
pub webmcp: WebMcpConfig,
/// 自动更新检查配置
#[serde(default)]
pub update_check: UpdateCheckConfig,
@@ -3157,11 +3170,18 @@ mod unit_tests {
assert_eq!(config.shortcut, "CommandOrControl+Alt+Q");
}
#[test]
fn test_webmcp_config_default() {
let config = WebMcpConfig::default();
assert!(!config.enabled);
}
#[test]
fn test_experimental_features_default() {
let config = ExperimentalFeatures::default();
assert!(!config.screenshot_chat.enabled);
assert_eq!(config.screenshot_chat.shortcut, "CommandOrControl+Alt+Q");
assert!(!config.webmcp.enabled);
}
#[test]
@@ -3171,10 +3191,12 @@ mod unit_tests {
enabled: true,
shortcut: "CommandOrControl+Alt+X".to_string(),
},
webmcp: WebMcpConfig { enabled: true },
..Default::default()
};
let yaml = serde_yaml::to_string(&config).unwrap();
assert!(yaml.contains("webmcp"));
assert!(yaml.contains("enabled: true"));
assert!(yaml.contains("shortcut: CommandOrControl+Alt+X"));
@@ -3215,6 +3237,7 @@ mod unit_tests {
config.experimental.screenshot_chat.shortcut,
"CommandOrControl+Alt+Q"
);
assert!(!config.experimental.webmcp.enabled);
// 语音输入测试
assert!(!config.experimental.voice_input.enabled);
assert_eq!(
@@ -40,6 +40,14 @@ async (args, helpers) => {
limit,
);
if (items.length === 0 && helpers.looksLikeLoginWall()) {
return {
ok: false,
error_code: "auth_required",
error_message: `B 站没有返回 "${query}" 的搜索结果,可能需要先登录。`,
};
}
return {
ok: true,
data: {
+14
View File
@@ -228,6 +228,20 @@ impl AsterAgentWrapper {
lime_agent::update_session_provider_config_sync(db, session_id, provider_name, model_name)
}
pub async fn persist_session_recent_preferences(
session_id: &str,
preferences: lime_agent::SessionExecutionRuntimePreferences,
) -> Result<(), String> {
lime_agent::persist_session_recent_preferences(session_id, preferences).await
}
pub async fn persist_session_recent_team_selection(
session_id: &str,
recent_team_selection: lime_agent::SessionExecutionRuntimeRecentTeamSelection,
) -> Result<(), String> {
lime_agent::persist_session_recent_team_selection(session_id, recent_team_selection).await
}
/// 删除会话
pub async fn delete_session(db: &DbConnection, session_id: &str) -> Result<(), String> {
lime_agent::delete_session(db, session_id).await
+14 -3
View File
@@ -12,6 +12,7 @@ pub const LIME_CREATE_IMAGE_TASK_TOOL_NAME: &str = "lime_create_image_generation
pub const LIME_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "lime_create_url_parse_task";
pub const LIME_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "lime_create_typesetting_task";
pub const LIME_SITE_LIST_TOOL_NAME: &str = "lime_site_list";
pub const LIME_SITE_RECOMMEND_TOOL_NAME: &str = "lime_site_recommend";
pub const LIME_SITE_SEARCH_TOOL_NAME: &str = "lime_site_search";
pub const LIME_SITE_INFO_TOOL_NAME: &str = "lime_site_info";
pub const LIME_SITE_RUN_TOOL_NAME: &str = "lime_site_run";
@@ -451,6 +452,15 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SITE_RECOMMEND_TOOL_NAME,
profiles: BROWSER_PROFILES,
capabilities: SITE_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SITE_SEARCH_TOOL_NAME,
profiles: BROWSER_PROFILES,
@@ -714,14 +724,14 @@ mod tests {
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
let browser = tool_catalog_entries_for_surface(WorkspaceToolSurface::browser_assist());
assert_eq!(browser.len(), 31);
assert_eq!(browser.len(), 32);
assert!(browser
.iter()
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
let combined =
tool_catalog_entries_for_surface(WorkspaceToolSurface::creator_with_browser_assist());
assert_eq!(combined.len(), 39);
assert_eq!(combined.len(), 40);
}
#[test]
@@ -740,9 +750,10 @@ mod tests {
let names = workspace_default_allowed_tool_names(
WorkspaceToolSurface::creator_with_browser_assist(),
);
assert_eq!(names.len(), 26);
assert_eq!(names.len(), 27);
assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(&"tool_search"));
assert!(names.contains(&LIME_SITE_RECOMMEND_TOOL_NAME));
assert!(names.contains(&LIME_SITE_RUN_TOOL_NAME));
assert!(!names
.iter()
+2 -2
View File
@@ -910,8 +910,8 @@ mod tests {
.map(ToString::to_string)
.collect::<Vec<_>>();
assert_eq!(inventory.counts.catalog_total, 39);
assert_eq!(inventory.counts.catalog_current_total, 38);
assert_eq!(inventory.counts.catalog_total, 40);
assert_eq!(inventory.counts.catalog_current_total, 39);
assert_eq!(inventory.counts.catalog_compat_total, 1);
assert_eq!(inventory.default_allowed_tools, expected_default_allowed);
assert_eq!(
+16
View File
@@ -127,6 +127,22 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
}
}
{
let conn = database::lock_db(&db).map_err(|e| format!("Failed to lock database: {e}"))?;
let seeded_profiles =
crate::services::browser_profile_service::ensure_default_browser_profiles(&conn)
.map_err(|error| format!("初始化默认浏览器资料失败: {error}"))?;
let seeded_environment_presets = crate::services::browser_environment_service::ensure_default_browser_environment_presets(&conn)
.map_err(|error| format!("初始化默认浏览器环境预设失败: {error}"))?;
if seeded_profiles || seeded_environment_presets {
tracing::info!(
"[Bootstrap] 默认浏览器资源已就绪: profiles_seeded={}, presets_seeded={}",
seeded_profiles,
seeded_environment_presets
);
}
}
initialize_aster_runtime(db.clone()).map_err(|e| format!("Aster 运行时初始化失败: {e}"))?;
// 服务状态
+6
View File
@@ -1287,6 +1287,7 @@ pub fn run() {
commands::browser_runtime_cmd::launch_browser_session,
commands::browser_runtime_cmd::launch_browser_runtime_assist,
commands::site_capability_cmd::site_list_adapters,
commands::site_capability_cmd::site_recommend_adapters,
commands::site_capability_cmd::site_search_adapters,
commands::site_capability_cmd::site_get_adapter_info,
commands::site_capability_cmd::site_get_adapter_catalog_status,
@@ -1449,6 +1450,11 @@ pub fn run() {
commands::aster_agent_cmd::command_api::session_api::agent_runtime_list_sessions,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_session,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_thread_read,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_analysis_handoff,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_handoff_bundle,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_evidence_pack,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_review_decision_template,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_replay_case,
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_tool_inventory,
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_spawn_subagent,
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_send_subagent_input,
@@ -47,7 +47,9 @@ pub(crate) use provider_api::{
aster_agent_reset, aster_agent_status,
};
pub(crate) use runtime_api::{
agent_runtime_compact_session, agent_runtime_get_session, agent_runtime_get_thread_read,
agent_runtime_compact_session, agent_runtime_export_analysis_handoff,
agent_runtime_export_evidence_pack, agent_runtime_export_handoff_bundle,
agent_runtime_export_replay_case, agent_runtime_get_session, agent_runtime_get_thread_read,
agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn,
agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn,
agent_runtime_replay_request, agent_runtime_resume_thread, agent_runtime_submit_turn,
@@ -1,5 +1,21 @@
use super::*;
use crate::services::runtime_analysis_handoff_service::{
export_runtime_analysis_handoff, RuntimeAnalysisHandoffExportResult,
};
use crate::services::runtime_evidence_pack_service::{
export_runtime_evidence_pack, RuntimeEvidencePackExportResult,
};
use crate::services::runtime_handoff_artifact_service::{
export_runtime_handoff_bundle, RuntimeHandoffBundleExportResult,
};
use crate::services::runtime_replay_case_service::{
export_runtime_replay_case, RuntimeReplayCaseExportResult,
};
use crate::services::runtime_review_decision_service::{
export_runtime_review_decision_template, RuntimeReviewDecisionTemplateExportResult,
};
use crate::services::thread_reliability_projection_service::sync_thread_reliability_projection;
use std::path::PathBuf;
#[tauri::command]
pub async fn agent_runtime_submit_turn(
@@ -205,6 +221,274 @@ pub async fn agent_runtime_get_thread_read(
))
}
struct RuntimeExportContext {
detail: SessionDetail,
thread_read: AgentRuntimeThreadReadModel,
workspace_root: PathBuf,
}
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())
}
async fn load_runtime_export_context(
app: &AppHandle,
state: &AsterAgentState,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderServiceState,
logs: &LogState,
config_manager: &GlobalConfigManagerState,
mcp_manager: &McpManagerState,
automation_state: &AutomationServiceState,
session_id: &str,
action_label: &str,
) -> Result<RuntimeExportContext, String> {
if let Err(error) = resume_runtime_queue_if_needed_service(
app.clone(),
state,
db,
api_key_provider_service,
logs,
config_manager,
mcp_manager,
automation_state,
session_id.to_string(),
build_runtime_queue_executor(),
)
.await
{
tracing::warn!(
"[AsterAgent][Queue] {} 前恢复排队执行失败: session_id={}, error={}",
action_label,
session_id,
error
);
}
let detail = AsterAgentWrapper::get_runtime_session_detail(db, session_id).await?;
let queued_turns = list_runtime_queue_snapshots_service(session_id).await?;
let projection = sync_thread_reliability_projection(db, &detail)?;
let interrupt_marker = state.get_interrupt_marker(session_id).await;
let thread_read = AgentRuntimeThreadReadModel::from_parts(
&detail,
&queued_turns,
projection.pending_requests,
projection.last_outcome,
projection.incidents,
interrupt_marker.as_ref(),
);
let workspace_root = resolve_runtime_export_workspace_root(db, &detail)?;
Ok(RuntimeExportContext {
detail,
thread_read,
workspace_root,
})
}
/// 统一运行时:导出当前会话的交接制品 bundle。
#[tauri::command]
pub async fn agent_runtime_export_handoff_bundle(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
session_id: String,
) -> Result<RuntimeHandoffBundleExportResult, String> {
tracing::info!("[AsterAgent] 导出 handoff bundle: {}", session_id);
let context = load_runtime_export_context(
&app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
&session_id,
"导出 handoff bundle",
)
.await?;
export_runtime_handoff_bundle(
&context.detail,
&context.thread_read,
&context.workspace_root,
)
}
/// 统一运行时:导出当前会话的最小问题证据包。
#[tauri::command]
pub async fn agent_runtime_export_evidence_pack(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
session_id: String,
) -> Result<RuntimeEvidencePackExportResult, String> {
tracing::info!("[AsterAgent] 导出 evidence pack: {}", session_id);
let context = load_runtime_export_context(
&app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
&session_id,
"导出 evidence pack",
)
.await?;
export_runtime_evidence_pack(
&context.detail,
&context.thread_read,
&context.workspace_root,
)
}
/// 统一运行时:导出当前会话的外部分析交接包。
#[tauri::command]
pub async fn agent_runtime_export_analysis_handoff(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
session_id: String,
) -> Result<RuntimeAnalysisHandoffExportResult, String> {
tracing::info!("[AsterAgent] 导出 analysis handoff: {}", session_id);
let context = load_runtime_export_context(
&app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
&session_id,
"导出 analysis handoff",
)
.await?;
export_runtime_analysis_handoff(
&context.detail,
&context.thread_read,
&context.workspace_root,
)
}
/// 统一运行时:导出当前会话的人工审核记录模板。
#[tauri::command]
pub async fn agent_runtime_export_review_decision_template(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
session_id: String,
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
tracing::info!("[AsterAgent] 导出 review decision 模板: {}", session_id);
let context = load_runtime_export_context(
&app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
&session_id,
"导出 review decision 模板",
)
.await?;
export_runtime_review_decision_template(
&context.detail,
&context.thread_read,
&context.workspace_root,
)
}
/// 统一运行时:导出当前会话的 replay case。
#[tauri::command]
pub async fn agent_runtime_export_replay_case(
app: AppHandle,
state: State<'_, AsterAgentState>,
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
logs: State<'_, LogState>,
config_manager: State<'_, GlobalConfigManagerState>,
mcp_manager: State<'_, McpManagerState>,
automation_state: State<'_, AutomationServiceState>,
session_id: String,
) -> Result<RuntimeReplayCaseExportResult, String> {
tracing::info!("[AsterAgent] 导出 replay case: {}", session_id);
let context = load_runtime_export_context(
&app,
state.inner(),
db.inner(),
api_key_provider_service.inner(),
logs.inner(),
config_manager.inner(),
mcp_manager.inner(),
automation_state.inner(),
&session_id,
"导出 replay case",
)
.await?;
export_runtime_replay_case(
&context.detail,
&context.thread_read,
&context.workspace_root,
)
}
/// 统一运行时:重新拉起指定 pending request 的前端交互载荷。
#[tauri::command]
pub async fn agent_runtime_replay_request(
@@ -91,5 +91,21 @@ pub async fn agent_runtime_update_session(
)?;
}
if let Some(recent_preferences) = request.recent_preferences {
AsterAgentWrapper::persist_session_recent_preferences(
&trimmed_session_id,
recent_preferences,
)
.await?;
}
if let Some(recent_team_selection) = request.recent_team_selection {
AsterAgentWrapper::persist_session_recent_team_selection(
&trimmed_session_id,
recent_team_selection,
)
.await?;
}
Ok(())
}
@@ -140,7 +140,7 @@ pub struct AgentRuntimeSubmitTurnRequest {
#[serde(default)]
pub images: Option<Vec<ImageInput>>,
#[serde(alias = "workspaceId")]
pub workspace_id: String,
pub workspace_id: Option<String>,
#[serde(default, alias = "turnConfig")]
pub turn_config: Option<AgentTurnConfigSnapshot>,
#[serde(default, alias = "turnId")]
@@ -173,7 +173,7 @@ impl From<AgentRuntimeSubmitTurnRequest> for AsterChatRequest {
.as_ref()
.and_then(|config| config.thinking_enabled),
project_id: None,
workspace_id: request.workspace_id,
workspace_id: request.workspace_id.unwrap_or_default(),
web_search: turn_config.as_ref().and_then(|config| config.web_search),
search_mode: turn_config.as_ref().and_then(|config| config.search_mode),
execution_strategy: turn_config
@@ -1595,6 +1595,10 @@ pub struct AgentRuntimeUpdateSessionRequest {
pub model_name: Option<String>,
#[serde(default, alias = "executionStrategy")]
pub execution_strategy: Option<AsterExecutionStrategy>,
#[serde(default, alias = "recentPreferences")]
pub recent_preferences: Option<lime_agent::SessionExecutionRuntimePreferences>,
#[serde(default, alias = "recentTeamSelection")]
pub recent_team_selection: Option<lime_agent::SessionExecutionRuntimeRecentTeamSelection>,
}
/// 自动续写参数
+22 -12
View File
@@ -24,8 +24,8 @@ use crate::agent_tools::catalog::{
LIME_CREATE_IMAGE_TASK_TOOL_NAME, LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME,
LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, LIME_CREATE_URL_PARSE_TASK_TOOL_NAME,
LIME_CREATE_VIDEO_TASK_TOOL_NAME, LIME_SITE_INFO_TOOL_NAME, LIME_SITE_LIST_TOOL_NAME,
LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME,
TOOL_SEARCH_TOOL_NAME,
LIME_SITE_RECOMMEND_TOOL_NAME, LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME,
SOCIAL_IMAGE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME,
};
#[cfg(test)]
use crate::agent_tools::execution::build_workspace_shell_allow_pattern;
@@ -301,6 +301,8 @@ pub(crate) use browser_assist::{
#[allow(unused_imports)]
pub(crate) use command_api::{
agent_runtime_close_subagent, agent_runtime_compact_session, agent_runtime_create_session,
agent_runtime_export_analysis_handoff, agent_runtime_export_evidence_pack,
agent_runtime_export_handoff_bundle, agent_runtime_export_replay_case,
agent_runtime_get_session, agent_runtime_get_thread_read, agent_runtime_get_tool_inventory,
agent_runtime_interrupt_turn, agent_runtime_list_sessions, agent_runtime_promote_queued_turn,
agent_runtime_remove_queued_turn, agent_runtime_replay_request, agent_runtime_resume_subagent,
@@ -309,21 +311,23 @@ pub(crate) use command_api::{
aster_agent_configure_from_pool, aster_agent_configure_provider, aster_agent_init,
aster_agent_reset, aster_agent_status,
};
#[allow(unused_imports)]
pub(crate) use dto::{
build_incidents, build_last_outcome, build_pending_requests, AgentRuntimeActionType,
AgentRuntimeCloseSubagentRequest, AgentRuntimeCloseSubagentResponse,
AgentRuntimeCompactSessionRequest, AgentRuntimeIncidentView, AgentRuntimeInterruptTurnRequest,
AgentRuntimeOutcomeView, AgentRuntimePromoteQueuedTurnRequest,
AgentRuntimeRemoveQueuedTurnRequest, AgentRuntimeReplayRequestRequest,
AgentRuntimeReplayedActionRequiredView, AgentRuntimeRequestView,
AgentRuntimeRespondActionRequest, AgentRuntimeResumeSubagentRequest,
AgentRuntimeCompactSessionRequest, AgentRuntimeDiagnosticPendingRequestSample,
AgentRuntimeDiagnosticWarningSample, AgentRuntimeIncidentView,
AgentRuntimeInterruptTurnRequest, AgentRuntimeOutcomeView,
AgentRuntimePromoteQueuedTurnRequest, AgentRuntimeRemoveQueuedTurnRequest,
AgentRuntimeReplayRequestRequest, AgentRuntimeReplayedActionRequiredView,
AgentRuntimeRequestView, AgentRuntimeRespondActionRequest, AgentRuntimeResumeSubagentRequest,
AgentRuntimeResumeSubagentResponse, AgentRuntimeResumeThreadRequest,
AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse,
AgentRuntimeSessionDetail, AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse,
AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadReadModel, AgentRuntimeToolInventoryRequest,
AgentRuntimeUpdateSessionRequest, AgentRuntimeWaitSubagentsRequest,
AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, AsterChatRequest, AutoContinuePayload,
ConfigureFromPoolRequest, ConfigureProviderRequest,
AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadDiagnostics, AgentRuntimeThreadReadModel,
AgentRuntimeToolInventoryRequest, AgentRuntimeUpdateSessionRequest,
AgentRuntimeWaitSubagentsRequest, AgentRuntimeWaitSubagentsResponse, AsterAgentStatus,
AsterChatRequest, AutoContinuePayload, ConfigureFromPoolRequest, ConfigureProviderRequest,
};
pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensions};
#[cfg(test)]
@@ -351,9 +355,15 @@ use run_metadata::{
resolve_social_run_artifact_descriptor,
};
pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_executor};
#[cfg(test)]
pub(crate) use runtime_turn::{
resolve_request_web_search_preference_from_sources, resolve_workspace_id_from_sources,
};
pub(crate) use session_runtime::{
delete_runtime_session_internal, persist_session_provider_routing,
resolve_session_provider_selector,
resolve_recent_preference_from_sources, resolve_session_provider_selector,
resolve_session_recent_harness_context, resolve_session_recent_preferences,
resolve_session_recent_team_selection, SessionRecentHarnessContext,
};
pub(crate) use subagent_runtime::{
agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal,
@@ -234,32 +234,97 @@ fn render_team_roles(role_items: &[serde_json::Value]) -> Vec<String> {
pub(crate) fn build_team_preference_system_prompt(
request_metadata: Option<&serde_json::Value>,
session_recent_team_selection: Option<&lime_agent::SessionExecutionRuntimeRecentTeamSelection>,
subagent_mode_enabled: bool,
) -> Option<String> {
let subagent_mode_enabled = extract_harness_bool(
request_metadata,
&["subagent_mode_enabled", "subagentModeEnabled"],
)
.unwrap_or(false);
let preferred_team_preset_id = extract_harness_string(
let request_has_team_selection = extract_harness_string(
request_metadata,
&["preferred_team_preset_id", "preferredTeamPresetId"],
);
let selected_team_source = extract_harness_string(
request_metadata,
&["selected_team_source", "selectedTeamSource"],
);
let selected_team_label = extract_harness_string(
request_metadata,
&["selected_team_label", "selectedTeamLabel"],
);
let selected_team_summary = extract_harness_string(
request_metadata,
&["selected_team_summary", "selectedTeamSummary"],
);
let selected_team_roles = extract_harness_array(
request_metadata,
&["selected_team_roles", "selectedTeamRoles"],
);
)
.is_some()
|| extract_harness_string(request_metadata, &["selected_team_id", "selectedTeamId"])
.is_some()
|| extract_harness_string(
request_metadata,
&["selected_team_source", "selectedTeamSource"],
)
.is_some()
|| extract_harness_string(
request_metadata,
&["selected_team_label", "selectedTeamLabel"],
)
.is_some()
|| extract_harness_string(
request_metadata,
&["selected_team_summary", "selectedTeamSummary"],
)
.is_some()
|| extract_harness_array(
request_metadata,
&["selected_team_roles", "selectedTeamRoles"],
)
.is_some();
let preferred_team_preset_id = if request_has_team_selection {
extract_harness_string(
request_metadata,
&["preferred_team_preset_id", "preferredTeamPresetId"],
)
} else {
session_recent_team_selection
.and_then(|selection| selection.preferred_team_preset_id.clone())
};
let selected_team_source = if request_has_team_selection {
extract_harness_string(
request_metadata,
&["selected_team_source", "selectedTeamSource"],
)
} else {
session_recent_team_selection.and_then(|selection| selection.selected_team_source.clone())
};
let selected_team_label = if request_has_team_selection {
extract_harness_string(
request_metadata,
&["selected_team_label", "selectedTeamLabel"],
)
} else {
session_recent_team_selection.and_then(|selection| selection.selected_team_label.clone())
};
let selected_team_summary = if request_has_team_selection {
extract_harness_string(
request_metadata,
&["selected_team_summary", "selectedTeamSummary"],
)
} else {
session_recent_team_selection.and_then(|selection| selection.selected_team_summary.clone())
};
let selected_team_roles = if request_has_team_selection {
extract_harness_array(
request_metadata,
&["selected_team_roles", "selectedTeamRoles"],
)
.cloned()
.filter(|roles| !roles.is_empty())
} else {
session_recent_team_selection
.and_then(|selection| selection.selected_team_roles.as_ref())
.map(|roles| {
roles
.iter()
.map(|role| {
serde_json::json!({
"id": role.id,
"label": role.label,
"summary": role.summary,
"profile_id": role.profile_id,
"role_key": role.role_key,
"skill_ids": role.skill_ids,
})
})
.collect::<Vec<_>>()
})
.filter(|roles| !roles.is_empty())
};
if !subagent_mode_enabled {
return None;
@@ -299,7 +364,7 @@ pub(crate) fn build_team_preference_system_prompt(
lines.push(format!("- Team 摘要:{team_summary}"));
}
if let Some(role_items) = selected_team_roles {
if let Some(role_items) = selected_team_roles.as_ref() {
let rendered_roles = render_team_roles(role_items);
if !rendered_roles.is_empty() {
lines.push("- 当前 Team 角色参考:".to_string());
@@ -330,8 +395,14 @@ pub(crate) fn build_team_preference_system_prompt(
pub(crate) fn merge_system_prompt_with_team_preference(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
session_recent_team_selection: Option<&lime_agent::SessionExecutionRuntimeRecentTeamSelection>,
subagent_mode_enabled: bool,
) -> Option<String> {
let Some(team_prompt) = build_team_preference_system_prompt(request_metadata) else {
let Some(team_prompt) = build_team_preference_system_prompt(
request_metadata,
session_recent_team_selection,
subagent_mode_enabled,
) else {
return base_prompt;
};
@@ -144,28 +144,59 @@ fn message_suggests_content_generation(message: &str) -> bool {
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn build_turn_runtime_statuses(
fn resolve_request_thinking_enabled_from_sources(
request: &AsterChatRequest,
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> bool {
request
.thinking_enabled
.or_else(|| {
resolve_recent_preference_from_sources(
request.metadata.as_ref(),
&["thinking_enabled", "thinkingEnabled"],
session_recent_preferences.map(|preferences| preferences.thinking),
)
})
.unwrap_or(false)
}
fn resolve_request_task_enabled_from_sources(
request: &AsterChatRequest,
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> bool {
resolve_recent_preference_from_sources(
request.metadata.as_ref(),
&["task_mode_enabled", "taskModeEnabled"],
session_recent_preferences.map(|preferences| preferences.task),
)
.unwrap_or(false)
}
fn resolve_request_subagent_enabled_from_sources(
request: &AsterChatRequest,
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> bool {
resolve_recent_preference_from_sources(
request.metadata.as_ref(),
&["subagent_mode_enabled", "subagentModeEnabled"],
session_recent_preferences.map(|preferences| preferences.subagent),
)
.unwrap_or(false)
}
pub(super) async fn build_turn_runtime_statuses(
request: &AsterChatRequest,
effective_strategy: AsterExecutionStrategy,
request_tool_policy: &RequestToolPolicy,
model_name: Option<&str>,
) -> (AgentRuntimeStatus, AgentRuntimeStatus) {
let thinking_enabled = extract_harness_bool(
request.metadata.as_ref(),
&["thinking_enabled", "thinkingEnabled"],
)
.or(request.thinking_enabled)
.unwrap_or(false);
let task_enabled = extract_harness_bool(
request.metadata.as_ref(),
&["task_mode_enabled", "taskModeEnabled"],
)
.unwrap_or(false);
let subagent_enabled = extract_harness_bool(
request.metadata.as_ref(),
&["subagent_mode_enabled", "subagentModeEnabled"],
)
.unwrap_or(false);
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> Result<(AgentRuntimeStatus, AgentRuntimeStatus), String> {
let thinking_enabled =
resolve_request_thinking_enabled_from_sources(request, session_recent_preferences);
let task_enabled =
resolve_request_task_enabled_from_sources(request, session_recent_preferences);
let subagent_enabled =
resolve_request_subagent_enabled_from_sources(request, session_recent_preferences);
let reasoning_supported = model_supports_reasoning(model_name);
let news_expansion_needed = request_tool_policy.allows_web_search()
&& message_suggests_news_expansion(&request.message);
@@ -306,7 +337,7 @@ pub(super) fn build_turn_runtime_statuses(
)
};
(
Ok((
AgentRuntimeStatus {
phase: "preparing".to_string(),
title: "正在理解意图".to_string(),
@@ -322,7 +353,7 @@ pub(super) fn build_turn_runtime_statuses(
checkpoints: decided.2,
metadata: None,
},
)
))
}
fn emit_projected_runtime_item_event(
@@ -564,16 +564,26 @@ fn resolve_provider_model_compatibility(provider_key: &str, model_id: &str) -> S
model_id.to_string()
}
fn extract_request_thinking_enabled(request: &AsterChatRequest) -> bool {
request.thinking_enabled.unwrap_or_else(|| {
fn extract_request_thinking_enabled(request: &AsterChatRequest) -> Option<bool> {
request.thinking_enabled.or_else(|| {
extract_harness_bool(
request.metadata.as_ref(),
&["thinking_enabled", "thinkingEnabled"],
)
.unwrap_or(false)
})
}
async fn resolve_request_thinking_enabled(request: &AsterChatRequest) -> Result<bool, String> {
if let Some(thinking_enabled) = extract_request_thinking_enabled(request) {
return Ok(thinking_enabled);
}
Ok(resolve_session_recent_preferences(&request.session_id)
.await?
.map(|preferences| preferences.thinking)
.unwrap_or(false))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RequestPreferenceSource {
Request,
@@ -699,7 +709,7 @@ pub(super) async fn resolve_runtime_request_provider_config(
let context =
build_provider_resolution_context(db, api_key_provider_service, &provider_selector)?;
let (catalog, _alias_config) = load_model_registry_catalog(app, &context).await;
let thinking_enabled = extract_request_thinking_enabled(request);
let thinking_enabled = resolve_request_thinking_enabled(request).await?;
let has_images = request
.images
.as_ref()
@@ -125,6 +125,8 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
("selectedTeamSource", "selected_team_source"),
("selected_team_label", "selected_team_label"),
("selectedTeamLabel", "selected_team_label"),
("selected_team_description", "selected_team_description"),
("selectedTeamDescription", "selected_team_description"),
("selected_team_summary", "selected_team_summary"),
("selectedTeamSummary", "selected_team_summary"),
("selected_team_roles", "selected_team_roles"),
@@ -178,6 +180,7 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
request_tool_policy: &RequestToolPolicy,
auto_continue_enabled: bool,
auto_continue_metadata: Option<&AutoContinuePayload>,
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> serde_json::Map<String, serde_json::Value> {
let mut metadata = serde_json::Map::new();
metadata.insert("workspace_id".to_string(), serde_json::json!(workspace_id));
@@ -214,6 +217,34 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
serde_json::json!(auto_continue_metadata),
);
extend_map_with_harness_fields(&mut metadata, request.metadata.as_ref());
for (target_key, preference_keys, session_value) in [
(
"thinking_enabled",
&["thinking_enabled", "thinkingEnabled"][..],
session_recent_preferences.map(|preferences| preferences.thinking),
),
(
"task_mode_enabled",
&["task_mode_enabled", "taskModeEnabled"][..],
session_recent_preferences.map(|preferences| preferences.task),
),
(
"subagent_mode_enabled",
&["subagent_mode_enabled", "subagentModeEnabled"][..],
session_recent_preferences.map(|preferences| preferences.subagent),
),
] {
if metadata.contains_key(target_key) {
continue;
}
if let Some(value) = resolve_recent_preference_from_sources(
request.metadata.as_ref(),
preference_keys,
session_value,
) {
metadata.insert(target_key.to_string(), serde_json::json!(value));
}
}
metadata
}
@@ -56,6 +56,64 @@ fn merge_turn_context_with_artifact_output_schema(
)
}
fn normalize_runtime_turn_request_metadata(
request: &mut AsterChatRequest,
session_recent_theme: Option<&str>,
session_recent_session_mode: Option<&str>,
session_recent_gate_key: Option<&str>,
session_recent_run_title: Option<&str>,
session_recent_content_id: Option<&str>,
) {
request.metadata = crate::services::artifact_request_metadata_service::
normalize_request_metadata_with_artifact_defaults(
request.metadata.take(),
session_recent_theme,
session_recent_session_mode,
session_recent_gate_key,
session_recent_run_title,
session_recent_content_id,
);
}
pub(crate) fn resolve_workspace_id_from_sources(
request_workspace_id: Option<String>,
session_workspace_id: Option<String>,
) -> Option<String> {
normalize_optional_text(request_workspace_id)
.or_else(|| normalize_optional_text(session_workspace_id))
}
fn resolve_runtime_turn_workspace_id(
db: &DbConnection,
request: &AsterChatRequest,
) -> Result<String, String> {
if let Some(workspace_id) =
resolve_workspace_id_from_sources(Some(request.workspace_id.clone()), None)
{
return Ok(workspace_id);
}
let session_workspace_id =
AsterAgentWrapper::get_session_sync(db, &request.session_id)?.workspace_id;
resolve_workspace_id_from_sources(None, session_workspace_id)
.ok_or_else(|| "workspace_id 必填,请先选择项目工作区".to_string())
}
pub(crate) fn resolve_request_web_search_preference_from_sources(
request_web_search: Option<bool>,
request_metadata: Option<&serde_json::Value>,
session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>,
) -> Option<bool> {
request_web_search.or_else(|| {
resolve_recent_preference_from_sources(
request_metadata,
&["web_search_enabled", "webSearchEnabled"],
session_recent_preferences.map(|preferences| preferences.web_search),
)
})
}
fn should_skip_artifact_document_autopersist(
run_observation: &Arc<Mutex<ChatRunObservation>>,
final_text_output: &str,
@@ -75,6 +133,7 @@ fn should_skip_artifact_document_autopersist(
fn maybe_persist_artifact_document_after_stream(
app: &AppHandle,
db: &DbConnection,
event_name: &str,
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
run_observation: &Arc<Mutex<ChatRunObservation>>,
@@ -137,6 +196,19 @@ fn maybe_persist_artifact_document_after_stream(
},
);
if let Err(error) =
crate::services::artifact_document_service::sync_persisted_artifact_document_to_content(
db,
request_metadata,
&persisted,
)
{
tracing::warn!(
"[AsterAgent] ArtifactDocument 已落盘,但同步内容版本状态失败: {}",
error
);
}
if persisted.repaired || persisted.status == "failed" {
let (code, prefix) = if persisted.status == "failed" {
(
@@ -218,20 +290,49 @@ async fn execute_aster_chat_request(
{
request.provider_config = Some(resolved_provider_config);
}
let should_resolve_session_recent_harness_context = extract_harness_string(
request.metadata.as_ref(),
&["theme", "harness_theme", "harnessTheme"],
)
.is_none()
|| extract_harness_string(request.metadata.as_ref(), &["session_mode", "sessionMode"])
.is_none()
|| extract_harness_string(request.metadata.as_ref(), &["gate_key", "gateKey"]).is_none()
|| extract_harness_string(
request.metadata.as_ref(),
&["run_title", "runTitle", "title"],
)
.is_none()
|| extract_harness_string(request.metadata.as_ref(), &["content_id", "contentId"])
.is_none();
let session_recent_harness_context = if should_resolve_session_recent_harness_context {
resolve_session_recent_harness_context(&request.session_id).await?
} else {
SessionRecentHarnessContext::default()
};
normalize_runtime_turn_request_metadata(
&mut request,
session_recent_harness_context.theme.as_deref(),
session_recent_harness_context.session_mode.as_deref(),
session_recent_harness_context.gate_key.as_deref(),
session_recent_harness_context.run_title.as_deref(),
session_recent_harness_context.content_id.as_deref(),
);
// 直接使用前端传递的 session_id
// LimeSessionStore 会在 add_message 时自动创建不存在的 session
// 同时 get_session 也会自动创建不存在的 session
let session_id = &request.session_id;
let workspace_id = request.workspace_id.trim().to_string();
if workspace_id.is_empty() {
let message = "workspace_id 必填,请先选择项目工作区".to_string();
logs.write()
.await
.add("error", &format!("[AsterAgent] {}", message));
return Err(message);
}
let workspace_id = match resolve_runtime_turn_workspace_id(db, &request) {
Ok(workspace_id) => workspace_id,
Err(message) => {
logs.write()
.await
.add("error", &format!("[AsterAgent] {}", message));
return Err(message);
}
};
let manager = WorkspaceManager::new(db.clone());
let workspace = match manager.get(&workspace_id) {
@@ -336,12 +437,19 @@ async fn execute_aster_chat_request(
);
}
let session_recent_preferences = resolve_session_recent_preferences(session_id).await?;
let session_recent_team_selection = resolve_session_recent_team_selection(session_id).await?;
let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref());
let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode);
let resolved_request_web_search = resolve_request_web_search_preference_from_sources(
request.web_search,
request.metadata.as_ref(),
session_recent_preferences.as_ref(),
);
let (request_web_search, request_search_mode) =
apply_browser_requirement_to_request_tool_policy(
request.metadata.as_ref(),
request.web_search,
resolved_request_web_search,
request.search_mode,
);
@@ -497,6 +605,15 @@ async fn execute_aster_chat_request(
let prompt_with_team_preference = merge_system_prompt_with_team_preference(
prompt_with_elicitation,
request.metadata.as_ref(),
session_recent_team_selection.as_ref(),
resolve_recent_preference_from_sources(
request.metadata.as_ref(),
&["subagent_mode_enabled", "subagentModeEnabled"],
session_recent_preferences
.as_ref()
.map(|preferences| preferences.subagent),
)
.unwrap_or(false),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::TeamPreference,
@@ -698,8 +815,6 @@ async fn execute_aster_chat_request(
let tracker = ExecutionTracker::new(db.clone());
let cancel_token = state.create_cancel_token(session_id).await;
let auto_continue_metadata = auto_continue_config.clone();
request.metadata = crate::services::artifact_request_metadata_service::
normalize_request_metadata_with_artifact_defaults(request.metadata.take());
let request_metadata = request.metadata.clone();
sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await;
let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref());
@@ -768,6 +883,7 @@ async fn execute_aster_chat_request(
&request_tool_policy,
auto_continue_enabled,
auto_continue_metadata.as_ref(),
session_recent_preferences.as_ref(),
);
if let Ok(session_state_value) = serde_json::to_value(&session_state_snapshot) {
run_start_metadata.insert("session_state".to_string(), session_state_value);
@@ -830,7 +946,9 @@ async fn execute_aster_chat_request(
.provider_config
.as_ref()
.map(|config| config.model_name.as_str()),
);
session_recent_preferences.as_ref(),
)
.await?;
for status in [initial_runtime_status, decided_runtime_status] {
emit_runtime_status_with_projection(
agent,
@@ -936,6 +1054,7 @@ async fn execute_aster_chat_request(
Ok(execution) => {
maybe_persist_artifact_document_after_stream(
&app,
db,
&request.event_name,
&timeline_recorder,
&run_observation,
@@ -1026,6 +1145,7 @@ async fn execute_aster_chat_request(
.map(|execution| {
maybe_persist_artifact_document_after_stream(
&app,
db,
&request.event_name,
&timeline_recorder,
&run_observation,
@@ -1813,6 +1933,7 @@ mod tests {
use lime_core::database::schema::create_tables;
use lime_services::aster_session_store::LimeSessionStore;
use rusqlite::Connection;
use serde_json::{json, Value};
use std::fs;
use tokio::sync::OnceCell;
@@ -1839,6 +1960,270 @@ mod tests {
.await;
}
#[test]
fn normalize_runtime_turn_request_metadata_should_enable_artifact_prompt_before_turn_build() {
let mut request = AsterChatRequest {
message: "请基于目标先生成一版演示提纲".to_string(),
session_id: "session-artifact".to_string(),
event_name: "agent_stream".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: None,
workspace_id: "workspace-artifact".to_string(),
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt: None,
metadata: Some(json!({
"harness": {
"theme": "document",
"session_mode": "theme_workbench",
"content_id": "content-1"
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
};
let raw_prompt = merge_system_prompt_with_artifact_context(
Some("基础系统提示".to_string()),
request.metadata.as_ref(),
)
.expect("raw prompt");
assert!(!raw_prompt.contains("【Artifact 交付策略】"));
normalize_runtime_turn_request_metadata(&mut request, None, None, None, None, None);
let normalized_metadata = request.metadata.as_ref().expect("normalized metadata");
assert_eq!(
normalized_metadata
.pointer("/artifact/artifact_mode")
.and_then(Value::as_str),
Some("draft")
);
let prompt = merge_system_prompt_with_artifact_context(
Some("基础系统提示".to_string()),
Some(normalized_metadata),
)
.expect("normalized prompt");
assert!(prompt.contains("【Artifact 交付策略】"));
assert!(prompt.contains("【Artifact Stage 2 合同】"));
assert!(prompt.contains("artifact:content-1"));
let mut turn_input_builder =
TurnInputEnvelopeBuilder::new(&request.session_id, &request.workspace_id);
turn_input_builder
.set_base_system_prompt(
TurnSystemPromptSource::Frontend,
Some("基础系统提示".to_string()),
)
.set_turn_context_metadata_from_value(request.metadata.as_ref())
.set_effective_user_message(&request.message)
.apply_prompt_stage(TurnPromptAugmentationStageKind::Artifact, Some(prompt));
let envelope = turn_input_builder.build();
let diagnostics = envelope.diagnostics_snapshot();
let turn_context = envelope.turn_context_override().expect("turn context");
assert!(diagnostics.has_turn_context_metadata);
assert!(diagnostics
.turn_context_metadata_keys
.contains(&"artifact".to_string()));
assert_eq!(
turn_context
.metadata
.get("artifact")
.and_then(|artifact| artifact.get("artifact_stage"))
.and_then(Value::as_str),
Some("stage2")
);
}
#[test]
fn normalize_runtime_turn_request_metadata_should_backfill_content_id_from_session_runtime() {
let mut request = AsterChatRequest {
message: "继续完善当前文档".to_string(),
session_id: "session-artifact-content-fallback".to_string(),
event_name: "agent_stream".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: None,
workspace_id: "workspace-artifact".to_string(),
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt: None,
metadata: Some(json!({
"harness": {
"theme": "document",
"session_mode": "theme_workbench"
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
};
normalize_runtime_turn_request_metadata(
&mut request,
Some("document"),
Some("theme_workbench"),
None,
None,
Some("content-from-session"),
);
let normalized_metadata = request.metadata.as_ref().expect("normalized metadata");
assert_eq!(
normalized_metadata
.pointer("/harness/theme")
.and_then(Value::as_str),
Some("document")
);
assert_eq!(
normalized_metadata
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
);
assert_eq!(
normalized_metadata
.pointer("/harness/content_id")
.and_then(Value::as_str),
Some("content-from-session")
);
assert_eq!(
normalized_metadata
.pointer("/artifact/artifact_request_id")
.and_then(Value::as_str),
Some("artifact:content-from-session")
);
}
#[test]
fn normalize_runtime_turn_request_metadata_should_backfill_theme_and_session_mode_from_session_runtime(
) {
let mut request = AsterChatRequest {
message: "继续推进当前主题工作台".to_string(),
session_id: "session-artifact-theme-fallback".to_string(),
event_name: "agent_stream".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: None,
workspace_id: "workspace-artifact".to_string(),
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt: None,
metadata: Some(json!({
"harness": {
"content_id": "content-from-session"
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
};
normalize_runtime_turn_request_metadata(
&mut request,
Some("social-media"),
Some("theme_workbench"),
None,
None,
Some("content-from-session"),
);
let normalized_metadata = request.metadata.as_ref().expect("normalized metadata");
assert_eq!(
normalized_metadata
.pointer("/harness/theme")
.and_then(Value::as_str),
Some("social-media")
);
assert_eq!(
normalized_metadata
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
);
assert_eq!(
normalized_metadata
.pointer("/harness/content_id")
.and_then(Value::as_str),
Some("content-from-session")
);
}
#[test]
fn normalize_runtime_turn_request_metadata_should_backfill_gate_key_and_run_title_from_session_runtime(
) {
let mut request = AsterChatRequest {
message: "继续当前社媒运行".to_string(),
session_id: "session-social-gate-fallback".to_string(),
event_name: "agent_stream".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: None,
workspace_id: "workspace-social".to_string(),
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt: None,
metadata: Some(json!({
"harness": {
"theme": "social-media",
"session_mode": "theme_workbench",
"content_id": "content-social-1"
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
};
normalize_runtime_turn_request_metadata(
&mut request,
Some("social-media"),
Some("theme_workbench"),
Some("write_mode"),
Some("社媒初稿"),
Some("content-social-1"),
);
let normalized_metadata = request.metadata.as_ref().expect("normalized metadata");
assert_eq!(
normalized_metadata
.pointer("/harness/gate_key")
.and_then(Value::as_str),
Some("write_mode")
);
assert_eq!(
normalized_metadata
.pointer("/harness/run_title")
.and_then(Value::as_str),
Some("社媒初稿")
);
}
#[tokio::test]
async fn update_compaction_session_metrics_should_move_summary_tokens_to_current_window() {
ensure_runtime_turn_test_session_manager().await;
@@ -1,4 +1,5 @@
use super::*;
use aster::session::load_shared_session_runtime_snapshot;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SessionProviderRoutingState {
@@ -39,6 +40,15 @@ impl SessionProviderRoutingState {
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SessionRecentHarnessContext {
pub(crate) theme: Option<String>,
pub(crate) session_mode: Option<String>,
pub(crate) gate_key: Option<String>,
pub(crate) run_title: Option<String>,
pub(crate) content_id: Option<String>,
}
pub(crate) async fn persist_session_provider_routing(
session_id: &str,
provider_selector: &str,
@@ -59,6 +69,88 @@ pub(crate) fn resolve_session_provider_selector(
SessionProviderRoutingState::from_session(session).map(|state| state.provider_selector)
}
pub(crate) async fn resolve_session_recent_preferences(
session_id: &str,
) -> Result<Option<lime_agent::SessionExecutionRuntimePreferences>, String> {
let session = read_session(session_id, false, "读取会话 recent_preferences 失败").await?;
Ok(lime_agent::build_session_execution_runtime(
session_id,
Some(&session),
None,
None,
resolve_session_provider_selector(&session),
)
.and_then(|runtime| runtime.recent_preferences))
}
pub(crate) async fn resolve_session_recent_team_selection(
session_id: &str,
) -> Result<Option<lime_agent::SessionExecutionRuntimeRecentTeamSelection>, String> {
let session = read_session(session_id, false, "读取会话 recent_team_selection 失败").await?;
Ok(lime_agent::build_session_execution_runtime(
session_id,
Some(&session),
None,
None,
resolve_session_provider_selector(&session),
)
.and_then(|runtime| runtime.recent_team_selection))
}
pub(crate) async fn resolve_session_recent_harness_context(
session_id: &str,
) -> Result<SessionRecentHarnessContext, String> {
let trimmed_session_id = session_id.trim();
if trimmed_session_id.is_empty() {
return Ok(SessionRecentHarnessContext::default());
}
match load_shared_session_runtime_snapshot(trimmed_session_id).await {
Ok(snapshot) => {
let runtime = lime_agent::build_session_execution_runtime(
trimmed_session_id,
None,
None,
Some(&snapshot),
None,
);
Ok(SessionRecentHarnessContext {
theme: runtime
.as_ref()
.and_then(|value| value.recent_theme.clone()),
session_mode: runtime
.as_ref()
.and_then(|value| value.recent_session_mode.clone()),
gate_key: runtime
.as_ref()
.and_then(|value| value.recent_gate_key.clone()),
run_title: runtime
.as_ref()
.and_then(|value| value.recent_run_title.clone()),
content_id: runtime
.as_ref()
.and_then(|value| value.recent_content_id.clone()),
})
}
Err(error) => {
tracing::debug!(
"[AsterAgent] 读取 runtime snapshot 失败,跳过 recent harness context 回退: session_id={}, error={}",
trimmed_session_id,
error
);
Ok(SessionRecentHarnessContext::default())
}
}
}
pub(crate) fn resolve_recent_preference_from_sources(
request_metadata: Option<&serde_json::Value>,
keys: &[&str],
session_recent_preference: Option<bool>,
) -> Option<bool> {
extract_harness_bool(request_metadata, keys).or(session_recent_preference)
}
pub(crate) async fn create_runtime_session_internal(
db: &DbConnection,
working_dir: Option<String>,
+365 -5
View File
@@ -4,8 +4,8 @@ mod tests {
use crate::commands::aster_agent_cmd::action_runtime::build_runtime_action_scope;
use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope;
use async_trait::async_trait;
use lime_agent::AgentEvent as RuntimeAgentEvent;
use lime_agent::request_tool_policy::resolve_request_tool_policy;
use lime_agent::AgentEvent as RuntimeAgentEvent;
use regex::Regex;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -140,6 +140,28 @@ mod tests {
));
}
#[test]
fn test_resolve_workspace_id_from_sources_prefers_request_value() {
assert_eq!(
resolve_workspace_id_from_sources(
Some("workspace-request".to_string()),
Some("workspace-session".to_string()),
),
Some("workspace-request".to_string())
);
}
#[test]
fn test_resolve_workspace_id_from_sources_falls_back_to_session_value() {
assert_eq!(
resolve_workspace_id_from_sources(
Some(" ".to_string()),
Some("workspace-session".to_string()),
),
Some("workspace-session".to_string())
);
}
#[test]
fn test_aster_chat_request_deserialize_with_execution_strategy() {
let json = r#"{
@@ -582,6 +604,30 @@ mod tests {
);
}
#[test]
fn test_agent_runtime_submit_turn_request_allows_missing_workspace_id() {
let json = r#"{
"message": "Hello runtime",
"session_id": "runtime-session",
"event_name": "runtime_stream",
"turn_config": {
"execution_strategy": "auto",
"web_search": true
}
}"#;
let request: AgentRuntimeSubmitTurnRequest = serde_json::from_str(json).unwrap();
assert_eq!(request.workspace_id, None);
let mapped: AsterChatRequest = request.into();
assert_eq!(mapped.workspace_id, "");
assert_eq!(
mapped.execution_strategy,
Some(AsterExecutionStrategy::Auto)
);
assert_eq!(mapped.web_search, Some(true));
}
#[test]
fn test_build_runtime_user_message_includes_images() {
let message = build_runtime_user_message(
@@ -749,6 +795,91 @@ mod tests {
assert_eq!(request.queued_turn_id, "queued-2");
}
#[test]
fn test_agent_runtime_update_session_request_deserializes_recent_preferences_aliases() {
let request: AgentRuntimeUpdateSessionRequest = serde_json::from_value(serde_json::json!({
"sessionId": "session-1",
"providerName": "openai",
"modelName": "gpt-5.4",
"recentPreferences": {
"webSearch": true,
"thinking": false,
"task": true,
"subagent": true
}
}))
.expect("request should deserialize");
assert_eq!(request.session_id, "session-1");
assert_eq!(request.provider_name.as_deref(), Some("openai"));
assert_eq!(request.model_name.as_deref(), Some("gpt-5.4"));
assert_eq!(
request.recent_preferences,
Some(lime_agent::SessionExecutionRuntimePreferences {
web_search: true,
thinking: false,
task: true,
subagent: true,
})
);
}
#[test]
fn test_agent_runtime_update_session_request_deserializes_recent_team_selection_aliases() {
let request: AgentRuntimeUpdateSessionRequest = serde_json::from_value(serde_json::json!({
"sessionId": "session-1",
"recentTeamSelection": {
"disabled": false,
"theme": "general",
"preferredTeamPresetId": "code-triage-team",
"selectedTeamId": "custom-team-1",
"selectedTeamSource": "custom",
"selectedTeamLabel": "前端联调团队",
"selectedTeamDescription": "分析、实现、验证三段式推进。",
"selectedTeamSummary": "分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。",
"selectedTeamRoles": [
{
"id": "explorer",
"label": "分析",
"summary": "负责定位问题与影响范围。",
"profileId": "code-explorer",
"roleKey": "explorer",
"skillIds": ["repo-exploration"]
}
]
}
}))
.expect("request should deserialize");
assert_eq!(request.session_id, "session-1");
assert_eq!(
request.recent_team_selection,
Some(lime_agent::SessionExecutionRuntimeRecentTeamSelection {
disabled: false,
theme: Some("general".to_string()),
preferred_team_preset_id: Some("code-triage-team".to_string()),
selected_team_id: Some("custom-team-1".to_string()),
selected_team_source: Some("custom".to_string()),
selected_team_label: Some("前端联调团队".to_string()),
selected_team_description: Some("分析、实现、验证三段式推进。".to_string()),
selected_team_summary: Some(
"分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。"
.to_string(),
),
selected_team_roles: Some(vec![
lime_agent::SessionExecutionRuntimeRecentTeamRole {
id: "explorer".to_string(),
label: "分析".to_string(),
summary: "负责定位问题与影响范围。".to_string(),
profile_id: Some("code-explorer".to_string()),
role_key: Some("explorer".to_string()),
skill_ids: vec!["repo-exploration".to_string()],
},
]),
})
);
}
#[test]
fn test_extract_artifact_path_from_tool_start_reads_write_file_path() {
let path = extract_artifact_path_from_tool_start(
@@ -825,6 +956,7 @@ mod tests {
},
false,
None,
None,
);
let mut observation = ChatRunObservation::default();
observation.record_artifact_path(
@@ -897,6 +1029,77 @@ mod tests {
);
}
#[test]
fn test_resolve_request_web_search_preference_from_sources_prefers_request_flag() {
let metadata = serde_json::json!({
"harness": {
"preferences": {
"web_search": false
}
}
});
let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences {
web_search: false,
thinking: true,
task: false,
subagent: true,
};
assert_eq!(
resolve_request_web_search_preference_from_sources(
Some(true),
Some(&metadata),
Some(&session_recent_preferences),
),
Some(true)
);
}
#[test]
fn test_resolve_request_web_search_preference_from_sources_reads_nested_metadata() {
let metadata = serde_json::json!({
"harness": {
"preferences": {
"web_search": true
}
}
});
let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences {
web_search: false,
thinking: true,
task: false,
subagent: true,
};
assert_eq!(
resolve_request_web_search_preference_from_sources(
None,
Some(&metadata),
Some(&session_recent_preferences),
),
Some(true)
);
}
#[test]
fn test_resolve_request_web_search_preference_from_sources_falls_back_to_session_runtime() {
let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences {
web_search: true,
thinking: true,
task: false,
subagent: true,
};
assert_eq!(
resolve_request_web_search_preference_from_sources(
None,
None,
Some(&session_recent_preferences),
),
Some(true)
);
}
#[test]
fn test_build_chat_run_metadata_base_flattens_nested_preferences() {
let metadata = build_chat_run_metadata_base(
@@ -941,6 +1144,7 @@ mod tests {
},
false,
None,
None,
);
assert_eq!(
@@ -963,6 +1167,74 @@ mod tests {
);
}
#[test]
fn test_build_chat_run_metadata_base_falls_back_to_session_recent_preferences() {
let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences {
web_search: false,
thinking: true,
task: true,
subagent: false,
};
let metadata = build_chat_run_metadata_base(
&AsterChatRequest {
message: "hello".to_string(),
session_id: "session-1".to_string(),
event_name: "event-1".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
project_id: Some("project-1".to_string()),
workspace_id: "workspace-1".to_string(),
web_search: None,
search_mode: None,
execution_strategy: Some(AsterExecutionStrategy::React),
auto_continue: None,
system_prompt: None,
metadata: Some(serde_json::json!({
"harness": {
"theme": "general",
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
},
"workspace-1",
AsterExecutionStrategy::React,
&RequestToolPolicy {
search_mode: RequestToolPolicyMode::Disabled,
effective_web_search: false,
required_tools: vec![],
allowed_tools: vec![],
disallowed_tools: vec![],
},
false,
None,
Some(&session_recent_preferences),
);
assert_eq!(
metadata
.get("thinking_enabled")
.and_then(serde_json::Value::as_bool),
Some(true)
);
assert_eq!(
metadata
.get("task_mode_enabled")
.and_then(serde_json::Value::as_bool),
Some(true)
);
assert_eq!(
metadata
.get("subagent_mode_enabled")
.and_then(serde_json::Value::as_bool),
Some(false)
);
}
#[test]
fn test_chat_run_observation_records_nested_artifact_protocol_paths_from_tool_result() {
let mut observation = ChatRunObservation::default();
@@ -1493,7 +1765,7 @@ mod tests {
"subagent_mode_enabled": true,
"preferred_team_preset_id": "code-triage-team",
}
})))
})), None, true)
.expect("team prompt should exist");
assert!(prompt.contains(TEAM_PREFERENCE_PROMPT_MARKER));
@@ -1505,7 +1777,7 @@ mod tests {
"subagent_mode_enabled": false,
"preferred_team_preset_id": "code-triage-team",
}
})));
})), None, false);
assert!(disabled.is_none());
}
@@ -1531,7 +1803,7 @@ mod tests {
}
]
}
})))
})), None, true)
.expect("team prompt should exist");
assert!(prompt.contains("前端联调团队"));
@@ -1561,7 +1833,7 @@ mod tests {
}
]
}
})))
})), None, true)
.expect("team prompt should exist");
assert!(prompt.contains("当前调试 Team"));
@@ -1571,6 +1843,94 @@ mod tests {
assert!(prompt.contains("主动汇总关键进展、风险和下一步"));
}
#[test]
fn test_build_team_preference_system_prompt_accepts_session_fallback_flag() {
let prompt = build_team_preference_system_prompt(
Some(&serde_json::json!({
"harness": {
"preferred_team_preset_id": "code-triage-team",
}
})),
None,
true,
)
.expect("team prompt should exist");
assert!(prompt.contains("代码排障团队"));
assert!(prompt.contains("spawn_agent"));
}
#[test]
fn test_build_team_preference_system_prompt_falls_back_to_session_recent_team_selection() {
let prompt = build_team_preference_system_prompt(
None,
Some(&lime_agent::SessionExecutionRuntimeRecentTeamSelection {
disabled: false,
theme: Some("general".to_string()),
preferred_team_preset_id: Some("code-triage-team".to_string()),
selected_team_id: Some("custom-team-1".to_string()),
selected_team_source: Some("custom".to_string()),
selected_team_label: Some("前端联调团队".to_string()),
selected_team_description: Some("分析、实现、验证三段式推进。".to_string()),
selected_team_summary: Some("分析、实现、验证三段式推进。".to_string()),
selected_team_roles: Some(vec![
lime_agent::SessionExecutionRuntimeRecentTeamRole {
id: "explorer".to_string(),
label: "分析".to_string(),
summary: "负责定位问题与影响范围。".to_string(),
profile_id: Some("code-explorer".to_string()),
role_key: Some("explorer".to_string()),
skill_ids: vec!["repo-exploration".to_string()],
},
]),
}),
true,
)
.expect("team prompt should exist");
assert!(prompt.contains("代码排障团队"));
assert!(prompt.contains("前端联调团队"));
assert!(prompt.contains("来源:custom"));
assert!(prompt.contains("分析、实现、验证三段式推进。"));
assert!(prompt.contains("分析:负责定位问题与影响范围。"));
assert!(prompt.contains("profile: code-explorer"));
assert!(prompt.contains("roleKey: explorer"));
assert!(prompt.contains("skills: repo-exploration"));
}
#[test]
fn test_build_team_preference_system_prompt_prefers_request_metadata_over_session_recent_team_selection()
{
let prompt = build_team_preference_system_prompt(
Some(&serde_json::json!({
"harness": {
"selected_team_source": "builtin",
"selected_team_label": "请求内 Team",
"selected_team_summary": "以本次请求为准。",
}
})),
Some(&lime_agent::SessionExecutionRuntimeRecentTeamSelection {
disabled: false,
theme: Some("general".to_string()),
preferred_team_preset_id: Some("research-team".to_string()),
selected_team_id: Some("runtime-team".to_string()),
selected_team_source: Some("custom".to_string()),
selected_team_label: Some("会话 Team".to_string()),
selected_team_description: Some("旧会话描述".to_string()),
selected_team_summary: Some("旧会话摘要".to_string()),
selected_team_roles: None,
}),
true,
)
.expect("team prompt should exist");
assert!(prompt.contains("请求内 Team"));
assert!(prompt.contains("来源:builtin"));
assert!(prompt.contains("以本次请求为准。"));
assert!(!prompt.contains("会话 Team"));
assert!(!prompt.contains("旧会话摘要"));
}
#[test]
fn test_build_subagent_customization_state_applies_profile_defaults() {
let customization = build_subagent_customization_state(&AgentRuntimeSpawnSubagentRequest {
@@ -4,43 +4,52 @@ use crate::services::site_capability_service::{
build_site_result_document_body, save_site_result_to_project,
};
use crate::services::site_capability_service::{
get_site_adapter, list_site_adapters, run_site_adapter_with_optional_save,
search_site_adapters, RunSiteAdapterRequest,
get_site_adapter, list_site_adapters, recommend_site_adapters,
run_site_adapter_with_optional_save, search_site_adapters, RunSiteAdapterRequest,
};
#[cfg(test)]
use crate::services::site_capability_service::{SiteAdapterDefinition, SiteAdapterRunResult};
use crate::services::site_capability_service::{
SiteAdapterDefinition, SiteAdapterRecommendation, SiteAdapterRunResult,
};
use aster::session::{load_shared_session_runtime_snapshot, SessionRuntimeSnapshot};
use serde_json::Value;
const PROJECT_ID_ENV_KEYS: &[&str] = &["LIME_PROJECT_ID", "PROXYCAST_PROJECT_ID"];
const CONTENT_ID_ENV_KEYS: &[&str] = &["LIME_CONTENT_ID", "PROXYCAST_CONTENT_ID"];
#[derive(Debug, Clone, Copy)]
enum LimeSiteToolKind {
List,
Recommend,
Search,
Info,
Run,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LimeSiteProjectSource {
enum LimeSiteSaveTargetSource {
ExplicitProject,
ContextProject,
ExplicitContent,
ContextContent,
}
impl LimeSiteProjectSource {
impl LimeSiteSaveTargetSource {
fn as_str(self) -> &'static str {
match self {
LimeSiteProjectSource::ExplicitProject => "explicit_project",
LimeSiteProjectSource::ContextProject => "context_project",
LimeSiteSaveTargetSource::ExplicitProject => "explicit_project",
LimeSiteSaveTargetSource::ContextProject => "context_project",
LimeSiteSaveTargetSource::ExplicitContent => "explicit_content",
LimeSiteSaveTargetSource::ContextContent => "context_content",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LimeSiteProjectTarget {
project_id: String,
source: LimeSiteProjectSource,
struct LimeSiteSaveTarget {
project_id: Option<String>,
content_id: Option<String>,
source: LimeSiteSaveTargetSource,
}
#[derive(Debug, Clone)]
@@ -140,6 +149,19 @@ impl LimeSiteTool {
})
}
fn build_recommend_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "可选返回数量上限;未传时返回按浏览器上下文排序后的推荐列表"
}
},
"additionalProperties": false,
})
}
fn build_info_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
@@ -168,7 +190,7 @@ impl LimeSiteTool {
},
"profile_key": {
"type": "string",
"description": "浏览器资料 Key,可选;未传时优先复用当前 browser assist 会话"
"description": "浏览器资料 Key,可选;未传时优先复用当前 browser assist 会话,否则自动选择已连接的 existing_session 或最合适的资料"
},
"target_id": {
"type": "string",
@@ -178,13 +200,17 @@ impl LimeSiteTool {
"type": "integer",
"description": "脚本执行超时时间,毫秒"
},
"content_id": {
"type": "string",
"description": "可选内容 ID;未传时优先复用当前内容上下文,成功后优先写回当前主稿"
},
"project_id": {
"type": "string",
"description": "可选项目 ID;未传时优先复用当前项目上下文,成功后会保存为资源文档"
"description": "可选项目 ID;未传时优先复用当前项目上下文。仅当没有 content_id 时,成功后会保存为新资源文档"
},
"save_title": {
"type": "string",
"description": "可选保存标题;仅在存在保存目标时生效"
"description": "可选保存标题;仅在保存为新资源文档时生效"
}
},
"required": ["adapter_name"],
@@ -200,6 +226,14 @@ impl LimeSiteTool {
.map(ToString::to_string)
}
fn extract_content_id_from_value(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn extract_project_id_from_metadata_map(
metadata: &HashMap<String, serde_json::Value>,
) -> Option<String> {
@@ -208,6 +242,14 @@ impl LimeSiteTool {
.find_map(|key| Self::extract_project_id_from_value(metadata.get(*key)))
}
fn extract_content_id_from_metadata_map(
metadata: &HashMap<String, serde_json::Value>,
) -> Option<String> {
["content_id", "contentId"]
.iter()
.find_map(|key| Self::extract_content_id_from_value(metadata.get(*key)))
}
fn extract_project_id_from_runtime_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> Option<String> {
@@ -237,6 +279,35 @@ impl LimeSiteTool {
})
}
fn extract_content_id_from_runtime_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> Option<String> {
snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.filter_map(|turn| {
let content_id = turn.context_override.as_ref().and_then(|context| {
Self::extract_content_id_from_metadata_map(&context.metadata)
})?;
Some((turn.updated_at, content_id))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, content_id)| content_id)
.or_else(|| {
snapshot
.threads
.iter()
.filter_map(|thread| {
let content_id =
Self::extract_content_id_from_metadata_map(&thread.thread.metadata)?;
Some((thread.thread.updated_at, content_id))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, content_id)| content_id)
})
}
fn extract_project_id_from_context_environment(context: &ToolContext) -> Option<String> {
PROJECT_ID_ENV_KEYS.iter().find_map(|key| {
context
@@ -249,6 +320,18 @@ impl LimeSiteTool {
})
}
fn extract_content_id_from_context_environment(context: &ToolContext) -> Option<String> {
CONTENT_ID_ENV_KEYS.iter().find_map(|key| {
context
.environment
.get(*key)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
})
}
async fn resolve_context_project_id(context: &ToolContext) -> Option<String> {
let session_id = context.session_id.trim();
if !session_id.is_empty() {
@@ -273,38 +356,80 @@ impl LimeSiteTool {
Self::extract_project_id_from_context_environment(context)
}
async fn resolve_project_target(
async fn resolve_context_content_id(context: &ToolContext) -> Option<String> {
let session_id = context.session_id.trim();
if !session_id.is_empty() {
match load_shared_session_runtime_snapshot(session_id).await {
Ok(snapshot) => {
if let Some(content_id) =
Self::extract_content_id_from_runtime_snapshot(&snapshot)
{
return Some(content_id);
}
}
Err(error) => {
tracing::debug!(
"[AsterAgent][SiteTool] 读取 runtime snapshot 失败,跳过上下文内容解析: session_id={}, error={}",
session_id,
error
);
}
}
}
Self::extract_content_id_from_context_environment(context)
}
async fn resolve_save_target(
params: &serde_json::Value,
context: &ToolContext,
) -> Option<LimeSiteProjectTarget> {
) -> Option<LimeSiteSaveTarget> {
if let Some(content_id) = Self::extract_optional_string(params, &["content_id"]) {
return Some(LimeSiteSaveTarget {
project_id: None,
content_id: Some(content_id),
source: LimeSiteSaveTargetSource::ExplicitContent,
});
}
if let Some(project_id) = Self::extract_optional_string(params, &["project_id"]) {
return Some(LimeSiteProjectTarget {
project_id,
source: LimeSiteProjectSource::ExplicitProject,
return Some(LimeSiteSaveTarget {
project_id: Some(project_id),
content_id: None,
source: LimeSiteSaveTargetSource::ExplicitProject,
});
}
if let Some(content_id) = Self::resolve_context_content_id(context).await {
return Some(LimeSiteSaveTarget {
project_id: None,
content_id: Some(content_id),
source: LimeSiteSaveTargetSource::ContextContent,
});
}
Self::resolve_context_project_id(context)
.await
.map(|project_id| LimeSiteProjectTarget {
project_id,
source: LimeSiteProjectSource::ContextProject,
.map(|project_id| LimeSiteSaveTarget {
project_id: Some(project_id),
content_id: None,
source: LimeSiteSaveTargetSource::ContextProject,
})
}
fn apply_project_target_to_run_result(
fn apply_save_target_to_run_result(
mut result: crate::services::site_capability_service::SiteAdapterRunResult,
project_target: Option<&LimeSiteProjectTarget>,
save_target: Option<&LimeSiteSaveTarget>,
) -> crate::services::site_capability_service::SiteAdapterRunResult {
let Some(project_target) = project_target else {
let Some(save_target) = save_target else {
return result;
};
let normalized_source = project_target.source.as_str().to_string();
if result.saved_project_id.is_some() {
let normalized_source = save_target.source.as_str().to_string();
if result.saved_content.is_some() || result.saved_project_id.is_some() {
result.saved_by = Some(normalized_source.clone());
}
if result.save_skipped_project_id.is_some() {
if result.save_skipped_by.is_some() || result.save_skipped_project_id.is_some() {
result.save_skipped_by = Some(normalized_source);
}
@@ -404,6 +529,21 @@ impl Tool for LimeSiteTool {
.with_metadata("tool_family", serde_json::json!("site"))
.with_metadata("result", serde_json::json!(result)))
}
LimeSiteToolKind::Recommend => {
let limit = params.get("limit").and_then(serde_json::Value::as_u64);
let limit = limit
.and_then(|value| usize::try_from(value).ok())
.filter(|value| *value > 0);
let result = recommend_site_adapters(&self.db, limit)
.await
.map_err(ToolError::execution_failed)?;
let payload = serde_json::to_string_pretty(&result).map_err(|error| {
ToolError::execution_failed(format!("序列化站点推荐结果失败: {error}"))
})?;
Ok(ToolResult::success(payload)
.with_metadata("tool_family", serde_json::json!("site"))
.with_metadata("result", serde_json::json!(result)))
}
LimeSiteToolKind::Search => {
let query = Self::extract_required_string(&params, &["query"], "query")?;
let result = search_site_adapters(&query);
@@ -447,7 +587,7 @@ impl Tool for LimeSiteTool {
.unwrap_or_else(|| serde_json::json!({}));
let target_id = Self::extract_optional_string(&params, &["target_id"]);
let timeout_ms = params.get("timeout_ms").and_then(serde_json::Value::as_u64);
let project_target = Self::resolve_project_target(&params, context).await;
let save_target = Self::resolve_save_target(&params, context).await;
let save_title = Self::extract_optional_string(&params, &["save_title"]);
let run_request = RunSiteAdapterRequest {
adapter_name: adapter_name.clone(),
@@ -455,23 +595,27 @@ impl Tool for LimeSiteTool {
profile_key,
target_id,
timeout_ms,
project_id: project_target
content_id: save_target
.as_ref()
.map(|target| target.project_id.clone()),
.and_then(|target| target.content_id.clone()),
project_id: save_target
.as_ref()
.and_then(|target| target.project_id.clone()),
save_title,
};
let result = Self::apply_project_target_to_run_result(
let result = Self::apply_save_target_to_run_result(
run_site_adapter_with_optional_save(&self.db, run_request.clone()).await,
project_target.as_ref(),
save_target.as_ref(),
);
let browser_session = match (&result.session_id, &result.target_id) {
(Some(session_id), Some(target_id)) => Some(serde_json::json!({
"session_id": session_id,
"target_id": target_id,
let browser_session = if result.session_id.is_some() || result.target_id.is_some() {
Some(serde_json::json!({
"session_id": result.session_id,
"target_id": result.target_id,
"profile_key": result.profile_key,
})),
_ => None,
}))
} else {
None
};
let payload = serde_json::to_string_pretty(&result).map_err(|error| {
ToolError::execution_failed(format!("序列化站点执行结果失败: {error}"))
@@ -531,10 +675,22 @@ mod tests {
.and_then(serde_json::Value::as_object)
.expect("properties should exist");
assert!(properties.contains_key("content_id"));
assert!(properties.contains_key("project_id"));
assert!(properties.contains_key("save_title"));
}
#[test]
fn should_include_limit_field_in_recommend_schema() {
let schema = LimeSiteTool::build_recommend_schema();
let properties = schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("properties should exist");
assert!(properties.contains_key("limit"));
}
#[test]
fn should_build_site_result_document_body_with_sections() {
let adapter = SiteAdapterDefinition {
@@ -556,6 +712,7 @@ mod tests {
profile_key: Some("general_browser_assist".to_string()),
target_id: None,
timeout_ms: Some(20_000),
content_id: None,
project_id: None,
save_title: None,
};
@@ -576,6 +733,7 @@ mod tests {
error_code: None,
error_message: None,
auth_hint: Some("请先登录 GitHub。".to_string()),
report_hint: None,
saved_content: None,
saved_project_id: None,
saved_by: None,
@@ -624,6 +782,7 @@ mod tests {
profile_key: Some("general_browser_assist".to_string()),
target_id: Some("target-1".to_string()),
timeout_ms: Some(20_000),
content_id: None,
project_id: None,
save_title: None,
};
@@ -644,6 +803,7 @@ mod tests {
error_code: None,
error_message: None,
auth_hint: Some("请先登录 GitHub。".to_string()),
report_hint: None,
saved_content: None,
saved_project_id: None,
saved_by: None,
@@ -759,6 +919,57 @@ mod tests {
assert_eq!(project_id.as_deref(), Some("project-current"));
}
#[test]
fn should_extract_latest_content_id_from_runtime_snapshot() {
let now = Utc::now();
let mut older_turn = TurnRuntime::new(
"turn-older",
"session-1",
"thread-1",
Some("旧 turn".to_string()),
Some(TurnContextOverride {
metadata: HashMap::from([(
"content_id".to_string(),
serde_json::json!("content-older"),
)]),
..TurnContextOverride::default()
}),
);
older_turn.updated_at = now;
let mut latest_turn = TurnRuntime::new(
"turn-latest",
"session-1",
"thread-1",
Some("新 turn".to_string()),
Some(TurnContextOverride {
metadata: HashMap::from([(
"content_id".to_string(),
serde_json::json!("content-current"),
)]),
..TurnContextOverride::default()
}),
);
latest_turn.updated_at = now + ChronoDuration::seconds(5);
let mut thread =
ThreadRuntime::new("thread-1", "session-1", PathBuf::from("/tmp/site-runtime"));
thread.updated_at = latest_turn.updated_at;
let snapshot = SessionRuntimeSnapshot {
session_id: "session-1".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread,
turns: vec![older_turn, latest_turn],
items: Vec::new(),
}],
};
let content_id = LimeSiteTool::extract_content_id_from_runtime_snapshot(&snapshot);
assert_eq!(content_id.as_deref(), Some("content-current"));
}
#[test]
fn should_extract_project_id_from_thread_metadata_when_turn_metadata_missing() {
let now = Utc::now();
@@ -796,7 +1007,7 @@ mod tests {
let context =
ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("session-1");
let target = runtime.block_on(LimeSiteTool::resolve_project_target(
let target = runtime.block_on(LimeSiteTool::resolve_save_target(
&serde_json::json!({
"project_id": "project-explicit"
}),
@@ -805,9 +1016,33 @@ mod tests {
assert_eq!(
target,
Some(LimeSiteProjectTarget {
project_id: "project-explicit".to_string(),
source: LimeSiteProjectSource::ExplicitProject,
Some(LimeSiteSaveTarget {
project_id: Some("project-explicit".to_string()),
content_id: None,
source: LimeSiteSaveTargetSource::ExplicitProject,
})
);
}
#[test]
fn should_resolve_content_target_as_explicit_when_param_exists() {
let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败");
let context =
ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("session-1");
let target = runtime.block_on(LimeSiteTool::resolve_save_target(
&serde_json::json!({
"content_id": "content-explicit"
}),
&context,
));
assert_eq!(
target,
Some(LimeSiteSaveTarget {
project_id: None,
content_id: Some("content-explicit".to_string()),
source: LimeSiteSaveTargetSource::ExplicitContent,
})
);
}
@@ -822,20 +1057,81 @@ mod tests {
"project-from-env".to_string(),
);
let target = runtime.block_on(LimeSiteTool::resolve_project_target(
let target = runtime.block_on(LimeSiteTool::resolve_save_target(
&serde_json::json!({}),
&context,
));
assert_eq!(
target,
Some(LimeSiteProjectTarget {
project_id: "project-from-env".to_string(),
source: LimeSiteProjectSource::ContextProject,
Some(LimeSiteSaveTarget {
project_id: Some("project-from-env".to_string()),
content_id: None,
source: LimeSiteSaveTargetSource::ContextProject,
})
);
}
#[test]
fn should_resolve_content_target_from_context_environment_when_runtime_missing() {
let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败");
let mut context =
ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("missing");
context.environment.insert(
"LIME_CONTENT_ID".to_string(),
"content-from-env".to_string(),
);
let target = runtime.block_on(LimeSiteTool::resolve_save_target(
&serde_json::json!({}),
&context,
));
assert_eq!(
target,
Some(LimeSiteSaveTarget {
project_id: None,
content_id: Some("content-from-env".to_string()),
source: LimeSiteSaveTargetSource::ContextContent,
})
);
}
#[test]
fn should_execute_recommend_tool_and_return_result_metadata() {
let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败");
let tool = LimeSiteTool::new(
LIME_SITE_RECOMMEND_TOOL_NAME.to_string(),
"推荐站点适配器",
LimeSiteTool::build_recommend_schema(),
LimeSiteToolKind::Recommend,
setup_test_db(),
);
let context =
ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("session-1");
let result = runtime
.block_on(tool.execute(
serde_json::json!({
"limit": 1
}),
&context,
))
.expect("推荐工具应返回 ToolResult");
assert!(result.success);
let recommendations = serde_json::from_value::<Vec<SiteAdapterRecommendation>>(
result
.metadata
.get("result")
.cloned()
.expect("metadata 应包含 result"),
)
.expect("应能解析推荐结果");
assert_eq!(recommendations.len(), 1);
assert!(!recommendations[0].adapter.name.is_empty());
}
#[test]
fn should_rewrite_saved_source_as_context_project_when_result_comes_from_context() {
let result = SiteAdapterRunResult {
@@ -851,6 +1147,7 @@ mod tests {
error_code: None,
error_message: None,
auth_hint: None,
report_hint: None,
saved_content: Some(
crate::services::site_capability_service::SavedSiteAdapterContent {
content_id: "content-1".to_string(),
@@ -865,11 +1162,12 @@ mod tests {
save_error_message: None,
};
let normalized = LimeSiteTool::apply_project_target_to_run_result(
let normalized = LimeSiteTool::apply_save_target_to_run_result(
result,
Some(&LimeSiteProjectTarget {
project_id: "project-context".to_string(),
source: LimeSiteProjectSource::ContextProject,
Some(&LimeSiteSaveTarget {
project_id: Some("project-context".to_string()),
content_id: None,
source: LimeSiteSaveTargetSource::ContextProject,
}),
);
@@ -891,6 +1189,7 @@ mod tests {
error_code: Some("adapter_not_found".to_string()),
error_message: Some("未找到对应的站点适配器".to_string()),
auth_hint: None,
report_hint: None,
saved_content: None,
saved_project_id: None,
saved_by: None,
@@ -899,11 +1198,12 @@ mod tests {
save_error_message: None,
};
let normalized = LimeSiteTool::apply_project_target_to_run_result(
let normalized = LimeSiteTool::apply_save_target_to_run_result(
result,
Some(&LimeSiteProjectTarget {
project_id: "project-context".to_string(),
source: LimeSiteProjectSource::ContextProject,
Some(&LimeSiteSaveTarget {
project_id: Some("project-context".to_string()),
content_id: None,
source: LimeSiteSaveTargetSource::ContextProject,
}),
);
@@ -962,6 +1262,7 @@ mod tests {
pub(super) fn site_tool_names() -> Vec<&'static str> {
vec![
LIME_SITE_LIST_TOOL_NAME,
LIME_SITE_RECOMMEND_TOOL_NAME,
LIME_SITE_SEARCH_TOOL_NAME,
LIME_SITE_INFO_TOOL_NAME,
LIME_SITE_RUN_TOOL_NAME,
@@ -979,6 +1280,12 @@ pub(super) fn register_site_tools_to_registry(
LimeSiteTool::build_list_schema(),
LimeSiteToolKind::List,
),
(
LIME_SITE_RECOMMEND_TOOL_NAME,
"基于当前浏览器资料、已连接标签页和站点范围推荐可直接运行的 Lime 站点适配器,优先复用现有登录态。",
LimeSiteTool::build_recommend_schema(),
LimeSiteToolKind::Recommend,
),
(
LIME_SITE_SEARCH_TOOL_NAME,
"按关键词搜索 Lime 内置站点适配器。",
+18 -3
View File
@@ -4,9 +4,10 @@ use crate::services::site_adapter_registry::{
get_site_adapter_catalog_status, SiteAdapterCatalogStatus,
};
use crate::services::site_capability_service::{
get_site_adapter, list_site_adapters, run_site_adapter, run_site_adapter_with_optional_save,
save_existing_site_result_to_project, search_site_adapters, RunSiteAdapterRequest,
SaveSiteAdapterResultRequest, SavedSiteAdapterContent, SiteAdapterDefinition,
get_site_adapter, list_site_adapters, recommend_site_adapters, run_site_adapter,
run_site_adapter_with_optional_save, save_existing_site_result_to_project,
search_site_adapters, RunSiteAdapterRequest, SaveSiteAdapterResultRequest,
SavedSiteAdapterContent, SiteAdapterDefinition, SiteAdapterRecommendation,
SiteAdapterRunResult,
};
use serde::Deserialize;
@@ -28,11 +29,25 @@ pub struct SiteAdapterCatalogBootstrapRequest {
pub payload: Value,
}
#[derive(Debug, Deserialize)]
pub struct SiteAdapterRecommendRequest {
#[serde(default)]
pub limit: Option<usize>,
}
#[tauri::command]
pub fn site_list_adapters() -> Result<Vec<SiteAdapterDefinition>, String> {
Ok(list_site_adapters())
}
#[tauri::command]
pub async fn site_recommend_adapters(
db: State<'_, DbConnection>,
request: SiteAdapterRecommendRequest,
) -> Result<Vec<SiteAdapterRecommendation>, String> {
recommend_site_adapters(db.inner(), request.limit).await
}
#[tauri::command]
pub fn site_search_adapters(
request: SiteAdapterSearchRequest,
+48 -2
View File
@@ -508,6 +508,8 @@ impl BrowserRuntimeAuditRecord {
profile_key: Option<String>,
requested_backend: Option<BrowserBackendType>,
selected_backend: Option<BrowserBackendType>,
session_id: Option<String>,
target_id: Option<String>,
success: bool,
error: Option<String>,
attempts: Vec<BrowserActionAttempt>,
@@ -526,8 +528,8 @@ impl BrowserRuntimeAuditRecord {
attempts,
environment_preset_id: None,
environment_preset_name: None,
target_id: None,
session_id: None,
target_id,
session_id,
url: None,
reused: None,
open_window: None,
@@ -1678,6 +1680,8 @@ pub async fn browser_execute_action_with_manager(
profile_key.clone(),
request.backend.clone(),
result.backend.clone(),
result.session_id.clone(),
result.target_id.clone(),
true,
None,
attempts,
@@ -1709,6 +1713,8 @@ pub async fn browser_execute_action_with_manager(
profile_key.clone(),
request.backend.clone(),
None,
None,
None,
false,
Some(error),
attempts,
@@ -1737,6 +1743,8 @@ pub async fn browser_execute_action_with_manager(
profile_key,
request.backend,
None,
None,
None,
false,
result.error.clone(),
attempts,
@@ -3420,4 +3428,42 @@ mod tests {
BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear();
}
#[tokio::test]
async fn browser_runtime_audit_should_store_action_session_keys() {
BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear();
append_browser_runtime_audit(BrowserRuntimeAuditRecord::action(
"browser-action-1".to_string(),
"read_page".to_string(),
Some("general_browser_assist".to_string()),
Some(BrowserBackendType::CdpDirect),
Some(BrowserBackendType::CdpDirect),
Some("session-42".to_string()),
Some("target-42".to_string()),
true,
None,
vec![BrowserActionAttempt {
backend: BrowserBackendType::CdpDirect,
success: true,
message: "执行成功".to_string(),
}],
))
.await;
let logs = get_browser_action_audit_logs(Some(5))
.await
.expect("audit logs should be readable");
let record = logs.first().expect("action audit must exist");
assert!(matches!(record.kind, BrowserRuntimeAuditKind::Action));
assert_eq!(record.id, "browser-action-1");
assert_eq!(record.session_id.as_deref(), Some("session-42"));
assert_eq!(record.target_id.as_deref(), Some("target-42"));
assert_eq!(
record.profile_key.as_deref(),
Some("general_browser_assist")
);
BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear();
}
}
+216
View File
@@ -312,4 +312,220 @@ mod tests {
assert!(status_value["shortcut_registered"].is_boolean());
assert!(status_value["translate_shortcut_registered"].is_boolean());
}
#[tokio::test]
async fn browser_profile_commands_roundtrip() {
let state = make_test_state();
let saved_value = handle_command(
&state,
"save_browser_profile_cmd",
Some(serde_json::json!({
"request": {
"profile_key": "github-attached",
"name": "GitHub 已登录 Chrome",
"description": "复用当前 Chrome",
"site_scope": "github.com",
"launch_url": "https://github.com/",
"transport_kind": "existing_session"
}
})),
)
.await
.unwrap();
let profile_id = saved_value["id"].as_str().unwrap().to_string();
assert_eq!(saved_value["profile_key"], "github-attached");
assert_eq!(saved_value["transport_kind"], "existing_session");
let active_list = handle_command(
&state,
"list_browser_profiles_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert_eq!(active_list.as_array().unwrap().len(), 1);
let archived = handle_command(
&state,
"archive_browser_profile_cmd",
Some(serde_json::json!({
"request": {
"id": profile_id
}
})),
)
.await
.unwrap();
assert_eq!(archived, serde_json::json!(true));
let active_list_after_archive = handle_command(
&state,
"list_browser_profiles_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert!(active_list_after_archive.as_array().unwrap().is_empty());
let archived_list = handle_command(
&state,
"list_browser_profiles_cmd",
Some(serde_json::json!({
"request": {
"include_archived": true
}
})),
)
.await
.unwrap();
assert_eq!(archived_list.as_array().unwrap().len(), 1);
assert!(archived_list.as_array().unwrap()[0]["archived_at"]
.as_str()
.is_some());
let restored = handle_command(
&state,
"restore_browser_profile_cmd",
Some(serde_json::json!({
"request": {
"id": archived_list.as_array().unwrap()[0]["id"]
}
})),
)
.await
.unwrap();
assert_eq!(restored, serde_json::json!(true));
let active_list_after_restore = handle_command(
&state,
"list_browser_profiles_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert_eq!(active_list_after_restore.as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn browser_environment_preset_commands_roundtrip() {
let state = make_test_state();
let saved_value = handle_command(
&state,
"save_browser_environment_preset_cmd",
Some(serde_json::json!({
"request": {
"name": "GitHub 搜索环境",
"description": "用于仓库线索检索",
"timezone_id": "Asia/Shanghai",
"locale": "zh_CN",
"accept_language": "zh-CN,zh;q=0.9",
"viewport_width": 1440,
"viewport_height": 960,
"device_scale_factor": 1.25
}
})),
)
.await
.unwrap();
let preset_id = saved_value["id"].as_str().unwrap().to_string();
assert_eq!(saved_value["name"], "GitHub 搜索环境");
assert_eq!(saved_value["timezone_id"], "Asia/Shanghai");
let active_list = handle_command(
&state,
"list_browser_environment_presets_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert_eq!(active_list.as_array().unwrap().len(), 1);
let archived = handle_command(
&state,
"archive_browser_environment_preset_cmd",
Some(serde_json::json!({
"request": {
"id": preset_id
}
})),
)
.await
.unwrap();
assert_eq!(archived, serde_json::json!(true));
let active_list_after_archive = handle_command(
&state,
"list_browser_environment_presets_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert!(active_list_after_archive.as_array().unwrap().is_empty());
let archived_list = handle_command(
&state,
"list_browser_environment_presets_cmd",
Some(serde_json::json!({
"request": {
"include_archived": true
}
})),
)
.await
.unwrap();
assert_eq!(archived_list.as_array().unwrap().len(), 1);
assert!(archived_list.as_array().unwrap()[0]["archived_at"]
.as_str()
.is_some());
let restored = handle_command(
&state,
"restore_browser_environment_preset_cmd",
Some(serde_json::json!({
"request": {
"id": archived_list.as_array().unwrap()[0]["id"]
}
})),
)
.await
.unwrap();
assert_eq!(restored, serde_json::json!(true));
let active_list_after_restore = handle_command(
&state,
"list_browser_environment_presets_cmd",
Some(serde_json::json!({
"request": {
"include_archived": false
}
})),
)
.await
.unwrap();
assert_eq!(active_list_after_restore.as_array().unwrap().len(), 1);
}
}
@@ -53,6 +53,131 @@ pub(super) async fn try_handle(
.await?,
)?
}
"list_browser_profiles_cmd" => {
let request: Option<crate::commands::browser_profile_cmd::ListBrowserProfilesRequest> =
parse_optional_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_profile_service::list_browser_profiles(
&conn,
request
.map(|payload| payload.include_archived)
.unwrap_or(false),
)?,
)?
}
"save_browser_profile_cmd" => {
let request: crate::commands::browser_profile_cmd::SaveBrowserProfileRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_profile_service::save_browser_profile(
&conn,
crate::services::browser_profile_service::SaveBrowserProfileInput {
id: request.id,
profile_key: request.profile_key,
name: request.name,
description: request.description,
site_scope: request.site_scope,
launch_url: request.launch_url,
transport_kind: request.transport_kind,
},
)?,
)?
}
"archive_browser_profile_cmd" => {
let request: crate::commands::browser_profile_cmd::BrowserProfileRecordRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_profile_service::archive_browser_profile(
&conn,
&request.id,
)?,
)?
}
"restore_browser_profile_cmd" => {
let request: crate::commands::browser_profile_cmd::BrowserProfileRecordRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_profile_service::restore_browser_profile(
&conn,
&request.id,
)?,
)?
}
"list_browser_environment_presets_cmd" => {
let request: Option<
crate::commands::browser_environment_cmd::ListBrowserEnvironmentPresetsRequest,
> = parse_optional_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_environment_service::list_browser_environment_presets(
&conn,
request
.map(|payload| payload.include_archived)
.unwrap_or(false),
)?,
)?
}
"save_browser_environment_preset_cmd" => {
let request: crate::commands::browser_environment_cmd::SaveBrowserEnvironmentPresetRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_environment_service::save_browser_environment_preset(
&conn,
crate::services::browser_environment_service::SaveBrowserEnvironmentPresetInput {
id: request.id,
name: request.name,
description: request.description,
proxy_server: request.proxy_server,
timezone_id: request.timezone_id,
locale: request.locale,
accept_language: request.accept_language,
geolocation_lat: request.geolocation_lat,
geolocation_lng: request.geolocation_lng,
geolocation_accuracy_m: request.geolocation_accuracy_m,
user_agent: request.user_agent,
platform: request.platform,
viewport_width: request.viewport_width,
viewport_height: request.viewport_height,
device_scale_factor: request.device_scale_factor,
},
)?,
)?
}
"archive_browser_environment_preset_cmd" => {
let request: crate::commands::browser_environment_cmd::BrowserEnvironmentPresetRecordRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_environment_service::archive_browser_environment_preset(
&conn,
&request.id,
)?,
)?
}
"restore_browser_environment_preset_cmd" => {
let request: crate::commands::browser_environment_cmd::BrowserEnvironmentPresetRecordRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
let conn = crate::database::lock_db(&db)?;
serde_json::to_value(
crate::services::browser_environment_service::restore_browser_environment_preset(
&conn,
&request.id,
)?,
)?
}
"launch_browser_runtime_assist" => {
let app_handle = require_app_handle(state)?;
let request: crate::commands::browser_runtime_cmd::LaunchBrowserRuntimeAssistRequest =
@@ -6,8 +6,9 @@ use crate::services::site_adapter_registry::{
get_site_adapter_catalog_status,
};
use crate::services::site_capability_service::{
get_site_adapter, list_site_adapters, run_site_adapter, run_site_adapter_with_optional_save,
save_existing_site_result_to_project, search_site_adapters,
get_site_adapter, list_site_adapters, recommend_site_adapters, run_site_adapter,
run_site_adapter_with_optional_save, save_existing_site_result_to_project,
search_site_adapters,
};
use serde_json::Value as JsonValue;
@@ -18,6 +19,12 @@ pub(super) async fn try_handle(
) -> Result<Option<JsonValue>, DynError> {
let result = match cmd {
"site_list_adapters" => serde_json::to_value(list_site_adapters())?,
"site_recommend_adapters" => {
let request: crate::commands::site_capability_cmd::SiteAdapterRecommendRequest =
parse_request(args)?;
let db = get_db(state)?.clone();
serde_json::to_value(recommend_site_adapters(&db, request.limit).await?)?
}
"site_search_adapters" => {
let request: crate::commands::site_capability_cmd::SiteAdapterSearchRequest =
parse_request(args)?;
@@ -3,6 +3,9 @@
//! 负责在工作区内生成稳定路径、落盘 JSON 快照,并给前端 workbench
//! 提供可直接消费的 snapshot metadata。
use crate::commands::content_cmd::THEME_WORKBENCH_DOCUMENT_META_KEY;
use crate::content::{ContentManager, ContentUpdateRequest};
use crate::database::DbConnection;
use crate::services::artifact_document_validator::{
validate_or_fallback_artifact_document, validate_or_repair_artifact_document_value,
ArtifactDocumentValidationContext, ArtifactDocumentValidationOutcome,
@@ -26,6 +29,8 @@ pub struct PersistedArtifactDocument {
pub absolute_path: PathBuf,
pub serialized_document: String,
pub snapshot_metadata: Map<String, Value>,
pub theme_workbench_document_state: Map<String, Value>,
pub content_body: String,
pub title: String,
pub kind: String,
pub status: String,
@@ -268,6 +273,9 @@ pub fn persist_artifact_document_from_text(
&source_links,
version_diff.as_ref(),
);
let theme_workbench_document_state =
build_theme_workbench_document_state(&version_history, current_version.id.as_str());
let content_body = build_content_body_from_document(&enriched_document);
Ok(PersistedArtifactDocument {
artifact_id,
@@ -277,6 +285,8 @@ pub fn persist_artifact_document_from_text(
absolute_path,
serialized_document,
snapshot_metadata,
theme_workbench_document_state,
content_body,
title: outcome.title,
kind: outcome.kind,
status: outcome.status,
@@ -397,6 +407,198 @@ fn build_snapshot_metadata(
metadata
}
fn parse_rfc3339_to_timestamp_millis(value: &str) -> Option<i64> {
DateTime::parse_from_rfc3339(value)
.ok()
.map(|parsed| parsed.timestamp_millis())
}
fn resolve_topic_branch_status(status: &str) -> Option<&'static str> {
match status.trim() {
"ready" | "success" => Some("merged"),
"draft" | "streaming" | "pending" | "queued" | "running" => Some("pending"),
"failed" | "error" | "timeout" | "canceled" => Some("candidate"),
_ => None,
}
}
fn build_theme_workbench_document_state(
version_history: &[ArtifactVersionSummary],
current_version_id: &str,
) -> Map<String, Value> {
let mut state = Map::new();
state.insert(
"currentVersionId".to_string(),
Value::String(current_version_id.to_string()),
);
state.insert(
"versions".to_string(),
Value::Array(
version_history
.iter()
.rev()
.map(|version| {
let mut record = Map::new();
record.insert("id".to_string(), Value::String(version.id.clone()));
record.insert(
"createdAt".to_string(),
Value::from(
parse_rfc3339_to_timestamp_millis(version.created_at.as_str())
.unwrap_or_default(),
),
);
record.insert(
"description".to_string(),
Value::String(
version
.summary
.clone()
.unwrap_or_else(|| format!("版本 {}", version.version_no)),
),
);
Value::Object(record)
})
.collect(),
),
);
state.insert(
"versionStatusMap".to_string(),
Value::Object(
version_history
.iter()
.filter_map(|version| {
resolve_topic_branch_status(version.status.as_str())
.map(|status| (version.id.clone(), Value::String(status.to_string())))
})
.collect(),
),
);
state
}
fn normalize_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn extract_block_text(block: &Map<String, Value>) -> Option<String> {
normalize_text(block.get("markdown").and_then(Value::as_str))
.or_else(|| normalize_text(block.get("text").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("content").and_then(Value::as_str)))
.or_else(|| normalize_text(block.get("summary").and_then(Value::as_str)))
.or_else(|| {
block.get("items").and_then(Value::as_array).map(|items| {
items
.iter()
.filter_map(|item| {
if let Some(text) = item.as_str() {
return normalize_text(Some(text));
}
let item = item.as_object()?;
normalize_text(item.get("label").and_then(Value::as_str))
.or_else(|| normalize_text(item.get("text").and_then(Value::as_str)))
.or_else(|| normalize_text(item.get("title").and_then(Value::as_str)))
})
.collect::<Vec<_>>()
.join("\n")
})
})
.and_then(|value| normalize_text(Some(value.as_str())))
}
fn build_content_body_from_document(document: &Value) -> String {
let Some(record) = document.as_object() else {
return String::new();
};
let mut sections = Vec::new();
if let Some(title) = normalize_text(record.get("title").and_then(Value::as_str)) {
sections.push(format!("# {title}"));
}
if let Some(summary) = normalize_text(record.get("summary").and_then(Value::as_str)) {
sections.push(summary);
}
if let Some(blocks) = record.get("blocks").and_then(Value::as_array) {
for block in blocks.iter().filter_map(Value::as_object) {
let mut parts = Vec::new();
if let Some(title) = normalize_text(block.get("title").and_then(Value::as_str)) {
parts.push(format!("## {title}"));
}
if let Some(body) = extract_block_text(block) {
parts.push(body);
}
if !parts.is_empty() {
sections.push(parts.join("\n\n"));
}
}
}
sections.join("\n\n").trim().to_string()
}
fn extract_content_id_from_request_metadata(request_metadata: Option<&Value>) -> Option<String> {
let root = request_metadata?.as_object()?;
let harness = root
.get("harness")
.and_then(Value::as_object)
.unwrap_or(root);
["content_id", "contentId"]
.iter()
.filter_map(|key| harness.get(*key))
.find_map(Value::as_str)
.and_then(|value| normalize_text(Some(value)))
}
fn should_sync_snapshot_metadata_key_to_content(key: &str) -> bool {
key.starts_with("artifact") || matches!(key, "previewText" | "lastUpdateSource")
}
pub fn sync_persisted_artifact_document_to_content(
db: &DbConnection,
request_metadata: Option<&Value>,
persisted: &PersistedArtifactDocument,
) -> Result<(), String> {
let Some(content_id) = extract_content_id_from_request_metadata(request_metadata) else {
return Ok(());
};
let manager = ContentManager::new(db.clone());
let Some(content) = manager.get(&content_id)? else {
return Err(format!("未找到要同步的内容: {content_id}"));
};
let mut next_metadata = content
.metadata
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
for (key, value) in persisted.snapshot_metadata.iter() {
if should_sync_snapshot_metadata_key_to_content(key.as_str()) {
next_metadata.insert(key.clone(), value.clone());
}
}
next_metadata.insert(
THEME_WORKBENCH_DOCUMENT_META_KEY.to_string(),
Value::Object(persisted.theme_workbench_document_state.clone()),
);
manager.update(
&content_id,
ContentUpdateRequest {
body: (!persisted.content_body.trim().is_empty())
.then(|| persisted.content_body.clone()),
metadata: Some(Value::Object(next_metadata)),
..Default::default()
},
)?;
Ok(())
}
fn build_version_id(artifact_id: &str, version_no: usize) -> String {
format!("{artifact_id}:v{version_no}")
}
@@ -1075,6 +1277,131 @@ mod tests {
assert!(persisted_second
.serialized_document
.contains("\"currentVersionDiff\""));
assert_eq!(
persisted_second
.theme_workbench_document_state
.get("currentVersionId")
.and_then(Value::as_str),
Some("artifact-document:artifact:analysis:demo:v2")
);
assert!(persisted_second.content_body.contains("# 结构化结论"));
}
#[test]
fn sync_persisted_artifact_document_to_content_should_update_body_and_metadata() {
use crate::content::{ContentCreateRequest, ContentManager};
use crate::database::init_database;
use crate::workspace::{WorkspaceManager, WorkspaceType};
let db = init_database().expect("db should init");
let workspace_root = tempdir().expect("tempdir").keep();
let workspace = WorkspaceManager::new(db.clone())
.create_with_type(
"自动化项目".to_string(),
workspace_root.clone(),
WorkspaceType::Document,
)
.expect("workspace should create");
let manager = ContentManager::new(db.clone());
let content = manager
.create(ContentCreateRequest {
project_id: workspace.id.clone(),
title: "自动化日报".to_string(),
content_type: None,
order: None,
body: Some(String::new()),
metadata: Some(serde_json::json!({
"source": "service_skill"
})),
})
.expect("content should create");
let params = ArtifactDocumentPersistParams {
workspace_root,
workspace_id: Some(workspace.id.clone()),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
request_metadata: Some(serde_json::json!({
"artifact": {
"artifact_mode": "draft",
"artifact_stage": "stage2",
"artifact_kind": "report",
"artifact_request_id": format!("artifact:{}", content.id.clone())
},
"harness": {
"content_id": content.id.clone()
}
})),
};
let persisted = persist_artifact_document_from_text(
r#"{
"type": "artifact_document_draft",
"schemaVersion": "artifact_document.v1",
"artifactId": "artifact-document:artifact:content",
"kind": "report",
"title": "自动化日报",
"status": "ready",
"summary": "最新日报已生成",
"blocks": [
{ "id": "body-1", "type": "rich_text", "markdown": "日报正文内容" }
]
}"#,
&params,
)
.expect("persist should succeed");
sync_persisted_artifact_document_to_content(
&db,
params.request_metadata.as_ref(),
&persisted,
)
.expect("sync should succeed");
let updated = manager
.get(&content.id)
.expect("get content should succeed")
.expect("updated content should exist");
assert!(updated.body.contains("日报正文内容"));
let metadata = updated.metadata.expect("metadata should exist");
assert_eq!(
metadata
.get(THEME_WORKBENCH_DOCUMENT_META_KEY)
.and_then(Value::as_object)
.and_then(|value| value.get("currentVersionId"))
.and_then(Value::as_str),
Some(format!("artifact-document:artifact:{}:v1", content.id).as_str())
);
assert_eq!(
metadata.get("artifactKind").and_then(Value::as_str),
Some("report")
);
assert_eq!(
metadata.get("artifactRequestId").and_then(Value::as_str),
Some(format!("artifact:{}", content.id).as_str())
);
assert_eq!(
metadata
.get("artifact_paths")
.and_then(Value::as_array)
.and_then(|paths| paths.first())
.and_then(Value::as_str),
Some(
format!(
".lime/artifacts/thread-1/{}.artifact.json",
normalize_slug(format!("artifact:{}", content.id).as_str())
)
.as_str()
)
);
assert_eq!(
metadata
.get("artifactDocument")
.and_then(Value::as_object)
.and_then(|document| document.get("title"))
.and_then(Value::as_str),
Some("自动化日报")
);
}
#[test]
@@ -151,10 +151,66 @@ fn is_meaningful_artifact_value(value: Option<&Value>) -> bool {
.is_some_and(|value| !value.is_empty())
}
fn backfill_harness_string_if_missing(
request_metadata: Value,
keys: &[&str],
fallback: Option<&str>,
) -> Value {
let Some(fallback) = normalize_text(fallback) else {
return request_metadata;
};
if extract_harness_string(Some(&request_metadata), keys).is_some() {
return request_metadata;
}
let mut request_metadata = request_metadata;
let Some(root) = request_metadata.as_object_mut() else {
return request_metadata;
};
if let Some(harness) = root.get_mut("harness").and_then(Value::as_object_mut) {
harness.insert(keys[0].to_string(), Value::String(fallback));
return request_metadata;
}
root.insert(keys[0].to_string(), Value::String(fallback));
request_metadata
}
pub fn normalize_request_metadata_with_artifact_defaults(
request_metadata: Option<Value>,
theme_fallback: Option<&str>,
session_mode_fallback: Option<&str>,
gate_key_fallback: Option<&str>,
run_title_fallback: Option<&str>,
content_id_fallback: Option<&str>,
) -> Option<Value> {
let request_metadata = request_metadata?;
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["theme", "harness_theme", "harnessTheme"],
theme_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["session_mode", "sessionMode"],
session_mode_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["gate_key", "gateKey"],
gate_key_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["run_title", "runTitle", "title"],
run_title_fallback,
);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["content_id", "contentId"],
content_id_fallback,
);
let Some(root) = request_metadata.as_object() else {
return Some(request_metadata);
};
@@ -250,8 +306,15 @@ mod tests {
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
None,
)
.expect("normalized metadata");
assert_eq!(
normalized
@@ -302,8 +365,15 @@ mod tests {
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
None,
)
.expect("normalized metadata");
assert!(normalized.get("artifact").is_none());
}
@@ -320,8 +390,15 @@ mod tests {
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata))
.expect("normalized metadata");
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
None,
)
.expect("normalized metadata");
assert_eq!(
normalized
@@ -342,4 +419,107 @@ mod tests {
None
);
}
#[test]
fn should_backfill_content_id_before_infer_artifact_request_id() {
let metadata = json!({
"harness": {
"theme": "document",
"session_mode": "theme_workbench"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
None,
None,
Some("content-from-session"),
)
.expect("normalized metadata");
assert_eq!(
normalized
.pointer("/harness/content_id")
.and_then(Value::as_str),
Some("content-from-session")
);
assert_eq!(
normalized
.pointer("/artifact/artifact_request_id")
.and_then(Value::as_str),
Some("artifact:content-from-session")
);
}
#[test]
fn should_backfill_theme_and_session_mode_before_infer_artifact_defaults() {
let metadata = json!({
"harness": {
"content_id": "content-1"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
Some("document"),
Some("theme_workbench"),
None,
None,
None,
)
.expect("normalized metadata");
assert_eq!(
normalized.pointer("/harness/theme").and_then(Value::as_str),
Some("document")
);
assert_eq!(
normalized
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
);
assert_eq!(
normalized
.pointer("/artifact/artifact_request_id")
.and_then(Value::as_str),
Some("artifact:content-1")
);
}
#[test]
fn should_backfill_gate_key_and_run_title_when_missing() {
let metadata = json!({
"harness": {
"theme": "social-media",
"session_mode": "theme_workbench",
"content_id": "content-social-1"
}
});
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
None,
None,
Some("write_mode"),
Some("社媒初稿"),
None,
)
.expect("normalized metadata");
assert_eq!(
normalized
.pointer("/harness/gate_key")
.and_then(Value::as_str),
Some("write_mode")
);
assert_eq!(
normalized
.pointer("/harness/run_title")
.and_then(Value::as_str),
Some("社媒初稿")
);
}
}
+160 -26
View File
@@ -1085,34 +1085,79 @@ pub(super) fn append_payload_tracking_metadata(metadata: &mut Map<String, Value>
Value::String(parsed_payload.kind().to_string()),
);
if let AutomationPayload::BrowserSession {
profile_id,
profile_key,
url,
environment_preset_id,
target_id,
open_window,
stream_mode,
} = parsed_payload
{
metadata.insert("profile_id".to_string(), Value::String(profile_id));
if let Some(profile_key) = profile_key {
metadata.insert("profile_key".to_string(), Value::String(profile_key));
match parsed_payload {
AutomationPayload::AgentTurn {
request_metadata,
content_id,
..
} => {
if let Some(content_id) = content_id.clone() {
metadata.insert("content_id".to_string(), Value::String(content_id));
}
let request_metadata = request_metadata.as_ref().and_then(Value::as_object);
if let Some(service_skill) = request_metadata.and_then(|value| {
value
.get("service_skill")
.or_else(|| value.get("serviceSkill"))
}) {
metadata.insert("service_skill".to_string(), service_skill.clone());
}
let harness = request_metadata
.and_then(|value| value.get("harness"))
.and_then(Value::as_object)
.map(|value| {
let mut next = value.clone();
if let Some(content_id) = content_id.as_ref() {
if !next.contains_key("content_id") && !next.contains_key("contentId") {
next.insert(
"content_id".to_string(),
Value::String(content_id.clone()),
);
}
}
Value::Object(next)
})
.or_else(|| {
content_id.map(|value| {
json!({
"content_id": value
})
})
});
if let Some(harness) = harness {
metadata.insert("harness".to_string(), harness);
}
}
if let Some(url) = url {
metadata.insert("url".to_string(), Value::String(url));
AutomationPayload::BrowserSession {
profile_id,
profile_key,
url,
environment_preset_id,
target_id,
open_window,
stream_mode,
} => {
metadata.insert("profile_id".to_string(), Value::String(profile_id));
if let Some(profile_key) = profile_key {
metadata.insert("profile_key".to_string(), Value::String(profile_key));
}
if let Some(url) = url {
metadata.insert("url".to_string(), Value::String(url));
}
if let Some(environment_preset_id) = environment_preset_id {
metadata.insert(
"environment_preset_id".to_string(),
Value::String(environment_preset_id),
);
}
if let Some(target_id) = target_id {
metadata.insert("target_id".to_string(), Value::String(target_id));
}
metadata.insert("open_window".to_string(), Value::Bool(open_window));
metadata.insert("stream_mode".to_string(), json!(stream_mode));
}
if let Some(environment_preset_id) = environment_preset_id {
metadata.insert(
"environment_preset_id".to_string(),
Value::String(environment_preset_id),
);
}
if let Some(target_id) = target_id {
metadata.insert("target_id".to_string(), Value::String(target_id));
}
metadata.insert("open_window".to_string(), Value::Bool(open_window));
metadata.insert("stream_mode".to_string(), json!(stream_mode));
}
}
@@ -1335,6 +1380,95 @@ mod tests {
);
}
#[test]
fn build_tracker_finish_metadata_should_include_agent_turn_service_skill_context() {
let job = AutomationJob {
id: "job-1".to_string(),
name: "每日趋势摘要".to_string(),
description: Some("围绕指定平台输出趋势摘要".to_string()),
enabled: true,
workspace_id: "workspace-1".to_string(),
execution_mode: AutomationExecutionMode::Skill,
schedule: TaskSchedule::Cron {
expr: "0 9 * * *".to_string(),
tz: Some("Asia/Shanghai".to_string()),
},
payload: json!({
"kind": "agent_turn",
"prompt": "[服务型技能] 每日趋势摘要",
"web_search": false,
"content_id": "content-1",
"request_metadata": {
"service_skill": {
"id": "daily-trend-briefing",
"title": "每日趋势摘要",
"runner_type": "scheduled",
"execution_location": "client_default",
"source": "cloud_catalog",
"slot_values": [
{
"key": "platform",
"label": "监测平台",
"value": "小红书"
}
],
"user_input": "关注增长最快的话题"
},
"harness": {
"theme": "social-media"
}
}
}),
delivery: DeliveryConfig::default(),
timeout_secs: None,
max_retries: 3,
next_run_at: None,
last_status: None,
last_error: None,
last_run_at: None,
last_finished_at: None,
running_started_at: None,
consecutive_failures: 0,
last_retry_count: 0,
auto_disabled_until: None,
last_delivery: None,
created_at: "2026-03-15T00:00:00Z".to_string(),
updated_at: "2026-03-15T00:00:00Z".to_string(),
};
let metadata = build_tracker_finish_metadata(
&job,
Some("session-1"),
"success",
"success",
0,
1200,
None,
);
assert_eq!(metadata.get("content_id"), Some(&json!("content-1")));
assert_eq!(
metadata.pointer("/service_skill/title"),
Some(&json!("每日趋势摘要"))
);
assert_eq!(
metadata.pointer("/service_skill/slot_values/0/label"),
Some(&json!("监测平台"))
);
assert_eq!(
metadata.pointer("/service_skill/user_input"),
Some(&json!("关注增长最快的话题"))
);
assert_eq!(
metadata.pointer("/harness/theme"),
Some(&json!("social-media"))
);
assert_eq!(
metadata.pointer("/harness/content_id"),
Some(&json!("content-1"))
);
}
#[test]
fn build_delivery_context_should_build_stable_attempt_id_without_run_id() {
let job = AutomationJob {
@@ -8,6 +8,21 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
const DEFAULT_CDP_TIMEOUT_MS: u64 = 10_000;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID: &str = "browser-environment-us-desktop";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME: &str = "美区桌面";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_DESCRIPTION: &str = "美国住宅代理 + 桌面视口";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER: &str = "http://127.0.0.1:7890";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_TIMEZONE_ID: &str = "America/Los_Angeles";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LOCALE: &str = "en-US";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_ACCEPT_LANGUAGE: &str = "en-US,en;q=0.9";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_USER_AGENT: &str = "Mozilla/5.0";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_PLATFORM: &str = "MacIntel";
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_WIDTH: i64 = 1440;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_HEIGHT: i64 = 900;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_DEVICE_SCALE_FACTOR: f64 = 2.0;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LAT: f64 = 37.7749;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LNG: f64 = -122.4194;
const DEFAULT_BROWSER_ENVIRONMENT_PRESET_GEO_ACCURACY_M: f64 = 100.0;
#[derive(Debug, Clone)]
pub struct SaveBrowserEnvironmentPresetInput {
@@ -105,6 +120,37 @@ pub fn list_browser_environment_presets(
.map_err(|error| format!("读取浏览器环境预设失败: {error}"))
}
pub fn ensure_default_browser_environment_presets(conn: &Connection) -> Result<bool, String> {
let existing_presets = BrowserEnvironmentPresetDao::list(conn, true)
.map_err(|error| format!("读取浏览器环境预设失败: {error}"))?;
if !existing_presets.is_empty() {
return Ok(false);
}
save_browser_environment_preset(
conn,
SaveBrowserEnvironmentPresetInput {
id: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID.to_string()),
name: DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME.to_string(),
description: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_DESCRIPTION.to_string()),
proxy_server: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER.to_string()),
timezone_id: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_TIMEZONE_ID.to_string()),
locale: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LOCALE.to_string()),
accept_language: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_ACCEPT_LANGUAGE.to_string()),
geolocation_lat: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LAT),
geolocation_lng: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LNG),
geolocation_accuracy_m: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_GEO_ACCURACY_M),
user_agent: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_USER_AGENT.to_string()),
platform: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PLATFORM.to_string()),
viewport_width: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_WIDTH),
viewport_height: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_HEIGHT),
device_scale_factor: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_DEVICE_SCALE_FACTOR),
},
)?;
Ok(true)
}
pub fn get_browser_environment_preset(
conn: &Connection,
id: &str,
@@ -405,6 +451,14 @@ fn extract_runtime_value(response: Value) -> Option<Value> {
#[cfg(test)]
mod tests {
use super::*;
use crate::database::schema::create_tables;
use rusqlite::Connection;
fn setup_db() -> Connection {
let conn = Connection::open_in_memory().expect("创建内存数据库失败");
create_tables(&conn).expect("创建数据表失败");
conn
}
#[test]
fn should_require_complete_geolocation_pair() {
@@ -441,4 +495,54 @@ mod tests {
let error = normalize_viewport(Some(1440), None).unwrap_err();
assert!(error.contains("必须同时填写"));
}
#[test]
fn should_seed_default_environment_preset_for_empty_table() {
let conn = setup_db();
let seeded = ensure_default_browser_environment_presets(&conn).unwrap();
let presets = list_browser_environment_presets(&conn, false).unwrap();
assert!(seeded);
assert_eq!(presets.len(), 1);
assert_eq!(presets[0].id, DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID);
assert_eq!(presets[0].name, DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME);
assert_eq!(
presets[0].proxy_server.as_deref(),
Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER)
);
}
#[test]
fn should_not_seed_default_environment_preset_when_table_has_records() {
let conn = setup_db();
save_browser_environment_preset(
&conn,
SaveBrowserEnvironmentPresetInput {
id: None,
name: "日本桌面".to_string(),
description: None,
proxy_server: None,
timezone_id: Some("Asia/Tokyo".to_string()),
locale: Some("ja-JP".to_string()),
accept_language: Some("ja-JP,ja;q=0.9".to_string()),
geolocation_lat: None,
geolocation_lng: None,
geolocation_accuracy_m: None,
user_agent: None,
platform: Some("MacIntel".to_string()),
viewport_width: Some(1440),
viewport_height: Some(900),
device_scale_factor: Some(2.0),
},
)
.unwrap();
let seeded = ensure_default_browser_environment_presets(&conn).unwrap();
let presets = list_browser_environment_presets(&conn, false).unwrap();
assert!(!seeded);
assert_eq!(presets.len(), 1);
assert_eq!(presets[0].name, "日本桌面");
}
}
@@ -7,6 +7,12 @@ use lime_core::database::dao::browser_profile::{
use rusqlite::Connection;
use url::Url;
const DEFAULT_BROWSER_PROFILE_KEY: &str = "general_browser_assist";
const DEFAULT_BROWSER_PROFILE_NAME: &str = "通用浏览器资料";
const DEFAULT_BROWSER_PROFILE_DESCRIPTION: &str = "默认浏览器协助资料";
const DEFAULT_BROWSER_PROFILE_SITE_SCOPE: &str = "通用";
const DEFAULT_BROWSER_PROFILE_LAUNCH_URL: &str = "https://www.google.com/";
#[derive(Debug, Clone)]
pub struct SaveBrowserProfileInput {
pub id: Option<String>,
@@ -63,6 +69,29 @@ pub fn list_browser_profiles(
.map_err(|error| format!("读取浏览器资料失败: {error}"))
}
pub fn ensure_default_browser_profiles(conn: &Connection) -> Result<bool, String> {
let existing_profiles = BrowserProfileDao::list(conn, true)
.map_err(|error| format!("读取浏览器资料失败: {error}"))?;
if !existing_profiles.is_empty() {
return Ok(false);
}
save_browser_profile(
conn,
SaveBrowserProfileInput {
id: None,
profile_key: DEFAULT_BROWSER_PROFILE_KEY.to_string(),
name: DEFAULT_BROWSER_PROFILE_NAME.to_string(),
description: Some(DEFAULT_BROWSER_PROFILE_DESCRIPTION.to_string()),
site_scope: Some(DEFAULT_BROWSER_PROFILE_SITE_SCOPE.to_string()),
launch_url: Some(DEFAULT_BROWSER_PROFILE_LAUNCH_URL.to_string()),
transport_kind: BrowserProfileTransportKind::ManagedCdp,
},
)?;
Ok(true)
}
pub fn get_browser_profile(
conn: &Connection,
id: &str,
@@ -306,4 +335,46 @@ mod tests {
assert_eq!(saved.profile_dir, "");
assert_eq!(saved.managed_profile_dir, None);
}
#[test]
fn should_seed_default_profile_for_empty_table() {
let conn = setup_db();
let seeded = ensure_default_browser_profiles(&conn).unwrap();
let profiles = list_browser_profiles(&conn, false).unwrap();
assert!(seeded);
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].profile_key, DEFAULT_BROWSER_PROFILE_KEY);
assert_eq!(profiles[0].name, DEFAULT_BROWSER_PROFILE_NAME);
assert_eq!(
profiles[0].transport_kind,
BrowserProfileTransportKind::ManagedCdp
);
}
#[test]
fn should_not_seed_default_profile_when_table_has_records() {
let conn = setup_db();
save_browser_profile(
&conn,
SaveBrowserProfileInput {
id: None,
profile_key: "weibo_attach".to_string(),
name: "微博附着".to_string(),
description: Some("依赖当前 Chrome".to_string()),
site_scope: Some("weibo.com".to_string()),
launch_url: Some("https://weibo.com".to_string()),
transport_kind: BrowserProfileTransportKind::ExistingSession,
},
)
.unwrap();
let seeded = ensure_default_browser_profiles(&conn).unwrap();
let profiles = list_browser_profiles(&conn, false).unwrap();
assert!(!seeded);
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].profile_key, "weibo_attach");
}
}
+5
View File
@@ -30,6 +30,11 @@ pub mod memory_source_resolver_service;
pub mod novel_service;
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_handoff_artifact_service;
pub mod runtime_replay_case_service;
pub mod runtime_review_decision_service;
pub mod site_adapter_registry;
pub mod site_capability_service;
pub mod sysinfo_service;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,740 @@
//! Runtime evidence pack 导出服务
//!
//! 将当前 Lime 会话的 runtime / timeline / artifact 事实,
//! 导出为最小可复盘的问题证据包。
use crate::agent::SessionDetail;
use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
use chrono::Utc;
use lime_core::database::dao::agent_timeline::AgentThreadItemPayload;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
const SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions";
const EVIDENCE_DIR_NAME: &str = "evidence";
const SUMMARY_FILE_NAME: &str = "summary.md";
const RUNTIME_FILE_NAME: &str = "runtime.json";
const TIMELINE_FILE_NAME: &str = "timeline.json";
const ARTIFACTS_FILE_NAME: &str = "artifacts.json";
const MAX_RECENT_ARTIFACTS: usize = 12;
const MAX_PREVIEW_CHARS: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeEvidenceArtifactKind {
Summary,
Runtime,
Timeline,
Artifacts,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeEvidenceArtifact {
pub kind: RuntimeEvidenceArtifactKind,
pub title: String,
pub relative_path: String,
pub absolute_path: String,
pub bytes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeEvidencePackExportResult {
pub session_id: String,
pub thread_id: String,
pub workspace_id: Option<String>,
pub workspace_root: String,
pub pack_relative_root: String,
pub pack_absolute_root: String,
pub exported_at: String,
pub thread_status: String,
pub latest_turn_status: Option<String>,
pub turn_count: usize,
pub item_count: usize,
pub pending_request_count: usize,
pub queued_turn_count: usize,
pub recent_artifact_count: usize,
pub known_gaps: Vec<String>,
pub artifacts: Vec<RuntimeEvidenceArtifact>,
}
pub fn export_runtime_evidence_pack(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
) -> Result<RuntimeEvidencePackExportResult, String> {
let session_id = detail.id.trim();
if session_id.is_empty() {
return Err("session_id 不能为空,无法导出问题证据包".to_string());
}
let thread_id = detail.thread_id.trim();
if thread_id.is_empty() {
return Err("thread_id 不能为空,无法导出问题证据包".to_string());
}
let workspace_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
let exported_at = Utc::now().to_rfc3339();
let pack_relative_root = format!("{SESSION_RELATIVE_ROOT}/{session_id}/{EVIDENCE_DIR_NAME}");
let pack_absolute_root =
workspace_root.join(pack_relative_root.replace('/', std::path::MAIN_SEPARATOR_STR));
fs::create_dir_all(&pack_absolute_root).map_err(|error| {
format!(
"创建 evidence pack 目录失败 {}: {error}",
pack_absolute_root.display()
)
})?;
let recent_artifacts = collect_recent_artifact_paths(detail);
let latest_turn_summary = collect_latest_turn_summary(detail);
let known_gaps = build_known_gaps(&recent_artifacts);
let artifacts = vec![
write_evidence_file(
&pack_absolute_root,
session_id,
SUMMARY_FILE_NAME,
RuntimeEvidenceArtifactKind::Summary,
"问题摘要",
build_summary_markdown(
detail,
thread_read,
&recent_artifacts,
latest_turn_summary.as_deref(),
&known_gaps,
exported_at.as_str(),
),
)?,
write_evidence_file(
&pack_absolute_root,
session_id,
RUNTIME_FILE_NAME,
RuntimeEvidenceArtifactKind::Runtime,
"运行时快照",
build_runtime_json(
detail,
thread_read,
workspace_root.as_path(),
&recent_artifacts,
&known_gaps,
exported_at.as_str(),
)?,
)?,
write_evidence_file(
&pack_absolute_root,
session_id,
TIMELINE_FILE_NAME,
RuntimeEvidenceArtifactKind::Timeline,
"时间线快照",
build_timeline_json(detail, exported_at.as_str())?,
)?,
write_evidence_file(
&pack_absolute_root,
session_id,
ARTIFACTS_FILE_NAME,
RuntimeEvidenceArtifactKind::Artifacts,
"产物与验证线索",
build_artifacts_json(
detail,
thread_read,
&recent_artifacts,
&known_gaps,
exported_at.as_str(),
)?,
)?,
];
Ok(RuntimeEvidencePackExportResult {
session_id: session_id.to_string(),
thread_id: thread_id.to_string(),
workspace_id: normalize_optional_text(detail.workspace_id.clone()),
workspace_root: workspace_root.to_string_lossy().to_string(),
pack_relative_root,
pack_absolute_root: pack_absolute_root.to_string_lossy().to_string(),
exported_at,
thread_status: thread_read.status.trim().to_string(),
latest_turn_status: thread_read
.diagnostics
.as_ref()
.and_then(|value| normalize_optional_text(value.latest_turn_status.clone())),
turn_count: detail.turns.len(),
item_count: detail.items.len(),
pending_request_count: thread_read.pending_requests.len(),
queued_turn_count: thread_read.queued_turns.len(),
recent_artifact_count: recent_artifacts.len(),
known_gaps,
artifacts,
})
}
fn write_evidence_file(
pack_root: &Path,
session_id: &str,
file_name: &str,
kind: RuntimeEvidenceArtifactKind,
title: &str,
content: String,
) -> Result<RuntimeEvidenceArtifact, String> {
let absolute_path = pack_root.join(file_name);
fs::write(&absolute_path, content.as_bytes()).map_err(|error| {
format!(
"写入 evidence pack 文件失败 {}: {error}",
absolute_path.display()
)
})?;
Ok(RuntimeEvidenceArtifact {
kind,
title: title.to_string(),
relative_path: format!(
"{SESSION_RELATIVE_ROOT}/{session_id}/{EVIDENCE_DIR_NAME}/{file_name}"
),
absolute_path: absolute_path.to_string_lossy().to_string(),
bytes: content.len(),
})
}
fn build_summary_markdown(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
recent_artifacts: &[String],
latest_turn_summary: Option<&str>,
known_gaps: &[String],
exported_at: &str,
) -> String {
let mut markdown = String::new();
let _ = writeln!(markdown, "# 问题证据包");
let _ = writeln!(markdown);
let _ = writeln!(
markdown,
"> 当前证据包继续沿用 Codex 的结构化交接思路,运行时事实承接 Aster 的 session / thread / diagnostics,最终制品由 Lime 落盘到工作区。"
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "- 会话:`{}`", detail.id);
let _ = writeln!(markdown, "- 线程:`{}`", detail.thread_id);
let _ = writeln!(markdown, "- 导出时间:{exported_at}");
let _ = writeln!(markdown, "- 线程状态:{}", thread_read.status);
let _ = writeln!(
markdown,
"- Pending request:{} · 排队 turn:{}",
thread_read.pending_requests.len(),
thread_read.queued_turns.len()
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 最近摘要");
let _ = writeln!(markdown);
let _ = writeln!(
markdown,
"{}",
latest_turn_summary
.unwrap_or("当前没有结构化 turn summary,请先读 runtime.json 与 timeline.json。")
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 证据概览");
let _ = writeln!(markdown);
let _ = writeln!(markdown, "- Turns:{}", detail.turns.len());
let _ = writeln!(markdown, "- Timeline items:{}", detail.items.len());
let _ = writeln!(markdown, "- 最近产物:{}", recent_artifacts.len());
if let Some(blocking_summary) = thread_read
.diagnostics
.as_ref()
.and_then(|value| value.primary_blocking_summary.clone())
{
let _ = writeln!(markdown, "- 当前主要阻塞:{blocking_summary}");
}
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 建议读取顺序");
let _ = writeln!(markdown);
let _ = writeln!(markdown, "1. 先读 `summary.md`,确认会话状态和当前阻塞。");
let _ = writeln!(
markdown,
"2. 再读 `runtime.json`,查看 pending request / queued turn / diagnostics。"
);
let _ = writeln!(
markdown,
"3. 再读 `timeline.json`,回放最近 turns 与 items。"
);
let _ = writeln!(
markdown,
"4. 最后读 `artifacts.json`,确认最近产物与当前证据缺口。"
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 已知缺口");
let _ = writeln!(markdown);
for gap in known_gaps {
let _ = writeln!(markdown, "- {gap}");
}
markdown
}
fn build_runtime_json(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
recent_artifacts: &[String],
known_gaps: &[String],
exported_at: &str,
) -> Result<String, String> {
let payload = json!({
"schemaVersion": "v1",
"source": {
"contractShape": "codex_trace_evidence_pack",
"runtimeSubstrate": "aster_session_thread_runtime",
"productSurface": "lime_workspace_evidence_pack"
},
"session": {
"sessionId": detail.id,
"threadId": detail.thread_id,
"name": detail.name,
"workspaceId": detail.workspace_id,
"workspaceRoot": workspace_root.to_string_lossy().to_string(),
"exportedAt": exported_at,
"updatedAt": detail.updated_at,
"executionStrategy": detail.execution_strategy,
"model": detail.model
},
"thread": {
"status": thread_read.status,
"activeTurnId": thread_read.active_turn_id,
"interruptState": thread_read.interrupt_state,
"latestTurnStatus": thread_read.diagnostics.as_ref().and_then(|value| value.latest_turn_status.clone()),
"pendingRequestCount": thread_read.pending_requests.len(),
"queuedTurnCount": thread_read.queued_turns.len(),
"diagnostics": {
"warningCount": thread_read.diagnostics.as_ref().map(|value| value.warning_count).unwrap_or(0),
"contextCompactionCount": thread_read.diagnostics.as_ref().map(|value| value.context_compaction_count).unwrap_or(0),
"failedToolCallCount": thread_read.diagnostics.as_ref().map(|value| value.failed_tool_call_count).unwrap_or(0),
"failedCommandCount": thread_read.diagnostics.as_ref().map(|value| value.failed_command_count).unwrap_or(0),
"primaryBlockingKind": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_kind.clone()),
"primaryBlockingSummary": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_summary.clone()),
"latestWarning": thread_read.diagnostics.as_ref().and_then(|value| value.latest_warning.as_ref().map(|warning| json!({
"code": warning.code,
"message": warning.message,
"updatedAt": warning.updated_at
}))),
"latestFailedTool": thread_read.diagnostics.as_ref().and_then(|value| value.latest_failed_tool.as_ref().map(|tool| json!({
"toolName": tool.tool_name,
"error": tool.error,
"updatedAt": tool.updated_at
}))),
"latestFailedCommand": thread_read.diagnostics.as_ref().and_then(|value| value.latest_failed_command.as_ref().map(|command| json!({
"command": command.command,
"exitCode": command.exit_code,
"error": command.error,
"updatedAt": command.updated_at
})))
}
},
"pendingRequests": thread_read.pending_requests.iter().map(|item| {
json!({
"id": item.id,
"type": item.request_type,
"status": item.status,
"title": item.title,
"turnId": item.turn_id
})
}).collect::<Vec<_>>(),
"queuedTurns": thread_read.queued_turns.iter().map(|item| {
json!({
"id": item.queued_turn_id,
"position": item.position,
"preview": item.message_preview,
"createdAt": item.created_at
})
}).collect::<Vec<_>>(),
"subagents": detail.child_subagent_sessions.iter().map(|session| {
json!({
"id": session.id,
"name": session.name,
"runtimeStatus": session.runtime_status,
"latestTurnStatus": session.latest_turn_status,
"taskSummary": session.task_summary,
"roleHint": session.role_hint,
"updatedAt": session.updated_at
})
}).collect::<Vec<_>>(),
"recentArtifacts": recent_artifacts,
"knownGaps": known_gaps
});
serde_json::to_string_pretty(&payload)
.map_err(|error| format!("序列化 runtime.json 失败: {error}"))
}
fn build_timeline_json(detail: &SessionDetail, exported_at: &str) -> Result<String, String> {
let payload = json!({
"schemaVersion": "v1",
"exportedAt": exported_at,
"turns": detail.turns.iter().map(|turn| {
json!({
"id": turn.id,
"status": serialize_enum_as_string(&turn.status, "unknown"),
"promptPreview": truncate_text(turn.prompt_text.as_str()),
"startedAt": turn.started_at,
"completedAt": turn.completed_at,
"updatedAt": turn.updated_at
})
}).collect::<Vec<_>>(),
"items": detail.items.iter().map(|item| {
let (payload_kind, payload_summary) = summarize_item_payload(&item.payload);
json!({
"id": item.id,
"turnId": item.turn_id,
"sequence": item.sequence,
"status": serialize_enum_as_string(&item.status, "unknown"),
"payloadKind": payload_kind,
"payloadSummary": payload_summary,
"updatedAt": item.updated_at
})
}).collect::<Vec<_>>()
});
serde_json::to_string_pretty(&payload)
.map_err(|error| format!("序列化 timeline.json 失败: {error}"))
}
fn build_artifacts_json(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
recent_artifacts: &[String],
known_gaps: &[String],
exported_at: &str,
) -> Result<String, String> {
let payload = json!({
"schemaVersion": "v1",
"exportedAt": exported_at,
"recentArtifacts": recent_artifacts,
"artifactCount": recent_artifacts.len(),
"verification": {
"artifactValidatorIssues": [],
"browserEvidence": [],
"guiSmoke": null
},
"requests": {
"pending": thread_read.pending_requests.iter().map(|item| {
json!({
"id": item.id,
"type": item.request_type,
"title": item.title,
"status": item.status
})
}).collect::<Vec<_>>(),
"knownGap": "provider request token / retry 摘要尚未接入当前 evidence pack"
},
"workspace": {
"workspaceId": detail.workspace_id,
"workingDir": detail.working_dir
},
"knownGaps": known_gaps
});
serde_json::to_string_pretty(&payload)
.map_err(|error| format!("序列化 artifacts.json 失败: {error}"))
}
fn build_known_gaps(recent_artifacts: &[String]) -> Vec<String> {
let mut gaps = vec![
"当前 Evidence Pack 尚未纳入 provider 请求级 token / retry / duration 摘要。".to_string(),
"当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。".to_string(),
];
if recent_artifacts.is_empty() {
gaps.push("当前未检测到最近产物路径,Artifact 证据为空。".to_string());
}
gaps
}
fn collect_latest_turn_summary(detail: &SessionDetail) -> Option<String> {
detail
.items
.iter()
.rev()
.find_map(|item| match &item.payload {
AgentThreadItemPayload::TurnSummary { text } => {
normalize_optional_text(Some(text.clone()))
}
_ => None,
})
}
fn collect_recent_artifact_paths(detail: &SessionDetail) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut paths = Vec::new();
for item in detail.items.iter().rev() {
let Some(path) = (match &item.payload {
AgentThreadItemPayload::FileArtifact { path, .. } => {
normalize_optional_text(Some(path.clone()))
}
_ => None,
}) else {
continue;
};
if seen.insert(path.clone()) {
paths.push(path);
}
if paths.len() >= MAX_RECENT_ARTIFACTS {
break;
}
}
paths
}
fn summarize_item_payload(payload: &AgentThreadItemPayload) -> (&'static str, Option<String>) {
match payload {
AgentThreadItemPayload::Plan { text } => {
("plan", normalize_optional_text(Some(truncate_text(text))))
}
AgentThreadItemPayload::TurnSummary { text } => (
"turn_summary",
normalize_optional_text(Some(truncate_text(text))),
),
AgentThreadItemPayload::FileArtifact { path, .. } => {
("file_artifact", normalize_optional_text(Some(path.clone())))
}
_ => ("other", None),
}
}
fn truncate_text(value: &str) -> String {
let normalized = value.trim();
if normalized.chars().count() <= MAX_PREVIEW_CHARS {
return normalized.to_string();
}
normalized
.chars()
.take(MAX_PREVIEW_CHARS)
.collect::<String>()
+ "..."
}
fn serialize_enum_as_string<T: Serialize>(value: &T, fallback: &str) -> String {
serde_json::to_value(value)
.ok()
.and_then(|item| item.as_str().map(str::to_string))
.unwrap_or_else(|| fallback.to_string())
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
let trimmed = value?.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::QueuedTurnSnapshot;
use lime_core::database::dao::agent_timeline::{
AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn,
AgentThreadTurnStatus,
};
use tempfile::TempDir;
fn build_detail() -> SessionDetail {
SessionDetail {
id: "session-1".to_string(),
name: "P2 evidence".to_string(),
created_at: 1,
updated_at: 2,
thread_id: "thread-1".to_string(),
model: Some("gpt-5.4".to_string()),
working_dir: Some("/tmp/workspace".to_string()),
workspace_id: Some("workspace-1".to_string()),
messages: Vec::new(),
execution_strategy: Some("react".to_string()),
execution_runtime: None,
turns: vec![AgentThreadTurn {
id: "turn-1".to_string(),
thread_id: "thread-1".to_string(),
prompt_text: "继续推进 evidence pack".to_string(),
status: AgentThreadTurnStatus::Completed,
started_at: "2026-03-27T10:00:00Z".to_string(),
completed_at: Some("2026-03-27T10:01:00Z".to_string()),
error_message: None,
created_at: "2026-03-27T10:00:00Z".to_string(),
updated_at: "2026-03-27T10:01:00Z".to_string(),
}],
items: vec![
AgentThreadItem {
id: "plan-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 1,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:05Z".to_string(),
completed_at: Some("2026-03-27T10:00:05Z".to_string()),
updated_at: "2026-03-27T10:00:05Z".to_string(),
payload: AgentThreadItemPayload::Plan {
text: "先导出 handoff,再导出 evidence pack".to_string(),
},
},
AgentThreadItem {
id: "artifact-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 2,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:20Z".to_string(),
completed_at: Some("2026-03-27T10:00:20Z".to_string()),
updated_at: "2026-03-27T10:00:20Z".to_string(),
payload: AgentThreadItemPayload::FileArtifact {
path: ".lime/artifacts/thread-1/report.md".to_string(),
source: "artifact_snapshot".to_string(),
content: None,
metadata: None,
},
},
AgentThreadItem {
id: "summary-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 3,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:30Z".to_string(),
completed_at: Some("2026-03-27T10:00:30Z".to_string()),
updated_at: "2026-03-27T10:00:30Z".to_string(),
payload: AgentThreadItemPayload::TurnSummary {
text: "已拿到 handoff 四件套,下一步补问题证据包。".to_string(),
},
},
],
todo_items: Vec::new(),
child_subagent_sessions: Vec::new(),
subagent_parent_context: None,
}
}
fn build_thread_read() -> AgentRuntimeThreadReadModel {
AgentRuntimeThreadReadModel {
thread_id: "thread-1".to_string(),
status: "running".to_string(),
active_turn_id: Some("turn-1".to_string()),
pending_requests: vec![crate::commands::aster_agent_cmd::AgentRuntimeRequestView {
id: "req-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
item_id: None,
request_type: "ask_user".to_string(),
status: "pending".to_string(),
title: Some("确认是否导出问题证据包".to_string()),
payload: None,
decision: None,
scope: None,
created_at: None,
resolved_at: None,
}],
last_outcome: None,
incidents: Vec::new(),
queued_turns: vec![QueuedTurnSnapshot {
queued_turn_id: "queued-1".to_string(),
message_preview: "继续补证据包 UI".to_string(),
message_text: "继续补证据包 UI".to_string(),
created_at: 3,
image_count: 0,
position: 1,
}],
interrupt_state: None,
updated_at: Some("2026-03-27T10:01:00Z".to_string()),
diagnostics: Some(
crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics {
latest_turn_status: Some("running".to_string()),
latest_turn_started_at: None,
latest_turn_completed_at: None,
latest_turn_updated_at: None,
latest_turn_elapsed_seconds: None,
latest_turn_stalled_seconds: None,
latest_turn_error_message: None,
interrupt_reason: None,
runtime_interrupt_source: None,
runtime_interrupt_requested_at: None,
runtime_interrupt_wait_seconds: None,
warning_count: 1,
context_compaction_count: 0,
failed_tool_call_count: 0,
failed_command_count: 0,
pending_request_count: 1,
oldest_pending_request_wait_seconds: None,
primary_blocking_kind: Some("pending_request".to_string()),
primary_blocking_summary: Some("等待用户确认是否导出问题证据包".to_string()),
latest_warning: Some(
crate::commands::aster_agent_cmd::AgentRuntimeDiagnosticWarningSample {
item_id: "warning-1".to_string(),
turn_id: Some("turn-1".to_string()),
code: Some("runtime.pending".to_string()),
message: "存在待处理请求".to_string(),
updated_at: "2026-03-27T10:01:00Z".to_string(),
},
),
latest_context_compaction: None,
latest_failed_tool: None,
latest_failed_command: None,
latest_pending_request: None,
},
),
}
}
#[test]
fn should_export_runtime_evidence_pack_to_workspace() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let thread_read = build_thread_read();
let result =
export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export");
assert_eq!(
result.pack_relative_root,
".lime/harness/sessions/session-1/evidence"
);
assert_eq!(result.artifacts.len(), 4);
assert_eq!(result.turn_count, 1);
assert_eq!(result.item_count, 3);
assert_eq!(result.pending_request_count, 1);
assert_eq!(result.queued_turn_count, 1);
assert_eq!(result.recent_artifact_count, 1);
assert!(!result.known_gaps.is_empty());
let summary_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/evidence/summary.md");
let runtime_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/evidence/runtime.json");
let timeline_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/evidence/timeline.json");
assert!(summary_path.exists());
assert!(runtime_path.exists());
assert!(timeline_path.exists());
let summary = fs::read_to_string(summary_path).expect("summary");
assert!(summary.contains("问题证据包"));
assert!(summary.contains("等待用户确认是否导出问题证据包"));
let runtime = fs::read_to_string(runtime_path).expect("runtime");
assert!(runtime.contains("\"sessionId\": \"session-1\""));
assert!(runtime.contains("\"pendingRequestCount\": 1"));
let timeline = fs::read_to_string(timeline_path).expect("timeline");
assert!(timeline.contains("\"payloadKind\": \"plan\""));
assert!(timeline.contains("\"status\": \"completed\""));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,625 @@
//! Runtime review decision 模板导出服务
//!
//! 将外部 Claude Code / Codex 的分析结论回挂为
//! Lime 工作区内可版本化的人工审核与决策记录模板。
//! 这条链只导出 review-decision 模板,不在 Lime 内自动批准或自动应用修复。
use crate::agent::SessionDetail;
use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
use crate::services::runtime_analysis_handoff_service::{
export_runtime_analysis_handoff, RuntimeAnalysisArtifact, RuntimeAnalysisHandoffExportResult,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
const SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions";
const REVIEW_DIR_NAME: &str = "review";
const REVIEW_DECISION_MARKDOWN_FILE_NAME: &str = "review-decision.md";
const REVIEW_DECISION_JSON_FILE_NAME: &str = "review-decision.json";
const DEFAULT_DECISION_STATUS: &str = "pending_review";
const DEFAULT_RISK_LEVEL: &str = "unknown";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeReviewDecisionArtifactKind {
ReviewDecisionMarkdown,
ReviewDecisionJson,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeReviewDecisionArtifact {
pub kind: RuntimeReviewDecisionArtifactKind,
pub title: String,
pub relative_path: String,
pub absolute_path: String,
pub bytes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeReviewDecisionTemplateExportResult {
pub session_id: String,
pub thread_id: String,
pub workspace_id: Option<String>,
pub workspace_root: String,
pub review_relative_root: String,
pub review_absolute_root: String,
pub analysis_relative_root: String,
pub analysis_absolute_root: String,
pub handoff_bundle_relative_root: String,
pub evidence_pack_relative_root: String,
pub replay_case_relative_root: String,
pub exported_at: String,
pub title: String,
pub thread_status: String,
pub latest_turn_status: Option<String>,
pub pending_request_count: usize,
pub queued_turn_count: usize,
pub default_decision_status: String,
pub review_checklist: Vec<String>,
pub analysis_artifacts: Vec<RuntimeAnalysisArtifact>,
pub artifacts: Vec<RuntimeReviewDecisionArtifact>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionDocument {
schema_version: String,
contract_shape: String,
exported_at: String,
source: ReviewDecisionSource,
review_context: ReviewDecisionContext,
decision: ReviewDecisionContent,
decision_status_options: Vec<String>,
risk_level_options: Vec<String>,
review_checklist: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionSource {
derived_from: Vec<String>,
upstream_alignment: ReviewDecisionUpstreamAlignment,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionUpstreamAlignment {
execution_environment_reference: String,
runtime_fact_source: String,
product_surface: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionContext {
session_id: String,
thread_id: String,
workspace_id: Option<String>,
title: String,
thread_status: String,
latest_turn_status: Option<String>,
pending_request_count: usize,
queued_turn_count: usize,
analysis_relative_root: String,
handoff_bundle_relative_root: String,
evidence_pack_relative_root: String,
replay_case_relative_root: String,
analysis_artifacts: Vec<ReviewDecisionArtifactReference>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionArtifactReference {
kind: String,
title: String,
relative_path: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDecisionContent {
decision_status: String,
decision_summary: String,
chosen_fix_strategy: String,
risk_level: String,
risk_tags: Vec<String>,
human_reviewer: String,
reviewed_at: Option<String>,
followup_actions: Vec<String>,
regression_requirements: Vec<String>,
notes: String,
}
pub fn export_runtime_review_decision_template(
detail: &SessionDetail,
thread_read: &AgentRuntimeThreadReadModel,
workspace_root: &Path,
) -> Result<RuntimeReviewDecisionTemplateExportResult, String> {
let session_id = detail.id.trim();
if session_id.is_empty() {
return Err("session_id 不能为空,无法导出 review decision 模板".to_string());
}
let thread_id = detail.thread_id.trim();
if thread_id.is_empty() {
return Err("thread_id 不能为空,无法导出 review decision 模板".to_string());
}
let workspace_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
let exported_at = Utc::now().to_rfc3339();
let review_relative_root = format!("{SESSION_RELATIVE_ROOT}/{session_id}/{REVIEW_DIR_NAME}");
let review_absolute_root =
workspace_root.join(review_relative_root.replace('/', std::path::MAIN_SEPARATOR_STR));
let analysis = export_runtime_analysis_handoff(detail, thread_read, workspace_root.as_path())?;
fs::create_dir_all(&review_absolute_root).map_err(|error| {
format!(
"创建 review decision 目录失败 {}: {error}",
review_absolute_root.display()
)
})?;
let review_checklist = build_review_checklist();
let document = build_review_decision_document(&analysis, &exported_at, &review_checklist);
let markdown = build_review_decision_markdown(&document);
let json = serde_json::to_string_pretty(&document)
.map_err(|error| format!("序列化 review decision json 失败: {error}"))?;
let artifacts = vec![
write_review_decision_artifact(
RuntimeReviewDecisionArtifactKind::ReviewDecisionMarkdown,
"人工审核记录",
&review_absolute_root.join(REVIEW_DECISION_MARKDOWN_FILE_NAME),
&format!("{review_relative_root}/{REVIEW_DECISION_MARKDOWN_FILE_NAME}"),
markdown.as_bytes(),
)?,
write_review_decision_artifact(
RuntimeReviewDecisionArtifactKind::ReviewDecisionJson,
"人工审核记录 JSON",
&review_absolute_root.join(REVIEW_DECISION_JSON_FILE_NAME),
&format!("{review_relative_root}/{REVIEW_DECISION_JSON_FILE_NAME}"),
json.as_bytes(),
)?,
];
Ok(RuntimeReviewDecisionTemplateExportResult {
session_id: analysis.session_id.clone(),
thread_id: analysis.thread_id.clone(),
workspace_id: analysis.workspace_id.clone(),
workspace_root: analysis.workspace_root.clone(),
review_relative_root,
review_absolute_root: to_portable_path(&review_absolute_root.to_string_lossy()),
analysis_relative_root: analysis.analysis_relative_root.clone(),
analysis_absolute_root: analysis.analysis_absolute_root.clone(),
handoff_bundle_relative_root: analysis.handoff_bundle_relative_root.clone(),
evidence_pack_relative_root: analysis.evidence_pack_relative_root.clone(),
replay_case_relative_root: analysis.replay_case_relative_root.clone(),
exported_at,
title: analysis.title.clone(),
thread_status: analysis.thread_status.clone(),
latest_turn_status: analysis.latest_turn_status.clone(),
pending_request_count: analysis.pending_request_count,
queued_turn_count: analysis.queued_turn_count,
default_decision_status: DEFAULT_DECISION_STATUS.to_string(),
review_checklist,
analysis_artifacts: analysis.artifacts.clone(),
artifacts,
})
}
fn build_review_decision_document(
analysis: &RuntimeAnalysisHandoffExportResult,
exported_at: &str,
review_checklist: &[String],
) -> ReviewDecisionDocument {
ReviewDecisionDocument {
schema_version: "v1".to_string(),
contract_shape: "lime_review_decision_template".to_string(),
exported_at: exported_at.to_string(),
source: ReviewDecisionSource {
derived_from: vec![
"lime_external_analysis_handoff".to_string(),
"runtime_handoff_bundle".to_string(),
"runtime_evidence_pack".to_string(),
"runtime_replay_case".to_string(),
],
upstream_alignment: ReviewDecisionUpstreamAlignment {
execution_environment_reference: "codex".to_string(),
runtime_fact_source: "aster-rust".to_string(),
product_surface: "lime".to_string(),
},
},
review_context: ReviewDecisionContext {
session_id: analysis.session_id.clone(),
thread_id: analysis.thread_id.clone(),
workspace_id: analysis.workspace_id.clone(),
title: analysis.title.clone(),
thread_status: analysis.thread_status.clone(),
latest_turn_status: analysis.latest_turn_status.clone(),
pending_request_count: analysis.pending_request_count,
queued_turn_count: analysis.queued_turn_count,
analysis_relative_root: analysis.analysis_relative_root.clone(),
handoff_bundle_relative_root: analysis.handoff_bundle_relative_root.clone(),
evidence_pack_relative_root: analysis.evidence_pack_relative_root.clone(),
replay_case_relative_root: analysis.replay_case_relative_root.clone(),
analysis_artifacts: analysis
.artifacts
.iter()
.map(|artifact| ReviewDecisionArtifactReference {
kind: review_analysis_artifact_kind_key(&artifact.kind).to_string(),
title: artifact.title.clone(),
relative_path: artifact.relative_path.clone(),
})
.collect(),
},
decision: ReviewDecisionContent {
decision_status: DEFAULT_DECISION_STATUS.to_string(),
decision_summary: String::new(),
chosen_fix_strategy: String::new(),
risk_level: DEFAULT_RISK_LEVEL.to_string(),
risk_tags: Vec::new(),
human_reviewer: String::new(),
reviewed_at: None,
followup_actions: Vec::new(),
regression_requirements: Vec::new(),
notes: String::new(),
},
decision_status_options: vec![
"accepted".to_string(),
"deferred".to_string(),
"rejected".to_string(),
"needs_more_evidence".to_string(),
DEFAULT_DECISION_STATUS.to_string(),
],
risk_level_options: vec![
"low".to_string(),
"medium".to_string(),
"high".to_string(),
DEFAULT_RISK_LEVEL.to_string(),
],
review_checklist: review_checklist.to_vec(),
}
}
fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
let checklist = document
.review_checklist
.iter()
.map(|item| format!("- [ ] {item}"))
.collect::<Vec<_>>()
.join("\n");
let analysis_files = document
.review_context
.analysis_artifacts
.iter()
.map(|artifact| format!("- `{}`:`{}`", artifact.title, artifact.relative_path))
.collect::<Vec<_>>()
.join("\n");
format!(
"# Lime 人工审核与决策记录\n\n\
> 状态:`{decision_status}`\n\
> 导出时间:`{exported_at}`\n\
> 说明:这份模板用于把外部 Claude Code / Codex 的分析结论,回挂为 Lime 工作区内可版本化的人工审核记录;最终是否接受修复仍由开发者决定。\n\n\
## 1. 审核上下文\n\
- 标题:{title}\n\
- session_id:`{session_id}`\n\
- thread_id:`{thread_id}`\n\
- 线程状态:`{thread_status}`\n\
- 最新 Turn:`{latest_turn_status}`\n\
- 待处理请求:`{pending_request_count}`\n\
- 排队任务:`{queued_turn_count}`\n\
- analysis 目录:`{analysis_relative_root}`\n\
- handoff 目录:`{handoff_bundle_relative_root}`\n\
- evidence 目录:`{evidence_pack_relative_root}`\n\
- replay 目录:`{replay_case_relative_root}`\n\n\
### 关联分析文件\n\
{analysis_files}\n\n\
## 2. 上游对齐\n\
- 执行环境参照:`codex`\n\
- 运行时事实源:`aster-rust`\n\
- 产品承接面:`lime`\n\n\
## 3. 审核清单\n\
{checklist}\n\n\
## 4. 决策状态\n\
- 当前值:`{decision_status}`\n\
- 可选值:`accepted` / `deferred` / `rejected` / `needs_more_evidence`\n\n\
## 5. 决策摘要\n\
待填写。\n\n\
## 6. 采用的修复策略\n\
待填写。\n\n\
## 7. 风险等级与标签\n\
- 风险等级:`{risk_level}`\n\
- 风险标签:待填写\n\n\
## 8. 回归要求\n\
- 待填写\n\n\
## 9. 后续动作\n\
- 待填写\n\n\
## 10. 审核备注\n\
- 审核人:待填写\n\
- 审核时间:待填写\n\
- 备注:待填写\n",
decision_status = document.decision.decision_status,
exported_at = document.exported_at,
title = empty_fallback(&document.review_context.title, "未命名"),
session_id = document.review_context.session_id,
thread_id = document.review_context.thread_id,
thread_status = empty_fallback(&document.review_context.thread_status, "unknown"),
latest_turn_status = document
.review_context
.latest_turn_status
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("unknown"),
pending_request_count = document.review_context.pending_request_count,
queued_turn_count = document.review_context.queued_turn_count,
analysis_relative_root = document.review_context.analysis_relative_root,
handoff_bundle_relative_root = document.review_context.handoff_bundle_relative_root,
evidence_pack_relative_root = document.review_context.evidence_pack_relative_root,
replay_case_relative_root = document.review_context.replay_case_relative_root,
analysis_files = if analysis_files.is_empty() {
"- 待补充".to_string()
} else {
analysis_files
},
checklist = if checklist.is_empty() {
"- [ ] 待补充审核清单".to_string()
} else {
checklist
},
risk_level = document.decision.risk_level,
)
}
fn build_review_checklist() -> Vec<String> {
vec![
"先阅读 analysis-brief.md 与 analysis-context.json,再决定是否进入修复。".to_string(),
"确认根因判断引用的是现有证据,而不是外部 AI 的猜测扩写。".to_string(),
"确认修复范围仍落在 current 主链,没有把 compat / deprecated 路径重新接回主线。"
.to_string(),
"明确最小回归集合,包括 contract、GUI smoke、Replay 或其它定向验证。".to_string(),
"把最终决定记录为 accepted / deferred / rejected / needs_more_evidence 之一。".to_string(),
]
}
fn review_analysis_artifact_kind_key(
kind: &crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind,
) -> &'static str {
match kind {
crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind::AnalysisBrief => {
"analysis_brief"
}
crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind::AnalysisContext => {
"analysis_context"
}
}
}
fn write_review_decision_artifact(
kind: RuntimeReviewDecisionArtifactKind,
title: &str,
absolute_path: &Path,
relative_path: &str,
contents: &[u8],
) -> Result<RuntimeReviewDecisionArtifact, String> {
fs::write(absolute_path, contents).map_err(|error| {
format!(
"写入 review decision 文件失败 {}: {error}",
absolute_path.display()
)
})?;
Ok(RuntimeReviewDecisionArtifact {
kind,
title: title.to_string(),
relative_path: relative_path.to_string(),
absolute_path: to_portable_path(&absolute_path.to_string_lossy()),
bytes: contents.len(),
})
}
fn empty_fallback<'a>(value: &'a str, fallback: &'a str) -> &'a str {
if value.trim().is_empty() {
fallback
} else {
value
}
}
fn to_portable_path(value: &str) -> String {
value.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::QueuedTurnSnapshot;
use crate::commands::aster_agent_cmd::{
AgentRuntimeDiagnosticPendingRequestSample, AgentRuntimeRequestView,
AgentRuntimeThreadDiagnostics,
};
use lime_core::database::dao::agent_timeline::{
AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn,
AgentThreadTurnStatus,
};
use serde_json::json;
use tempfile::TempDir;
fn build_detail() -> SessionDetail {
SessionDetail {
id: "session-1".to_string(),
thread_id: "thread-1".to_string(),
workspace_id: Some("workspace-1".to_string()),
name: "Harness Review Demo".to_string(),
model: Some("gpt-5.4".to_string()),
working_dir: Some("/tmp/workspace".to_string()),
created_at: 1,
updated_at: 2,
execution_strategy: Some("react".to_string()),
messages: Vec::new(),
execution_runtime: None,
turns: vec![AgentThreadTurn {
id: "turn-1".to_string(),
thread_id: "thread-1".to_string(),
prompt_text: "请导出 review decision 模板。".to_string(),
status: AgentThreadTurnStatus::Completed,
started_at: "2026-03-27T10:00:00Z".to_string(),
completed_at: Some("2026-03-27T10:01:00Z".to_string()),
error_message: None,
created_at: "2026-03-27T10:00:00Z".to_string(),
updated_at: "2026-03-27T10:01:00Z".to_string(),
}],
items: vec![
AgentThreadItem {
id: "item-plan-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 1,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:00:10Z".to_string(),
completed_at: Some("2026-03-27T10:00:10Z".to_string()),
updated_at: "2026-03-27T10:00:10Z".to_string(),
payload: AgentThreadItemPayload::Plan {
text: "补 review decision 模板导出".to_string(),
},
},
AgentThreadItem {
id: "item-summary-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 2,
status: AgentThreadItemStatus::Completed,
started_at: "2026-03-27T10:01:00Z".to_string(),
completed_at: Some("2026-03-27T10:01:00Z".to_string()),
updated_at: "2026-03-27T10:01:00Z".to_string(),
payload: AgentThreadItemPayload::TurnSummary {
text: "外部分析已可导出,下一步需要固定人工审核记录。".to_string(),
},
},
],
todo_items: vec![lime_agent::SessionTodoItem {
content: "导出人工审核记录".to_string(),
status: serde_json::from_value(json!("in_progress")).expect("status"),
active_form: None,
}],
child_subagent_sessions: vec![],
subagent_parent_context: None,
}
}
fn build_thread_read() -> AgentRuntimeThreadReadModel {
AgentRuntimeThreadReadModel {
thread_id: "thread-1".to_string(),
status: "waiting_request".to_string(),
active_turn_id: Some("turn-1".to_string()),
pending_requests: vec![AgentRuntimeRequestView {
id: "req-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
item_id: Some("request-1".to_string()),
request_type: "approval_request".to_string(),
status: "pending".to_string(),
title: Some("是否接受最小修复".to_string()),
payload: None,
decision: None,
scope: None,
created_at: None,
resolved_at: None,
}],
last_outcome: None,
incidents: Vec::new(),
queued_turns: vec![QueuedTurnSnapshot {
queued_turn_id: "queued-1".to_string(),
message_preview: "继续补 review decision".to_string(),
message_text: "继续补 review decision".to_string(),
created_at: 3,
image_count: 0,
position: 1,
}],
interrupt_state: None,
updated_at: Some("2026-03-27T10:01:20Z".to_string()),
diagnostics: Some(AgentRuntimeThreadDiagnostics {
latest_turn_status: Some("action_required".to_string()),
latest_turn_started_at: None,
latest_turn_completed_at: None,
latest_turn_updated_at: None,
latest_turn_elapsed_seconds: None,
latest_turn_stalled_seconds: None,
latest_turn_error_message: None,
interrupt_reason: None,
runtime_interrupt_source: None,
runtime_interrupt_requested_at: None,
runtime_interrupt_wait_seconds: None,
warning_count: 0,
context_compaction_count: 0,
failed_tool_call_count: 0,
failed_command_count: 0,
pending_request_count: 1,
oldest_pending_request_wait_seconds: None,
primary_blocking_kind: Some("pending_request".to_string()),
primary_blocking_summary: Some("等待人工审核修复方案".to_string()),
latest_warning: None,
latest_context_compaction: None,
latest_failed_tool: None,
latest_failed_command: None,
latest_pending_request: Some(AgentRuntimeDiagnosticPendingRequestSample {
request_id: "req-1".to_string(),
turn_id: Some("turn-1".to_string()),
request_type: "approval_request".to_string(),
title: Some("是否接受最小修复".to_string()),
waited_seconds: Some(10),
created_at: None,
}),
}),
}
}
#[test]
fn should_export_runtime_review_decision_template_to_workspace() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let thread_read = build_thread_read();
let result =
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
.expect("export");
assert_eq!(
result.review_relative_root,
".lime/harness/sessions/session-1/review"
);
assert_eq!(result.default_decision_status, "pending_review");
assert_eq!(result.artifacts.len(), 2);
assert_eq!(result.analysis_artifacts.len(), 2);
assert!(!result.review_checklist.is_empty());
let markdown_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.md");
let json_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.json");
assert!(markdown_path.exists());
assert!(json_path.exists());
let markdown = fs::read_to_string(markdown_path).expect("markdown");
assert!(markdown.contains("人工审核与决策记录"));
assert!(markdown.contains("analysis-brief.md"));
assert!(markdown.contains("aster-rust"));
assert!(markdown.contains("pending_review"));
let json = fs::read_to_string(json_path).expect("json");
assert!(json.contains("\"contractShape\": \"lime_review_decision_template\""));
assert!(json.contains("\"decisionStatus\": \"pending_review\""));
assert!(json.contains("\"executionEnvironmentReference\": \"codex\""));
assert!(json.contains("\"runtimeFactSource\": \"aster-rust\""));
}
}
@@ -386,7 +386,7 @@ fn load_site_adapters_from_str(
document
.adapters
.into_iter()
.map(|entry| manifest_entry_to_spec(entry, dir, source_kind))
.map(|entry| manifest_entry_to_spec(normalize_manifest_entry(entry), dir, source_kind))
.collect()
}
@@ -429,6 +429,40 @@ fn manifest_entry_to_spec(
})
}
fn normalize_manifest_entry(mut entry: SiteAdapterManifestEntry) -> SiteAdapterManifestEntry {
if should_upgrade_legacy_github_search_entry(&entry) {
entry.entry = SiteAdapterEntryManifest::UrlTemplate {
template: "https://github.com/search?q={{query|urlencode}}&type=repositories"
.to_string(),
};
}
entry
}
fn should_upgrade_legacy_github_search_entry(entry: &SiteAdapterManifestEntry) -> bool {
if normalize_site_adapter_name(&entry.name) != "github/search" {
return false;
}
let has_query_arg = entry.args.iter().any(|arg| {
arg.name == "query" && matches!(arg.arg_type, SiteAdapterArgTypeManifest::String)
});
if !has_query_arg {
return false;
}
matches!(
&entry.entry,
SiteAdapterEntryManifest::FixedUrl { url }
if normalize_fixed_url(url) == "https://github.com/search"
)
}
fn normalize_fixed_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_ascii_lowercase()
}
fn extract_site_adapter_catalog_from_bootstrap_payload<'a>(
payload: &'a Value,
) -> Option<&'a Value> {
@@ -502,7 +536,7 @@ fn write_server_synced_catalog_to_dir(
format!("写入站点适配器脚本失败 {}: {error}", script_path.display())
})?;
adapters.push(SiteAdapterManifestEntry {
adapters.push(normalize_manifest_entry(SiteAdapterManifestEntry {
name: normalize_required_text(&entry.name, "name")?,
domain: normalize_required_text(&entry.domain, "domain")?,
description: normalize_required_text(&entry.description, "description")?,
@@ -514,7 +548,7 @@ fn write_server_synced_catalog_to_dir(
entry: entry.entry,
script_file,
source_version: normalize_optional_text(entry.source_version),
});
}));
}
let document = SiteAdapterRegistryDocument {
@@ -842,6 +876,64 @@ mod tests {
assert_eq!(adapters[0].source_version.as_deref(), Some("sync-1"));
}
#[test]
fn should_upgrade_legacy_server_synced_github_search_fixed_url_to_template() {
let temp_dir = tempdir().expect("temp dir should exist");
let dir = temp_dir.path();
fs::create_dir_all(dir.join("scripts")).expect("scripts dir should exist");
fs::write(
dir.join("index.json"),
r#"
{
"adapters": [
{
"name": "github/search",
"domain": "github.com",
"description": "server synced",
"read_only": true,
"capabilities": ["search"],
"args": [
{
"name": "query",
"description": "搜索关键词",
"required": true,
"arg_type": "string",
"example": "mcp"
}
],
"example": "github/search {\"query\":\"mcp\"}",
"entry": {
"kind": "fixed_url",
"url": "https://github.com/search"
},
"script_file": "scripts/github-search.js",
"source_version": "sync-legacy"
}
]
}
"#,
)
.expect("index should write");
fs::write(
dir.join("scripts/github-search.js"),
"async () => ({ ok: true })",
)
.expect("script should write");
let adapters = load_site_adapters_from_dir(dir, SiteAdapterSourceKind::ServerSynced)
.expect("server synced adapters should load");
let github = adapters
.iter()
.find(|adapter| adapter.name == "github/search")
.expect("github/search should exist");
let mut args = Map::new();
args.insert("query".to_string(), Value::String("mcp".to_string()));
let url = build_entry_url(github, &args).expect("entry url should build");
assert_eq!(url, "https://github.com/search?q=mcp&type=repositories");
}
#[test]
fn should_extract_site_adapter_catalog_from_nested_bootstrap_payload() {
let payload = serde_json::json!({
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "0.96.0",
"version": "0.97.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev:web-bridge",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "0.96.0",
"version": "0.97.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -0,0 +1,382 @@
use anyhow::{anyhow, Result};
use aster::conversation::Conversation;
use aster::model::ModelConfig;
use aster::providers::api_client::{ApiClient, AuthMethod};
use aster::providers::base::Provider;
use aster::providers::openai::OpenAiProvider;
use aster::recipe::Recipe;
use aster::session::{
ChatHistoryMatch, CommitOptions, CommitReport, ExtensionData, MemoryCategory, MemoryHealth,
MemoryRecord, MemorySearchResult, MemoryStats, NoopSessionStore, Session, SessionInsights,
SessionStore, SessionType, TokenStatsUpdate, TurnOutputSchemaStrategy,
};
use async_trait::async_trait;
use chrono::Utc;
use lime_agent::{build_session_execution_runtime, SessionConfigBuilder};
use lime_lib::services::artifact_output_schema_service::merge_turn_context_with_artifact_output_schema;
use serde_json::json;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::tempdir;
use tokio::sync::RwLock;
use uuid::Uuid;
fn build_openai_provider(model_config: ModelConfig) -> Result<OpenAiProvider> {
let api_client = ApiClient::new(
"https://api.deepseek.com".to_string(),
AuthMethod::BearerToken("test-key".to_string()),
)?;
Ok(OpenAiProvider::new(api_client, model_config))
}
struct TestSessionStore {
fallback: NoopSessionStore,
sessions: RwLock<HashMap<String, Session>>,
}
impl Default for TestSessionStore {
fn default() -> Self {
Self {
fallback: NoopSessionStore,
sessions: RwLock::new(HashMap::new()),
}
}
}
impl TestSessionStore {
async fn create_user_session(&self, working_dir: PathBuf, name: &str) -> Result<Session> {
<Self as SessionStore>::create_session(
self,
working_dir,
name.to_string(),
SessionType::User,
)
.await
}
}
#[async_trait]
impl SessionStore for TestSessionStore {
async fn create_session(
&self,
working_dir: PathBuf,
name: String,
session_type: SessionType,
) -> Result<Session> {
let session = Session {
id: format!("test-session-{}", Uuid::new_v4()),
working_dir,
name,
user_set_name: false,
session_type,
created_at: Utc::now(),
updated_at: Utc::now(),
extension_data: ExtensionData::default(),
total_tokens: None,
input_tokens: None,
output_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
schedule_id: None,
recipe: None,
user_recipe_values: None,
conversation: Some(Conversation::default()),
message_count: 0,
provider_name: None,
model_config: None,
};
self.sessions
.write()
.await
.insert(session.id.clone(), session.clone());
Ok(session)
}
async fn get_session(&self, id: &str, _include_messages: bool) -> Result<Session> {
self.sessions
.read()
.await
.get(id)
.cloned()
.ok_or_else(|| anyhow!("session not found: {id}"))
}
async fn add_message(
&self,
session_id: &str,
message: &aster::conversation::message::Message,
) -> Result<()> {
self.fallback.add_message(session_id, message).await
}
async fn replace_conversation(
&self,
session_id: &str,
conversation: &Conversation,
) -> Result<()> {
self.fallback
.replace_conversation(session_id, conversation)
.await
}
async fn list_sessions(&self) -> Result<Vec<Session>> {
Ok(self.sessions.read().await.values().cloned().collect())
}
async fn list_sessions_by_types(&self, types: &[SessionType]) -> Result<Vec<Session>> {
Ok(self
.sessions
.read()
.await
.values()
.filter(|session| types.contains(&session.session_type))
.cloned()
.collect())
}
async fn delete_session(&self, id: &str) -> Result<()> {
self.sessions.write().await.remove(id);
Ok(())
}
async fn get_insights(&self) -> Result<SessionInsights> {
Ok(SessionInsights {
total_sessions: self.sessions.read().await.len(),
total_tokens: 0,
})
}
async fn export_session(&self, id: &str) -> Result<String> {
self.fallback.export_session(id).await
}
async fn import_session(&self, json: &str) -> Result<Session> {
self.fallback.import_session(json).await
}
async fn copy_session(&self, session_id: &str, new_name: String) -> Result<Session> {
self.fallback.copy_session(session_id, new_name).await
}
async fn truncate_conversation(&self, session_id: &str, timestamp: i64) -> Result<()> {
self.fallback
.truncate_conversation(session_id, timestamp)
.await
}
async fn update_session_name(
&self,
session_id: &str,
name: String,
user_set: bool,
) -> Result<()> {
self.fallback
.update_session_name(session_id, name, user_set)
.await
}
async fn update_extension_data(
&self,
session_id: &str,
extension_data: ExtensionData,
) -> Result<()> {
self.fallback
.update_extension_data(session_id, extension_data)
.await
}
async fn update_token_stats(&self, session_id: &str, stats: TokenStatsUpdate) -> Result<()> {
self.fallback.update_token_stats(session_id, stats).await
}
async fn update_provider_config(
&self,
session_id: &str,
provider_name: Option<String>,
model_config: Option<ModelConfig>,
) -> Result<()> {
let mut sessions = self.sessions.write().await;
let session = sessions
.get_mut(session_id)
.ok_or_else(|| anyhow!("session not found: {session_id}"))?;
session.provider_name = provider_name;
session.model_config = model_config;
session.updated_at = Utc::now();
Ok(())
}
async fn update_recipe(
&self,
session_id: &str,
recipe: Option<Recipe>,
user_recipe_values: Option<HashMap<String, String>>,
) -> Result<()> {
self.fallback
.update_recipe(session_id, recipe, user_recipe_values)
.await
}
async fn search_chat_history(
&self,
query: &str,
limit: Option<usize>,
after_date: Option<chrono::DateTime<chrono::Utc>>,
before_date: Option<chrono::DateTime<chrono::Utc>>,
exclude_session_id: Option<String>,
) -> Result<Vec<ChatHistoryMatch>> {
self.fallback
.search_chat_history(query, limit, after_date, before_date, exclude_session_id)
.await
}
async fn commit_session(&self, id: &str, options: CommitOptions) -> Result<CommitReport> {
self.fallback.commit_session(id, options).await
}
async fn search_memories(
&self,
query: &str,
limit: Option<usize>,
session_scope: Option<&str>,
categories: Option<Vec<MemoryCategory>>,
) -> Result<Vec<MemorySearchResult>> {
self.fallback
.search_memories(query, limit, session_scope, categories)
.await
}
async fn retrieve_context_memories(
&self,
session_id: &str,
query: &str,
limit: usize,
) -> Result<Vec<MemoryRecord>> {
self.fallback
.retrieve_context_memories(session_id, query, limit)
.await
}
async fn memory_stats(&self) -> Result<MemoryStats> {
self.fallback.memory_stats().await
}
async fn memory_health(&self) -> Result<MemoryHealth> {
self.fallback.memory_health().await
}
}
#[test]
fn deepseek_reasoner_should_not_use_openai_native_output_schema() -> Result<()> {
let deepseek_model = ModelConfig::new("deepseek-reasoner")?;
let deepseek_provider = build_openai_provider(deepseek_model.clone())?;
assert!(
!deepseek_provider.supports_native_output_schema_with_model(&deepseek_model),
"deepseek-reasoner 不应被判定为 OpenAI native output schema 模型"
);
let codex_model = ModelConfig::new("gpt-5.3-codex")?;
let codex_provider = build_openai_provider(codex_model.clone())?;
assert!(
codex_provider.supports_native_output_schema_with_model(&codex_model),
"gpt-5.3-codex 应保持 native output schema 能力"
);
Ok(())
}
#[tokio::test]
async fn artifact_runtime_should_mark_deepseek_reasoner_as_final_output_tool() -> Result<()> {
let working_dir = tempdir()?;
let store = Arc::new(TestSessionStore::default());
let session = store
.create_user_session(
working_dir.path().to_path_buf(),
"deepseek artifact runtime",
)
.await?;
let agent = aster::agents::Agent::new().with_session_store(store.clone());
let model_config = ModelConfig::new("deepseek-reasoner")?;
let provider = Arc::new(build_openai_provider(model_config.clone())?);
agent.update_provider(provider, &session.id).await?;
let request_metadata = json!({
"artifact": {
"artifact_mode": "draft",
"artifact_stage": "stage2",
"artifact_kind": "report",
"source_policy": "required"
}
});
let turn_context = merge_turn_context_with_artifact_output_schema(
Some(aster::session::TurnContextOverride {
model: Some("deepseek-reasoner".to_string()),
..aster::session::TurnContextOverride::default()
}),
Some(&request_metadata),
)
.expect("turn context");
assert!(turn_context.output_schema.is_some());
let session_config = SessionConfigBuilder::new(&session.id)
.thread_id("thread-deepseek")
.turn_id("turn-deepseek")
.turn_context(turn_context)
.build();
agent
.ensure_runtime_turn_initialized(&session_config, Some("生成 Artifact 文档".to_string()))
.await?;
let snapshot = agent.runtime_snapshot(&session.id).await?;
let turn = snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.find(|turn| turn.id == "turn-deepseek")
.expect("runtime turn");
assert_eq!(turn.status, aster::session::TurnStatus::Running);
let output_schema_runtime = turn
.output_schema_runtime
.as_ref()
.expect("output schema runtime");
assert_eq!(
output_schema_runtime.strategy,
TurnOutputSchemaStrategy::FinalOutputTool
);
assert_eq!(
output_schema_runtime.model_name.as_deref(),
Some("deepseek-reasoner")
);
let updated_session = store.get_session(&session.id, false).await?;
let execution_runtime = build_session_execution_runtime(
&session.id,
Some(&updated_session),
None,
Some(&snapshot),
Some("deepseek".to_string()),
)
.expect("execution runtime");
assert_eq!(
execution_runtime
.output_schema_runtime
.as_ref()
.map(|runtime| runtime.strategy),
Some(TurnOutputSchemaStrategy::FinalOutputTool)
);
assert_eq!(
execution_runtime.model_name.as_deref(),
Some("deepseek-reasoner")
);
assert_eq!(
execution_runtime.latest_turn_status.as_deref(),
Some("running")
);
Ok(())
}