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