Watch
1
0
Fork
You've already forked souveraine
0

fix(tui): interjection delivery, turn cancel on agent switch, resume-or-new

Three turn-lifecycle bugs Casey hit in daily use.

④ Interjections: the backend drains the queue between tool rounds and
   after the final LLM call, but one arriving during the chunked
   stream-out — or after the turn ended — just sat in the queue, never
   delivered ("she isn't getting that message"). On turn completion
   (Done / channel close) any still-queued interjections now open a
   fresh turn. submit() is split into spawn_turn() so this delivery
   reuses the Interjection bubbles instead of duplicating a User bubble.

⑤ Agent switch mid-stream: select_agent() dropped the chat surface
   without cancelling the running turn, so run_turn kept executing
   orphaned and resurfaced as "doing the same message again" on a later
   /resume. select_agent() now fires the turn's cancel token first
   (cancel_active_turn) — the backend commits partial output and stops.

⑥ Resume-or-new: entering chat silently auto-created a conversation.
   ChatState::connect now offers it — offer_resume_or_new() lists the
   agent's prior conversations (excluding the just-created empty one)
   and raises the ConversationPicker with an [n] new option; silent
   when the agent has no prior conversation.
This commit is contained in:
Fimeg 2026-05-17 19:07:08 -04:00
commit 332dd4bf65
2 changed files with 115 additions and 13 deletions

View file

@ -328,6 +328,12 @@ impl App {
self.agent_status.name = agent_name.to_string();
if changed {
// Cancel any in-flight turn before dropping the chat surface —
// otherwise the turn keeps running orphaned and resurfaces as
// "she's doing the same message again" on a later /resume.
if let Some(chat) = &self.chat {
chat.cancel_active_turn();
}
self.chat = None;
self.chat_error = None;
self.settings = None;
@ -1142,6 +1148,12 @@ impl App {
chat.accept_conversation_pick();
return;
}
KeyCode::Char('n') => {
// Keep the fresh conversation connect() already made.
chat.overlay = Overlay::None;
chat.system_message("New conversation.".to_string());
return;
}
KeyCode::Esc => {
chat.overlay = Overlay::None;
return;
@ -1247,7 +1259,10 @@ impl App {
// Chat — lazily connect to a backend on first entry.
if self.chat.is_none() {
match ChatState::connect(self.config.clone(), &self.agent_pref).await {
Ok(c) => {
Ok(mut c) => {
// Fresh chat surface (first entry, or after an
// agent switch): offer resume-or-new.
c.offer_resume_or_new();
self.chat = Some(c);
self.chat_error = None;
}

View file

@ -406,6 +406,9 @@ pub struct ChatState {
pub switch_rx: Option<oneshot::Receiver<Result<(String, Vec<crate::core::session::ConversationMessage>)>>>,
/// Conversation ID waiting for the current turn to finalize before loading.
pub switch_pending: Option<String>,
/// True when the conversation list was requested as an on-entry
/// resume-or-new offer — silent on empty, raises the picker on non-empty.
pub resume_offer: bool,
/// `/btw` fork state — an ephemeral side-quest conversation running
/// in parallel to the main chat. Rendered as a floating bordered pane.
pub btw_state: BtwState,
@ -511,6 +514,7 @@ impl ChatState {
convos_rx: None,
switch_rx: None,
switch_pending: None,
resume_offer: false,
btw_state: BtwState::Idle,
btw_rx: None,
tool_cards_expanded: false,
@ -591,13 +595,21 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
return true;
}
// Normal chat message
// Normal chat message — a User bubble, then a turn for it.
let text = trimmed;
let ts = Instant::now();
self.messages.push(ChatMessage::User { text: text.clone(), ts });
// No pre-created Assistant bubble — if the agent starts with tool
// calls, the empty bubble would finalize as a tiny blank box.
// `append_streaming()` creates one on first text token.
self.messages.push(ChatMessage::User { text: text.clone(), ts: Instant::now() });
self.spawn_turn(text);
true
}
/// Start a backend turn for `text`: set up the turn channel, cancel
/// token, and relay task. Does NOT push a user bubble — the caller owns
/// what the user sees (a normal `User` bubble for [`submit`], or the
/// already-shown `Interjection` bubbles for a deferred interjection
/// delivery). No pre-created Assistant bubble — `append_streaming()`
/// makes one on the first text token, so a tool-first turn doesn't
/// finalize a blank box.
fn spawn_turn(&mut self, text: String) {
self.busy = true;
self.tool_calls_this_turn = 0;
self.phase = TurnPhase::Thinking;
@ -607,7 +619,8 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
let (tx, rx) = mpsc::channel::<BackendEvent>(64);
self.turn_rx = Some(rx);
// Cancellation token for this turn — Esc fires it.
// Cancellation token for this turn — Esc raises a hand, an agent
// switch cancels it outright (see cancel_active_turn).
let cancel = CancellationToken::new();
self.cancel_token = Some(cancel.clone());
@ -641,7 +654,47 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
}
let _ = tx.send(BackendEvent::Done).await;
});
true
}
/// Cancel any in-flight turn — used when the chat surface is torn down
/// (an agent switch) so the turn does not keep running orphaned, still
/// working the message the user has already walked away from. The
/// backend reads the cancel token, commits whatever partial output
/// exists, and stops making new calls.
pub fn cancel_active_turn(&self) {
if let Some(token) = &self.cancel_token {
if !token.is_cancelled() {
token.cancel();
}
}
}
/// Deliver interjections the backend did not consume before the turn
/// ended. The user typed them while she was working; if her turn
/// finished without picking them up, they must still reach her — so we
/// open a fresh turn carrying them, rather than leaving them stranded
/// in the queue (the bug where "she isn't getting that message").
fn deliver_pending_interjections(&mut self) {
if self.busy {
return;
}
let pending: Vec<String> = self
.pending_interjections
.lock()
.ok()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
if pending.is_empty() {
return;
}
// The Interjection bubbles already show what the user said — mark
// them delivered rather than pushing a duplicate User bubble.
for msg in &mut self.messages {
if let ChatMessage::Interjection { delivered, .. } = msg {
*delivered = true;
}
}
self.spawn_turn(pending.join("\n"));
}
fn handle_slash_command(&mut self, input: &str) -> bool {
@ -742,6 +795,23 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
self.new_conv_rx = Some(rx);
}
/// On chat entry, quietly check whether this agent has earlier
/// conversations. If it does, the resolved list raises the
/// ConversationPicker so the user chooses resume-or-new; if it does not,
/// nothing is shown and the fresh conversation simply begins. Unlike
/// `/resume`, this prints no "loading" / "none" chatter.
pub fn offer_resume_or_new(&mut self) {
let backend = self.backend.clone();
let agent_id = self.agent_id.clone();
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
let result = backend.list_conversations(&agent_id).await;
let _ = tx.send(result);
});
self.resume_offer = true;
self.convos_rx = Some(rx);
}
fn handle_list_conversations(&mut self) {
let backend = self.backend.clone();
let agent_id = self.agent_id.clone();
@ -959,10 +1029,19 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
// Check for /resume conversation list result → show picker overlay
if let Some(rx) = self.convos_rx.as_mut() {
if let Ok(result) = rx.try_recv() {
// `offer` = this list was an on-entry resume-or-new check, not
// an explicit `/resume`. It stays silent when there's nothing
// to resume, and excludes the just-created empty conversation.
let offer = std::mem::take(&mut self.resume_offer);
match result {
Ok(convos) => {
Ok(mut convos) => {
if offer {
convos.retain(|c| c.id != self.conversation_id);
}
if convos.is_empty() {
self.system_message("No saved conversations.".to_string());
if !offer {
self.system_message("No saved conversations.".to_string());
}
} else {
self.overlay = Overlay::ConversationPicker {
selected: 0,
@ -971,7 +1050,9 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
}
}
Err(e) => {
self.system_message(format!("Failed to list conversations: {}", e));
if !offer {
self.system_message(format!("Failed to list conversations: {}", e));
}
}
}
self.convos_rx = None;
@ -1244,6 +1325,10 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
self.tool_calls_this_turn = 0;
if let Some(conv_id) = self.switch_pending.take() {
self.initiate_switch_load(conv_id);
} else {
// Turn ended — any interjection that arrived too late
// to be drained gets its own fresh turn now.
self.deliver_pending_interjections();
}
return;
}
@ -1260,6 +1345,8 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
self.tool_calls_this_turn = 0;
if let Some(conv_id) = self.switch_pending.take() {
self.initiate_switch_load(conv_id);
} else {
self.deliver_pending_interjections();
}
}
@ -2803,7 +2890,7 @@ fn draw_overlay(f: &mut Frame, state: &ChatState, full_area: Rect, input_area: R
let inner_width = (width as usize).saturating_sub(4);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
" Conversations — ↑↓ select · Enter switch · Esc cancel",
" Conversations — ↑↓ select · Enter resume · n new · Esc dismiss",
Style::default().fg(state.palette.agent_dim).add_modifier(Modifier::ITALIC),
)));