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"])) + ); + } }