2273 lines
87 KiB
Rust
2273 lines
87 KiB
Rust
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#"<|DSML|tool_calls>
|
||
<|DSML|invoke name="bash">
|
||
<|DSML|parameter name="command" string="true">pwd</|DSML|parameter>
|
||
</|DSML|invoke>
|
||
</|DSML|tool_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));
|
||
}
|
||
}
|