,
+ /**
+ * 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`.
*
From 1037829d0b47e4b642220ecc5ec6a540542b4a0e Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Mon, 27 Jul 2026 16:59:33 +1000
Subject: [PATCH 2/4] fix(pikchr): fall back to the invoking agent when the
diagram override goes stale
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The diagram-generation override stored in `diagram-subsession-config` can
drift out from under the preference: an agent update can drop the pinned
model/effort value id, or the pinned agent can be uninstalled or lack the
HTTP MCP support the render tool needs. Previously any of those failed the
`generate_pikchr` call hard — and since the stale preference was re-read on
every call and never recovered from, every subsequent diagram generation
failed too, while the settings UI silently reconciled the stale id to a
valid-looking default.
Backend — degrade to the no-override behaviour instead of erroring:
- pikchr_subsession: generate_pikchr_source now takes an optional
DiagramFallback (the invoking session's agent at its default
model/effort). When the override run fails with an error attributable to
the override — a stale/missing config selection per acp-client's
is_config_selection_unavailable_error, or the required-MCP-transport
check — the run retries once on the fallback driver with no config
options, and the child session row is moved to the fallback provider so
the transcript names the agent that actually drew the diagram. Unrelated
failures and cancellations still surface as before.
- pikchr_mcp: override runs carry the invoking session's agent as that
fallback; an override provider that no longer resolves at all falls back
before the run even starts. The stored preference is deliberately left
intact so a later agent update can revive it.
Settings — show the stale state instead of masking it:
- DiagramConfigSetting: detect a stored provider missing from discovery and
stored model/effort ids the live options no longer include. The trigger
keeps the stale stored label and gains a danger border, and the field
description switches to an error explaining that diagrams fall back to
the requesting session's own agent until the selection is updated.
Tests cover the stale-selection fallback (fallback runs at defaults in a
fresh agent session, session row reassigned), that unrelated failures do
not reroute, and the override-unavailable error matcher.
Co-Authored-By: Claude Fable 5
Signed-off-by: Matt Toohey
---
apps/staged/src-tauri/src/pikchr_mcp.rs | 112 +++++++--
.../staged/src-tauri/src/pikchr_subsession.rs | 234 +++++++++++++++++-
.../settings/DiagramConfigSetting.svelte | 74 +++++-
3 files changed, 386 insertions(+), 34 deletions(-)
diff --git a/apps/staged/src-tauri/src/pikchr_mcp.rs b/apps/staged/src-tauri/src/pikchr_mcp.rs
index 712e9c12..48fbe98f 100644
--- a/apps/staged/src-tauri/src/pikchr_mcp.rs
+++ b/apps/staged/src-tauri/src/pikchr_mcp.rs
@@ -820,10 +820,23 @@ rendered PNG preview you may open as an optional final check."
// 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) = match diagram_config.provider_id() {
- Some(configured) => (configured.to_string(), diagram_config.config_options()),
- None => (self.provider_id.clone(), Vec::new()),
+ 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))?;
@@ -896,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
@@ -944,6 +1022,7 @@ accepted a render, so the diagram run was cancelled.",
&p.description,
p.previous_pikchr.as_deref(),
&config_options,
+ fallback,
&slot,
&worker_cancel,
&worker_cancel_reason,
@@ -1487,6 +1566,7 @@ arrow from COLL.e to SNOW.w"#;
"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 bf032e7d..7886295c 100644
--- a/apps/staged/src-tauri/src/pikchr_subsession.rs
+++ b/apps/staged/src-tauri/src/pikchr_subsession.rs
@@ -115,6 +115,32 @@ 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.
+/// The transport fragment mirrors the message produced by the acp-client
+/// driver's required-transport check — the same producer/matcher string
+/// coupling `is_config_selection_unavailable_error` itself relies on; if the
+/// producer is reworded this degrades to today's hard error, never to a
+/// spurious fallback.
+fn is_diagram_override_unavailable_error(error: &str) -> bool {
+ acp_client::is_config_selection_unavailable_error(error)
+ || error.contains("does not support required MCP transports")
+}
+
/// Drive the sub-agent to produce validated Pikchr for `description`.
///
/// Generic over [`AgentDriver`] so it can be unit-tested with a fake driver
@@ -128,11 +154,12 @@ pub(crate) async fn generate_pikchr_source(
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,
@@ -145,6 +172,42 @@ pub(crate) async fn generate_pikchr_source(
)
.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_reference,
+ 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
@@ -427,16 +490,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()),
}
}
@@ -498,14 +573,6 @@ mod tests {
.unwrap()
.push(config_options.to_vec());
- // 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();
- }
-
let turn = {
let mut turns = self.turns.lock().unwrap();
if turns.is_empty() {
@@ -514,6 +581,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);
}
@@ -568,6 +649,7 @@ mod tests {
"a friendly box",
None,
&[],
+ None,
slot,
cancel,
cancel_reason,
@@ -662,6 +744,7 @@ mod tests {
"a friendly box",
None,
&config_options,
+ None,
&slot,
&CancellationToken::new(),
&CancelReason::new(),
@@ -677,6 +760,137 @@ mod tests {
);
}
+ #[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,
+ "/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,
+ "/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
index 601bfc6b..1825185b 100644
--- a/apps/staged/src/lib/features/settings/DiagramConfigSetting.svelte
+++ b/apps/staged/src/lib/features/settings/DiagramConfigSetting.svelte
@@ -14,6 +14,7 @@
-->