1use 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
37static SESSION_LOCKS: std::sync::LazyLock<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>> =
47 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
48
49const SYSTEM_TEMPLATE: &str = include_str!("../prompts/control_system.md");
51
52const AGENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
54
55const MAX_CONTROL_MESSAGE_BYTES: usize = 32 * 1024;
59
60enum DeliveryMode {
62 Single,
64 Chunked,
66 ChunkedNonSession,
68}
69
70#[derive(Debug, Clone, PartialEq)]
72pub struct ModelSpec {
73 pub agent: String,
74 pub model: String,
75}
76
77#[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 "grok" => "",
91 _ => "sonnet",
92 }
93}
94
95pub 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
115pub 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
134fn 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"
151 }
152}
153
154pub 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
173pub async fn validate_model(spec: &ModelSpec) -> Result<()> {
178 validate_agent(&spec.agent)?;
180
181 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 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
218async 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
238async fn test_invoke(agent: &str, model: &str) -> Result<()> {
240 let msg = "Reply with just the word 'ok'.";
241 invoke_agent(agent, model, "", msg).await?;
243 Ok(())
244}
245
246pub struct ChatContext {
248 pub system: String,
250 pub state: String,
252}
253
254pub async fn assemble_context(store: &TaskStore, session_id: &str) -> Result<ChatContext> {
256 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 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 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 let system = SYSTEM_TEMPLATE.to_string();
339
340 Ok(ChatContext { system, state })
341}
342
343pub 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
353pub 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
363pub 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
371pub 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
380pub 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
389pub 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
396pub 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(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(store, session_id).await?;
448 Ok(format_switched_spec(&spec))
449 }
450 ControlCommand::Restart => {
451 if let Some(ct) = channel_thread {
453 store.kv_set("control:restart_pending", ct).await.ok();
454 }
455 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
469pub 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#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct MemoryEntry {
487 pub key: String,
488 pub value: String,
489}
490
491#[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
506pub 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
547pub 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
575async 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
585pub 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
616fn session_uuid_key(session_name: &str) -> String {
618 format!("control:session_id:{session_name}")
619}
620
621pub 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
650pub 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
665pub 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
703pub 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 if let Some(response) =
716 maybe_handle_control_command(store, session_id, channel_thread, message).await?
717 {
718 return Ok(response);
719 }
720
721 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
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 let ctx = assemble_context(store, session_id).await?;
752
753 let model = get_model(store).await?;
755 let agent = get_agent(store).await?;
756
757 let full_message = format!("{}\n\n---\n\n{}", ctx.state, message);
759
760 let (session_uuid, is_new) = get_or_create_session_uuid(store, session_id).await?;
762
763 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 (
781 build_control_chunks(&ctx.state, message, true),
782 DeliveryMode::Chunked,
783 )
784 } else {
785 (
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 let result = match delivery_mode {
799 DeliveryMode::Chunked => {
800 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 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 let final_parsed = parse_response(&last_result.expect("at least one chunk").text);
909 final_parsed.clean_text
910 }
911 DeliveryMode::ChunkedNonSession => {
912 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 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 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 let parsed = parse_response(&chunk_result.text);
1010
1011 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 drop(_guard);
1056
1057 if Arc::strong_count(&session_lock) == 2 {
1065 let mut map = SESSION_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
1066 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
1079fn 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 while end > start && !text.is_char_boundary(end) {
1093 end -= 1;
1094 }
1095 if end == start {
1096 if let Some(ch) = text[start..].chars().next() {
1098 end = start + ch.len_utf8();
1099 } else {
1100 break;
1101 }
1102 }
1103 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
1115fn 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 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 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 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 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 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 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_agent(&store, "claude").await.unwrap();
1456
1457 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 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 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 #[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 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 #[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 let content = "x".repeat(97) + "😀"; 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 let text = "aaaa\nbbbb";
1653 let chunks = chunk_text(text, 6);
1654 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 #[test]
1673 fn build_control_chunks_session_agent_splits_state_and_message() {
1674 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 let state = "line\n".repeat(10000); let message = "question";
1689 let chunks = build_control_chunks(&state, message, true);
1690 assert!(chunks.len() >= 3);
1692 for chunk in &chunks[..chunks.len() - 1] {
1694 assert!(chunk.contains("line"));
1695 }
1696 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); let chunks = build_control_chunks(state, &message, false);
1717 assert!(chunks.len() >= 2);
1719 assert!(chunks[0].starts_with("[Context chunk 1/1]"));
1720 for chunk in &chunks[1..] {
1722 assert!(chunk.starts_with("[User question chunk"));
1723 }
1724 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); let message = "question";
1733 let chunks = build_control_chunks(&state, message, false);
1734 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}