Skip to main content

orch/
control.rs

1//! Control session — context assembly, one-shot agent invocation, response parsing.
2//!
3//! The control session provides an interactive ops assistant that assembles context
4//! from SQLite (memories, summaries, live state), invokes an agent CLI one-shot
5//! with `--session-id` for conversation continuity, and parses the response for
6//! summary extraction.
7//!
8//! ## Session continuity
9//!
10//! Each named session (e.g., "default", "ops") gets a UUID stored in SQLite KV
11//! (`control:session_id:{name}`). Session agents (claude, kimi, minimax, grok)
12//! receive `--session-id <uuid>` so they resume prior conversation state.
13//! The UUID is regenerated when the agent or model changes.
14//!
15//! ## Model selection
16//!
17//! `/model <spec>` where spec is:
18//! - `agent:model` — explicit agent + model (e.g., `minimax:sonnet`, `opencode:minimax-m2.5-free`)
19//! - `model` — infer agent from model name (e.g., `sonnet` → claude, `gpt-4o` → codex)
20//!
21//! All agents: claude, codex, opencode, kimi, minimax, grok
22//! - kimi and minimax are claude-compatible wrappers (separate binaries in PATH)
23//! - opencode supports `opencode models` for listing available models
24
25use std::collections::HashMap;
26use std::process;
27use std::sync::PoisonError;
28use std::sync::{Arc, LazyLock, Mutex};
29use std::time;
30
31use crate::cmd_cache;
32use crate::engine;
33use crate::engine::router::RouterConfig;
34use crate::engine::runner::agents;
35use crate::engine::runner::agents::opencode;
36use crate::engine::runner::direct;
37use crate::home;
38use crate::store::TaskStore;
39use anyhow::{Context, Result};
40use regex::Regex;
41use tokio::fs;
42use tokio::process::Command;
43use tokio::sync;
44use tokio::time as tokio_time;
45use tokio::time::{Duration, timeout};
46use uuid::Uuid;
47
48const SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
49
50/// Per-session invocation locks — ensures only one agent invocation runs at a time per session.
51///
52/// Keyed by `session_id`. A new `Arc<tokio::sync::Mutex<()>>` is inserted on first use and
53/// reused for all subsequent calls with the same session_id. Entries are evicted after each
54/// `send_message` call when no concurrent waiters remain. Note: because callers keep a
55/// local `Arc` clone while executing, the map entry + local clone equal 2 strong refs when
56/// there are no other waiters. We therefore test for `Arc::strong_count() == 2` when
57/// deciding to evict the map entry (map entry + local clone). If other waiters exist the
58/// count will be > 2 and we leave the entry in place.
59static SESSION_LOCKS: LazyLock<Mutex<HashMap<String, Arc<sync::Mutex<()>>>>> =
60    LazyLock::new(|| Mutex::new(HashMap::new()));
61
62/// System prompt template, loaded at compile time.
63const SYSTEM_TEMPLATE: &str = include_str!("../prompts/control_system.md");
64
65/// Timeout for agent invocations (2 minutes).
66const AGENT_TIMEOUT: time::Duration = time::Duration::from_secs(120);
67
68/// Maximum bytes for a single control-session turn payload.
69/// When the combined state + user message exceeds this limit, the payload is split
70/// across multiple sequential turns so the user's question is never truncated.
71const MAX_CONTROL_MESSAGE_BYTES: usize = 32 * 1024;
72
73/// Delivery mode for control session messages.
74enum DeliveryMode {
75    /// Single message delivery (under-limit messages)
76    Single,
77    /// Chunked delivery with session continuity (claude, kimi, minimax) — uses --resume
78    Chunked,
79    /// Chunked delivery without session continuity (codex, opencode) — independent invocations
80    ChunkedNonSession,
81}
82
83/// Parsed model specification from `/model` command.
84#[derive(Debug, Clone, PartialEq)]
85pub struct ModelSpec {
86    pub agent: String,
87    pub model: String,
88}
89
90/// Parsed control command from the chat session.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum ControlCommand {
93    Model(Option<String>),
94    Agent(Option<String>),
95    Restart,
96}
97
98/// The agent's first model under `models:` in the config. Empty means orch passes no `--model`
99/// and the agent's CLI uses its own default.
100fn default_model_for_agent(agent: &str) -> String {
101    first_model(&RouterConfig::configured_models(agent))
102}
103
104// A pool keyword such as `opencode:free` names no model, so it is skipped
105fn first_model(models: &[String]) -> String {
106    models
107        .iter()
108        .find(|m| !m.contains(':'))
109        .cloned()
110        .unwrap_or_default()
111}
112
113/// Parse a control-session command.
114pub fn parse_control_command(message: &str) -> Option<ControlCommand> {
115    let trimmed = message.trim();
116    let rest = trimmed.strip_prefix('/')?;
117    let (command, args) = rest
118        .split_once(' ')
119        .map_or((rest, ""), |(cmd, tail)| (cmd, tail.trim()));
120
121    match command {
122        "model" => Some(ControlCommand::Model(
123            (!args.is_empty()).then(|| args.to_string()),
124        )),
125        "agent" => Some(ControlCommand::Agent(
126            (!args.is_empty()).then(|| args.to_string()),
127        )),
128        "restart" => Some(ControlCommand::Restart),
129        _ => None,
130    }
131}
132
133/// Parse a model spec string into agent + model.
134///
135/// Formats:
136/// - `agent:model` → explicit (e.g., `minimax:sonnet`, `opencode:minimax-m2.5-free`)
137/// - `model` → infer agent from model name
138pub fn parse_model_spec(spec: &str, current_agent: &str) -> ModelSpec {
139    if let Some((agent, model)) = spec.split_once(':') {
140        ModelSpec {
141            agent: agent.to_string(),
142            model: RouterConfig::normalize_model_identifier(model),
143        }
144    } else {
145        let model = RouterConfig::normalize_model_identifier(spec);
146        let configured: Vec<(String, Vec<String>)> = engine::configured_agents()
147            .into_iter()
148            .map(|agent| {
149                let models = RouterConfig::configured_models(&agent);
150                (agent, models)
151            })
152            .collect();
153        ModelSpec {
154            agent: infer_agent(&model, &configured, current_agent),
155            model,
156        }
157    }
158}
159
160/// The agent whose `models:` entry lists the model, or the current agent when none does.
161fn infer_agent(model: &str, configured: &[(String, Vec<String>)], current_agent: &str) -> String {
162    configured
163        .iter()
164        .find(|(_, models)| models.iter().any(|m| m == model))
165        .map_or(current_agent, |(agent, _)| agent.as_str())
166        .to_string()
167}
168
169/// Validate that an agent name is known and installed.
170///
171/// Performs lightweight checks (known agent + binary in PATH) without
172/// a test invocation. Used by `/agent` before persisting.
173pub fn validate_agent(agent: &str) -> Result<()> {
174    let agents = engine::configured_agents();
175    if !agents.iter().any(|a| a == agent) {
176        anyhow::bail!(
177            "unknown agent '{}'. Available: {}",
178            agent,
179            agents.join(", ")
180        );
181    }
182    if !cmd_cache::command_exists(agent) {
183        anyhow::bail!("agent '{agent}' not found in PATH. Is it installed?");
184    }
185    Ok(())
186}
187
188/// Validate a model spec by running a quick test invocation.
189///
190/// Sends "hello" to the agent with the specified model to verify
191/// the agent binary exists and the model is available.
192#[expect(clippy::print_stderr, reason = "progress line for `orch chat /model`")]
193pub async fn validate_model(spec: &ModelSpec) -> Result<()> {
194    // 1. Check agent is known and installed
195    validate_agent(&spec.agent)?;
196
197    // 3. For opencode, pre-check against `opencode models` list (fast rejection)
198    if spec.agent == "opencode"
199        && let Ok(models) = list_opencode_models().await
200        && !models.iter().any(|m| m == &spec.model)
201    {
202        let suggestions: Vec<_> = models
203            .iter()
204            .filter(|m| m.contains(&spec.model) || spec.model.contains(m.as_str()))
205            .take(10)
206            .cloned()
207            .collect();
208        let shown = if suggestions.is_empty() {
209            models.iter().take(20).cloned().collect()
210        } else {
211            suggestions
212        };
213        anyhow::bail!(
214            "model '{}' not found in opencode. Available models:\n{}",
215            spec.model,
216            shown.join("\n")
217        );
218    }
219
220    // 4. Test invocation — always run to verify the model actually works
221    eprintln!("Testing {}:{} ...", spec.agent, spec.model);
222    let result = test_invoke(&spec.agent, &spec.model).await;
223    match result {
224        Ok(()) => Ok(()),
225        Err(e) => anyhow::bail!(
226            "model '{}' on agent '{}' is not available: {e}",
227            spec.model,
228            spec.agent
229        ),
230    }
231}
232
233/// List available opencode models via `opencode models`.
234async fn list_opencode_models() -> Result<Vec<String>> {
235    match opencode::run_opencode_models_cli(time::Duration::from_secs(10)).await {
236        (models, "ok" | "empty_output") => Ok(models),
237        (_, reason) => anyhow::bail!("opencode models failed: {reason}"),
238    }
239}
240
241/// Run a minimal test invocation to verify agent+model availability.
242async fn test_invoke(agent: &str, model: &str) -> Result<()> {
243    let msg = "Reply with just the word 'ok'.";
244    // Reuse invoke_agent — it already handles temp files, timeout, and error checking
245    invoke_agent(agent, model, "", msg).await?;
246    Ok(())
247}
248
249/// Assembled context for a chat invocation.
250#[derive(Debug)]
251pub struct ChatContext {
252    /// System prompt (instructions, role, commands)
253    pub system: String,
254    /// User-visible context prepended to the message (live state, memories)
255    pub state: String,
256}
257
258/// Assemble the chat context from SQLite state and live system info.
259pub async fn assemble_context(store: &TaskStore, session_id: &str) -> Result<ChatContext> {
260    // 1. Gather memories from KV (keys matching control:memory:{session}:*)
261    // Use kv_list_prefix which correctly escapes LIKE metacharacters so that
262    // session names containing '_' or '%' don't leak memories from other sessions.
263    let memory_prefix = format!("control:memory:{session_id}:");
264    let raw_memories = store
265        .kv_list_prefix(&memory_prefix)
266        .await
267        .unwrap_or_else(|e| {
268            tracing::warn!(session_id, error = %e, "failed to load control memories from store");
269            Vec::new()
270        });
271    let memories = if raw_memories.is_empty() {
272        "(none)".to_string()
273    } else {
274        raw_memories
275            .iter()
276            .map(|(key, value)| {
277                let short_key = key.strip_prefix("control:memory:").unwrap_or(key);
278                format!("- **{short_key}**: {value}")
279            })
280            .collect::<Vec<_>>()
281            .join("\n")
282    };
283
284    // 2. Recent conversation summaries
285    let summaries = store
286        .control_recent_summaries(session_id, 20)
287        .await
288        .unwrap_or_else(|e| {
289            tracing::warn!(session_id, error = %e, "failed to load control summaries from store");
290            Vec::new()
291        });
292    let recent_summaries = if summaries.is_empty() {
293        "(no recent conversation)".to_string()
294    } else {
295        summaries
296            .iter()
297            .map(|s| format!("- {s}"))
298            .collect::<Vec<_>>()
299            .join("\n")
300    };
301
302    // 3-5. Run independent subprocess calls in parallel (best-effort, 10s timeout each)
303    let (service_status, task_list, job_list) = tokio::join!(
304        async {
305            let mut cmd = Command::new("brew");
306            cmd.args(["services", "info", "orch"]).kill_on_drop(true);
307            match timeout(SUBPROCESS_TIMEOUT, cmd.output()).await {
308                Ok(Ok(output)) if output.status.success() => {
309                    String::from_utf8_lossy(&output.stdout).trim().to_string()
310                }
311                _ => "(could not check service status)".to_string(),
312            }
313        },
314        async {
315            let mut cmd = Command::new("orch");
316            cmd.args(["task", "list"]).kill_on_drop(true);
317            match timeout(SUBPROCESS_TIMEOUT, cmd.output()).await {
318                Ok(Ok(output)) if output.status.success() => {
319                    String::from_utf8_lossy(&output.stdout).trim().to_string()
320                }
321                _ => "(could not fetch live state)".to_string(),
322            }
323        },
324        async {
325            let mut cmd = Command::new("orch");
326            cmd.args(["job", "list"]).kill_on_drop(true);
327            match timeout(SUBPROCESS_TIMEOUT, cmd.output()).await {
328                Ok(Ok(output)) if output.status.success() => {
329                    String::from_utf8_lossy(&output.stdout).trim().to_string()
330                }
331                _ => "(no jobs configured)".to_string(),
332            }
333        },
334    );
335
336    let version = env!("ORCH_VERSION");
337    let state = format!(
338        "## Current Live State\n### Version\norch {version}\n\n### Service\n{service_status}\n\n### Tasks\n{task_list}\n\n### Scheduled Jobs\n{job_list}\n\n### Memories\n{memories}\n\n### Recent Conversation\n{recent_summaries}"
339    );
340
341    // 6. Build system prompt (instructions only, no live state in system prompt)
342    let system = SYSTEM_TEMPLATE.to_string();
343
344    Ok(ChatContext { system, state })
345}
346
347/// Get the current model from KV, defaulting to the current agent's configured model when the
348/// key is absent. Returns an error if the store read fails.
349pub async fn get_model(store: &TaskStore) -> Result<String> {
350    match store
351        .kv_get("control:model")
352        .await
353        .context("reading control:model from store")?
354    {
355        Some(model) => Ok(model),
356        None => Ok(default_model_for_agent(&get_agent(store).await?)),
357    }
358}
359
360/// Get the current agent from KV, defaulting to `"claude"` when the key is absent.
361/// Returns an error if the store read fails.
362pub async fn get_agent(store: &TaskStore) -> Result<String> {
363    Ok(store
364        .kv_get("control:agent")
365        .await
366        .context("reading control:agent from store")?
367        .unwrap_or_else(|| "claude".to_string()))
368}
369
370/// Get the current control session spec from KV.
371pub async fn get_current_spec(store: &TaskStore) -> Result<ModelSpec> {
372    Ok(ModelSpec {
373        agent: get_agent(store).await?,
374        model: get_model(store).await?,
375    })
376}
377
378/// Set the current model in KV.
379pub async fn set_model(store: &TaskStore, model: &str) -> Result<()> {
380    store
381        .kv_set("control:model", model)
382        .await
383        .context("setting control model")?;
384    Ok(())
385}
386
387/// Set the current agent in KV.
388pub async fn set_agent(store: &TaskStore, agent: &str) -> Result<()> {
389    store
390        .kv_set("control:agent", agent)
391        .await
392        .context("setting control agent")?;
393    Ok(())
394}
395
396/// Set the current model and agent in KV.
397pub async fn set_model_spec(store: &TaskStore, spec: &ModelSpec) -> Result<()> {
398    set_model(store, &spec.model).await?;
399    set_agent(store, &spec.agent).await?;
400    Ok(())
401}
402
403/// Set the current agent and its default model in KV.
404pub async fn set_agent_spec(store: &TaskStore, agent: &str) -> Result<ModelSpec> {
405    let spec = ModelSpec {
406        agent: agent.to_string(),
407        model: default_model_for_agent(agent),
408    };
409    set_model_spec(store, &spec).await?;
410    Ok(spec)
411}
412
413fn display_model(model: &str) -> &str {
414    if model.is_empty() { "default" } else { model }
415}
416
417fn format_current_spec(spec: &ModelSpec) -> String {
418    format!("Current: {}:{}", spec.agent, display_model(&spec.model))
419}
420
421fn format_switched_spec(spec: &ModelSpec) -> String {
422    format!(
423        "Switched to **{}:{}** (validated)",
424        spec.agent,
425        display_model(&spec.model)
426    )
427}
428
429async fn handle_control_command(
430    store: &TaskStore,
431    session_id: &str,
432    channel_thread: Option<&str>,
433    command: ControlCommand,
434) -> Result<String> {
435    match command {
436        ControlCommand::Model(None) | ControlCommand::Agent(None) => {
437            Ok(format_current_spec(&get_current_spec(store).await?))
438        }
439        ControlCommand::Model(Some(new_spec)) => {
440            let spec = parse_model_spec(&new_spec, &get_agent(store).await?);
441            validate_model(&spec).await?;
442            set_model_spec(store, &spec).await?;
443            // Reset session UUID so next message starts a fresh conversation
444            reset_session_uuid(store, session_id).await?;
445            Ok(format_switched_spec(&spec))
446        }
447        ControlCommand::Agent(Some(agent)) => {
448            validate_agent(&agent)?;
449            let spec = set_agent_spec(store, &agent).await?;
450            // Reset session UUID so next message starts a fresh conversation
451            reset_session_uuid(store, session_id).await?;
452            Ok(format_switched_spec(&spec))
453        }
454        ControlCommand::Restart => {
455            // Store the conversation key so the post-restart startup check can send a confirmation.
456            if let Some(ct) = channel_thread {
457                store.kv_set("control:restart_pending", ct).await.ok();
458            }
459            // Delay the SIGTERM slightly so the acknowledgment reply is delivered first.
460            tokio::spawn(async {
461                tokio_time::sleep(Duration::from_secs(2)).await;
462                let pid = process::id().to_string();
463                let _ = Command::new("kill").args(["-TERM", &pid]).status().await;
464            });
465            Ok("Restarting orch service\u{2026} I'll confirm here when back online.".to_string())
466        }
467    }
468}
469
470/// Handle a control-session command if present.
471pub async fn maybe_handle_control_command(
472    store: &TaskStore,
473    session_id: &str,
474    channel_thread: Option<&str>,
475    message: &str,
476) -> Result<Option<String>> {
477    Ok(match parse_control_command(message) {
478        Some(command) => {
479            Some(handle_control_command(store, session_id, channel_thread, command).await?)
480        }
481        None => None,
482    })
483}
484
485/// Memory entry parsed from an agent response.
486#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct MemoryEntry {
488    pub key: String,
489    pub value: String,
490}
491
492/// Parsed agent response metadata.
493#[derive(Debug, Clone, PartialEq, Eq)]
494pub struct ParsedResponse {
495    pub clean_text: String,
496    pub summary: Option<String>,
497    pub memories: Vec<MemoryEntry>,
498}
499
500static MEMORY_RE: LazyLock<Regex> = LazyLock::new(|| {
501    Regex::new(r#"(?s)<memory\s+key="([^"]+)">(.+?)</memory>"#).expect("valid memory regex")
502});
503
504static SUMMARY_RE: LazyLock<Regex> =
505    LazyLock::new(|| Regex::new(r"(?s)<summary>(.+?)</summary>").expect("valid summary regex"));
506
507/// Parse an agent response, extracting optional `<summary>` and `<memory>` tags.
508pub fn parse_response(raw: &str) -> ParsedResponse {
509    let mut clean = raw.to_string();
510    let memories = MEMORY_RE
511        .captures_iter(raw)
512        .map(|caps| MemoryEntry {
513            key: caps[1].trim().to_string(),
514            value: caps[2].trim().to_string(),
515        })
516        .collect::<Vec<_>>();
517    clean = MEMORY_RE.replace_all(&clean, "").to_string();
518
519    let summary = SUMMARY_RE
520        .captures(raw)
521        .map(|caps| caps[1].trim().to_string());
522    clean = SUMMARY_RE.replace_all(&clean, "").to_string();
523
524    ParsedResponse {
525        clean_text: clean.trim().to_string(),
526        summary,
527        memories,
528    }
529}
530
531async fn persist_memories(
532    store: &TaskStore,
533    session_id: &str,
534    memories: &[MemoryEntry],
535) -> Result<()> {
536    for memory in memories {
537        store
538            .kv_set(
539                &format!("control:memory:{session_id}:{}", memory.key),
540                &memory.value,
541            )
542            .await
543            .with_context(|| format!("storing control memory {}", memory.key))?;
544    }
545    Ok(())
546}
547
548/// Result of an agent invocation, including response text and token usage.
549#[derive(Debug)]
550pub struct InvokeResult {
551    pub text: String,
552    pub input_tokens: Option<u64>,
553    pub output_tokens: Option<u64>,
554    /// Cost in USD as the agent reported it
555    pub cost_usd: Option<f64>,
556}
557
558// An empty model omits `--model`, so the agent's CLI uses its own default
559fn model_arg(model: &str) -> Option<&str> {
560    (!model.is_empty()).then_some(model)
561}
562
563fn token_u64_to_i64_saturating(tokens: u64) -> i64 {
564    i64::try_from(tokens).unwrap_or(i64::MAX)
565}
566
567fn opt_token_u64_to_i64_saturating(tokens: Option<u64>) -> Option<i64> {
568    tokens.map(token_u64_to_i64_saturating)
569}
570
571fn total_tokens_u64_to_i64_saturating(
572    input_tokens: Option<u64>,
573    output_tokens: Option<u64>,
574) -> Option<i64> {
575    match (input_tokens, output_tokens) {
576        (Some(input), Some(output)) => {
577            Some(token_u64_to_i64_saturating(input.saturating_add(output)))
578        }
579        (Some(tokens), None) | (None, Some(tokens)) => Some(token_u64_to_i64_saturating(tokens)),
580        _ => None,
581    }
582}
583
584/// Prepare temp directory for agent invocation files.
585///
586/// Uses `~/.orch/state/control/{pid}/` for isolation between concurrent processes.
587async fn prepare_temp_dir() -> Result<String> {
588    let home = home::orch_home()?;
589    let dir = format!("{}/state/control/{}", home.display(), process::id());
590    fs::create_dir_all(&dir).await?;
591    Ok(dir)
592}
593
594/// Invoke an agent CLI one-shot and return structured result with token usage.
595///
596/// Delegates to `runner::direct::run_direct()` for command building, execution,
597/// parsing, and error classification.
598///
599/// Writes temp files to `~/.orch/state/control/{pid}/`.
600pub async fn invoke_agent(
601    agent: &str,
602    model: &str,
603    context: &str,
604    message: &str,
605) -> Result<InvokeResult> {
606    let dir = prepare_temp_dir().await?;
607
608    let result = direct::run_direct(
609        agent,
610        model_arg(model),
611        context,
612        message,
613        AGENT_TIMEOUT,
614        &dir,
615    )
616    .await?;
617
618    Ok(InvokeResult {
619        text: result.text,
620        input_tokens: result.input_tokens,
621        output_tokens: result.output_tokens,
622        cost_usd: result.cost_usd,
623    })
624}
625
626/// KV key for storing the session UUID for a named session.
627fn session_uuid_key(session_name: &str) -> String {
628    format!("control:session_id:{session_name}")
629}
630
631/// Get or create a session UUID for the given session name.
632///
633/// On first call for a session, generates a new UUID and persists it to KV.
634/// Subsequent calls return the same UUID, giving agents conversation continuity
635/// via `--session-id`.
636/// Get or create a session UUID for the given session name.
637///
638/// On first call for a session, generates a new UUID and persists it to KV.
639/// Subsequent calls return the same UUID, giving agents conversation continuity
640/// via `--session-id` (new) or `--resume` (existing).
641///
642/// Returns `(uuid, is_new)` so callers know whether to create or resume the session.
643pub async fn get_or_create_session_uuid(
644    store: &TaskStore,
645    session_name: &str,
646) -> Result<(String, bool)> {
647    let key = session_uuid_key(session_name);
648    let candidate = Uuid::new_v4().to_string();
649    let stored = store
650        .kv_insert_if_absent(&key, &candidate)
651        .await
652        .context("storing session UUID")?;
653    let is_new = stored == candidate;
654    if is_new {
655        tracing::info!(session_name, uuid = %stored, "created new chat session UUID");
656    }
657    Ok((stored, is_new))
658}
659
660/// Reset the session UUID so the next message starts a fresh conversation.
661///
662/// Called when agent or model changes, since the new agent won't share
663/// the previous agent's session state. Deletes the stored UUID so the next
664/// `get_or_create_session_uuid` call generates a fresh one.
665pub async fn reset_session_uuid(store: &TaskStore, session_name: &str) -> Result<()> {
666    let key = session_uuid_key(session_name);
667    store
668        .kv_delete(&key)
669        .await
670        .context("resetting session UUID")?;
671    tracing::info!(session_name, "reset chat session UUID");
672    Ok(())
673}
674
675/// Invoke an agent one-shot with `--session-id` or `--resume` for conversation continuity.
676///
677/// For claude-compatible agents (claude, kimi, minimax), passes `--session-id <uuid>`
678/// when creating a new session, or `--resume <uuid>` when continuing an existing one.
679/// Other agents get a plain one-shot call.
680pub async fn invoke_agent_with_session(
681    agent: &str,
682    model: &str,
683    context: &str,
684    message: &str,
685    session_uuid: Option<&str>,
686    resume: bool,
687) -> Result<InvokeResult> {
688    let dir = prepare_temp_dir().await?;
689
690    let session = session_uuid.map(|sid| crate::engine::runner::direct::SessionContinuation {
691        session_id: sid,
692        resume,
693    });
694
695    let result = direct::run_direct_with_session(
696        agent,
697        model_arg(model),
698        context,
699        message,
700        AGENT_TIMEOUT,
701        &dir,
702        session,
703    )
704    .await?;
705
706    Ok(InvokeResult {
707        text: result.text,
708        input_tokens: result.input_tokens,
709        output_tokens: result.output_tokens,
710        cost_usd: result.cost_usd,
711    })
712}
713
714/// High-level entry point: process a user message and return the assistant response.
715///
716/// Handles `/model` and `/agent` commands, assembles context, invokes the agent,
717/// parses the response for summaries, and stores both user and assistant messages.
718pub async fn send_message(
719    store: &TaskStore,
720    session_id: &str,
721    channel: &str,
722    channel_thread: Option<&str>,
723    message: &str,
724) -> Result<String> {
725    // Handle control commands — don't store as messages.
726    if let Some(response) =
727        maybe_handle_control_command(store, session_id, channel_thread, message).await?
728    {
729        return Ok(response);
730    }
731
732    // Acquire per-session lock — serializes concurrent invocations for the same session.
733    // Messages that arrive while an invocation is running will queue here and execute in order.
734    let session_lock = {
735        let mut map = SESSION_LOCKS.lock().unwrap_or_else(PoisonError::into_inner);
736        Arc::clone(
737            map.entry(session_id.to_string())
738                .or_insert_with(|| Arc::new(sync::Mutex::new(()))),
739        )
740    };
741    let guard = session_lock.lock().await;
742
743    // Store user message
744    store
745        .insert_control_message(
746            session_id,
747            "user",
748            channel,
749            channel_thread,
750            message,
751            None,
752            None,
753            None,
754            None,
755            None,
756            None,
757            None,
758        )
759        .await?;
760
761    // Assemble context
762    let ctx = assemble_context(store, session_id).await?;
763
764    // Resolve model and agent from KV
765    let model = get_model(store).await?;
766    let agent = get_agent(store).await?;
767
768    // Prepend live state to user message so the LLM sees it directly
769    let full_message = format!("{}\n\n---\n\n{}", ctx.state, message);
770
771    // Get or create a session UUID for conversation continuity
772    let (session_uuid, is_new) = get_or_create_session_uuid(store, session_id).await?;
773
774    // When the combined payload exceeds the per-turn limit, split it across multiple
775    // sequential turns so the user's question (trailing content) is never truncated.
776    // For session-supporting agents (claude, kimi, minimax), use --resume for continuity
777    // and persist ALL intermediate assistant responses so local transcript matches provider.
778    // For other agents (codex, opencode), split into chunks and send each as independent
779    // invocation (no --resume), each getting its own persisted assistant response.
780    let agent_has_sessions = agents::supports_session(&agent);
781    let (chunks, delivery_mode) = if full_message.len() > MAX_CONTROL_MESSAGE_BYTES {
782        tracing::info!(
783            bytes = full_message.len(),
784            limit = MAX_CONTROL_MESSAGE_BYTES,
785            agent = %agent,
786            agent_has_sessions,
787            "control session payload exceeds limit"
788        );
789        if agent_has_sessions {
790            // Session agents: split into chunks for sequential delivery with --resume
791            (
792                build_control_chunks(&ctx.state, message, true),
793                DeliveryMode::Chunked,
794            )
795        } else {
796            // Non-session agents: split into chunks, send each independently
797            (
798                build_control_chunks(&ctx.state, message, false),
799                DeliveryMode::ChunkedNonSession,
800            )
801        }
802    } else {
803        (vec![full_message], DeliveryMode::Single)
804    };
805
806    let _num_chunks = chunks.len();
807
808    // Deliver chunks based on mode
809    let result = match delivery_mode {
810        DeliveryMode::Chunked => {
811            // Session-based delivery (claude, kimi, minimax) — use --resume for continuity
812            // Persist ALL intermediate responses so local DB matches provider transcript.
813            let mut active_uuid = session_uuid.clone();
814            let mut is_first_chunk = !is_new;
815            let mut last_result: Option<InvokeResult> = None;
816
817            for chunk in &chunks {
818                let r = invoke_agent_with_session(
819                    &agent,
820                    &model,
821                    &ctx.system,
822                    chunk,
823                    Some(&active_uuid),
824                    is_first_chunk,
825                )
826                .await;
827                let chunk_result = match r {
828                    Ok(result) => result,
829                    Err(e) => {
830                        let err_str = e.to_string();
831                        let is_stale = err_str.contains("stale session");
832                        let is_timeout = err_str.contains("timeout after")
833                            || err_str.contains("timed out after");
834                        if is_stale || is_timeout {
835                            if is_stale {
836                                tracing::info!(
837                                    session_id,
838                                    "chat session UUID expired; resetting and retrying with fresh session"
839                                );
840                            } else {
841                                tracing::info!(
842                                    session_id,
843                                    "chat session timed out; resetting and retrying with fresh session"
844                                );
845                            }
846                            reset_session_uuid(store, session_id).await?;
847                            let (fresh_uuid, _) =
848                                get_or_create_session_uuid(store, session_id).await?;
849                            active_uuid = fresh_uuid;
850                            is_first_chunk = false;
851                            let fresh_result = invoke_agent_with_session(
852                                &agent,
853                                &model,
854                                &ctx.system,
855                                chunk,
856                                Some(&active_uuid),
857                                false,
858                            )
859                            .await?;
860
861                            if is_timeout {
862                                InvokeResult {
863                                    text: format!(
864                                        "_(New session started.)_\n\n{}",
865                                        fresh_result.text
866                                    ),
867                                    ..fresh_result
868                                }
869                            } else {
870                                fresh_result
871                            }
872                        } else {
873                            return Err(e);
874                        }
875                    }
876                };
877
878                // Persist this chunk's assistant response (even intermediate ones)
879                let parsed = parse_response(&chunk_result.text);
880                let total_tokens = total_tokens_u64_to_i64_saturating(
881                    chunk_result.input_tokens,
882                    chunk_result.output_tokens,
883                );
884                let input_tokens = opt_token_u64_to_i64_saturating(chunk_result.input_tokens);
885                let output_tokens = opt_token_u64_to_i64_saturating(chunk_result.output_tokens);
886                let cost_usd = chunk_result.cost_usd;
887                store
888                    .insert_control_message(
889                        session_id,
890                        "assistant",
891                        channel,
892                        channel_thread,
893                        &parsed.clean_text,
894                        parsed.summary.as_deref(),
895                        Some(&model),
896                        Some(&agent),
897                        input_tokens,
898                        output_tokens,
899                        total_tokens,
900                        cost_usd,
901                    )
902                    .await?;
903                persist_memories(store, session_id, &parsed.memories).await?;
904
905                last_result = Some(chunk_result);
906            }
907
908            // Return final clean_text directly — all chunks already persisted inline
909            let final_parsed = parse_response(&last_result.expect("at least one chunk").text);
910            final_parsed.clean_text
911        }
912        DeliveryMode::ChunkedNonSession => {
913            // Non-session agents (codex, opencode) — each chunk is independent invocation
914            // No --resume, but each chunk's response is persisted to keep transcript complete.
915            let mut last_text = String::new();
916
917            for chunk in &chunks {
918                let chunk_result = invoke_agent(&agent, &model, &ctx.system, chunk).await?;
919
920                // Persist this chunk's assistant response
921                let parsed = parse_response(&chunk_result.text);
922                let total_tokens = total_tokens_u64_to_i64_saturating(
923                    chunk_result.input_tokens,
924                    chunk_result.output_tokens,
925                );
926                let input_tokens = opt_token_u64_to_i64_saturating(chunk_result.input_tokens);
927                let output_tokens = opt_token_u64_to_i64_saturating(chunk_result.output_tokens);
928                let cost_usd = chunk_result.cost_usd;
929                store
930                    .insert_control_message(
931                        session_id,
932                        "assistant",
933                        channel,
934                        channel_thread,
935                        &parsed.clean_text,
936                        parsed.summary.as_deref(),
937                        Some(&model),
938                        Some(&agent),
939                        input_tokens,
940                        output_tokens,
941                        total_tokens,
942                        cost_usd,
943                    )
944                    .await?;
945                persist_memories(store, session_id, &parsed.memories).await?;
946
947                last_text = parsed.clean_text;
948            }
949
950            last_text
951        }
952        DeliveryMode::Single => {
953            // Single message delivery (under-limit messages) — use invoke_agent_with_session
954            // for conversation continuity with --session-id / --resume
955            let mut active_uuid = session_uuid.clone();
956            let mut is_first_chunk = !is_new;
957            let chunk_result = loop {
958                let r = invoke_agent_with_session(
959                    &agent,
960                    &model,
961                    &ctx.system,
962                    &chunks[0],
963                    Some(&active_uuid),
964                    is_first_chunk,
965                )
966                .await;
967                match r {
968                    Ok(result) => break result,
969                    Err(e) => {
970                        let err_str = e.to_string();
971                        let is_stale = err_str.contains("stale session");
972                        let is_timeout = err_str.contains("timeout after")
973                            || err_str.contains("timed out after");
974                        if is_stale || is_timeout {
975                            if is_stale {
976                                tracing::info!(
977                                    session_id,
978                                    "chat session UUID expired; resetting and retrying with fresh session"
979                                );
980                            } else {
981                                tracing::info!(
982                                    session_id,
983                                    "chat session timed out; resetting and retrying with fresh session"
984                                );
985                            }
986                            reset_session_uuid(store, session_id).await?;
987                            let (fresh_uuid, _) =
988                                get_or_create_session_uuid(store, session_id).await?;
989                            active_uuid = fresh_uuid;
990                            is_first_chunk = false;
991                            continue;
992                        }
993                        return Err(e);
994                    }
995                }
996            };
997
998            // Parse response for summary and memory tags
999            let parsed = parse_response(&chunk_result.text);
1000
1001            // Store assistant message with token usage.
1002            // Must happen before persist_memories so that if the DB write fails the
1003            // KV store is not left with orphaned memory keys that have no corresponding
1004            // conversation history entry.
1005            let total_tokens = total_tokens_u64_to_i64_saturating(
1006                chunk_result.input_tokens,
1007                chunk_result.output_tokens,
1008            );
1009            let input_tokens = opt_token_u64_to_i64_saturating(chunk_result.input_tokens);
1010            let output_tokens = opt_token_u64_to_i64_saturating(chunk_result.output_tokens);
1011            let cost_usd = chunk_result.cost_usd;
1012            store
1013                .insert_control_message(
1014                    session_id,
1015                    "assistant",
1016                    channel,
1017                    channel_thread,
1018                    &parsed.clean_text,
1019                    parsed.summary.as_deref(),
1020                    Some(&model),
1021                    Some(&agent),
1022                    input_tokens,
1023                    output_tokens,
1024                    total_tokens,
1025                    cost_usd,
1026                )
1027                .await?;
1028            persist_memories(store, session_id, &parsed.memories).await?;
1029
1030            parsed.clean_text
1031        }
1032    };
1033
1034    // Release the per-session lock before eviction check.
1035    drop(guard);
1036
1037    // Evict the session lock entry if no concurrent waiters are holding a reference.
1038    // After drop(guard) above the remaining strong references are:
1039    // - the map entry
1040    // - the local `session_lock` clone held by this function.
1041    // That sums to 2 when there are no other waiters. If another waiter entered
1042    // concurrently the count will be > 2 and we should not evict. Only remove the
1043    // map entry when the strong_count == 2.
1044    if Arc::strong_count(&session_lock) == 2 {
1045        let mut map = SESSION_LOCKS.lock().unwrap_or_else(PoisonError::into_inner);
1046        // Re-check under the map lock: a concurrent caller may have cloned the Arc
1047        // between our strong_count check above and acquiring the map lock.
1048        if map
1049            .get(session_id)
1050            .is_some_and(|l| Arc::strong_count(l) == 2)
1051        {
1052            map.remove(session_id);
1053        }
1054    }
1055
1056    Ok(result)
1057}
1058
1059/// Split `text` into chunks of at most `max_bytes` bytes each.
1060/// Prefers splitting at newline boundaries to preserve readability.
1061fn chunk_text(text: &str, max_bytes: usize) -> Vec<String> {
1062    if text.len() <= max_bytes {
1063        return vec![text.to_string()];
1064    }
1065    let mut chunks = Vec::new();
1066    let mut start = 0;
1067    let total = text.len();
1068
1069    while start < total {
1070        let mut end = (start + max_bytes).min(total);
1071        // Respect UTF-8 char boundaries
1072        while end > start && !text.is_char_boundary(end) {
1073            end -= 1;
1074        }
1075        if end == start {
1076            // Single char exceeds limit — advance past it
1077            if let Some(ch) = text[start..].chars().next() {
1078                end = start + ch.len_utf8();
1079            } else {
1080                break;
1081            }
1082        }
1083        // Prefer splitting at a newline within the chunk
1084        if end < total
1085            && let Some(nl_offset) = text[start..end].rfind('\n')
1086        {
1087            end = start + nl_offset + 1;
1088        }
1089        chunks.push(text[start..end].to_string());
1090        start = end;
1091    }
1092    chunks
1093}
1094
1095/// Build control session message chunks for agents with or without session support.
1096///
1097/// For session-supporting agents (claude, kimi, minimax), chunks are raw text pieces
1098/// that will be sent sequentially with `--resume` for conversation continuity.
1099///
1100/// For non-session agents (codex, opencode), each chunk is prefixed with a header
1101/// indicating its position in the sequence, since each invocation is independent.
1102fn build_control_chunks(state: &str, message: &str, agent_has_sessions: bool) -> Vec<String> {
1103    let header_reserve = if agent_has_sessions { 0 } else { 200 };
1104    let effective_limit = MAX_CONTROL_MESSAGE_BYTES.saturating_sub(header_reserve);
1105    let mut parts = Vec::new();
1106
1107    // Split state into chunks
1108    let state_chunks = chunk_text(state, effective_limit);
1109    for (i, chunk) in state_chunks.iter().enumerate() {
1110        let prefixed = if agent_has_sessions {
1111            chunk.clone()
1112        } else {
1113            format!(
1114                "[Context chunk {}/{}]\n{}",
1115                i + 1,
1116                state_chunks.len(),
1117                chunk
1118            )
1119        };
1120        parts.push(prefixed);
1121    }
1122
1123    // Split user message if needed, then add as final chunk(s)
1124    let msg_chunks = chunk_text(message, effective_limit);
1125    for (i, chunk) in msg_chunks.iter().enumerate() {
1126        let prefixed = if agent_has_sessions {
1127            chunk.clone()
1128        } else if msg_chunks.len() == 1 {
1129            format!("[Context complete. User question:]\n{chunk}")
1130        } else {
1131            format!(
1132                "[User question chunk {}/{}]\n{}",
1133                i + 1,
1134                msg_chunks.len(),
1135                chunk
1136            )
1137        };
1138        parts.push(prefixed);
1139    }
1140    parts
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145    use tokio::runtime::Runtime;
1146
1147    use std::collections::BTreeSet;
1148
1149    use super::*;
1150    use crate::store::TaskStore;
1151
1152    #[test]
1153    fn parse_spec_agent_model() {
1154        let spec = parse_model_spec("minimax:sonnet", "claude");
1155        assert_eq!(spec.agent, "minimax");
1156        assert_eq!(spec.model, "sonnet");
1157    }
1158
1159    #[test]
1160    fn parse_spec_opencode_model() {
1161        let spec = parse_model_spec("opencode:minimax-m2.5-free", "claude");
1162        assert_eq!(spec.agent, "opencode");
1163        assert_eq!(spec.model, "minimax-m2.5-free");
1164    }
1165
1166    fn configured(entries: &[(&str, &[&str])]) -> Vec<(String, Vec<String>)> {
1167        entries
1168            .iter()
1169            .map(|(agent, models)| {
1170                (
1171                    agent.to_string(),
1172                    models.iter().map(ToString::to_string).collect(),
1173                )
1174            })
1175            .collect()
1176    }
1177
1178    #[test]
1179    fn infer_agent_finds_the_agent_whose_config_lists_the_model() {
1180        let configured = configured(&[("claude", &["sonnet"]), ("codex", &["gpt-4o"])]);
1181        assert_eq!(infer_agent("gpt-4o", &configured, "claude"), "codex");
1182        assert_eq!(infer_agent("sonnet", &configured, "codex"), "claude");
1183    }
1184
1185    #[test]
1186    fn infer_agent_keeps_the_current_agent_for_an_unlisted_model() {
1187        let configured = configured(&[("claude", &["sonnet"])]);
1188        assert_eq!(
1189            infer_agent("deepseek-r1", &configured, "opencode"),
1190            "opencode"
1191        );
1192    }
1193
1194    #[test]
1195    fn first_model_skips_pool_keywords() {
1196        let models = vec!["opencode:free".to_string(), "provider/model".to_string()];
1197        assert_eq!(first_model(&models), "provider/model");
1198    }
1199
1200    #[test]
1201    fn first_model_is_empty_without_models() {
1202        assert_eq!(first_model(&[]), "");
1203        assert_eq!(model_arg(""), None);
1204        assert_eq!(model_arg("sonnet"), Some("sonnet"));
1205    }
1206
1207    #[tokio::test]
1208    async fn assemble_context_includes_memories_and_summaries() {
1209        let store = TaskStore::open_memory().await.unwrap();
1210        store
1211            .kv_set("control:memory:default:tz", "User timezone is UTC-3")
1212            .await
1213            .unwrap();
1214        store
1215            .insert_control_message(
1216                "default",
1217                "assistant",
1218                "cli",
1219                None,
1220                "response",
1221                Some("checked status"),
1222                Some("sonnet"),
1223                Some("claude"),
1224                None,
1225                None,
1226                None,
1227                None,
1228            )
1229            .await
1230            .unwrap();
1231
1232        let ctx = assemble_context(&store, "default").await.unwrap();
1233        assert!(ctx.state.contains("UTC-3"), "should include memories");
1234        assert!(
1235            ctx.state.contains("checked status"),
1236            "should include summaries"
1237        );
1238    }
1239
1240    #[test]
1241    fn extract_summary_from_response() {
1242        let response = "Here are your tasks.\n\n<summary>listed 3 active tasks</summary>";
1243        let parsed = parse_response(response);
1244        assert_eq!(parsed.summary.as_deref(), Some("listed 3 active tasks"));
1245        assert!(!parsed.clean_text.contains("<summary>"));
1246    }
1247
1248    #[test]
1249    fn extract_summary_missing() {
1250        let response = "No tasks found.";
1251        let parsed = parse_response(response);
1252        assert_eq!(parsed.summary, None);
1253        assert_eq!(parsed.clean_text, "No tasks found.");
1254    }
1255
1256    #[test]
1257    fn extract_summary_closing_tag_before_opening_does_not_panic() {
1258        // </summary> appears before <summary> — must not panic
1259        let response = "Example: </summary> is a closing tag.\n<summary>actual summary</summary>";
1260        let parsed = parse_response(response);
1261        assert_eq!(parsed.summary.as_deref(), Some("actual summary"));
1262        assert!(!parsed.clean_text.contains("<summary>"));
1263    }
1264
1265    #[test]
1266    fn extract_memory_tags() {
1267        let response = r#"Remember these.
1268
1269<memory key="tz">User timezone is UTC-3</memory>
1270<memory key="theme">Prefer concise answers</memory>
1271
1272<summary>stored two memories</summary>"#;
1273        let parsed = parse_response(response);
1274        assert_eq!(parsed.memories.len(), 2);
1275        assert_eq!(parsed.memories[0].key, "tz");
1276        assert_eq!(parsed.memories[0].value, "User timezone is UTC-3");
1277        assert_eq!(parsed.memories[1].key, "theme");
1278        assert_eq!(parsed.memories[1].value, "Prefer concise answers");
1279        assert_eq!(parsed.summary.as_deref(), Some("stored two memories"));
1280        assert!(!parsed.clean_text.contains("<memory"));
1281    }
1282
1283    #[tokio::test]
1284    async fn persist_memories_writes_to_kv() {
1285        let store = TaskStore::open_memory().await.unwrap();
1286        let memories = vec![MemoryEntry {
1287            key: "tz".to_string(),
1288            value: "User timezone is UTC-3".to_string(),
1289        }];
1290
1291        persist_memories(&store, "default", &memories)
1292            .await
1293            .unwrap();
1294
1295        assert_eq!(
1296            store
1297                .kv_get("control:memory:default:tz")
1298                .await
1299                .unwrap()
1300                .as_deref(),
1301            Some("User timezone is UTC-3")
1302        );
1303    }
1304
1305    #[tokio::test]
1306    async fn send_message_model_switch() {
1307        let store = TaskStore::open_memory().await.unwrap();
1308        set_agent(&store, "unlisted-agent").await.unwrap();
1309        let model = get_model(&store).await.unwrap();
1310        assert_eq!(model, "", "an agent with no models: entry gets no --model");
1311
1312        // /model with explicit agent:model — will fail validation (no binary in test)
1313        // so test parse_model_spec + set_model_spec directly
1314        let spec = parse_model_spec("minimax:sonnet", "claude");
1315        set_model_spec(&store, &spec).await.unwrap();
1316        assert_eq!(get_model(&store).await.unwrap(), "sonnet");
1317        assert_eq!(get_agent(&store).await.unwrap(), "minimax");
1318    }
1319
1320    #[tokio::test]
1321    async fn send_message_model_show_current() {
1322        let store = TaskStore::open_memory().await.unwrap();
1323        set_agent(&store, "unlisted-agent").await.unwrap();
1324        let response = send_message(&store, "default", "cli", None, "/model")
1325            .await
1326            .unwrap();
1327        assert_eq!(response, "Current: unlisted-agent:default");
1328    }
1329
1330    #[tokio::test]
1331    async fn send_message_agent_show_current() {
1332        let store = TaskStore::open_memory().await.unwrap();
1333        set_agent(&store, "unlisted-agent").await.unwrap();
1334        let response = send_message(&store, "default", "cli", None, "/agent")
1335            .await
1336            .unwrap();
1337        assert_eq!(response, "Current: unlisted-agent:default");
1338    }
1339
1340    #[tokio::test]
1341    async fn send_message_agent_switches_with_default_model() {
1342        let store = TaskStore::open_memory().await.unwrap();
1343        // Directly set agent spec to bypass validation (no binary in test env)
1344        let spec = set_agent_spec(&store, "unlisted-agent").await.unwrap();
1345        assert_eq!(spec.agent, "unlisted-agent");
1346        assert_eq!(
1347            spec.model, "",
1348            "an agent with no models: entry gets no --model"
1349        );
1350        assert_eq!(get_agent(&store).await.unwrap(), "unlisted-agent");
1351        assert_eq!(get_model(&store).await.unwrap(), "");
1352    }
1353
1354    #[test]
1355    fn parse_control_command_model_and_agent() {
1356        assert_eq!(
1357            parse_control_command("/model"),
1358            Some(ControlCommand::Model(None))
1359        );
1360        assert_eq!(
1361            parse_control_command("/agent"),
1362            Some(ControlCommand::Agent(None))
1363        );
1364        assert_eq!(
1365            parse_control_command("/agent codex"),
1366            Some(ControlCommand::Agent(Some("codex".to_string())))
1367        );
1368        assert_eq!(
1369            parse_control_command("/model opencode:deepseek-r1"),
1370            Some(ControlCommand::Model(Some(
1371                "opencode:deepseek-r1".to_string()
1372            )))
1373        );
1374        assert_eq!(
1375            parse_control_command("/restart"),
1376            Some(ControlCommand::Restart)
1377        );
1378        assert_eq!(parse_control_command("/kill"), None);
1379        assert_eq!(parse_control_command("/session"), None);
1380    }
1381
1382    #[test]
1383    fn control_prompt_slash_commands_match_supported_surface() {
1384        let documented = Regex::new(r"`/([a-z]+)(?:[^`]*)`")
1385            .unwrap()
1386            .captures_iter(SYSTEM_TEMPLATE)
1387            .map(|caps| caps[1].to_string())
1388            .collect::<BTreeSet<_>>();
1389
1390        let expected = ["agent", "model", "restart"]
1391            .into_iter()
1392            .map(str::to_string)
1393            .collect::<BTreeSet<_>>();
1394
1395        assert_eq!(documented, expected);
1396    }
1397
1398    #[tokio::test]
1399    async fn set_agent_spec_persists_default_model() {
1400        let store = TaskStore::open_memory().await.unwrap();
1401        let spec = set_agent_spec(&store, "unlisted-agent").await.unwrap();
1402        assert_eq!(spec.agent, "unlisted-agent");
1403        assert_eq!(spec.model, "");
1404        assert_eq!(get_agent(&store).await.unwrap(), "unlisted-agent");
1405        assert_eq!(get_model(&store).await.unwrap(), "");
1406    }
1407
1408    #[tokio::test]
1409    async fn send_message_model_switch_does_not_store() {
1410        let store = TaskStore::open_memory().await.unwrap();
1411        // set directly to avoid validation in test env
1412        let spec = parse_model_spec("claude:haiku", "claude");
1413        set_model_spec(&store, &spec).await.unwrap();
1414        let messages = store
1415            .list_control_messages("default", None, 10)
1416            .await
1417            .unwrap();
1418        assert_eq!(messages.len(), 0);
1419    }
1420
1421    #[test]
1422    fn validate_unknown_agent_fails() {
1423        let spec = ModelSpec {
1424            agent: "nonexistent".to_string(),
1425            model: "test".to_string(),
1426        };
1427        let rt = Runtime::new().unwrap();
1428        let result = rt.block_on(validate_model(&spec));
1429        assert!(result.is_err());
1430        assert!(result.unwrap_err().to_string().contains("unknown agent"));
1431    }
1432
1433    #[test]
1434    fn validate_agent_rejects_unknown() {
1435        let result = validate_agent("nonexistent");
1436        assert!(result.is_err());
1437        let err = result.unwrap_err().to_string();
1438        assert!(err.contains("unknown agent"), "got: {err}");
1439    }
1440
1441    #[test]
1442    fn validate_agent_rejects_empty() {
1443        let result = validate_agent("");
1444        assert!(result.is_err());
1445        assert!(result.unwrap_err().to_string().contains("unknown agent"));
1446    }
1447
1448    #[tokio::test]
1449    async fn agent_command_rejects_unknown_before_persisting() {
1450        let store = TaskStore::open_memory().await.unwrap();
1451        // Set a known-good agent first
1452        set_agent(&store, "claude").await.unwrap();
1453
1454        // Try switching to an unknown agent — should fail
1455        let result =
1456            maybe_handle_control_command(&store, "default", None, "/agent bogusagent").await;
1457        assert!(result.is_err(), "should reject unknown agent");
1458        assert!(result.unwrap_err().to_string().contains("unknown agent"));
1459
1460        // Verify the original agent is still set (not overwritten)
1461        assert_eq!(get_agent(&store).await.unwrap(), "claude");
1462    }
1463
1464    #[test]
1465    fn token_u64_to_i64_saturating_caps_large_values() {
1466        assert_eq!(token_u64_to_i64_saturating(u64::MAX), i64::MAX);
1467    }
1468
1469    #[test]
1470    fn total_tokens_u64_to_i64_saturating_caps_single_large_value() {
1471        assert_eq!(
1472            total_tokens_u64_to_i64_saturating(Some(u64::MAX), None),
1473            Some(i64::MAX)
1474        );
1475    }
1476
1477    #[test]
1478    fn total_tokens_u64_to_i64_saturating_caps_large_sum() {
1479        assert_eq!(
1480            total_tokens_u64_to_i64_saturating(Some(u64::MAX), Some(1)),
1481            Some(i64::MAX)
1482        );
1483    }
1484
1485    #[tokio::test]
1486    async fn insert_control_message_stores_cost_usd() {
1487        let store = TaskStore::open_memory().await.unwrap();
1488
1489        let input_tokens: u64 = 500_000;
1490        let output_tokens: u64 = 100_000;
1491        let cost_usd = Some(0.25);
1492
1493        store
1494            .insert_control_message(
1495                "default",
1496                "assistant",
1497                "cli",
1498                None,
1499                "hello",
1500                Some("greeted"),
1501                Some("sonnet"),
1502                Some("claude"),
1503                opt_token_u64_to_i64_saturating(Some(input_tokens)),
1504                opt_token_u64_to_i64_saturating(Some(output_tokens)),
1505                total_tokens_u64_to_i64_saturating(Some(input_tokens), Some(output_tokens)),
1506                cost_usd,
1507            )
1508            .await
1509            .unwrap();
1510
1511        let messages = store
1512            .list_control_messages("default", None, 10)
1513            .await
1514            .unwrap();
1515        assert_eq!(messages.len(), 1);
1516        let msg = &messages[0];
1517        assert!(msg.cost_usd.is_some(), "cost_usd must be stored, not NULL");
1518        assert_eq!(
1519            msg.cost_usd,
1520            Some(0.25),
1521            "the reported cost is stored as is"
1522        );
1523    }
1524
1525    #[tokio::test]
1526    async fn session_uuid_created_and_stable() {
1527        let store = TaskStore::open_memory().await.unwrap();
1528        let (uuid1, is_new1) = get_or_create_session_uuid(&store, "default").await.unwrap();
1529        let (uuid2, is_new2) = get_or_create_session_uuid(&store, "default").await.unwrap();
1530        assert_eq!(uuid1, uuid2, "same session should return same UUID");
1531        assert!(is_new1, "first call should be new");
1532        assert!(!is_new2, "second call should be existing");
1533        assert_eq!(uuid1.len(), 36, "should be a valid UUID string");
1534    }
1535
1536    #[tokio::test]
1537    async fn session_uuid_differs_per_session() {
1538        let store = TaskStore::open_memory().await.unwrap();
1539        let (uuid_default, _) = get_or_create_session_uuid(&store, "default").await.unwrap();
1540        let (uuid_ops, _) = get_or_create_session_uuid(&store, "ops").await.unwrap();
1541        assert_ne!(uuid_default, uuid_ops);
1542    }
1543
1544    #[tokio::test]
1545    async fn session_uuid_reset_creates_new() {
1546        let store = TaskStore::open_memory().await.unwrap();
1547        let (uuid1, _) = get_or_create_session_uuid(&store, "default").await.unwrap();
1548        reset_session_uuid(&store, "default").await.unwrap();
1549        let (uuid2, is_new2) = get_or_create_session_uuid(&store, "default").await.unwrap();
1550        assert_ne!(uuid1, uuid2, "reset should produce a new UUID");
1551        assert!(is_new2, "after reset should be new");
1552    }
1553
1554    /// Verify that the timeout error patterns used in send_message recovery match
1555    /// the actual error strings produced by the agent runner (AgentError::Timeout
1556    /// formats as "timeout after Xs"; run_shell_command formats as "agent timed out
1557    /// after Xs"). Both must trigger session reset rather than bubbling up as errors.
1558    #[test]
1559    fn timeout_error_patterns_match_runner_output() {
1560        let patterns = ["timeout after 1800s", "agent timed out after 120s"];
1561        for &msg in &patterns {
1562            let is_timeout = msg.contains("timeout after") || msg.contains("timed out after");
1563            assert!(is_timeout, "pattern should be detected as timeout: {msg:?}");
1564        }
1565        // Unrelated errors must not be misidentified as timeouts.
1566        let non_timeout = [
1567            "rate limit: 429",
1568            "stale session (session expired): abc",
1569            "auth error: invalid key",
1570        ];
1571        for &msg in &non_timeout {
1572            let is_timeout = msg.contains("timeout after") || msg.contains("timed out after");
1573            assert!(
1574                !is_timeout,
1575                "pattern must not be detected as timeout: {msg:?}"
1576            );
1577        }
1578    }
1579
1580    // ── chunk_text ──────────────────────────────────────────────────────────────
1581
1582    #[test]
1583    fn chunk_text_under_limit_returns_single_chunk() {
1584        let text = "hello world";
1585        let chunks = chunk_text(text, 100);
1586        assert_eq!(chunks, vec!["hello world".to_string()]);
1587    }
1588
1589    #[test]
1590    fn chunk_text_exact_limit_returns_single_chunk() {
1591        let text = "abcde";
1592        let chunks = chunk_text(text, 5);
1593        assert_eq!(chunks.len(), 1);
1594        assert_eq!(chunks[0], "abcde");
1595    }
1596
1597    #[test]
1598    fn chunk_text_splits_and_reassembles() {
1599        let text = "line1\nline2\nline3\nline4\nline5";
1600        let chunks = chunk_text(text, 12);
1601        let reassembled: String = chunks.join("");
1602        assert_eq!(reassembled, text);
1603    }
1604
1605    #[test]
1606    fn chunk_text_respects_utf8_boundaries() {
1607        // 4-byte emoji placed at the split boundary
1608        let content = format!("{}😀", "x".repeat(97)); // 101 bytes; limit=100 cuts inside emoji
1609
1610        let chunks = chunk_text(&content, 100);
1611        let reassembled: String = chunks.join("");
1612        assert_eq!(reassembled, content);
1613        for c in &chunks {
1614            assert!(
1615                str::from_utf8(c.as_bytes()).is_ok(),
1616                "chunk is not valid UTF-8"
1617            );
1618        }
1619    }
1620
1621    #[test]
1622    fn chunk_text_prefers_newline_boundary() {
1623        // "aaaa\nbbbb" with limit=6 — should split after the '\n', not mid-word
1624        let text = "aaaa\nbbbb";
1625        let chunks = chunk_text(text, 6);
1626        // First chunk should end with '\n' (split at newline, not byte 6 into "aaaa\nb")
1627        assert!(
1628            chunks[0].ends_with('\n'),
1629            "expected split at newline, got: {:?}",
1630            chunks[0]
1631        );
1632        let reassembled: String = chunks.join("");
1633        assert_eq!(reassembled, text);
1634    }
1635
1636    #[test]
1637    fn chunk_text_empty_string() {
1638        let chunks = chunk_text("", 32);
1639        assert!(chunks.is_empty() || chunks == vec![String::new()]);
1640    }
1641
1642    // ── build_control_chunks ────────────────────────────────────────────────
1643
1644    #[test]
1645    fn build_control_chunks_session_agent_splits_state_and_message() {
1646        // build_control_chunks always splits state and message into separate chunks
1647        // (it's only called when full_message exceeds the limit)
1648        let state = "state content";
1649        let message = "user question";
1650        let chunks = build_control_chunks(state, message, true);
1651        assert_eq!(chunks.len(), 2);
1652        assert_eq!(chunks[0], "state content");
1653        assert_eq!(chunks[1], "user question");
1654    }
1655
1656    #[test]
1657    fn build_control_chunks_session_agent_long_state_splits() {
1658        // Long state gets split into multiple chunks
1659        let state = "line\n".repeat(10000); // ~60KB
1660        let message = "question";
1661        let chunks = build_control_chunks(&state, message, true);
1662        // Should have multiple state chunks + 1 message chunk
1663        assert!(chunks.len() >= 3);
1664        // All but last are state chunks
1665        for chunk in &chunks[..chunks.len() - 1] {
1666            assert!(chunk.contains("line"));
1667        }
1668        // Last is message
1669        assert_eq!(chunks.last().unwrap(), "question");
1670    }
1671
1672    #[test]
1673    fn build_control_chunks_non_session_agent_adds_headers() {
1674        let state = "state content";
1675        let message = "user question";
1676        let chunks = build_control_chunks(state, message, false);
1677        assert_eq!(chunks.len(), 2);
1678        assert!(chunks[0].starts_with("[Context chunk 1/1]"));
1679        assert!(chunks[0].contains("state content"));
1680        assert!(chunks[1].starts_with("[Context complete. User question:]"));
1681        assert!(chunks[1].contains("user question"));
1682    }
1683
1684    #[test]
1685    fn build_control_chunks_non_session_agent_splits_long_message() {
1686        let state = "state";
1687        let message = "x".repeat(50000); // Exceeds 32KB limit
1688        let chunks = build_control_chunks(state, &message, false);
1689        // Should have 1 state chunk + multiple message chunks
1690        assert!(chunks.len() >= 2);
1691        assert!(chunks[0].starts_with("[Context chunk 1/1]"));
1692        // Message chunks should have question headers
1693        for chunk in &chunks[1..] {
1694            assert!(chunk.starts_with("[User question chunk"));
1695        }
1696        // Verify reassembly (without headers)
1697        let reassembled_state = chunks[0].split_once('\n').map_or("", |(_, r)| r);
1698        assert_eq!(reassembled_state, "state");
1699    }
1700
1701    #[test]
1702    fn build_control_chunks_non_session_agent_long_state_splits() {
1703        let state = "line\n".repeat(10000); // ~60KB
1704        let message = "question";
1705        let chunks = build_control_chunks(&state, message, false);
1706        // Multiple state chunks + 1 message chunk
1707        assert!(chunks.len() >= 3);
1708        for (i, chunk) in chunks.iter().enumerate() {
1709            if i < chunks.len() - 1 {
1710                assert!(chunk.starts_with(&format!("[Context chunk {}/", i + 1)));
1711                assert!(chunk.contains("line"));
1712            } else {
1713                assert!(chunk.starts_with("[Context complete. User question:]"));
1714                assert_eq!(chunk.split_once('\n').map_or("", |(_, r)| r), "question");
1715            }
1716        }
1717    }
1718}