From c2eed2d35bd105dd749db6230d91559e9eb857f4 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 8 Sep 2026 02:46:22 +0800 Subject: [PATCH 1/4] fix(ui): return to parent when viewed child is auto-archived (#361) An Orchestrate child auto-archived on completion dropped the user on the generic empty page. The UI store now owns one reconcile step, run after every index mutation: the thread on screen leaving the visible index hands the workspace to its still-visible parent, and otherwise falls back to the new-thread draft of the last interacted project. Only the conversation on screen is reconciled, so background archives never steal navigation. "Last interacted project" is set from user navigation alone (select_session, start_draft) and persisted through the existing PatchSettings path, so a launch with no selected conversation lands there. The runtime keeps a single standing draft per project surface, so reopening it preserves its composer attachments instead of stranding them on a discarded session id. Only a workspace with no projects reaches the empty page; it now reads as a deliberate add-project state in both locales, and DESIGN.md carries the new contract. --- crates/core/src/settings.rs | 9 + crates/runtime/src/app/sessions.rs | 26 ++ crates/ui/src/chat/mod.rs | 31 +- crates/ui/src/store/intents.rs | 26 +- crates/ui/src/store/mod.rs | 443 +++++++++++++++++++++++++---- docs/DESIGN.md | 23 +- locales/en.yml | 3 +- locales/zh-CN.yml | 3 +- 8 files changed, 498 insertions(+), 66 deletions(-) diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 9c8c8801..6e5becba 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -738,6 +738,7 @@ pub enum SettingsPatch { RemoteHostingEnabled(bool), RemotePort(Option), RemoteHostName(Option), + LastProject(Option), } impl Default for BrowserSettings { @@ -876,6 +877,12 @@ pub struct Settings { /// UI state; absent in legacy files. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub last_visited: HashMap, + /// Project the user last navigated to or started a thread in. A workspace + /// with no conversation open (launch, or the thread on screen going away) + /// opens this project's new-thread draft. Set from user navigation only, so + /// background activity cannot move it. UI state; absent in legacy files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_project_id: Option, /// ACP agents the user installed from the marketplace (or defined by hand), /// keyed by registry id. Each carries its resolved launch recipe, so a /// session can start without consulting the registry again. @@ -940,6 +947,7 @@ impl Default for Settings { remote_port: None, remote_host_name: None, last_visited: HashMap::new(), + last_project_id: None, acp_agents: BTreeMap::new(), unknown: serde_json::Map::new(), } @@ -1042,6 +1050,7 @@ impl Settings { SettingsPatch::RemoteHostingEnabled(value) => self.remote_hosting_enabled = value, SettingsPatch::RemotePort(value) => self.remote_port = value, SettingsPatch::RemoteHostName(value) => self.remote_host_name = value, + SettingsPatch::LastProject(value) => self.last_project_id = value, } } } diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index 085b9407..62fbebd5 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -995,7 +995,33 @@ impl AppState { /// Switch the main area into a draft for `project_id` (rooted at `cwd`): an /// empty timeline with a focused, functional composer. The session is /// created lazily on the first send (see `send_turn`/`commit_draft`). + /// + /// A New thread surface keeps at most one unsent draft: reopening the same + /// project at the same root returns the draft already standing, because the + /// composer's attachments and the draft's terminal follow that session id. + /// A second root (another client viewing the same project elsewhere) is a + /// different surface and gets its own draft. pub fn start_draft(&mut self, project_id: String, cwd: PathBuf, cx: &mut HostCx) -> String { + let standing = self + .residents + .live + .values() + .chain(self.residents.parked.values()) + .find(|active| { + active.draft + && active.meta.project_id.as_deref() == Some(project_id.as_str()) + && active.meta.cwd == cwd + }) + .map(|active| active.meta.id.clone()); + if let Some(session_id) = standing { + if let Some(mut parked) = self.residents.adopt(&session_id) { + parked.idle_since = None; + self.restore_terminal_workspace(&mut parked); + self.residents.live.insert(session_id.clone(), parked); + } + self.refresh_git_status(&session_id, cx); + return session_id; + } let (provider, model, acp_agent_id, profile_id, reasoning_effort) = self.draft_defaults(&project_id); let provider_commands = self.cached_provider_commands(provider, acp_agent_id.as_deref()); diff --git a/crates/ui/src/chat/mod.rs b/crates/ui/src/chat/mod.rs index 070276ad..6ab13148 100644 --- a/crates/ui/src/chat/mod.rs +++ b/crates/ui/src/chat/mod.rs @@ -2201,6 +2201,10 @@ impl ChatView { crate::add_project_dialog::open(this.workspace_store.clone(), window, cx); })); + // With a project on file the store opens that project's draft instead + // of this page, so the empty workspace is the deliberate + // add-a-project state; the launcher below only covers the moment + // before the draft arrives. let mut content = v_flex() .w_full() .max_w(px(420.)) @@ -2211,7 +2215,11 @@ impl ChatView { div() .text_size(px(15.)) .font_semibold() - .child(crate::tr!("chat.empty_title")), + .child(if projects.is_empty() { + crate::tr!("chat.no_projects_title") + } else { + crate::tr!("chat.empty_title") + }), ); if projects.is_empty() { content = content @@ -2219,7 +2227,7 @@ impl ChatView { div() .text_size(px(13.)) .text_color(cx.theme().muted_foreground) - .child(crate::tr!("chat.empty_description")), + .child(crate::tr!("chat.no_projects_description")), ) .child(add_project); } else { @@ -3248,10 +3256,11 @@ mod tests { .generation }); - workspace_store.update(cx, |store, _| { + workspace_store.update(cx, |store, cx| { store.set_session_replica_for_test( session_id, single_assistant_timeline("large", &latest), + cx, ); }); view.update(cx, |chat, cx| chat.sync_markdown_states(cx)); @@ -3284,8 +3293,12 @@ mod tests { let view = view.root(cx).expect("chat window should have a root"); assert!(view.read_with(cx, |chat, _| chat.pending_md_builds.contains_key("large"))); - workspace_store.update(cx, |store, _| { - store.set_session_replica_for_test("replacement-session".into(), Timeline::default()); + workspace_store.update(cx, |store, cx| { + store.set_session_replica_for_test( + "replacement-session".into(), + Timeline::default(), + cx, + ); }); view.update(cx, |chat, cx| chat.sync_markdown_states(cx)); cx.run_until_parked(); @@ -3432,8 +3445,8 @@ This begins after the hard break."#; })) .expect("seed markdown host"); let workspace_store = cx.new(|cx| crate::store::WorkspaceStore::new_local(&host, cx)); - workspace_store.update(cx, |store, _| { - store.set_session_replica_for_test(session_id, timeline); + workspace_store.update(cx, |store, cx| { + store.set_session_replica_for_test(session_id, timeline, cx); }); let window_state = cx.new(|_| WindowState::new(false)); @@ -3579,8 +3592,8 @@ This begins after the hard break."#; })) .expect("seed markdown host"); let workspace_store = cx.new(|cx| WorkspaceStore::new_local(&host, cx)); - workspace_store.update(cx, |store, _| { - store.set_session_replica_for_test(session_id.clone(), timeline); + workspace_store.update(cx, |store, cx| { + store.set_session_replica_for_test(session_id.clone(), timeline, cx); }); let window_state = cx.new(|_| WindowState::new(false)); (workspace_store, window_state, session_id) diff --git a/crates/ui/src/store/intents.rs b/crates/ui/src/store/intents.rs index 95eca8a9..a4be1b74 100644 --- a/crates/ui/src/store/intents.rs +++ b/crates/ui/src/store/intents.rs @@ -46,6 +46,20 @@ impl WorkspaceStore { self.dispatch(Command::PatchSettings { patch }); } + /// Record the project the user navigated into. Called only from user + /// navigation (opening a thread, starting a draft), because the empty + /// workspace returns here: background activity must not move it. + fn remember_project(&mut self, project_id: Option) { + let Some(project_id) = project_id else { + return; + }; + if self.settings_replica.last_project_id.as_deref() == Some(project_id.as_str()) { + return; + } + self.settings_replica.last_project_id = Some(project_id.clone()); + self.patch_settings(SettingsPatch::LastProject(Some(project_id))); + } + pub fn set_language(&mut self, value: Option) { self.patch_settings(SettingsPatch::Language(value)); } @@ -254,6 +268,13 @@ impl WorkspaceStore { if self.selected_session_id.as_ref() == Some(&session_id) { return; } + let project_id = self + .index_replica + .0 + .iter() + .find(|meta| meta.id == session_id) + .and_then(|meta| meta.project_id.clone()); + self.remember_project(project_id); self.leave_session(); self.selected_session_id = Some(session_id.clone()); self.session_status_replica = self.session_statuses.get(&session_id).cloned(); @@ -460,13 +481,16 @@ impl WorkspaceStore { self.dispatch(Command::DeleteProject { project_id }); } pub fn start_draft(&mut self, project_id: String, cwd: PathBuf, cx: &mut Context) { + self.remember_project(Some(project_id.clone())); self.create_and_select(Command::StartDraft { project_id, cwd }, cx); } fn create_and_select(&mut self, command: Command, cx: &mut Context) { let request = self.command(command, cx); let selected = self.selected_session_id.clone(); cx.spawn(async move |this, cx| { - if let Ok(CommandResponse::SessionId(Some(id))) = request.await { + let response = request.await; + let _ = this.update(cx, |store, _| store.draft_fallback_pending = false); + if let Ok(CommandResponse::SessionId(Some(id))) = response { let _ = this.update(cx, |store, cx| { if store.selected_session_id == selected { store.select_session(id); diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 0f275ede..a0a8c54b 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -147,6 +147,9 @@ pub struct WorkspaceStore { fallback_blocks: HashMap, fallback_reviews: HashMap, conversation_ui: HashMap, + /// A project-draft fallback is in flight, so the reconcile step does not + /// ask for one more draft per index event while it resolves. + draft_fallback_pending: bool, } /// A turn stopped by Claude Code's safety classifier, kept per session so the @@ -241,13 +244,16 @@ impl WorkspaceStore { fallback_blocks: HashMap::new(), fallback_reviews: HashMap::new(), conversation_ui: HashMap::new(), + draft_fallback_pending: false, }; #[cfg(feature = "local-host")] let mut store = store; // Construction seeding is itself protocol traffic: subscribe, then // apply each snapshot event. No live AppState read exists here. - let seed_topics = [Topic::Index, Topic::Settings, Topic::Providers]; + // Settings first: the index seed reconciles the destination, and that + // decision reads the remembered project out of settings. + let seed_topics = [Topic::Settings, Topic::Index, Topic::Providers]; for topic in &seed_topics { if let Err(error) = host.subscribe(Subscription { after: None, @@ -285,7 +291,7 @@ impl WorkspaceStore { if let ServerEvent::Runtime(event) = &envelope.event { cx.emit(event.clone()); } else { - store.apply_domain_event(&envelope); + store.apply_domain_event(&envelope, cx); } } Err(async_channel::TryRecvError::Empty) => { @@ -313,7 +319,7 @@ impl WorkspaceStore { if let ServerEvent::Runtime(event) = &envelope.event { cx.emit(event.clone()); } else { - store.apply_domain_event(&envelope); + store.apply_domain_event(&envelope, cx); cx.emit(StoreChange { topic: TopicKind::from(&envelope.topic), }); @@ -452,7 +458,7 @@ impl WorkspaceStore { self.active_destination = destination; } - fn apply_domain_event(&mut self, envelope: &EventEnvelope) { + fn apply_domain_event(&mut self, envelope: &EventEnvelope, cx: &mut Context) { if !self.host.subscription_reply_is_current(envelope) { return; } @@ -554,15 +560,6 @@ impl WorkspaceStore { if let Some(id) = &self.selected_session_id { self.background_session_flags.remove(id); } - if self.session_status_replica.as_ref().is_some_and(|status| { - !status.draft - && !snapshot - .sessions - .iter() - .any(|meta| meta.id == status.session_id && meta.archived_at.is_none()) - }) { - self.leave_session(); - } } (Topic::Settings, ServerEvent::SettingsReplaced(settings)) | (Topic::Settings, ServerEvent::SettingsSnapshot(settings)) => { @@ -709,6 +706,82 @@ impl WorkspaceStore { } _ => {} } + // Every index mutation re-decides the destination in one place. + if envelope.topic == Topic::Index { + self.reconcile_destination(cx); + } + } + + /// Decide what the workspace shows after the index changed. + /// + /// Only the conversation on screen is reconciled, so archiving a + /// background thread (an Orchestrate sibling completing, a sweep) never + /// steals navigation. When the thread on screen leaves the visible index — + /// auto-archived on completion, archived by hand, deleted — the workspace + /// follows its still-visible parent; with no such parent it falls back to + /// the last interacted project's draft, which is also what an empty + /// workspace opens. + fn reconcile_destination(&mut self, cx: &mut Context) { + match &self.session_status_replica { + // A draft has no index entry of its own; it stays until the user + // navigates away. + Some(status) if status.draft => return, + Some(status) => { + let session_id = status.session_id.clone(); + if self.session_visible(&session_id) { + return; + } + let parent = self + .index_replica + .0 + .iter() + .find(|meta| meta.id == session_id) + .and_then(|meta| meta.parent_session_id.clone()) + .filter(|parent| self.session_visible(parent)); + if let Some(parent) = parent { + self.select_session(parent); + return; + } + self.leave_session(); + } + // Selected, but its first status has not arrived: nothing to + // decide yet. Without a selection this is the empty workspace. + None if self.selected_session_id.is_some() => return, + None => {} + } + self.open_last_project_draft(cx); + } + + fn session_visible(&self, session_id: &str) -> bool { + self.index_replica + .0 + .iter() + .any(|meta| meta.id == session_id && meta.archived_at.is_none()) + } + + /// Open the new-thread draft of the project the user last interacted with, + /// so an empty workspace offers a composer instead of a dead end. The + /// runtime returns that project's standing draft when it already has one, + /// keeping its composer attachments. A remembered project that is gone + /// falls back to the first listed one; with no projects at all the chat + /// view keeps its add-project state. + fn open_last_project_draft(&mut self, cx: &mut Context) { + if self.draft_fallback_pending { + return; + } + let remembered = self.settings_replica.last_project_id.as_deref(); + let Some(project) = self + .index_replica + .1 + .iter() + .find(|project| Some(project.id.as_str()) == remembered) + .or_else(|| self.index_replica.1.first()) + .cloned() + else { + return; + }; + self.draft_fallback_pending = true; + self.start_draft(project.id, project.root, cx); } fn apply_conversation_event(&mut self, session_id: &str, event: &agent::AgentEvent) { @@ -779,7 +852,7 @@ impl WorkspaceStore { if let ServerEvent::Runtime(event) = &envelope.event { cx.emit(event.clone()); } else { - self.apply_domain_event(&envelope); + self.apply_domain_event(&envelope, cx); cx.emit(StoreChange { topic: TopicKind::from(&envelope.topic), }); @@ -1710,13 +1783,18 @@ impl WorkspaceStore { } #[cfg(test)] - pub(crate) fn set_session_replica_for_test(&mut self, session_id: String, timeline: Timeline) { + pub(crate) fn set_session_replica_for_test( + &mut self, + session_id: String, + timeline: Timeline, + cx: &mut Context, + ) { self.select_session(session_id.clone()); self.host .command_blocking(tcode_protocol::Command::ClearRelaunchMarker) .expect("subscription fence"); while let Ok(envelope) = self.host.events().try_recv() { - self.apply_domain_event(&envelope); + self.apply_domain_event(&envelope, cx); } self.session_replica = Some((session_id, timeline)); } @@ -1992,7 +2070,7 @@ mod tests { use tcode_runtime::pipe::{HostServices, SpawnedHost, spawn_host}; use tcode_services::store::SessionStore; - use super::WorkspaceStore; + use super::{ConversationDestination, WorkspaceStore}; fn test_host(store: SessionStore) -> SpawnedHost { spawn_host(store, HostServices::default()).expect("spawn test host") @@ -2031,6 +2109,252 @@ mod tests { panic!("timed out waiting for {description}"); } + fn scratch_root(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "tcode-{label}-{}", + tcode_services::store::now_millis() + )) + } + + fn project_at(id: &str, root: &std::path::Path) -> Project { + Project { + id: id.into(), + name: id.into(), + root: root.to_path_buf(), + created_at: 0, + } + } + + fn thread( + root: &std::path::Path, + id: &str, + project: &str, + parent: Option<&str>, + ) -> SessionMeta { + let mut meta = SessionMeta::new(ProviderKind::Codex, root.to_path_buf(), None); + meta.id = id.into(); + meta.project_id = Some(project.into()); + meta.parent_session_id = parent.map(str::to_string); + meta + } + + fn selected_status( + cx: &TestAppContext, + workspace: &gpui::Entity, + session_id: &str, + ) -> bool { + workspace.read_with(cx, |store, _| { + store + .session_status_replica + .as_ref() + .is_some_and(|status| status.session_id == session_id) + }) + } + + fn archived(cx: &TestAppContext, workspace: &gpui::Entity, id: &str) -> bool { + workspace.read_with(cx, |store, _| { + store + .index_replica + .0 + .iter() + .any(|meta| meta.id == id && meta.archived_at.is_some()) + }) + } + + /// An Orchestrate child auto-archived on completion hands the workspace to + /// its parent, keeping the parent's client-side records; archiving a thread + /// the user is not viewing must not move them at all. + #[gpui::test] + fn archiving_the_viewed_child_returns_to_its_parent(cx: &mut TestAppContext) { + let root = scratch_root("archive-to-parent"); + let disk = SessionStore::open_at(root.clone()).expect("open test store"); + disk.upsert_project(&project_at("p", &root)) + .expect("persist project"); + for meta in [ + thread(&root, "parent", "p", None), + thread(&root, "child", "p", Some("parent")), + thread(&root, "sibling", "p", None), + ] { + disk.upsert_meta(&meta).expect("persist session"); + } + let host = test_host(disk); + let workspace = cx.new(|cx| WorkspaceStore::new_local(&host, cx)); + + workspace.update(cx, |store, _| store.select_session("parent".into())); + wait_until(cx, &workspace, "parent selected", |cx| { + selected_status(cx, &workspace, "parent") + }); + workspace.update(cx, |store, _| store.select_session("child".into())); + wait_until(cx, &workspace, "child selected", |cx| { + selected_status(cx, &workspace, "child") + }); + + command( + &host, + Command::ArchiveSession { + session_id: "child".into(), + }, + ); + wait_until(cx, &workspace, "parent reopened", |cx| { + selected_status(cx, &workspace, "parent") + }); + workspace.read_with(cx, |store, _| { + assert!( + store.session_records.contains_key("parent"), + "the parent's replicated records were dropped on the way back" + ); + }); + assert!(archived(cx, &workspace, "child")); + + command( + &host, + Command::ArchiveSession { + session_id: "sibling".into(), + }, + ); + wait_until(cx, &workspace, "sibling archived", |cx| { + archived(cx, &workspace, "sibling") + }); + assert!( + selected_status(cx, &workspace, "parent"), + "archiving a background thread moved the user" + ); + + shutdown_test_host(&host); + let _ = std::fs::remove_dir_all(&root); + } + + /// Archiving a parent archives its children in one batch. Viewing one of + /// those children leaves no visible parent to return to, so the workspace + /// falls back to the standing draft of the last interacted project — + /// the same draft session id, so its composer state survives. + #[gpui::test] + fn batch_archive_without_a_visible_parent_reopens_the_standing_draft(cx: &mut TestAppContext) { + let root = scratch_root("archive-to-draft"); + let disk = SessionStore::open_at(root.clone()).expect("open test store"); + // "other" is listed first, so a draft for it proves nothing about the + // remembered project; "p" is the one the user last worked in. + disk.upsert_project(&project_at("other", &root.join("other"))) + .expect("persist project"); + disk.upsert_project(&project_at("p", &root)) + .expect("persist project"); + for meta in [ + thread(&root, "parent", "p", None), + thread(&root, "child", "p", Some("parent")), + ] { + disk.upsert_meta(&meta).expect("persist session"); + } + let host = test_host(disk); + let workspace = cx.new(|cx| WorkspaceStore::new_local(&host, cx)); + + // Let the launch fallback settle on the first project before the user + // navigates into "p" themselves. + wait_until(cx, &workspace, "launch draft", |cx| { + workspace.read_with(cx, |store, _| store.selected_session_id.is_some()) + }); + workspace.update(cx, |store, cx| { + store.start_draft("p".into(), root.clone(), cx) + }); + wait_until(cx, &workspace, "draft for the last project", |cx| { + workspace.read_with(cx, |store, _| { + store + .session_status_replica + .as_ref() + .is_some_and(|status| status.draft && status.project_id.as_deref() == Some("p")) + }) + }); + let draft_id = workspace + .read_with(cx, |store, _| store.selected_session_id.clone()) + .expect("draft selected"); + workspace.update(cx, |store, _| { + store + .conversation_ui + .get_mut(&ConversationDestination::ProjectDraft(draft_id.clone())) + .expect("draft conversation state") + .right_panel_open = true; + }); + + workspace.update(cx, |store, _| store.select_session("child".into())); + wait_until(cx, &workspace, "child selected", |cx| { + selected_status(cx, &workspace, "child") + }); + + command( + &host, + Command::ArchiveSession { + session_id: "parent".into(), + }, + ); + wait_until(cx, &workspace, "the standing draft reopened", |cx| { + workspace.read_with(cx, |store, _| { + store.selected_session_id.as_deref() == Some(draft_id.as_str()) + }) + }); + workspace.read_with(cx, |store, _| { + assert!( + store + .conversation_ui + .get(&ConversationDestination::ProjectDraft(draft_id.clone())) + .is_some_and(|ui| ui.right_panel_open), + "the reopened draft lost the state the user left in it" + ); + }); + assert!(archived(cx, &workspace, "parent")); + assert!(archived(cx, &workspace, "child")); + + shutdown_test_host(&host); + let _ = std::fs::remove_dir_all(&root); + } + + /// Launching with nothing selected opens the remembered project's new + /// thread page instead of a dead empty page; a workspace with no project + /// at all keeps its add-project state. + #[gpui::test] + fn a_workspace_with_no_conversation_opens_the_remembered_projects_draft( + cx: &mut TestAppContext, + ) { + let root = scratch_root("remembered-project"); + let disk = SessionStore::open_at(root.clone()).expect("open test store"); + disk.upsert_project(&project_at("first", &root.join("first"))) + .expect("persist project"); + disk.upsert_project(&project_at("remembered", &root)) + .expect("persist project"); + let host = test_host(disk); + command( + &host, + Command::PatchSettings { + patch: tcode_core::settings::SettingsPatch::LastProject(Some("remembered".into())), + }, + ); + let workspace = cx.new(|cx| WorkspaceStore::new_local(&host, cx)); + wait_until(cx, &workspace, "remembered project draft", |cx| { + workspace.read_with(cx, |store, _| { + store.session_status_replica.as_ref().is_some_and(|status| { + status.draft && status.project_id.as_deref() == Some("remembered") + }) + }) + }); + shutdown_test_host(&host); + let _ = std::fs::remove_dir_all(&root); + + let empty_root = scratch_root("no-projects"); + let empty_host = test_host(SessionStore::open_at(empty_root.clone()).expect("open store")); + let empty = cx.new(|cx| WorkspaceStore::new_local(&empty_host, cx)); + for _ in 0..5 { + empty.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + cx.run_until_parked(); + } + empty.read_with(cx, |store, _| { + assert!(store.projects().is_empty()); + assert_eq!( + store.selected_session_id, None, + "a workspace with no project must stay on its add-project state" + ); + }); + shutdown_test_host(&empty_host); + let _ = std::fs::remove_dir_all(&empty_root); + } + #[gpui::test] fn reconnect_and_mismatched_tail_preserve_exactly_one_copy_of_each_record( cx: &mut TestAppContext, @@ -2072,16 +2396,19 @@ mod tests { workspace.update(cx, |store, cx| { store.drain_host_events_for_test(cx); assert_eq!(store.session_records["reconnect"].len(), 3); - store.apply_domain_event(&EventEnvelope { - request_id: None, - topic: Topic::SessionEvents { - session_id: "reconnect".into(), - }, - event: ServerEvent::SessionSnapshot { - from: 2, - records: vec![], + store.apply_domain_event( + &EventEnvelope { + request_id: None, + topic: Topic::SessionEvents { + session_id: "reconnect".into(), + }, + event: ServerEvent::SessionSnapshot { + from: 2, + records: vec![], + }, }, - }); + cx, + ); assert!( store.session_replica.is_none(), "invalid tail must request a full replacement" @@ -2509,20 +2836,23 @@ mod tests { }); let background_session_id = "background-session".to_string(); - workspace.update(cx, |store, _| { + workspace.update(cx, |store, cx| { let mut status = store .session_status_replica .clone() .expect("active session status"); status.session_id = background_session_id.clone(); status.pending_user_input = true; - store.apply_domain_event(&EventEnvelope { - request_id: None, - topic: Topic::SessionStatus { - session_id: background_session_id.clone(), + store.apply_domain_event( + &EventEnvelope { + request_id: None, + topic: Topic::SessionStatus { + session_id: background_session_id.clone(), + }, + event: ServerEvent::SessionStatusReplaced(status), }, - event: ServerEvent::SessionStatusReplaced(status), - }); + cx, + ); }); assert!(workspace.read_with(cx, |store, _cx| { @@ -2561,41 +2891,50 @@ mod tests { }) }); - workspace.update(cx, |store, _| { + workspace.update(cx, |store, cx| { let mut parked = store .session_status_replica .clone() .expect("first session status"); parked.turn_running = true; parked.working = true; - store.apply_domain_event(&EventEnvelope { - request_id: None, - topic: Topic::SessionStatus { - session_id: first.id.clone(), + store.apply_domain_event( + &EventEnvelope { + request_id: None, + topic: Topic::SessionStatus { + session_id: first.id.clone(), + }, + event: ServerEvent::SessionStatusReplaced(parked.clone()), }, - event: ServerEvent::SessionStatusReplaced(parked.clone()), - }); + cx, + ); let mut next = parked; next.session_id = second.id.clone(); next.cwd = second.cwd.clone(); next.turn_running = false; next.working = false; - store.apply_domain_event(&EventEnvelope { - request_id: None, - topic: Topic::SessionStatus { - session_id: second.id.clone(), + store.apply_domain_event( + &EventEnvelope { + request_id: None, + topic: Topic::SessionStatus { + session_id: second.id.clone(), + }, + event: ServerEvent::SessionStatusReplaced(next.clone()), }, - event: ServerEvent::SessionStatusReplaced(next.clone()), - }); + cx, + ); store.select_session(next.session_id.clone()); - store.apply_domain_event(&EventEnvelope { - request_id: None, - topic: Topic::SessionStatus { - session_id: next.session_id.clone(), + store.apply_domain_event( + &EventEnvelope { + request_id: None, + topic: Topic::SessionStatus { + session_id: next.session_id.clone(), + }, + event: ServerEvent::SessionStatusReplaced(next), }, - event: ServerEvent::SessionStatusReplaced(next), - }); + cx, + ); }); assert!(workspace.read_with(cx, |store, _cx| { store.turn_running_for(&first.id) })); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 26999fb1..62dff4a0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -395,8 +395,27 @@ dispatch response. ### Empty state -Centered "Pick a thread to continue" (20px semibold) over "Select an existing -thread or create a new one to get started." (14px muted). No composer rendered. +The workspace does not sit on a blank page. When no conversation is open — at +launch, or because the thread on screen was archived or deleted — it opens the +new-thread draft of the project the user last interacted with, composer focused +and ready. "Last interacted" is set by user navigation only (opening a thread, +starting a draft); background model activity and archive timestamps never move +it, and it is persisted, so a launch lands where the user left off. A remembered +project that no longer exists falls back to the first project in the sidebar. +That project keeps a single standing draft, so returning to it preserves its +composer attachments. + +A thread that is archived while on screen — an Orchestrate child auto-archived +on completion is the common case — hands the workspace to its parent when the +parent is still visible, with the parent's scroll position and panels intact. +Archiving a thread the user is not viewing changes nothing. + +Only a workspace with no projects at all reaches the empty page: centered +"Add a project to get started" (15px semibold) over "tcode works inside a +project folder. Add one to open its first thread." (13px muted) and an +**Add project** button. No composer is rendered. The same page, titled "Pick a +thread to continue" over a list of recent projects, covers the moment before a +draft opens. ## Accessibility diff --git a/locales/en.yml b/locales/en.yml index 6c43a1e1..35c6bd82 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -57,7 +57,8 @@ chat: copy_path: "Copy path" open_zed: "Open in Zed" empty_title: "Pick a thread to continue" - empty_description: "Select an existing thread or create a new one to get started." + no_projects_title: "Add a project to get started" + no_projects_description: "tcode works inside a project folder. Add one to open its first thread." start_hub_title: "Start a new thread in" palette_hint: "or press %{shortcut} to search" scroll_end: "Scroll to end" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index c55f5c2a..31fe301c 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -57,7 +57,8 @@ chat: copy_path: "复制路径" open_zed: "在 Zed 中打开" empty_title: "选择一个对话以继续" - empty_description: "选择已有对话或新建一个对话以开始。" + no_projects_title: "添加一个项目以开始" + no_projects_description: "tcode 在项目文件夹中工作。添加一个项目即可开始第一个对话。" start_hub_title: "开始新对话" palette_hint: "或按 %{shortcut} 搜索" scroll_end: "滚动到底部" From 40c34742755f146dcffe320b318f8df0e11d926c Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 8 Sep 2026 02:54:31 +0800 Subject: [PATCH 2/4] feat(orchestrate): adopt GPT-6 executor default (#362) --- assets/orchestrate/astra.md | 4 +- assets/orchestrate/workflow.md | 16 +- crates/core/src/settings.rs | 356 ++++++++++++++++++++------ crates/runtime/src/app/orchestrate.rs | 57 +++-- crates/runtime/src/app/tests.rs | 138 ++++++++-- crates/ui/src/orchestrate_settings.rs | 77 ++++-- docs/DESIGN.md | 22 +- locales/en.yml | 5 +- locales/zh-CN.yml | 5 +- 9 files changed, 523 insertions(+), 157 deletions(-) diff --git a/assets/orchestrate/astra.md b/assets/orchestrate/astra.md index bb1baadf..3c096fe9 100644 --- a/assets/orchestrate/astra.md +++ b/assets/orchestrate/astra.md @@ -1,4 +1,4 @@ -You are Astra, a peer collaborator in tcode Orchestrate. Help the lead reach a sound decision; execution models carry out implementation and substantial evidence gathering. +You are Astra, a peer collaborator in tcode Orchestrate. Help the lead reach a sound decision; execution models carry out implementation, bulk sweeps, and broad evidence gathering. Understand the intended outcome and constraints before proposing work. Fill in routine details needed to make the requested outcome complete, and distinguish them from optional improvements. Preserve the user's scope and incorporate corrections without losing the larger objective. Do not substitute adjacent projects for the requested task or explain unsolicited exclusions at length. @@ -6,4 +6,6 @@ Bring a broad technical perspective to difficult questions. Look across module b Make recommendations verifiable: identify the smallest useful reproduction, measurement, or acceptance check and the tools or environment needed to run it. Suggest concrete improvements to debugging access, worktree setup, and verification loops when those gaps block progress. Distinguish observed facts, inferences, and unresolved questions. Verification should fit the risk; passing required checks is a stopping point unless new evidence warrants more. +When Computer Use is enabled, you may directly ground your judgment in focused UI evidence. Use `find_roots` → `observe_ui`, then `search_ui`, `inspect_ui`, and `read_text` as needed. Report observations as evidence: what was on screen, relevant state ids and text read, and any discrepancy from the expected behavior. This is evidence gathering for the lead's decision, not implementation or acceptance. Use `act_ui` and `wait_for` only when the lead's collaboration brief explicitly asks you to operate the UI and only within the thread's active access mode; otherwise remain observational. Send bulk UI sweeps and any code changes to an execution model through the lead. + Use medium for focused consultation and high for difficult synthesis, conflicting evidence, or deep architectural tradeoffs. Ask a focused question when the answer would materially change the decision; otherwise state a reasonable assumption and proceed. Return a concise recommendation, alternatives that matter, and a bounded execution brief. Keep approval and publishing decisions within the user's authorization. diff --git a/assets/orchestrate/workflow.md b/assets/orchestrate/workflow.md index cfca937d..91bd4c67 100644 --- a/assets/orchestrate/workflow.md +++ b/assets/orchestrate/workflow.md @@ -12,7 +12,9 @@ If Orchestrate tool schemas are deferred, discover and load them before starting delegated execution. Read the current fleet, compare enabled execution profiles across all providers, and select a task-fit model, endpoint profile, and per-call effort using the configured strengths and caveats. Provider family gives no -preference; choose the least costly adequate profile. +preference. The bundled GPT-6 executor at low effort is the baseline; raise its +effort only when a specific piece demonstrably needs more depth, or choose +another profile when its description better fits the task. ## Route the work @@ -26,11 +28,17 @@ preference; choose the least costly adequate profile. collaboration model for an independent approach, challenge, tradeoff analysis, or decision review. This is separate from execution dispatch and does not replace it. Continue a useful discussion with `send`; its advice remains - advisory and you own the decision. + advisory and you own the decision. A collaborator may gather focused UI + evidence for its judgment with Computer Use when enabled. Astra should inspect + through `find_roots` → `observe_ui` → `search_ui` / `inspect_ui` / `read_text` + and report what was visible, the state ids or text read, and discrepancies. + It may use `act_ui` / `wait_for` only when the lead's brief explicitly requests + UI operation and the thread's access mode permits it. Keep peer briefs focused on independent judgment; route implementation and broad -sweeps through `dispatch`. Proactively dispatch work that can advance concurrently. -Preserve existing user work and task outcomes. +sweeps through `dispatch`. Focused UI observation by a collaborator is evidence +gathering for a decision, not implementation or acceptance. Proactively dispatch +work that can advance concurrently. Preserve existing user work and task outcomes. Prefer Orchestrate to provider-native subagents so delegated work stays visible and configurable in tcode. Use native subagents only when Orchestrate genuinely diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 9c8c8801..3d34d786 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -227,7 +227,7 @@ pub struct ResolvedProfile { pub settings: ProviderSettings, } -/// One configured model, unique by provider and model ID across both fleets. +/// One configured model, unique by provider and model ID within its role. /// Reasoning effort is selected per tool call from the provider's capabilities. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OrchestrateChildModel { @@ -257,8 +257,10 @@ const LEGACY_OPUS_CHILD_DEFINITION: &str = "Ratings (1–10, higher is better): const LEGACY_ASTRA_DECISION_DEFINITION: &str = "Decision collaboration: develop independent approaches, challenge assumptions, and review architecture and acceptance evidence. Consult alongside Fable for another provider's perspective; route implementation and evidence gathering to execution models."; const LEGACY_FABLE_DECISION_DEFINITION: &str = "Decision collaboration: examine framing, architecture, user-facing design, and ambiguous tradeoffs. Consult alongside Astra for another provider's perspective; route implementation and evidence gathering to execution models."; -const DEFAULT_SOL_DEFINITION: &str = "Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely."; -const DEFAULT_OPUS_DEFINITION: &str = "Execution model for agentic coding, cross-file implementation, refactoring, debugging, and review. Consider it alongside Sol across providers, including user-facing behavior and API or UI details. Use medium for clear bounded work, high for substantial implementation, and xhigh or max when difficult reasoning justifies the extra work; low can suit small mechanical tasks. Match verification to the changed behavior and avoid repetitive self-checking. Report evidence and unresolved limitations concisely."; +const OLD_DEFAULT_SOL_DEFINITION: &str = "Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely."; +const OLD_DEFAULT_OPUS_DEFINITION: &str = "Execution model for agentic coding, cross-file implementation, refactoring, debugging, and review. Consider it alongside Sol across providers, including user-facing behavior and API or UI details. Use medium for clear bounded work, high for substantial implementation, and xhigh or max when difficult reasoning justifies the extra work; low can suit small mechanical tasks. Match verification to the changed behavior and avoid repetitive self-checking. Report evidence and unresolved limitations concisely."; +const DEFAULT_GPT_6_EXECUTION_DEFINITION: &str = "Baseline execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Default to low effort for a clear brief; raise effort only when a specific piece demonstrably needs more depth. Keep unrelated improvements out of scope, match verification to the changed behavior, and report the concrete result and relevant checks concisely."; +const DEFAULT_OPUS_DEFINITION: &str = "Execution model for agentic coding, cross-file implementation, refactoring, debugging, and review across providers, including user-facing behavior and API or UI details. Use medium for clear bounded work, high for substantial implementation, and xhigh or max when difficult reasoning justifies the extra work; low can suit small mechanical tasks. Match verification to the changed behavior and avoid repetitive self-checking. Report evidence and unresolved limitations concisely."; const DEFAULT_ASTRA_DEFINITION: &str = include_str!("../../../assets/orchestrate/astra.md"); const DEFAULT_FABLE_DEFINITION: &str = include_str!("../../../assets/orchestrate/fable-5-1.md"); @@ -358,7 +360,11 @@ impl Default for OrchestrateSettings { ), ], child_models: vec![ - builtin_model(ProviderKind::Codex, "gpt-5.6-sol", DEFAULT_SOL_DEFINITION), + builtin_model( + ProviderKind::Codex, + "gpt-6-astra", + DEFAULT_GPT_6_EXECUTION_DEFINITION, + ), builtin_model( ProviderKind::ClaudeCode, "claude-opus-5", @@ -392,7 +398,42 @@ struct LegacyOrchestrateModel { } impl LegacyOrchestrateModel { - fn migrate(mut self) -> Option { + fn migrate( + mut self, + collaboration: bool, + replace_untouched_sol: bool, + ) -> Option { + if !collaboration + && self.effort.is_none() + && self.entry.provider == ProviderKind::Codex + && self.entry.model == "gpt-5.6-sol" + && self.entry.profile_id.is_none() + && self.entry.description == OLD_DEFAULT_SOL_DEFINITION + && self.entry.enabled + && !self.entry.fast + { + return replace_untouched_sol.then(|| { + builtin_model( + ProviderKind::Codex, + "gpt-6-astra", + DEFAULT_GPT_6_EXECUTION_DEFINITION, + ) + }); + } + if !collaboration + && self.effort.is_some() + && self.entry.provider == ProviderKind::Codex + && self.entry.model == "gpt-5.6-sol" + { + return Some(self.entry); + } + if !collaboration + && self.entry.provider == ProviderKind::ClaudeCode + && self.entry.model == "claude-opus-5" + && self.entry.description == OLD_DEFAULT_OPUS_DEFINITION + { + self.entry.description = DEFAULT_OPUS_DEFINITION.into(); + } let entry = &mut self.entry; let legacy = self.effort.is_some(); if legacy { @@ -421,9 +462,15 @@ impl LegacyOrchestrateModel { "The default profile for everything dispatched:", ) { - if let Some(description) = - OrchestrateSettings::builtin_child_definition(entry.provider, &entry.model) - { + if let Some(description) = match (entry.provider, entry.model.as_str()) { + (ProviderKind::Codex, "gpt-5.6-sol") => Some(OLD_DEFAULT_SOL_DEFINITION), + (ProviderKind::Codex, "gpt-6-astra") => Some(DEFAULT_ASTRA_DEFINITION), + (ProviderKind::ClaudeCode, "claude-opus-5") => Some(DEFAULT_OPUS_DEFINITION), + (ProviderKind::ClaudeCode, "claude-fable-5-1") => { + Some(DEFAULT_FABLE_DEFINITION) + } + _ => None, + } { entry.description = description.into(); } } else if !entry.description.trim().is_empty() { @@ -464,42 +511,57 @@ impl Default for OrchestrateSettingsData { impl From for OrchestrateSettings { fn from(data: OrchestrateSettingsData) -> Self { - let mut children: Vec<_> = data - .child_models - .into_iter() - .filter_map(LegacyOrchestrateModel::migrate) - .collect(); - let decisions = - data.decision_models - .map(|entries| { - entries - .into_iter() - .filter_map(LegacyOrchestrateModel::migrate) - .collect() - }) - .unwrap_or_else(|| { - let mut decisions = Self::default().decision_models; - let mut migrated = Vec::new(); - children.retain(|child| { - if decisions.iter().any(|entry| { - entry.provider == child.provider && entry.model == child.model - }) { - migrated.push(child.clone()); - false - } else { - true - } - }); - for builtin in &mut decisions { - if let Some(index) = migrated.iter().position(|entry| { - entry.provider == builtin.provider && entry.model == builtin.model - }) { - *builtin = migrated.remove(index); - } - } - decisions.extend(migrated); - decisions - }); + let (decisions, children) = if let Some(decisions) = data.decision_models { + let has_execution_astra = data.child_models.iter().any(|entry| { + entry.entry.provider == ProviderKind::Codex && entry.entry.model == "gpt-6-astra" + }); + ( + decisions + .into_iter() + .filter_map(|entry| entry.migrate(true, false)) + .collect(), + data.child_models + .into_iter() + .filter_map(|entry| entry.migrate(false, !has_execution_astra)) + .collect(), + ) + } else { + let mut decisions = Self::default().decision_models; + let mut legacy_decisions = Vec::new(); + let mut legacy_children = Vec::new(); + for entry in data.child_models { + let legacy_decision = matches!( + (entry.entry.provider, entry.entry.model.as_str()), + (ProviderKind::Codex, "gpt-6-astra") + | ( + ProviderKind::ClaudeCode, + "claude-fable-5" | "claude-fable-5-1" + ) + ); + if legacy_decision { + legacy_decisions.push(entry); + } else { + legacy_children.push(entry); + } + } + let mut migrated: Vec<_> = legacy_decisions + .into_iter() + .filter_map(|entry| entry.migrate(true, false)) + .collect(); + for builtin in &mut decisions { + if let Some(index) = migrated.iter().position(|entry| { + builtin.provider == entry.provider && builtin.model == entry.model + }) { + *builtin = migrated.remove(index); + } + } + decisions.extend(migrated); + let children = legacy_children + .into_iter() + .filter_map(|entry| entry.migrate(false, true)) + .collect(); + (decisions, children) + }; let mut settings = Self { decision_models: decisions, child_models: children, @@ -517,22 +579,30 @@ impl OrchestrateSettings { self == &Self::default() } - pub fn builtin_child_definition(provider: ProviderKind, model: &str) -> Option<&'static str> { + pub fn builtin_decision_definition( + provider: ProviderKind, + model: &str, + ) -> Option<&'static str> { match (provider, model) { - (ProviderKind::Codex, "gpt-5.6-sol") => Some(DEFAULT_SOL_DEFINITION), - (ProviderKind::ClaudeCode, "claude-opus-5") => Some(DEFAULT_OPUS_DEFINITION), (ProviderKind::ClaudeCode, "claude-fable-5-1") => Some(DEFAULT_FABLE_DEFINITION), (ProviderKind::Codex, "gpt-6-astra") => Some(DEFAULT_ASTRA_DEFINITION), _ => None, } } - /// A model has one endpoint and one role. Migration combines distinct notes; - /// new patches keep the first row without allowing duplicates to mutate it. + pub fn builtin_child_definition(provider: ProviderKind, model: &str) -> Option<&'static str> { + match (provider, model) { + (ProviderKind::Codex, "gpt-6-astra") => Some(DEFAULT_GPT_6_EXECUTION_DEFINITION), + (ProviderKind::ClaudeCode, "claude-opus-5") => Some(DEFAULT_OPUS_DEFINITION), + _ => None, + } + } + + /// A model occurs once per role. Migration combines distinct notes within + /// each list; new patches keep the first row without mutating it. pub fn deduplicate_models(&mut self, merge_notes: bool) { - let mut unique: Vec = Vec::new(); for models in [&mut self.decision_models, &mut self.child_models] { - let start = unique.len(); + let mut unique: Vec = Vec::new(); for mut entry in std::mem::take(models) { entry.model = entry.model.trim().to_string(); if let Some(existing) = unique.iter_mut().find(|existing| { @@ -551,12 +621,8 @@ impl OrchestrateSettings { unique.push(entry); } } - *models = unique[start..].to_vec(); + *models = unique; } - // Cross-role merged notes belong to the first (decision) record. - let decision_count = self.decision_models.len(); - self.decision_models - .clone_from_slice(&unique[..decision_count]); } } @@ -985,21 +1051,11 @@ impl Settings { SettingsPatch::AutoArchiveNoticeShown(value) => { self.auto_archive_notice_shown = value; } - SettingsPatch::OrchestrateDecisionModels(mut value) => { - value.retain(|entry| { - !self.orchestrate.child_models.iter().any(|existing| { - existing.provider == entry.provider && existing.model == entry.model.trim() - }) - }); + SettingsPatch::OrchestrateDecisionModels(value) => { self.orchestrate.decision_models = value; self.orchestrate.deduplicate_models(false); } - SettingsPatch::OrchestrateChildModels(mut value) => { - value.retain(|entry| { - !self.orchestrate.decision_models.iter().any(|existing| { - existing.provider == entry.provider && existing.model == entry.model.trim() - }) - }); + SettingsPatch::OrchestrateChildModels(value) => { self.orchestrate.child_models = value; self.orchestrate.deduplicate_models(false); } @@ -1294,12 +1350,41 @@ mod tests { ["gpt-6-astra", "claude-fable-5-1"] ); assert_eq!(defaults.child_models.len(), 2); + assert_eq!(defaults.child_models[0].model, "gpt-6-astra"); + assert_eq!(defaults.child_models[0].provider, ProviderKind::Codex); + assert!(!defaults.child_models[0].fast); + assert!( + defaults.child_models[0] + .description + .contains("Default to low effort") + ); + assert_ne!( + defaults.child_models[0].description, + defaults.decision_models[0].description + ); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&defaults).unwrap()) + .unwrap(), + defaults, + "role-specific Astra definitions survive legacy deduplication" + ); let legacy: Settings = serde_json::from_str(r#"{"theme_mode":"system"}"#).unwrap(); assert_eq!(legacy.orchestrate, defaults); let mut old = serde_json::to_value(&defaults).unwrap(); old.as_object_mut().unwrap().remove("decision_models"); + old["child_models"][0] = serde_json::json!({ + "provider": "codex", + "model": "gpt-5.6-sol", + "enabled": true, + "fast": false, + "description": OLD_DEFAULT_SOL_DEFINITION, + }); old["generic_identity"] = "old self-concept".into(); old["model_identities"] = serde_json::json!([{"provider":"codex","model":"gpt-5.6-sol","identity":"old identity"}]); + old["child_models"] + .as_array_mut() + .unwrap() + .push(serde_json::to_value(&defaults.decision_models[0]).unwrap()); let mut fable = defaults.decision_models[1].clone(); fable.enabled = false; @@ -1334,6 +1419,115 @@ mod tests { ); } + #[test] + fn orchestrate_migrates_untouched_sol_and_opus_defaults() { + let old_json = r#"{ + "decision_models": [], + "child_models": [ + { + "provider": "codex", + "model": "gpt-5.6-sol", + "enabled": true, + "fast": false, + "description": "Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely." + }, + { + "provider": "claude_code", + "model": "claude-opus-5", + "enabled": true, + "fast": false, + "description": "Execution model for agentic coding, cross-file implementation, refactoring, debugging, and review. Consider it alongside Sol across providers, including user-facing behavior and API or UI details. Use medium for clear bounded work, high for substantial implementation, and xhigh or max when difficult reasoning justifies the extra work; low can suit small mechanical tasks. Match verification to the changed behavior and avoid repetitive self-checking. Report evidence and unresolved limitations concisely." + } + ] + }"#; + let migrated: OrchestrateSettings = serde_json::from_str(old_json).unwrap(); + + assert_eq!( + migrated.child_models, + OrchestrateSettings::default().child_models + ); + } + + #[test] + fn orchestrate_preserves_every_customized_sol_shape() { + let customized = [ + r#"{"description":"custom","enabled":true,"fast":false}"#, + r#"{"description":"Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely.","profile_id":"custom","enabled":true,"fast":false}"#, + r#"{"description":"Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely.","enabled":false,"fast":false}"#, + r#"{"description":"Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely.","enabled":true,"fast":true}"#, + r#"{"description":"Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely.","enabled":true,"fast":false,"effort":"low"}"#, + ]; + for row in customized { + let expected: serde_json::Value = serde_json::from_str(row).unwrap(); + let old_json = format!( + r#"{{"decision_models":[],"child_models":[{{"provider":"codex","model":"gpt-5.6-sol",{}}}]}}"#, + &row[1..row.len() - 1] + ); + let migrated: OrchestrateSettings = serde_json::from_str(&old_json).unwrap(); + assert_eq!(migrated.child_models.len(), 1, "input: {row}"); + let actual = &migrated.child_models[0]; + assert_eq!(actual.model, "gpt-5.6-sol", "input: {row}"); + assert_eq!( + actual.description, + expected["description"].as_str().unwrap(), + "input: {row}" + ); + assert_eq!( + actual.enabled, + expected["enabled"].as_bool().unwrap(), + "input: {row}" + ); + assert_eq!( + actual.fast, + expected["fast"].as_bool().unwrap(), + "input: {row}" + ); + assert_eq!( + actual.profile_id.as_deref(), + expected["profile_id"].as_str(), + "input: {row}" + ); + } + } + + #[test] + fn orchestrate_migration_does_not_duplicate_existing_execution_astra() { + let old_json = r#"{ + "decision_models": [], + "child_models": [ + { + "provider": "codex", + "model": "gpt-5.6-sol", + "enabled": true, + "fast": false, + "description": "Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely." + }, + { + "provider": "codex", + "model": "gpt-6-astra", + "profile_id": "custom-codex", + "enabled": false, + "fast": true, + "description": "User execution guidance" + } + ] + }"#; + let migrated: OrchestrateSettings = serde_json::from_str(old_json).unwrap(); + + assert_eq!(migrated.child_models.len(), 1); + assert_eq!(migrated.child_models[0].model, "gpt-6-astra"); + assert_eq!( + migrated.child_models[0].profile_id.as_deref(), + Some("custom-codex") + ); + assert_eq!( + migrated.child_models[0].description, + "User execution guidance" + ); + assert!(!migrated.child_models[0].enabled); + assert!(migrated.child_models[0].fast); + } + #[test] fn computer_use_defaults_disabled_and_round_trips() { let legacy: Settings = serde_json::from_str(r#"{"theme_mode":"system"}"#).unwrap(); @@ -1457,8 +1651,8 @@ mod tests { })).unwrap(); assert_eq!(settings.child_models.len(), 2); let sol = &settings.child_models[0]; - assert!(sol.description.contains("medium effort: Routine work")); - assert!(sol.description.contains("max effort: Difficult bugs")); + assert!(sol.description.contains("Routine work")); + assert!(sol.description.contains("Difficult bugs")); assert!(!sol.enabled); assert!(sol.fast); assert_eq!(sol.profile_id.as_deref(), Some("custom")); @@ -1518,22 +1712,32 @@ mod tests { } #[test] - fn orchestrate_settings_patches_reject_duplicate_models_across_roles_and_endpoints() { + fn orchestrate_settings_patches_deduplicate_within_each_role() { let mut settings = Settings::default(); - let sol = settings.orchestrate.child_models[0].clone(); - let mut duplicate = sol.clone(); + let executor = settings.orchestrate.child_models[0].clone(); + let peer = settings.orchestrate.decision_models[0].clone(); + assert_eq!(executor.provider, peer.provider); + assert_eq!(executor.model, peer.model); + assert_ne!(executor.description, peer.description); + + let mut duplicate = executor.clone(); duplicate.profile_id = Some("another-endpoint".into()); duplicate.description = "must not overwrite".into(); let mut children = settings.orchestrate.child_models.clone(); - children.push(duplicate.clone()); + children.push(duplicate); settings.apply(SettingsPatch::OrchestrateChildModels(children)); assert_eq!(settings.orchestrate.child_models.len(), 2); - assert_eq!(settings.orchestrate.child_models[0], sol); + assert_eq!(settings.orchestrate.child_models[0], executor); + + let mut duplicate = peer.clone(); + duplicate.profile_id = Some("another-endpoint".into()); + duplicate.description = "must not overwrite".into(); let mut decisions = settings.orchestrate.decision_models.clone(); decisions.push(duplicate); settings.apply(SettingsPatch::OrchestrateDecisionModels(decisions)); assert_eq!(settings.orchestrate.decision_models.len(), 2); - assert_eq!(settings.orchestrate.child_models[0], sol); + assert_eq!(settings.orchestrate.decision_models[0], peer); + assert_eq!(settings.orchestrate.child_models[0], executor); } #[test] diff --git a/crates/runtime/src/app/orchestrate.rs b/crates/runtime/src/app/orchestrate.rs index 1fbbfe83..05802bdd 100644 --- a/crates/runtime/src/app/orchestrate.rs +++ b/crates/runtime/src/app/orchestrate.rs @@ -1270,33 +1270,43 @@ pub(super) fn render_orchestrate_configuration( })) }) .map(|entry| { + let catalog = catalogs + .get(&entry.provider) + .map(Vec::as_slice) + .unwrap_or_default(); ( entry, - orchestrate_efforts( - entry.provider, - &entry.model, - catalogs - .get(&entry.provider) - .map(Vec::as_slice) - .unwrap_or_default(), - collaboration, - ), + model_missing_from_loaded_catalog(catalog, &entry.model), + orchestrate_efforts(entry.provider, &entry.model, catalog, collaboration), ) }) - .filter(|(_, choices)| !collaboration || !choices.is_empty()) + .filter(|(_, unavailable, choices)| { + *unavailable || !collaboration || !choices.is_empty() + }) .collect(); if available.is_empty() { text.push_str(&format!( "No eligible configured models; `{tool}` is unavailable with the current configuration.\n" )); } - for (entry, choices) in available { + for (entry, unavailable, choices) in available { let profile = entry .profile_id .as_ref() .map(|id| format!(" — profile `{}`", escape_markdown_inline(id))) .unwrap_or_default(); let fast = if entry.fast { " — fast mode" } else { "" }; + if unavailable { + text.push_str(&format!( + "\n#### `{}` / `{}` — unavailable{fast}{profile}\n\nUnavailable: model `{}` is not present in the loaded `{}` catalog.\n\n{}\n", + provider_name(entry.provider), + escape_markdown_inline(&entry.model), + escape_markdown_inline(&entry.model), + provider_name(entry.provider), + entry.description.trim() + )); + continue; + } let efforts = if choices.is_empty() { "omit (provider default)".to_string() } else { @@ -1442,15 +1452,18 @@ fn resolve_orchestrate_profiles( if enabled.is_empty() { "none" } else { &enabled } ) })?; - let available = orchestrate_efforts( - provider, - &child.model, - catalogs - .get(&provider) - .map(Vec::as_slice) - .unwrap_or_default(), - collaboration, - ); + let catalog = catalogs + .get(&provider) + .map(Vec::as_slice) + .unwrap_or_default(); + if model_missing_from_loaded_catalog(catalog, &child.model) { + return Err(format!( + "model `{}` is unavailable for provider `{}`: not present in the loaded catalog", + child.model, + provider_name(provider) + )); + } + let available = orchestrate_efforts(provider, &child.model, catalog, collaboration); let selected_effort = match requested_effort { Some(requested) => Some( available @@ -1490,6 +1503,10 @@ fn resolve_orchestrate_profiles( )) } +fn model_missing_from_loaded_catalog(catalog: &[agent::ModelSpec], model: &str) -> bool { + !catalog.is_empty() && !catalog.iter().any(|spec| spec.id == model) +} + pub(super) fn resolve_dispatch_access(access: Option<&str>) -> Result { let Some(access) = access.map(str::trim).filter(|access| !access.is_empty()) else { return Ok(ApprovalMode::FullAccess); diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index ddc0328d..77f297b7 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -1062,7 +1062,7 @@ fn orchestrate_guidance_and_current_configuration_are_composed() { ); assert!(first.contains("### Execution models — `dispatch`")); assert!(first.contains( - "#### `codex` / `gpt-5.6-sol` — available `effort`: `low`, `medium`, `high`, `xhigh`, `max`" + "#### `codex` / `gpt-6-astra` — available `effort`: `low`, `medium`, `high`, `xhigh`, `max`" )); assert!(first.ends_with("\n\nShip it")); settings.decision_models[0].enabled = false; @@ -1114,8 +1114,8 @@ fn dispatch_validates_against_live_efforts_instead_of_bundled_fallback() { let catalogs = HashMap::from([( ProviderKind::Codex, vec![ModelSpec { - id: "gpt-5.6-sol".into(), - display_name: "Sol".into(), + id: "gpt-6-astra".into(), + display_name: "GPT-6 Astra".into(), is_default: false, options: vec![OptionDescriptor::Select { id: "reasoningEffort".into(), @@ -1145,7 +1145,52 @@ fn dispatch_validates_against_live_efforts_instead_of_bundled_fallback() { .contains("unsupported effort max") ); let configuration = render_orchestrate_configuration(&settings, None, &catalogs); - assert!(configuration.contains("`gpt-5.6-sol` — available `effort`: `medium`, `high`, `deep`")); + assert!(configuration.contains("`gpt-6-astra` — available `effort`: `medium`, `high`, `deep`")); +} + +#[test] +fn loaded_catalog_marks_missing_orchestrate_model_unavailable() { + let settings = OrchestrateSettings::default(); + let catalogs = HashMap::from([( + ProviderKind::Codex, + vec![ModelSpec { + id: "gpt-5.6-terra".into(), + display_name: "Terra".into(), + is_default: false, + options: Vec::new(), + }], + )]); + let expected = "model `gpt-6-astra` is unavailable for provider `codex`: not present in the loaded catalog"; + + assert_eq!( + resolve_orchestrate_dispatch( + &settings, + "codex", + Some("gpt-6-astra"), + Some("low"), + None, + &catalogs + ) + .unwrap_err(), + expected + ); + assert_eq!( + resolve_orchestrate_collaboration( + &settings, + "codex", + Some("gpt-6-astra"), + Some("medium"), + None, + &catalogs + ) + .unwrap_err(), + expected + ); + let configuration = render_orchestrate_configuration(&settings, None, &catalogs); + assert!(configuration.contains("#### `codex` / `gpt-6-astra` — unavailable")); + assert!(configuration.contains( + "Unavailable: model `gpt-6-astra` is not present in the loaded `codex` catalog." + )); } #[test] @@ -1167,23 +1212,26 @@ fn collaboration_and_execution_resolve_separate_profile_lists() { resolve_orchestrate_collaboration( &settings, "codex", - Some("gpt-5.6-sol"), - None, + Some("gpt-6-astra"), + Some("high"), None, &HashMap::new() ) - .is_err() + .is_ok() ); - assert!( + assert_eq!( resolve_orchestrate_dispatch( &settings, "codex", Some("gpt-6-astra"), - None, + Some("low"), None, &HashMap::new() ) - .is_err() + .unwrap() + .2 + .as_deref(), + Some("low") ); assert!( resolve_orchestrate_dispatch( @@ -1531,12 +1579,19 @@ fn orchestrate_title_generation_uses_only_the_users_request() { fn orchestrate_dispatch_enforces_child_allow_list_and_defaults() { let mut settings = OrchestrateSettings::default(); assert_eq!( - resolve_orchestrate_dispatch(&settings, "codex", None, None, None, &HashMap::new()) - .unwrap(), + resolve_orchestrate_dispatch( + &settings, + "codex", + Some("gpt-6-astra"), + Some("low"), + None, + &HashMap::new() + ) + .unwrap(), ( ProviderKind::Codex, - "gpt-5.6-sol".into(), - Some("medium".into()), + "gpt-6-astra".into(), + Some("low".into()), false, None ) @@ -1546,7 +1601,7 @@ fn orchestrate_dispatch_enforces_child_allow_list_and_defaults() { resolve_orchestrate_dispatch( &settings, "codex", - Some("gpt-5.6-sol"), + Some("gpt-6-astra"), Some("medium"), Some("KIMI"), &HashMap::new() @@ -1554,7 +1609,7 @@ fn orchestrate_dispatch_enforces_child_allow_list_and_defaults() { .unwrap(), ( ProviderKind::Codex, - "gpt-5.6-sol".into(), + "gpt-6-astra".into(), Some("medium".into()), false, Some("kimi".into()), @@ -1563,7 +1618,7 @@ fn orchestrate_dispatch_enforces_child_allow_list_and_defaults() { let unknown_profile = resolve_orchestrate_dispatch( &settings, "codex", - Some("gpt-5.6-sol"), + Some("gpt-6-astra"), Some("medium"), Some("missing"), &HashMap::new(), @@ -1589,12 +1644,12 @@ fn orchestrate_dispatch_enforces_child_allow_list_and_defaults() { None ) ); - for effort in ["medium", "high", "xhigh", "max"] { + for effort in ["low", "medium", "high", "xhigh", "max"] { assert_eq!( resolve_orchestrate_dispatch( &settings, "codex", - Some("gpt-5.6-sol"), + Some("gpt-6-astra"), Some(effort), None, &HashMap::new() @@ -1608,14 +1663,14 @@ fn orchestrate_dispatch_enforces_child_allow_list_and_defaults() { let wrong_effort = resolve_orchestrate_dispatch( &settings, "codex", - Some("gpt-5.6-sol"), + Some("gpt-6-astra"), Some("imaginary"), None, &HashMap::new(), ) .unwrap_err(); assert!(wrong_effort.contains("unsupported effort imaginary")); - assert!(wrong_effort.contains("medium, high, xhigh, max")); + assert!(wrong_effort.contains("low, medium, high, xhigh, max")); let denied = resolve_orchestrate_dispatch( &settings, "claude", @@ -2589,6 +2644,39 @@ fn session_options_gates_computer_use_registration_on_global_setting() { ); } +#[test] +fn collaboration_child_receives_enabled_computer_use_registration() { + let mut settings = Settings::default(); + settings.computer_use.enabled = true; + let mut meta = SessionMeta::new( + ProviderKind::Codex, + PathBuf::from("/x"), + Some("gpt-6-astra".into()), + ); + meta.parent_session_id = Some("lead".into()); + meta.approval_mode = ApprovalMode::ReadOnly; + let computer_use = agent::McpRegistration { + name: agent::McpRegistration::SERVER_NAME_COMPUTER_USE.into(), + url: "http://127.0.0.1:9/mcp".into(), + bearer_token: "computer-token".into(), + }; + + let options = session_options( + &meta, + &settings, + LaunchEnv::default(), + None, + None, + None, + Some(computer_use), + ); + + assert!(options.mcp_servers.iter().any(|registration| { + registration.name == agent::McpRegistration::SERVER_NAME_COMPUTER_USE + })); + assert_eq!(options.approval_mode, ApprovalMode::ReadOnly); +} + #[test] fn child_meta_links_parent_project_and_maps_effort() { let mut parent = SessionMeta::new(ProviderKind::ClaudeCode, PathBuf::from("/p"), None); @@ -6136,7 +6224,7 @@ fn orchestrate_dispatch_fast_override_beats_profile_setting() { .orchestrate .child_models .iter_mut() - .find(|child| child.model == "gpt-5.6-sol") + .find(|child| child.model == "gpt-6-astra") .unwrap(); max.fast = true; }); @@ -6148,7 +6236,7 @@ fn orchestrate_dispatch_fast_override_beats_profile_setting() { purpose: orchestrate_mcp::ThreadPurpose::Execution, parent_id: parent_id.clone(), provider: "codex".into(), - model: Some("gpt-5.6-sol".into()), + model: Some("gpt-6-astra".into()), effort: Some(effort.into()), profile: None, access: None, @@ -6203,7 +6291,7 @@ fn orchestrate_dispatch_resolves_cwd_before_reply() { purpose: orchestrate_mcp::ThreadPurpose::Execution, parent_id, provider: "codex".into(), - model: Some("gpt-5.6-sol".into()), + model: Some("gpt-6-astra".into()), effort: None, profile: None, access: None, @@ -6263,7 +6351,7 @@ fn orchestrate_worktree_dispatch_resolves_child_cwd_to_worktree() { purpose: orchestrate_mcp::ThreadPurpose::Execution, parent_id, provider: "codex".into(), - model: Some("gpt-5.6-sol".into()), + model: Some("gpt-6-astra".into()), effort: None, profile: None, access: None, diff --git a/crates/ui/src/orchestrate_settings.rs b/crates/ui/src/orchestrate_settings.rs index e5d15234..7261cf90 100644 --- a/crates/ui/src/orchestrate_settings.rs +++ b/crates/ui/src/orchestrate_settings.rs @@ -176,16 +176,20 @@ impl OrchestrateSettingsPanel { self.input_subscriptions.clear(); self.child_rows.clear(); let orchestrate = self.store.read(cx).settings().orchestrate; - let excluded: Vec<_> = orchestrate + let decision_excluded: Vec<_> = orchestrate .decision_models .iter() - .chain(&orchestrate.child_models) + .map(|entry| (entry.provider, entry.model.clone())) + .collect(); + let child_excluded: Vec<_> = orchestrate + .child_models + .iter() .map(|entry| (entry.provider, entry.model.clone())) .collect(); self.decision_model_picker - .update(cx, |picker, cx| picker.set_excluded(excluded.clone(), cx)); + .update(cx, |picker, cx| picker.set_excluded(decision_excluded, cx)); self.child_model_picker - .update(cx, |picker, cx| picker.set_excluded(excluded, cx)); + .update(cx, |picker, cx| picker.set_excluded(child_excluded, cx)); for (index, entry) in orchestrate .decision_models @@ -244,23 +248,29 @@ impl OrchestrateSettingsPanel { fn add_child(&mut self, option: &ModelOption, decision: bool, cx: &mut Context) { let settings = self.store.read(cx).settings().orchestrate; - if settings - .decision_models + let models = if decision { + &settings.decision_models + } else { + &settings.child_models + }; + if models .iter() - .chain(&settings.child_models) .any(|entry| entry.provider == option.provider && entry.model == option.id) { return; } + let description = if decision { + OrchestrateSettings::builtin_decision_definition(option.provider, &option.id) + } else { + OrchestrateSettings::builtin_child_definition(option.provider, &option.id) + }; let profile = OrchestrateChildModel { provider: option.provider, model: option.id.clone(), profile_id: option.profile_id.clone(), enabled: true, fast: false, - description: OrchestrateSettings::builtin_child_definition(option.provider, &option.id) - .unwrap_or_default() - .to_string(), + description: description.unwrap_or_default().to_string(), }; self.update_models( decision, @@ -302,7 +312,8 @@ impl OrchestrateSettingsPanel { .into_iter() .chain(settings.orchestrate.child_models) .collect(); - let Some(target) = builtin_child_target(&models, index) else { + let (decision, _) = self.profile_location(index, cx); + let Some(target) = builtin_child_target(&models, index, decision) else { return; }; let provider = target.provider; @@ -780,7 +791,7 @@ impl OrchestrateSettingsPanel { } else { format!("{} · {}", provider_label(provider), row.model) }; - let reset = builtin_child_target(&models, index) + let reset = builtin_child_target(&models, index, decision) .filter(|target| target.description != profile.description) .map(|_| { self.reset_button( @@ -862,13 +873,13 @@ impl OrchestrateSettingsPanel { ), ) .child({ - let choices = orchestrate_efforts( - provider, - &row.model, - &self.store.read(cx).provider_model_catalog(provider), - decision, - ); - let efforts = if choices.is_empty() { + let catalog = self.store.read(cx).provider_model_catalog(provider); + let unavailable = + !catalog.is_empty() && !catalog.iter().any(|spec| spec.id == row.model); + let choices = orchestrate_efforts(provider, &row.model, &catalog, decision); + let efforts = if unavailable { + crate::tr!("orchestrate.children.model_unavailable").into_owned() + } else if choices.is_empty() { if decision { crate::tr!("orchestrate.decisions.effort_unavailable").into_owned() } else { @@ -960,17 +971,21 @@ fn model_fast_supported(catalog: &[agent::ModelSpec], model: &str) -> bool { }) } -/// The bundled description for this model, independent of effort or endpoint. +/// The bundled description for this model and role, independent of endpoint. fn builtin_child_target( rows: &[OrchestrateChildModel], index: usize, + decision: bool, ) -> Option { let row = rows.get(index)?; let defaults = OrchestrateSettings::default(); + let defaults = if decision { + defaults.decision_models + } else { + defaults.child_models + }; defaults - .decision_models .into_iter() - .chain(defaults.child_models) .find(|entry| entry.provider == row.provider && entry.model == row.model) } @@ -1021,4 +1036,22 @@ mod tests { assert!(model_fast_supported(&catalog, "gpt-6-astra")); } + + #[test] + fn bundled_astra_restore_target_is_role_aware() { + let settings = OrchestrateSettings::default(); + let decision_count = settings.decision_models.len(); + let rows: Vec<_> = settings + .decision_models + .into_iter() + .chain(settings.child_models) + .collect(); + + let peer = builtin_child_target(&rows, 0, true).unwrap(); + let executor = builtin_child_target(&rows, decision_count, false).unwrap(); + assert_eq!(peer.model, "gpt-6-astra"); + assert_eq!(executor.model, "gpt-6-astra"); + assert_ne!(peer.description, executor.description); + assert!(executor.description.contains("Default to low effort")); + } } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 26999fb1..a514c4d1 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -331,7 +331,8 @@ over proportionate verification. Settings show two model lists: **Collaboration models**, bundled with GPT-6 Astra and Claude Fable 5.1, and **Execution models**, bundled with -GPT-5.6 Sol and Claude Opus 5. Other models may still initiate `/orchestrate`. +GPT-6 Astra and Claude Opus 5. The two Astra rows are separate role-specific +profiles with different descriptions. Other models may still initiate `/orchestrate`. `collaborate` opens a read-only peer discussion, continued through `send`; `dispatch` assigns concrete work to execution models. Model selection considers the whole cross-provider fleet, preferring tcode Orchestrate to native subagents. @@ -341,20 +342,31 @@ serve as the main decision model. Turning every peer off still permits the main thread to use `/orchestrate` and dispatch execution work. Status chips and switch tooltips explicitly name collaboration to make this distinction visible. -Each provider/model ID occurs once across both lists, regardless of endpoint. -Add pickers exclude configured models and settings patches enforce uniqueness. +Each provider/model ID occurs once per list, regardless of endpoint. The same ID +may have separate collaboration and execution profiles. Each add picker excludes +models configured in its own list, and settings patches enforce within-list uniqueness. Each row has an editable description, enable switch, restore/delete actions, a read-only list of available reasoning efforts, and a Fast switch when supported (or when a stored value needs to remain visible). Effort is selected per tool call from the live provider catalog, with bundled startup fallbacks. There is no saved fixed-effort field. Collaboration is limited to medium/high; omitted effort uses -medium when available. Sol's description recommends medium for routine execution, -high/xhigh as difficulty grows, and max for the hardest well-defined problems. +medium when available. GPT-6 execution starts at low as the baseline and escalates +only when a specific piece demonstrably needs depth. Fast mode remains independent. +Once a provider catalog is loaded, a configured model absent from it is rendered +unavailable with the catalog mismatch and dispatch or collaboration is rejected; +an empty pre-discovery catalog continues to use bundled fallbacks. The main workflow has no self-concept. Peer descriptions contain their collaboration self-concepts: the main thread sees only other peers, and a consulted peer receives its own description with the discussion brief. These texts emphasize complementary perspectives, useful initiative within scope, and proportionate verification. +Astra may use Computer Use in a collaboration thread to gather focused decision +evidence by observing and reading the app UI. It reports visible state, state ids, +read text, and discrepancies rather than treating observation as implementation. +It operates the UI only when the lead's brief explicitly requests it and the +thread's access mode permits it. Bulk UI sweeps and code changes remain execution +work for `dispatch`. Enabled Computer Use registrations are attached to child +threads, including collaboration children. Both add-model popovers reuse the provider/model picker with fixed tabs and a 300px scrollable model list. diff --git a/locales/en.yml b/locales/en.yml index 6c43a1e1..fafc8594 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -333,7 +333,7 @@ orchestrate: description: "The main thread frames decisions, collaborates with peers, routes concrete work to execution models, and verifies the outcome. Prefer tcode Orchestrate over provider-native subagents and compare profiles across all providers. Every main model may use /orchestrate; bundled decision peers are Astra and Fable 5.1." decisions: title: "Collaboration models" - description: "Astra and Fable 5.1 provide independent perspectives through collaborate. Switches only control availability for consultation; they do not affect use as the main decision model. Peer self-concepts are shared with other leads and the consulted peer. Collaboration supports medium and high only." + description: "Astra and Fable 5.1 provide independent perspectives through collaborate. Astra may gather focused app-UI evidence with Computer Use when enabled; implementation and bulk sweeps remain execution work. Switches only control consultation, not use as the main decision model. Collaboration supports medium and high only." add: "Add collaboration model" empty: "No collaboration models are configured. Add a model to enable peer collaboration. Main decision model selection is unaffected." none_enabled: "Collaboration is off for all models. They remain available as main decision models; execution dispatch is configured independently." @@ -344,7 +344,7 @@ orchestrate: effort_unavailable: "Collaboration unavailable until medium/high capabilities are known" children: title: "Execution models" - description: "Built-in executors: Sol and Opus 5. Each provider/model appears once across both lists, including endpoint profiles. Choose reasoning effort per dispatch from all supported values, guided by the model description and task difficulty." + description: "Built-in executors: GPT-6 Astra and Opus 5. Each provider/model appears once per list, including endpoint profiles, so collaboration and execution may use separate profiles for the same model. Start the GPT-6 executor at low effort and raise it only when a specific piece needs more depth." add: "Add execution model" empty: "No child-model profiles are configured. /orchestrate remains available, but dispatch calls will be rejected until a child model is added." none_enabled: "Every child-model profile is switched off. /orchestrate can still plan, but all dispatch calls will be rejected." @@ -354,6 +354,7 @@ orchestrate: disable: "Pause this model while keeping its configuration" effort_label: "Available reasoning efforts" effort_default: "No known choices — use provider default" + model_unavailable: "Unavailable — model absent from loaded provider catalog" effort_hint: "Selected through the tool parameter on each call" fast_label: "Fast mode" fast_hint: "Dispatch with the provider's fast mode (Claude fastMode, Codex fast service tier)" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index c55f5c2a..26351a9c 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -333,7 +333,7 @@ orchestrate: description: "主线程负责形成决策、与同级模型协作、将具体工作派发给执行模型并验收结果。优先使用 tcode Orchestrate,综合所有提供方的模型特点选择协作方和执行者。所有主模型均可使用 /orchestrate;内置决策模型为 Astra 和 Fable 5.1。" decisions: title: "协作模型" - description: "Astra 与 Fable 5.1 通过 collaborate 提供独立视角。开关仅控制是否可被邀请协作,不影响作为主决策模型使用。协作认知提供给其它主模型和被咨询的协作方;协作仅支持 medium 和 high。" + description: "Astra 与 Fable 5.1 通过 collaborate 提供独立视角。启用电脑操作后,Astra 可收集聚焦的应用界面证据;实现与批量检查仍属于执行任务。开关仅控制是否可被邀请协作,不影响作为主决策模型使用;协作仅支持 medium 和 high。" add: "添加协作模型" empty: "尚未配置协作模型,添加后可发起同级协作。不影响选择主决策模型。" none_enabled: "所有模型均已关闭协作,仍可作为主决策模型使用;执行派发由执行模型列表独立控制。" @@ -344,7 +344,7 @@ orchestrate: effort_unavailable: "尚无 medium/high 能力信息,暂不可协作" children: title: "执行模型" - description: "内置执行模型为 Sol 和 Opus 5。同一提供方的同一模型在两个列表中仅保留一条,不因端点配置重复添加。每次派发根据模型描述与任务难度,从全部可用思考程度中选择。" + description: "内置执行模型为 GPT-6 Astra 和 Opus 5。同一提供方的同一模型在每个列表中仅保留一条,因此同一模型可分别配置协作与执行角色。GPT-6 执行模型默认使用 low,仅在具体任务确实需要更深推理时提高档位。" add: "添加执行模型" empty: "目前没有配置任何子模型。/orchestrate 仍然可用,但在添加子模型前,派发调用会被拒绝。" none_enabled: "所有子模型配置都已关闭。/orchestrate 仍可进行规划,但所有派发调用都会被拒绝。" @@ -354,6 +354,7 @@ orchestrate: disable: "暂停使用,但保留这个模型的配置" effort_label: "可用思考程度" effort_default: "暂无已知档位,使用提供方默认值" + model_unavailable: "不可用——已加载的提供方目录中没有此模型" effort_hint: "由模型在每次工具调用时选择" fast_label: "快速模式" fast_hint: "派发时启用提供方的快速模式(Claude fastMode、Codex fast 服务档位)" From f607883a7f22846aa529052d2aa45c95c5701c89 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 8 Sep 2026 02:59:34 +0800 Subject: [PATCH 3/4] fix(settings): keep legacy Sol rows on the shared migration path The dedicated early return skipped the effort-prefix merge that every other legacy row receives; the generic path already preserves customized Sol rows. --- crates/core/src/settings.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index eb7fb7f8..f136957d 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -420,13 +420,6 @@ impl LegacyOrchestrateModel { ) }); } - if !collaboration - && self.effort.is_some() - && self.entry.provider == ProviderKind::Codex - && self.entry.model == "gpt-5.6-sol" - { - return Some(self.entry); - } if !collaboration && self.entry.provider == ProviderKind::ClaudeCode && self.entry.model == "claude-opus-5" @@ -1464,7 +1457,6 @@ mod tests { r#"{"description":"Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely.","profile_id":"custom","enabled":true,"fast":false}"#, r#"{"description":"Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely.","enabled":false,"fast":false}"#, r#"{"description":"Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely.","enabled":true,"fast":true}"#, - r#"{"description":"Execution model for scoped implementation, debugging with a reproduction, migrations, code review, data analysis, and evidence gathering. Use medium for routine work with a clear brief; increase through high and xhigh as interacting constraints or reasoning difficulty grow; use max for the hardest well-defined problems or when a lower effort has demonstrably stalled. Choose any supported effort that fits the task, not just the endpoints. Keep unrelated improvements out of scope. Report the concrete result and relevant checks concisely.","enabled":true,"fast":false,"effort":"low"}"#, ]; for row in customized { let expected: serde_json::Value = serde_json::from_str(row).unwrap(); @@ -1660,8 +1652,8 @@ mod tests { })).unwrap(); assert_eq!(settings.child_models.len(), 2); let sol = &settings.child_models[0]; - assert!(sol.description.contains("Routine work")); - assert!(sol.description.contains("Difficult bugs")); + assert!(sol.description.contains("medium effort: Routine work")); + assert!(sol.description.contains("max effort: Difficult bugs")); assert!(!sol.enabled); assert!(sol.fast); assert_eq!(sol.profile_id.as_deref(), Some("custom")); From 3dc58a584e903aa770c8419f91a9df93acba9030 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 8 Sep 2026 03:06:04 +0800 Subject: [PATCH 4/4] test(runtime): disabling one Orchestrate role leaves the other resolvable (#363) --- crates/runtime/src/app/tests.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 77f297b7..d32ef76f 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -1249,7 +1249,31 @@ fn collaboration_and_execution_resolve_separate_profile_lists() { resolve_orchestrate_collaboration(&settings, "codex", None, None, None, &HashMap::new()) .is_err() ); + // Disabling one role leaves the other role's row untouched. + assert_eq!( + resolve_orchestrate_dispatch( + &settings, + "codex", + Some("gpt-6-astra"), + Some("low"), + None, + &HashMap::new() + ) + .unwrap() + .1, + "gpt-6-astra" + ); settings.decision_models[0].enabled = true; + settings.child_models[0].enabled = false; + assert!( + resolve_orchestrate_dispatch(&settings, "codex", None, None, None, &HashMap::new()) + .is_err() + ); + assert!( + resolve_orchestrate_collaboration(&settings, "codex", None, None, None, &HashMap::new()) + .is_ok() + ); + settings.child_models[0].enabled = true; settings.decision_models[0].profile_id = Some("custom".into()); assert!( resolve_orchestrate_collaboration(