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
53pub 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
263async 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
302fn 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
331async 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
423const LOG_TAIL_DEFAULT_LINES: usize = 100;
425const LOG_TAIL_MAX_LINES: usize = 300;
426const LOG_FILTER_SCAN_LINES: usize = 5000;
430
431fn build_log_response(config: &Config, chat_id: i64, command: &str) -> String {
438 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 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 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
968pub 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 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 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 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 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 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 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}