1use 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
52static SESSION_LOCKS: LazyLock<Mutex<HashMap<String, Arc<sync::Mutex<()>>>>> =
62 LazyLock::new(|| Mutex::new(HashMap::new()));
63
64const SYSTEM_TEMPLATE: &str = include_str!("../prompts/control_system.md");
66
67const AGENT_TIMEOUT: time::Duration = time::Duration::from_secs(120);
69
70const MAX_CONTROL_MESSAGE_BYTES: usize = 32 * 1024;
74
75enum DeliveryMode {
77 Single,
79 Chunked,
81 ChunkedNonSession,
83}
84
85#[derive(Debug, Clone, PartialEq)]
87pub struct ModelSpec {
88 pub agent: String,
89 pub model: String,
90}
91
92#[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 "grok" => "",
106 _ => "sonnet",
107 }
108}
109
110pub 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
130pub 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
149fn 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"
166 }
167}
168
169pub 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#[expect(clippy::print_stderr, reason = "progress line for `orch chat /model`")]
193pub async fn validate_model(spec: &ModelSpec) -> Result<()> {
194 validate_agent(&spec.agent)?;
196
197 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 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
233async 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
241async fn test_invoke(agent: &str, model: &str) -> Result<()> {
243 let msg = "Reply with just the word 'ok'.";
244 invoke_agent(agent, model, "", msg).await?;
246 Ok(())
247}
248
249#[derive(Debug)]
251pub struct ChatContext {
252 pub system: String,
254 pub state: String,
256}
257
258pub async fn assemble_context(store: &TaskStore, session_id: &str) -> Result<ChatContext> {
260 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 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 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 let system = SYSTEM_TEMPLATE.to_string();
343
344 Ok(ChatContext { system, state })
345}
346
347pub 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
357pub 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
367pub 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
375pub 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
384pub 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
393pub 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
400pub 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(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(store, session_id).await?;
449 Ok(format_switched_spec(&spec))
450 }
451 ControlCommand::Restart => {
452 if let Some(ct) = channel_thread {
454 store.kv_set("control:restart_pending", ct).await.ok();
455 }
456 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
467pub 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#[derive(Debug, Clone, PartialEq, Eq)]
484pub struct MemoryEntry {
485 pub key: String,
486 pub value: String,
487}
488
489#[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
504pub 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#[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
574async 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
584pub 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
608fn session_uuid_key(session_name: &str) -> String {
610 format!("control:session_id:{session_name}")
611}
612
613pub 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
642pub 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
657pub 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
695pub 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 if let Some(response) =
708 maybe_handle_control_command(store, session_id, channel_thread, message).await?
709 {
710 return Ok(response);
711 }
712
713 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
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 let ctx = assemble_context(store, session_id).await?;
744
745 let model = get_model(store).await?;
747 let agent = get_agent(store).await?;
748
749 let full_message = format!("{}\n\n---\n\n{}", ctx.state, message);
751
752 let (session_uuid, is_new) = get_or_create_session_uuid(store, session_id).await?;
754
755 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 (
773 build_control_chunks(&ctx.state, message, true),
774 DeliveryMode::Chunked,
775 )
776 } else {
777 (
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 let result = match delivery_mode {
791 DeliveryMode::Chunked => {
792 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 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 let final_parsed = parse_response(&last_result.expect("at least one chunk").text);
901 final_parsed.clean_text
902 }
903 DeliveryMode::ChunkedNonSession => {
904 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 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 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 let parsed = parse_response(&chunk_result.text);
1001
1002 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 drop(guard);
1047
1048 if Arc::strong_count(&session_lock) == 2 {
1056 let mut map = SESSION_LOCKS.lock().unwrap_or_else(PoisonError::into_inner);
1057 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
1070fn 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 while end > start && !text.is_char_boundary(end) {
1084 end -= 1;
1085 }
1086 if end == start {
1087 if let Some(ch) = text[start..].chars().next() {
1089 end = start + ch.len_utf8();
1090 } else {
1091 break;
1092 }
1093 }
1094 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
1106fn 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 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 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 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 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 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 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_agent(&store, "claude").await.unwrap();
1453
1454 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 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 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 #[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 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 #[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 let content = format!("{}😀", "x".repeat(97)); 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 let text = "aaaa\nbbbb";
1647 let chunks = chunk_text(text, 6);
1648 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 #[test]
1667 fn build_control_chunks_session_agent_splits_state_and_message() {
1668 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 let state = "line\n".repeat(10000); let message = "question";
1683 let chunks = build_control_chunks(&state, message, true);
1684 assert!(chunks.len() >= 3);
1686 for chunk in &chunks[..chunks.len() - 1] {
1688 assert!(chunk.contains("line"));
1689 }
1690 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); let chunks = build_control_chunks(state, &message, false);
1711 assert!(chunks.len() >= 2);
1713 assert!(chunks[0].starts_with("[Context chunk 1/1]"));
1714 for chunk in &chunks[1..] {
1716 assert!(chunk.starts_with("[User question chunk"));
1717 }
1718 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); let message = "question";
1727 let chunks = build_control_chunks(&state, message, false);
1728 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}