Watch
0
0
Fork
You've already forked hyperhive
0

hive-runtime, hive-agent: model/effort picker for ACP agents from configOptions

An ACP agent's model and effort pickers now list what its session offers
(its `model` and `thought_level` config options) instead of the claude
model list and EFFORT_LEVELS. A pick goes through the same Bus::set_model /
Bus::set_effort -> Config.model / Config.effort path as on claude; before
each prompt the ACP runtime sets it with `session/set_config_option`, model
first, and only when the session offers that value. Options are re-read
from the set response and from `config_option_update`, so the effort picker
disappears when the chosen model offers no effort levels, and is hidden
while a newly picked model waits for the next turn.

The session's options reach the web UI through a `Choices` handle from a
new `Runtime::choices`, registered on the bus the way `canceller` is.
/api/model and /api/effort accept only offered values on ACP. The claude
path is unchanged.

Refs #4391
This commit is contained in:
atlas 2026-09-30 07:59:27 +02:00
commit f189724a4c
13 changed files with 559 additions and 44 deletions

View file

@ -212,6 +212,61 @@ pub(super) fn advertised_commands(update: &Value) -> Option<Vec<String>> {
)
}
/// One select config option a session advertises.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ConfigOption {
pub(super) id: String,
pub(super) category: Option<String>,
pub(super) current: String,
pub(super) values: Vec<String>,
}
/// The select options in `value`'s `configOptions`, as a session response, a
/// `session/set_config_option` response and a `config_option_update` carry
/// them; `None` if it has none. Grouped values are flattened; boolean options
/// are left out.
pub(super) fn config_options(value: &Value) -> Option<Vec<ConfigOption>> {
let options = value.get("configOptions")?.as_array()?;
Some(
options
.iter()
.filter_map(|option| {
let entries = option.get("options").and_then(Value::as_array);
let values = entries
.into_iter()
.flatten()
.flat_map(
|entry| match entry.get("options").and_then(Value::as_array) {
Some(group) => group.iter().collect(),
None => vec![entry],
},
)
.filter_map(|entry| entry.get("value").and_then(Value::as_str))
.map(str::to_owned)
.collect();
Some(ConfigOption {
id: option.get("id")?.as_str()?.to_owned(),
category: option
.get("category")
.and_then(Value::as_str)
.map(str::to_owned),
current: option.get("currentValue")?.as_str()?.to_owned(),
values,
})
})
.collect(),
)
}
/// The options a `config_option_update` sets, or `None` for any other
/// `update`. Each replaces the session's previous options.
pub(super) fn updated_config_options(update: &Value) -> Option<Vec<ConfigOption>> {
if update.get("sessionUpdate").and_then(Value::as_str) != Some("config_option_update") {
return None;
}
Some(config_options(update).unwrap_or_default())
}
/// Convert a claude `--mcp-config` document into ACP's `mcpServers` list.
/// Returns the list and the server names, in the same order.
pub(super) fn mcp_servers(config: &Value) -> (Vec<Value>, Vec<String>) {
@ -346,7 +401,9 @@ fn tool_result(id: &str, output: &str, is_error: bool) -> Value {
#[cfg(test)]
mod tests {
use super::{StreamMapper, canonical_tool_name, mcp_servers, session_model};
use super::{
ConfigOption, StreamMapper, canonical_tool_name, config_options, mcp_servers, session_model,
};
use serde_json::{Value, json};
fn mapper() -> StreamMapper {
@ -547,4 +604,31 @@ mod tests {
assert_eq!(session_model(&resp).as_deref(), Some("p/m-2"));
assert_eq!(session_model(&json!({})), None);
}
#[test]
fn config_options_flatten_groups_and_skip_booleans() {
let resp = json!({
"configOptions": [
{ "id": "model", "name": "Model", "category": "model", "type": "select",
"currentValue": "p/a",
"options": [
{ "group": "p", "name": "P", "options": [
{ "value": "p/a", "name": "A" }, { "value": "p/b", "name": "B" },
] },
{ "value": "q/c", "name": "C" },
] },
{ "id": "fast", "name": "Fast", "type": "boolean", "currentValue": true },
],
});
assert_eq!(
config_options(&resp),
Some(vec![ConfigOption {
id: "model".into(),
category: Some("model".into()),
current: "p/a".into(),
values: vec!["p/a".into(), "p/b".into(), "q/c".into()],
}])
);
assert_eq!(config_options(&json!({})), None);
}
}