From b257674d049952bbf8dc119fd91b293d74b3c8b7 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Fri, 4 Sep 2026 21:37:44 +0800 Subject: [PATCH 1/2] feat(orchestrate): let a dispatch override the profile's fast mode Add an optional `fast` parameter to the orchestrate `dispatch` tool. `true`/`false` overrides the child profile's fast-mode setting for that one child; omitted, the profile decides. The tool description, fleet table intro, and the three orchestrator prompt assets tell the lead to pass it only when the user explicitly asks for fast mode on or off. Providers without a fast mode still ignore it. Verified: clippy clean, cargo test --workspace green, including a new runtime test covering both override directions. --- assets/orchestrate/codex.md | 2 +- assets/orchestrate/fable.md | 2 +- assets/orchestrate/generic.md | 2 +- crates/orchestrate-mcp/src/lib.rs | 2 + crates/orchestrate-mcp/src/tools.rs | 8 +++- crates/runtime/src/app/orchestrate.rs | 6 ++- crates/runtime/src/app/tests.rs | 65 +++++++++++++++++++++++++++ 7 files changed, 82 insertions(+), 5 deletions(-) diff --git a/assets/orchestrate/codex.md b/assets/orchestrate/codex.md index b835cdfd..54f8bfc4 100644 --- a/assets/orchestrate/codex.md +++ b/assets/orchestrate/codex.md @@ -18,7 +18,7 @@ The fleet table below is the authoritative allow list — user-configured profil ## Tools -- `dispatch {provider, model?, effort?, access?, title, brief, cwd?, archive_on_complete?, result_max_chars?}` → new child thread, `brief` is its first message, returns `thread_id`. Visible in the user's sidebar. `model` + `effort` must name an enabled profile from the fleet table exactly (omit both → the provider's first enabled profile). `access`: `read_only` for reviews/investigation (no file changes; anything beyond pauses for approval, routed per Settings → Orchestrate → child approvals), `workspace_write` for implementation with auto-approved workspace edits, `full` (default). Completed children auto-archive after their result is delivered (default per Settings → Orchestrate); `archive_on_complete: false` keeps one visible, failed children always stay, and `send` to an archived child revives it. `result_max_chars` caps inline RESULT text (omit → 1200 characters; 0 → no limit — set 0 when the full report will be needed, instead of fetching it afterwards). +- `dispatch {provider, model?, effort?, access?, title, brief, cwd?, archive_on_complete?, result_max_chars?, fast?}` → new child thread, `brief` is its first message, returns `thread_id`. Visible in the user's sidebar. `model` + `effort` must name an enabled profile from the fleet table exactly (omit both → the provider's first enabled profile). `access`: `read_only` for reviews/investigation (no file changes; anything beyond pauses for approval, routed per Settings → Orchestrate → child approvals), `workspace_write` for implementation with auto-approved workspace edits, `full` (default). Completed children auto-archive after their result is delivered (default per Settings → Orchestrate); `archive_on_complete: false` keeps one visible, failed children always stay, and `send` to an archived child revives it. `result_max_chars` caps inline RESULT text (omit → 1200 characters; 0 → no limit — set 0 when the full report will be needed, instead of fetching it afterwards). `fast` overrides the profile's fast-mode setting for this child (true/false); pass it only when the user explicitly asks for fast mode on or off. - `status {thread_id?}` → running/completed/failed + output tail + token usage. No `thread_id` = all children. - `send {thread_id, message}` → follow-up to a child that still has useful context (fix instructions, mid-course corrections, one focused retry). Injected into the child's live turn when one is running, otherwise sent as its next turn — the response says which. - `result {thread_id}` → full final message of a completed child, with token usage. diff --git a/assets/orchestrate/fable.md b/assets/orchestrate/fable.md index cf32d33b..e64960b1 100644 --- a/assets/orchestrate/fable.md +++ b/assets/orchestrate/fable.md @@ -18,7 +18,7 @@ The fleet table below is the authoritative allow list — user-configured profil ## Tools -- `dispatch {provider, model?, effort?, access?, title, brief, cwd?, archive_on_complete?, result_max_chars?}` → creates a child thread, sends `brief` as its first message, returns `thread_id`. The child appears in the user's sidebar; they can watch it live. `model` + `effort` must name an enabled profile exactly as listed in the fleet table (omit both to get the provider's first enabled profile). `access` gates what the child may do: `read_only` for reviews and investigation (the child cannot change files; anything beyond that pauses for approval, routed per Settings → Orchestrate → child approvals), `workspace_write` for implementation with edits auto-approved inside the workspace, `full` (default) for no prompts. Completed children are auto-archived after their result reaches you (default per Settings → Orchestrate) — pass `archive_on_complete: false` to keep one around; failed children stay visible, and `send` to an archived child revives it. `result_max_chars` sets the inline RESULT cap (omit for 1200 characters; 0 for no limit — set 0 when you know you will need the full report, rather than fetching it afterwards). +- `dispatch {provider, model?, effort?, access?, title, brief, cwd?, archive_on_complete?, result_max_chars?, fast?}` → creates a child thread, sends `brief` as its first message, returns `thread_id`. The child appears in the user's sidebar; they can watch it live. `model` + `effort` must name an enabled profile exactly as listed in the fleet table (omit both to get the provider's first enabled profile). `access` gates what the child may do: `read_only` for reviews and investigation (the child cannot change files; anything beyond that pauses for approval, routed per Settings → Orchestrate → child approvals), `workspace_write` for implementation with edits auto-approved inside the workspace, `full` (default) for no prompts. Completed children are auto-archived after their result reaches you (default per Settings → Orchestrate) — pass `archive_on_complete: false` to keep one around; failed children stay visible, and `send` to an archived child revives it. `result_max_chars` sets the inline RESULT cap (omit for 1200 characters; 0 for no limit — set 0 when you know you will need the full report, rather than fetching it afterwards). `fast` overrides the profile's fast-mode setting for this child (true or false); pass it only when the user explicitly asks for fast mode on or off. - `status {thread_id?}` → running/completed/failed, an output tail, and token usage. Omit `thread_id` for all your children. - `send {thread_id, message}` → follow-up message to a child (feedback, a mid-course correction, one focused retry). Steered into the child's live turn immediately when one is running, otherwise sent as its next turn — the response says which. Prefer this over dispatching a fresh child when the child's context is useful. - `result {thread_id}` → the completed child's full final message plus token usage. diff --git a/assets/orchestrate/generic.md b/assets/orchestrate/generic.md index 73a4329a..77e6d740 100644 --- a/assets/orchestrate/generic.md +++ b/assets/orchestrate/generic.md @@ -18,7 +18,7 @@ The fleet table below is the authoritative allow list — user-configured profil ## Tools -- `dispatch {provider, model?, effort?, access?, title, brief, cwd?, archive_on_complete?, result_max_chars?}` → creates a child thread and sends `brief` as its first message; returns `thread_id`. The child is visible in the user's sidebar. `model` + `effort` must name an enabled profile from the fleet table exactly (omit both for the provider's first enabled profile). `access`: `read_only` for reviews/investigation (no file changes; anything beyond pauses for approval, routed per Settings → Orchestrate → child approvals), `workspace_write` for implementation with auto-approved workspace edits, `full` (default). Completed children auto-archive after their result is delivered (default per Settings → Orchestrate); set `archive_on_complete: false` to keep one visible, failed children always stay, and `send` to an archived child revives it. `result_max_chars` caps inline RESULT text (omit for the 1200-character default; 0 means no limit — set 0 when the full report will be needed, instead of fetching it afterwards). +- `dispatch {provider, model?, effort?, access?, title, brief, cwd?, archive_on_complete?, result_max_chars?, fast?}` → creates a child thread and sends `brief` as its first message; returns `thread_id`. The child is visible in the user's sidebar. `model` + `effort` must name an enabled profile from the fleet table exactly (omit both for the provider's first enabled profile). `access`: `read_only` for reviews/investigation (no file changes; anything beyond pauses for approval, routed per Settings → Orchestrate → child approvals), `workspace_write` for implementation with auto-approved workspace edits, `full` (default). Completed children auto-archive after their result is delivered (default per Settings → Orchestrate); set `archive_on_complete: false` to keep one visible, failed children always stay, and `send` to an archived child revives it. `result_max_chars` caps inline RESULT text (omit for the 1200-character default; 0 means no limit — set 0 when the full report will be needed, instead of fetching it afterwards). `fast` overrides the profile's fast-mode setting for this child (true/false); pass it only when the user explicitly asks for fast mode on or off. - `status {thread_id?}` → running/completed/failed plus the latest output tail and token usage. Omit `thread_id` for all children. - `send {thread_id, message}` → follow-up to a child with useful context (feedback, mid-course corrections, one focused retry). Delivered into the child's live turn when one is running, otherwise sent as its next turn — the response says which. - `result {thread_id}` → completed child's full final message, with token usage. diff --git a/crates/orchestrate-mcp/src/lib.rs b/crates/orchestrate-mcp/src/lib.rs index 02cbbe23..5ffcea47 100644 --- a/crates/orchestrate-mcp/src/lib.rs +++ b/crates/orchestrate-mcp/src/lib.rs @@ -19,6 +19,8 @@ pub enum OrchestrateOp { worktree: Option, archive_on_complete: Option, result_max_chars: Option, + /// Per-dispatch override of the profile's fast-mode setting. + fast: Option, }, Status { parent_id: String, diff --git a/crates/orchestrate-mcp/src/tools.rs b/crates/orchestrate-mcp/src/tools.rs index 2ddcbb99..9da57389 100644 --- a/crates/orchestrate-mcp/src/tools.rs +++ b/crates/orchestrate-mcp/src/tools.rs @@ -42,6 +42,11 @@ struct DispatchParams { description = "Character cap for the inline result text in the completion callback (default 1200; 0 = unlimited). Raise it or pass 0 when you will need the full report anyway — cheaper than a follow-up result call." )] result_max_chars: Option, + #[serde(default)] + #[schemars( + description = "Override the child profile's fast-mode setting for this dispatch (true = on, false = off). Pass it only when the user explicitly asked for fast mode on or off; otherwise omit it and the profile decides. Ignored by providers without a fast mode." + )] + fast: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct StatusParams { @@ -87,7 +92,7 @@ impl OrchestrateTools { } #[tool( - description = "Dispatch a brief to a new child tcode thread and return its thread id. profile is the provider-profile id from the fleet table, required when the entry names one. access is one of read_only (review/investigation: read-only actions run without prompts; anything that mutates pauses for user approval), workspace_write (edits auto-approved inside the workspace), or full (default; no approval prompts). worktree optionally isolates the child in tcode/ and overrides the Orchestrate setting; the response identifies the path and branch or explains fallback. Completed children are auto-archived after their result is delivered unless archive_on_complete: false; failed children stay visible for retries." + description = "Dispatch a brief to a new child tcode thread and return its thread id. profile is the provider-profile id from the fleet table, required when the entry names one. access is one of read_only (review/investigation: read-only actions run without prompts; anything that mutates pauses for user approval), workspace_write (edits auto-approved inside the workspace), or full (default; no approval prompts). worktree optionally isolates the child in tcode/ and overrides the Orchestrate setting; the response identifies the path and branch or explains fallback. Completed children are auto-archived after their result is delivered unless archive_on_complete: false; failed children stay visible for retries. fast overrides the profile's fast-mode setting for this child; use it only on the user's explicit instruction." )] async fn dispatch( &self, @@ -107,6 +112,7 @@ impl OrchestrateTools { worktree: p.worktree, archive_on_complete: p.archive_on_complete, result_max_chars: p.result_max_chars, + fast: p.fast, }) .await) } diff --git a/crates/runtime/src/app/orchestrate.rs b/crates/runtime/src/app/orchestrate.rs index f0064e90..89648271 100644 --- a/crates/runtime/src/app/orchestrate.rs +++ b/crates/runtime/src/app/orchestrate.rs @@ -334,6 +334,7 @@ impl AppState { worktree, archive_on_complete, result_max_chars, + fast: fast_override, } => { let resolved = (|| { let (provider, model, effort, fast, profile_id) = resolve_orchestrate_dispatch( @@ -349,6 +350,9 @@ impl AppState { return Err(format!("unknown profile: {id}")); } let approval_mode = resolve_dispatch_access(access.as_deref())?; + // The profile's fast setting is the default; a dispatch may + // override it either way on the user's explicit instruction. + let fast = fast_override.unwrap_or(fast); Ok((provider, model, effort, fast, profile_id, approval_mode)) })(); let (provider, model, effort, fast, profile_id, approval_mode) = match resolved { @@ -1128,7 +1132,7 @@ pub(super) fn render_orchestrate_configuration( text.push_str(identity); } text.push_str( - "\n\n### Allowed child models\n\nProfiles pin the effort they dispatch at. A dispatch must name `model` and `effort` exactly as listed; both may be omitted, in which case tcode picks the first enabled profile for the provider. When an entry names a `profile`, pass it exactly as listed. The definitions below are user-configured routing guidance.\n", + "\n\n### Allowed child models\n\nProfiles pin the effort they dispatch at. A dispatch must name `model` and `effort` exactly as listed; both may be omitted, in which case tcode picks the first enabled profile for the provider. When an entry names a `profile`, pass it exactly as listed. A profile marked `fast mode` dispatches with the provider's fast mode; pass `fast: true|false` on a dispatch to override that only when the user explicitly asks. The definitions below are user-configured routing guidance.\n", ); if !settings.child_models.iter().any(|child| child.enabled) { text.push_str("No child models are enabled. Work without dispatching until the user enables one in Settings → Orchestrate."); diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 197a0fb6..cc2eca49 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -5794,6 +5794,69 @@ fn recv_dispatch_reply(cx: &mut TestAppContext, rx: &smol::channel::Receiver< panic!("dispatch reply did not arrive within the polling budget"); } +#[test] +fn orchestrate_dispatch_fast_override_beats_profile_setting() { + let cx = &mut TestAppContext::default(); + let test_store = TestStore::new("tcode-dispatch-fast-data"); + let store = (*test_store).clone(); + let state = cx.new_entity(|_| AppState::new(store)); + let parent = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/tmp/project"), None); + let parent_id = parent.id.clone(); + + // Profile default: medium is plain, max is fast. + state.host_update(cx, |state, _| { + state.sessions.push(parent); + let max = state + .settings + .orchestrate + .child_models + .iter_mut() + .find(|child| child.effort.as_deref() == Some("max")) + .unwrap(); + max.fast = true; + }); + + let dispatch = |state: &mut AppState, cx: &mut HostCx, effort: &str, fast: Option| { + let (reply, response) = smol::channel::bounded(1); + state.handle_orchestrate_op( + orchestrate_mcp::OrchestrateOp::Dispatch { + parent_id: parent_id.clone(), + provider: "codex".into(), + model: Some("gpt-5.6-sol".into()), + effort: Some(effort.into()), + profile: None, + access: None, + title: "Child".into(), + brief: "Inspect the workspace".into(), + cwd: None, + worktree: None, + archive_on_complete: None, + result_max_chars: None, + fast, + }, + reply, + cx, + ); + let id = response.try_recv().unwrap().unwrap()["thread_id"] + .as_str() + .unwrap() + .to_string(); + state + .find_meta(&id) + .unwrap() + .option_selections + .iter() + .any(|selection| selection.id == "serviceTier" && selection.value == "fast") + }; + + state.host_update(cx, |state, cx| { + assert!(!dispatch(state, cx, "medium", None), "profile default: off"); + assert!(dispatch(state, cx, "medium", Some(true)), "override on"); + assert!(dispatch(state, cx, "max", None), "profile default: on"); + assert!(!dispatch(state, cx, "max", Some(false)), "override off"); + }); +} + #[test] fn orchestrate_dispatch_resolves_cwd_before_reply() { let cx = &mut TestAppContext::default(); @@ -5823,6 +5886,7 @@ fn orchestrate_dispatch_resolves_cwd_before_reply() { worktree: None, archive_on_complete: None, result_max_chars: None, + fast: None, }, reply, cx, @@ -5881,6 +5945,7 @@ fn orchestrate_worktree_dispatch_resolves_child_cwd_to_worktree() { worktree: Some(true), archive_on_complete: None, result_max_chars: None, + fast: None, }, reply, cx, From 47bc331a24b276f9a947be53bd1d1d1db100f4f1 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Fri, 4 Sep 2026 21:44:19 +0800 Subject: [PATCH 2/2] feat(orchestrate): let send switch a dispatched child's fast mode Add an optional `fast` parameter to the orchestrate `send` tool. It rewrites the child's fast-mode selection (Claude `fastMode`, Codex `fast` service tier) and persists it, so the existing launch-option restart machinery resumes the child on a fresh process before its next turn. A turn already running keeps its speed; the tool description and the orchestrator prompt assets tell the lead to cancel first and then send with `fast` set when the user wants work in progress sped up. Verified: clippy clean, cargo test --workspace green, including a new runtime test that the switch persists on both the resident and the index and flags a live child for restart. --- assets/orchestrate/codex.md | 2 +- assets/orchestrate/fable.md | 2 +- assets/orchestrate/generic.md | 2 +- crates/orchestrate-mcp/src/lib.rs | 2 + crates/orchestrate-mcp/src/tools.rs | 6 +++ crates/runtime/src/app/orchestrate.rs | 40 ++++++++++++++-- crates/runtime/src/app/tests.rs | 67 +++++++++++++++++++++++++++ 7 files changed, 114 insertions(+), 7 deletions(-) diff --git a/assets/orchestrate/codex.md b/assets/orchestrate/codex.md index 54f8bfc4..b9ce6a2e 100644 --- a/assets/orchestrate/codex.md +++ b/assets/orchestrate/codex.md @@ -20,7 +20,7 @@ The fleet table below is the authoritative allow list — user-configured profil - `dispatch {provider, model?, effort?, access?, title, brief, cwd?, archive_on_complete?, result_max_chars?, fast?}` → new child thread, `brief` is its first message, returns `thread_id`. Visible in the user's sidebar. `model` + `effort` must name an enabled profile from the fleet table exactly (omit both → the provider's first enabled profile). `access`: `read_only` for reviews/investigation (no file changes; anything beyond pauses for approval, routed per Settings → Orchestrate → child approvals), `workspace_write` for implementation with auto-approved workspace edits, `full` (default). Completed children auto-archive after their result is delivered (default per Settings → Orchestrate); `archive_on_complete: false` keeps one visible, failed children always stay, and `send` to an archived child revives it. `result_max_chars` caps inline RESULT text (omit → 1200 characters; 0 → no limit — set 0 when the full report will be needed, instead of fetching it afterwards). `fast` overrides the profile's fast-mode setting for this child (true/false); pass it only when the user explicitly asks for fast mode on or off. - `status {thread_id?}` → running/completed/failed + output tail + token usage. No `thread_id` = all children. -- `send {thread_id, message}` → follow-up to a child that still has useful context (fix instructions, mid-course corrections, one focused retry). Injected into the child's live turn when one is running, otherwise sent as its next turn — the response says which. +- `send {thread_id, message, fast?}` → follow-up to a child that still has useful context (fix instructions, mid-course corrections, one focused retry). Injected into the child's live turn when one is running, otherwise sent as its next turn — the response says which. `fast: true|false` switches the child's fast mode from its next turn; a running turn keeps its speed, so to speed up work in progress `cancel` first, then `send` with `fast` set (the child resumes its transcript on a fresh process). Only on the user's explicit instruction. - `result {thread_id}` → full final message of a completed child, with token usage. - `cancel {thread_id}` → stop a child. - `archive {thread_ids}` → batch-archive children; reversible, and shuts down running children. Rarely needed: completed children auto-archive — use for failed children you will not retry and children kept with `archive_on_complete: false`. diff --git a/assets/orchestrate/fable.md b/assets/orchestrate/fable.md index e64960b1..5436307c 100644 --- a/assets/orchestrate/fable.md +++ b/assets/orchestrate/fable.md @@ -20,7 +20,7 @@ The fleet table below is the authoritative allow list — user-configured profil - `dispatch {provider, model?, effort?, access?, title, brief, cwd?, archive_on_complete?, result_max_chars?, fast?}` → creates a child thread, sends `brief` as its first message, returns `thread_id`. The child appears in the user's sidebar; they can watch it live. `model` + `effort` must name an enabled profile exactly as listed in the fleet table (omit both to get the provider's first enabled profile). `access` gates what the child may do: `read_only` for reviews and investigation (the child cannot change files; anything beyond that pauses for approval, routed per Settings → Orchestrate → child approvals), `workspace_write` for implementation with edits auto-approved inside the workspace, `full` (default) for no prompts. Completed children are auto-archived after their result reaches you (default per Settings → Orchestrate) — pass `archive_on_complete: false` to keep one around; failed children stay visible, and `send` to an archived child revives it. `result_max_chars` sets the inline RESULT cap (omit for 1200 characters; 0 for no limit — set 0 when you know you will need the full report, rather than fetching it afterwards). `fast` overrides the profile's fast-mode setting for this child (true or false); pass it only when the user explicitly asks for fast mode on or off. - `status {thread_id?}` → running/completed/failed, an output tail, and token usage. Omit `thread_id` for all your children. -- `send {thread_id, message}` → follow-up message to a child (feedback, a mid-course correction, one focused retry). Steered into the child's live turn immediately when one is running, otherwise sent as its next turn — the response says which. Prefer this over dispatching a fresh child when the child's context is useful. +- `send {thread_id, message, fast?}` → follow-up message to a child (feedback, a mid-course correction, one focused retry). Steered into the child's live turn immediately when one is running, otherwise sent as its next turn — the response says which. Prefer this over dispatching a fresh child when the child's context is useful. `fast: true|false` switches the child's fast mode from its next turn on; a turn already running keeps its speed, so to speed up work in progress `cancel` first, then `send` with `fast` set and the child resumes its transcript on a fresh process. Use it only when the user explicitly asks. - `result {thread_id}` → the completed child's full final message plus token usage. - `cancel {thread_id}` → stop a child. - `archive {thread_ids}` → batch-archive children; reversible, and shuts down any still running. Rarely needed: completed children auto-archive — use this for failed children you will not retry and children kept with `archive_on_complete: false`. diff --git a/assets/orchestrate/generic.md b/assets/orchestrate/generic.md index 77e6d740..745d75ad 100644 --- a/assets/orchestrate/generic.md +++ b/assets/orchestrate/generic.md @@ -20,7 +20,7 @@ The fleet table below is the authoritative allow list — user-configured profil - `dispatch {provider, model?, effort?, access?, title, brief, cwd?, archive_on_complete?, result_max_chars?, fast?}` → creates a child thread and sends `brief` as its first message; returns `thread_id`. The child is visible in the user's sidebar. `model` + `effort` must name an enabled profile from the fleet table exactly (omit both for the provider's first enabled profile). `access`: `read_only` for reviews/investigation (no file changes; anything beyond pauses for approval, routed per Settings → Orchestrate → child approvals), `workspace_write` for implementation with auto-approved workspace edits, `full` (default). Completed children auto-archive after their result is delivered (default per Settings → Orchestrate); set `archive_on_complete: false` to keep one visible, failed children always stay, and `send` to an archived child revives it. `result_max_chars` caps inline RESULT text (omit for the 1200-character default; 0 means no limit — set 0 when the full report will be needed, instead of fetching it afterwards). `fast` overrides the profile's fast-mode setting for this child (true/false); pass it only when the user explicitly asks for fast mode on or off. - `status {thread_id?}` → running/completed/failed plus the latest output tail and token usage. Omit `thread_id` for all children. -- `send {thread_id, message}` → follow-up to a child with useful context (feedback, mid-course corrections, one focused retry). Delivered into the child's live turn when one is running, otherwise sent as its next turn — the response says which. +- `send {thread_id, message, fast?}` → follow-up to a child with useful context (feedback, mid-course corrections, one focused retry). Delivered into the child's live turn when one is running, otherwise sent as its next turn — the response says which. `fast: true|false` switches the child's fast mode from its next turn; a running turn keeps its speed, so to speed up work in progress `cancel` first, then `send` with `fast` set (the child resumes its transcript on a fresh process). Only on the user's explicit instruction. - `result {thread_id}` → completed child's full final message, with token usage. - `cancel {thread_id}` → stop a child. - `archive {thread_ids}` → batch-archive children; reversible, and shuts down running children. Rarely needed: completed children auto-archive — use for failed children you will not retry and children kept with `archive_on_complete: false`. diff --git a/crates/orchestrate-mcp/src/lib.rs b/crates/orchestrate-mcp/src/lib.rs index 5ffcea47..cd21d6b0 100644 --- a/crates/orchestrate-mcp/src/lib.rs +++ b/crates/orchestrate-mcp/src/lib.rs @@ -30,6 +30,8 @@ pub enum OrchestrateOp { parent_id: String, thread_id: String, message: String, + /// Switch the child's fast mode before delivering the message. + fast: Option, }, Result { parent_id: String, diff --git a/crates/orchestrate-mcp/src/tools.rs b/crates/orchestrate-mcp/src/tools.rs index 9da57389..353e6147 100644 --- a/crates/orchestrate-mcp/src/tools.rs +++ b/crates/orchestrate-mcp/src/tools.rs @@ -57,6 +57,11 @@ struct StatusParams { struct SendParams { thread_id: String, message: String, + #[serde(default)] + #[schemars( + description = "Switch the child's fast mode (true = on, false = off) before delivering this message. Takes effect from the child's next turn: a turn already running keeps its speed, so to speed up work in progress cancel the child first, then send with fast set — it resumes its transcript on a fresh process. Pass it only when the user explicitly asks; omit it to leave the setting alone." + )] + fast: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct ThreadParams { @@ -142,6 +147,7 @@ impl OrchestrateTools { parent_id: self.parent_id.clone(), thread_id: p.thread_id, message: p.message, + fast: p.fast, }) .await) } diff --git a/crates/runtime/src/app/orchestrate.rs b/crates/runtime/src/app/orchestrate.rs index 89648271..14fa3a4f 100644 --- a/crates/runtime/src/app/orchestrate.rs +++ b/crates/runtime/src/app/orchestrate.rs @@ -303,6 +303,24 @@ impl AppState { Ok(id) } + /// Switch a child's fast mode and persist it. Fast mode is a launch-time + /// option, so a live child restarts before its next turn (see + /// `options_changed_while_live`); a turn already running is unaffected. + fn set_child_fast(&mut self, thread_id: &str, fast: bool, cx: &mut HostCx) { + let Some(mut meta) = self + .resident(thread_id) + .map(|child| child.meta.clone()) + .or_else(|| self.find_meta(thread_id)) + else { + return; + }; + apply_fast_selection(&mut meta.option_selections, meta.provider, fast); + if let Some(child) = self.resident_mut(thread_id) { + child.meta.option_selections = meta.option_selections.clone(); + } + self.persist_meta(&meta, cx); + } + /// Resolve one MCP operation on the host owner thread. pub(crate) fn handle_orchestrate_op( &mut self, @@ -466,12 +484,16 @@ impl AppState { parent_id, thread_id, message, + fast, } => { let result = (|| { let archived = self .require_child(&parent_id, &thread_id)? .archived_at .is_some(); + if let Some(fast) = fast { + self.set_child_fast(&thread_id, fast, cx); + } // A follow-up starts a new piece of work: a result reported // before it must not be delivered as the answer to it. self.child_reported_results.remove(&thread_id); @@ -1132,7 +1154,7 @@ pub(super) fn render_orchestrate_configuration( text.push_str(identity); } text.push_str( - "\n\n### Allowed child models\n\nProfiles pin the effort they dispatch at. A dispatch must name `model` and `effort` exactly as listed; both may be omitted, in which case tcode picks the first enabled profile for the provider. When an entry names a `profile`, pass it exactly as listed. A profile marked `fast mode` dispatches with the provider's fast mode; pass `fast: true|false` on a dispatch to override that only when the user explicitly asks. The definitions below are user-configured routing guidance.\n", + "\n\n### Allowed child models\n\nProfiles pin the effort they dispatch at. A dispatch must name `model` and `effort` exactly as listed; both may be omitted, in which case tcode picks the first enabled profile for the provider. When an entry names a `profile`, pass it exactly as listed. A profile marked `fast mode` dispatches with the provider's fast mode; pass `fast: true|false` on a dispatch (or on a `send`, for a child that already exists) to override that only when the user explicitly asks. The definitions below are user-configured routing guidance.\n", ); if !settings.child_models.iter().any(|child| child.enabled) { text.push_str("No child models are enabled. Work without dispatching until the user enables one in Settings → Orchestrate."); @@ -1351,13 +1373,23 @@ pub(super) fn build_child_meta( value: serde_json::Value::String(effort), }); } - if fast && let Some((id, value)) = fast_selection(provider) { - meta.option_selections.push(OptionSelection { + apply_fast_selection(&mut meta.option_selections, provider, fast); + meta +} + +/// Set or clear the provider's fast-mode selection in `selections`. Other +/// selections (a Codex `flex` tier, say) are left alone. +fn apply_fast_selection(selections: &mut Vec, provider: ProviderKind, fast: bool) { + let Some((id, value)) = fast_selection(provider) else { + return; + }; + selections.retain(|selection| !(selection.id == id && selection.value == value)); + if fast { + selections.push(OptionSelection { id: id.into(), value, }); } - meta } /// The option selection that turns on a provider's fast mode: Claude's diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index cc2eca49..3f30f731 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -2680,6 +2680,7 @@ fn orchestrate_send_unarchives_the_child() { parent_id: "parent".into(), thread_id: "child".into(), message: "retry with the failing test".into(), + fast: None, }, reply, cx, @@ -2692,6 +2693,72 @@ fn orchestrate_send_unarchives_the_child() { }); } +#[test] +fn orchestrate_send_fast_switch_persists_and_schedules_restart() { + let cx = &mut TestAppContext::default(); + let test_store = TestStore::new("tcode-orchestrate-send-fast-test"); + let store = (*test_store).clone(); + let state = cx.new_entity(|_| AppState::new(store)); + let (commands, _receiver) = smol::channel::unbounded(); + + state.host_update(cx, |state, cx| { + let mut child = live_session(ProviderKind::Codex, commands); + child.meta.id = "child".into(); + child.meta.parent_session_id = Some("parent".into()); + child.turn_in_flight = true; + state.sessions.push(child.meta.clone()); + state.residents.parked.insert(child.meta.id.clone(), child); + + let send = |state: &mut AppState, cx: &mut HostCx, fast: Option| { + let (reply, response) = smol::channel::bounded(1); + state.handle_orchestrate_op( + orchestrate_mcp::OrchestrateOp::Send { + parent_id: "parent".into(), + thread_id: "child".into(), + message: "carry on".into(), + fast, + }, + reply, + cx, + ); + assert!(response.try_recv().unwrap().is_ok()); + }; + let is_fast = |state: &AppState| { + let selected = |selections: &[OptionSelection]| { + selections + .iter() + .any(|selection| selection.id == "serviceTier" && selection.value == "fast") + }; + let resident = selected(&state.resident("child").unwrap().meta.option_selections); + let indexed = selected(&state.find_meta("child").unwrap().option_selections); + assert_eq!(resident, indexed, "resident and index must agree"); + resident + }; + + send(state, cx, None); + assert!(!is_fast(state), "omitted: unchanged"); + assert!( + !state + .resident("child") + .unwrap() + .options_changed_while_live() + ); + + send(state, cx, Some(true)); + assert!(is_fast(state), "fast on"); + assert!( + state + .resident("child") + .unwrap() + .options_changed_while_live(), + "a live child must restart before its next turn" + ); + + send(state, cx, Some(false)); + assert!(!is_fast(state), "fast off"); + }); +} + #[test] fn orchestrate_archive_is_batch_atomic_and_parent_scoped() { let cx = &mut TestAppContext::default();