agents: remove the hive-level /effort control too
mara on #5034: "effort shares /models fate". The per-agent web UI no longer sets effort: the `/effort` slash command, the effort picker, `POST /api/effort`, `Bus::set_effort`, the `hyperhive-effort` writer, `EFFORT_LEVELS`/`is_valid_effort` and the ACP effort-list payload are gone, and `lib/modelEffort.ts` with them. The effort chip is display-only and shows its source (`effort_source` in `/api/state`); on ACP it stays hidden while the session offers no effort level. The read side is unchanged: with no swarm value an agent runs an existing `hyperhive-effort` file, else its agent.nix effort, else medium. Existing files are not touched. Refs #5034
This commit is contained in:
parent
594693f4c7
commit
ed271e655f
25 changed files with 170 additions and 464 deletions
|
|
@ -21,7 +21,7 @@ use tokio::sync::broadcast;
|
|||
|
||||
use crate::harness_state::{
|
||||
configured_effort, configured_model, context_window_tokens, local_effort, local_model,
|
||||
persist_effort, read_harness_state, write_harness_state,
|
||||
read_harness_state, write_harness_state,
|
||||
};
|
||||
|
||||
const CHANNEL_CAPACITY: usize = 256;
|
||||
|
|
@ -119,9 +119,8 @@ pub enum LiveEvent {
|
|||
/// The web UI updates the chip + the per-turn stats sink will key off this
|
||||
/// to mark the boundary in its log.
|
||||
ModelChanged { model: String },
|
||||
/// `/api/effort` switched the active claude effort level. Applies on
|
||||
/// the next session start; the web UI updates the picker chip to
|
||||
/// reflect it.
|
||||
/// The active claude effort level changed (a swarm value applied or
|
||||
/// cleared). The web UI updates the chip.
|
||||
EffortChanged { effort: String },
|
||||
/// Token usage for the turn just ended. Carries two snapshots:
|
||||
/// - `ctx` is the LAST inference's usage block (the actual context
|
||||
|
|
@ -341,7 +340,7 @@ pub struct Bus {
|
|||
turn_canceller: Arc<OnceLock<hive_runtime::Canceller>>,
|
||||
/// The model and effort the session offers, for a runtime that reads them
|
||||
/// from its session (ACP). Set once by the serve loop when it builds the
|
||||
/// session; unset on claude, whose effort picker lists `EFFORT_LEVELS`.
|
||||
/// session; unset on claude.
|
||||
session_choices: Arc<OnceLock<hive_runtime::Choices>>,
|
||||
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the
|
||||
/// bin loop after minting a session row on a fresh start; stamped onto
|
||||
|
|
@ -601,26 +600,9 @@ impl Bus {
|
|||
self.effort.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Switch the effort level for future sessions. Applies on the next
|
||||
/// claude launch (no mid-session swap). Persisted to the agent's
|
||||
/// state dir (`hyperhive-effort`) so the override survives harness
|
||||
/// restart and container rebuild (gone on `--purge`). Callers must
|
||||
/// pre-validate with [`crate::harness_state::is_valid_effort`].
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal lock is poisoned.
|
||||
pub fn set_effort(&self, level: impl Into<String>) {
|
||||
let value: String = level.into();
|
||||
if let Err(e) = persist_effort(&value) {
|
||||
tracing::warn!(error = ?e, "effort: persist failed");
|
||||
}
|
||||
self.apply_effort(value, Source::LocalFile);
|
||||
}
|
||||
|
||||
/// Switch the effort level without persisting it, so `hyperhive-effort`
|
||||
/// keeps the agent's local value. For a value the swarm sets
|
||||
/// (`crate::swarm_inference`). Emits `EffortChanged` even when only
|
||||
/// Switch the effort level for future turns. Never writes
|
||||
/// `hyperhive-effort`, so clearing the swarm value finds the agent's local
|
||||
/// value again. For a value the swarm sets (`crate::swarm_inference`). Emits `EffortChanged` even when only
|
||||
/// `source` changed.
|
||||
///
|
||||
/// # Panics
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ pub(crate) fn load_model() -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Path to the persisted effort-level file. Sibling of `hyperhive-model`,
|
||||
/// overridable via `HYPERHIVE_EFFORT_FILE` for dev / tests; otherwise
|
||||
/// derived from the agent's harness dir.
|
||||
/// Path to the `hyperhive-effort` file. The harness only reads it, as the
|
||||
/// fallback in [`local_effort`]. Overridable via `HYPERHIVE_EFFORT_FILE` for
|
||||
/// dev / tests; otherwise derived from the agent's harness dir.
|
||||
fn effort_file_path() -> PathBuf {
|
||||
std::env::var_os("HYPERHIVE_EFFORT_FILE").map_or_else(
|
||||
|| crate::paths::harness_dir().join("hyperhive-effort"),
|
||||
|
|
@ -65,10 +65,9 @@ fn model_from(configured: Option<&str>, file: Option<String>) -> (String, Source
|
|||
}
|
||||
|
||||
/// The effort this agent runs at when the swarm sets none, and where it
|
||||
/// comes from. The inverse of [`local_model`]: a persisted operator pick
|
||||
/// wins over `configured` (`HIVE_DEFAULT_EFFORT`), so the runtime choice
|
||||
/// survives a harness restart (it only resets on `--purge`); nix is the
|
||||
/// default for a never-touched agent.
|
||||
/// comes from. The inverse of [`local_model`]: an existing
|
||||
/// `hyperhive-effort` file wins over `configured` (`HIVE_DEFAULT_EFFORT`);
|
||||
/// nix is the default for an agent without one.
|
||||
pub(crate) fn local_effort(configured: Option<&str>) -> (String, Source) {
|
||||
effort_from(configured, load_effort())
|
||||
}
|
||||
|
|
@ -81,14 +80,6 @@ fn effort_from(configured: Option<&str>, file: Option<String>) -> (String, Sourc
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_effort(level: &str) -> std::io::Result<()> {
|
||||
let path = effort_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
std::fs::write(path, format!("{level}\n"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consolidated harness state file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -233,22 +224,11 @@ pub fn configured_model() -> Option<&'static str> {
|
|||
/// into `prompts/claude-settings.json`.
|
||||
pub const DEFAULT_EFFORT: &str = "medium";
|
||||
|
||||
/// Valid claude `--effort` levels, ascending. The operator picker is
|
||||
/// constrained to these; [`is_valid_effort`] guards the persist path.
|
||||
pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
/// True iff `level` is one of [`EFFORT_LEVELS`].
|
||||
#[must_use]
|
||||
pub fn is_valid_effort(level: &str) -> bool {
|
||||
EFFORT_LEVELS.contains(&level)
|
||||
}
|
||||
|
||||
/// Return the effort level declared in `HIVE_DEFAULT_EFFORT` (set from
|
||||
/// `services.hyperhive.agent.effortLevel` in `agent.nix`), or `None` if absent / empty.
|
||||
/// Mirrors [`configured_model`]'s env shape. Unlike model, the persisted
|
||||
/// runtime override takes precedence over this baseline (see `Bus::new`):
|
||||
/// the operator's effort pick sticks across harness restart, with the nix
|
||||
/// value only the default when no override was ever set.
|
||||
/// Mirrors [`configured_model`]'s env shape. Unlike model, a
|
||||
/// `hyperhive-effort` file takes precedence over this baseline (see
|
||||
/// [`local_effort`]).
|
||||
#[must_use]
|
||||
pub fn configured_effort() -> Option<&'static str> {
|
||||
std::env::var("HIVE_DEFAULT_EFFORT")
|
||||
|
|
@ -298,28 +278,11 @@ pub fn context_window_tokens(model: &str) -> u64 {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
DEFAULT_EFFORT, DEFAULT_MODEL, EFFORT_LEVELS, Source, effort_from, is_valid_effort,
|
||||
model_from,
|
||||
};
|
||||
use super::{DEFAULT_EFFORT, DEFAULT_MODEL, Source, effort_from, model_from};
|
||||
|
||||
#[test]
|
||||
fn effort_validation_accepts_only_known_levels() {
|
||||
for level in EFFORT_LEVELS {
|
||||
assert!(is_valid_effort(level), "{level} should be valid");
|
||||
}
|
||||
// Out-of-set, empty, and wrong-case inputs are rejected so a bad
|
||||
// picker POST never reaches `claude --effort`.
|
||||
for bad in ["lowest", "", "MEDIUM", "ultra", "high "] {
|
||||
assert!(!is_valid_effort(bad), "{bad:?} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiled_default_effort_is_selectable() {
|
||||
// The fallback must itself be a valid level, and match the value
|
||||
// baked into prompts/claude-settings.json.
|
||||
assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT));
|
||||
fn compiled_default_effort_matches_claude_settings() {
|
||||
// The value baked into prompts/claude-settings.json.
|
||||
assert_eq!(DEFAULT_EFFORT, "medium");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ mod tests {
|
|||
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
|
||||
let bus = Bus::new();
|
||||
std::fs::write(dir.path().join("hyperhive-model"), "sonnet\n").unwrap();
|
||||
bus.set_effort("low");
|
||||
std::fs::write(dir.path().join("hyperhive-effort"), "low\n").unwrap();
|
||||
let target = Harness {
|
||||
bus: bus.clone(),
|
||||
nix_model: None,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Operator action POST handlers (send, cancel, compact, model, effort,
|
||||
//! reset, todos mark-done).
|
||||
//! Operator action POST handlers (send, cancel, compact, reset, todos
|
||||
//! mark-done).
|
||||
|
||||
use axum::{
|
||||
Form,
|
||||
|
|
@ -126,68 +126,6 @@ pub(super) async fn post_new_session(State(state): State<AppState>) -> Response
|
|||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct EffortForm {
|
||||
effort: String,
|
||||
}
|
||||
|
||||
/// Switch the claude effort level for future sessions. Operator-only
|
||||
/// (the dashboard picker POSTs here through the gateway). Validated
|
||||
/// server-side against [`crate::harness_state::EFFORT_LEVELS`] — an out-of-set
|
||||
/// value is rejected rather than handed to `claude --effort`, since an
|
||||
/// unknown level would fail every subsequent launch. Applies on the next
|
||||
/// session start (no mid-session swap). On ACP the level must instead be one
|
||||
/// the session offers for its model, and applies from the next turn.
|
||||
pub(super) async fn post_set_effort(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<EffortForm>,
|
||||
) -> Response {
|
||||
let level = form.effort.trim();
|
||||
let text = if state.bus.session_choices().is_some() {
|
||||
let offered = super::state::pickers(&state.bus).available_efforts;
|
||||
if let Some(response) = accept_or_refuse("effort", level, &offered) {
|
||||
return response;
|
||||
}
|
||||
format!("operator: /effort — effort set to '{level}' from the next turn")
|
||||
} else if crate::harness_state::is_valid_effort(level) {
|
||||
format!("operator: /effort — claude effort set to '{level}' for future sessions")
|
||||
} else {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!(
|
||||
"effort: level must be one of {}",
|
||||
crate::harness_state::EFFORT_LEVELS.join(", ")
|
||||
),
|
||||
);
|
||||
};
|
||||
state.bus.set_effort(level);
|
||||
state.bus.emit(crate::events::LiveEvent::Note { text });
|
||||
tracing::info!(%level, "operator set effort");
|
||||
(axum::http::StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
/// The ACP branch of `post_set_effort`: `None` when `name`
|
||||
/// is one `offered` (the session's current pickers list) names, else the
|
||||
/// [`not_offered`] refusal to return. Before a session has attached,
|
||||
/// `offered` is empty and every name is refused.
|
||||
fn accept_or_refuse(what: &str, name: &str, offered: &[String]) -> Option<Response> {
|
||||
if offered.iter().any(|o| o == name) {
|
||||
None
|
||||
} else {
|
||||
Some(not_offered(what, offered))
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses a pick the ACP session does not offer.
|
||||
fn not_offered(what: &str, offered: &[String]) -> Response {
|
||||
let message = if offered.is_empty() {
|
||||
format!("{what}: the agent's session offers no choice now")
|
||||
} else {
|
||||
format!("{what}: the agent's session offers {}", offered.join(", "))
|
||||
};
|
||||
error_response(StatusCode::BAD_REQUEST, &message)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct MarkTodosDoneForm {
|
||||
/// Comma-separated todo ids. Same "one field, JS joins the checked
|
||||
|
|
@ -230,31 +168,3 @@ pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) ->
|
|||
struct MarkTodosDoneBody {
|
||||
acked: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::accept_or_refuse;
|
||||
|
||||
#[test]
|
||||
fn an_offered_value_is_accepted() {
|
||||
let offered = vec!["m/think".to_owned(), "m/plain".to_owned()];
|
||||
assert!(accept_or_refuse("model", "m/plain", &offered).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_the_session_does_not_offer_is_refused_with_400() {
|
||||
let offered = vec!["m/think".to_owned()];
|
||||
let response = accept_or_refuse("model", "m/plain", &offered).unwrap();
|
||||
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_offered_yet_refuses_with_400() {
|
||||
// Before a session has attached (or on the effort picker while a
|
||||
// model switch is pending), `pickers()` returns an empty list —
|
||||
// every name is refused, not just ones the session once offered
|
||||
// and later dropped.
|
||||
let response = accept_or_refuse("effort", "high", &[]).unwrap();
|
||||
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ fn routes() -> Router<AppState> {
|
|||
.route("/login/cancel", post(auth::post_login_cancel))
|
||||
.route("/api/cancel", post(actions::post_cancel_turn))
|
||||
.route("/api/compact", post(actions::post_compact))
|
||||
.route("/api/effort", post(actions::post_set_effort))
|
||||
.route("/api/new-session", post(actions::post_new_session))
|
||||
.route("/api/logout", post(auth::post_logout))
|
||||
.route("/api/todos", get(stats::api_todos))
|
||||
|
|
@ -444,10 +443,10 @@ mod tests {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
/// The swarm sets the model, so the agent serves no route for it; effort
|
||||
/// is still set here.
|
||||
/// The swarm sets the model and effort, so the agent serves no route for
|
||||
/// either.
|
||||
#[test]
|
||||
fn the_model_has_no_route_and_effort_still_does() {
|
||||
fn neither_model_nor_effort_has_a_route() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let _env = crate::paths::HARNESS_DIR_ENV
|
||||
.lock()
|
||||
|
|
@ -465,15 +464,20 @@ mod tests {
|
|||
interrupted: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
let app = routes().with_state(state);
|
||||
let (model, effort) = tokio::runtime::Builder::new_current_thread()
|
||||
let (model, effort, compact) = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let model = app.clone().oneshot(form("/api/model", "model=opus"));
|
||||
// An unknown level reaches the handler, which refuses it
|
||||
// before anything is applied.
|
||||
let effort = app.oneshot(form("/api/effort", "effort=bogus"));
|
||||
(model.await.unwrap(), effort.await.unwrap())
|
||||
let effort = app.clone().oneshot(form("/api/effort", "effort=high"));
|
||||
// A route the router does serve, so the 404s above are not
|
||||
// the router refusing everything.
|
||||
let compact = app.oneshot(form("/api/compact", ""));
|
||||
(
|
||||
model.await.unwrap(),
|
||||
effort.await.unwrap(),
|
||||
compact.await.unwrap(),
|
||||
)
|
||||
});
|
||||
match prev {
|
||||
// SAFETY: as above.
|
||||
|
|
@ -481,6 +485,7 @@ mod tests {
|
|||
None => unsafe { std::env::remove_var("HYPERHIVE_HARNESS_DIR") },
|
||||
}
|
||||
assert_eq!(model.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(effort.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(effort.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(compact.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
|
|||
let context_window_tokens = state.bus.effective_context_window(&model);
|
||||
let ctx_usage = state.bus.last_ctx_usage();
|
||||
let cost_usage = state.bus.last_cost_usage();
|
||||
let pickers = pickers(&state.bus);
|
||||
let shown = shown(&state.bus);
|
||||
axum::Json(StateSnapshot {
|
||||
seq,
|
||||
label: state.label.clone(),
|
||||
|
|
@ -58,7 +58,7 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
|
|||
inbox,
|
||||
turn_state,
|
||||
turn_state_since,
|
||||
model: pickers.model,
|
||||
model: shown.model,
|
||||
model_source: state.bus.model_source(),
|
||||
resolved_model,
|
||||
context_window_tokens,
|
||||
|
|
@ -70,8 +70,8 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State
|
|||
.filter(|s| !s.is_empty()),
|
||||
hive_name: crate::identity::hive_name(),
|
||||
swarm_name: crate::identity::swarm_name(),
|
||||
effort: pickers.effort,
|
||||
available_efforts: pickers.available_efforts,
|
||||
effort: shown.effort,
|
||||
effort_source: state.bus.effort_source(),
|
||||
paused: crate::paths::paused_marker().exists(),
|
||||
})
|
||||
}
|
||||
|
|
@ -182,17 +182,12 @@ pub(super) struct StateSnapshot {
|
|||
/// Human name of the swarm (e.g. `"constellat1on"`). Sourced from
|
||||
/// `HYPERHIVE_SWARM_NAME`; `None` when unset.
|
||||
swarm_name: Option<String>,
|
||||
/// Currently-active claude effort level. Reflected on the page so the
|
||||
/// operator's effort picker shows the live selection. Mutable at
|
||||
/// runtime via `POST /api/effort`; applies on the next session.
|
||||
effort: String,
|
||||
/// Selectable effort levels for the picker, ascending. Fixed set
|
||||
/// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from
|
||||
/// [`crate::harness_state::EFFORT_LEVELS`]. The frontend renders one
|
||||
/// button per entry.
|
||||
/// On ACP, the levels the session offers instead, empty when it offers
|
||||
/// none, which hides the picker (see [`pickers`]).
|
||||
available_efforts: Vec<String>,
|
||||
/// Currently-active claude effort level, shown on the page; `None` per
|
||||
/// [`Shown`]. The swarm sets it (`crate::swarm_inference`); the page
|
||||
/// cannot.
|
||||
effort: Option<String>,
|
||||
/// Where `effort` comes from.
|
||||
effort_source: Source,
|
||||
/// Whether this agent's turn loop is currently parked (the harness
|
||||
/// keeps serving this page + its MCP daemons but drives no turns).
|
||||
/// Same on-disk marker hive-c0re's `Coordinator::is_paused` checks
|
||||
|
|
@ -416,48 +411,36 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxR
|
|||
}
|
||||
}
|
||||
|
||||
/// The model the page shows, and the effort picker: the value it shows and
|
||||
/// the values it offers.
|
||||
/// The model and effort the page shows. No effort on an ACP agent whose
|
||||
/// session offers no effort level now.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) struct Pickers {
|
||||
pub(super) struct Shown {
|
||||
pub(super) model: String,
|
||||
pub(super) effort: String,
|
||||
pub(super) available_efforts: Vec<String>,
|
||||
pub(super) effort: Option<String>,
|
||||
}
|
||||
|
||||
/// On claude, the requested model and effort and
|
||||
/// [`crate::harness_state::EFFORT_LEVELS`]. On ACP, what the session offers
|
||||
/// (see [`offered_pickers`]).
|
||||
pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers {
|
||||
/// On claude, the requested model and effort. On ACP, see [`offered_shown`].
|
||||
pub(super) fn shown(bus: &crate::events::Bus) -> Shown {
|
||||
let (model, effort) = (bus.model(), bus.effort());
|
||||
match bus.session_choices() {
|
||||
Some(choices) => offered_pickers(model, effort, choices.get()),
|
||||
None => Pickers {
|
||||
Some(choices) => offered_shown(model, effort, choices.get()),
|
||||
None => Shown {
|
||||
model,
|
||||
effort,
|
||||
available_efforts: crate::harness_state::EFFORT_LEVELS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
effort: Some(effort),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Pickers for what an ACP session offers. The runtime sets a requested value
|
||||
/// on the next turn, and only if the session offers it, so the model and the
|
||||
/// effort each show the requested value when offered and not refused by the
|
||||
/// agent, and the session's own otherwise. The effort levels on offer come
|
||||
/// with the model, so while a new model waits for the next turn the effort
|
||||
/// picker is empty.
|
||||
fn offered_pickers(
|
||||
model: String,
|
||||
effort: String,
|
||||
offered: hive_runtime::SessionChoices,
|
||||
) -> Pickers {
|
||||
/// What an ACP agent shows. The runtime sets a requested value on the next
|
||||
/// turn, and only if the session offers it, so the model and the effort each
|
||||
/// show the requested value when offered and not refused by the agent, and
|
||||
/// the session's own otherwise. The effort levels on offer come with the
|
||||
/// model, so while a new model waits for the next turn no effort shows.
|
||||
fn offered_shown(model: String, effort: String, offered: hive_runtime::SessionChoices) -> Shown {
|
||||
let settable = |wanted: &String, c: &hive_runtime::Choice| {
|
||||
c.values.contains(wanted) && c.refused.as_ref() != Some(wanted)
|
||||
};
|
||||
let shown = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
|
||||
let pick = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
|
||||
Some(c) if !settable(&wanted, c) => c.current.clone(),
|
||||
_ => wanted,
|
||||
};
|
||||
|
|
@ -466,10 +449,9 @@ fn offered_pickers(
|
|||
.as_ref()
|
||||
.is_some_and(|c| c.current != model && settable(&model, c));
|
||||
let effort_choice = offered.effort.filter(|_| !model_pending);
|
||||
Pickers {
|
||||
model: shown(model, offered.model.as_ref()),
|
||||
effort: shown(effort, effort_choice.as_ref()),
|
||||
available_efforts: effort_choice.map(|c| c.values).unwrap_or_default(),
|
||||
Shown {
|
||||
model: pick(model, offered.model.as_ref()),
|
||||
effort: effort_choice.map(|c| pick(effort, Some(&c))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -477,7 +459,7 @@ fn offered_pickers(
|
|||
mod tests {
|
||||
use hive_runtime::{Choice, SessionChoices};
|
||||
|
||||
use super::{Pickers, offered_pickers};
|
||||
use super::{Shown, offered_shown};
|
||||
|
||||
fn choice(current: &str, values: &[&str]) -> Choice {
|
||||
Choice {
|
||||
|
|
@ -487,51 +469,52 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|v| (*v).to_owned()).collect()
|
||||
fn shown(model: &str, effort: Option<&str>) -> Shown {
|
||||
Shown {
|
||||
model: model.to_owned(),
|
||||
effort: effort.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_pickers_list_what_the_session_offers() {
|
||||
fn acp_shows_the_requested_values_the_session_offers() {
|
||||
let offered = SessionChoices {
|
||||
model: Some(choice("m/think", &["m/think", "m/plain"])),
|
||||
effort: Some(choice("low", &["low", "high"])),
|
||||
};
|
||||
// `haiku` and `max` are not on offer: the session's own values show.
|
||||
assert_eq!(
|
||||
offered_pickers("haiku".into(), "max".into(), offered.clone()),
|
||||
Pickers {
|
||||
model: "m/think".into(),
|
||||
effort: "low".into(),
|
||||
available_efforts: strings(&["low", "high"]),
|
||||
}
|
||||
offered_shown("haiku".into(), "max".into(), offered.clone()),
|
||||
shown("m/think", Some("low"))
|
||||
);
|
||||
assert_eq!(
|
||||
offered_shown("m/think".into(), "high".into(), offered),
|
||||
shown("m/think", Some("high"))
|
||||
);
|
||||
let picked = offered_pickers("m/think".into(), "high".into(), offered);
|
||||
assert_eq!(picked.effort, "high");
|
||||
assert_eq!(picked.available_efforts, strings(&["low", "high"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_effort_picker_hides_without_effort_or_while_a_model_is_pending() {
|
||||
let plain = SessionChoices {
|
||||
model: Some(choice("m/plain", &["m/think", "m/plain"])),
|
||||
effort: None,
|
||||
};
|
||||
let shown = offered_pickers("m/plain".into(), "high".into(), plain);
|
||||
assert_eq!(shown.model, "m/plain");
|
||||
assert!(shown.available_efforts.is_empty());
|
||||
|
||||
fn acp_shows_no_effort_without_one_on_offer_or_while_a_model_is_pending() {
|
||||
let think = SessionChoices {
|
||||
model: Some(choice("m/think", &["m/think", "m/plain"])),
|
||||
effort: Some(choice("low", &["low", "high"])),
|
||||
};
|
||||
let pending = offered_pickers("m/plain".into(), "high".into(), think);
|
||||
assert_eq!(pending.model, "m/plain");
|
||||
assert!(pending.available_efforts.is_empty());
|
||||
assert_eq!(
|
||||
offered_shown("m/plain".into(), "max".into(), think),
|
||||
shown("m/plain", None)
|
||||
);
|
||||
let plain = SessionChoices {
|
||||
model: Some(choice("m/plain", &["m/think", "m/plain"])),
|
||||
effort: None,
|
||||
};
|
||||
assert_eq!(
|
||||
offered_shown("m/plain".into(), "high".into(), plain),
|
||||
shown("m/plain", None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_pickers_show_the_session_s_model_once_the_agent_refuses_the_wanted_one() {
|
||||
fn acp_shows_the_session_s_model_once_the_agent_refuses_the_wanted_one() {
|
||||
let refused = SessionChoices {
|
||||
model: Some(Choice {
|
||||
refused: Some("m/plain".into()),
|
||||
|
|
@ -540,18 +523,16 @@ mod tests {
|
|||
effort: Some(choice("low", &["low", "high"])),
|
||||
};
|
||||
assert_eq!(
|
||||
offered_pickers("m/plain".into(), "high".into(), refused),
|
||||
Pickers {
|
||||
model: "m/think".into(),
|
||||
effort: "high".into(),
|
||||
available_efforts: strings(&["low", "high"]),
|
||||
}
|
||||
offered_shown("m/plain".into(), "high".into(), refused),
|
||||
shown("m/think", Some("high"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_pickers_are_empty_before_a_session_offers_anything() {
|
||||
let none = offered_pickers("haiku".into(), "high".into(), SessionChoices::default());
|
||||
assert!(none.available_efforts.is_empty());
|
||||
fn acp_shows_the_requested_model_and_no_effort_before_a_session_offers_anything() {
|
||||
assert_eq!(
|
||||
offered_shown("haiku".into(), "high".into(), SessionChoices::default()),
|
||||
shown("haiku", None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue