Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "nomifun-conversation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nomifun-common.workspace = true
|
||||
nomifun-db.workspace = true
|
||||
nomifun-api-types.workspace = true
|
||||
nomifun-realtime.workspace = true
|
||||
nomifun-auth.workspace = true
|
||||
nomifun-ai-agent.workspace = true
|
||||
nomifun-extension.workspace = true
|
||||
nomifun-file.workspace = true
|
||||
nomifun-knowledge.workspace = true
|
||||
nomifun-mcp.workspace = true
|
||||
nomifun-runtime.workspace = true
|
||||
axum.workspace = true
|
||||
regex.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
async-trait.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
# Enable the `AgentInstance::Mock` variant so tests can build fake agents
|
||||
# through the trait-object escape hatch without spawning real CLI processes.
|
||||
nomifun-ai-agent = { workspace = true, features = ["test-support"] }
|
||||
@@ -0,0 +1,237 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::AgentErrorCode;
|
||||
use nomifun_common::{AgentKillReason, AgentType, ConversationSource, now_ms};
|
||||
use nomifun_db::{ConversationRowUpdate, SaveRuntimeStateParams};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::convert::string_to_enum;
|
||||
use crate::service::{ConversationService, parse_conv_id};
|
||||
use crate::stream_relay::RelayOutcome;
|
||||
use nomifun_ai_agent::IWorkerTaskManager;
|
||||
|
||||
impl ConversationService {
|
||||
async fn clear_conversation_model_seed_after_model_not_found(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
error_code: Option<AgentErrorCode>,
|
||||
) {
|
||||
if error_code != Some(AgentErrorCode::UserLlmProviderModelNotFound) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(conv_id) = parse_conv_id(conversation_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let row = match self.conversation_repo().get(conv_id).await {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
conversation_id,
|
||||
error_code = ?error_code,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"Conversation ACP model seed clear skipped because conversation row is missing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
conversation_id,
|
||||
error = %err,
|
||||
error_code = ?error_code,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"Failed to load conversation before clearing ACP model seed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut extra: serde_json::Value = match serde_json::from_str(&row.extra) {
|
||||
Ok(extra) => extra,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
conversation_id,
|
||||
error = %err,
|
||||
error_code = ?error_code,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"Conversation ACP model seed clear skipped because extra JSON is invalid"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(extra_obj) = extra.as_object_mut() else {
|
||||
warn!(
|
||||
conversation_id,
|
||||
error_code = ?error_code,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"Conversation ACP model seed clear skipped because extra is not an object"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(previous_model_value) = extra_obj.remove("current_model_id") else {
|
||||
return;
|
||||
};
|
||||
let previous_model_id = previous_model_value.as_str().map(ToOwned::to_owned);
|
||||
if previous_model_id.is_none() {
|
||||
warn!(
|
||||
conversation_id,
|
||||
error_code = ?error_code,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"Conversation ACP model seed was malformed and will be cleared"
|
||||
);
|
||||
}
|
||||
|
||||
let extra_json = match serde_json::to_string(&extra) {
|
||||
Ok(json) => json,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
conversation_id,
|
||||
?previous_model_id,
|
||||
error = %err,
|
||||
error_code = ?error_code,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"Failed to serialize conversation extra after clearing ACP model seed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let update = ConversationRowUpdate {
|
||||
extra: Some(extra_json),
|
||||
updated_at: Some(now_ms()),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(err) = self.conversation_repo().update(conv_id, &update).await {
|
||||
warn!(
|
||||
conversation_id,
|
||||
?previous_model_id,
|
||||
error = %err,
|
||||
error_code = ?error_code,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"Failed to clear conversation ACP model seed after model_not_found"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let source = row
|
||||
.source
|
||||
.as_deref()
|
||||
.and_then(|value| string_to_enum::<ConversationSource>(value).ok());
|
||||
self.broadcast_list_changed(conversation_id, "updated", source.as_ref());
|
||||
info!(
|
||||
conversation_id,
|
||||
?previous_model_id,
|
||||
error_code = ?error_code,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"Conversation ACP model seed cleared after model_not_found"
|
||||
);
|
||||
}
|
||||
|
||||
async fn clear_persisted_acp_model_after_model_not_found(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
error_code: Option<AgentErrorCode>,
|
||||
) {
|
||||
if error_code != Some(AgentErrorCode::UserLlmProviderModelNotFound) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(conv_id) = parse_conv_id(conversation_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let previous_model_id = match self.acp_session_repo().load_runtime_state(conv_id).await {
|
||||
Ok(Some(state)) => state.current_model_id,
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
conversation_id,
|
||||
error = %err,
|
||||
"Failed to load ACP persisted model before clearing after model_not_found"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let params = SaveRuntimeStateParams {
|
||||
current_model_id: Some(None),
|
||||
..Default::default()
|
||||
};
|
||||
match self
|
||||
.acp_session_repo()
|
||||
.save_runtime_state(conv_id, ¶ms)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
info!(
|
||||
conversation_id,
|
||||
?previous_model_id,
|
||||
error_code = ?error_code,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"ACP persisted model cleared after model_not_found"
|
||||
);
|
||||
}
|
||||
Ok(false) => {
|
||||
warn!(
|
||||
conversation_id,
|
||||
?previous_model_id,
|
||||
error_code = ?error_code,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"ACP persisted model clear skipped because session row is missing"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
conversation_id,
|
||||
?previous_model_id,
|
||||
error = %err,
|
||||
error_code = ?error_code,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"Failed to clear ACP persisted model after model_not_found"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn evict_acp_task_after_terminal_error(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
agent_type: AgentType,
|
||||
outcome: &RelayOutcome,
|
||||
task_manager: &Arc<dyn IWorkerTaskManager>,
|
||||
) -> bool {
|
||||
if agent_type != AgentType::Acp || !outcome.terminal.is_error() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let started_at = now_ms();
|
||||
let error_code = outcome.terminal.code();
|
||||
let retryable = outcome.terminal.retryable();
|
||||
info!(
|
||||
conversation_id,
|
||||
?agent_type,
|
||||
error_code = ?error_code,
|
||||
retryable = ?retryable,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"ACP task marked unhealthy after terminal error; evicting task"
|
||||
);
|
||||
task_manager
|
||||
.kill_and_wait(conversation_id, Some(AgentKillReason::AgentErrorRecovery))
|
||||
.await;
|
||||
self.clear_persisted_acp_model_after_model_not_found(conversation_id, error_code)
|
||||
.await;
|
||||
self.clear_conversation_model_seed_after_model_not_found(conversation_id, error_code)
|
||||
.await;
|
||||
info!(
|
||||
conversation_id,
|
||||
?agent_type,
|
||||
error_code = ?error_code,
|
||||
retryable = ?retryable,
|
||||
elapsed_ms = now_ms().saturating_sub(started_at),
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"ACP task eviction completed after terminal error"
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
use std::path::Path;
|
||||
|
||||
use nomifun_api_types::{ConversationArtifactResponse, ConversationResponse, MessageResponse, MessageSearchItem};
|
||||
use nomifun_common::{
|
||||
AgentType, AppError, ConversationSource, ConversationStatus, MessagePosition, MessageStatus, MessageType,
|
||||
ProviderWithModel,
|
||||
};
|
||||
use nomifun_db::MessageSearchRow;
|
||||
use nomifun_db::models::{ConversationArtifactRow, ConversationRow, MessageRow};
|
||||
|
||||
pub(crate) const TOOL_CONTENT_COMPACT_THRESHOLD_BYTES: usize = 64 * 1024;
|
||||
const TOOL_CONTENT_PREVIEW_CHARS: usize = 4096;
|
||||
|
||||
/// Convert a database row into an API response DTO.
|
||||
///
|
||||
/// Parses string enum fields and JSON text fields back into typed values.
|
||||
/// `data_dir` is required so the response can expose a derived
|
||||
/// `is_temporary_workspace` flag without storing that attribute on disk —
|
||||
/// see [`row_to_response_with_extra`].
|
||||
pub fn row_to_response(row: ConversationRow, data_dir: &Path) -> Result<ConversationResponse, AppError> {
|
||||
let extra: serde_json::Value =
|
||||
serde_json::from_str(&row.extra).map_err(|e| AppError::Internal(format!("Invalid extra JSON: {e}")))?;
|
||||
row_to_response_with_extra(row, extra, data_dir)
|
||||
}
|
||||
|
||||
/// Same as [`row_to_response`] but takes a pre-parsed `extra` value. Used
|
||||
/// by callers that need to mutate `extra` (e.g. lazy `skills` backfill)
|
||||
/// before building the response DTO.
|
||||
///
|
||||
/// Injects a derived `is_temporary_workspace: bool` into the returned
|
||||
/// `extra` blob by checking whether `extra.workspace` sits under the
|
||||
/// backend-managed `data_dir`. The flag is not persisted — it is
|
||||
/// computed on every read so the frontend never has to pattern-match
|
||||
/// the directory name. Old rows that have no such flag on disk
|
||||
/// automatically gain it on read, which means no migration is needed.
|
||||
pub fn row_to_response_with_extra(
|
||||
row: ConversationRow,
|
||||
mut extra: serde_json::Value,
|
||||
data_dir: &Path,
|
||||
) -> Result<ConversationResponse, AppError> {
|
||||
let is_temporary_workspace = {
|
||||
let ws = extra.get("workspace").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// Companion sessions own a fixed, permanent per-companion work folder.
|
||||
// It sits under the data dir but is NOT a throwaway temp workspace —
|
||||
// mark it non-temporary so the chat tab keeps the "open workspace folder"
|
||||
// affordance and doesn't mislabel a locked, browsable work path.
|
||||
let is_companion = extra.get("companionSession").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
!is_companion && !ws.is_empty() && Path::new(ws).starts_with(data_dir)
|
||||
};
|
||||
if let Some(obj) = extra.as_object_mut() {
|
||||
obj.insert(
|
||||
"is_temporary_workspace".to_owned(),
|
||||
serde_json::Value::Bool(is_temporary_workspace),
|
||||
);
|
||||
}
|
||||
|
||||
let agent_type: AgentType = string_to_enum(&row.r#type)?;
|
||||
let status: ConversationStatus = match row.status.as_deref() {
|
||||
None | Some("") => ConversationStatus::Finished,
|
||||
Some(s) => string_to_enum(s)?,
|
||||
};
|
||||
|
||||
let source: Option<ConversationSource> = row.source.as_deref().map(string_to_enum).transpose()?;
|
||||
|
||||
let model: Option<ProviderWithModel> = row.model.as_deref().map(parse_provider_with_model).transpose()?;
|
||||
|
||||
Ok(ConversationResponse {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
r#type: agent_type,
|
||||
model,
|
||||
status,
|
||||
runtime: None,
|
||||
source,
|
||||
pinned: row.pinned,
|
||||
pinned_at: row.pinned_at,
|
||||
channel_chat_id: row.channel_chat_id,
|
||||
created_at: row.created_at,
|
||||
modified_at: row.updated_at,
|
||||
extra,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the model JSON column into `ProviderWithModel`.
|
||||
///
|
||||
/// Nomi stores the full provider object (`TProviderWithModel`) which includes
|
||||
/// fields like `id`, `platform`, `base_url`, `api_key`, `use_model`, and a `model`
|
||||
/// field that can be an array of model objects. The backend only needs
|
||||
/// `provider_id`, `model` (the selected model name), and `use_model`.
|
||||
/// Accepts both snake_case and legacy camelCase key names for backward compatibility.
|
||||
fn parse_provider_with_model(s: &str) -> Result<ProviderWithModel, AppError> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(s).map_err(|e| AppError::Internal(format!("Invalid model JSON: {e}")))?;
|
||||
|
||||
if let Some(provider_id) = v
|
||||
.get("provider_id")
|
||||
.or_else(|| v.get("providerId"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let model = v.get("model").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let use_model = v
|
||||
.get("use_model")
|
||||
.or_else(|| v.get("useModel"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
return Ok(ProviderWithModel {
|
||||
provider_id: provider_id.to_string(),
|
||||
model: model.to_string(),
|
||||
use_model,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(id) = v.get("id").and_then(|v| v.as_str()) {
|
||||
let use_model_str = v
|
||||
.get("use_model")
|
||||
.or_else(|| v.get("useModel"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
return Ok(ProviderWithModel {
|
||||
provider_id: id.to_string(),
|
||||
model: use_model_str.clone().unwrap_or_default(),
|
||||
use_model: use_model_str,
|
||||
});
|
||||
}
|
||||
|
||||
Err(AppError::Internal(format!(
|
||||
"Model JSON missing both 'provider_id'/'providerId' and 'id': {s}"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Parse a DB string value into a typed enum via serde.
|
||||
///
|
||||
/// e.g. `"acp"` → `AgentType::Acp`
|
||||
pub fn string_to_enum<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, AppError> {
|
||||
serde_json::from_value(serde_json::Value::String(s.to_owned()))
|
||||
.map_err(|e| AppError::Internal(format!("Invalid enum value '{s}': {e}")))
|
||||
}
|
||||
|
||||
/// Convert a message database row into an API response DTO.
|
||||
pub fn row_to_message_response(row: MessageRow) -> Result<MessageResponse, AppError> {
|
||||
let msg_type: MessageType = string_to_enum(&row.r#type)?;
|
||||
|
||||
let position: Option<MessagePosition> = row.position.as_deref().map(string_to_enum).transpose()?;
|
||||
|
||||
let status: Option<MessageStatus> = row.status.as_deref().map(string_to_enum).transpose()?;
|
||||
|
||||
let content: serde_json::Value = serde_json::from_str(&row.content)
|
||||
.map_err(|e| AppError::Internal(format!("Invalid message content JSON: {e}")))?;
|
||||
|
||||
Ok(MessageResponse {
|
||||
id: row.id,
|
||||
conversation_id: row.conversation_id,
|
||||
msg_id: row.msg_id,
|
||||
r#type: msg_type,
|
||||
content,
|
||||
position,
|
||||
status,
|
||||
hidden: row.hidden,
|
||||
created_at: row.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a message row for history-list use, compacting oversized tool payloads.
|
||||
pub fn row_to_message_response_compact(row: MessageRow) -> Result<MessageResponse, AppError> {
|
||||
let original_size = row.content.len();
|
||||
let mut response = row_to_message_response(row)?;
|
||||
if !is_tool_message(response.r#type) || original_size <= TOOL_CONTENT_COMPACT_THRESHOLD_BYTES {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let mut truncated = false;
|
||||
truncate_large_strings(&mut response.content, TOOL_CONTENT_PREVIEW_CHARS, &mut truncated);
|
||||
if truncated && let Some(obj) = response.content.as_object_mut() {
|
||||
obj.insert(
|
||||
"_compact".to_string(),
|
||||
serde_json::json!({
|
||||
"truncated": true,
|
||||
"original_size": original_size,
|
||||
"preview_chars": TOOL_CONTENT_PREVIEW_CHARS
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn is_tool_message(msg_type: MessageType) -> bool {
|
||||
matches!(
|
||||
msg_type,
|
||||
MessageType::ToolCall | MessageType::ToolGroup | MessageType::AcpToolCall
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_large_strings(value: &mut serde_json::Value, max_chars: usize, truncated: &mut bool) {
|
||||
match value {
|
||||
serde_json::Value::String(text) if text.chars().count() > max_chars => {
|
||||
let preview: String = text.chars().take(max_chars).collect();
|
||||
*text = format!("{preview}\n...[truncated]");
|
||||
*truncated = true;
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
truncate_large_strings(item, max_chars, truncated);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
for entry in map.values_mut() {
|
||||
truncate_large_strings(entry, max_chars, truncated);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an artifact database row into an API response DTO.
|
||||
pub fn row_to_artifact_response(row: ConversationArtifactRow) -> Result<ConversationArtifactResponse, AppError> {
|
||||
let kind = string_to_enum(&row.kind)?;
|
||||
let status = string_to_enum(&row.status)?;
|
||||
let payload: serde_json::Value = serde_json::from_str(&row.payload)
|
||||
.map_err(|e| AppError::Internal(format!("Invalid artifact payload JSON: {e}")))?;
|
||||
|
||||
Ok(ConversationArtifactResponse {
|
||||
id: row.id,
|
||||
conversation_id: row.conversation_id,
|
||||
cron_job_id: row.cron_job_id,
|
||||
kind,
|
||||
status,
|
||||
payload,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract plain-text preview from a message content field.
|
||||
///
|
||||
/// Message content is stored as JSON (arrays, objects with nested strings).
|
||||
/// This recursively collects all string values and joins them with spaces,
|
||||
/// producing a flat preview suitable for search snippet display.
|
||||
fn extract_preview_text(raw_content: &str) -> String {
|
||||
fn collect_strings(value: &serde_json::Value, bucket: &mut Vec<String>) {
|
||||
match value {
|
||||
serde_json::Value::String(s) => {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
bucket.push(trimmed.to_owned());
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
for item in arr {
|
||||
collect_strings(item, bucket);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
for item in map.values() {
|
||||
collect_strings(item, bucket);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match serde_json::from_str::<serde_json::Value>(raw_content) {
|
||||
Ok(parsed) => {
|
||||
let mut bucket = Vec::new();
|
||||
collect_strings(&parsed, &mut bucket);
|
||||
let joined = bucket.join(" ");
|
||||
let normalized = joined.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
raw_content.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
Err(_) => raw_content.split_whitespace().collect::<Vec<_>>().join(" "),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a search result row into an API search item DTO.
|
||||
pub fn search_row_to_item(row: MessageSearchRow, data_dir: &Path) -> Result<MessageSearchItem, AppError> {
|
||||
let conversation_row = ConversationRow {
|
||||
id: row.conversation_id,
|
||||
user_id: String::new(),
|
||||
name: row.conversation_name,
|
||||
r#type: row.conversation_type,
|
||||
extra: row.conversation_extra,
|
||||
model: row.conversation_model,
|
||||
status: row.conversation_status,
|
||||
source: row.conversation_source,
|
||||
channel_chat_id: row.conversation_channel_chat_id,
|
||||
pinned: row.conversation_pinned,
|
||||
pinned_at: row.conversation_pinned_at,
|
||||
// Search rows don't project `cron_job_id`; it isn't needed for the
|
||||
// search-result conversation summary (no artifact card rendered there).
|
||||
cron_job_id: None,
|
||||
created_at: row.conversation_created_at,
|
||||
updated_at: row.conversation_updated_at,
|
||||
};
|
||||
|
||||
let conversation = row_to_response(conversation_row, data_dir)?;
|
||||
let preview_text = extract_preview_text(&row.content);
|
||||
|
||||
Ok(MessageSearchItem {
|
||||
message_id: row.message_id,
|
||||
message_type: row.r#type,
|
||||
message_created_at: row.created_at,
|
||||
preview_text,
|
||||
conversation,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_common::{AgentType, ConversationSource, ConversationStatus};
|
||||
use serde_json::json;
|
||||
|
||||
fn make_row(
|
||||
agent_type: &str,
|
||||
status: &str,
|
||||
source: Option<&str>,
|
||||
model_json: Option<&str>,
|
||||
extra_json: &str,
|
||||
) -> ConversationRow {
|
||||
ConversationRow {
|
||||
id: 1,
|
||||
user_id: "user_1".into(),
|
||||
name: "Test".into(),
|
||||
r#type: agent_type.into(),
|
||||
extra: extra_json.into(),
|
||||
model: model_json.map(|s| s.into()),
|
||||
status: Some(status.into()),
|
||||
source: source.map(|s| s.into()),
|
||||
channel_chat_id: None,
|
||||
pinned: false,
|
||||
pinned_at: None,
|
||||
cron_job_id: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_to_response_basic() {
|
||||
let model = json!({"providerId": "p1", "model": "m1"});
|
||||
let row = make_row(
|
||||
"acp",
|
||||
"pending",
|
||||
Some("nomifun"),
|
||||
Some(&model.to_string()),
|
||||
r#"{"workspace": "/project"}"#,
|
||||
);
|
||||
let resp = row_to_response(row, Path::new("/tmp/data")).unwrap();
|
||||
assert_eq!(resp.id, 1);
|
||||
assert_eq!(resp.r#type, AgentType::Acp);
|
||||
assert_eq!(resp.status, ConversationStatus::Pending);
|
||||
assert_eq!(resp.source, Some(ConversationSource::Nomifun));
|
||||
assert_eq!(resp.model.unwrap().model, "m1");
|
||||
assert_eq!(resp.extra["workspace"], "/project");
|
||||
assert_eq!(resp.modified_at, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_to_response_no_source() {
|
||||
let row = make_row("acp", "running", None, None, "{}");
|
||||
let resp = row_to_response(row, Path::new("/tmp/data")).unwrap();
|
||||
assert!(resp.source.is_none());
|
||||
assert!(resp.model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_to_response_invalid_type() {
|
||||
let row = make_row("invalid", "pending", None, None, "{}");
|
||||
let err = row_to_response(row, Path::new("/tmp/data")).unwrap_err();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_to_response_invalid_extra_json() {
|
||||
let row = ConversationRow {
|
||||
id: 1,
|
||||
user_id: "user_1".into(),
|
||||
name: "Test".into(),
|
||||
r#type: "acp".into(),
|
||||
extra: "not-json".into(),
|
||||
model: None,
|
||||
status: Some("pending".into()),
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
pinned: false,
|
||||
pinned_at: None,
|
||||
cron_job_id: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
};
|
||||
let err = row_to_response(row, Path::new("/tmp/data")).unwrap_err();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_to_enum_valid() {
|
||||
let agent: AgentType = string_to_enum("acp").unwrap();
|
||||
assert_eq!(agent, AgentType::Acp);
|
||||
|
||||
let status: ConversationStatus = string_to_enum("finished").unwrap();
|
||||
assert_eq!(status, ConversationStatus::Finished);
|
||||
|
||||
let src: ConversationSource = string_to_enum("telegram").unwrap();
|
||||
assert_eq!(src, ConversationSource::Telegram);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_to_enum_invalid() {
|
||||
let err = string_to_enum::<AgentType>("not_valid").unwrap_err();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_with_model_backend_format() {
|
||||
let json = r#"{"providerId":"p1","model":"claude-sonnet-4-20250514","useModel":"claude-sonnet"}"#;
|
||||
let result = parse_provider_with_model(json).unwrap();
|
||||
assert_eq!(result.provider_id, "p1");
|
||||
assert_eq!(result.model, "claude-sonnet-4-20250514");
|
||||
assert_eq!(result.use_model.as_deref(), Some("claude-sonnet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_with_model_nomifun_format() {
|
||||
let json = r#"{"id":"prov_1","platform":"openai","name":"My Provider","baseUrl":"https://api.openai.com","apiKey":"sk-xxx","model":[{"id":"gpt-4","name":"GPT-4"}],"capabilities":["text","vision"],"useModel":"gpt-4-turbo","enabled":true}"#;
|
||||
let result = parse_provider_with_model(json).unwrap();
|
||||
assert_eq!(result.provider_id, "prov_1");
|
||||
assert_eq!(result.model, "gpt-4-turbo");
|
||||
assert_eq!(result.use_model.as_deref(), Some("gpt-4-turbo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_provider_with_model_missing_both_ids() {
|
||||
let json = r#"{"name":"invalid"}"#;
|
||||
assert!(parse_provider_with_model(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_to_response_marks_workspace_inside_data_dir_as_temporary() {
|
||||
let row = make_row(
|
||||
"acp",
|
||||
"pending",
|
||||
Some("nomifun"),
|
||||
None,
|
||||
r#"{"workspace":"/srv/nomifun-data/conversations/claude-temp-abc"}"#,
|
||||
);
|
||||
let resp = row_to_response(row, Path::new("/srv/nomifun-data")).unwrap();
|
||||
assert_eq!(resp.extra["is_temporary_workspace"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_to_response_marks_workspace_outside_data_dir_as_non_temporary() {
|
||||
let row = make_row(
|
||||
"acp",
|
||||
"pending",
|
||||
Some("nomifun"),
|
||||
None,
|
||||
r#"{"workspace":"/Users/alice/my-project"}"#,
|
||||
);
|
||||
let resp = row_to_response(row, Path::new("/srv/nomifun-data")).unwrap();
|
||||
assert_eq!(resp.extra["is_temporary_workspace"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_to_response_marks_missing_workspace_as_non_temporary() {
|
||||
let row = make_row("acp", "pending", Some("nomifun"), None, r#"{}"#);
|
||||
let resp = row_to_response(row, Path::new("/srv/nomifun-data")).unwrap();
|
||||
assert_eq!(resp.extra["is_temporary_workspace"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_to_response_marks_companion_workspace_as_non_temporary() {
|
||||
// A companion's fixed work folder sits under the data dir but is a
|
||||
// permanent per-companion workspace, not a throwaway temp one — the
|
||||
// `companionSession` flag must override the under-data-dir heuristic.
|
||||
let row = make_row(
|
||||
"nomi",
|
||||
"pending",
|
||||
Some("nomifun"),
|
||||
None,
|
||||
r#"{"companionSession":true,"workspace":"/srv/nomifun-data/companion/companions/companion_x/workspace"}"#,
|
||||
);
|
||||
let resp = row_to_response(row, Path::new("/srv/nomifun-data")).unwrap();
|
||||
assert_eq!(resp.extra["is_temporary_workspace"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_with_pinned_at() {
|
||||
let row = ConversationRow {
|
||||
id: 2,
|
||||
user_id: "user_1".into(),
|
||||
name: "Pinned".into(),
|
||||
r#type: "acp".into(),
|
||||
extra: "{}".into(),
|
||||
model: None,
|
||||
status: Some("pending".into()),
|
||||
source: Some("nomifun".into()),
|
||||
channel_chat_id: Some("chat:1".into()),
|
||||
pinned: true,
|
||||
pinned_at: Some(5000),
|
||||
cron_job_id: None,
|
||||
created_at: 1000,
|
||||
updated_at: 3000,
|
||||
};
|
||||
let resp = row_to_response(row, Path::new("/tmp/data")).unwrap();
|
||||
assert!(resp.pinned);
|
||||
assert_eq!(resp.pinned_at, Some(5000));
|
||||
assert_eq!(resp.channel_chat_id.as_deref(), Some("chat:1"));
|
||||
}
|
||||
|
||||
// ── extract_preview_text ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_extract_preview_text_json_array() {
|
||||
let content = r#"[{"type":"text","content":"Hello world"},{"type":"text","content":"How are you?"}]"#;
|
||||
let result = extract_preview_text(content);
|
||||
assert!(result.contains("Hello world"));
|
||||
assert!(result.contains("How are you?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_preview_text_plain_string() {
|
||||
let content = "Just plain text message";
|
||||
let result = extract_preview_text(content);
|
||||
assert_eq!(result, "Just plain text message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_preview_text_nested_object() {
|
||||
let content = r#"{"text":"nested value","items":[{"content":"inner"}]}"#;
|
||||
let result = extract_preview_text(content);
|
||||
assert!(result.contains("nested value"));
|
||||
assert!(result.contains("inner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_preview_text_malformed_json() {
|
||||
let content = "this is not { json at all";
|
||||
let result = extract_preview_text(content);
|
||||
assert_eq!(result, "this is not { json at all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_preview_text_empty_content() {
|
||||
let result = extract_preview_text("");
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_preview_text_whitespace_normalization() {
|
||||
let content = r#"{"content":" hello world "}"#;
|
||||
let result = extract_preview_text(content);
|
||||
assert_eq!(result, "hello world");
|
||||
}
|
||||
|
||||
// ── search_row_to_item ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_search_row_to_item_builds_nested_conversation() {
|
||||
let row = MessageSearchRow {
|
||||
message_id: "msg_1".into(),
|
||||
r#type: "text".into(),
|
||||
content: r#"{"content":"hello world"}"#.into(),
|
||||
created_at: 5000,
|
||||
conversation_id: 1,
|
||||
conversation_name: "Test Conv".into(),
|
||||
conversation_type: "acp".into(),
|
||||
conversation_extra: r#"{"workspace":"/project"}"#.into(),
|
||||
conversation_model: None,
|
||||
conversation_status: Some("finished".into()),
|
||||
conversation_source: Some("nomifun".into()),
|
||||
conversation_channel_chat_id: None,
|
||||
conversation_pinned: false,
|
||||
conversation_pinned_at: None,
|
||||
conversation_created_at: 1000,
|
||||
conversation_updated_at: 2000,
|
||||
};
|
||||
|
||||
let item = search_row_to_item(row, Path::new("/tmp/data")).unwrap();
|
||||
|
||||
assert_eq!(item.message_id, "msg_1");
|
||||
assert_eq!(item.message_type, "text");
|
||||
assert_eq!(item.message_created_at, 5000);
|
||||
assert_eq!(item.preview_text, "hello world");
|
||||
|
||||
assert_eq!(item.conversation.id, 1);
|
||||
assert_eq!(item.conversation.name, "Test Conv");
|
||||
assert_eq!(item.conversation.r#type, AgentType::Acp);
|
||||
assert_eq!(item.conversation.source, Some(ConversationSource::Nomifun));
|
||||
assert_eq!(item.conversation.extra["workspace"], "/project");
|
||||
assert_eq!(item.conversation.modified_at, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_row_to_item_invalid_conversation_type() {
|
||||
let row = MessageSearchRow {
|
||||
message_id: "msg_1".into(),
|
||||
r#type: "text".into(),
|
||||
content: "plain text".into(),
|
||||
created_at: 5000,
|
||||
conversation_id: 1,
|
||||
conversation_name: "Test".into(),
|
||||
conversation_type: "invalid_type".into(),
|
||||
conversation_extra: "{}".into(),
|
||||
conversation_model: None,
|
||||
conversation_status: Some("finished".into()),
|
||||
conversation_source: None,
|
||||
conversation_channel_chat_id: None,
|
||||
conversation_pinned: false,
|
||||
conversation_pinned_at: None,
|
||||
conversation_created_at: 1000,
|
||||
conversation_updated_at: 2000,
|
||||
};
|
||||
|
||||
let err = search_row_to_item(row, Path::new("/tmp/data")).unwrap_err();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_row_to_item_invalid_conversation_extra_json() {
|
||||
let row = MessageSearchRow {
|
||||
message_id: "msg_1".into(),
|
||||
r#type: "text".into(),
|
||||
content: r#"{"content":"hello"}"#.into(),
|
||||
created_at: 5000,
|
||||
conversation_id: 1,
|
||||
conversation_name: "Test".into(),
|
||||
conversation_type: "acp".into(),
|
||||
conversation_extra: "not valid json".into(),
|
||||
conversation_model: None,
|
||||
conversation_status: Some("finished".into()),
|
||||
conversation_source: None,
|
||||
conversation_channel_chat_id: None,
|
||||
conversation_pinned: false,
|
||||
conversation_pinned_at: None,
|
||||
conversation_created_at: 1000,
|
||||
conversation_updated_at: 2000,
|
||||
};
|
||||
|
||||
let err = search_row_to_item(row, Path::new("/tmp/data")).unwrap_err();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! Phase 3 模型故障转移 seam(plan D3/D5/D6)的会话服务侧实现。
|
||||
//!
|
||||
//! 纯逻辑(挑选器 / 配置读写 / 故障分类)在 [`crate::model_failover`];本模块只放
|
||||
//! 需要 `&ConversationService`(仓库 + task_manager)的有副作用步骤,并把
|
||||
//! 「挑下一候选 → 写 `conversation.model` →(可选)标失败模型 Unhealthy →
|
||||
//! kill_and_wait → 重建任务」抽成**一个** pub 方法 [`ConversationService::perform_model_failover`],
|
||||
//! 供 send-loop(D3)与 IDMM 故障值守(D6,Task 3)共用同一份实现。
|
||||
//!
|
||||
//! 这是 [`crate::acp_error_recovery::ConversationService::evict_acp_task_after_terminal_error`]
|
||||
//! 的泛化:那条路径在 ACP 终态错误后 kill 任务,这条路径换模型后重建并交回新句柄。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{HealthStatus, ModelHealthStatus};
|
||||
use nomifun_common::{AgentKillReason, AgentType, ErrorChain, ProviderWithModel, now_ms};
|
||||
use nomifun_db::{ConversationRowUpdate, UpdateProviderParams};
|
||||
use nomifun_ai_agent::{AgentInstance, IWorkerTaskManager};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::convert::string_to_enum;
|
||||
use crate::model_failover::{
|
||||
get_global_failover_config, next_failover_model, read_conversation_failover_override,
|
||||
};
|
||||
use crate::service::{ConversationService, parse_conv_id};
|
||||
use crate::stream_relay::RelayOutcome;
|
||||
use crate::task_options::provider_model_from_conversation_row;
|
||||
|
||||
/// 一次成功的故障转移结果:重建后的新任务句柄 + 被选中的候选模型。
|
||||
pub struct FailoverSwitch {
|
||||
/// 换模型并重建后的 agent 句柄。send-loop 用它 `subscribe()` + 重发同一内容。
|
||||
pub agent: AgentInstance,
|
||||
/// 本次切换到的 `(provider_id, model)`(已写入 `conversation.model`)。
|
||||
pub picked: ProviderWithModel,
|
||||
}
|
||||
|
||||
impl ConversationService {
|
||||
/// 解析该会话**生效**的故障转移配置:会话级 `extra.model_failover` 覆盖存在
|
||||
/// 则优先,否则回落到全局 `client_preferences` 的 `agent.model_failover`。
|
||||
/// 未注册 client-prefs 依赖(`with_failover_deps` 没调过)时返回 `None` —— 视为
|
||||
/// 故障转移关闭(fail-safe)。
|
||||
pub(crate) async fn resolve_failover_config(
|
||||
&self,
|
||||
extra_json: &str,
|
||||
) -> Option<nomifun_api_types::ModelFailoverConfig> {
|
||||
if let Some(override_cfg) = read_conversation_failover_override(extra_json) {
|
||||
return Some(override_cfg);
|
||||
}
|
||||
let (_, client_prefs) = self.failover_deps()?;
|
||||
Some(get_global_failover_config(&client_prefs).await)
|
||||
}
|
||||
|
||||
/// 把失败模型的 `model_health[model]` 标 `Unhealthy`(read-改-write,保留其余
|
||||
/// 模型的健康记录)。fail-open:任何一步出错只 warn 不致命 —— 标记是尽力而为的
|
||||
/// 加分项,不能拖垮故障转移本身。
|
||||
async fn stamp_model_unhealthy(&self, failed: &ProviderWithModel) {
|
||||
let Some((provider_repo, _)) = self.failover_deps() else {
|
||||
return;
|
||||
};
|
||||
let provider = match provider_repo.find_by_id(&failed.provider_id).await {
|
||||
Ok(Some(provider)) => provider,
|
||||
Ok(None) => {
|
||||
warn!(provider_id = %failed.provider_id, "Failover stamp-unhealthy skipped: provider row missing");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %ErrorChain(&e), provider_id = %failed.provider_id, "Failover stamp-unhealthy: failed to load provider");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut health: std::collections::HashMap<String, ModelHealthStatus> = provider
|
||||
.model_health
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
health.insert(
|
||||
failed.model.clone(),
|
||||
ModelHealthStatus {
|
||||
status: HealthStatus::Unhealthy,
|
||||
last_check: Some(now_ms()),
|
||||
latency: None,
|
||||
error: Some("model_failover: provider fault on live turn".into()),
|
||||
},
|
||||
);
|
||||
let serialized = match serde_json::to_string(&health) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(error = %ErrorChain(&e), "Failover stamp-unhealthy: serialize model_health failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let params = UpdateProviderParams {
|
||||
model_health: Some(Some(serialized.as_str())),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = provider_repo.update(&failed.provider_id, params).await {
|
||||
warn!(error = %ErrorChain(&e), provider_id = %failed.provider_id, "Failover stamp-unhealthy: provider update failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// **核心、可复用**的故障转移动作(plan D3 的「Some(next)」分支主体):
|
||||
/// 挑下一候选 → 写 `conversation.model`(origin 标记,非用户编辑)→
|
||||
/// (`stamp_unhealthy` 则)标失败模型 Unhealthy → `kill_and_wait`(镜像
|
||||
/// [`Self::evict_acp_task_after_terminal_error`])→ 用刷新后的行
|
||||
/// `build_task_options` 重建任务。返回 `Some(FailoverSwitch)` 表示换好新模型、
|
||||
/// 新句柄就绪;返回 `None` 表示**队列耗尽**(无可用候选)—— 调用方据此回落到
|
||||
/// 「emit 原始错误」,绝不无限切换。
|
||||
///
|
||||
/// send-loop(D3)与 IDMM 故障值守(D6)共用此方法:一份实现,两处触发。
|
||||
///
|
||||
/// **ACP 边界(review #9,plan D7)**:加载会话行后在此**统一**判定 agent 类型——
|
||||
/// 仅 `AgentType::Nomi` 放行,其余(ACP / 终端 CLI / 远程 …)`warn` + 返回 `None`
|
||||
/// (不 kill、不写 model)。send-loop 自己也有一道便宜的早闸,但**这里**才是
|
||||
/// 唯一的强制点:send-loop 与 IDMM 两条路径都过这道闸,所以 ACP 会话无论从哪条
|
||||
/// 路径进来都安全地被拒。
|
||||
///
|
||||
/// 注意:这里只换模型 + 重建 + 交回句柄,**不**负责重发消息 —— 重发是触发方
|
||||
/// (send-loop 重发同一 `current_send`;IDMM 自行决定)的职责。
|
||||
///
|
||||
/// `tried` 是本轮**已经切到过**的候选(review #2 单调性):挑选器跳过它们,
|
||||
/// 多次切换不回头重试同一候选。send-loop 累积本轮 picks 后传入;IDMM 单次切换
|
||||
/// 传空切片即可(它每次 `WakeAction::Failover` 只切一次,无跨回合累积)。
|
||||
pub async fn perform_model_failover(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
config: &nomifun_api_types::ModelFailoverConfig,
|
||||
tried: &[ProviderWithModel],
|
||||
task_manager: &Arc<dyn IWorkerTaskManager>,
|
||||
) -> Option<FailoverSwitch> {
|
||||
let Some((provider_repo, _)) = self.failover_deps() else {
|
||||
return None;
|
||||
};
|
||||
let conv_id = parse_conv_id(conversation_id).ok()?;
|
||||
let row = match self.conversation_repo().get(conv_id).await {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
warn!(conversation_id, "Failover skipped: conversation row missing");
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %ErrorChain(&e), conversation_id, "Failover skipped: failed to load conversation");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// ACP 边界(review #9,plan D7)的**唯一强制闸**:仅 nomi 自有引擎的普通会话
|
||||
// 可换模型重建。ACP / 终端 / 远程等 agent 自管模型(独立 reconcile),在此被
|
||||
// fail-safe 拒绝——不 kill、不写 model。send-loop 与 IDMM inject 都走这条
|
||||
// 路径,故两处都被这一道闸覆盖。
|
||||
let agent_type: AgentType = match string_to_enum(&row.r#type) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
warn!(error = %ErrorChain(&e), conversation_id, agent_type = %row.r#type, "Failover skipped: unparseable agent type");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if agent_type != AgentType::Nomi {
|
||||
warn!(
|
||||
conversation_id,
|
||||
agent_type = ?agent_type,
|
||||
"Failover skipped: not a nomi conversation (ACP/terminal self-manage their model)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let failed = provider_model_from_conversation_row(&row);
|
||||
let providers = match provider_repo.list().await {
|
||||
Ok(providers) => providers,
|
||||
Err(e) => {
|
||||
warn!(error = %ErrorChain(&e), conversation_id, "Failover skipped: failed to list providers");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// 队列耗尽 / 无可用候选 → None(调用方回落到原始错误)。
|
||||
let picked = next_failover_model(&config.queue, &failed, tried, &providers)?;
|
||||
|
||||
// 写 conversation.model(origin 标记:非用户编辑)。这正是 spec §5.5 锚定的
|
||||
// service.rs:896-927「改模型 + kill → 下次 send 重建」形状,只是这里立刻重建。
|
||||
// 同时这会改掉 IDMM 默认 bypass 模型(可接受:换走的正是那个故障模型)。
|
||||
let model_json = match serde_json::to_string(&picked) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
warn!(error = %ErrorChain(&e), conversation_id, "Failover aborted: serialize picked model failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let update = ConversationRowUpdate {
|
||||
model: Some(Some(model_json)),
|
||||
updated_at: Some(now_ms()),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = self.conversation_repo().update(conv_id, &update).await {
|
||||
warn!(error = %ErrorChain(&e), conversation_id, "Failover aborted: failed to persist new model");
|
||||
return None;
|
||||
}
|
||||
|
||||
if config.stamp_unhealthy {
|
||||
self.stamp_model_unhealthy(&failed).await;
|
||||
}
|
||||
|
||||
info!(
|
||||
conversation_id,
|
||||
failed_provider = %failed.provider_id,
|
||||
failed_model = %failed.model,
|
||||
next_provider = %picked.provider_id,
|
||||
next_model = %picked.model,
|
||||
reason = ?AgentKillReason::AgentErrorRecovery,
|
||||
"Model failover: switching model and rebuilding task"
|
||||
);
|
||||
|
||||
// kill_and_wait,镜像 evict_acp_task_after_terminal_error(acp_error_recovery.rs):
|
||||
// 旧任务句柄绑定旧 provider/model,必须等它落幕再用新行重建。
|
||||
task_manager
|
||||
.kill_and_wait(conversation_id, Some(AgentKillReason::AgentErrorRecovery))
|
||||
.await;
|
||||
|
||||
// 用**刷新后**的行重建。re-fetch 以拿到刚写入的新 model 列。
|
||||
let refreshed = match self.conversation_repo().get(conv_id).await {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
warn!(conversation_id, "Failover aborted: conversation vanished after model write");
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %ErrorChain(&e), conversation_id, "Failover aborted: re-fetch after model write failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let build_opts = match self.build_task_options(&refreshed) {
|
||||
Ok(opts) => opts,
|
||||
Err(e) => {
|
||||
warn!(error = %ErrorChain(&e), conversation_id, "Failover aborted: build_task_options on refreshed row failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let agent = match task_manager.get_or_build_task(conversation_id, build_opts).await {
|
||||
Ok(agent) => agent,
|
||||
Err(e) => {
|
||||
warn!(error = %ErrorChain(&e), conversation_id, "Failover aborted: rebuild task failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(FailoverSwitch { agent, picked })
|
||||
}
|
||||
|
||||
/// send-loop(plan D3)的故障转移决策入口:在 `consume_with_send_error` 之后调用。
|
||||
/// **全部满足**才转移(否则返回 `None`,send-loop 按现状 emit 原始错误):
|
||||
/// 1. terminal 是 Error 且 code 命中 [`crate::model_failover::is_provider_fault`];
|
||||
/// 2. **pre-response**:本轮未吐任何 assistant Text / 工具动作
|
||||
/// (`!outcome.emitted_response`,plan D4 + review #4)—— 杜绝重复输出 /
|
||||
/// 重复副作用 / 重复计费;
|
||||
/// 3. 故障转移启用(会话级覆盖否则全局,`enabled == true`);
|
||||
/// 4. `switches_done < min(max_switches, queue.len())` —— bounded;
|
||||
/// 5. agent 是 **nomi** 实例(plan D7;终端 CLI / ACP 自管模型,排除)。
|
||||
///
|
||||
/// 命中且挑到可用候选 → 换模型 + 重建,返回 `Some(FailoverSwitch)`;
|
||||
/// 任一条件不满足 / 队列耗尽 → `None`。
|
||||
///
|
||||
/// **不变量**:user-cancel 不会进到这里(取消是 `RelayTerminal::ChannelClosed`
|
||||
/// 或非 provider-fault 码,`is_provider_fault` 与 `is_error` 双重过滤);
|
||||
/// mid-response 故障被第 2 条挡掉(emit 错误,不转移)。
|
||||
pub(crate) async fn maybe_failover_in_send_loop(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
agent_type: AgentType,
|
||||
outcome: &RelayOutcome,
|
||||
switches_done: u32,
|
||||
tried: &[ProviderWithModel],
|
||||
extra_json: &str,
|
||||
task_manager: &Arc<dyn IWorkerTaskManager>,
|
||||
) -> Option<FailoverSwitch> {
|
||||
// (5) 仅 nomi 自有引擎的普通会话。便宜的早闸(避免无谓加载);真正的强制点
|
||||
// 在 `perform_model_failover` 的 ACP 边界闸(review #9),send-loop 与 IDMM
|
||||
// 共用那一处。
|
||||
if agent_type != AgentType::Nomi {
|
||||
return None;
|
||||
}
|
||||
// (1) provider 故障的终态错误。
|
||||
let RelayOutcome {
|
||||
terminal,
|
||||
emitted_response,
|
||||
..
|
||||
} = outcome;
|
||||
if !terminal.is_error() {
|
||||
return None;
|
||||
}
|
||||
let Some(code) = terminal.code() else {
|
||||
return None;
|
||||
};
|
||||
if !crate::model_failover::is_provider_fault(code) {
|
||||
return None;
|
||||
}
|
||||
// (2) pre-response:本轮已吐过 Text / 工具动作则不转移(post-response 故障 →
|
||||
// emit 错误,杜绝重复输出 / 重复副作用 / 重复计费)。
|
||||
if *emitted_response {
|
||||
return None;
|
||||
}
|
||||
// (3) 启用?(会话级覆盖否则全局)
|
||||
let config = self.resolve_failover_config(extra_json).await?;
|
||||
if !config.enabled {
|
||||
return None;
|
||||
}
|
||||
// (4) bounded:受 max_switches 与队列长度双重封顶。
|
||||
let bound = config.max_switches.min(config.queue.len() as u32);
|
||||
if switches_done >= bound {
|
||||
warn!(
|
||||
conversation_id,
|
||||
switches_done,
|
||||
max_switches = config.max_switches,
|
||||
queue_len = config.queue.len(),
|
||||
"Model failover bound reached; surfacing original error"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
self.perform_model_failover(conversation_id, &config, tried, task_manager)
|
||||
.await
|
||||
}
|
||||
|
||||
/// IDMM 故障值守(plan D6)的故障转移入口。`maybe_failover_in_send_loop` 是
|
||||
/// send-loop 的进入条件闸(pre-response / bounded / nomi-only 都已由 send-loop
|
||||
/// 上下文保证);**这条**是 IDMM 探针(`ConversationProbe::inject(Failover)`)的
|
||||
/// 进入条件闸:此刻没有活跃 send-loop,IDMM 自己是触发方,故由本方法
|
||||
/// 解析配置 → 调用**同一个** [`Self::perform_model_failover`] 换模型重建 →
|
||||
/// 重新驱动本轮(发一条 hidden 续聊消息,镜像 inject 的 Retry 路径)。
|
||||
///
|
||||
/// 返回 `Ok(true)` = 成功切到下一候选并已重新驱动;`Ok(false)` = 未转移
|
||||
/// (故障转移关闭 / 队列耗尽 / 依赖未注册 / 非 nomi),调用方据此回落(不无限切换)。
|
||||
///
|
||||
/// **IDMM 切换次数边界(review #3)**:本方法**每次** `WakeAction::Failover` 只执行
|
||||
/// **一次**模型切换(调一次 `perform_model_failover`),不持有任何跨回合计数器,也
|
||||
/// 不读 `max_switches`(那是 send-loop 单轮内自重发的封顶)。IDMM 路径下的总切换
|
||||
/// 次数由**故障值守自身的 `fault_watch.max_retries`** 间接封顶:值守每观察到一次
|
||||
/// provider 故障最多发一次 `Failover`,`max_retries` 用尽后值守 ladder 升级 / 兜底,
|
||||
/// 不再发 `Failover`。故无需也不应在此另设跨回合计数。
|
||||
///
|
||||
/// **不变量**:与 send-loop 共用 `perform_model_failover` 这一份实现(no
|
||||
/// duplicate);队列耗尽 → `Ok(false)`(IDMM 不再自动切换,值守 ladder 继续按
|
||||
/// 现状把它当 provider 故障处理 / 升级 / 兜底)。ACP 边界由 `perform_model_failover`
|
||||
/// 内部统一闸守(review #9):非 nomi 会话在那里返回 `None` → 本方法 `Ok(false)`。
|
||||
pub async fn idmm_failover_conversation(
|
||||
&self,
|
||||
user_id: &str,
|
||||
conversation_id: &str,
|
||||
task_manager: &Arc<dyn IWorkerTaskManager>,
|
||||
) -> Result<bool, nomifun_common::AppError> {
|
||||
let conv_id = parse_conv_id(conversation_id)?;
|
||||
let extra_json = match self.conversation_repo().get(conv_id).await {
|
||||
Ok(Some(row)) => row.extra,
|
||||
Ok(None) => {
|
||||
warn!(conversation_id, "IDMM failover skipped: conversation row missing");
|
||||
return Ok(false);
|
||||
}
|
||||
Err(e) => return Err(nomifun_common::AppError::from(e)),
|
||||
};
|
||||
|
||||
let Some(config) = self.resolve_failover_config(&extra_json).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !config.enabled {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 同一份换模型 + 重建实现(send-loop 也调它)。None = 队列耗尽 → 不转移。
|
||||
// IDMM 每次 Failover 只切一次,无跨回合累积,故 `tried` 传空切片(review #2)。
|
||||
if self
|
||||
.perform_model_failover(conversation_id, &config, &[], task_manager)
|
||||
.await
|
||||
.is_none()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 换好新模型 + 重建句柄后,重新驱动本轮:发一条 hidden 续聊消息,镜像
|
||||
// `ConversationProbe::inject(Retry)` 的 send_message(origin="idmm")路径。
|
||||
let req = nomifun_api_types::SendMessageRequest {
|
||||
content: "Please continue.".to_string(),
|
||||
files: vec![],
|
||||
inject_skills: vec![],
|
||||
hidden: true,
|
||||
origin: Some("idmm".into()),
|
||||
channel_platform: None,
|
||||
};
|
||||
self.send_message(user_id, conversation_id, req, task_manager)
|
||||
.await
|
||||
.map(|_| true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Conversation and message CRUD with streaming relay and event emission.
|
||||
mod acp_error_recovery;
|
||||
mod convert;
|
||||
mod failover_seam;
|
||||
mod message_persistence;
|
||||
pub mod model_failover;
|
||||
pub mod response_middleware;
|
||||
pub mod routes;
|
||||
pub mod routes_aux;
|
||||
pub mod runtime_state;
|
||||
pub mod service;
|
||||
mod service_ops;
|
||||
pub mod skill_resolver;
|
||||
pub mod skill_snapshot;
|
||||
pub mod state;
|
||||
pub mod stream_relay;
|
||||
pub mod task_options;
|
||||
|
||||
pub use response_middleware::{
|
||||
CronCommand, CronCommandResult, CronCreateParams, CronUpdateParams, ICronService, MessageMiddleware,
|
||||
MiddlewareResult, detect_cron_commands, has_cron_commands, strip_cron_commands, strip_think_tags,
|
||||
};
|
||||
pub use failover_seam::FailoverSwitch;
|
||||
pub use routes::conversation_routes;
|
||||
pub use routes_aux::conversation_ops_routes;
|
||||
pub use service::{ConversationService, ConversationSupervisionHook};
|
||||
pub use state::ConversationRouterState;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "service_test.rs"]
|
||||
mod service_test;
|
||||
@@ -0,0 +1,49 @@
|
||||
use nomifun_ai_agent::AgentSendError;
|
||||
use nomifun_common::{AppError, ErrorChain, now_ms};
|
||||
use nomifun_db::models::MessageRow;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::service::ConversationService;
|
||||
|
||||
impl ConversationService {
|
||||
pub(crate) async fn persist_send_failure_tip(&self, conversation_id: &str, err: &AppError) -> Option<MessageRow> {
|
||||
let Ok(conv_id) = conversation_id.parse::<i64>() else {
|
||||
warn!(
|
||||
conversation_id,
|
||||
"persist_send_failure_tip: non-numeric conversation id; skipping error tip persist"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let stream_error = AgentSendError::from_app_error_ref(err).into_stream_error();
|
||||
let row = MessageRow {
|
||||
id: Self::mint_msg_id(),
|
||||
conversation_id: conv_id,
|
||||
msg_id: None,
|
||||
r#type: "tips".into(),
|
||||
content: serde_json::json!({
|
||||
"content": &stream_error.message,
|
||||
"type": "error",
|
||||
"source": "send_failed",
|
||||
"code": err.error_code(),
|
||||
"details": err.error_details(),
|
||||
"error": stream_error,
|
||||
})
|
||||
.to_string(),
|
||||
position: Some("center".into()),
|
||||
status: Some("error".into()),
|
||||
hidden: false,
|
||||
created_at: now_ms(),
|
||||
};
|
||||
|
||||
if let Err(store_err) = self.conversation_repo().insert_message(&row).await {
|
||||
warn!(
|
||||
conversation_id,
|
||||
error = %ErrorChain(&store_err),
|
||||
"Failed to persist send failure error tip"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(row)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
//! Phase 3 模型故障转移队列(spec §5.5)的纯逻辑层 + 配置读写。
|
||||
//!
|
||||
//! 故障转移**只能**在会话服务层(send loop)做:`NomiAgentManager` 不保留重建
|
||||
//! 输入、engine 也无法原地换 provider,所以"换模型"等于改 `conversation.model` +
|
||||
//! 杀任务、下次 send 重建。本模块只承担两件无副作用/低副作用的事:
|
||||
//!
|
||||
//! 1. [`next_failover_model`] —— 纯函数挑选器(D2),给定失败模型与队列,按序返回
|
||||
//! 首个可用候选;跳过 provider 关停 / 模型禁用 / 健康检查标 Unhealthy / 失败本身;
|
||||
//! 队列耗尽返回 `None`(send-loop 见 `None` 即按现状 emit 原始错误,绝不无限切换)。
|
||||
//! 2. 配置读写 —— 全局存 `client_preferences` 键 `agent.model_failover`(整体 JSON,
|
||||
//! 形状抄 `nomifun-idmm/service.rs` 的多字段 pref 先例),会话级可在
|
||||
//! `conversations.extra.model_failover` 覆盖(存在则优先于全局)。
|
||||
//!
|
||||
//! 健康字段 fail-open:`provider` 的 `model_enabled` / `model_health` 是 TEXT JSON,
|
||||
//! 解析失败时按"未禁用 / 未知健康"处理 —— 宁可多保留一个候选也不要因脏数据把队列误清空。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{AgentErrorCode, HealthStatus, ModelFailoverConfig};
|
||||
use nomifun_common::{AppError, ErrorChain, ProviderWithModel};
|
||||
use nomifun_db::IClientPreferenceRepository;
|
||||
use nomifun_db::models::Provider;
|
||||
use tracing::warn;
|
||||
|
||||
/// `client_preferences` 键,存放全局模型故障转移配置(整体 JSON)。
|
||||
pub const MODEL_FAILOVER_PREF_KEY: &str = "agent.model_failover";
|
||||
|
||||
/// 判定一个 `AgentErrorCode` 是否为「provider 故障」——即换个备用模型可能绕过的
|
||||
/// 单厂商失败(限流 / 5xx / 网络 / 配置)。
|
||||
///
|
||||
/// 这张 matches 表是 `nomifun-idmm/config.rs::is_provider_fault` 的**就地副本**:
|
||||
/// 故障转移 seam 在 `nomifun-conversation`,而 `nomifun-idmm` 在其之上,直接依赖
|
||||
/// 会形成倒置的 crate 边界。两份必须保持一致;改动其一时同步另一处。
|
||||
pub fn is_provider_fault(code: AgentErrorCode) -> bool {
|
||||
use AgentErrorCode::*;
|
||||
matches!(
|
||||
code,
|
||||
UserLlmProviderAuthFailed
|
||||
| UserLlmProviderPermissionDenied
|
||||
| UserLlmProviderBillingRequired
|
||||
| UserLlmProviderConfigError
|
||||
| UserLlmProviderModelNotFound
|
||||
| UserLlmProviderUnsupportedModel
|
||||
| UserLlmProviderEndpointNotFound
|
||||
| UserLlmProviderInvalidRequest
|
||||
| UserLlmProviderInvalidToolSchema
|
||||
| UserLlmProviderContextTooLarge
|
||||
| UserLlmProviderRateLimited
|
||||
| UserLlmProviderTimeout
|
||||
| UserLlmProviderNetworkError
|
||||
| UserLlmProviderEmptyResponse
|
||||
| UserLlmProviderGatewayError
|
||||
| UnknownUpstreamError
|
||||
)
|
||||
}
|
||||
|
||||
/// 读全局故障转移配置。未设置(无该 pref 行)或 JSON 损坏时回落到
|
||||
/// [`ModelFailoverConfig::default`](默认关闭),保证调用方永远拿到可用配置。
|
||||
pub async fn get_global_failover_config(
|
||||
client_prefs: &Arc<dyn IClientPreferenceRepository>,
|
||||
) -> ModelFailoverConfig {
|
||||
let rows = match client_prefs.get_by_keys(&[MODEL_FAILOVER_PREF_KEY]).await {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
warn!(error = %ErrorChain(&e), "Failed to read model failover pref; defaulting to disabled");
|
||||
return ModelFailoverConfig::default();
|
||||
}
|
||||
};
|
||||
rows.into_iter()
|
||||
.find(|r| r.key == MODEL_FAILOVER_PREF_KEY)
|
||||
.and_then(|r| match serde_json::from_str::<ModelFailoverConfig>(&r.value) {
|
||||
Ok(cfg) => Some(cfg),
|
||||
Err(e) => {
|
||||
warn!(error = %ErrorChain(&e), "Malformed model failover pref; defaulting to disabled");
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 写全局故障转移配置(整体 JSON 进单个 pref 键)。形状抄 idmm `set_settings` 的
|
||||
/// `upsert_batch` 先例。
|
||||
pub async fn set_global_failover_config(
|
||||
client_prefs: &Arc<dyn IClientPreferenceRepository>,
|
||||
config: &ModelFailoverConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let value =
|
||||
serde_json::to_string(config).map_err(|e| AppError::Internal(format!("serialize failover config: {e}")))?;
|
||||
client_prefs
|
||||
.upsert_batch(&[(MODEL_FAILOVER_PREF_KEY, value.as_str())])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 `conversations.extra` 的 JSON 文本里读会话级覆盖。`extra.model_failover`
|
||||
/// 存在(且能解析)则返回它,否则 `None`(交由调用方回落到全局)。脏 `extra` /
|
||||
/// 缺字段一律按"无覆盖"处理 —— 与会话其余 extra 字段的容错读法一致。
|
||||
pub fn read_conversation_failover_override(extra_json: &str) -> Option<ModelFailoverConfig> {
|
||||
let value: serde_json::Value = serde_json::from_str(extra_json).ok()?;
|
||||
let raw = value.get("model_failover")?;
|
||||
serde_json::from_value::<ModelFailoverConfig>(raw.clone()).ok()
|
||||
}
|
||||
|
||||
/// 解析的可用性视图(从 [`Provider`] 的 TEXT JSON 字段抽出,fail-open)。
|
||||
struct ProviderAvailability {
|
||||
enabled: bool,
|
||||
model_enabled: std::collections::HashMap<String, bool>,
|
||||
model_health: std::collections::HashMap<String, HealthStatus>,
|
||||
}
|
||||
|
||||
impl ProviderAvailability {
|
||||
/// 解析一行 provider 的 `enabled` / `model_enabled` / `model_health`。JSON 字段
|
||||
/// 解析失败时退化为空映射(=未知/未禁用),保证脏数据不会误判候选不可用。
|
||||
fn from_provider(provider: &Provider) -> Self {
|
||||
let model_enabled = provider
|
||||
.model_enabled
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<std::collections::HashMap<String, bool>>(s).ok())
|
||||
.unwrap_or_default();
|
||||
// 只取每个模型的 `status` 字段;其余健康元数据(last_check 等)与挑选无关。
|
||||
let model_health = provider
|
||||
.model_health
|
||||
.as_deref()
|
||||
.and_then(|s| {
|
||||
serde_json::from_str::<std::collections::HashMap<String, nomifun_api_types::ModelHealthStatus>>(s).ok()
|
||||
})
|
||||
.map(|m| m.into_iter().map(|(k, v)| (k, v.status)).collect())
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
enabled: provider.enabled,
|
||||
model_enabled,
|
||||
model_health,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该模型是否可作为候选:provider 启用 && 模型未被显式禁用 && 健康检查未标
|
||||
/// Unhealthy(Unknown / Healthy / 无记录都放行)。
|
||||
fn model_is_candidate(&self, model: &str) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
if self.model_enabled.get(model) == Some(&false) {
|
||||
return false;
|
||||
}
|
||||
if self.model_health.get(model) == Some(&HealthStatus::Unhealthy) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// D2 挑选器(纯函数):按队列序返回首个可用候选模型。
|
||||
///
|
||||
/// 跳过:`provider.enabled == false`、`model_enabled[model] == Some(false)`、
|
||||
/// `model_health[model].status == Unhealthy`、与刚失败的 `(provider_id, model)`
|
||||
/// 完全相同的条目、以及**本轮已经试过**的任何 `(provider_id, model)`(`tried`,
|
||||
/// review #2 单调性:多次切换时不回头重试已切过的候选,杜绝队列里循环抖动)。
|
||||
/// 无可用候选时返回 `None`(队列耗尽 → send-loop 不再转移)。
|
||||
///
|
||||
/// `providers` 是当前全部 provider 行;队列里引用的 provider 若不在表中,该候选
|
||||
/// 被视为不可用(找不到 = 不能用)。
|
||||
pub fn next_failover_model(
|
||||
queue: &[ProviderWithModel],
|
||||
failed: &ProviderWithModel,
|
||||
tried: &[ProviderWithModel],
|
||||
providers: &[Provider],
|
||||
) -> Option<ProviderWithModel> {
|
||||
let same = |a: &ProviderWithModel, b: &ProviderWithModel| a.provider_id == b.provider_id && a.model == b.model;
|
||||
queue.iter().find_map(|candidate| {
|
||||
// 跳过刚失败的同一 (provider_id, model)。
|
||||
if same(candidate, failed) {
|
||||
return None;
|
||||
}
|
||||
// review #2:跳过本轮已经切到过的候选(单调推进,不重试)。
|
||||
if tried.iter().any(|t| same(candidate, t)) {
|
||||
return None;
|
||||
}
|
||||
let provider = providers.iter().find(|p| p.id == candidate.provider_id)?;
|
||||
let availability = ProviderAvailability::from_provider(provider);
|
||||
if availability.model_is_candidate(&candidate.model) {
|
||||
Some(candidate.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn pwm(provider_id: &str, model: &str) -> ProviderWithModel {
|
||||
ProviderWithModel {
|
||||
provider_id: provider_id.into(),
|
||||
model: model.into(),
|
||||
use_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一行 provider:`enabled` + 每模型启用/健康映射序列化进 TEXT JSON。
|
||||
fn provider(
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
model_enabled: &[(&str, bool)],
|
||||
model_health: &[(&str, HealthStatus)],
|
||||
) -> Provider {
|
||||
let enabled_map: HashMap<String, bool> =
|
||||
model_enabled.iter().map(|(m, e)| (m.to_string(), *e)).collect();
|
||||
let health_map: HashMap<String, nomifun_api_types::ModelHealthStatus> = model_health
|
||||
.iter()
|
||||
.map(|(m, s)| {
|
||||
(
|
||||
m.to_string(),
|
||||
nomifun_api_types::ModelHealthStatus {
|
||||
status: *s,
|
||||
last_check: None,
|
||||
latency: None,
|
||||
error: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Provider {
|
||||
id: id.into(),
|
||||
platform: "openai".into(),
|
||||
name: id.into(),
|
||||
base_url: "https://example.com".into(),
|
||||
api_key_encrypted: "x".into(),
|
||||
models: "[]".into(),
|
||||
enabled,
|
||||
capabilities: "[]".into(),
|
||||
context_limit: None,
|
||||
model_protocols: None,
|
||||
model_enabled: if enabled_map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::to_string(&enabled_map).unwrap())
|
||||
},
|
||||
model_health: if health_map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::to_string(&health_map).unwrap())
|
||||
},
|
||||
bedrock_config: None,
|
||||
is_full_url: false,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_next_available_skipping_failed() {
|
||||
let queue = vec![pwm("p1", "m1"), pwm("p2", "m2")];
|
||||
let failed = pwm("p1", "m1");
|
||||
let providers = vec![provider("p1", true, &[], &[]), provider("p2", true, &[], &[])];
|
||||
let pick = next_failover_model(&queue, &failed, &[], &providers).expect("should pick p2/m2");
|
||||
assert_eq!(pick.provider_id, "p2");
|
||||
assert_eq!(pick.model, "m2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_already_tried_candidates() {
|
||||
// review #2 (monotonicity): a candidate already switched to this turn is
|
||||
// skipped even though it is still healthy/enabled — so multiple failover
|
||||
// hops advance through the queue instead of bouncing back to p2/m2.
|
||||
let queue = vec![pwm("p1", "m1"), pwm("p2", "m2"), pwm("p3", "m3")];
|
||||
let failed = pwm("p1", "m1");
|
||||
let tried = vec![pwm("p2", "m2")];
|
||||
let providers = vec![
|
||||
provider("p1", true, &[], &[]),
|
||||
provider("p2", true, &[], &[]),
|
||||
provider("p3", true, &[], &[]),
|
||||
];
|
||||
let pick = next_failover_model(&queue, &failed, &tried, &providers).expect("should skip tried p2/m2");
|
||||
assert_eq!(pick.provider_id, "p3");
|
||||
assert_eq!(pick.model, "m3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausts_when_only_remaining_candidate_already_tried() {
|
||||
// Queue has p1 (failed) and p2 (already tried) → nothing left → None.
|
||||
let queue = vec![pwm("p1", "m1"), pwm("p2", "m2")];
|
||||
let failed = pwm("p1", "m1");
|
||||
let tried = vec![pwm("p2", "m2")];
|
||||
let providers = vec![provider("p1", true, &[], &[]), provider("p2", true, &[], &[])];
|
||||
assert!(next_failover_model(&queue, &failed, &tried, &providers).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_disabled_provider() {
|
||||
let queue = vec![pwm("p1", "m1"), pwm("p2", "m2")];
|
||||
let failed = pwm("orig", "orig");
|
||||
// p1 是禁用 provider → 跳过,落到 p2。
|
||||
let providers = vec![provider("p1", false, &[], &[]), provider("p2", true, &[], &[])];
|
||||
let pick = next_failover_model(&queue, &failed, &[], &providers).expect("should skip disabled p1");
|
||||
assert_eq!(pick.provider_id, "p2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_model_disabled() {
|
||||
let queue = vec![pwm("p1", "m1"), pwm("p1", "m2")];
|
||||
let failed = pwm("orig", "orig");
|
||||
// p1 的 m1 被显式禁用 → 跳过,落到 m2。
|
||||
let providers = vec![provider("p1", true, &[("m1", false), ("m2", true)], &[])];
|
||||
let pick = next_failover_model(&queue, &failed, &[], &providers).expect("should skip disabled m1");
|
||||
assert_eq!(pick.model, "m2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unhealthy_model() {
|
||||
let queue = vec![pwm("p1", "m1"), pwm("p1", "m2")];
|
||||
let failed = pwm("orig", "orig");
|
||||
// m1 标 Unhealthy → 跳过;m2 Healthy → 选中。
|
||||
let providers = vec![provider(
|
||||
"p1",
|
||||
true,
|
||||
&[],
|
||||
&[("m1", HealthStatus::Unhealthy), ("m2", HealthStatus::Healthy)],
|
||||
)];
|
||||
let pick = next_failover_model(&queue, &failed, &[], &providers).expect("should skip unhealthy m1");
|
||||
assert_eq!(pick.model, "m2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_health_is_still_a_candidate() {
|
||||
// Unknown / 无健康记录 不应被跳过(只有 Unhealthy 才排除)。
|
||||
let queue = vec![pwm("p1", "m1")];
|
||||
let failed = pwm("orig", "orig");
|
||||
let providers = vec![provider("p1", true, &[], &[("m1", HealthStatus::Unknown)])];
|
||||
assert!(next_failover_model(&queue, &failed, &[], &providers).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_exhausted() {
|
||||
// 队列里唯一候选就是刚失败的那个 → 耗尽 → None。
|
||||
let queue = vec![pwm("p1", "m1")];
|
||||
let failed = pwm("p1", "m1");
|
||||
let providers = vec![provider("p1", true, &[], &[])];
|
||||
assert!(next_failover_model(&queue, &failed, &[], &providers).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_all_unavailable() {
|
||||
let queue = vec![pwm("p1", "m1"), pwm("p2", "m2")];
|
||||
let failed = pwm("orig", "orig");
|
||||
// p1 禁用 + p2 的 m2 Unhealthy → 全不可用 → None。
|
||||
let providers = vec![
|
||||
provider("p1", false, &[], &[]),
|
||||
provider("p2", true, &[], &[("m2", HealthStatus::Unhealthy)]),
|
||||
];
|
||||
assert!(next_failover_model(&queue, &failed, &[], &providers).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_provider_row_is_not_a_candidate() {
|
||||
// 候选引用的 provider 不在表中 → 找不到即不可用,跳到下一个。
|
||||
let queue = vec![pwm("ghost", "m1"), pwm("p2", "m2")];
|
||||
let failed = pwm("orig", "orig");
|
||||
let providers = vec![provider("p2", true, &[], &[])];
|
||||
let pick = next_failover_model(&queue, &failed, &[], &providers).expect("should fall to p2");
|
||||
assert_eq!(pick.provider_id, "p2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_queue_returns_none() {
|
||||
let providers = vec![provider("p1", true, &[], &[])];
|
||||
assert!(next_failover_model(&[], &pwm("p1", "m1"), &[], &providers).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_model_health_json_fails_open() {
|
||||
// model_health 是垃圾字符串 → 按未知健康处理,候选仍可用。
|
||||
let mut p = provider("p1", true, &[], &[]);
|
||||
p.model_health = Some("{not json".into());
|
||||
let queue = vec![pwm("p1", "m1")];
|
||||
assert!(next_failover_model(&queue, &pwm("orig", "orig"), &[], &[p]).is_some());
|
||||
}
|
||||
|
||||
// ── 配置读写 ──
|
||||
|
||||
#[test]
|
||||
fn conversation_override_present_parses() {
|
||||
let extra = serde_json::json!({
|
||||
"workspace": "/tmp/x",
|
||||
"model_failover": {"enabled": true, "max_switches": 2}
|
||||
})
|
||||
.to_string();
|
||||
let cfg = read_conversation_failover_override(&extra).expect("override present");
|
||||
assert!(cfg.enabled);
|
||||
assert_eq!(cfg.max_switches, 2);
|
||||
// 未给的字段仍走默认。
|
||||
assert!(cfg.stamp_unhealthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_override_absent_is_none() {
|
||||
let extra = serde_json::json!({"workspace": "/tmp/x"}).to_string();
|
||||
assert!(read_conversation_failover_override(&extra).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_override_malformed_extra_is_none() {
|
||||
assert!(read_conversation_failover_override("{not json").is_none());
|
||||
}
|
||||
|
||||
// ── 故障分类(本地副本与 idmm 表对齐)──
|
||||
|
||||
#[test]
|
||||
fn is_provider_fault_matches_known_codes() {
|
||||
assert!(is_provider_fault(AgentErrorCode::UserLlmProviderRateLimited));
|
||||
assert!(is_provider_fault(AgentErrorCode::UserLlmProviderGatewayError));
|
||||
assert!(is_provider_fault(AgentErrorCode::UserLlmProviderTimeout));
|
||||
assert!(is_provider_fault(AgentErrorCode::UnknownUpstreamError));
|
||||
// 非 provider 故障:用户取消 / 会话忙 等不应触发转移。
|
||||
assert!(!is_provider_fault(AgentErrorCode::UserAgentNotInstalled));
|
||||
assert!(!is_provider_fault(AgentErrorCode::NomifunConversationBusy));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Think-tag cleaning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Regex for `<think>...</think>` and `<thinking>...</thinking>` tags.
|
||||
///
|
||||
/// Uses `(?s)` (dot-all) so `.` matches newlines within the tag body.
|
||||
static THINK_TAG_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?s)<think(?:ing)?>.*?</think(?:ing)?>").expect("valid think-tag regex"));
|
||||
|
||||
/// Remove `<think>...</think>` and `<thinking>...</thinking>` tags from text.
|
||||
pub fn strip_think_tags(text: &str) -> String {
|
||||
THINK_TAG_RE.replace_all(text, "").into_owned()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cron command detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Regex for `[CRON_CREATE]...[/CRON_CREATE]` blocks (dot-all).
|
||||
static CRON_CREATE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?s)\[CRON_CREATE\]\s*(.*?)\s*\[/CRON_CREATE\]").expect("valid cron-create regex"));
|
||||
|
||||
/// Regex for `[CRON_UPDATE: <id>]...[/CRON_UPDATE]` blocks (dot-all).
|
||||
static CRON_UPDATE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?s)\[CRON_UPDATE:\s*([^\]]+)\]\s*(.*?)\s*\[/CRON_UPDATE\]").expect("valid cron-update regex")
|
||||
});
|
||||
|
||||
/// Regex for `[CRON_LIST]`.
|
||||
static CRON_LIST_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[CRON_LIST\]").expect("valid cron-list regex"));
|
||||
|
||||
/// Regex for `[CRON_DELETE: <id>]`.
|
||||
static CRON_DELETE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\[CRON_DELETE:\s*([^\]]+)\]").expect("valid cron-delete regex"));
|
||||
|
||||
/// A parsed cron command extracted from agent text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CronCommand {
|
||||
Create(CronCreateParams),
|
||||
Update(CronUpdateParams),
|
||||
List,
|
||||
Delete(String),
|
||||
}
|
||||
|
||||
/// Parameters for a cron-create command.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CronCreateParams {
|
||||
pub name: String,
|
||||
pub schedule: String,
|
||||
pub schedule_description: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Parameters for a cron-update command.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CronUpdateParams {
|
||||
pub job_id: String,
|
||||
pub name: String,
|
||||
pub schedule: String,
|
||||
pub schedule_description: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Detect all cron commands embedded in the text.
|
||||
pub fn detect_cron_commands(text: &str) -> Vec<CronCommand> {
|
||||
let mut commands = Vec::new();
|
||||
|
||||
for cap in CRON_CREATE_RE.captures_iter(text) {
|
||||
if let Some(body) = cap.get(1)
|
||||
&& let Some(params) = parse_cron_create_body(body.as_str())
|
||||
{
|
||||
commands.push(CronCommand::Create(params));
|
||||
}
|
||||
}
|
||||
|
||||
for cap in CRON_UPDATE_RE.captures_iter(text) {
|
||||
if let (Some(job_id_match), Some(body)) = (cap.get(1), cap.get(2))
|
||||
&& let Some(params) = parse_cron_update_body(job_id_match.as_str().trim(), body.as_str())
|
||||
{
|
||||
commands.push(CronCommand::Update(params));
|
||||
}
|
||||
}
|
||||
|
||||
if CRON_LIST_RE.is_match(text) {
|
||||
commands.push(CronCommand::List);
|
||||
}
|
||||
|
||||
for cap in CRON_DELETE_RE.captures_iter(text) {
|
||||
if let Some(id_match) = cap.get(1) {
|
||||
let id = id_match.as_str().trim().to_string();
|
||||
if !id.is_empty() {
|
||||
commands.push(CronCommand::Delete(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
}
|
||||
|
||||
/// Quick check: does the text contain any cron commands?
|
||||
pub fn has_cron_commands(text: &str) -> bool {
|
||||
CRON_CREATE_RE.is_match(text)
|
||||
|| CRON_UPDATE_RE.is_match(text)
|
||||
|| CRON_LIST_RE.is_match(text)
|
||||
|| CRON_DELETE_RE.is_match(text)
|
||||
}
|
||||
|
||||
/// Strip all cron command tags from text, returning cleaned content.
|
||||
pub fn strip_cron_commands(text: &str) -> String {
|
||||
let result = CRON_CREATE_RE.replace_all(text, "");
|
||||
let result = CRON_UPDATE_RE.replace_all(&result, "");
|
||||
let result = CRON_LIST_RE.replace_all(&result, "");
|
||||
let result = CRON_DELETE_RE.replace_all(&result, "");
|
||||
result.into_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CronCommandFields {
|
||||
name: Option<String>,
|
||||
schedule: Option<String>,
|
||||
schedule_description: Option<String>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse the body of a `[CRON_CREATE]...[/CRON_CREATE]` block.
|
||||
///
|
||||
/// Expected key-value format (one per line):
|
||||
/// ```text
|
||||
/// name: <value>
|
||||
/// schedule: <cron expression>
|
||||
/// schedule_description: <human-readable>
|
||||
/// message: <prompt text>
|
||||
/// ```
|
||||
fn parse_cron_command_body(body: &str) -> Option<CronCommandFields> {
|
||||
let mut name = None;
|
||||
let mut schedule = None;
|
||||
let mut schedule_description = None;
|
||||
let mut message = None;
|
||||
|
||||
for line in body.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(val) = line.strip_prefix("name:") {
|
||||
name = Some(val.trim().to_string());
|
||||
} else if let Some(val) = line.strip_prefix("schedule_description:") {
|
||||
schedule_description = Some(val.trim().to_string());
|
||||
} else if let Some(val) = line.strip_prefix("schedule:") {
|
||||
schedule = Some(val.trim().to_string());
|
||||
} else if let Some(val) = line.strip_prefix("message:") {
|
||||
message = Some(val.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Some(CronCommandFields {
|
||||
name,
|
||||
schedule,
|
||||
schedule_description,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_cron_create_body(body: &str) -> Option<CronCreateParams> {
|
||||
let fields = parse_cron_command_body(body)?;
|
||||
Some(CronCreateParams {
|
||||
name: fields.name.unwrap_or_default(),
|
||||
schedule: fields.schedule?,
|
||||
schedule_description: fields.schedule_description.unwrap_or_default(),
|
||||
message: fields.message.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_cron_update_body(job_id: &str, body: &str) -> Option<CronUpdateParams> {
|
||||
if job_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let fields = parse_cron_command_body(body)?;
|
||||
Some(CronUpdateParams {
|
||||
job_id: job_id.to_string(),
|
||||
name: fields.name?,
|
||||
schedule: fields.schedule?,
|
||||
schedule_description: fields.schedule_description?,
|
||||
message: fields.message?,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ICronService trait (implemented in Phase 12)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of a cron command execution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CronCommandResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Abstract cron service for executing cron commands.
|
||||
///
|
||||
/// This trait will be implemented in Phase 12 (cron module).
|
||||
/// The middleware uses it via dependency injection.
|
||||
#[async_trait]
|
||||
pub trait ICronService: Send + Sync {
|
||||
/// Create a cron job. Returns the created job ID on success.
|
||||
async fn create_job(&self, user_id: &str, conversation_id: &str, params: &CronCreateParams) -> CronCommandResult;
|
||||
|
||||
/// Update an existing cron job.
|
||||
async fn update_job(&self, user_id: &str, conversation_id: &str, params: &CronUpdateParams) -> CronCommandResult;
|
||||
|
||||
/// List cron jobs for the current conversation scope.
|
||||
/// Returns a formatted text response.
|
||||
async fn list_jobs(&self, user_id: &str, conversation_id: &str) -> CronCommandResult;
|
||||
|
||||
/// Delete a cron job by ID.
|
||||
async fn delete_job(&self, user_id: &str, job_id: &str) -> CronCommandResult;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MessageMiddleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of message middleware processing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiddlewareResult {
|
||||
/// The processed message content (think tags stripped, cron commands stripped).
|
||||
pub message: String,
|
||||
/// Optional display-only message (same as `message` if no cron commands).
|
||||
pub display_message: Option<String>,
|
||||
/// System responses generated by cron command execution.
|
||||
pub system_responses: Vec<String>,
|
||||
}
|
||||
|
||||
/// Post-processing pipeline for completed agent messages.
|
||||
///
|
||||
/// Runs on each finished agent response to:
|
||||
/// 1. Strip think/thinking tags
|
||||
/// 2. Detect and execute embedded cron commands
|
||||
/// 3. Return cleaned message + any system responses
|
||||
pub struct MessageMiddleware {
|
||||
cron_service: Option<Box<dyn ICronService>>,
|
||||
}
|
||||
|
||||
impl MessageMiddleware {
|
||||
/// Create middleware with optional cron service.
|
||||
///
|
||||
/// When `cron_service` is `None`, cron commands are still detected and
|
||||
/// stripped, but not executed (system responses will indicate unavailability).
|
||||
pub fn new(cron_service: Option<Box<dyn ICronService>>) -> Self {
|
||||
Self { cron_service }
|
||||
}
|
||||
|
||||
/// Process a completed agent message through the middleware pipeline.
|
||||
pub async fn process(&self, message: &str, user_id: &str, conversation_id: &str) -> MiddlewareResult {
|
||||
// Step 1: Strip think tags
|
||||
let cleaned = strip_think_tags(message);
|
||||
|
||||
// Step 2: Detect cron commands
|
||||
if !has_cron_commands(&cleaned) {
|
||||
return MiddlewareResult {
|
||||
message: cleaned,
|
||||
display_message: None,
|
||||
system_responses: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let commands = detect_cron_commands(&cleaned);
|
||||
let display_message = strip_cron_commands(&cleaned);
|
||||
|
||||
// Step 3: Execute cron commands
|
||||
let system_responses = self.execute_cron_commands(user_id, conversation_id, &commands).await;
|
||||
|
||||
MiddlewareResult {
|
||||
message: display_message.clone(),
|
||||
display_message: Some(display_message),
|
||||
system_responses,
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a list of cron commands via the injected service.
|
||||
async fn execute_cron_commands(
|
||||
&self,
|
||||
user_id: &str,
|
||||
conversation_id: &str,
|
||||
commands: &[CronCommand],
|
||||
) -> Vec<String> {
|
||||
let Some(cron_service) = &self.cron_service else {
|
||||
debug!("Cron commands detected but no cron service configured");
|
||||
return vec!["[System: Cron service is not available]".to_string()];
|
||||
};
|
||||
|
||||
let mut responses = Vec::new();
|
||||
|
||||
for command in commands {
|
||||
let result = match command {
|
||||
CronCommand::Create(params) => cron_service.create_job(user_id, conversation_id, params).await,
|
||||
CronCommand::Update(params) => cron_service.update_job(user_id, conversation_id, params).await,
|
||||
CronCommand::List => cron_service.list_jobs(user_id, conversation_id).await,
|
||||
CronCommand::Delete(id) => cron_service.delete_job(user_id, id).await,
|
||||
};
|
||||
|
||||
if result.success {
|
||||
responses.push(format!("[System: {}]", result.message));
|
||||
} else {
|
||||
warn!(
|
||||
command = ?command,
|
||||
error = %result.message,
|
||||
"Cron command execution failed"
|
||||
);
|
||||
responses.push(format!("[System Error: {}]", result.message));
|
||||
}
|
||||
}
|
||||
|
||||
responses
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Think tag stripping
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_basic() {
|
||||
let input = "Before<think>internal thought</think>After";
|
||||
assert_eq!(strip_think_tags(input), "BeforeAfter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_thinking_tags_basic() {
|
||||
let input = "<thinking>deep reasoning</thinking>Answer here";
|
||||
assert_eq!(strip_think_tags(input), "Answer here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_multiline() {
|
||||
let input = "Start\n<think>\nline 1\nline 2\n</think>\nEnd";
|
||||
assert_eq!(strip_think_tags(input), "Start\n\nEnd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_multiple() {
|
||||
let input = "<think>a</think>middle<thinking>b</thinking>end";
|
||||
assert_eq!(strip_think_tags(input), "middleend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_none() {
|
||||
let input = "No tags here at all.";
|
||||
assert_eq!(strip_think_tags(input), "No tags here at all.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_empty() {
|
||||
assert_eq!(strip_think_tags(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_nested_content() {
|
||||
// Non-greedy match consumes the inner content correctly
|
||||
let input = "<think>outer <b>bold</b> text</think>after";
|
||||
assert_eq!(strip_think_tags(input), "after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_with_code_blocks() {
|
||||
let input = "```rust\nfn main() {}\n```\n<think>private</think>\nResult";
|
||||
assert_eq!(strip_think_tags(input), "```rust\nfn main() {}\n```\n\nResult");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cron command detection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_cron_create_command() {
|
||||
let input = "[CRON_CREATE]\nname: Daily review\nschedule: 0 9 * * MON\nschedule_description: Every Monday 9am\nmessage: Review code\n[/CRON_CREATE]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
match &commands[0] {
|
||||
CronCommand::Create(params) => {
|
||||
assert_eq!(params.name, "Daily review");
|
||||
assert_eq!(params.schedule, "0 9 * * MON");
|
||||
assert_eq!(params.schedule_description, "Every Monday 9am");
|
||||
assert_eq!(params.message, "Review code");
|
||||
}
|
||||
_ => panic!("Expected CronCommand::Create"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_cron_list_command() {
|
||||
let input = "Here are the jobs: [CRON_LIST]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0], CronCommand::List);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_cron_update_command() {
|
||||
let input = "[CRON_UPDATE: job-123]\nname: Updated Job\nschedule: 0 10 * * *\nschedule_description: Daily at 10am\nmessage: Updated instructions\n[/CRON_UPDATE]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
match &commands[0] {
|
||||
CronCommand::Update(params) => {
|
||||
assert_eq!(params.job_id, "job-123");
|
||||
assert_eq!(params.name, "Updated Job");
|
||||
assert_eq!(params.schedule, "0 10 * * *");
|
||||
assert_eq!(params.schedule_description, "Daily at 10am");
|
||||
assert_eq!(params.message, "Updated instructions");
|
||||
}
|
||||
_ => panic!("Expected CronCommand::Update"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_cron_delete_command() {
|
||||
let input = "[CRON_DELETE: job-123]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0], CronCommand::Delete("job-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_mixed_cron_commands() {
|
||||
let input = "I'll manage your crons.\n[CRON_CREATE]\nname: test\nschedule: * * * * *\nschedule_description: every minute\nmessage: ping\n[/CRON_CREATE]\nUpdate too: [CRON_UPDATE: existing-job]\nname: updated\nschedule: 0 * * * *\nschedule_description: hourly\nmessage: pong\n[/CRON_UPDATE]\nAlso listing: [CRON_LIST]\nAnd deleting: [CRON_DELETE: old-job]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 4);
|
||||
assert!(matches!(&commands[0], CronCommand::Create(_)));
|
||||
assert!(matches!(&commands[1], CronCommand::Update(_)));
|
||||
assert_eq!(commands[2], CronCommand::List);
|
||||
assert_eq!(commands[3], CronCommand::Delete("old-job".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_no_cron_commands() {
|
||||
let commands = detect_cron_commands("Just a normal reply.");
|
||||
assert!(commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_cron_create_missing_schedule() {
|
||||
let input = "[CRON_CREATE]\nname: broken\nmessage: oops\n[/CRON_CREATE]";
|
||||
let commands = detect_cron_commands(input);
|
||||
// Missing required `schedule` field → not parsed
|
||||
assert!(commands.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// has_cron_commands
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn has_cron_commands_true() {
|
||||
assert!(has_cron_commands("[CRON_LIST]"));
|
||||
assert!(has_cron_commands("[CRON_DELETE: x]"));
|
||||
assert!(has_cron_commands("[CRON_UPDATE: x]\nschedule: *\n[/CRON_UPDATE]"));
|
||||
assert!(has_cron_commands("[CRON_CREATE]\nschedule: *\n[/CRON_CREATE]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cron_commands_false() {
|
||||
assert!(!has_cron_commands("No cron here"));
|
||||
assert!(!has_cron_commands("[CRON_SOMETHING_ELSE]"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// strip_cron_commands
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn strip_cron_commands_all_types() {
|
||||
let input = "Before [CRON_LIST] middle [CRON_DELETE: abc] after [CRON_CREATE]\nname: t\nschedule: *\n[/CRON_CREATE] and [CRON_UPDATE: abc]\nname: t2\nschedule: 0 * * * *\n[/CRON_UPDATE] end";
|
||||
let stripped = strip_cron_commands(input);
|
||||
assert!(!stripped.contains("[CRON_"));
|
||||
assert!(stripped.contains("Before"));
|
||||
assert!(stripped.contains("end"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_cron_commands_no_commands() {
|
||||
let input = "Nothing to strip.";
|
||||
assert_eq!(strip_cron_commands(input), "Nothing to strip.");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// parse_cron_create_body
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_cron_create_body_full() {
|
||||
let body = "name: My Job\nschedule: 0 9 * * *\nschedule_description: Daily 9am\nmessage: Run tests";
|
||||
let params = parse_cron_create_body(body).unwrap();
|
||||
assert_eq!(params.name, "My Job");
|
||||
assert_eq!(params.schedule, "0 9 * * *");
|
||||
assert_eq!(params.schedule_description, "Daily 9am");
|
||||
assert_eq!(params.message, "Run tests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cron_create_body_minimal() {
|
||||
let body = "schedule: */5 * * * *";
|
||||
let params = parse_cron_create_body(body).unwrap();
|
||||
assert!(params.name.is_empty());
|
||||
assert_eq!(params.schedule, "*/5 * * * *");
|
||||
assert!(params.schedule_description.is_empty());
|
||||
assert!(params.message.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cron_create_body_no_schedule() {
|
||||
let body = "name: broken";
|
||||
assert!(parse_cron_create_body(body).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cron_update_body_full() {
|
||||
let body = "name: Updated\nschedule: 0 9 * * *\nschedule_description: Daily 9am\nmessage: Run tests";
|
||||
let params = parse_cron_update_body("job-7", body).unwrap();
|
||||
assert_eq!(params.job_id, "job-7");
|
||||
assert_eq!(params.name, "Updated");
|
||||
assert_eq!(params.schedule, "0 9 * * *");
|
||||
assert_eq!(params.schedule_description, "Daily 9am");
|
||||
assert_eq!(params.message, "Run tests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cron_update_body_requires_job_id() {
|
||||
let body = "name: Updated\nschedule: 0 9 * * *";
|
||||
assert!(parse_cron_update_body("", body).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cron_update_body_requires_all_fields() {
|
||||
let body = "name: Updated\nschedule: 0 9 * * *\nmessage: Run tests";
|
||||
assert!(parse_cron_update_body("job-7", body).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cron_create_body_schedule_description_before_schedule() {
|
||||
// schedule_description should match before schedule due to prefix ordering
|
||||
let body = "schedule_description: desc first\nschedule: 0 * * * *\nname: test";
|
||||
let params = parse_cron_create_body(body).unwrap();
|
||||
assert_eq!(params.schedule, "0 * * * *");
|
||||
assert_eq!(params.schedule_description, "desc first");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MessageMiddleware
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
struct MockCronService;
|
||||
|
||||
#[async_trait]
|
||||
impl ICronService for MockCronService {
|
||||
async fn create_job(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_conversation_id: &str,
|
||||
params: &CronCreateParams,
|
||||
) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: format!("Created cron job '{}'", params.name),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_job(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_conversation_id: &str,
|
||||
params: &CronUpdateParams,
|
||||
) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: format!("Updated cron job '{}'", params.job_id),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_jobs(&self, _user_id: &str, _conversation_id: &str) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: "No cron jobs found".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_job(&self, _user_id: &str, job_id: &str) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: format!("Deleted cron job '{}'", job_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_strips_think_tags() {
|
||||
let mw = MessageMiddleware::new(None);
|
||||
let result = mw
|
||||
.process("<think>reasoning</think>The answer is 42.", "user1", "conv1")
|
||||
.await;
|
||||
assert_eq!(result.message, "The answer is 42.");
|
||||
assert!(result.display_message.is_none());
|
||||
assert!(result.system_responses.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_processes_cron_commands() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(MockCronService)));
|
||||
let input = "Done! [CRON_CREATE]\nname: daily\nschedule: 0 9 * * *\nschedule_description: Daily\nmessage: run\n[/CRON_CREATE]";
|
||||
let result = mw.process(input, "user1", "conv1").await;
|
||||
assert!(!result.message.contains("[CRON_CREATE]"));
|
||||
assert!(result.display_message.is_some());
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("Created cron job"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_processes_cron_update() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(MockCronService)));
|
||||
let input = "Updated it. [CRON_UPDATE: job-99]\nname: daily\nschedule: 0 10 * * *\nschedule_description: Daily\nmessage: run\n[/CRON_UPDATE]";
|
||||
let result = mw.process(input, "user1", "conv1").await;
|
||||
assert!(!result.message.contains("[CRON_UPDATE"));
|
||||
assert!(result.display_message.is_some());
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("Updated cron job 'job-99'"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_no_cron_service_reports_unavailable() {
|
||||
let mw = MessageMiddleware::new(None);
|
||||
let input = "Check [CRON_LIST] please";
|
||||
let result = mw.process(input, "user1", "conv1").await;
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("not available"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_combined_think_and_cron() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(MockCronService)));
|
||||
let input = "<thinking>let me plan</thinking>I'll delete that. [CRON_DELETE: job-99]";
|
||||
let result = mw.process(input, "user1", "conv1").await;
|
||||
assert!(!result.message.contains("<thinking>"));
|
||||
assert!(!result.message.contains("[CRON_DELETE"));
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("Deleted cron job"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_plain_text_passthrough() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(MockCronService)));
|
||||
let result = mw.process("Just a normal response.", "user1", "conv1").await;
|
||||
assert_eq!(result.message, "Just a normal response.");
|
||||
assert!(result.display_message.is_none());
|
||||
assert!(result.system_responses.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
use axum::Router;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Extension, Json, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, patch, post};
|
||||
|
||||
use nomifun_api_types::{
|
||||
ActiveCountResponse, ApiResponse, ApprovalCheckQuery, ApprovalCheckResponse, CloneConversationRequest,
|
||||
ConfirmRequest, ConfirmationListResponse, ConversationArtifactListResponse, ConversationArtifactResponse,
|
||||
ConversationListResponse, ConversationResponse, CreateConversationRequest, ListConversationsQuery,
|
||||
ListMessagesQuery, MessageListResponse, MessageResponse, MessageSearchResponse, SearchMessagesQuery,
|
||||
SendMessageRequest, SendMessageResponse, UpdateConversationArtifactRequest, UpdateConversationRequest,
|
||||
};
|
||||
use nomifun_auth::{CurrentUser, LocalTrusted};
|
||||
use nomifun_common::AppError;
|
||||
|
||||
use crate::state::ConversationRouterState;
|
||||
|
||||
/// Build the conversation router (CRUD + message flow + confirmation + extended operations).
|
||||
///
|
||||
/// All routes require authentication (applied by the caller).
|
||||
pub fn conversation_routes(state: ConversationRouterState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/conversations", post(create).get(list))
|
||||
.route("/api/conversations/{id}", get(get_one).patch(update).delete(delete_one))
|
||||
.route("/api/conversations/{id}/reset", post(reset))
|
||||
.route("/api/conversations/{id}/associated", get(associated))
|
||||
.route("/api/conversations/{id}/messages", get(list_msg).post(send_msg))
|
||||
.route("/api/conversations/{id}/messages/{messageId}", get(get_msg))
|
||||
.route(
|
||||
"/api/conversations/{id}/messages/{messageId}/edit-resubmit",
|
||||
post(edit_resubmit),
|
||||
)
|
||||
.route("/api/conversations/{id}/artifacts", get(list_artifacts))
|
||||
.route("/api/conversations/{id}/artifacts/{artifactId}", patch(update_artifact))
|
||||
.route("/api/conversations/{id}/cancel", post(cancel))
|
||||
.route("/api/conversations/{id}/steer", post(steer))
|
||||
.route("/api/conversations/{id}/warmup", post(warmup))
|
||||
// Confirmation system
|
||||
.route("/api/conversations/{id}/confirmations", get(list_confirmations))
|
||||
.route("/api/conversations/{id}/confirmations/{callId}/confirm", post(confirm))
|
||||
.route("/api/conversations/{id}/approvals/check", get(check_approval))
|
||||
.route("/api/conversations/active-count", get(active_count))
|
||||
.route("/api/conversations/clone", post(clone))
|
||||
.route("/api/messages/search", get(search_messages))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────
|
||||
|
||||
/// `extra.desktopGateway` entitles a session to the Desktop Gateway MCP (full
|
||||
/// desktop control: conversations, cron, memory, requirements). Only backend
|
||||
/// code paths (channel master-agent sessions, companion companion threads) may set
|
||||
/// it — strip both spellings from any extra JSON arriving over HTTP so a
|
||||
/// client cannot self-authorize a session.
|
||||
fn strip_desktop_gateway_flag(extra: &mut serde_json::Value) {
|
||||
if let Some(map) = extra.as_object_mut() {
|
||||
map.remove("desktopGateway");
|
||||
map.remove("desktop_gateway");
|
||||
}
|
||||
}
|
||||
|
||||
/// Grant the Desktop Gateway to a session the BACKEND has decided is entitled.
|
||||
/// Called after [`strip_desktop_gateway_flag`] (clients cannot self-authorize;
|
||||
/// the backend re-grants). Ensures `extra` is an object first.
|
||||
fn grant_desktop_gateway(extra: &mut serde_json::Value) {
|
||||
if !extra.is_object() {
|
||||
*extra = serde_json::json!({});
|
||||
}
|
||||
if let Some(map) = extra.as_object_mut() {
|
||||
map.insert("desktopGateway".to_owned(), serde_json::Value::Bool(true));
|
||||
}
|
||||
}
|
||||
|
||||
async fn create(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
// Present only for locally-trusted requests (the desktop webview / NoAuth),
|
||||
// NOT for remote LAN browser sessions. See `grant` rationale below.
|
||||
local: Option<Extension<LocalTrusted>>,
|
||||
body: Result<Json<CreateConversationRequest>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<ConversationResponse>>), AppError> {
|
||||
let Json(mut req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
strip_desktop_gateway_flag(&mut req.extra);
|
||||
// The desktop is the owner's own machine, so EVERY locally-trusted session is
|
||||
// entitled to the Desktop Gateway by default — any conversation becomes a
|
||||
// semantic super-gateway over the whole platform (the product's "0-code, any
|
||||
// session does everything" goal). Granted by the backend AFTER the strip, so a
|
||||
// client still cannot self-authorize. Remote LAN browser sessions get no
|
||||
// `LocalTrusted` marker and are NOT granted here; companion/channel sessions
|
||||
// are granted on their own (service-direct) paths. What a granted session may
|
||||
// actually DO is still governed by the gateway's danger-tier × surface gate.
|
||||
if local.is_some() {
|
||||
grant_desktop_gateway(&mut req.extra);
|
||||
}
|
||||
let conversation = state.service.create(&user.id, req).await?;
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(conversation))))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Query(query): Query<ListConversationsQuery>,
|
||||
) -> Result<Json<ApiResponse<ConversationListResponse>>, AppError> {
|
||||
// 普通会话列表保留 companion 行(前端侧边栏自行过滤),不在此处排除。
|
||||
let result = state.service.list(&user.id, query, false).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
async fn clone(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
body: Result<Json<CloneConversationRequest>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<ConversationResponse>>), AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let conversation = state.service.clone_create(&user.id, req).await?;
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(conversation))))
|
||||
}
|
||||
|
||||
async fn get_one(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<ConversationResponse>>, AppError> {
|
||||
let conversation = state.service.get(&user.id, &id).await?;
|
||||
Ok(Json(ApiResponse::ok(conversation)))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<UpdateConversationRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<ConversationResponse>>, AppError> {
|
||||
let Json(mut req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
if let Some(extra) = req.extra.as_mut() {
|
||||
// `update` merges extra keys, so a client could otherwise smuggle the
|
||||
// gateway flag into an existing conversation.
|
||||
strip_desktop_gateway_flag(extra);
|
||||
}
|
||||
let conversation = state.service.update(&user.id, &id, req, &state.task_manager).await?;
|
||||
Ok(Json(ApiResponse::ok(conversation)))
|
||||
}
|
||||
|
||||
async fn delete_one(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.service.delete(&user.id, &id).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn reset(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.service.reset(&user.id, &id).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn associated(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Vec<ConversationResponse>>>, AppError> {
|
||||
let items = state.service.list_associated(&user.id, &id).await?;
|
||||
Ok(Json(ApiResponse::ok(items)))
|
||||
}
|
||||
|
||||
async fn list_msg(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<ListMessagesQuery>,
|
||||
) -> Result<Json<ApiResponse<MessageListResponse>>, AppError> {
|
||||
let result = state.service.list_messages(&user.id, &id, query).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct MessagePathParams {
|
||||
id: String,
|
||||
#[serde(rename = "messageId")]
|
||||
message_id: String,
|
||||
}
|
||||
|
||||
async fn get_msg(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(params): Path<MessagePathParams>,
|
||||
) -> Result<Json<ApiResponse<MessageResponse>>, AppError> {
|
||||
let result = state
|
||||
.service
|
||||
.get_message(&user.id, ¶ms.id, ¶ms.message_id)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
async fn edit_resubmit(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(params): Path<MessagePathParams>,
|
||||
body: Result<Json<SendMessageRequest>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<SendMessageResponse>>), AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let msg_id = state
|
||||
.service
|
||||
.edit_and_resubmit(&user.id, ¶ms.id, ¶ms.message_id, req, &state.task_manager)
|
||||
.await?;
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(ApiResponse::ok(SendMessageResponse { msg_id })),
|
||||
))
|
||||
}
|
||||
|
||||
async fn send_msg(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<SendMessageRequest>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<SendMessageResponse>>), AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let msg_id = state
|
||||
.service
|
||||
.send_message(&user.id, &id, req, &state.task_manager)
|
||||
.await?;
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(ApiResponse::ok(SendMessageResponse { msg_id })),
|
||||
))
|
||||
}
|
||||
|
||||
async fn steer(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<SendMessageRequest>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<SendMessageResponse>>), AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let msg_id = state
|
||||
.service
|
||||
.steer_message(&user.id, &id, req, &state.task_manager)
|
||||
.await?;
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(ApiResponse::ok(SendMessageResponse { msg_id })),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_artifacts(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<ConversationArtifactListResponse>>, AppError> {
|
||||
let result = state.service.list_artifacts(&user.id, &id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ArtifactPathParams {
|
||||
id: String,
|
||||
#[serde(rename = "artifactId")]
|
||||
artifact_id: String,
|
||||
}
|
||||
|
||||
async fn update_artifact(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(params): Path<ArtifactPathParams>,
|
||||
body: Result<Json<UpdateConversationArtifactRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<ConversationArtifactResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let artifact_id: i64 = params
|
||||
.artifact_id
|
||||
.parse()
|
||||
.map_err(|_| AppError::BadRequest(format!("invalid artifact id: {}", params.artifact_id)))?;
|
||||
let artifact = state
|
||||
.service
|
||||
.update_artifact(&user.id, ¶ms.id, artifact_id, req)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(artifact)))
|
||||
}
|
||||
|
||||
async fn cancel(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.service.cancel(&user.id, &id, &state.task_manager).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn warmup(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.service.warmup(&user.id, &id, &state.task_manager).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn search_messages(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Query(query): Query<SearchMessagesQuery>,
|
||||
) -> Result<Json<ApiResponse<MessageSearchResponse>>, AppError> {
|
||||
let result = state.service.search_messages(&user.id, query).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// ── Confirmation handlers ─────────────────────────────────────────
|
||||
|
||||
async fn list_confirmations(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<ConfirmationListResponse>>, AppError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_confirmations(&user.id, &id, &state.task_manager)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(items)))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ConfirmPathParams {
|
||||
id: String,
|
||||
#[serde(rename = "callId")]
|
||||
call_id: String,
|
||||
}
|
||||
|
||||
async fn confirm(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(params): Path<ConfirmPathParams>,
|
||||
body: Result<Json<ConfirmRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state
|
||||
.service
|
||||
.confirm(&user.id, ¶ms.id, ¶ms.call_id, req, &state.task_manager)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn check_approval(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<ApprovalCheckQuery>,
|
||||
) -> Result<Json<ApiResponse<ApprovalCheckResponse>>, AppError> {
|
||||
if query.action.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("action must not be empty".into()));
|
||||
}
|
||||
|
||||
let result = state
|
||||
.service
|
||||
.check_approval(
|
||||
&user.id,
|
||||
&id,
|
||||
&query.action,
|
||||
query.command_type.as_deref(),
|
||||
&state.task_manager,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
async fn active_count(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
) -> Result<Json<ApiResponse<ActiveCountResponse>>, AppError> {
|
||||
let count = state.task_manager.active_count();
|
||||
Ok(Json(ApiResponse::ok(ActiveCountResponse { count })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strip_desktop_gateway_flag;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn strips_both_spellings_of_the_gateway_flag() {
|
||||
let mut extra = json!({
|
||||
"desktopGateway": true,
|
||||
"desktop_gateway": true,
|
||||
"companionSession": true,
|
||||
"backend": "claude",
|
||||
});
|
||||
strip_desktop_gateway_flag(&mut extra);
|
||||
assert!(extra.get("desktopGateway").is_none());
|
||||
assert!(extra.get("desktop_gateway").is_none());
|
||||
// Unrelated keys survive.
|
||||
assert_eq!(extra["companionSession"], json!(true));
|
||||
assert_eq!(extra["backend"], json!("claude"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_is_a_noop_on_non_objects() {
|
||||
let mut extra = json!("not an object");
|
||||
strip_desktop_gateway_flag(&mut extra);
|
||||
assert_eq!(extra, json!("not an object"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use crate::state::ConversationRouterState;
|
||||
use axum::Router;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Extension, Json, Path, Query, State};
|
||||
use axum::routing::{get, post};
|
||||
use nomifun_api_types::{
|
||||
AgentModeResponse, ApiResponse, GetModelInfoResponse, SetModeRequest, SetModelRequest, SideQuestionRequest,
|
||||
SideQuestionResponse, SlashCommandItem, WorkspaceBrowseQuery, WorkspaceEntry,
|
||||
};
|
||||
use nomifun_auth::CurrentUser;
|
||||
use nomifun_common::AppError;
|
||||
|
||||
/// Build the conversation-ops router (no auth layer applied — the caller is
|
||||
/// responsible for wrapping this with the auth middleware).
|
||||
pub fn conversation_ops_routes(state: ConversationRouterState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/conversations/{id}/side-question", post(side_question))
|
||||
.route("/api/conversations/{id}/slash-commands", get(get_slash_commands))
|
||||
.route("/api/conversations/{id}/usage", get(get_usage))
|
||||
.route("/api/conversations/{id}/mode", get(get_mode).put(set_mode))
|
||||
.route("/api/conversations/{id}/model", get(get_model).put(set_model))
|
||||
.route("/api/conversations/{id}/openclaw/runtime", get(get_openclaw_runtime))
|
||||
.route("/api/conversations/{id}/workspace", get(browse_workspace))
|
||||
.route("/api/conversations/{id}/clear-context", post(clear_context))
|
||||
.route("/api/conversations/{id}/clear-messages", post(clear_messages))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ── Route handlers ─────────────────────────────────────────────────
|
||||
|
||||
async fn get_mode(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<AgentModeResponse>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.get_mode(&id).await?)))
|
||||
}
|
||||
|
||||
async fn set_mode(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<SetModeRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state.service.set_mode(&id, req).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
/// Clear a conversation's agent context (release model context) while keeping
|
||||
/// the visible message history. See [`ConversationService::clear_context`].
|
||||
async fn clear_context(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state
|
||||
.service
|
||||
.clear_context(&user.id, &id, &state.task_manager)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
/// Clear a conversation's **messages** (and artifacts) while keeping the
|
||||
/// conversation row — the work-partner「清空上下文」按钮。Does not reset
|
||||
/// status and never touches the companion memory store. See
|
||||
/// [`ConversationService::clear_messages`].
|
||||
async fn clear_messages(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state
|
||||
.service
|
||||
.clear_messages(&user.id, &id, &state.task_manager)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn get_model(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<GetModelInfoResponse>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.get_model(&id).await?)))
|
||||
}
|
||||
|
||||
async fn set_model(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<SetModelRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state.service.set_model(&id, req).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn get_usage(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Option<serde_json::Value>>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.get_usage(&id).await?)))
|
||||
}
|
||||
|
||||
async fn side_question(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SideQuestionRequest>,
|
||||
) -> Result<Json<ApiResponse<SideQuestionResponse>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(
|
||||
state.service.handle_side_question(&id, req).await?,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn get_slash_commands(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Vec<SlashCommandItem>>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.get_slash_commands(&id).await?)))
|
||||
}
|
||||
|
||||
async fn get_openclaw_runtime(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.get_openclaw_runtime(&id).await?)))
|
||||
}
|
||||
|
||||
async fn browse_workspace(
|
||||
State(state): State<ConversationRouterState>,
|
||||
Extension(_user): Extension<CurrentUser>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<WorkspaceBrowseQuery>,
|
||||
) -> Result<Json<ApiResponse<Vec<WorkspaceEntry>>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.browse_workspace(&id, query).await?)))
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex, Weak},
|
||||
};
|
||||
|
||||
use nomifun_api_types::{ConversationRuntimeStateKind, ConversationRuntimeSummary};
|
||||
use nomifun_common::{AppError, ConversationStatus, now_ms};
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ConversationRuntimeStateService {
|
||||
/// Conversations with a live turn claim, mapped to the wall-clock time
|
||||
/// (epoch ms) the claim was taken. The timestamp is surfaced in
|
||||
/// `ConversationRuntimeSummary::processing_started_at` so the frontend's
|
||||
/// elapsed-time indicator can anchor to the real turn start and survive
|
||||
/// view unmount/remount instead of restarting from zero.
|
||||
active_turns: Mutex<HashMap<String, i64>>,
|
||||
/// Per-conversation signature of the knowledge mounts the live agent task
|
||||
/// was last built with. The agent bakes the knowledge retrieval-protocol
|
||||
/// section at build time and is cached per conversation, so a binding
|
||||
/// toggled mid-session does not reach the already-running agent.
|
||||
/// `apply_knowledge_mounts` compares the freshly-resolved signature against
|
||||
/// this map to decide whether to recycle the cached task. In-memory only
|
||||
/// (cleared on restart), which is intentional: after a restart the task map
|
||||
/// is empty too, so the first build naturally carries the current mounts.
|
||||
knowledge_signatures: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TurnClaim {
|
||||
conversation_id: String,
|
||||
state: Weak<ConversationRuntimeStateService>,
|
||||
released: bool,
|
||||
}
|
||||
|
||||
impl ConversationRuntimeStateService {
|
||||
pub fn try_claim_turn(self: &Arc<Self>, conversation_id: &str) -> Result<TurnClaim, AppError> {
|
||||
let mut active_turns = self.active_turns.lock().map_err(|_| {
|
||||
warn!(
|
||||
conversation_id,
|
||||
"conversation runtime state lock poisoned while claiming turn"
|
||||
);
|
||||
AppError::Internal("conversation runtime state lock poisoned".into())
|
||||
})?;
|
||||
|
||||
if active_turns.contains_key(conversation_id) {
|
||||
info!(conversation_id, "conversation runtime turn claim rejected");
|
||||
return Err(AppError::Conflict(format!(
|
||||
"conversation {conversation_id} is already running"
|
||||
)));
|
||||
}
|
||||
|
||||
active_turns.insert(conversation_id.to_owned(), now_ms());
|
||||
|
||||
info!(conversation_id, "conversation runtime turn claimed");
|
||||
|
||||
Ok(TurnClaim {
|
||||
conversation_id: conversation_id.to_owned(),
|
||||
state: Arc::downgrade(self),
|
||||
released: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_claimed(&self, conversation_id: &str) -> bool {
|
||||
self.active_turns
|
||||
.lock()
|
||||
.map(|active_turns| active_turns.contains_key(conversation_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Wall-clock time (epoch ms) the live turn for `conversation_id` was
|
||||
/// claimed, if one is active. `None` when no turn is in flight.
|
||||
pub fn claimed_at(&self, conversation_id: &str) -> Option<i64> {
|
||||
self.active_turns
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|active_turns| active_turns.get(conversation_id).copied())
|
||||
}
|
||||
|
||||
/// The knowledge-mount signature the live agent for `conversation_id` was
|
||||
/// last built with, if recorded. `None` means no build has been observed
|
||||
/// (e.g. after a restart, or a conversation never started) — callers treat
|
||||
/// that as "no live agent to reconcile against".
|
||||
pub fn knowledge_signature(&self, conversation_id: &str) -> Option<String> {
|
||||
self.knowledge_signatures
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|sigs| sigs.get(conversation_id).cloned())
|
||||
}
|
||||
|
||||
/// Record the knowledge-mount signature the agent for `conversation_id` was
|
||||
/// (re)built with. Called right after `apply_knowledge_mounts` resolves the
|
||||
/// mounts for an upcoming build so the NEXT binding change is detectable.
|
||||
pub fn set_knowledge_signature(&self, conversation_id: &str, signature: String) {
|
||||
if let Ok(mut sigs) = self.knowledge_signatures.lock() {
|
||||
sigs.insert(conversation_id.to_owned(), signature);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a conversation's recorded knowledge signature (on delete) so the
|
||||
/// map does not grow unbounded across a long-lived process.
|
||||
pub fn clear_knowledge_signature(&self, conversation_id: &str) {
|
||||
if let Ok(mut sigs) = self.knowledge_signatures.lock() {
|
||||
sigs.remove(conversation_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn summary_from_parts(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
task_status: Option<ConversationStatus>,
|
||||
has_task: bool,
|
||||
pending_confirmations: usize,
|
||||
) -> ConversationRuntimeSummary {
|
||||
let claimed_at = self.claimed_at(conversation_id);
|
||||
let claimed = claimed_at.is_some();
|
||||
|
||||
let state = if pending_confirmations > 0 {
|
||||
ConversationRuntimeStateKind::WaitingConfirmation
|
||||
} else if claimed && task_status != Some(ConversationStatus::Running) {
|
||||
ConversationRuntimeStateKind::Starting
|
||||
} else if claimed || task_status == Some(ConversationStatus::Running) {
|
||||
ConversationRuntimeStateKind::Running
|
||||
} else {
|
||||
ConversationRuntimeStateKind::Idle
|
||||
};
|
||||
|
||||
let is_processing = state != ConversationRuntimeStateKind::Idle;
|
||||
|
||||
ConversationRuntimeSummary {
|
||||
state,
|
||||
can_send_message: !is_processing,
|
||||
has_task,
|
||||
task_status,
|
||||
is_processing,
|
||||
pending_confirmations,
|
||||
// Only surface a start time while actually processing. When the
|
||||
// turn is driven purely by a persisted Running status (no live
|
||||
// claim, e.g. an edge case after restart), `claimed_at` is None and
|
||||
// the frontend gracefully falls back to its local mount time.
|
||||
processing_started_at: if is_processing { claimed_at } else { None },
|
||||
}
|
||||
}
|
||||
|
||||
fn release(&self, conversation_id: &str) {
|
||||
match self.active_turns.lock() {
|
||||
Ok(mut active_turns) => {
|
||||
active_turns.remove(conversation_id);
|
||||
info!(conversation_id, "conversation runtime turn claim released");
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
conversation_id,
|
||||
"conversation runtime state lock poisoned while releasing turn"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnClaim {
|
||||
pub fn release(&mut self) {
|
||||
self.release_inner();
|
||||
}
|
||||
|
||||
fn release_inner(&mut self) {
|
||||
if self.released {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(state) = self.state.upgrade() {
|
||||
state.release(&self.conversation_id);
|
||||
}
|
||||
self.released = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TurnClaim {
|
||||
fn drop(&mut self) {
|
||||
self.release_inner();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn claim_rejects_second_active_turn() {
|
||||
let state = Arc::new(ConversationRuntimeStateService::default());
|
||||
let _claim = state.try_claim_turn("conv-1").expect("first claim should win");
|
||||
|
||||
let err = state.try_claim_turn("conv-1").expect_err("second claim should fail");
|
||||
assert!(err.to_string().contains("already running"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_releases_on_drop() {
|
||||
let state = Arc::new(ConversationRuntimeStateService::default());
|
||||
{
|
||||
let _claim = state.try_claim_turn("conv-1").expect("claim should be created");
|
||||
assert!(state.is_claimed("conv-1"));
|
||||
}
|
||||
|
||||
assert!(!state.is_claimed("conv-1"));
|
||||
assert!(state.try_claim_turn("conv-1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_uses_claim_as_starting_state() {
|
||||
let state = Arc::new(ConversationRuntimeStateService::default());
|
||||
let _claim = state.try_claim_turn("conv-1").expect("claim should be created");
|
||||
|
||||
let summary = state.summary_from_parts("conv-1", None, false, 0);
|
||||
|
||||
assert_eq!(summary.state, ConversationRuntimeStateKind::Starting);
|
||||
assert!(summary.is_processing);
|
||||
assert!(!summary.can_send_message);
|
||||
assert!(
|
||||
summary.processing_started_at.is_some(),
|
||||
"a claimed turn must expose its start time"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_exposes_claim_time_and_clears_when_idle() {
|
||||
let state = Arc::new(ConversationRuntimeStateService::default());
|
||||
|
||||
// Idle: no claim, no start time.
|
||||
let idle = state.summary_from_parts("conv-1", None, false, 0);
|
||||
assert_eq!(idle.state, ConversationRuntimeStateKind::Idle);
|
||||
assert!(!idle.is_processing);
|
||||
assert_eq!(idle.processing_started_at, None);
|
||||
|
||||
// Claimed: start time matches the recorded claim time.
|
||||
let claim = state.try_claim_turn("conv-1").expect("claim should be created");
|
||||
let expected = state.claimed_at("conv-1");
|
||||
assert!(expected.is_some());
|
||||
|
||||
let running = state.summary_from_parts("conv-1", None, false, 0);
|
||||
assert!(running.is_processing);
|
||||
assert_eq!(running.processing_started_at, expected);
|
||||
|
||||
// Released: back to idle, start time gone.
|
||||
drop(claim);
|
||||
let after = state.summary_from_parts("conv-1", None, false, 0);
|
||||
assert!(!after.is_processing);
|
||||
assert_eq!(after.processing_started_at, None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
//! Agent-session operations on ConversationService.
|
||||
//!
|
||||
//! These forward to the active AgentInstance (via `self.task(id)`) for
|
||||
//! mode/model/usage/slash-commands/side-question/openclaw-runtime queries,
|
||||
//! plus workspace browsing that needs the conversations.extra.workspace
|
||||
//! field.
|
||||
//!
|
||||
//! Kept in a separate file from service.rs to avoid pushing that file
|
||||
//! over 2000 lines.
|
||||
|
||||
use nomifun_api_types::{
|
||||
AgentModeResponse, GetModelInfoResponse, SetModeRequest, SetModelRequest, SideQuestionRequest,
|
||||
SideQuestionResponse, SlashCommandItem, WorkspaceBrowseQuery, WorkspaceEntry,
|
||||
};
|
||||
use nomifun_common::AppError;
|
||||
use nomifun_file::list_workspace_level;
|
||||
|
||||
use crate::service::{ConversationService, parse_conv_id};
|
||||
|
||||
impl ConversationService {
|
||||
// ── Mode ────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_mode(&self, conversation_id: &str) -> Result<AgentModeResponse, AppError> {
|
||||
self.task(conversation_id)?.get_mode().await
|
||||
}
|
||||
|
||||
pub async fn set_mode(&self, conversation_id: &str, req: SetModeRequest) -> Result<(), AppError> {
|
||||
if req.mode.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("mode must not be empty".into()));
|
||||
}
|
||||
self.task(conversation_id)?.set_mode(&req.mode).await
|
||||
}
|
||||
|
||||
// ── Model ───────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_model(&self, conversation_id: &str) -> Result<GetModelInfoResponse, AppError> {
|
||||
self.task(conversation_id)?.get_model().await
|
||||
}
|
||||
|
||||
pub async fn set_model(&self, conversation_id: &str, req: SetModelRequest) -> Result<(), AppError> {
|
||||
if req.model_id.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("model_id must not be empty".into()));
|
||||
}
|
||||
let task = match self.task(conversation_id) {
|
||||
Ok(task) => task,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
conversation_id,
|
||||
model_id = %req.model_id,
|
||||
error_code = err.error_code(),
|
||||
"Set model skipped because active agent task is unavailable"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
task.set_model(&req.model_id).await
|
||||
}
|
||||
|
||||
// ── Usage / Slash commands ──────────────────────────────────────
|
||||
|
||||
pub async fn get_usage(&self, conversation_id: &str) -> Result<Option<serde_json::Value>, AppError> {
|
||||
self.task(conversation_id)?.get_usage().await
|
||||
}
|
||||
|
||||
pub async fn get_slash_commands(&self, conversation_id: &str) -> Result<Vec<SlashCommandItem>, AppError> {
|
||||
self.task(conversation_id)?.get_slash_commands().await
|
||||
}
|
||||
|
||||
// ── Side question ───────────────────────────────────────────────
|
||||
|
||||
pub async fn handle_side_question(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
req: SideQuestionRequest,
|
||||
) -> Result<SideQuestionResponse, AppError> {
|
||||
// `AgentInstance::handle_side_question` already validates that the
|
||||
// question is non-empty; no need to duplicate the check here.
|
||||
self.task(conversation_id)?.handle_side_question(req).await
|
||||
}
|
||||
|
||||
// ── OpenClaw runtime diagnostics ────────────────────────────────
|
||||
|
||||
pub async fn get_openclaw_runtime(&self, conversation_id: &str) -> Result<serde_json::Value, AppError> {
|
||||
self.task(conversation_id)?.get_openclaw_runtime().await
|
||||
}
|
||||
|
||||
// ── Workspace browsing ──────────────────────────────────────────
|
||||
|
||||
/// Enumerate entries under `query.path` inside the conversation's
|
||||
/// workspace root. Resolves the root from the conversation's
|
||||
/// `extra.workspace` and delegates the path-scoped listing (isolation
|
||||
/// guards + depth cap) to [`nomifun_file::list_workspace_level`].
|
||||
pub async fn browse_workspace(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
query: WorkspaceBrowseQuery,
|
||||
) -> Result<Vec<WorkspaceEntry>, AppError> {
|
||||
if query.path.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("path must not be empty".into()));
|
||||
}
|
||||
|
||||
let row = self
|
||||
.conversation_repo()
|
||||
.get(parse_conv_id(conversation_id)?)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to load conversation: {e}")))?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Conversation '{conversation_id}' not found")))?;
|
||||
|
||||
let extra: serde_json::Value =
|
||||
serde_json::from_str(&row.extra).map_err(|e| AppError::Internal(format!("Invalid extra JSON: {e}")))?;
|
||||
let workspace = extra
|
||||
.get("workspace")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_owned();
|
||||
if workspace.is_empty() {
|
||||
return Err(AppError::BadRequest("Conversation has no workspace assigned".into()));
|
||||
}
|
||||
|
||||
list_workspace_level(
|
||||
std::path::Path::new(&workspace),
|
||||
&query.path,
|
||||
query.search.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+142
@@ -0,0 +1,142 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_evicts_acp_task_after_terminal_error() {
|
||||
let (svc, _broadcaster, _repo, _default_task_mgr) = make_service();
|
||||
let task_mgr = Arc::new(MockTaskManager::new());
|
||||
let conv = svc.create("user_1", make_create_req()).await.unwrap();
|
||||
|
||||
let scripted_agent = Arc::new(ScriptedAgent::new(
|
||||
&conv.id.to_string(),
|
||||
vec![vec![AgentStreamEvent::Error(ErrorEventData::legacy(
|
||||
"Agent completed the turn without producing visible output.",
|
||||
Some(AgentErrorCode::UnknownUpstreamError),
|
||||
))]],
|
||||
));
|
||||
task_mgr.insert_agent(&conv.id.to_string(), AgentInstance::Mock(scripted_agent));
|
||||
|
||||
let task_mgr_dyn: Arc<dyn IWorkerTaskManager> = task_mgr.clone();
|
||||
svc.send_message("user_1", &conv.id.to_string(), make_send_req(), &task_mgr_dyn)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if task_mgr.kill_count() == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("ACP terminal error should evict the cached task");
|
||||
wait_for_turn_released(&svc, &conv.id.to_string()).await;
|
||||
|
||||
assert_eq!(task_mgr.active_count(), 0);
|
||||
assert_eq!(
|
||||
task_mgr.kill_records(),
|
||||
vec![(conv.id.to_string(), Some(AgentKillReason::AgentErrorRecovery))]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_clears_persisted_acp_model_after_model_not_found() {
|
||||
let acp_session_repo = Arc::new(StubAcpSessionRepo::default());
|
||||
let (svc, _broadcaster, repo, _default_task_mgr) = make_service_with_resolver_and_acp_session_repo(
|
||||
Arc::new(FixedSkillResolver { names: vec![] }),
|
||||
acp_session_repo.clone(),
|
||||
);
|
||||
let task_mgr = Arc::new(MockTaskManager::new());
|
||||
let conv = svc.create("user_1", make_create_req()).await.unwrap();
|
||||
repo.update(
|
||||
conv.id,
|
||||
&ConversationRowUpdate {
|
||||
extra: Some(
|
||||
serde_json::to_string(&json!({
|
||||
"workspace": "/project",
|
||||
"current_model_id": "deepseek-v4-pro",
|
||||
}))
|
||||
.unwrap(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let scripted_agent = Arc::new(ScriptedAgent::new(
|
||||
&conv.id.to_string(),
|
||||
vec![vec![AgentStreamEvent::Error(ErrorEventData::legacy(
|
||||
"The configured model was not found by the provider.",
|
||||
Some(AgentErrorCode::UserLlmProviderModelNotFound),
|
||||
))]],
|
||||
));
|
||||
task_mgr.insert_agent(&conv.id.to_string(), AgentInstance::Mock(scripted_agent));
|
||||
|
||||
let task_mgr_dyn: Arc<dyn IWorkerTaskManager> = task_mgr.clone();
|
||||
svc.send_message("user_1", &conv.id.to_string(), make_send_req(), &task_mgr_dyn)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let saves = acp_session_repo.runtime_state_saves();
|
||||
if saves
|
||||
.iter()
|
||||
.any(|call| call.conversation_id == conv.id.to_string() && call.current_model_id == Some(None))
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("model_not_found should clear persisted ACP model");
|
||||
wait_for_turn_released(&svc, &conv.id.to_string()).await;
|
||||
|
||||
assert_eq!(task_mgr.active_count(), 0);
|
||||
assert_eq!(
|
||||
acp_session_repo.runtime_state_saves(),
|
||||
vec![RuntimeStateSaveCall {
|
||||
conversation_id: conv.id.to_string(),
|
||||
current_model_id: Some(None),
|
||||
}]
|
||||
);
|
||||
|
||||
let row = repo.get(conv.id).await.unwrap().unwrap();
|
||||
let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap();
|
||||
assert!(extra.get("workspace").is_some());
|
||||
assert!(
|
||||
extra.get("current_model_id").is_none(),
|
||||
"model_not_found must clear conversation.extra.current_model_id so rebuild cannot reseed stale desired model"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_does_not_clear_persisted_acp_model_for_other_terminal_errors() {
|
||||
let acp_session_repo = Arc::new(StubAcpSessionRepo::default());
|
||||
let (svc, _broadcaster, _repo, _default_task_mgr) = make_service_with_resolver_and_acp_session_repo(
|
||||
Arc::new(FixedSkillResolver { names: vec![] }),
|
||||
acp_session_repo.clone(),
|
||||
);
|
||||
let task_mgr = Arc::new(MockTaskManager::new());
|
||||
let conv = svc.create("user_1", make_create_req()).await.unwrap();
|
||||
|
||||
let scripted_agent = Arc::new(ScriptedAgent::new(
|
||||
&conv.id.to_string(),
|
||||
vec![vec![AgentStreamEvent::Error(ErrorEventData::legacy(
|
||||
"Unknown upstream error.",
|
||||
Some(AgentErrorCode::UnknownUpstreamError),
|
||||
))]],
|
||||
));
|
||||
task_mgr.insert_agent(&conv.id.to_string(), AgentInstance::Mock(scripted_agent));
|
||||
|
||||
let task_mgr_dyn: Arc<dyn IWorkerTaskManager> = task_mgr.clone();
|
||||
svc.send_message("user_1", &conv.id.to_string(), make_send_req(), &task_mgr_dyn)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_turn_released(&svc, &conv.id.to_string()).await;
|
||||
|
||||
assert_eq!(task_mgr.active_count(), 0);
|
||||
assert!(acp_session_repo.runtime_state_saves().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Abstraction over "what are the auto-inject skill names right now?" so
|
||||
//! `ConversationService` can compute the initial snapshot without forcing
|
||||
//! every test setup to stand up a real `SkillPaths`.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
pub use nomifun_extension::ResolvedAgentSkill;
|
||||
|
||||
#[async_trait]
|
||||
pub trait SkillResolver: Send + Sync {
|
||||
/// Returns the sorted list of auto-inject builtin skill names currently
|
||||
/// available on this installation.
|
||||
async fn auto_inject_names(&self) -> Vec<String>;
|
||||
|
||||
/// Resolve each skill name to its on-disk source directory, using the
|
||||
/// same search order as `materialize_skills_for_agent`.
|
||||
async fn resolve_skills(&self, names: &[String]) -> Vec<ResolvedAgentSkill>;
|
||||
|
||||
/// Create symlinks pointing at each resolved skill inside the given
|
||||
/// workspace's per-backend native skills directories. `rel_dirs` is
|
||||
/// the list of relative paths (e.g. `.claude/skills`) to populate.
|
||||
/// Returns the number of symlinks successfully created.
|
||||
async fn link_workspace_skills(&self, workspace: &Path, rel_dirs: &[&str], skills: &[ResolvedAgentSkill]) -> usize;
|
||||
}
|
||||
|
||||
/// Production adapter backed by `nomifun_extension::skill_service`.
|
||||
pub struct ExtensionSkillResolver {
|
||||
paths: Arc<nomifun_extension::SkillPaths>,
|
||||
}
|
||||
|
||||
impl ExtensionSkillResolver {
|
||||
pub fn new(paths: Arc<nomifun_extension::SkillPaths>) -> Self {
|
||||
Self { paths }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SkillResolver for ExtensionSkillResolver {
|
||||
async fn auto_inject_names(&self) -> Vec<String> {
|
||||
match nomifun_extension::list_builtin_auto_skills(&self.paths).await {
|
||||
Ok(items) => {
|
||||
let mut names: Vec<String> = items.into_iter().map(|i| i.name).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"auto_inject_names: list_builtin_auto_skills failed, falling back to empty"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_skills(&self, names: &[String]) -> Vec<ResolvedAgentSkill> {
|
||||
if names.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Conversation_id is validated upstream; we don't use a real one here
|
||||
// because this resolver is purely a path-resolution helper.
|
||||
match nomifun_extension::materialize_skills_for_agent(&self.paths, "workspace-link", names).await {
|
||||
Ok(list) => list,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"resolve_skills failed; returning empty list"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn link_workspace_skills(&self, workspace: &Path, rel_dirs: &[&str], skills: &[ResolvedAgentSkill]) -> usize {
|
||||
if rel_dirs.is_empty() || skills.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
match nomifun_extension::link_workspace_skills(workspace, rel_dirs, skills).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
workspace = %workspace.display(),
|
||||
error = %e,
|
||||
"link_workspace_skills failed"
|
||||
);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub struct FixedSkillResolver {
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[async_trait]
|
||||
impl SkillResolver for FixedSkillResolver {
|
||||
async fn auto_inject_names(&self) -> Vec<String> {
|
||||
self.names.clone()
|
||||
}
|
||||
|
||||
async fn resolve_skills(&self, _names: &[String]) -> Vec<ResolvedAgentSkill> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn link_workspace_skills(
|
||||
&self,
|
||||
_workspace: &Path,
|
||||
_rel_dirs: &[&str],
|
||||
_skills: &[ResolvedAgentSkill],
|
||||
) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Pure helpers that compute `conversation.extra.skills` values. No I/O here
|
||||
//! — callers (e.g. `ConversationService::create`) fetch the auto-inject name
|
||||
//! set up-front and pass it in. This keeps unit tests deterministic and keeps
|
||||
//! `nomifun-conversation` from taking a hard dep on `nomifun-extension` beyond
|
||||
//! the `SkillResolver` trait.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Compute the initial `skills` snapshot for a brand-new conversation.
|
||||
///
|
||||
/// Formula: `(auto_inject − exclude_auto_inject) ∪ preset_enabled`,
|
||||
/// sorted ascending, deduplicated.
|
||||
pub fn compute_initial_skills(
|
||||
auto_inject: &[String],
|
||||
preset_enabled: &[String],
|
||||
exclude_auto_inject: &[String],
|
||||
) -> Vec<String> {
|
||||
let excluded: std::collections::HashSet<&String> = exclude_auto_inject.iter().collect();
|
||||
let mut out: std::collections::BTreeSet<String> =
|
||||
auto_inject.iter().filter(|n| !excluded.contains(n)).cloned().collect();
|
||||
for name in preset_enabled {
|
||||
out.insert(name.clone());
|
||||
}
|
||||
out.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Mutate `extra` in place to add a `skills` array derived from legacy
|
||||
/// fields if absent. Returns `true` when a mutation happened (caller
|
||||
/// persists the row). Strips the legacy fields whether or not `skills`
|
||||
/// was already present, so a single pass cleans up partial rows too.
|
||||
///
|
||||
/// Legacy formula: `(auto_inject_now − extra.exclude_builtin_skills) ∪
|
||||
/// extra.enabled_skills`.
|
||||
pub fn backfill_skills_if_missing(extra: &mut Value, auto_inject_now: &[String]) -> bool {
|
||||
let Some(obj) = extra.as_object_mut() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let legacy_enabled = take_string_array(obj, "enabled_skills");
|
||||
let legacy_excluded = take_string_array(obj, "exclude_builtin_skills");
|
||||
let legacy_loaded = obj.remove("loaded_skills");
|
||||
let had_legacy = !legacy_enabled.is_empty() || !legacy_excluded.is_empty() || legacy_loaded.is_some();
|
||||
|
||||
let needs_compute = !obj.contains_key("skills");
|
||||
if needs_compute {
|
||||
let computed = compute_initial_skills(auto_inject_now, &legacy_enabled, &legacy_excluded);
|
||||
obj.insert(
|
||||
"skills".to_owned(),
|
||||
Value::Array(computed.into_iter().map(Value::String).collect()),
|
||||
);
|
||||
}
|
||||
|
||||
needs_compute || had_legacy
|
||||
}
|
||||
|
||||
fn take_string_array(obj: &mut serde_json::Map<String, Value>, key: &str) -> Vec<String> {
|
||||
obj.remove(key)
|
||||
.and_then(|v| serde_json::from_value::<Vec<String>>(v).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn compute_initial_union_dedup_sort() {
|
||||
let skills = compute_initial_skills(
|
||||
&["cron".into(), "todo-tracker".into()],
|
||||
&["pdf".into(), "cron".into()],
|
||||
&[],
|
||||
);
|
||||
assert_eq!(skills, vec!["cron", "pdf", "todo-tracker"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_initial_applies_exclude() {
|
||||
let skills = compute_initial_skills(&["cron".into(), "todo-tracker".into()], &[], &["cron".into()]);
|
||||
assert_eq!(skills, vec!["todo-tracker"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_initial_exclude_does_not_affect_preset_opt_in() {
|
||||
// User excluded cron from auto-inject, but the preset still added it
|
||||
// explicitly — preset wins.
|
||||
let skills = compute_initial_skills(&["cron".into()], &["cron".into()], &["cron".into()]);
|
||||
assert_eq!(skills, vec!["cron"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_writes_skills_and_strips_legacy() {
|
||||
let mut extra = json!({
|
||||
"workspace": "/tmp/foo",
|
||||
"enabled_skills": ["pdf"],
|
||||
"exclude_builtin_skills": ["cron"],
|
||||
"loaded_skills": [{"name": "cron", "description": "old cache"}],
|
||||
});
|
||||
let mutated = backfill_skills_if_missing(&mut extra, &["cron".into(), "todo-tracker".into()]);
|
||||
assert!(mutated);
|
||||
assert_eq!(extra["skills"], json!(["pdf", "todo-tracker"]));
|
||||
assert!(extra.get("enabled_skills").is_none());
|
||||
assert!(extra.get("exclude_builtin_skills").is_none());
|
||||
assert!(extra.get("loaded_skills").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_noop_when_skills_present_and_no_legacy() {
|
||||
let mut extra = json!({
|
||||
"skills": ["cron"],
|
||||
"workspace": "/tmp/foo",
|
||||
});
|
||||
let mutated = backfill_skills_if_missing(&mut extra, &["cron".into()]);
|
||||
assert!(!mutated);
|
||||
assert_eq!(extra["skills"], json!(["cron"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_strips_legacy_even_when_skills_already_present() {
|
||||
let mut extra = json!({
|
||||
"skills": ["cron"],
|
||||
"loaded_skills": [{"name": "cron", "description": "stale"}],
|
||||
});
|
||||
let mutated = backfill_skills_if_missing(&mut extra, &[]);
|
||||
assert!(mutated);
|
||||
assert!(extra.get("loaded_skills").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_ignores_non_object_extra() {
|
||||
let mut extra = json!(null);
|
||||
let mutated = backfill_skills_if_missing(&mut extra, &["cron".into()]);
|
||||
assert!(!mutated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::service::ConversationService;
|
||||
use nomifun_ai_agent::IWorkerTaskManager;
|
||||
|
||||
/// Shared state for conversation route handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct ConversationRouterState {
|
||||
pub service: ConversationService,
|
||||
pub task_manager: Arc<dyn IWorkerTaskManager>,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
//! Shared helpers for translating a [`ConversationRow`] into the inputs
|
||||
//! agent factories expect.
|
||||
//!
|
||||
//! The two execution entry points — interactive `send_message` and the
|
||||
//! cron executor — must derive the same `(provider_id, model)` for a
|
||||
//! given conversation; otherwise an nomi job that runs fine
|
||||
//! interactively can fail under cron with `Provider '<vendor>' not
|
||||
//! found` (Sentry ELECTRON-1HM). Centralising the lookup here forces
|
||||
//! both paths through one parser.
|
||||
//!
|
||||
//! The parser intentionally accepts both the canonical `ProviderWithModel`
|
||||
//! shape and a few legacy variants (camelCase keys, `id` instead of
|
||||
//! `provider_id`). When the row holds an unparseable or missing model,
|
||||
//! we return an empty `ProviderWithModel`; non-nomi factory branches
|
||||
//! ignore the field, and the nomi branch surfaces a clear "provider
|
||||
//! not found" error against an empty id rather than a stale vendor
|
||||
//! label.
|
||||
|
||||
use nomifun_common::ProviderWithModel;
|
||||
use nomifun_db::models::ConversationRow;
|
||||
|
||||
/// Resolve a conversation row's stored model into a [`ProviderWithModel`].
|
||||
///
|
||||
/// Returns an empty `ProviderWithModel { provider_id: "", model: "", use_model: None }`
|
||||
/// when the row's `model` column is `NULL` or unparseable. This matches the
|
||||
/// legacy behaviour of `ConversationService::build_task_options` and is the
|
||||
/// canonical "no model selected" sentinel consumed by agent factories.
|
||||
pub fn provider_model_from_conversation_row(row: &ConversationRow) -> ProviderWithModel {
|
||||
row.model
|
||||
.as_deref()
|
||||
.and_then(parse_provider_with_model_loose)
|
||||
.unwrap_or_else(empty_provider_model)
|
||||
}
|
||||
|
||||
/// Canonical sentinel `ProviderWithModel` used when a conversation row has
|
||||
/// no parseable model. Shared by both the interactive `send_message` path
|
||||
/// and the cron executor so they agree on the "no model selected" shape:
|
||||
/// `provider_id: ""`, `model: ""`, `use_model: None`. Non-nomi factories
|
||||
/// ignore the field, while the nomi factory surfaces a clear "Provider
|
||||
/// '' not found" error against the empty id rather than silently using a
|
||||
/// stale vendor label.
|
||||
pub fn empty_provider_model() -> ProviderWithModel {
|
||||
ProviderWithModel {
|
||||
provider_id: String::new(),
|
||||
model: String::new(),
|
||||
use_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Permissive parser for `conversation.model` JSON.
|
||||
///
|
||||
/// Tries strict serde first, then falls back to manual extraction so older
|
||||
/// shapes (camelCase, `id` instead of `provider_id`) keep working. Returns
|
||||
/// `None` when no `provider_id` can be extracted; callers treat that as
|
||||
/// "no model selected".
|
||||
fn parse_provider_with_model_loose(raw: &str) -> Option<ProviderWithModel> {
|
||||
if let Ok(model) = serde_json::from_str::<ProviderWithModel>(raw) {
|
||||
return Some(model);
|
||||
}
|
||||
|
||||
let value = serde_json::from_str::<serde_json::Value>(raw).ok()?;
|
||||
let provider_id = value
|
||||
.get("provider_id")
|
||||
.or_else(|| value.get("providerId"))
|
||||
.or_else(|| value.get("id"))
|
||||
.and_then(|item| item.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
|
||||
if provider_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let model = value
|
||||
.get("model")
|
||||
.and_then(|item| item.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let use_model = value
|
||||
.get("use_model")
|
||||
.or_else(|| value.get("useModel"))
|
||||
.and_then(|item| item.as_str())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
Some(ProviderWithModel {
|
||||
provider_id,
|
||||
model,
|
||||
use_model,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn row_with_model(model: Option<&str>) -> ConversationRow {
|
||||
ConversationRow {
|
||||
id: 1,
|
||||
user_id: "user-1".into(),
|
||||
name: "test".into(),
|
||||
r#type: "nomi".into(),
|
||||
model: model.map(ToOwned::to_owned),
|
||||
extra: "{}".into(),
|
||||
status: None,
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
pinned: false,
|
||||
pinned_at: None,
|
||||
cron_job_id: None,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_canonical_shape() {
|
||||
let json = r#"{"provider_id":"abc123","model":"gpt-5","use_model":"gpt-5-turbo"}"#;
|
||||
let row = row_with_model(Some(json));
|
||||
let m = provider_model_from_conversation_row(&row);
|
||||
assert_eq!(m.provider_id, "abc123");
|
||||
assert_eq!(m.model, "gpt-5");
|
||||
assert_eq!(m.use_model.as_deref(), Some("gpt-5-turbo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_camelcase_legacy_shape() {
|
||||
let json = r#"{"providerId":"abc123","model":"gpt-5","useModel":"gpt-5-turbo"}"#;
|
||||
let row = row_with_model(Some(json));
|
||||
let m = provider_model_from_conversation_row(&row);
|
||||
assert_eq!(m.provider_id, "abc123");
|
||||
assert_eq!(m.model, "gpt-5");
|
||||
assert_eq!(m.use_model.as_deref(), Some("gpt-5-turbo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_id_alias() {
|
||||
let json = r#"{"id":"abc123","model":"gpt-5"}"#;
|
||||
let row = row_with_model(Some(json));
|
||||
let m = provider_model_from_conversation_row(&row);
|
||||
assert_eq!(m.provider_id, "abc123");
|
||||
assert_eq!(m.model, "gpt-5");
|
||||
assert!(m.use_model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_provider_model_returns_documented_sentinel() {
|
||||
let m = empty_provider_model();
|
||||
assert!(m.provider_id.is_empty());
|
||||
assert!(m.model.is_empty());
|
||||
assert!(m.use_model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_model_returns_empty_sentinel() {
|
||||
let row = row_with_model(None);
|
||||
let m = provider_model_from_conversation_row(&row);
|
||||
assert!(m.provider_id.is_empty());
|
||||
assert!(m.model.is_empty());
|
||||
assert!(m.use_model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_returns_empty_sentinel() {
|
||||
let row = row_with_model(Some("not-json"));
|
||||
let m = provider_model_from_conversation_row(&row);
|
||||
assert!(m.provider_id.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_provider_id_returns_empty_sentinel() {
|
||||
let json = r#"{"model":"gpt-5"}"#;
|
||||
let row = row_with_model(Some(json));
|
||||
let m = provider_model_from_conversation_row(&row);
|
||||
assert!(m.provider_id.is_empty());
|
||||
}
|
||||
|
||||
/// Regression: the interactive `send_message` path and the cron
|
||||
/// executor must derive the same `(provider_id, model)` for a given
|
||||
/// conversation. Before this helper existed, cron read
|
||||
/// `agent_config.backend` (which fell back to the literal vendor
|
||||
/// label `"nomi"` when the conversation's model JSON was an older
|
||||
/// shape) and `send_message` parsed the row directly, so the cron
|
||||
/// path would emit `Provider 'nomi' not found` while the
|
||||
/// interactive path used the real provider hash. Now both paths
|
||||
/// route through `provider_model_from_conversation_row` and must
|
||||
/// agree on every row shape we accept.
|
||||
#[test]
|
||||
fn interactive_and_cron_paths_agree_on_provider_id() {
|
||||
// Canonical shape (what `build_task_options` previously parsed strictly).
|
||||
let canonical = r#"{"provider_id":"hash-abc","model":"gpt-5","use_model":null}"#;
|
||||
// Legacy camelCase shape (what cron's loose parser previously
|
||||
// accepted but `build_task_options`'s strict parser rejected).
|
||||
let legacy = r#"{"providerId":"hash-abc","model":"gpt-5"}"#;
|
||||
|
||||
let canonical_row = row_with_model(Some(canonical));
|
||||
let legacy_row = row_with_model(Some(legacy));
|
||||
|
||||
let canonical_resolved = provider_model_from_conversation_row(&canonical_row);
|
||||
let legacy_resolved = provider_model_from_conversation_row(&legacy_row);
|
||||
|
||||
// Both shapes must resolve to the same provider hash so the cron
|
||||
// executor and interactive `send_message` can never diverge.
|
||||
assert_eq!(canonical_resolved.provider_id, "hash-abc");
|
||||
assert_eq!(legacy_resolved.provider_id, "hash-abc");
|
||||
assert_eq!(canonical_resolved.provider_id, legacy_resolved.provider_id);
|
||||
// The vendor-label fallback must not leak in.
|
||||
assert_ne!(canonical_resolved.provider_id, "nomi");
|
||||
assert_ne!(legacy_resolved.provider_id, "nomi");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_ai_agent::IWorkerTaskManager;
|
||||
use nomifun_api_types::{
|
||||
CreateConversationRequest, ListConversationsQuery, UpdateConversationRequest, WebSocketMessage,
|
||||
};
|
||||
use nomifun_common::{AgentKillReason, AgentType, AppError, ConversationSource, ConversationStatus, TimestampMs};
|
||||
use nomifun_conversation::ConversationService;
|
||||
use nomifun_conversation::skill_resolver::SkillResolver;
|
||||
use nomifun_db::{SqliteConversationRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use serde_json::json;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// ── Test infrastructure ────────────────────────────────────────────
|
||||
|
||||
struct TestBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl TestBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
|
||||
std::mem::take(&mut self.events.lock().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for TestBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopTaskManager;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IWorkerTaskManager for NoopTaskManager {
|
||||
fn get_task(&self, _: &str) -> Option<nomifun_ai_agent::AgentInstance> {
|
||||
None
|
||||
}
|
||||
async fn get_or_build_task(
|
||||
&self,
|
||||
_: &str,
|
||||
_: nomifun_ai_agent::types::BuildTaskOptions,
|
||||
) -> Result<nomifun_ai_agent::AgentInstance, AppError> {
|
||||
Err(AppError::Internal("noop".into()))
|
||||
}
|
||||
fn kill(&self, _: &str, _: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
fn kill_and_wait(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<AgentKillReason>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
Box::pin(std::future::ready(()))
|
||||
}
|
||||
fn clear(&self) {}
|
||||
fn active_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn collect_idle(&self, _: TimestampMs) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptySkillResolver;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SkillResolver for EmptySkillResolver {
|
||||
async fn auto_inject_names(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn resolve_skills(&self, _names: &[String]) -> Vec<nomifun_extension::ResolvedAgentSkill> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn link_workspace_skills(
|
||||
&self,
|
||||
_workspace: &std::path::Path,
|
||||
_rel_dirs: &[&str],
|
||||
_skills: &[nomifun_extension::ResolvedAgentSkill],
|
||||
) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> (ConversationService, Arc<TestBroadcaster>, Arc<dyn IWorkerTaskManager>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let agent_metadata_repo: Arc<dyn nomifun_db::IAgentMetadataRepository> =
|
||||
Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone()));
|
||||
let acp_session_repo: Arc<dyn nomifun_db::IAcpSessionRepository> =
|
||||
Arc::new(nomifun_db::SqliteAcpSessionRepository::new(db.pool().clone()));
|
||||
let task_mgr: Arc<dyn IWorkerTaskManager> = Arc::new(NoopTaskManager);
|
||||
let svc = ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
broadcaster.clone(),
|
||||
Arc::new(EmptySkillResolver),
|
||||
task_mgr.clone(),
|
||||
repo,
|
||||
agent_metadata_repo,
|
||||
acp_session_repo,
|
||||
);
|
||||
(svc, broadcaster, task_mgr)
|
||||
}
|
||||
|
||||
const USER_ID: &str = "system_default_user";
|
||||
|
||||
fn make_create_req() -> CreateConversationRequest {
|
||||
serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": { "workspace": "/home/user/project" }
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ── T1: Create conversation ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_1_create_with_defaults() {
|
||||
let (svc, broadcaster, _task_mgr) = setup().await;
|
||||
|
||||
let resp = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
assert!(resp.id > 0);
|
||||
assert_eq!(resp.r#type, AgentType::Acp);
|
||||
assert_eq!(resp.status, ConversationStatus::Pending);
|
||||
assert_eq!(resp.source, Some(ConversationSource::Nomifun));
|
||||
assert!(!resp.pinned);
|
||||
assert!(resp.pinned_at.is_none());
|
||||
assert_eq!(resp.extra["workspace"], "/home/user/project");
|
||||
assert!(resp.created_at > 0);
|
||||
assert_eq!(resp.created_at, resp.modified_at);
|
||||
|
||||
// Non-nomi: top-level model is None.
|
||||
assert!(resp.model.is_none(), "ACP response should not carry top-level model");
|
||||
|
||||
// WebSocket event
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "conversation.listChanged");
|
||||
assert_eq!(events[0].data["action"], "created");
|
||||
assert_eq!(events[0].data["conversation_id"], resp.id);
|
||||
assert_eq!(events[0].data["source"], "nomifun");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_2_create_each_agent_type() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let types = vec![
|
||||
("acp", AgentType::Acp),
|
||||
("openclaw-gateway", AgentType::OpenclawGateway),
|
||||
("nanobot", AgentType::Nanobot),
|
||||
("remote", AgentType::Remote),
|
||||
("nomi", AgentType::Nomi),
|
||||
];
|
||||
|
||||
for (type_str, expected_type) in types {
|
||||
let body = if type_str == "nomi" {
|
||||
json!({
|
||||
"type": type_str,
|
||||
"model": { "provider_id": "p1", "model": "m1" },
|
||||
"extra": {}
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"type": type_str,
|
||||
"extra": {}
|
||||
})
|
||||
};
|
||||
let req: CreateConversationRequest = serde_json::from_value(body).unwrap();
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
assert_eq!(resp.r#type, expected_type, "Type mismatch for {type_str}");
|
||||
if type_str == "nomi" {
|
||||
assert!(resp.model.is_some(), "nomi should keep top-level model");
|
||||
} else {
|
||||
assert!(resp.model.is_none(), "{type_str} should have no top-level model");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_3_create_with_optional_fields() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"name": "Custom Name",
|
||||
"source": "telegram",
|
||||
"channel_chat_id": "user:123",
|
||||
"extra": { "workspace": "/path" }
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.name, "Custom Name");
|
||||
assert_eq!(resp.source, Some(ConversationSource::Telegram));
|
||||
assert_eq!(resp.channel_chat_id.as_deref(), Some("user:123"));
|
||||
}
|
||||
|
||||
// ── T2: List conversations ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_list_empty() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let result = svc.list(USER_ID, ListConversationsQuery::default(), false).await.unwrap();
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total, 0);
|
||||
assert!(!result.has_more);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_2_list_basic() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
for _ in 0..3 {
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
}
|
||||
|
||||
let result = svc.list(USER_ID, ListConversationsQuery::default(), false).await.unwrap();
|
||||
assert_eq!(result.items.len(), 3);
|
||||
assert_eq!(result.total, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_3_cursor_pagination() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
for _ in 0..5 {
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
}
|
||||
|
||||
// First page: limit=2
|
||||
let query = ListConversationsQuery {
|
||||
limit: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let page1 = svc.list(USER_ID, query, false).await.unwrap();
|
||||
assert_eq!(page1.items.len(), 2);
|
||||
assert!(page1.has_more);
|
||||
assert_eq!(page1.total, 5);
|
||||
|
||||
// Second page: cursor = last ID from page 1
|
||||
let cursor = page1.items.last().unwrap().id;
|
||||
let query2 = ListConversationsQuery {
|
||||
cursor: Some(cursor.to_string()),
|
||||
limit: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let page2 = svc.list(USER_ID, query2, false).await.unwrap();
|
||||
assert_eq!(page2.items.len(), 2);
|
||||
assert!(page2.has_more);
|
||||
|
||||
// Third page
|
||||
let cursor2 = page2.items.last().unwrap().id;
|
||||
let query3 = ListConversationsQuery {
|
||||
cursor: Some(cursor2.to_string()),
|
||||
limit: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let page3 = svc.list(USER_ID, query3, false).await.unwrap();
|
||||
assert_eq!(page3.items.len(), 1);
|
||||
assert!(!page3.has_more);
|
||||
|
||||
// No overlap between pages
|
||||
let all_ids: Vec<i64> = page1
|
||||
.items
|
||||
.iter()
|
||||
.chain(page2.items.iter())
|
||||
.chain(page3.items.iter())
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let unique: std::collections::HashSet<&i64> = all_ids.iter().collect();
|
||||
assert_eq!(all_ids.len(), unique.len());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_4_source_filter() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
// 2 nomifun + 1 telegram
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let telegram_req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"source": "telegram",
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
svc.create(USER_ID, telegram_req).await.unwrap();
|
||||
|
||||
let query = ListConversationsQuery {
|
||||
source: Some("telegram".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = svc.list(USER_ID, query, false).await.unwrap();
|
||||
assert_eq!(result.items.len(), 1);
|
||||
assert_eq!(result.items[0].source, Some(ConversationSource::Telegram));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_5_pinned_filter() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
// Pin one
|
||||
let pin_req: UpdateConversationRequest = serde_json::from_value(json!({ "pinned": true })).unwrap();
|
||||
svc.update(USER_ID, &conv.id.to_string(), pin_req, &task_mgr).await.unwrap();
|
||||
|
||||
let query = ListConversationsQuery {
|
||||
pinned: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let result = svc.list(USER_ID, query, false).await.unwrap();
|
||||
assert_eq!(result.items.len(), 1);
|
||||
assert!(result.items[0].pinned);
|
||||
}
|
||||
|
||||
// ── T3: Get single conversation ────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_1_get_existing() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let created = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let fetched = svc.get(USER_ID, &created.id.to_string()).await.unwrap();
|
||||
assert_eq!(fetched.id, created.id);
|
||||
assert_eq!(fetched.r#type, created.r#type);
|
||||
assert_eq!(fetched.name, created.name);
|
||||
assert_eq!(fetched.status, created.status);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_2_get_not_found() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let err = svc.get(USER_ID, "non-existent-uuid").await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T4: Update conversation ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_1_update_name() {
|
||||
let (svc, broadcaster, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
broadcaster.take_events();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({ "name": "New Name" })).unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
|
||||
assert_eq!(updated.name, "New Name");
|
||||
assert!(updated.modified_at >= conv.modified_at);
|
||||
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].data["action"], "updated");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_2_pin_conversation() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({ "pinned": true })).unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
|
||||
assert!(updated.pinned);
|
||||
assert!(updated.pinned_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_3_unpin_clears_pinned_at() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
// Pin
|
||||
let pin: UpdateConversationRequest = serde_json::from_value(json!({ "pinned": true })).unwrap();
|
||||
let pinned = svc.update(USER_ID, &conv.id.to_string(), pin, &task_mgr).await.unwrap();
|
||||
assert!(pinned.pinned_at.is_some());
|
||||
|
||||
// Unpin
|
||||
let unpin: UpdateConversationRequest = serde_json::from_value(json!({ "pinned": false })).unwrap();
|
||||
let unpinned = svc.update(USER_ID, &conv.id.to_string(), unpin, &task_mgr).await.unwrap();
|
||||
assert!(!unpinned.pinned);
|
||||
assert!(unpinned.pinned_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_4_extra_merge_preserves_existing_keys() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": { "workspace": "/old", "contextFileName": "ctx.md" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
// Update only workspace
|
||||
let update_req: UpdateConversationRequest =
|
||||
serde_json::from_value(json!({ "extra": { "workspace": "/new" } })).unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), update_req, &task_mgr).await.unwrap();
|
||||
|
||||
assert_eq!(updated.extra["workspace"], "/new");
|
||||
assert_eq!(updated.extra["contextFileName"], "ctx.md");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_5_update_model() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
|
||||
// Top-level model updates are only valid on nomi conversations
|
||||
// (Task 8 enforces the nomi-only rule in update).
|
||||
let create_req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "m1" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, create_req).await.unwrap();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"model": { "provider_id": "p2", "model": "new-model" }
|
||||
}))
|
||||
.unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
|
||||
let model = updated.model.unwrap();
|
||||
assert_eq!(model.provider_id, "p2");
|
||||
assert_eq!(model.model, "new-model");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_6_update_not_found() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({ "name": "x" })).unwrap();
|
||||
let err = svc.update(USER_ID, "non-existent", req, &task_mgr).await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T5: Delete conversation ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_1_delete_conversation() {
|
||||
let (svc, broadcaster, _task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
broadcaster.take_events();
|
||||
|
||||
svc.delete(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
|
||||
// Verify gone
|
||||
let err = svc.get(USER_ID, &conv.id.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
|
||||
// Verify broadcast
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].data["action"], "deleted");
|
||||
assert_eq!(events[0].data["conversation_id"], conv.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_2_delete_then_get_returns_404() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
svc.delete(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
let err = svc.get(USER_ID, &conv.id.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_3_delete_not_found() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let err = svc.delete(USER_ID, "non-existent").await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T11: WebSocket event verification ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t11_1_create_broadcasts_created() {
|
||||
let (svc, broadcaster, _task_mgr) = setup().await;
|
||||
let resp = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "conversation.listChanged");
|
||||
assert_eq!(events[0].data["action"], "created");
|
||||
assert_eq!(events[0].data["conversation_id"], resp.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t11_2_update_broadcasts_updated() {
|
||||
let (svc, broadcaster, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
broadcaster.take_events();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({ "name": "x" })).unwrap();
|
||||
svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events[0].data["action"], "updated");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t11_3_delete_broadcasts_deleted() {
|
||||
let (svc, broadcaster, _task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
broadcaster.take_events();
|
||||
|
||||
svc.delete(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events[0].data["action"], "deleted");
|
||||
}
|
||||
|
||||
// ── T12: Boundary scenarios ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_1_long_name() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let long_name = "x".repeat(1000);
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"name": long_name,
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
assert_eq!(resp.name.len(), 1000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_large_extra_json() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let large_extra = json!({
|
||||
"workspace": "/project",
|
||||
"nested": {
|
||||
"deep": {
|
||||
"array": [1, 2, 3, 4, 5],
|
||||
"object": { "key": "value" }
|
||||
}
|
||||
},
|
||||
"list": (0..100).collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": large_extra
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.extra["workspace"], "/project");
|
||||
assert_eq!(resp.extra["nested"]["deep"]["array"][2], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_3_concurrent_creates() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let mut handles = vec![];
|
||||
for _ in 0..10 {
|
||||
let svc = svc.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap()
|
||||
}));
|
||||
}
|
||||
|
||||
let mut ids = vec![];
|
||||
for handle in handles {
|
||||
let resp = handle.await.unwrap();
|
||||
ids.push(resp.id);
|
||||
}
|
||||
|
||||
// All IDs unique
|
||||
let unique: std::collections::HashSet<&i64> = ids.iter().collect();
|
||||
assert_eq!(ids.len(), unique.len());
|
||||
}
|
||||
|
||||
// ── Full lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_lifecycle_create_get_update_delete() {
|
||||
let (svc, broadcaster, task_mgr) = setup().await;
|
||||
|
||||
// Create
|
||||
let created = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
assert_eq!(created.status, ConversationStatus::Pending);
|
||||
|
||||
// Get
|
||||
let fetched = svc.get(USER_ID, &created.id.to_string()).await.unwrap();
|
||||
assert_eq!(fetched.id, created.id);
|
||||
|
||||
// Update
|
||||
let update_req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"name": "Updated",
|
||||
"pinned": true,
|
||||
"extra": { "workspace": "/updated" }
|
||||
}))
|
||||
.unwrap();
|
||||
let updated = svc.update(USER_ID, &created.id.to_string(), update_req, &task_mgr).await.unwrap();
|
||||
assert_eq!(updated.name, "Updated");
|
||||
assert!(updated.pinned);
|
||||
assert_eq!(updated.extra["workspace"], "/updated");
|
||||
|
||||
// Delete
|
||||
svc.delete(USER_ID, &created.id.to_string()).await.unwrap();
|
||||
assert!(svc.get(USER_ID, &created.id.to_string()).await.is_err());
|
||||
|
||||
// Verify all events: created + updated + deleted
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events.len(), 3);
|
||||
assert_eq!(events[0].data["action"], "created");
|
||||
assert_eq!(events[1].data["action"], "updated");
|
||||
assert_eq!(events[2].data["action"], "deleted");
|
||||
}
|
||||
|
||||
// ── Type-aware model rules ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_top_level_model_for_acp() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"model": { "provider_id": "p1", "model": "claude-sonnet-4-20250514" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let err = svc.create(USER_ID, req).await.unwrap_err();
|
||||
match err {
|
||||
AppError::BadRequest(msg) => {
|
||||
assert!(msg.contains("model"), "error message should mention model: {msg}");
|
||||
assert!(msg.contains("extra"), "error message should mention extra: {msg}");
|
||||
}
|
||||
other => panic!("expected BadRequest, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_top_level_model_for_remote() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "remote",
|
||||
"model": { "provider_id": "p1", "model": "m1" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(svc.create(USER_ID, req).await, Err(AppError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_accepts_top_level_model_for_nomi() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "gpt-4o" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
assert_eq!(resp.r#type, AgentType::Nomi);
|
||||
let model = resp.model.expect("nomi response should carry top-level model");
|
||||
assert_eq!(model.provider_id, "p1");
|
||||
assert_eq!(model.model, "gpt-4o");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_nomi_strips_extra_model_field() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "gpt-4o" },
|
||||
"extra": {
|
||||
"workspace": "/home/user/project",
|
||||
"model": "bogus-from-legacy-client"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
assert!(
|
||||
!resp.extra.as_object().unwrap().contains_key("model"),
|
||||
"nomi create must strip extra.model to avoid dual source of truth; got {:?}",
|
||||
resp.extra
|
||||
);
|
||||
// Top-level model is still present and wins.
|
||||
assert_eq!(resp.model.unwrap().model, "gpt-4o");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_rejects_top_level_model_for_acp() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"model": { "provider_id": "p1", "model": "claude-sonnet-4-20250514" }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let err = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AppError::BadRequest(_)),
|
||||
"expected BadRequest, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_accepts_top_level_model_for_nomi() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
|
||||
let create_req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "gpt-4o" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, create_req).await.unwrap();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"model": { "provider_id": "p1", "model": "gpt-4o-mini" }
|
||||
}))
|
||||
.unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
assert_eq!(updated.model.unwrap().model, "gpt-4o-mini");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_non_nomi_extra_model_does_not_kill_task() {
|
||||
// Verifies the explicit rule that `extra.model` changes for non-nomi
|
||||
// do NOT trigger task_manager.kill. Since our `NoopTaskManager::kill` is
|
||||
// a no-op we can't assert the negative directly; we assert the update
|
||||
// succeeds and the merged extra carries the new field, and that top-level
|
||||
// model remains None.
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"extra": { "current_model_id": "claude-opus-4" }
|
||||
}))
|
||||
.unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
assert_eq!(updated.extra["current_model_id"], "claude-opus-4");
|
||||
assert!(updated.model.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_nomi_strips_extra_model_from_patch() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
|
||||
let create_req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "gpt-4o" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, create_req).await.unwrap();
|
||||
|
||||
// Client mistakenly sends extra.model on an nomi PATCH. It should be
|
||||
// silently stripped from the merged extra, not persisted.
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"extra": { "model": "legacy-value", "last_token_usage": { "total_tokens": 42 } }
|
||||
}))
|
||||
.unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
|
||||
assert!(
|
||||
!updated.extra.as_object().unwrap().contains_key("model"),
|
||||
"nomi PATCH must strip extra.model; got {:?}",
|
||||
updated.extra
|
||||
);
|
||||
// Other extra keys from the patch are merged as usual.
|
||||
assert_eq!(updated.extra["last_token_usage"]["total_tokens"], 42);
|
||||
// Top-level model unchanged by the extra-only patch.
|
||||
assert_eq!(updated.model.unwrap().model, "gpt-4o");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_acp_seeds_acp_session_runtime_from_extra() {
|
||||
use nomifun_db::{SqliteAcpSessionRepository, init_database_memory};
|
||||
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(nomifun_db::SqliteConversationRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let agent_metadata_repo: Arc<dyn nomifun_db::IAgentMetadataRepository> =
|
||||
Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone()));
|
||||
let acp_session_repo: Arc<dyn nomifun_db::IAcpSessionRepository> =
|
||||
Arc::new(SqliteAcpSessionRepository::new(db.pool().clone()));
|
||||
let task_mgr: Arc<dyn IWorkerTaskManager> = Arc::new(NoopTaskManager);
|
||||
let svc = nomifun_conversation::ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
broadcaster.clone(),
|
||||
Arc::new(EmptySkillResolver),
|
||||
task_mgr,
|
||||
repo,
|
||||
agent_metadata_repo,
|
||||
acp_session_repo.clone(),
|
||||
);
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": {
|
||||
"backend": "claude",
|
||||
"current_mode_id": "bypassPermissions",
|
||||
"current_model_id": "claude-opus-4"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
let runtime = acp_session_repo
|
||||
.load_runtime_state(conv.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("acp_session runtime state should exist after create");
|
||||
assert_eq!(
|
||||
runtime.current_mode_id.as_deref(),
|
||||
Some("bypassPermissions"),
|
||||
"extra.current_mode_id must be seeded into acp_session on create"
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.current_model_id.as_deref(),
|
||||
Some("claude-opus-4"),
|
||||
"extra.current_model_id must be seeded into acp_session on create"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_acp_skips_seed_when_extra_has_empty_runtime_fields() {
|
||||
use nomifun_db::{SqliteAcpSessionRepository, init_database_memory};
|
||||
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(nomifun_db::SqliteConversationRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let agent_metadata_repo: Arc<dyn nomifun_db::IAgentMetadataRepository> =
|
||||
Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone()));
|
||||
let acp_session_repo: Arc<dyn nomifun_db::IAcpSessionRepository> =
|
||||
Arc::new(SqliteAcpSessionRepository::new(db.pool().clone()));
|
||||
let task_mgr: Arc<dyn IWorkerTaskManager> = Arc::new(NoopTaskManager);
|
||||
let svc = nomifun_conversation::ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
broadcaster.clone(),
|
||||
Arc::new(EmptySkillResolver),
|
||||
task_mgr,
|
||||
repo,
|
||||
agent_metadata_repo,
|
||||
acp_session_repo.clone(),
|
||||
);
|
||||
|
||||
// Both fields present but empty — treated as absent, no save_runtime_state call.
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": { "backend": "claude", "current_mode_id": "", "current_model_id": "" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
let runtime = acp_session_repo.load_runtime_state(conv.id).await.unwrap();
|
||||
// Either `None` (no runtime key yet) or Some(default) — both mean "nothing seeded".
|
||||
assert!(
|
||||
runtime
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.current_mode_id.is_none() && r.current_model_id.is_none()),
|
||||
"empty runtime fields should not produce a seed: got {runtime:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_ai_agent::IWorkerTaskManager;
|
||||
use nomifun_api_types::{
|
||||
CloneConversationRequest, CreateConversationRequest, ListMessagesQuery, SearchMessagesQuery, WebSocketMessage,
|
||||
};
|
||||
use nomifun_common::{AgentKillReason, AppError, ConversationStatus, TimestampMs, generate_prefixed_id, now_ms};
|
||||
use nomifun_conversation::ConversationService;
|
||||
use nomifun_conversation::skill_resolver::SkillResolver;
|
||||
use nomifun_db::models::MessageRow;
|
||||
use nomifun_db::{IConversationRepository, SqliteConversationRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use serde_json::json;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// ── Test infrastructure ────────────────────────────────────────────
|
||||
|
||||
struct TestBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl TestBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(vec![]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for TestBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopTaskManager;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IWorkerTaskManager for NoopTaskManager {
|
||||
fn get_task(&self, _: &str) -> Option<nomifun_ai_agent::AgentInstance> {
|
||||
None
|
||||
}
|
||||
async fn get_or_build_task(
|
||||
&self,
|
||||
_: &str,
|
||||
_: nomifun_ai_agent::types::BuildTaskOptions,
|
||||
) -> Result<nomifun_ai_agent::AgentInstance, AppError> {
|
||||
Err(AppError::Internal("noop".into()))
|
||||
}
|
||||
fn kill(&self, _: &str, _: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
fn kill_and_wait(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<AgentKillReason>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
Box::pin(std::future::ready(()))
|
||||
}
|
||||
fn clear(&self) {}
|
||||
fn active_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn collect_idle(&self, _: TimestampMs) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptySkillResolver;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SkillResolver for EmptySkillResolver {
|
||||
async fn auto_inject_names(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn resolve_skills(&self, _names: &[String]) -> Vec<nomifun_extension::ResolvedAgentSkill> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn link_workspace_skills(
|
||||
&self,
|
||||
_workspace: &std::path::Path,
|
||||
_rel_dirs: &[&str],
|
||||
_skills: &[nomifun_extension::ResolvedAgentSkill],
|
||||
) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> (
|
||||
ConversationService,
|
||||
Arc<SqliteConversationRepository>,
|
||||
Arc<TestBroadcaster>,
|
||||
) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let agent_metadata_repo: Arc<dyn nomifun_db::IAgentMetadataRepository> =
|
||||
Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone()));
|
||||
let acp_session_repo: Arc<dyn nomifun_db::IAcpSessionRepository> =
|
||||
Arc::new(nomifun_db::SqliteAcpSessionRepository::new(db.pool().clone()));
|
||||
let task_mgr: Arc<dyn IWorkerTaskManager> = Arc::new(NoopTaskManager);
|
||||
let svc = ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
broadcaster.clone(),
|
||||
Arc::new(EmptySkillResolver),
|
||||
task_mgr,
|
||||
repo.clone(),
|
||||
agent_metadata_repo,
|
||||
acp_session_repo,
|
||||
);
|
||||
(svc, repo, broadcaster)
|
||||
}
|
||||
|
||||
const USER_ID: &str = "system_default_user";
|
||||
|
||||
fn make_create_req() -> CreateConversationRequest {
|
||||
serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": { "workspace": "/home/user/project" }
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn make_message(conv_id: i64, content: &str, offset_ms: i64) -> MessageRow {
|
||||
MessageRow {
|
||||
id: generate_prefixed_id("msg"),
|
||||
conversation_id: conv_id,
|
||||
msg_id: Some(generate_prefixed_id("client")),
|
||||
r#type: "text".to_string(),
|
||||
content: format!(r#"{{"content":"{content}"}}"#),
|
||||
position: Some("right".to_string()),
|
||||
status: Some("finish".to_string()),
|
||||
hidden: false,
|
||||
created_at: now_ms() + offset_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_acp_tool_message(conv_id: i64, id: &str, output: &str, offset_ms: i64) -> MessageRow {
|
||||
MessageRow {
|
||||
id: id.to_string(),
|
||||
conversation_id: conv_id,
|
||||
msg_id: Some(id.to_string()),
|
||||
r#type: "acp_tool_call".to_string(),
|
||||
content: json!({
|
||||
"session_id": "session-1",
|
||||
"update": {
|
||||
"session_update": "tool_call",
|
||||
"tool_call_id": id,
|
||||
"status": "completed",
|
||||
"title": "rg",
|
||||
"kind": "search",
|
||||
"raw_input": { "pattern": "needle", "path": "." },
|
||||
"content": [{
|
||||
"type": "content",
|
||||
"content": { "type": "text", "text": output }
|
||||
}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
position: Some("left".to_string()),
|
||||
status: Some("finish".to_string()),
|
||||
hidden: false,
|
||||
created_at: now_ms() + offset_ms,
|
||||
}
|
||||
}
|
||||
|
||||
// ── T6: Clone conversation ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t6_2_clone_without_source() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
|
||||
let req: CloneConversationRequest = serde_json::from_value(json!({
|
||||
"conversation": {
|
||||
"type": "acp",
|
||||
"name": "Direct",
|
||||
"extra": {}
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = svc.clone_create(USER_ID, req).await.unwrap();
|
||||
assert_eq!(resp.name, "Direct");
|
||||
// No source to merge from — only the caller-provided CreateConversationRequest
|
||||
// drives `extra`, so source-only keys (e.g. `contextFileName`) must not appear.
|
||||
assert!(resp.extra.get("contextFileName").is_none());
|
||||
}
|
||||
|
||||
// ── T7: Reset conversation ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_1_reset_clears_messages_and_status() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
// Insert messages
|
||||
for i in 0..3 {
|
||||
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
svc.reset(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
|
||||
let fetched = svc.get(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
assert_eq!(fetched.status, ConversationStatus::Pending);
|
||||
|
||||
let messages = svc
|
||||
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(messages.items.is_empty());
|
||||
assert_eq!(messages.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_3_reset_not_found() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
let err = svc.reset(USER_ID, "nonexistent").await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T8: Message list ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_empty_messages() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let result = svc
|
||||
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2_pagination() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
for i in 0..10 {
|
||||
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i * 100))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let query = ListMessagesQuery {
|
||||
page: Some(1),
|
||||
page_size: Some(3),
|
||||
order: None,
|
||||
content_mode: None,
|
||||
cursor: None,
|
||||
};
|
||||
let result = svc.list_messages(USER_ID, &conv.id.to_string(), query).await.unwrap();
|
||||
assert_eq!(result.items.len(), 3);
|
||||
assert_eq!(result.total, 10);
|
||||
assert!(result.has_more);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_3_asc_order_default() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
for i in 0..3 {
|
||||
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i * 1000))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let result = svc
|
||||
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap();
|
||||
// ASC (default): oldest first
|
||||
assert!(result.items[0].created_at <= result.items[1].created_at);
|
||||
assert!(result.items[1].created_at <= result.items[2].created_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_4_asc_order() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
for i in 0..3 {
|
||||
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i * 1000))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let query = ListMessagesQuery {
|
||||
order: Some("ASC".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = svc.list_messages(USER_ID, &conv.id.to_string(), query).await.unwrap();
|
||||
assert!(result.items[0].created_at <= result.items[1].created_at);
|
||||
assert!(result.items[1].created_at <= result.items[2].created_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_5_conversation_not_found() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
let err = svc
|
||||
.list_messages(USER_ID, "nonexistent", ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T9: Message search ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_6_compact_mode_truncates_large_tool_content_only_for_list_response() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
let large_output = "match line\n".repeat(10_000);
|
||||
|
||||
repo.insert_message(&make_acp_tool_message(conv.id, "tool-big", &large_output, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let full = svc
|
||||
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
full.items[0].content["update"]["content"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
large_output
|
||||
);
|
||||
|
||||
let compact = svc
|
||||
.list_messages(
|
||||
USER_ID,
|
||||
&conv.id.to_string(),
|
||||
ListMessagesQuery {
|
||||
content_mode: Some("compact".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let compact_content = &compact.items[0].content;
|
||||
let preview = compact_content["update"]["content"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
|
||||
assert!(compact_content["_compact"]["truncated"].as_bool().unwrap());
|
||||
assert!(compact_content["_compact"]["original_size"].as_u64().unwrap() > preview.len() as u64);
|
||||
assert!(preview.len() < large_output.len());
|
||||
assert!(!preview.contains(&large_output));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_7_get_message_returns_full_tool_content_after_compact_list() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
let large_output = "wide rg output\n".repeat(10_000);
|
||||
|
||||
repo.insert_message(&make_acp_tool_message(conv.id, "tool-detail", &large_output, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = svc
|
||||
.list_messages(
|
||||
USER_ID,
|
||||
&conv.id.to_string(),
|
||||
ListMessagesQuery {
|
||||
content_mode: Some("compact".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let detail = svc.get_message(USER_ID, &conv.id.to_string(), "tool-detail").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
detail.content["update"]["content"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
large_output
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_1_keyword_match() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
repo.insert_message(&make_message(conv.id, "Rust review report", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_message(&make_message(conv.id, "Python test", 100))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "review".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert_eq!(result.items.len(), 1);
|
||||
assert_eq!(result.total, 1);
|
||||
|
||||
let item = &result.items[0];
|
||||
assert_eq!(item.message_type, "text");
|
||||
assert!(item.message_created_at > 0);
|
||||
assert!(item.preview_text.contains("Rust review report"));
|
||||
|
||||
assert_eq!(item.conversation.id, conv.id);
|
||||
assert_eq!(item.conversation.name, conv.name);
|
||||
assert_eq!(item.conversation.extra["workspace"], "/home/user/project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_2_no_match() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
repo.insert_message(&make_message(conv.id, "hello world", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "xxxxnotexist".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_3_search_pagination() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
for i in 0..5 {
|
||||
repo.insert_message(&make_message(conv.id, &format!("match keyword item {i}"), i * 100))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "keyword".into(),
|
||||
page: Some(1),
|
||||
page_size: Some(2),
|
||||
};
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert_eq!(result.items.len(), 2);
|
||||
assert_eq!(result.total, 5);
|
||||
assert!(result.has_more);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_4_empty_keyword() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let err = svc.search_messages(USER_ID, query).await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_5_preview_text_extracts_from_json_content() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let complex_msg = MessageRow {
|
||||
id: generate_prefixed_id("msg"),
|
||||
conversation_id: conv.id.clone(),
|
||||
msg_id: None,
|
||||
r#type: "text".to_string(),
|
||||
content: r#"[{"type":"text","content":"Design document for search"},{"type":"text","content":"feature implementation"}]"#.to_string(),
|
||||
position: Some("right".to_string()),
|
||||
status: Some("finish".to_string()),
|
||||
hidden: false,
|
||||
created_at: now_ms(),
|
||||
};
|
||||
repo.insert_message(&complex_msg).await.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "search".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert_eq!(result.items.len(), 1);
|
||||
|
||||
let item = &result.items[0];
|
||||
assert!(!item.preview_text.contains('{'));
|
||||
assert!(!item.preview_text.contains('['));
|
||||
assert!(item.preview_text.contains("Design document for search"));
|
||||
assert!(item.preview_text.contains("feature implementation"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_6_search_result_includes_conversation_model() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
|
||||
// Search surfaces conversation.model only for nomi (the only type that
|
||||
// carries a top-level model under the nomi-only rule).
|
||||
let nomi_req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "claude-sonnet-4-20250514" },
|
||||
"extra": { "workspace": "/home/user/project" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, nomi_req).await.unwrap();
|
||||
|
||||
repo.insert_message(&make_message(conv.id, "model test keyword", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "model test".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert_eq!(result.items.len(), 1);
|
||||
|
||||
let item = &result.items[0];
|
||||
let model = item.conversation.model.as_ref().unwrap();
|
||||
assert_eq!(model.provider_id, "p1");
|
||||
assert_eq!(model.model, "claude-sonnet-4-20250514");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_7_search_does_not_leak_other_users_messages() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
repo.insert_message(&make_message(conv.id, "secret keyword data", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "secret".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let result = svc.search_messages("other_user_id", query).await.unwrap();
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
|
||||
// ── T10: Associated conversations ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_1_same_workspace() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
|
||||
let req1: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"name": "Conv A",
|
||||
"extra": { "workspace": "/shared/path" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv1 = svc.create(USER_ID, req1).await.unwrap();
|
||||
|
||||
let req2: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"name": "Conv B",
|
||||
"extra": { "workspace": "/shared/path" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv2 = svc.create(USER_ID, req2).await.unwrap();
|
||||
|
||||
// Different workspace
|
||||
let req3: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"name": "Conv C",
|
||||
"extra": { "workspace": "/other/path" }
|
||||
}))
|
||||
.unwrap();
|
||||
svc.create(USER_ID, req3).await.unwrap();
|
||||
|
||||
let associated = svc.list_associated(USER_ID, &conv1.id.to_string()).await.unwrap();
|
||||
assert_eq!(associated.len(), 1);
|
||||
assert_eq!(associated[0].id, conv2.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_2_no_associated() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": { "workspace": "/unique/path" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
let associated = svc.list_associated(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
assert!(associated.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_3_associated_not_found() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
let err = svc.list_associated(USER_ID, "nonexistent").await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T12: Boundary scenarios ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_4_search_sql_injection() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
repo.insert_message(&make_message(conv.id, "safe content", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "'; DROP TABLE messages; --".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
// Should return empty results, not crash
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
// ── Ownership cross-cutting ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn messages_wrong_user_returns_not_found() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
repo.insert_message(&make_message(conv.id, "hello", 0)).await.unwrap();
|
||||
|
||||
let err = svc
|
||||
.list_messages("other_user", &conv.id.to_string(), ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_wrong_user_returns_not_found() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let err = svc.reset("other_user", &conv.id.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
//! Black-box integration tests for the message middleware.
|
||||
//!
|
||||
//! Tests cover the test-plan.md section 6 (消息中间件):
|
||||
//! - Think tag cleaning (6.1)
|
||||
//! - Cron command detection (6.2)
|
||||
//! - MessageMiddleware pipeline end-to-end
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_conversation::{
|
||||
CronCommand, CronCommandResult, CronCreateParams, CronUpdateParams, ICronService, MessageMiddleware,
|
||||
detect_cron_commands, has_cron_commands, strip_cron_commands, strip_think_tags,
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// 6.1 Think tag cleaning
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn think_tag_before_and_after_text() {
|
||||
let input = "前文<think>内部思考</think>后文";
|
||||
assert_eq!(strip_think_tags(input), "前文后文");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_tag_before_answer() {
|
||||
let input = "<thinking>深度思考</thinking>回答";
|
||||
assert_eq!(strip_think_tags(input), "回答");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_think_tags() {
|
||||
// Non-greedy: `<think>外<think>内</think>` matches first close,
|
||||
// then `外</think>后` remains. The second `</think>` is literal text.
|
||||
// Per API spec this is the expected behavior — nested tags are consumed.
|
||||
let input = "<think>外<think>内</think>外</think>后";
|
||||
let result = strip_think_tags(input);
|
||||
// First match: `<think>外<think>内</think>` → removed → "外</think>后"
|
||||
assert_eq!(result, "外</think>后");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_think_tags() {
|
||||
let input = "普通文本";
|
||||
assert_eq!(strip_think_tags(input), "普通文本");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_think_tag() {
|
||||
let input = "a<think></think>b";
|
||||
assert_eq!(strip_think_tags(input), "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn think_tag_with_multiline_content() {
|
||||
let input = "Start\n<think>\nLine 1\nLine 2\nLine 3\n</think>\nEnd";
|
||||
let result = strip_think_tags(input);
|
||||
assert_eq!(result, "Start\n\nEnd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_think_and_thinking_tags() {
|
||||
let input = "<think>a</think>middle<thinking>b</thinking>end";
|
||||
assert_eq!(strip_think_tags(input), "middleend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_think_tag_preserved() {
|
||||
let input = "<think>no closing tag";
|
||||
assert_eq!(strip_think_tags(input), "<think>no closing tag");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 6.2 Cron command detection
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn detect_cron_create_with_all_fields() {
|
||||
let input = "[CRON_CREATE]\nname: 每日代码审查\nschedule: 0 9 * * MON\nschedule_description: 每周一上午 9 点\nmessage: 请审查本周的代码变更\n[/CRON_CREATE]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
match &commands[0] {
|
||||
CronCommand::Create(params) => {
|
||||
assert_eq!(params.name, "每日代码审查");
|
||||
assert_eq!(params.schedule, "0 9 * * MON");
|
||||
assert_eq!(params.schedule_description, "每周一上午 9 点");
|
||||
assert_eq!(params.message, "请审查本周的代码变更");
|
||||
}
|
||||
_ => panic!("Expected Create"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_cron_list() {
|
||||
let input = "[CRON_LIST]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0], CronCommand::List);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_cron_update_with_all_fields() {
|
||||
let input = "[CRON_UPDATE: job-456]\nname: 更新后的任务\nschedule: 0 10 * * MON\nschedule_description: 每周一上午 10 点\nmessage: 请发送更新后的提醒\n[/CRON_UPDATE]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
match &commands[0] {
|
||||
CronCommand::Update(params) => {
|
||||
assert_eq!(params.job_id, "job-456");
|
||||
assert_eq!(params.name, "更新后的任务");
|
||||
assert_eq!(params.schedule, "0 10 * * MON");
|
||||
assert_eq!(params.schedule_description, "每周一上午 10 点");
|
||||
assert_eq!(params.message, "请发送更新后的提醒");
|
||||
}
|
||||
_ => panic!("Expected Update"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_cron_delete_with_id() {
|
||||
let input = "[CRON_DELETE: job-123]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0], CronCommand::Delete("job-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_mixed_content_with_cron() {
|
||||
let input = "Here's what I did:\n\n[CRON_CREATE]\nname: cleanup\nschedule: 0 0 * * *\nschedule_description: daily midnight\nmessage: clean old files\n[/CRON_CREATE]\n\nThen updated one:\n[CRON_UPDATE: job-22]\nname: cleanup-v2\nschedule: 0 1 * * *\nschedule_description: daily 1am\nmessage: clean old files carefully\n[/CRON_UPDATE]\n\nAlso check: [CRON_LIST]\n\nAnd remove old one: [CRON_DELETE: old-123]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 4);
|
||||
assert!(matches!(&commands[0], CronCommand::Create(_)));
|
||||
assert!(matches!(&commands[1], CronCommand::Update(_)));
|
||||
assert_eq!(commands[2], CronCommand::List);
|
||||
assert_eq!(commands[3], CronCommand::Delete("old-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_no_commands_in_normal_text() {
|
||||
let commands = detect_cron_commands("普通回复");
|
||||
assert!(commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cron_detects_all_types() {
|
||||
assert!(has_cron_commands("[CRON_CREATE]\nschedule: *\n[/CRON_CREATE]"));
|
||||
assert!(has_cron_commands("[CRON_UPDATE: job-1]\nschedule: *\n[/CRON_UPDATE]"));
|
||||
assert!(has_cron_commands("[CRON_LIST]"));
|
||||
assert!(has_cron_commands("[CRON_DELETE: x]"));
|
||||
assert!(!has_cron_commands("nothing here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_cron_removes_all_types_preserves_text() {
|
||||
let input = "Before\n[CRON_CREATE]\nname: t\nschedule: *\n[/CRON_CREATE]\nMiddle [CRON_LIST] After [CRON_DELETE: x] Between [CRON_UPDATE: id-7]\nname: t2\nschedule: 0 * * * *\n[/CRON_UPDATE] End";
|
||||
let stripped = strip_cron_commands(input);
|
||||
assert!(!stripped.contains("[CRON_"));
|
||||
assert!(stripped.contains("Before"));
|
||||
assert!(stripped.contains("Middle"));
|
||||
assert!(stripped.contains("After"));
|
||||
assert!(stripped.contains("End"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_create_missing_schedule_not_parsed() {
|
||||
let input = "[CRON_CREATE]\nname: broken\nmessage: no schedule\n[/CRON_CREATE]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert!(commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_delete_with_whitespace_in_id() {
|
||||
let input = "[CRON_DELETE: spaced-id ]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0], CronCommand::Delete("spaced-id".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_cron_creates() {
|
||||
let input = "[CRON_CREATE]\nname: first\nschedule: 0 * * * *\n[/CRON_CREATE] text [CRON_CREATE]\nname: second\nschedule: 0 0 * * *\n[/CRON_CREATE]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 2);
|
||||
match (&commands[0], &commands[1]) {
|
||||
(CronCommand::Create(a), CronCommand::Create(b)) => {
|
||||
assert_eq!(a.name, "first");
|
||||
assert_eq!(b.name, "second");
|
||||
}
|
||||
_ => panic!("Expected two Create commands"),
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// MessageMiddleware end-to-end
|
||||
// ===========================================================================
|
||||
|
||||
/// Test cron service that tracks execution.
|
||||
struct TrackingCronService;
|
||||
|
||||
#[async_trait]
|
||||
impl ICronService for TrackingCronService {
|
||||
async fn create_job(&self, _user_id: &str, _conversation_id: &str, params: &CronCreateParams) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: format!("Job '{}' created with schedule '{}'", params.name, params.schedule),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_job(&self, _user_id: &str, conversation_id: &str, params: &CronUpdateParams) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: format!("Job '{}' updated in conversation '{}'", params.job_id, conversation_id),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_jobs(&self, _user_id: &str, conversation_id: &str) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: format!("Active jobs for '{}': daily-check (0 9 * * *)", conversation_id),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_job(&self, _user_id: &str, job_id: &str) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: format!("Job '{}' deleted", job_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Failing cron service for error path testing.
|
||||
struct FailingCronService;
|
||||
|
||||
#[async_trait]
|
||||
impl ICronService for FailingCronService {
|
||||
async fn create_job(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_conversation_id: &str,
|
||||
_params: &CronCreateParams,
|
||||
) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: false,
|
||||
message: "Database connection lost".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_job(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_conversation_id: &str,
|
||||
_params: &CronUpdateParams,
|
||||
) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: false,
|
||||
message: "Update rejected".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_jobs(&self, _user_id: &str, _conversation_id: &str) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: false,
|
||||
message: "Service unavailable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_job(&self, _user_id: &str, _job_id: &str) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: false,
|
||||
message: "Permission denied".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_plain_text_passes_through() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let result = mw.process("Hello world!", "u1", "c1").await;
|
||||
assert_eq!(result.message, "Hello world!");
|
||||
assert!(result.display_message.is_none());
|
||||
assert!(result.system_responses.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_strips_think_and_thinking() {
|
||||
let mw = MessageMiddleware::new(None);
|
||||
let input = "<think>reasoning about the problem</think>The answer is 42.<thinking>more thought</thinking>";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
assert_eq!(result.message, "The answer is 42.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_executes_cron_create_successfully() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "Done! I've set up the job.\n[CRON_CREATE]\nname: daily-review\nschedule: 0 9 * * *\nschedule_description: Daily at 9am\nmessage: Review PRs\n[/CRON_CREATE]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert!(!result.message.contains("[CRON_CREATE]"));
|
||||
assert!(result.message.contains("Done!"));
|
||||
assert!(result.display_message.is_some());
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("daily-review"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_executes_cron_list() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "Here are your jobs: [CRON_LIST]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert!(!result.message.contains("[CRON_LIST]"));
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("Active jobs for 'c1'"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_executes_cron_update() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "Updating it now. [CRON_UPDATE: job-42]\nname: renamed\nschedule: 0 8 * * *\nschedule_description: Daily at 8am\nmessage: New prompt\n[/CRON_UPDATE]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert!(!result.message.contains("[CRON_UPDATE"));
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("job-42"));
|
||||
assert!(result.system_responses[0].contains("c1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_executes_cron_delete() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "Removing it now. [CRON_DELETE: job-42]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert!(!result.message.contains("[CRON_DELETE"));
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("job-42"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_handles_cron_failure() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(FailingCronService)));
|
||||
let input = "[CRON_UPDATE: x]\nname: renamed\nschedule: 0 8 * * *\nschedule_description: Daily at 8am\nmessage: New prompt\n[/CRON_UPDATE]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("System Error"));
|
||||
assert!(result.system_responses[0].contains("Update rejected"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_no_cron_service_returns_unavailable() {
|
||||
let mw = MessageMiddleware::new(None);
|
||||
let input = "Listing jobs [CRON_LIST]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("not available"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_combined_think_tags_and_cron_commands() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "<thinking>Let me think about this...</thinking>Sure, I'll set that up for you.\n[CRON_CREATE]\nname: weekly\nschedule: 0 0 * * SUN\nschedule_description: Every Sunday\nmessage: Weekly report\n[/CRON_CREATE]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
// Think tags stripped
|
||||
assert!(!result.message.contains("<thinking>"));
|
||||
// Cron commands stripped
|
||||
assert!(!result.message.contains("[CRON_CREATE]"));
|
||||
// Text preserved
|
||||
assert!(result.message.contains("Sure, I'll set that up for you."));
|
||||
// Cron executed
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("weekly"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_multiple_cron_commands_all_executed() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "[CRON_CREATE]\nname: job1\nschedule: 0 * * * *\n[/CRON_CREATE] and [CRON_UPDATE: old]\nname: job2\nschedule: 0 1 * * *\nschedule_description: daily 1am\nmessage: New prompt\n[/CRON_UPDATE] and [CRON_LIST] and [CRON_DELETE: old]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert_eq!(result.system_responses.len(), 4);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_ai_agent::{
|
||||
AgentStreamEvent,
|
||||
protocol::events::{FinishEventData, ToolCallEventData, ToolCallStatus},
|
||||
};
|
||||
use nomifun_common::now_ms;
|
||||
use nomifun_conversation::stream_relay::StreamRelay;
|
||||
use nomifun_db::{
|
||||
IConversationRepository, SortOrder, SqliteConversationRepository, init_database_memory, models::ConversationRow,
|
||||
};
|
||||
use nomifun_realtime::BroadcastEventBus;
|
||||
use serde_json::json;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
async fn setup_repo() -> (Arc<SqliteConversationRepository>, nomifun_db::Database) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
|
||||
let now = now_ms();
|
||||
repo.create(&ConversationRow {
|
||||
id: 1,
|
||||
user_id: "system_default_user".into(),
|
||||
name: "Tool call test".into(),
|
||||
r#type: "nomi".into(),
|
||||
extra: "{}".into(),
|
||||
model: None,
|
||||
status: Some("running".into()),
|
||||
source: Some("nomifun".into()),
|
||||
channel_chat_id: None,
|
||||
pinned: false,
|
||||
pinned_at: None,
|
||||
cron_job_id: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(repo, db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_with_empty_call_id_is_not_persisted() {
|
||||
let (repo, _db) = setup_repo().await;
|
||||
let bus = Arc::new(BroadcastEventBus::new(64));
|
||||
let (tx, _) = broadcast::channel(64);
|
||||
|
||||
let relay = StreamRelay::new(
|
||||
"1".into(),
|
||||
"asst-1".into(),
|
||||
"system_default_user".into(),
|
||||
repo.clone(),
|
||||
bus,
|
||||
None,
|
||||
);
|
||||
|
||||
let rx = tx.subscribe();
|
||||
tx.send(AgentStreamEvent::ToolCall(ToolCallEventData {
|
||||
call_id: "".into(),
|
||||
name: "Glob".into(),
|
||||
args: json!({"pattern": "*.rs"}),
|
||||
status: ToolCallStatus::Running,
|
||||
input: Some(json!({"pattern": "*.rs"})),
|
||||
output: None,
|
||||
description: None,
|
||||
}))
|
||||
.unwrap();
|
||||
tx.send(AgentStreamEvent::Finish(FinishEventData::default())).unwrap();
|
||||
|
||||
relay.consume(rx).await;
|
||||
|
||||
let messages = repo.get_messages(1, 1, 100, SortOrder::Asc).await.unwrap();
|
||||
|
||||
assert!(
|
||||
messages.items.iter().all(|row| row.r#type != "tool_call"),
|
||||
"empty call_id tool_call must not be persisted"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user