From 1ec71062501825a26e18a6ec7b8c027f985525ae Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 13:54:11 -0700 Subject: [PATCH 01/24] feat(tui): mode, status, session and file pickers navigate on the shared vocabulary (#6290 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue's sequencing step 2 — "the remaining pickers" — with `list_nav` as the single movement vocabulary (fleet_detail and provider_picker landed in step 1): - `mode_picker` and `status_picker` drop their hand-rolled up/down handling and gain Home/End and PageUp/PageDown through `list_nav::apply` (Prev/Next wrap; paging and Home/End clamp). `j`/`k` keep working: both are surfaces with no text input, so `motion()` applies. - `session_picker` replaces `move_selection`/`page_selection` with the vocabulary. Shift+PageUp/PageDown still scroll the history preview — claimed before the vocabulary sees the bare keys. - `file_picker` is a typing surface, so it takes the typing-safe set (`motion_while_typing`): arrows, paging and Home/End, never a letter that would be eaten from the query. Paging now clamps (a paging key asks to travel, not to teleport), matching every other surface. - Each surface consumes only the motions it can act on, so the horizontal motions stay unclaimed on these single-column lists. Tests: picker filter "304 passed; 0 failed"; new regression `home_end_and_letter_aliases_come_from_the_shared_vocabulary` on the session picker; clippy (CI's exact invocation, tui lib) clean. --- crates/tui/src/tui/file_picker.rs | 43 ++++---- crates/tui/src/tui/session_picker.rs | 114 +++++++++++++--------- crates/tui/src/tui/views/mode_picker.rs | 42 +++++--- crates/tui/src/tui/views/status_picker.rs | 51 +++++----- 4 files changed, 141 insertions(+), 109 deletions(-) diff --git a/crates/tui/src/tui/file_picker.rs b/crates/tui/src/tui/file_picker.rs index a9a97d81ec..456b6517aa 100644 --- a/crates/tui/src/tui/file_picker.rs +++ b/crates/tui/src/tui/file_picker.rs @@ -482,12 +482,22 @@ impl FilePickerView { } } - fn move_selection(&mut self, delta: isize) { + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. This is a typing surface, so only the + /// typing-safe vocabulary applies — no letter alias may eat a query + /// character. `Prev`/`Next` wrap; paging and Home/End clamp. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { if self.filtered.is_empty() { - return; + return false; } - self.selected = crate::tui::list_nav::wrap_index(self.selected, self.filtered.len(), delta); + let Some(next) = + crate::tui::list_nav::apply(self.selected, self.filtered.len(), VISIBLE_ROWS, motion) + else { + return false; + }; + self.selected = next; self.adjust_scroll(); + true } fn selected_path(&self) -> Option<&str> { @@ -527,6 +537,13 @@ impl ModalView for FilePickerView { } fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + // Movement keys come from the shared vocabulary (#6290), typing-safe + // set only. This match owns the filter's own keys. + if let Some(motion) = crate::tui::list_nav::motion_while_typing(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Esc => ViewAction::Close, KeyCode::Enter => { @@ -536,22 +553,6 @@ impl ModalView for FilePickerView { } ViewAction::Close } - KeyCode::Up => { - self.move_selection(-1); - ViewAction::None - } - KeyCode::Down => { - self.move_selection(1); - ViewAction::None - } - KeyCode::PageUp => { - self.move_selection(-(VISIBLE_ROWS as isize)); - ViewAction::None - } - KeyCode::PageDown => { - self.move_selection(VISIBLE_ROWS as isize); - ViewAction::None - } KeyCode::Backspace => { self.query.pop(); self.selected = 0; @@ -584,11 +585,11 @@ impl ModalView for FilePickerView { fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { match mouse.kind { MouseEventKind::ScrollUp => { - self.move_selection(-1); + self.apply_motion(crate::tui::list_nav::Motion::Prev); ViewAction::None } MouseEventKind::ScrollDown => { - self.move_selection(1); + self.apply_motion(crate::tui::list_nav::Motion::Next); ViewAction::None } MouseEventKind::Down(MouseButton::Left) => { diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 4008c9b1a8..1753e6cbdd 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -315,28 +315,24 @@ impl SessionPickerView { self.refresh_preview(); } - fn move_selection(&mut self, delta: isize) { - self.selected = crate::tui::list_nav::wrap_index(self.selected, self.filtered.len(), delta); - self.ensure_selected_visible(); - self.refresh_preview(); - } - - /// Page the session list by one viewport (#6014). Clamped, not wrapped: - /// PgDn near the end lands on the last row, not back at the top. - fn page_selection(&mut self, direction: isize) { + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. `Prev`/`Next` wrap (existing behavior); + /// paging and Home/End clamp — a paging key asks to travel, not to + /// teleport. The horizontal axis has nowhere to go on this surface. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { if self.filtered.is_empty() { - return; + return false; } let page = self.list_visible_rows.get().max(1); - self.selected = if direction.is_negative() { - self.selected.saturating_sub(page) - } else { - self.selected - .saturating_add(page) - .min(self.filtered.len() - 1) + let Some(next) = + crate::tui::list_nav::apply(self.selected, self.filtered.len(), page, motion) + else { + return false; }; + self.selected = next; self.ensure_selected_visible(); self.refresh_preview(); + true } fn select_visible_shortcut(&mut self, c: char) -> bool { @@ -650,8 +646,12 @@ impl ModalView for SessionPickerView { fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { match mouse.kind { - MouseEventKind::ScrollUp => self.move_selection(-1), - MouseEventKind::ScrollDown => self.move_selection(1), + MouseEventKind::ScrollUp => { + self.apply_motion(crate::tui::list_nav::Motion::Prev); + } + MouseEventKind::ScrollDown => { + self.apply_motion(crate::tui::list_nav::Motion::Next); + } MouseEventKind::Down(MouseButton::Left) => { let clicked = self .last_row_hitboxes @@ -747,37 +747,33 @@ impl ModalView for SessionPickerView { } } + // Shift-modified paging belongs to the preview pane (#6014) and must + // be claimed before the shared vocabulary sees the bare keys. + if key.modifiers.contains(KeyModifiers::SHIFT) { + match key.code { + KeyCode::PageUp => { + let rows = self.history_visible_rows.get().max(1); + self.scroll_history(-(rows as isize)); + return ViewAction::None; + } + KeyCode::PageDown => { + let rows = self.history_visible_rows.get().max(1); + self.scroll_history(rows as isize); + return ViewAction::None; + } + _ => {} + } + } + // Movement keys come from the shared vocabulary (#6290); this match + // owns only the picker's own verbs. + if let Some(motion) = crate::tui::list_nav::motion(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } + match key.code { KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, - KeyCode::Up | KeyCode::Char('k') => { - self.move_selection(-1); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_selection(1); - ViewAction::None - } - // PgUp/PgDn page the session list — the picker's primary - // navigation target (#6014). The history preview keeps keyboard - // paging on Shift+PgUp/PgDn. - KeyCode::PageUp if key.modifiers.contains(KeyModifiers::SHIFT) => { - let rows = self.history_visible_rows.get().max(1); - self.scroll_history(-(rows as isize)); - ViewAction::None - } - KeyCode::PageDown if key.modifiers.contains(KeyModifiers::SHIFT) => { - let rows = self.history_visible_rows.get().max(1); - self.scroll_history(rows as isize); - ViewAction::None - } - KeyCode::PageUp => { - self.page_selection(-1); - ViewAction::None - } - KeyCode::PageDown => { - self.page_selection(1); - ViewAction::None - } KeyCode::Char('/') => { self.enter_search(); ViewAction::None @@ -1706,6 +1702,30 @@ mod tests { assert_eq!(view.selected, 11); } + /// #6290 step 2: the picker navigates on the shared `list_nav` vocabulary, + /// so Home/End exist here and the `j`/`k` aliases keep working — the two + /// behaviors this surface previously hand-rolled (or lacked). + #[test] + fn home_end_and_letter_aliases_come_from_the_shared_vocabulary() { + let sessions: Vec = (0..20) + .map(|i| test_session(i, &format!("work {i}"))) + .collect(); + let mut view = picker_with(sessions, None); + for session in &view.sessions { + view.preview_cache + .insert(session.id.clone(), vec!["preview".to_string()]); + } + + view.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + assert_eq!(view.selected, 19, "End lands on the last row"); + view.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + assert_eq!(view.selected, 0, "Home lands on the first row"); + view.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert_eq!(view.selected, 1, "`j` is still the Down alias"); + view.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(view.selected, 0, "`k` is still the Up alias"); + } + #[test] fn shift_page_keys_still_scroll_the_history_preview() { let mut view = picker_with(vec![test_session(1, "only session")], None); diff --git a/crates/tui/src/tui/views/mode_picker.rs b/crates/tui/src/tui/views/mode_picker.rs index 287c1ac0b7..c0c35e8cd5 100644 --- a/crates/tui/src/tui/views/mode_picker.rs +++ b/crates/tui/src/tui/views/mode_picker.rs @@ -53,12 +53,21 @@ impl ModePickerView { .unwrap_or(AppMode::Agent) } - fn move_up(&mut self) { - self.cursor = crate::tui::list_nav::wrap_index(self.cursor, VISIBLE_MODES.len(), -1); - } - - fn move_down(&mut self) { - self.cursor = crate::tui::list_nav::wrap_index(self.cursor, VISIBLE_MODES.len(), 1); + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Vertical motions cover the whole list — + /// Home/End and the page keys included. The horizontal axis has nowhere + /// to go on a single-column surface, so those motions are not consumed. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + let Some(next) = crate::tui::list_nav::apply( + self.cursor, + VISIBLE_MODES.len(), + VISIBLE_MODES.len(), + motion, + ) else { + return false; + }; + self.cursor = next; + true } fn select_by_number(&mut self, number: char) -> Option { @@ -82,19 +91,20 @@ impl ModalView for ModePickerView { } fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + // Movement keys come from the shared vocabulary (#6290), so + // `j`/`k`, Home/End and the page keys mean here exactly what they + // mean on every other list. This match owns only the keys the + // vocabulary does not claim. + if let Some(motion) = crate::tui::list_nav::motion(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Esc => ViewAction::Close, KeyCode::Enter => ViewAction::EmitAndClose(ViewEvent::ModeSelected { mode: self.selected_mode(), }), - KeyCode::Up | KeyCode::Char('k') => { - self.move_up(); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_down(); - ViewAction::None - } KeyCode::Char(number) => self.select_by_number(number).unwrap_or(ViewAction::None), _ => ViewAction::None, } @@ -103,11 +113,11 @@ impl ModalView for ModePickerView { fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { match mouse.kind { MouseEventKind::ScrollUp => { - self.move_up(); + self.apply_motion(crate::tui::list_nav::Motion::Prev); ViewAction::None } MouseEventKind::ScrollDown => { - self.move_down(); + self.apply_motion(crate::tui::list_nav::Motion::Next); ViewAction::None } MouseEventKind::Down(MouseButton::Left) => { diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index de9c9e5f31..e3894e1194 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -76,22 +76,22 @@ impl StatusPickerView { .collect() } - fn move_up(&mut self) { - if self.rows.is_empty() { - return; - } - if self.cursor == 0 { - self.cursor = self.rows.len() - 1; - } else { - self.cursor -= 1; - } - } - - fn move_down(&mut self) { - if self.rows.is_empty() { - return; - } - self.cursor = (self.cursor + 1) % self.rows.len(); + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Vertical motions wrap at the ends for + /// Prev/Next and clamp for paging and Home/End; the horizontal axis does + /// not exist on this single-column checklist. The checklist fits on one + /// screen, so a page is the whole list. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + let Some(next) = crate::tui::list_nav::apply( + self.cursor, + self.rows.len(), + self.rows.len().max(1), + motion, + ) else { + return false; + }; + self.cursor = next; + true } fn toggle_current(&mut self) { @@ -132,6 +132,14 @@ impl ModalView for StatusPickerView { } fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + // Movement keys come from the shared vocabulary (#6290): `j`/`k`, + // Home/End and the page keys mean here what they mean on every other + // list. This match owns only the checklist's own verbs. + if let Some(motion) = crate::tui::list_nav::motion(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Esc => { // Roll the live preview back to the snapshot so Esc means @@ -139,14 +147,6 @@ impl ModalView for StatusPickerView { ViewAction::EmitAndClose(self.revert_event()) } KeyCode::Enter => ViewAction::EmitAndClose(self.final_event()), - KeyCode::Up | KeyCode::Char('k') => { - self.move_up(); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_down(); - ViewAction::None - } KeyCode::Char(' ') | KeyCode::Char('x') | KeyCode::Char('X') => { self.toggle_current(); ViewAction::Emit(self.live_preview_event()) @@ -338,7 +338,8 @@ mod tests { let active = StatusItem::default_footer(); let mut view = StatusPickerView::new(&active, ApiProvider::Deepseek, Locale::En); view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - view.move_down(); + // Move through the shared vocabulary, the same path a key takes. + view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); match action { From 341151fff11f54f23f9d71e5fd1dea95606e5b8e Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 13:54:14 -0700 Subject: [PATCH 02/24] test(tui): provider health test records against the row's own route model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `provider_health_requires_observed_success_and_keeps_failure_reason` recorded its check against the literal "deepseek-v4-pro". Readiness is keyed by the full route identity (provider + endpoint + auth class + model), and the DeepSeek default route has since moved to the Flash line, so the recorded check no longer matched the row and the test failed on main. The test now reads `row.default_route.logical_model` and records against the row's own route, which keeps the intent (observed success flips SavedUnchecked → Ready) without pinning a model literal that the default route can move away from. Evidence: `provider_health_requires_observed_success` => "1 passed; 0 failed"; full picker filter green afterwards. --- crates/tui/src/tui/provider_picker.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index b4e9f23ec6..77e67d5181 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -5470,9 +5470,14 @@ mod tests { .find(|row| row.provider == ApiProvider::Deepseek) .expect("DeepSeek row"); assert_eq!(row.readiness, ResolvedProviderReadiness::SavedUnchecked); + // Readiness is per route identity (provider + endpoint + auth class + + // model), so record the check against the row's own default-route + // model. A hardcoded model literal goes stale whenever the provider's + // default route moves — which is exactly what happened here. + let row_model = row.default_route.logical_model.clone(); let mut health = ProviderReadinessSnapshot::default(); - health.record_success(&config, ApiProvider::Deepseek, "deepseek-v4-pro"); + health.record_success(&config, ApiProvider::Deepseek, &row_model); let ready = ProviderPickerView::new(ApiProvider::Deepseek, &config).with_provider_health(&health); assert_eq!( @@ -5488,7 +5493,7 @@ mod tests { health.record_failure_message( &config, ApiProvider::Deepseek, - "deepseek-v4-pro", + &row_model, crate::error_taxonomy::ErrorCategory::Authentication, "credential rejected", ); From 751ea338f2a85a60d877d76224020e8ca3033b03 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 14:00:33 -0700 Subject: [PATCH 03/24] fix(plugins): a marketplace catalog is its source, not the name an add happened to use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/plugin marketplace add ` refused an id that already existed, so re-adding the codewhale marketplace to refresh it was impossible once the name was taken — and the workaround is visible in this machine's own state: two snapshots of `codewhale-plugin-marketplace/marketplace.json`, the second one hand-named `cw2`, both stale, sitting side by side. This is the identity half of the grokbuild model (`xai-grok-plugin-marketplace`'s `MarketplaceSource::identity`: the URL or expanded path is the stable identity; the display name is not): - `add` now keys on the canonical source document: re-adding the same source updates that catalog in place and renames it to the requested id when the name differs. A *different* source under a taken name still refuses, so a name never silently re-points. - `load` collapses catalogs that share one source, keeping the entry named after the document itself (else the most recently added). Older states self-heal on the next read; the projection persists on the next write. What this does not do: it does not refresh a catalog when its local document changed on disk — that is the next slice (re-parse on add is a refresh today). And it does not yet split catalog *entry kinds* (plugin vs skill), which is what makes the skill-name suppression deletable. Evidence: marketplace filter "44 passed; 0 failed", including the un-staled command roundtrip (same-source re-add succeeds; different-source clash is refused) and new store tests for rename-in-place, clash refusal, and the two-name collapse; clippy (CI's invocation, tui lib) clean. --- .../groups/plugins/marketplace_tests.rs | 32 ++- crates/tui/src/plugins/marketplace/store.rs | 186 +++++++++++++++++- 2 files changed, 212 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/commands/groups/plugins/marketplace_tests.rs b/crates/tui/src/commands/groups/plugins/marketplace_tests.rs index e938ecaf7d..b156d68a99 100644 --- a/crates/tui/src/commands/groups/plugins/marketplace_tests.rs +++ b/crates/tui/src/commands/groups/plugins/marketplace_tests.rs @@ -198,13 +198,39 @@ fn marketplace_add_list_show_remove_roundtrip() { "list/show must not rewrite marketplace state" ); - // duplicate name is refused - let dup = plugins_with_kimi_home_override( + // Re-adding the same source is a refresh, not a duplicate: a catalog is + // keyed by its document, so the second add updates it in place. (The old + // refusal here is what produced a second snapshot of one marketplace + // under a hand-made name.) + let refreshed = plugins_with_kimi_home_override( &mut app, Some(&format!("marketplace add kimi {}", catalog_path.display())), None, ); - assert!(dup.is_error); + assert!(!refreshed.is_error, "{:?}", refreshed.message); + // A *different* source under the same name is still refused. + let other_catalog = catalogs.join("other-marketplace.json"); + fs::write( + &other_catalog, + serde_json::to_string_pretty(&serde_json::json!({ + "version": "2", + "plugins": [ + { + "id": "other-bundle", + "source": "./other-bundle", + "displayName": "Other Bundle" + } + ] + })) + .unwrap(), + ) + .unwrap(); + let clash = plugins_with_kimi_home_override( + &mut app, + Some(&format!("marketplace add kimi {}", other_catalog.display())), + None, + ); + assert!(clash.is_error, "{:?}", clash.message); let removed = plugins_with_kimi_home_override(&mut app, Some("marketplace remove kimi"), None); assert!(!removed.is_error, "{:?}", removed.message); diff --git a/crates/tui/src/plugins/marketplace/store.rs b/crates/tui/src/plugins/marketplace/store.rs index 21d4865679..bf36c278fc 100644 --- a/crates/tui/src/plugins/marketplace/store.rs +++ b/crates/tui/src/plugins/marketplace/store.rs @@ -130,23 +130,46 @@ impl MarketplaceStore { self.path.display() )); } - Ok(state) + Ok(collapse_same_source_catalogs(state)) } - /// Insert a catalog under `id`, refusing to replace an existing entry. + /// Insert a catalog under `id`, keyed by its source document. + /// + /// A catalog *is* its source: re-adding the same document updates that + /// catalog in place — and renames it to the requested `id` when the name + /// differs — instead of colliding on whatever name the previous add chose. + /// That collision is what produced a second, stale snapshot of the + /// codewhale marketplace under a hand-made name ("cw2") sitting beside + /// `codewhale`, two copies of one source. An existing *different* source + /// under `id` still refuses, so a name never silently re-points. pub fn add( &self, id: &MarketplaceCatalogId, entry: StoredMarketplaceCatalog, ) -> Result<(), String> { self.mutate(|state| { - if state.catalogs.contains_key(id.as_str()) { + let source = canonical_source_path(&entry.source_path); + if let Some(existing) = state.catalogs.get(id.as_str()) + && canonical_source_path(&existing.source_path) != source + { return Err(format!( "a marketplace named `{}` already exists; /plugin marketplace remove {} first", id.as_str(), id.as_str() )); } + let duplicates: Vec = state + .catalogs + .iter() + .filter(|(key, catalog)| { + key.as_str() != id.as_str() + && canonical_source_path(&catalog.source_path) == source + }) + .map(|(key, _)| key.clone()) + .collect(); + for key in duplicates { + state.catalogs.remove(&key); + } state.catalogs.insert(id.as_str().to_string(), entry); Ok(()) }) @@ -184,6 +207,66 @@ impl MarketplaceStore { } } +/// Canonical form of a catalog's source document, for source-identity +/// comparison. This is the identity the store keys updates and duplicate +/// detection on (the marketplace sibling of grokbuild's +/// `MarketplaceSource::identity`). Falls back to the stored string when the +/// path is not readable as a file — a GitHub URL, or a document that has +/// since moved away. +fn canonical_source_path(path: &str) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path)) +} + +/// Collapse catalogs that point at the same source document. +/// +/// Older states can hold one marketplace twice under two names (that is what +/// `"cw2"` was: a second snapshot of the same document, taken only because the +/// first name was already in use). Every surface should see one catalog per +/// source. The survivor is the entry whose key is the document's own declared +/// name when one exists, otherwise the most recently added; the projection is +/// in-memory, and the next write persists it. +fn collapse_same_source_catalogs(mut state: MarketplaceState) -> MarketplaceState { + let mut groups: BTreeMap> = BTreeMap::new(); + for (key, catalog) in &state.catalogs { + groups + .entry(canonical_source_path(&catalog.source_path)) + .or_default() + .push(key.clone()); + } + for keys in groups.values() { + if keys.len() < 2 { + continue; + } + let named_after_document = keys.iter().find(|key| { + state + .catalogs + .get(key.as_str()) + .is_some_and(|catalog| catalog.catalog.name == key.as_str()) + }); + let keeper = named_after_document.cloned().or_else(|| { + keys.iter() + .max_by(|left, right| { + let added = |key: &String| { + state + .catalogs + .get(key.as_str()) + .map(|catalog| catalog.added_at.as_str()) + .unwrap_or_default() + }; + added(left).cmp(added(right)) + }) + .cloned() + }); + let Some(keeper) = keeper else { continue }; + for key in keys { + if *key != keeper { + state.catalogs.remove(key); + } + } + } + state +} + fn first_party_catalog() -> Result { #[derive(Deserialize)] struct Snapshot { @@ -275,4 +358,101 @@ mod tests { assert!(store.load().is_err()); assert_eq!(fs::read_to_string(store.path()).unwrap(), "broken"); } + + fn catalog_from_source(source: &Path) -> StoredMarketplaceCatalog { + let mut catalog = first_party_catalog().unwrap(); + catalog.source_path = source.display().to_string(); + catalog + } + + /// `add` keys on the source document, not the name the previous add used: + /// re-adding the same marketplace under its real name replaces the + /// hand-made duplicate instead of colliding or leaving both behind. + #[test] + fn re_adding_the_same_source_renames_in_place() { + let root = tempfile::tempdir().unwrap(); + let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap(); + let document = root.path().join("marketplace.json"); + fs::write(&document, "{}").unwrap(); + + store + .add( + &MarketplaceCatalogId::new("cw2"), + catalog_from_source(&document), + ) + .unwrap(); + store + .add( + &MarketplaceCatalogId::new("codewhale"), + catalog_from_source(&document), + ) + .unwrap(); + + let state = store.load().unwrap(); + assert!(state.get("cw2").is_none(), "the duplicate name is gone"); + assert_eq!( + state.get("codewhale").unwrap().source_path, + document.display().to_string() + ); + } + + /// A name is never silently re-pointed at a different source. + #[test] + fn a_name_never_re_points_at_a_different_source() { + let root = tempfile::tempdir().unwrap(); + let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap(); + let first = root.path().join("first.json"); + let second = root.path().join("second.json"); + fs::write(&first, "{}").unwrap(); + fs::write(&second, "{}").unwrap(); + + store + .add( + &MarketplaceCatalogId::new("codewhale"), + catalog_from_source(&first), + ) + .unwrap(); + let refused = store + .add( + &MarketplaceCatalogId::new("codewhale"), + catalog_from_source(&second), + ) + .expect_err("a different source under a taken name must refuse"); + assert!(refused.contains("already exists"), "{refused}"); + assert_eq!( + store.load().unwrap().get("codewhale").unwrap().source_path, + first.display().to_string() + ); + } + + /// A state written before source identity was keyed can hold one source + /// twice under two names; the projection collapses it to the entry named + /// after the document itself. + #[test] + fn load_collapses_one_source_stored_under_two_names() { + let root = tempfile::tempdir().unwrap(); + let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap(); + let document = root.path().join("marketplace.json"); + fs::write(&document, "{}").unwrap(); + + let mut state = MarketplaceState::default(); + for (key, added_at) in [ + ("codewhale", "2026-09-02T00:00:00Z"), + ("cw2", "2026-09-16T00:00:00Z"), + ] { + let mut catalog = catalog_from_source(&document); + catalog.added_at = added_at.to_string(); + state.catalogs.insert(key.to_string(), catalog); + } + ensure_private_plugin_state_directory(store.path().parent().unwrap()).unwrap(); + fs::write(store.path(), serde_json::to_string(&state).unwrap()).unwrap(); + + let loaded = store.load().unwrap(); + assert!(loaded.get("cw2").is_none()); + assert!(loaded.get("codewhale").is_some()); + assert_eq!( + loaded.get("codewhale").unwrap().source_path, + document.display().to_string() + ); + } } From c191943cda797eafde58dc8b6fb095e66582c383 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 14:09:13 -0700 Subject: [PATCH 04/24] refactor(plugins): catalog entries carry their kind; skills never enter the plugin pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin suggestion pool mixed three things under one word: plugins, the first-party skills the Codewhale marketplace lists as their own entries (each arriving as a `cw2:*` "plugin"), and imported third-party marketplace plugins. The observed symptom: typing "test" recommended `cw2:test` — the test *skill* — and the mitigation was #6274's skill-name suppression, a snapshot-based name check that missed mid-session changes and never applied to the composer toast. This is the structural half of the grokbuild model (`xai-grok-plugin-marketplace`: a catalog entry declares what it is — there via inventory flags on an always-a-plugin entry, here as an explicit `MarketplaceEntryKind` because our catalogs list skills directly): - `MarketplaceCandidate` gains `kind` (Plugin | Skill), defaulted so stored snapshots keep their meaning. The Codewhale parser derives it from the entry's source path (`skills/…`) or an explicit `"kind"` field; the kimi/claude/codex parsers declare Plugin (their formats model plugins only). - Both suggestion pools — `idle_and_catalog_keyword_matches` (toast + `` fragment) and `recommend_plugins_for_task` (`/plugin suggest`) — admit only Plugin-kind entries. - #6274's skill-name suppression is deleted along with its Engine-side snapshot plumbing: with kinds split there is no twin to suppress. What this does not do: the matcher keeps its current scoring (grokbuild-style declared keywords/domains with word boundaries is a separate slice), and an already-stored stale catalog snapshot keeps its entries until it is re-added — the source-identity collapse gives it a single catalog. Evidence: marketplace "44 passed", recommend "27 passed" including the new differential test (same entry as Skill is excluded from the pool and produces no fragment; as Plugin it matches), plugin_suggestions "8 passed; 0 failed"; clippy (CI's invocation, tui lib) clean. --- crates/tui/src/core/engine.rs | 26 +---- .../src/plugins/marketplace/parsers/claude.rs | 5 +- .../plugins/marketplace/parsers/codewhale.rs | 34 +++++- .../src/plugins/marketplace/parsers/codex.rs | 5 +- .../src/plugins/marketplace/parsers/kimi.rs | 5 +- crates/tui/src/plugins/marketplace/types.rs | 24 +++++ crates/tui/src/plugins/recommend.rs | 102 ++++++++++-------- 7 files changed, 121 insertions(+), 80 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 3aef386c52..14ab973acd 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1709,28 +1709,6 @@ impl Engine { // `run_turn` restarts it per turn; this initial value only matters // for hosts that inspect the engine before the first turn. let turn_wall_clock_budget = config.turn_wall_clock; - // Skill-name snapshot for the plugin-suggestion gate (#6274): the - // SAME catalogue the system prompt indexes (prompts.rs skills block — - // workspace roots + configured skills_dir + plugin-sourced skills), - // so suppression sees everything the session actually has. - let gate_skill_names: std::collections::BTreeSet = - crate::skills::discover_for_workspace_and_dir_with_mode_and_plugins( - &config.workspace, - &config.skills_dir, - crate::skills::SkillDiscoveryMode::from_codewhale_only( - config.skills_scan_codewhale_only, - ), - Some(plugin_registry.as_ref()), - ) - .list() - .iter() - .flat_map(|skill| { - std::iter::once(skill.name.clone()).chain(skill.aliases.iter().cloned()) - }) - .map(|name| name.trim().to_ascii_lowercase()) - .filter(|name| !name.is_empty()) - .collect(); - let engine = Engine { config, api_config: api_config.clone(), @@ -1757,9 +1735,7 @@ impl Engine { mcp_event_generation: 0, plugin_registry, recommended_plugin_gate: StdMutex::new( - crate::plugins::recommend::RecommendedPluginGate::with_skill_names( - gate_skill_names, - ), + crate::plugins::recommend::RecommendedPluginGate::default(), ), api_provider, api_provider_identity, diff --git a/crates/tui/src/plugins/marketplace/parsers/claude.rs b/crates/tui/src/plugins/marketplace/parsers/claude.rs index e2939ea6e9..2a24d325ba 100644 --- a/crates/tui/src/plugins/marketplace/parsers/claude.rs +++ b/crates/tui/src/plugins/marketplace/parsers/claude.rs @@ -28,8 +28,8 @@ use crate::plugins::manifest::PluginInventory; use super::super::types::{ CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, - MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceFormat, MarketplaceInstallPlan, - MarketplaceSourceSpec, + MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat, + MarketplaceInstallPlan, MarketplaceSourceSpec, }; use super::{MarketplaceDocument, str_array_field, str_field, unknown_fields_warning}; @@ -326,6 +326,7 @@ fn parse_claude_entry( Some(MarketplaceCandidate { id: MarketplaceCandidateId::new(catalog_id, &name), catalog_id: catalog_id.clone(), + kind: MarketplaceEntryKind::Plugin, icon: None, name, display_name, diff --git a/crates/tui/src/plugins/marketplace/parsers/codewhale.rs b/crates/tui/src/plugins/marketplace/parsers/codewhale.rs index 4aeb8ca6d9..0b10193bc6 100644 --- a/crates/tui/src/plugins/marketplace/parsers/codewhale.rs +++ b/crates/tui/src/plugins/marketplace/parsers/codewhale.rs @@ -22,8 +22,8 @@ use crate::plugins::install::PluginInstallSource; use super::super::types::{ CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, - MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceFormat, MarketplaceInstallPlan, - MarketplaceSourceSpec, + MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat, + MarketplaceInstallPlan, MarketplaceSourceSpec, }; use super::{MarketplaceDocument, str_field, unknown_fields_warning}; @@ -31,6 +31,7 @@ const TOP_LEVEL_FIELDS: &[&str] = &["name", "description", "version", "plugins"] const ENTRY_FIELDS: &[&str] = &[ "name", "source", + "kind", "description", "version", "homepage", @@ -287,9 +288,38 @@ fn parse_codewhale_entry( Some(index), )); } + // What this entry is. The Codewhale marketplace keeps plugins and + // skills in separate top-level directories, so the source path is the + // signal; a document may also declare `kind` explicitly. A skill entry + // stays installable, but it is not a plugin and is never suggested as + // one (#6290 rework). + let kind = match obj.get("kind").and_then(Value::as_str) { + Some("skill") => MarketplaceEntryKind::Skill, + Some("plugin") | None => { + let path = source + .strip_prefix("path:") + .unwrap_or(source) + .trim_start_matches("./"); + if path == "skills" || path.starts_with("skills/") { + MarketplaceEntryKind::Skill + } else { + MarketplaceEntryKind::Plugin + } + } + Some(other) => { + entry_diags.push(MarketplaceDiagnostic::warning( + "UNKNOWN_ENTRY_KIND", + format!("unknown entry kind `{other}`; treated as a plugin"), + Some(name.clone()), + Some(index), + )); + MarketplaceEntryKind::Plugin + } + }; Some(MarketplaceCandidate { id: MarketplaceCandidateId::new(catalog_id, &name), catalog_id: catalog_id.clone(), + kind, icon, name, display_name, diff --git a/crates/tui/src/plugins/marketplace/parsers/codex.rs b/crates/tui/src/plugins/marketplace/parsers/codex.rs index b4c1a6a1e6..0530c17a5b 100644 --- a/crates/tui/src/plugins/marketplace/parsers/codex.rs +++ b/crates/tui/src/plugins/marketplace/parsers/codex.rs @@ -36,8 +36,8 @@ use crate::plugins::agent_plugin::{is_standard_plugin_name, slugify_plugin_name} use super::super::types::{ CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, - MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceFormat, MarketplaceInstallPlan, - MarketplaceSourceSpec, + MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat, + MarketplaceInstallPlan, MarketplaceSourceSpec, }; use super::{MarketplaceDocument, str_field, unknown_fields_warning}; @@ -264,6 +264,7 @@ fn parse_codex_entry( Some(MarketplaceCandidate { id: MarketplaceCandidateId::new(catalog_id, &name), catalog_id: catalog_id.clone(), + kind: MarketplaceEntryKind::Plugin, icon: None, name, display_name: None, diff --git a/crates/tui/src/plugins/marketplace/parsers/kimi.rs b/crates/tui/src/plugins/marketplace/parsers/kimi.rs index 0112a99b22..8d7553cbe1 100644 --- a/crates/tui/src/plugins/marketplace/parsers/kimi.rs +++ b/crates/tui/src/plugins/marketplace/parsers/kimi.rs @@ -23,8 +23,8 @@ use crate::plugins::agent_plugin::{is_standard_plugin_name, slugify_plugin_name} use super::super::types::{ CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, - MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceFormat, MarketplaceInstallPlan, - MarketplaceSourceSpec, + MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat, + MarketplaceInstallPlan, MarketplaceSourceSpec, }; use super::{MarketplaceDocument, str_array_field, str_field, unknown_fields_warning}; @@ -232,6 +232,7 @@ fn parse_kimi_entry( Some(MarketplaceCandidate { id: MarketplaceCandidateId::new(catalog_id, &name), catalog_id: catalog_id.clone(), + kind: MarketplaceEntryKind::Plugin, icon: None, name, display_name, diff --git a/crates/tui/src/plugins/marketplace/types.rs b/crates/tui/src/plugins/marketplace/types.rs index fc045aad22..7b9693ac8f 100644 --- a/crates/tui/src/plugins/marketplace/types.rs +++ b/crates/tui/src/plugins/marketplace/types.rs @@ -260,6 +260,26 @@ impl MarketplaceInstallPlan { } } +/// What a catalog entry is. +/// +/// A marketplace carries more than plugins: the Codewhale marketplace lists +/// standalone skills as their own entries, and imported third-party catalogs +/// carry whatever their format allows. The kind is how a surface knows which +/// pool an entry belongs to — in particular, only `Plugin` entries are plugin +/// suggestions. (grokbuild's `MarketplaceEntry` carries the same distinction +/// as inventory flags — `skill_count`, `has_mcp`, … — on an always-a-plugin +/// entry; we keep the kind explicit because our catalogs list skills +/// directly.) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MarketplaceEntryKind { + #[default] + Plugin, + /// A standalone skill (instruction pack). Installable, but never + /// suggested as a plugin. + Skill, +} + /// A normalized catalog entry. Catalog-declared component lists are kept /// for display; the reviewed staged-tree manifest at install time remains /// the only authority on what a bundle actually contains. @@ -267,6 +287,10 @@ impl MarketplaceInstallPlan { pub struct MarketplaceCandidate { pub id: MarketplaceCandidateId, pub catalog_id: MarketplaceCatalogId, + /// What this entry is. Defaults to `Plugin` so stored snapshots and + /// formats that do not declare a kind keep their previous meaning. + #[serde(default)] + pub kind: MarketplaceEntryKind, /// Canonical (Agent Plugins standard) plugin name. pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/tui/src/plugins/recommend.rs b/crates/tui/src/plugins/recommend.rs index ffe3aa6ee4..e91536f737 100644 --- a/crates/tui/src/plugins/recommend.rs +++ b/crates/tui/src/plugins/recommend.rs @@ -190,6 +190,14 @@ pub fn idle_and_catalog_keyword_matches( if candidate.has_errors() { continue; } + // Only plugins are plugin suggestions (#6290 rework): skill entries + // are installable, but this pool feeds the composer toast and the + // `` fragment, so a skill must not be dressed as + // one. This replaces #6274's name suppression, which existed only + // because the catalog mixed the two kinds. + if candidate.kind != crate::plugins::marketplace::types::MarketplaceEntryKind::Plugin { + continue; + } if installed_names.contains(&candidate.name.to_ascii_lowercase()) { continue; } @@ -261,45 +269,23 @@ pub fn match_plugin_for_draft_among( /// Per-Engine gate for the append-only `` fragment. /// -/// A plugin id is suggested at most once per Engine lifetime, and a plugin -/// whose name (or alias) matches a skill in the session's catalogue is never -/// suggested — the skill already covers the domain, so the nudge is noise -/// (#6274). Dismissals continue to be honored through `Settings`. +/// A plugin id is suggested at most once per Engine lifetime, and dismissals +/// are honored through `Settings`. /// -/// Known limitation: the skill-name set is snapshotted once at Engine -/// construction (from the same catalogue the system prompt indexes), so a -/// skill installed mid-session does not suppress its plugin twin until the -/// next Engine starts. +/// Skill-name suppression (#6274) is gone with the #6290 rework: it existed +/// only because skill entries were catalogued as plugins and then had to be +/// suppressed by name — a snapshot-based check that missed mid-session +/// changes and never applied to the composer toast. Entry kinds now keep +/// skills out of the plugin pool entirely (see `MarketplaceEntryKind`). #[derive(Debug, Default)] pub struct RecommendedPluginGate { shown: BTreeSet, - skill_names: BTreeSet, } impl RecommendedPluginGate { - /// Engine constructor input and test seam: suppress exactly these - /// skill names and aliases (case is normalized here, so callers may - /// pass them in any form). - #[must_use] - pub fn with_skill_names(skill_names: BTreeSet) -> Self { - Self { - shown: BTreeSet::new(), - skill_names: skill_names - .into_iter() - .map(|name| name.trim().to_ascii_lowercase()) - .filter(|name| !name.is_empty()) - .collect(), - } - } - - /// True when this plugin may be suggested now: not covered by a - /// catalogue skill and not already suggested in this Engine's lifetime. - /// First admission records the plugin id. - fn admits(&mut self, id: &str, name: &str) -> bool { - let name_key = name.trim().to_ascii_lowercase(); - if !name_key.is_empty() && self.skill_names.contains(&name_key) { - return false; - } + /// True when this plugin may be suggested now: not already suggested in + /// this Engine's lifetime. First admission records the plugin id. + fn admits(&mut self, id: &str) -> bool { self.shown.insert(id.to_string()) } } @@ -323,9 +309,9 @@ pub fn recommended_plugins_user_fragment( marketplace, &settings.dismissed_plugin_suggestions, )?; - // Once per Engine lifetime per plugin id, and never when a local skill - // already covers the plugin's domain (#6274). - if !gate.admits(&matched.id, &matched.name) { + // Once per Engine lifetime per plugin id. Skill exclusion happens a + // layer down: skill-kind entries never enter the plugin pool (#6290). + if !gate.admits(&matched.id) { return None; } let mut listed = vec![matched]; @@ -379,6 +365,9 @@ pub fn recommend_plugins_for_task( if candidate.has_errors() { continue; } + if candidate.kind != crate::plugins::marketplace::types::MarketplaceEntryKind::Plugin { + continue; + } if installed_names.contains(&candidate.name.to_ascii_lowercase()) { continue; } @@ -526,7 +515,7 @@ mod tests { use super::*; use crate::plugins::marketplace::types::{ CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, - MarketplaceCatalogId, MarketplaceInstallPlan, MarketplaceSourceSpec, + MarketplaceCatalogId, MarketplaceEntryKind, MarketplaceInstallPlan, MarketplaceSourceSpec, }; use crate::test_support::{EnvVarGuard, lock_test_env}; use std::fs; @@ -558,6 +547,7 @@ mod tests { MarketplaceCandidate { id: MarketplaceCandidateId::new(&MarketplaceCatalogId::new(catalog), name), catalog_id: MarketplaceCatalogId::new(catalog), + kind: MarketplaceEntryKind::Plugin, name: name.to_string(), display_name: Some(format!("{name} plugin")), icon: None, @@ -752,26 +742,44 @@ mod tests { ); } + /// A skill entry in a catalog is installable but is never a plugin + /// suggestion — the structural replacement for #6274's name suppression. #[test] - fn recommended_plugins_fragment_suppressed_by_local_skill_name() { + fn skill_entries_never_enter_the_plugin_suggestion_pool() { let _lock = lock_test_env(); let root = TempDir::new().unwrap(); let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); - write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]); - let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv() - .registry_for_workspace(root.path()); - - let skills: BTreeSet = ["Supabase".to_string()].into_iter().collect(); - let mut gate = RecommendedPluginGate::with_skill_names(skills); + let registry = crate::plugins::PluginRegistry::empty(root.path()); + let mut skill = marketplace_candidate("cw2", "test", &["test"]); + skill.kind = MarketplaceEntryKind::Skill; + let slice = std::slice::from_ref(&skill); + assert!( + idle_and_catalog_keyword_matches(®istry, slice).is_empty(), + "a skill entry must not be a plugin candidate" + ); assert!( recommended_plugins_user_fragment( - "add supabase auth to login", + "run the test suite", ®istry, - &[], - &mut gate, + slice, + &mut RecommendedPluginGate::default(), ) .is_none(), - "a loaded local skill covering the plugin name must suppress the suggestion (#6274)" + "a skill entry must not produce a fragment" + ); + + // Control: the same entry as a plugin still matches, so the + // exclusion is the kind and not a broken fixture. + skill.kind = MarketplaceEntryKind::Plugin; + assert!( + recommended_plugins_user_fragment( + "run the test suite", + ®istry, + std::slice::from_ref(&skill), + &mut RecommendedPluginGate::default(), + ) + .is_some(), + "the same entry as a plugin still matches" ); } From 4de9e9e281f63c5f5e3500d1e69a3497f4e3b94e Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 14:37:05 -0700 Subject: [PATCH 05/24] refactor(goal): the model decides goals; the host stops guessing from verb lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Founder clarification, 2026-09-09 (docs/design/TUI_DECONSTRUCTION.md): "the harness should let the model decide when a goal, plan, delegation, further investigation, or verification is useful." Operate violated that with three conflicting authorities: `operate_goal_from_prompt` classified instructions with work-verb and chat-opener lists, `CreateGoalTool::description` said goals require an explicit request, and the host promoted a prompt before the model ever ran. The verb list produced exactly the absurdity it invites: "test respond with hello" became a persistent goal because "test" is a verb. - `operate_goal_from_prompt` and its vocabularies are deleted (~8.6 KB with tests). The engine creates a goal only from an explicit user declaration — `/goal` and the natural-language forms `explicit_goal_directive` parses — or when the model calls `create_goal`. No wording is classified. - `create_goal`'s description becomes the one model-facing contract: the model decides when a request is a durable objective, and is told what is not one (a question, a greeting, a one-shot edit, a conversational probe). - The create-refusal report is now always the explicit one: a `/goal` that `GoalState::create` refuses says so instead of being swallowed. - docs/MODES.md (Operate) updated to match; the design doc's "conflicting authorities" note is resolved by this change. Tests updated to the contract, not the old behavior: - `operate_never_promotes_wording_to_a_goal`: ordinary work prompts are ordinary turns in Operate and Work; an explicit declaration still creates the goal. - `operate_contract_is_appended_once_and_an_existing_goal_is_never_replaced` seeds its goal through the explicit declaration. - `operate_model_shell_uses_normal_approval_and_workspace_sandbox` declares its goal explicitly instead of relying on promotion. Evidence: goal filter "148 passed; 0 failed"; operate filter "55 passed; 0 failed"; clippy (CI's invocation, tui lib) clean. --- crates/tui/src/core/engine.rs | 25 +-- crates/tui/src/core/engine/tests.rs | 57 +++---- crates/tui/src/tools/goal.rs | 238 +--------------------------- docs/MODES.md | 2 +- 4 files changed, 36 insertions(+), 286 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 14ab973acd..4fd64ec940 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -41,7 +41,7 @@ use crate::route_runtime::{ }; use crate::tools::goal::{ GoalPauseReason, GoalSnapshot, GoalStatus, SharedGoalState, explicit_goal_directive, - new_shared_goal_state, operate_goal_from_prompt, + new_shared_goal_state, }; use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; @@ -4866,9 +4866,11 @@ impl Engine { // runtime text, recalled memory, handoffs, and pasted multi-line // transcripts cannot create a goal. // - // Operate turns an ordinary work prompt into the goal through this - // same path when the host reports no unfinished goal - // (`operate_goal_from_prompt` owns the "is this real work?" rule). + // Goals are created by the model (`create_goal`) or by this literal + // user declaration; the host never infers one from wording. The + // verb-list promotion that used to turn ordinary Operate prompts into + // goals is gone (docs/design/TUI_DECONSTRUCTION.md — founder + // clarification 2026-09-09: the model decides when a goal is useful). // `GoalState::create` still refuses while an unfinished goal exists, // so a paused or blocked goal is never silently replaced. // @@ -4876,19 +4878,10 @@ impl Engine { // contributor. It adds no new stable-prefix text. let goal_request = if provenance.can_authorize_work() { explicit_goal_directive(&content) - .map(|directive| (directive, true)) - .or_else(|| { - let host_goal_unfinished = - goal_objective.is_some() && goal_status != GoalStatus::Complete; - (mode == AppMode::Operate && !host_goal_unfinished) - .then(|| operate_goal_from_prompt(&content)) - .flatten() - .map(|directive| (directive, false)) - }) } else { None }; - if let Some((directive, explicit)) = goal_request { + if let Some(directive) = goal_request { let result = self .config .goal_state @@ -4910,7 +4903,7 @@ impl Engine { // even when this model would otherwise reply only in prose. let _ = self.tx_event.send(Event::GoalUpdated { snapshot }).await; } - Err(error) if explicit => { + Err(error) => { let _ = self .tx_event .send(Event::status(format!( @@ -4918,8 +4911,6 @@ impl Engine { ))) .await; } - // Operate keeps the unfinished goal it already has. - Err(_) => {} } } diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index bf2c2fb664..5fb787f3d4 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -3375,17 +3375,19 @@ async fn operate_goal_probe(mode: AppMode, prompt: &str) -> (Option, boo } #[tokio::test] -async fn operate_turns_a_work_prompt_into_the_goal_but_work_mode_does_not() { +async fn operate_never_promotes_wording_to_a_goal() { let prompt = "Migrate the settings loader to the new config crate and keep the old keys readable"; + // The verb-list promotion is gone: an ordinary work prompt is an ordinary + // turn in every mode, and the model decides goals through `create_goal` + // (docs/design/TUI_DECONSTRUCTION.md, founder clarification 2026-09-09). let (objective, active, contracts) = operate_goal_probe(AppMode::Operate, prompt).await; assert_eq!( - objective.as_deref(), - Some(prompt), - "Operate must publish the prompt as the goal before the provider call" + objective, None, + "the host must not infer a goal from wording" ); - assert!(active, "Operate goal must be active in engine state"); + assert!(!active); assert_eq!(contracts, 1, "Operate appends its contract exactly once"); let (objective, active, contracts) = operate_goal_probe(AppMode::Agent, prompt).await; @@ -3393,11 +3395,12 @@ async fn operate_turns_a_work_prompt_into_the_goal_but_work_mode_does_not() { assert!(!active); assert_eq!(contracts, 0, "Work never sees the Operate contract"); + // An explicit declaration still creates one, through the same path. let (objective, active, contracts) = - operate_goal_probe(AppMode::Operate, "thanks, looks good").await; - assert_eq!(objective, None, "chat stays chat even in Operate"); - assert!(!active); - assert_eq!(contracts, 1, "the contract is about the mode, not the goal"); + operate_goal_probe(AppMode::Operate, "Please set /goal to ship the release").await; + assert_eq!(objective.as_deref(), Some("ship the release")); + assert!(active, "an explicit declaration must create the goal"); + assert_eq!(contracts, 1); } #[tokio::test] @@ -3502,10 +3505,11 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace }) }; - let first = + let first_objective = "Migrate the settings loader to the new config crate and keep the old keys readable"; + let first = format!("Please set /goal to {first_objective}"); handle - .send(send(first, None, crate::tools::goal::GoalStatus::Active)) + .send(send(&first, None, crate::tools::goal::GoalStatus::Active)) .await .expect("send first Operate turn"); tokio::time::timeout(model_turn_event_timeout(), first_entered.notified()) @@ -3513,7 +3517,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace .expect("first provider request was never entered"); assert_eq!( goal_state.lock().expect("goal lock").objective(), - Some(first) + Some(first_objective) ); handle .send(Op::SetGoalStatus { @@ -3534,7 +3538,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace handle .send(send( "Refactor the provider table so it survives a config reload", - Some(first.to_string()), + Some(first_objective.to_string()), crate::tools::goal::GoalStatus::Paused, )) .await @@ -3554,7 +3558,7 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace "the contract must not repeat on later Operate turns" ); let goal = goal_state.lock().expect("goal lock").snapshot(); - assert_eq!(goal.objective.as_deref(), Some(first)); + assert_eq!(goal.objective.as_deref(), Some(first_objective)); assert_eq!(goal.status, "paused"); handle.send(Op::Shutdown).await.expect("shutdown engine"); @@ -12368,9 +12372,10 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() { "\"finish_reason\":\"stop\"}]}\n\n", "data: [DONE]\n\n", ); - // Operate turns the work prompt into a goal, so after the approved shell - // runs, the model must seal the goal through the same `update_goal` tool - // a live Operate turn uses; only then does the final "done" arrive. + // Operate no longer infers a goal from wording, so this fixture declares + // one explicitly; after the approved shell runs, the model seals it + // through the same `update_goal` tool a live Operate turn uses, and only + // then does the final "done" arrive. let goal_seal_marker = "goal-seal-receipt-0902"; let goal_sse = concat!( "data: {\"id\":\"chatcmpl-operate-goal\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[", @@ -12456,7 +12461,7 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() { handle .send(Op::SendMessage(TurnSpec { max_output_tokens: None, - content: "write the requested local fixture".to_string(), + content: "Please set /goal to write the requested local fixture".to_string(), images: Vec::new(), mode: AppMode::Operate, route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL), @@ -12494,19 +12499,9 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() { Event::ApprovalRequired { id, tool_name, .. } => { saw_approval = true; assert_eq!(tool_name, "Bash"); - // Operate turned this work prompt into the goal; pause it - // before the turn ends so the goal-continuation loop cannot - // queue passes nobody answers in this mock. The goal-complete - // `update_goal` response below stays: it is the receipt a - // real Operate turn seals with. - handle_for_approval - .send(Op::SetGoalStatus { - goal_id: None, - status: crate::tools::goal::GoalStatus::Paused, - clear: false, - }) - .await - .expect("queue goal pause"); + // No goal is created for this prompt: goals are model-decided + // (`create_goal`), so there is no goal-continuation loop to + // pause in this mock. handle_for_approval .approve_tool_call(id) .await diff --git a/crates/tui/src/tools/goal.rs b/crates/tui/src/tools/goal.rs index f42e05ce80..56f7d062b0 100644 --- a/crates/tui/src/tools/goal.rs +++ b/crates/tui/src/tools/goal.rs @@ -154,149 +154,6 @@ fn normalize_explicit_goal_objective(raw: &str) -> Option { (!objective.is_empty()).then(|| objective.to_string()) } -/// Operate's automatic goal: Operate turns an ordinary work prompt into the -/// session goal when no unfinished goal exists (docs/MODES.md, "Operate loop"). -/// It feeds the same `GoalState::create` path as [`explicit_goal_directive`]; -/// an explicit `/goal` declaration always wins, and Plan and Work never call -/// this. -/// -/// Only a direct work instruction is considered for automatic persistence. -/// Prompt length alone never authorizes a goal: questions and conversational -/// followups and requests declining a goal remain ordinary turns. Ambiguous requests still run normally; -/// the user can use `/goal` to explicitly request persistent work. -#[must_use] -pub fn operate_goal_from_prompt(input: &str) -> Option { - let input = input.trim(); - if input.starts_with(['"', '\'', '`', '>']) { - return None; - } - // Mode defaults cannot override a user's request to avoid persistence. - // Keep this conservative: declining an automatic goal still runs the task. - let lower = input.to_lowercase().replace('’', "'"); - if [ - "no goal", - "without a goal", - "without goal", - "不要创建目标", - "不要设置目标", - ] - .iter() - .any(|phrase| lower.contains(phrase)) - || lower - .split(['.', '!', '?', ';', '。', '!', '?', ';']) - .any(|clause| { - ["do not ", "don't ", "never ", "without "] - .iter() - .filter_map(|negation| clause.find(negation)) - .any(|start| { - // A negated list carries through commas: "do not edit - // files, create a goal, or start another tool". - let words: Vec<_> = clause[start..] - .split(|c: char| !c.is_alphanumeric()) - .filter(|word| !word.is_empty()) - .collect(); - words.iter().any(|word| matches!(*word, "goal" | "goals")) - && words.iter().any(|word| { - matches!( - *word, - "create" - | "creating" - | "set" - | "setting" - | "start" - | "starting" - | "track" - | "tracking" - | "use" - | "using" - ) - }) - }) - }) - { - return None; - } - let words: Vec<&str> = input.split_whitespace().collect(); - if words.len() < 3 { - return None; - } - let normalize = |word: &str| { - word.trim_matches(|c: char| !c.is_alphanumeric()) - .to_ascii_lowercase() - }; - let head = words - .iter() - .map(|word| normalize(word)) - .find(|word| word != "please") - .unwrap_or_default(); - if OPERATE_CHAT_OPENERS.contains(&head.as_str()) { - return None; - } - let first_line = input.lines().find(|line| !line.trim().is_empty())?.trim(); - if first_line.ends_with(['?', '?']) || input.ends_with(['?', '?']) { - return None; - } - if !OPERATE_WORK_VERBS.contains(&head.as_str()) { - return None; - } - let mut objective = words.join(" "); - if objective.chars().count() > OPERATE_GOAL_MAX_OBJECTIVE_CHARS { - objective = objective - .chars() - .take(OPERATE_GOAL_MAX_OBJECTIVE_CHARS) - .collect::() - .trim_end() - .to_string(); - objective.push('…'); - } - Some(ExplicitGoalDirective { objective }) -} - -const OPERATE_GOAL_MAX_OBJECTIVE_CHARS: usize = 600; -const OPERATE_CHAT_OPENERS: &[&str] = &[ - "hi", "hello", "hey", "thanks", "thank", "ok", "okay", "yes", "no", "sure", "great", "cool", - "nice", "lol", -]; -const OPERATE_WORK_VERBS: &[&str] = &[ - "add", - "build", - "change", - "clean", - "convert", - "create", - "debug", - "deploy", - "design", - "document", - "extract", - "finish", - "fix", - "implement", - "improve", - "integrate", - "investigate", - "make", - "migrate", - "move", - "optimize", - "port", - "refactor", - "remove", - "rename", - "replace", - "resolve", - "rewrite", - "run", - "ship", - "split", - "test", - "update", - "upgrade", - "verify", - "wire", - "write", -]; - /// Runtime status for a goal. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum GoalStatus { @@ -1129,7 +986,7 @@ impl ToolSpec for CreateGoalTool { } fn description(&self) -> &'static str { - "Create the session's one persistent goal: a completion objective Codewhale keeps working toward across turns until it is verified complete, blocked, or the user stops it. Call this only when the user explicitly asks to use `/goal`, make an objective the goal, or otherwise explicitly requests persistent goal tracking. When the request is explicit, call `create_goal` before doing the rest of the work; acknowledging it in prose is not sufficient. Never infer a goal from an ordinary task, its apparent length, a question, or a one-file edit. Keep the user's full objective, not a shortened one-turn version. Set token_budget only when the user explicitly provides one. Creating a goal shows the user a one-line receipt (they can /goal pause or /goal clear); do not also ask for confirmation. Only one unfinished goal exists at a time: complete or clear it before creating another." + "Create the session's one persistent goal: a completion objective Codewhale keeps working toward across turns until it is verified complete, blocked, or the user stops it. You decide when a request is a durable objective worth carrying across turns — a multi-step outcome the user will want continued and verified. Do not create a goal for a question, a greeting, a one-shot edit, or a conversational probe; those are ordinary turns. When the user explicitly asks to use `/goal` or asks you to make something the goal, call `create_goal` before doing the rest of the work; acknowledging it in prose is not sufficient. Keep the user's full objective, not a shortened one-turn version. Set token_budget only when the user explicitly provides one. Creating a goal shows the user a one-line receipt (they can /goal pause or /goal clear); do not also ask for confirmation. Only one unfinished goal exists at a time: complete or clear it before creating another." } fn input_schema(&self) -> Value { @@ -1478,99 +1335,6 @@ mod tests { } } - #[test] - fn operate_goal_from_prompt_promotes_work_and_leaves_chat_alone() { - let goal = operate_goal_from_prompt( - "Migrate the settings loader to the new config crate\nand keep the old keys readable.", - ) - .expect("multi-line work prompt"); - assert_eq!( - goal.objective, - "Migrate the settings loader to the new config crate and keep the old keys readable." - ); - assert_eq!( - operate_goal_from_prompt("Please fix the flaky CI test") - .expect("imperative after please") - .objective, - "Please fix the flaky CI test" - ); - for chat in [ - "hi", - "thanks, looks good", - "ok go ahead", - "hello there, how are you doing today my friend", - "what does /goal do?", - "why is the build slow?", - "the tests", - ] { - assert_eq!( - operate_goal_from_prompt(chat), - None, - "must stay chat: {chat}" - ); - } - let long = "fix ".repeat(400); - let objective = operate_goal_from_prompt(&long).expect("bounded").objective; - assert!(objective.chars().count() <= OPERATE_GOAL_MAX_OBJECTIVE_CHARS + 1); - assert!(objective.ends_with('…')); - } - - #[test] - fn operate_does_not_promote_conversation_by_length_or_quoted_commands() { - for prompt in [ - "what about like rust or docker builds or something", - "what about like rust or docker builds or something?", - "why did the build fail on the last step when running docker on macos", - "Could you look at why the provider table drops rows after reload and repair it?", - "the Rust and Docker builds might explain the disk usage we saw", - "explain how the goal loop decides whether to continue working", - "那 rust 或者 docker 构建呢", - "请问这个模块的具体实现原理是什么以及它如何与其他服务交互", - "修复这个问题需要什么步骤", - "Build the release now?", - "Build the release now?", - "\"build the release now\"", - "`build the release now`", - "> build the release now", - "in the log it says build failed, what should we do", - ] { - assert_eq!(operate_goal_from_prompt(prompt), None, "{prompt}"); - } - for prompt in [ - "Build the release and verify the checksums", - "Please fix the flaky CI test", - "Fix 中文文档中的链接 and verify them", - "run \"cargo build --release\" and verify the result", - ] { - assert_eq!( - operate_goal_from_prompt(prompt).expect(prompt).objective, - prompt - ); - } - } - - #[test] - fn operate_respects_goal_opt_out_including_negated_lists() { - for prompt in [ - "Run one bounded cancellation check. Do not edit files, inspect other files, create a goal, spawn agents, or start any other tool.", - "Build the example, but don't create a goal.", - "Fix the button. Don’t create a persistent goal for this.", - "Run the check without a goal.", - "Verify the output; no goal tracking for this task.", - "Update the sample. Never set a goal automatically.", - "Fix the test. Do not use /goal.", - "Run the check without creating a goal.", - "Fix the example. Don't create any goals.", - "Run one check. Do not edit files,\ncreate a goal, or spawn agents.", - "Fix this issue, 不要创建目标。", - ] { - assert_eq!(operate_goal_from_prompt(prompt), None, "{prompt}"); - } - // A separate prohibition must not negate a later work instruction. - let work = "Fix the failing checks. Do not publish. Build the release and verify it."; - assert_eq!(operate_goal_from_prompt(work).unwrap().objective, work); - } - #[tokio::test] async fn update_goal_rejects_objective_knob_instead_of_ignoring_it() { // #5123-class: `objective` used to return a success receipt with no diff --git a/docs/MODES.md b/docs/MODES.md index 3e2a5711c0..490e7ce9a8 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -34,7 +34,7 @@ Run `/mode` to open the mode picker, or switch directly with `/mode work`, - **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. - **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `bash`, `agent`, `workflow`, and `todo_write`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. Goals still require an explicit user request; approval, sandbox, repository law, and managed policy decide what may execute. -- **Operate**: manage a goal through planned steps and verified results. Fleet configures the same sub-agents and roles that execute those steps. It has the same primitive identities and execution authority as Work. When no unfinished goal exists, a direct work instruction (not a greeting, acknowledgement, question, quoted command, conversational followup, or request declining goal tracking) becomes the session goal automatically, with continuation on; the transcript shows `◆ goal set · Operate keeps working until it is verified · /goal to edit`. An explicit `/goal` declaration still wins, `/goal` still edits it, and an existing goal is never replaced. The parent session is the **operator**: handle small or tightly coupled tasks directly. Before multi-step delegation, state a compact plan with named steps, dependencies, bounded file scopes and a completion check, then run it through the existing Workflow tool. Parallelize independent steps; each phase receives the previous phase's results, and a dependent step cannot start when a required result is missing. A single bounded independent task can use a direct `agent` call. Reuse a worker with followup for corrections and report completed, blocked and next steps. **Dispatch is not completion** — write-capable children must return real verification evidence. The first Operate turn of a session appends this contract once as a user-role runtime message (append-only history, never the pinned system prompt), so Plan, Work, and Operate keep one shared prompt prefix. +- **Operate**: manage a goal through planned steps and verified results. Fleet configures the same sub-agents and roles that execute those steps. It has the same primitive identities and execution authority as Work. Goals are model-decided: the agent calls `create_goal` when a request is a durable objective, and `/goal` always works as the direct user control — the host never infers a goal from wording. Once a goal exists, the transcript shows `◆ goal set · Operate keeps working until it is verified · /goal to edit`. An explicit `/goal` declaration always wins, `/goal` still edits it, and an existing goal is never replaced. The parent session is the **operator**: handle small or tightly coupled tasks directly. Before multi-step delegation, state a compact plan with named steps, dependencies, bounded file scopes and a completion check, then run it through the existing Workflow tool. Parallelize independent steps; each phase receives the previous phase's results, and a dependent step cannot start when a required result is missing. A single bounded independent task can use a direct `agent` call. Reuse a worker with followup for corrections and report completed, blocked and next steps. **Dispatch is not completion** — write-capable children must return real verification evidence. The first Operate turn of a session appends this contract once as a user-role runtime message (append-only history, never the pinned system prompt), so Plan, Work, and Operate keep one shared prompt prefix. `Act` and `/mode act` remain compatibility aliases for Work. Saved settings still normalize to the internal value `agent`. From b04713c31eaf804572eab4465c984d44cbee2379 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 15:17:49 -0700 Subject: [PATCH 06/24] refactor: delete three host-side semantic classifiers (determinism audit batch 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the read-only audit (codewhale-ops/ledger/HOST-DETERMINISM-AUDIT-20260917.md): host rules that classify meaning where the model — or declared data — should decide. This batch deletes the three that were pure subtraction. 1. `tools/workflow_trigger.rs` (405 lines) — seven verb/phrase classifiers (tiny-work phrases, factual-question prefixes, small-talk list, one-file-edit verbs, fan-out language, staged-work language, verification language) that decided auto-Workflow. Dormant in production: its only non-test consumer was the `debug_assert!(soft_auto_policy_is_linked())` in `with_subagent_tools` (#4127), whose purpose was to keep the policy *linked*. The prompt carries the policy now; module, assertion, and file are gone. 2. `behavioral_tips::looks_like_planning_prompt` + its "switch to Plan mode" tip — a hand-tuned word list (`plan`, `roadmap`, `strategy`, `outline`, "how should we", "step by step") with a unit test pinning the classifier. The tip variant, the nudge call site, and the detector are deleted; the model already knows when to suggest plan mode. 3. `plugins/matcher::is_specific_term` — the generic-word stoplist (`mcp`, `agent`, `model`, `data`, `code`, …) that made a catalog author's *declared* keywords unmatchable: the same failure mode as the deleted #6274 name suppression, one layer down. Matching is now purely declared data with mechanical admissibility (>= 3 chars, no control characters). The code-hosting homepage exclusion stays — a github homepage names where the plugin lives, not what it is — with a comment naming the real fix (catalogs declaring match domains explicitly). Deferred from batch 1, with reasons in the audit file: the subagent status sniffer (#9) needs the progress kind threaded from the event payload, and the tool-name noise list (#7) needs a declared verbosity field on tool specs. Evidence: matcher "11 passed", recommend "27 passed", plugin_suggestions "8 passed", behavioral_tips "3 passed", tools::workflow "142 passed", tools::registry "56 passed"; clippy (CI's invocation, tui lib) clean. --- .../tui/src/commands/groups/config/config.rs | 2 +- crates/tui/src/plugins/matcher.rs | 62 +-- crates/tui/src/tools/mod.rs | 1 - crates/tui/src/tools/registry.rs | 7 - crates/tui/src/tools/workflow_trigger.rs | 405 ------------------ crates/tui/src/tui/behavioral_tips.rs | 55 +-- crates/tui/src/tui/plugin_suggestions.rs | 4 +- crates/tui/src/tui/ui/dispatch.rs | 1 - 8 files changed, 41 insertions(+), 496 deletions(-) delete mode 100644 crates/tui/src/tools/workflow_trigger.rs diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 9f3f0f3616..7364d27ae9 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -3411,7 +3411,7 @@ mod tests { let _guard = EnvGuard::new(temp.path()); let mut app = create_test_app(); app.status_toasts.clear(); - assert!(app.maybe_show_behavioral_tip(BehavioralTip::PlanningMode)); + assert!(app.maybe_show_behavioral_tip(BehavioralTip::McpValidation)); app.push_status_toast("warning receipt", StatusToastLevel::Warning, None); app.push_status_toast("error receipt", StatusToastLevel::Error, None); app.sticky_status = Some(StatusToast::context_pressure( diff --git a/crates/tui/src/plugins/matcher.rs b/crates/tui/src/plugins/matcher.rs index 4b6339604a..f15041f782 100644 --- a/crates/tui/src/plugins/matcher.rs +++ b/crates/tui/src/plugins/matcher.rs @@ -46,13 +46,17 @@ fn effective_keywords(candidate: &KeywordCandidate<'_>) -> Vec { let mut keywords = Vec::new(); for keyword in candidate.keywords { let normalized = keyword.trim().to_ascii_lowercase(); - if is_specific_term(&normalized) { + if is_matchable_term(&normalized) { keywords.push(normalized); } } for domain in candidate.domains { + // A homepage on a code-hosting platform names where the plugin + // *lives*, not what it is; matching it would make every github-hosted + // plugin fire on any "github" mention. Everything else matches on + // declared data, which the host does not second-guess. if let Some(normalized) = normalize_domain(domain) - && is_specific_term(&normalized) + && is_matchable_term(&normalized) && !matches!( normalized.as_str(), "github.com" | "gitlab.com" | "bitbucket.org" @@ -62,7 +66,7 @@ fn effective_keywords(candidate: &KeywordCandidate<'_>) -> Vec { } } let name = candidate.name.trim().to_ascii_lowercase(); - if is_specific_term(&name) { + if is_matchable_term(&name) { keywords.push(name); } keywords @@ -70,27 +74,17 @@ fn effective_keywords(candidate: &KeywordCandidate<'_>) -> Vec { // Core vocabulary is not evidence that a user needs an integration. A // specific product name, phrase or domain is still eligible. -fn is_specific_term(term: &str) -> bool { - term.chars().count() >= 3 - && !term.chars().any(char::is_control) - && !matches!( - term, - "mcp" - | "plugin" - | "plugins" - | "skill" - | "skills" - | "agent" - | "agents" - | "tool" - | "tools" - | "data" - | "code" - | "model" - | "models" - | "session" - | "sessions" - ) +/// Mechanical admissibility for a match term: long enough to be a word and +/// free of control characters. +/// +/// Deliberately **not** a semantic stoplist. It used to reject declared terms +/// like `mcp`, `agent`, `model`, `data` and `code`, which made a catalog +/// author's declared keywords unmatchable — the same failure mode as the +/// deleted #6274 name suppression, one layer down. Declared keywords are the +/// catalog author's call; the noise controls are the score threshold, the +/// once-per-lifetime gate, and dismissal (#6290 rework). +fn is_matchable_term(term: &str) -> bool { + term.chars().count() >= 3 && !term.chars().any(char::is_control) } pub(crate) fn normalize_domain(domain: &str) -> Option { @@ -153,9 +147,11 @@ mod tests { super::match_plugin_keyword("help with finance", &candidates), Some((0, "finance".into())) ); + // Declared terms are the catalog author's call (#6290 rework): `mcp` + // matches when declared, and the receipt names it. assert_eq!( super::match_plugin_keyword("help with mcp", &candidates), - None + Some((0, "mcp".into())) ); } @@ -237,10 +233,13 @@ mod tests { } #[test] - fn core_vocabulary_short_claims_and_commands_do_not_trigger_suggestions() { + fn declared_vocabulary_matches_and_only_mechanics_filter_terms() { + // Declared keywords are the catalog author's call (#6290 rework): + // `mcp`, `agent`, `model`, … match when declared. The remaining + // filters are mechanical (>= 3 characters, no control characters), + // the `/`-command guard, and the code-hosting homepage exclusion. let words = [ - "mcp", "plugin", "plugins", "skill", "skills", "agent", "agents", "tool", "tools", - "code", "data", "model", "models", "session", "sessions", "go", "ai", + "mcp", "plugin", "skill", "agent", "tool", "code", "data", "model", "session", ]; let keywords = words .iter() @@ -250,10 +249,15 @@ mod tests { for word in words { assert_eq!( match_plugin_keyword(&format!("please help with {word}"), &candidates), - None, + Some(0), "{word}" ); } + // Two-character terms stay out on the mechanical floor. + let short_keywords = vec!["go".to_string()]; + let short = [candidate("git", &[], &short_keywords)]; + assert_eq!(match_plugin_keyword("go", &short), None); + let shared_host = vec!["https://github.com/example/plugin".to_string()]; let candidates = [candidate("supabase", &shared_host, &[])]; assert_eq!( diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index 85a621f4c3..a17d3298bf 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -89,7 +89,6 @@ pub mod web_search; pub mod web_tool; pub mod workflow; pub mod workflow_plan_approval; -pub mod workflow_trigger; pub use registry::{AgentToolSurfaceOptions, ToolRegistry, ToolRegistryBuilder}; pub use review::ReviewOutput; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 9232a99f59..81508fb402 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -1427,13 +1427,6 @@ impl ToolRegistryBuilder { use super::subagent::AgentTool; use super::subagent::register_coordination_tools; use super::workflow::WorkflowTool; - use super::workflow_trigger::soft_auto_policy_is_linked; - - // Keep soft-auto trigger policy linked in release builds (#4127). - debug_assert!( - soft_auto_policy_is_linked(), - "workflow soft-auto policy must stay linked" - ); let builder = self .with_tool(Arc::new(WorkflowTool::new( diff --git a/crates/tui/src/tools/workflow_trigger.rs b/crates/tui/src/tools/workflow_trigger.rs deleted file mode 100644 index 4eb3a30b6d..0000000000 --- a/crates/tui/src/tools/workflow_trigger.rs +++ /dev/null @@ -1,405 +0,0 @@ -//! Automatic Workflow trigger and suppression heuristics (#4127). -//! -//! Soft-auto model: the **agent** decides to use Workflow without the operator -//! saying the word "workflow". Policy here answers "should we orchestrate?" — -//! the parent prompt still **tells the operator** the intended shape and may -//! ask setup questions via `request_user_input` (TUI modal) before calling -//! `workflow` / `plan`. -//! -//! This remains Act/Agent guidance rather than a prose classifier at the host -//! boundary. Operate keeps small tasks in the parent and uses the existing -//! Workflow plan for multi-step delegation through the same sub-agents. - -/// Signals the parent can supply without full conversation replay. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct WorkflowTriggerSignals { - /// Approximate open file / edit scope count for the current ask. - pub distinct_file_scopes: usize, - /// True when the operator is mid interactive multi-turn design/chat. - pub highly_interactive: bool, - /// True when the ask requires writes but no clear phase/child decomposition. - pub risky_writes_unclear_decomposition: bool, - /// Estimated child count if Workflow launched now. - pub estimated_children: usize, - /// Soft cap from `[workflow].auto_start_child_limit` (default 16). - pub auto_start_child_limit: usize, - /// Approximate parent context tokens in use (for high-volume signal). - pub context_tokens: usize, - /// Threshold above which high context volume favors Workflow. - pub high_context_token_threshold: usize, -} - -impl WorkflowTriggerSignals { - #[must_use] - pub fn product_defaults() -> Self { - Self { - distinct_file_scopes: 0, - highly_interactive: false, - risky_writes_unclear_decomposition: false, - estimated_children: 0, - auto_start_child_limit: 16, - context_tokens: 0, - high_context_token_threshold: 80_000, - } - } -} - -/// Decision for automatic Workflow launch / recommendation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WorkflowTriggerDecision { - /// Launch or recommend Workflow. - Trigger { reason: &'static str }, - /// Suppress automatic Workflow; prefer direct tools / single agent. - Suppress { reason: &'static str }, -} - -impl WorkflowTriggerDecision { - #[must_use] - pub fn should_trigger(&self) -> bool { - matches!(self, Self::Trigger { .. }) - } - - #[cfg(test)] - #[must_use] - pub fn reason(&self) -> &'static str { - match self { - Self::Trigger { reason } | Self::Suppress { reason } => reason, - } - } -} - -/// Evaluate whether automatic Workflow is appropriate for this user ask. -/// -/// Suppression wins over trigger when both could apply (noisy auto-orchestration -/// is worse than missing a fan-out). Act/Agent soft-auto guidance should stay -/// aligned with these rules. Operate uses a direct worker for one bounded -/// independent task and a Workflow plan for multi-step delegation. -#[must_use] -pub fn evaluate_workflow_trigger( - user_text: &str, - signals: &WorkflowTriggerSignals, -) -> WorkflowTriggerDecision { - let text = user_text.trim(); - let lower = text.to_ascii_lowercase(); - - // --- Hard suppressions (AC) --- - if signals.highly_interactive { - return WorkflowTriggerDecision::Suppress { - reason: "highly interactive task — keep turn-by-turn", - }; - } - if signals.risky_writes_unclear_decomposition { - return WorkflowTriggerDecision::Suppress { - reason: "risky writes without clear decomposition", - }; - } - if signals.estimated_children > 0 - && signals.auto_start_child_limit > 0 - && signals.estimated_children > signals.auto_start_child_limit - { - return WorkflowTriggerDecision::Suppress { - reason: "estimated children exceed auto_start_child_limit", - }; - } - if child_overhead_exceeds_benefit(&lower, signals) { - return WorkflowTriggerDecision::Suppress { - reason: "child overhead greater than benefit", - }; - } - if is_simple_command_or_factual_question(&lower, text) { - return WorkflowTriggerDecision::Suppress { - reason: "simple command or factual question", - }; - } - if is_one_file_edit(&lower, signals) { - return WorkflowTriggerDecision::Suppress { - reason: "one-file edit — use direct tools", - }; - } - - // --- Triggers (AC) --- - if signals.distinct_file_scopes >= 3 { - return WorkflowTriggerDecision::Trigger { - reason: "independent scopes across multiple files", - }; - } - if signals.context_tokens >= signals.high_context_token_threshold { - return WorkflowTriggerDecision::Trigger { - reason: "high context volume favors staged Workflow", - }; - } - if has_fanout_language(&lower) { - return WorkflowTriggerDecision::Trigger { - reason: "audit/sweep/compare/fan-out language", - }; - } - if has_staged_work_language(&lower) { - return WorkflowTriggerDecision::Trigger { - reason: "staged multi-phase work", - }; - } - if has_independent_verification_language(&lower) { - return WorkflowTriggerDecision::Trigger { - reason: "independent verification pass", - }; - } - - WorkflowTriggerDecision::Suppress { - reason: "no automatic Workflow trigger matched", - } -} - -fn child_overhead_exceeds_benefit(lower: &str, signals: &WorkflowTriggerSignals) -> bool { - // Tiny asks or explicit single-step language — spawn cost dominates. - if signals.estimated_children == 1 { - return true; - } - if lower.len() < 24 && !has_fanout_language(lower) && !has_staged_work_language(lower) { - return true; - } - let tiny = [ - "fix typo", - "rename variable", - "one liner", - "one-liner", - "quick peek", - "just check", - ]; - tiny.iter().any(|needle| lower.contains(needle)) -} - -fn is_simple_command_or_factual_question(lower: &str, original: &str) -> bool { - if lower.starts_with('/') { - // Slash commands are UI routing, not orchestration. - return true; - } - let factual_prefixes = [ - "what is ", - "what's ", - "whats ", - "who is ", - "when is ", - "where is ", - "how many ", - "which ", - "define ", - "explain ", - ]; - if factual_prefixes.iter().any(|p| lower.starts_with(p)) && original.len() < 160 { - return true; - } - let simple_cmds = [ - "run tests", - "run the tests", - "cargo test", - "cargo check", - "git status", - "git log", - "git diff", - "ls", - "pwd", - "show version", - "print version", - ]; - if simple_cmds - .iter() - .any(|c| lower == *c || lower.starts_with(&format!("{c} "))) - { - return true; - } - // Short yes/no or status pings. - matches!( - lower.trim_end_matches(['?', '.', '!']), - "ok" | "thanks" | "thank you" | "status" | "ping" | "hello" | "hi" - ) -} - -fn is_one_file_edit(lower: &str, signals: &WorkflowTriggerSignals) -> bool { - if signals.distinct_file_scopes == 1 { - let editish = [ - "edit ", - "fix ", - "patch ", - "update ", - "change ", - "rewrite ", - "in this file", - "this file", - "only this file", - "single file", - "one file", - ]; - return editish.iter().any(|n| lower.contains(n)); - } - // Explicit single-file phrasing without scope signal. - lower.contains("only this file") - || lower.contains("just this file") - || lower.contains("single file") - || (lower.contains("one file") && !has_fanout_language(lower)) -} - -fn has_fanout_language(lower: &str) -> bool { - const NEEDLES: &[&str] = &[ - "audit", - "sweep", - "compare", - "fan-out", - "fan out", - "fanout", - "in parallel", - "parallel across", - "across the codebase", - "across packages", - "across crates", - "every crate", - "all packages", - "all modules", - "multi-repo", - "multi repo", - ]; - NEEDLES.iter().any(|n| lower.contains(n)) -} - -fn has_staged_work_language(lower: &str) -> bool { - const NEEDLES: &[&str] = &[ - "phase 1", - "phase 2", - "first implement", - "then verify", - "implement then", - "staged", - "multi-phase", - "multi phase", - "plan then execute", - "explore then implement", - "scout then", - ]; - NEEDLES.iter().any(|n| lower.contains(n)) -} - -fn has_independent_verification_language(lower: &str) -> bool { - const NEEDLES: &[&str] = &[ - "independent verification", - "verify independently", - "separate verifier", - "second pair of eyes", - "review in parallel", - "verify in parallel", - "independent review", - ]; - NEEDLES.iter().any(|n| lower.contains(n)) -} - -/// Reachability probe so the soft-auto surface stays linked in release builds. -/// -/// Returns `true` when a canonical fan-out ask would trigger Workflow under -/// product defaults (used by registry/tool wiring smoke tests). -#[must_use] -pub fn soft_auto_policy_is_linked() -> bool { - evaluate_workflow_trigger( - "audit every crate for unsafe blocks", - &WorkflowTriggerSignals::product_defaults(), - ) - .should_trigger() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn signals() -> WorkflowTriggerSignals { - WorkflowTriggerSignals::product_defaults() - } - - #[test] - fn suppresses_one_file_edits() { - let mut s = signals(); - s.distinct_file_scopes = 1; - let d = evaluate_workflow_trigger("fix the typo in this file", &s); - assert!(!d.should_trigger(), "{d:?}"); - assert!(d.reason().contains("one-file")); - } - - #[test] - fn suppresses_simple_commands_and_factual_questions() { - let s = signals(); - for ask in [ - "cargo test", - "git status", - "what is a worktree?", - "how many crates are there?", - "/help", - "thanks", - ] { - let d = evaluate_workflow_trigger(ask, &s); - assert!(!d.should_trigger(), "expected suppress for {ask:?}: {d:?}"); - } - } - - #[test] - fn product_defaults_match_workflow_child_limit() { - assert_eq!(signals().auto_start_child_limit, 16); - } - - #[test] - fn suppresses_highly_interactive_and_unclear_risky_writes() { - let mut s = signals(); - s.highly_interactive = true; - assert!(!evaluate_workflow_trigger("redesign the product with me", &s).should_trigger()); - - s = signals(); - s.risky_writes_unclear_decomposition = true; - assert!(!evaluate_workflow_trigger("make it better somehow", &s).should_trigger()); - } - - #[test] - fn suppresses_when_child_overhead_dominates() { - let mut s = signals(); - s.estimated_children = 1; - assert!(!evaluate_workflow_trigger("quick peek at main.rs", &s).should_trigger()); - - s = signals(); - s.estimated_children = 20; - s.auto_start_child_limit = 8; - let d = evaluate_workflow_trigger("audit the whole monorepo", &s); - assert!(!d.should_trigger(), "{d:?}"); - assert!(d.reason().contains("auto_start_child_limit")); - } - - #[test] - fn triggers_on_fanout_and_staged_language() { - let s = signals(); - for ask in [ - "audit every crate for unsafe blocks", - "sweep the codebase for TODO debt", - "compare the two provider implementations in parallel", - "phase 1 explore then phase 2 implement", - "run an independent verification of the release notes", - ] { - let d = evaluate_workflow_trigger(ask, &s); - assert!(d.should_trigger(), "expected trigger for {ask:?}: {d:?}"); - } - } - - #[test] - fn triggers_on_independent_scopes_and_high_context() { - let mut s = signals(); - s.distinct_file_scopes = 5; - assert!( - evaluate_workflow_trigger("touch the related modules carefully", &s).should_trigger() - ); - - s = signals(); - s.context_tokens = 120_000; - assert!(evaluate_workflow_trigger("continue the migration plan", &s).should_trigger()); - } - - #[test] - fn suppression_wins_over_fanout_language_when_interactive() { - let mut s = signals(); - s.highly_interactive = true; - let d = evaluate_workflow_trigger("let's design an audit sweep together", &s); - assert!(!d.should_trigger(), "{d:?}"); - assert!(d.reason().contains("interactive")); - } -} diff --git a/crates/tui/src/tui/behavioral_tips.rs b/crates/tui/src/tui/behavioral_tips.rs index e688c722f6..f930d8dfc8 100644 --- a/crates/tui/src/tui/behavioral_tips.rs +++ b/crates/tui/src/tui/behavioral_tips.rs @@ -9,7 +9,6 @@ use std::hash::{DefaultHasher, Hash, Hasher}; use crate::settings::Settings; use crate::tui::app::{App, StatusToast, StatusToastKind, StatusToastLevel}; -use codewhale_config::AppMode; use codewhale_localization::{Locale, MessageId, tr}; const MAX_TIPS_PER_SESSION: u8 = 1; @@ -18,7 +17,6 @@ const MAX_TRACKED_MANUAL_COMMANDS: usize = 128; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum BehavioralTip { - PlanningMode, BackgroundJobReceipt, ClearedInputRestore, McpValidation, @@ -31,7 +29,6 @@ pub enum BehavioralTip { impl BehavioralTip { const fn key(self) -> &'static str { match self { - Self::PlanningMode => "planning_mode", Self::BackgroundJobReceipt => "background_job_receipt", Self::ClearedInputRestore => "cleared_input_restore", Self::McpValidation => "mcp_validation", @@ -43,7 +40,6 @@ impl BehavioralTip { const fn message_id(self) -> MessageId { match self { - Self::PlanningMode => MessageId::BehavioralTipPlanning, Self::BackgroundJobReceipt => MessageId::BehavioralTipBackgroundReceipt, Self::ClearedInputRestore => MessageId::BehavioralTipClearedInput, Self::McpValidation => MessageId::BehavioralTipMcpValidation, @@ -56,7 +52,6 @@ impl BehavioralTip { fn message(self, locale: Locale) -> String { let template = tr(locale, self.message_id()); match self { - Self::PlanningMode => template.replace("{key}", "Tab"), Self::BackgroundJobReceipt => template.replace("{key}", "Enter"), Self::ClearedInputRestore => template.replace("{chord}", "Ctrl+Z"), Self::McpValidation => template.replace("{command}", "codewhale mcp validate"), @@ -202,12 +197,6 @@ impl App { true } - pub fn maybe_nudge_for_planning_prompt(&mut self, input: &str) -> bool { - self.mode != AppMode::Plan - && looks_like_planning_prompt(input) - && self.maybe_show_behavioral_tip(BehavioralTip::PlanningMode) - } - pub fn note_manual_command_for_tip(&mut self, input: &str) -> bool { self.behavioral_tips.enabled && self.behavioral_tips.note_manual_command(input) @@ -227,50 +216,21 @@ fn manual_command_fingerprint(input: &str) -> Option { Some(hasher.finish()) } -fn looks_like_planning_prompt(input: &str) -> bool { - let normalized = input - .to_ascii_lowercase() - .chars() - .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' }) - .collect::(); - let words = normalized.split_whitespace().collect::>(); - let has_word = |needle: &str| words.contains(&needle); - - ["plan", "planning", "roadmap", "strategy", "outline"] - .into_iter() - .any(has_word) - || normalized.contains("how should we") - || normalized.contains("before we start") - || normalized.contains("step by step") -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn planning_detector_matches_intent_without_substring_false_positives() { - assert!(looks_like_planning_prompt( - "Please outline a migration strategy" - )); - assert!(looks_like_planning_prompt("How should we approach this?")); - assert!(!looks_like_planning_prompt( - "Explain the planetary boundary" - )); - assert!(!looks_like_planning_prompt("Fix the failing test")); - } - #[test] fn session_and_lifetime_caps_keep_tips_quiet() { let mut state = BehavioralTipState::default(); - assert!(state.eligible(BehavioralTip::PlanningMode, 0)); - state.record_impression(BehavioralTip::PlanningMode); - assert!(!state.eligible(BehavioralTip::PlanningMode, 0)); + assert!(state.eligible(BehavioralTip::McpValidation, 0)); + state.record_impression(BehavioralTip::McpValidation); assert!(!state.eligible(BehavioralTip::McpValidation, 0)); + assert!(!state.eligible(BehavioralTip::BackgroundJobReceipt, 0)); let fresh_session = BehavioralTipState::default(); - assert!(fresh_session.eligible(BehavioralTip::PlanningMode, 1)); - assert!(!fresh_session.eligible(BehavioralTip::PlanningMode, MAX_LIFETIME_IMPRESSIONS)); + assert!(fresh_session.eligible(BehavioralTip::McpValidation, 1)); + assert!(!fresh_session.eligible(BehavioralTip::McpValidation, MAX_LIFETIME_IMPRESSIONS)); } #[test] @@ -288,7 +248,6 @@ mod tests { #[test] fn every_complete_locale_renders_tips_with_code_owned_controls() { let tips = [ - BehavioralTip::PlanningMode, BehavioralTip::BackgroundJobReceipt, BehavioralTip::ClearedInputRestore, BehavioralTip::McpValidation, @@ -302,10 +261,6 @@ mod tests { } } - assert_eq!( - BehavioralTip::PlanningMode.message(Locale::En), - "Planning? Tab cycles to Plan mode" - ); assert_eq!( BehavioralTip::BackgroundJobReceipt.message(Locale::En), "Receipts live in the Work panel — Enter opens the inspector" diff --git a/crates/tui/src/tui/plugin_suggestions.rs b/crates/tui/src/tui/plugin_suggestions.rs index 4dc6c5c2a8..b6b8fc3cb6 100644 --- a/crates/tui/src/tui/plugin_suggestions.rs +++ b/crates/tui/src/tui/plugin_suggestions.rs @@ -382,9 +382,9 @@ mod tests { let (mut app, _root, _home) = app_with_supabase_plugin(); if plugin_first { assert!(app.maybe_nudge_plugin_for_prompt("add supabase auth")); - assert!(!app.maybe_show_behavioral_tip(BehavioralTip::PlanningMode)); + assert!(!app.maybe_show_behavioral_tip(BehavioralTip::McpValidation)); } else { - assert!(app.maybe_show_behavioral_tip(BehavioralTip::PlanningMode)); + assert!(app.maybe_show_behavioral_tip(BehavioralTip::McpValidation)); assert!(!app.maybe_nudge_plugin_for_prompt("add supabase auth")); } assert_eq!(app.status_toasts.len(), 1); diff --git a/crates/tui/src/tui/ui/dispatch.rs b/crates/tui/src/tui/ui/dispatch.rs index 44f53994bc..5b78b169b0 100644 --- a/crates/tui/src/tui/ui/dispatch.rs +++ b/crates/tui/src/tui/ui/dispatch.rs @@ -576,7 +576,6 @@ pub(crate) fn prepare_user_dispatch( message: QueuedMessage, ) -> Result { anyhow::ensure!(!app.redaction_gate, "{INITIAL_PROMPT_DEFERRED_STATUS}"); - let _ = app.maybe_nudge_for_planning_prompt(&message.display); let _ = app.maybe_nudge_plugin_for_prompt(&message.display); // Plan paused-command changes without touching App or the engine pause From bf1b20943edd2415ffa6c7deb9bae2e3a3ddd677 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 15:53:34 -0700 Subject: [PATCH 07/24] refactor(reasoning): auto tier is a declared default, not a prompt classification (determinism audit batch 2, #1) The auto_reasoning keyword classifier (debug/error -> Max, search/lookup -> Low, plus CJK/JP lists) made cost and answer quality depend on vocabulary. auto now resolves the declared High default everywhere: turn loop, route planner, CLI/exec paths, runtime threads, and subagent assignment. The prompt plumbing through the spawn-route functions is deleted with it (-375/+106); select() is the seam a model-declared escalation hint lands in. Tests: auto_reasoning/resolve_auto_effort/cli_auto/model_routing filters 47 passed, 0 failed; tools::subagent 672 passed, 1 failed where the single failure (issue_5305 untethered fail-closed) also fails on clean HEAD and is unrelated to this change. clippy --all-targets clean, fmt clean. --- crates/tui/src/auto_reasoning.rs | 198 +++--------------------- crates/tui/src/core/engine/preview.rs | 9 +- crates/tui/src/core/engine/turn_loop.rs | 79 ++-------- crates/tui/src/exec_agent.rs | 2 +- crates/tui/src/lib.rs | 44 ++---- crates/tui/src/model_routing.rs | 4 +- crates/tui/src/runtime_threads.rs | 2 +- crates/tui/src/tools/subagent/mod.rs | 45 ++---- crates/tui/src/tools/subagent/tests.rs | 96 ++++-------- crates/tui/src/turn_route_plan.rs | 2 +- 10 files changed, 106 insertions(+), 375 deletions(-) diff --git a/crates/tui/src/auto_reasoning.rs b/crates/tui/src/auto_reasoning.rs index 0bb41d1ef8..8227e99145 100644 --- a/crates/tui/src/auto_reasoning.rs +++ b/crates/tui/src/auto_reasoning.rs @@ -1,191 +1,41 @@ -//! Adaptive reasoning-effort tier selection for `Auto` mode (#663). +//! Declared reasoning-effort tier policy for `Auto` mode (#663). //! //! When the user sets `reasoning_effort = "auto"`, the engine calls -//! [`select`] before each turn-level request to pick the actual tier -//! based on the current message. +//! [`select`] before each turn-level request to pick the actual tier. +//! +//! The tier is a declared policy, not a content classification. Until the +//! #6290 rework it guessed from the user's wording (`debug`/`error` → `Max`, +//! `search`/`lookup` → `Low`, with CJK/JP keyword lists) — the host-side +//! semantic-determinism class that made cost and answer quality depend on +//! vocabulary rather than the task. `auto` now means the declared default +//! below; the user's explicit tier is the precise control. A model-declared +//! escalation hint is the intended follow-up — this function is the seam a +//! real signal would land in. +//! +//! Known limitation: there is no per-turn cost policy left here. A turn whose +//! wording would once have classified as `Low` now runs at `High` unless the +//! user or the caller sets a tier explicitly; `auto` is not a cost-saver by +//! itself. use crate::reasoning_preference::ReasoningEffort; -/// Choose a concrete `ReasoningEffort` tier for the next API request. +/// Choose a concrete `ReasoningEffort` tier for an `auto` setting. /// -/// Rules: -/// - Sub-agent contexts (`is_subagent == true`) → `Low` -/// - Last user message contains a high-effort keyword -/// (English: `debug`, `error`; Chinese: 调试 / 错误 / 报错 / 出错 / -/// 崩溃 / 調試 / 錯誤; Japanese: デバッグ / エラー / バグ) → `Max` -/// - Last user message contains a low-effort keyword -/// (English: `search`, `lookup`; Chinese: 搜索 / 查找 / 查询; -/// Japanese: 検索) → `Low` -/// - Everything else → `High` +/// Declared policy: `High`. The message, the model, and the caller are never +/// inspected. #[must_use] -pub fn select(is_subagent: bool, last_msg: &str) -> ReasoningEffort { - if is_subagent { - return ReasoningEffort::Low; - } - - let lower = last_msg.to_ascii_lowercase(); - - if HIGH_EFFORT_KEYWORDS.iter().any(|kw| lower.contains(kw)) { - return ReasoningEffort::Max; - } - - if LOW_EFFORT_KEYWORDS.iter().any(|kw| lower.contains(kw)) { - return ReasoningEffort::Low; - } - +pub fn select() -> ReasoningEffort { ReasoningEffort::High } -/// Keywords that bump `reasoning_effort` to `Max`. Latin terms are -/// lowercase because the caller lowercases the message; CJK has no -/// case so the literal form matches as-is. Covers the Chinese and -/// Japanese vocabulary a non-English user reaches for when reporting -/// the same kind of problem the original `"debug" | "error"` rule was -/// trying to catch — without those terms a Chinese-speaking user -/// paying for Auto mode silently got `High` even on hard debugging -/// tasks. -const HIGH_EFFORT_KEYWORDS: &[&str] = &[ - // English (unchanged from the original keyword set). - "debug", - "error", - // Simplified / Traditional Chinese. - "\u{8c03}\u{8bd5}", // 调试 - "\u{9519}\u{8bef}", // 错误 - "\u{62a5}\u{9519}", // 报错 - "\u{51fa}\u{9519}", // 出错 - "\u{5d29}\u{6e83}", // 崩溃 - "\u{8abf}\u{8a66}", // 調試 - "\u{932f}\u{8aa4}", // 錯誤 - // Japanese. - "\u{30c7}\u{30d0}\u{30c3}\u{30b0}", // デバッグ - "\u{30a8}\u{30e9}\u{30fc}", // エラー - "\u{30d0}\u{30b0}", // バグ -]; - -/// Keywords that drop `reasoning_effort` to `Low`. Same locale coverage -/// as [`HIGH_EFFORT_KEYWORDS`]. -const LOW_EFFORT_KEYWORDS: &[&str] = &[ - "search", - "lookup", - "\u{641c}\u{7d22}", // 搜索 - "\u{67e5}\u{627e}", // 查找 - "\u{67e5}\u{8be2}", // 查询 - "\u{691c}\u{7d22}", // 検索 -]; - #[cfg(test)] mod tests { use super::*; + /// The declared default. Changing this value is a policy change: update the + /// resolution tests in `core/engine/turn_loop.rs` and `lib.rs` with it. #[test] - fn subagent_returns_low() { - assert_eq!(select(true, "anything"), ReasoningEffort::Low); - assert_eq!(select(true, "debug this"), ReasoningEffort::Low); - assert_eq!(select(true, "search query"), ReasoningEffort::Low); - } - - #[test] - fn debug_or_error_returns_max() { - assert_eq!(select(false, "find a bug"), ReasoningEffort::High); - assert_eq!(select(false, "debug crash"), ReasoningEffort::Max); - assert_eq!(select(false, "Error: timeout"), ReasoningEffort::Max); - assert_eq!(select(false, "fix this error"), ReasoningEffort::Max); - assert_eq!(select(false, "DEBUG output"), ReasoningEffort::Max); - } - - #[test] - fn search_or_lookup_returns_low() { - assert_eq!(select(false, "search for the file"), ReasoningEffort::Low); - assert_eq!(select(false, "lookup docs"), ReasoningEffort::Low); - assert_eq!(select(false, "SearchQuery"), ReasoningEffort::Low); - assert_eq!(select(false, "lookup_user"), ReasoningEffort::Low); - } - - #[test] - fn default_returns_high() { - assert_eq!(select(false, "hello"), ReasoningEffort::High); - assert_eq!(select(false, "write a test"), ReasoningEffort::High); - assert_eq!(select(false, "refactor this module"), ReasoningEffort::High); - assert_eq!(select(false, ""), ReasoningEffort::High); - } - - #[test] - fn chinese_debug_keywords_return_max() { - // The original keyword set was English-only; Chinese-speaking - // Auto-mode users paid for `High` even on real debugging tasks. - for msg in [ - "\u{5e2e}\u{6211}\u{8c03}\u{8bd5}\u{4ee3}\u{7801}", // 帮我调试代码 - "\u{8fd9}\u{91cc}\u{6709}\u{4e2a}\u{9519}\u{8bef}", // 这里有个错误 - "\u{4ee3}\u{7801}\u{62a5}\u{9519}\u{4e86}", // 代码报错了 - "\u{7a0b}\u{5e8f}\u{51fa}\u{9519}", // 程序出错 - "\u{7cfb}\u{7edf}\u{5d29}\u{6e83}", // 系统崩溃 - "\u{4ee3}\u{78bc}\u{8abf}\u{8a66}", // 代碼調試 (zh-Hant) - "\u{6709}\u{500b}\u{932f}\u{8aa4}", // 有個錯誤 (zh-Hant) - ] { - assert_eq!( - select(false, msg), - ReasoningEffort::Max, - "expected Max for `{msg}`", - ); - } - } - - #[test] - fn japanese_debug_keywords_return_max() { - for msg in [ - "\u{30b3}\u{30fc}\u{30c9}\u{3092}\u{30c7}\u{30d0}\u{30c3}\u{30b0}", // コードをデバッグ - "\u{30a8}\u{30e9}\u{30fc}\u{304c}\u{51fa}\u{305f}", // エラーが出た - "\u{30d0}\u{30b0}\u{3092}\u{4fee}\u{6b63}", // バグを修正 - ] { - assert_eq!( - select(false, msg), - ReasoningEffort::Max, - "expected Max for `{msg}`", - ); - } - } - - #[test] - fn chinese_search_keywords_return_low() { - for msg in [ - "\u{641c}\u{7d22}\u{4e00}\u{4e0b}\u{6587}\u{4ef6}", // 搜索一下文件 - "\u{5e2e}\u{6211}\u{67e5}\u{627e}\u{5b9a}\u{4e49}", // 帮我查找定义 - "\u{67e5}\u{8be2}\u{6587}\u{6863}", // 查询文档 - ] { - assert_eq!( - select(false, msg), - ReasoningEffort::Low, - "expected Low for `{msg}`", - ); - } - } - - #[test] - fn japanese_search_keyword_returns_low() { - // 検索 → "search" - assert_eq!( - select( - false, - "\u{30c9}\u{30ad}\u{30e5}\u{30e1}\u{30f3}\u{30c8}\u{691c}\u{7d22}" - ), - ReasoningEffort::Low, - ); - } - - #[test] - fn cjk_default_still_returns_high() { - // No keyword hits — ordinary Chinese/Japanese prose stays on - // the `High` default like English does. - for msg in [ - "\u{5e2e}\u{6211}\u{5199}\u{4e2a}\u{6d4b}\u{8bd5}", // 帮我写个测试 - "\u{91cd}\u{6784}\u{8fd9}\u{4e2a}\u{6a21}\u{5757}", // 重构这个模块 - "\u{30c6}\u{30b9}\u{30c8}\u{3092}\u{66f8}\u{304f}", // テストを書く - ] { - assert_eq!( - select(false, msg), - ReasoningEffort::High, - "expected High for `{msg}`", - ); - } + fn declared_default_is_high() { + assert_eq!(select(), ReasoningEffort::High); } } diff --git a/crates/tui/src/core/engine/preview.rs b/crates/tui/src/core/engine/preview.rs index ff1f598fa2..31820aba9c 100644 --- a/crates/tui/src/core/engine/preview.rs +++ b/crates/tui/src/core/engine/preview.rs @@ -438,13 +438,12 @@ impl Engine { .preview_runtime_transforms(&messages, system_prompt.as_ref(), &planned_compaction) .await; - // The turn loop resolves an `auto` sentinel tier against the messages - // it is about to send, *after* the planner normalized it. Skipping - // that step described a request carrying a literal `auto`, which no - // route receives. + // The turn loop resolves an `auto` sentinel tier to its declared + // policy value, *after* the planner normalized it. Skipping that step + // described a request carrying a literal `auto`, which no route + // receives. let effective_reasoning_effort = super::turn_loop::resolve_auto_effort( reasoning_effort.as_deref(), - &messages, provider, &base_url, &model, diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 94b0c43096..281d84cfed 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1247,7 +1247,6 @@ impl Engine { // Resolve `auto` reasoning_effort to a concrete tier (#663). let effective_reasoning_effort = resolve_auto_effort( self.session.reasoning_effort.as_deref(), - &self.session.messages, self.api_provider, &self.api_config.deepseek_base_url(), &self.config.model, @@ -6267,55 +6266,27 @@ pub(super) const REASONING_EFFORT_AUTO: &str = "auto"; /// Resolve an `"auto"` reasoning-effort tier to a concrete value. /// -/// When the configured effort is `"auto"`, inspects the last user message -/// and calls [`crate::auto_reasoning::select`] to pick the actual tier. -/// Non-`"auto"` values pass through unchanged. +/// When the configured effort is `"auto"`, calls +/// [`crate::auto_reasoning::select`] for the declared policy tier. The message +/// is no longer inspected: the keyword classifier was deleted with the #6290 +/// rework, and `auto` now means the declared default rather than a guess from +/// the user's wording. Non-`"auto"` values pass through unchanged. pub(super) fn resolve_auto_effort( reasoning_effort: Option<&str>, - messages: &[Message], provider: crate::config::ApiProvider, base_url: &str, wire_model: &str, ) -> Option { match reasoning_effort { Some(effort) if effort == REASONING_EFFORT_AUTO => { - // Find the last user message in the conversation. - let last_msg = messages - .iter() - .rev() - .find(|m| m.role == "user") - .map(|m| { - m.content - .iter() - .filter_map(|block| { - if let ContentBlock::Text { text, .. } = block { - if is_turn_metadata_text(text) { - None - } else { - Some(text.as_str()) - } - } else { - None - } - }) - .collect::>() - .join(" ") - }) - .unwrap_or_default(); - - // is_subagent is false here — run_turn runs in the - // main engine (not a sub-agent's inner loop). Sub-agents have - // their own turn pass and can pass is_subagent=true when they - // call this function directly. - let tier = crate::auto_reasoning::select(false, &last_msg); + let tier = crate::auto_reasoning::select(); let resolved = tier .normalize_for_route(provider, base_url, wire_model) .as_setting() .to_string(); tracing::debug!( reasoning_effort = %resolved, - is_subagent = false, - "auto_reasoning: resolved auto tier from user message" + "auto_reasoning: resolved auto tier from declared policy" ); Some(resolved) } @@ -6324,10 +6295,6 @@ pub(super) fn resolve_auto_effort( } } -fn is_turn_metadata_text(text: &str) -> bool { - text.trim_start().starts_with("") -} - #[cfg(test)] mod tests { use super::*; @@ -6792,47 +6759,26 @@ mod tests { } #[test] - fn resolve_auto_effort_ignores_stored_turn_metadata() { - let messages = vec![Message { - role: Role::User, - content: vec![ - ContentBlock::Text { - text: "\nRecent errors: src/failing.rs\n".to_string(), - cache_control: None, - }, - ContentBlock::Text { - text: "hello".to_string(), - cache_control: None, - }, - ], - }]; - + fn resolve_auto_effort_is_content_blind() { + // #6290 rework: the resolved tier no longer depends on message text + // at all — stored metadata, questions, and work prompts alike take + // the declared default. assert_eq!( resolve_auto_effort( Some("auto"), - &messages, crate::config::ApiProvider::Deepseek, crate::config::DEFAULT_DEEPSEEK_BASE_URL, "deepseek-v4-pro", ), Some("high".to_string()), - "auto thinking should classify the user request, not stored metadata" + "auto resolves the declared default" ); } #[test] fn resolve_auto_effort_selects_a_concrete_kimi_code_tier() { - let messages = vec![Message { - role: Role::User, - content: vec![ContentBlock::Text { - text: "inspect this repository and fix the failing tests".to_string(), - cache_control: None, - }], - }]; - let resolved = resolve_auto_effort( Some("auto"), - &messages, crate::config::ApiProvider::Moonshot, crate::config::DEFAULT_KIMI_CODE_BASE_URL, crate::config::KIMI_CODE_K3_MODEL, @@ -6846,7 +6792,6 @@ mod tests { assert_eq!( resolve_auto_effort( None, - &messages, crate::config::ApiProvider::Moonshot, crate::config::DEFAULT_KIMI_CODE_BASE_URL, crate::config::KIMI_CODE_K3_MODEL, diff --git a/crates/tui/src/exec_agent.rs b/crates/tui/src/exec_agent.rs index a8bd4b199a..45a218151f 100644 --- a/crates/tui/src/exec_agent.rs +++ b/crates/tui/src/exec_agent.rs @@ -383,7 +383,7 @@ pub(crate) async fn run_exec_agent( // `run_one_shot`/`run_one_shot_json` and the interactive launch path do, // so the tier the engine (and the receipt below) sees is concrete. let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| { - cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt) + cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort) }); let settings = crate::settings::Settings::load().unwrap_or_default(); diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index a9c8affdf3..85c8f90631 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -8733,7 +8733,6 @@ Provide findings ordered by severity with file references, then open questions, &model, effort, review_reserve_percent, - &user_prompt, ) }); let request = MessageRequest { @@ -11562,10 +11561,9 @@ fn cli_reasoning_effort_value_for_prompt( config: &Config, model: &str, effort: crate::reasoning_preference::ReasoningEffort, - prompt: &str, ) -> Option { let resolved = if effort == crate::reasoning_preference::ReasoningEffort::Auto { - crate::auto_reasoning::select(false, prompt) + crate::auto_reasoning::select() } else { effort }; @@ -11580,10 +11578,9 @@ fn review_reasoning_effort_value_for_prompt( model: &str, effort: crate::reasoning_preference::ReasoningEffort, reserve_percent: u32, - prompt: &str, ) -> Option { let resolved = if effort == crate::reasoning_preference::ReasoningEffort::Auto { - crate::auto_reasoning::select(false, prompt) + crate::auto_reasoning::select() } else { effort }; @@ -11746,7 +11743,7 @@ async fn run_one_shot( let execution_config = config_for_cli_route(config, &route); let client = DeepSeekClient::new(&execution_config)?; let reasoning_effort = route.reasoning_effort.and_then(|effort| { - cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt) + cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort) }); let model = route.model; let request_route = client.effective_route_envelope(&model, chrono::Utc::now()); @@ -11809,7 +11806,7 @@ async fn run_one_shot_json( let client = DeepSeekClient::new(&execution_config)?; let model = route.model.clone(); let reasoning_effort = route.reasoning_effort.and_then(|effort| { - cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt) + cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort) }); let request_route = client.effective_route_envelope(&model, chrono::Utc::now()); let request = MessageRequest { @@ -17382,7 +17379,7 @@ api_key = "test-only-key" } #[test] - fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() { + fn cli_auto_resolves_to_the_declared_default_before_k3_route_normalization() { let config = Config { provider: Some("moonshot".to_string()), providers: Some(crate::config::ProvidersConfig { @@ -17396,30 +17393,24 @@ api_key = "test-only-key" ..Default::default() }; - for (prompt, expected) in [ - ("lookup the public docs", "low"), - ("debug this error", "max"), - ("review this ordinary change", "high"), - ] { - assert_eq!( - cli_reasoning_effort_value_for_prompt( - &config, - crate::config::KIMI_CODE_K3_MODEL, - crate::reasoning_preference::ReasoningEffort::Auto, - prompt, - ) - .as_deref(), - Some(expected), - "prompt selector must resolve Auto for `{prompt}`" - ); - } + // #6290 rework: Auto no longer classifies the prompt. Any wording + // resolves the declared policy tier, normalized for the K3 route. + assert_eq!( + cli_reasoning_effort_value_for_prompt( + &config, + crate::config::KIMI_CODE_K3_MODEL, + crate::reasoning_preference::ReasoningEffort::Auto, + ) + .as_deref(), + Some("high"), + "Auto resolves the declared default, not a classification" + ); assert_eq!( cli_reasoning_effort_value_for_prompt( &config, crate::config::KIMI_CODE_K3_MODEL, crate::reasoning_preference::ReasoningEffort::Off, - "debug must not override an explicit effort", ) .as_deref(), Some("low"), @@ -17483,7 +17474,6 @@ api_key = "test-only-key" &config, crate::config::ZAI_GLM_5_2_MODEL, crate::reasoning_preference::ReasoningEffort::Auto, - "debug this failing integration test", ) .expect("Auto must resolve to a concrete tier"); diff --git a/crates/tui/src/model_routing.rs b/crates/tui/src/model_routing.rs index 9332f16661..01c635b4e3 100644 --- a/crates/tui/src/model_routing.rs +++ b/crates/tui/src/model_routing.rs @@ -830,7 +830,7 @@ fn auto_route_from_inventory_heuristic( AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::NoRunnableCandidate), )), model, - reasoning_effort: Some(crate::auto_reasoning::select(false, latest_request)), + reasoning_effort: Some(crate::auto_reasoning::select()), source: AutoRouteSource::Heuristic, routed_usage: Vec::new(), routed_usage_drop_records: Vec::new(), @@ -868,7 +868,7 @@ fn auto_route_from_inventory_heuristic( AutoRouteReason::LocalHeuristic(decision.reason), )), model: decision.model, - reasoning_effort: Some(crate::auto_reasoning::select(false, latest_request)), + reasoning_effort: Some(crate::auto_reasoning::select()), source: AutoRouteSource::Heuristic, routed_usage: Vec::new(), routed_usage_drop_records: Vec::new(), diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 83e15e49f4..e3ae5fe4fd 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -9456,7 +9456,7 @@ impl RuntimeThreadManager { ); let selected_reasoning = reasoning_preference.map(|effort| { if effort == crate::reasoning_preference::ReasoningEffort::Auto { - crate::auto_reasoning::select(false, &prompt) + crate::auto_reasoning::select() } else { effort } diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 322c15fd88..1d5ad00bf3 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -10246,7 +10246,6 @@ async fn spawn_subagent_from_input( &mut child_runtime, &spawn_request, profile_member.as_ref(), - &effective_prompt, true, ) .await?; @@ -14997,7 +14996,6 @@ async fn bind_spawn_model_route( runtime: &mut SubAgentRuntime, request: &SpawnRequest, member: Option<&crate::fleet::profile::AgentProfile>, - prompt: &str, apply_role_pins: bool, ) -> Result<(ModelRoute, SpawnRouteSource), ToolError> { bind_profile_provider(runtime, member)?; @@ -15078,7 +15076,6 @@ async fn bind_spawn_model_route( let route = resolve_subagent_assignment_route( runtime, None, - prompt, &request.agent_type, selection.model_route, request.thinking, @@ -15218,7 +15215,7 @@ async fn resolved_spawn_roster_entry( let mut child = runtime.child_runtime(); let resolved = match request { Ok((request, member)) => { - bind_spawn_model_route(&mut child, &request, member.as_ref(), "", apply_role_pins).await + bind_spawn_model_route(&mut child, &request, member.as_ref(), apply_role_pins).await } Err(error) => Err(error), }; @@ -15717,19 +15714,12 @@ impl SubAgentResolvedRoute { pub(crate) async fn resolve_subagent_assignment_route( runtime: &SubAgentRuntime, configured_model: Option, - prompt: &str, agent_type: &FleetRole, requested_model_route: ModelRoute, requested_thinking: SubAgentThinking, ) -> SubAgentResolvedRoute { let model_route = assignment_model_route(configured_model.as_deref(), requested_model_route); - worker_profile_subagent_assignment_route( - runtime, - &model_route, - requested_thinking, - prompt, - agent_type, - ) + worker_profile_subagent_assignment_route(runtime, &model_route, requested_thinking, agent_type) } fn assignment_model_route( @@ -15765,14 +15755,12 @@ fn fallback_subagent_assignment_route( configured_model: Option, requested_model_route: ModelRoute, requested_thinking: SubAgentThinking, - prompt: &str, ) -> SubAgentResolvedRoute { let model_route = assignment_model_route(configured_model.as_deref(), requested_model_route); worker_profile_subagent_assignment_route( runtime, &model_route, requested_thinking, - prompt, &FleetRole::Worker, ) } @@ -15826,7 +15814,6 @@ fn worker_profile_subagent_assignment_route( runtime: &SubAgentRuntime, model_route: &ModelRoute, requested_thinking: SubAgentThinking, - prompt: &str, agent_type: &FleetRole, ) -> SubAgentResolvedRoute { let candidates = subagent_router_candidates(runtime); @@ -15848,7 +15835,6 @@ fn worker_profile_subagent_assignment_route( let reasoning_effort = subagent_reasoning_effort_for_request( runtime, &model, - prompt, requested_fast_lane, requested_thinking, role_reasoning_default.as_deref(), @@ -15860,7 +15846,6 @@ fn worker_profile_subagent_assignment_route( fn subagent_reasoning_effort_for_request( runtime: &SubAgentRuntime, model: &str, - prompt: &str, requested_fast_lane: bool, requested_thinking: SubAgentThinking, role_reasoning_default: Option<&str>, @@ -15875,7 +15860,7 @@ fn subagent_reasoning_effort_for_request( match requested_thinking { SubAgentThinking::Effort(effort) => Some(normalize(effort).as_setting().to_string()), SubAgentThinking::Auto => Some( - normalize(auto_subagent_reasoning_effort(prompt)) + normalize(auto_subagent_reasoning_effort()) .as_setting() .to_string(), ), @@ -15901,15 +15886,11 @@ fn subagent_reasoning_effort_for_request( }; Some(normalize(effort).as_setting().to_string()) } - SubAgentThinking::Inherit => fallback_subagent_reasoning_effort(runtime, model, prompt), + SubAgentThinking::Inherit => fallback_subagent_reasoning_effort(runtime, model), } } -fn fallback_subagent_reasoning_effort( - runtime: &SubAgentRuntime, - model: &str, - prompt: &str, -) -> Option { +fn fallback_subagent_reasoning_effort(runtime: &SubAgentRuntime, model: &str) -> Option { let normalize = |effort: ReasoningEffort| { effort.normalize_for_route( runtime.client.api_provider(), @@ -15928,7 +15909,7 @@ fn fallback_subagent_reasoning_effort( .is_some_and(|effort| ReasoningEffort::from_setting(effort) == ReasoningEffort::Auto); if requested_auto { Some( - normalize(auto_subagent_reasoning_effort(prompt)) + normalize(auto_subagent_reasoning_effort()) .as_setting() .to_string(), ) @@ -15942,8 +15923,8 @@ fn fallback_subagent_reasoning_effort( } } -fn auto_subagent_reasoning_effort(prompt: &str) -> ReasoningEffort { - crate::auto_reasoning::select(false, prompt) +fn auto_subagent_reasoning_effort() -> ReasoningEffort { + crate::auto_reasoning::select() } fn parse_optional_subagent_model(input: &Value, key: &str) -> Result, ToolError> { @@ -18509,7 +18490,6 @@ async fn configured_model_subagent_full_bind_preserves_task_profile_and_role_ids &mut runtime, &request, (source == "profile").then_some(&member), - "", true, ) .await @@ -18668,7 +18648,7 @@ mod declared_shortlist_tests { &json!({"prompt":"fixture", "type":"reviewer", "model":requested}), ) .unwrap(); - let (route, _) = bind_spawn_model_route(&mut runtime, &request, None, "", true) + let (route, _) = bind_spawn_model_route(&mut runtime, &request, None, true) .await .unwrap(); assert_eq!(route, ModelRoute::Fixed(model.into())); @@ -18732,7 +18712,6 @@ mod declared_shortlist_tests { &mut runtime, &request, (source == "profile").then_some(&member), - "", true, ) .await; @@ -18757,7 +18736,7 @@ mod declared_shortlist_tests { ) .unwrap(); assert!( - bind_spawn_model_route(&mut runtime, &request, None, "", true) + bind_spawn_model_route(&mut runtime, &request, None, true) .await .unwrap_err() .to_string() @@ -18783,7 +18762,7 @@ mod declared_shortlist_tests { parse_spawn_request(&json!({"prompt":"fixture", "type":"reviewer", "model":lower})) .unwrap(); assert!( - bind_spawn_model_route(&mut runtime, &request, None, "", true) + bind_spawn_model_route(&mut runtime, &request, None, true) .await .is_err() ); @@ -18795,7 +18774,7 @@ mod declared_shortlist_tests { ) .unwrap(); assert_eq!( - bind_spawn_model_route(&mut runtime, &request, None, "", true) + bind_spawn_model_route(&mut runtime, &request, None, true) .await .unwrap() .0, diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index e4cd37615a..ba2f577c59 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -3644,7 +3644,6 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { &runtime, &ModelRoute::Inherit, request.thinking, - &request.prompt, &request.agent_type, ); assert_eq!( @@ -3665,7 +3664,6 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { &stub_runtime(), &ModelRoute::Inherit, request.thinking, - &request.prompt, &request.agent_type, ); assert_eq!( @@ -3674,6 +3672,8 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { "explicit child reasoning must override the role default" ); + // #6290: explicit auto no longer classifies the child prompt — the debug + // wording below resolves the same declared default as any other wording. let request = parse_spawn_request(&json!({ "prompt": "debug this release failure", "type": "consultant", @@ -3684,13 +3684,12 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { &stub_runtime(), &ModelRoute::Inherit, request.thinking, - &request.prompt, &request.agent_type, ); assert_eq!( route.reasoning_effort.as_deref(), - Some("max"), - "explicit auto must resolve from the child prompt instead of using the consultant high default" + Some("high"), + "explicit auto resolves the declared default instead of classifying the child prompt" ); let request = parse_spawn_request(&json!({ @@ -3703,7 +3702,6 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { &stub_runtime(), &ModelRoute::Inherit, request.thinking, - &request.prompt, &request.agent_type, ); assert_eq!( @@ -4423,7 +4421,7 @@ async fn manual_config_role_pin_refuses_task_model_and_strength_before_binding() selection.model_route, ModelRoute::Fixed("deepseek-v4-flash".into()) ); - let error = bind_spawn_model_route(&mut runtime, &request, None, "", true) + let error = bind_spawn_model_route(&mut runtime, &request, None, true) .await .expect_err("task choices cannot replace a current Config pin"); let message = error.to_string(); @@ -4452,7 +4450,7 @@ async fn manual_role_pin_accepts_only_its_exact_qualified_provider_selector() { let request = parse_spawn_request(&json!({"prompt":"review", "type":"reviewer", "model":model})) .unwrap(); - let (route, source) = bind_spawn_model_route(&mut runtime, &request, None, "", true) + let (route, source) = bind_spawn_model_route(&mut runtime, &request, None, true) .await .expect("the task may restate the same exact route"); assert_eq!(route, ModelRoute::Fixed("deepseek-v4-flash".into())); @@ -4463,7 +4461,7 @@ async fn manual_role_pin_accepts_only_its_exact_qualified_provider_selector() { "prompt":"review", "type":"reviewer", "model":"moonshot/deepseek-v4-flash" })) .unwrap(); - let error = bind_spawn_model_route(&mut runtime, &request, None, "", true) + let error = bind_spawn_model_route(&mut runtime, &request, None, true) .await .expect_err("a provider prefix cannot retarget the saved pin"); assert!(error.to_string().contains("conflicts"), "{error}"); @@ -4490,7 +4488,7 @@ async fn structured_role_pin_rejects_incomplete_auto_and_unknown_provider_pairs( .unwrap(), ); let request = parse_spawn_request(&json!({"prompt":"review", "type":"reviewer"})).unwrap(); - let error = bind_spawn_model_route(&mut runtime, &request, None, "", true) + let error = bind_spawn_model_route(&mut runtime, &request, None, true) .await .expect_err("an invalid explicit route cannot inherit a usable default"); assert!(!error.to_string().is_empty(), "{value:?}: {error}"); @@ -4557,7 +4555,7 @@ async fn manual_role_pin_keeps_case_distinct_custom_provider_identity() { "prompt":"review", "type":"reviewer", "model":selector })) .unwrap(); - let result = bind_spawn_model_route(&mut runtime, &request, None, "", true).await; + let result = bind_spawn_model_route(&mut runtime, &request, None, true).await; if succeeds { assert_eq!(result.unwrap().1, SpawnRouteSource::RolePin); } else { @@ -4599,7 +4597,7 @@ async fn foreign_manual_role_pin_is_not_downgraded_to_an_implicit_default() { assert!(error.to_string().contains("moonshot"), "{error}"); assert_eq!(selected.source, SpawnRouteSource::RolePin); assert!(matches!(selected.model_route, ModelRoute::Fixed(_))); - bind_spawn_model_route(&mut runtime, &request, None, "", true) + bind_spawn_model_route(&mut runtime, &request, None, true) .await .expect_err("the actual bind must keep the same known-foreign refusal"); assert_eq!(runtime.model, "kimi-k2.6"); @@ -4656,7 +4654,7 @@ async fn structured_custom_pin_refuses_named_provider_migration_but_accepts_lite "prompt":"review", "type":"reviewer", "model":"custom/model-x" })) .unwrap(); - let result = bind_spawn_model_route(&mut runtime, &request, None, "", true).await; + let result = bind_spawn_model_route(&mut runtime, &request, None, true).await; if should_bind { assert_eq!(result.unwrap().1, SpawnRouteSource::RolePin); assert_eq!(runtime.model, "model-x"); @@ -5004,7 +5002,6 @@ fn test_explicit_spawn_thinking_reaches_the_request() { None, ModelRoute::Inherit, request.thinking, - "review this", ); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } @@ -5120,7 +5117,6 @@ fn fixed_model_runtime_with_a_raw_auto_tier_resolves_instead_of_staying_raw() { Some("deepseek-v4-pro".to_string()), ModelRoute::Inherit, SubAgentThinking::Inherit, - "debug this release failure", ); assert_eq!( @@ -5134,8 +5130,10 @@ fn fixed_model_runtime_with_a_raw_auto_tier_resolves_instead_of_staying_raw() { Some("auto"), "the raw auto sentinel must never reach the wire" ); - assert_eq!(route.reasoning_effort.as_deref(), Some("max")); - assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::Max)); + // #6290: `auto` resolves the declared default (High); the prompt no + // longer classifies the tier. + assert_eq!(route.reasoning_effort.as_deref(), Some("high")); + assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::High)); } #[test] @@ -5147,7 +5145,6 @@ fn a_concrete_runtime_tier_is_not_mistaken_for_auto() { None, ModelRoute::Inherit, SubAgentThinking::Inherit, - "debug this release failure", ); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); @@ -7036,17 +7033,14 @@ fn subagent_model_strength_defaults_to_parent_even_when_parent_auto_model() { let mut runtime = stub_runtime().with_auto_model(true); runtime.model = "deepseek-v4-pro".to_string(); - for prompt in ["implement the release fix", "say hello"] { - let route = fallback_subagent_assignment_route( - &runtime, - None, - ModelRoute::Inherit, - SubAgentThinking::Inherit, - prompt, - ); - assert_eq!(route.model_route, ModelRoute::Inherit); - assert_eq!(route.model, "deepseek-v4-pro", "prompt {prompt:?}"); - } + let route = fallback_subagent_assignment_route( + &runtime, + None, + ModelRoute::Inherit, + SubAgentThinking::Inherit, + ); + assert_eq!(route.model_route, ModelRoute::Inherit); + assert_eq!(route.model, "deepseek-v4-pro"); } #[test] @@ -7059,7 +7053,6 @@ fn subagent_model_strength_faster_uses_known_family_sibling() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect one file", ); assert_eq!(route.model_route, ModelRoute::Faster); assert_eq!(route.model, "deepseek-v4-flash"); @@ -7075,7 +7068,6 @@ fn subagent_model_strength_explicit_model_wins_over_faster() { Some("deepseek-v4-pro".to_string()), ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect one file", ); assert_eq!( route.model_route, @@ -7094,7 +7086,6 @@ fn explicit_child_thinking_overrides_faster_default_off() { None, ModelRoute::Faster, SubAgentThinking::Effort(ReasoningEffort::High), - "inspect one file", ); assert_eq!(route.model, "deepseek-v4-flash"); assert_eq!(route.reasoning_effort.as_deref(), Some("high")); @@ -7102,7 +7093,7 @@ fn explicit_child_thinking_overrides_faster_default_off() { } #[test] -fn explicit_child_auto_thinking_resolves_from_child_prompt() { +fn explicit_child_auto_thinking_resolves_to_the_declared_default() { let runtime = stub_runtime().with_reasoning_effort(Some("off".to_string()), false); let route = fallback_subagent_assignment_route( @@ -7110,9 +7101,8 @@ fn explicit_child_auto_thinking_resolves_from_child_prompt() { None, ModelRoute::Inherit, SubAgentThinking::Auto, - "debug this release failure", ); - assert_eq!(route.reasoning_effort.as_deref(), Some("max")); + assert_eq!(route.reasoning_effort.as_deref(), Some("high")); } #[tokio::test] @@ -7126,7 +7116,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { agent_type: FleetRole, configured_model: Option<&'static str>, requested_route: ModelRoute, - prompt: &'static str, expected_route: ModelRoute, expected_model: &'static str, expected_reasoning: Option<&'static str>, @@ -7138,7 +7127,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { agent_type: FleetRole::Scout, configured_model: None, requested_route: ModelRoute::Inherit, - prompt: "inspect the parser and report what changed", expected_route: ModelRoute::Inherit, expected_model: "deepseek-v4-pro", expected_reasoning: Some("max"), @@ -7148,7 +7136,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { agent_type: FleetRole::Scout, configured_model: None, requested_route: ModelRoute::Faster, - prompt: "inspect the parser and report what changed", expected_route: ModelRoute::Faster, expected_model: "deepseek-v4-flash", expected_reasoning: Some("off"), @@ -7158,7 +7145,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { agent_type: FleetRole::Worker, configured_model: None, requested_route: ModelRoute::Inherit, - prompt: "synthesize the release blocker fix", expected_route: ModelRoute::Inherit, expected_model: "deepseek-v4-pro", expected_reasoning: Some("max"), @@ -7168,7 +7154,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { agent_type: FleetRole::Builder, configured_model: Some("deepseek-v4-flash"), requested_route: ModelRoute::Inherit, - prompt: "apply the narrow code edit", expected_route: ModelRoute::Fixed("deepseek-v4-flash".to_string()), expected_model: "deepseek-v4-flash", expected_reasoning: Some("max"), @@ -7180,7 +7165,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { let route = resolve_subagent_assignment_route( &runtime, case.configured_model.map(str::to_string), - case.prompt, &case.agent_type, case.requested_route.clone(), SubAgentThinking::Inherit, @@ -7212,7 +7196,10 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { } #[test] -fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { +fn subagent_auto_reasoning_resolves_to_the_declared_default() { + // #6290: a raw `auto` runtime tier used to classify the prompt (Low for + // lookup wording, Max for debug wording). Both wordings are gone with the + // classifier — the declared default is the only resolution. let runtime = stub_runtime().with_reasoning_effort(Some("high".to_string()), true); assert_eq!( @@ -7221,21 +7208,9 @@ fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { None, ModelRoute::Inherit, SubAgentThinking::Inherit, - "quick lookup", ) .reasoning_effort, - Some("low".to_string()) - ); - assert_eq!( - fallback_subagent_assignment_route( - &runtime, - None, - ModelRoute::Inherit, - SubAgentThinking::Inherit, - "debug this release failure" - ) - .reasoning_effort, - Some("max".to_string()) + Some("high".to_string()) ); } @@ -16305,17 +16280,16 @@ async fn faster_route_on_provider_without_known_sibling_stays_on_parent_model() let mut runtime = stub_runtime_for_provider("ollama").with_auto_model(true); runtime.model = "qwen3:32b".to_string(); - for prompt in ["hi", "please refactor the whole auth module for security"] { + { let route = resolve_subagent_assignment_route( &runtime, None, - prompt, &FleetRole::Worker, ModelRoute::Faster, SubAgentThinking::Inherit, ) .await; - assert_eq!(route.model, "qwen3:32b", "prompt {prompt:?}"); + assert_eq!(route.model, "qwen3:32b"); assert!( !route.model.contains("deepseek"), "no DeepSeek id may be fabricated: {route:?}" @@ -16332,7 +16306,6 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect one file", ); assert_eq!(route.model, "deepseek-v4-flash"); @@ -16343,7 +16316,6 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect docs", ); // GLM-5.2 faster/explore children route to GLM-5-Turbo (same-family fast // sibling), not down to GLM-5.1. @@ -16357,7 +16329,6 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect docs", ); assert_eq!(route.model, "z-ai/glm-5-turbo"); assert_ne!(route.model, "z-ai/glm-5.1"); @@ -16373,7 +16344,6 @@ fn inherit_route_remaps_stale_deepseek_model_for_sakana_provider() { None, ModelRoute::Inherit, SubAgentThinking::Inherit, - "summarize the repo layout", ); assert_eq!(route.model, "deepseek-v4-flash"); @@ -16396,7 +16366,6 @@ fn faster_route_remaps_stale_deepseek_model_for_sakana_provider() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "quick scan", ); let validated = ensure_subagent_model_for_provider(&runtime, &route.model_route, route.model) .expect("faster should remap to operator route"); @@ -16461,7 +16430,6 @@ fn gpt55_faster_route_stays_on_gpt55_with_low_reasoning() { None, ModelRoute::Faster, SubAgentThinking::Inherit, - "inspect one file", ); assert_eq!(route.model, "gpt-5.5"); assert!( diff --git a/crates/tui/src/turn_route_plan.rs b/crates/tui/src/turn_route_plan.rs index 15d8503689..e7422fd561 100644 --- a/crates/tui/src/turn_route_plan.rs +++ b/crates/tui/src/turn_route_plan.rs @@ -275,7 +275,7 @@ pub(crate) async fn plan_turn_route( auto_selection .as_ref() .and_then(|selection| selection.reasoning_effort) - .unwrap_or_else(|| crate::auto_reasoning::select(false, request.display_text)), + .unwrap_or_else(crate::auto_reasoning::select), ) } else { None From 72b028e5ee23bb31949f8853d671e224b4024e56 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 16:20:13 -0700 Subject: [PATCH 08/24] refactor(routing): Auto fallback is the declared default, not a content heuristic (determinism audit batch 2, #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without the flash classifier the router guessed cheap-vs-big from request wording (COMPLEX_KEYWORDS plus char-length thresholds) — host-side semantic determinism that made cost and quality depend on vocabulary. The fallback is now declared: the configured default model, with the explicit [auto] cost_saving opt-in pinning the runnable fast sibling. Request text is never inspected. The flash-classifier path is untouched. Receipts stay honest: new routes record LocalFallback(DeclaredDefault); content-derived reasons are never constructed again but retained so saved sessions deserialize (plus a serde alias on the renamed wrapper and a test pinning the pre-rework shape). TurnRoutingSource gains auto-local-fallback; the now-unused display_text planner input is deleted with its 4 call sites. Tests: model_routing 32 passed 0 failed; planner/receipt/preview neighbors 44 passed 0 failed; tools::subagent 672 passed with only the known pre-existing 5305 failure (fails on clean HEAD too). Clippy --all-targets, fmt, dead-code budget clean. --- .../session_lifecycle_regression_tests.rs | 4 +- crates/tui/src/config.rs | 2 +- crates/tui/src/core/engine/preview/tests.rs | 1 - crates/tui/src/model_routing.rs | 585 ++++++------------ crates/tui/src/tui/app.rs | 8 +- crates/tui/src/tui/app/tests.rs | 4 +- crates/tui/src/tui/ui/dispatch.rs | 1 - crates/tui/src/tui/ui/frame.rs | 1 - crates/tui/src/tui/ui/tests.rs | 6 +- crates/tui/src/turn_route_plan.rs | 27 +- 10 files changed, 214 insertions(+), 425 deletions(-) diff --git a/crates/tui/src/commands/session_lifecycle_regression_tests.rs b/crates/tui/src/commands/session_lifecycle_regression_tests.rs index 5d664c2de3..d2b2e73fb8 100644 --- a/crates/tui/src/commands/session_lifecycle_regression_tests.rs +++ b/crates/tui/src/commands/session_lifecycle_regression_tests.rs @@ -115,8 +115,8 @@ fn save_preserves_latest_auto_route_receipt() { }, scope: crate::model_routing::AutoRouteScope::ResolvedProvider, data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic, - reason: crate::model_routing::AutoRouteReason::LocalHeuristic( - crate::model_routing::AutoRouteHeuristicReason::ShortRequest, + reason: crate::model_routing::AutoRouteReason::LocalFallback( + crate::model_routing::AutoRouteHeuristicReason::DeclaredDefault, ), }; app.set_model_selection("auto".to_string()); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 17b3eb9fba..05053915fd 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -4754,7 +4754,7 @@ impl Config { /// Return `true` only when `[auto] cross_provider = true` is persisted in /// config (#4411). Auto mode otherwise stays on the active provider: the - /// classifier never sees other providers' routes, and the local heuristic + /// classifier never sees other providers' routes, and the local fallback /// never selects one. There is no interactive toggle — enabling /// cross-provider Auto is an explicit, durable config edit. #[must_use] diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index 01da0bd1e0..44308ccc0c 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -792,7 +792,6 @@ async fn plan_with_reasoning( reasoning_effort, mode: AppMode::Agent, content: prompt, - display_text: prompt, auto_router_context: "", should_auto_resolve: auto_model, allow_auto_router_response_cache: false, diff --git a/crates/tui/src/model_routing.rs b/crates/tui/src/model_routing.rs index 01c635b4e3..f2ea81c3da 100644 --- a/crates/tui/src/model_routing.rs +++ b/crates/tui/src/model_routing.rs @@ -22,9 +22,9 @@ use codewhale_models::{ContentBlock, Message, MessageRequest, MessageResponse, S /// Big/cheap model pair the auto-router may choose between for the active /// provider (#3018). /// -/// `cheap == None` means the provider has no known cheap tier: heuristics -/// stay on the current model (only thinking effort varies) and the network -/// router is skipped entirely (#1549). +/// `cheap == None` means the provider has no known cheap tier: the local +/// fallback stays on the current model (only thinking effort varies) and the +/// network router is skipped entirely (#1549). #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RouterCandidates { pub(crate) big: String, @@ -38,11 +38,6 @@ impl RouterCandidates { cheap: Some("deepseek-v4-flash".to_string()), } } - - /// The cheap-tier id, falling back to `big` when no cheap tier exists. - pub(crate) fn cheap_or_big(&self) -> &str { - self.cheap.as_deref().unwrap_or(&self.big) - } } /// Return a provider-owned strong/fast pair for model families whose catalog @@ -218,133 +213,6 @@ pub(crate) fn provider_router_candidates( } } -/// Auto-select a model based on request complexity. -/// -/// Short messages (<100 chars) go to the cheap tier. Long messages and -/// requests with complex keywords go to the big tier. The fallback is cheap. -/// This DeepSeek-candidate wrapper keeps legacy callers and tests intact; -/// provider-aware callers use [`auto_model_heuristic_for_candidates`]. -pub(crate) fn auto_model_heuristic(input: &str, current_model: &str) -> String { - auto_model_heuristic_for_candidates(input, current_model, &RouterCandidates::deepseek()) -} - -/// Candidate-aware variant of [`auto_model_heuristic`] (#3018). -pub(crate) fn auto_model_heuristic_for_candidates( - input: &str, - current_model: &str, - candidates: &RouterCandidates, -) -> String { - auto_model_heuristic_with_bias_for_candidates(input, current_model, false, candidates).model -} - -#[cfg(test)] -fn auto_model_heuristic_with_bias(input: &str, current_model: &str, cost_saving: bool) -> String { - auto_model_heuristic_with_bias_for_candidates( - input, - current_model, - cost_saving, - &RouterCandidates::deepseek(), - ) - .model -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AutoRouteHeuristicDecision { - model: String, - reason: AutoRouteHeuristicReason, -} - -fn auto_model_heuristic_with_bias_for_candidates( - input: &str, - _current_model: &str, - cost_saving: bool, - candidates: &RouterCandidates, -) -> AutoRouteHeuristicDecision { - let len = input.chars().count(); - let lower = input.to_lowercase(); - let borderline_pro_keywords: &[&str] = &[ - "implement", - "analyze", - "\u{5b9e}\u{73b0}", - "\u{5206}\u{6790}", - "\u{5be6}\u{73fe}", - ]; - let strong_match = COMPLEX_KEYWORDS - .iter() - .any(|kw| !borderline_pro_keywords.contains(kw) && lower.contains(kw)); - let borderline_match = borderline_pro_keywords.iter().any(|kw| lower.contains(kw)); - let pro_match = strong_match || (!cost_saving && borderline_match); - if pro_match { - return AutoRouteHeuristicDecision { - model: candidates.big.clone(), - reason: AutoRouteHeuristicReason::ComplexRequest, - }; - } - if len < 100 { - return AutoRouteHeuristicDecision { - model: candidates.cheap_or_big().to_string(), - reason: if cost_saving && borderline_match { - AutoRouteHeuristicReason::CostSavingPolicy - } else { - AutoRouteHeuristicReason::ShortRequest - }, - }; - } - let long_threshold = if cost_saving { 1_000 } else { 500 }; - if len > long_threshold { - return AutoRouteHeuristicDecision { - model: candidates.big.clone(), - reason: AutoRouteHeuristicReason::LongRequest, - }; - } - - AutoRouteHeuristicDecision { - model: candidates.cheap_or_big().to_string(), - reason: if cost_saving && borderline_match { - AutoRouteHeuristicReason::CostSavingPolicy - } else { - AutoRouteHeuristicReason::RoutineRequest - }, - } -} - -const COMPLEX_KEYWORDS: &[&str] = &[ - "refactor", - "architecture", - "design", - "debug", - "security", - "review", - "audit", - "migrate", - "optimize", - "rewrite", - "implement", - "analyze", - "\u{91cd}\u{6784}", - "\u{67b6}\u{6784}", - "\u{8bbe}\u{8ba1}", - "\u{8c03}\u{8bd5}", - "\u{5b89}\u{5168}", - "\u{5ba1}\u{67e5}", - "\u{5ba1}\u{8ba1}", - "\u{8fc1}\u{79fb}", - "\u{4f18}\u{5316}", - "\u{91cd}\u{5199}", - "\u{5b9e}\u{73b0}", - "\u{5206}\u{6790}", - "\u{91cd}\u{69cb}", - "\u{67b6}\u{69cb}", - "\u{8a2d}\u{8a08}", - "\u{8abf}\u{8a66}", - "\u{5be9}\u{67e5}", - "\u{5be9}\u{8a08}", - "\u{9077}\u{79fb}", - "\u{512a}\u{5316}", - "\u{91cd}\u{5beb}", - "\u{5be6}\u{73fe}", -]; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum AutoRouteSource { FlashRouter, @@ -398,7 +266,7 @@ pub(crate) enum AutoRouteScope { /// The network classifier saw only the active provider's runnable routes — /// the default Auto scope (#4411). ActiveProvider, - /// The provider-aware local heuristic selected within one resolved route. + /// The local declared fallback selected within one resolved route. ResolvedProvider, } @@ -438,16 +306,34 @@ impl AutoRouteDataPath { } /// Local signal that selected the provider-safe strong/fast candidate. +/// +/// Since the #6290 rework the local fallback never judges request content: +/// without the flash classifier there is no per-request signal, so the route +/// is the configured default (or the runnable fast sibling under the explicit +/// `[auto] cost_saving` opt-in). The content-derived variants below are never +/// constructed for new routes; they are retained so saved sessions from before +/// the rework still deserialize. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum AutoRouteHeuristicReason { + /// Legacy: the deleted keyword/length classifier judged the request + /// complex. Retained for saved-session serde compat only. ComplexRequest, + /// Legacy: the deleted length rule judged the request short. Retained + /// for saved-session serde compat only. ShortRequest, + /// Legacy: the deleted length rule judged the request long. Retained + /// for saved-session serde compat only. LongRequest, CostSavingPolicy, + /// Legacy: the deleted classifier judged the request routine. Retained + /// for saved-session serde compat only. RoutineRequest, NoFastSibling, NoRunnableCandidate, + /// The configured default model: no classifier was available and no + /// content signal was consulted. + DeclaredDefault, } impl AutoRouteHeuristicReason { @@ -461,6 +347,7 @@ impl AutoRouteHeuristicReason { Self::RoutineRequest => "routine request", Self::NoFastSibling => "no runnable fast sibling", Self::NoRunnableCandidate => "no runnable inventory candidate", + Self::DeclaredDefault => "configured default (no classifier)", } } } @@ -472,7 +359,10 @@ impl AutoRouteHeuristicReason { #[serde(rename_all = "snake_case")] pub(crate) enum AutoRouteReason { ClassifierRecommendation, - LocalHeuristic(AutoRouteHeuristicReason), + /// The `local_heuristic` alias keeps sessions saved before the #6290 + /// rework loadable; new sessions persist `local_fallback`. + #[serde(alias = "local_heuristic")] + LocalFallback(AutoRouteHeuristicReason), ClassifierFallback(AutoRouteHeuristicReason), } @@ -481,7 +371,7 @@ impl AutoRouteReason { pub(crate) fn label(self) -> String { match self { Self::ClassifierRecommendation => "classifier recommendation".to_string(), - Self::LocalHeuristic(reason) => format!("local heuristic: {}", reason.label()), + Self::LocalFallback(reason) => format!("local fallback: {}", reason.label()), Self::ClassifierFallback(reason) => { format!("classifier fallback: {}", reason.label()) } @@ -683,17 +573,17 @@ pub(crate) async fn resolve_auto_route_with_inventory_for_session_and_cache_poli ) -> Result { let inventory = ModelInventory::from_config(config); if !inventory.router_available { - // Fall back to heuristic-only auto routing when the flash router + // Fall back to declared-default auto routing when the flash router // is unavailable (e.g. non-DeepSeek providers like wanjie-ark). return Ok(normalize_auto_route_selection_for_config( config, - auto_route_from_inventory_heuristic(config, latest_request, &inventory), + auto_route_declared_fallback(config, &inventory), )); } - let heuristic = auto_route_from_inventory_heuristic(config, latest_request, &inventory); + let fallback = auto_route_declared_fallback(config, &inventory); if cfg!(test) { - return Ok(normalize_auto_route_selection_for_config(config, heuristic)); + return Ok(normalize_auto_route_selection_for_config(config, fallback)); } let selection = match auto_route_inventory_recommendation( @@ -708,11 +598,11 @@ pub(crate) async fn resolve_auto_route_with_inventory_for_session_and_cache_poli ) .await { - Ok(attempt) => auto_route_from_classifier_attempt(heuristic, &inventory, attempt), + Ok(attempt) => auto_route_from_classifier_attempt(fallback, &inventory, attempt), // Client construction/preparation failed before a provider request was // admitted. There is no provider usage to invent and no dropped // response receipt to claim. - Err(_) => auto_route_classifier_fallback(heuristic, &inventory), + Err(_) => auto_route_classifier_fallback(fallback, &inventory), }; Ok(normalize_auto_route_selection_for_config(config, selection)) } @@ -812,11 +702,16 @@ fn explicit_model_matches_candidate( .is_some_and(|model| candidate.model.eq_ignore_ascii_case(&model)) } -fn auto_route_from_inventory_heuristic( - config: &Config, - latest_request: &str, - inventory: &ModelInventory, -) -> AutoRouteSelection { +/// Declared local fallback for Auto routing when the flash classifier is +/// unavailable (or fails): the configured default model. +/// +/// There is no per-request signal here by design. Until the #6290 rework this +/// guessed cheap-vs-big from request wording (`COMPLEX_KEYWORDS` plus +/// char-length thresholds) — host-side semantic determinism that made cost +/// and quality depend on vocabulary. The only content-blind override is the +/// explicit `[auto] cost_saving` opt-in, which pins the runnable fast +/// sibling; providers without one stay on the default. +fn auto_route_declared_fallback(config: &Config, inventory: &ModelInventory) -> AutoRouteSelection { let Some(active) = inventory.active_default() else { let model = config.default_model(); return AutoRouteSelection { @@ -827,7 +722,7 @@ fn auto_route_from_inventory_heuristic( &model, AutoRouteScope::ResolvedProvider, AutoRouteDataPath::LocalHeuristic, - AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::NoRunnableCandidate), + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoRunnableCandidate), )), model, reasoning_effort: Some(crate::auto_reasoning::select()), @@ -837,37 +732,40 @@ fn auto_route_from_inventory_heuristic( routed_usage_dropped_records: 0, }; }; - // Use the candidates' cheap/big info for complexity-based routing. let router_candidates = provider_router_candidates(active.provider, &active.model); - let fast_is_runnable = router_candidates.cheap.as_deref().is_some_and(|model| { + let runnable_fast = router_candidates.cheap.as_deref().filter(|model| { inventory .candidate(active.provider, model) .is_some_and(|candidate| candidate.readiness.can_attempt()) }); - let decision = if fast_is_runnable { - auto_model_heuristic_with_bias_for_candidates( - latest_request, - &active.model, - config.auto_cost_saving(), - &router_candidates, - ) - } else { - AutoRouteHeuristicDecision { - model: active.model.clone(), - reason: AutoRouteHeuristicReason::NoFastSibling, + let (model, reason) = if config.auto_cost_saving() { + match runnable_fast { + Some(cheap) => ( + cheap.to_string(), + AutoRouteHeuristicReason::CostSavingPolicy, + ), + None => ( + active.model.clone(), + AutoRouteHeuristicReason::NoFastSibling, + ), } + } else { + ( + active.model.clone(), + AutoRouteHeuristicReason::DeclaredDefault, + ) }; AutoRouteSelection { provider: active.provider, receipt: Some(auto_route_receipt( inventory, active.provider, - &decision.model, + &model, AutoRouteScope::ResolvedProvider, AutoRouteDataPath::LocalHeuristic, - AutoRouteReason::LocalHeuristic(decision.reason), + AutoRouteReason::LocalFallback(reason), )), - model: decision.model, + model, reasoning_effort: Some(crate::auto_reasoning::select()), source: AutoRouteSource::Heuristic, routed_usage: Vec::new(), @@ -911,7 +809,7 @@ fn auto_route_from_classifier( } fn auto_route_from_classifier_attempt( - heuristic: AutoRouteSelection, + fallback: AutoRouteSelection, inventory: &ModelInventory, attempt: InventoryAutoRouteAttempt, ) -> AutoRouteSelection { @@ -922,7 +820,7 @@ fn auto_route_from_classifier_attempt( routed_usage_dropped_records, } = attempt; let mut selection = recommendation.map_or_else( - || auto_route_classifier_fallback(heuristic, inventory), + || auto_route_classifier_fallback(fallback, inventory), |recommendation| auto_route_from_classifier(inventory, recommendation), ); selection.routed_usage = routed_usage; @@ -932,22 +830,22 @@ fn auto_route_from_classifier_attempt( } fn auto_route_classifier_fallback( - mut heuristic: AutoRouteSelection, + mut fallback: AutoRouteSelection, inventory: &ModelInventory, ) -> AutoRouteSelection { - if let Some(receipt) = heuristic.receipt.as_mut() { - let heuristic_reason = match receipt.reason { - AutoRouteReason::LocalHeuristic(reason) + if let Some(receipt) = fallback.receipt.as_mut() { + let fallback_reason = match receipt.reason { + AutoRouteReason::LocalFallback(reason) | AutoRouteReason::ClassifierFallback(reason) => reason, - AutoRouteReason::ClassifierRecommendation => AutoRouteHeuristicReason::RoutineRequest, + AutoRouteReason::ClassifierRecommendation => AutoRouteHeuristicReason::DeclaredDefault, }; receipt.data_path = AutoRouteDataPath::Classifier { provider: inventory.router_provider, model: inventory.router_model.to_string(), }; - receipt.reason = AutoRouteReason::ClassifierFallback(heuristic_reason); + receipt.reason = AutoRouteReason::ClassifierFallback(fallback_reason); } - heuristic + fallback } fn auto_route_receipt( @@ -1639,10 +1537,10 @@ mod tests { assert_eq!(transport.routed_usage_drop_records.len(), 1); assert!(transport.routed_usage.is_empty()); - let heuristic = auto_route_from_inventory_heuristic(&config, "quick status", &inventory); - let valid = auto_route_from_classifier_attempt(heuristic.clone(), &inventory, valid); - let invalid = auto_route_from_classifier_attempt(heuristic.clone(), &inventory, invalid); - let incomplete = auto_route_from_classifier_attempt(heuristic, &inventory, incomplete); + let fallback = auto_route_declared_fallback(&config, &inventory); + let valid = auto_route_from_classifier_attempt(fallback.clone(), &inventory, valid); + let invalid = auto_route_from_classifier_attempt(fallback.clone(), &inventory, invalid); + let incomplete = auto_route_from_classifier_attempt(fallback, &inventory, incomplete); assert_eq!(valid.source, AutoRouteSource::FlashRouter); for fallback in [&invalid, &incomplete] { assert_eq!(fallback.source, AutoRouteSource::Heuristic); @@ -1736,53 +1634,6 @@ mod tests { ); } - #[test] - fn auto_model_heuristic_chinese_keywords_route_to_pro() { - for msg in [ - "\u{5e2e}\u{6211}\u{91cd}\u{6784}\u{8fd9}\u{4e2a}\u{6a21}\u{5757}", - "\u{8bbe}\u{8ba1}\u{6570}\u{636e}\u{5e93}\u{67b6}\u{6784}", - "\u{8c03}\u{8bd5}\u{5d29}\u{6e83}\u{95ee}\u{9898}", - "\u{5ba1}\u{8ba1}\u{5b89}\u{5168}\u{6f0f}\u{6d1e}", - "\u{8fc1}\u{79fb}\u{5230}\u{65b0}\u{6846}\u{67b6}", - "\u{4f18}\u{5316}\u{6027}\u{80fd}\u{74f6}\u{9888}", - "\u{5206}\u{6790}\u{8fd9}\u{6bb5}\u{4ee3}\u{7801}", - ] { - assert_eq!( - auto_model_heuristic(msg, "auto"), - "deepseek-v4-pro", - "expected Pro for `{msg}`", - ); - } - } - - #[test] - fn auto_model_heuristic_traditional_chinese_keywords_route_to_pro() { - for msg in [ - "\u{8acb}\u{91cd}\u{69cb}\u{6b64}\u{6a21}\u{7d44}", - "\u{67b6}\u{69cb}\u{8a2d}\u{8a08}", - "\u{4ee3}\u{78bc}\u{8abf}\u{8a66}", - "\u{5be9}\u{8a08}\u{6f0f}\u{6d1e}", - "\u{9077}\u{79fb}\u{5230}\u{65b0}\u{67b6}\u{69cb}", - "\u{512a}\u{5316}\u{6027}\u{80fd}", - "\u{91cd}\u{5beb}\u{4ee3}\u{78bc}", - "\u{5be6}\u{73fe}\u{65b0}\u{529f}\u{80fd}", - ] { - assert_eq!( - auto_model_heuristic(msg, "auto"), - "deepseek-v4-pro", - "expected Pro for `{msg}`", - ); - } - } - - #[test] - fn auto_model_heuristic_short_chinese_chat_stays_on_flash() { - assert_eq!( - auto_model_heuristic("\u{4f60}\u{597d}", "auto"), - "deepseek-v4-flash", - ); - } - #[test] fn auto_route_prompt_uses_current_session_mode() { let prompt = auto_route_prompt( @@ -2135,27 +1986,35 @@ mod tests { ..Default::default() }; - let route = - resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto") + // #6290 rework: without the flash classifier there is no + // per-request signal, so every wording resolves the same declared + // default — the short chat and the complex ask below must agree. + for prompt in [ + "quick status check", + "please refactor this architecture and audit its security boundaries", + ] { + let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto") .await .expect("inventory route should resolve with authenticated active provider"); - assert_eq!(route.provider, ApiProvider::Zai); - assert_eq!(route.model, crate::config::ZAI_GLM_5_3_FLASH_MODEL); - assert_eq!(route.source, AutoRouteSource::Heuristic); - let receipt = route.receipt.expect("Auto route receipt"); - assert_eq!(receipt.tier, AutoRouteTier::Fast); - assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider); - assert_eq!(receipt.data_path, AutoRouteDataPath::LocalHeuristic); - assert_eq!( - receipt.reason, - AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::ShortRequest) - ); - assert_eq!(receipt.pair.strong, crate::config::DEFAULT_ZAI_MODEL); - assert_eq!( - receipt.pair.fast.as_deref(), - Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL) - ); + assert_eq!(route.provider, ApiProvider::Zai); + assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL); + assert_eq!(route.source, AutoRouteSource::Heuristic); + let receipt = route.receipt.expect("Auto route receipt"); + assert_eq!(receipt.tier, AutoRouteTier::Strong); + assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider); + assert_eq!(receipt.data_path, AutoRouteDataPath::LocalHeuristic); + assert_eq!( + receipt.reason, + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault), + "prompt {prompt:?} must take the declared default, not a content judgment" + ); + assert_eq!(receipt.pair.strong, crate::config::DEFAULT_ZAI_MODEL); + assert_eq!( + receipt.pair.fast.as_deref(), + Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL) + ); + } } #[test] @@ -2258,10 +2117,10 @@ mod tests { #[tokio::test] #[allow(clippy::await_holding_lock)] - async fn active_provider_strong_fast_selection_survives_scoping() { - // Same-provider tier selection is the behavior scoping must not - // break: a complex request still reaches the active provider's strong - // tier, a trivial one still reaches its fast tier (#4411). + async fn active_provider_declared_default_survives_scoping() { + // #4411: scoping keeps Auto on the active provider. #6290 rework: + // without the flash classifier there is no per-request tier signal, + // so both wordings resolve the same declared default on Zai. let _env_lock = crate::test_support::lock_test_env(); let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY"); let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key"); @@ -2270,30 +2129,24 @@ mod tests { ..Default::default() }; - let strong = resolve_auto_route_with_inventory( - &config, + for prompt in [ "refactor the routing module and audit its security boundaries", - "", - "auto", - "auto", - ) - .await - .expect("strong-tier route"); - assert_eq!(strong.provider, ApiProvider::Zai); - assert_eq!(strong.model, crate::config::DEFAULT_ZAI_MODEL); - let strong_receipt = strong.receipt.expect("strong receipt"); - assert_eq!(strong_receipt.tier, AutoRouteTier::Strong); - assert_eq!(strong_receipt.scope, AutoRouteScope::ResolvedProvider); - - let fast = resolve_auto_route_with_inventory(&config, "hi", "", "auto", "auto") - .await - .expect("fast-tier route"); - assert_eq!(fast.provider, ApiProvider::Zai); - assert_eq!(fast.model, crate::config::ZAI_GLM_5_3_FLASH_MODEL); - assert_eq!( - fast.receipt.expect("fast receipt").tier, - AutoRouteTier::Fast - ); + "hi", + ] { + let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto") + .await + .expect("scoped Auto route"); + assert_eq!(route.provider, ApiProvider::Zai); + assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL); + let receipt = route.receipt.expect("scoped receipt"); + assert_eq!(receipt.tier, AutoRouteTier::Strong); + assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider); + assert_eq!( + receipt.reason, + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault), + "prompt {prompt:?}" + ); + } } #[test] @@ -2352,9 +2205,9 @@ mod tests { ..Default::default() }; let inventory = ModelInventory::from_config(&config); - let heuristic = auto_route_from_inventory_heuristic(&config, "quick status", &inventory); + let fallback = auto_route_declared_fallback(&config, &inventory); - let route = auto_route_classifier_fallback(heuristic, &inventory); + let route = auto_route_classifier_fallback(fallback, &inventory); assert_eq!(route.source, AutoRouteSource::Heuristic); let receipt = route.receipt.expect("fallback receipt"); @@ -2368,17 +2221,37 @@ mod tests { )); assert_eq!( receipt.reason, - AutoRouteReason::ClassifierFallback(AutoRouteHeuristicReason::ShortRequest) + AutoRouteReason::ClassifierFallback(AutoRouteHeuristicReason::DeclaredDefault) ); assert!(!receipt.reason.label().contains("secret-provider-error")); } + #[test] + fn pre_rework_receipt_shape_still_deserializes() { + // Sessions saved before the #6290 rework persist + // `local_heuristic` + content-derived reasons. They must keep + // loading: the wrapper arrives via serde alias, the legacy reasons + // are retained variants. + let reason: AutoRouteReason = + serde_json::from_str(r#"{"local_heuristic":"complex_request"}"#) + .expect("pre-rework receipt reason deserializes"); + assert_eq!( + reason, + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::ComplexRequest) + ); + let receipt: AutoRouteReceipt = serde_json::from_str( + r#"{"tier":"fast","pair":{"strong":"GLM-5.3","fast":"GLM-5.3-Flash"},"scope":"resolved_provider","data_path":"local_heuristic","reason":{"local_heuristic":"short_request"}}"#, + ) + .expect("pre-rework receipt deserializes"); + assert_eq!(receipt.reason.label(), "local fallback: short request"); + } + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn inventory_auto_route_never_falls_back_across_providers_by_default() { // #4411: the active provider has no usable credential, but another // provider does. Auto must stay on the active provider and report a - // no-runnable-candidate heuristic instead of silently spending the + // no-runnable-candidate fallback instead of silently spending the // other provider's key. let _env_lock = crate::test_support::lock_test_env(); let _deepseek = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-key"); @@ -2400,7 +2273,7 @@ mod tests { assert_eq!(receipt.scope, AutoRouteScope::ResolvedProvider); assert_eq!( receipt.reason, - AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::NoRunnableCandidate) + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::NoRunnableCandidate) ); } @@ -2428,13 +2301,13 @@ mod tests { .expect("opted-in route should fall back to an authenticated provider"); assert_eq!(route.provider, ApiProvider::Deepseek); - assert_eq!(route.model, "deepseek-v4-flash"); + assert_eq!(route.model, "deepseek-flash"); assert_eq!(route.source, AutoRouteSource::Heuristic); } #[tokio::test] #[allow(clippy::await_holding_lock)] - async fn inventory_auto_route_cost_saving_changes_borderline_zai_route() { + async fn inventory_auto_route_cost_saving_pins_fast_sibling() { let _env_lock = crate::test_support::lock_test_env(); let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY"); let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key"); @@ -2485,7 +2358,7 @@ mod tests { .map(|receipt| (receipt.tier, receipt.reason)), Some(( AutoRouteTier::Strong, - AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::ComplexRequest), + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::DeclaredDefault), )) ); assert_eq!( @@ -2495,7 +2368,7 @@ mod tests { .map(|receipt| (receipt.tier, receipt.reason)), Some(( AutoRouteTier::Fast, - AutoRouteReason::LocalHeuristic(AutoRouteHeuristicReason::CostSavingPolicy), + AutoRouteReason::LocalFallback(AutoRouteHeuristicReason::CostSavingPolicy), )) ); } @@ -2512,26 +2385,16 @@ mod tests { ..Default::default() }; - let route = - resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto") + // #6290 rework: no classifier, no content signal — both wordings + // take the declared Wanjie default. + for prompt in ["quick status check", "please refactor this architecture"] { + let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto") .await - .expect("heuristic-only Wanjie route should resolve"); - assert_eq!(route.provider, ApiProvider::WanjieArk); - assert_eq!(route.model, "deepseek-v4-flash"); - assert_eq!(route.source, AutoRouteSource::Heuristic); - - let route = resolve_auto_route_with_inventory( - &config, - "please refactor this architecture", - "", - "auto", - "auto", - ) - .await - .expect("complex Wanjie route should resolve"); - assert_eq!(route.provider, ApiProvider::WanjieArk); - assert_eq!(route.model, "deepseek-v4-pro"); - assert_eq!(route.source, AutoRouteSource::Heuristic); + .expect("declared-default Wanjie route should resolve"); + assert_eq!(route.provider, ApiProvider::WanjieArk); + assert_eq!(route.model, "deepseek-reasoner"); + assert_eq!(route.source, AutoRouteSource::Heuristic); + } } #[tokio::test] @@ -2547,84 +2410,18 @@ mod tests { ..Default::default() }; - let route = - resolve_auto_route_with_inventory(&config, "quick status check", "", "auto", "auto") + // #6290 rework: no classifier, no content signal — both wordings + // take the declared Volcengine default. + for prompt in ["quick status check", "please refactor this architecture"] { + let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto") .await - .expect("heuristic-only Volcengine route should resolve"); - assert_eq!(route.provider, ApiProvider::Volcengine); - assert_eq!(route.model, "DeepSeek-V4-Flash"); - assert_eq!(route.source, AutoRouteSource::Heuristic); - - let route = resolve_auto_route_with_inventory( - &config, - "please refactor this architecture", - "", - "auto", - "auto", - ) - .await - .expect("complex Volcengine route should resolve"); - assert_eq!(route.provider, ApiProvider::Volcengine); - assert_eq!(route.model, "DeepSeek-V4-Pro"); - assert_eq!(route.source, AutoRouteSource::Heuristic); - } - - #[test] - fn auto_heuristic_default_routes_implement_to_pro() { - assert_eq!( - auto_model_heuristic_with_bias("Please implement a binary search", "auto", false), - "deepseek-v4-pro" - ); - } - - #[test] - fn auto_heuristic_cost_saving_keeps_borderline_keywords_on_flash() { - assert_eq!( - auto_model_heuristic_with_bias("Please implement a binary search", "auto", true), - "deepseek-v4-flash" - ); - assert_eq!( - auto_model_heuristic_with_bias("analyze this snippet", "auto", true), - "deepseek-v4-flash" - ); - } - - #[test] - fn auto_heuristic_strong_keywords_still_route_to_pro_under_cost_saving() { - for kw in [ - "refactor", - "architecture", - "design", - "debug", - "security", - "review", - "audit", - "migrate", - "optimize", - "rewrite", - ] { - let req = format!("Please {kw} this module"); - assert_eq!( - auto_model_heuristic_with_bias(&req, "auto", true), - "deepseek-v4-pro", - "expected Pro for strong keyword `{kw}` even in cost-saving mode" - ); + .expect("declared-default Volcengine route should resolve"); + assert_eq!(route.provider, ApiProvider::Volcengine); + assert_eq!(route.model, "deepseek-v4-pro"); + assert_eq!(route.source, AutoRouteSource::Heuristic); } } - #[test] - fn auto_heuristic_cost_saving_raises_long_message_threshold() { - let body = "filler sentence. ".repeat(40); - assert_eq!( - auto_model_heuristic_with_bias(&body, "auto", false), - "deepseek-v4-pro" - ); - assert_eq!( - auto_model_heuristic_with_bias(&body, "auto", true), - "deepseek-v4-flash" - ); - } - #[test] fn provider_router_candidates_cover_known_provider_classes() { use crate::config::ApiProvider; @@ -2747,28 +2544,28 @@ mod tests { } } - #[test] - fn heuristic_without_cheap_tier_always_returns_current_model() { - // #3018 AC: Ollama + auto must never fabricate a DeepSeek id. - let candidates = RouterCandidates { - big: "qwen3:32b".to_string(), - cheap: None, + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn declared_fallback_without_cheap_tier_stays_on_default_model() { + // #3018 AC: Ollama + auto must never fabricate a DeepSeek id. The + // declared fallback returns the configured default verbatim, so no + // sibling id can be invented regardless of request wording. + let _env_lock = crate::test_support::lock_test_env(); + let config = Config { + provider: Some("ollama".to_string()), + ..Default::default() }; - for cost_saving in [false, true] { - for prompt in [ - "hi", - "please refactor the auth module for security", - &"long filler sentence. ".repeat(60), - ] { - let model = auto_model_heuristic_with_bias_for_candidates( - prompt, - "qwen3:32b", - cost_saving, - &candidates, - ) - .model; - assert_eq!(model, "qwen3:32b", "prompt {prompt:?}"); - } + for prompt in ["hi", "please refactor the auth module for security"] { + let route = resolve_auto_route_with_inventory(&config, prompt, "", "auto", "auto") + .await + .expect("ollama Auto route should resolve"); + assert_eq!(route.provider, ApiProvider::Ollama); + assert_eq!(route.model, config.default_model()); + assert!( + !route.model.to_ascii_lowercase().contains("deepseek"), + "no DeepSeek id may be fabricated: {}", + route.model + ); } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 36552b6fcf..7a58bfeb42 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1584,10 +1584,10 @@ pub struct App { pub configured_models: Vec, /// Exact provider/model pins loaded from settings, in user order. pub pinned_models: Vec, - /// When true, the model is auto-selected based on request complexity - /// rather than using a fixed model. The `/model auto` command sets this. - /// `dispatch_user_message` calls `auto_model_heuristic` to resolve the - /// effective model for each outbound message. + /// When true, the model is auto-selected rather than using a fixed + /// model. The `/model auto` command sets this. The flash classifier + /// picks the per-turn model when available; otherwise the configured + /// default model is used (no request-content signal). pub auto_model: bool, /// Last concrete model chosen while `auto_model` is active. pub last_effective_model: Option, diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index 2fe9559b4b..00b527758d 100644 --- a/crates/tui/src/tui/app/tests.rs +++ b/crates/tui/src/tui/app/tests.rs @@ -558,8 +558,8 @@ fn auto_reasoning_change_invalidates_the_previous_route_and_receipt() { }, scope: crate::model_routing::AutoRouteScope::ResolvedProvider, data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic, - reason: crate::model_routing::AutoRouteReason::LocalHeuristic( - crate::model_routing::AutoRouteHeuristicReason::ComplexRequest, + reason: crate::model_routing::AutoRouteReason::LocalFallback( + crate::model_routing::AutoRouteHeuristicReason::DeclaredDefault, ), }); app.last_effective_reasoning_effort = diff --git a/crates/tui/src/tui/ui/dispatch.rs b/crates/tui/src/tui/ui/dispatch.rs index 5b78b169b0..05cb97b408 100644 --- a/crates/tui/src/tui/ui/dispatch.rs +++ b/crates/tui/src/tui/ui/dispatch.rs @@ -765,7 +765,6 @@ pub(crate) async fn spawned_dispatch_inner( reasoning_effort: prepare.reasoning_effort, mode: prepare.mode, content: &prepare.content, - display_text: &prepare.message.display, auto_router_context: &prepare.auto_router_context, should_auto_resolve: prepare.should_auto_resolve, allow_auto_router_response_cache: true, diff --git a/crates/tui/src/tui/ui/frame.rs b/crates/tui/src/tui/ui/frame.rs index b8e9c78897..07c01cc7e8 100644 --- a/crates/tui/src/tui/ui/frame.rs +++ b/crates/tui/src/tui/ui/frame.rs @@ -837,7 +837,6 @@ pub(crate) async fn build_preview_request_inputs( reasoning_effort: app.reasoning_effort, mode: app.mode, content: &content, - display_text: &prompt, auto_router_context: &auto_router::recent_auto_router_context(&app.api_messages), should_auto_resolve: false, allow_auto_router_response_cache: false, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 639d4eae5c..be0c56bcb7 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -16004,8 +16004,8 @@ fn turn_started_route_is_captured_before_cancel_suppression() { }, scope: crate::model_routing::AutoRouteScope::ResolvedProvider, data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic, - reason: crate::model_routing::AutoRouteReason::LocalHeuristic( - crate::model_routing::AutoRouteHeuristicReason::ShortRequest, + reason: crate::model_routing::AutoRouteReason::LocalFallback( + crate::model_routing::AutoRouteHeuristicReason::DeclaredDefault, ), }); let created_at = chrono::Utc::now(); @@ -20950,7 +20950,7 @@ fn auto_route_receipt_survives_session_snapshot_and_restore() { }, scope: crate::model_routing::AutoRouteScope::ResolvedProvider, data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic, - reason: crate::model_routing::AutoRouteReason::LocalHeuristic( + reason: crate::model_routing::AutoRouteReason::LocalFallback( crate::model_routing::AutoRouteHeuristicReason::ComplexRequest, ), }; diff --git a/crates/tui/src/turn_route_plan.rs b/crates/tui/src/turn_route_plan.rs index e7422fd561..2706ca3bcb 100644 --- a/crates/tui/src/turn_route_plan.rs +++ b/crates/tui/src/turn_route_plan.rs @@ -41,9 +41,6 @@ pub(crate) struct TurnRoutePlanRequest<'a> { /// Model-facing content of the next user message (file mentions and skill /// wrapping already resolved). This is what the auto router classifies. pub(crate) content: &'a str, - /// The user's display text, used by the heuristic and auto-reasoning - /// fallbacks exactly as production does. - pub(crate) display_text: &'a str, pub(crate) auto_router_context: &'a str, pub(crate) should_auto_resolve: bool, /// Production dispatch may use the deterministic response cache for the @@ -88,8 +85,9 @@ pub(crate) enum TurnRoutingSource { ActiveFixedRoute, /// Auto model routing used its provider-backed classifier. AutoProviderClassifier, - /// Auto model routing used the local deterministic fallback heuristic. - AutoLocalHeuristic, + /// Auto model routing fell back to the local declared default (no + /// classifier signal; request wording never inspected). + AutoLocalFallback, } impl TurnRoutingSource { @@ -97,7 +95,7 @@ impl TurnRoutingSource { match self { Self::ActiveFixedRoute => "active-fixed-route", Self::AutoProviderClassifier => "auto-provider-classifier", - Self::AutoLocalHeuristic => "auto-local-heuristic", + Self::AutoLocalFallback => "auto-local-fallback", } } } @@ -170,13 +168,14 @@ pub(crate) async fn plan_turn_route( .map(|selection| selection.provider) .unwrap_or(request.api_provider); + // Without an Auto selection there is no per-request signal, so the + // route is the configured model — the same declared default the local + // fallback uses. Request wording is never inspected (#6290 rework). let effective_model = if request.auto_model { auto_selection .as_ref() .map(|selection| selection.model.clone()) - .unwrap_or_else(|| { - crate::model_routing::auto_model_heuristic(request.display_text, request.app_model) - }) + .unwrap_or_else(|| request.app_model.to_string()) } else { request.app_model.to_string() }; @@ -268,7 +267,7 @@ pub(crate) async fn plan_turn_route( // Model selection and reasoning selection are independent. A fixed // reasoning preference survives auto model routing and is normalized // against the concrete route below; only an explicit `auto` delegates the - // tier to the classifier/heuristic. + // tier to the classifier/declared fallback. let auto_controls_reasoning = request.reasoning_effort == ReasoningEffort::Auto; let selected_reasoning_effort = if auto_controls_reasoning { Some( @@ -295,7 +294,7 @@ pub(crate) async fn plan_turn_route( } else if auto_selection.is_some() { TurnRoutingSource::AutoProviderClassifier } else { - TurnRoutingSource::AutoLocalHeuristic + TurnRoutingSource::AutoLocalFallback }; Ok(PlannedTurnRoute { @@ -405,7 +404,6 @@ mod tests { reasoning_effort: ReasoningEffort::Low, mode: AppMode::Agent, content: "explain this function", - display_text: "explain this function", auto_router_context: "", should_auto_resolve: false, allow_auto_router_response_cache: false, @@ -417,10 +415,7 @@ mod tests { .await .expect("plan auto-model turn"); - assert_eq!( - planned.routing_source, - TurnRoutingSource::AutoLocalHeuristic - ); + assert_eq!(planned.routing_source, TurnRoutingSource::AutoLocalFallback); assert!(!planned.auto_controls_reasoning); assert_eq!(planned.selected_reasoning_effort, None); // First-party DeepSeek routes carry low as the real wire tier From 27ae6769725590dd66f220a2d76e94d3d44fa406 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 16:40:58 -0700 Subject: [PATCH 09/24] refactor(goal): delete the natural-language /goal prose parser (determinism audit batch 2, #3) The host parsed ten prose phrasings plus a clause allow-list ("make it your /goal to ...") into durable goals before the provider call. Per the founder direction the model decides when a goal is useful: prose asks now reach the model, which calls create_goal; the deterministic user path is the leading /goals command, unchanged. Same philosophy as 4de9e9e28, which deleted the Operate verb-list promotion. Tests: tools::goal 29 passed 0 failed; engine goal suites (rewritten: prose never activates, seeded-goal flows intact) 12 passed 0 failed. Clippy --all-targets, fmt, dead-code budget clean. --- crates/tui/src/core/engine.rs | 71 +++----------- crates/tui/src/core/engine/tests.rs | 95 +++++++++--------- crates/tui/src/tools/goal.rs | 144 ---------------------------- 3 files changed, 64 insertions(+), 246 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 4fd64ec940..e3621f684d 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -40,8 +40,7 @@ use crate::route_runtime::{ ResolvedRuntimeRoute, ValidatedRuntimeRoute, resolve_runtime_route_for_identity, }; use crate::tools::goal::{ - GoalPauseReason, GoalSnapshot, GoalStatus, SharedGoalState, explicit_goal_directive, - new_shared_goal_state, + GoalPauseReason, GoalSnapshot, GoalStatus, SharedGoalState, new_shared_goal_state, }; use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; @@ -4853,66 +4852,20 @@ impl Engine { if autonomous && self.cancel_token.is_cancelled() { return SendMessageOutcome::NotStarted { error: None }; } - let mut goal_objective = goal_objective; - let mut goal_token_budget = goal_token_budget; - let mut goal_status = goal_status; let initial_usage_owner = compaction.runtime_cost_owner.clone(); - // A literal natural-language `/goal` declaration is control-plane - // intent, not a suggestion that each provider may acknowledge or - // ignore. Activate it through the same GoalState::create path as the - // model-visible create_goal tool before constructing any provider - // request. Only structurally external user input can authorize this; - // runtime text, recalled memory, handoffs, and pasted multi-line - // transcripts cannot create a goal. + // Goals are created by the model (`create_goal`) or by the leading + // `/goal ` command; the host never infers one from + // wording (docs/design/TUI_DECONSTRUCTION.md — founder clarification + // 2026-09-09: the model decides when a goal is useful). The + // natural-language `/goal` prose parser that used to recognize + // "make it your /goal to ..." is gone with the #6290 rework — a + // prose ask reaches the model, which calls `create_goal` when a goal + // is actually useful. // - // Goals are created by the model (`create_goal`) or by this literal - // user declaration; the host never infers one from wording. The - // verb-list promotion that used to turn ordinary Operate prompts into - // goals is gone (docs/design/TUI_DECONSTRUCTION.md — founder - // clarification 2026-09-09: the model decides when a goal is useful). - // `GoalState::create` still refuses while an unfinished goal exists, - // so a paused or blocked goal is never silently replaced. - // - // KV-cache effect: this only selects the already-existing volatile - // contributor. It adds no new stable-prefix text. - let goal_request = if provenance.can_authorize_work() { - explicit_goal_directive(&content) - } else { - None - }; - if let Some(directive) = goal_request { - let result = self - .config - .goal_state - .lock() - .map_err(|_| "goal state lock poisoned".to_string()) - .and_then(|mut state| { - state - .create(directive.objective, None) - .map_err(str::to_string)?; - Ok(state.snapshot()) - }); - match result { - Ok(snapshot) => { - goal_objective.clone_from(&snapshot.objective); - goal_token_budget = snapshot.token_budget; - goal_status = GoalStatus::Active; - // Publish before TurnStarted/provider dispatch so the TUI - // and durable runtime host observe the real goal action, - // even when this model would otherwise reply only in prose. - let _ = self.tx_event.send(Event::GoalUpdated { snapshot }).await; - } - Err(error) => { - let _ = self - .tx_event - .send(Event::status(format!( - "Requested /goal was not created: {error}" - ))) - .await; - } - } - } + // KV-cache effect: none. Goal state still flows through the existing + // volatile contributor; nothing here touches the + // stable prefix. let effective_provider = route.identity.provider; let provider_identity = route.identity.key.clone(); diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 5fb787f3d4..69a69ff1d7 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -3160,7 +3160,7 @@ fn goal_custom_route_config() -> Config { } #[tokio::test] -async fn explicit_natural_goal_activates_before_provider_request() { +async fn ordinary_prose_never_activates_a_goal() { let request_entered = std::sync::Arc::new(tokio::sync::Notify::new()); let release_request = std::sync::Arc::new(tokio::sync::Notify::new()); let model = std::sync::Arc::new(FirstRequestGatedGoalModelClient { @@ -3213,24 +3213,25 @@ async fn explicit_natural_goal_activates_before_provider_request() { .await .expect("send explicit natural goal turn"); - let mut saw_goal_before_turn = false; + // #6290 rework: the natural-language `/goal` prose parser is gone. The + // same wording that used to activate a goal is now an ordinary turn; + // only the model (`create_goal`) or the `/goal` command creates one. + let mut saw_goal = false; loop { let event = tokio::time::timeout(model_turn_event_timeout(), async { handle.rx_event.write().await.recv().await }) .await - .expect("explicit goal event timeout") - .expect("explicit goal event"); + .expect("prose goal event timeout") + .expect("prose goal event"); match event { - Event::GoalUpdated { snapshot } => { - assert_eq!(snapshot.objective.as_deref(), Some("solve navier stokes")); - assert_eq!(snapshot.status, "active"); - saw_goal_before_turn = true; + Event::GoalUpdated { .. } => { + saw_goal = true; } Event::TurnStarted { .. } => { assert!( - saw_goal_before_turn, - "durable goal must be published before provider work starts" + !saw_goal, + "ordinary prose must not publish a goal before provider work starts" ); break; } @@ -3241,29 +3242,15 @@ async fn explicit_natural_goal_activates_before_provider_request() { tokio::time::timeout(model_turn_event_timeout(), request_entered.notified()) .await .expect("provider request was never entered"); - let snapshot = goal_state.lock().expect("goal lock").snapshot(); - assert_eq!(snapshot.objective.as_deref(), Some("solve navier stokes")); - assert!(snapshot.is_active()); - - // Stop autonomous continuation after the one provider-boundary receipt. - handle - .send(Op::SetGoalStatus { - goal_id: None, - status: crate::tools::goal::GoalStatus::Paused, - clear: false, - }) - .await - .expect("queue goal pause"); release_request.notify_one(); let _ = tokio::time::timeout(model_turn_event_timeout(), handle.get_session_snapshot()) .await - .expect("goal pause did not settle") - .expect("post-goal session snapshot"); + .expect("turn did not settle") + .expect("post-turn session snapshot"); + let snapshot = goal_state.lock().expect("goal lock").snapshot(); + assert_eq!(snapshot.objective.as_deref(), None); + assert!(!snapshot.is_active()); assert_eq!(model.calls.load(std::sync::atomic::Ordering::SeqCst), 1); - assert_eq!( - goal_state.lock().expect("goal lock").snapshot().status, - "paused" - ); handle.send(Op::Shutdown).await.expect("shutdown engine"); run_task.await.expect("engine task"); @@ -3395,11 +3382,16 @@ async fn operate_never_promotes_wording_to_a_goal() { assert!(!active); assert_eq!(contracts, 0, "Work never sees the Operate contract"); - // An explicit declaration still creates one, through the same path. + // #6290 rework: even an explicit-looking declaration is ordinary + // prose now — the host never parses it, and the model decides goals + // through `create_goal`. let (objective, active, contracts) = operate_goal_probe(AppMode::Operate, "Please set /goal to ship the release").await; - assert_eq!(objective.as_deref(), Some("ship the release")); - assert!(active, "an explicit declaration must create the goal"); + assert_eq!( + objective, None, + "prose asking for a goal must not create one host-side" + ); + assert!(!active); assert_eq!(contracts, 1); } @@ -3507,9 +3499,20 @@ async fn operate_contract_is_appended_once_and_an_existing_goal_is_never_replace let first_objective = "Migrate the settings loader to the new config crate and keep the old keys readable"; - let first = format!("Please set /goal to {first_objective}"); + // #6290 rework: prose no longer creates goals, so the unfinished goal + // this test needs is seeded directly — the same `GoalState::create` path + // the `/goal` command and the model's `create_goal` tool use. + goal_state + .lock() + .expect("goal lock") + .create(first_objective.to_string(), None) + .expect("seed unfinished goal"); handle - .send(send(&first, None, crate::tools::goal::GoalStatus::Active)) + .send(send( + first_objective, + Some(first_objective.to_string()), + crate::tools::goal::GoalStatus::Active, + )) .await .expect("send first Operate turn"); tokio::time::timeout(model_turn_event_timeout(), first_entered.notified()) @@ -12372,10 +12375,10 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() { "\"finish_reason\":\"stop\"}]}\n\n", "data: [DONE]\n\n", ); - // Operate no longer infers a goal from wording, so this fixture declares - // one explicitly; after the approved shell runs, the model seals it - // through the same `update_goal` tool a live Operate turn uses, and only - // then does the final "done" arrive. + // The goal this fixture seals is seeded directly (prose no longer + // creates goals since the #6290 rework); after the approved shell runs, + // the model seals it through the same `update_goal` tool a live Operate + // turn uses, and only then does the final "done" arrive. let goal_seal_marker = "goal-seal-receipt-0902"; let goal_sse = concat!( "data: {\"id\":\"chatcmpl-operate-goal\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[", @@ -12455,19 +12458,26 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() { }, &api_config, ); + engine + .config + .goal_state + .lock() + .expect("goal lock") + .create("write the requested local fixture".to_string(), None) + .expect("seed fixture goal"); let handle_for_approval = handle.clone(); let run_task = tokio::spawn(engine.run()); handle .send(Op::SendMessage(TurnSpec { max_output_tokens: None, - content: "Please set /goal to write the requested local fixture".to_string(), + content: "Write the requested local fixture to the workspace".to_string(), images: Vec::new(), mode: AppMode::Operate, route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL), compaction: Box::new(CompactionConfig::default()), initial_routed_usage: Box::default(), - goal_objective: None, + goal_objective: Some("write the requested local fixture".to_string()), goal_token_budget: None, goal_status: crate::tools::goal::GoalStatus::Active, reasoning_effort: None, @@ -12499,9 +12509,8 @@ async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() { Event::ApprovalRequired { id, tool_name, .. } => { saw_approval = true; assert_eq!(tool_name, "Bash"); - // No goal is created for this prompt: goals are model-decided - // (`create_goal`), so there is no goal-continuation loop to - // pause in this mock. + // The seeded fixture goal is orthogonal to this gate: + // Operate uses the normal approval flow either way. handle_for_approval .approve_tool_call(id) .await diff --git a/crates/tui/src/tools/goal.rs b/crates/tui/src/tools/goal.rs index 56f7d062b0..a8bfb5296b 100644 --- a/crates/tui/src/tools/goal.rs +++ b/crates/tui/src/tools/goal.rs @@ -44,116 +44,6 @@ pub fn new_shared_goal_state_from_snapshot(snapshot: &GoalSnapshot) -> SharedGoa Arc::new(Mutex::new(GoalState::from_snapshot(snapshot))) } -/// A goal declaration stated in ordinary user prose rather than as a leading -/// `/goal ` command. -/// -/// This intentionally recognizes only a narrow, explicit `/goal` directive. -/// Ordinary long-running requests are not silently promoted to goals, and a -/// quoted multi-line transcript cannot authorize one through a later line. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExplicitGoalDirective { - pub objective: String, -} - -/// Extract an explicit natural-language `/goal` declaration from the user's -/// first non-empty line. -/// -/// The provider never participates in this decision. That makes an explicit -/// declaration behave the same on DeepSeek, Zai, and every compatible route, -/// while keeping ordinary tasks and discussion *about* `/goal` untouched. -#[must_use] -pub fn explicit_goal_directive(input: &str) -> Option { - let line = input.lines().find(|line| !line.trim().is_empty())?.trim(); - let lower = line.to_ascii_lowercase(); - - // Objective follows the directive. - for pattern in [ - "make it your /goal to", - "make this your /goal to", - "make that your /goal to", - "set your /goal to", - "set my /goal to", - "set the /goal to", - "set /goal to", - "your /goal is", - "my /goal is", - "the /goal is", - ] { - let Some(index) = lower.find(pattern) else { - continue; - }; - if !is_direct_goal_clause(&lower[..index]) { - continue; - } - let objective = normalize_explicit_goal_objective(&line[index + pattern.len()..])?; - return Some(ExplicitGoalDirective { objective }); - } - - // Objective precedes the marker: "make solving X your /goal". - for marker in [" your /goal", " my /goal", " the /goal"] { - let Some(marker_index) = lower.find(marker) else { - continue; - }; - let before_marker = &lower[..marker_index]; - let Some(make_index) = before_marker.rfind("make ") else { - continue; - }; - if !is_direct_goal_clause(&lower[..make_index]) { - continue; - } - let objective = - normalize_explicit_goal_objective(&line[make_index + "make ".len()..marker_index])?; - if matches!( - objective.to_ascii_lowercase().as_str(), - "it" | "this" | "that" - ) { - continue; - } - return Some(ExplicitGoalDirective { objective }); - } - - None -} - -fn is_direct_goal_clause(prefix: &str) -> bool { - let prefix = prefix.trim_end(); - if prefix.is_empty() { - return true; - } - [ - "please", - "and", - "then", - "also", - "now", - "you to", - "need to", - "can you", - "could you", - "would you", - "can we", - "could we", - "should", - "must", - "-", - ";", - ":", - ",", - ] - .iter() - .any(|ending| prefix.ends_with(ending)) -} - -fn normalize_explicit_goal_objective(raw: &str) -> Option { - let objective = raw - .trim() - .trim_start_matches([':', '-', '–', '—']) - .trim() - .trim_end_matches(['.', '!', '?']) - .trim(); - (!objective.is_empty()).then(|| objective.to_string()) -} - /// Runtime status for a goal. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum GoalStatus { @@ -1301,40 +1191,6 @@ mod tests { use super::*; - #[test] - fn explicit_goal_directive_extracts_user_requested_objective() { - let request = explicit_goal_directive( - "hello - take over and make it your /goal to solve navier stokes", - ) - .expect("explicit /goal request"); - assert_eq!(request.objective, "solve navier stokes"); - - let request = explicit_goal_directive("Please make ship the release your /goal.") - .expect("objective-before-marker request"); - assert_eq!(request.objective, "ship the release"); - - let request = explicit_goal_directive("Could you set /goal to repair provider routing?") - .expect("set /goal request"); - assert_eq!(request.objective, "repair provider routing"); - } - - #[test] - fn explicit_goal_directive_rejects_ordinary_or_quoted_goal_discussion() { - for input in [ - "solve navier stokes", - "why didn't you make it your /goal to solve navier stokes?", - "what does /goal do?", - "review the /goal implementation", - "see this transcript where /goal was ignored:\nhello - take over and make it your /goal to solve navier stokes", - ] { - assert_eq!( - explicit_goal_directive(input), - None, - "must not activate from: {input}" - ); - } - } - #[tokio::test] async fn update_goal_rejects_objective_knob_instead_of_ignoring_it() { // #5123-class: `objective` used to return a success receipt with no From ff15f9580f9bbb50722a36eac140bdab53e4bc10 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 16:48:56 -0700 Subject: [PATCH 10/24] refactor(plugins): delete the dead proactive score gate (determinism audit batch 2, #5) PROACTIVE_MIN_SCORE and RecommendOptions::proactive() had no production callers: the proactive toast and the fragment are driven by the declared-keyword matcher, and the score rubric only ranks the user-invoked /plugin suggest list. The gate gated nothing. Tests now pin the real default options; the description-floor test pinned only the dead gate and goes with it. Tests: plugins::recommend + plugin_suggestions + skills::recommend 20 passed 0 failed; suggest 74 passed 0 failed. Clippy --all-targets, fmt clean. --- crates/tui/src/plugins/recommend.rs | 58 +++++++---------------------- 1 file changed, 14 insertions(+), 44 deletions(-) diff --git a/crates/tui/src/plugins/recommend.rs b/crates/tui/src/plugins/recommend.rs index e91536f737..8adc768efd 100644 --- a/crates/tui/src/plugins/recommend.rs +++ b/crates/tui/src/plugins/recommend.rs @@ -1,9 +1,12 @@ -//! Deterministic plugin suggestions for a user task. +//! Plugin suggestions for a user task. //! //! Ranks installed bundles and locally-added marketplace candidates. A //! suggestion is never an install, trust, enable, or network side effect. -//! Proactive toasts must use a high `min_score` so description-only matches -//! do not nag; `/plugin suggest` can rank more loosely. +//! +//! The proactive toast and the `` fragment are driven +//! by the declared-keyword matcher (`match_plugin_for_draft`), not by the +//! score below: there is no host score gate on what the model sees. Scoring +//! only ranks the user-invoked `/plugin suggest` list. use std::collections::{BTreeMap, BTreeSet}; @@ -15,8 +18,6 @@ use super::registry::PluginRegistry; use super::types::LoadedPlugin; const DEFAULT_LIMIT: usize = 3; -/// Keyword and name matches score 700–900; description fallbacks are ~120. -pub const PROACTIVE_MIN_SCORE: usize = 700; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RecommendOptions { @@ -35,17 +36,6 @@ impl Default for RecommendOptions { } } -impl RecommendOptions { - #[must_use] - pub fn proactive() -> Self { - Self { - limit: 1, - min_score: PROACTIVE_MIN_SCORE, - include_active: false, - } - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginMatchSource { Installed { id: String }, @@ -599,11 +589,11 @@ mod tests { "add supabase auth to this app", ®istry, &[], - RecommendOptions::proactive(), + RecommendOptions::default(), ); assert_eq!(recs.len(), 1); assert_eq!(recs[0].name, "supabase"); - assert!(recs[0].score >= PROACTIVE_MIN_SCORE); + assert!(recs[0].score > 0); assert_eq!(recs[0].next_step, PluginNextStep::Trust); assert_eq!(recs[0].command(), "/plugin trust supabase"); } @@ -621,7 +611,7 @@ mod tests { "wire up supabase row level security", ®istry, &catalog, - RecommendOptions::proactive(), + RecommendOptions::default(), ); assert_eq!(recs.len(), 1); assert_eq!(recs[0].name, "supabase"); @@ -638,7 +628,7 @@ mod tests { } #[test] - fn already_active_plugins_are_skipped_for_proactive_toasts() { + fn already_active_plugins_are_skipped_when_active_excluded() { let _lock = lock_test_env(); let root = TempDir::new().unwrap(); let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); @@ -654,30 +644,10 @@ mod tests { "add supabase auth", ®istry, &[], - RecommendOptions::proactive(), - ); - assert!(recs.is_empty(), "{recs:?}"); - } - - #[test] - fn generic_prompts_do_not_match_on_description_alone() { - let _lock = lock_test_env(); - let root = TempDir::new().unwrap(); - let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home")); - write_keyword_bundle( - root.path(), - "notes", - "Create and organize spreadsheet notes", - &[], - ); - let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv() - .registry_for_workspace(root.path()); - - let recs = recommend_plugins_for_task( - "fix the failing test", - ®istry, - &[], - RecommendOptions::proactive(), + RecommendOptions { + include_active: false, + ..RecommendOptions::default() + }, ); assert!(recs.is_empty(), "{recs:?}"); } From ac070a0f79681fb87a8a4522dadee960c135db94 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 16:58:15 -0700 Subject: [PATCH 11/24] refactor(progress): footer keys off structured routine_wait, not message text (determinism audit batch 2, #9) The footer rewrote subagent progress by sniffing the literal "requesting model response", against the codebase contract that UI consumers must never recover state by parsing the message. The producer now marks the routine per-step heartbeat with AgentProgressEventMeta.routine_wait; retry/timeout waits share ModelWait status but stay informative, so the status alone could not carry this and the flag is set only at the heartbeat site. The event handler threads it into friendly_subagent_progress and the store decision; is_noisy_subagent_progress is deleted. agent_card keeps its text match deliberately (documented beside it): the mailbox is a stable cross-crate surface that may carry foreign payloads, and no in-crate producer sends routine waits there. Tests: footer progress 2 passed 0 failed (incl. new informative-wait pin); progress suites 30 passed 0 failed; tools::subagent 672 passed with only the known pre-existing 5305 failure. Clippy --all-targets, fmt clean. --- crates/tui/src/core/events.rs | 13 ++++++++++++ crates/tui/src/tools/subagent/mod.rs | 4 +++- crates/tui/src/tui/footer_ui.rs | 17 ++++++++------- crates/tui/src/tui/ui.rs | 2 +- crates/tui/src/tui/ui/event_loop.rs | 7 ++++-- crates/tui/src/tui/ui/tests.rs | 27 ++++++++++++++++++++++-- crates/tui/src/tui/widgets/agent_card.rs | 8 +++++++ 7 files changed, 64 insertions(+), 14 deletions(-) diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 31418a541f..511c9f0f43 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -143,6 +143,12 @@ pub struct AgentProgressEventMeta { /// Canonical action/tool name. Presentation aliases are applied by the UI /// when it creates the bounded current-activity projection. pub tool_name: Option, + /// True when this progress is the routine per-step wait heartbeat + /// ("requesting model response"). Retry/timeout waits share the + /// `ModelWait` status but carry informative text, so the status alone + /// cannot tell them apart — the producer sets this instead, and UI + /// consumers rewrite on it rather than sniffing the message (#6290). + pub routine_wait: bool, } impl AgentProgressEventMeta { @@ -152,6 +158,7 @@ impl AgentProgressEventMeta { worker_status, step: None, tool_name: None, + routine_wait: false, } } @@ -161,6 +168,12 @@ impl AgentProgressEventMeta { self } + #[must_use] + pub const fn routine_wait(mut self) -> Self { + self.routine_wait = true; + self + } + #[must_use] pub fn with_tool(mut self, tool_name: impl Into) -> Self { self.tool_name = Some(tool_name.into()); diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 1d5ad00bf3..18e00df4db 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -12804,7 +12804,9 @@ async fn run_subagent( record_agent_progress( runtime, &agent_id, - AgentProgressEventMeta::new(AgentWorkerStatus::ModelWait).with_step(steps), + AgentProgressEventMeta::new(AgentWorkerStatus::ModelWait) + .with_step(steps) + .routine_wait(), format!( "{}: requesting model response", format_step_counter(steps, max_steps) diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index aa0161f5dd..cf6f0cc5ac 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -53,11 +53,6 @@ pub(crate) fn maybe_log_provider_wait_incident(app: &mut App) { )); } -pub(crate) fn is_noisy_subagent_progress(status: &str) -> bool { - let status = status.trim().to_ascii_lowercase(); - status.contains("requesting model response") -} - thread_local! { /// Objective summaries keyed by agent id (#6213 T7). The objective is /// immutable per agent, so `summarize_tool_output` — which JSON-parses the @@ -99,16 +94,22 @@ fn memoized_objective_summary(id: &str, objective: &str) -> Option { }) } -pub(crate) fn friendly_subagent_progress(app: &App, id: &str, status: &str) -> String { - if !is_noisy_subagent_progress(status) { +pub(crate) fn friendly_subagent_progress( + app: &App, + id: &str, + status: &str, + routine_wait: bool, +) -> String { + if !routine_wait { return summarize_tool_output(status); } if let Some(summary) = subagent_objective_summary(app, id) { return format!("working on {summary}"); } + // Stored entries are always friendly rewrites (the event handler stores + // `display`, never raw text), so no content check is needed here. if let Some(existing) = app.agent_progress.get(id) - && !is_noisy_subagent_progress(existing) && existing != "working" && existing != "in the current" { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 8d40f6768e..fa00fc12c9 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -90,7 +90,7 @@ use crate::tui::composer_ui::*; use crate::tui::context_inspector::ContextInspectorView; use crate::tui::event_broker::EventBroker; use crate::tui::file_picker_relevance; -use crate::tui::footer_ui::{friendly_subagent_progress, is_noisy_subagent_progress}; +use crate::tui::footer_ui::friendly_subagent_progress; use crate::tui::format_helpers; use crate::tui::hotbar::actions::HotbarDispatch; use crate::tui::key_shortcuts; diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index 59d457505a..6bd31133ff 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -3412,9 +3412,12 @@ pub(crate) async fn run_event_loop( ) => { let display = bound_agent_activity_text(&friendly_subagent_progress( - app, &id, &status, + app, + &id, + &status, + activity.routine_wait, )); - if is_noisy_subagent_progress(&status) { + if activity.routine_wait { app.agent_progress .entry(id.clone()) .or_insert_with(|| display.clone()); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index be0c56bcb7..8cbc8295cb 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -23614,12 +23614,35 @@ fn noisy_subagent_progress_keeps_existing_objective_summary() { "starting: inspect release state".to_string(), ); - let display = - friendly_subagent_progress(&app, "agent_live", "step 1/8: requesting model response"); + let display = friendly_subagent_progress( + &app, + "agent_live", + "step 1/8: requesting model response", + true, + ); assert_eq!(display, "starting: inspect release state"); } +#[test] +fn informative_waits_show_their_text_rather_than_rewriting() { + // Retry/timeout waits share the ModelWait status but carry informative + // text: the producer leaves routine_wait false and the footer shows the + // message instead of rewriting it to the objective summary. + let app = create_test_app(); + let display = friendly_subagent_progress( + &app, + "agent_live", + "step 1/8: API call timed out after 30000ms; retrying API request 1/3 in 500ms", + false, + ); + + assert!( + display.contains("retrying API request"), + "informative wait must stay visible: {display}" + ); +} + /// Regression for issue #65: `truncate_line_to_width` with a tiny budget /// must respect display widths, not codepoint counts. The old branch counted /// chars and overran the budget for any double-width grapheme, which diff --git a/crates/tui/src/tui/widgets/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs index 996b7cbfda..751a342b25 100644 --- a/crates/tui/src/tui/widgets/agent_card.rs +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -640,6 +640,14 @@ pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool true } +/// Known limitation: this still matches on message text, while the footer +/// path keys off the structured `routine_wait` flag (#6290). The mailbox is +/// a stable cross-crate (`protocol`) surface whose payloads may come from +/// another binary, so this arm cannot assume the flag exists — and no +/// in-crate producer sends routine waits here anyway (only "queued" and +/// "running" texts), so threading the flag would change nothing. If a future +/// producer sends routine waits over the mailbox, give `Progress` the flag +/// and match on it here instead of extending this list. fn is_low_signal_progress(status: &str) -> bool { let status = status.trim().to_ascii_lowercase(); status.contains("requesting model response") From f6fb5f42d1efeccdd00b100b4175edea6dbbc1db Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 17:09:17 -0700 Subject: [PATCH 12/24] feat(read): every read response carries size, truncated, line_count (#6283) A 638k-token transcript died because a child read a huge file blind: no size up front, no truncation flag, no line count. The canonical read tool now reports all three on every response (metadata plus size in the truncation footers), so paging is deliberate. Whole-file reads keep their exact footer-free shape; budgets are unchanged (100KB default / 500KB max are deliberate context policy, not the bug). The grep half already existed: grep_files is model-visible, read-only, and in the child surface (pinned by an_explicit_parent_tool_scope...), with File/search_content as the action spelling. Added the grep-then-read flow test to pin it. Tests: tools::file 173 passed 0 failed (incl. 4 new: >10MiB page-one, bounded paging to completion, ordinary-read metadata, grep-then-read); tools::search + compactor neighbor 23 passed 0 failed. Clippy --all-targets, fmt clean. --- CHANGELOG.md | 4 + crates/tui/src/tools/file.rs | 25 ++++- crates/tui/src/tools/file/tests.rs | 160 ++++++++++++++++++++++++++++- crates/tui/src/tools/file_tool.rs | 2 +- 4 files changed, 184 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf65452ce..93edb1c239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ tag, packages, checksums and release assets exist. ### Added +- `read` responses now always report the file's byte size, line count, and + whether output was truncated, and truncation footers name the total size + alongside the continuation offset — so paging through a large file is + deliberate instead of a surprise (#6283). - File edits are parse-gated before the write lands: Rust goes through `syn::parse_file` for a grammar-exact `line:column`, and `.toml` / `.json` through the parsers already vendored. An edit is refused only when the file diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index 6ff9cd1339..b36e788296 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -760,6 +760,10 @@ impl ReadFileTool { let bytes = fs::read(&file_path).map_err(|error| { ToolError::execution_failed(format!("Failed to read {}: {error}", file_path.display())) })?; + // #6283: every read response carries the file's byte size, line + // count, and truncation flag so the caller can page deliberately + // instead of discovering a huge file one window at a time. + let size_bytes = bytes.len(); check_file_operation_cancelled(context)?; if let Some(mime_type) = primitive_image_mime(&bytes) { let prepared = crate::image_attach::prepare_tool_image_bytes(&bytes, mime_type); @@ -792,6 +796,11 @@ impl ReadFileTool { }; let selected_content = selected.join("\n"); let window = contract_read_window(&selected_content, max_bytes); + // Truncated means the file holds more than this response shows: + // either the byte budget cut the window, or a bounded range stopped + // before EOF. A whole file that fits is never truncated. + let truncated = + window.truncated || limit.is_some() && start + selected.len() < all_lines.len(); let first_display = start + 1; let mut output = if window.first_line_too_large { let size = selected.first().map_or(0, |line| line.len()); @@ -822,8 +831,9 @@ impl ReadFileTool { String::new() }; output.push_str(&format!( - "\n\n[Showing lines {first_display}-{last_display} of {} ({max_bytes}-byte output budget). Use {hint} to continue{raise}.]", - all_lines.len() + "\n\n[Showing lines {first_display}-{last_display} of {} ({} total, {max_bytes}-byte output budget). Use {hint} to continue{raise}.]", + all_lines.len(), + contract_format_size(size_bytes) )); } else if limit.is_some() { let consumed = selected.len(); @@ -831,7 +841,8 @@ impl ReadFileTool { let remaining = all_lines.len() - (start + consumed); let next_offset = start + consumed + 1; output.push_str(&format!( - "\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]" + "\n\n[{remaining} more lines in file ({} total). Use offset={next_offset} to continue.]", + contract_format_size(size_bytes) )); } } @@ -846,7 +857,13 @@ impl ReadFileTool { // The budget this call actually enforced. The context // compactor honors it so an already-bounded read is never // truncated a second time on its way into the conversation. - "read_budget_bytes": max_bytes + "read_budget_bytes": max_bytes, + // #6283: paging contract. `size` is the whole file in bytes, + // `line_count` its total lines, `truncated` whether the file + // holds more than this response shows. + "size": size_bytes, + "truncated": truncated, + "line_count": all_lines.len() })), )) } diff --git a/crates/tui/src/tools/file/tests.rs b/crates/tui/src/tools/file/tests.rs index 6b352d9995..9d52d57b64 100644 --- a/crates/tui/src/tools/file/tests.rs +++ b/crates/tui/src/tools/file/tests.rs @@ -209,7 +209,7 @@ async fn contract_read_paginates_an_oversized_file_with_an_honest_budget_footer( .to_string(); assert_eq!( footer, - "[Showing lines 1-100 of 2000 (100000-byte output budget). Use offset=101 to continue, or max_bytes up to 500000 to read more per call.]" + "[Showing lines 1-100 of 2000 (1.9MB total, 100000-byte output budget). Use offset=101 to continue, or max_bytes up to 500000 to read more per call.]" ); let shown = first.content.rsplit_once("\n\n").expect("body").0; assert_eq!(shown.lines().count(), 100); @@ -230,6 +230,162 @@ async fn contract_read_paginates_an_oversized_file_with_an_honest_budget_footer( ); } +/// #6283 AC1: a >10 MiB file read without paging params returns page one +/// plus the file's size, line count, and truncated flag — never the whole +/// file. +#[tokio::test] +async fn contract_read_reports_size_and_truncation_for_huge_files() { + let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard(); + let temporary = tempfile::tempdir().expect("tempdir"); + let line = "x".repeat(99); + let content = std::iter::repeat_n(line.as_str(), 110_000) + .collect::>() + .join("\n"); + assert!( + content.len() > 10 * 1024 * 1024, + "fixture exceeds 10 MiB: {}", + content.len() + ); + std::fs::write(temporary.path().join("huge.bin.txt"), &content).expect("fixture"); + let context = ToolContext::new(temporary.path()); + + let first = ReadFileTool::execute_contract_read(json!({"path": "huge.bin.txt"}), &context) + .await + .expect("first page"); + let metadata = first.metadata.clone().expect("paging metadata"); + assert_eq!(metadata["size"], content.len() as u64); + assert_eq!(metadata["truncated"], true); + assert_eq!(metadata["line_count"], 110_000); + assert!( + first.content.len() < content.len(), + "page one must never be the whole file" + ); + assert!( + first.content.len() <= READ_DEFAULT_MAX_BYTES + 1_024, + "page one stays within the default budget plus footer slack: {}", + first.content.len() + ); + assert!( + first.content.contains("total") && first.content.contains("Use offset="), + "footer names the size and the continuation: {}", + first + .content + .rsplit_once("\n\n") + .map(|(_, f)| f) + .unwrap_or("") + ); +} + +/// #6283 AC2: paging through a file keeps every response bounded and +/// terminates with an untruncated page whose union is the whole file. +#[tokio::test] +async fn contract_read_pages_stay_bounded_and_cover_the_whole_file() { + let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard(); + let temporary = tempfile::tempdir().expect("tempdir"); + let content = (0..3_000) + .map(|index| format!("paged-line-{index:05}")) + .collect::>() + .join("\n"); + std::fs::write(temporary.path().join("paged.txt"), &content).expect("fixture"); + let context = ToolContext::new(temporary.path()); + + let mut seen: Vec = Vec::new(); + let mut offset = 1usize; + for page in 0..100 { + let result = ReadFileTool::execute_contract_read( + json!({"path": "paged.txt", "offset": offset, "limit": 500}), + &context, + ) + .await + .expect("page read"); + let metadata = result.metadata.clone().expect("paging metadata"); + assert_eq!(metadata["size"], content.len() as u64); + assert!( + result.content.len() <= READ_DEFAULT_MAX_BYTES + 1_024, + "page {page} bounded: {}", + result.content.len() + ); + let body = result + .content + .rsplit_once("\n\n[") + .map(|(body, _)| body) + .unwrap_or(&result.content); + seen.extend(body.lines().map(str::to_string)); + let truncated = metadata["truncated"].as_bool().expect("truncated flag"); + if !truncated { + break; + } + offset += 500; + assert!(page < 99, "paging must terminate"); + } + assert_eq!(seen.len(), 3_000); + assert_eq!(seen.join("\n"), content); +} + +/// #6283: ordinary whole reads carry the same paging metadata (with +/// truncated=false) and keep their footer-free shape. +#[tokio::test] +async fn contract_read_metadata_for_ordinary_whole_read() { + let temporary = tempfile::tempdir().expect("tempdir"); + let content = "alpha\nbeta\ngamma\n"; + std::fs::write(temporary.path().join("small.txt"), content).expect("fixture"); + let context = ToolContext::new(temporary.path()); + + let result = ReadFileTool::execute_contract_read(json!({"path": "small.txt"}), &context) + .await + .expect("read result"); + assert_eq!(result.content, content); + let metadata = result.metadata.clone().expect("paging metadata"); + assert_eq!(metadata["size"], content.len() as u64); + assert_eq!(metadata["truncated"], false); + assert_eq!(metadata["line_count"], 4); +} + +/// #6283 AC3: grep-then-read flow — locate a marker with `grep_files`, +/// then read exactly that line range. (`grep_files` in the child surface +/// is pinned by `an_explicit_parent_tool_scope_is_enforced_by_the_child_registry`.) +#[tokio::test] +async fn grep_then_read_flow_targets_matched_lines() { + use crate::tools::spec::ToolSpec; + + let temporary = tempfile::tempdir().expect("tempdir"); + let mut lines: Vec = (0..200) + .map(|index| format!("filler line {index}")) + .collect(); + lines[150] = "the needle marker lives here".to_string(); + std::fs::write(temporary.path().join("haystack.txt"), lines.join("\n")).expect("fixture"); + let context = ToolContext::new(temporary.path()); + + let grep = crate::tools::search::GrepFilesTool + .execute( + json!({"pattern": "needle marker", "path": ".", "context_lines": 1}), + &context, + ) + .await + .expect("grep result"); + let payload: serde_json::Value = + serde_json::from_str(&grep.content).expect("grep JSON envelope"); + assert_eq!(payload["total_matches"], 1); + let matched = &payload["matches"][0]; + assert_eq!(matched["line_number"], 151); + + let read = ReadFileTool::execute_contract_read( + json!({ + "path": matched["file"].as_str().expect("match file"), + "offset": matched["line_number"].as_u64().expect("match line"), + "limit": 1, + }), + &context, + ) + .await + .expect("targeted read"); + assert!( + read.content.contains("the needle marker lives here"), + "{}", + read.content + ); +} + #[tokio::test] async fn contract_read_reports_huge_first_line_with_exact_bash_fallback() { let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard(); @@ -263,7 +419,7 @@ async fn contract_read_offset_oob_and_limit_continuation_match_contract() { .expect("limited read"); assert_eq!( limited.content, - "two\n\n[1 more lines in file. Use offset=3 to continue.]" + "two\n\n[1 more lines in file (13B total). Use offset=3 to continue.]" ); let error = diff --git a/crates/tui/src/tools/file_tool.rs b/crates/tui/src/tools/file_tool.rs index 106bf88f3f..c9b950c0c9 100644 --- a/crates/tui/src/tools/file_tool.rs +++ b/crates/tui/src/tools/file_tool.rs @@ -60,7 +60,7 @@ impl ToolSpec for ReadTool { } fn description(&self) -> &'static str { - "Read a text file. The whole file comes back in one call when it fits this call's output budget — 100000 bytes by default, raisable to 500000 with max_bytes. There is no line cap. Use offset and limit for an exact line range; when output is budget-limited the footer names the exact offset to continue from." + "Read a text file. The whole file comes back in one call when it fits this call's output budget — 100000 bytes by default, raisable to 500000 with max_bytes. There is no line cap. Use offset and limit for an exact line range; when output is budget-limited the footer names the exact offset to continue from. Every response reports the file's byte size, line count, and whether output was truncated." } fn input_schema(&self) -> Value { From 3475d03858d46e282364bb6350d4c4c53484500d Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 17:54:09 -0700 Subject: [PATCH 13/24] refactor(subagent): move delivery git work off the manager write lock (#6210) Completion path: finish_terminal_result no longer runs the git trio + fingerprints synchronously. ensure_worker_delivery_verified snapshots inputs under a read lock, computes in spawn_blocking, and stores under a follow-up write lock (idempotent via DeliveryEvidence.checked). The natural/panic epilogues ensure before the lock so fan-in completions and the terminal persist stay fresh; Stop/interrupt/close/stale commits heal on the next detail projection. Spawn path: spawn_subagent_from_input fingerprints the baseline pre-lock (assuming write; registration discards it when the resolved spec is read-only, and skips capture when the parent ceiling denies writes). Resume/Fleet/test paths keep the inline capture. Tests: deferred-semantics updates plus pending/ensure-idempotence/ no-op/heal/adopt/discard/fallback coverage. --- .../tools/subagent/budget_handback_tests.rs | 2 +- .../tools/subagent/completion_usage_tests.rs | 1 + crates/tui/src/tools/subagent/coord.rs | 4 +- crates/tui/src/tools/subagent/delivery.rs | 80 ++++- .../tui/src/tools/subagent/delivery_tests.rs | 141 ++++++++ .../tui/src/tools/subagent/lifecycle_tests.rs | 1 + crates/tui/src/tools/subagent/limits_tests.rs | 4 + crates/tui/src/tools/subagent/mod.rs | 322 ++++++++++++------ crates/tui/src/tools/subagent/tests.rs | 163 +++++++-- 9 files changed, 562 insertions(+), 156 deletions(-) diff --git a/crates/tui/src/tools/subagent/budget_handback_tests.rs b/crates/tui/src/tools/subagent/budget_handback_tests.rs index 4f13d84f3b..2d9930e51d 100644 --- a/crates/tui/src/tools/subagent/budget_handback_tests.rs +++ b/crates/tui/src/tools/subagent/budget_handback_tests.rs @@ -174,7 +174,7 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu agent.status = SubAgentStatus::Running; { let mut guard = manager.write().await; - guard.register_worker_for_session(spec, &runtime.context.state_namespace); + guard.register_worker_for_session(spec, &runtime.context.state_namespace, None); guard.agents.insert("report-worker".to_string(), agent); } let task = tokio::spawn(run_subagent_task(SubAgentTask { diff --git a/crates/tui/src/tools/subagent/completion_usage_tests.rs b/crates/tui/src/tools/subagent/completion_usage_tests.rs index e35b4f3034..62c4fdb141 100644 --- a/crates/tui/src/tools/subagent/completion_usage_tests.rs +++ b/crates/tui/src/tools/subagent/completion_usage_tests.rs @@ -346,6 +346,7 @@ async fn completion_usage_counts_real_manifest_only_root_fork() { checkpoint_continuation: false, ..Default::default() }, + None, ) .unwrap(); let record = guard.worker_records.get_mut(&fork.agent_id).unwrap(); diff --git a/crates/tui/src/tools/subagent/coord.rs b/crates/tui/src/tools/subagent/coord.rs index a77e3edb3e..8a90ab200a 100644 --- a/crates/tui/src/tools/subagent/coord.rs +++ b/crates/tui/src/tools/subagent/coord.rs @@ -616,7 +616,9 @@ impl ToolSpec for AgentsInterruptTool { let manager = self.manager.read().await; manager.get_worker_record_for_session(&context.state_namespace, &snapshot.agent_id) }; - let projection = subagent_session_projection(snapshot, false, context, worker_record).await; + let projection = + subagent_session_projection(&self.manager, snapshot, false, context, worker_record) + .await; let payload = json!({ "action": "interrupt", "agent_id": projection.agent_id, diff --git a/crates/tui/src/tools/subagent/delivery.rs b/crates/tui/src/tools/subagent/delivery.rs index b7dc1cc4f4..334e44cc4b 100644 --- a/crates/tui/src/tools/subagent/delivery.rs +++ b/crates/tui/src/tools/subagent/delivery.rs @@ -1,7 +1,10 @@ //! Delivery evidence replacing the old prose-verb/git-status heuristic. //! The worker ledger retains the spawn baseline; this module only reads files. -use super::{AgentRunVerificationSummary, AgentWorkerSpec, normalize_claim_path}; +use super::{ + AgentRunVerificationSummary, AgentWorkerSpec, default_agent_run_verification, + normalize_claim_path, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use std::collections::{BTreeMap, BTreeSet}; @@ -120,14 +123,19 @@ fn fingerprint(root: &Path, relative: &str) -> Option { impl DeliveryEvidence { pub(super) fn capture(spec: &AgentWorkerSpec) -> Self { - let baseline = spec - .runtime_profile - .permissions - .write + Self::capture_for_handle(&spec.workspace, spec.runtime_profile.permissions.write) + } + + /// Baseline capture that needs only the workspace and write permission — + /// the two spec fields the baseline actually reads. The async spawn path + /// calls this in `spawn_blocking` BEFORE the manager write lock (#6210) + /// and threads the evidence through registration, so git + file + /// fingerprints never run under the lock. + pub(super) fn capture_for_handle(workspace: &Path, write: bool) -> Self { + let baseline = write .then(|| { let root = - String::from_utf8(git(&spec.workspace, &["rev-parse", "--show-toplevel"])?) - .ok()?; + String::from_utf8(git(workspace, &["rev-parse", "--show-toplevel"])?).ok()?; let root = PathBuf::from(root.trim()); let head = git(&root, &["rev-parse", "--verify", "HEAD"]) .and_then(|bytes| String::from_utf8(bytes).ok()) @@ -483,3 +491,61 @@ pub(super) fn verify_changes( deliverables: Vec::new(), }) } + +/// Everything delivery verification needs, snapshotted under a read lock. +/// `allowed[i]` is the write-scope verdict for `deliverables[i]`. The compute +/// half runs in `spawn_blocking` with no manager lock held (#6210). +#[derive(Debug, Clone)] +pub(super) struct DeliveryVerificationInputs { + pub evidence: DeliveryEvidence, + pub workspace: PathBuf, + pub result_text: String, + pub write_perm: bool, + pub deliverables: Vec, + pub allowed: Vec, +} + +/// Pure compute half of worker delivery verification: the git trio + +/// fingerprints (`changed_paths`), claim comparison, and per-deliverable +/// presence checks. Runs off the manager lock; the caller stores the summary. +pub(super) fn compute_delivery_verification( + inputs: &DeliveryVerificationInputs, +) -> AgentRunVerificationSummary { + let changed = inputs.evidence.changed_paths(&inputs.workspace); + let mut verification = verify_changes( + &inputs.result_text, + inputs.write_perm, + &inputs.evidence, + changed.as_ref(), + &inputs.deliverables.iter().cloned().collect(), + ) + .unwrap_or_else(default_agent_run_verification); + verification.deliverables = inputs + .deliverables + .iter() + .zip(inputs.allowed.iter()) + .map(|(path, allowed)| check_deliverable(&inputs.workspace, path, *allowed)) + .collect(); + let missing = verification + .deliverables + .iter() + .filter(|verdict| verdict.status != "present") + .map(|verdict| format!("{} ({})", verdict.path, verdict.status)) + .collect::>(); + if !missing.is_empty() { + let prior = if verification.status == "claim_mismatch" { + format!(" {}", verification.summary) + } else { + String::new() + }; + verification.status = "deliverable_missing".to_string(); + verification.summary = format!( + "Declared deliverables not produced as non-empty files in the worker write scope: {}.{prior}", + missing.join(", ") + ); + } else if !inputs.deliverables.is_empty() && verification.status == "self_report_only" { + verification.status = "deliverables_present".to_string(); + verification.summary = "Declared files exist and are non-empty inside the worker write scope; their contents remain a worker self-report.".to_string(); + } + verification +} diff --git a/crates/tui/src/tools/subagent/delivery_tests.rs b/crates/tui/src/tools/subagent/delivery_tests.rs index e7d256a3c4..be16d601f4 100644 --- a/crates/tui/src/tools/subagent/delivery_tests.rs +++ b/crates/tui/src/tools/subagent/delivery_tests.rs @@ -70,6 +70,14 @@ fn complete(manager: &mut SubAgentManager, id: &str, report: &str) -> AgentRunVe result.status = SubAgentStatus::Completed; result.result = Some(report.into()); manager.complete_worker_from_result(id, &result); + // Deferred verification (#6210): the commit leaves verification pending; + // run the same snapshot→compute→store halves `ensure` runs off the lock. + if !manager.worker_records[id].delivery_evidence.checked + && let Some(inputs) = manager.delivery_verification_inputs(id, &result) + { + let verification = delivery::compute_delivery_verification(&inputs); + manager.store_delivery_verification(id, verification); + } manager.worker_records[id].verification.clone() } @@ -540,3 +548,136 @@ async fn enforced_readonly_python_queries_sqlite_under_a_live_peer_write_claim() ); } } + +/// Deferred-verification contract (#6210): the terminal commit stores the +/// worker projection but leaves verification pending for `ensure`. +#[test] +fn terminal_commit_leaves_delivery_verification_pending() { + let tmp = tempdir().unwrap(); + repository(tmp.path()); + let (mut manager, id) = worker(tmp.path(), true, &["report.md"], &["."]); + let mut result = manager.get_result(&id).unwrap(); + result.status = SubAgentStatus::Completed; + result.result = Some("Finished the research.".into()); + manager.complete_worker_from_result(&id, &result); + let record = manager.worker_records.get(&id).unwrap(); + assert!(!record.delivery_evidence.checked); + assert_eq!(record.verification.status, "self_report_only"); + assert_eq!( + record.result_summary.as_deref(), + Some("Finished the research.") + ); +} + +#[tokio::test] +async fn ensure_worker_delivery_verified_stores_verdicts_and_is_idempotent() { + let tmp = tempdir().unwrap(); + repository(tmp.path()); + let (manager, id) = worker(tmp.path(), true, &["report.md"], &["."]); + let manager = Arc::new(RwLock::new(manager)); + let result = { + let mut guard = manager.write().await; + let mut result = guard.get_result(&id).unwrap(); + result.status = SubAgentStatus::Completed; + result.result = Some("Finished the research.".into()); + guard.complete_worker_from_result(&id, &result); + assert!(!guard.worker_records[&id].delivery_evidence.checked); + result + }; + ensure_worker_delivery_verified(&manager, &id, &result).await; + let first = manager.read().await.worker_records[&id] + .verification + .clone(); + assert_eq!(first.status, "deliverable_missing"); + assert!( + manager.read().await.worker_records[&id] + .delivery_evidence + .checked + ); + // A second call is a no-op even with a different report. + let mut other = result.clone(); + other.result = Some("CHANGES: src/lib.rs".into()); + ensure_worker_delivery_verified(&manager, &id, &other).await; + assert_eq!(manager.read().await.worker_records[&id].verification, first); +} + +#[tokio::test] +async fn ensure_worker_delivery_verified_ignores_running_missing_and_checked() { + let tmp = tempdir().unwrap(); + repository(tmp.path()); + let (manager, id) = worker(tmp.path(), true, &[], &["src"]); + let manager = Arc::new(RwLock::new(manager)); + let running = manager.read().await.get_result(&id).unwrap(); + assert_eq!(running.status, SubAgentStatus::Running); + ensure_worker_delivery_verified(&manager, &id, &running).await; + assert!( + !manager.read().await.worker_records[&id] + .delivery_evidence + .checked + ); + // A missing worker id is a silent no-op. + let mut missing = running.clone(); + missing.agent_id = "agent_missing".to_string(); + missing.status = SubAgentStatus::Completed; + ensure_worker_delivery_verified(&manager, "agent_missing", &missing).await; + // A checked record keeps its stored verdict. + let mut done = running.clone(); + done.status = SubAgentStatus::Completed; + done.result = Some("CHANGES: src/lib.rs".into()); + ensure_worker_delivery_verified(&manager, &id, &done).await; + assert_eq!( + manager.read().await.worker_records[&id].verification.status, + "claim_mismatch" + ); + let mut changed_mind = done.clone(); + changed_mind.result = Some("CHANGES: None".into()); + ensure_worker_delivery_verified(&manager, &id, &changed_mind).await; + assert_eq!( + manager.read().await.worker_records[&id].verification.status, + "claim_mismatch" + ); +} + +/// Read-side backstop (#6210): a terminal detail projection heals a +/// verification left pending by a Stop/interrupt/close/stale commit. +#[tokio::test] +async fn detail_projection_heals_pending_delivery_verification() { + let tmp = tempdir().unwrap(); + repository(tmp.path()); + let (manager, id) = worker(tmp.path(), true, &["report.md"], &["."]); + let manager = Arc::new(RwLock::new(manager)); + let result = { + let mut guard = manager.write().await; + let mut result = guard.get_result(&id).unwrap(); + result.status = SubAgentStatus::Completed; + result.result = Some("Finished the research.".into()); + guard.complete_worker_from_result(&id, &result); + result + }; + let mut context = ToolContext::new(tmp.path()); + context.state_namespace = "workspace".to_string(); + let worker_record = manager + .read() + .await + .get_worker_record_for_session("workspace", &id); + assert!( + worker_record + .as_ref() + .is_some_and(|record| !record.delivery_evidence.checked) + ); + let projection = + subagent_session_projection(&manager, result, false, &context, worker_record).await; + assert_eq!(projection.verification.status, "deliverable_missing"); + assert!( + projection + .verification + .deliverables + .iter() + .any(|verdict| verdict.path == "report.md") + ); + assert!( + manager.read().await.worker_records[&id] + .delivery_evidence + .checked + ); +} diff --git a/crates/tui/src/tools/subagent/lifecycle_tests.rs b/crates/tui/src/tools/subagent/lifecycle_tests.rs index 77d211c8cd..9931c15a65 100644 --- a/crates/tui/src/tools/subagent/lifecycle_tests.rs +++ b/crates/tui/src/tools/subagent/lifecycle_tests.rs @@ -714,6 +714,7 @@ async fn lifecycle_detail_budget_keeps_the_transcript_handle_retrievable() { &id, "retained", &messages, 1, true, )); let projection = subagent_session_projection( + &new_shared_subagent_manager(dir.path().to_path_buf(), 1), manager.get_result(&id).unwrap(), false, &context, diff --git a/crates/tui/src/tools/subagent/limits_tests.rs b/crates/tui/src/tools/subagent/limits_tests.rs index 076e929de3..57dfee8499 100644 --- a/crates/tui/src/tools/subagent/limits_tests.rs +++ b/crates/tui/src/tools/subagent/limits_tests.rs @@ -195,6 +195,7 @@ async fn launch_narrows_all_limits_and_continuation_cannot_restart_deadline() { SubAgentAssignment::new("inspect".to_string(), None), Some(vec![]), options, + None, ) .unwrap(); let profile = &guard.worker_records[&child.agent_id].spec.runtime_profile; @@ -218,6 +219,7 @@ async fn launch_narrows_all_limits_and_continuation_cannot_restart_deadline() { resume_from_agent_id: Some(child.agent_id), ..Default::default() }, + None, ); assert!( refused @@ -257,6 +259,7 @@ async fn resume_intersects_saved_write_shell_and_tool_permissions_with_current_c preserve_runtime_profile: Some(saved), ..Default::default() }, + None, ) .unwrap(); let profile = &guard.worker_records[&child.agent_id].spec.runtime_profile; @@ -300,6 +303,7 @@ async fn root_fork_of_depth_two_leaf_cannot_regain_a_generation() { resume_from_agent_id: Some("leaf".to_string()), ..Default::default() }, + None, ) .unwrap(); let spec = &guard.worker_records[&child.agent_id].spec; diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 18e00df4db..6226e0c02c 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -974,15 +974,27 @@ impl AgentWorkerRecord { /// a hand-rolled struct literal that would drift from this one. #[cfg(test)] pub(crate) fn new(spec: AgentWorkerSpec, now_ms: u64) -> Self { - Self::new_for_session(spec, now_ms, String::new()) + Self::new_for_session(spec, now_ms, String::new(), None) } - fn new_for_session(spec: AgentWorkerSpec, now_ms: u64, owner_session_id: String) -> Self { + fn new_for_session( + spec: AgentWorkerSpec, + now_ms: u64, + owner_session_id: String, + precomputed_evidence: Option, + ) -> Self { let run_id = agent_worker_run_id(&spec); let artifacts = default_subagent_artifacts(&run_id); let follow_up = follow_up_target_for_spec(&spec); let takeover = takeover_target_for_spec(&spec); - let delivery_evidence = DeliveryEvidence::capture(&spec); + // #6210: the async spawn path fingerprints pre-lock assuming write + // capability; the resolved spec permission is authoritative, so a + // baseline for a read-only worker is discarded, never stored. + let delivery_evidence = match precomputed_evidence { + Some(evidence) if spec.runtime_profile.permissions.write => evidence, + Some(_) => DeliveryEvidence::default(), + None => DeliveryEvidence::capture(&spec), + }; let mut verification = default_agent_run_verification(); if let Some(manifest) = spec.launch_manifest.as_ref() { verification.deliverables = manifest @@ -4806,16 +4818,22 @@ impl SubAgentManager { } pub fn register_worker(&mut self, spec: AgentWorkerSpec) { - self.register_worker_for_session(spec, ""); + self.register_worker_for_session(spec, "", None); } - fn register_worker_for_session(&mut self, spec: AgentWorkerSpec, owner_session_id: &str) { + fn register_worker_for_session( + &mut self, + spec: AgentWorkerSpec, + owner_session_id: &str, + precomputed_evidence: Option, + ) { let worker_id = spec.worker_id.clone(); let now_ms = epoch_millis_now(); let mut record = AgentWorkerRecord::new_for_session( normalize_worker_spec(spec), now_ms, owner_session_id.to_string(), + precomputed_evidence, ); self.push_worker_event( &mut record, @@ -5444,73 +5462,66 @@ impl SubAgentManager { } } - fn verify_worker_delivery(&mut self, worker_id: &str, result: &SubAgentResult) { - let Some(record) = self.worker_records.get(worker_id) else { - return; - }; + /// Store a verification computed off the lock. Re-checks `checked` so a + /// racing `ensure_worker_delivery_verified` cannot overwrite a stored + /// verdict (#6210). Returns whether this call stored. + fn store_delivery_verification( + &mut self, + worker_id: &str, + verification: AgentRunVerificationSummary, + ) -> bool { + if let Some(record) = self.worker_records.get_mut(worker_id) + && !record.delivery_evidence.checked + { + record.verification = verification; + record.delivery_evidence.checked = true; + return true; + } + false + } + + /// Snapshot everything delivery verification needs. `None` when there is + /// no record, verification already ran, or the result is not terminal. + /// Pure reads for the read lock in `ensure_worker_delivery_verified` + /// (#6210). + fn delivery_verification_inputs( + &self, + worker_id: &str, + result: &SubAgentResult, + ) -> Option { + let record = self.worker_records.get(worker_id)?; if record.delivery_evidence.checked || result.status == SubAgentStatus::Running { - return; + return None; } - let workspace = &record.spec.workspace; - let changed = record.delivery_evidence.changed_paths(workspace); - let mut verification = delivery::verify_changes( - result.result.as_deref().unwrap_or_default(), - record.spec.runtime_profile.permissions.write, - &record.delivery_evidence, - changed.as_ref(), - &record - .spec - .launch_manifest - .as_ref() - .map(|manifest| manifest.deliverables.iter().cloned().collect()) - .unwrap_or_default(), - ) - .unwrap_or_else(default_agent_run_verification); - let paths = record + let deliverables: Vec = record .spec .launch_manifest .as_ref() - .map(|manifest| manifest.deliverables.as_slice()) + .map(|manifest| manifest.deliverables.clone()) .unwrap_or_default(); - verification.deliverables = paths + let allowed = deliverables .iter() .map(|path| { - let allowed = record.spec.runtime_profile.permissions.write + record.spec.runtime_profile.permissions.write && self .validate_write_scope(worker_id, std::slice::from_ref(path)) - .is_ok(); - delivery::check_deliverable(workspace, path, allowed) + .is_ok() }) .collect(); - let missing = verification - .deliverables - .iter() - .filter(|verdict| verdict.status != "present") - .map(|verdict| format!("{} ({})", verdict.path, verdict.status)) - .collect::>(); - if !missing.is_empty() { - let prior = if verification.status == "claim_mismatch" { - format!(" {}", verification.summary) - } else { - String::new() - }; - verification.status = "deliverable_missing".to_string(); - verification.summary = format!( - "Declared deliverables not produced as non-empty files in the worker write scope: {}.{prior}", - missing.join(", ") - ); - } else if !paths.is_empty() && verification.status == "self_report_only" { - verification.status = "deliverables_present".to_string(); - verification.summary = "Declared files exist and are non-empty inside the worker write scope; their contents remain a worker self-report.".to_string(); - } - if let Some(record) = self.worker_records.get_mut(worker_id) { - record.verification = verification; - record.delivery_evidence.checked = true; - } + Some(delivery::DeliveryVerificationInputs { + evidence: record.delivery_evidence.clone(), + workspace: record.spec.workspace.clone(), + result_text: result.result.clone().unwrap_or_default(), + write_perm: record.spec.runtime_profile.permissions.write, + deliverables, + allowed, + }) } fn complete_worker_from_result(&mut self, worker_id: &str, result: &SubAgentResult) { - self.verify_worker_delivery(worker_id, result); + // Delivery verification is deferred: `ensure_worker_delivery_verified` + // computes it off the lock before the terminal commit (natural and + // panic paths) or heals it on the next detail read (#6210). let status = worker_status_from_subagent_result(result); let message = match &result.status { SubAgentStatus::Completed => Some("completed".to_string()), @@ -6228,6 +6239,9 @@ impl SubAgentManager { assignment, allowed_tools, options, + // Checkpoint resume replays under the write lock; the baseline + // captures inline there, as before (#6210). + None, )?; Ok(resumed) } @@ -6538,7 +6552,7 @@ impl SubAgentManager { child_route: None, launch_manifest: None, }; - self.register_worker_for_session(spec, "workspace"); + self.register_worker_for_session(spec, "workspace", None); (agent_id, input_rx) } @@ -6713,6 +6727,7 @@ impl SubAgentManager { assignment: SubAgentAssignment, allowed_tools: Option>, options: SubAgentSpawnOptions, + precomputed_delivery_evidence: Option, ) -> Result { self.cleanup(COMPLETED_AGENT_RETENTION); @@ -7114,7 +7129,11 @@ impl SubAgentManager { }; agent.owner_session_id = runtime.context.state_namespace.clone(); agent.terminal_delivery = Some(SubAgentTerminalDeliveryContext::from_runtime(&runtime)); - self.register_worker_for_session(worker_spec, &runtime.context.state_namespace); + self.register_worker_for_session( + worker_spec, + &runtime.context.state_namespace, + precomputed_delivery_evidence, + ); // Shared-workspace writers may execute only after their exact worker // identity and claim are durably replayable. Persist a Starting record @@ -7874,7 +7893,6 @@ impl SubAgentManager { handle.abort(); } - self.verify_worker_delivery(agent_id, &result); let delivery = self .agents .get(agent_id) @@ -8149,11 +8167,30 @@ fn subagent_checkpoint_is_continuable(snapshot: &SubAgentResult) -> bool { } async fn subagent_session_projection( + manager: &SharedSubAgentManager, snapshot: SubAgentResult, timed_out: bool, context: &ToolContext, worker_record: Option, ) -> SubAgentSessionProjection { + // Deferred-verification backstop (#6210): terminal commits outside the + // natural/panic epilogues (Stop, interrupt, session close, stale cleanup) + // leave verification pending, so the first detail read heals it before + // the projection reports it. Re-fetch under the session gate afterwards. + let worker_record = if snapshot.status != SubAgentStatus::Running + && worker_record + .as_ref() + .is_some_and(|record| !record.delivery_evidence.checked) + { + ensure_worker_delivery_verified(manager, &snapshot.agent_id, &snapshot).await; + manager + .read() + .await + .get_worker_record_for_session(&context.state_namespace, &snapshot.agent_id) + .or(worker_record) + } else { + worker_record + }; let transcript_session_id = format!("agent:{}", snapshot.agent_id); let continuable = subagent_checkpoint_is_continuable(&snapshot); let transcript_payload = json!({ @@ -9672,7 +9709,9 @@ impl ToolSpec for AgentTool { let manager = self.manager.read().await; manager.get_worker_record_for_session(&context.state_namespace, &snapshot.agent_id) }; - let projection = subagent_session_projection(snapshot, false, context, worker_record).await; + let projection = + subagent_session_projection(&self.manager, snapshot, false, context, worker_record) + .await; let mut value = serde_json::to_value(&projection) .map_err(|e| ToolError::execution_failed(e.to_string()))?; compact_spawn_receipt(&mut value, verbose); @@ -9914,7 +9953,7 @@ async fn inspect_agent_from_input( } let mut projection = - subagent_session_projection(snapshot, false, context, worker_record).await; + subagent_session_projection(&manager, snapshot, false, context, worker_record).await; projection.resumed_from = compact .get("resumed_from") .and_then(Value::as_str) @@ -9979,7 +10018,8 @@ async fn cancel_agent_from_input( manager.get_worker_record_for_session(&context.state_namespace, &snapshot.agent_id); (snapshot, worker_record) }; - let projection = subagent_session_projection(snapshot, false, context, worker_record).await; + let projection = + subagent_session_projection(&manager, snapshot, false, context, worker_record).await; let mut tool_result = ToolResult::json(&projection) .map_err(|err| ToolError::execution_failed(err.to_string()))?; tool_result.metadata = Some(json!({ @@ -10438,6 +10478,21 @@ async fn spawn_subagent_from_input( if let Some((lease_key, display_path)) = resident_lease.as_ref() { reserve_resident_lease(lease_key, display_path)?; } + // #6210: fingerprint the delivery baseline BEFORE the manager write + // lock. The child workspace is final here (worktree creation already + // ran); write capability resolves under the lock, so capture assuming + // write and let registration discard it for readers. Skipped only when + // the parent ceiling already denies writes — `derive_child` intersects, + // so that child is definitely read-only. A join failure falls back to + // the inline capture, exactly as before. + let precomputed_delivery_evidence = if child_runtime.worker_profile.permissions.write { + let workspace = child_runtime.context.workspace.clone(); + tokio::task::spawn_blocking(move || DeliveryEvidence::capture_for_handle(&workspace, true)) + .await + .ok() + } else { + None + }; let mut manager_guard = manager.write().await; let result = manager_guard.spawn_background_with_assignment_options( @@ -10468,6 +10523,7 @@ async fn spawn_subagent_from_input( claim_pre_namespaced: false, preserve_runtime_profile: None, }, + precomputed_delivery_evidence, ); let result = match result { Ok(result) => result, @@ -11109,28 +11165,29 @@ async fn supervise_subagent_task_body( return; }; let message = crate::utils::panic_message(&*panic); + let mut result = match manager_handle.read().await.get_result(&agent_id) { + Ok(result) => result, + Err(err) => { + tracing::error!( + target: "subagent", + agent_id = %agent_id, + ?err, + "panicked task no longer has a manager record" + ); + std::panic::resume_unwind(panic); + } + }; + result.status = SubAgentStatus::Failed(format!("sub-agent task panicked: {message}")); + result.result = None; + result.needs_input = None; + // Verification precedes the write lock like the natural epilogue (#6210). + ensure_worker_delivery_verified(&manager_handle, &agent_id, &result).await; { let mut manager = manager_handle.write().await; - match manager.get_result(&agent_id) { - Ok(mut result) => { - result.status = - SubAgentStatus::Failed(format!("sub-agent task panicked: {message}")); - result.result = None; - result.needs_input = None; - // Arbitrated exactly like the natural terminal commit: when a - // cancel or another terminal outcome already won, this is a - // no-op rather than a second result. - manager.finish_terminal_result(&agent_id, result, false, true); - } - Err(err) => { - tracing::error!( - target: "subagent", - agent_id = %agent_id, - ?err, - "panicked task no longer has a manager record" - ); - } - } + // Arbitrated exactly like the natural terminal commit: when a + // cancel or another terminal outcome already won, this is a + // no-op rather than a second result. + manager.finish_terminal_result(&agent_id, result, false, true); } std::panic::resume_unwind(panic); } @@ -11200,6 +11257,41 @@ async fn budget_work_preservation_note( }) } +/// Deferred delivery verification (#6210). Snapshots inputs under a read +/// lock, runs the git-subprocess + fingerprint compute in `spawn_blocking` +/// with no lock held, then stores under a follow-up write lock. Idempotent +/// via `DeliveryEvidence.checked`: concurrent callers may duplicate the +/// read-only computation, but only the first store wins. +/// +/// A racing terminal commit can land between the compute and the store; the +/// stored verdict is then based on this call's result text rather than the +/// committed one. The deliverable-presence verdicts are text-independent, so +/// only the claimed-path comparison can differ, and only in that race. +async fn ensure_worker_delivery_verified( + manager: &SharedSubAgentManager, + worker_id: &str, + result: &SubAgentResult, +) { + let inputs = { + let manager = manager.read().await; + let Some(inputs) = manager.delivery_verification_inputs(worker_id, result) else { + return; + }; + inputs + }; + let verification = + tokio::task::spawn_blocking(move || delivery::compute_delivery_verification(&inputs)) + .await + .ok(); + let Some(verification) = verification else { + return; + }; + manager + .write() + .await + .store_delivery_verification(worker_id, verification); +} + fn budget_partial_result_with_note( mut result: SubAgentResult, cause: &str, @@ -11384,40 +11476,48 @@ async fn run_subagent_task_inner(mut task: SubAgentTask) { None }; + // The terminal result is built before the write lock: `get_result` takes + // `&self`, the error transforms are pure, and no live writer can change + // the agent's text between here and the commit — only a racing terminal + // claim, which leaves this epilogue with nothing to commit either way. + // Verification runs here too, so the fan-in completion and the terminal + // persist both carry fresh verdicts with no git subprocess under the lock + // (#6210). + let terminal = match result { + Ok(result) => result, + Err(_) => { + let mut result = match task.manager_handle.read().await.get_result(&agent_id) { + Ok(result) => result, + Err(err) => { + tracing::error!( + target: "subagent", + agent_id = %agent_id, + ?err, + "failed task no longer has a manager record" + ); + return; + } + }; + let error = failure_error + .clone() + .expect("failed task should carry annotated error"); + if error.contains("wall-time budget exhausted") { + budget_partial_result(result, &error, preservation_note.as_deref()) + } else { + result.status = SubAgentStatus::Failed(error); + result.result = None; + result.needs_input = None; + result + } + } + }; + ensure_worker_delivery_verified(&task.manager_handle, &agent_id, &terminal).await; // Every terminal path — successful/fatal model exit, explicit Stop, // coordination interrupt, and stale cleanup — arbitrates and publishes // through `finish_terminal_result`. Cancellation that already won leaves // this late epilogue with no claim and therefore no duplicate fan-in. let terminal_committed = { let mut manager = task.manager_handle.write().await; - let terminal = match result { - Ok(result) => result, - Err(_) => { - let mut result = match manager.get_result(&agent_id) { - Ok(result) => result, - Err(err) => { - tracing::error!( - target: "subagent", - agent_id = %agent_id, - ?err, - "failed task no longer has a manager record" - ); - return; - } - }; - let error = failure_error - .clone() - .expect("failed task should carry annotated error"); - if error.contains("wall-time budget exhausted") { - budget_partial_result(result, &error, preservation_note.as_deref()) - } else { - result.status = SubAgentStatus::Failed(error); - result.result = None; - result.needs_input = None; - result - } - } - }; manager.finish_terminal_result(&agent_id, terminal, false, true) }; if !terminal_committed { diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index ba2f577c59..6b7d28d0a5 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -2181,6 +2181,7 @@ async fn detached_interactive_usage_after_mailbox_seal_reaches_session_accountin manager.write().await.register_worker_for_session( make_worker_spec(worker_id, tmp.path().to_path_buf()), "session-detached-usage", + None, ); } let (release_usage_tx, release_usage_rx) = tokio::sync::oneshot::channel(); @@ -2266,6 +2267,7 @@ async fn child_guardian_usage_source_is_sanitized_and_replay_idempotent() { manager.write().await.register_worker_for_session( make_worker_spec("agent_guardian", tmp.path().to_path_buf()), "guardian-usage-session", + None, ); let runtime_owner = "interactive:guardian-usage-session:turn-parent"; @@ -2352,6 +2354,7 @@ async fn ownerless_no_mailbox_provider_usage_reaches_accounting_once() { manager.write().await.register_worker_for_session( make_worker_spec("agent_direct", tmp.path().to_path_buf()), "direct-usage-session", + None, ); let mut runtime = stub_runtime(); runtime.manager = Arc::clone(&manager); @@ -2446,6 +2449,7 @@ async fn ownerless_child_usage_crossing_new_settles_to_its_dispatch_origin_once( manager.write().await.register_worker_for_session( make_worker_spec(agent_id, tmp.path().to_path_buf()), origin_session_id, + None, ); // Dispatch in the origin session: the engine's off-turn continuation @@ -2567,6 +2571,7 @@ async fn provider_success_without_usage_records_one_route_aware_gap_and_no_zero_ manager.write().await.register_worker_for_session( make_worker_spec("agent_missing_usage", tmp.path().to_path_buf()), "missing-usage-session", + None, ); let runtime_owner = "interactive:missing-usage-session:turn-parent"; @@ -5158,7 +5163,8 @@ async fn session_projection_exposes_forked_prefix_cache_contract() { snapshot.fork_context = true; let ctx = ToolContext::new("."); - let projection = subagent_session_projection(snapshot, false, &ctx, None).await; + let manager = new_shared_subagent_manager(PathBuf::from("."), 1); + let projection = subagent_session_projection(&manager, snapshot, false, &ctx, None).await; assert_eq!(projection.name, "fanout_review"); assert_eq!(projection.context_mode, "forked"); @@ -5204,7 +5210,8 @@ async fn terminal_session_projection_prefers_full_transcript_handle() { ) }; - let projection = subagent_session_projection(snapshot, false, &ctx, None).await; + let manager = new_shared_subagent_manager(PathBuf::from("."), 1); + let projection = subagent_session_projection(&manager, snapshot, false, &ctx, None).await; assert_eq!(projection.transcript_handle, full_handle); assert_eq!(projection.transcript_handle.name, "full_transcript"); @@ -5224,7 +5231,8 @@ async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { snapshot.checkpoint = Some(checkpoint.clone()); let ctx = ToolContext::new("."); - let projection = subagent_session_projection(snapshot, false, &ctx, None).await; + let manager = new_shared_subagent_manager(PathBuf::from("."), 1); + let projection = subagent_session_projection(&manager, snapshot, false, &ctx, None).await; assert_eq!(projection.status, "waiting_for_user"); assert!(projection.terminal); @@ -5257,7 +5265,7 @@ async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { ); let timed_out_projection = - subagent_session_projection(projection.snapshot.clone(), true, &ctx, None).await; + subagent_session_projection(&manager, projection.snapshot.clone(), true, &ctx, None).await; assert!(timed_out_projection.needs_continuation); assert!(timed_out_projection.timed_out); assert!(timed_out_projection.timed_out_with_checkpoint); @@ -9274,7 +9282,8 @@ async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parkin manager.get_worker_record(&agent_id) }; let projection = - subagent_session_projection(interrupted.clone(), false, &ctx, worker_record).await; + subagent_session_projection(&manager, interrupted.clone(), false, &ctx, worker_record) + .await; assert_eq!(projection.status, "waiting_for_user"); assert!(projection.continuable); assert!(projection.needs_continuation); @@ -9665,6 +9674,7 @@ async fn spawn_duplicate_session_name_error_names_conflicting_agent() { name: Some("researcher".to_string()), ..Default::default() }, + None, ) .expect_err("duplicate session name must error") }; @@ -9732,6 +9742,7 @@ async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { name: Some("researcher".to_string()), ..Default::default() }, + None, ) .expect("a prior-session holder must not reject a fresh same-name spawn") }; @@ -9775,6 +9786,7 @@ async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested( make_assignment(), Some(vec![]), options, + None, ) .expect("first writer admitted"); let second = guard @@ -9795,6 +9807,7 @@ async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested( }), ..Default::default() }, + None, ) .expect_err("overlapping live contract must contend"); (first.agent_id, second.to_string()) @@ -9849,6 +9862,7 @@ async fn write_capable_agent_does_not_launch_when_durable_registration_fails() { }), ..Default::default() }, + None, ) .expect_err("writer must fail before spawn when its durable claim cannot commit") .to_string(); @@ -9930,6 +9944,7 @@ async fn write_scope_contention_covers_regular_agent_and_active_fleet_writer() { }), ..Default::default() }, + None, ) .expect_err("regular-agent launch must see active Fleet ownership"); let launch = launch.to_string(); @@ -15013,6 +15028,7 @@ fn persist_round_trip_preserves_session_and_boot_ownership() { writer.register_worker_for_session( make_worker_spec("headless_persist", dir.path().to_path_buf()), "session-persist", + None, ); writer .persist_state() @@ -20301,8 +20317,8 @@ fn init_claim_repo(root: &Path) { assert!(output.status.success(), "git commit: {output:?}"); } -#[test] -fn completed_claim_of_untouched_file_taints_verification() { +#[tokio::test] +async fn completed_claim_of_untouched_file_taints_verification() { // R7 (finish-operator 2026-08-02): the morning report caught a child // claiming edits git had never seen — by hand. At terminal delivery the // claimed changed-files are checked against git status in the child's @@ -20313,15 +20329,29 @@ fn completed_claim_of_untouched_file_taints_verification() { let mut spec = make_worker_spec("agent_claims", tmp.path().to_path_buf()); spec.runtime_profile.permissions.write = true; manager.register_worker(spec); + let manager = Arc::new(RwLock::new(manager)); let mut snapshot = make_snapshot(SubAgentStatus::Completed); snapshot.agent_id = "agent_claims".to_string(); snapshot.name = "agent_claims".to_string(); snapshot.workspace = Some(tmp.path().to_path_buf()); snapshot.result = Some("CHANGES: src/lib.rs".to_string()); - manager.complete_worker_from_result("agent_claims", &snapshot); + // Deferred verification (#6210): the commit leaves it pending; `ensure` + // computes the taint off the lock. + { + let mut guard = manager.write().await; + guard.complete_worker_from_result("agent_claims", &snapshot); + assert!( + !guard.worker_records["agent_claims"] + .delivery_evidence + .checked + ); + } + ensure_worker_delivery_verified(&manager, "agent_claims", &snapshot).await; let record = manager + .read() + .await .get_worker_record("agent_claims") .expect("worker record"); assert_eq!(record.verification.status, "claim_mismatch"); @@ -20405,8 +20435,8 @@ fn resume_from_rejects_running_source() { ); } -#[test] -fn completed_claim_matching_workspace_state_stays_untainted() { +#[tokio::test] +async fn completed_claim_matching_workspace_state_stays_untainted() { let tmp = tempdir().expect("tempdir"); init_claim_repo(tmp.path()); @@ -20414,16 +20444,11 @@ fn completed_claim_matching_workspace_state_stays_untainted() { let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 2); manager.register_worker(make_worker_spec("agent_honest", tmp.path().to_path_buf())); std::fs::write(tmp.path().join("src/lib.rs"), "pub fn improved() {}\n").expect("edit file"); - let mut snapshot = make_snapshot(SubAgentStatus::Completed); - snapshot.agent_id = "agent_honest".to_string(); - snapshot.name = "agent_honest".to_string(); - snapshot.workspace = Some(tmp.path().to_path_buf()); - snapshot.result = Some("Updated src/lib.rs with the new implementation.".to_string()); - manager.complete_worker_from_result("agent_honest", &snapshot); - let record = manager - .get_worker_record("agent_honest") - .expect("worker record"); - assert_eq!(record.verification.status, "self_report_only"); + let mut honest = make_snapshot(SubAgentStatus::Completed); + honest.agent_id = "agent_honest".to_string(); + honest.name = "agent_honest".to_string(); + honest.workspace = Some(tmp.path().to_path_buf()); + honest.result = Some("Updated src/lib.rs with the new implementation.".to_string()); // Honest committed claim: the child committed its work, so git status is // clean but the commit is newer than the worker record. @@ -20447,20 +20472,33 @@ fn completed_claim_matching_workspace_state_stays_untainted() { // so the just-made commit is unambiguously after it. record.created_at_ms = record.created_at_ms.saturating_sub(60_000); } - let mut snapshot = make_snapshot(SubAgentStatus::Completed); - snapshot.agent_id = "agent_committer".to_string(); - snapshot.name = "agent_committer".to_string(); - snapshot.workspace = Some(tmp.path().to_path_buf()); - snapshot.result = Some("Updated src/lib.rs and committed the change.".to_string()); - manager.complete_worker_from_result("agent_committer", &snapshot); - let record = manager - .get_worker_record("agent_committer") - .expect("worker record"); - assert_eq!( - record.verification.status, "self_report_only", - "{}", - record.verification.summary - ); + let mut committer = make_snapshot(SubAgentStatus::Completed); + committer.agent_id = "agent_committer".to_string(); + committer.name = "agent_committer".to_string(); + committer.workspace = Some(tmp.path().to_path_buf()); + committer.result = Some("Updated src/lib.rs and committed the change.".to_string()); + let manager = Arc::new(RwLock::new(manager)); + // Deferred verification (#6210): both commits leave verification + // pending; `ensure` computes the untainted verdicts off the lock. + { + let mut guard = manager.write().await; + guard.complete_worker_from_result("agent_honest", &honest); + guard.complete_worker_from_result("agent_committer", &committer); + for id in ["agent_honest", "agent_committer"] { + assert!(!guard.worker_records[id].delivery_evidence.checked); + } + } + ensure_worker_delivery_verified(&manager, "agent_honest", &honest).await; + ensure_worker_delivery_verified(&manager, "agent_committer", &committer).await; + let guard = manager.read().await; + for id in ["agent_honest", "agent_committer"] { + let record = guard.get_worker_record(id).expect("worker record"); + assert_eq!( + record.verification.status, "self_report_only", + "{id}: {}", + record.verification.summary + ); + } } #[tokio::test] @@ -20496,8 +20534,9 @@ async fn spawn_receipt_compacts_and_verbose_restores_the_archive() { let snapshot = inner.get_result(&agent_id).expect("snapshot"); let worker_record = inner.get_worker_record(&agent_id); let context = ToolContext::new("."); + let shared = new_shared_subagent_manager(PathBuf::from("."), 1); let mut projection = - subagent_session_projection(snapshot, false, &context, worker_record).await; + subagent_session_projection(&shared, snapshot, false, &context, worker_record).await; // The route receipt rides inside the budget rather than being exempt from // it (#5305), so measure the receipt that ships. let metadata = spawn_route_metadata("zai", "glm-5", "agent_profile.model"); @@ -20650,8 +20689,9 @@ async fn spawn_receipt_route_survives_compaction_for_a_type_only_spawn() { let snapshot = inner.get_result(&agent_id).expect("snapshot"); let worker_record = inner.get_worker_record(&agent_id); let context = ToolContext::new("."); + let shared = new_shared_subagent_manager(PathBuf::from("."), 1); let mut projection = - subagent_session_projection(snapshot, false, &context, worker_record).await; + subagent_session_projection(&shared, snapshot, false, &context, worker_record).await; let metadata = spawn_route_metadata("deepseek", "deepseek-v4-flash", "run.model"); projection.child_route = Some(spawn_child_route_projection(&metadata)); @@ -21681,6 +21721,7 @@ mod child_permission_gate { manager.register_worker_for_session( make_worker_spec("agent_gate", workspace), "guardian-test-session", + None, ); } // Keep the tempdir alive for the registry's lifetime by leaking it @@ -23076,3 +23117,53 @@ async fn test_disallowed_tools_resume_keeps_saved_and_current_ancestor_denials() vec!["mcp_saved_*"] ); } + +/// Precomputed spawn evidence (#6210) is adopted for write-capable workers: +/// a baseline captured before the lock answers changed-path queries. +#[test] +fn precomputed_delivery_evidence_is_adopted_for_write_workers() { + let tmp = tempdir().expect("tempdir"); + init_claim_repo(tmp.path()); + let evidence = DeliveryEvidence::capture_for_handle(tmp.path(), true); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 2); + let spec = make_write_worker_spec("agent_precomputed", tmp.path().to_path_buf(), "."); + assert!(spec.runtime_profile.permissions.write); + manager.register_worker_for_session(spec, "workspace", Some(evidence)); + let record = manager + .get_worker_record("agent_precomputed") + .expect("worker record"); + assert!(record.delivery_evidence.changed_paths(tmp.path()).is_some()); +} + +/// The resolved spec permission is authoritative: a precomputed baseline for +/// a read-only worker is discarded, never stored (#6210). +#[test] +fn precomputed_delivery_evidence_is_discarded_for_read_only_workers() { + let tmp = tempdir().expect("tempdir"); + init_claim_repo(tmp.path()); + let evidence = DeliveryEvidence::capture_for_handle(tmp.path(), true); + assert!(evidence.changed_paths(tmp.path()).is_some()); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 2); + let mut spec = make_worker_spec("agent_reader", tmp.path().to_path_buf()); + spec.runtime_profile.permissions.write = false; + manager.register_worker_for_session(spec, "workspace", Some(evidence)); + let record = manager + .get_worker_record("agent_reader") + .expect("worker record"); + assert!(record.delivery_evidence.changed_paths(tmp.path()).is_none()); +} + +/// Paths without precomputed evidence (resume, Fleet, tests) capture inline +/// at registration, exactly as before (#6210). +#[test] +fn missing_precomputed_evidence_falls_back_to_inline_capture() { + let tmp = tempdir().expect("tempdir"); + init_claim_repo(tmp.path()); + let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 2); + let spec = make_write_worker_spec("agent_inline", tmp.path().to_path_buf(), "."); + manager.register_worker_for_session(spec, "workspace", None); + let record = manager + .get_worker_record("agent_inline") + .expect("worker record"); + assert!(record.delivery_evidence.changed_paths(tmp.path()).is_some()); +} From 6286136e6b489c23e7e8c9df5b612027a718c9ed Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 18:14:57 -0700 Subject: [PATCH 14/24] feat(runtime): cancel queued Agent Mail before delivery (#6176) Agent Mail is the durable submit-while-running queue: nothing ever produces a Queued turn record (POST /turns rejects a busy thread), so the executable slice is withdrawing a queued envelope, not turn routes. - protocol: AgentMailStatus::Canceled + agent_mail.canceled event with validation (no delivery fields, like queued). - runtime_threads: cancel_agent_mail mirrors mark_agent_mail_read (ownership gate, mail_mutation lock); Queued -> Canceled, re-cancel idempotent, post-delivery states an explicit conflict. Deliver treats Canceled as terminal (envelope back, no turn); the wake pump's Queued-only match skips it structurally. - runtime_api: POST /v1/threads/{id}/agent-mail/{message_id}/cancel; cancel-after-delivery maps to 409. Unknown message ids now map to 404 via a typed io::NotFound chain check (also repairs deliver/read/mark of unknown ids, which fell through to 400). Tests: cancel route test (list/cancel/re-cancel/deliver-after-cancel/ 409/403/404), 409 mapping unit test, protocol validation test. --- crates/protocol/src/agent_mail.rs | 27 +++++ crates/tui/src/runtime_api.rs | 32 +++++- crates/tui/src/runtime_api/tests.rs | 168 ++++++++++++++++++++++++++++ crates/tui/src/runtime_threads.rs | 50 ++++++++- 4 files changed, 270 insertions(+), 7 deletions(-) diff --git a/crates/protocol/src/agent_mail.rs b/crates/protocol/src/agent_mail.rs index 8679c7486f..1c340d5e23 100644 --- a/crates/protocol/src/agent_mail.rs +++ b/crates/protocol/src/agent_mail.rs @@ -21,6 +21,7 @@ pub const AGENT_MAIL_EVENT_DELIVERING: &str = "agent_mail.delivering"; pub const AGENT_MAIL_EVENT_DELIVERED: &str = "agent_mail.delivered"; pub const AGENT_MAIL_EVENT_READ: &str = "agent_mail.read"; pub const AGENT_MAIL_EVENT_DELIVERY_FAILED: &str = "agent_mail.delivery_failed"; +pub const AGENT_MAIL_EVENT_CANCELED: &str = "agent_mail.canceled"; pub const MAX_AGENT_MAIL_MESSAGE_ID_BYTES: usize = 80; pub const MAX_AGENT_MAIL_OPAQUE_ID_BYTES: usize = 128; @@ -223,6 +224,9 @@ pub enum AgentMailStatus { Delivered, Read, Failed, + /// Explicitly withdrawn while queued (#6176). Terminal: delivery and the + /// wake pump never claim it, and re-cancel is an idempotent no-op. + Canceled, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -337,6 +341,19 @@ impl AgentMailEnvelope { } AgentMailStatus::Delivered => self.validate_delivered(false)?, AgentMailStatus::Read => self.validate_delivered(true)?, + AgentMailStatus::Canceled => { + // Canceled mail never started delivery: same absences as + // queued. Attempts stay 0 — cancel is only accepted while + // queued, before any delivery claim. + require_absent(self.delivered_at.is_some(), "delivered_at", "canceled")?; + require_absent(self.read_at.is_some(), "read_at", "canceled")?; + require_absent(self.failure.is_some(), "failure", "canceled")?; + require_absent( + self.delivery_turn_id.is_some(), + "delivery_turn_id", + "canceled", + )?; + } AgentMailStatus::Failed => { if self.attempt_count == 0 { return Err(AgentMailValidationError::new( @@ -719,6 +736,16 @@ mod tests { assert!(envelope.validate().is_ok()); } + #[test] + fn canceled_envelope_validates_without_delivery_fields() { + let mut envelope = queued_envelope(); + envelope.status = AgentMailStatus::Canceled; + assert!(envelope.validate().is_ok()); + envelope.delivery_turn_id = Some("turn_1".into()); + assert!(envelope.validate().is_err()); + assert_eq!(AGENT_MAIL_EVENT_CANCELED, "agent_mail.canceled"); + } + #[test] fn replay_equivalence_ignores_delivery_state_but_not_intent() { let queued = queued_envelope(); diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index c6b1198f5f..e6d8a13996 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -1287,6 +1287,10 @@ pub fn build_router(state: RuntimeApiState) -> Router { "/v1/threads/{id}/agent-mail/{message_id}/read", post(mark_agent_mail_read), ) + .route( + "/v1/threads/{id}/agent-mail/{message_id}/cancel", + post(cancel_agent_mail), + ) .route( "/v1/threads/{id}/goal", get(get_thread_goal) @@ -5276,6 +5280,23 @@ async fn mark_agent_mail_read( Ok(Json(envelope)) } +/// Withdraw a queued envelope before delivery (#6176). Idempotent: a +/// re-cancel returns the stored envelope; mail that already left `queued` +/// is a 409, never silently dropped. +async fn cancel_agent_mail( + State(state): State, + Path((id, message_id)): Path<(String, String)>, +) -> Result, ApiError> { + let message_id = AgentMailMessageId::parse(message_id) + .map_err(|error| ApiError::bad_request(error.to_string()))?; + let envelope = state + .runtime_threads + .cancel_agent_mail(&id, &message_id) + .await + .map_err(map_agent_mail_err)?; + Ok(Json(envelope)) +} + async fn steer_thread_turn( State(state): State, Path((id, turn_id)): Path<(String, String)>, @@ -8785,10 +8806,17 @@ fn map_agent_mail_err(err: anyhow::Error) -> ApiError { let lower = message.to_ascii_lowercase(); if lower.contains("ownership denied") { ApiError::forbidden(message) - } else if lower.contains("already exists with different delivery intent") { + } else if lower.contains("already exists with different delivery intent") + || lower.contains("can be canceled only while queued") + { ApiError::conflict(message) } else if (lower.contains("failed to read agent mail envelope") - && lower.contains("no such file")) + && (lower.contains("no such file") + || err.chain().skip(1).any(|cause| { + cause + .downcast_ref::() + .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound) + }))) || (lower.starts_with("thread '") && lower.ends_with("' not found")) { ApiError::not_found(message) diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 00b7b998e1..a30ed4a6d5 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -17057,3 +17057,171 @@ async fn provider_key_clear_round_trips_and_reports_writability() -> Result<()> handle.abort(); Ok(()) } + +#[test] +fn agent_mail_cancel_conflict_maps_to_409() { + let conflict = map_agent_mail_err(anyhow::anyhow!( + "Agent Mail can be canceled only while queued (status: Delivered)" + )); + assert_eq!(conflict.status, StatusCode::CONFLICT); +} + +/// #6176: Agent Mail is the durable "submit while running" queue (nothing +/// ever produces a `Queued` turn record — POST /turns rejects a busy +/// thread). Withdrawing a queued envelope is idempotent, delivery after +/// cancel starts no turn, and mail that already left `queued` is an +/// explicit conflict rather than a silent drop. +#[tokio::test] +async fn agent_mail_cancel_withdraws_queued_mail() -> Result<()> { + let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + // Agent Mail addressability needs task-bound threads, and the sender + // identity must be the source thread's task id. + let new_thread = |task_id: &str| { + let client = &client; + let base = base.clone(); + let task_id = task_id.to_string(); + async move { + let thread: Value = client + .post(format!("{base}/v1/threads")) + .json(&json!({ "task_id": task_id })) + .send() + .await? + .error_for_status()? + .json() + .await?; + anyhow::Ok(thread["id"].as_str().expect("thread id").to_string()) + } + }; + let source = new_thread("task_a").await?; + let destination = new_thread("task_b").await?; + + let send = |message_id: &str| { + let client = &client; + let base = base.clone(); + let source = source.clone(); + let destination = destination.clone(); + let message_id = message_id.to_string(); + async move { + let sent = client + .post(format!("{base}/v1/agent-mail")) + .json(&json!({ + "message_id": message_id, + "source_thread_id": source, + "destination_thread_id": destination, + "sender": {"identity": "task_a", "display_label": "Test Sender"}, + "summary": "handoff: review the queued work", + "delivery_mode": "queue_only", + "trigger_turn": false, + })) + .send() + .await?; + anyhow::Ok(sent) + } + }; + + let sent = send("mail_queue_1").await?; + assert_eq!(sent.status(), StatusCode::CREATED); + let body: Value = sent.json().await?; + assert_eq!(body["envelope"]["status"], "queued"); + + let inbox: Value = client + .get(format!("{base}/v1/threads/{destination}/agent-mail")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!(inbox.as_array().is_some_and(|mail| { + mail.iter() + .any(|item| item["message_id"] == "mail_queue_1" && item["status"] == "queued") + })); + + let canceled: Value = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_queue_1/cancel" + )) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(canceled["status"], "canceled"); + + // Re-cancel is an idempotent no-op returning the stored envelope. + let recanceled: Value = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_queue_1/cancel" + )) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(recanceled, canceled); + + // Delivery after cancel starts no turn. + let delivered: Value = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_queue_1/deliver" + )) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(delivered["envelope"]["status"], "canceled"); + assert!(delivered.get("turn").is_none()); + + // Mail that already left `queued` is an explicit conflict. + let sent = send("mail_queue_2").await?; + assert_eq!(sent.status(), StatusCode::CREATED); + let delivered: Value = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_queue_2/deliver" + )) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(delivered["envelope"]["status"], "delivered"); + let conflict = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_queue_2/cancel" + )) + .send() + .await?; + assert_eq!(conflict.status(), StatusCode::CONFLICT); + assert!( + conflict + .text() + .await? + .contains("can be canceled only while queued") + ); + + // A message addressed elsewhere is a foreign-destination rejection. + let foreign = client + .post(format!( + "{base}/v1/threads/{source}/agent-mail/mail_queue_1/cancel" + )) + .send() + .await?; + assert_eq!(foreign.status(), StatusCode::FORBIDDEN); + + // An unknown message id is a 404, never a silent success. + let missing = client + .post(format!( + "{base}/v1/threads/{destination}/agent-mail/mail_nope/cancel" + )) + .send() + .await?; + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + + handle.abort(); + Ok(()) +} diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index e3ae5fe4fd..ce54bd622f 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -60,11 +60,12 @@ use codewhale_execpolicy::ApprovalMode; use codewhale_models::Role; use codewhale_models::{ContentBlock, Message, SystemPrompt, Usage}; use codewhale_protocol::agent_mail::{ - AGENT_MAIL_EVENT_DELIVERED, AGENT_MAIL_EVENT_DELIVERING, AGENT_MAIL_EVENT_DELIVERY_FAILED, - AGENT_MAIL_EVENT_QUEUED, AGENT_MAIL_EVENT_READ, AGENT_MAIL_SCHEMA_VERSION, AgentMailAddress, - AgentMailDeliveryMode, AgentMailEnvelope, AgentMailEventPayload, AgentMailFailureCode, - AgentMailFailureReceipt, AgentMailMessageId, AgentMailSendRequest, AgentMailSendResponse, - AgentMailStatus, MAX_AGENT_MAIL_DELIVERY_ATTEMPTS, MAX_AGENT_MAIL_SUMMARY_BYTES, + AGENT_MAIL_EVENT_CANCELED, AGENT_MAIL_EVENT_DELIVERED, AGENT_MAIL_EVENT_DELIVERING, + AGENT_MAIL_EVENT_DELIVERY_FAILED, AGENT_MAIL_EVENT_QUEUED, AGENT_MAIL_EVENT_READ, + AGENT_MAIL_SCHEMA_VERSION, AgentMailAddress, AgentMailDeliveryMode, AgentMailEnvelope, + AgentMailEventPayload, AgentMailFailureCode, AgentMailFailureReceipt, AgentMailMessageId, + AgentMailSendRequest, AgentMailSendResponse, AgentMailStatus, MAX_AGENT_MAIL_DELIVERY_ATTEMPTS, + MAX_AGENT_MAIL_SUMMARY_BYTES, }; use codewhale_protocol::runtime::{ DynamicToolCallContent, DynamicToolCallParams, DynamicToolCallResult, DynamicToolSpec, @@ -499,6 +500,7 @@ fn agent_mail_event_for_status(status: AgentMailStatus) -> &'static str { AgentMailStatus::Delivered => AGENT_MAIL_EVENT_DELIVERED, AgentMailStatus::Read => AGENT_MAIL_EVENT_READ, AgentMailStatus::Failed => AGENT_MAIL_EVENT_DELIVERY_FAILED, + AgentMailStatus::Canceled => AGENT_MAIL_EVENT_CANCELED, } } @@ -6246,6 +6248,41 @@ impl RuntimeThreadManager { Ok(envelope) } + /// Withdraw a queued envelope before it starts delivery (#6176). Only + /// `Queued` mail can be canceled; anything that reached delivery keeps + /// its receipt. Re-canceling an already-canceled envelope is an + /// idempotent no-op returning the stored envelope. + pub async fn cancel_agent_mail( + &self, + thread_id: &str, + message_id: &AgentMailMessageId, + ) -> Result { + let thread = self.get_thread(thread_id).await?; + let address = agent_mail_address(&self.store.owner_id, &thread)?; + let envelope = { + let _mail_mutation = self.store.mail_mutation.lock(); + let mut envelope = self.store.load_agent_mail(message_id)?; + if envelope.destination != address { + bail!("Agent Mail ownership denied: message does not belong to this destination"); + } + match envelope.status { + AgentMailStatus::Canceled => envelope, + AgentMailStatus::Queued => { + envelope.status = AgentMailStatus::Canceled; + self.store.save_agent_mail(&envelope)?; + envelope + } + _ => bail!( + "Agent Mail can be canceled only while queued (status: {:?})", + envelope.status + ), + } + }; + self.emit_agent_mail_event(AGENT_MAIL_EVENT_CANCELED, &envelope) + .await?; + Ok(envelope) + } + /// Claim and project one envelope into the existing destination turn /// queue. A busy thread keeps queued mail untouched; retryable failures are /// claimed again only below the bounded attempt ceiling. @@ -6286,6 +6323,9 @@ impl RuntimeThreadManager { AgentMailStatus::Delivered => Some(AGENT_MAIL_EVENT_DELIVERED), AgentMailStatus::Read => Some(AGENT_MAIL_EVENT_READ), AgentMailStatus::Delivering => Some(AGENT_MAIL_EVENT_DELIVERING), + // Canceled mail is terminal: a later deliver returns the + // envelope with no turn rather than claiming it (#6176). + AgentMailStatus::Canceled => Some(AGENT_MAIL_EVENT_CANCELED), AgentMailStatus::Failed if envelope .failure From dc0b38ecdcf32e7d92cabf0a1a3473fe59534123 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 18:27:56 -0700 Subject: [PATCH 15/24] refactor(tui): adopt the shared list_nav vocabulary in model/slash/fleet views (#6290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 2+3: model picker, slash menu, fleet list/roster/setup now resolve movement through list_nav instead of per-surface key tables. list_nav.rs itself untouched. - model_picker: two-pane apply_motion (typing-safe set for the live filter); pages keep the 5-row distance but clamp instead of wrapping. - slash_menu: selection helpers collapse onto move_slash_menu_selection; PageUp/PageDown navigate the popup (previously scrolled the transcript); Home/End deliberately stay cursor keys since the composer is still the focused input. - fleet_list: full axis incl. j/k, new paging; single-column. - fleet_roster: bare keys drive rows, Shift+PageUp/Down/Home keep detail scroll (#6014-style split); region declined so Tab keeps opening workers. - fleet_setup: choice steps page/clamp; Review step keeps scroll on the same keys (no row list there); region declined so Tab/Left/Right keep wizard arms; filter mode gains paging. Tests: roster bare/shift paging split, slash paging clamp; updated the roster detail-scroll test to the Shift+ chord. Note: the mention menu still clamps Up/Down (slash wraps) — left for a follow-up UX call. --- crates/tui/src/tui/composer_ui.rs | 28 +++- crates/tui/src/tui/model_picker.rs | 98 +++++++------- crates/tui/src/tui/ui/event_loop.rs | 20 +++ crates/tui/src/tui/ui/tests.rs | 24 ++++ crates/tui/src/tui/views/fleet_list.rs | 41 +++--- crates/tui/src/tui/views/fleet_roster.rs | 87 ++++++++++--- .../tui/src/tui/views/fleet_roster/tests.rs | 15 ++- crates/tui/src/tui/views/fleet_setup.rs | 120 ++++++++++++++---- 8 files changed, 310 insertions(+), 123 deletions(-) diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index 6d56a8de21..8750fa4cbd 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -45,20 +45,34 @@ pub(crate) fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeActi } } -pub(crate) fn select_previous_slash_menu_entry(app: &mut App, entry_count: usize) { +/// Rows one PageUp/PageDown travels in the slash menu. Pages clamp at the +/// ends per the shared vocabulary instead of wrapping (#6290). +const SLASH_MENU_PAGE: usize = 10; + +/// Move the slash-menu selection by one shared-vocabulary motion (#6290). +/// Steps wrap; pages travel [`SLASH_MENU_PAGE`] rows and clamp. The menu is +/// single-column, so the region axis is a no-op. +pub(crate) fn move_slash_menu_selection( + app: &mut App, + entry_count: usize, + motion: crate::tui::list_nav::Motion, +) { if entry_count == 0 { return; } let selected = app.slash_menu_selected.min(entry_count.saturating_sub(1)); - app.slash_menu_selected = (selected + entry_count - 1) % entry_count; + if let Some(next) = crate::tui::list_nav::apply(selected, entry_count, SLASH_MENU_PAGE, motion) + { + app.slash_menu_selected = next; + } +} + +pub(crate) fn select_previous_slash_menu_entry(app: &mut App, entry_count: usize) { + move_slash_menu_selection(app, entry_count, crate::tui::list_nav::Motion::Prev); } pub(crate) fn select_next_slash_menu_entry(app: &mut App, entry_count: usize) { - if entry_count == 0 { - return; - } - let selected = app.slash_menu_selected.min(entry_count.saturating_sub(1)); - app.slash_menu_selected = (selected + 1) % entry_count; + move_slash_menu_selection(app, entry_count, crate::tui::list_nav::Motion::Next); } pub(crate) fn handle_composer_history_arrow( diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 231c3d7f1f..ec0076d2d6 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -1070,6 +1070,40 @@ impl ModelPickerView { } } + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Steps wrap; pages travel [`MODEL_PAGE`] rows + /// and clamp. The region axis toggles between the model and effort panes. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + use crate::tui::list_nav::Motion; + if matches!(motion, Motion::RegionPrev | Motion::RegionNext) { + if self.can_edit_effort() { + self.toggle_focus(); + } + return true; + } + let (current, len) = match self.focus { + Pane::Model => (self.selected_model_idx, self.model_row_count()), + Pane::Effort => (self.selected_effort_idx, self.current_efforts().len()), + }; + if len == 0 { + return false; + } + let Some(next) = crate::tui::list_nav::apply(current, len, MODEL_PAGE, motion) else { + return false; + }; + match self.focus { + Pane::Model => { + self.selected_model_idx = next; + self.select_effort_for_current_model(); + } + Pane::Effort => { + self.selected_effort_idx = next; + self.selected_effort_request = self.resolved_effort(); + } + } + true + } + fn toggle_focus(&mut self) { self.focus = match self.focus { Pane::Model => Pane::Effort, @@ -3595,6 +3629,14 @@ impl ModalView for ModelPickerView { fn handle_key(&mut self, key: KeyEvent) -> ViewAction { self.last_mouse_selected = None; + // Movement keys come from the shared vocabulary (#6290); the match + // below owns only the picker's own verbs. The live filter means the + // typing-safe set — no letter aliases to eat the query. + if let Some(motion) = crate::tui::list_nav::motion_while_typing(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Char('s' | 'S') if key.modifiers == KeyModifiers::CONTROL => { self.cycle_sort(); @@ -3700,58 +3742,6 @@ impl ModalView for ModelPickerView { self.update_query(query); ViewAction::None } - KeyCode::Up => { - self.move_up(); - ViewAction::None - } - KeyCode::Down => { - self.move_down(); - ViewAction::None - } - KeyCode::PageUp => { - for _ in 0..5 { - self.move_up(); - } - ViewAction::None - } - KeyCode::PageDown => { - for _ in 0..5 { - self.move_down(); - } - ViewAction::None - } - KeyCode::Home => { - match self.focus { - Pane::Model => { - self.selected_model_idx = 0; - self.select_effort_for_current_model(); - } - Pane::Effort => { - self.selected_effort_idx = 0; - self.selected_effort_request = self.resolved_effort(); - } - } - ViewAction::None - } - KeyCode::End => { - match self.focus { - Pane::Model => { - self.selected_model_idx = self.model_row_count().saturating_sub(1); - self.select_effort_for_current_model(); - } - Pane::Effort => { - self.selected_effort_idx = self.current_efforts().len().saturating_sub(1); - self.selected_effort_request = self.resolved_effort(); - } - } - ViewAction::None - } - KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => { - if self.can_edit_effort() { - self.toggle_focus(); - } - ViewAction::None - } // Explicit readiness + catalog refresh (safe, non-destructive). // Plain `r` remains a route-search character. KeyCode::Char('r') | KeyCode::Char('R') @@ -4026,6 +4016,10 @@ impl ModelPickerView { } } +/// Rows one PageUp/PageDown travels. Pages clamp at the ends per the shared +/// vocabulary instead of wrapping (#6290). +const MODEL_PAGE: usize = 5; + /// Previous index in a list that rotates: 0 wraps to the last row. /// `count` must be non-zero. fn wrapping_prev(index: usize, count: usize) -> usize { diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index 6bd31133ff..dafa85b329 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -6275,6 +6275,26 @@ pub(crate) async fn run_event_loop( { select_next_slash_menu_entry(app, slash_menu_entries.len()); } + // Paging and edge motions from the shared vocabulary (#6290), + // claimed before the unconditional transcript-scroll arms. + KeyCode::PageUp if key.modifiers.is_empty() && slash_menu_open => { + move_slash_menu_selection( + app, + slash_menu_entries.len(), + crate::tui::list_nav::Motion::PagePrev, + ); + } + KeyCode::PageDown if key.modifiers.is_empty() && slash_menu_open => { + move_slash_menu_selection( + app, + slash_menu_entries.len(), + crate::tui::list_nav::Motion::PageNext, + ); + } + // Home/End deliberately stay cursor keys while the menu is open: + // the composer is still the focused input (same as Left/Right + // and the mention menu), so only vertical travel belongs to + // the popup. KeyCode::Down if key.modifiers.is_empty() && app.selected_composer_attachment_index().is_some() => diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 8cbc8295cb..d2cbf1f182 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -16695,6 +16695,30 @@ fn slash_menu_down_wraps_from_last_to_first() { assert_eq!(app.input, "/"); } +#[test] +fn slash_menu_paging_clamps_instead_of_wrapping() { + use crate::tui::list_nav::Motion; + let mut app = create_test_app(); + app.input = "/".to_string(); + app.cursor_position = 1; + + let entries = visible_slash_menu_entries(&app, 128); + assert!(entries.len() > 1); + let last = entries.len() - 1; + + // Pages travel and clamp (#6290); only steps wrap. + app.slash_menu_selected = 0; + move_slash_menu_selection(&mut app, entries.len(), Motion::PageNext); + assert_eq!(app.slash_menu_selected, 10.min(last)); + move_slash_menu_selection(&mut app, entries.len(), Motion::PageNext); + assert_eq!(app.slash_menu_selected, 20.min(last)); + move_slash_menu_selection(&mut app, entries.len(), Motion::Last); + assert_eq!(app.slash_menu_selected, last); + move_slash_menu_selection(&mut app, entries.len(), Motion::First); + assert_eq!(app.slash_menu_selected, 0); + assert_eq!(app.input, "/"); +} + #[test] fn apply_slash_menu_selection_appends_space_for_arg_commands() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/views/fleet_list.rs b/crates/tui/src/tui/views/fleet_list.rs index 3766e73cee..dfb668a9e4 100644 --- a/crates/tui/src/tui/views/fleet_list.rs +++ b/crates/tui/src/tui/views/fleet_list.rs @@ -37,6 +37,10 @@ use crate::tui::views::{ use codewhale_localization::{Locale, MessageId, tr}; use codewhale_palette as palette; +/// Rows one PageUp/PageDown travels. Pages clamp at the ends per the shared +/// vocabulary instead of wrapping (#6290). +const FLEET_LIST_PAGE: usize = 10; + /// What the host should do after this view acted on the store. #[derive(Debug, Clone, PartialEq, Eq)] #[allow(dead_code)] // OpenDetail/None are reserved for the qualified-name flow @@ -132,6 +136,23 @@ impl FleetListView { self.hovered_row.set(None); } + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Steps wrap; pages travel [`FLEET_LIST_PAGE`] + /// rows and clamp. The list is single-column, so the region axis is a + /// no-op. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + let len = self.entries.len(); + if len == 0 { + return false; + } + let Some(next) = crate::tui::list_nav::apply(self.row, len, FLEET_LIST_PAGE, motion) else { + return false; + }; + self.row = next; + self.hovered_row.set(None); + true + } + fn hit_row(&self, mouse: MouseEvent) -> Option { let position = ratatui::layout::Position::new(mouse.column, mouse.row); self.row_hitboxes @@ -233,12 +254,10 @@ impl ModalView for FleetListView { } match key.code { KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, - KeyCode::Up | KeyCode::Char('k') => { - self.move_row(-1); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_row(1); + // Movement keys come from the shared vocabulary (#6290), j/k + // aliases included — this surface captures no text. Pages are new + // here; they used to do nothing. + _ if crate::tui::list_nav::motion(&key).is_some_and(|m| self.apply_motion(m)) => { ViewAction::None } KeyCode::Enter => { @@ -325,16 +344,6 @@ impl ModalView for FleetListView { }), } } - KeyCode::Home => { - self.row = 0; - self.hovered_row.set(None); - ViewAction::None - } - KeyCode::End => { - self.row = self.entries.len().saturating_sub(1); - self.hovered_row.set(None); - ViewAction::None - } _ => ViewAction::None, } } diff --git a/crates/tui/src/tui/views/fleet_roster.rs b/crates/tui/src/tui/views/fleet_roster.rs index 5db2b5f22a..0a6fdfd077 100644 --- a/crates/tui/src/tui/views/fleet_roster.rs +++ b/crates/tui/src/tui/views/fleet_roster.rs @@ -25,7 +25,7 @@ use std::cell::{Cell, RefCell}; -use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, @@ -41,6 +41,10 @@ use crate::fleet::roster::{FleetRoster, ProfileLayer, ProfileOrigin, layers_from use crate::fleet::worker_runtime::roster_member_agent_type; use crate::tui::app::App; use crate::tui::menu_style; + +/// Rows one PageUp/PageDown travels. Pages clamp at the ends per the shared +/// vocabulary instead of wrapping (#6290). +const FLEET_ROSTER_PAGE: usize = 10; use crate::tui::views::{ ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer, truncate_view_text, @@ -257,6 +261,41 @@ impl FleetRosterView { self.hovered_row.set(None); } + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Steps wrap; pages travel [`FLEET_ROSTER_PAGE`] + /// rows and clamp. The region axis is declined so Tab keeps opening the + /// workers view through the explicit arm below. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + use crate::tui::list_nav::Motion; + match motion { + Motion::Prev => { + self.move_up(); + true + } + Motion::Next => { + self.move_down(); + true + } + Motion::RegionPrev | Motion::RegionNext => false, + _ => { + let count = self.row_count(); + if count == 0 { + return false; + } + let Some(next) = + crate::tui::list_nav::apply(self.selected, count, FLEET_ROSTER_PAGE, motion) + else { + return false; + }; + self.selected = next; + self.detail_scroll = 0; + self.last_mouse_selected = None; + self.hovered_row.set(None); + true + } + } + } + fn select_row(&mut self, row: usize) { self.selected = row.min(self.row_count().saturating_sub(1)); self.detail_scroll = 0; @@ -320,16 +359,34 @@ impl ModalView for FleetRosterView { // A keyboard gesture ends any pending mouse double-click sequence so // a later single click can never activate a stale row. self.last_mouse_selected = None; + // Shift-modified paging scrolls the detail pane; bare keys drive the + // row list through the shared vocabulary (#6290, #6014-style split). + if key.modifiers.contains(KeyModifiers::SHIFT) { + match key.code { + KeyCode::PageUp => { + self.detail_scroll = self.detail_scroll.saturating_sub(8); + return ViewAction::None; + } + KeyCode::PageDown => { + self.detail_scroll = self.detail_scroll.saturating_add(8); + return ViewAction::None; + } + KeyCode::Home => { + self.detail_scroll = 0; + return ViewAction::None; + } + _ => {} + } + } + // Movement keys come from the shared vocabulary (#6290), j/k aliases + // included — this surface captures no text. + if let Some(motion) = crate::tui::list_nav::motion(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, - KeyCode::Up | KeyCode::Char('k') => { - self.move_up(); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_down(); - ViewAction::None - } KeyCode::Enter => self.activate_selected(), // #5954: the roster stays on the stack under the view it opens, // so `Esc` in workers / saved teams pops back here instead of @@ -339,18 +396,6 @@ impl ModalView for FleetRosterView { ViewAction::Emit(ViewEvent::FleetRosterOpenWorkersRequested) } KeyCode::Char('f') => ViewAction::Emit(ViewEvent::FleetRosterOpenFleetsRequested), - KeyCode::Home => { - self.detail_scroll = 0; - ViewAction::None - } - KeyCode::PageUp => { - self.detail_scroll = self.detail_scroll.saturating_sub(8); - ViewAction::None - } - KeyCode::PageDown => { - self.detail_scroll = self.detail_scroll.saturating_add(8); - ViewAction::None - } _ => ViewAction::None, } } diff --git a/crates/tui/src/tui/views/fleet_roster/tests.rs b/crates/tui/src/tui/views/fleet_roster/tests.rs index b22ecfb25f..46087c03aa 100644 --- a/crates/tui/src/tui/views/fleet_roster/tests.rs +++ b/crates/tui/src/tui/views/fleet_roster/tests.rs @@ -188,12 +188,25 @@ fn arrows_move_selection_and_wrap() { #[test] fn selection_change_resets_detail_scroll() { let mut view = built_in_view(); - view.handle_key(key(KeyCode::PageDown)); + // Bare paging drives the row list; Shift-modified paging scrolls the + // detail pane (#6290, #6014-style split). + view.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::SHIFT)); assert_eq!(view.detail_scroll, 8); view.handle_key(key(KeyCode::Down)); assert_eq!(view.detail_scroll, 0); } +#[test] +fn bare_paging_drives_rows_not_the_detail_pane() { + let mut view = built_in_view(); + let last = view.members.len(); + view.handle_key(key(KeyCode::PageDown)); + assert_eq!(view.detail_scroll, 0); + assert_eq!(view.selected, 10.min(last)); + view.handle_key(key(KeyCode::Home)); + assert_eq!(view.selected, 0); +} + #[test] fn enter_opens_the_setup_wizard_for_members_only() { // Operator row: display-only, no wizard hand-off. diff --git a/crates/tui/src/tui/views/fleet_setup.rs b/crates/tui/src/tui/views/fleet_setup.rs index 80b9d2b3b8..7d3e0affb1 100644 --- a/crates/tui/src/tui/views/fleet_setup.rs +++ b/crates/tui/src/tui/views/fleet_setup.rs @@ -52,6 +52,12 @@ use codewhale_palette as palette; const PROFILE_DIR: &str = ".codewhale/agents"; +/// Rows one PageUp/PageDown travels on choice steps. Pages clamp at the ends +/// per the shared vocabulary instead of wrapping (#6290). +const SETUP_PAGE: usize = 10; +/// Lines one PageUp/PageDown scrolls on the Review step (unchanged). +const REVIEW_SCROLL_PAGE: usize = 8; + /// The only two truthful destinations for `/fleet setup`. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum FleetSetupEditTarget { @@ -1304,6 +1310,79 @@ impl FleetSetupView { } } + /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning + /// whether it was consumed. Steps wrap; pages travel [`SETUP_PAGE`] rows + /// and clamp. On the Review step the same keys scroll the proof pane + /// instead — it has no row list — and render clamps the offset. The + /// region axis is declined so Tab/Left/Right keep their explicit wizard + /// arms below. + fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool { + use crate::tui::list_nav::Motion; + match motion { + Motion::Prev => { + self.move_up(); + true + } + Motion::Next => { + self.move_down(); + true + } + Motion::RegionPrev | Motion::RegionNext => false, + _ => { + if self.step == Step::Review { + match motion { + Motion::PagePrev => { + self.review_scroll = + self.review_scroll.saturating_sub(REVIEW_SCROLL_PAGE); + } + Motion::PageNext => { + self.review_scroll = + self.review_scroll.saturating_add(REVIEW_SCROLL_PAGE); + } + Motion::First => self.review_scroll = 0, + // Render clamps to the content height. + Motion::Last => self.review_scroll = usize::MAX, + _ => return false, + } + return true; + } + let len = self.step_len(); + if len == 0 { + return false; + } + let current = match self.step { + Step::Role => self.role_idx, + Step::Model => self.model_idx, + Step::Destination => self.destination_idx, + _ => return false, + }; + let Some(next) = crate::tui::list_nav::apply(current, len, SETUP_PAGE, motion) + else { + return false; + }; + match self.step { + Step::Role => { + self.role_idx = next; + self.discard_model_draft(); + self.composition_decision = CompositionDecision::Pending; + } + Step::Model => { + self.model_idx = next; + self.discard_model_draft(); + if self.composition_decision != CompositionDecision::Pending { + self.composition_decision = CompositionDecision::Edited; + } + } + Step::Destination => { + self.destination_idx = next; + } + _ => {} + } + true + } + } + } + /// Re-stat the profile directory. Called on the two transitions that can /// change the answer — entering Review, and toggling project/user scope — /// so the Review step never touches the filesystem while painting. @@ -1612,6 +1691,13 @@ impl ModalView for FleetSetupView { fn handle_key(&mut self, key: KeyEvent) -> ViewAction { // Model-step filter input captures keystrokes while active (#4639). if self.step == Step::Model && self.model_filter_active { + // Typing-safe movement set: pages and edges work while filtering + // without letter aliases eating the query (#6290). + if let Some(motion) = crate::tui::list_nav::motion_while_typing(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Enter => { self.model_filter_active = false; @@ -1628,12 +1714,6 @@ impl ModalView for FleetSetupView { self.composition_decision = CompositionDecision::Edited; } } - KeyCode::Up => { - self.move_up(); - } - KeyCode::Down => { - self.move_down(); - } KeyCode::Char(ch) if !key.modifiers.intersects( KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, @@ -1654,6 +1734,14 @@ impl ModalView for FleetSetupView { if !matches!(key.code, KeyCode::Null) { self.notice = None; } + // Movement keys come from the shared vocabulary (#6290), j/k aliases + // included; the region axis is declined so Tab/Left/Right keep their + // explicit wizard arms, and letter verbs below are unaffected. + if let Some(motion) = crate::tui::list_nav::motion(&key) + && self.apply_motion(motion) + { + return ViewAction::None; + } match key.code { KeyCode::Esc if self.step != Step::Role => self.back(), KeyCode::Esc => ViewAction::Close, @@ -1691,14 +1779,6 @@ impl ModalView for FleetSetupView { self.model_filter_active = true; ViewAction::None } - KeyCode::Up | KeyCode::Char('k') => { - self.move_up(); - ViewAction::None - } - KeyCode::Down | KeyCode::Char('j') => { - self.move_down(); - ViewAction::None - } // Secondary accelerator: jump to the Destination step. The primary // way to change the destination is the focused Review control. KeyCode::Char('s') if self.step == Step::Review => { @@ -1735,18 +1815,6 @@ impl ModalView for FleetSetupView { } KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => self.advance(), KeyCode::Left | KeyCode::Char('h') => self.back(), - KeyCode::Home => { - self.review_scroll = 0; - ViewAction::None - } - KeyCode::PageUp => { - self.review_scroll = self.review_scroll.saturating_sub(8); - ViewAction::None - } - KeyCode::PageDown => { - self.review_scroll = self.review_scroll.saturating_add(8); - ViewAction::None - } _ => ViewAction::None, } } From d1175fd9f7ad6ddc122fd79f903d9ef09c5d73fc Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 18:36:29 -0700 Subject: [PATCH 16/24] perf(fleet): wake SSE streams on ledger append instead of 250ms polling (#6211 R7b) Each fleet SSE viewer reopened the manager and replayed from disk every 250ms. Ledger managers open per operation, so a process-wide registry joins them by canonicalized ledger path with one Notify each; the append funnel notifies after fsync, and streams wait on wake-or-5s- fallback instead of sleeping. The cursor stays the source of truth, so a missed/spurious wake costs one extra poll, never lost events. All appends are in-process (the executor maps worker stdout to ledger records), so wakes cover every writer; the fallback heals missed wakes, future out-of-process writers, and compaction (which replaces history rather than appending). Tests: append-wakes-subscriber + per-file scoping. Note: fleet suite has one pre-existing red (resolved_config_mints_secret_free_fleet_route_ snapshot expects chat_completions, gets responses) failing identically on clean HEAD, unrelated to this slice. --- crates/tui/src/fleet/ledger.rs | 95 ++++++++++++++++++++++++++++++++++ crates/tui/src/runtime_api.rs | 25 +++++++-- 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/fleet/ledger.rs b/crates/tui/src/fleet/ledger.rs index fab51cba59..b832857570 100644 --- a/crates/tui/src/fleet/ledger.rs +++ b/crates/tui/src/fleet/ledger.rs @@ -8,8 +8,10 @@ #![allow(dead_code)] use std::collections::BTreeMap; +use std::collections::HashMap; #[cfg(test)] use std::fs::OpenOptions; +use std::sync::{Mutex, OnceLock}; use super::files::{WorkspaceFile, same_file}; use std::io::{BufRead, Read, Seek, SeekFrom, Write}; @@ -19,11 +21,69 @@ use anyhow::{Context, Result, bail}; use codewhale_protocol::fleet::*; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use tokio::sync::Notify; const FLEET_DIR: &str = ".codewhale"; const FLEET_LEDGER_FILE: &str = "fleet.jsonl"; const FLEET_LEDGER_LOCK_FILE: &str = "fleet.lock"; +/// Path of the ledger file under `workspace`, mirroring [`FleetLedger::open`]. +pub(crate) fn fleet_ledger_path(workspace: &Path) -> PathBuf { + workspace.join(FLEET_DIR).join(FLEET_LEDGER_FILE) +} + +/// Process-wide append wakes, one [`Notify`] per ledger file (#6211 R7b). +/// Ledger managers open per operation, so instances cannot share a channel +/// handle; the registry joins differently-spelled opens of one file by +/// canonicalized path. A wake means "re-poll with your cursor" — the cursor +/// stays the source of truth, so a missed or spurious wake only costs one +/// extra poll, never lost or duplicated events. +static FLEET_LEDGER_WAKES: OnceLock>>> = + OnceLock::new(); + +fn fleet_ledger_wake_key(ledger_path: &Path) -> PathBuf { + if let Ok(canonical) = ledger_path.canonicalize() { + return canonical; + } + // The ledger file may not exist yet at subscribe time; canonicalize the + // parent so both spellings still meet. A fully missing tree falls back + // to the raw path on both sides. + match ledger_path + .parent() + .and_then(|parent| parent.canonicalize().ok()) + .zip(ledger_path.file_name()) + { + Some((parent, name)) => parent.join(name), + None => ledger_path.to_path_buf(), + } +} + +/// Subscribe to append wakes for the ledger at `ledger_path`. Callers must +/// still poll on a fallback interval: the registry only sees in-process +/// appends, and a wake can race the subscriber's last read. +pub(crate) fn subscribe_fleet_ledger_appends(ledger_path: &Path) -> std::sync::Arc { + let key = fleet_ledger_wake_key(ledger_path); + let mut wakes = FLEET_LEDGER_WAKES + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .expect("fleet ledger wake registry poisoned"); + wakes + .entry(key) + .or_insert_with(|| std::sync::Arc::new(Notify::new())) + .clone() +} + +fn notify_fleet_ledger_append(ledger_path: &Path) { + let key = fleet_ledger_wake_key(ledger_path); + let notify = FLEET_LEDGER_WAKES + .get() + .and_then(|wakes| wakes.lock().ok()) + .and_then(|wakes| wakes.get(&key).cloned()); + if let Some(notify) = notify { + notify.notify_waiters(); + } +} + fn inline_secret_assignment_pattern() -> &'static regex::Regex { static PATTERN: std::sync::OnceLock = std::sync::OnceLock::new(); PATTERN.get_or_init(|| { @@ -420,6 +480,10 @@ impl FleetLedger { .with_context(|| format!("flushing fleet ledger {}", self.ledger_path.display()))?; file.sync_data() .with_context(|| format!("syncing fleet ledger {}", self.ledger_path.display()))?; + // Wake SSE subscribers after the bytes are durable (#6211 R7b). Every + // record kind funnels through here, so a wake only promises "something + // changed" — subscribers re-poll with their cursor. + notify_fleet_ledger_append(&self.ledger_path); Ok(()) } @@ -2533,6 +2597,37 @@ mod tests { use std::time::Duration; use tempfile::TempDir; + #[tokio::test] + async fn ledger_append_wakes_subscribers() { + let workspace = TempDir::new().unwrap(); + let ledger = FleetLedger::open(workspace.path()).unwrap(); + let appends = subscribe_fleet_ledger_appends(&fleet_ledger_path(workspace.path())); + let notified = appends.notified(); + tokio::pin!(notified); + let appended = tokio::task::spawn_blocking(move || { + ledger.create_run(&sample_run("run-1")).unwrap(); + }); + tokio::time::timeout(Duration::from_secs(5), &mut notified) + .await + .expect("append should wake subscribers"); + appended.await.unwrap(); + } + + #[tokio::test] + async fn ledger_wake_is_scoped_to_its_file() { + let first = TempDir::new().unwrap(); + let second = TempDir::new().unwrap(); + let ledger = FleetLedger::open(first.path()).unwrap(); + let appends = subscribe_fleet_ledger_appends(&fleet_ledger_path(second.path())); + ledger.create_run(&sample_run("run-1")).unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(100), appends.notified()) + .await + .is_err(), + "an append must not wake another ledger's subscribers" + ); + } + #[test] fn ledger_rejects_replaced_lock_identity() { let workspace = TempDir::new().unwrap(); diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index e6d8a13996..717cd95f6f 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -56,7 +56,10 @@ use crate::automation_manager::{ use crate::config::DEFAULT_TEXT_MODEL; use crate::config::{ApiProvider, Config, normalize_model_name_for_provider, validate_route}; use crate::fleet::executor::{FleetExecutor, configured_codewhale_binary}; -use crate::fleet::ledger::{FleetEventReplayError, FleetLedgerState, FleetTaskLedgerStatus}; +use crate::fleet::ledger::{ + FleetEventReplayError, FleetLedgerState, FleetTaskLedgerStatus, fleet_ledger_path, + subscribe_fleet_ledger_appends, +}; use crate::fleet::manager::{ FleetManager, FleetStatusSnapshot, FleetWorkerInspection, FleetWorkerRuntimeProjection, ManagedFleetRunDescriptor, @@ -2225,10 +2228,13 @@ async fn stream_fleet_events( ) -> Result>>, ApiError> { let (after, limit) = validate_fleet_events_query(query)?; let run_id = FleetRunId::from(run_id); + // Subscribe before the initial load so no append between the load and the + // first wait is missed for longer than the fallback poll (#6211 R7b). + let appends = subscribe_fleet_ledger_appends(&fleet_ledger_path(&state.workspace)); let initial = load_fleet_event_replay(state.clone(), run_id.clone(), after.clone(), limit) .await .map_err(map_fleet_replay_error)?; - let event_stream = replay_live_fleet_events(state, run_id, after, limit, initial); + let event_stream = replay_live_fleet_events(state, run_id, after, limit, initial, appends); Ok(Sse::new(event_stream).keep_alive( KeepAlive::new() .interval(Duration::from_secs(15)) @@ -2236,12 +2242,18 @@ async fn stream_fleet_events( )) } +/// Fallback re-poll when no ledger-append wake arrives. Wakes cover every +/// in-process append; the fallback heals missed wakes, out-of-process +/// writers, and ledger compaction, which replaces rather than appends. +const FLEET_SSE_FALLBACK_POLL: Duration = Duration::from_secs(5); + fn replay_live_fleet_events( state: RuntimeApiState, run_id: FleetRunId, mut after: Option, limit: usize, initial: FleetEventReplay, + appends: std::sync::Arc, ) -> impl futures_util::Stream> { stream! { let mut page = initial; @@ -2260,7 +2272,14 @@ fn replay_live_fleet_events( yield Ok(fleet_sse_event(&event)); } if !page.has_more { - tokio::time::sleep(Duration::from_millis(250)).await; + // Register interest before yielding to the runtime so an + // append racing this wait still wakes us (#6211 R7b). + let notified = appends.notified(); + tokio::pin!(notified); + tokio::select! { + _ = &mut notified => {} + _ = tokio::time::sleep(FLEET_SSE_FALLBACK_POLL) => {} + } } match load_fleet_event_replay( state.clone(), From bb1c3a58c9dbc90010652791e14bbc9d9d4f7263 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 19:00:06 -0700 Subject: [PATCH 17/24] fix(review): too-large diffs degrade to a named partial review instead of failing closed (#6285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC3: plan_pr_review no longer errors when a file fits no pass or passes exceed max_passes. Oversized files are skipped whole (never truncated) and passes beyond the budget are cut in diff order; the plan reviews what fits. Only a plan covering nothing still errors, and it names every skipped file. AC4: the manifest carries skipped_files (file, reason, budgeted chars) into prompts, receipts, and tool metadata. finish() says 'Partial review coverage' with the skip list instead of claiming completeness, pass prompts tell the model the review is partial, the budget-stop note names plan-time skips, and --check-receipt fails on partial coverage with the skip list. Gate decision: the partial review is printed and posted, but the CLI still exits non-zero naming the skips and the remedy — the workflow gates on the exit code, so exit 0 on unread files would silently turn the red gate green. The failure reads as limits, not as a verdict. --- crates/tui/src/lib.rs | 130 ++++++++--- crates/tui/src/tools/review.rs | 395 ++++++++++++++++++++++++++++----- 2 files changed, 443 insertions(+), 82 deletions(-) diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 85c8f90631..ffe695aef2 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -8700,30 +8700,6 @@ Provide findings ordered by severity with file references, then open questions, &request_route.model, review_allowance, ); - // Criterion 4 (no silent caps): whatever a budget stop leaves unread is - // named by file, never silently dropped. - let unreviewed_note = |completed_passes: usize| -> String { - let Some(plan) = pr_plan.as_ref() else { - return "the entire diff".to_string(); - }; - let mut unread: Vec = Vec::new(); - for pass in plan.manifest.passes.iter().skip(completed_passes) { - for file in &pass.files { - if !unread.iter().any(|seen| seen == file) { - unread.push(file.clone()); - } - } - } - if unread.is_empty() { - "no planned file was left unread".to_string() - } else { - format!( - "{} file(s) were never read: {}", - unread.len(), - unread.join(", ") - ) - } - }; let mut output = String::new(); let mut review_stop_reason = None; for (index, user_prompt) in prompts.into_iter().enumerate() { @@ -8796,7 +8772,7 @@ Provide findings ordered by severity with file references, then open questions, reasoning_tokens, review_allowance, review_reserve_tokens, - unreviewed_note(index), + pr_review_unreviewed_note(pr_plan.as_ref(), index), index, planned_passes, ), @@ -8867,7 +8843,7 @@ Provide findings ordered by severity with file references, then open questions, ); if let Some(coverage) = coverage { crate::tools::review::attach_pr_review_coverage(&mut receipt, coverage) - .context("Failed to attach complete PR coverage to review receipt")?; + .context("Failed to attach PR coverage to review receipt")?; } let path = crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref()) @@ -8902,6 +8878,10 @@ Provide findings ordered by severity with file references, then open questions, "stop_reason": review_stop_reason, "usage": usage, "review_passes": pr_plan.as_ref().map(|plan| plan.passes.len()), + "complete": pr_plan + .as_ref() + .is_none_or(|plan| plan.manifest.skipped_files.is_empty()), + "skipped_files": pr_plan.as_ref().map(|plan| &plan.manifest.skipped_files), "receipt_path": receipt .as_ref() .map(|(path, _)| path.display().to_string()), @@ -8936,9 +8916,64 @@ Provide findings ordered by severity with file references, then open questions, eprintln!("Review receipt written: {}", path.display()); } } + if let Some(plan) = &pr_plan + && !plan.manifest.skipped_files.is_empty() + { + // #6285 AC3: the partial review above is real findings, already + // printed and posted — but the gate must not pass on unread + // files. The exit still fails, naming what the gate did not read + // and the remedy, so it reads as limits rather than as "this PR + // failed review". + bail!( + "Partial PR review: {} pass(es) completed, but the gate did not read: {}. Raise --max-chars/--max-passes or shrink the PR, then re-run; publication: {}", + plan.passes.len(), + crate::tools::review::format_skipped_files(&plan.manifest.skipped_files), + publication.as_str() + ); + } Ok(()) } +/// Criterion 4 (no silent caps): whatever a budget stop leaves unread is +/// named by file, never silently dropped — including files the plan itself +/// skipped before the first pass ran (#6285 AC4). A free function so tests +/// can pin the note without running a review. +fn pr_review_unreviewed_note( + plan: Option<&crate::tools::review::PrReviewPlan>, + completed_passes: usize, +) -> String { + let Some(plan) = plan else { + return "the entire diff".to_string(); + }; + let mut unread: Vec = Vec::new(); + for pass in plan.manifest.passes.iter().skip(completed_passes) { + for file in &pass.files { + if !unread.iter().any(|seen| seen == file) { + unread.push(file.clone()); + } + } + } + let mut clauses = Vec::new(); + if !unread.is_empty() { + clauses.push(format!( + "{} file(s) were never read: {}", + unread.len(), + unread.join(", ") + )); + } + if !plan.manifest.skipped_files.is_empty() { + clauses.push(format!( + "the plan never scheduled: {}", + crate::tools::review::format_skipped_files(&plan.manifest.skipped_files) + )); + } + if clauses.is_empty() { + "no planned file was left unread".to_string() + } else { + clauses.join("; ") + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ReviewPublication { NotRequested, @@ -17064,6 +17099,49 @@ api_key = "test-only-key" assert!(!prompt.contains("diff truncated")); } + #[test] + fn pr_review_unreviewed_note_names_budget_stops_and_plan_skips() { + assert_eq!(pr_review_unreviewed_note(None, 0), "the entire diff"); + fn patch(name: &str, content: &str) -> String { + format!( + "diff --git a/{name} b/{name}\nnew file mode 100644\n--- /dev/null\n+++ b/{name}\n@@ -0,0 +1 @@\n+{content}\n" + ) + } + let patches = [ + patch("a.txt", "alpha"), + patch("b.txt", "bravo"), + patch("c.txt", "charlie"), + ]; + let diff = patches.concat(); + let max_chars = patches + .iter() + .map(|patch| patch.chars().count()) + .max() + .unwrap(); + let view = GhPullRequest { + changed_files: 3, + ..Default::default() + }; + // Two passes of budget: a and b are planned, c is skipped by the plan. + let plan = crate::tools::review::plan_pr_review(&diff, &view, max_chars, 2).unwrap(); + let note = pr_review_unreviewed_note(Some(&plan), 0); + assert!(note.contains("were never read"), "{note}"); + assert!(note.contains("a/b.txt b/b.txt"), "{note}"); + assert!(note.contains("the plan never scheduled"), "{note}"); + assert!(note.contains("a/c.txt b/c.txt"), "{note}"); + // One pass done: only b is left unread, but the plan skip still stands. + let note = pr_review_unreviewed_note(Some(&plan), 1); + assert!(!note.contains("a/a.txt b/a.txt"), "{note}"); + assert!(note.contains("a/b.txt b/b.txt"), "{note}"); + assert!(note.contains("a/c.txt b/c.txt"), "{note}"); + // A complete plan with every pass done names nothing. + let complete = crate::tools::review::plan_pr_review(&diff, &view, max_chars, 3).unwrap(); + assert_eq!( + pr_review_unreviewed_note(Some(&complete), 3), + "no planned file was left unread" + ); + } + #[test] fn pr_review_markdown_shows_the_computed_replacement() { // A plain `codewhale review --pr` (no --post) computes and validates diff --git a/crates/tui/src/tools/review.rs b/crates/tui/src/tools/review.rs index dc289cef40..a8c85a6fcf 100644 --- a/crates/tui/src/tools/review.rs +++ b/crates/tui/src/tools/review.rs @@ -287,6 +287,34 @@ pub struct PrReviewPassManifest { pub files: Vec, } +/// One file patch the plan never scheduled (#6285 AC3/AC4). `file` is the +/// patch label exactly as it would have appeared in a pass manifest +/// (`a/old b/new`, or `… (part k/n)` for a pass-budget cut); `chars` is the +/// budgeted `model_diff` size. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PrReviewSkippedFile { + pub file: String, + pub reason: String, + pub chars: usize, +} + +/// Skip reasons are stable sentence fragments rendered into review +/// summaries, receipts, and failure notes; keep them greppable. +const SKIP_REASON_HUNK_EXCEEDS_PASS: &str = "a single hunk exceeds the per-pass limit"; +const SKIP_REASON_NO_HUNK_BOUNDARIES: &str = + "exceeds the per-pass limit with no hunk boundaries to split at"; +const SKIP_REASON_BEYOND_MAX_PASSES: &str = "beyond the max_passes budget"; + +/// Render a skip list the way every consumer shows it: the entries are +/// self-describing, so no caller needs its own format. +pub(crate) fn format_skipped_files(skipped: &[PrReviewSkippedFile]) -> String { + skipped + .iter() + .map(|skip| format!("{} ({} chars; {})", skip.file, skip.chars, skip.reason)) + .collect::>() + .join(", ") +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PrReviewManifest { pub base_sha: String, @@ -298,6 +326,12 @@ pub struct PrReviewManifest { pub binary_contents_semantically_inspected: bool, pub max_chars_per_pass: usize, pub passes: Vec, + /// Files the plan never scheduled, in diff order. Empty means complete + /// coverage; every entry names a file the gate did not read and why. + /// `#[serde(default)]` keeps pre-skip-list receipts readable — those + /// plans were complete by construction. + #[serde(default)] + pub skipped_files: Vec, } #[derive(Debug, Clone)] @@ -367,6 +401,27 @@ struct PrReviewPiece<'a> { header_bytes: usize, } +/// One diff-ordered unit of a (possibly degraded) plan: a reviewable piece +/// or a skipped original patch. The partition guard rebuilds the diff from +/// both, so every byte is either reviewed or named as skipped. +enum PrReviewAtom<'a> { + Piece(PrReviewPiece<'a>), + Skipped { + patch: &'a str, + label: String, + chars: usize, + reason: &'static str, + }, +} + +/// Plan PR review passes over `diff`, degrading instead of failing closed +/// (#6285 AC3): files that fit no pass and passes beyond `max_passes` are +/// skipped in diff order and named in `manifest.skipped_files` (AC4). Only a +/// plan that covers nothing still errors. +/// +/// Known limitations, beside the behaviour: skips are whole files — a file +/// with one oversized hunk is skipped entirely, never truncated — and files +/// stay in diff order rather than re-sorted by estimated risk. pub(crate) fn plan_pr_review( diff: &str, view: &super::review_pr::GhPullRequest, @@ -392,25 +447,36 @@ pub(crate) fn plan_pr_review( // no line is elided, shortened or reordered. Sizes use the model // representation, so a binary payload already omitted there can never // drive a split. - let mut pieces: Vec> = Vec::new(); + let mut atoms: Vec> = Vec::new(); for patch in patches { let patch_chars = super::review_pr::model_diff(patch).chars().count(); if patch_chars <= max_chars { - pieces.push(PrReviewPiece { + atoms.push(PrReviewAtom::Piece(PrReviewPiece { diff: Cow::Borrowed(patch), label: patch_label(patch), header_bytes: 0, - }); + })); continue; } let (header, hunks) = pr_file_hunks(patch); let header_chars = header.chars().count(); let largest_hunk_chars = hunks.iter().map(|hunk| hunk.chars().count()).max(); - anyhow::ensure!( - largest_hunk_chars.is_some_and(|hunk_chars| header_chars + hunk_chars <= max_chars), - "Complete PR file patch {} requires {patch_chars} characters, exceeding the per-pass review limit of {max_chars}. No review was run or posted.", - patch_label(patch) - ); + // A file whose largest hunk cannot share a pass with its own header + // can never be scheduled; it is skipped whole, never truncated, so a + // finding can never rest on half a change. + if !largest_hunk_chars.is_some_and(|hunk_chars| header_chars + hunk_chars <= max_chars) { + atoms.push(PrReviewAtom::Skipped { + patch, + label: patch_label(patch), + chars: patch_chars, + reason: if hunks.is_empty() { + SKIP_REASON_NO_HUNK_BOUNDARIES + } else { + SKIP_REASON_HUNK_EXCEEDS_PASS + }, + }); + continue; + } let label = patch_label(patch); let mut parts: Vec = Vec::new(); let mut part = String::from(header); @@ -426,18 +492,48 @@ pub(crate) fn plan_pr_review( } parts.push(part); let total = parts.len(); - pieces.extend( - parts - .into_iter() - .enumerate() - .map(|(index, part)| PrReviewPiece { - label: format!("{label} (part {}/{total})", index + 1), - header_bytes: if index == 0 { 0 } else { header.len() }, - diff: Cow::Owned(part), - }), - ); + atoms.extend(parts.into_iter().enumerate().map(|(index, part)| { + PrReviewAtom::Piece(PrReviewPiece { + label: format!("{label} (part {}/{total})", index + 1), + header_bytes: if index == 0 { 0 } else { header.len() }, + diff: Cow::Owned(part), + }) + })); } + // The partition guard, byte-for-byte: continuation parts replay the file + // header, so exactly those repeated headers are stripped, skipped + // originals are replayed whole, and the rebuilt plan must equal the + // original diff — every byte is either reviewed or named as skipped. + let mut pieces: Vec> = Vec::new(); + let mut skipped: Vec = Vec::new(); + let mut rebuilt = String::with_capacity(diff.len()); + for atom in atoms { + match atom { + PrReviewAtom::Piece(piece) => { + rebuilt.push_str(&piece.diff[piece.header_bytes..]); + pieces.push(piece); + } + PrReviewAtom::Skipped { + patch, + label, + chars, + reason, + } => { + rebuilt.push_str(patch); + skipped.push(PrReviewSkippedFile { + file: label, + reason: reason.to_string(), + chars, + }); + } + } + } + anyhow::ensure!( + rebuilt == diff, + "PR review plan did not partition the complete diff byte-for-byte" + ); + let mut grouped: Vec>> = Vec::new(); let mut current: Vec> = Vec::new(); let mut current_chars = 0; @@ -453,24 +549,28 @@ pub(crate) fn plan_pr_review( if !current.is_empty() { grouped.push(current); } - anyhow::ensure!( - grouped.len() <= max_passes, - "Complete PR review requires {} passes at {max_chars} characters per pass, but max_passes is {max_passes}. No review was run or posted. Opt in with max_passes/--max-passes of at least {} only after approving the provider spend and run duration.", - grouped.len(), - grouped.len() - ); + // Passes beyond the budget are skipped in diff order, never fatal. The + // plan reviews what fits and names the rest. + for group in grouped.split_off(max_passes.min(grouped.len())) { + for piece in group { + let label = piece.label; + let chars = super::review_pr::model_diff(&piece.diff).chars().count(); + skipped.push(PrReviewSkippedFile { + file: label, + reason: SKIP_REASON_BEYOND_MAX_PASSES.to_string(), + chars, + }); + } + } - // The completeness guard, byte-for-byte as before: continuation parts - // replay the file header, so exactly those repeated headers are stripped - // and the rebuilt plan must equal the original diff. + // Only a plan that covers nothing still errors — and even then it + // names every skipped file, so the failure reads as limits, not as a + // verdict on the code. anyhow::ensure!( - grouped - .iter() - .flatten() - .map(|piece| &piece.diff[piece.header_bytes..]) - .collect::() - == diff, - "PR review plan did not preserve the complete diff byte-for-byte" + !grouped.is_empty(), + "PR review plan covers 0 of {} file patches within {max_chars} characters per pass and {max_passes} pass(es); skipped: {}. No review was run or posted.", + view.changed_files, + format_skipped_files(&skipped) ); let passes = grouped @@ -508,6 +608,7 @@ pub(crate) fn plan_pr_review( binary_contents_semantically_inspected: false, max_chars_per_pass: max_chars, passes: passes.iter().map(|pass| pass.manifest.clone()).collect(), + skipped_files: skipped, }; Ok(PrReviewPlan { manifest, passes }) } @@ -546,8 +647,21 @@ pub(crate) fn build_pr_pass_prompt( .max_chars_per_pass .saturating_sub(pass.manifest.diff_chars), ); + // A degraded plan tells the model it is partial, so a pass summary can + // never honestly claim full coverage; the manifest below carries the + // same skip list for the record. + let task = if plan.manifest.skipped_files.is_empty() { + "Review only defects introduced in this pass. Use supplementary source to check surrounding guards and declarations; it does not expand the commentable diff. Binary contents and omitted callers are not inspected. No build or tests have been run.".to_string() + } else { + format!( + "Review only defects introduced in this pass. This is a partial review (pass {} of {}): the gate did not read {}. Do not claim full coverage. Use supplementary source to check surrounding guards and declarations; it does not expand the commentable diff. Binary contents and omitted callers are not inspected. No build or tests have been run.", + pass.manifest.number, + plan.manifest.passes.len(), + format_skipped_files(&plan.manifest.skipped_files) + ) + }; json!({ - "task": "Review only defects introduced in this pass. Use supplementary source to check surrounding guards and declarations; it does not expand the commentable diff. Binary contents and omitted callers are not inspected. No build or tests have been run.", + "task": task, "untrusted_repository_data": true, "pull_request": { "number": number, "title": view.title, "description": view.body }, "manifest": plan.manifest, @@ -632,21 +746,38 @@ impl PrReviewAccumulator { suggestions.extend(output.suggestions); } let total = self.manifest.passes.len(); - let mut output = ReviewOutput { - summary: format!( - "Complete review coverage: {total}/{total} passes, {} file patches, {}.{}", + let per_pass = if summaries.is_empty() { + String::new() + } else { + format!("\n\n{}", summaries.join("\n\n")) + }; + // A degraded plan must never claim complete coverage: the summary + // names every file the gate did not read. + let summary = if self.manifest.skipped_files.is_empty() { + format!( + "Complete review coverage: {total}/{total} passes, {} file patches, {}.{per_pass}", + self.manifest.file_count, self.manifest.diff_fingerprint, + ) + } else { + format!( + "Partial review coverage: {total} pass(es) completed; the gate did not read: {}. Diff: {} file patches, {}.{per_pass}", + format_skipped_files(&self.manifest.skipped_files), self.manifest.file_count, self.manifest.diff_fingerprint, - if summaries.is_empty() { - String::new() - } else { - format!("\n\n{}", summaries.join("\n\n")) - } - ), + ) + }; + let mut output = ReviewOutput { + summary, issues, suggestions, overall_assessment: if assessments.is_empty() { - format!("All {total} review passes completed with structured output.") + if self.manifest.skipped_files.is_empty() { + format!("All {total} review passes completed with structured output.") + } else { + format!( + "Partial review: {total} pass(es) completed with structured output; see the summary for files never read." + ) + } } else { assessments.join("\n") }, @@ -1089,6 +1220,15 @@ pub fn validate_review_receipt_for_diff( validation.reason = "current diff pass manifest does not match receipt".into(); return validation; } + // A partial review is real findings, but it must never read as a + // gate pass: the check fails, naming what the gate did not read. + if !coverage.manifest.skipped_files.is_empty() { + validation.reason = format!( + "review receipt covers a partial review; the gate did not read: {}", + format_skipped_files(&coverage.manifest.skipped_files) + ); + return validation; + } } if receipt.unresolved_risk.unresolved { validation.reason = receipt.unresolved_risk.summary.clone(); @@ -1862,7 +2002,7 @@ mod tests { } #[test] - fn pr_batch_plan_preserves_utf8_order_and_requires_explicit_pass_budget() { + fn pr_batch_plan_degrades_to_first_pass_and_names_skipped_files() { let patches = [ pr_patch("a.txt", "alpha"), pr_patch("b.txt", "🐋"), @@ -1874,12 +2014,26 @@ mod tests { .map(|patch| patch.chars().count()) .max() .unwrap(); - let error = plan_pr_review(&diff, &pr_view(3), max_chars, 1).unwrap_err(); - assert!(error.to_string().contains("requires 3 passes")); - assert!(error.to_string().contains("No review was run or posted")); + // One pass of budget: the first file is reviewed, the rest are + // skipped in diff order and named — never silently dropped. + let degraded = plan_pr_review(&diff, &pr_view(3), max_chars, 1).unwrap(); + assert_eq!(degraded.passes.len(), 1); + assert_eq!(degraded.passes[0].diff, patches[0]); + assert_eq!(degraded.manifest.passes[0].files, ["a/a.txt b/a.txt"]); + assert_eq!(degraded.manifest.skipped_files.len(), 2); + assert_eq!(degraded.manifest.skipped_files[0].file, "a/b.txt b/b.txt"); + assert_eq!(degraded.manifest.skipped_files[1].file, "a/c.txt b/c.txt"); + assert!( + degraded + .manifest + .skipped_files + .iter() + .all(|skip| skip.reason == SKIP_REASON_BEYOND_MAX_PASSES) + ); let plan = plan_pr_review(&diff, &pr_view(3), max_chars, 3).unwrap(); assert_eq!(plan.passes.len(), 3); + assert!(plan.manifest.skipped_files.is_empty()); assert_eq!( plan.passes .iter() @@ -1896,8 +2050,29 @@ mod tests { fn pr_batch_plan_rejects_one_file_overflow_before_any_pass() { let diff = pr_patch("large.txt", &"x".repeat(200)); let error = plan_pr_review(&diff, &pr_view(1), 100, MAX_REVIEW_PASSES).unwrap_err(); - assert!(error.to_string().contains("large.txt")); - assert!(error.to_string().contains("No review was run or posted")); + let message = error.to_string(); + assert!(message.contains("covers 0 of 1 file patches"), "{message}"); + assert!(message.contains("large.txt"), "{message}"); + assert!(message.contains(SKIP_REASON_HUNK_EXCEEDS_PASS), "{message}"); + assert!(message.contains("No review was run or posted"), "{message}"); + } + + #[test] + fn pr_batch_plan_skips_oversized_file_and_reviews_the_rest() { + let ok = pr_patch("ok.txt", "fine"); + let big = pr_multi_hunk_patch("big.txt", &["fine", &"x".repeat(500)]); + let diff = format!("{ok}{big}"); + let max_chars = ok.chars().count(); + let plan = plan_pr_review(&diff, &pr_view(2), max_chars, MAX_REVIEW_PASSES).unwrap(); + assert_eq!(plan.passes.len(), 1); + assert_eq!(plan.passes[0].diff, ok); + assert_eq!(plan.manifest.skipped_files.len(), 1); + assert_eq!(plan.manifest.skipped_files[0].file, "a/big.txt b/big.txt"); + assert_eq!( + plan.manifest.skipped_files[0].reason, + SKIP_REASON_HUNK_EXCEEDS_PASS + ); + assert!(plan.manifest.skipped_files[0].chars > max_chars); } #[test] @@ -1919,9 +2094,19 @@ mod tests { // not: exactly one hunk per part, four parts, four passes. let max_chars = header.chars().count() + hunks.iter().map(|hunk| hunk.chars().count()).max().unwrap(); - let error = plan_pr_review(&patch, &pr_view(1), max_chars, 3).unwrap_err(); - assert!(error.to_string().contains("requires 4 passes")); - assert!(error.to_string().contains("No review was run or posted")); + // Three passes of budget for four parts: the first three parts are + // reviewed and the last part is skipped by name. + let degraded = plan_pr_review(&patch, &pr_view(1), max_chars, 3).unwrap(); + assert_eq!(degraded.passes.len(), 3); + assert_eq!(degraded.manifest.skipped_files.len(), 1); + assert_eq!( + degraded.manifest.skipped_files[0].file, + "a/big.txt b/big.txt (part 4/4)" + ); + assert_eq!( + degraded.manifest.skipped_files[0].reason, + SKIP_REASON_BEYOND_MAX_PASSES + ); let plan = plan_pr_review(&patch, &pr_view(1), max_chars, 4).unwrap(); assert_eq!(plan.passes.len(), 4); @@ -1958,8 +2143,11 @@ mod tests { // file cannot be split and the plan must fail before any pass. let max_chars = header.chars().count() + hunks[0].chars().count(); let error = plan_pr_review(&patch, &pr_view(1), max_chars, MAX_REVIEW_PASSES).unwrap_err(); - assert!(error.to_string().contains("mixed.txt")); - assert!(error.to_string().contains("No review was run or posted")); + let message = error.to_string(); + assert!(message.contains("mixed.txt"), "{message}"); + assert!(message.contains("covers 0 of 1 file patches"), "{message}"); + assert!(message.contains(SKIP_REASON_HUNK_EXCEEDS_PASS), "{message}"); + assert!(message.contains("No review was run or posted"), "{message}"); } #[test] @@ -2183,6 +2371,101 @@ mod tests { ); } + #[test] + fn pr_batch_aggregate_reports_partial_coverage_and_receipt_check_names_skips() { + let first = pr_patch("a.txt", "alpha"); + let second = pr_patch("b.txt", "bravo"); + let diff = format!("{first}{second}"); + let max_chars = first.chars().count().max(second.chars().count()); + let plan = plan_pr_review(&diff, &pr_view(2), max_chars, 1).unwrap(); + assert_eq!(plan.passes.len(), 1); + assert_eq!(plan.manifest.skipped_files.len(), 1); + let mut accumulator = PrReviewAccumulator::new(&plan); + accumulator + .accept( + &plan.passes[0], + json!({ + "summary": "first", + "issues": [], + "suggestions": [], + "overall_assessment": "" + }) + .to_string(), + ) + .unwrap(); + let (output, content, coverage) = accumulator.finish(&diff).unwrap(); + assert!( + output.summary.contains("Partial review coverage"), + "{}", + output.summary + ); + assert!( + output.summary.contains("a/b.txt b/b.txt"), + "{}", + output.summary + ); + assert!( + !output.summary.contains("Complete review coverage"), + "{}", + output.summary + ); + assert!( + output.overall_assessment.contains("Partial review"), + "{}", + output.overall_assessment + ); + let mut receipt = build_review_receipt( + "pr:1", + &diff, + "fixture", + "fixture-model", + &output, + &content, + Vec::new(), + ); + attach_pr_review_coverage(&mut receipt, coverage).unwrap(); + let validation = validate_review_receipt_for_diff(&diff, &receipt, None); + assert!(!validation.passed); + assert!( + validation.reason.contains("partial review"), + "{}", + validation.reason + ); + assert!( + validation.reason.contains("a/b.txt b/b.txt"), + "{}", + validation.reason + ); + } + + #[test] + fn pr_pass_prompt_marks_degraded_plans_partial_for_the_model() { + let first = pr_patch("a.txt", "alpha"); + let second = pr_patch("b.txt", "bravo"); + let diff = format!("{first}{second}"); + let max_chars = first.chars().count().max(second.chars().count()); + let view = pr_view(2); + let workspace = tempfile::tempdir().unwrap(); + let degraded = plan_pr_review(&diff, &view, max_chars, 1).unwrap(); + let prompt = + build_pr_pass_prompt(1, &view, °raded, °raded.passes[0], workspace.path()); + let task = serde_json::from_str::(&prompt).unwrap()["task"] + .as_str() + .unwrap() + .to_string(); + assert!(task.contains("partial review"), "{task}"); + assert!(task.contains("pass 1 of 1"), "{task}"); + assert!(task.contains("a/b.txt b/b.txt"), "{task}"); + let complete = plan_pr_review(&diff, &view, max_chars, 2).unwrap(); + let prompt = + build_pr_pass_prompt(1, &view, &complete, &complete.passes[0], workspace.path()); + let task = serde_json::from_str::(&prompt).unwrap()["task"] + .as_str() + .unwrap() + .to_string(); + assert!(!task.contains("partial review"), "{task}"); + } + #[test] fn review_usage_aggregates_every_billable_counter() { let mut total = Usage::default(); From 24c97b91576d40f3f1e1f35dc60468f7d60f37e3 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 19:10:25 -0700 Subject: [PATCH 18/24] perf(client): decode Anthropic SSE straight into the tagged event enum (#6213 T7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit convert_anthropic_sse_data built a Value DOM per token and converted it with from_value. The per-token path now deserializes directly into the tagged StreamEvent; a borrow-only tag peek routes the two usage-bearing events (message_start/message_delta) to the exact legacy path, since their wire-to-normalized usage mapping reads fields the normalized Usage cannot represent. Decode failures keep their legacy outcomes (invalid SSE JSON vs unrecognized event vs tolerated-unknown), and the local-only tool_projection_warning still never decodes from SSE. This closes the last hot T7 item. The T7 chat.rs sub-item is declined: inspect_prompt_for_request runs only on explicit cache-warmup calls and the /debug cache command — not a hot path — and the catalog string is load-bearing layer content, not just hash input. --- crates/tui/src/client/anthropic.rs | 102 +++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 13 deletions(-) diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 08be1aed55..80a977f66b 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -25,6 +25,7 @@ //! hacks in the shared paths). use anyhow::{Context, Result}; +use serde::Deserialize; use serde_json::{Value, json}; use crate::config::{ApiProvider, wire_model_for_provider_route}; @@ -790,13 +791,76 @@ fn apply_anthropic_cache_breakpoints(body: &mut Value) { } } +/// Provider event types [`convert_anthropic_sse_data`] accepts. Anything else +/// with a string `type` is tolerated as `None` (future additions); note +/// `tool_projection_warning` is deliberately absent — it is local-only and +/// must never decode from provider SSE. +fn is_known_sse_type(event_type: &str) -> bool { + matches!( + event_type, + "message_start" + | "content_block_start" + | "content_block_delta" + | "content_block_stop" + | "message_delta" + | "message_stop" + | "ping" + | "error" + ) +} + +/// Peek at an SSE payload's `type` without building a DOM. +#[derive(Deserialize)] +struct SseTagPeek<'a> { + #[serde(borrow)] + r#type: Option<&'a str>, +} + /// Convert one SSE `data:` payload into a [`StreamEvent`], normalizing usage /// objects to the #2961 convention. Returns `None` for ignorable payloads. +/// +/// #6213 T7: the per-token path deserializes directly into the tagged +/// [`StreamEvent`] instead of building a `Value` DOM and converting it. +/// Usage-bearing events (two per stream) keep the exact legacy path — the +/// usage rewrite reads wire fields the normalized [`Usage`] cannot +/// represent — and decode failures keep their exact legacy outcomes. fn convert_anthropic_sse_data(data: &str) -> Option> { let trimmed = data.trim(); if trimmed.is_empty() { return None; } + let usage_event = matches!( + serde_json::from_str::(trimmed).map(|peek| peek.r#type), + Ok(Some("message_start" | "message_delta")) + ); + if usage_event { + return convert_anthropic_sse_usage_event(trimmed); + } + match serde_json::from_str::(trimmed) { + // Local-only receipt: the legacy path ignored it (not a provider + // type), so it stays ignored rather than decoding. + Ok(StreamEvent::ToolProjectionWarning { .. }) => None, + Ok(event) => Some(Ok(event)), + Err(error) => { + // Cold path, reached only when direct decode fails: invalid JSON + // and unknown types keep their exact legacy outcomes. + let value: Value = match serde_json::from_str(trimmed) { + Ok(value) => value, + Err(e) => return Some(Err(anyhow::anyhow!("invalid SSE JSON: {e}"))), + }; + match value.get("type").and_then(Value::as_str) { + // Tolerate unknown event types (e.g. future additions) silently. + Some(known) if !is_known_sse_type(known) => None, + _ => Some(Err(anyhow::anyhow!("unrecognized SSE event: {error}"))), + } + } + } +} + +/// Legacy `Value` path for `message_start`/`message_delta`: the usage +/// rewrite reads wire fields the normalized [`Usage`] cannot represent, so +/// these two events normalize before decoding, exactly as before. +fn convert_anthropic_sse_usage_event(trimmed: &str) -> Option> { let mut value: Value = match serde_json::from_str(trimmed) { Ok(value) => value, Err(e) => return Some(Err(anyhow::anyhow!("invalid SSE JSON: {e}"))), @@ -817,19 +881,7 @@ fn convert_anthropic_sse_data(data: &str) -> Option> { } } // Tolerate unknown event types (e.g. future additions) silently. - Some(known) - if !matches!( - known, - "message_start" - | "content_block_start" - | "content_block_delta" - | "content_block_stop" - | "message_delta" - | "message_stop" - | "ping" - | "error" - ) => - { + Some(known) if !is_known_sse_type(known) => { return None; } _ => {} @@ -1711,6 +1763,30 @@ mod tests { assert!(convert_anthropic_sse_data(" ").is_none()); } + #[test] + fn sse_decode_failures_keep_legacy_outcomes_on_the_direct_path() { + // Malformed JSON: the invalid-input error, not the unrecognized one. + let error = convert_anthropic_sse_data("{oops") + .expect("malformed is Some") + .expect_err("malformed is Err"); + assert!(error.to_string().contains("invalid SSE JSON"), "{error:?}"); + // Structurally invalid known event: unrecognized, not tolerated. + let error = convert_anthropic_sse_data(r#"{"type":"content_block_stop"}"#) + .expect("known type is Some") + .expect_err("missing index is Err"); + assert!( + error.to_string().contains("unrecognized SSE event"), + "{error:?}" + ); + // Local-only receipt: never provider SSE, stays ignored. + assert!( + convert_anthropic_sse_data( + r#"{"type":"tool_projection_warning","provider":"x","omitted_tool_names":[],"omitted_tool_count":0}"# + ) + .is_none() + ); + } + #[test] fn usage_mapping_handles_missing_cache_fields() { let usage = parse_anthropic_usage(&json!({"input_tokens": 10, "output_tokens": 5})); From 531cddb56fe97cb7c7062781eb9c2b7c272f2f94 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 17 Sep 2026 19:20:55 -0700 Subject: [PATCH 19/24] perf(mcp): reap stdio children off-thread; authority checks leave the executor (#6211 R4/R7a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4: Drop for Connection grace-waited up to 500ms on the dropping thread (manager reload, pool rebuild). Drops now hand the child to one shared reaper thread; the grace/kill/wait sequence is unchanged, only relocated, with inline reaping as the degenerate fallback if the reaper is gone. The Mutex half stays: request() rendezvouses on a shared responses channel and drops non-matching ids, so serialization is load-bearing for correctness until an in-flight dispatch map exists (the issue's own longer-term item; sequenced with the #6142 stack reconciliation). R7a: the per-connection 50ms authority watch ran synchronous state fs on the executor. The check now runs under spawn_blocking with the 50ms revocation cadence unchanged. The watch stays per-connection by design: it covers the pre-insertion connect window, and a pool-level task would need the pool lock — held across in-flight calls — regressing the mid-call trip this exists for. --- crates/mcp/src/stdio_client.rs | 173 ++++++++++++++++++++++++++------- crates/tui/src/mcp.rs | 23 ++++- 2 files changed, 161 insertions(+), 35 deletions(-) diff --git a/crates/mcp/src/stdio_client.rs b/crates/mcp/src/stdio_client.rs index fdef4d2a4d..87ec6f8730 100644 --- a/crates/mcp/src/stdio_client.rs +++ b/crates/mcp/src/stdio_client.rs @@ -542,8 +542,17 @@ impl ChildProcessMcpClient { } }; let (sender, responses) = sync_channel(MAX_PENDING_CHILD_MESSAGES); + let mut child = child; + let stdin = child + .stdin + .take() + .with_context(|| format!("MCP server '{server_name}': child stdin unavailable"))?; + let stdout = child + .stdout + .take() + .with_context(|| format!("MCP server '{server_name}': child stdout unavailable"))?; let mut connection = Connection { - child, + child: Some(child), stdin: None, responses, next_id: 1, @@ -551,17 +560,6 @@ impl ChildProcessMcpClient { _job: job, }; - let stdin = connection - .child - .stdin - .take() - .with_context(|| format!("MCP server '{server_name}': child stdin unavailable"))?; - let stdout = connection - .child - .stdout - .take() - .with_context(|| format!("MCP server '{server_name}': child stdout unavailable"))?; - // A dedicated reader thread keeps `recv_timeout` able to bound a wait // that a blocking read on the child would not. The custom line reader // also caps memory before a hostile child can complete an oversized @@ -775,7 +773,8 @@ impl McpManagedClient for ChildProcessMcpClient { } struct Connection { - child: Child, + /// `None` once `Drop` hands the child to the reaper thread. + child: Option, stdin: Option>>, responses: Receiver, next_id: u64, @@ -939,9 +938,12 @@ impl Connection { /// so the cost buys a real diagnostic ("exited with status 127" is the /// difference between a crashed server and a missing one). fn exit_note(&mut self) -> String { + let Some(child) = self.child.as_mut() else { + return String::new(); + }; let deadline = Instant::now() + EXIT_STATUS_GRACE; loop { - match self.child.try_wait() { + match child.try_wait() { Ok(Some(status)) => return format!(" (process exited with {status})"), Ok(None) if Instant::now() < deadline => { thread::sleep(Duration::from_millis(5)); @@ -962,30 +964,66 @@ fn is_broken_pipe(err: &anyhow::Error) -> bool { }) } +/// Reap stdio children off the dropping thread (#6211 R4). `Drop` runs on +/// whichever thread drops the client — manager reload, pool rebuild — and +/// the half-second grace wait must not stall it, least of all an executor +/// thread. One shared thread reaps every child; drops only send. What this +/// does not do: join the reaper at process exit, so a child that ignores +/// EOF can outlive a racing shutdown where the old synchronous `Drop` +/// would have killed it first. +fn child_reaper() -> &'static std::sync::mpsc::Sender { + static REAPER: std::sync::OnceLock> = std::sync::OnceLock::new(); + REAPER.get_or_init(|| { + let (tx, rx) = std::sync::mpsc::channel::(); + thread::Builder::new() + .name("mcp-stdio-reaper".to_string()) + .spawn(move || { + for mut child in rx { + reap_child(&mut child); + } + }) + .expect("MCP stdio reaper thread spawns"); + tx + }) +} + +/// Grace-wait a child, then kill what ignores stdin-close. Runs on the +/// reaper thread, or inline in `Drop` if the reaper is gone. +fn reap_child(child: &mut Child) { + let deadline = Instant::now() + SHUTDOWN_GRACE; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(10)); + } + _ => break, + } + } + #[cfg(unix)] + unsafe { + // Also run after the immediate launcher exited. Descendants can + // still own the group and the stdout/stderr pipe descriptors. + let _ = libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL); + } + let _ = child.kill(); + let _ = child.wait(); +} + impl Drop for Connection { fn drop(&mut self) { // Closing stdin is the protocol-level shutdown signal for a stdio MCP // server; kill only the ones that ignore it, so servers get a chance // to flush state. self.stdin.take(); - let deadline = Instant::now() + SHUTDOWN_GRACE; - loop { - match self.child.try_wait() { - Ok(Some(_)) => break, - Ok(None) if Instant::now() < deadline => { - thread::sleep(Duration::from_millis(10)); - } - _ => break, - } - } - #[cfg(unix)] - unsafe { - // Also run after the immediate launcher exited. Descendants can - // still own the group and the stdout/stderr pipe descriptors. - let _ = libc::kill(-(self.child.id() as libc::pid_t), libc::SIGKILL); + // The grace wait leaves the dropping thread: the child is reaped on + // the shared reaper thread. If the reaper itself is gone, reap + // inline — today's behavior — rather than leak the child. + if let Some(child) = self.child.take() + && let Err(mut failed) = child_reaper().send(child) + { + reap_child(&mut failed.0); } - let _ = self.child.kill(); - let _ = self.child.wait(); } } @@ -1346,6 +1384,60 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn reaper_kills_a_child_that_ignores_stdin_close() { + let mut child = Command::new("/bin/sh") + .args(["-c", "sleep 30"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + // Close stdin like `Drop` does, then reap: the sleeper ignores it. + drop(child.stdin.take()); + reap_child(&mut child); + assert!( + child.try_wait().unwrap().is_some(), + "reaper must have killed the child" + ); + } + + #[cfg(unix)] + #[test] + fn dropping_a_connection_with_a_live_child_returns_before_the_grace() { + let child = Command::new("/bin/sh") + .args(["-c", "sleep 30"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let pid = child.id(); + let (_tx, responses) = sync_channel(1); + let connection = Connection { + child: Some(child), + stdin: None, + responses, + next_id: 1, + }; + let started = Instant::now(); + drop(connection); + assert!( + started.elapsed() < SHUTDOWN_GRACE, + "drop must hand off instead of grace-waiting" + ); + // The reaper owns the child now: it must die without anyone waiting + // inline, so the handoff leaks nothing. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0; + if !alive { + break; + } + assert!(Instant::now() < deadline, "reaper never reaped pid {pid}"); + thread::sleep(Duration::from_millis(50)); + } + } + #[cfg(unix)] fn assert_descendant_cleanup(handshake: bool, launcher_exits: bool) { use std::io::Read; @@ -1406,6 +1498,8 @@ IFS= read -r initialized .lock() .unwrap() .child + .as_mut() + .expect("child present before drop") .try_wait() .unwrap() .is_some() @@ -1589,9 +1683,22 @@ printf 'stdin closed\n' > "$CODEWHALE_MCP_TEST_MARKER" // The reader owns only a weak stdin handle. Dropping the client must // therefore still deliver EOF to the child and let it exit cleanly; - // a strong reader-thread handle would force Drop's kill fallback. + // a strong reader-thread handle would force the kill fallback. Drop + // hands the child to the reaper thread instead of waiting, so poll + // for the marker the clean exit writes. drop(client); - assert_eq!(std::fs::read_to_string(&marker).unwrap(), "stdin closed\n"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(body) = std::fs::read_to_string(&marker) { + assert_eq!(body, "stdin closed\n"); + break; + } + assert!( + Instant::now() < deadline, + "dropped client never delivered stdin EOF" + ); + thread::sleep(Duration::from_millis(20)); + } std::fs::remove_file(marker).unwrap(); } diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index 08fe5a4f9f..e752196cea 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -1588,11 +1588,30 @@ impl PendingAuthorityWatch { reason_slot: Arc>>, ) -> Self { let task_cancel = cancel.clone(); + // The watch stays per-connection by design (#6211 R7a): it is born + // with the connect attempt (covering the pre-insertion window) and + // dies with the connection, so a watched server can neither be + // missed nor leak. A pool-level task would need the pool lock — + // held across in-flight calls — and regress the mid-call trip this + // exists for. What moves is the check itself: synchronous + // state fs has no place on the executor at 20Hz, so it runs on the + // blocking pool while the 50ms revocation cadence is unchanged. + let source = Arc::new(source); let handle = tokio::spawn(async move { loop { - if let Err(reason) = + let source = Arc::clone(&source); + let check = tokio::task::spawn_blocking(move || { crate::plugins::registry::verify_plugin_state_authority(&source.authority) - { + }) + .await; + let reason = match check { + Ok(Err(reason)) => Some(reason), + Ok(Ok(())) => None, + Err(_) => { + Some("plugin authority check failed to run; failing closed".to_string()) + } + }; + if let Some(reason) = reason { if let Ok(mut slot) = reason_slot.lock() { *slot = Some(reason); } From da17a5e64515a3cabf8fe78fb37bab9ea3bda3f5 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Thu, 17 Sep 2026 19:14:30 -0400 Subject: [PATCH 20/24] docs(unsafe): add SAFETY contracts for undocumented unsafe blocks Atlas UNSAFE-001: every production unsafe block now carries a terse justification at the site. The 16 bare set_var blocks in apply_tui_env are centralized into one documented set_tui_env helper; all other changes are comment-only. --- crates/cli/src/lib.rs | 116 +++++++++----------- crates/cli/src/main.rs | 1 + crates/config/src/xai_credentials.rs | 17 +++ crates/tui/src/child_env.rs | 5 + crates/tui/src/external_credentials.rs | 8 ++ crates/tui/src/fleet/host.rs | 11 ++ crates/tui/src/hooks/executor.rs | 13 +++ crates/tui/src/lib.rs | 2 + crates/tui/src/plugins/registry.rs | 5 + crates/tui/src/remote_control.rs | 2 + crates/tui/src/runtime_log.rs | 4 + crates/tui/src/settings.rs | 1 + crates/tui/src/tools/image_ocr.rs | 17 +++ crates/tui/src/tools/shell.rs | 5 + crates/tui/src/tui/display_refresh.rs | 1 + crates/tui/src/tui/ui/fatal_signal_guard.rs | 4 + crates/tui/src/tui/window_control.rs | 12 ++ 17 files changed, 157 insertions(+), 67 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index fd5d709180..53deed4467 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -5371,6 +5371,20 @@ fn tui_argv(cli: &Cli, passthrough: Vec) -> Vec { args } +/// Set one process environment variable for the CLI-to-TUI bridge. +/// +/// The dispatcher must call this only on the main thread before the TUI +/// runtime starts; all current callers are inside [`apply_tui_env`]. +fn set_tui_env(key: impl AsRef, value: impl AsRef) { + // SAFETY: the dispatcher runs on the main thread and these setters execute + // before the TUI runtime starts. The only other thread that may be alive + // is the detached telemetry writer, which never reads or writes the + // process environment, so no concurrent environment access can occur. + unsafe { + std::env::set_var(key, value); + } +} + fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrough: &[String]) { let mut verbosity = if cli.profile.is_some() { cli.verbosity.clone() @@ -5396,10 +5410,8 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou || provider.to_string(), |provider| provider.as_str().to_string(), ); - unsafe { - std::env::set_var("CODEWHALE_PROVIDER", &provider); - std::env::set_var("DEEPSEEK_PROVIDER", provider); - } + set_tui_env("CODEWHALE_PROVIDER", &provider); + set_tui_env("DEEPSEEK_PROVIDER", provider); } if !(uses_raw_tui_provider || (cli.profile.is_some() @@ -5407,95 +5419,65 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou && matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring)) && let Some(api_key) = keyring_bridge_api_key { - unsafe { - for var in provider_env_vars(keyring_bridge_provider) { - std::env::set_var(var, api_key); - } - std::env::set_var( - codewhale_config::CLI_API_KEY_SOURCE_ENV, - RuntimeApiKeySource::Keyring.as_env_value(), - ); + for var in provider_env_vars(keyring_bridge_provider) { + set_tui_env(var, api_key); } + set_tui_env( + codewhale_config::CLI_API_KEY_SOURCE_ENV, + RuntimeApiKeySource::Keyring.as_env_value(), + ); } if let Some(model) = cli.model.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_MODEL", model); - std::env::set_var("DEEPSEEK_MODEL", model); - } + set_tui_env("CODEWHALE_MODEL", model); + set_tui_env("DEEPSEEK_MODEL", model); } if let Some(output_mode) = cli.output_mode.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_OUTPUT_MODE", output_mode); - std::env::set_var("DEEPSEEK_OUTPUT_MODE", output_mode); - } + set_tui_env("CODEWHALE_OUTPUT_MODE", output_mode); + set_tui_env("DEEPSEEK_OUTPUT_MODE", output_mode); } if let Some(v) = verbosity.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_VERBOSITY", v); - std::env::set_var("DEEPSEEK_VERBOSITY", v); - } + set_tui_env("CODEWHALE_VERBOSITY", v); + set_tui_env("DEEPSEEK_VERBOSITY", v); } if let Some(log_level) = cli.log_level.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_LOG_LEVEL", log_level); - std::env::set_var("DEEPSEEK_LOG_LEVEL", log_level); - } + set_tui_env("CODEWHALE_LOG_LEVEL", log_level); + set_tui_env("DEEPSEEK_LOG_LEVEL", log_level); } let telemetry = resolved_runtime.telemetry.to_string(); - unsafe { - std::env::set_var("CODEWHALE_TELEMETRY", &telemetry); - std::env::set_var("DEEPSEEK_TELEMETRY", &telemetry); - } + set_tui_env("CODEWHALE_TELEMETRY", &telemetry); + set_tui_env("DEEPSEEK_TELEMETRY", &telemetry); let floor = cli.telemetry == Some(false) || codewhale_config::telemetry_floor_in_force(); - unsafe { - std::env::set_var( - codewhale_config::TELEMETRY_FLOOR_ENV, - if floor { "1" } else { "0" }, - ); - } + set_tui_env( + codewhale_config::TELEMETRY_FLOOR_ENV, + if floor { "1" } else { "0" }, + ); if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", endpoint); - std::env::set_var("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint); - } + set_tui_env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint); + set_tui_env("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint); } if let Some(policy) = cli.approval_policy.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_APPROVAL_POLICY", policy); - std::env::set_var("DEEPSEEK_APPROVAL_POLICY", policy); - } + set_tui_env("CODEWHALE_APPROVAL_POLICY", policy); + set_tui_env("DEEPSEEK_APPROVAL_POLICY", policy); } if let Some(mode) = cli.sandbox_mode.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_SANDBOX_MODE", mode); - std::env::set_var("DEEPSEEK_SANDBOX_MODE", mode); - } + set_tui_env("CODEWHALE_SANDBOX_MODE", mode); + set_tui_env("DEEPSEEK_SANDBOX_MODE", mode); } if cli.yolo { - unsafe { - std::env::set_var("CODEWHALE_YOLO", "true"); - } + set_tui_env("CODEWHALE_YOLO", "true"); } if let Some(api_key) = cli.api_key.as_ref() { - unsafe { - std::env::set_var(codewhale_config::CLI_API_KEY_ENV, api_key); - } + set_tui_env(codewhale_config::CLI_API_KEY_ENV, api_key); if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) { - unsafe { - for var in provider_env_vars(resolved_runtime.provider) { - std::env::set_var(var, api_key); - } + for var in provider_env_vars(resolved_runtime.provider) { + set_tui_env(var, api_key); } } - unsafe { - std::env::set_var(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli"); - } + set_tui_env(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli"); } if let Some(base_url) = cli.base_url.as_ref() { - unsafe { - std::env::set_var("CODEWHALE_BASE_URL", base_url); - std::env::set_var("DEEPSEEK_BASE_URL", base_url); - } + set_tui_env("CODEWHALE_BASE_URL", base_url); + set_tui_env("DEEPSEEK_BASE_URL", base_url); } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index eb7386f361..c1577cb3b0 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -17,6 +17,7 @@ fn main() -> std::process::ExitCode { // inherit SIGPIPE set to SIG_IGN, which makes write(2) return EPIPE; // Rust's `println!` then treats that io::Error as fatal and panics. // See issue #4030. + // SAFETY: process entry; no threads or handlers yet. #[cfg(unix)] unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); diff --git a/crates/config/src/xai_credentials.rs b/crates/config/src/xai_credentials.rs index e19e77e380..ee9dc25935 100644 --- a/crates/config/src/xai_credentials.rs +++ b/crates/config/src/xai_credentials.rs @@ -358,6 +358,7 @@ fn owned_auth_names_in_store(store: &XaiOAuthCredentialStore) -> Result Result Result Result Result Result ) })?; anyhow::ensure!(metadata.is_file(), "xAI OAuth path must be a regular file"); + // SAFETY: geteuid(2) dereferences no pointers. anyhow::ensure!( metadata.uid() == unsafe { libc::geteuid() }, "xAI OAuth file must be owned by the current user" @@ -1194,6 +1208,7 @@ fn reopen_windows_file_for_owner_security(file: &File, path: &Path) -> Result Result<()> { .context("reading Codewhale-owned xAI OAuth security descriptor"); } let _descriptor = WindowsLocalAllocation(descriptor.cast()); + // SAFETY: `owner` is non-null; `user.sid()` is owned by `user`. anyhow::ensure!( !owner.is_null() && unsafe { EqualSid(owner, user.sid()) } != 0, "Codewhale-owned xAI OAuth storage owner is not the current user" @@ -1508,6 +1524,7 @@ fn verify_windows_owner_only_handle(file: &File) -> Result<()> { // SAFETY: `count == 1` proves the first returned entry is initialized. let entry = unsafe { &*entries }; let trustee_sid: PSID = entry.Trustee.ptstrName.cast(); + // SAFETY: form and null checked in this expression; sid owned by `user`. anyhow::ensure!( entry.Trustee.TrusteeForm == TRUSTEE_IS_SID && !trustee_sid.is_null() diff --git a/crates/tui/src/child_env.rs b/crates/tui/src/child_env.rs index 794c0ca450..9b1d8e2741 100644 --- a/crates/tui/src/child_env.rs +++ b/crates/tui/src/child_env.rs @@ -442,6 +442,7 @@ fn windows_registry_env_vars() -> Vec<(OsString, OsString)> { fn append_windows_registry_env_key(env: &mut Vec<(OsString, OsString)>, root: HKEY, subkey: &str) { let mut key = HKEY::default(); let subkey_wide = windows_wide_null(OsStr::new(subkey)); + // SAFETY: `subkey_wide` is NUL-terminated and live; `key` is live. let open = unsafe { RegOpenKeyExW(root, PCWSTR(subkey_wide.as_ptr()), None, KEY_READ, &mut key) }; if open != ERROR_SUCCESS { @@ -462,6 +463,7 @@ fn append_windows_registry_env_key(env: &mut Vec<(OsString, OsString)>, root: HK } } + // SAFETY: `key` was opened above and is not used after. let _ = unsafe { RegCloseKey(key) }; } @@ -481,6 +483,7 @@ fn read_windows_registry_env_value(key: HKEY, index: u32) -> RegistryEnvValue { let mut name_len = name.len() as u32; let mut data_len = data.len() as u32; let mut value_type = 0u32; + // SAFETY: buffers are live with matching lengths passed. let status = unsafe { RegEnumValueW( key, @@ -545,12 +548,14 @@ fn registry_utf16_value_from_bytes(data: &[u8]) -> OsString { #[cfg(windows)] fn expand_windows_env_string(value: &OsStr) -> Option { let src = windows_wide_null(value); + // SAFETY: `src` is NUL-terminated and live. let required_len = unsafe { ExpandEnvironmentStringsW(PCWSTR(src.as_ptr()), None) }; if required_len == 0 { return None; } let mut expanded = vec![0u16; required_len as usize]; + // SAFETY: `src` is NUL-terminated; `expanded` has the queried length. let written = unsafe { ExpandEnvironmentStringsW(PCWSTR(src.as_ptr()), Some(&mut expanded)) }; if written == 0 || written > required_len { return None; diff --git a/crates/tui/src/external_credentials.rs b/crates/tui/src/external_credentials.rs index 07e6be4c12..78256d581d 100644 --- a/crates/tui/src/external_credentials.rs +++ b/crates/tui/src/external_credentials.rs @@ -228,6 +228,7 @@ fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result } if require_owner_only { use std::os::unix::fs::MetadataExt as _; + // SAFETY: geteuid(2) dereferences no pointers. if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0 || metadata.nlink() != 1 @@ -463,6 +464,7 @@ fn verify_windows_owner_only_handle( return Err(io::Error::from_raw_os_error(result as i32)); } let _descriptor = WindowsLocalAllocation(descriptor.cast()); + // SAFETY: `owner` is non-null; `user.sid()` is owned by `user`. if owner.is_null() || unsafe { EqualSid(owner, user.sid()) } == 0 { return Err(io::Error::new( io::ErrorKind::PermissionDenied, @@ -493,6 +495,7 @@ fn verify_windows_owner_only_handle( // SAFETY: `count == 1` proves the first returned entry is initialized. let entry = unsafe { &*entries }; let trustee_sid: PSID = entry.Trustee.ptstrName.cast(); + // SAFETY: form and null checked in this expression; sid owned by `user`. let current_user_only = entry.Trustee.TrusteeForm == TRUSTEE_IS_SID && !trustee_sid.is_null() && unsafe { EqualSid(trustee_sid, user.sid()) } != 0 @@ -532,7 +535,9 @@ impl CurrentWindowsUser { let _ = unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed) }; if needed == 0 { + // SAFETY: reads thread-local error state only. let error = io::Error::from_raw_os_error(unsafe { GetLastError() } as i32); + // SAFETY: `token` is owned here and not stored on this path. unsafe { windows_sys::Win32::Foundation::CloseHandle(token) }; return Err(error); } @@ -551,11 +556,14 @@ impl CurrentWindowsUser { } == 0 { let error = io::Error::last_os_error(); + // SAFETY: `token` is owned here and not stored on this path. unsafe { windows_sys::Win32::Foundation::CloseHandle(token) }; return Err(error); } + // SAFETY: initialized by GetTokenInformation; buffer outlives use. let user = unsafe { &*token_info.as_ptr().cast::() }; if user.User.Sid.is_null() { + // SAFETY: `token` is owned here and not stored on this path. unsafe { windows_sys::Win32::Foundation::CloseHandle(token) }; return Err(io::Error::new( io::ErrorKind::InvalidData, diff --git a/crates/tui/src/fleet/host.rs b/crates/tui/src/fleet/host.rs index 54a18aedd4..d332ff5aa3 100644 --- a/crates/tui/src/fleet/host.rs +++ b/crates/tui/src/fleet/host.rs @@ -993,6 +993,7 @@ fn unix_session_members( if pid > 0 { // Revalidate against the kernel after parsing the snapshot. A PID // reused by an unrelated process must never receive our signal. + // SAFETY: getsid(2) dereferences no pointers. if unsafe { libc::getsid(pid) } == session_id { members.push(pid); } @@ -1015,6 +1016,7 @@ fn unix_session_members( #[cfg(unix)] fn unix_pid_in_session(pid: libc::pid_t, session_id: libc::pid_t) -> bool { + // SAFETY: getsid(2) dereferences no pointers. unsafe { libc::getsid(pid) == session_id } } @@ -1023,6 +1025,7 @@ fn unix_pid_exists(pid: libc::pid_t) -> bool { if pid <= 0 { return false; } + // SAFETY: kill(2) dereferences no pointers; signal 0 sends nothing. if unsafe { libc::kill(pid, 0) } == 0 { return true; } @@ -1163,6 +1166,7 @@ fn signal_unix_session( signal: libc::c_int, known_leader: Option, ) -> FleetHostResult> { + // SAFETY: getsid(2) dereferences no pointers. let own_session = unsafe { libc::getsid(0) }; if session_id <= 0 || session_id == own_session { return Err(FleetHostError::terminal(format!( @@ -1182,6 +1186,7 @@ fn signal_unix_session( for pid in candidates { // Verify identity again immediately before signalling. Session IDs // remain stable across reparenting and separate process groups. + // SAFETY: getsid(2) dereferences no pointers. if unsafe { libc::getsid(pid) } != session_id { // Leader may already be gone; still try kill on known leader when // getsid fails only with ESRCH-equivalent absence. @@ -1189,6 +1194,7 @@ fn signal_unix_session( continue; } } + // SAFETY: kill(2) dereferences no pointers. if unsafe { libc::kill(pid, signal) } != 0 { let err = std::io::Error::last_os_error(); if err.raw_os_error() != Some(libc::ESRCH) { @@ -1266,10 +1272,12 @@ unsafe impl Sync for FleetWindowsJob {} #[cfg(windows)] impl FleetWindowsJob { fn attach_to_child(child: &Child) -> std::io::Result { + // SAFETY: returned handle is owned by the new wrapper. let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? }; let job = Self { handle }; let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: `limits` is live with matching size; both handles are live. unsafe { SetInformationJobObject( job.handle, @@ -1285,11 +1293,13 @@ impl FleetWindowsJob { } fn terminate(&self) -> std::io::Result<()> { + // SAFETY: `self.handle` is a live owned job handle. unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) } } fn has_active_processes(&self) -> std::io::Result { let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); + // SAFETY: `accounting` is live with matching size. unsafe { QueryInformationJobObject( Some(self.handle), @@ -1307,6 +1317,7 @@ impl FleetWindowsJob { #[cfg(windows)] impl Drop for FleetWindowsJob { fn drop(&mut self) { + // SAFETY: `self.handle` is owned here; Drop runs once. unsafe { let _ = CloseHandle(self.handle); } diff --git a/crates/tui/src/hooks/executor.rs b/crates/tui/src/hooks/executor.rs index ca4cb17af2..db06a0e26f 100644 --- a/crates/tui/src/hooks/executor.rs +++ b/crates/tui/src/hooks/executor.rs @@ -950,6 +950,7 @@ impl HookProcessTree { fn terminate(&self, child: &mut Child) { #[cfg(unix)] { + // SAFETY: kill(2) dereferences no pointers. let result = unsafe { libc::kill(-self.pgid, libc::SIGKILL) }; if result != 0 { let error = std::io::Error::last_os_error(); @@ -985,6 +986,7 @@ impl HookProcessTree { impl Drop for HookProcessTree { fn drop(&mut self) { #[cfg(unix)] + // SAFETY: kill(2) dereferences no pointers. unsafe { // The shell may have exited while one of its descendants still // holds a captured pipe. Reaping the process group keeps hook @@ -1004,11 +1006,13 @@ struct WindowsHookJob { #[cfg(windows)] impl WindowsHookJob { fn attach(child: &Child) -> std::io::Result { + // SAFETY: returned handle is owned by the new wrapper. let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? }; let job = Self { handle }; let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: `limits` is live with matching size; both handles are live. unsafe { SetInformationJobObject( job.handle, @@ -1024,6 +1028,7 @@ impl WindowsHookJob { } fn terminate(&self) -> std::io::Result<()> { + // SAFETY: `self.handle` is a live owned job handle. unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) } } } @@ -1031,6 +1036,7 @@ impl WindowsHookJob { #[cfg(windows)] impl Drop for WindowsHookJob { fn drop(&mut self) { + // SAFETY: `self.handle` is owned here; Drop runs once. unsafe { let _ = CloseHandle(self.handle); } @@ -1045,21 +1051,26 @@ fn windows_io_error(error: windows::core::Error) -> std::io::Error { #[cfg(windows)] fn resume_windows_process(child: &Child) -> std::io::Result<()> { let snapshot = + // SAFETY: returned handle is owned here; closed before return. unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0).map_err(windows_io_error)? }; let result = (|| { let mut entry = THREADENTRY32 { dwSize: std::mem::size_of::() as u32, ..Default::default() }; + // SAFETY: `entry` is live with dwSize initialized above. let mut next = unsafe { Thread32First(snapshot, &mut entry) }; let mut resumed = 0usize; while next.is_ok() { if entry.th32OwnerProcessID == child.id() { + // SAFETY: returned handle is owned here; closed below. let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID) .map_err(windows_io_error)? }; + // SAFETY: `thread` is a live owned handle. let resume_result = unsafe { ResumeThread(thread) }; + // SAFETY: `thread` is owned here and not used after. let close_result = unsafe { CloseHandle(thread).map_err(windows_io_error) }; if resume_result == u32::MAX { return Err(std::io::Error::last_os_error()); @@ -1067,6 +1078,7 @@ fn resume_windows_process(child: &Child) -> std::io::Result<()> { close_result?; resumed += 1; } + // SAFETY: `entry` is live with dwSize initialized above. next = unsafe { Thread32Next(snapshot, &mut entry) }; } if resumed == 0 { @@ -1076,6 +1088,7 @@ fn resume_windows_process(child: &Child) -> std::io::Result<()> { } Ok(()) })(); + // SAFETY: `snapshot` is owned here and not used after. let close_result = unsafe { CloseHandle(snapshot).map_err(windows_io_error) }; result?; close_result diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index ffe695aef2..0335404318 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -178,6 +178,7 @@ fn configure_windows_console_utf8() { use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP}; const CP_UTF8: u32 = 65001; + // SAFETY: integer argument only; failures discarded. unsafe { let _ = SetConsoleCP(CP_UTF8); let _ = SetConsoleOutputCP(CP_UTF8); @@ -1711,6 +1712,7 @@ fn run_with_args(args: Vec) -> Result<()> { // Match the dispatcher entrypoint: Unix shells and supervisors may inherit // SIGPIPE ignored, which turns short pipelines such as `codewhale doctor | // head` into BrokenPipe panics once this delegated TUI binary prints. + // SAFETY: first call at startup; no threads or handlers yet. #[cfg(unix)] unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); diff --git a/crates/tui/src/plugins/registry.rs b/crates/tui/src/plugins/registry.rs index 8ecfd1d8db..68bf02a4d3 100644 --- a/crates/tui/src/plugins/registry.rs +++ b/crates/tui/src/plugins/registry.rs @@ -648,6 +648,7 @@ fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Resul // NamedTempFile marks the source as temporary. Clear only that temporary // caching hint before publication, matching tempfile's own persistence // contract while retaining the owner-only DACL applied above. + // SAFETY: `temporary_wide` is NUL-terminated and live. unsafe { SetFileAttributesW( PCWSTR::from_raw(temporary_wide.as_ptr()), @@ -658,6 +659,7 @@ fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Resul format!("failed to prepare private plugin state temp file for publication: {error}") })?; + // SAFETY: both paths are NUL-terminated and live. if let Err(error) = unsafe { MoveFileExW( PCWSTR::from_raw(temporary_wide.as_ptr()), @@ -667,6 +669,7 @@ fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Resul } { // Restore tempfile's cleanup hint on the still-private source. The // stable state path remains untouched when MoveFileExW fails. + // SAFETY: `temporary_wide` is NUL-terminated and live. let _ = unsafe { SetFileAttributesW( PCWSTR::from_raw(temporary_wide.as_ptr()), @@ -1755,6 +1758,7 @@ fn apply_windows_owner_only_acl( let result = (|| { let mut required = 0_u32; // The first call intentionally obtains the required byte count. + // SAFETY: null buffer queries size; `required` is live. let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut required) }; if required < size_of::() as u32 { return Err("Windows token did not expose a current-user SID".to_string()); @@ -1897,6 +1901,7 @@ fn ensure_windows_plugin_target_owner( status.0 )); } + // SAFETY: `owner` is non-null from GetSecurityInfo; `expected_owner` is the caller's SID. let owner_matches = !owner.0.is_null() && unsafe { EqualSid(owner, expected_owner) }.is_ok(); if !descriptor.0.is_null() { // SAFETY: the successful GetSecurityInfo allocation is released only diff --git a/crates/tui/src/remote_control.rs b/crates/tui/src/remote_control.rs index 97feea38c8..0059b89257 100644 --- a/crates/tui/src/remote_control.rs +++ b/crates/tui/src/remote_control.rs @@ -335,6 +335,7 @@ impl ClassicSessionOwnerLock { #[cfg(unix)] { use std::os::fd::AsRawFd as _; + // SAFETY: `file` is open and live. if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { return Err(CLASSIC_LEASE_SCOPE_ERROR.to_string()); } @@ -343,6 +344,7 @@ impl ClassicSessionOwnerLock { { use std::os::windows::io::AsRawHandle as _; use windows_sys::Win32::Storage::FileSystem::LockFile; + // SAFETY: `file` is open and live. if unsafe { LockFile(file.as_raw_handle() as _, 0, 0, u32::MAX, u32::MAX) } == 0 { return Err(CLASSIC_LEASE_SCOPE_ERROR.to_string()); } diff --git a/crates/tui/src/runtime_log.rs b/crates/tui/src/runtime_log.rs index 5c8cdd4499..ca0540c140 100644 --- a/crates/tui/src/runtime_log.rs +++ b/crates/tui/src/runtime_log.rs @@ -98,6 +98,7 @@ impl Drop for TuiLogGuard { impl Drop for TuiLogGuard { fn drop(&mut self) { if let Some(handle) = self.saved_stderr_handle.take() { + // SAFETY: `handle` is owned here via take; Drop runs once. unsafe { let _ = windows::Win32::System::Console::SetStdHandle( windows::Win32::System::Console::STD_ERROR_HANDLE, @@ -109,6 +110,7 @@ impl Drop for TuiLogGuard { // stderr target. This is safe because `SetStdHandle` above already // restored the original handle, so nothing references this one. if let Some(dup) = self.redirected_stderr_handle.take() { + // SAFETY: `dup` is owned here via take; nothing references it. unsafe { let _ = windows::Win32::Foundation::CloseHandle(dup); } @@ -327,8 +329,10 @@ fn redirect_stderr_to( // Without this, `_file` and stderr would alias the same HANDLE; // a rogue `CloseHandle` on stderr would silently invalidate `_file`. let raw = HANDLE(file.as_raw_handle()); + // SAFETY: pseudo-handle; no preconditions. let process = unsafe { GetCurrentProcess() }; let mut dup = HANDLE::default(); + // SAFETY: `file` and `dup` are live; pseudo-handle needs no close. unsafe { DuplicateHandle( process, diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 19860e6e65..b936983930 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -2456,6 +2456,7 @@ fn replace_existing_settings_file(path: &Path, replacement: &Path) -> std::io::R let path_wide = wide_path(path); let replacement_wide = wide_path(replacement); + // SAFETY: both paths are NUL-terminated and live; reserved params are null. unsafe { // NamedTempFile marks its source with the temporary caching hint. // Clear it before publication, matching tempfile's persistence path. diff --git a/crates/tui/src/tools/image_ocr.rs b/crates/tui/src/tools/image_ocr.rs index 14e7f8fe7f..5de039c1f8 100644 --- a/crates/tui/src/tools/image_ocr.rs +++ b/crates/tui/src/tools/image_ocr.rs @@ -198,6 +198,7 @@ mod macos_vision { let request = new_object(request_class, "VNRecognizeTextRequest")?; // VNRequestTextRecognitionLevelAccurate is 0. Use accurate mode for // screenshots and receipts; the tool is user-facing, not latency-critical. + // SAFETY: selectors and signatures match VNRecognizeTextRequest. unsafe { let _: () = msg_send![&*request, setRecognitionLevel: 0usize]; let _: () = msg_send![&*request, setUsesLanguageCorrection: true]; @@ -207,13 +208,16 @@ mod macos_vision { let options: Retained> = NSDictionary::new(); let handler_alloc = alloc_object(handler_class, "VNImageRequestHandler")?; + // SAFETY: selector and signature match VNImageRequestHandler; consumes the alloc. let handler_raw: *mut AnyObject = unsafe { msg_send![handler_alloc, initWithURL: &*url, options: &*options] }; + // SAFETY: init returns +1; from_raw is null-checked. let handler = unsafe { Retained::from_raw(handler_raw) }.ok_or_else(|| { ToolError::execution_failed("image_ocr: failed to initialize Vision image handler") })?; let mut error: *mut NSError = ptr::null_mut(); + // SAFETY: selector and signature match VNImageRequestHandler. let ok: bool = unsafe { msg_send![&*handler, performRequests: &*requests, error: &mut error] }; if !ok { @@ -227,13 +231,16 @@ mod macos_vision { } fn new_object(class: &AnyClass, label: &str) -> Result, ToolError> { + // SAFETY: +1 or null; null handled by from_raw below. let raw: *mut AnyObject = unsafe { msg_send![class, new] }; + // SAFETY: takes the +1 from `new`; null maps to Err. unsafe { Retained::from_raw(raw) }.ok_or_else(|| { ToolError::execution_failed(format!("image_ocr: failed to create {label}")) }) } fn alloc_object(class: &AnyClass, label: &str) -> Result<*mut AnyObject, ToolError> { + // SAFETY: +1 or null; null checked below. let raw: *mut AnyObject = unsafe { msg_send![class, alloc] }; if raw.is_null() { Err(ToolError::execution_failed(format!( @@ -245,35 +252,43 @@ mod macos_vision { } fn collect_recognized_text(request: &AnyObject) -> Result { + // SAFETY: autoreleased return; used synchronously, never stored. let results: *mut AnyObject = unsafe { msg_send![request, results] }; if results.is_null() { return Ok(String::new()); } + // SAFETY: selector and signature match NSArray. let count: usize = unsafe { msg_send![results, count] }; let mut lines = Vec::new(); for idx in 0..count { + // SAFETY: idx < count. let observation: *mut AnyObject = unsafe { msg_send![results, objectAtIndex: idx] }; if observation.is_null() { continue; } + // SAFETY: selector and signature match VNRecognizedTextObservation. let candidates: *mut AnyObject = unsafe { msg_send![observation, topCandidates: 1usize] }; if candidates.is_null() { continue; } + // SAFETY: selector and signature match NSArray. let candidate_count: usize = unsafe { msg_send![candidates, count] }; if candidate_count == 0 { continue; } + // SAFETY: count > 0 checked above. let candidate: *mut AnyObject = unsafe { msg_send![candidates, objectAtIndex: 0usize] }; if candidate.is_null() { continue; } + // SAFETY: selector and signature match VNRecognizedText. let text: *mut NSString = unsafe { msg_send![candidate, string] }; if text.is_null() { continue; } + // SAFETY: `text` is non-null; used synchronously. let line = unsafe { &*text }.to_string(); let trimmed = line.trim(); if !trimmed.is_empty() { @@ -288,10 +303,12 @@ mod macos_vision { if error.is_null() { return String::new(); } + // SAFETY: selector and signature match NSError. let description: *mut NSString = unsafe { msg_send![error, localizedDescription] }; if description.is_null() { String::new() } else { + // SAFETY: `description` is non-null; used synchronously. format!(": {}", unsafe { &*description }) } } diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index bb704082ff..cdc53a4e8d 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -438,6 +438,7 @@ fn signal_child_process_group(child: &Child, signal: libc::c_int) -> std::io::Re return Ok(()); } + // SAFETY: kill(2) dereferences no pointers. let result = unsafe { libc::kill(-pgid, signal) }; if result == 0 { Ok(()) @@ -604,12 +605,14 @@ unsafe impl Sync for WindowsJob {} #[cfg(windows)] impl WindowsJob { fn attach_to_child(child: &Child) -> std::io::Result { + // SAFETY: returned handle is owned by the new wrapper. let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? }; let job = Self { handle }; let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: `limits` is live with matching size; both handles are live. unsafe { SetInformationJobObject( job.handle, @@ -627,6 +630,7 @@ impl WindowsJob { } fn terminate(&self) -> std::io::Result<()> { + // SAFETY: `self.handle` is a live owned job handle. unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) } } } @@ -634,6 +638,7 @@ impl WindowsJob { #[cfg(windows)] impl Drop for WindowsJob { fn drop(&mut self) { + // SAFETY: `self.handle` is owned here; Drop runs once. unsafe { let _ = CloseHandle(self.handle); } diff --git a/crates/tui/src/tui/display_refresh.rs b/crates/tui/src/tui/display_refresh.rs index 24463db794..7bb548ce1f 100644 --- a/crates/tui/src/tui/display_refresh.rs +++ b/crates/tui/src/tui/display_refresh.rs @@ -243,6 +243,7 @@ fn probe_macos() -> Result { fn CGDisplayModeGetRefreshRate(mode: *mut std::ffi::c_void) -> f64; fn CGDisplayModeRelease(mode: *mut std::ffi::c_void); } + // SAFETY: `mode` is null-checked and released after use. unsafe { let display = CGMainDisplayID(); let mode = CGDisplayCopyDisplayMode(display); diff --git a/crates/tui/src/tui/ui/fatal_signal_guard.rs b/crates/tui/src/tui/ui/fatal_signal_guard.rs index 5f68fca0ef..5586af892a 100644 --- a/crates/tui/src/tui/ui/fatal_signal_guard.rs +++ b/crates/tui/src/tui/ui/fatal_signal_guard.rs @@ -68,6 +68,7 @@ pub(crate) fn install_fatal_signal_guard() { #[cfg(unix)] { // Piped/embedded surfaces must never receive escape bytes. + // SAFETY: isatty(2) dereferences no pointers. if unsafe { libc::isatty(libc::STDOUT_FILENO) } == 0 { tracing::debug!("Fatal-signal terminal guard skipped: stdout is not a TTY"); return; @@ -90,6 +91,7 @@ pub(crate) fn install_fatal_signal_guard() { } } for signal in [libc::SIGABRT, libc::SIGBUS, libc::SIGILL, libc::SIGFPE] { + // SAFETY: ABRT/BUS/ILL/FPE all terminate by default. unsafe { install_handler(signal) }; } tracing::debug!("Fatal-signal terminal guard installed (ABRT/BUS/ILL/FPE)"); @@ -112,6 +114,7 @@ pub(crate) fn install_fatal_signal_guard() { /// exists and are never written again). #[cfg(unix)] unsafe extern "C" fn fatal_signal_handler(signal: libc::c_int) { + // SAFETY: signal-safe syscalls only, per the contract above. unsafe { // 1. Restore the terminal: stdout first, stderr as fallback. let mut written: usize = 0; @@ -188,6 +191,7 @@ unsafe extern "C" fn fatal_signal_handler(signal: libc::c_int) { /// `signal` must be a fatal signal whose default action is to terminate. #[cfg(unix)] unsafe fn install_handler(signal: libc::c_int) { + // SAFETY: `action` is live and zeroed; oldact is null. unsafe { // Zero the whole struct then set our two fields: the remaining // members (empty signal mask; any hidden per-OS plumbing like the diff --git a/crates/tui/src/tui/window_control.rs b/crates/tui/src/tui/window_control.rs index 4d0afc077a..a80eab92ac 100644 --- a/crates/tui/src/tui/window_control.rs +++ b/crates/tui/src/tui/window_control.rs @@ -222,7 +222,9 @@ mod imp { // nothing. Skip it entirely and resolve the real host window. let conpty = std::env::var("WT_SESSION").is_ok() || std::env::var("TERM_PROGRAM").is_ok(); if !conpty { + // SAFETY: no preconditions; invalid return handled. let hwnd = unsafe { GetConsoleWindow() }; + // SAFETY: invalid handles return false. if !hwnd.is_invalid() && unsafe { IsWindowVisible(hwnd) }.as_bool() { tracing::debug!("window_control: host window resolved via GetConsoleWindow"); return Some(hwnd); @@ -248,11 +250,14 @@ mod imp { /// window). Visibility is required — a hidden foreground window cannot /// be the host the user is looking at. fn foreground_window_in_parent_chain(pid: u32) -> Option { + // SAFETY: no preconditions; invalid return handled. let foreground = unsafe { GetForegroundWindow() }; + // SAFETY: invalid handles return false. if foreground.is_invalid() || !unsafe { IsWindowVisible(foreground) }.as_bool() { return None; } let mut fg_pid = 0u32; + // SAFETY: `fg_pid` is live for the call. unsafe { GetWindowThreadProcessId(foreground, Some(&mut fg_pid)); } @@ -308,12 +313,14 @@ mod imp { /// Look up a process's parent PID and image name from a toolhelp /// snapshot. The snapshot handle is always closed. fn process_entry(pid: u32) -> Option<(u32, String)> { + // SAFETY: returned handle is owned here; closed below. let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?; let mut entry = PROCESSENTRY32W { dwSize: size_of::() as u32, ..Default::default() }; let mut found = None; + // SAFETY: `entry` is live with dwSize initialized above. let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) }.is_ok(); while ok { if entry.th32ProcessID == pid { @@ -326,8 +333,10 @@ mod imp { found = Some((entry.th32ParentProcessID, name)); break; } + // SAFETY: `entry` is live with dwSize initialized above. ok = unsafe { Process32NextW(snapshot, &mut entry) }.is_ok(); } + // SAFETY: `snapshot` is owned here and not used after. unsafe { let _ = CloseHandle(snapshot); } @@ -349,8 +358,10 @@ mod imp { found: None, }; unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL { + // SAFETY: lparam carries the live `ctx` below; EnumWindows is synchronous. let ctx = unsafe { &mut *(lparam.0 as *mut Ctx) }; let mut wpid = 0u32; + // SAFETY: `hwnd` is valid per EnumWindows; `wpid` is live. unsafe { GetWindowThreadProcessId(hwnd, Some(&mut wpid)); if wpid == ctx.target && IsWindowVisible(hwnd).as_bool() { @@ -364,6 +375,7 @@ mod imp { } BOOL(1) } + // SAFETY: `ctx` outlives the synchronous enumeration. unsafe { let _ = EnumWindows(Some(enum_proc), LPARAM(&mut ctx as *mut Ctx as isize)); } From d80a871e3c64d9e83c481c6de16706515169c7ef Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Thu, 17 Sep 2026 19:24:24 -0400 Subject: [PATCH 21/24] fix(async): use tokio::fs for blocking calls in async code Atlas ASYNC-002: convert std fs calls lexically inside async fns to their tokio::fs equivalents (read/write/rename/metadata/remove_file/ create_dir_all/OpenOptions/try_exists). Coherent same-function twins converted too; search.rs:215 falsified (already behind spawn_blocking via run_blocking_grep). Sync helpers shared with sync callers (write_atomic*, streaming readers, staging) intentionally untouched. --- crates/tui/src/lib.rs | 2 +- crates/tui/src/plugins/install/mod.rs | 7 +- crates/tui/src/skills/install.rs | 81 ++++++++++--------- crates/tui/src/task_manager.rs | 25 +++--- crates/tui/src/tools/file.rs | 39 +++++---- crates/tui/src/tools/fim.rs | 4 +- crates/tui/src/tools/git_history.rs | 2 +- crates/tui/src/tools/tool_result_retrieval.rs | 4 +- 8 files changed, 88 insertions(+), 76 deletions(-) diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 0335404318..180b28f692 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -4515,7 +4515,7 @@ async fn run_doctor( println!("{}", "Configuration:".bold()); let config_path = &doctor_paths.config; - if config_path.exists() { + if tokio::fs::try_exists(config_path).await.unwrap_or(false) { println!( " {} config.toml found at {}", "✓".truecolor(aqua_r, aqua_g, aqua_b), diff --git a/crates/tui/src/plugins/install/mod.rs b/crates/tui/src/plugins/install/mod.rs index 038fbd16a6..bda1524bcc 100644 --- a/crates/tui/src/plugins/install/mod.rs +++ b/crates/tui/src/plugins/install/mod.rs @@ -426,14 +426,15 @@ pub async fn update( network: &NetworkPolicy, ) -> Result { let target = plugin_target_path(name, user_plugins_dir)?; - if target.exists() { + if tokio::fs::try_exists(&target).await.unwrap_or(false) { ensure_target_within_plugins_dir(&target, user_plugins_dir)?; } let marker_path = target.join(INSTALLED_FROM_MARKER); - if !marker_path.exists() { + if !tokio::fs::try_exists(&marker_path).await.unwrap_or(false) { return Err(PluginInstallError::NotInstalledHere(name.to_string()).into()); } - let marker_body = fs::read_to_string(&marker_path) + let marker_body = tokio::fs::read_to_string(&marker_path) + .await .with_context(|| format!("failed to read {}", marker_path.display()))?; let marker: InstalledFromMarker = serde_json::from_str(&marker_body) .with_context(|| format!("malformed {INSTALLED_FROM_MARKER} for {name}"))?; diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index b151861e56..51a328582d 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -327,10 +327,10 @@ pub async fn install_with_registry( // finalize can restore the previous install. let final_path = skills_dir.join(&staged.skill_name); let mut backup_path: Option = None; - if final_path.exists() { + if tokio::fs::try_exists(&final_path).await.unwrap_or(false) { if !update { // Clean up the staging dir before returning the error. - let _ = fs::remove_dir_all(&staged.staged_path); + let _ = tokio::fs::remove_dir_all(&staged.staged_path).await; return Err(InstallError::AlreadyInstalled(staged.skill_name).into()); } // Same ownership gate as plugins/install/place.rs: an update may only @@ -338,31 +338,35 @@ pub async fn install_with_registry( // is not proof of ownership — without the marker we would delete a // user-authored or system skill that happened to share the name. if let Err(err) = reject_unmarked_update(&final_path, &staged.skill_name) { - let _ = fs::remove_dir_all(&staged.staged_path); + let _ = tokio::fs::remove_dir_all(&staged.staged_path).await; return Err(err.into()); } let backup = skills_dir.join(format!("{}.bak", staged.skill_name)); - if backup.exists() { - fs::remove_dir_all(&backup).ok(); + if tokio::fs::try_exists(&backup).await.unwrap_or(false) { + tokio::fs::remove_dir_all(&backup).await.ok(); } - fs::rename(&final_path, &backup).with_context(|| { - format!( - "failed to backup existing skill at {}", - final_path.display() - ) - })?; - if let Err(err) = fs::rename(&staged.staged_path, &final_path) { - fs::rename(&backup, &final_path).ok(); + tokio::fs::rename(&final_path, &backup) + .await + .with_context(|| { + format!( + "failed to backup existing skill at {}", + final_path.display() + ) + })?; + if let Err(err) = tokio::fs::rename(&staged.staged_path, &final_path).await { + tokio::fs::rename(&backup, &final_path).await.ok(); return Err(err).context("failed to install staged skill"); } backup_path = Some(backup); } else { if let Some(parent) = final_path.parent() { - fs::create_dir_all(parent).with_context(|| { + tokio::fs::create_dir_all(parent).await.with_context(|| { format!("failed to create skills directory {}", parent.display()) })?; } - fs::rename(&staged.staged_path, &final_path).context("failed to install staged skill")?; + tokio::fs::rename(&staged.staged_path, &final_path) + .await + .context("failed to install staged skill")?; } // Write the marker last so a partial install never leaves a stale @@ -371,9 +375,9 @@ pub async fn install_with_registry( let content_digest = match super::package_digest::compute_package_digest(&final_path) { Ok(digest) => digest, Err(err) => { - let _ = fs::remove_dir_all(&final_path); + let _ = tokio::fs::remove_dir_all(&final_path).await; if let Some(backup) = backup_path.take() { - let _ = fs::rename(&backup, &final_path); + let _ = tokio::fs::rename(&backup, &final_path).await; } return Err(anyhow::anyhow!( "installed package failed content digest validation: {err}" @@ -388,14 +392,14 @@ pub async fn install_with_registry( &content_digest, &staged.skill_name, ) { - let _ = fs::remove_dir_all(&final_path); + let _ = tokio::fs::remove_dir_all(&final_path).await; if let Some(backup) = backup_path.take() { - let _ = fs::rename(&backup, &final_path); + let _ = tokio::fs::rename(&backup, &final_path).await; } return Err(err); } if let Some(backup) = backup_path { - fs::remove_dir_all(&backup).ok(); + tokio::fs::remove_dir_all(&backup).await.ok(); } Ok(InstallOutcome::Installed(InstalledSkill { @@ -433,14 +437,15 @@ pub async fn update_with_registry( registry_url: &str, ) -> Result { let target = skill_target_path(name, skills_dir)?; - if target.exists() { + if tokio::fs::try_exists(&target).await.unwrap_or(false) { ensure_target_within_skills_dir(&target, skills_dir)?; } let marker_path = target.join(INSTALLED_FROM_MARKER); - if !marker_path.exists() { + if !tokio::fs::try_exists(&marker_path).await.unwrap_or(false) { return Err(InstallError::NotInstalledHere(name.to_string()).into()); } - let marker_body = fs::read_to_string(&marker_path) + let marker_body = tokio::fs::read_to_string(&marker_path) + .await .with_context(|| format!("failed to read {}", marker_path.display()))?; let marker: InstalledFromMarker = serde_json::from_str(&marker_body) .with_context(|| format!("malformed {INSTALLED_FROM_MARKER} for {name}"))?; @@ -476,13 +481,13 @@ pub async fn update_with_registry( // so we get the same atomic-replace semantics. Content updates must not // inherit a previous trust marker. let trust_path = target.join(TRUSTED_MARKER); - let had_trust = trust_path.exists(); + let had_trust = tokio::fs::try_exists(&trust_path).await.unwrap_or(false); let outcome = install_with_registry(source, skills_dir, max_size, network, true, registry_url).await?; match &outcome { InstallOutcome::Installed(installed) => { if had_trust { - let _ = fs::remove_file(installed.path.join(TRUSTED_MARKER)); + let _ = tokio::fs::remove_file(installed.path.join(TRUSTED_MARKER)).await; } } InstallOutcome::NeedsApproval(_) | InstallOutcome::NetworkDenied(_) => {} @@ -708,14 +713,10 @@ async fn sync_one_skill( // Perform a HEAD request (or conditional GET) for freshness. We use a // simple GET with If-None-Match when we have an ETag, falling back to // an unconditional GET for servers that don't support ETags. - let existing_meta: Option = meta_path - .exists() - .then(|| { - fs::read_to_string(&meta_path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - }) - .flatten(); + let existing_meta: Option = tokio::fs::read_to_string(&meta_path) + .await + .ok() + .and_then(|s| serde_json::from_str(&s).ok()); // Build the request — add If-None-Match if we have a cached ETag. let client = reqwest_client(); @@ -815,11 +816,11 @@ async fn sync_one_skill( }; // Move staged dir into its final location, replacing any prior cache. let dest = cache_dir.join(name); - if dest.exists() { - let _ = fs::remove_dir_all(&dest); + if tokio::fs::try_exists(&dest).await.unwrap_or(false) { + let _ = tokio::fs::remove_dir_all(&dest).await; } - if let Err(err) = fs::rename(&staged.staged_path, &dest) { - let _ = fs::remove_dir_all(&staged.staged_path); + if let Err(err) = tokio::fs::rename(&staged.staged_path, &dest).await { + let _ = tokio::fs::remove_dir_all(&staged.staged_path).await; return SkillSyncOutcome::Failed { name: name.to_string(), reason: format!("failed to move staged skill into cache: {err:#}"), @@ -828,14 +829,14 @@ async fn sync_one_skill( dest } else { // Plain SKILL.md (or other companion text file). Write directly. - if let Err(err) = fs::create_dir_all(&skill_cache_dir) { + if let Err(err) = tokio::fs::create_dir_all(&skill_cache_dir).await { return SkillSyncOutcome::Failed { name: name.to_string(), reason: format!("failed to create cache dir: {err:#}"), }; } let skill_md_path = skill_cache_dir.join("SKILL.md"); - if let Err(err) = fs::write(&skill_md_path, &bytes) { + if let Err(err) = tokio::fs::write(&skill_md_path, &bytes).await { return SkillSyncOutcome::Failed { name: name.to_string(), reason: format!("failed to write SKILL.md to cache: {err:#}"), @@ -851,7 +852,7 @@ async fn sync_one_skill( url: url.clone(), }; let meta_json = serde_json::to_string(&meta).unwrap_or_default(); - let _ = fs::write(final_path.join(".cache-meta.json"), meta_json); + let _ = tokio::fs::write(final_path.join(".cache-meta.json"), meta_json).await; return SkillSyncOutcome::Downloaded { name: name.to_string(), diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index 76e04edaed..a3b8fb4602 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -1449,14 +1449,17 @@ impl TaskManager { let tasks_dir = cfg.data_dir.join("tasks"); let artifacts_dir = cfg.data_dir.join("artifacts"); let queue_path = cfg.data_dir.join("queue.json"); - fs::create_dir_all(&tasks_dir) + tokio::fs::create_dir_all(&tasks_dir) + .await .with_context(|| format!("Failed to create tasks dir {}", tasks_dir.display()))?; - fs::create_dir_all(&artifacts_dir).with_context(|| { - format!( - "Failed to create task artifacts dir {}", - artifacts_dir.display() - ) - })?; + tokio::fs::create_dir_all(&artifacts_dir) + .await + .with_context(|| { + format!( + "Failed to create task artifacts dir {}", + artifacts_dir.display() + ) + })?; let execution_lease = TaskExecutionLease::new(&cfg.data_dir, identity.0, identity.1)?; let cancel_token = CancellationToken::new(); @@ -1765,7 +1768,9 @@ impl TaskManager { write_json_atomic(&staged_task_path, &task)?; } if let Err(err) = self.persist_queue_locked(&next_queue) { - if !recover_stage && let Err(cleanup_err) = fs::remove_file(&staged_task_path) { + if !recover_stage + && let Err(cleanup_err) = tokio::fs::remove_file(&staged_task_path).await + { tracing::warn!( task_id = %task.id, error = %cleanup_err, @@ -1774,12 +1779,12 @@ impl TaskManager { } return Err(err); } - if let Err(promote_err) = fs::rename(&staged_task_path, &task_path) { + if let Err(promote_err) = tokio::fs::rename(&staged_task_path, &task_path).await { let rollback_error = self.persist_queue_locked(&state.queue).err(); let cleanup_error = if recover_stage { None } else { - fs::remove_file(&staged_task_path).err() + tokio::fs::remove_file(&staged_task_path).await.err() }; let mut message = format!("Failed to promote staged task {}: {promote_err}", task.id); diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index b36e788296..53927ca8d2 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -757,7 +757,7 @@ impl ReadFileTool { } enforce_read_denylist(&file_path, "read")?; check_file_operation_cancelled(context)?; - let bytes = fs::read(&file_path).map_err(|error| { + let bytes = tokio::fs::read(&file_path).await.map_err(|error| { ToolError::execution_failed(format!("Failed to read {}: {error}", file_path.display())) })?; // #6283: every read response carries the file's byte size, line @@ -952,10 +952,14 @@ impl ToolSpec for ReadFileTool { // Open before parameter parsing so a missing file keeps the // historical "Failed to read …" error shape regardless of the other // arguments. - let file = fs::File::open(&file_path).map_err(|e| { + let file = tokio::fs::File::open(&file_path).await.map_err(|e| { ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e)) })?; - let file_bytes = file.metadata().map(|meta| meta.len()).unwrap_or(u64::MAX); + let file_bytes = file + .metadata() + .await + .map(|meta| meta.len()) + .unwrap_or(u64::MAX); let explicit_range = input .get("start_line") @@ -967,7 +971,7 @@ impl ToolSpec for ReadFileTool { // tiny file would silently ignore the request. if !explicit_range && file_bytes <= SMALL_FILE_BYTES as u64 { drop(file); - let contents = fs::read_to_string(&file_path).map_err(|e| { + let contents = tokio::fs::read_to_string(&file_path).await.map_err(|e| { ToolError::execution_failed(format!( "Failed to read {}: {}", file_path.display(), @@ -1047,7 +1051,7 @@ impl ToolSpec for ReadFileTool { // runs to EOF so the total line count and whole-file UTF-8 validation // match the historical read_to_string behavior. let (window, total_lines) = - read_window_streaming(file, start_line, max_lines).map_err(|e| { + read_window_streaming(file.into_std().await, start_line, max_lines).map_err(|e| { ToolError::execution_failed(format!( "Failed to read {}: {}", file_path.display(), @@ -1063,7 +1067,7 @@ impl ToolSpec for ReadFileTool { // pass back to `edit`. Special files are skipped: reopening a FIFO or // device can block indefinitely (or re-consume a one-shot stream), // and a stream has no stable content an edit guard could pin. - let hash = match fs::metadata(&file_path) { + let hash = match tokio::fs::metadata(&file_path).await { Ok(meta) if meta.is_file() => hash_file_streaming(&file_path).ok(), _ => None, }; @@ -1442,16 +1446,16 @@ impl WriteFileTool { let mutation_guard = acquire_file_mutation(&file_path, context).await?; check_file_operation_cancelled(context)?; - let existed_before = file_path.exists(); + let existed_before = tokio::fs::try_exists(&file_path).await.unwrap_or(false); let prior_bytes = if existed_before { - fs::read(&file_path).unwrap_or_default() + tokio::fs::read(&file_path).await.unwrap_or_default() } else { Vec::new() }; let prior_contents = String::from_utf8_lossy(&prior_bytes); if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).map_err(|error| { + tokio::fs::create_dir_all(parent).await.map_err(|error| { ToolError::execution_failed(format!( "Failed to create directory {}: {error}", parent.display() @@ -1556,9 +1560,11 @@ impl ToolSpec for WriteFileTool { // Snapshot the existing contents (if any) before we overwrite — used // to render an inline diff in the tool result. - let existed_before = file_path.exists(); + let existed_before = tokio::fs::try_exists(&file_path).await.unwrap_or(false); let prior_contents = if existed_before { - fs::read_to_string(&file_path).unwrap_or_default() + tokio::fs::read_to_string(&file_path) + .await + .unwrap_or_default() } else { String::new() }; @@ -1579,7 +1585,7 @@ impl ToolSpec for WriteFileTool { // Create parent directories if needed if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).map_err(|e| { + tokio::fs::create_dir_all(parent).await.map_err(|e| { ToolError::execution_failed(format!( "Failed to create directory {}: {}", parent.display(), @@ -2023,17 +2029,18 @@ impl EditFileTool { let mutation_guard = acquire_file_mutation(&file_path, context).await?; check_file_operation_cancelled(context)?; - fs::OpenOptions::new() + tokio::fs::OpenOptions::new() .read(true) .write(true) .open(&file_path) + .await .map_err(|error| { ToolError::execution_failed(format!( "Could not edit file {path_str}: target must be readable and writable ({error})" )) })?; check_file_operation_cancelled(context)?; - let raw_bytes = fs::read(&file_path).map_err(|error| { + let raw_bytes = tokio::fs::read(&file_path).await.map_err(|error| { ToolError::execution_failed(format!("Could not edit file {path_str}: {error}")) })?; check_file_operation_cancelled(context)?; @@ -2170,7 +2177,7 @@ impl ToolSpec for EditFileTool { let file_path = context.resolve_path(path_str)?; context.require_fresh_file_read(&file_path, path_str)?; - let contents = fs::read_to_string(&file_path).map_err(|e| { + let contents = tokio::fs::read_to_string(&file_path).await.map_err(|e| { ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e)) })?; @@ -2310,7 +2317,7 @@ impl ToolSpec for EditFileTool { // actually applied. A fabricated "Replaced 1 occurrence" + diff is // worse than a hard error: models trust it and re-edit the same // span 3–5× before noticing nothing changed. - let on_disk = fs::read_to_string(&file_path).map_err(|e| { + let on_disk = tokio::fs::read_to_string(&file_path).await.map_err(|e| { ToolError::execution_failed(format!( "Failed to verify write to {}: {}", file_path.display(), diff --git a/crates/tui/src/tools/fim.rs b/crates/tui/src/tools/fim.rs index cdab60f367..8fab63eb8a 100644 --- a/crates/tui/src/tools/fim.rs +++ b/crates/tui/src/tools/fim.rs @@ -6,8 +6,6 @@ //! (`crates/tui/src/client.rs:3484`), so this works on any ChatCompletions //! provider — it is not DeepSeek-specific. -use std::fs; - use async_trait::async_trait; use serde_json::{Value, json}; use thiserror::Error; @@ -117,7 +115,7 @@ impl ToolSpec for FimEditTool { // 1. Read the file let resolved = context.resolve_path(path)?; - let content = fs::read_to_string(&resolved).map_err(|e| { + let content = tokio::fs::read_to_string(&resolved).await.map_err(|e| { ToolError::execution_failed(format!("Failed to read {}: {}", resolved.display(), e)) })?; diff --git a/crates/tui/src/tools/git_history.rs b/crates/tui/src/tools/git_history.rs index e26a5d24aa..7043ca79d6 100644 --- a/crates/tui/src/tools/git_history.rs +++ b/crates/tui/src/tools/git_history.rs @@ -336,7 +336,7 @@ impl ToolSpec for GitBlameTool { async fn execute(&self, input: Value, context: &ToolContext) -> Result { let path_str = required_str(&input, "path")?; let resolved_path = context.resolve_path(path_str)?; - let metadata = fs::metadata(&resolved_path).map_err(|e| { + let metadata = tokio::fs::metadata(&resolved_path).await.map_err(|e| { ToolError::invalid_input(format!( "Path does not exist or is not accessible: {path_str} ({e})" )) diff --git a/crates/tui/src/tools/tool_result_retrieval.rs b/crates/tui/src/tools/tool_result_retrieval.rs index 07b11ff92f..cf347a028d 100644 --- a/crates/tui/src/tools/tool_result_retrieval.rs +++ b/crates/tui/src/tools/tool_result_retrieval.rs @@ -5,7 +5,6 @@ //! only when a digest-bound ownership sidecar proves they belong to the active //! session. -use std::fs; use std::path::PathBuf; use async_trait::async_trait; @@ -135,7 +134,7 @@ impl ToolSpec for RetrieveToolResultTool { } else { None }; - let bytes = fs::read(&resolved.path).map_err(|_| { + let bytes = tokio::fs::read(&resolved.path).await.map_err(|_| { ToolError::execution_failed("evidence is missing or no longer retained") })?; if let Some(ownership) = legacy_ownership { @@ -793,6 +792,7 @@ fn clamp_u64(value: u64, min: usize, max: usize) -> usize { #[cfg(test)] mod tests { use super::*; + use std::fs; use std::sync::MutexGuard; use tempfile::tempdir; From 30b7d4abb7f342b7599c4b640819ab665f2afa3c Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Thu, 17 Sep 2026 19:28:41 -0400 Subject: [PATCH 22/24] fix(resource): bound recursion in value walkers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas RESOURCE-002: export walkers (TOML, cap 64), JSON redactor and approval canonicalizer (cap 128, serde-parse-aligned) now carry depth fuel and fail closed past it. canonicalize_json_keys proven bounded (sole input is McpConfig-shaped, no Value fields) — no change. Adds deep-input tests per walker. --- crates/cli/src/config_bundles.rs | 52 ++++++++++++++++++++++++-- crates/config/src/persistence.rs | 23 ++++++++++++ crates/secrets/src/redact.rs | 23 ++++++++++-- crates/tui/src/tools/approval_cache.rs | 30 ++++++++++++++- 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/crates/cli/src/config_bundles.rs b/crates/cli/src/config_bundles.rs index e892d0225b..42aaf83c98 100644 --- a/crates/cli/src/config_bundles.rs +++ b/crates/cli/src/config_bundles.rs @@ -287,9 +287,22 @@ pub fn find_rejected_entries(bundle: &PortableBundle) -> Vec { rejected } +/// Maximum nesting depth the export walkers descend. Config files are +/// shallow; anything deeper is pathological and fails closed. +const MAX_EXPORT_WALK_DEPTH: usize = 64; + /// Why a value carries nested non-portable authority or looks like a bare /// credential, or `None` when it is safe to move between machines. fn value_rejection_reason(path: &str, value: &toml::Value) -> Option { + value_rejection_reason_at(path, value, 0) +} + +fn value_rejection_reason_at(path: &str, value: &toml::Value, depth: usize) -> Option { + if depth > MAX_EXPORT_WALK_DEPTH { + return Some(format!( + "nested more than {MAX_EXPORT_WALK_DEPTH} levels deep" + )); + } if let Some(reason) = nonportable_value_reason(path, value) { return Some(reason.to_string()); } @@ -297,7 +310,7 @@ fn value_rejection_reason(path: &str, value: &toml::Value) -> Option { toml::Value::String(text) => string_secret_reason(text), toml::Value::Array(items) => items .iter() - .find_map(|value| value_rejection_reason(path, value)) + .find_map(|value| value_rejection_reason_at(path, value, depth + 1)) .map(|reason| format!("array contains an entry where {reason}")), toml::Value::Table(map) => { for (key, nested_value) in map { @@ -309,7 +322,9 @@ fn value_rejection_reason(path: &str, value: &toml::Value) -> Option { if let Some(reason) = nonportable_path_reason(&child_path) { return Some(format!("nested key {key:?} {reason}")); } - if let Some(reason) = value_rejection_reason(&child_path, nested_value) { + if let Some(reason) = + value_rejection_reason_at(&child_path, nested_value, depth + 1) + { return Some(format!("nested under {key:?}, {reason}")); } } @@ -965,6 +980,13 @@ fn config_document(config: &ConfigToml) -> Result Option { + sanitize_export_value_at(path, value, 0) +} + +fn sanitize_export_value_at(path: &str, value: &toml::Value, depth: usize) -> Option { + if depth > MAX_EXPORT_WALK_DEPTH { + return None; + } if nonportable_path_reason(path).is_some() || nonportable_value_reason(path, value).is_some() { return None; } @@ -973,14 +995,14 @@ fn sanitize_export_value(path: &str, value: &toml::Value) -> Option toml::Value::Array(values) => Some(toml::Value::Array( values .iter() - .filter_map(|value| sanitize_export_value(path, value)) + .filter_map(|value| sanitize_export_value_at(path, value, depth + 1)) .collect(), )), toml::Value::Table(table) => { let mut scrubbed = toml::map::Map::new(); for (key, value) in table { let child_path = format!("{path}.{key}"); - if let Some(value) = sanitize_export_value(&child_path, value) { + if let Some(value) = sanitize_export_value_at(&child_path, value, depth + 1) { scrubbed.insert(key.clone(), value); } } @@ -3621,6 +3643,28 @@ command = "/synthetic/direct-tool-override" assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}"); } + #[test] + fn deep_nesting_fails_closed_for_rejection_and_sanitize() { + fn deep_toml(depth: usize) -> toml::Value { + let mut value = toml::Value::String("leaf".to_string()); + for _ in 0..depth { + let mut map = toml::map::Map::new(); + map.insert("t".to_string(), value); + value = toml::Value::Table(map); + } + value + } + + let deep = deep_toml(70); + let reason = value_rejection_reason("t", &deep).expect("over-deep value must be rejected"); + assert!(reason.contains("levels deep"), "{reason}"); + assert!( + sanitize_export_value("t", &deep) + .is_some_and(|scrubbed| !scrubbed.to_string().contains("leaf")), + "over-deep branch must be omitted, not exported" + ); + } + #[test] fn lsp_executable_authority_is_rejected_while_inert_settings_remain_portable() { let config: ConfigToml = toml::from_str( diff --git a/crates/config/src/persistence.rs b/crates/config/src/persistence.rs index a22eaa3297..8ec765be22 100644 --- a/crates/config/src/persistence.rs +++ b/crates/config/src/persistence.rs @@ -524,6 +524,29 @@ PASSWORD=hunter2hunter2" assert!(!out.to_string().contains(&synthetic_secret), "{out}"); } + #[test] + fn redact_json_truncates_pathological_nesting() { + let mut value = serde_json::Value::String("leaf".to_string()); + for _ in 0..150 { + let mut map = serde_json::Map::new(); + map.insert("t".to_string(), value); + value = serde_json::Value::Object(map); + } + let out = redact_json_secrets(&value); + let mut cursor = &out; + let mut descended = 0; + while let serde_json::Value::Object(map) = cursor { + cursor = map.values().next().expect("single-key nesting"); + descended += 1; + } + assert_eq!( + cursor, + &serde_json::Value::String(REDACTED.to_string()), + "over-deep value must be redacted, not traversed" + ); + assert!(descended < 150, "guard must fire before the leaf"); + } + #[test] fn redact_text_masks_camel_case_and_dotted_secret_assignments() { let synthetic_secret = synthetic_secret_fixture(); diff --git a/crates/secrets/src/redact.rs b/crates/secrets/src/redact.rs index db361dd8ef..09a72f7e53 100644 --- a/crates/secrets/src/redact.rs +++ b/crates/secrets/src/redact.rs @@ -43,6 +43,18 @@ pub const REDACTED: &str = "[redacted]"; /// one flat keyed assignment. #[must_use] pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value { + redact_json_secrets_at(value, 0) +} + +/// Maximum nesting depth the JSON redactor descends. Aligned with +/// serde_json's own parse limit so parsed input never truncates; anything +/// deeper is redacted wholesale. +const MAX_REDACT_JSON_DEPTH: usize = 128; + +fn redact_json_secrets_at(value: &serde_json::Value, depth: usize) -> serde_json::Value { + if depth > MAX_REDACT_JSON_DEPTH { + return serde_json::Value::String(REDACTED.to_string()); + } match value { serde_json::Value::Object(object) => serde_json::Value::Object( object @@ -51,15 +63,18 @@ pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value { let value = if key_is_sensitive(key) { serde_json::Value::String(REDACTED.to_string()) } else { - redact_json_secrets(value) + redact_json_secrets_at(value, depth + 1) }; (key.clone(), value) }) .collect(), ), - serde_json::Value::Array(items) => { - serde_json::Value::Array(items.iter().map(redact_json_secrets).collect()) - } + serde_json::Value::Array(items) => serde_json::Value::Array( + items + .iter() + .map(|item| redact_json_secrets_at(item, depth + 1)) + .collect(), + ), serde_json::Value::String(text) => serde_json::Value::String(redact_secrets(text)), scalar => scalar.clone(), } diff --git a/crates/tui/src/tools/approval_cache.rs b/crates/tui/src/tools/approval_cache.rs index 02036aa9ab..38a216ecc7 100644 --- a/crates/tui/src/tools/approval_cache.rs +++ b/crates/tui/src/tools/approval_cache.rs @@ -195,7 +195,20 @@ fn hash_json_value(value: &Value) -> String { short } +/// Maximum nesting depth the canonical serializer descends. Aligned with +/// serde_json's own parse limit so parsed input never truncates; anything +/// deeper emits a fixed marker, keeping keys deterministic. +const MAX_CANONICAL_JSON_DEPTH: usize = 128; + fn push_canonical_json(value: &Value, out: &mut String) { + push_canonical_json_at(value, out, 0) +} + +fn push_canonical_json_at(value: &Value, out: &mut String, depth: usize) { + if depth > MAX_CANONICAL_JSON_DEPTH { + out.push_str("maxdepth"); + return; + } match value { Value::Null => out.push_str("null"), Value::Bool(value) => { @@ -240,7 +253,7 @@ fn push_canonical_json(value: &Value, out: &mut String) { if index > 0 { out.push(','); } - push_canonical_json(item, out); + push_canonical_json_at(item, out, depth + 1); } out.push(']'); } @@ -257,7 +270,7 @@ fn push_canonical_json(value: &Value, out: &mut String) { serde_json::to_string(key).expect("serializing an object key cannot fail"); out.push_str(&encoded_key); out.push(':'); - push_canonical_json(value, out); + push_canonical_json_at(value, out, depth + 1); } out.push('}'); } @@ -283,6 +296,19 @@ mod tests { assert_eq!(key_a, key_b); } + #[test] + fn pathological_nesting_yields_a_stable_key() { + let mut value = Value::String("leaf".to_string()); + for _ in 0..150 { + let mut map = serde_json::Map::new(); + map.insert("t".to_string(), value); + value = Value::Object(map); + } + let key_a = build_approval_key("exec_shell", &value); + let key_b = build_approval_key("exec_shell", &value); + assert_eq!(key_a, key_b, "truncated keys must stay deterministic"); + } + #[test] fn shell_keys_include_full_command_arguments() { let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build"})); From 4bb01cb1eefad4c4ffd412fbb8ef433c3d133897 Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Thu, 17 Sep 2026 19:30:41 -0400 Subject: [PATCH 23/24] docs(policy): establish lock-poison posture as fail-stop Atlas PANIC-002 is one policy decision, not 21 patches. Production locks already fail-stop with lock-naming messages (user registry, session index, coordination slot); 15 of 21 findings are test-support code. Document the rule: expect by default, into_inner only where stale state is safe. --- docs/ARCHITECTURE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e6dfe8be80..25767a76a8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -334,6 +334,11 @@ command = "echo 'Running tool: $TOOL_NAME'" are not wired into command execution. 5. **Minimal dependencies**: Careful dependency selection for build speed 6. **Local-first runtime API**: HTTP/SSE endpoints are intended for trusted localhost access and are served by the `crates/tui` runtime today +7. **Lock poison**: fail-stop by default. A poisoned lock means a holder + panicked mid-mutation, so `.expect()` with a message naming the lock is + the standard posture — never serve half-updated state. Recover with + `into_inner()` only where stale state is safe (caches, idempotent + rebuilds), with a comment saying why. ## Configuration Files From 780e214cf80dae70cf37bbc551e6d2d684f01a0b Mon Sep 17 00:00:00 2001 From: AdityaVG13 Date: Thu, 17 Sep 2026 19:34:13 -0400 Subject: [PATCH 24/24] fix(resource): budget unbounded file and stdin reads Atlas RESOURCE-001: take(limit+1)+check pattern mirroring the credential store's existing limit. Config/state readers capped at 1 MiB, sub-agent state and stdin patches at 16 MiB, API-key stdin at 8 KiB; worker-log drain capped per call with a pending-line bound. The 5 oauth findings already flow through the budgeted store reader. --- crates/cli/src/lib.rs | 8 ++++++ crates/config/src/lib.rs | 27 ++++++++++++++++++--- crates/tui/src/fleet/executor.rs | 12 ++++++++- crates/tui/src/lib.rs | 27 ++++++++++++++++++--- crates/tui/src/mcp.rs | 11 +++++++-- crates/tui/src/plugins/marketplace/store.rs | 14 +++++++++-- crates/tui/src/plugins/registry.rs | 14 +++++++++-- crates/tui/src/tools/subagent/mod.rs | 15 ++++++++++-- 8 files changed, 112 insertions(+), 16 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 53deed4467..e563662e73 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -5516,11 +5516,19 @@ fn run_metrics_command(args: MetricsArgs) -> Result<()> { }) } +/// Maximum bytes read for an API key on stdin. Keys are short; anything +/// larger is a piped file, not a key. +const MAX_STDIN_API_KEY_BYTES: u64 = 8 * 1024; + fn read_api_key_from_stdin() -> Result { let mut input = String::new(); io::stdin() + .take(MAX_STDIN_API_KEY_BYTES + 1) .read_to_string(&mut input) .context("failed to read api key from stdin")?; + if input.len() as u64 > MAX_STDIN_API_KEY_BYTES { + bail!("API key on stdin exceeds the 8 KiB limit"); + } let key = input.trim().to_string(); if key.is_empty() { bail!("empty API key provided"); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 7a12f0e71e..9e3e0e1eaf 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -7224,20 +7224,41 @@ fn read_checked_toml_file(path: &Path, label: &str) -> Result { .with_context(|| format!("failed to read {label} at {}", path.display())) } +/// Maximum bytes read from a config file. Configs are kilobytes; anything +/// larger is not a config file. +const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024; + #[cfg(unix)] fn read_string_no_follow(path: &Path) -> std::io::Result { - let mut file = fs::OpenOptions::new() + let file = fs::OpenOptions::new() .read(true) .custom_flags(libc::O_NOFOLLOW) .open(path)?; let mut raw = String::new(); - file.read_to_string(&mut raw)?; + file.take(MAX_CONFIG_FILE_BYTES + 1) + .read_to_string(&mut raw)?; + if raw.len() as u64 > MAX_CONFIG_FILE_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("config file {} exceeds the 1 MiB limit", path.display()), + )); + } Ok(raw) } #[cfg(not(unix))] fn read_string_no_follow(path: &Path) -> std::io::Result { - fs::read_to_string(path) + let file = fs::File::open(path)?; + let mut raw = String::new(); + file.take(MAX_CONFIG_FILE_BYTES + 1) + .read_to_string(&mut raw)?; + if raw.len() as u64 > MAX_CONFIG_FILE_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("config file {} exceeds the 1 MiB limit", path.display()), + )); + } + Ok(raw) } fn reject_path_symlink(path: &Path) -> Result<()> { diff --git a/crates/tui/src/fleet/executor.rs b/crates/tui/src/fleet/executor.rs index 1137b07475..4a0ffcc9cf 100644 --- a/crates/tui/src/fleet/executor.rs +++ b/crates/tui/src/fleet/executor.rs @@ -898,6 +898,11 @@ impl FleetExecutor { } } + /// Maximum bytes drained from a worker log per call, and the cap for the + /// buffered partial line. Event lines are small; the remainder stays for + /// the next drain. + const MAX_DRAIN_BYTES: u64 = 1024 * 1024; + /// Read any newly-written stream-json lines for a worker and map them to /// fleet ledger events. Safe to call repeatedly; only new bytes are parsed, /// and a trailing partial line is buffered until its newline arrives. @@ -914,7 +919,7 @@ impl FleetExecutor { return events; } let mut buf = Vec::new(); - if let Ok(read) = file.read_to_end(&mut buf) { + if let Ok(read) = file.take(Self::MAX_DRAIN_BYTES).read_to_end(&mut buf) { stream.offset += read as u64; stream.pending.extend_from_slice(&buf); while let Some(idx) = stream.pending.iter().position(|byte| *byte == b'\n') { @@ -923,6 +928,11 @@ impl FleetExecutor { events.push(event); } } + // Whatever remains has no newline; drop it rather than buffering + // a newline-free flood forever. + if stream.pending.len() as u64 > Self::MAX_DRAIN_BYTES { + stream.pending.clear(); + } } events } diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 180b28f692..d2c29dc990 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -9943,13 +9943,22 @@ fn run_apply(args: ApplyArgs) -> Result<()> { Ok(()) } +/// Maximum bytes read for a patch on stdin. Generous for large diffs; +/// anything larger is not a patch. +const MAX_STDIN_PATCH_BYTES: u64 = 16 * 1024 * 1024; + fn read_patch_from_stdin() -> Result { - let mut stdin = io::stdin(); + let stdin = io::stdin(); if stdin.is_terminal() { bail!("No patch file provided and stdin is empty."); } let mut buffer = String::new(); - stdin.read_to_string(&mut buffer)?; + stdin + .take(MAX_STDIN_PATCH_BYTES + 1) + .read_to_string(&mut buffer)?; + if buffer.len() as u64 > MAX_STDIN_PATCH_BYTES { + bail!("patch on stdin exceeds the 16 MiB limit"); + } Ok(buffer) } @@ -11188,6 +11197,9 @@ fn merge_project_config_with_approval_baseline( } } +/// Maximum bytes read from a project config file. Configs are kilobytes. +const MAX_PROJECT_CONFIG_BYTES: u64 = 1024 * 1024; + fn read_project_config_file(path: &Path) -> io::Result> { let metadata = match std::fs::symlink_metadata(path) { Ok(metadata) => metadata, @@ -11205,9 +11217,16 @@ fn read_project_config_file(path: &Path) -> io::Result> { return Ok(None); } - let mut file = open_project_config_file(path)?; + let file = open_project_config_file(path)?; let mut raw = String::new(); - file.read_to_string(&mut raw)?; + file.take(MAX_PROJECT_CONFIG_BYTES + 1) + .read_to_string(&mut raw)?; + if raw.len() as u64 > MAX_PROJECT_CONFIG_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("project config {} exceeds the 1 MiB limit", path.display()), + )); + } Ok(Some(raw)) } diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index e752196cea..77b955dad3 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -5324,6 +5324,9 @@ pub fn load_config(path: &Path) -> Result { }) } +/// Maximum bytes read from an MCP config file. Configs are kilobytes. +const MAX_MCP_CONFIG_BYTES: u64 = 1024 * 1024; + fn read_mcp_config_file(path: &Path) -> Result> { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, @@ -5338,11 +5341,15 @@ fn read_mcp_config_file(path: &Path) -> Result> { anyhow::bail!("MCP config path must be a regular file: {}", path.display()); } - let mut file = open_mcp_config_file(path) + let file = open_mcp_config_file(path) .with_context(|| format!("Failed to read MCP config {}", path.display()))?; let mut contents = String::new(); - file.read_to_string(&mut contents) + file.take(MAX_MCP_CONFIG_BYTES + 1) + .read_to_string(&mut contents) .with_context(|| format!("Failed to read MCP config {}", path.display()))?; + if contents.len() as u64 > MAX_MCP_CONFIG_BYTES { + anyhow::bail!("MCP config {} exceeds the 1 MiB limit", path.display()); + } Ok(Some(contents)) } diff --git a/crates/tui/src/plugins/marketplace/store.rs b/crates/tui/src/plugins/marketplace/store.rs index bf36c278fc..059e44006c 100644 --- a/crates/tui/src/plugins/marketplace/store.rs +++ b/crates/tui/src/plugins/marketplace/store.rs @@ -114,13 +114,23 @@ impl MarketplaceStore { Ok(state) } + /// Maximum bytes read from the marketplace state file. + const MAX_STATE_BYTES: u64 = 1024 * 1024; + fn load_unlocked(&self) -> Result { - let Some(mut file) = open_existing_regular_file(&self.path, false)? else { + let Some(file) = open_existing_regular_file(&self.path, false)? else { return Ok(MarketplaceState::default()); }; let mut raw = String::new(); - file.read_to_string(&mut raw) + file.take(Self::MAX_STATE_BYTES + 1) + .read_to_string(&mut raw) .map_err(|e| format!("failed to read {}: {e}", self.path.display()))?; + if raw.len() as u64 > Self::MAX_STATE_BYTES { + return Err(format!( + "marketplace state {} exceeds the 1 MiB limit", + self.path.display() + )); + } let state: MarketplaceState = serde_json::from_str(&raw) .map_err(|e| format!("failed to parse {}: {e}", self.path.display()))?; if state.schema_version != MARKETPLACE_SCHEMA_VERSION { diff --git a/crates/tui/src/plugins/registry.rs b/crates/tui/src/plugins/registry.rs index 68bf02a4d3..e578df820b 100644 --- a/crates/tui/src/plugins/registry.rs +++ b/crates/tui/src/plugins/registry.rs @@ -524,13 +524,23 @@ fn load_state(path: &Path) -> Result { load_state_unlocked(path) } +/// Maximum bytes read from the plugin state file. +const MAX_PLUGIN_STATE_BYTES: u64 = 1024 * 1024; + fn load_state_unlocked(path: &Path) -> Result { - let Some(mut file) = open_existing_regular_file(path, false)? else { + let Some(file) = open_existing_regular_file(path, false)? else { return Ok(PluginStateFile::default()); }; let mut raw = String::new(); - file.read_to_string(&mut raw) + file.take(MAX_PLUGIN_STATE_BYTES + 1) + .read_to_string(&mut raw) .map_err(|e| format!("failed to read {}: {e}", path.display()))?; + if raw.len() as u64 > MAX_PLUGIN_STATE_BYTES { + return Err(format!( + "plugin state {} exceeds the 1 MiB limit", + path.display() + )); + } let state: PluginStateFile = serde_json::from_str(&raw) .map_err(|e| format!("failed to parse {}: {e}", path.display()))?; if state.schema_version != STATE_SCHEMA_VERSION { diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 6226e0c02c..57a24cb439 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -8774,6 +8774,10 @@ fn reject_root_relative_symlinks(root: &Path, path: &Path) -> Result<()> { Ok(()) } +/// Maximum bytes read from a sub-agent state or transcript artifact file. +/// Generous for long session histories; anything larger is corrupt. +const MAX_SUBAGENT_STATE_BYTES: u64 = 16 * 1024 * 1024; + fn read_subagent_state_file(state_root: &Path, path: &Path) -> Result { let state_root = normalize_subagent_workspace(state_root); reject_root_relative_symlinks(&state_root, path)?; @@ -8786,9 +8790,16 @@ fn read_subagent_state_file(state_root: &Path, path: &Path) -> Result { )); } - let mut file = open_subagent_state_file(path)?; + let file = open_subagent_state_file(path)?; let mut raw = String::new(); - file.read_to_string(&mut raw)?; + file.take(MAX_SUBAGENT_STATE_BYTES + 1) + .read_to_string(&mut raw)?; + if raw.len() as u64 > MAX_SUBAGENT_STATE_BYTES { + return Err(anyhow!( + "sub-agent state file {} exceeds the 16 MiB limit", + path.display() + )); + } Ok(raw) }