Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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, &params)
.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, &params.id, &params.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, &params.id, &params.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, &params.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, &params.id, &params.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
@@ -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(_)));
}
@@ -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"
);
}