Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9b11006
feat(desktop): harness-agnostic config bridge
wpfleger96 Jun 12, 2026
1b89faf
feat(config-bridge): resolve persona config in a shared helper and re…
Jun 16, 2026
7b24664
feat(config-bridge): live model override and folded config panel
Jun 17, 2026
6b0de60
fix(config-bridge): gate runtime override on harness model_overridden…
Jun 17, 2026
b732e6e
fix(desktop): use rem text tokens in config-bridge UI
Jun 17, 2026
43efdd4
test(desktop): unit-pin multi-channel live-switch fail-fast logic
Jun 17, 2026
4819aa4
test: harden liveSwitchOutcome scenario 2 interim resolve guard
Jun 17, 2026
5768c5a
fix(desktop): surface genuine-explicit live model switch in config panel
Jun 17, 2026
013d50b
fix(desktop): stop build_model_field leaking acp override into secondary
Jun 17, 2026
0622205
test(desktop): re-ground config-bridge screenshot spec to sentence UI
Jun 18, 2026
959de21
fix(desktop): integrate config section into profile panel, add value …
Jun 24, 2026
afb8923
fix(desktop): show full override text on hover in agent config
Jun 24, 2026
a57397e
fix(desktop): add missing ManagedAgentRecord fields to test helpers
wpfleger96 Jun 25, 2026
96a060a
test(e2e): add profile side panel config screenshot (shot 06)
Jun 25, 2026
af248f1
feat(config-bridge): replace hardcoded field lists with schema-driven…
Jun 25, 2026
9b394c4
docs: clarify schema_walker object traversal, annotate provider_locke…
Jun 25, 2026
8aabd2a
refactor(config-bridge): replace schema-driven with config-driven fie…
Jun 25, 2026
706a5a3
fix(desktop): use effective_agent_command and surface all env vars in…
Jun 25, 2026
cfcf017
fix(desktop): improve config bridge provider display and deep config …
Jun 25, 2026
cb69412
fix(desktop): add harnessConstraint origin to TypeScript config bridg…
Jun 25, 2026
2b97dce
fix(desktop): address Thufir review findings and surface scalar array…
Jun 25, 2026
c8ed559
feat(config-bridge): add is_required to NormalizedField
Jun 26, 2026
ee6771e
refactor(config-bridge): cut write path, extract resolve_with_overrid…
Jun 26, 2026
4121e18
fix(config-bridge): address PR #887 review feedback
Jun 26, 2026
615ec2c
fix(config-bridge): clear session cache at 3 missed High-5 call sites
Jun 26, 2026
90e4b33
chore(config-bridge): fix rustfmt and biome formatting
wpfleger96 Jun 27, 2026
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
91 changes: 91 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,43 @@ pub fn resolve_model_switch_method(
None
}

/// Whether `desired_model` appears in pre-extracted catalog halves.
///
/// Mirrors [`resolve_model_switch_method`]'s match, but operates on the
/// already-extracted `configOptions` (model category) and `models` state that
/// [`AgentModelCapabilities`](crate::pool::AgentModelCapabilities) caches — the
/// idle-path pre-cancel guard has those halves, not the full `session/new` JSON.
pub fn model_in_catalog(
config_options: &[serde_json::Value],
available_models: Option<&serde_json::Value>,
desired_model: &str,
) -> bool {
let in_config_options = config_options.iter().any(|config_opt| {
config_opt
.get("options")
.and_then(|v| v.as_array())
.is_some_and(|options| {
options
.iter()
.any(|opt| opt.get("value").and_then(|v| v.as_str()) == Some(desired_model))
})
});
if in_config_options {
return true;
}

available_models
.and_then(|models| models.get("availableModels"))
.and_then(|v| v.as_array())
.is_some_and(|available| {
available
.iter()
.any(|model| model.get("modelId").and_then(|v| v.as_str()) == Some(desired_model))
})
}

// ─── Drop: kill child process ─────────────────────────────────────────────────

impl Drop for AcpClient {
fn drop(&mut self) {
// Best-effort SIGKILL + reap. We cannot `await` in Drop (sync context).
Expand Down Expand Up @@ -1755,6 +1792,60 @@ mod tests {
);
}

// ── model_in_catalog tests ────────────────────────────────────────────

#[test]
fn model_in_catalog_true_when_in_config_options() {
let config_options = vec![serde_json::json!({
"configId": "model",
"category": "model",
"options": [
{ "value": "claude-sonnet-4-20250514" },
{ "value": "claude-opus-4-20250514" }
]
})];
assert!(super::model_in_catalog(
&config_options,
None,
"claude-opus-4-20250514"
));
}

#[test]
fn model_in_catalog_true_when_in_available_models() {
let available = serde_json::json!({
"currentModelId": "gpt-5",
"availableModels": [
{ "modelId": "gpt-5" },
{ "modelId": "o3-pro" }
]
});
assert!(super::model_in_catalog(&[], Some(&available), "o3-pro"));
}

#[test]
fn model_in_catalog_false_when_absent_from_both_halves() {
let config_options = vec![serde_json::json!({
"configId": "model",
"options": [{ "value": "claude-sonnet-4-20250514" }]
})];
let available = serde_json::json!({
"availableModels": [{ "modelId": "gpt-5" }]
});
assert!(!super::model_in_catalog(
&config_options,
Some(&available),
"nonexistent-model"
));
}

#[test]
fn model_in_catalog_false_when_both_halves_empty() {
assert!(!super::model_in_catalog(&[], None, "anything"));
}

// ── Error variant display ─────────────────────────────────────────────

#[test]
fn idle_timeout_error_includes_duration() {
let err = AcpError::IdleTimeout(std::time::Duration::from_secs(320));
Expand Down
106 changes: 100 additions & 6 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ use filter::SubscriptionRule;
use futures_util::FutureExt;
use nostr::{PublicKey, ToBech32};
use pool::{
AgentPool, ControlSignal, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource,
SessionState,
AgentPool, ControlSignal, IdleSwitchResult, OwnedAgent, PromptContext, PromptOutcome,
PromptResult, PromptSource, SessionState,
};
use queue::{EventQueue, QueuedEvent, ThreadTags};
use relay::{HarnessRelay, RelayEventPublisher};
Expand Down Expand Up @@ -710,11 +710,25 @@ fn handle_relay_observer_control_event(
};

let command_type = payload.get("type").and_then(|value| value.as_str());
if command_type != Some("cancel_turn") {
tracing::debug!(payload = %payload, "ignoring unknown observer control frame");
return;
match command_type {
Some("cancel_turn") => {
handle_cancel_turn_control(&payload, pool, observer);
}
Some("switch_model") => {
handle_switch_model_control(&payload, pool, observer);
}
_ => {
tracing::debug!(payload = %payload, "ignoring unknown observer control frame");
}
}
}

/// Handle a `cancel_turn` control frame: signal the in-flight task to cancel.
fn handle_cancel_turn_control(
payload: &serde_json::Value,
pool: &mut AgentPool,
observer: Option<&observer::ObserverHandle>,
) {
let Some(channel_id) = payload
.get("channelId")
.and_then(|value| value.as_str())
Expand Down Expand Up @@ -743,6 +757,83 @@ fn handle_relay_observer_control_event(
}
}

/// Handle a `switch_model` control frame (Phase 3a, Option ii).
///
/// Busy path: deliver `SwitchModel` over the in-flight task's oneshot — the
/// task cancels the turn, sets `desired_model`, and requeues the batch so it
/// re-runs on a fresh session under the new model. A catalog miss surfaces
/// post-cancel via `create_session_and_apply_model` (the turn restarts on the
/// unchanged model + an `unsupported_model` result).
///
/// Idle path: validate against the cached catalog *before* invalidating
/// (pre-cancel guard), then set `desired_model` + invalidate. The override
/// takes visible effect on the agent's next turn.
fn handle_switch_model_control(
payload: &serde_json::Value,
pool: &mut AgentPool,
observer: Option<&observer::ObserverHandle>,
) {
let Some(channel_id) = payload
.get("channelId")
.and_then(|value| value.as_str())
.and_then(|value| value.parse::<Uuid>().ok())
else {
tracing::warn!("observer switch_model control frame missing valid channelId");
return;
};
let Some(model_id) = payload.get("modelId").and_then(|value| value.as_str()) else {
tracing::warn!("observer switch_model control frame missing modelId");
return;
};

// A turn is in flight for this channel iff a task_map entry exists. The
// agent is moved out of the pool during a turn, so the control oneshot is
// the only reachable lever; an idle channel has no such entry.
let turn_in_flight = pool
.task_map()
.values()
.any(|m| m.channel_id == Some(channel_id));

let status = if turn_in_flight {
// Busy path: deliver over the oneshot. `false` means the oneshot was
// already consumed this turn (a prior cancel/interrupt) — the turn is
// already ending, so the switch cannot land on it.
if signal_in_flight_task(
pool,
channel_id,
ControlSignal::SwitchModel(model_id.to_string()),
) {
"sent"
} else {
"turn_ending"
}
} else {
// Idle path: validate against the cached catalog before invalidating.
match pool.switch_idle_agent_model(channel_id, model_id) {
IdleSwitchResult::Switched => "switched",
IdleSwitchResult::UnsupportedModel => "unsupported_model",
IdleSwitchResult::NoIdleAgent => "no_active_turn",
}
};

if let Some(observer) = observer {
observer.emit(
"control_result",
None,
&observer::ObserverContext {
channel_id: Some(channel_id.to_string()),
session_id: None,
turn_id: None,
},
serde_json::json!({
"type": "switch_model",
"status": status,
"modelId": model_id,
}),
);
}
}

/// Maximum crashes in a 60-second window before a slot's circuit opens.
const CIRCUIT_BREAKER_THRESHOLD: usize = 3;
/// Window for circuit-breaker crash counting.
Expand Down Expand Up @@ -1035,6 +1126,7 @@ async fn tokio_main() -> Result<()> {
state: SessionState::default(),
model_capabilities: None,
desired_model: config.model.clone(),
model_overridden: false,
protocol_version,
}));
}
Expand Down Expand Up @@ -1455,6 +1547,7 @@ async fn tokio_main() -> Result<()> {
state: SessionState::default(),
model_capabilities: None,
desired_model: config.model.clone(),
model_overridden: false,
protocol_version,
};
pool.return_agent(agent);
Expand Down Expand Up @@ -2115,8 +2208,8 @@ fn signal_in_flight_task(

if let Some(meta) = entry {
if let Some(tx) = meta.control_tx.take() {
let _ = tx.send(mode);
tracing::info!(channel = %channel_id, ?mode, "control signal sent to in-flight task");
let _ = tx.send(mode);
return true;
}
}
Expand Down Expand Up @@ -3421,6 +3514,7 @@ mod error_outcome_emission_tests {
state: Default::default(),
model_capabilities: None,
desired_model: None,
model_overridden: false,
// Error branches under test never read this; 1 is the legacy
// non-systemPrompt path, the simplest valid value.
protocol_version: 1,
Expand Down
Loading
Loading