Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions apps/staged/src-tauri/src/acp_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(default)]
pub(crate) model: Option<AcpConfigValueSelection>,
#[serde(default)]
pub(crate) effort: Option<AcpConfigValueSelection>,
}

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<AcpSessionConfigOptionSelection> {
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::<serde_json::Value>(&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,
Expand Down Expand Up @@ -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());
}
}
120 changes: 106 additions & 14 deletions apps/staged/src-tauri/src/pikchr_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -814,7 +814,30 @@ rendered PNG preview you may open as an optional final check."
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1475,6 +1565,8 @@ arrow from COLL.e to SNOW.w"#;
Some("test grammar body"),
"a friendly box",
None,
&[],
None,
&slot,
&worker_cancel,
&reason,
Expand Down
Loading