use super::*; /// Asks for a title short enough to fit a sidebar row. const TITLE_INSTRUCTION: &str = "Give this conversation a short title of at most six words. \ Reply with the title alone: no quotes, no trailing period, no explanation."; const TITLE_MAX_TOKENS: i32 = 48; const TITLE_MAX_CHARS: usize = 60; const AGENTS_PREFIX: &str = "Project instructions from AGENTS.md:\n\n"; /// A one-shot generation that produces a session title. It runs against the /// transient KV cache, so it never creates or touches a stored session. #[cfg(target_os = "macos")] pub(super) struct TitleRequest { session_id: i32, /// Title the session had when the request was queued. If it changed since, /// somebody renamed it by hand and that newer intent wins. expected: String, active: ActiveGeneration, content: String, } #[cfg(target_os = "macos")] pub(super) enum PendingContinuation { None, User(String), Tool(String), } #[cfg(target_os = "macos")] pub(super) struct CompactionRequest { pub(super) active: ActiveGeneration, pending: PendingContinuation, message_ids: Vec, } #[cfg(target_os = "macos")] #[derive(Clone, Copy)] enum ToolCheckStage { Initial, AfterCompaction, BoundedError, } #[cfg(target_os = "macos")] pub(super) struct ToolResultCheck { pub(super) active: ActiveGeneration, result: String, stage: ToolCheckStage, } #[derive(Debug)] pub(crate) struct ChatMessage { pub(super) id: i32, pub(super) user: bool, pub(super) tool: bool, pub(super) system: bool, pub(super) compaction: bool, pub(super) compaction_tail_start: Option, pub(super) generation_stats: Option, pub(super) reasoning: Option, pub(super) reasoning_complete: bool, pub(super) reasoning_open: bool, pub(super) content: String, pub(super) model_content: Option, pub(super) markdown: markdown::Content, pub(super) transcript: text_editor::Content, pub(super) a2ui_lines_processed: usize, pub(super) a2ui_errors: Vec, pub(super) a2ui_replies: Vec, pub(super) a2ui_open_urls: Vec, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(super) struct GenerationStats { pub(super) duration_ms: u64, pub(super) input_tokens: u32, pub(super) cached_tokens: u32, pub(super) output_tokens: u32, } impl GenerationStats { fn add(&mut self, other: Self) { self.duration_ms = self.duration_ms.saturating_add(other.duration_ms); self.input_tokens = self.input_tokens.saturating_add(other.input_tokens); self.cached_tokens = self.cached_tokens.saturating_add(other.cached_tokens); self.output_tokens = self.output_tokens.saturating_add(other.output_tokens); } } pub(super) struct TurnSummary { started_at: Instant, message_id: Option, stats: GenerationStats, } impl TurnSummary { fn new() -> Self { Self { started_at: Instant::now(), message_id: None, stats: GenerationStats::default(), } } fn record(&mut self, input_tokens: u32, cached_tokens: u32, output_tokens: u32) { self.stats.add(GenerationStats { duration_ms: 0, input_tokens, cached_tokens, output_tokens, }); } fn finish(mut self) -> GenerationStats { self.stats.duration_ms = u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX); self.stats } } pub(super) fn promote_legacy_turn_summaries(messages: &mut [ChatMessage]) { let mut owner = None; for index in 0..messages.len() { if messages[index].user { owner = Some((index, messages[index].generation_stats.is_some())); } else if let Some(stats) = messages[index].generation_stats.take() && let Some((owner, false)) = owner { messages[owner] .generation_stats .get_or_insert_default() .add(stats); } } } const TOOL_PROTOCOL_CORRECTION: &str = "Tool protocol correction (no tool was executed): you emitted a complete tool call inside private reasoning. End reasoning, then emit the tool call again as assistant content using the required syntax; do not merely discuss it."; const EMPTY_RESPONSE_CORRECTION: &str = "Protocol correction: your previous response was empty. Continue the task now with either a valid tool call or a final answer."; impl ChatMessage { pub(super) fn append(&mut self, reasoning: bool, chunk: &str) { if reasoning { self.reasoning.get_or_insert_default().push_str(chunk); } else { self.reasoning_complete = true; self.content.push_str(chunk); } } pub(super) fn refresh_markdown(&mut self) { let visible = crate::agent::visible_content(&self.content); let visible = crate::a2ui::transcript_fallback(visible); let content = if self.reasoning.is_some() { visible.trim_start() } else { &visible }; if self.transcript.text() != content { self.transcript = text_editor::Content::with_text(content); } if !self.user && !self.tool { self.markdown = markdown::Content::parse(content); } } } fn has_misplaced_tool_call(model: ModelChoice, message: &ChatMessage) -> bool { message.content.trim().is_empty() && message.reasoning.as_deref().is_some_and(|reasoning| { matches!( crate::agent::parse_tool_calls(model, reasoning), Ok((_, calls)) if !calls.is_empty() ) }) } fn is_empty_response(message: &ChatMessage) -> bool { message.content.trim().is_empty() && message .reasoning .as_deref() .is_none_or(|reasoning| reasoning.trim().is_empty()) } fn correction_already_sent(conversation: &[ChatMessage], correction: &str) -> bool { conversation .iter() .rev() .skip(1) .take_while(|message| { !message.user && !(message.tool && message.content.starts_with("Tool result ")) }) .any(|message| message.tool && message.content == correction) } impl From for ChatMessage { fn from(message: StoredMessage) -> Self { let generation_stats = match ( message.generation_duration_ms, message.input_tokens, message.cached_tokens, message.output_tokens, ) { (Some(duration_ms), Some(input_tokens), Some(cached_tokens), Some(output_tokens)) if duration_ms >= 0 && input_tokens >= 0 && cached_tokens >= 0 && output_tokens >= 0 => { Some(GenerationStats { duration_ms: duration_ms as u64, input_tokens: input_tokens as u32, cached_tokens: cached_tokens as u32, output_tokens: output_tokens as u32, }) } _ => None, }; let mut message = Self { id: message.id, user: message.user, tool: message.tool, system: message.system, compaction: message.compaction, compaction_tail_start: message.compaction_tail_start, generation_stats, reasoning: message.reasoning, reasoning_complete: message.reasoning_complete, reasoning_open: false, content: message.content, model_content: message.model_content, markdown: iced::widget::markdown::Content::new(), transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), a2ui_open_urls: Vec::new(), }; message.refresh_markdown(); message } } fn sync_a2ui_message( store: &mut crate::a2ui::Store, database: &mut Option, session_id: Option, message: &mut ChatMessage, ) -> bool { let lines = crate::a2ui::extract_lines(&message.content); let mut renderable_surface_updated = false; for (index, line) in lines.iter().enumerate().skip(message.a2ui_lines_processed) { let applied = match &line.value { Ok(value) => store.apply(value.clone(), line.raw.clone(), message.id), Err(error) => Err(error.clone()), }; match applied { Ok(applied) => { renderable_surface_updated |= line.value.as_ref().is_ok_and(|value| { crate::a2ui::message_surface_id(value).is_some_and(|id| { store.active_surface().is_some_and(|surface| { surface.id == id && surface.components.contains_key("root") }) }) }); if let Some(reply) = applied.reply { message.a2ui_replies.push(reply); } if let Some(url) = applied.open_url { message.a2ui_open_urls.push(url); } if let (Some(session_id), Some(database)) = (session_id, database.as_mut()) { for raw in applied.raws { if let Err(error) = database.insert_a2ui_message(session_id, message.id, &raw) { message.a2ui_errors.push(format!( "line {} could not be persisted: {error}", index + 1 )); break; } } } } Err(error) => message .a2ui_errors .push(format!("line {}: {error}", index + 1)), } } message.a2ui_lines_processed = lines.len(); message.refresh_markdown(); renderable_surface_updated } #[cfg(target_os = "macos")] fn chat_turn(message: &ChatMessage) -> ChatTurn { ChatTurn { user: message.user, tool: message.tool, system: message.system, skip_previous_eos: false, reasoning: message.reasoning.clone(), reasoning_complete: message.reasoning_complete, content: message .model_content .clone() .unwrap_or_else(|| message.content.clone()), } } fn queued_prompt(messages: impl IntoIterator) -> Option { let messages = messages.into_iter().collect::>(); match messages.as_slice() { [] => None, [message] => Some(message.clone()), _ => Some( messages .iter() .enumerate() .map(|(index, message)| format!("Queued user message {}:\n{message}", index + 1)) .collect::>() .join("\n\n"), ), } } fn compacted_context_start(messages: &[ChatMessage]) -> usize { let Some(marker_index) = messages.iter().rposition(|message| message.compaction) else { return 0; }; messages[marker_index] .compaction_tail_start .and_then(|id| { messages[..marker_index] .iter() .position(|message| message.id == id) }) .unwrap_or(marker_index + 1) } fn has_chat_after_last_compaction(messages: &[ChatMessage]) -> bool { let start = messages .iter() .rposition(|message| message.compaction) .map_or(0, |index| index + 1); messages[start..].iter().any(|message| { !message.system && !message.compaction && !message .content .starts_with(crate::agent::COMPACTION_OBSERVATION_PREFIX) && (!message.content.trim().is_empty() || message .reasoning .as_deref() .is_some_and(|reasoning| !reasoning.trim().is_empty())) }) } fn project_agents(path: &Path) -> Result, String> { let path = path.join("AGENTS.md"); match fs::read_to_string(&path) { Ok(content) if content.is_empty() => Ok(None), Ok(content) => Ok(Some(content)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(format!("Could not read {}: {error}", path.display())), } } #[cfg(target_os = "macos")] fn title_context(messages: impl IntoIterator) -> Vec { let mut started = false; messages .into_iter() .filter(|message| { if !started { started = message.user && !message.tool && !message.system; } started && !message.system && !message.content.trim().is_empty() }) .collect() } #[cfg(any(target_os = "macos", test))] fn agents_prompt_for_turn( opening_turn: bool, opening_prompt: Option, stored_prompt: Option<&str>, ) -> Option { if opening_turn { opening_prompt } else { stored_prompt.map(str::to_owned) } } impl App { fn chat_system_prompt(&self, model: ModelChoice, prompt: &str, agents: Option<&str>) -> String { let mut prompt = crate::agent::system_prompt(model, prompt, self.config.dev_brain.enabled); if self.config.a2ui_enabled { prompt.push_str("\n\n"); prompt.push_str(crate::a2ui::SYSTEM_PROMPT); } if let Some(agents) = agents { prompt.push_str("\n\n"); prompt.push_str(agents); } prompt } fn dev_brain_skills_prompt(&self) -> Option { self.config.dev_brain.enabled.then(|| { let projects = self .projects .iter() .map(|project| project.project.clone()) .collect::>(); crate::dev_brain::skills_prompt(&self.config.dev_brain, &projects).unwrap_or_else(|error| { format!( "# Available Dev Brain skills\n\nThe verified skill index is unavailable: {error}. Repair the vault with dev_brain_info and dev_brain_validate before relying on a skill." ) }) }) } fn session_agents_prompt(&self) -> Option<&str> { self.conversation .iter() .find(|message| message.system && message.content.starts_with(AGENTS_PREFIX)) .map(|message| message.content.as_str()) } pub(super) fn can_compact_session(&self, session_id: i32) -> bool { !self.generating && self.selected_session == Some(session_id) && has_chat_after_last_compaction(&self.conversation) } pub(super) fn start_generation(&mut self) { if self.selected_project.is_none() { return; } let prompt = self.composer.text().trim().to_owned(); if prompt.is_empty() { return; } #[cfg(target_os = "macos")] if prompt == "/compact" { self.composer = text_editor::Content::new(); if self.generating { self.manual_compaction_queued = true; self.activity = Some("Compaction queued for the next safe point…".into()); } else if let Err(error) = self.start_compaction(PendingContinuation::None, "manual /compact request") { self.error = Some(error); } return; } if self.generating { self.queued_inputs.push_back(prompt); self.composer = text_editor::Content::new(); self.activity = Some(format!( "{} queued input{}", self.queued_inputs.len(), if self.queued_inputs.len() == 1 { "" } else { "s" } )); return; } #[cfg(target_os = "macos")] let opening_turn = self.selected_session.is_none(); #[cfg(target_os = "macos")] let opening_agents = if opening_turn { let Some(project) = self .projects .iter() .find(|project| Some(project.project.id) == self.selected_project) else { self.error = Some("The selected project is unavailable.".into()); return; }; match project_agents(Path::new(&project.project.path)) { Ok(agents) => agents.map(|agents| format!("{AGENTS_PREFIX}{agents}")), Err(error) => { self.error = Some(error); return; } } } else { None }; #[cfg(target_os = "macos")] let agents = agents_prompt_for_turn(opening_turn, opening_agents, self.session_agents_prompt()); self.a2ui_auto_switch_pending = true; self.context_notice = None; #[cfg(target_os = "macos")] self.tool_cards.clear(); #[cfg(target_os = "macos")] if !std::mem::take(&mut self.skip_compaction_once) && crate::compaction::should_compact(self.context_used, self.context_limit) { self.active_turn.get_or_insert_with(TurnSummary::new); if let Err(error) = self.start_compaction( PendingContinuation::User(prompt), "soft limit before user turn", ) { self.active_turn = None; self.error = Some(error); } return; } let model = self.config.model; let effective = crate::settings::effective_settings( model, &self.config.generation, &self.config.runtime, &models_path(), ); let mut effective = match effective { Ok(settings) => settings, Err(error) => { self.error = Some(error); return; } }; effective.turn.system_prompt = self.chat_system_prompt(model, &effective.turn.system_prompt, agents.as_deref()); effective.turn.system_prompt = crate::compaction::summary_system_prompt( &effective.turn.system_prompt, self.compaction_summary(), ); let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct; #[cfg(target_os = "macos")] let model_prompt = if self.config.a2ui_enabled { let mut prompt = format!( "{prompt}\n\nA2UI client metadata:\n{}", self.a2ui.client_metadata() ); if self.a2ui.active_surface().is_none() { prompt.push_str( "\n\nThere is no active A2UI surface. If this response presents UI, its first A2UI message must be createSurface with a new surfaceId and a complete root component tree. Do not update any surfaceId found only in earlier chat history.", ); } prompt } else { prompt.clone() }; #[cfg(target_os = "macos")] let mut injected_system = Vec::new(); #[cfg(target_os = "macos")] if opening_turn { injected_system.extend(agents.clone()); injected_system.extend(self.dev_brain_skills_prompt()); injected_system.push(crate::agent::datetime_context()); } #[cfg(target_os = "macos")] let reminder_injected = self.system_prompt_reminder_due(); #[cfg(target_os = "macos")] if reminder_injected { injected_system.extend(self.system_prompt_reminders(model)); } #[cfg(target_os = "macos")] let mut messages = self .model_chat_messages() .into_iter() .map(chat_turn) .collect::>(); #[cfg(target_os = "macos")] messages.extend( injected_system .iter() .skip(usize::from(opening_turn && agents.is_some())) .map(|content| ChatTurn { user: false, tool: false, system: true, skip_previous_eos: false, reasoning: None, reasoning_complete: true, content: content.clone(), }), ); #[cfg(target_os = "macos")] messages.push(ChatTurn { user: true, tool: false, system: false, skip_previous_eos: false, reasoning: None, reasoning_complete: true, content: model_prompt.clone(), }); #[cfg(target_os = "macos")] { // A draft session only reaches the database once there is a turn to store. let session_id = match self.selected_session { Some(session_id) => session_id, None => { let Some(project_id) = self.selected_project else { return; }; match self.persist_session(project_id) { Ok(session_id) => session_id, Err(error) => { self.error = Some(format!("Could not create the session: {error}")); return; } } } }; let archived_session = self .projects .iter() .flat_map(|project| &project.sessions) .find(|session| session.id == session_id) .is_some_and(|session| session.state() == SessionState::Archived); let Some(service) = self.generation_service.clone() else { self.error = Some("The model runtime is unavailable.".into()); return; }; let Some(database) = &mut self.database else { return; }; let mut saved = match database.start_chat_turn( session_id, &prompt, (model_prompt != prompt).then_some(model_prompt.as_str()), &injected_system, assistant_reasoning, ) { Ok(turn) => turn, Err(error) => { self.error = Some(format!("Could not save the chat turn: {error}")); return; } }; if archived_session { self.reload_projects(); self.context_notice = Some( "Rebuilding context: the session was archived and its checkpoint was discarded." .into(), ); } let user_id = saved[saved.len() - 2].id; self.active_turn .get_or_insert_with(TurnSummary::new) .message_id = Some(user_id); if reminder_injected { self.system_prompt_seen_at = self.context_used; } let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60); let checkpoint = if opening_turn { CheckpointTarget::Local { checkpoint: session_checkpoint_path(session_id), bootstrap: Some(transient_cache_path()), } } else { CheckpointTarget::Local { checkpoint: session_checkpoint_path(session_id), bootstrap: None, } }; self.active_generation = match service.generate( effective.engine, effective.turn, messages, checkpoint, idle_timeout, ) { Ok(active) => Some(active), Err(error) => { self.generation_service = None; self.error = Some(error); self.finish_turn_summary(); return; } }; let mut assistant = ChatMessage::from(saved.pop().unwrap()); let user = ChatMessage::from(saved.pop().unwrap()); for message in saved { self.conversation.push(ChatMessage::from(message)); } assistant.reasoning_open = assistant_reasoning; self.composer = text_editor::Content::new(); self.conversation.push(user); self.conversation.push(assistant); self.generating = true; self.stop_requested = false; self.activity = Some("Loading model…".into()); self.tokens_per_second = None; self.error = None; if opening_turn { // Queued behind the reply, so the answer is never delayed by it. self.request_first_title(session_id); } } #[cfg(not(target_os = "macos"))] { let _ = effective; self.error = Some("Local Metal generation requires macOS.".into()); return; } } fn system_prompt_reminder_due(&self) -> bool { crate::agent::prompt_reminder_due(self.context_used, self.system_prompt_seen_at) } fn system_prompt_reminders(&self, model: ModelChoice) -> Vec { let mut reminders = vec![crate::agent::system_prompt_reminder( model, self.config.dev_brain.enabled, )]; if let Some(skills) = self.dev_brain_skills_prompt() { reminders.push(skills); } if self.config.a2ui_enabled { reminders.push(crate::a2ui::SYSTEM_PROMPT.to_owned()); } if !self.config.generation.system_prompt.trim().is_empty() { reminders.push(self.config.generation.system_prompt.clone()); } if let Some(agents) = self.session_agents_prompt() { reminders.push(agents.to_owned()); } reminders } pub(super) fn poll_generation(&mut self) -> bool { let was_generating = self.generating; let changed = self.poll_generation_step(); if !self.generating { self.finish_turn_summary(); } if was_generating && !self.generating { self.refresh_git_state(); if self.detail_tab == DetailTab::Git && let Some(project_id) = self.selected_project { self.refresh_git_worktree(project_id); } } changed } fn poll_generation_step(&mut self) -> bool { #[cfg(target_os = "macos")] if self.active_compaction.is_some() { return self.poll_compaction(); } #[cfg(target_os = "macos")] if self.active_tool_check.is_some() { return self.poll_tool_result_check(); } #[cfg(target_os = "macos")] if self.active_tools.is_some() { while let Some(event) = self .active_tools .as_ref() .and_then(crate::agent::try_tool_event) { match event { crate::agent::ToolEvent::State { index, state, result, } => { if let Some(card) = self.tool_cards.get_mut(index) { card.state = state; if result.is_some() { card.result = result; } } self.activity = Some(format!("Tool {} · {}", index + 1, state.label())); } crate::agent::ToolEvent::Approval { index, prompt, decision, } => { if let Some(card) = self.tool_cards.get_mut(index) { card.state = crate::agent::ToolLifecycle::AwaitingApproval; } self.pending_tool_approval = Some((prompt, decision)); self.activity = Some(format!("Tool {} · Awaiting approval", index + 1)); } } } } #[cfg(target_os = "macos")] if let Some(active) = &self.active_tools { match crate::agent::try_tool_result(active) { Ok(Some(result)) => { let cancelled = active.cancel.load(Ordering::Relaxed); self.active_tools = None; self.pending_tool_approval = None; if cancelled { self.generating = false; self.activity = Some("Stopped".into()); self.start_next_queued(); return false; } let continuation = if self.manual_compaction_queued { self.manual_compaction_queued = false; self.start_compaction( PendingContinuation::Tool(result), "queued manual compaction", ) } else { self.start_tool_result_check(result, ToolCheckStage::Initial) }; if let Err(error) = continuation { self.generating = false; self.activity = Some("Failed".into()); self.error = Some(error); } return true; } Ok(None) => return false, Err(error) => { self.active_tools = None; self.generating = false; self.activity = Some("Failed".into()); self.error = Some(error); return false; } } } #[cfg(target_os = "macos")] let Some(active) = &mut self.active_generation else { self.generating = false; return false; }; #[cfg(target_os = "macos")] let mut transcript_changed = false; #[cfg(target_os = "macos")] let mut context_changed = false; #[cfg(target_os = "macos")] let mut start_queued = false; #[cfg(target_os = "macos")] let mut continuation_feedback = None; #[cfg(target_os = "macos")] let mut a2ui_changed = false; #[cfg(target_os = "macos")] loop { match active.events.try_recv() { Ok(GenerationEvent::Loading) => {} Ok(GenerationEvent::Activity(activity)) => { if activity.starts_with("Rebuilding context:") { self.context_notice = Some(activity.to_owned()); } self.activity = Some(activity.into()); } Ok(GenerationEvent::Compacted(_)) => { self.generating = false; self.error = Some("The model runtime returned an unexpected compaction event.".into()); } Ok(GenerationEvent::Chunk { reasoning, content }) => { if let Some(message) = self.conversation.last_mut() && !message.user { message.append(reasoning, &content); if !reasoning && self.tool_cards.is_empty() && crate::agent::has_tool_markup(&message.content) { self.tool_cards.push(crate::agent::ToolCard::streaming()); self.activity = Some("Parsing tool call…".into()); } transcript_changed = true; } if !reasoning && self.config.a2ui_enabled && let Some(message) = self.conversation.last_mut() { let renderable_surface_updated = sync_a2ui_message( &mut self.a2ui, &mut self.database, self.selected_session, message, ); a2ui_changed = true; if self.a2ui_auto_switch_pending && renderable_surface_updated { self.detail_tab = DetailTab::A2ui; self.a2ui_auto_switch_pending = false; } } } Ok(GenerationEvent::Context { used, limit, tokens_per_second, }) => { self.context_used = used; self.context_limit = limit; self.tokens_per_second = tokens_per_second; if !self.stop_requested { self.activity = Some( if tokens_per_second.is_some() { "Generating…" } else { "Reading conversation…" } .into(), ); } context_changed = true; } Ok(GenerationEvent::Finished(result)) => { if let Ok(output) = &result && let Some(turn) = &mut self.active_turn { turn.record( output.prompt_tokens, output.cached_tokens, output.completion_tokens, ); } match result { Ok(_) if self.stop_requested => { self.generating = false; self.activity = Some("Stopped".into()); start_queued = !self.queued_inputs.is_empty() || self.manual_compaction_queued; } Ok(_) => { let model = self.config.model; let protocol_recovery = if self .conversation .last() .is_some_and(|message| has_misplaced_tool_call(model, message)) { Some(( TOOL_PROTOCOL_CORRECTION, "Correcting tool call…", "The model emitted a complete tool call inside private reasoning twice; no tool was executed.", )) } else if self.conversation.last().is_some_and(is_empty_response) { Some(( EMPTY_RESPONSE_CORRECTION, "Retrying empty response…", "The model returned an empty response twice; generation stopped.", )) } else { None }; if let Some((correction, activity, repeated_error)) = protocol_recovery { self.generating = false; self.tool_cards.clear(); self.active_generation = None; if correction_already_sent(&self.conversation, correction) { self.activity = Some("Failed".into()); self.error = Some(repeated_error.into()); } else { self.activity = Some(activity.into()); continuation_feedback = Some(correction.to_owned()); } break; } let (validation_errors, replies, open_urls, error_surface_id) = self .conversation .last_mut() .map(|message| { let surface_id = crate::a2ui::extract_lines(&message.content) .into_iter() .rev() .filter_map(|line| line.value.ok()) .find_map(|value| { crate::a2ui::message_surface_id(&value) .map(str::to_owned) }) .unwrap_or_else(|| "unknown".to_owned()); ( std::mem::take(&mut message.a2ui_errors), std::mem::take(&mut message.a2ui_replies), std::mem::take(&mut message.a2ui_open_urls), surface_id, ) }) .unwrap_or_default(); for url in open_urls { if let Err(error) = std::process::Command::new("open").arg(url).spawn() { self.error = Some(format!("Could not open the A2UI link: {error}")); } } if !validation_errors.is_empty() { self.generating = false; self.activity = Some("Correcting A2UI…".into()); self.tool_cards.clear(); let mut feedback = serde_json::json!({ "version": crate::a2ui::VERSION, "error": { "code": "VALIDATION_FAILED", "surfaceId": error_surface_id, "path": "/", "message": validation_errors.join("; ") } }) .to_string(); if self.a2ui.active_surface().is_none() { feedback.push_str( "\nThere is no active A2UI surface. Correct this by emitting createSurface with a new surfaceId and a complete root component tree; do not retry updateComponents for an earlier surface.", ); } continuation_feedback = Some(feedback); self.active_generation = None; break; } if !replies.is_empty() { self.generating = false; self.activity = Some("Continuing A2UI function call…".into()); self.tool_cards.clear(); continuation_feedback = Some(format!( "A2UI client response:\n{}", replies .iter() .map(ToString::to_string) .collect::>() .join("\n") )); self.active_generation = None; break; } let model = self.config.model; let content = self .conversation .last() .map(|message| message.content.clone()) .unwrap_or_default(); match crate::agent::parse_tool_calls(model, &content) { Ok((_, calls)) if !calls.is_empty() => { if let Err(error) = self.start_agent_tools(calls) { self.generating = false; self.error = Some(error); } } Ok(_) => { self.generating = false; self.activity = None; self.tool_cards.clear(); start_queued = !self.queued_inputs.is_empty() || self.manual_compaction_queued; } Err(error) => { self.active_tools = Some(crate::agent::error_async(error)); } } } Err(_) if self.stop_requested => { self.generating = false; self.activity = Some("Stopped".into()); } Err(error) => { self.generating = false; self.activity = Some("Failed".into()); self.error = Some(error); } } self.active_generation = None; break; } Ok(GenerationEvent::Measured(_)) => { self.generating = false; self.activity = Some("Failed".into()); self.error = Some( "The model runtime returned an unexpected context measurement.".into(), ); } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { self.generating = false; self.active_generation = None; self.error = Some("The model runtime stopped unexpectedly.".into()); self.activity = Some("Failed".into()); break; } } } #[cfg(target_os = "macos")] if a2ui_changed { self.sync_a2ui_renderer_state(); } #[cfg(target_os = "macos")] if transcript_changed && let Some(message) = self.conversation.last_mut() { message.refresh_markdown(); } #[cfg(target_os = "macos")] if transcript_changed && let Some(message) = self.conversation.last() && let Some(database) = &mut self.database && let Err(error) = database.update_message( message.id, message.reasoning.as_deref(), message.reasoning_complete, &message.content, ) { if let Some(active) = &self.active_generation { active.cancel.store(true, Ordering::Relaxed); } self.error = Some(format!("Could not save generated chat text: {error}")); } #[cfg(target_os = "macos")] if context_changed && let Some(session_id) = self.selected_session && let Some(database) = &mut self.database { if let Err(error) = database.update_session_context( session_id, self.context_used, self.context_limit, self.tokens_per_second, ) { self.error = Some(format!("Could not save context usage: {error}")); } else if let Some(session) = self .projects .iter_mut() .flat_map(|project| &mut project.sessions) .find(|session| session.id == session_id) { session.context_used = self.context_used as i32; session.context_limit = self.context_limit as i32; session.last_tokens_per_second = self.tokens_per_second; } } #[cfg(target_os = "macos")] if start_queued { self.start_next_queued(); } #[cfg(target_os = "macos")] if let Some(feedback) = continuation_feedback && let Err(error) = self.continue_after_tool_result(&feedback) { self.generating = false; self.activity = Some("Failed".into()); self.error = Some(error); } #[cfg(target_os = "macos")] return transcript_changed; #[cfg(not(target_os = "macos"))] false } fn start_next_queued(&mut self) { #[cfg(target_os = "macos")] if std::mem::take(&mut self.manual_compaction_queued) { if let Err(error) = self.start_compaction(PendingContinuation::None, "queued manual compaction") { self.activity = Some("Failed".into()); self.error = Some(error); } return; } if let Some(prompt) = queued_prompt(self.queued_inputs.drain(..)) { self.finish_turn_summary(); self.composer = text_editor::Content::with_text(&prompt); self.start_generation(); } } fn finish_turn_summary(&mut self) { self.context_notice = None; let Some(turn) = self.active_turn.take() else { return; }; let Some(message_id) = turn.message_id else { return; }; let stats = turn.finish(); if let Some(message) = self .conversation .iter_mut() .find(|message| message.id == message_id) { message.generation_stats = Some(stats); } if let Some(database) = &mut self.database && let Err(error) = database.update_message_generation_stats( message_id, stats.duration_ms, stats.input_tokens, stats.cached_tokens, stats.output_tokens, ) { self.error = Some(format!("Could not save turn summary: {error}")); } } #[cfg(target_os = "macos")] fn start_agent_tools(&mut self, calls: Vec) -> Result<(), String> { let session_id = self .selected_session .ok_or_else(|| "The active session is unavailable.".to_owned())?; if self.agent_tools.as_ref().map(|(id, _)| *id) != Some(session_id) { let project_id = self .selected_project .ok_or_else(|| "The active project is unavailable.".to_owned())?; let root = self .projects .iter() .find(|project| project.project.id == project_id) .map(|project| PathBuf::from(&project.project.path)) .ok_or_else(|| "The active project is unavailable.".to_owned())?; let mut tools = crate::agent::Tools::new(&root, self.config.generation.context_tokens)?; if self.config.dev_brain.enabled { let projects = self .projects .iter() .map(|project| project.project.clone()) .collect::>(); tools.enable_dev_brain(&self.config.dev_brain, &projects)?; } self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools)))); } let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1); self.tool_cards = calls .iter() .cloned() .map(crate::agent::ToolCard::parsing) .collect(); self.active_tools = Some(crate::agent::execute_async(tools, calls)); self.activity = Some("Parsing tool calls…".into()); Ok(()) } #[cfg(target_os = "macos")] fn continue_after_tool_result(&mut self, result: &str) -> Result<(), String> { let session_id = self .selected_session .ok_or_else(|| "The active session is unavailable.".to_owned())?; self.skip_compaction_once = false; let model = self.config.model; let mut effective = crate::settings::effective_settings( model, &self.config.generation, &self.config.runtime, &models_path(), )?; effective.turn.system_prompt = self.chat_system_prompt( model, &effective.turn.system_prompt, self.session_agents_prompt(), ); effective.turn.system_prompt = crate::compaction::summary_system_prompt( &effective.turn.system_prompt, self.compaction_summary(), ); let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct; let queued = queued_prompt(self.queued_inputs.drain(..)); if queued.is_some() { self.a2ui_auto_switch_pending = true; } let reminders = if self.system_prompt_reminder_due() { self.system_prompt_reminders(model) } else { Vec::new() }; let mut saved = self .database .as_mut() .ok_or_else(|| "The project database is unavailable.".to_owned())? .continue_tool_turn( session_id, result, queued.as_deref(), &reminders, assistant_reasoning, ) .map_err(|error| format!("Could not save the tool turn: {error}"))?; if !reminders.is_empty() { self.system_prompt_seen_at = self.context_used; } let mut assistant = ChatMessage::from(saved.pop().unwrap()); for message in saved { self.conversation.push(ChatMessage::from(message)); } let messages = self .model_chat_messages() .into_iter() .map(chat_turn) .collect(); assistant.reasoning_open = assistant_reasoning; self.conversation.push(assistant); self.tool_cards.clear(); let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60); self.active_generation = Some( self.generation_service .as_ref() .ok_or_else(|| "The model runtime is unavailable.".to_owned())? .generate( effective.engine, effective.turn, messages, CheckpointTarget::Local { checkpoint: session_checkpoint_path(session_id), bootstrap: None, }, idle_timeout, )?, ); self.tokens_per_second = None; self.activity = Some("Continuing after tools…".into()); self.stop_requested = false; self.generating = true; Ok(()) } #[cfg(target_os = "macos")] pub(super) fn stop_agent_jobs(&mut self) { let Some((_, tools)) = &self.agent_tools else { return; }; let Ok(mut tools) = tools.try_lock() else { return; }; let failures = tools.stop_all_jobs(); if !failures.is_empty() { self.error = Some(failures.join("\n")); } } #[cfg(target_os = "macos")] fn start_tool_result_check( &mut self, result: String, stage: ToolCheckStage, ) -> Result<(), String> { if matches!(stage, ToolCheckStage::Initial) && crate::compaction::should_compact(self.context_used, self.context_limit) { return self.start_compaction( PendingContinuation::Tool(result), "soft limit before tool continuation", ); } let model = self.config.model; let mut effective = crate::settings::effective_settings( model, &self.config.generation, &self.config.runtime, &models_path(), )?; effective.turn.system_prompt = self.chat_system_prompt( model, &effective.turn.system_prompt, self.session_agents_prompt(), ); effective.turn.system_prompt = crate::compaction::summary_system_prompt( &effective.turn.system_prompt, self.compaction_summary(), ); let mut messages = self .model_chat_messages() .into_iter() .map(chat_turn) .collect::>(); messages.push(ChatTurn { user: false, tool: true, system: false, skip_previous_eos: false, reasoning: None, reasoning_complete: true, content: result.clone(), }); let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60); let active = self .generation_service .as_ref() .ok_or_else(|| "The model runtime is unavailable.".to_owned())? .measure_context(effective.engine, effective.turn, messages, idle_timeout)?; self.active_tool_check = Some(ToolResultCheck { active, result, stage, }); self.generating = true; self.activity = Some("Checking tool result context…".into()); Ok(()) } #[cfg(target_os = "macos")] fn poll_tool_result_check(&mut self) -> bool { let event = self .active_tool_check .as_mut() .unwrap() .active .events .try_recv(); match event { Ok(GenerationEvent::Loading) => { self.activity = Some("Loading model…".into()); false } Ok(GenerationEvent::Measured(Ok(projected))) => { let check = self.active_tool_check.take().unwrap(); if self.manual_compaction_queued && matches!(check.stage, ToolCheckStage::Initial) { self.manual_compaction_queued = false; if let Err(error) = self.start_compaction( PendingContinuation::Tool(check.result), "queued manual compaction", ) { self.generating = false; self.activity = Some("Failed".into()); self.error = Some(error); } return true; } let reserve = if matches!(check.stage, ToolCheckStage::BoundedError) { 16 } else { crate::compaction::tool_result_reserve(self.context_limit) }; if crate::compaction::tool_result_fits(projected, self.context_limit, reserve) { if let Err(error) = self.continue_after_tool_result(&check.result) { self.generating = false; self.activity = Some("Failed".into()); self.error = Some(error); } } else { match check.stage { ToolCheckStage::Initial => { if let Err(error) = self.start_compaction( PendingContinuation::Tool(check.result), "tool result would exceed context", ) { self.generating = false; self.activity = Some("Failed".into()); self.error = Some(error); } } ToolCheckStage::AfterCompaction => { let error = crate::compaction::bounded_tool_error( projected, self.context_limit, reserve, ); if let Err(error) = self.start_tool_result_check(error, ToolCheckStage::BoundedError) { self.generating = false; self.activity = Some("Failed".into()); self.error = Some(error); } } ToolCheckStage::BoundedError => { self.generating = false; self.activity = Some("Failed".into()); self.error = Some("context full after compaction".into()); } } } true } Ok(GenerationEvent::Measured(Err(error))) => { self.active_tool_check = None; self.generating = false; if self.stop_requested { self.activity = Some("Stopped".into()); } else { self.activity = Some("Failed".into()); self.error = Some(error); } false } Ok(GenerationEvent::Activity(activity)) => { self.activity = Some(activity.into()); false } Ok(_) => false, Err(TryRecvError::Empty) => false, Err(TryRecvError::Disconnected) => { self.active_tool_check = None; self.generating = false; self.activity = Some("Failed".into()); self.error = Some("The model runtime stopped unexpectedly.".into()); false } } } #[cfg(target_os = "macos")] pub(super) fn start_compaction( &mut self, pending: PendingContinuation, reason: &str, ) -> Result<(), String> { let model = self.config.model; let mut effective = crate::settings::effective_settings( model, &self.config.generation, &self.config.runtime, &models_path(), )?; effective.turn.system_prompt = self.chat_system_prompt( model, &effective.turn.system_prompt, self.session_agents_prompt(), ); let rebuild_system_prompt = effective.turn.system_prompt.clone(); effective.turn.system_prompt = crate::compaction::summary_system_prompt( &effective.turn.system_prompt, self.compaction_summary(), ); let model_messages = self.model_chat_messages(); let message_ids = model_messages.iter().map(|message| message.id).collect(); let messages = model_messages.into_iter().map(chat_turn).collect(); let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60); let active = self .generation_service .as_ref() .ok_or_else(|| "The model runtime is unavailable.".to_owned())? .compact( effective.engine, effective.turn, messages, reason, rebuild_system_prompt, session_compaction_checkpoint_path( self.selected_session .ok_or_else(|| "The active session is unavailable.".to_owned())?, ), idle_timeout, )?; self.active_compaction = Some(CompactionRequest { active, pending, message_ids, }); self.manual_compaction_queued = false; self.generating = true; self.activity = Some("Compacting durable task state…".into()); self.tokens_per_second = None; Ok(()) } #[cfg(target_os = "macos")] fn poll_compaction(&mut self) -> bool { let Some(request) = &mut self.active_compaction else { return false; }; loop { match request.active.events.try_recv() { Ok(GenerationEvent::Loading) => self.activity = Some("Loading model…".into()), Ok(GenerationEvent::Activity(activity)) => self.activity = Some(activity.into()), Ok(GenerationEvent::Context { used, limit, tokens_per_second, }) => { self.context_used = used; self.context_limit = limit; self.tokens_per_second = tokens_per_second; } Ok(GenerationEvent::Compacted(result)) => { let request = self.active_compaction.take().unwrap(); match result { Ok(compacted) => { if let Err(error) = self.apply_compaction(&compacted, &request.message_ids) { let _ = fs::remove_file(&compacted.checkpoint); self.generating = false; self.activity = Some("Failed".into()); self.error = Some(error); return false; } self.context_used = compacted.context_tokens; self.system_prompt_seen_at = compacted.context_tokens; self.generating = false; self.activity = None; match request.pending { PendingContinuation::None => self.start_next_queued(), PendingContinuation::User(prompt) => { self.composer = text_editor::Content::with_text(&prompt); self.skip_compaction_once = true; self.start_generation(); } PendingContinuation::Tool(result) => { if let Err(error) = self.start_tool_result_check( result, ToolCheckStage::AfterCompaction, ) { self.generating = false; self.activity = Some("Failed".into()); self.error = Some(error); } } } return true; } Err(error) => { self.generating = false; if let PendingContinuation::User(prompt) = request.pending { if self.composer.text().trim().is_empty() { self.composer = text_editor::Content::with_text(&prompt); } else { self.queued_inputs.push_front(prompt); } } if self.stop_requested { self.activity = Some("Stopped".into()); } else { self.activity = Some("Failed".into()); self.error = Some(error); } return false; } } } Ok(GenerationEvent::Finished(_)) | Ok(GenerationEvent::Chunk { .. }) | Ok(GenerationEvent::Measured(_)) => {} Err(TryRecvError::Empty) => return false, Err(TryRecvError::Disconnected) => { self.active_compaction = None; self.generating = false; self.activity = Some("Failed".into()); self.error = Some("The model runtime stopped unexpectedly.".into()); return false; } } } } #[cfg(target_os = "macos")] fn apply_compaction( &mut self, compacted: &crate::engine::CompactionOutput, message_ids: &[i32], ) -> Result<(), String> { let session_id = self .selected_session .ok_or_else(|| "The active session is unavailable.".to_owned())?; let running_jobs = if let Some((tools_session, tools)) = &self.agent_tools && *tools_session == session_id { tools .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .compaction_observation() } else { None }; let archived_session = self .projects .iter() .flat_map(|project| &project.sessions) .find(|session| session.id == session_id) .is_some_and(|session| session.state() == SessionState::Archived); let tail_start = message_ids.get(compacted.tail_start).copied(); let messages = self .database .as_mut() .ok_or_else(|| "The project database is unavailable.".to_owned())? .record_compaction( session_id, &compacted.summary, tail_start, running_jobs.as_deref(), compacted.context_tokens, self.context_limit, ) .map_err(|error| format!("Could not save compacted conversation: {error}"))?; self.conversation .extend(messages.into_iter().map(ChatMessage::from)); if archived_session { self.reload_projects(); } else if let Some(session) = self .projects .iter_mut() .flat_map(|project| &mut project.sessions) .find(|session| session.id == session_id) { session.compacted_summary = Some(compacted.summary.clone()); session.context_used = compacted.context_tokens as i32; session.context_limit = self.context_limit as i32; session.last_tokens_per_second = None; } let final_checkpoint = session_checkpoint_path(session_id); if let Err(error) = fs::rename(&compacted.checkpoint, &final_checkpoint) { let _ = fs::remove_file(&compacted.checkpoint); self.error = Some(format!( "The compacted conversation was saved, but its checkpoint could not be promoted: {error}. It will rebuild on next use." )); } Ok(()) } pub(super) fn compaction_summary(&self) -> Option<&str> { if let Some(summary) = self .conversation .iter() .rfind(|message| message.compaction) .map(|message| message.content.as_str()) { return Some(summary); } let session_id = self.selected_session?; self.projects .iter() .flat_map(|project| &project.sessions) .find(|session| session.id == session_id) .and_then(|session| session.compacted_summary.as_deref()) } fn model_chat_messages(&self) -> Vec<&ChatMessage> { let start = compacted_context_start(&self.conversation); self.conversation[start..] .iter() .filter(|message| { !message.compaction && !(message.system && message.content.starts_with(AGENTS_PREFIX)) }) .collect() } /// Asks the model for a session title without opening a session for it: the /// turn runs against the shared transient KV cache and only its text is kept. /// /// Reports failures to the caller instead of the error banner: the automatic /// first-chat titling must stay silent, while the menu action shows them. #[cfg(target_os = "macos")] fn request_title(&mut self, session_id: i32) -> Result<(), String> { if self.active_titling.is_some() { return Err("A title is already being generated.".into()); } let model = self.config.model; let mut effective = crate::settings::effective_settings( model, &self.config.generation, &self.config.runtime, &models_path(), )?; let database = self .database .as_mut() .ok_or_else(|| "The project database is unavailable.".to_owned())?; let stored = database .load_messages(session_id) .map_err(|error| format!("Could not read the session: {error}"))?; // The assistant row is inserted empty when a turn starts; an empty turn // is never useful context, and skipping it is what lets the automatic // pass summarize the user's opening message on its own. let messages = stored .into_iter() .filter(|message| !message.compaction) .map(|message| ChatTurn { user: message.user, tool: message.tool, system: message.system, skip_previous_eos: false, reasoning: None, reasoning_complete: true, content: message.content, }); let mut messages = title_context(messages); if messages.is_empty() { return Err("This session has no messages to summarize yet.".into()); } messages.push(ChatTurn { user: true, tool: false, system: false, skip_previous_eos: false, reasoning: None, reasoning_complete: true, content: TITLE_INSTRUCTION.to_owned(), }); effective.turn.max_generated_tokens = TITLE_MAX_TOKENS; effective.turn.reasoning_mode = ReasoningMode::Direct; let service = self .generation_service .as_ref() .ok_or_else(|| "The model runtime is unavailable.".to_owned())?; let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60); match service.generate( effective.engine, effective.turn, messages, CheckpointTarget::OneShot(transient_cache_path()), idle_timeout, ) { Ok(active) => { self.active_titling = Some(TitleRequest { session_id, expected: self.session_title(session_id).unwrap_or_default(), active, content: String::new(), }); Ok(()) } Err(error) => { self.generation_service = None; Err(error) } } } /// The "Retitle with AI" menu action, which reports why it could not run. #[cfg(target_os = "macos")] pub(super) fn retitle_session(&mut self, session_id: i32) { match self.request_title(session_id) { Ok(()) => self.error = None, Err(error) => self.error = Some(error), } } /// Titles a session from its opening message. Queued behind the reply that /// is already running, and skipped without complaint if it cannot start — /// the user did not ask for this, so it must never interrupt them. #[cfg(target_os = "macos")] pub(super) fn request_first_title(&mut self, session_id: i32) { let _ = self.request_title(session_id); } /// Drains the one-shot title generation. Returns true while it is running so /// the caller keeps ticking. #[cfg(target_os = "macos")] pub(super) fn poll_titling(&mut self) -> bool { let Some(mut request) = self.active_titling.take() else { return false; }; let failure = loop { match request.active.events.try_recv() { Ok(GenerationEvent::Chunk { reasoning: false, content, }) => request.content.push_str(&content), Ok(GenerationEvent::Finished(Err(error))) => break Some(error), Ok(GenerationEvent::Finished(Ok(_))) => break None, Ok(_) => {} Err(TryRecvError::Empty) => { self.active_titling = Some(request); return true; } Err(TryRecvError::Disconnected) => { break Some("The model runtime stopped unexpectedly.".to_owned()); } } }; if let Some(error) = failure { self.error = Some(format!("Could not generate a title: {error}")); } else if let Some(title) = session_title(&request.content) { self.apply_title(&request, &title); } else { self.error = Some("The model did not return a usable title.".into()); } false } pub(super) fn session_title(&self, session_id: i32) -> Option { self.projects .iter() .flat_map(|project| &project.sessions) .find(|session| session.id == session_id) .map(|session| session.title.clone()) } #[cfg(target_os = "macos")] fn apply_title(&mut self, request: &TitleRequest, title: &str) { let Some(current) = self.session_title(request.session_id) else { return; }; if current != request.expected { return; } let Some(database) = &mut self.database else { return; }; match database.rename_session(request.session_id, title) { Ok(()) => { self.error = None; self.reload_projects(); } Err(error) => self.error = Some(format!("Could not save the session title: {error}")), } } } /// Reduces a model reply to a single sidebar-sized line, or `None` if nothing /// usable came back. pub(super) fn session_title(reply: &str) -> Option { let title = reply .lines() .map(str::trim) .find(|line| !line.is_empty())? .trim_matches(|character: char| { character.is_whitespace() || matches!(character, '"' | '\'' | '`' | '*' | '#' | '.') }) .trim(); if title.is_empty() { return None; } Some(match title.char_indices().nth(TITLE_MAX_CHARS) { Some((index, _)) => format!("{}…", title[..index].trim_end()), None => title.to_owned(), }) } #[cfg(test)] mod tests { use super::{ ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, agents_prompt_for_turn, chat_turn, compacted_context_start, correction_already_sent, has_chat_after_last_compaction, has_misplaced_tool_call, is_empty_response, project_agents, promote_legacy_turn_summaries, queued_prompt, sync_a2ui_message, title_context, }; use crate::engine::ChatTurn; use crate::model::ModelChoice; fn assistant(reasoning: Option<&str>, content: &str) -> ChatMessage { ChatMessage { id: 1, user: false, tool: false, system: false, compaction: false, compaction_tail_start: None, generation_stats: None, reasoning: reasoning.map(str::to_owned), reasoning_complete: false, reasoning_open: false, content: content.to_owned(), model_content: None, markdown: iced::widget::markdown::Content::new(), transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), a2ui_open_urls: Vec::new(), } } fn turn(user: bool, system: bool, content: &str) -> ChatTurn { ChatTurn { user, tool: false, system, skip_previous_eos: false, reasoning: None, reasoning_complete: true, content: content.to_owned(), } } #[test] fn project_agents_is_optional_and_preserves_the_file() { let directory = std::env::temp_dir().join(format!("ds4-server-agents-{}", std::process::id())); std::fs::create_dir_all(&directory).unwrap(); assert_eq!(project_agents(&directory).unwrap(), None); let instructions = "# Instructions\n\nKeep this exact.\n"; std::fs::write(directory.join("AGENTS.md"), instructions).unwrap(); assert_eq!( project_agents(&directory).unwrap().as_deref(), Some(instructions) ); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn user_continuations_reuse_the_stored_agents_prompt() { let stored = "Project AGENTS.md instructions:\nkeep this exact"; assert_eq!( agents_prompt_for_turn(false, None, Some(stored)).as_deref(), Some(stored) ); assert_eq!( agents_prompt_for_turn(true, Some("opening".into()), Some(stored)).as_deref(), Some("opening") ); } #[test] fn turn_summary_accumulates_model_continuations() { let mut summary = TurnSummary::new(); summary.record(100, 80, 20); summary.record(140, 120, 30); let stats = summary.finish(); assert_eq!(stats.input_tokens, 240); assert_eq!(stats.cached_tokens, 200); assert_eq!(stats.output_tokens, 50); } #[test] fn model_history_uses_persisted_content_without_changing_the_transcript() { let mut user = assistant(None, "visible question"); user.user = true; user.model_content = Some("visible question\n\nhidden metadata".into()); assert_eq!( chat_turn(&user).content, "visible question\n\nhidden metadata" ); assert_eq!(user.content, "visible question"); } #[test] fn legacy_generation_rows_collapse_into_their_user_turn() { let mut user = assistant(None, "question"); user.user = true; let mut first = assistant(None, "tool call"); first.generation_stats = Some(super::GenerationStats { duration_ms: 1_000, input_tokens: 100, cached_tokens: 80, output_tokens: 20, }); let mut second = assistant(None, "answer"); second.generation_stats = Some(super::GenerationStats { duration_ms: 2_000, input_tokens: 140, cached_tokens: 120, output_tokens: 30, }); let mut messages = [user, first, second]; promote_legacy_turn_summaries(&mut messages); assert_eq!( messages[0].generation_stats, Some(super::GenerationStats { duration_ms: 3_000, input_tokens: 240, cached_tokens: 200, output_tokens: 50, }) ); assert!(messages[1].generation_stats.is_none()); assert!(messages[2].generation_stats.is_none()); } #[test] fn title_context_starts_at_the_first_user_and_ignores_system_rows() { let messages = title_context([ turn(false, true, "project AGENTS.md"), turn(false, true, "date and time"), turn(true, false, "actual request"), turn(false, true, "system reminder"), turn(false, false, "answer"), ]); assert_eq!(messages.len(), 2); assert_eq!(messages[0].content, "actual request"); assert_eq!(messages[1].content, "answer"); } #[test] fn complete_tool_call_in_reasoning_gets_one_correction() { let call = r#"<|DSML|tool_calls> <|DSML|invoke name="bash"> <|DSML|parameter name="command" string="true">pwd "#; let malformed = assistant(Some(call), ""); assert!(has_misplaced_tool_call( ModelChoice::DeepSeekV4Flash, &malformed )); assert!(!has_misplaced_tool_call( ModelChoice::DeepSeekV4Flash, &assistant(Some(call), "I was only discussing this call.") )); assert!(is_empty_response(&assistant(Some(""), ""))); assert!(!is_empty_response(&assistant(Some("still thinking"), ""))); let mut correction = assistant(None, TOOL_PROTOCOL_CORRECTION); correction.tool = true; let conversation = vec![assistant(Some(call), ""), correction, malformed]; assert!(correction_already_sent( &conversation, TOOL_PROTOCOL_CORRECTION )); let mut result = assistant(None, "Tool result 1 (bash):\nok\n"); result.tool = true; let mut prior_correction = assistant(None, TOOL_PROTOCOL_CORRECTION); prior_correction.tool = true; let conversation = vec![ assistant(Some(call), ""), prior_correction, assistant(None, call), result, assistant(Some(call), ""), ]; assert!(!correction_already_sent( &conversation, TOOL_PROTOCOL_CORRECTION )); } #[test] fn queued_guidance_is_one_reference_style_user_turn() { assert_eq!(queued_prompt(Vec::new()), None); assert_eq!(queued_prompt(["one".to_owned()]), Some("one".to_owned())); assert_eq!( queued_prompt(["one".to_owned(), "two".to_owned()]), Some("Queued user message 1:\none\n\nQueued user message 2:\ntwo".to_owned()) ); } #[test] fn assistant_markdown_keeps_llm_tables() { let mut message = ChatMessage { id: 1, user: false, tool: false, system: false, compaction: false, compaction_tail_start: None, generation_stats: None, reasoning: None, reasoning_complete: true, reasoning_open: false, content: "### Core / Setup\n\n| File | Lines |\n|---|---:|\n| `src/app.rs` | **1,750** |\n| `src/engine.rs` | 2,400 |\n\n### Summary\n\nDone." .to_owned(), model_content: None, markdown: iced::widget::markdown::Content::new(), transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), a2ui_open_urls: Vec::new(), }; message.refresh_markdown(); let table = message.markdown.items().iter().find_map(|item| { if let iced::widget::markdown::Item::Table { columns, rows } = item { Some((columns, rows)) } else { None } }); let (columns, rows) = table.expect("the completed Markdown table must remain renderable"); assert_eq!(columns.len(), 2); assert_eq!(rows.len(), 2); } #[test] fn a2ui_auto_switch_waits_for_a_renderable_surface() { let mut store = crate::a2ui::Store::default(); let mut database = None; let mut message = assistant( None, r#"```a2ui {"version":"v1.0","createSurface":{"surfaceId":"answer","catalogId":"https://ds4server.local/a2ui/v1_0/catalog.json"}} ```"#, ); assert!(!sync_a2ui_message( &mut store, &mut database, None, &mut message )); message.content = r#"```a2ui {"version":"v1.0","createSurface":{"surfaceId":"answer","catalogId":"https://ds4server.local/a2ui/v1_0/catalog.json"}} {"version":"v1.0","updateComponents":{"surfaceId":"answer","components":[{"id":"root","component":"Text","text":"Ready"}]}} ```"# .to_owned(); assert!(sync_a2ui_message( &mut store, &mut database, None, &mut message )); } #[test] fn last_compaction_selects_its_tail_without_hiding_history() { let message = |id: i32, compaction: bool, tail: Option| ChatMessage { id, user: !compaction, tool: false, system: compaction, compaction, compaction_tail_start: tail, generation_stats: None, reasoning: None, reasoning_complete: true, reasoning_open: false, content: format!("message {id}"), model_content: None, markdown: iced::widget::markdown::Content::new(), transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), a2ui_open_urls: Vec::new(), }; let history = vec![ message(1, false, None), message(2, false, None), message(3, true, Some(2)), message(4, false, None), message(5, true, Some(4)), message(6, false, None), message(7, true, Some(6)), message(8, false, None), ]; assert_eq!(history.len(), 8); assert_eq!(compacted_context_start(&history), 5); assert_eq!(history[compacted_context_start(&history)].id, 6); } #[test] fn forced_compaction_requires_new_visible_chat() { let message = |id: i32, user: bool, tool: bool, system: bool, compaction: bool| ChatMessage { id, user, tool, system, compaction, compaction_tail_start: None, generation_stats: None, reasoning: None, reasoning_complete: true, reasoning_open: false, content: format!("message {id}"), model_content: None, markdown: iced::widget::markdown::Content::new(), transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), a2ui_open_urls: Vec::new(), }; let mut history = vec![ message(1, true, false, false, false), message(2, false, false, true, true), ChatMessage { content: format!( "{} running job", crate::agent::COMPACTION_OBSERVATION_PREFIX ), ..message(3, false, true, false, false) }, message(4, false, false, true, false), ]; assert!(!has_chat_after_last_compaction(&history)); history.push(message(5, false, true, false, false)); assert!(has_chat_after_last_compaction(&history)); history.push(message(6, false, false, true, true)); assert!(!has_chat_after_last_compaction(&history)); history.push(message(7, true, false, false, false)); assert!(has_chat_after_last_compaction(&history)); } }