From 449d7e59e1154c2373d0b3b93ebfbf73a524ac9d Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 15 Jul 2026 10:44:19 +0800 Subject: [PATCH 1/4] fix(assistant): canonicalize rule file storage --- crates/aionui-app/src/router/state.rs | 5 +- crates/aionui-app/tests/assistants_e2e.rs | 3 - crates/aionui-app/tests/conversation_e2e.rs | 10 +- crates/aionui-assistant/src/service.rs | 273 ++++++++++++++---- crates/aionui-channel/src/channel_settings.rs | 5 +- .../tests/message_service_integration.rs | 5 +- .../aionui-conversation/src/service_test.rs | 24 -- .../aionui-cron/tests/service_integration.rs | 5 +- ...rop_unused_assistant_definition_fields.sql | 14 + crates/aionui-db/src/models/assistant.rs | 6 - .../src/repository/sqlite_assistant.rs | 19 +- .../assistant_data_unification_schema.rs | 3 + ...tant_definition_field_removal_migration.rs | 117 ++++++++ .../tests/feedback_diagnostics_repository.rs | 9 +- .../aionui-team/src/service/spawn_support.rs | 10 +- .../tests/session_service_integration.rs | 40 +-- 16 files changed, 379 insertions(+), 169 deletions(-) create mode 100644 crates/aionui-db/migrations/023_drop_unused_assistant_definition_fields.sql create mode 100644 crates/aionui-db/tests/assistant_definition_field_removal_migration.rs diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index dd06ed362..e504f8acd 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -967,8 +967,6 @@ mod tests { source: "generated", owner_type: "system", source_ref: Some("bare-channel-aionrs"), - source_version: None, - source_hash: None, name: "Bare Channel Aionrs", name_i18n: "{}", description: Some("Channel state regression assistant"), @@ -976,9 +974,8 @@ mod tests { avatar_type: "emoji", avatar_value: Some("A"), agent_id: "632f31d2", - rule_resource_type: "inline", + rule_resource_type: "user_file", rule_resource_ref: None, - rule_inline_content: Some(""), recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index 7393dcf25..f6e0fea91 100644 --- a/crates/aionui-app/tests/assistants_e2e.rs +++ b/crates/aionui-app/tests/assistants_e2e.rs @@ -503,8 +503,6 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { source: &definition.source, owner_type: &definition.owner_type, source_ref: definition.source_ref.as_deref(), - source_version: definition.source_version.as_deref(), - source_hash: definition.source_hash.as_deref(), name: &definition.name, name_i18n: &definition.name_i18n, description: definition.description.as_deref(), @@ -514,7 +512,6 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { agent_id: &definition.agent_id, rule_resource_type: &definition.rule_resource_type, rule_resource_ref: definition.rule_resource_ref.as_deref(), - rule_inline_content: definition.rule_inline_content.as_deref(), recommended_prompts: r#"["draft a summary","share next steps"]"#, recommended_prompts_i18n: r#"{"zh-CN":["ζ€»η»“δΈ€δΈ‹"]}"#, default_model_mode: "fixed", diff --git a/crates/aionui-app/tests/conversation_e2e.rs b/crates/aionui-app/tests/conversation_e2e.rs index c0a1be075..b894a493b 100644 --- a/crates/aionui-app/tests/conversation_e2e.rs +++ b/crates/aionui-app/tests/conversation_e2e.rs @@ -113,7 +113,7 @@ async fn t1_3_create_with_optional_fields() { } #[tokio::test] -async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { +async fn t1_3b_create_persists_locale_fallback_rule_in_assistant_snapshot() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; @@ -136,8 +136,7 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { "/api/skills/assistant-rule/write", json!({ "assistant_id": "u1", - "content": "assistant snapshot rule", - "locale": "en-US" + "content": "assistant snapshot rule" }), &token, &csrf, @@ -159,8 +158,6 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { source: &definition.source, owner_type: &definition.owner_type, source_ref: definition.source_ref.as_deref(), - source_version: definition.source_version.as_deref(), - source_hash: definition.source_hash.as_deref(), name: &definition.name, name_i18n: &definition.name_i18n, description: definition.description.as_deref(), @@ -170,7 +167,6 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { agent_id: &definition.agent_id, rule_resource_type: &definition.rule_resource_type, rule_resource_ref: definition.rule_resource_ref.as_deref(), - rule_inline_content: definition.rule_inline_content.as_deref(), recommended_prompts: &definition.recommended_prompts, recommended_prompts_i18n: &definition.recommended_prompts_i18n, default_model_mode: "auto", @@ -219,7 +215,7 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { "name": "Snapshot Flow", "assistant": { "id": "u1", - "locale": "en-US", + "locale": "zh-CN", "conversation_overrides": { "model": "override-model", "skill_ids": ["override-skill"], diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 61f859f9c..4a79fc891 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -23,7 +23,7 @@ use aionui_db::{ }; use aionui_extension::{AssistantClassifier, AssistantRuleDispatcher, ExtensionError}; use serde_json; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; use crate::agent_catalog::AssistantAgentCatalogPort; #[cfg(test)] @@ -177,8 +177,6 @@ impl AssistantService { source: "builtin", owner_type: "system", source_ref: Some(&builtin.id), - source_version: None, - source_hash: None, name: &builtin.name, name_i18n: &name_i18n, description: builtin.description.as_deref(), @@ -192,7 +190,6 @@ impl AssistantService { "none" }, rule_resource_ref: builtin.rule_file.as_ref().map(|_| builtin.id.as_str()), - rule_inline_content: None, recommended_prompts: &recommended_prompts, recommended_prompts_i18n: &recommended_prompts_i18n, default_model_mode, @@ -452,13 +449,17 @@ impl AssistantService { || definition.avatar_type != avatar_type || definition.avatar_value.as_deref() != avatar_value || definition.agent_id != row.id - || definition.source_ref.as_deref() != Some(row.id.as_str()); + || definition.source_ref.as_deref() != Some(row.id.as_str()) + || definition.rule_resource_type != "user_file" + || definition.rule_resource_ref.as_deref() != Some(assistant_id.as_str()); definition.name = row.name.clone(); definition.avatar_type = avatar_type.to_string(); definition.avatar_value = avatar_value.map(ToOwned::to_owned); definition.agent_id = row.id.clone(); definition.source_ref = Some(row.id.clone()); + definition.rule_resource_type = "user_file".into(); + definition.rule_resource_ref = Some(assistant_id.clone()); if should_upgrade_skill_defaults { definition.default_skills_mode = "fixed".into(); } @@ -471,8 +472,6 @@ impl AssistantService { source: "generated".into(), owner_type: "system".into(), source_ref: Some(row.id.clone()), - source_version: None, - source_hash: None, name: row.name.clone(), name_i18n: "{}".into(), description: row.description.clone(), @@ -484,9 +483,8 @@ impl AssistantService { }, avatar_value: avatar_value.map(ToOwned::to_owned), agent_id: row.id.clone(), - rule_resource_type: "none".into(), - rule_resource_ref: None, - rule_inline_content: None, + rule_resource_type: "user_file".into(), + rule_resource_ref: Some(assistant_id.clone()), recommended_prompts: "[]".into(), recommended_prompts_i18n: "{}".into(), default_model_mode: "auto".into(), @@ -585,8 +583,6 @@ impl AssistantService { source: "user", owner_type: "user", source_ref: Some(&row.id), - source_version: None, - source_hash: None, name: &row.name, name_i18n: &name_i18n, description: row.description.as_deref(), @@ -596,7 +592,6 @@ impl AssistantService { agent_id: &agent_id, rule_resource_type: "user_file", rule_resource_ref: Some(&row.id), - rule_inline_content: None, recommended_prompts: &recommended_prompts, recommended_prompts_i18n: &recommended_prompts_i18n, default_model_mode: "auto", @@ -1548,9 +1543,18 @@ impl AssistantService { /// create the conversation with `assistant: None`, so no UI locale reaches /// rule resolution and the localized file would otherwise be missed. fn read_user_rule_with_fallback(&self, id: &str, locale: Option<&str>) -> String { - let content = read_file_or_empty(&self.user_rule_path(id, locale)); + let rules_dir = self.user_rules_dir(); + let content = read_assistant_md_with_legacy(&rules_dir, id, locale); if content.is_empty() { - read_first_assistant_md(&self.user_rules_dir(), id) + let locale_less = locale + .filter(|value| !value.is_empty()) + .map(|_| read_assistant_md_with_legacy(&rules_dir, id, None)) + .unwrap_or_default(); + if locale_less.is_empty() { + read_first_assistant_md(&rules_dir, id) + } else { + locale_less + } } else { content } @@ -1591,8 +1595,7 @@ impl AssistantService { match self.classify_source(id).await { AssistantSource::Builtin => Ok(String::new()), AssistantSource::Generated | AssistantSource::User => { - let path = self.user_skill_path(id, locale); - Ok(read_file_or_empty(&path)) + Ok(read_assistant_md_with_legacy(&self.user_skills_dir(), id, locale)) } } } @@ -2163,11 +2166,7 @@ impl AssistantService { agent: projection.agent.clone(), }, rules: AssistantRulesResponse { - content: if rules_content.is_empty() { - definition.rule_inline_content.clone().unwrap_or_default() - } else { - rules_content.to_owned() - }, + content: rules_content.to_owned(), storage_mode: definition.rule_resource_type.clone(), }, prompts: AssistantPromptsResponse { @@ -2616,8 +2615,6 @@ fn upsert_params_from_definition(definition: &AssistantDefinitionRow) -> UpsertA source: &definition.source, owner_type: &definition.owner_type, source_ref: definition.source_ref.as_deref(), - source_version: definition.source_version.as_deref(), - source_hash: definition.source_hash.as_deref(), name: &definition.name, name_i18n: &definition.name_i18n, description: definition.description.as_deref(), @@ -2627,7 +2624,6 @@ fn upsert_params_from_definition(definition: &AssistantDefinitionRow) -> UpsertA agent_id: &definition.agent_id, rule_resource_type: &definition.rule_resource_type, rule_resource_ref: definition.rule_resource_ref.as_deref(), - rule_inline_content: definition.rule_inline_content.as_deref(), recommended_prompts: &definition.recommended_prompts, recommended_prompts_i18n: &definition.recommended_prompts_i18n, default_model_mode: &definition.default_model_mode, @@ -2743,6 +2739,15 @@ fn normalize_json_array_string(raw: Option<&str>, field: &str) -> Result) -> PathBuf { + let id = encode_filename_component(id); + let filename = match locale { + Some(loc) if !loc.is_empty() => format!("{id}.{}.md", encode_filename_component(loc)), + _ => format!("{id}.md"), + }; + dir.join(filename) +} + +fn legacy_assistant_md_path(dir: &Path, id: &str, locale: Option<&str>) -> PathBuf { let filename = match locale { Some(loc) if !loc.is_empty() => format!("{id}.{loc}.md"), _ => format!("{id}.md"), @@ -2750,48 +2755,140 @@ fn assistant_md_path(dir: &Path, id: &str, locale: Option<&str>) -> PathBuf { dir.join(filename) } +fn legacy_filename_component_is_safe(value: &str) -> bool { + !value.bytes().any(|byte| matches!(byte, b'/' | b'\\' | b'\0')) +} + +fn encode_filename_component(value: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[(byte >> 4) as usize])); + encoded.push(char::from(HEX[(byte & 0x0f) as usize])); + } + } + encoded +} + fn read_file_or_empty(path: &Path) -> String { std::fs::read_to_string(path).unwrap_or_default() } -/// Read the first available `{id}.*.md` rule file in `dir`, preferring the -/// locale-less `{id}.md`. Used as a fallback when a rule is resolved without a -/// locale (e.g. scheduled/cron runs, which create the conversation with -/// `assistant: None`) and the exact `{id}.{locale}.md` file is therefore not -/// found. Returns an empty string when no rule file exists. +fn read_assistant_md_with_legacy(dir: &Path, id: &str, locale: Option<&str>) -> String { + let path = assistant_md_path(dir, id, locale); + let content = read_file_or_empty(&path); + if !content.is_empty() { + return content; + } + + if !legacy_filename_component_is_safe(id) || locale.is_some_and(|value| !legacy_filename_component_is_safe(value)) { + return String::new(); + } + + let legacy_path = legacy_assistant_md_path(dir, id, locale); + if legacy_path == path { + return String::new(); + } + let legacy_content = read_file_or_empty(&legacy_path); + if legacy_content.is_empty() { + return String::new(); + } + + match std::fs::write(&path, &legacy_content) { + Ok(()) => { + info!( + assistant_id = id, + locale = locale.unwrap_or_default(), + "migrated legacy assistant markdown path" + ); + if let Err(error) = std::fs::remove_file(&legacy_path) { + warn!( + assistant_id = id, + locale = locale.unwrap_or_default(), + %error, + "failed to remove legacy assistant markdown path after migration" + ); + } + } + Err(error) => { + warn!( + assistant_id = id, + locale = locale.unwrap_or_default(), + %error, + "failed to migrate legacy assistant markdown path" + ); + } + } + legacy_content +} + +/// Read the first available assistant markdown file in `dir`, preferring the +/// locale-less file. Both encoded filenames and pre-encoding legacy filenames +/// are recognized. fn read_first_assistant_md(dir: &Path, id: &str) -> String { let Ok(entries) = std::fs::read_dir(dir) else { return String::new(); }; - let prefix = format!("{id}."); - let exact = format!("{id}.md"); + let encoded_id = encode_filename_component(id); + let encoded_prefix = format!("{encoded_id}."); + let encoded_exact = format!("{encoded_id}.md"); + let legacy_prefix = format!("{id}."); + let legacy_exact = format!("{id}.md"); let mut fallback: Option = None; for entry in entries.flatten() { let name = entry.file_name(); let name = name.to_string_lossy(); - if name == exact { + if name == encoded_exact || name == legacy_exact { return read_file_or_empty(&entry.path()); } - if fallback.is_none() && name.starts_with(&prefix) && name.ends_with(".md") { + if fallback.is_none() + && (name.starts_with(&encoded_prefix) || name.starts_with(&legacy_prefix)) + && name.ends_with(".md") + { fallback = Some(entry.path()); } } fallback.map(|path| read_file_or_empty(&path)).unwrap_or_default() } -/// Remove every `{id}*.md` file in `dir`. Returns `true` if any file was -/// deleted. +/// Remove encoded and pre-encoding legacy markdown files for an assistant. fn remove_assistant_md_files(dir: &Path, id: &str) -> bool { + let mut deleted = false; + if legacy_filename_component_is_safe(id) { + let legacy_path = legacy_assistant_md_path(dir, id, None); + if legacy_path != assistant_md_path(dir, id, None) { + match std::fs::remove_file(&legacy_path) { + Ok(()) => deleted = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => warn!( + assistant_id = id, + %error, + "failed to remove legacy assistant markdown path" + ), + } + } + } + let Ok(entries) = std::fs::read_dir(dir) else { - return false; + return deleted; }; - let mut deleted = false; - let prefix = format!("{id}."); - let exact = format!("{id}.md"); + let encoded_id = encode_filename_component(id); + let encoded_prefix = format!("{encoded_id}."); + let encoded_exact = format!("{encoded_id}.md"); + let legacy_prefix = format!("{id}."); + let legacy_exact = format!("{id}.md"); for entry in entries.flatten() { let name = entry.file_name(); let name = name.to_string_lossy(); - if name == exact || (name.starts_with(&prefix) && name.ends_with(".md")) { + if name == encoded_exact + || name == legacy_exact + || ((name.starts_with(&encoded_prefix) || name.starts_with(&legacy_prefix)) && name.ends_with(".md")) + { if let Err(e) = std::fs::remove_file(entry.path()) { warn!("failed to remove {}: {e}", entry.path().display()); continue; @@ -3112,8 +3209,6 @@ mod tests { source: "generated", owner_type: "system", source_ref: Some(agent_id), - source_version: None, - source_hash: None, name: "Historical generated agent", name_i18n: "{}", description: None, @@ -3123,7 +3218,6 @@ mod tests { agent_id, rule_resource_type: "none", rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", @@ -3170,8 +3264,6 @@ mod tests { source: "user", owner_type: "user", source_ref: Some("custom-canonical-only"), - source_version: None, - source_hash: None, name: "Canonical Only", name_i18n: "{}", description: None, @@ -3181,7 +3273,6 @@ mod tests { agent_id: "aionrs", rule_resource_type: "user_file", rule_resource_ref: Some("custom-canonical-only"), - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", @@ -3280,8 +3371,6 @@ mod tests { source: "generated", owner_type: "system", source_ref: Some("agent-dirty"), - source_version: None, - source_hash: None, name: "Dirty", name_i18n: "{}", description: None, @@ -3291,7 +3380,6 @@ mod tests { agent_id: "agent-dirty", rule_resource_type: "none", rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", @@ -3385,8 +3473,6 @@ mod tests { source: "generated", owner_type: "system", source_ref: Some("agent-claude"), - source_version: None, - source_hash: None, name: "Claude", name_i18n: "{}", description: None, @@ -3396,7 +3482,6 @@ mod tests { agent_id: "agent-claude", rule_resource_type: "none", rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", @@ -5521,6 +5606,88 @@ mod tests { .unwrap(); let content = fx.service.read_rule("bare:agent-claude", Some("en-US")).await.unwrap(); assert_eq!(content, "rule body"); + assert!( + fx._tmp + .path() + .join("assistant-rules/bare%3Aagent-claude.en-US.md") + .is_file() + ); + assert!( + !fx._tmp + .path() + .join("assistant-rules/bare:agent-claude.en-US.md") + .exists() + ); + + let definition = fx + .definition_repo + .get_by_assistant_id("bare:agent-claude") + .await + .unwrap() + .unwrap(); + assert_eq!(definition.rule_resource_type, "user_file"); + assert_eq!(definition.rule_resource_ref.as_deref(), Some("bare:agent-claude")); + } + + #[test] + fn legacy_generated_rule_path_is_migrated_to_encoded_filename() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("assistant-rules"); + std::fs::create_dir_all(&dir).unwrap(); + let legacy_path = legacy_assistant_md_path(&dir, "bare:632f31d2", None); + std::fs::write(&legacy_path, "legacy rule").unwrap(); + + assert_eq!( + read_assistant_md_with_legacy(&dir, "bare:632f31d2", None), + "legacy rule" + ); + + let encoded_path = assistant_md_path(&dir, "bare:632f31d2", None); + assert_eq!( + encoded_path.file_name().and_then(|name| name.to_str()), + Some("bare%3A632f31d2.md") + ); + assert_eq!(std::fs::read_to_string(encoded_path).unwrap(), "legacy rule"); + assert!(!legacy_path.exists()); + } + + #[tokio::test] + async fn generated_rule_with_requested_locale_falls_back_to_legacy_locale_less_path() { + let fx = fixture_with_options(FixtureOpts { + agent_rows: vec![mk_agent_row( + "632f31d2", + "aionrs", + aionui_api_types::AgentManagementStatus::Online, + )], + ..Default::default() + }) + .await; + let dir = fx._tmp.path().join("assistant-rules"); + std::fs::create_dir_all(&dir).unwrap(); + let legacy_path = legacy_assistant_md_path(&dir, "bare:632f31d2", None); + std::fs::write(&legacy_path, "legacy locale-less rule").unwrap(); + + assert_eq!( + fx.service.read_rule("bare:632f31d2", Some("zh-CN")).await.unwrap(), + "legacy locale-less rule" + ); + + let encoded_path = assistant_md_path(&dir, "bare:632f31d2", None); + assert_eq!( + std::fs::read_to_string(encoded_path).unwrap(), + "legacy locale-less rule" + ); + assert!(!legacy_path.exists()); + } + + #[test] + fn assistant_markdown_path_encodes_path_separators_and_percent() { + let path = assistant_md_path(Path::new("rules"), "../bare:%2F", Some(r"en\US")); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("%2E%2E%2Fbare%3A%252F.en%5CUS.md") + ); + assert_eq!(path.parent(), Some(Path::new("rules"))); } #[tokio::test] diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index 9c825eaef..be223a69b 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -646,8 +646,6 @@ mod tests { source: "generated".to_owned(), owner_type: "system".to_owned(), source_ref: Some(assistant_id.to_owned()), - source_version: None, - source_hash: None, name: assistant_id.to_owned(), name_i18n: "{}".to_owned(), description: None, @@ -655,9 +653,8 @@ mod tests { avatar_type: "emoji".to_owned(), avatar_value: None, agent_id: agent_id.to_owned(), - rule_resource_type: "inline".to_owned(), + rule_resource_type: "user_file".to_owned(), rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]".to_owned(), recommended_prompts_i18n: "{}".to_owned(), default_model_mode: "auto".to_owned(), diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index 448c5e381..085cedc2d 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -192,8 +192,6 @@ fn bare_assistant_definition_params<'a>( source: "generated", owner_type: "system", source_ref: Some(assistant_id), - source_version: None, - source_hash: None, name: assistant_id, name_i18n: "{}", description: Some("Channel bare assistant"), @@ -201,9 +199,8 @@ fn bare_assistant_definition_params<'a>( avatar_type: "emoji", avatar_value: Some("πŸ€–"), agent_id, - rule_resource_type: "inline", + rule_resource_type: "user_file", rule_resource_ref: None, - rule_inline_content: Some(""), recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 5e75f94b7..8d5181025 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -1264,8 +1264,6 @@ async fn upsert_test_assistant_definition_with_thought_level( source: "builtin", owner_type: "system", source_ref: Some(assistant_id), - source_version: None, - source_hash: None, name: assistant_id, name_i18n: "{}", description: Some("desc"), @@ -1275,7 +1273,6 @@ async fn upsert_test_assistant_definition_with_thought_level( agent_id, rule_resource_type: "builtin_asset", rule_resource_ref: Some(assistant_id), - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode, @@ -6121,8 +6118,6 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { source: "builtin", owner_type: "system", source_ref: Some("preset-1"), - source_version: None, - source_hash: None, name: "Preset", name_i18n: "{}", description: Some("desc"), @@ -6132,7 +6127,6 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { agent_id: "claude", rule_resource_type: "builtin_asset", rule_resource_ref: Some("preset-1"), - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", @@ -6284,8 +6278,6 @@ async fn existing_conversation_reads_current_assistant_identity() { source: "user", owner_type: "user", source_ref: Some("live-identity"), - source_version: None, - source_hash: None, name: "Old Name", name_i18n: "{}", description: None, @@ -6295,7 +6287,6 @@ async fn existing_conversation_reads_current_assistant_identity() { agent_id: "claude", rule_resource_type: "none", rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", @@ -6343,8 +6334,6 @@ async fn existing_conversation_reads_current_assistant_identity() { source: "user", owner_type: "user", source_ref: Some("live-identity"), - source_version: None, - source_hash: None, name: "New Name", name_i18n: "{}", description: None, @@ -6354,7 +6343,6 @@ async fn existing_conversation_reads_current_assistant_identity() { agent_id: "claude", rule_resource_type: "none", rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", @@ -6417,8 +6405,6 @@ async fn create_routes_asset_avatar_in_assistant_identity_through_backend() { source: "user", owner_type: "user", source_ref: Some("custom-data-avatar"), - source_version: None, - source_hash: None, name: "Data Avatar", name_i18n: "{}", description: None, @@ -6428,7 +6414,6 @@ async fn create_routes_asset_avatar_in_assistant_identity_through_backend() { agent_id: "claude", rule_resource_type: "none", rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", @@ -6578,8 +6563,6 @@ async fn create_prefers_assistant_snapshot_over_legacy_runtime_seed_fields() { source: "builtin", owner_type: "system", source_ref: Some("preset-1"), - source_version: None, - source_hash: None, name: "Preset", name_i18n: "{}", description: Some("desc"), @@ -6589,7 +6572,6 @@ async fn create_prefers_assistant_snapshot_over_legacy_runtime_seed_fields() { agent_id: "claude", rule_resource_type: "builtin_asset", rule_resource_ref: Some("preset-1"), - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", @@ -6742,8 +6724,6 @@ async fn create_does_not_overwrite_preferences_for_fixed_skills_and_mcps() { source: "builtin", owner_type: "system", source_ref: Some("preset-fixed"), - source_version: None, - source_hash: None, name: "Preset Fixed", name_i18n: "{}", description: Some("desc"), @@ -6753,7 +6733,6 @@ async fn create_does_not_overwrite_preferences_for_fixed_skills_and_mcps() { agent_id: "claude", rule_resource_type: "builtin_asset", rule_resource_ref: Some("preset-fixed"), - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", @@ -6843,8 +6822,6 @@ async fn create_with_auto_builtin_defaults_without_preferences_keeps_snapshot_va source: "builtin", owner_type: "system", source_ref: Some("preset-auto"), - source_version: None, - source_hash: None, name: "Preset Unset", name_i18n: "{}", description: Some("desc"), @@ -6854,7 +6831,6 @@ async fn create_with_auto_builtin_defaults_without_preferences_keeps_snapshot_va agent_id: "claude", rule_resource_type: "builtin_asset", rule_resource_ref: Some("preset-auto"), - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 094e65555..61cb7b928 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -992,8 +992,6 @@ async fn seed_assistant_definition( source: "user", owner_type: "user", source_ref: Some(assistant_id), - source_version: None, - source_hash: None, name: assistant_id, name_i18n: "{}", description: Some("test assistant"), @@ -1001,9 +999,8 @@ async fn seed_assistant_definition( avatar_type: "emoji", avatar_value: Some("πŸ€–"), agent_id, - rule_resource_type: "inline", + rule_resource_type: "user_file", rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", default_model_mode: "auto", diff --git a/crates/aionui-db/migrations/023_drop_unused_assistant_definition_fields.sql b/crates/aionui-db/migrations/023_drop_unused_assistant_definition_fields.sql new file mode 100644 index 000000000..d46c81e50 --- /dev/null +++ b/crates/aionui-db/migrations/023_drop_unused_assistant_definition_fields.sql @@ -0,0 +1,14 @@ +UPDATE assistant_definitions +SET rule_resource_type = CASE + WHEN source IN ('user', 'generated') THEN 'user_file' + ELSE 'none' + END, + rule_resource_ref = CASE + WHEN source IN ('user', 'generated') THEN COALESCE(rule_resource_ref, assistant_id) + ELSE NULL + END +WHERE rule_resource_type = 'inline'; + +ALTER TABLE assistant_definitions DROP COLUMN rule_inline_content; +ALTER TABLE assistant_definitions DROP COLUMN source_version; +ALTER TABLE assistant_definitions DROP COLUMN source_hash; diff --git a/crates/aionui-db/src/models/assistant.rs b/crates/aionui-db/src/models/assistant.rs index 2dbaefad0..24b501be4 100644 --- a/crates/aionui-db/src/models/assistant.rs +++ b/crates/aionui-db/src/models/assistant.rs @@ -45,8 +45,6 @@ pub struct AssistantDefinitionRow { pub source: String, pub owner_type: String, pub source_ref: Option, - pub source_version: Option, - pub source_hash: Option, pub name: String, pub name_i18n: String, pub description: Option, @@ -56,7 +54,6 @@ pub struct AssistantDefinitionRow { pub agent_id: String, pub rule_resource_type: String, pub rule_resource_ref: Option, - pub rule_inline_content: Option, pub recommended_prompts: String, pub recommended_prompts_i18n: String, pub default_model_mode: String, @@ -158,8 +155,6 @@ pub struct UpsertAssistantDefinitionParams<'a> { pub source: &'a str, pub owner_type: &'a str, pub source_ref: Option<&'a str>, - pub source_version: Option<&'a str>, - pub source_hash: Option<&'a str>, pub name: &'a str, pub name_i18n: &'a str, pub description: Option<&'a str>, @@ -169,7 +164,6 @@ pub struct UpsertAssistantDefinitionParams<'a> { pub agent_id: &'a str, pub rule_resource_type: &'a str, pub rule_resource_ref: Option<&'a str>, - pub rule_inline_content: Option<&'a str>, pub recommended_prompts: &'a str, pub recommended_prompts_i18n: &'a str, pub default_model_mode: &'a str, diff --git a/crates/aionui-db/src/repository/sqlite_assistant.rs b/crates/aionui-db/src/repository/sqlite_assistant.rs index 56e313f09..afd11b38c 100644 --- a/crates/aionui-db/src/repository/sqlite_assistant.rs +++ b/crates/aionui-db/src/repository/sqlite_assistant.rs @@ -427,9 +427,9 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { sqlx::query( "INSERT INTO assistant_definitions ( - id, assistant_id, source, owner_type, source_ref, source_version, source_hash, + id, assistant_id, source, owner_type, source_ref, name, name_i18n, description, description_i18n, avatar_type, avatar_value, - agent_id, rule_resource_type, rule_resource_ref, rule_inline_content, + agent_id, rule_resource_type, rule_resource_ref, recommended_prompts, recommended_prompts_i18n, default_model_mode, default_model_value, default_permission_mode, default_permission_value, @@ -437,14 +437,12 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { default_skills_mode, default_skill_ids, custom_skill_names, default_disabled_builtin_skill_ids, default_mcps_mode, default_mcp_ids, created_at, updated_at, deleted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) ON CONFLICT(id) DO UPDATE SET assistant_id = excluded.assistant_id, source = excluded.source, owner_type = excluded.owner_type, source_ref = excluded.source_ref, - source_version = excluded.source_version, - source_hash = excluded.source_hash, name = excluded.name, name_i18n = excluded.name_i18n, description = excluded.description, @@ -454,7 +452,6 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { agent_id = excluded.agent_id, rule_resource_type = excluded.rule_resource_type, rule_resource_ref = excluded.rule_resource_ref, - rule_inline_content = excluded.rule_inline_content, recommended_prompts = excluded.recommended_prompts, recommended_prompts_i18n = excluded.recommended_prompts_i18n, default_model_mode = excluded.default_model_mode, @@ -477,8 +474,6 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { .bind(params.source) .bind(params.owner_type) .bind(params.source_ref) - .bind(params.source_version) - .bind(params.source_hash) .bind(params.name) .bind(params.name_i18n) .bind(params.description) @@ -488,7 +483,6 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { .bind(params.agent_id) .bind(params.rule_resource_type) .bind(params.rule_resource_ref) - .bind(params.rule_inline_content) .bind(params.recommended_prompts) .bind(params.recommended_prompts_i18n) .bind(params.default_model_mode) @@ -727,8 +721,6 @@ mod tests { source: "user", owner_type: "user", source_ref: Some(id), - source_version: None, - source_hash: None, name, name_i18n: r#"{"zh-CN":"εŠ©ζ‰‹"}"#, description: Some("desc"), @@ -736,9 +728,8 @@ mod tests { avatar_type: "emoji", avatar_value: Some("πŸ€–"), agent_id: "gemini", - rule_resource_type: "inline", + rule_resource_type: "user_file", rule_resource_ref: None, - rule_inline_content: Some("# rule"), recommended_prompts: r#"["hello"]"#, recommended_prompts_i18n: "{}", default_model_mode: "auto", @@ -1017,7 +1008,7 @@ mod tests { let fetched = d.get_by_assistant_id("u1").await.unwrap().unwrap(); assert_eq!(fetched.name, "User One"); - assert_eq!(fetched.rule_inline_content.as_deref(), Some("# rule")); + assert_eq!(fetched.rule_resource_type, "user_file"); assert_eq!(fetched.avatar_type, "emoji"); assert_eq!(fetched.avatar_value.as_deref(), Some("πŸ€–")); } diff --git a/crates/aionui-db/tests/assistant_data_unification_schema.rs b/crates/aionui-db/tests/assistant_data_unification_schema.rs index 165bac2e4..38a17045b 100644 --- a/crates/aionui-db/tests/assistant_data_unification_schema.rs +++ b/crates/aionui-db/tests/assistant_data_unification_schema.rs @@ -54,6 +54,9 @@ async fn assistant_definition_table_has_expected_default_columns() { assert!(columns.iter().any(|name| name == "default_mcp_ids")); assert!(columns.iter().any(|name| name == "avatar_type")); assert!(columns.iter().any(|name| name == "avatar_value")); + assert!(!columns.iter().any(|name| name == "rule_inline_content")); + assert!(!columns.iter().any(|name| name == "source_version")); + assert!(!columns.iter().any(|name| name == "source_hash")); let overlay_columns: Vec = sqlx::query_scalar("SELECT name FROM pragma_table_info('assistant_overlays')") .fetch_all(db.pool()) diff --git a/crates/aionui-db/tests/assistant_definition_field_removal_migration.rs b/crates/aionui-db/tests/assistant_definition_field_removal_migration.rs new file mode 100644 index 000000000..040f81ef1 --- /dev/null +++ b/crates/aionui-db/tests/assistant_definition_field_removal_migration.rs @@ -0,0 +1,117 @@ +use std::borrow::Cow; +use std::path::Path; + +use sqlx::migrate::Migrator; +use sqlx::sqlite::SqlitePoolOptions; + +async fn run_migrations_through(pool: &sqlx::SqlitePool, max_version: i64) { + let full = Migrator::new(Path::new("migrations")).await.unwrap(); + let migrations = full + .migrations + .iter() + .filter(|migration| migration.version <= max_version) + .cloned() + .collect::>(); + Migrator { + migrations: Cow::Owned(migrations), + ignore_missing: false, + locking: true, + no_tx: false, + } + .run(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn migration_023_removes_unused_fields_and_preserves_definition() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + run_migrations_through(&pool, 22).await; + + sqlx::query( + "INSERT INTO assistant_definitions ( + id, assistant_id, source, owner_type, source_ref, source_version, source_hash, + name, name_i18n, description_i18n, avatar_type, agent_id, + rule_resource_type, rule_inline_content, + recommended_prompts, recommended_prompts_i18n, + default_model_mode, default_permission_mode, + default_skills_mode, default_skill_ids, custom_skill_names, + default_disabled_builtin_skill_ids, default_mcps_mode, default_mcp_ids, + created_at, updated_at + ) VALUES ( + 'definition-1', 'custom-1', 'user', 'user', 'custom-1', '1.0.0', 'hash', + 'Custom', '{}', '{}', 'none', 'aionrs', + 'inline', '# stale inline rule', + '[]', '{}', + 'auto', 'auto', + 'fixed', '[]', '[]', + '[]', 'auto', '[]', + 1, 1 + )", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO assistant_definitions ( + id, assistant_id, source, owner_type, source_ref, + name, name_i18n, description_i18n, avatar_type, agent_id, + rule_resource_type, rule_resource_ref, rule_inline_content, + recommended_prompts, recommended_prompts_i18n, + default_model_mode, default_permission_mode, + default_skills_mode, default_skill_ids, custom_skill_names, + default_disabled_builtin_skill_ids, default_mcps_mode, default_mcp_ids, + created_at, updated_at + ) VALUES ( + 'definition-2', 'builtin-1', 'builtin', 'system', 'builtin-1', + 'Builtin', '{}', '{}', 'none', 'aionrs', + 'inline', 'stale-ref', '# stale inline rule', + '[]', '{}', + 'auto', 'auto', + 'fixed', '[]', '[]', + '[]', 'auto', '[]', + 1, 1 + )", + ) + .execute(&pool) + .await + .unwrap(); + + run_migrations_through(&pool, 23).await; + + let columns: Vec = sqlx::query_scalar("SELECT name FROM pragma_table_info('assistant_definitions')") + .fetch_all(&pool) + .await + .unwrap(); + assert!(!columns.iter().any(|column| column == "rule_inline_content")); + assert!(!columns.iter().any(|column| column == "source_version")); + assert!(!columns.iter().any(|column| column == "source_hash")); + + let row: (String, String, Option) = sqlx::query_as( + "SELECT name, rule_resource_type, rule_resource_ref + FROM assistant_definitions + WHERE id = 'definition-1'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row.0, "Custom"); + assert_eq!(row.1, "user_file"); + assert_eq!(row.2.as_deref(), Some("custom-1")); + + let builtin: (String, Option) = sqlx::query_as( + "SELECT rule_resource_type, rule_resource_ref + FROM assistant_definitions + WHERE id = 'definition-2'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(builtin.0, "none"); + assert_eq!(builtin.1, None); +} diff --git a/crates/aionui-db/tests/feedback_diagnostics_repository.rs b/crates/aionui-db/tests/feedback_diagnostics_repository.rs index 8fb127660..820258187 100644 --- a/crates/aionui-db/tests/feedback_diagnostics_repository.rs +++ b/crates/aionui-db/tests/feedback_diagnostics_repository.rs @@ -464,22 +464,20 @@ async fn insert_feedback_fixture(db: &aionui_db::Database) { ] { sqlx::query( "INSERT INTO assistant_definitions \ - (id, assistant_id, source, owner_type, source_ref, source_version, source_hash, \ + (id, assistant_id, source, owner_type, source_ref, \ name, name_i18n, description, description_i18n, avatar_type, avatar_value, \ - agent_id, rule_resource_type, rule_resource_ref, rule_inline_content, \ + agent_id, rule_resource_type, rule_resource_ref, \ recommended_prompts, recommended_prompts_i18n, default_model_mode, default_model_value, \ default_permission_mode, default_permission_value, default_skills_mode, default_skill_ids, \ custom_skill_names, default_disabled_builtin_skill_ids, default_mcps_mode, default_mcp_ids, \ created_at, updated_at, deleted_at, default_thought_level_mode, default_thought_level_value) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(definition_id) .bind(assistant_id) .bind("user") .bind("user") .bind(None::) - .bind(None::) - .bind(None::) .bind(assistant_id) .bind("{}") .bind(None::) @@ -489,7 +487,6 @@ async fn insert_feedback_fixture(db: &aionui_db::Database) { .bind("opencode") .bind("none") .bind(None::) - .bind(None::) .bind("[]") .bind("{}") .bind("auto") diff --git a/crates/aionui-team/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index e61866be2..6b3c2fe97 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -547,8 +547,6 @@ mod tests { source: "builtin".into(), owner_type: "system".into(), source_ref: Some(assistant_id.into()), - source_version: None, - source_hash: None, name: assistant_id.into(), name_i18n: "{}".into(), description: None, @@ -556,9 +554,8 @@ mod tests { avatar_type: "emoji".into(), avatar_value: None, agent_id: agent_id.into(), - rule_resource_type: "inline".into(), + rule_resource_type: "none".into(), rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]".into(), recommended_prompts_i18n: "{}".into(), default_model_mode: "auto".into(), @@ -795,8 +792,6 @@ mod tests { source: "builtin".into(), owner_type: "system".into(), source_ref: Some("word-creator".into()), - source_version: None, - source_hash: None, name: "Word Creator".into(), name_i18n: "{}".into(), description: None, @@ -804,9 +799,8 @@ mod tests { avatar_type: "emoji".into(), avatar_value: None, agent_id: "aionrs".into(), - rule_resource_type: "inline".into(), + rule_resource_type: "none".into(), rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]".into(), recommended_prompts_i18n: "{}".into(), default_model_mode: "auto".into(), diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index e8d8ba223..bf1aef4ae 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1960,8 +1960,6 @@ fn word_creator_definition() -> AssistantDefinitionRow { source: "builtin".into(), owner_type: "system".into(), source_ref: Some("word-creator".into()), - source_version: None, - source_hash: None, name: "Word Creator".into(), name_i18n: "{}".into(), description: Some("Drafts Word documents".into()), @@ -1969,9 +1967,8 @@ fn word_creator_definition() -> AssistantDefinitionRow { avatar_type: "builtin_asset".into(), avatar_value: None, agent_id: "claude".into(), - rule_resource_type: "inline".into(), + rule_resource_type: "none".into(), rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]".into(), recommended_prompts_i18n: "{}".into(), default_model_mode: "auto".into(), @@ -2356,8 +2353,6 @@ async fn tc_create_team_prefers_assistant_avatar_over_backend_logo() { source: "builtin".into(), owner_type: "system".into(), source_ref: Some("assistant-lead".into()), - source_version: None, - source_hash: None, name: "Lead Assistant".into(), name_i18n: "{}".into(), description: None, @@ -2365,9 +2360,8 @@ async fn tc_create_team_prefers_assistant_avatar_over_backend_logo() { avatar_type: "builtin_asset".into(), avatar_value: Some("avatars/assistant-lead.png".into()), agent_id: "claude".into(), - rule_resource_type: "inline".into(), + rule_resource_type: "none".into(), rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]".into(), recommended_prompts_i18n: "{}".into(), default_model_mode: "auto".into(), @@ -2429,8 +2423,6 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( source: "user".into(), owner_type: "user".into(), source_ref: None, - source_version: None, - source_hash: None, name: "Lead Assistant".into(), name_i18n: "{}".into(), description: None, @@ -2438,9 +2430,8 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( avatar_type: "emoji".into(), avatar_value: Some("πŸ€–".into()), agent_id: "claude".into(), - rule_resource_type: "inline".into(), + rule_resource_type: "user_file".into(), rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]".into(), recommended_prompts_i18n: "{}".into(), default_model_mode: "auto".into(), @@ -2507,8 +2498,6 @@ async fn tc_create_team_derives_backend_from_assistant_when_backend_missing() { source: "user".into(), owner_type: "user".into(), source_ref: None, - source_version: None, - source_hash: None, name: "Lead Assistant".into(), name_i18n: "{}".into(), description: None, @@ -2516,9 +2505,8 @@ async fn tc_create_team_derives_backend_from_assistant_when_backend_missing() { avatar_type: "emoji".into(), avatar_value: Some("πŸ€–".into()), agent_id: "claude".into(), - rule_resource_type: "inline".into(), + rule_resource_type: "user_file".into(), rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]".into(), recommended_prompts_i18n: "{}".into(), default_model_mode: "auto".into(), @@ -2596,8 +2584,6 @@ async fn tc_create_team_ignores_requested_backend_when_assistant_id_present() { source: "user".into(), owner_type: "user".into(), source_ref: None, - source_version: None, - source_hash: None, name: "Lead Assistant".into(), name_i18n: "{}".into(), description: None, @@ -2605,9 +2591,8 @@ async fn tc_create_team_ignores_requested_backend_when_assistant_id_present() { avatar_type: "emoji".into(), avatar_value: Some("πŸ€–".into()), agent_id: "claude".into(), - rule_resource_type: "inline".into(), + rule_resource_type: "user_file".into(), rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]".into(), recommended_prompts_i18n: "{}".into(), default_model_mode: "auto".into(), @@ -2845,8 +2830,6 @@ async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { source: "user".into(), owner_type: "user".into(), source_ref: None, - source_version: None, - source_hash: None, name: "Worker Assistant".into(), name_i18n: "{}".into(), description: None, @@ -2854,9 +2837,8 @@ async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { avatar_type: "emoji".into(), avatar_value: Some("πŸ€–".into()), agent_id: "claude".into(), - rule_resource_type: "inline".into(), + rule_resource_type: "user_file".into(), rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]".into(), recommended_prompts_i18n: "{}".into(), default_model_mode: "auto".into(), @@ -2942,8 +2924,6 @@ async fn ta_add_agent_ignores_requested_backend_when_assistant_id_present() { source: "user".into(), owner_type: "user".into(), source_ref: None, - source_version: None, - source_hash: None, name: "Worker Assistant".into(), name_i18n: "{}".into(), description: None, @@ -2951,9 +2931,8 @@ async fn ta_add_agent_ignores_requested_backend_when_assistant_id_present() { avatar_type: "emoji".into(), avatar_value: Some("πŸ€–".into()), agent_id: "claude".into(), - rule_resource_type: "inline".into(), + rule_resource_type: "user_file".into(), rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]".into(), recommended_prompts_i18n: "{}".into(), default_model_mode: "auto".into(), @@ -4746,8 +4725,6 @@ async fn spawn_agent_in_session_succeeds_without_active_team_run() { source: "user".into(), owner_type: "user".into(), source_ref: None, - source_version: None, - source_hash: None, name: "Worker Assistant".into(), name_i18n: "{}".into(), description: None, @@ -4755,9 +4732,8 @@ async fn spawn_agent_in_session_succeeds_without_active_team_run() { avatar_type: "emoji".into(), avatar_value: Some("πŸ€–".into()), agent_id: "claude".into(), - rule_resource_type: "inline".into(), + rule_resource_type: "user_file".into(), rule_resource_ref: None, - rule_inline_content: None, recommended_prompts: "[]".into(), recommended_prompts_i18n: "{}".into(), default_model_mode: "auto".into(), From 0dd2b3783ebaeca5d06b43c22b85903f91571530 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 15 Jul 2026 20:05:01 +0800 Subject: [PATCH 2/4] fix(db): resolve assistant migration version conflict --- ....sql => 024_drop_unused_assistant_definition_fields.sql} | 0 .../tests/assistant_definition_field_removal_migration.rs | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename crates/aionui-db/migrations/{023_drop_unused_assistant_definition_fields.sql => 024_drop_unused_assistant_definition_fields.sql} (100%) diff --git a/crates/aionui-db/migrations/023_drop_unused_assistant_definition_fields.sql b/crates/aionui-db/migrations/024_drop_unused_assistant_definition_fields.sql similarity index 100% rename from crates/aionui-db/migrations/023_drop_unused_assistant_definition_fields.sql rename to crates/aionui-db/migrations/024_drop_unused_assistant_definition_fields.sql diff --git a/crates/aionui-db/tests/assistant_definition_field_removal_migration.rs b/crates/aionui-db/tests/assistant_definition_field_removal_migration.rs index 040f81ef1..337182ab0 100644 --- a/crates/aionui-db/tests/assistant_definition_field_removal_migration.rs +++ b/crates/aionui-db/tests/assistant_definition_field_removal_migration.rs @@ -24,13 +24,13 @@ async fn run_migrations_through(pool: &sqlx::SqlitePool, max_version: i64) { } #[tokio::test] -async fn migration_023_removes_unused_fields_and_preserves_definition() { +async fn migration_024_removes_unused_fields_and_preserves_definition() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); - run_migrations_through(&pool, 22).await; + run_migrations_through(&pool, 23).await; sqlx::query( "INSERT INTO assistant_definitions ( @@ -82,7 +82,7 @@ async fn migration_023_removes_unused_fields_and_preserves_definition() { .await .unwrap(); - run_migrations_through(&pool, 23).await; + run_migrations_through(&pool, 24).await; let columns: Vec = sqlx::query_scalar("SELECT name FROM pragma_table_info('assistant_definitions')") .fetch_all(&pool) From 26e28cafcccadee1f2640d7d4167f84b76e56ce0 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 15 Jul 2026 20:28:24 +0800 Subject: [PATCH 3/4] fix(assistant): make locale rule fallback deterministic --- crates/aionui-app/tests/conversation_e2e.rs | 26 +++++---- crates/aionui-assistant/src/service.rs | 63 ++++++++++++++------- 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/crates/aionui-app/tests/conversation_e2e.rs b/crates/aionui-app/tests/conversation_e2e.rs index b894a493b..69e643fcf 100644 --- a/crates/aionui-app/tests/conversation_e2e.rs +++ b/crates/aionui-app/tests/conversation_e2e.rs @@ -113,15 +113,16 @@ async fn t1_3_create_with_optional_fields() { } #[tokio::test] -async fn t1_3b_create_persists_locale_fallback_rule_in_assistant_snapshot() { +async fn t1_3b_create_persists_available_locale_fallback_rule_in_assistant_snapshot() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + let assistant_id = "locale-fallback-u1"; let create_assistant_req = json_with_token( "POST", "/api/assistants", json!({ - "id": "u1", + "id": assistant_id, "name": "Snapshot Assistant", "agent_id": "8e1acf31" }), @@ -135,8 +136,9 @@ async fn t1_3b_create_persists_locale_fallback_rule_in_assistant_snapshot() { "POST", "/api/skills/assistant-rule/write", json!({ - "assistant_id": "u1", - "content": "assistant snapshot rule" + "assistant_id": assistant_id, + "content": "zh-TW fallback snapshot rule", + "locale": "zh-TW" }), &token, &csrf, @@ -149,7 +151,11 @@ async fn t1_3b_create_persists_locale_fallback_rule_in_assistant_snapshot() { let state_repo = SqliteAssistantOverlayRepository::new(pool.clone()); let preference_repo = SqliteAssistantPreferenceRepository::new(pool); let conversation_repo = SqliteConversationRepository::new(services.database.pool().clone()); - let definition = definition_repo.get_by_assistant_id("u1").await.unwrap().unwrap(); + let definition = definition_repo + .get_by_assistant_id(assistant_id) + .await + .unwrap() + .unwrap(); definition_repo .upsert(&UpsertAssistantDefinitionParams { @@ -214,8 +220,8 @@ async fn t1_3b_create_persists_locale_fallback_rule_in_assistant_snapshot() { "type": "acp", "name": "Snapshot Flow", "assistant": { - "id": "u1", - "locale": "zh-CN", + "id": assistant_id, + "locale": "en-US", "conversation_overrides": { "model": "override-model", "skill_ids": ["override-skill"], @@ -233,7 +239,7 @@ async fn t1_3b_create_persists_locale_fallback_rule_in_assistant_snapshot() { let json = body_json(resp).await; let data = &json["data"]; - assert_eq!(data["assistant"]["id"], "u1"); + assert_eq!(data["assistant"]["id"], assistant_id); assert_eq!(data["assistant"]["backend"], "codex"); assert!(data["extra"].get("assistant_id").is_none()); assert!(data["extra"].get("preset_assistant_id").is_none()); @@ -256,9 +262,9 @@ async fn t1_3b_create_persists_locale_fallback_rule_in_assistant_snapshot() { .await .unwrap() .unwrap(); - assert_eq!(snapshot.assistant_id, "u1"); + assert_eq!(snapshot.assistant_id, assistant_id); assert_eq!(snapshot.agent_id, "8e1acf31"); - assert_eq!(snapshot.rules_content, "assistant snapshot rule"); + assert_eq!(snapshot.rules_content, "zh-TW fallback snapshot rule"); assert_eq!(snapshot.resolved_permission_value.as_deref(), Some("workspace-write")); assert_eq!(snapshot.resolved_skill_ids, r#"["override-skill"]"#); assert_eq!(snapshot.resolved_mcp_ids, r#"["override-mcp"]"#); diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 4a79fc891..7d560b8de 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -1546,15 +1546,7 @@ impl AssistantService { let rules_dir = self.user_rules_dir(); let content = read_assistant_md_with_legacy(&rules_dir, id, locale); if content.is_empty() { - let locale_less = locale - .filter(|value| !value.is_empty()) - .map(|_| read_assistant_md_with_legacy(&rules_dir, id, None)) - .unwrap_or_default(); - if locale_less.is_empty() { - read_first_assistant_md(&rules_dir, id) - } else { - locale_less - } + read_first_assistant_md(&rules_dir, id) } else { content } @@ -2839,21 +2831,29 @@ fn read_first_assistant_md(dir: &Path, id: &str) -> String { let encoded_exact = format!("{encoded_id}.md"); let legacy_prefix = format!("{id}."); let legacy_exact = format!("{id}.md"); - let mut fallback: Option = None; + let mut candidates = Vec::new(); for entry in entries.flatten() { let name = entry.file_name(); - let name = name.to_string_lossy(); - if name == encoded_exact || name == legacy_exact { - return read_file_or_empty(&entry.path()); - } - if fallback.is_none() - && (name.starts_with(&encoded_prefix) || name.starts_with(&legacy_prefix)) - && name.ends_with(".md") - { - fallback = Some(entry.path()); + let name = name.to_string_lossy().into_owned(); + let priority = if name == encoded_exact || name == legacy_exact { + 0 + } else if name.starts_with(&encoded_prefix) && name.ends_with(".md") { + 1 + } else if name.starts_with(&legacy_prefix) && name.ends_with(".md") { + 2 + } else { + continue; + }; + candidates.push((priority, name, entry.path())); + } + candidates.sort_by(|left, right| (left.0, &left.1).cmp(&(right.0, &right.1))); + for (_, _, path) in candidates { + let content = read_file_or_empty(&path); + if !content.is_empty() { + return content; } } - fallback.map(|path| read_file_or_empty(&path)).unwrap_or_default() + String::new() } /// Remove encoded and pre-encoding legacy markdown files for an assistant. @@ -5578,6 +5578,29 @@ mod tests { assert_eq!(fx.service.read_rule("u1", Some("en-US")).await.unwrap(), "rule body"); } + #[tokio::test] + async fn read_rule_user_fallback_skips_empty_files() { + let fx = fixture().await; + fx.service + .create(CreateAssistantRequest { + id: Some("u1".into()), + name: "A".into(), + ..req_default() + }) + .await + .unwrap(); + fx.service.write_rule("u1", None, "").await.unwrap(); + fx.service + .write_rule("u1", Some("zh-TW"), "available rule") + .await + .unwrap(); + + assert_eq!( + fx.service.read_rule("u1", Some("en-US")).await.unwrap(), + "available rule" + ); + } + #[tokio::test] async fn write_rule_builtin_rejects() { let fx = fixture_with_builtins(vec![mk_builtin("builtin-office", "Office")]).await; From 7a635643b98259724933854ba2ff906fc23bb8c4 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 16 Jul 2026 10:01:34 +0800 Subject: [PATCH 4/4] fix(assistant): migrate locale-less rule during fallback --- crates/aionui-assistant/src/service.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 7d560b8de..2044acb02 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -1545,11 +1545,18 @@ impl AssistantService { fn read_user_rule_with_fallback(&self, id: &str, locale: Option<&str>) -> String { let rules_dir = self.user_rules_dir(); let content = read_assistant_md_with_legacy(&rules_dir, id, locale); - if content.is_empty() { - read_first_assistant_md(&rules_dir, id) - } else { - content + if !content.is_empty() { + return content; } + + if locale.is_some_and(|value| !value.is_empty()) { + let locale_less = read_assistant_md_with_legacy(&rules_dir, id, None); + if !locale_less.is_empty() { + return locale_less; + } + } + + read_first_assistant_md(&rules_dir, id) } /// Write an assistant rule file. User-authored and generated assistants