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
147 changes: 147 additions & 0 deletions src-tauri/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4556,6 +4556,11 @@ async fn apply_and_emit_session_config_options(
// session birth, and on a catalog broadcast). `None` when empty
// keeps the switch path on the flat-fallback branch.
state.write().await.grok_model_specs = (!specs.is_empty()).then(|| specs.clone());
// Grok's picker comes from its handshake, not a config-option list:
// its values are what the agent picked on its own, read before the
// replay below rewrites them (see
// `SessionState::agent_chosen_config_values`).
state.write().await.agent_chosen_config_values = current_config_option_values(&opts);
let session_id = session.session_id().clone();
apply_grok_preferred_options(
cx,
Expand All @@ -4571,6 +4576,10 @@ async fn apply_and_emit_session_config_options(
// No x.ai/sessionConfig (unexpected): fall through to the standard path,
// which for Grok emits an empty list (no selectors) — same as before.
}
// What the agent picked on its own, read before the replay below rewrites
// any of it (see `SessionState::agent_chosen_config_values`).
state.write().await.agent_chosen_config_values =
current_config_option_values(&map_session_config_options(&initial_config_options));
let updated = apply_preferred_session_options(
cx,
session,
Expand Down Expand Up @@ -30864,6 +30873,144 @@ mod tests {
assert_eq!(ordered, vec!["a_thing", "z_thing"]);
}

/// Establishment keeps what the agent picked on its own, read BEFORE the
/// saved preferences replay over it. The options probe reports that for
/// each option it applied (`report_agent_chosen_values` in the manager), so
/// the delegation panel's "Default" names the agent's model rather than the
/// user's. Shaped like a live opencode 2.0.24: switching the model re-lists
/// `effort` for it.
#[tokio::test]
async fn establishment_keeps_the_agents_own_picks_from_before_the_preference_replay() {
use agent_client_protocol::schema::v1::SetSessionConfigOptionResponse;

fn options(model: &str, effort: &str, efforts: &[&str]) -> Vec<SessionConfigOption> {
let efforts: Vec<serde_json::Value> = efforts
.iter()
.map(|value| serde_json::json!({"value": value, "name": value}))
.collect();
serde_json::from_value(serde_json::json!([
{
"type": "select",
"id": "model",
"name": "Model",
"category": "model",
"currentValue": model,
"options": [
{"value": "opencode/exo-free", "name": "Exo"},
{"value": "opencode/step-5-preview-free", "name": "Step 5"}
]
},
{
"type": "select",
"id": "effort",
"name": "Effort",
"category": "thought_level",
"currentValue": effort,
"options": efforts
},
]))
.expect("parses")
}

let (client_end, agent_end) = agent_client_protocol::Channel::duplex();
let agent = tokio::spawn(async move {
let _ = Agent
.builder()
.on_receive_request(
async |_req: NewSessionRequest,
responder: Responder<NewSessionResponse>,
_cx: ConnectionTo<Client>| {
let opened = options("opencode/exo-free", "high", &["high", "default"]);
responder.respond(
NewSessionResponse::new(SessionId::new("s1")).config_options(opened),
)
},
on_receive_request!(),
)
.on_receive_request(
async |_req: SetSessionConfigOptionRequest,
responder: Responder<SetSessionConfigOptionResponse>,
_cx: ConnectionTo<Client>| {
// Only the model is replayed: the answer re-lists effort.
let switched = options(
"opencode/step-5-preview-free",
"default",
&["low", "medium", "high", "default"],
);
responder.respond(SetSessionConfigOptionResponse::new(switched))
},
on_receive_request!(),
)
.connect_with(agent_end, async |_cx: ConnectionTo<Client>| {
std::future::pending::<Result<(), agent_client_protocol::Error>>().await
})
.await;
});

let state = Arc::new(RwLock::new(SessionState::new(
"conn-probe".to_string(),
AgentType::OpenCode,
None,
"delegation-probe".to_string(),
None,
)));
let establishment_state = Arc::clone(&state);
Client
.builder()
.connect_with(client_end, async move |cx: ConnectionTo<Agent>| {
let raw = cx
.send_request_to(
Agent,
UntypedMessage::new("session/new", NewSessionRequest::new("/tmp"))?,
)
.block_task()
.await?;
let response: NewSessionResponse = serde_json::from_value(raw)
.map_err(agent_client_protocol::Error::into_internal_error)?;
let initial = response.config_options.clone().unwrap_or_default();
let mut session = AgentSession::attach(&cx, response)?;
let preferred = BTreeMap::from([(
"model".to_string(),
"opencode/step-5-preview-free".to_string(),
)]);
apply_and_emit_session_config_options(
&cx,
&mut session,
&establishment_state,
&EventEmitter::Noop,
AgentType::OpenCode,
None,
None,
None,
&preferred,
initial,
)
.await;
Ok(())
})
.await
.expect("the establishment runs");
agent.abort();

let state = state.read().await;
assert_eq!(
state.agent_chosen_config_values,
BTreeMap::from([
("model".to_string(), "opencode/exo-free".to_string()),
("effort".to_string(), "high".to_string()),
]),
"the agent's own picks, from before the replay"
);
// …while the session itself runs the preference, effort re-listed for it.
let current =
current_config_option_values(state.config_options.as_deref().unwrap_or_default());
assert_eq!(
current.get("model").map(String::as_str),
Some("opencode/step-5-preview-free")
);
assert_eq!(current.get("effort").map(String::as_str), Some("default"));
}

/// The claude shape: a model select plus the effort option that hangs off it.
fn asserted_drift_options(model: &str, effort: &str) -> Vec<SessionConfigOption> {
serde_json::from_value(serde_json::json!([
Expand Down
130 changes: 128 additions & 2 deletions src-tauri/src/acp/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2419,6 +2419,15 @@ impl ConnectionManager {
/// what `codeg-mcp` will pass through to `session/set_config_option`
/// when a delegation actually fires.
///
/// `preferred_config_values` are applied on the probe session before the
/// snapshot is read, exactly as a real launch would apply them: an agent
/// that derives one option's choices from another's value (opencode
/// re-lists `effort` per selected model) then answers for the user's
/// selection instead of its own default. A value the agent rejects is
/// logged and skipped — the snapshot still comes back. Each applied
/// option itself still reports the agent's own pick as its current value
/// (see [`report_agent_chosen_values`]).
///
/// Returns `Ok(snapshot)` even when the agent advertises no options
/// (empty `config_options`, `None` modes) — that's a valid outcome the
/// UI can render as "this agent has nothing to configure."
Expand All @@ -2427,6 +2436,7 @@ impl ConnectionManager {
agent_type: AgentType,
working_dir: Option<String>,
runtime_env: BTreeMap<String, String>,
preferred_config_values: BTreeMap<String, String>,
) -> Result<AgentOptionsSnapshot, AcpError> {
// Owner window label is informational only (used for
// disconnect_by_owner_window), but worth being explicit so a probe
Expand All @@ -2453,6 +2463,7 @@ impl ConnectionManager {
.clone()
};
let _probe_guard = per_agent_lock.lock_owned().await;
let applied_ids: Vec<String> = preferred_config_values.keys().cloned().collect();
let conn_id = self
.spawn_agent(
agent_type,
Expand All @@ -2462,7 +2473,7 @@ impl ConnectionManager {
owner_window,
EventEmitter::Noop,
None,
BTreeMap::new(),
preferred_config_values,
)
.await?;

Expand All @@ -2487,7 +2498,19 @@ impl ConnectionManager {
// message over the generic ProbeTimedOut / ConnectionNotFound —
// an agent that died on Initialize already explained why.
let snapshot = match raw_snapshot {
Ok(s) => Ok(s),
Ok(mut s) => {
if !applied_ids.is_empty() {
if let Some(state) = state_arc.as_ref() {
let agent_chosen = state.read().await.agent_chosen_config_values.clone();
report_agent_chosen_values(
&mut s.config_options,
&applied_ids,
&agent_chosen,
);
}
}
Ok(s)
}
Err(wait_err) => {
let captured = if let Some(state) = state_arc.as_ref() {
state.read().await.last_error.clone()
Expand Down Expand Up @@ -4194,11 +4217,114 @@ impl SessionPlanApprovalAccess for ConnectionManagerPlanApprovalLookup {
}
}

/// Make every option the probe applied a caller's selection to report the value
/// the agent picked on its own, instead of that selection.
///
/// Every consumer of the probe's snapshot reads an option's current value as
/// what it runs when the caller leaves it unset: the delegation settings panel
/// names its "Default" choice after it, and the task and automation editors
/// show and pin it for an option nobody touched. For an applied option that is
/// the agent's own pick, which the session no longer holds once the selection
/// is in — reporting the selection would label the user's model as the agent's
/// default. The options the agent derived from the selection (opencode's
/// `effort`, re-listed per model) keep the value it answered, because that is
/// what they run when unset given the selection.
///
/// `agent_chosen` is the establishment's own answer
/// (`SessionState::agent_chosen_config_values`). An applied id the agent never
/// answered with, or the snapshot does not carry, is left alone.
fn report_agent_chosen_values(
options: &mut [crate::acp::types::SessionConfigOptionInfo],
applied_ids: &[String],
agent_chosen: &BTreeMap<String, String>,
) {
use crate::acp::types::SessionConfigKindInfo;
for option in options
.iter_mut()
.filter(|option| applied_ids.contains(&option.id))
{
let Some(chosen) = agent_chosen.get(&option.id) else {
continue;
};
match &mut option.kind {
SessionConfigKindInfo::Select(select) => select.current_value = chosen.clone(),
SessionConfigKindInfo::Boolean(boolean) => boolean.current_value = chosen == "true",
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::acp::connection::AgentConnection;

/// The probe applies the caller's model, so the session it reads holds that
/// model; the delegation panel names its "Default" choice after the
/// snapshot's current value. Model ids from a live opencode 2.0.24 probe:
/// the agent opened on `exo-free` and the caller asked for
/// `step-5-preview-free`. The agent's own effort differs from the one it
/// answered after the switch, so the test can tell which one survives.
#[test]
fn the_probe_reports_the_agents_own_pick_for_the_options_it_applied() {
use crate::acp::types::{
SessionConfigBooleanInfo, SessionConfigKindInfo, SessionConfigOptionInfo,
SessionConfigSelectInfo,
};
let option = |id: &str, kind: SessionConfigKindInfo| SessionConfigOptionInfo {
id: id.to_string(),
name: id.to_string(),
description: None,
category: None,
kind,
recommended_value: None,
};
let select = |current: &str| {
SessionConfigKindInfo::Select(SessionConfigSelectInfo {
current_value: current.to_string(),
options: Vec::new(),
groups: Vec::new(),
})
};
let current = |options: &[SessionConfigOptionInfo], id: &str| {
let option = options.iter().find(|o| o.id == id).expect("present");
match &option.kind {
SessionConfigKindInfo::Select(select) => select.current_value.clone(),
SessionConfigKindInfo::Boolean(boolean) => boolean.current_value.to_string(),
}
};
let mut options = vec![
option("model", select("opencode/step-5-preview-free")),
option("effort", select("default")),
option("mode", select("plan")),
option(
"auto_approve",
SessionConfigKindInfo::Boolean(SessionConfigBooleanInfo {
current_value: true,
}),
),
];
let agent_chosen: BTreeMap<String, String> = [
("model", "opencode/exo-free"),
("effort", "high"),
("mode", "build"),
("auto_approve", "false"),
]
.into_iter()
.map(|(id, value)| (id.to_string(), value.to_string()))
.collect();
// `provider` was applied but neither answered nor in the snapshot.
let applied = ["model", "auto_approve", "provider"].map(String::from);

report_agent_chosen_values(&mut options, &applied, &agent_chosen);

assert_eq!(current(&options, "model"), "opencode/exo-free");
assert_eq!(current(&options, "auto_approve"), "false");
// Not applied: what the agent answered GIVEN the selection stands — the
// default effort of the selected model, and a mode nobody asked for.
assert_eq!(current(&options, "effort"), "default");
assert_eq!(current(&options, "mode"), "plan");
}

/// An agent that has left the connection map but not yet exited can still
/// be appending to the transcript a restore is about to unlink, so the
/// gate has to see it. `disconnect` drops the map entry immediately, which
Expand Down
16 changes: 16 additions & 0 deletions src-tauri/src/acp/session_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,21 @@ pub struct SessionState {
///
/// Backend-internal — not serialized, not carried on `to_snapshot()`.
pub asserted_config_values: BTreeMap<String, String>,
/// Each config option's value as the agent itself picked it for this
/// session: the establishment's own answer (or the picker Grok's handshake
/// yields), read BEFORE codeg replays any saved preference over it.
/// Rewritten by every establishment, whether or not it had preferences to
/// replay.
///
/// Only the options probe reads it (`ConnectionManager::probe_agent_options`).
/// The probe applies the caller's model so that options the agent derives
/// from it — opencode re-lists `effort` per model — answer for that model.
/// `config_options` then holds the caller's own model as the current one,
/// while what the probe must report for an applied option is what it runs
/// when left unset: the agent's default, kept here.
///
/// Backend-internal — not serialized, not carried on `to_snapshot()`.
pub agent_chosen_config_values: BTreeMap<String, String>,
/// Config-option ids this launch pinned through the environment, which the
/// agent will therefore refuse to change for as long as the process lives.
///
Expand Down Expand Up @@ -708,6 +723,7 @@ impl SessionState {
grok_catalog_broadcast: None,
pi_startup_banner: None,
asserted_config_values: BTreeMap::new(),
agent_chosen_config_values: BTreeMap::new(),
env_pinned_config_option_ids: Vec::new(),
prompt_capabilities: None,
fork_supported: false,
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/acp/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,11 @@ pub struct GrokModelCatalog {
/// to give the delegation settings UI an authoritative view of what an
/// agent will accept (no reliance on chat-side caches).
///
/// The caller's selections (the model) are applied first, as a real launch
/// applies them, so an option the agent derives from one answers for it. Each
/// option's current value is what it runs when the caller leaves it unset —
/// for an applied option that is the agent's own pick, not the selection.
///
/// Both fields mirror `SessionState`: `modes` is `None` when the agent
/// reports no mode catalog (e.g. some thin wrappers); `config_options` is
/// empty when the agent advertises no configurable options.
Expand Down
Loading
Loading