Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,15 @@ The gate applies to **all** inbound events — @mentions, DMs, thread replies, a
| `!shutdown` | Gracefully exits the harness. |
| `!cancel` | Cancels the current in-flight turn for that channel, if any. |
| `!rotate` | Rotates the ACP session for that channel. If a turn is in-flight, it is cancelled and the channel session is invalidated when the task returns; otherwise the cached idle session is invalidated immediately. The next queued/received event starts a fresh session. |
| `!model [id]` | Switches the model backing that channel and replies in-channel. If a turn is in-flight, it is cancelled and re-run on the new model; otherwise the model applies on the next turn. With no `id`, lists the available models. |

Use `!cancel` to stop only the current turn; it is a no-op when the channel is idle. Use `!rotate` when you want the next turn in the channel to start from a fresh ACP session, even if the channel is currently idle.

Owner control commands must be kind:9 stream messages from the owner, must mention this agent with a `p` tag, and are consumed by the harness instead of being forwarded to the agent.
`!model` matches model IDs exactly — adapters ship near-identical pairs (`opus[1m]` vs `claude-opus-5`, `gpt-5.3-codex` vs `gpt-5.3-codex/low`) where a prefix match would silently pick a different context lane and price point. An unknown ID is rejected with the list and leaves the current model untouched.

Owner control commands must be kind:9 stream messages from the owner, must mention this agent with a `p` tag, and are consumed by the harness instead of being forwarded to the agent. The command may be typed on its own (`!rotate`) or immediately after the leading `@mention` tokens (`@Agent !rotate`, `@Sol @Eva !rotate`).

The bang must be at that command position. A bang appearing later in the message is ordinary text, so asking an agent *about* a command — `@Agent what happens if I use !shutdown` — is delivered as a normal message rather than executed. Since the harness has no display-name list, a mention holding spaces (`@Will Pfleger !rotate`) does not reach command position either; address such an agent with a single-token handle to use control commands.

> **Note:** The default mode is `owner-only`. Agents without a registered `agent_owner_pubkey` will not respond to any events until the owner is resolved. Set `--respond-to anyone` to disable the gate entirely.

Expand Down
70 changes: 62 additions & 8 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1869,6 +1869,30 @@ pub fn extract_model_config_options(result: &serde_json::Value) -> Vec<serde_jso
.unwrap_or_default()
}

/// Identifier of a `configOptions` entry.
///
/// Adapters disagree on the key: the ACP spec says `configId` on the request
/// (`SetSessionConfigOptionRequest.configId`), while responses carry `id`
/// (`SessionConfigOption.id`). Accept both; the set request always uses
/// `configId` on the wire.
pub fn config_option_id(config_opt: &serde_json::Value) -> Option<&str> {
config_opt
.get("configId")
.or_else(|| config_opt.get("id"))
.and_then(|v| v.as_str())
}

/// Human-readable label of a `configOptions` entry or one of its options.
///
/// The schema field is `name`; `displayName` is a pre-standardization spelling
/// still emitted by some adapters.
pub fn config_option_label(value: &serde_json::Value) -> Option<&str> {
value
.get("name")
.or_else(|| value.get("displayName"))
.and_then(|v| v.as_str())
}

/// Extract `SessionModelState` (unstable path) from a `session/new` result.
///
/// Returns the `models` object if present: `{ currentModelId, availableModels: [...] }`.
Expand All @@ -1889,14 +1913,7 @@ pub fn resolve_model_switch_method(
// 1. Search stable configOptions for a "model"-category entry whose
// options contain a value matching desired_model.
for config_opt in extract_model_config_options(session_new_result) {
// Adapters disagree on the key: the ACP spec says `configId`, but
// claude-agent-acp emits `id`. Accept both; the set request always
// uses `configId` on the wire.
let config_id = match config_opt
.get("configId")
.or_else(|| config_opt.get("id"))
.and_then(|v| v.as_str())
{
let config_id = match config_option_id(&config_opt) {
Some(id) => id,
None => continue,
};
Expand Down Expand Up @@ -2571,6 +2588,43 @@ mod tests {
);
}

#[test]
fn resolve_finds_config_options_only_model() {
// codex-acp shape: the halves disagree — configOptions offers clean ids
// while availableModels offers reasoning-suffixed ones. Only the stable
// half can serve `gpt-5.4`.
let result = serde_json::json!({
"configOptions": [{
"id": "model",
"category": "model",
"currentValue": "gpt-5.4",
"options": [{ "value": "gpt-5.4", "name": "GPT-5.4" }]
}],
"models": {
"currentModelId": "gpt-5.3-codex/medium",
"availableModels": [{ "modelId": "gpt-5.3-codex/medium" }]
}
});
assert_eq!(
super::resolve_model_switch_method(&result, "gpt-5.4"),
Some(super::ModelSwitchMethod::ConfigOption {
config_id: "model".to_string(),
option_value: "gpt-5.4".to_string(),
})
);
}

#[test]
fn config_option_label_prefers_schema_name() {
let schema = serde_json::json!({ "name": "Haiku", "displayName": "stale" });
assert_eq!(super::config_option_label(&schema), Some("Haiku"));

let legacy = serde_json::json!({ "displayName": "Haiku" });
assert_eq!(super::config_option_label(&legacy), Some("Haiku"));

assert_eq!(super::config_option_label(&serde_json::json!({})), None);
}

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

#[test]
Expand Down
Loading