Files
DS4Server/src/app/generation.rs
2026-07-28 21:11:46 +02:00

2273 lines
87 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<i32>,
}
#[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<i32>,
pub(super) generation_stats: Option<GenerationStats>,
pub(super) reasoning: Option<String>,
pub(super) reasoning_complete: bool,
pub(super) reasoning_open: bool,
pub(super) content: String,
pub(super) model_content: Option<String>,
pub(super) markdown: markdown::Content,
pub(super) transcript: text_editor::Content,
pub(super) a2ui_lines_processed: usize,
pub(super) a2ui_errors: Vec<String>,
pub(super) a2ui_replies: Vec<serde_json::Value>,
pub(super) a2ui_open_urls: Vec<String>,
}
#[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<i32>,
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<StoredMessage> 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<Database>,
session_id: Option<i32>,
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<Item = String>) -> Option<String> {
let messages = messages.into_iter().collect::<Vec<_>>();
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::<Vec<_>>()
.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<Option<String>, 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<Item = ChatTurn>) -> Vec<ChatTurn> {
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<String>,
stored_prompt: Option<&str>,
) -> Option<String> {
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<String> {
self.config.dev_brain.enabled.then(|| {
let projects = self
.projects
.iter()
.map(|project| project.project.clone())
.collect::<Vec<_>>();
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::<Vec<_>>();
#[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<String> {
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::<Vec<_>>()
.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<crate::agent::ToolCall>) -> 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::<Vec<_>>();
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::<Vec<_>>();
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<String> {
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<String> {
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#"<DSMLtool_calls>
<DSMLinvoke name="bash">
<DSMLparameter name="command" string="true">pwd</DSMLparameter>
</DSMLinvoke>
</DSMLtool_calls>"#;
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<i32>| 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));
}
}