From 7ccbeaa161e02bed0e11c3d878ffacf97c6885f6 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 30 Sep 2026 13:03:48 +0200 Subject: [PATCH] hive-runtime: drop an ACP refusal on respawn and when the held value is wanted Choice::refused was cleared only when the agent accepted a session/set_config_option, so it outlived what it described in two cases: a respawned agent process kept the dead one's refusal masking the wanted value in the picker, and a refusal survived a turn whose wanted value was the one the session already held (or none), so re-picking the refused value snapped the picker back to the session's value. start() now resets the refusals along with the offered options, and choose() clears the category's refusal when the wanted value is none or the one the session holds. Refs #4832 --- hive-runtime/src/acp/mod.rs | 75 +++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/hive-runtime/src/acp/mod.rs b/hive-runtime/src/acp/mod.rs index c08a13f3..dee41271 100644 --- a/hive-runtime/src/acp/mod.rs +++ b/hive-runtime/src/acp/mod.rs @@ -159,7 +159,8 @@ impl Canceller { pub struct Choice { pub current: String, pub values: Vec, - /// The value the agent last refused to set, until it accepts one. + /// The value the agent last refused to set, until it accepts one, the + /// value wanted is the one the session holds, or the agent is respawned. pub refused: Option, } @@ -211,6 +212,10 @@ impl Choices { self.lock().options = options; } + fn reset(&self) { + *self.lock() = Offered::default(); + } + fn refuse(&self, category: &'static str, value: &str) { self.lock().refused.insert(category, value.to_owned()); } @@ -368,7 +373,7 @@ impl AcpRuntime

{ return Err(AcpError::NoHttpMcp.into()); } tracing::info!(agent = %init["agentInfo"], "ACP agent initialized"); - self.choices.replace(Vec::new()); + self.choices.reset(); Ok(Live { conn, load_session: caps["loadSession"] == Value::Bool(true), @@ -832,8 +837,9 @@ async fn choose_model_and_effort( /// Set `session`'s config option of `category` to `wanted`, if the session /// offers that value and holds another. A refusal is reported to `sink` and -/// kept as the choice's [`Choice::refused`] until the agent accepts a value; -/// the turn goes ahead on the value the session holds. +/// kept as the choice's [`Choice::refused`] until the agent accepts a value +/// or `wanted` is none or the value the session holds; the turn goes ahead on +/// the value the session holds. async fn choose( live: &mut Live, session: &str, @@ -844,9 +850,11 @@ async fn choose( let Some(option) = live.choices.find(category) else { return Ok(()); }; - let Some(wanted) = - wanted.filter(|w| *w != option.current && option.values.iter().any(|v| v == w)) - else { + if wanted.is_none_or(|w| w == option.current) { + live.choices.accept(category); + return Ok(()); + } + let Some(wanted) = wanted.filter(|w| option.values.iter().any(|v| v == w)) else { return Ok(()); }; let params = json!({ "sessionId": session, "configId": option.id, "value": wanted }); @@ -1578,4 +1586,57 @@ done Some(choice("m/plain", &["m/think", "m/plain"])) ); } + + #[tokio::test] + async fn a_refusal_is_dropped_when_the_agent_is_respawned() { + let dir = tempfile::tempdir().unwrap(); + let env = [("OPTIONS", "1"), ("REFUSE", "m/plain")]; + let runtime = agent(dir.path(), "ok", &env, policy(0)); + let choices = runtime.choices().unwrap(); + let mut config = Config { + model: Some("m/plain".into()), + ..config(dir.path()) + }; + runtime.run(&config, "one", &NoopSink).await.unwrap(); + assert_eq!( + choices.get().model.and_then(|c| c.refused).as_deref(), + Some("m/plain") + ); + + // `haiku` is not on offer, so the new process is asked to set nothing. + *runtime.live.lock().await = None; + config.model = Some("haiku".into()); + runtime.run(&config, "two", &NoopSink).await.unwrap(); + assert_eq!(count(dir.path(), "start"), 2); + assert_eq!(sets(dir.path()), ["model=m/plain"]); + assert_eq!( + choices.get().model, + Some(choice("m/think", &["m/think", "m/plain"])) + ); + } + + #[tokio::test] + async fn a_refusal_is_dropped_when_the_session_s_own_value_is_wanted() { + let dir = tempfile::tempdir().unwrap(); + let env = [("OPTIONS", "1"), ("REFUSE", "m/plain")]; + let runtime = agent(dir.path(), "ok", &env, policy(0)); + let choices = runtime.choices().unwrap(); + let mut config = Config { + model: Some("m/plain".into()), + ..config(dir.path()) + }; + runtime.run(&config, "one", &NoopSink).await.unwrap(); + assert_eq!( + choices.get().model.and_then(|c| c.refused).as_deref(), + Some("m/plain") + ); + + config.model = Some("m/think".into()); + runtime.run(&config, "two", &NoopSink).await.unwrap(); + assert_eq!(sets(dir.path()), ["model=m/plain"]); + assert_eq!( + choices.get().model, + Some(choice("m/think", &["m/think", "m/plain"])) + ); + } }