mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.97.0
This commit is contained in:
Generated
+17
-17
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
// 服务状态
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
/// 自动续写参数
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(¶ms, &["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(¶ms, &["target_id"]);
|
||||
let timeout_ms = params.get("timeout_ms").and_then(serde_json::Value::as_u64);
|
||||
let project_target = Self::resolve_project_target(¶ms, context).await;
|
||||
let save_target = Self::resolve_save_target(¶ms, context).await;
|
||||
let save_title = Self::extract_optional_string(¶ms, &["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 内置站点适配器。",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "日报正文内容" }
|
||||
]
|
||||
}"#,
|
||||
¶ms,
|
||||
)
|
||||
.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("社媒初稿")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,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(())
|
||||
}
|
||||
Reference in New Issue
Block a user