Skip to main content

microclaw_engine/
chat_commands.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use crate::agent_engine::archive_conversation;
5use crate::config::{
6    normalize_model_name, resolve_model_name_with_fallback, Config, ResolvedLlmProviderProfile,
7};
8use crate::http_client::llm_user_agent;
9use crate::internal::storage::db::{call_blocking, Database};
10use crate::internal::storage::usage::build_usage_report;
11use crate::internal::tool_runtime::todo_store::clear_todos;
12use crate::run_control;
13use crate::runtime::AppState;
14use microclaw_core::llm_types::Message;
15use serde::Deserialize;
16use tracing::warn;
17
18pub fn is_slash_command(text: &str) -> bool {
19    normalized_slash_command(text).is_some()
20}
21
22fn normalized_slash_command(text: &str) -> Option<&str> {
23    let mut s = text.trim_start();
24    loop {
25        if s.starts_with('/') {
26            return Some(s);
27        }
28        if s.starts_with("<@") {
29            let end = s.find('>')?;
30            s = s[end + 1..].trim_start();
31            continue;
32        }
33        if let Some(rest) = s.strip_prefix('@') {
34            if rest.is_empty() {
35                return None;
36            }
37            let end = rest
38                .char_indices()
39                .find(|(_, c)| c.is_whitespace())
40                .map(|(i, _)| i)
41                .unwrap_or(rest.len());
42            s = rest[end..].trim_start();
43            continue;
44        }
45        return None;
46    }
47}
48
49pub fn unknown_command_response() -> String {
50    "Unknown command.".to_string()
51}
52
53/// Render the in-chat command reference. Pure (no `AppState`) so it can be
54/// unit-tested and reused. Backs `/help`.
55pub fn build_help_response() -> String {
56    [
57        "MicroClaw commands",
58        "",
59        "Session & context",
60        "  /status              Session info: provider, model, message & task counts",
61        "  /clear               Clear this chat's session + history (keep scheduled tasks)",
62        "  /reset               Clear this chat's session + history",
63        "  /reset memory        Clear this chat's long-term memory (AGENTS.md)",
64        "  /stop                Abort the run currently in progress",
65        "  /archive             Archive the current session to disk",
66        "",
67        "Model & provider",
68        "  /model [name|reset]  Show or set the model for this chat",
69        "  /models [provider]   List available models",
70        "  /provider [name]     Show or set the provider for this chat",
71        "  /providers           List configured providers",
72        "",
73        "Skills",
74        "  /skills              List available skills",
75        "  /reload-skills       Reload skills from disk",
76        "  /learn               Distill this session into a reusable skill (control chats)",
77        "",
78        "Memory & usage",
79        "  /user [clear]        View or clear your USER.md profile",
80        "  /usage               Token usage report for this chat",
81        "  /learning [run_id|journal] Show run evidence or the self-learning journal",
82        "  /rewind [id]         List or restore conversation checkpoints",
83        "",
84        "Safety",
85        "  /approvals [clear]   List or revoke standing high-risk tool approvals for this chat",
86        "",
87        "Diagnostics (admin only)",
88        "  /log [N] [keyword]   Tail last N log lines (default 100, max 300); filter by keyword",
89        "",
90        "  /help                Show this message",
91        "",
92        "Tip: in groups, mention me first (e.g. @bot /status).",
93    ]
94    .join("\n")
95}
96
97async fn handle_learning_command(state: &AppState, chat_id: i64, args: &str) -> String {
98    let requested = args.trim().to_string();
99    if requested.len() > 128 {
100        return "Usage: /learning [run_id|journal]".into();
101    }
102    if requested.eq_ignore_ascii_case("journal") {
103        return match call_blocking(state.db.clone(), move |db| {
104            Ok((
105                db.get_learning_claims(Some(chat_id), 20)?,
106                db.get_skill_candidates(Some(chat_id), 20)?,
107                db.get_shadow_evaluations(Some(chat_id))?,
108                db.get_learning_journal(Some(chat_id), 30)?,
109            ))
110        })
111        .await
112        {
113            Ok((claims, candidates, evaluations, events)) => {
114                let mut lines = vec![
115                    "Learning Journal".to_string(),
116                    format!(
117                        "claims={} candidates={} shadow_evaluations={} events={}",
118                        claims.len(),
119                        candidates.len(),
120                        evaluations.len(),
121                        events.len()
122                    ),
123                ];
124                for event in events {
125                    lines.push(format!(
126                        "  [{}] {} {}:{} — {}{}",
127                        event.created_at,
128                        event.event_type,
129                        event.entity_type,
130                        event.entity_id,
131                        event.summary,
132                        event
133                            .undo_action
134                            .as_deref()
135                            .map(|action| format!(" (undo={action})"))
136                            .unwrap_or_default()
137                    ));
138                }
139                lines.join("\n")
140            }
141            Err(error) => format!("Failed to load Learning Journal: {error}"),
142        };
143    }
144    let detail = call_blocking(state.db.clone(), move |db| {
145        let run_id = if requested.is_empty() {
146            db.latest_experience_run_id(chat_id)?
147        } else {
148            Some(requested)
149        };
150        let Some(run_id) = run_id else {
151            return Ok(None);
152        };
153        Ok(db
154            .get_experience_run_detail(&run_id)?
155            .filter(|detail| detail.run.chat_id == chat_id))
156    })
157    .await;
158    let detail = match detail {
159        Ok(Some(detail)) => detail,
160        Ok(None) => return "No matching learning run in this chat.".into(),
161        Err(error) => return format!("Failed to load learning run: {error}"),
162    };
163    format_learning_run_detail(&detail)
164}
165
166fn format_learning_run_detail(
167    detail: &crate::internal::storage::db::ExperienceRunDetail,
168) -> String {
169    let mut lines = vec![
170        format!("Learning run {}", detail.run.run_id),
171        format!(
172            "status={} kind={} duration_ms={} tokens={} tool_calls={} tool_errors={}",
173            detail.run.status,
174            detail.run.run_kind,
175            detail.run.duration_ms.unwrap_or_default(),
176            detail.run.input_tokens + detail.run.output_tokens,
177            detail.run.tool_calls,
178            detail.run.tool_errors
179        ),
180        format!("objective: {}", detail.run.objective),
181        format!(
182            "task: {} / {} [{}]",
183            detail.run.task_signature.task_type,
184            detail.run.task_signature.task_family,
185            detail.run.task_signature.capability_tags.join(", ")
186        ),
187        format!(
188            "result: {}",
189            detail.run.result_summary.as_deref().unwrap_or("(none)")
190        ),
191        "".into(),
192        format!("Experiences used ({})", detail.retrieved_experiences.len()),
193    ];
194    if detail.retrieved_experiences.is_empty() {
195        lines.push("  (none)".into());
196    } else {
197        for retrieval in &detail.retrieved_experiences {
198            lines.push(format!(
199                "  #{} {} score={:.3} objective={} result={} reason={}",
200                retrieval.rank,
201                retrieval.source_run_id,
202                retrieval.relevance_score,
203                retrieval.source_objective,
204                retrieval
205                    .source_result_summary
206                    .as_deref()
207                    .unwrap_or("(none)"),
208                retrieval.selection_reason
209            ));
210        }
211    }
212    lines.push(String::new());
213    lines.push(format!(
214        "Experiences rejected ({})",
215        detail.rejected_experiences.len()
216    ));
217    if detail.rejected_experiences.is_empty() {
218        lines.push("  (none)".into());
219    } else {
220        for rejection in &detail.rejected_experiences {
221            lines.push(format!(
222                "  {} score={:.3} objective={} reason={}",
223                rejection.source_run_id,
224                rejection.relevance_score,
225                rejection.source_objective,
226                rejection.rejection_reason
227            ));
228        }
229    }
230    lines.push(String::new());
231    lines.push(format!("Outcome evidence ({})", detail.outcomes.len()));
232    if detail.outcomes.is_empty() {
233        lines.push("  (none)".into());
234    } else {
235        for outcome in &detail.outcomes {
236            let evidence = outcome.evidence.as_deref().unwrap_or("(none)");
237            let end = microclaw_core::text::floor_char_boundary(evidence, evidence.len().min(500));
238            lines.push(format!(
239                "  [{}:{}] {} confidence={:.2}: {}",
240                outcome.source_kind,
241                outcome.source_name,
242                outcome.verdict,
243                outcome.confidence,
244                &evidence[..end]
245            ));
246        }
247    }
248    lines.push(String::new());
249    lines.push(format!("Skills used ({})", detail.activated_skills.len()));
250    for skill in &detail.activated_skills {
251        lines.push(format!(
252            "  {} v{}",
253            skill.skill_name,
254            skill
255                .skill_version
256                .map(|version| version.to_string())
257                .unwrap_or_else(|| "?".into())
258        ));
259    }
260    lines.join("\n")
261}
262
263/// `/learn` — user-triggered skill distillation (the Hermes `/learn`
264/// pattern). Enqueues a forced skill-review job: the worker bypasses the
265/// automatic threshold/success gates (the user explicitly asked) but keeps
266/// all content-security scans and the agent-created cap, then posts the
267/// outcome back to the chat. Queued rather than inline because slash
268/// commands must stay fast — an inline LLM round-trip would stall
269/// single-threaded channel loops (e.g. the Weixin poller) and webhook
270/// handlers. Restricted to control chats: distilled skills alter future
271/// agent behavior in every chat.
272async fn handle_learn_command(state: &AppState, chat_id: i64) -> String {
273    if !state.config.is_control_chat(chat_id) {
274        return "Permission denied: /learn is restricted to control chats.".to_string();
275    }
276    state.skill_review_queue.enqueue_learn(chat_id);
277    "Distilling this session into a skill — I'll post the result here shortly.".to_string()
278}
279
280pub(crate) fn format_learn_reply(outcome: &crate::skill_review::LearnOutcome) -> String {
281    use crate::skill_review::LearnOutcome;
282    match outcome {
283        LearnOutcome::Applied(applied) => {
284            let verb = match applied.action_kind {
285                "create" => "Created",
286                "edit" => "Updated",
287                "patch" => "Patched",
288                other => other,
289            };
290            format!(
291                "{verb} skill '{}' (v{}) from this session. It will be considered in future \
292                 conversations; use /skills to inspect it or skill_manage to edit/delete it.",
293                applied.name, applied.version
294            )
295        }
296        LearnOutcome::Nothing(reason) => format!("No skill saved: {reason}"),
297        LearnOutcome::Skipped(reason) => format!("Nothing to learn: {reason}"),
298        LearnOutcome::Error(e) => format!("Learn failed: {e}"),
299    }
300}
301
302/// Show or clear the per-chat USER.md user model. Backs the `/user` slash
303/// command. Lives outside `handle_chat_command` so it can be unit-tested
304/// without spinning up the full AppState match arm.
305fn handle_user_command(state: &AppState, caller_channel: &str, chat_id: i64, args: &str) -> String {
306    let args = args.trim();
307    if args == "clear" {
308        match state.memory.clear_chat_user_model(caller_channel, chat_id) {
309            Ok(true) => "USER.md cleared. The reflector will rebuild it on the next tick.".into(),
310            Ok(false) => "No USER.md to clear for this chat.".into(),
311            Err(e) => format!("Failed to clear USER.md: {e}"),
312        }
313    } else if args.is_empty() {
314        match state.memory.read_chat_user_model(caller_channel, chat_id) {
315            Some(content) if !content.trim().is_empty() => {
316                let cap = state.config.user_model_max_chars;
317                let cap_note = if cap == 0 {
318                    "(layer disabled — user_model_max_chars=0)".to_string()
319                } else {
320                    format!("({}/{} chars)", content.chars().count(), cap)
321                };
322                format!("USER.md {cap_note}:\n\n{}", content.trim())
323            }
324            _ => "No USER.md yet — the reflector populates it from PROFILE memories. Send a few personal facts and check back after the next reflector tick.".into(),
325        }
326    } else {
327        "Usage: /user            show current USER.md\n       /user clear     remove USER.md so the reflector rebuilds it".into()
328    }
329}
330
331/// Handle `/rewind` (list checkpoints) or `/rewind <hash>` (restore).
332async fn handle_rewind_command(
333    state: &AppState,
334    caller_channel: &str,
335    chat_id: i64,
336    args: &str,
337) -> String {
338    if !state.config.checkpoints_enabled {
339        return "Checkpoints are disabled. Set `checkpoints_enabled: true` in microclaw.config.yaml \
340                to record per-turn snapshots of this chat's working directory."
341            .into();
342    }
343
344    let working_dir = crate::internal::tool_runtime::runtime::working_dir_for_context(
345        std::path::Path::new(&state.config.working_dir),
346        state.config.working_dir_isolation,
347        caller_channel,
348        chat_id,
349    );
350    let shadow_root = std::path::PathBuf::from(&state.config.data_dir).join("checkpoints");
351    let shadow_repo = crate::checkpoint::shadow_repo_path(&shadow_root, &working_dir);
352
353    if args.is_empty() {
354        match crate::checkpoint::list(&shadow_repo, &working_dir, 20).await {
355            Ok(entries) if entries.is_empty() => {
356                "No checkpoints yet — they're created at the start of each agent turn that modifies files."
357                    .into()
358            }
359            Ok(entries) => {
360                let mut out = String::from("Recent checkpoints (newest first):\n\n");
361                for e in entries {
362                    out.push_str(&format!(
363                        "  {}  {}  {}\n",
364                        e.commit, e.timestamp, e.label
365                    ));
366                }
367                out.push_str("\nUse `/rewind <hash>` to restore.");
368                out
369            }
370            Err(e) => format!("Failed to list checkpoints: {e}"),
371        }
372    } else {
373        match crate::checkpoint::restore(&shadow_repo, &working_dir, args).await {
374            Ok(()) => format!("Restored working directory to checkpoint {args}."),
375            Err(e) => format!("Restore failed: {e}"),
376        }
377    }
378}
379
380#[derive(Clone, Copy)]
381enum PersistedOverride<'a> {
382    Unchanged,
383    Clear,
384    Set(&'a str),
385}
386
387fn config_path_for_save() -> Result<PathBuf, String> {
388    match Config::resolve_config_path() {
389        Ok(Some(path)) => Ok(path),
390        Ok(None) => Ok(PathBuf::from("./microclaw.config.yaml")),
391        Err(e) => Err(e.to_string()),
392    }
393}
394
395fn persist_channel_llm_overrides(
396    config: &Config,
397    caller_channel: &str,
398    provider: PersistedOverride<'_>,
399    model: PersistedOverride<'_>,
400) -> Result<(), String> {
401    let path = config_path_for_save()?;
402    let mut cfg = Config::load().unwrap_or_else(|_| config.clone());
403    let before_cfg = cfg.clone();
404    match provider {
405        PersistedOverride::Unchanged => {}
406        PersistedOverride::Clear => cfg.set_provider_override_for_channel(caller_channel, None),
407        PersistedOverride::Set(value) => {
408            cfg.set_provider_override_for_channel(caller_channel, Some(value))
409        }
410    }
411    match model {
412        PersistedOverride::Unchanged => {}
413        PersistedOverride::Clear => cfg.set_model_override_for_channel(caller_channel, None),
414        PersistedOverride::Set(value) => {
415            cfg.set_model_override_for_channel(caller_channel, Some(value))
416        }
417    }
418    cfg.post_deserialize().map_err(|e| e.to_string())?;
419    crate::config_persistence::save_config_delta_preserving_comments(&path, &before_cfg, &cfg)
420        .map_err(|e| e.to_string())
421}
422
423/// Default / maximum number of log lines a single `/log` invocation tails.
424const LOG_TAIL_DEFAULT_LINES: usize = 100;
425const LOG_TAIL_MAX_LINES: usize = 300;
426/// When a keyword filter is given, scan up to this many recent lines for matches
427/// before keeping the last N. Bounds the work while still reaching back far
428/// enough to catch a rare term (e.g. an error from an hour ago).
429const LOG_FILTER_SCAN_LINES: usize = 5000;
430
431/// Tail the bot's own log files into chat for troubleshooting. Supports
432/// `/log [N]`, `/log read 100`, and a keyword filter: `/log error`,
433/// `/log 50 scheduler`. Admin-gated: logs can contain other chats' content and
434/// secrets, so access is restricted to configured `control_chat_ids`. File
435/// logging is only active under the gateway (`MICROCLAW_GATEWAY`); in console
436/// mode logs stream to stdout and there is no file to read.
437fn build_log_response(config: &Config, chat_id: i64, command: &str) -> String {
438    // Default-deny when no control chats are configured.
439    if config.control_chat_ids.is_empty() {
440        return "Log access is disabled. Set `control_chat_ids` in the config to your admin chat(s) first — logs can contain other chats' content and secrets, so this is admin-only.".to_string();
441    }
442    if !config.is_control_chat(chat_id) {
443        return "Not authorized: `/log` is restricted to the configured control chat(s)."
444            .to_string();
445    }
446
447    // Parse the args after `/log`: a bare number sets the line count; the verbs
448    // "read"/"print" are ignored; anything else becomes a case-insensitive
449    // keyword filter (joined when multiple words are given).
450    let mut requested_lines: Option<usize> = None;
451    let mut filter_tokens: Vec<&str> = Vec::new();
452    for tok in command.split_whitespace().skip(1) {
453        if let Ok(n) = tok.parse::<usize>() {
454            requested_lines.get_or_insert(n);
455        } else if tok.eq_ignore_ascii_case("read") || tok.eq_ignore_ascii_case("print") {
456            continue;
457        } else {
458            filter_tokens.push(tok);
459        }
460    }
461    let lines = requested_lines
462        .unwrap_or(LOG_TAIL_DEFAULT_LINES)
463        .clamp(1, LOG_TAIL_MAX_LINES);
464    let filter = (!filter_tokens.is_empty()).then(|| filter_tokens.join(" ").to_lowercase());
465
466    let log_dir = PathBuf::from(config.runtime_data_dir()).join("logs");
467    // With a filter, scan a wider window then keep the last N matches; otherwise
468    // just tail the last N lines directly.
469    let scan = if filter.is_some() {
470        LOG_FILTER_SCAN_LINES
471    } else {
472        lines
473    };
474    match crate::logging::read_last_lines_from_logs(&log_dir, scan) {
475        Ok(rows) if rows.is_empty() => format!(
476            "No log lines found in {}. File logging is only active when running under the gateway (MICROCLAW_GATEWAY); in console mode logs go to stdout.",
477            log_dir.display()
478        ),
479        Ok(rows) => {
480            let (rows, suffix) = match &filter {
481                Some(kw) => {
482                    let mut matched: Vec<String> = rows
483                        .into_iter()
484                        .filter(|l| l.to_lowercase().contains(kw))
485                        .collect();
486                    if matched.len() > lines {
487                        matched = matched.split_off(matched.len() - lines);
488                    }
489                    (matched, format!(" matching \"{kw}\""))
490                }
491                None => (rows, String::new()),
492            };
493            if rows.is_empty() {
494                return format!("No log line{suffix} in the last {LOG_FILTER_SCAN_LINES} lines.");
495            }
496            let body = rows.join("\n");
497            format!(
498                "📜 Last {} log line(s){suffix}:\n```\n{body}\n```",
499                rows.len()
500            )
501        }
502        Err(e) => format!("Failed to read logs from {}: {e}", log_dir.display()),
503    }
504}
505
506pub async fn handle_chat_command(
507    state: &AppState,
508    chat_id: i64,
509    caller_channel: &str,
510    command_text: &str,
511    sender_id: Option<&str>,
512) -> Option<String> {
513    let trimmed = normalized_slash_command(command_text)?.trim();
514
515    if trimmed == "/help" || trimmed == "/commands" || trimmed == "/?" {
516        return Some(build_help_response());
517    }
518
519    if trimmed == "/reset memory" {
520        let _ = call_blocking(state.db.clone(), move |db| db.clear_chat_memory(chat_id)).await;
521        let groups_dir = std::path::PathBuf::from(&state.config.data_dir).join("groups");
522        let chat_memory_path = groups_dir
523            .join(caller_channel)
524            .join(chat_id.to_string())
525            .join("AGENTS.md");
526        if let Err(e) = std::fs::remove_file(&chat_memory_path) {
527            if e.kind() != std::io::ErrorKind::NotFound {
528                warn!(
529                    "Failed to remove chat memory file for chat {} at {}: {}",
530                    chat_id,
531                    chat_memory_path.display(),
532                    e
533                );
534            }
535        }
536        return Some("Memory cleared for this chat.".to_string());
537    }
538
539    if trimmed == "/clear" {
540        let _ = call_blocking(state.db.clone(), move |db| {
541            db.clear_chat_conversation(chat_id)
542        })
543        .await;
544        let groups_dir = std::path::PathBuf::from(&state.config.data_dir).join("groups");
545        if let Err(e) = clear_todos(&groups_dir, caller_channel, chat_id) {
546            warn!("Failed to clear TODO.json for chat {}: {}", chat_id, e);
547        }
548        return Some("Context cleared (session + chat history, scheduled tasks kept).".to_string());
549    }
550
551    if trimmed == "/plan" || trimmed.starts_with("/plan ") {
552        let arg = trimmed.strip_prefix("/plan").map(str::trim).unwrap_or("");
553        let mode_key = format!("chat_mode:{chat_id}");
554        if arg.eq_ignore_ascii_case("off") || arg.eq_ignore_ascii_case("exit") {
555            let plan_key = format!("pending_plan_approval:{chat_id}");
556            let _ = call_blocking(state.db.clone(), move |db| {
557                db.delete_runtime_meta(&mode_key)?;
558                db.delete_runtime_meta(&plan_key)?;
559                Ok::<_, microclaw_core::error::MicroClawError>(())
560            })
561            .await;
562            return Some("Plan mode off — back to normal execution.".to_string());
563        }
564        if arg.is_empty() || arg.eq_ignore_ascii_case("on") {
565            let _ = call_blocking(state.db.clone(), move |db| {
566                db.set_runtime_meta(&mode_key, "plan")
567            })
568            .await;
569            return Some(
570                "📋 Plan mode on: I'll research with read-only tools and present a plan for approval before changing anything. `/plan off` to exit."
571                    .to_string(),
572            );
573        }
574        return Some("Usage: /plan (enter plan mode), /plan off (exit).".to_string());
575    }
576
577    if trimmed == "/reset" {
578        let _ = call_blocking(state.db.clone(), move |db| db.clear_chat_context(chat_id)).await;
579        let groups_dir = std::path::PathBuf::from(&state.config.data_dir).join("groups");
580        if let Err(e) = clear_todos(&groups_dir, caller_channel, chat_id) {
581            warn!("Failed to clear TODO.json for chat {}: {}", chat_id, e);
582        }
583        return Some("Context cleared (session + chat history).".to_string());
584    }
585
586    if trimmed == "/stop" {
587        let stopped = run_control::abort_runs(caller_channel, chat_id).await;
588        if stopped > 0 {
589            return Some(format!("Stopping current run ({stopped} active)."));
590        }
591        return Some("No active run in this chat.".to_string());
592    }
593
594    if trimmed == "/skills" {
595        return Some(state.skills.list_skills_formatted());
596    }
597
598    if let Some(rest) = trimmed.strip_prefix("/rewind") {
599        return Some(handle_rewind_command(state, caller_channel, chat_id, rest.trim()).await);
600    }
601
602    if let Some(args) = trimmed.strip_prefix("/user") {
603        return Some(handle_user_command(state, caller_channel, chat_id, args));
604    }
605
606    if trimmed == "/reload-skills" {
607        let count = state.skills.reload().len();
608        return Some(format!("Reloaded {count} skills from disk."));
609    }
610
611    if trimmed == "/learn" {
612        return Some(handle_learn_command(state, chat_id).await);
613    }
614
615    if trimmed == "/archive" {
616        if let Ok(Some((json, _))) =
617            call_blocking(state.db.clone(), move |db| db.load_session(chat_id)).await
618        {
619            let messages: Vec<Message> = serde_json::from_str(&json).unwrap_or_default();
620            if messages.is_empty() {
621                return Some("No session to archive.".to_string());
622            }
623            archive_conversation(&state.config.data_dir, caller_channel, chat_id, &messages);
624            return Some(format!("Archived {} messages.", messages.len()));
625        }
626        return Some("No session to archive.".to_string());
627    }
628
629    if trimmed == "/usage" {
630        let text = match build_usage_report(state.db.clone(), chat_id).await {
631            Ok(v) => v,
632            Err(e) => format!("Failed to query usage statistics: {e}"),
633        };
634        return Some(text);
635    }
636
637    if trimmed == "/learning" || trimmed.starts_with("/learning ") {
638        let args = trimmed.strip_prefix("/learning").unwrap_or_default();
639        return Some(handle_learning_command(state, chat_id, args).await);
640    }
641
642    if trimmed == "/status" {
643        return Some(
644            build_status_response(
645                state.db.clone(),
646                &state.config,
647                state.llm_provider_overrides.clone(),
648                state.llm_model_overrides.clone(),
649                chat_id,
650                caller_channel,
651            )
652            .await,
653        );
654    }
655
656    if trimmed == "/approvals" || trimmed == "/approvals clear" {
657        let prefix = format!("approved_tool:{chat_id}:");
658        if trimmed == "/approvals clear" {
659            let removed = call_blocking(state.db.clone(), {
660                let prefix = prefix.clone();
661                move |db| {
662                    let removed = db.delete_runtime_meta_prefix(&prefix)?;
663                    if removed > 0 {
664                        db.log_audit_event(
665                            "approval",
666                            &format!("chat:{chat_id}"),
667                            "standing_grants_cleared",
668                            None,
669                            "removed",
670                            Some(&format!("{removed} grant(s)")),
671                        )?;
672                    }
673                    Ok::<_, microclaw_core::error::MicroClawError>(removed)
674                }
675            })
676            .await;
677            return Some(match removed {
678                Ok(0) => "No standing tool approvals to clear for this chat.".to_string(),
679                Ok(n) => format!("Cleared {n} standing tool approval(s) for this chat."),
680                Err(e) => format!("Failed to clear standing approvals: {e}"),
681            });
682        }
683        let grants = call_blocking(state.db.clone(), move |db| {
684            db.list_runtime_meta_prefix(&prefix)
685        })
686        .await;
687        return Some(match grants {
688            Ok(rows) if rows.is_empty() => {
689                "No standing tool approvals for this chat. Reply \"2\" or \"always\" to an \
690                 approval prompt to add one."
691                    .to_string()
692            }
693            Ok(rows) => {
694                let mut out = String::from("Standing tool approvals for this chat:\n");
695                for (key, since) in rows {
696                    let tool = key.rsplit(':').next().unwrap_or(&key);
697                    out.push_str(&format!("- {tool} (since {since})\n"));
698                }
699                out.push_str("Revoke them all with /approvals clear.");
700                out
701            }
702            Err(e) => format!("Failed to list standing approvals: {e}"),
703        });
704    }
705
706    if trimmed == "/log"
707        || trimmed == "/logs"
708        || trimmed.starts_with("/log ")
709        || trimmed.starts_with("/logs ")
710    {
711        return Some(build_log_response(&state.config, chat_id, trimmed));
712    }
713
714    if trimmed == "/start" {
715        if let Some(id) = sender_id.map(str::trim).filter(|v| !v.is_empty()) {
716            return Some(format!("Hello MicroClaw :) Your ID: {id}"));
717        }
718        return Some("Hello MicroClaw :)".to_string());
719    }
720
721    if trimmed == "/providers" {
722        return Some(
723            build_providers_response(
724                &state.config,
725                state.llm_provider_overrides.clone(),
726                state.llm_model_overrides.clone(),
727                caller_channel,
728            )
729            .await,
730        );
731    }
732
733    if trimmed == "/provider" || trimmed.starts_with("/provider ") {
734        return Some(
735            build_provider_response_with_persistence(
736                &state.config,
737                state.llm_provider_overrides.clone(),
738                state.llm_model_overrides.clone(),
739                caller_channel,
740                chat_id,
741                trimmed,
742                true,
743            )
744            .await,
745        );
746    }
747
748    if trimmed == "/models" || trimmed.starts_with("/models ") {
749        return Some(
750            build_models_response(
751                &state.config,
752                state.llm_provider_overrides.clone(),
753                state.llm_model_overrides.clone(),
754                caller_channel,
755                trimmed,
756            )
757            .await,
758        );
759    }
760
761    if trimmed == "/model" || trimmed.starts_with("/model ") {
762        return Some(
763            build_model_response_with_persistence(
764                &state.config,
765                state.llm_provider_overrides.clone(),
766                state.llm_model_overrides.clone(),
767                caller_channel,
768                chat_id,
769                trimmed,
770                true,
771            )
772            .await,
773        );
774    }
775
776    if let Some(plugin_response) =
777        maybe_handle_plugin_command(&state.config, trimmed, chat_id, caller_channel).await
778    {
779        return Some(plugin_response);
780    }
781
782    None
783}
784
785pub async fn build_status_response(
786    db: Arc<Database>,
787    config: &Config,
788    llm_provider_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
789    llm_model_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
790    chat_id: i64,
791    caller_channel: &str,
792) -> String {
793    let (profile, model) = resolve_effective_provider_and_model(
794        config,
795        &llm_provider_overrides,
796        &llm_model_overrides,
797        caller_channel,
798    )
799    .await;
800    let provider = profile.alias.clone();
801
802    let session_line = match call_blocking(db.clone(), move |db| db.load_session(chat_id)).await {
803        Ok(Some((json, updated_at))) => {
804            let messages: Vec<Message> = serde_json::from_str(&json).unwrap_or_default();
805            format!(
806                "Session: active ({} messages, updated at {})",
807                messages.len(),
808                updated_at
809            )
810        }
811        Ok(None) => "Session: empty".to_string(),
812        Err(e) => format!("Session: unavailable ({e})"),
813    };
814
815    let task_line = match call_blocking(db.clone(), move |db| db.get_tasks_for_chat(chat_id)).await
816    {
817        Ok(tasks) => {
818            let mut active = 0usize;
819            let mut paused = 0usize;
820            let mut completed = 0usize;
821            let mut cancelled = 0usize;
822            let mut other = 0usize;
823
824            for task in tasks {
825                match task.status.as_str() {
826                    "active" => active += 1,
827                    "paused" => paused += 1,
828                    "completed" => completed += 1,
829                    "cancelled" => cancelled += 1,
830                    _ => other += 1,
831                }
832            }
833
834            let total = active + paused + completed + cancelled + other;
835            if other == 0 {
836                format!(
837                    "Scheduled tasks: total={total}, active={active}, paused={paused}, completed={completed}, cancelled={cancelled}"
838                )
839            } else {
840                format!(
841                    "Scheduled tasks: total={total}, active={active}, paused={paused}, completed={completed}, cancelled={cancelled}, other={other}"
842                )
843            }
844        }
845        Err(e) => format!("Scheduled tasks: unavailable ({e})"),
846    };
847
848    let runtime_line = match call_blocking(db.clone(), move |db| {
849        let active = db
850            .list_active_turns()?
851            .into_iter()
852            .find(|turn| turn.chat_id == chat_id);
853        let delivery = db.outbound_delivery_health()?;
854        let recoveries = db.list_audit_logs(Some("turn_recovery"), 20)?.len();
855        Ok::<_, microclaw_core::error::MicroClawError>((active, delivery, recoveries))
856    })
857    .await
858    {
859        Ok((Some(turn), delivery, recoveries)) => format!(
860            "Durable run: {} iteration={} resumable={} progress={}\n\
861             Delivery: pending={} retrying={} failed={}\n\
862             Recoveries recorded: {}",
863            turn.phase,
864            turn.iteration + 1,
865            turn.resumable,
866            turn.progress_text.as_deref().unwrap_or("starting"),
867            delivery.pending_chunks,
868            delivery.retry_chunks,
869            delivery.failed_chunks,
870            recoveries
871        ),
872        Ok((None, delivery, recoveries)) => format!(
873            "Durable run: idle\nDelivery: pending={} retrying={} failed={}\nRecoveries recorded: {}",
874            delivery.pending_chunks, delivery.retry_chunks, delivery.failed_chunks, recoveries
875        ),
876        Err(error) => format!("Durable runtime: unavailable ({error})"),
877    };
878
879    let since_24h = (chrono::Utc::now() - chrono::Duration::hours(24)).to_rfc3339();
880    let health_line = match call_blocking(db.clone(), {
881        let since = since_24h.clone();
882        move |db| {
883            let dlq = db.count_scheduled_task_dlq(false)?;
884            let contracts = db.contract_verdict_counts_since(&since)?;
885            Ok::<_, microclaw_core::error::MicroClawError>((dlq, contracts))
886        }
887    })
888    .await
889    {
890        Ok((dlq, (verified, failed))) => {
891            let contracts = if verified + failed == 0 {
892                "Contracts (24h): none".to_string()
893            } else {
894                format!("Contracts (24h): verified={verified} failed={failed}")
895            };
896            format!("Scheduler DLQ: {dlq} unreplayed\n{contracts}")
897        }
898        Err(e) => format!("Scheduler DLQ: unavailable ({e})"),
899    };
900
901    let budget_line = {
902        let budget = config.token_budget.daily_per_chat;
903        if budget > 0 {
904            match call_blocking(db.clone(), move |db| {
905                db.get_llm_usage_summary_since(Some(chat_id), Some(&since_24h))
906            })
907            .await
908            {
909                Ok(usage) => format!(
910                    "Token budget: {} of {budget} used in the last 24h",
911                    usage.total_tokens
912                ),
913                Err(e) => format!("Token budget: unavailable ({e})"),
914            }
915        } else {
916            "Token budget: off".to_string()
917        }
918    };
919
920    let provider_snapshot = crate::llm::provider_failover_snapshot();
921    let provider_line = format!(
922        "Provider health: fallbacks={} consecutive_failures={}{}",
923        provider_snapshot.total_fallbacks,
924        provider_snapshot.consecutive_failures,
925        if provider_snapshot.breaker_open {
926            " (circuit breaker OPEN)"
927        } else {
928            ""
929        }
930    );
931
932    let restarts = crate::supervision::restart_counts();
933    let restart_line = if restarts.is_empty() {
934        "Loop restarts: none".to_string()
935    } else {
936        let parts: Vec<String> = restarts
937            .iter()
938            .map(|(name, count)| format!("{name}={count}"))
939            .collect();
940        format!("Loop restarts: {}", parts.join(" "))
941    };
942
943    format!(
944        "Status\nChannel: {caller_channel}\nProvider: {provider}\nModel: {model}\n{session_line}\n{task_line}\n{runtime_line}\n{health_line}\n{budget_line}\n{provider_line}\n{restart_line}"
945    )
946}
947
948pub async fn build_model_response(
949    config: &Config,
950    llm_provider_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
951    llm_model_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
952    caller_channel: &str,
953    _chat_id: i64,
954    command_text: &str,
955) -> String {
956    build_model_response_with_persistence(
957        config,
958        llm_provider_overrides,
959        llm_model_overrides,
960        caller_channel,
961        _chat_id,
962        command_text,
963        false,
964    )
965    .await
966}
967
968/// Override-map key for a per-chat model/provider override
969/// (`/model here …`). `#` cannot appear in channel names, so chat-scoped
970/// keys never collide with channel-wide ones.
971pub fn chat_scoped_override_key(channel: &str, chat_id: i64) -> String {
972    format!("{channel}#{chat_id}")
973}
974
975async fn build_model_response_with_persistence(
976    config: &Config,
977    llm_provider_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
978    llm_model_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
979    caller_channel: &str,
980    chat_id: i64,
981    command_text: &str,
982    persist_to_config: bool,
983) -> String {
984    let caller_channel = caller_channel.to_string();
985    let requested = command_text
986        .trim()
987        .strip_prefix("/model")
988        .map(str::trim)
989        .unwrap_or("");
990    let (profile, current_model) = resolve_effective_provider_and_model(
991        config,
992        &llm_provider_overrides,
993        &llm_model_overrides,
994        &caller_channel,
995    )
996    .await;
997    let provider = profile.alias.clone();
998
999    // `/model here <name>` — override for this chat only (runtime scope;
1000    // cleared with `/model here reset` or on restart).
1001    if let Some(rest) =
1002        requested
1003            .strip_prefix("here ")
1004            .or(if requested == "here" { Some("") } else { None })
1005    {
1006        let rest = rest.trim();
1007        let chat_key = chat_scoped_override_key(&caller_channel, chat_id);
1008        if rest.is_empty() {
1009            let overrides = llm_model_overrides.read().await;
1010            return match overrides.get(&chat_key) {
1011                Some(model) => format!("Model for this chat: {provider} / {model} (per-chat override; `/model here reset` to clear)"),
1012                None => format!("No per-chat model override. Current provider/model: {provider} / {current_model}. Set one with `/model here <name>`."),
1013            };
1014        }
1015        if rest.eq_ignore_ascii_case("reset") || rest.eq_ignore_ascii_case("default") {
1016            let mut overrides = llm_model_overrides.write().await;
1017            overrides.remove(&chat_key);
1018            return format!(
1019                "Per-chat model override cleared. Current provider/model: {provider} / {current_model}"
1020            );
1021        }
1022        if normalize_model_name(rest).is_none() {
1023            return format!(
1024                "Model '{rest}' is not a valid model id. Use `/model here reset` to clear the per-chat override."
1025            );
1026        }
1027        let mut allowed_models = profile.models.clone();
1028        if is_placeholder_model_list(&allowed_models) {
1029            if let Ok(live) = fetch_models_from_provider_api(&profile).await {
1030                if !live.is_empty() {
1031                    allowed_models = live;
1032                }
1033            }
1034        }
1035        if !allowed_models.is_empty() && !allowed_models.iter().any(|m| m == rest) {
1036            return format!(
1037                "Model '{rest}' is not configured for provider '{provider}'. Available: {}",
1038                allowed_models.join(", ")
1039            );
1040        }
1041        let mut overrides = llm_model_overrides.write().await;
1042        overrides.insert(chat_key, rest.to_string());
1043        return format!(
1044            "Model switched for this chat to: {provider} / {rest} (runtime only; `/model here reset` to clear)"
1045        );
1046    }
1047
1048    if requested.is_empty() {
1049        return format!("Current provider/model: {provider} / {current_model}");
1050    }
1051
1052    if requested.eq_ignore_ascii_case("reset") || requested.eq_ignore_ascii_case("default") {
1053        if persist_to_config {
1054            if let Err(e) = persist_channel_llm_overrides(
1055                config,
1056                &caller_channel,
1057                PersistedOverride::Unchanged,
1058                PersistedOverride::Clear,
1059            ) {
1060                return format!("Failed to persist model override reset: {e}");
1061            }
1062        }
1063        let mut overrides = llm_model_overrides.write().await;
1064        overrides.remove(&caller_channel);
1065        return format!(
1066            "Model override cleared. Current provider/model: {provider} / {}",
1067            profile.default_model
1068        );
1069    }
1070
1071    if normalize_model_name(requested).is_none() {
1072        return format!(
1073            "Model '{requested}' is not a valid model id. Use `/model reset` to clear the override."
1074        );
1075    }
1076
1077    let mut allowed_models = profile.models.clone();
1078    if is_placeholder_model_list(&allowed_models) {
1079        if let Ok(live) = fetch_models_from_provider_api(&profile).await {
1080            if !live.is_empty() {
1081                allowed_models = live;
1082            }
1083        }
1084    }
1085    if !allowed_models.is_empty() && !allowed_models.iter().any(|m| m == requested) {
1086        return format!(
1087            "Model '{requested}' is not configured for provider '{provider}'. Available: {}",
1088            allowed_models.join(", ")
1089        );
1090    }
1091
1092    if persist_to_config {
1093        if let Err(e) = persist_channel_llm_overrides(
1094            config,
1095            &caller_channel,
1096            PersistedOverride::Unchanged,
1097            PersistedOverride::Set(requested),
1098        ) {
1099            return format!("Failed to persist model override: {e}");
1100        }
1101    }
1102    let mut overrides = llm_model_overrides.write().await;
1103    overrides.insert(caller_channel.clone(), requested.to_string());
1104    format!("Model switched for this channel to: {provider} / {requested}")
1105}
1106
1107pub async fn build_providers_response(
1108    config: &Config,
1109    llm_provider_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
1110    llm_model_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
1111    caller_channel: &str,
1112) -> String {
1113    let (active_profile, active_model) = resolve_effective_provider_and_model(
1114        config,
1115        &llm_provider_overrides,
1116        &llm_model_overrides,
1117        caller_channel,
1118    )
1119    .await;
1120    let mut lines = vec!["Configured providers:".to_string()];
1121    for profile in config.list_llm_provider_profiles() {
1122        let marker = if profile.alias == active_profile.alias {
1123            " (active)"
1124        } else {
1125            ""
1126        };
1127        lines.push(format!(
1128            "- {}{} -> backend={}, default_model={}",
1129            profile.alias, marker, profile.provider, profile.default_model
1130        ));
1131    }
1132    lines.push(format!(
1133        "Current provider/model: {} / {}",
1134        active_profile.alias, active_model
1135    ));
1136    lines.join("\n")
1137}
1138
1139pub async fn build_provider_response(
1140    config: &Config,
1141    llm_provider_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
1142    llm_model_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
1143    caller_channel: &str,
1144    command_text: &str,
1145) -> String {
1146    build_provider_response_with_persistence(
1147        config,
1148        llm_provider_overrides,
1149        llm_model_overrides,
1150        caller_channel,
1151        0,
1152        command_text,
1153        false,
1154    )
1155    .await
1156}
1157
1158async fn build_provider_response_with_persistence(
1159    config: &Config,
1160    llm_provider_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
1161    llm_model_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
1162    caller_channel: &str,
1163    chat_id: i64,
1164    command_text: &str,
1165    persist_to_config: bool,
1166) -> String {
1167    let requested = command_text
1168        .trim()
1169        .strip_prefix("/provider")
1170        .map(str::trim)
1171        .unwrap_or("");
1172
1173    // `/provider here <alias>` — override for this chat only (runtime
1174    // scope; cleared with `/provider here reset` or on restart).
1175    if let Some(rest) =
1176        requested
1177            .strip_prefix("here ")
1178            .or(if requested == "here" { Some("") } else { None })
1179    {
1180        let rest = rest.trim();
1181        let chat_key = chat_scoped_override_key(caller_channel, chat_id);
1182        if rest.is_empty() {
1183            let overrides = llm_provider_overrides.read().await;
1184            return match overrides.get(&chat_key) {
1185                Some(alias) => format!(
1186                    "Provider for this chat: {alias} (per-chat override; `/provider here reset` to clear)"
1187                ),
1188                None => "No per-chat provider override. Set one with `/provider here <alias>`."
1189                    .to_string(),
1190            };
1191        }
1192        if rest.eq_ignore_ascii_case("reset") || rest.eq_ignore_ascii_case("default") {
1193            {
1194                let mut provider_overrides = llm_provider_overrides.write().await;
1195                provider_overrides.remove(&chat_key);
1196            }
1197            {
1198                let mut model_overrides = llm_model_overrides.write().await;
1199                model_overrides.remove(&chat_key);
1200            }
1201            return "Per-chat provider override cleared.".to_string();
1202        }
1203        let requested_alias = rest.to_ascii_lowercase();
1204        let Some(profile) = config.resolve_llm_provider_profile(&requested_alias) else {
1205            let names = config
1206                .list_llm_provider_profiles()
1207                .into_iter()
1208                .map(|p| p.alias)
1209                .collect::<Vec<_>>()
1210                .join(", ");
1211            return format!("Unknown provider '{rest}'. Available providers: {names}");
1212        };
1213        {
1214            let mut provider_overrides = llm_provider_overrides.write().await;
1215            provider_overrides.insert(chat_key.clone(), profile.alias.clone());
1216        }
1217        {
1218            let mut model_overrides = llm_model_overrides.write().await;
1219            model_overrides.remove(&chat_key);
1220        }
1221        return format!(
1222            "Provider switched for this chat to: {} (backend={}), model {} (runtime only; `/provider here reset` to clear)",
1223            profile.alias, profile.provider, profile.default_model
1224        );
1225    }
1226
1227    if requested.is_empty() {
1228        let (profile, model) = resolve_effective_provider_and_model(
1229            config,
1230            &llm_provider_overrides,
1231            &llm_model_overrides,
1232            caller_channel,
1233        )
1234        .await;
1235        return format!(
1236            "Current provider/model: {} / {} (backend={})",
1237            profile.alias, model, profile.provider
1238        );
1239    }
1240
1241    if requested.eq_ignore_ascii_case("reset") || requested.eq_ignore_ascii_case("default") {
1242        if persist_to_config {
1243            if let Err(e) = persist_channel_llm_overrides(
1244                config,
1245                caller_channel,
1246                PersistedOverride::Clear,
1247                PersistedOverride::Clear,
1248            ) {
1249                return format!("Failed to persist provider override reset: {e}");
1250            }
1251        }
1252        {
1253            let mut provider_overrides = llm_provider_overrides.write().await;
1254            provider_overrides.remove(caller_channel);
1255        }
1256        {
1257            let mut model_overrides = llm_model_overrides.write().await;
1258            model_overrides.remove(caller_channel);
1259        }
1260        let profile = config
1261            .resolve_llm_provider_profile(&config.llm_provider)
1262            .expect("default provider should resolve");
1263        return format!(
1264            "Provider override cleared. Current provider/model: {} / {}",
1265            profile.alias, profile.default_model
1266        );
1267    }
1268
1269    let requested_alias = requested.to_ascii_lowercase();
1270    let Some(profile) = config.resolve_llm_provider_profile(&requested_alias) else {
1271        let names = config
1272            .list_llm_provider_profiles()
1273            .into_iter()
1274            .map(|p| p.alias)
1275            .collect::<Vec<_>>()
1276            .join(", ");
1277        return format!("Unknown provider '{requested}'. Available providers: {names}");
1278    };
1279    if persist_to_config {
1280        if let Err(e) = persist_channel_llm_overrides(
1281            config,
1282            caller_channel,
1283            PersistedOverride::Set(&profile.alias),
1284            PersistedOverride::Clear,
1285        ) {
1286            return format!("Failed to persist provider override: {e}");
1287        }
1288    }
1289    {
1290        let mut provider_overrides = llm_provider_overrides.write().await;
1291        provider_overrides.insert(caller_channel.to_string(), profile.alias.clone());
1292    }
1293    {
1294        let mut model_overrides = llm_model_overrides.write().await;
1295        model_overrides.remove(caller_channel);
1296    }
1297    format!(
1298        "Provider switched for this channel to: {} (backend={}), model reset to {}",
1299        profile.alias, profile.provider, profile.default_model
1300    )
1301}
1302
1303pub async fn build_models_response(
1304    config: &Config,
1305    llm_provider_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
1306    llm_model_overrides: Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
1307    caller_channel: &str,
1308    command_text: &str,
1309) -> String {
1310    let requested = command_text
1311        .trim()
1312        .strip_prefix("/models")
1313        .map(str::trim)
1314        .unwrap_or("");
1315    let (api_mode, provider_arg) = parse_models_command_args(requested);
1316    let profile = if requested.is_empty() {
1317        resolve_effective_provider_and_model(
1318            config,
1319            &llm_provider_overrides,
1320            &llm_model_overrides,
1321            caller_channel,
1322        )
1323        .await
1324        .0
1325    } else {
1326        let alias = provider_arg.unwrap_or_default().to_ascii_lowercase();
1327        if alias.is_empty() && api_mode {
1328            resolve_effective_provider_and_model(
1329                config,
1330                &llm_provider_overrides,
1331                &llm_model_overrides,
1332                caller_channel,
1333            )
1334            .await
1335            .0
1336        } else {
1337            let Some(profile) = config.resolve_llm_provider_profile(&alias) else {
1338                return format!(
1339                    "Unknown provider '{}'. Try /providers",
1340                    provider_arg.unwrap_or_default()
1341                );
1342            };
1343            profile
1344        }
1345    };
1346    if api_mode {
1347        return match fetch_models_from_provider_api(&profile).await {
1348            Ok(models) => {
1349                let listed = models
1350                    .iter()
1351                    .take(50)
1352                    .map(|m| format!("- {m}"))
1353                    .collect::<Vec<_>>();
1354                let suffix = if models.len() > 50 {
1355                    format!("\n... and {} more", models.len() - 50)
1356                } else {
1357                    String::new()
1358                };
1359                format!(
1360                    "Live models from provider '{}' (backend={}):\n{}{}",
1361                    profile.alias,
1362                    profile.provider,
1363                    listed.join("\n"),
1364                    suffix
1365                )
1366            }
1367            Err(e) => format!("Failed to fetch live models for '{}': {e}", profile.alias),
1368        };
1369    }
1370    if is_placeholder_model_list(&profile.models) {
1371        return match fetch_models_from_provider_api(&profile).await {
1372            Ok(models) if !models.is_empty() => format!(
1373                "Live models for provider '{}': {}",
1374                profile.alias,
1375                models.join(", ")
1376            ),
1377            _ => format!(
1378                "Models for provider '{}': {}",
1379                profile.alias,
1380                profile.models.join(", ")
1381            ),
1382        };
1383    }
1384    format!(
1385        "Models for provider '{}': {}",
1386        profile.alias,
1387        profile.models.join(", ")
1388    )
1389}
1390
1391fn is_placeholder_model_list(models: &[String]) -> bool {
1392    models.len() == 1 && models[0].eq_ignore_ascii_case("custom-model")
1393}
1394
1395fn parse_models_command_args(requested: &str) -> (bool, Option<&str>) {
1396    let trimmed = requested.trim();
1397    if trimmed.is_empty() {
1398        return (false, None);
1399    }
1400    let parts = trimmed.split_whitespace().collect::<Vec<_>>();
1401    if parts.first().copied() == Some("api") {
1402        return (true, parts.get(1).copied());
1403    }
1404    (false, parts.first().copied())
1405}
1406
1407#[derive(Debug, Deserialize)]
1408struct OpenAiModelsApiResponse {
1409    data: Vec<OpenAiModelItem>,
1410}
1411
1412#[derive(Debug, Deserialize)]
1413struct OpenAiModelItem {
1414    id: String,
1415}
1416
1417#[derive(Debug, Deserialize)]
1418struct AnthropicModelsApiResponse {
1419    data: Vec<AnthropicModelItem>,
1420}
1421
1422#[derive(Debug, Deserialize)]
1423struct AnthropicModelItem {
1424    id: String,
1425    #[serde(default)]
1426    display_name: Option<String>,
1427}
1428
1429fn resolve_openai_models_url(profile: &ResolvedLlmProviderProfile) -> String {
1430    let backend = profile.provider.trim().to_ascii_lowercase();
1431    let default_base = match backend.as_str() {
1432        "openai" => "https://api.openai.com/v1",
1433        "deepseek" => "https://api.deepseek.com/v1",
1434        "synthetic" => "https://api.synthetic.new/openai/v1",
1435        "chutes" => "https://llm.chutes.ai/v1",
1436        _ => "https://api.openai.com/v1",
1437    };
1438    let base = profile
1439        .llm_base_url
1440        .as_deref()
1441        .map(str::trim)
1442        .filter(|v| !v.is_empty())
1443        .unwrap_or(default_base)
1444        .trim_end_matches('/');
1445    if base.ends_with("/models") {
1446        base.to_string()
1447    } else if base.ends_with("/chat/completions") {
1448        format!(
1449            "{}/models",
1450            base.trim_end_matches("/chat/completions")
1451                .trim_end_matches('/')
1452        )
1453    } else {
1454        format!("{base}/models")
1455    }
1456}
1457
1458fn resolve_anthropic_models_url(profile: &ResolvedLlmProviderProfile) -> String {
1459    let configured = profile
1460        .llm_base_url
1461        .as_deref()
1462        .map(str::trim)
1463        .filter(|v| !v.is_empty())
1464        .unwrap_or("https://api.anthropic.com/v1/models");
1465    let base = configured.trim_end_matches('/');
1466    if base.ends_with("/models") {
1467        base.to_string()
1468    } else if base.ends_with("/messages") {
1469        format!(
1470            "{}/models",
1471            base.trim_end_matches("/messages").trim_end_matches('/')
1472        )
1473    } else {
1474        format!("{base}/models")
1475    }
1476}
1477
1478async fn fetch_models_from_provider_api(
1479    profile: &ResolvedLlmProviderProfile,
1480) -> Result<Vec<String>, String> {
1481    let backend = profile.provider.trim().to_ascii_lowercase();
1482    if backend == "openai-codex" && profile.api_key.trim().is_empty() {
1483        return Err(
1484            "openai-codex API listing requires provider api_key in llm_providers.<name>.api_key"
1485                .to_string(),
1486        );
1487    }
1488    if backend == "anthropic" {
1489        if profile.api_key.trim().is_empty() {
1490            return Err("missing api_key for anthropic profile".to_string());
1491        }
1492        let url = resolve_anthropic_models_url(profile);
1493        let client = reqwest::Client::builder()
1494            .user_agent(llm_user_agent(&profile.llm_user_agent))
1495            .build()
1496            .map_err(|e| e.to_string())?;
1497        let response = client
1498            .get(&url)
1499            .header("x-api-key", profile.api_key.as_str())
1500            .header("anthropic-version", "2023-06-01")
1501            .send()
1502            .await
1503            .map_err(|e| e.to_string())?;
1504        let status = response.status();
1505        let body = response.text().await.map_err(|e| e.to_string())?;
1506        if !status.is_success() {
1507            return Err(format!("HTTP {status}: {body}"));
1508        }
1509        let mut out = parse_anthropic_models_json_ids(&body)?;
1510        out.sort();
1511        out.dedup();
1512        return Ok(out);
1513    }
1514
1515    let url = resolve_openai_models_url(profile);
1516    let client = reqwest::Client::builder()
1517        .user_agent(llm_user_agent(&profile.llm_user_agent))
1518        .build()
1519        .map_err(|e| e.to_string())?;
1520    let mut request = client.get(&url);
1521    if !profile.api_key.trim().is_empty() {
1522        request = request.bearer_auth(profile.api_key.as_str());
1523    }
1524    let response = request.send().await.map_err(|e| e.to_string())?;
1525    let status = response.status();
1526    let body = response.text().await.map_err(|e| e.to_string())?;
1527    if !status.is_success() {
1528        return Err(format!("HTTP {status}: {body}"));
1529    }
1530    let mut out = parse_openai_models_json_ids(&body)?;
1531    out.sort();
1532    out.dedup();
1533    Ok(out)
1534}
1535
1536fn parse_openai_models_json_ids(body: &str) -> Result<Vec<String>, String> {
1537    let parsed: OpenAiModelsApiResponse =
1538        serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
1539    Ok(parsed.data.into_iter().map(|m| m.id).collect())
1540}
1541
1542fn parse_anthropic_models_json_ids(body: &str) -> Result<Vec<String>, String> {
1543    let parsed: AnthropicModelsApiResponse =
1544        serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
1545    Ok(parsed
1546        .data
1547        .into_iter()
1548        .map(|m| match m.display_name {
1549            Some(name) if !name.trim().is_empty() => format!("{} ({})", m.id, name),
1550            _ => m.id,
1551        })
1552        .collect())
1553}
1554
1555async fn resolve_effective_provider_and_model(
1556    config: &Config,
1557    llm_provider_overrides: &Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
1558    llm_model_overrides: &Arc<tokio::sync::RwLock<std::collections::HashMap<String, String>>>,
1559    caller_channel: &str,
1560) -> (ResolvedLlmProviderProfile, String) {
1561    let provider_alias = {
1562        let provider_overrides = llm_provider_overrides.read().await;
1563        provider_overrides
1564            .get(caller_channel)
1565            .cloned()
1566            .unwrap_or_else(|| config.llm_provider.clone())
1567    };
1568    let profile = config
1569        .resolve_llm_provider_profile(&provider_alias)
1570        .or_else(|| config.resolve_llm_provider_profile(&config.llm_provider))
1571        .expect("default provider should resolve");
1572    let raw_model_override = {
1573        let model_overrides = llm_model_overrides.read().await;
1574        model_overrides.get(caller_channel).cloned()
1575    };
1576    if raw_model_override
1577        .as_deref()
1578        .is_some_and(|model| normalize_model_name(model).is_none())
1579    {
1580        warn!(
1581            "Ignoring invalid model override '{}' for channel '{}'",
1582            raw_model_override.as_deref().unwrap_or_default(),
1583            caller_channel
1584        );
1585    }
1586    let model = resolve_model_name_with_fallback(
1587        &profile.provider,
1588        raw_model_override.as_deref(),
1589        Some(&profile.default_model),
1590    );
1591    (profile, model)
1592}
1593
1594pub async fn maybe_handle_plugin_command(
1595    config: &Config,
1596    command_text: &str,
1597    chat_id: i64,
1598    caller_channel: &str,
1599) -> Option<String> {
1600    let normalized = normalized_slash_command(command_text)?;
1601    if let Some(admin) = crate::plugins::handle_plugins_admin_command(config, chat_id, normalized) {
1602        return Some(admin);
1603    }
1604    crate::plugins::execute_plugin_slash_command(config, caller_channel, chat_id, normalized).await
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609    use super::{
1610        build_model_response, build_model_response_with_persistence, build_models_response,
1611        build_provider_response, build_provider_response_with_persistence,
1612        is_placeholder_model_list, parse_anthropic_models_json_ids, parse_models_command_args,
1613        parse_openai_models_json_ids, resolve_effective_provider_and_model,
1614        resolve_openai_models_url,
1615    };
1616    use crate::config::{Config, LlmProviderProfile, ResolvedLlmProviderProfile};
1617    use chrono::Utc;
1618    use std::collections::HashMap;
1619    use std::fs;
1620    use std::io::{Read, Write};
1621    use std::net::TcpListener;
1622    use std::sync::mpsc;
1623    use std::sync::Arc;
1624    use std::time::Duration;
1625    use tokio::sync::RwLock;
1626
1627    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1628        crate::test_support::env_lock()
1629    }
1630
1631    fn test_config() -> Config {
1632        let mut cfg = Config::test_defaults();
1633        cfg.llm_provider = "openai".to_string();
1634        cfg.model = "gpt-5.2".to_string();
1635        cfg.llm_providers.insert(
1636            "openai".to_string(),
1637            LlmProviderProfile {
1638                provider: Some("openai".to_string()),
1639                api_key: None,
1640                api_keys: Vec::new(),
1641                llm_base_url: None,
1642                llm_user_agent: None,
1643                default_model: Some("gpt-5.2".to_string()),
1644                models: vec!["gpt-5.2".to_string(), "gpt-5".to_string()],
1645                show_thinking: None,
1646            },
1647        );
1648        cfg.llm_providers.insert(
1649            "anthropic".to_string(),
1650            LlmProviderProfile {
1651                provider: Some("anthropic".to_string()),
1652                api_key: Some("k".to_string()),
1653                api_keys: Vec::new(),
1654                llm_base_url: None,
1655                llm_user_agent: None,
1656                default_model: Some("claude-sonnet-4-5-20250929".to_string()),
1657                models: vec![
1658                    "claude-sonnet-4-5-20250929".to_string(),
1659                    "claude-opus-4-6-20260205".to_string(),
1660                ],
1661                show_thinking: None,
1662            },
1663        );
1664        cfg
1665    }
1666
1667    #[tokio::test]
1668    async fn model_command_reports_current_model() {
1669        let cfg = test_config();
1670        let provider_overrides = Arc::new(RwLock::new(HashMap::new()));
1671        let overrides = Arc::new(RwLock::new(HashMap::new()));
1672        let text =
1673            build_model_response(&cfg, provider_overrides, overrides, "telegram", 1, "/model")
1674                .await;
1675        assert_eq!(text, "Current provider/model: openai / gpt-5.2");
1676    }
1677
1678    #[tokio::test]
1679    async fn model_command_sets_override() {
1680        let cfg = test_config();
1681        let provider_overrides = Arc::new(RwLock::new(HashMap::new()));
1682        let overrides = Arc::new(RwLock::new(HashMap::new()));
1683        let text = build_model_response(
1684            &cfg,
1685            provider_overrides,
1686            overrides.clone(),
1687            "telegram",
1688            1,
1689            "/model gpt-5",
1690        )
1691        .await;
1692        assert_eq!(text, "Model switched for this channel to: openai / gpt-5");
1693        let guard = overrides.read().await;
1694        assert_eq!(guard.get("telegram").map(String::as_str), Some("gpt-5"));
1695    }
1696
1697    #[tokio::test]
1698    async fn model_command_here_sets_per_chat_override() {
1699        let cfg = test_config();
1700        let provider_overrides = Arc::new(RwLock::new(HashMap::new()));
1701        let overrides = Arc::new(RwLock::new(HashMap::new()));
1702        let text = build_model_response(
1703            &cfg,
1704            provider_overrides.clone(),
1705            overrides.clone(),
1706            "telegram",
1707            42,
1708            "/model here gpt-5",
1709        )
1710        .await;
1711        assert!(
1712            text.contains("Model switched for this chat to: openai / gpt-5"),
1713            "unexpected: {text}"
1714        );
1715        {
1716            let guard = overrides.read().await;
1717            assert_eq!(
1718                guard
1719                    .get(&super::chat_scoped_override_key("telegram", 42))
1720                    .map(String::as_str),
1721                Some("gpt-5")
1722            );
1723            // Channel-wide override untouched.
1724            assert!(guard.get("telegram").is_none());
1725        }
1726        let text = build_model_response(
1727            &cfg,
1728            provider_overrides,
1729            overrides.clone(),
1730            "telegram",
1731            42,
1732            "/model here reset",
1733        )
1734        .await;
1735        assert!(text.contains("Per-chat model override cleared"));
1736        let guard = overrides.read().await;
1737        assert!(guard
1738            .get(&super::chat_scoped_override_key("telegram", 42))
1739            .is_none());
1740    }
1741
1742    #[tokio::test]
1743    async fn model_command_rejects_wildcard_override() {
1744        let cfg = test_config();
1745        let provider_overrides = Arc::new(RwLock::new(HashMap::new()));
1746        let overrides = Arc::new(RwLock::new(HashMap::new()));
1747        let text = build_model_response(
1748            &cfg,
1749            provider_overrides,
1750            overrides.clone(),
1751            "telegram",
1752            1,
1753            "/model *",
1754        )
1755        .await;
1756        assert_eq!(
1757            text,
1758            "Model '*' is not a valid model id. Use `/model reset` to clear the override."
1759        );
1760        let guard = overrides.read().await;
1761        assert!(!guard.contains_key("telegram"));
1762    }
1763
1764    #[tokio::test]
1765    async fn model_command_resets_override() {
1766        let cfg = test_config();
1767        let provider_overrides = Arc::new(RwLock::new(HashMap::new()));
1768        let mut map = HashMap::new();
1769        map.insert("telegram".to_string(), "qwen".to_string());
1770        let overrides = Arc::new(RwLock::new(map));
1771        let text = build_model_response(
1772            &cfg,
1773            provider_overrides,
1774            overrides.clone(),
1775            "telegram",
1776            1,
1777            "/model reset",
1778        )
1779        .await;
1780        assert_eq!(
1781            text,
1782            "Model override cleared. Current provider/model: openai / gpt-5.2"
1783        );
1784        let guard = overrides.read().await;
1785        assert!(!guard.contains_key("telegram"));
1786    }
1787
1788    #[tokio::test]
1789    async fn provider_resolution_ignores_invalid_runtime_model_override() {
1790        let cfg = test_config();
1791        let provider_overrides = Arc::new(RwLock::new(HashMap::new()));
1792        let mut map = HashMap::new();
1793        map.insert("telegram".to_string(), "*".to_string());
1794        let overrides = Arc::new(RwLock::new(map));
1795
1796        let (profile, model) =
1797            resolve_effective_provider_and_model(&cfg, &provider_overrides, &overrides, "telegram")
1798                .await;
1799        assert_eq!(profile.alias, "openai");
1800        assert_eq!(model, "gpt-5.2");
1801    }
1802
1803    #[tokio::test]
1804    async fn provider_command_switches_provider_and_resets_model() {
1805        let cfg = test_config();
1806        let provider_overrides = Arc::new(RwLock::new(HashMap::new()));
1807        let mut model_map = HashMap::new();
1808        model_map.insert("telegram".to_string(), "gpt-5".to_string());
1809        let model_overrides = Arc::new(RwLock::new(model_map));
1810        let text = build_provider_response(
1811            &cfg,
1812            provider_overrides.clone(),
1813            model_overrides.clone(),
1814            "telegram",
1815            "/provider anthropic",
1816        )
1817        .await;
1818        assert!(
1819            text.contains("Provider switched for this channel to: anthropic"),
1820            "unexpected text: {text}"
1821        );
1822        let provider_guard = provider_overrides.read().await;
1823        assert_eq!(
1824            provider_guard.get("telegram").map(String::as_str),
1825            Some("anthropic")
1826        );
1827        drop(provider_guard);
1828        let model_guard = model_overrides.read().await;
1829        assert!(!model_guard.contains_key("telegram"));
1830    }
1831
1832    #[allow(clippy::await_holding_lock)]
1833    #[tokio::test]
1834    async fn provider_command_persists_override_for_default_account_channel() {
1835        let _guard = env_lock();
1836        let temp = std::env::temp_dir().join(format!(
1837            "microclaw_chat_commands_provider_persist_{}",
1838            Utc::now().timestamp_nanos_opt().unwrap_or_default()
1839        ));
1840        fs::create_dir_all(&temp).unwrap();
1841        let old_cwd = std::env::current_dir().unwrap();
1842        std::env::set_current_dir(&temp).unwrap();
1843        fs::write(
1844            temp.join("microclaw.config.yaml"),
1845            r#"
1846bot_username: bot
1847api_key: key
1848llm_provider: openai
1849model: gpt-5.2
1850provider_presets:
1851  modal:
1852    provider: openai
1853    default_model: gpt-5.2
1854  cloudflare:
1855    provider: openai
1856    default_model: "@cf/zai-org/glm-4.7-flash"
1857channels:
1858  telegram:
1859    enabled: true
1860    default_account: sales
1861    accounts:
1862      sales:
1863        enabled: true
1864        bot_token: tok
1865        provider_preset: modal
1866"#,
1867        )
1868        .unwrap();
1869
1870        let cfg = Config::load().unwrap();
1871        let provider_overrides = Arc::new(RwLock::new(cfg.llm_provider_overrides()));
1872        let model_overrides = Arc::new(RwLock::new(HashMap::new()));
1873        let text = build_provider_response_with_persistence(
1874            &cfg,
1875            provider_overrides.clone(),
1876            model_overrides,
1877            "telegram",
1878            0,
1879            "/provider cloudflare",
1880            true,
1881        )
1882        .await;
1883        assert!(text.contains("Provider switched for this channel to: cloudflare"));
1884
1885        let saved = Config::load().unwrap();
1886        assert_eq!(
1887            saved.provider_override_for_channel("telegram").as_deref(),
1888            Some("cloudflare")
1889        );
1890        assert_eq!(
1891            provider_overrides
1892                .read()
1893                .await
1894                .get("telegram")
1895                .map(String::as_str),
1896            Some("cloudflare")
1897        );
1898
1899        std::env::set_current_dir(old_cwd).unwrap();
1900        let _ = fs::remove_file(temp.join("microclaw.config.yaml"));
1901        let _ = fs::remove_dir_all(&temp);
1902    }
1903
1904    #[allow(clippy::await_holding_lock)]
1905    #[tokio::test]
1906    async fn model_command_persists_override_for_default_account_channel() {
1907        let _guard = env_lock();
1908        let temp = std::env::temp_dir().join(format!(
1909            "microclaw_chat_commands_model_persist_{}",
1910            Utc::now().timestamp_nanos_opt().unwrap_or_default()
1911        ));
1912        fs::create_dir_all(&temp).unwrap();
1913        let old_cwd = std::env::current_dir().unwrap();
1914        std::env::set_current_dir(&temp).unwrap();
1915        fs::write(
1916            temp.join("microclaw.config.yaml"),
1917            r#"
1918bot_username: bot
1919api_key: key
1920llm_provider: openai
1921model: gpt-5.2
1922provider_presets:
1923  cloudflare:
1924    provider: openai
1925    default_model: "@cf/zai-org/glm-4.7-flash"
1926channels:
1927  telegram:
1928    enabled: true
1929    default_account: sales
1930    accounts:
1931      sales:
1932        enabled: true
1933        bot_token: tok
1934        provider_preset: cloudflare
1935"#,
1936        )
1937        .unwrap();
1938
1939        let cfg = Config::load().unwrap();
1940        let provider_overrides = Arc::new(RwLock::new(cfg.llm_provider_overrides()));
1941        let model_overrides = Arc::new(RwLock::new(HashMap::new()));
1942        let text = build_model_response_with_persistence(
1943            &cfg,
1944            provider_overrides,
1945            model_overrides.clone(),
1946            "telegram",
1947            1,
1948            "/model @cf/zai-org/glm-4.7-flash",
1949            true,
1950        )
1951        .await;
1952        assert_eq!(
1953            text,
1954            "Model switched for this channel to: cloudflare / @cf/zai-org/glm-4.7-flash"
1955        );
1956
1957        let saved = fs::read_to_string(temp.join("microclaw.config.yaml")).unwrap();
1958        assert!(
1959            saved.contains("model: '@cf/zai-org/glm-4.7-flash'")
1960                || saved.contains("model: \"@cf/zai-org/glm-4.7-flash\"")
1961        );
1962        let saved_cfg = Config::load().unwrap();
1963        assert_eq!(
1964            saved_cfg.model_override_for_channel("telegram").as_deref(),
1965            Some("@cf/zai-org/glm-4.7-flash")
1966        );
1967        assert_eq!(
1968            model_overrides
1969                .read()
1970                .await
1971                .get("telegram")
1972                .map(String::as_str),
1973            Some("@cf/zai-org/glm-4.7-flash")
1974        );
1975
1976        std::env::set_current_dir(old_cwd).unwrap();
1977        let _ = fs::remove_file(temp.join("microclaw.config.yaml"));
1978        let _ = fs::remove_dir_all(&temp);
1979    }
1980
1981    #[allow(clippy::await_holding_lock)]
1982    #[tokio::test]
1983    async fn model_command_persists_override_only_for_current_bot_account() {
1984        let _guard = env_lock();
1985        let temp = std::env::temp_dir().join(format!(
1986            "microclaw_chat_commands_model_bot_scope_{}",
1987            Utc::now().timestamp_nanos_opt().unwrap_or_default()
1988        ));
1989        fs::create_dir_all(&temp).unwrap();
1990        let old_cwd = std::env::current_dir().unwrap();
1991        std::env::set_current_dir(&temp).unwrap();
1992        fs::write(
1993            temp.join("microclaw.config.yaml"),
1994            r#"
1995bot_username: bot
1996api_key: key
1997llm_provider: openai
1998model: gpt-5.2
1999llm_providers:
2000  openai:
2001    provider: openai
2002    default_model: gpt-5.2
2003    models:
2004      - gpt-5.2
2005      - gpt-5
2006channels:
2007  telegram:
2008    enabled: true
2009    default_account: sales
2010    accounts:
2011      sales:
2012        enabled: true
2013        bot_token: sales-tok
2014        model: gpt-5.2
2015      ops:
2016        enabled: true
2017        bot_token: ops-tok
2018        model: gpt-5-mini
2019"#,
2020        )
2021        .unwrap();
2022
2023        let cfg = Config::load().unwrap();
2024        let provider_overrides = Arc::new(RwLock::new(cfg.llm_provider_overrides()));
2025        let model_overrides = Arc::new(RwLock::new(HashMap::new()));
2026        let text = build_model_response_with_persistence(
2027            &cfg,
2028            provider_overrides,
2029            model_overrides.clone(),
2030            "telegram.ops",
2031            1,
2032            "/model gpt-5",
2033            true,
2034        )
2035        .await;
2036        assert_eq!(text, "Model switched for this channel to: openai / gpt-5");
2037
2038        let saved_cfg = Config::load().unwrap();
2039        assert_eq!(
2040            saved_cfg.model_override_for_channel("telegram").as_deref(),
2041            Some("gpt-5.2")
2042        );
2043        assert_eq!(
2044            saved_cfg
2045                .model_override_for_channel("telegram.ops")
2046                .as_deref(),
2047            Some("gpt-5")
2048        );
2049        assert_eq!(
2050            model_overrides
2051                .read()
2052                .await
2053                .get("telegram.ops")
2054                .map(String::as_str),
2055            Some("gpt-5")
2056        );
2057
2058        std::env::set_current_dir(old_cwd).unwrap();
2059        let _ = fs::remove_file(temp.join("microclaw.config.yaml"));
2060        let _ = fs::remove_dir_all(&temp);
2061    }
2062
2063    #[tokio::test]
2064    async fn models_command_lists_profile_models() {
2065        let cfg = test_config();
2066        let provider_overrides = Arc::new(RwLock::new(HashMap::new()));
2067        let model_overrides = Arc::new(RwLock::new(HashMap::new()));
2068        let text = build_models_response(
2069            &cfg,
2070            provider_overrides,
2071            model_overrides,
2072            "telegram",
2073            "/models anthropic",
2074        )
2075        .await;
2076        assert!(text.contains("claude-sonnet-4-5-20250929"));
2077        assert!(text.contains("claude-opus-4-6-20260205"));
2078    }
2079
2080    #[test]
2081    fn models_command_parses_api_mode() {
2082        assert_eq!(parse_models_command_args(""), (false, None));
2083        assert_eq!(
2084            parse_models_command_args("anthropic"),
2085            (false, Some("anthropic"))
2086        );
2087        assert_eq!(parse_models_command_args("api"), (true, None));
2088        assert_eq!(
2089            parse_models_command_args("api openai"),
2090            (true, Some("openai"))
2091        );
2092    }
2093
2094    #[test]
2095    fn placeholder_models_detection() {
2096        assert!(is_placeholder_model_list(&["custom-model".to_string()]));
2097        assert!(!is_placeholder_model_list(&[
2098            "custom-model".to_string(),
2099            "other".to_string()
2100        ]));
2101        assert!(!is_placeholder_model_list(&["deepseek-chat".to_string()]));
2102    }
2103
2104    #[test]
2105    fn parse_openai_models_ids() {
2106        let json = r#"{"data":[{"id":"gpt-5.2"},{"id":"gpt-5"}]}"#;
2107        let ids = parse_openai_models_json_ids(json).expect("openai parse");
2108        assert_eq!(ids, vec!["gpt-5.2".to_string(), "gpt-5".to_string()]);
2109    }
2110
2111    #[test]
2112    fn parse_anthropic_models_ids() {
2113        let json = r#"{"data":[{"id":"claude-sonnet","display_name":"Claude Sonnet"},{"id":"claude-opus"}]}"#;
2114        let ids = parse_anthropic_models_json_ids(json).expect("anthropic parse");
2115        assert_eq!(
2116            ids,
2117            vec![
2118                "claude-sonnet (Claude Sonnet)".to_string(),
2119                "claude-opus".to_string()
2120            ]
2121        );
2122    }
2123
2124    #[tokio::test]
2125    async fn models_command_uses_live_models_when_placeholder_profile() {
2126        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2127        let addr = listener.local_addr().unwrap();
2128        let (path_tx, path_rx) = mpsc::channel::<String>();
2129        let server = std::thread::spawn(move || {
2130            let (mut stream, _) = listener.accept().unwrap();
2131            stream
2132                .set_read_timeout(Some(Duration::from_secs(2)))
2133                .unwrap();
2134            let mut buf = [0u8; 8192];
2135            let n = stream.read(&mut buf).unwrap_or(0);
2136            let req = String::from_utf8_lossy(&buf[..n]).to_string();
2137            let path = req
2138                .lines()
2139                .next()
2140                .and_then(|line| line.split_whitespace().nth(1))
2141                .unwrap_or("")
2142                .to_string();
2143            let _ = path_tx.send(path);
2144            let body = r#"{"data":[{"id":"model-live-a"},{"id":"model-live-b"}]}"#;
2145            let response = format!(
2146                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2147                body.len(),
2148                body
2149            );
2150            let _ = stream.write_all(response.as_bytes());
2151            let _ = stream.flush();
2152        });
2153
2154        let mut cfg = Config::test_defaults();
2155        cfg.llm_provider = "lab-local".to_string();
2156        cfg.api_key = "k".to_string();
2157        cfg.model = "custom-model".to_string();
2158        cfg.llm_base_url = Some(format!("http://{addr}/v1"));
2159        cfg.llm_providers.insert(
2160            "lab-local".to_string(),
2161            LlmProviderProfile {
2162                provider: Some("openai".to_string()),
2163                api_key: None,
2164                api_keys: Vec::new(),
2165                llm_base_url: Some(format!("http://{addr}/v1")),
2166                llm_user_agent: None,
2167                default_model: Some("custom-model".to_string()),
2168                models: vec!["custom-model".to_string()],
2169                show_thinking: None,
2170            },
2171        );
2172        let provider_overrides = Arc::new(RwLock::new(HashMap::new()));
2173        let model_overrides = Arc::new(RwLock::new(HashMap::new()));
2174        let text =
2175            build_models_response(&cfg, provider_overrides, model_overrides, "web", "/models")
2176                .await;
2177        let path = path_rx.recv_timeout(Duration::from_secs(2)).unwrap();
2178        server.join().unwrap();
2179        assert_eq!(path, "/v1/models");
2180        assert!(text.contains("Live models for provider 'lab-local'"));
2181        assert!(text.contains("model-live-a"));
2182    }
2183
2184    #[tokio::test]
2185    async fn model_command_validates_against_live_models_for_placeholder_profile() {
2186        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2187        let addr = listener.local_addr().unwrap();
2188        let server = std::thread::spawn(move || {
2189            for _ in 0..2 {
2190                let (mut stream, _) = listener.accept().unwrap();
2191                stream
2192                    .set_read_timeout(Some(Duration::from_secs(2)))
2193                    .unwrap();
2194                let mut buf = [0u8; 8192];
2195                let _ = stream.read(&mut buf).unwrap_or(0);
2196                let body = r#"{"data":[{"id":"model-live-a"},{"id":"model-live-b"}]}"#;
2197                let response = format!(
2198                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2199                    body.len(),
2200                    body
2201                );
2202                let _ = stream.write_all(response.as_bytes());
2203                let _ = stream.flush();
2204            }
2205        });
2206
2207        let mut cfg = Config::test_defaults();
2208        cfg.llm_provider = "lab-local".to_string();
2209        cfg.api_key = "k".to_string();
2210        cfg.model = "custom-model".to_string();
2211        cfg.llm_base_url = Some(format!("http://{addr}/v1"));
2212        cfg.llm_providers.insert(
2213            "lab-local".to_string(),
2214            LlmProviderProfile {
2215                provider: Some("openai".to_string()),
2216                api_key: None,
2217                api_keys: Vec::new(),
2218                llm_base_url: Some(format!("http://{addr}/v1")),
2219                llm_user_agent: None,
2220                default_model: Some("custom-model".to_string()),
2221                models: vec!["custom-model".to_string()],
2222                show_thinking: None,
2223            },
2224        );
2225        let provider_overrides = Arc::new(RwLock::new(HashMap::new()));
2226        let model_overrides = Arc::new(RwLock::new(HashMap::new()));
2227
2228        let ok = build_model_response(
2229            &cfg,
2230            provider_overrides.clone(),
2231            model_overrides.clone(),
2232            "web",
2233            1,
2234            "/model model-live-a",
2235        )
2236        .await;
2237        assert_eq!(
2238            ok,
2239            "Model switched for this channel to: lab-local / model-live-a"
2240        );
2241
2242        let bad = build_model_response(
2243            &cfg,
2244            provider_overrides,
2245            model_overrides,
2246            "web",
2247            1,
2248            "/model not-real",
2249        )
2250        .await;
2251        server.join().unwrap();
2252        assert!(bad.contains("Model 'not-real' is not configured"));
2253        assert!(bad.contains("model-live-a"));
2254    }
2255
2256    #[test]
2257    fn resolve_openai_models_url_supports_synthetic_and_chutes_defaults() {
2258        let mk = |provider: &str| ResolvedLlmProviderProfile {
2259            alias: provider.to_string(),
2260            provider: provider.to_string(),
2261            api_key: String::new(),
2262            api_keys: Vec::new(),
2263            llm_base_url: None,
2264            llm_user_agent: String::new(),
2265            default_model: "x".to_string(),
2266            models: vec!["x".to_string()],
2267            show_thinking: false,
2268        };
2269        assert_eq!(
2270            resolve_openai_models_url(&mk("synthetic")),
2271            "https://api.synthetic.new/openai/v1/models"
2272        );
2273        assert_eq!(
2274            resolve_openai_models_url(&mk("chutes")),
2275            "https://llm.chutes.ai/v1/models"
2276        );
2277    }
2278}
2279
2280#[cfg(test)]
2281mod slash_command_tests {
2282    use super::{
2283        build_help_response, build_log_response, format_learn_reply, format_learning_run_detail,
2284        is_slash_command,
2285    };
2286    use crate::config::Config;
2287
2288    #[test]
2289    fn test_log_command_denied_without_control_chats() {
2290        let mut cfg = Config::test_defaults();
2291        cfg.control_chat_ids = vec![];
2292        let resp = build_log_response(&cfg, 1, "/log");
2293        assert!(resp.contains("disabled"), "got: {resp}");
2294    }
2295
2296    #[test]
2297    fn test_log_command_denied_for_non_control_chat() {
2298        let mut cfg = Config::test_defaults();
2299        cfg.control_chat_ids = vec![999];
2300        let resp = build_log_response(&cfg, 1, "/log 50");
2301        assert!(resp.contains("Not authorized"), "got: {resp}");
2302    }
2303
2304    #[test]
2305    fn test_log_command_keyword_filter() {
2306        let dir = std::env::temp_dir().join(format!(
2307            "microclaw_log_cmd_test_{}/runtime",
2308            uuid::Uuid::new_v4()
2309        ));
2310        let logs = dir.join("logs");
2311        std::fs::create_dir_all(&logs).unwrap();
2312        std::fs::write(
2313            logs.join("microclaw-2026-02-08-10.log"),
2314            "INFO starting up\nERROR scheduler exploded\nINFO all good\n",
2315        )
2316        .unwrap();
2317
2318        let mut cfg = Config::test_defaults();
2319        cfg.control_chat_ids = vec![7];
2320        cfg.data_dir = dir.to_string_lossy().to_string();
2321
2322        // Keyword filter keeps only matching lines (case-insensitive).
2323        let resp = build_log_response(&cfg, 7, "/log error");
2324        assert!(resp.contains("scheduler exploded"), "got: {resp}");
2325        assert!(!resp.contains("all good"), "got: {resp}");
2326        assert!(resp.contains("matching \"error\""), "got: {resp}");
2327
2328        // No filter tails everything.
2329        let all = build_log_response(&cfg, 7, "/log");
2330        assert!(
2331            all.contains("all good") && all.contains("starting up"),
2332            "got: {all}"
2333        );
2334
2335        let _ = std::fs::remove_dir_all(dir.parent().unwrap());
2336    }
2337
2338    #[test]
2339    fn test_is_slash_command_with_leading_mentions() {
2340        assert!(is_slash_command("/status"));
2341        assert!(is_slash_command("@bot /status"));
2342        assert!(is_slash_command("<@U123> /status"));
2343        assert!(is_slash_command(" <@U123>   @bot   /status"));
2344        assert!(!is_slash_command("@bot hello"));
2345    }
2346
2347    #[test]
2348    fn help_lists_real_commands_and_is_recognized() {
2349        assert!(is_slash_command("/help"));
2350        let help = build_help_response();
2351        // Every command surfaced in help must be a real dispatch entry.
2352        for cmd in [
2353            "/status",
2354            "/clear",
2355            "/reset",
2356            "/stop",
2357            "/archive",
2358            "/model",
2359            "/models",
2360            "/provider",
2361            "/providers",
2362            "/skills",
2363            "/reload-skills",
2364            "/learn",
2365            "/user",
2366            "/usage",
2367            "/learning",
2368            "/rewind",
2369            "/log",
2370            "/help",
2371        ] {
2372            assert!(help.contains(cmd), "help missing {cmd}");
2373        }
2374    }
2375
2376    #[test]
2377    fn learning_detail_cli_shows_used_experience_and_evidence() {
2378        let dir = std::env::temp_dir().join(format!("mc_learning_cli_{}", uuid::Uuid::new_v4()));
2379        std::fs::create_dir_all(&dir).unwrap();
2380        let db = crate::internal::storage::db::Database::new(dir.to_str().unwrap()).unwrap();
2381        for (run_id, objective) in [
2382            ("cli-source", "deploy the service"),
2383            ("cli-query", "deploy it again"),
2384        ] {
2385            db.start_experience_run(run_id, None, 44, "web", "interactive", objective, None)
2386                .unwrap();
2387        }
2388        db.finish_experience_run("cli-source", "completed", Some("healthy"), 10)
2389            .unwrap();
2390        db.record_verifier_result(
2391            "cli-source",
2392            "deterministic",
2393            "health_check",
2394            "passed",
2395            1.0,
2396            Some("HTTP 200"),
2397            None,
2398            None,
2399        )
2400        .unwrap();
2401        db.record_experience_retrievals(
2402            "cli-query",
2403            &[(
2404                "cli-source".into(),
2405                "strong_verified; environment_match=true".into(),
2406                1.25,
2407            )],
2408        )
2409        .unwrap();
2410        db.record_verifier_result(
2411            "cli-query",
2412            "runtime",
2413            "agent_loop_completion",
2414            "passed",
2415            0.55,
2416            Some("finished"),
2417            None,
2418            None,
2419        )
2420        .unwrap();
2421
2422        let detail = db.get_experience_run_detail("cli-query").unwrap().unwrap();
2423        let output = format_learning_run_detail(&detail);
2424        assert!(output.contains("Experiences used (1)"));
2425        assert!(output.contains("objective=deploy the service"));
2426        assert!(output.contains("result=healthy"));
2427        assert!(output.contains("Outcome evidence (1)"));
2428        assert!(output.contains("agent_loop_completion"));
2429
2430        drop(db);
2431        let _ = std::fs::remove_dir_all(&dir);
2432    }
2433
2434    #[test]
2435    fn learn_reply_formats_all_outcomes() {
2436        use crate::skill_review::{AppliedAction, LearnOutcome};
2437        let applied = format_learn_reply(&LearnOutcome::Applied(AppliedAction {
2438            name: "deploy-checklist".into(),
2439            action_kind: "create",
2440            version: 1,
2441        }));
2442        assert!(applied.contains("Created skill 'deploy-checklist' (v1)"));
2443        assert!(
2444            format_learn_reply(&LearnOutcome::Nothing("nothing reusable".into()))
2445                .contains("No skill saved")
2446        );
2447        assert!(
2448            format_learn_reply(&LearnOutcome::Skipped("no session".into()))
2449                .contains("Nothing to learn")
2450        );
2451        assert!(format_learn_reply(&LearnOutcome::Error("boom".into())).contains("Learn failed"));
2452    }
2453}