diff --git a/apps/staged/src-tauri/src/acp_config.rs b/apps/staged/src-tauri/src/acp_config.rs index cb3efb8f..e0be6fb2 100644 --- a/apps/staged/src-tauri/src/acp_config.rs +++ b/apps/staged/src-tauri/src/acp_config.rs @@ -86,6 +86,75 @@ fn selected_config_option( } } +/// Preferences-store key holding the diagram sub-session override (General +/// settings → Diagram generation). Written by the frontend preferences store +/// and read directly from `preferences.json` here, mirroring `branch-prefix`. +const DIAGRAM_SUBSESSION_CONFIG_KEY: &str = "diagram-subsession-config"; + +/// The agent/model/effort the `generate_pikchr` diagram sub-session runs under, +/// distinct from the session that invoked the tool. Every field is optional: an +/// unset (or empty) provider falls the sub-session back to the invoking +/// session's agent, and unset model/effort fall back to that agent's defaults — +/// so an all-unset config reproduces the pre-setting behaviour. Model/effort +/// value ids are meaningful only relative to the provider they were chosen for, +/// so they are applied only when a provider is configured (see +/// [`Self::config_options`]). +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DiagramSubsessionConfig { + #[serde(default)] + pub(crate) provider: Option, + #[serde(default)] + pub(crate) model: Option, + #[serde(default)] + pub(crate) effort: Option, +} + +impl DiagramSubsessionConfig { + /// The configured provider id to run the sub-session under, if any. Blank + /// (whitespace-only) values read as unset. + pub(crate) fn provider_id(&self) -> Option<&str> { + self.provider + .as_deref() + .map(str::trim) + .filter(|provider| !provider.is_empty()) + } + + /// The model/effort selections as ACP config options to apply per turn. + /// + /// Returns empty when no provider is configured: the stored model/effort + /// value ids belong to the configured provider, so applying them against a + /// different (inherited) agent would be meaningless. + pub(crate) fn config_options(&self) -> Vec { + if self.provider_id().is_none() { + return Vec::new(); + } + selected_acp_config_options(Some(&AcpConfigSelection { + model: self.model.clone(), + effort: self.effort.clone(), + })) + } +} + +/// Read the diagram sub-session override from the preferences store. Any +/// failure — missing file, unparseable JSON, absent or malformed key — yields +/// the default (all-unset) config, so the sub-session simply inherits the +/// invoking session's agent at its default model/effort. +pub(crate) fn read_diagram_subsession_config() -> DiagramSubsessionConfig { + let Some(path) = crate::preferences_store_path_buf() else { + return DiagramSubsessionConfig::default(); + }; + let Ok(contents) = std::fs::read_to_string(&path) else { + return DiagramSubsessionConfig::default(); + }; + let Ok(json) = serde_json::from_str::(&contents) else { + return DiagramSubsessionConfig::default(); + }; + json.get(DIAGRAM_SUBSESSION_CONFIG_KEY) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or_default() +} + fn normalize_selector_for_category( config_options: &[SessionConfigOption], category: &SessionConfigOptionCategory, @@ -345,4 +414,72 @@ mod tests { assert_eq!(selected[1].config_id, "reasoning"); assert_eq!(selected[1].value_id, "high"); } + + #[test] + fn diagram_config_deserializes_provider_model_and_effort() { + let config: DiagramSubsessionConfig = serde_json::from_value(serde_json::json!({ + "provider": "claude", + "model": { "configId": "model", "valueId": "opus", "label": "Opus" }, + "effort": { "configId": "reasoning", "valueId": "high", "label": "High" }, + })) + .expect("valid diagram config"); + + assert_eq!(config.provider_id(), Some("claude")); + + let options = config.config_options(); + assert_eq!(options.len(), 2); + assert_eq!(options[0].category, SessionConfigOptionCategory::Model); + assert_eq!(options[0].value_id, "opus"); + assert_eq!( + options[1].category, + SessionConfigOptionCategory::ThoughtLevel + ); + assert_eq!(options[1].value_id, "high"); + } + + #[test] + fn diagram_config_treats_blank_provider_as_unset() { + let config = DiagramSubsessionConfig { + provider: Some(" ".to_string()), + model: Some(AcpConfigValueSelection { + config_id: "model".to_string(), + value_id: "opus".to_string(), + label: None, + }), + effort: None, + }; + + assert_eq!(config.provider_id(), None); + } + + #[test] + fn diagram_config_without_provider_applies_no_config_options() { + // Model/effort value ids are provider-specific; with no configured + // provider the sub-session inherits the invoking agent and must not + // apply overrides meant for a different one. + let config = DiagramSubsessionConfig { + provider: None, + model: Some(AcpConfigValueSelection { + config_id: "model".to_string(), + value_id: "opus".to_string(), + label: None, + }), + effort: Some(AcpConfigValueSelection { + config_id: "reasoning".to_string(), + value_id: "high".to_string(), + label: None, + }), + }; + + assert!(config.config_options().is_empty()); + } + + #[test] + fn diagram_config_defaults_when_missing() { + let config: DiagramSubsessionConfig = + serde_json::from_value(serde_json::json!({})).expect("empty object is valid"); + assert_eq!(config, DiagramSubsessionConfig::default()); + assert_eq!(config.provider_id(), None); + assert!(config.config_options().is_empty()); + } } diff --git a/apps/staged/src-tauri/src/pikchr_mcp.rs b/apps/staged/src-tauri/src/pikchr_mcp.rs index 47236c63..48fbe98f 100644 --- a/apps/staged/src-tauri/src/pikchr_mcp.rs +++ b/apps/staged/src-tauri/src/pikchr_mcp.rs @@ -814,7 +814,30 @@ rendered PNG preview you may open as an optional final check." ctx: RequestContext, ) -> Result { let scale = p.scale.unwrap_or(DEFAULT_SCALE); - let provider_id = self.provider_id.clone(); + // Resolve the agent/model/effort the diagram sub-session runs under. The + // user can point it at a specific agent — and that agent's model and + // effort — via General settings → Diagram generation, distinct from the + // session that invoked this tool. When unset, fall back to the invoking + // session's agent (the field this handler was built with) at its + // default model/effort, reproducing the pre-setting behaviour. + // + // An override run additionally carries the invoking session's agent as + // a fallback (`fallback_provider_id`): the stored preference can drift + // stale between runs — agent uninstalled, model/effort id dropped by an + // agent update, agent without the HTTP MCP support the render tool + // needs — and a stale override degrades to the no-override behaviour + // instead of failing every call until the setting is fixed. The stored + // preference stays untouched; the settings UI surfaces its stale state. + let diagram_config = crate::acp_config::read_diagram_subsession_config(); + let (provider_id, config_options, fallback_provider_id) = match diagram_config.provider_id() + { + Some(configured) => ( + configured.to_string(), + diagram_config.config_options(), + Some(self.provider_id.clone()), + ), + None => (self.provider_id.clone(), Vec::new(), None), + }; let session = create_pikchr_child_session(&self.store, &provider_id) .map_err(|e| ErrorData::internal_error(e, None))?; let inner_session_id = session.id.clone(); @@ -886,22 +909,87 @@ rendered PNG preview you may open as an optional final check." return Err(e); } }; + let preview_mcp_server = || { + vec![McpServer::Http(McpServerHttp::new( + "pikchr-preview", + format!("http://127.0.0.1:{preview_port}/mcp"), + ))] + }; // Providers without HTTP MCP support fail the required- - // transport check and the call errors — acceptable, since the - // parent session already requires an MCP-capable provider to - // have `generate_pikchr` at all. + // transport check inside the run. For a no-override run that + // errors the call — acceptable, since the parent session + // already requires an MCP-capable provider to have + // `generate_pikchr` at all. An override run instead falls back + // to that same invoking agent (below), which the same argument + // makes a safe harbour. + let mut config_options = config_options; + let mut fallback_provider_id = fallback_provider_id; let driver = match AcpDriver::new(&provider_id) { - Ok(driver) => { - driver.with_mcp_servers(vec![McpServer::Http(McpServerHttp::new( - "pikchr-preview", - format!("http://127.0.0.1:{preview_port}/mcp"), - ))]) - } - Err(e) => { - mark_pikchr_child_session_error(&worker_store, &worker_session_id, &e); - return Err(e); - } + Ok(driver) => driver, + // The configured diagram agent no longer resolves (e.g. + // uninstalled since it was chosen): fall back to the + // invoking session's agent up front rather than after a + // doomed run. + Err(e) => match fallback_provider_id.take() { + Some(parent_provider) => match AcpDriver::new(&parent_provider) { + Ok(driver) => { + log::warn!( + "[pikchr_mcp] configured diagram agent unavailable ({e}); \ +falling back to the invoking session's agent" + ); + if let Err(store_error) = worker_store + .set_session_provider(&worker_session_id, &parent_provider) + { + log::warn!( + "[pikchr_mcp] failed to move Pikchr session \ +{worker_session_id} to the fallback provider: {store_error}" + ); + } + config_options = Vec::new(); + driver + } + Err(parent_error) => { + mark_pikchr_child_session_error( + &worker_store, + &worker_session_id, + &parent_error, + ); + return Err(parent_error); + } + }, + None => { + mark_pikchr_child_session_error(&worker_store, &worker_session_id, &e); + return Err(e); + } + }, }; + let driver = driver.with_mcp_servers(preview_mcp_server()); + // The fallback driver for override failures that only surface + // inside the run (stale model/effort selection, unsupported + // MCP transport) — see generate_pikchr_source. An invoking + // agent that itself doesn't resolve simply leaves those + // failures as the hard errors they were. + let fallback_driver = fallback_provider_id.as_deref().and_then(|parent| { + match AcpDriver::new(parent) { + Ok(driver) => Some(driver.with_mcp_servers(preview_mcp_server())), + Err(e) => { + log::warn!( + "[pikchr_mcp] invoking session's agent unavailable as the \ +diagram fallback: {e}" + ); + None + } + } + }); + let fallback = fallback_driver + .as_ref() + .zip(fallback_provider_id.as_deref()) + .map( + |(driver, provider_id)| crate::pikchr_subsession::DiagramFallback { + driver, + provider_id, + }, + ); // Enforce the wall-clock cap by cancelling the sub-session's // token; the driver shuts its subprocess down gracefully. The // parent's `DropGuard` cancels this same token if the MCP @@ -933,6 +1021,8 @@ accepted a render, so the diagram run was cancelled.", grammar.as_deref(), &p.description, p.previous_pikchr.as_deref(), + &config_options, + fallback, &slot, &worker_cancel, &worker_cancel_reason, @@ -1475,6 +1565,8 @@ arrow from COLL.e to SNOW.w"#; Some("test grammar body"), "a friendly box", None, + &[], + None, &slot, &worker_cancel, &reason, diff --git a/apps/staged/src-tauri/src/pikchr_subsession.rs b/apps/staged/src-tauri/src/pikchr_subsession.rs index ca02180b..5d58fab2 100644 --- a/apps/staged/src-tauri/src/pikchr_subsession.rs +++ b/apps/staged/src-tauri/src/pikchr_subsession.rs @@ -115,6 +115,30 @@ impl LastRenderSlot { } } +/// Fallback identity for a run pinned to the configured diagram override: the +/// invoking session's agent at its default model/effort — exactly what the run +/// would have used with no override. Consulted only when the override itself +/// can't run (see [`is_diagram_override_unavailable_error`]). +pub(crate) struct DiagramFallback<'a, D: AgentDriver + ?Sized> { + pub(crate) driver: &'a D, + /// Provider id written back onto the child session row when the fallback + /// takes over, so the transcript names the agent that actually drew the + /// diagram. + pub(crate) provider_id: &'a str, +} + +/// Whether a generation failure is attributable to the configured diagram +/// override rather than to the request itself: the pinned model/effort +/// selection no longer resolves on the agent, or the pinned agent doesn't +/// support the HTTP MCP transport the `render_pikchr` tool server requires. +/// Both halves go through acp-client matchers kept in lockstep with their +/// producers, so rewording an error message over there can't silently turn +/// the graceful fallback back into a hard error. +fn is_diagram_override_unavailable_error(error: &str) -> bool { + acp_client::is_config_selection_unavailable_error(error) + || acp_client::is_missing_mcp_transport_error(error) +} + /// Drive the sub-agent to produce validated Pikchr for `description`. /// /// Generic over [`AgentDriver`] so it can be unit-tested with a fake driver @@ -127,22 +151,61 @@ pub(crate) async fn generate_pikchr_source( grammar: Option<&str>, description: &str, previous_pikchr: Option<&str>, + config_options: &[acp_client::AcpSessionConfigOptionSelection], + fallback: Option>, slot: &LastRenderSlot, cancel_token: &CancellationToken, cancel_reason: &CancelReason, ) -> Result { - let result = generate_pikchr_source_inner( + let mut result = generate_pikchr_source_inner( driver, Arc::clone(&store), session_id, grammar, description, previous_pikchr, + config_options, slot, cancel_token, ) .await; + // The configured diagram override can drift stale between runs: an agent + // update can drop the pinned model/effort id, or the pinned agent may not + // support the transport the render tool needs. Those failures are + // properties of the override, not of this request — retry once on the + // invoking session's agent at its defaults instead of failing the tool. + // The stored preference is deliberately left intact (the settings UI + // surfaces its stale state) so a later agent update can revive it. + if let Some(fallback) = fallback { + if let Err(GenerationError::Failed(message)) = &result { + if is_diagram_override_unavailable_error(message) { + log::warn!( + "[pikchr_subsession] configured diagram agent can't run ({message}); \ +retrying with the invoking session's agent" + ); + if let Err(e) = store.set_session_provider(session_id, fallback.provider_id) { + log::warn!( + "[pikchr_subsession] failed to move Pikchr session {session_id} to the \ +fallback provider: {e}" + ); + } + result = generate_pikchr_source_inner( + fallback.driver, + Arc::clone(&store), + session_id, + grammar, + description, + previous_pikchr, + &[], + slot, + cancel_token, + ) + .await; + } + } + } + // A token cancellation is externally imposed — the wall-clock timeout or // the caller abandoning the MCP request — so resolve the recorded reason // once and tell the same story on the child session's status row and in @@ -227,6 +290,7 @@ async fn generate_pikchr_source_inner( grammar: Option<&str>, description: &str, previous_pikchr: Option<&str>, + config_options: &[acp_client::AcpSessionConfigOptionSelection], slot: &LastRenderSlot, cancel_token: &CancellationToken, ) -> Result { @@ -263,7 +327,7 @@ async fn generate_pikchr_source_inner( &writer_dyn, cancel_token, agent_session_id.as_deref(), - &[], + config_options, ) .await; writer_dyn.finalize().await; @@ -424,16 +488,28 @@ mod tests { const CLEAN_SOURCE: &str = r#"box "Clean" fit"#; /// One scripted specialist turn: optionally store a render into the slot - /// (as a real `render_pikchr` call would mid-turn), then reply with `reply`. + /// (as a real `render_pikchr` call would mid-turn), then reply with `reply` + /// — or fail the whole turn with `error` (as a run whose session setup + /// failed would, e.g. on a stale config selection). struct FakeTurn { store_render: Option, reply: String, + error: Option, } fn turn(store_render: Option, reply: &str) -> FakeTurn { FakeTurn { store_render, reply: reply.to_string(), + error: None, + } + } + + fn failing(error: &str) -> FakeTurn { + FakeTurn { + store_render: None, + reply: String::new(), + error: Some(error.to_string()), } } @@ -446,13 +522,15 @@ mod tests { /// Scripted driver that replays canned turns (writing the shared slot the /// way the `render_pikchr` tool would) and records the `agent_session_id` - /// it was handed each turn (to assert resumption). + /// and `config_options` it was handed each turn (to assert resumption and + /// that the diagram model/effort selections reach the driver). struct FakeDriver { slot: Arc, turns: Mutex>, calls: Mutex, seen_session_ids: Mutex>>, prompts: Mutex>, + seen_config_options: Mutex>>, } impl FakeDriver { @@ -463,6 +541,7 @@ mod tests { calls: Mutex::new(0), seen_session_ids: Mutex::new(Vec::new()), prompts: Mutex::new(Vec::new()), + seen_config_options: Mutex::new(Vec::new()), } } } @@ -479,7 +558,7 @@ mod tests { writer: &Arc, _cancel_token: &CancellationToken, agent_session_id: Option<&str>, - _config_options: &[acp_client::AcpSessionConfigOptionSelection], + config_options: &[acp_client::AcpSessionConfigOptionSelection], ) -> Result { *self.calls.lock().unwrap() += 1; self.seen_session_ids @@ -487,14 +566,10 @@ mod tests { .unwrap() .push(agent_session_id.map(str::to_string)); self.prompts.lock().unwrap().push(prompt.to_string()); - - // Mimic a new-session turn: register an agent session id so the - // loop resumes it next time. - if agent_session_id.is_none() { - store - .set_agent_session_id(session_id, "fake-agent-session") - .unwrap(); - } + self.seen_config_options + .lock() + .unwrap() + .push(config_options.to_vec()); let turn = { let mut turns = self.turns.lock().unwrap(); @@ -504,6 +579,20 @@ mod tests { turns.remove(0) } }; + // A failing turn dies during session setup: no agent session comes + // up and nothing is written. + if let Some(error) = turn.error { + return Err(error); + } + + // Mimic a new-session turn: register an agent session id so the + // loop resumes it next time. + if agent_session_id.is_none() { + store + .set_agent_session_id(session_id, "fake-agent-session") + .unwrap(); + } + if let Some(outcome) = turn.store_render { self.slot.store(outcome); } @@ -557,6 +646,8 @@ mod tests { Some("test grammar body"), "a friendly box", None, + &[], + None, slot, cancel, cancel_reason, @@ -613,6 +704,191 @@ mod tests { assert_eq!(messages[1].content, ACCEPT_SENTINEL); } + #[tokio::test] + async fn forwards_config_options_to_the_driver_each_turn() { + use acp_client::AcpSessionConfigOptionSelection; + use agent_client_protocol::schema::v1::SessionConfigOptionCategory; + + let slot = Arc::new(LastRenderSlot::new()); + // Two turns: a protocol miss (no sentinel) then acceptance, so the loop + // resumes and the selections must accompany the resumed turn too. + let driver = FakeDriver::new( + Arc::clone(&slot), + vec![ + turn(Some(render(CLEAN_SOURCE)), "still working on it"), + turn(Some(render(CLEAN_SOURCE)), ACCEPT_SENTINEL), + ], + ); + let (store, session_id) = child_session(); + + let config_options = vec![ + AcpSessionConfigOptionSelection { + category: SessionConfigOptionCategory::Model, + config_id: "model".to_string(), + value_id: "opus".to_string(), + }, + AcpSessionConfigOptionSelection { + category: SessionConfigOptionCategory::ThoughtLevel, + config_id: "reasoning".to_string(), + value_id: "high".to_string(), + }, + ]; + + generate_pikchr_source( + &driver, + Arc::clone(&store), + &session_id, + Some("/tmp/grammar.md"), + "a friendly box", + None, + &config_options, + None, + &slot, + &CancellationToken::new(), + &CancelReason::new(), + ) + .await + .expect("should accept the rendered diagram"); + + let seen = driver.seen_config_options.lock().unwrap(); + assert_eq!(seen.len(), 2, "one entry per driver turn"); + assert!( + seen.iter().all(|options| *options == config_options), + "every turn (including the resumed one) receives the diagram selections" + ); + } + + #[tokio::test] + async fn stale_override_falls_back_to_the_invoking_agent_at_defaults() { + use acp_client::AcpSessionConfigOptionSelection; + use agent_client_protocol::schema::v1::SessionConfigOptionCategory; + + let slot = Arc::new(LastRenderSlot::new()); + // The override run dies applying its pinned model — the message shape + // acp-client produces when a stored selection no longer resolves. + let override_driver = FakeDriver::new( + Arc::clone(&slot), + vec![failing( + "Selected ACP model value 'opus-legacy' is no longer available \ +for config option 'model'", + )], + ); + let fallback_driver = FakeDriver::new( + Arc::clone(&slot), + vec![turn(Some(render(CLEAN_SOURCE)), ACCEPT_SENTINEL)], + ); + let (store, session_id) = child_session(); + + let config_options = vec![AcpSessionConfigOptionSelection { + category: SessionConfigOptionCategory::Model, + config_id: "model".to_string(), + value_id: "opus-legacy".to_string(), + }]; + + let outcome = generate_pikchr_source( + &override_driver, + Arc::clone(&store), + &session_id, + Some("/tmp/grammar.md"), + "a friendly box", + None, + &config_options, + Some(DiagramFallback { + driver: &fallback_driver, + provider_id: "parent-agent", + }), + &slot, + &CancellationToken::new(), + &CancelReason::new(), + ) + .await + .expect("the fallback run should accept the rendered diagram"); + + assert_eq!(outcome.source, CLEAN_SOURCE); + assert_eq!(*override_driver.calls.lock().unwrap(), 1); + assert_eq!(*fallback_driver.calls.lock().unwrap(), 1); + // The fallback runs the invoking agent at its defaults — the stale + // selections belong to the override agent and must not follow along. + assert!(fallback_driver.seen_config_options.lock().unwrap()[0].is_empty()); + // ...in a fresh agent session, not a resume of the failed attempt. + assert_eq!(fallback_driver.seen_session_ids.lock().unwrap()[0], None); + + let session = store + .get_session(&session_id) + .expect("load session") + .expect("session exists"); + assert_eq!(session.status, SessionStatus::Completed); + assert_eq!( + session.provider.as_deref(), + Some("parent-agent"), + "the session row names the agent that actually drew the diagram" + ); + } + + #[tokio::test] + async fn unrelated_failures_do_not_trigger_the_fallback() { + let slot = Arc::new(LastRenderSlot::new()); + let override_driver = + FakeDriver::new(Arc::clone(&slot), vec![failing("agent subprocess crashed")]); + let fallback_driver = FakeDriver::new(Arc::clone(&slot), vec![]); + let (store, session_id) = child_session(); + + let result = generate_pikchr_source( + &override_driver, + Arc::clone(&store), + &session_id, + Some("/tmp/grammar.md"), + "a friendly box", + None, + &[], + Some(DiagramFallback { + driver: &fallback_driver, + provider_id: "parent-agent", + }), + &slot, + &CancellationToken::new(), + &CancelReason::new(), + ) + .await; + + assert_eq!(result.err().as_deref(), Some("agent subprocess crashed")); + assert_eq!( + *fallback_driver.calls.lock().unwrap(), + 0, + "a failure not attributable to the override must surface, not reroute" + ); + + let session = store + .get_session(&session_id) + .expect("load session") + .expect("session exists"); + assert_eq!(session.status, SessionStatus::Error); + assert_eq!(session.provider.as_deref(), Some("fake-agent")); + } + + #[test] + fn override_unavailable_matcher_covers_stale_selections_and_transport() { + // Stale/missing config selections (the acp-client matcher's cases). + assert!(is_diagram_override_unavailable_error( + "Selected ACP model value 'opus-legacy' is no longer available for config option 'model'" + )); + assert!(is_diagram_override_unavailable_error( + "Agent did not return ACP config options needed to apply selected model before prompting" + )); + // The pinned agent can't host the render tool's HTTP MCP server. + assert!(is_diagram_override_unavailable_error( + "Agent does not support required MCP transports (required: http=true, sse=false; \ +agent: http=false, sse=false). Select a provider that supports MCP over HTTP/SSE." + )); + // Anything else is a real failure and must not reroute. + assert!(!is_diagram_override_unavailable_error( + "agent subprocess crashed" + )); + assert!(!is_diagram_override_unavailable_error( + "The Pikchr specialist did not accept a rendered diagram." + )); + } + #[test] fn sentinel_must_be_the_final_line() { for reply in [ diff --git a/apps/staged/src/lib/features/settings/DiagramConfigSetting.svelte b/apps/staged/src/lib/features/settings/DiagramConfigSetting.svelte new file mode 100644 index 00000000..1825185b --- /dev/null +++ b/apps/staged/src/lib/features/settings/DiagramConfigSetting.svelte @@ -0,0 +1,347 @@ + + + +
+ Diagram generation + + +
+ + + + Same as session + + {#each providers as provider (provider.id)} + + + + {provider.label} + + + {/each} + +
+ + {#if selectedProvider} + {#if modelSelector} +
+ +
+ {/if} + + {#if effortSelector} +
+ +
+ {/if} + + {#if configLoading && (!modelSelector || loadingEffortOptions)} +
+ + + + Loading options… + + +
+ {/if} + {/if} + + {#snippet footer()} + {#if providerMissing} + + Agent no longer available + + {:else if selectedProvider && configError && !modelSelector && !effortSelector} + + Using agent defaults + + {/if} + {/snippet} +
+ +

+ {#if configUnavailable} + + {:else} + + {/if} + {#if !selectedProvider} + Diagrams are drawn by a sub-agent using the same agent as the session that requested them. + Pick an agent to always draw diagrams with a specific agent, model, and effort instead. + {:else if providerMissing} + {providerTriggerLabel} is no longer available. Diagrams will use the requesting session's own agent + until you choose a different agent. + {:else if modelStale || effortStale} + {providerTriggerLabel} no longer offers the chosen {staleSelectionNoun}. Diagrams will use the + requesting session's own agent until you update the selection. + {:else if configError && !modelSelector && !effortSelector} + Diagrams will use {providerTriggerLabel} at its default model and effort. + {:else} + Diagrams are drawn by {providerTriggerLabel}{modelTriggerLabel + ? `, model ${modelTriggerLabel}` + : ''}{effortTriggerLabel ? `, ${effortTriggerLabel} effort` : ''}. + {/if} +

+
+ + diff --git a/apps/staged/src/lib/features/settings/GeneralSettingsPanel.svelte b/apps/staged/src/lib/features/settings/GeneralSettingsPanel.svelte index c5be1cc7..b2d082c3 100644 --- a/apps/staged/src/lib/features/settings/GeneralSettingsPanel.svelte +++ b/apps/staged/src/lib/features/settings/GeneralSettingsPanel.svelte @@ -9,6 +9,7 @@ import * as ToggleGroup from '$lib/components/ui/toggle-group'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; + import DiagramConfigSetting from './DiagramConfigSetting.svelte'; import { preferences, getAvailableSyntaxThemes, @@ -188,6 +189,10 @@

+
+ +
+
, + /** + * Agent/model/effort override for the diagram-generation sub-session, or + * `null` to inherit the invoking session's agent. Read by the backend from + * the store file. + */ + diagramSubsessionConfig: null as DiagramSubsessionConfig | null, /** Whether auto code reviews are triggered after commits */ autoReviewMode: 'after-changes' as AutoReviewMode, /** @@ -297,6 +324,18 @@ export async function initPreferences(): Promise { preferences.acpConfigPrefs = savedConfigPrefs; } + // Load the diagram sub-session agent/model/effort override + const savedDiagramConfig = await getStoreValue( + DIAGRAM_SUBSESSION_CONFIG_STORE_KEY + ); + if ( + savedDiagramConfig && + typeof savedDiagramConfig === 'object' && + !Array.isArray(savedDiagramConfig) + ) { + preferences.diagramSubsessionConfig = savedDiagramConfig; + } + // Load auto-review mode const savedAutoReview = await getStoreValue(AUTO_REVIEW_STORE_KEY); if (savedAutoReview === 'never' || savedAutoReview === 'after-changes') { @@ -442,6 +481,23 @@ export function getAcpConfigPref(providerId: string): AcpConfigPref | null { return preferences.acpConfigPrefs[providerId] ?? null; } +// ============================================================================= +// Diagram Sub-session Config Actions +// ============================================================================= + +/** + * Persist the diagram sub-session agent/model/effort override. + * + * `null` clears the override so diagram generation inherits the invoking + * session's agent (and its default model/effort). Dropping empty configs to + * `null` keeps the store tidy and the backend on its inherit path. + */ +export function setDiagramSubsessionConfig(config: DiagramSubsessionConfig | null): void { + const normalized = config && config.provider ? config : null; + preferences.diagramSubsessionConfig = normalized; + setStoreValue(DIAGRAM_SUBSESSION_CONFIG_STORE_KEY, normalized); +} + /** * Return the most recently used agent that is present in `available`. * diff --git a/crates/acp-client/src/driver.rs b/crates/acp-client/src/driver.rs index 3f60a971..ef8c8bb5 100644 --- a/crates/acp-client/src/driver.rs +++ b/crates/acp-client/src/driver.rs @@ -2126,6 +2126,19 @@ pub fn is_config_selection_unavailable_error(error: &str) -> bool { || error.contains(CONFIG_SELECTION_STALE_NOT_SELECT))) } +// Fragment shared by `setup_acp_session`'s required-transport check and +// [`is_missing_mcp_transport_error`] — the same producer/matcher contract as +// the config-selection constants above. +const MISSING_MCP_TRANSPORT_PREFIX: &str = "Agent does not support required MCP transports"; + +/// Whether a session-establishment error means the agent lacks a transport +/// required by the session's MCP servers (e.g. no HTTP MCP support), rather +/// than the run failing for its own reasons. Callers that pin work to a +/// specific agent use this to reroute to one that can host the servers. +pub fn is_missing_mcp_transport_error(error: &str) -> bool { + error.contains(MISSING_MCP_TRANSPORT_PREFIX) +} + async fn apply_or_record_session_config_options( connection: &ConnectionTo, agent_session_id: &str, @@ -2349,7 +2362,7 @@ async fn setup_acp_session(context: AcpSessionSetupContext<'_>) -> Result::new())); diff --git a/crates/acp-client/src/lib.rs b/crates/acp-client/src/lib.rs index dae186a4..0405c627 100644 --- a/crates/acp-client/src/lib.rs +++ b/crates/acp-client/src/lib.rs @@ -27,11 +27,11 @@ pub use agent_client_protocol::schema::v1::{ SessionConfigOptionCategory, SessionInfoUpdate, SessionModeState as SessionModelState, }; pub use driver::{ - autoapprove_permission_decision, is_config_selection_unavailable_error, strip_code_fences, - AcpDriver, AcpEventMetadata, AcpInitializeMetadata, AcpPermissionDecision, AcpPermissionOption, - AcpPermissionOptionKind, AcpPermissionRequest, AcpSessionConfigOptionSelection, - AcpToolCallMetadata, AgentDriver, AgentRunOutcome, BasicMessageWriter, MessageWriter, - ReplayBoundary, Store, + autoapprove_permission_decision, is_config_selection_unavailable_error, + is_missing_mcp_transport_error, strip_code_fences, AcpDriver, AcpEventMetadata, + AcpInitializeMetadata, AcpPermissionDecision, AcpPermissionOption, AcpPermissionOptionKind, + AcpPermissionRequest, AcpSessionConfigOptionSelection, AcpToolCallMetadata, AgentDriver, + AgentRunOutcome, BasicMessageWriter, MessageWriter, ReplayBoundary, Store, }; pub use simple::{run_acp_prompt, run_acp_prompt_with_interpreter_env_snapshot}; pub use types::{