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 @@
+
+
+
+