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::TaskStore;
39use anyhow::{Context, Result};
40use regex::Regex;
41use tokio::fs;
42use tokio::process::Command;
43use tokio::sync;
44use tokio::time as tokio_time;
45use tokio::time::{Duration, timeout};
46use uuid::Uuid;
47
48const SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
49
50static SESSION_LOCKS: LazyLock<Mutex<HashMap<String, Arc<sync::Mutex<()>>>>> =
60 LazyLock::new(|| Mutex::new(HashMap::new()));
61
62const SYSTEM_TEMPLATE: &str = include_str!("../prompts/control_system.md");
64
65const AGENT_TIMEOUT: time::Duration = time::Duration::from_secs(120);
67
68const MAX_CONTROL_MESSAGE_BYTES: usize = 32 * 1024;
72
73enum DeliveryMode {
75 Single,
77 Chunked,
79 ChunkedNonSession,
81}
82
83#[derive(Debug, Clone, PartialEq)]
85pub struct ModelSpec {
86 pub agent: String,
87 pub model: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum ControlCommand {
93 Model(Option<String>),
94 Agent(Option<String>),
95 Restart,
96}
97
98fn default_model_for_agent(agent: &str) -> String {
101 first_model(&RouterConfig::configured_models(agent))
102}
103
104fn first_model(models: &[String]) -> String {
106 models
107 .iter()
108 .find(|m| !m.contains(':'))
109 .cloned()
110 .unwrap_or_default()
111}
112
113pub fn parse_control_command(message: &str) -> Option<ControlCommand> {
115 let trimmed = message.trim();
116 let rest = trimmed.strip_prefix('/')?;
117 let (command, args) = rest
118 .split_once(' ')
119 .map_or((rest, ""), |(cmd, tail)| (cmd, tail.trim()));
120
121 match command {
122 "model" => Some(ControlCommand::Model(
123 (!args.is_empty()).then(|| args.to_string()),
124 )),
125 "agent" => Some(ControlCommand::Agent(
126 (!args.is_empty()).then(|| args.to_string()),
127 )),
128 "restart" => Some(ControlCommand::Restart),
129 _ => None,
130 }
131}
132
133pub fn parse_model_spec(spec: &str, current_agent: &str) -> ModelSpec {
139 if let Some((agent, model)) = spec.split_once(':') {
140 ModelSpec {
141 agent: agent.to_string(),
142 model: RouterConfig::normalize_model_identifier(model),
143 }
144 } else {
145 let model = RouterConfig::normalize_model_identifier(spec);
146 let configured: Vec<(String, Vec<String>)> = engine::configured_agents()
147 .into_iter()
148 .map(|agent| {
149 let models = RouterConfig::configured_models(&agent);
150 (agent, models)
151 })
152 .collect();
153 ModelSpec {
154 agent: infer_agent(&model, &configured, current_agent),
155 model,
156 }
157 }
158}
159
160fn infer_agent(model: &str, configured: &[(String, Vec<String>)], current_agent: &str) -> String {
162 configured
163 .iter()
164 .find(|(_, models)| models.iter().any(|m| m == model))
165 .map_or(current_agent, |(agent, _)| agent.as_str())
166 .to_string()
167}
168
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 match store
351 .kv_get("control:model")
352 .await
353 .context("reading control:model from store")?
354 {
355 Some(model) => Ok(model),
356 None => Ok(default_model_for_agent(&get_agent(store).await?)),
357 }
358}
359
360pub async fn get_agent(store: &TaskStore) -> Result<String> {
363 Ok(store
364 .kv_get("control:agent")
365 .await
366 .context("reading control:agent from store")?
367 .unwrap_or_else(|| "claude".to_string()))
368}
369
370pub async fn get_current_spec(store: &TaskStore) -> Result<ModelSpec> {
372 Ok(ModelSpec {
373 agent: get_agent(store).await?,
374 model: get_model(store).await?,
375 })
376}
377
378pub async fn set_model(store: &TaskStore, model: &str) -> Result<()> {
380 store
381 .kv_set("control:model", model)
382 .await
383 .context("setting control model")?;
384 Ok(())
385}
386
387pub async fn set_agent(store: &TaskStore, agent: &str) -> Result<()> {
389 store
390 .kv_set("control:agent", agent)
391 .await
392 .context("setting control agent")?;
393 Ok(())
394}
395
396pub async fn set_model_spec(store: &TaskStore, spec: &ModelSpec) -> Result<()> {
398 set_model(store, &spec.model).await?;
399 set_agent(store, &spec.agent).await?;
400 Ok(())
401}
402
403pub async fn set_agent_spec(store: &TaskStore, agent: &str) -> Result<ModelSpec> {
405 let spec = ModelSpec {
406 agent: agent.to_string(),
407 model: default_model_for_agent(agent),
408 };
409 set_model_spec(store, &spec).await?;
410 Ok(spec)
411}
412
413fn display_model(model: &str) -> &str {
414 if model.is_empty() { "default" } else { model }
415}
416
417fn format_current_spec(spec: &ModelSpec) -> String {
418 format!("Current: {}:{}", spec.agent, display_model(&spec.model))
419}
420
421fn format_switched_spec(spec: &ModelSpec) -> String {
422 format!(
423 "Switched to **{}:{}** (validated)",
424 spec.agent,
425 display_model(&spec.model)
426 )
427}
428
429async fn handle_control_command(
430 store: &TaskStore,
431 session_id: &str,
432 channel_thread: Option<&str>,
433 command: ControlCommand,
434) -> Result<String> {
435 match command {
436 ControlCommand::Model(None) | ControlCommand::Agent(None) => {
437 Ok(format_current_spec(&get_current_spec(store).await?))
438 }
439 ControlCommand::Model(Some(new_spec)) => {
440 let spec = parse_model_spec(&new_spec, &get_agent(store).await?);
441 validate_model(&spec).await?;
442 set_model_spec(store, &spec).await?;
443 reset_session_uuid(store, session_id).await?;
445 Ok(format_switched_spec(&spec))
446 }
447 ControlCommand::Agent(Some(agent)) => {
448 validate_agent(&agent)?;
449 let spec = set_agent_spec(store, &agent).await?;
450 reset_session_uuid(store, session_id).await?;
452 Ok(format_switched_spec(&spec))
453 }
454 ControlCommand::Restart => {
455 if let Some(ct) = channel_thread {
457 store.kv_set("control:restart_pending", ct).await.ok();
458 }
459 tokio::spawn(async {
461 tokio_time::sleep(Duration::from_secs(2)).await;
462 let pid = process::id().to_string();
463 let _ = Command::new("kill").args(["-TERM", &pid]).status().await;
464 });
465 Ok("Restarting orch service\u{2026} I'll confirm here when back online.".to_string())
466 }
467 }
468}
469
470pub async fn maybe_handle_control_command(
472 store: &TaskStore,
473 session_id: &str,
474 channel_thread: Option<&str>,
475 message: &str,
476) -> Result<Option<String>> {
477 Ok(match parse_control_command(message) {
478 Some(command) => {
479 Some(handle_control_command(store, session_id, channel_thread, command).await?)
480 }
481 None => None,
482 })
483}
484
485#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct MemoryEntry {
488 pub key: String,
489 pub value: String,
490}
491
492#[derive(Debug, Clone, PartialEq, Eq)]
494pub struct ParsedResponse {
495 pub clean_text: String,
496 pub summary: Option<String>,
497 pub memories: Vec<MemoryEntry>,
498}
499
500static MEMORY_RE: LazyLock<Regex> = LazyLock::new(|| {
501 Regex::new(r#"(?s)<memory\s+key="([^"]+)">(.+?)</memory>"#).expect("valid memory regex")
502});
503
504static SUMMARY_RE: LazyLock<Regex> =
505 LazyLock::new(|| Regex::new(r"(?s)<summary>(.+?)</summary>").expect("valid summary regex"));
506
507pub fn parse_response(raw: &str) -> ParsedResponse {
509 let mut clean = raw.to_string();
510 let memories = MEMORY_RE
511 .captures_iter(raw)
512 .map(|caps| MemoryEntry {
513 key: caps[1].trim().to_string(),
514 value: caps[2].trim().to_string(),
515 })
516 .collect::<Vec<_>>();
517 clean = MEMORY_RE.replace_all(&clean, "").to_string();
518
519 let summary = SUMMARY_RE
520 .captures(raw)
521 .map(|caps| caps[1].trim().to_string());
522 clean = SUMMARY_RE.replace_all(&clean, "").to_string();
523
524 ParsedResponse {
525 clean_text: clean.trim().to_string(),
526 summary,
527 memories,
528 }
529}
530
531async fn persist_memories(
532 store: &TaskStore,
533 session_id: &str,
534 memories: &[MemoryEntry],
535) -> Result<()> {
536 for memory in memories {
537 store
538 .kv_set(
539 &format!("control:memory:{session_id}:{}", memory.key),
540 &memory.value,
541 )
542 .await
543 .with_context(|| format!("storing control memory {}", memory.key))?;
544 }
545 Ok(())
546}
547
548#[derive(Debug)]
550pub struct InvokeResult {
551 pub text: String,
552 pub input_tokens: Option<u64>,
553 pub output_tokens: Option<u64>,
554 pub cost_usd: Option<f64>,
556}
557
558fn model_arg(model: &str) -> Option<&str> {
560 (!model.is_empty()).then_some(model)
561}
562
563fn token_u64_to_i64_saturating(tokens: u64) -> i64 {
564 i64::try_from(tokens).unwrap_or(i64::MAX)
565}
566
567fn opt_token_u64_to_i64_saturating(tokens: Option<u64>) -> Option<i64> {
568 tokens.map(token_u64_to_i64_saturating)
569}
570
571fn total_tokens_u64_to_i64_saturating(
572 input_tokens: Option<u64>,
573 output_tokens: Option<u64>,
574) -> Option<i64> {
575 match (input_tokens, output_tokens) {
576 (Some(input), Some(output)) => {
577 Some(token_u64_to_i64_saturating(input.saturating_add(output)))
578 }
579 (Some(tokens), None) | (None, Some(tokens)) => Some(token_u64_to_i64_saturating(tokens)),
580 _ => None,
581 }
582}
583
584async fn prepare_temp_dir() -> Result<String> {
588 let home = home::orch_home()?;
589 let dir = format!("{}/state/control/{}", home.display(), process::id());
590 fs::create_dir_all(&dir).await?;
591 Ok(dir)
592}
593
594pub async fn invoke_agent(
601 agent: &str,
602 model: &str,
603 context: &str,
604 message: &str,
605) -> Result<InvokeResult> {
606 let dir = prepare_temp_dir().await?;
607
608 let result = direct::run_direct(
609 agent,
610 model_arg(model),
611 context,
612 message,
613 AGENT_TIMEOUT,
614 &dir,
615 )
616 .await?;
617
618 Ok(InvokeResult {
619 text: result.text,
620 input_tokens: result.input_tokens,
621 output_tokens: result.output_tokens,
622 cost_usd: result.cost_usd,
623 })
624}
625
626fn session_uuid_key(session_name: &str) -> String {
628 format!("control:session_id:{session_name}")
629}
630
631pub async fn get_or_create_session_uuid(
644 store: &TaskStore,
645 session_name: &str,
646) -> Result<(String, bool)> {
647 let key = session_uuid_key(session_name);
648 let candidate = Uuid::new_v4().to_string();
649 let stored = store
650 .kv_insert_if_absent(&key, &candidate)
651 .await
652 .context("storing session UUID")?;
653 let is_new = stored == candidate;
654 if is_new {
655 tracing::info!(session_name, uuid = %stored, "created new chat session UUID");
656 }
657 Ok((stored, is_new))
658}
659
660pub async fn reset_session_uuid(store: &TaskStore, session_name: &str) -> Result<()> {
666 let key = session_uuid_key(session_name);
667 store
668 .kv_delete(&key)
669 .await
670 .context("resetting session UUID")?;
671 tracing::info!(session_name, "reset chat session UUID");
672 Ok(())
673}
674
675pub async fn invoke_agent_with_session(
681 agent: &str,
682 model: &str,
683 context: &str,
684 message: &str,
685 session_uuid: Option<&str>,
686 resume: bool,
687) -> Result<InvokeResult> {
688 let dir = prepare_temp_dir().await?;
689
690 let session = session_uuid.map(|sid| crate::engine::runner::direct::SessionContinuation {
691 session_id: sid,
692 resume,
693 });
694
695 let result = direct::run_direct_with_session(
696 agent,
697 model_arg(model),
698 context,
699 message,
700 AGENT_TIMEOUT,
701 &dir,
702 session,
703 )
704 .await?;
705
706 Ok(InvokeResult {
707 text: result.text,
708 input_tokens: result.input_tokens,
709 output_tokens: result.output_tokens,
710 cost_usd: result.cost_usd,
711 })
712}
713
714pub async fn send_message(
719 store: &TaskStore,
720 session_id: &str,
721 channel: &str,
722 channel_thread: Option<&str>,
723 message: &str,
724) -> Result<String> {
725 if let Some(response) =
727 maybe_handle_control_command(store, session_id, channel_thread, message).await?
728 {
729 return Ok(response);
730 }
731
732 let session_lock = {
735 let mut map = SESSION_LOCKS.lock().unwrap_or_else(PoisonError::into_inner);
736 Arc::clone(
737 map.entry(session_id.to_string())
738 .or_insert_with(|| Arc::new(sync::Mutex::new(()))),
739 )
740 };
741 let guard = session_lock.lock().await;
742
743 store
745 .insert_control_message(
746 session_id,
747 "user",
748 channel,
749 channel_thread,
750 message,
751 None,
752 None,
753 None,
754 None,
755 None,
756 None,
757 None,
758 )
759 .await?;
760
761 let ctx = assemble_context(store, session_id).await?;
763
764 let model = get_model(store).await?;
766 let agent = get_agent(store).await?;
767
768 let full_message = format!("{}\n\n---\n\n{}", ctx.state, message);
770
771 let (session_uuid, is_new) = get_or_create_session_uuid(store, session_id).await?;
773
774 let agent_has_sessions = agents::supports_session(&agent);
781 let (chunks, delivery_mode) = if full_message.len() > MAX_CONTROL_MESSAGE_BYTES {
782 tracing::info!(
783 bytes = full_message.len(),
784 limit = MAX_CONTROL_MESSAGE_BYTES,
785 agent = %agent,
786 agent_has_sessions,
787 "control session payload exceeds limit"
788 );
789 if agent_has_sessions {
790 (
792 build_control_chunks(&ctx.state, message, true),
793 DeliveryMode::Chunked,
794 )
795 } else {
796 (
798 build_control_chunks(&ctx.state, message, false),
799 DeliveryMode::ChunkedNonSession,
800 )
801 }
802 } else {
803 (vec![full_message], DeliveryMode::Single)
804 };
805
806 let _num_chunks = chunks.len();
807
808 let result = match delivery_mode {
810 DeliveryMode::Chunked => {
811 let mut active_uuid = session_uuid.clone();
814 let mut is_first_chunk = !is_new;
815 let mut last_result: Option<InvokeResult> = None;
816
817 for chunk in &chunks {
818 let r = invoke_agent_with_session(
819 &agent,
820 &model,
821 &ctx.system,
822 chunk,
823 Some(&active_uuid),
824 is_first_chunk,
825 )
826 .await;
827 let chunk_result = match r {
828 Ok(result) => result,
829 Err(e) => {
830 let err_str = e.to_string();
831 let is_stale = err_str.contains("stale session");
832 let is_timeout = err_str.contains("timeout after")
833 || err_str.contains("timed out after");
834 if is_stale || is_timeout {
835 if is_stale {
836 tracing::info!(
837 session_id,
838 "chat session UUID expired; resetting and retrying with fresh session"
839 );
840 } else {
841 tracing::info!(
842 session_id,
843 "chat session timed out; resetting and retrying with fresh session"
844 );
845 }
846 reset_session_uuid(store, session_id).await?;
847 let (fresh_uuid, _) =
848 get_or_create_session_uuid(store, session_id).await?;
849 active_uuid = fresh_uuid;
850 is_first_chunk = false;
851 let fresh_result = invoke_agent_with_session(
852 &agent,
853 &model,
854 &ctx.system,
855 chunk,
856 Some(&active_uuid),
857 false,
858 )
859 .await?;
860
861 if is_timeout {
862 InvokeResult {
863 text: format!(
864 "_(New session started.)_\n\n{}",
865 fresh_result.text
866 ),
867 ..fresh_result
868 }
869 } else {
870 fresh_result
871 }
872 } else {
873 return Err(e);
874 }
875 }
876 };
877
878 let parsed = parse_response(&chunk_result.text);
880 let total_tokens = total_tokens_u64_to_i64_saturating(
881 chunk_result.input_tokens,
882 chunk_result.output_tokens,
883 );
884 let input_tokens = opt_token_u64_to_i64_saturating(chunk_result.input_tokens);
885 let output_tokens = opt_token_u64_to_i64_saturating(chunk_result.output_tokens);
886 let cost_usd = chunk_result.cost_usd;
887 store
888 .insert_control_message(
889 session_id,
890 "assistant",
891 channel,
892 channel_thread,
893 &parsed.clean_text,
894 parsed.summary.as_deref(),
895 Some(&model),
896 Some(&agent),
897 input_tokens,
898 output_tokens,
899 total_tokens,
900 cost_usd,
901 )
902 .await?;
903 persist_memories(store, session_id, &parsed.memories).await?;
904
905 last_result = Some(chunk_result);
906 }
907
908 let final_parsed = parse_response(&last_result.expect("at least one chunk").text);
910 final_parsed.clean_text
911 }
912 DeliveryMode::ChunkedNonSession => {
913 let mut last_text = String::new();
916
917 for chunk in &chunks {
918 let chunk_result = invoke_agent(&agent, &model, &ctx.system, chunk).await?;
919
920 let parsed = parse_response(&chunk_result.text);
922 let total_tokens = total_tokens_u64_to_i64_saturating(
923 chunk_result.input_tokens,
924 chunk_result.output_tokens,
925 );
926 let input_tokens = opt_token_u64_to_i64_saturating(chunk_result.input_tokens);
927 let output_tokens = opt_token_u64_to_i64_saturating(chunk_result.output_tokens);
928 let cost_usd = chunk_result.cost_usd;
929 store
930 .insert_control_message(
931 session_id,
932 "assistant",
933 channel,
934 channel_thread,
935 &parsed.clean_text,
936 parsed.summary.as_deref(),
937 Some(&model),
938 Some(&agent),
939 input_tokens,
940 output_tokens,
941 total_tokens,
942 cost_usd,
943 )
944 .await?;
945 persist_memories(store, session_id, &parsed.memories).await?;
946
947 last_text = parsed.clean_text;
948 }
949
950 last_text
951 }
952 DeliveryMode::Single => {
953 let mut active_uuid = session_uuid.clone();
956 let mut is_first_chunk = !is_new;
957 let chunk_result = loop {
958 let r = invoke_agent_with_session(
959 &agent,
960 &model,
961 &ctx.system,
962 &chunks[0],
963 Some(&active_uuid),
964 is_first_chunk,
965 )
966 .await;
967 match r {
968 Ok(result) => break result,
969 Err(e) => {
970 let err_str = e.to_string();
971 let is_stale = err_str.contains("stale session");
972 let is_timeout = err_str.contains("timeout after")
973 || err_str.contains("timed out after");
974 if is_stale || is_timeout {
975 if is_stale {
976 tracing::info!(
977 session_id,
978 "chat session UUID expired; resetting and retrying with fresh session"
979 );
980 } else {
981 tracing::info!(
982 session_id,
983 "chat session timed out; resetting and retrying with fresh session"
984 );
985 }
986 reset_session_uuid(store, session_id).await?;
987 let (fresh_uuid, _) =
988 get_or_create_session_uuid(store, session_id).await?;
989 active_uuid = fresh_uuid;
990 is_first_chunk = false;
991 continue;
992 }
993 return Err(e);
994 }
995 }
996 };
997
998 let parsed = parse_response(&chunk_result.text);
1000
1001 let total_tokens = total_tokens_u64_to_i64_saturating(
1006 chunk_result.input_tokens,
1007 chunk_result.output_tokens,
1008 );
1009 let input_tokens = opt_token_u64_to_i64_saturating(chunk_result.input_tokens);
1010 let output_tokens = opt_token_u64_to_i64_saturating(chunk_result.output_tokens);
1011 let cost_usd = chunk_result.cost_usd;
1012 store
1013 .insert_control_message(
1014 session_id,
1015 "assistant",
1016 channel,
1017 channel_thread,
1018 &parsed.clean_text,
1019 parsed.summary.as_deref(),
1020 Some(&model),
1021 Some(&agent),
1022 input_tokens,
1023 output_tokens,
1024 total_tokens,
1025 cost_usd,
1026 )
1027 .await?;
1028 persist_memories(store, session_id, &parsed.memories).await?;
1029
1030 parsed.clean_text
1031 }
1032 };
1033
1034 drop(guard);
1036
1037 if Arc::strong_count(&session_lock) == 2 {
1045 let mut map = SESSION_LOCKS.lock().unwrap_or_else(PoisonError::into_inner);
1046 if map
1049 .get(session_id)
1050 .is_some_and(|l| Arc::strong_count(l) == 2)
1051 {
1052 map.remove(session_id);
1053 }
1054 }
1055
1056 Ok(result)
1057}
1058
1059fn chunk_text(text: &str, max_bytes: usize) -> Vec<String> {
1062 if text.len() <= max_bytes {
1063 return vec![text.to_string()];
1064 }
1065 let mut chunks = Vec::new();
1066 let mut start = 0;
1067 let total = text.len();
1068
1069 while start < total {
1070 let mut end = (start + max_bytes).min(total);
1071 while end > start && !text.is_char_boundary(end) {
1073 end -= 1;
1074 }
1075 if end == start {
1076 if let Some(ch) = text[start..].chars().next() {
1078 end = start + ch.len_utf8();
1079 } else {
1080 break;
1081 }
1082 }
1083 if end < total
1085 && let Some(nl_offset) = text[start..end].rfind('\n')
1086 {
1087 end = start + nl_offset + 1;
1088 }
1089 chunks.push(text[start..end].to_string());
1090 start = end;
1091 }
1092 chunks
1093}
1094
1095fn build_control_chunks(state: &str, message: &str, agent_has_sessions: bool) -> Vec<String> {
1103 let header_reserve = if agent_has_sessions { 0 } else { 200 };
1104 let effective_limit = MAX_CONTROL_MESSAGE_BYTES.saturating_sub(header_reserve);
1105 let mut parts = Vec::new();
1106
1107 let state_chunks = chunk_text(state, effective_limit);
1109 for (i, chunk) in state_chunks.iter().enumerate() {
1110 let prefixed = if agent_has_sessions {
1111 chunk.clone()
1112 } else {
1113 format!(
1114 "[Context chunk {}/{}]\n{}",
1115 i + 1,
1116 state_chunks.len(),
1117 chunk
1118 )
1119 };
1120 parts.push(prefixed);
1121 }
1122
1123 let msg_chunks = chunk_text(message, effective_limit);
1125 for (i, chunk) in msg_chunks.iter().enumerate() {
1126 let prefixed = if agent_has_sessions {
1127 chunk.clone()
1128 } else if msg_chunks.len() == 1 {
1129 format!("[Context complete. User question:]\n{chunk}")
1130 } else {
1131 format!(
1132 "[User question chunk {}/{}]\n{}",
1133 i + 1,
1134 msg_chunks.len(),
1135 chunk
1136 )
1137 };
1138 parts.push(prefixed);
1139 }
1140 parts
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145 use tokio::runtime::Runtime;
1146
1147 use std::collections::BTreeSet;
1148
1149 use super::*;
1150 use crate::store::TaskStore;
1151
1152 #[test]
1153 fn parse_spec_agent_model() {
1154 let spec = parse_model_spec("minimax:sonnet", "claude");
1155 assert_eq!(spec.agent, "minimax");
1156 assert_eq!(spec.model, "sonnet");
1157 }
1158
1159 #[test]
1160 fn parse_spec_opencode_model() {
1161 let spec = parse_model_spec("opencode:minimax-m2.5-free", "claude");
1162 assert_eq!(spec.agent, "opencode");
1163 assert_eq!(spec.model, "minimax-m2.5-free");
1164 }
1165
1166 fn configured(entries: &[(&str, &[&str])]) -> Vec<(String, Vec<String>)> {
1167 entries
1168 .iter()
1169 .map(|(agent, models)| {
1170 (
1171 agent.to_string(),
1172 models.iter().map(ToString::to_string).collect(),
1173 )
1174 })
1175 .collect()
1176 }
1177
1178 #[test]
1179 fn infer_agent_finds_the_agent_whose_config_lists_the_model() {
1180 let configured = configured(&[("claude", &["sonnet"]), ("codex", &["gpt-4o"])]);
1181 assert_eq!(infer_agent("gpt-4o", &configured, "claude"), "codex");
1182 assert_eq!(infer_agent("sonnet", &configured, "codex"), "claude");
1183 }
1184
1185 #[test]
1186 fn infer_agent_keeps_the_current_agent_for_an_unlisted_model() {
1187 let configured = configured(&[("claude", &["sonnet"])]);
1188 assert_eq!(
1189 infer_agent("deepseek-r1", &configured, "opencode"),
1190 "opencode"
1191 );
1192 }
1193
1194 #[test]
1195 fn first_model_skips_pool_keywords() {
1196 let models = vec!["opencode:free".to_string(), "provider/model".to_string()];
1197 assert_eq!(first_model(&models), "provider/model");
1198 }
1199
1200 #[test]
1201 fn first_model_is_empty_without_models() {
1202 assert_eq!(first_model(&[]), "");
1203 assert_eq!(model_arg(""), None);
1204 assert_eq!(model_arg("sonnet"), Some("sonnet"));
1205 }
1206
1207 #[tokio::test]
1208 async fn assemble_context_includes_memories_and_summaries() {
1209 let store = TaskStore::open_memory().await.unwrap();
1210 store
1211 .kv_set("control:memory:default:tz", "User timezone is UTC-3")
1212 .await
1213 .unwrap();
1214 store
1215 .insert_control_message(
1216 "default",
1217 "assistant",
1218 "cli",
1219 None,
1220 "response",
1221 Some("checked status"),
1222 Some("sonnet"),
1223 Some("claude"),
1224 None,
1225 None,
1226 None,
1227 None,
1228 )
1229 .await
1230 .unwrap();
1231
1232 let ctx = assemble_context(&store, "default").await.unwrap();
1233 assert!(ctx.state.contains("UTC-3"), "should include memories");
1234 assert!(
1235 ctx.state.contains("checked status"),
1236 "should include summaries"
1237 );
1238 }
1239
1240 #[test]
1241 fn extract_summary_from_response() {
1242 let response = "Here are your tasks.\n\n<summary>listed 3 active tasks</summary>";
1243 let parsed = parse_response(response);
1244 assert_eq!(parsed.summary.as_deref(), Some("listed 3 active tasks"));
1245 assert!(!parsed.clean_text.contains("<summary>"));
1246 }
1247
1248 #[test]
1249 fn extract_summary_missing() {
1250 let response = "No tasks found.";
1251 let parsed = parse_response(response);
1252 assert_eq!(parsed.summary, None);
1253 assert_eq!(parsed.clean_text, "No tasks found.");
1254 }
1255
1256 #[test]
1257 fn extract_summary_closing_tag_before_opening_does_not_panic() {
1258 let response = "Example: </summary> is a closing tag.\n<summary>actual summary</summary>";
1260 let parsed = parse_response(response);
1261 assert_eq!(parsed.summary.as_deref(), Some("actual summary"));
1262 assert!(!parsed.clean_text.contains("<summary>"));
1263 }
1264
1265 #[test]
1266 fn extract_memory_tags() {
1267 let response = r#"Remember these.
1268
1269<memory key="tz">User timezone is UTC-3</memory>
1270<memory key="theme">Prefer concise answers</memory>
1271
1272<summary>stored two memories</summary>"#;
1273 let parsed = parse_response(response);
1274 assert_eq!(parsed.memories.len(), 2);
1275 assert_eq!(parsed.memories[0].key, "tz");
1276 assert_eq!(parsed.memories[0].value, "User timezone is UTC-3");
1277 assert_eq!(parsed.memories[1].key, "theme");
1278 assert_eq!(parsed.memories[1].value, "Prefer concise answers");
1279 assert_eq!(parsed.summary.as_deref(), Some("stored two memories"));
1280 assert!(!parsed.clean_text.contains("<memory"));
1281 }
1282
1283 #[tokio::test]
1284 async fn persist_memories_writes_to_kv() {
1285 let store = TaskStore::open_memory().await.unwrap();
1286 let memories = vec![MemoryEntry {
1287 key: "tz".to_string(),
1288 value: "User timezone is UTC-3".to_string(),
1289 }];
1290
1291 persist_memories(&store, "default", &memories)
1292 .await
1293 .unwrap();
1294
1295 assert_eq!(
1296 store
1297 .kv_get("control:memory:default:tz")
1298 .await
1299 .unwrap()
1300 .as_deref(),
1301 Some("User timezone is UTC-3")
1302 );
1303 }
1304
1305 #[tokio::test]
1306 async fn send_message_model_switch() {
1307 let store = TaskStore::open_memory().await.unwrap();
1308 set_agent(&store, "unlisted-agent").await.unwrap();
1309 let model = get_model(&store).await.unwrap();
1310 assert_eq!(model, "", "an agent with no models: entry gets no --model");
1311
1312 let spec = parse_model_spec("minimax:sonnet", "claude");
1315 set_model_spec(&store, &spec).await.unwrap();
1316 assert_eq!(get_model(&store).await.unwrap(), "sonnet");
1317 assert_eq!(get_agent(&store).await.unwrap(), "minimax");
1318 }
1319
1320 #[tokio::test]
1321 async fn send_message_model_show_current() {
1322 let store = TaskStore::open_memory().await.unwrap();
1323 set_agent(&store, "unlisted-agent").await.unwrap();
1324 let response = send_message(&store, "default", "cli", None, "/model")
1325 .await
1326 .unwrap();
1327 assert_eq!(response, "Current: unlisted-agent:default");
1328 }
1329
1330 #[tokio::test]
1331 async fn send_message_agent_show_current() {
1332 let store = TaskStore::open_memory().await.unwrap();
1333 set_agent(&store, "unlisted-agent").await.unwrap();
1334 let response = send_message(&store, "default", "cli", None, "/agent")
1335 .await
1336 .unwrap();
1337 assert_eq!(response, "Current: unlisted-agent:default");
1338 }
1339
1340 #[tokio::test]
1341 async fn send_message_agent_switches_with_default_model() {
1342 let store = TaskStore::open_memory().await.unwrap();
1343 let spec = set_agent_spec(&store, "unlisted-agent").await.unwrap();
1345 assert_eq!(spec.agent, "unlisted-agent");
1346 assert_eq!(
1347 spec.model, "",
1348 "an agent with no models: entry gets no --model"
1349 );
1350 assert_eq!(get_agent(&store).await.unwrap(), "unlisted-agent");
1351 assert_eq!(get_model(&store).await.unwrap(), "");
1352 }
1353
1354 #[test]
1355 fn parse_control_command_model_and_agent() {
1356 assert_eq!(
1357 parse_control_command("/model"),
1358 Some(ControlCommand::Model(None))
1359 );
1360 assert_eq!(
1361 parse_control_command("/agent"),
1362 Some(ControlCommand::Agent(None))
1363 );
1364 assert_eq!(
1365 parse_control_command("/agent codex"),
1366 Some(ControlCommand::Agent(Some("codex".to_string())))
1367 );
1368 assert_eq!(
1369 parse_control_command("/model opencode:deepseek-r1"),
1370 Some(ControlCommand::Model(Some(
1371 "opencode:deepseek-r1".to_string()
1372 )))
1373 );
1374 assert_eq!(
1375 parse_control_command("/restart"),
1376 Some(ControlCommand::Restart)
1377 );
1378 assert_eq!(parse_control_command("/kill"), None);
1379 assert_eq!(parse_control_command("/session"), None);
1380 }
1381
1382 #[test]
1383 fn control_prompt_slash_commands_match_supported_surface() {
1384 let documented = Regex::new(r"`/([a-z]+)(?:[^`]*)`")
1385 .unwrap()
1386 .captures_iter(SYSTEM_TEMPLATE)
1387 .map(|caps| caps[1].to_string())
1388 .collect::<BTreeSet<_>>();
1389
1390 let expected = ["agent", "model", "restart"]
1391 .into_iter()
1392 .map(str::to_string)
1393 .collect::<BTreeSet<_>>();
1394
1395 assert_eq!(documented, expected);
1396 }
1397
1398 #[tokio::test]
1399 async fn set_agent_spec_persists_default_model() {
1400 let store = TaskStore::open_memory().await.unwrap();
1401 let spec = set_agent_spec(&store, "unlisted-agent").await.unwrap();
1402 assert_eq!(spec.agent, "unlisted-agent");
1403 assert_eq!(spec.model, "");
1404 assert_eq!(get_agent(&store).await.unwrap(), "unlisted-agent");
1405 assert_eq!(get_model(&store).await.unwrap(), "");
1406 }
1407
1408 #[tokio::test]
1409 async fn send_message_model_switch_does_not_store() {
1410 let store = TaskStore::open_memory().await.unwrap();
1411 let spec = parse_model_spec("claude:haiku", "claude");
1413 set_model_spec(&store, &spec).await.unwrap();
1414 let messages = store
1415 .list_control_messages("default", None, 10)
1416 .await
1417 .unwrap();
1418 assert_eq!(messages.len(), 0);
1419 }
1420
1421 #[test]
1422 fn validate_unknown_agent_fails() {
1423 let spec = ModelSpec {
1424 agent: "nonexistent".to_string(),
1425 model: "test".to_string(),
1426 };
1427 let rt = Runtime::new().unwrap();
1428 let result = rt.block_on(validate_model(&spec));
1429 assert!(result.is_err());
1430 assert!(result.unwrap_err().to_string().contains("unknown agent"));
1431 }
1432
1433 #[test]
1434 fn validate_agent_rejects_unknown() {
1435 let result = validate_agent("nonexistent");
1436 assert!(result.is_err());
1437 let err = result.unwrap_err().to_string();
1438 assert!(err.contains("unknown agent"), "got: {err}");
1439 }
1440
1441 #[test]
1442 fn validate_agent_rejects_empty() {
1443 let result = validate_agent("");
1444 assert!(result.is_err());
1445 assert!(result.unwrap_err().to_string().contains("unknown agent"));
1446 }
1447
1448 #[tokio::test]
1449 async fn agent_command_rejects_unknown_before_persisting() {
1450 let store = TaskStore::open_memory().await.unwrap();
1451 set_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 token_u64_to_i64_saturating_caps_large_values() {
1466 assert_eq!(token_u64_to_i64_saturating(u64::MAX), i64::MAX);
1467 }
1468
1469 #[test]
1470 fn total_tokens_u64_to_i64_saturating_caps_single_large_value() {
1471 assert_eq!(
1472 total_tokens_u64_to_i64_saturating(Some(u64::MAX), None),
1473 Some(i64::MAX)
1474 );
1475 }
1476
1477 #[test]
1478 fn total_tokens_u64_to_i64_saturating_caps_large_sum() {
1479 assert_eq!(
1480 total_tokens_u64_to_i64_saturating(Some(u64::MAX), Some(1)),
1481 Some(i64::MAX)
1482 );
1483 }
1484
1485 #[tokio::test]
1486 async fn insert_control_message_stores_cost_usd() {
1487 let store = TaskStore::open_memory().await.unwrap();
1488
1489 let input_tokens: u64 = 500_000;
1490 let output_tokens: u64 = 100_000;
1491 let cost_usd = Some(0.25);
1492
1493 store
1494 .insert_control_message(
1495 "default",
1496 "assistant",
1497 "cli",
1498 None,
1499 "hello",
1500 Some("greeted"),
1501 Some("sonnet"),
1502 Some("claude"),
1503 opt_token_u64_to_i64_saturating(Some(input_tokens)),
1504 opt_token_u64_to_i64_saturating(Some(output_tokens)),
1505 total_tokens_u64_to_i64_saturating(Some(input_tokens), Some(output_tokens)),
1506 cost_usd,
1507 )
1508 .await
1509 .unwrap();
1510
1511 let messages = store
1512 .list_control_messages("default", None, 10)
1513 .await
1514 .unwrap();
1515 assert_eq!(messages.len(), 1);
1516 let msg = &messages[0];
1517 assert!(msg.cost_usd.is_some(), "cost_usd must be stored, not NULL");
1518 assert_eq!(
1519 msg.cost_usd,
1520 Some(0.25),
1521 "the reported cost is stored as is"
1522 );
1523 }
1524
1525 #[tokio::test]
1526 async fn session_uuid_created_and_stable() {
1527 let store = TaskStore::open_memory().await.unwrap();
1528 let (uuid1, is_new1) = get_or_create_session_uuid(&store, "default").await.unwrap();
1529 let (uuid2, is_new2) = get_or_create_session_uuid(&store, "default").await.unwrap();
1530 assert_eq!(uuid1, uuid2, "same session should return same UUID");
1531 assert!(is_new1, "first call should be new");
1532 assert!(!is_new2, "second call should be existing");
1533 assert_eq!(uuid1.len(), 36, "should be a valid UUID string");
1534 }
1535
1536 #[tokio::test]
1537 async fn session_uuid_differs_per_session() {
1538 let store = TaskStore::open_memory().await.unwrap();
1539 let (uuid_default, _) = get_or_create_session_uuid(&store, "default").await.unwrap();
1540 let (uuid_ops, _) = get_or_create_session_uuid(&store, "ops").await.unwrap();
1541 assert_ne!(uuid_default, uuid_ops);
1542 }
1543
1544 #[tokio::test]
1545 async fn session_uuid_reset_creates_new() {
1546 let store = TaskStore::open_memory().await.unwrap();
1547 let (uuid1, _) = get_or_create_session_uuid(&store, "default").await.unwrap();
1548 reset_session_uuid(&store, "default").await.unwrap();
1549 let (uuid2, is_new2) = get_or_create_session_uuid(&store, "default").await.unwrap();
1550 assert_ne!(uuid1, uuid2, "reset should produce a new UUID");
1551 assert!(is_new2, "after reset should be new");
1552 }
1553
1554 #[test]
1559 fn timeout_error_patterns_match_runner_output() {
1560 let patterns = ["timeout after 1800s", "agent timed out after 120s"];
1561 for &msg in &patterns {
1562 let is_timeout = msg.contains("timeout after") || msg.contains("timed out after");
1563 assert!(is_timeout, "pattern should be detected as timeout: {msg:?}");
1564 }
1565 let non_timeout = [
1567 "rate limit: 429",
1568 "stale session (session expired): abc",
1569 "auth error: invalid key",
1570 ];
1571 for &msg in &non_timeout {
1572 let is_timeout = msg.contains("timeout after") || msg.contains("timed out after");
1573 assert!(
1574 !is_timeout,
1575 "pattern must not be detected as timeout: {msg:?}"
1576 );
1577 }
1578 }
1579
1580 #[test]
1583 fn chunk_text_under_limit_returns_single_chunk() {
1584 let text = "hello world";
1585 let chunks = chunk_text(text, 100);
1586 assert_eq!(chunks, vec!["hello world".to_string()]);
1587 }
1588
1589 #[test]
1590 fn chunk_text_exact_limit_returns_single_chunk() {
1591 let text = "abcde";
1592 let chunks = chunk_text(text, 5);
1593 assert_eq!(chunks.len(), 1);
1594 assert_eq!(chunks[0], "abcde");
1595 }
1596
1597 #[test]
1598 fn chunk_text_splits_and_reassembles() {
1599 let text = "line1\nline2\nline3\nline4\nline5";
1600 let chunks = chunk_text(text, 12);
1601 let reassembled: String = chunks.join("");
1602 assert_eq!(reassembled, text);
1603 }
1604
1605 #[test]
1606 fn chunk_text_respects_utf8_boundaries() {
1607 let content = format!("{}😀", "x".repeat(97)); let chunks = chunk_text(&content, 100);
1611 let reassembled: String = chunks.join("");
1612 assert_eq!(reassembled, content);
1613 for c in &chunks {
1614 assert!(
1615 str::from_utf8(c.as_bytes()).is_ok(),
1616 "chunk is not valid UTF-8"
1617 );
1618 }
1619 }
1620
1621 #[test]
1622 fn chunk_text_prefers_newline_boundary() {
1623 let text = "aaaa\nbbbb";
1625 let chunks = chunk_text(text, 6);
1626 assert!(
1628 chunks[0].ends_with('\n'),
1629 "expected split at newline, got: {:?}",
1630 chunks[0]
1631 );
1632 let reassembled: String = chunks.join("");
1633 assert_eq!(reassembled, text);
1634 }
1635
1636 #[test]
1637 fn chunk_text_empty_string() {
1638 let chunks = chunk_text("", 32);
1639 assert!(chunks.is_empty() || chunks == vec![String::new()]);
1640 }
1641
1642 #[test]
1645 fn build_control_chunks_session_agent_splits_state_and_message() {
1646 let state = "state content";
1649 let message = "user question";
1650 let chunks = build_control_chunks(state, message, true);
1651 assert_eq!(chunks.len(), 2);
1652 assert_eq!(chunks[0], "state content");
1653 assert_eq!(chunks[1], "user question");
1654 }
1655
1656 #[test]
1657 fn build_control_chunks_session_agent_long_state_splits() {
1658 let state = "line\n".repeat(10000); let message = "question";
1661 let chunks = build_control_chunks(&state, message, true);
1662 assert!(chunks.len() >= 3);
1664 for chunk in &chunks[..chunks.len() - 1] {
1666 assert!(chunk.contains("line"));
1667 }
1668 assert_eq!(chunks.last().unwrap(), "question");
1670 }
1671
1672 #[test]
1673 fn build_control_chunks_non_session_agent_adds_headers() {
1674 let state = "state content";
1675 let message = "user question";
1676 let chunks = build_control_chunks(state, message, false);
1677 assert_eq!(chunks.len(), 2);
1678 assert!(chunks[0].starts_with("[Context chunk 1/1]"));
1679 assert!(chunks[0].contains("state content"));
1680 assert!(chunks[1].starts_with("[Context complete. User question:]"));
1681 assert!(chunks[1].contains("user question"));
1682 }
1683
1684 #[test]
1685 fn build_control_chunks_non_session_agent_splits_long_message() {
1686 let state = "state";
1687 let message = "x".repeat(50000); let chunks = build_control_chunks(state, &message, false);
1689 assert!(chunks.len() >= 2);
1691 assert!(chunks[0].starts_with("[Context chunk 1/1]"));
1692 for chunk in &chunks[1..] {
1694 assert!(chunk.starts_with("[User question chunk"));
1695 }
1696 let reassembled_state = chunks[0].split_once('\n').map_or("", |(_, r)| r);
1698 assert_eq!(reassembled_state, "state");
1699 }
1700
1701 #[test]
1702 fn build_control_chunks_non_session_agent_long_state_splits() {
1703 let state = "line\n".repeat(10000); let message = "question";
1705 let chunks = build_control_chunks(&state, message, false);
1706 assert!(chunks.len() >= 3);
1708 for (i, chunk) in chunks.iter().enumerate() {
1709 if i < chunks.len() - 1 {
1710 assert!(chunk.starts_with(&format!("[Context chunk {}/", i + 1)));
1711 assert!(chunk.contains("line"));
1712 } else {
1713 assert!(chunk.starts_with("[Context complete. User question:]"));
1714 assert_eq!(chunk.split_once('\n').map_or("", |(_, r)| r), "question");
1715 }
1716 }
1717 }
1718}