diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 644a3c368..21fceb8a1 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -197,6 +197,11 @@ pub async fn build_module_states( RouterBuildError::new("router.assistant.bootstrap", "failed to bootstrap assistant storage").with_source(error) })?; let cron = build_cron_state(services); + // Cron builds its own ConversationService (not a clone of the shared one), + // so wire the assistant rule dispatcher here — otherwise scheduled runs + // resolve empty rules. Mirrors the interactive path in build_conversation_state. + cron.conversation_service + .with_assistant_dispatcher(assistant.service.clone() as Arc); cron.cron_service.init().await; tracing::info!( elapsed_ms = boot.elapsed().as_millis(), diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 30cf4d6fa..71546ef67 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -1132,10 +1132,22 @@ impl AssistantService { .and_then(|b| String::from_utf8(b).ok()) .unwrap_or_default()) } - AssistantSource::User => { - let path = self.user_rule_path(id, locale); - Ok(read_file_or_empty(&path)) - } + AssistantSource::User => Ok(self.read_user_rule_with_fallback(id, locale)), + } + } + + /// Read a user assistant's rule, falling back to any saved `.*.md` file + /// when the locale-specific `..md` is absent. Scheduled/cron runs + /// create the conversation with `assistant: None`, so no UI locale reaches + /// rule resolution and the localized file would otherwise be missed — + /// silently dropping the assistant's rules. Kept separate so the `read_rule` + /// match arm stays a one-line call (minimizes upstream-merge surface). + 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)); + if content.is_empty() { + read_first_assistant_md(&self.user_rules_dir(), id) + } else { + content } } @@ -1902,6 +1914,31 @@ 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_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 mut fallback: Option = None; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name == exact { + return read_file_or_empty(&entry.path()); + } + if fallback.is_none() && name.starts_with(&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. fn remove_assistant_md_files(dir: &Path, id: &str) -> bool { @@ -2941,6 +2978,29 @@ mod tests { assert_eq!(content, "rule body"); } + #[tokio::test] + async fn read_rule_user_falls_back_to_saved_locale_when_locale_missing() { + // Scheduled/cron runs resolve rules without a locale (conversation is + // created with `assistant: None`). The rule is stored locale-suffixed + // (`u1.ko-KR.md`), so a locale-less or mismatched-locale read must still + // find it instead of silently returning empty. + let fx = fixture().await; + fx.service + .create(CreateAssistantRequest { + id: Some("u1".into()), + name: "A".into(), + ..req_default() + }) + .await + .unwrap(); + fx.service.write_rule("u1", Some("ko-KR"), "rule body").await.unwrap(); + + // No locale (the cron path) falls back to the saved file. + assert_eq!(fx.service.read_rule("u1", None).await.unwrap(), "rule body"); + // A different locale also falls back rather than returning empty. + assert_eq!(fx.service.read_rule("u1", Some("en-US")).await.unwrap(), "rule body"); + } + #[tokio::test] async fn write_rule_builtin_rejects() { let fx = fixture_with_builtins(vec![mk_builtin("builtin-office", "Office")]).await;