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