Skip to content
Merged
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
69 changes: 66 additions & 3 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,13 +558,17 @@ impl AcpClient {
/// `cwd` must be an absolute path. `mcp_servers` may be empty.
/// `system_prompt` is included in the request when `Some` — agents that
/// support the field will use it; others ignore unknown fields per JSON-RPC.
/// `session_title` rides in `_meta.sessionTitle` when `Some`; `_meta` is
/// omitted entirely otherwise, since adapters may distinguish an absent
/// member from a null one.
/// Callers use [`extract_model_config_options`] and [`extract_model_state`]
/// to pull model info from the raw result.
pub async fn session_new_full(
&mut self,
cwd: &str,
mcp_servers: Vec<McpServer>,
system_prompt: Option<&str>,
session_title: Option<&str>,
) -> Result<SessionNewResponse, AcpError> {
let mut params = serde_json::json!({
"cwd": cwd,
Expand All @@ -573,6 +577,9 @@ impl AcpClient {
if let Some(sp) = system_prompt {
params["systemPrompt"] = serde_json::Value::String(sp.to_owned());
}
if let Some(title) = session_title {
params["_meta"] = serde_json::json!({ "sessionTitle": title });
}
let result = self.send_request("session/new", params).await?;
let session_id = result["sessionId"]
.as_str()
Expand All @@ -594,9 +601,10 @@ impl AcpClient {
cwd: &str,
mcp_servers: Vec<McpServer>,
system_prompt: Option<&str>,
session_title: Option<&str>,
) -> Result<String, AcpError> {
Ok(self
.session_new_full(cwd, mcp_servers, system_prompt)
.session_new_full(cwd, mcp_servers, system_prompt, session_title)
.await?
.session_id)
}
Expand Down Expand Up @@ -2954,7 +2962,7 @@ mod tests {
.expect("initialize should succeed");

let resp = client
.session_new_full("/tmp", vec![], Some("Custom system prompt"))
.session_new_full("/tmp", vec![], Some("Custom system prompt"), None)
.await
.expect("session_new_full should succeed");

Expand Down Expand Up @@ -3039,7 +3047,7 @@ mod tests {
.expect("initialize should succeed");

let resp = client
.session_new_full("/tmp", vec![], None)
.session_new_full("/tmp", vec![], None, None)
.await
.expect("session_new_full should succeed");

Expand All @@ -3051,6 +3059,61 @@ mod tests {
);
}

#[tokio::test]
async fn session_new_full_sends_session_title_in_meta_when_some() {
let script = r#"
read -t 2 _init
echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}'
read -t 2 REQ
echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_test","_receivedRequest":'"$REQ"'}}'
sleep 1
"#;
let mut client = spawn_script(script).await;
client
.initialize()
.await
.expect("initialize should succeed");

let resp = client
.session_new_full("/tmp", vec![], None, Some("Fizz · #buzz-dev"))
.await
.expect("session_new_full should succeed");

let received = &resp.raw["_receivedRequest"];
assert_eq!(
received["params"]["_meta"]["sessionTitle"].as_str(),
Some("Fizz · #buzz-dev"),
"title should ride in _meta.sessionTitle, out of band from the prompt"
);
}

#[tokio::test]
async fn session_new_full_omits_meta_when_session_title_none() {
let script = r#"
read -t 2 _init
echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}'
read -t 2 REQ
echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_test","_receivedRequest":'"$REQ"'}}'
sleep 1
"#;
let mut client = spawn_script(script).await;
client
.initialize()
.await
.expect("initialize should succeed");

let resp = client
.session_new_full("/tmp", vec![], None, None)
.await
.expect("session_new_full should succeed");

let received = &resp.raw["_receivedRequest"];
assert!(
received["params"].get("_meta").is_none(),
"_meta should be absent entirely, not an empty object or null"
);
}

// ── Goose-native steer scaffold (PR follow-up to #1160) ──────────────

/// Helper: spawn an inert `cat` subprocess so we have a real AcpClient
Expand Down
135 changes: 135 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,12 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_MODEL")]
pub model: Option<String>,

/// Title for the agent's ACP sessions, passed out-of-band in `session/new`
/// `_meta`. Adapters that recognize it name the session after this value;
/// others ignore it. Never enters the prompt.
#[arg(long, env = "BUZZ_ACP_SESSION_TITLE")]
pub session_title: Option<String>,

/// Permission mode for agents that support `session/set_config_option`
/// with `configId: "mode"` (e.g. `claude-agent-acp`).
///
Expand Down Expand Up @@ -522,6 +528,9 @@ pub struct Config {
pub memory_enabled: bool,
/// Desired LLM model ID. Applied after every `session_new_full()`.
pub model: Option<String>,
/// Sanitized session title, sent as `_meta.sessionTitle` on `session/new`.
/// `None` when unset or when the configured value sanitized to empty.
pub session_title: Option<String>,
/// Permission mode to apply after session creation. `Default` = skip.
pub permission_mode: PermissionMode,
/// Inbound author gate mode.
Expand Down Expand Up @@ -554,6 +563,68 @@ pub struct Config {
pub base_prompt_content: Option<String>,
}

/// Maximum length, in characters, of a session title sent to the adapter.
const SESSION_TITLE_MAX_CHARS: usize = 80;

/// Normalize a configured session title into something safe to hand an adapter.
///
/// Control characters are dropped, runs of whitespace collapse to a single
/// space, and the result is trimmed and capped at
/// [`SESSION_TITLE_MAX_CHARS`]. Returns `None` when nothing printable is left.
///
/// Buzz is the only guard here: Codex's own `normalize_thread_name` merely
/// trims, so an unbounded display name would be persisted verbatim into its
/// thread store.
fn sanitize_session_title(raw: &str) -> Option<String> {
let collapsed = raw
.split_whitespace()
.map(|word| word.chars().filter(|c| !c.is_control()).collect::<String>())
.filter(|word| !word.is_empty())
.collect::<Vec<_>>()
.join(" ");
// Truncate by chars, not bytes, so a multi-byte name can't be cut mid-UTF-8.
let title: String = collapsed
.chars()
.take(SESSION_TITLE_MAX_CHARS)
.collect::<String>()
.trim_end()
.to_string();
if title.is_empty() {
None
} else {
Some(title)
}
}

/// Separator between the agent name and the channel in a composed title.
/// U+00B7 MIDDLE DOT, spaces on both sides.
const SESSION_TITLE_SEPARATOR: &str = " · ";

/// Compose a per-session title as `Agent · #channel`.
///
/// One agent in five channels gets five sessions; a bare agent name would show
/// five identical rows in the adapter's thread list. Only the channel part is
/// truncated to fit [`SESSION_TITLE_MAX_CHARS`], so the agent name always
/// survives. Returns the bare agent name when there is no channel, the channel
/// name is blank, or no room is left for it.
pub(crate) fn compose_session_title(agent: &str, channel_name: Option<&str>) -> String {
let Some(channel) = channel_name.and_then(sanitize_session_title) else {
return agent.to_string();
};
// Reserve the separator and the `#` sigil alongside the agent name.
let reserved = agent.chars().count() + SESSION_TITLE_SEPARATOR.chars().count() + 1;
let channel: String = channel
.chars()
.take(SESSION_TITLE_MAX_CHARS.saturating_sub(reserved))
.collect::<String>()
.trim_end()
.to_string();
if channel.is_empty() {
return agent.to_string();
}
format!("{agent}{SESSION_TITLE_SEPARATOR}#{channel}")
}

/// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars.
fn validate_allowlist(entries: &[String]) -> Result<HashSet<String>, ConfigError> {
let mut validated = HashSet::new();
Expand Down Expand Up @@ -992,6 +1063,10 @@ impl Config {
typing_enabled: !args.no_typing,
memory_enabled: args.memory && !args.no_memory,
model,
session_title: args
.session_title
.as_deref()
.and_then(sanitize_session_title),
permission_mode: args.permission_mode,
respond_to: args.respond_to,
respond_to_allowlist,
Expand Down Expand Up @@ -1361,6 +1436,7 @@ mod tests {
typing_enabled: true,
memory_enabled: true,
model: None,
session_title: None,
permission_mode: PermissionMode::BypassPermissions,
respond_to: RespondTo::Anyone,
respond_to_allowlist: HashSet::new(),
Expand Down Expand Up @@ -2706,4 +2782,63 @@ channels = "ALL"
assert!(MAX_TURN_DURATION_CEILING_SECS < u64::MAX - 100);
}
}

#[test]
fn sanitize_session_title_collapses_whitespace_and_strips_control_chars() {
assert_eq!(
sanitize_session_title(" Fizz\t\tthe\n Bot\u{7} "),
Some("Fizz the Bot".to_string())
);
}

#[test]
fn sanitize_session_title_returns_none_when_nothing_printable_remains() {
assert_eq!(sanitize_session_title(" \n\t "), None);
assert_eq!(sanitize_session_title(""), None);
assert_eq!(sanitize_session_title("\u{1}\u{2}"), None);
}

#[test]
fn sanitize_session_title_caps_length_without_splitting_multibyte_chars() {
let raw = "\u{1f41d}".repeat(SESSION_TITLE_MAX_CHARS + 10);
let title = sanitize_session_title(&raw).expect("emoji title survives sanitizing");
assert_eq!(title.chars().count(), SESSION_TITLE_MAX_CHARS);
assert!(title.chars().all(|c| c == '\u{1f41d}'));
}

#[test]
fn sanitize_session_title_does_not_leave_a_trailing_space_after_the_cap() {
// The cap lands mid-word, so trimming must not leave a dangling space.
let raw = format!("{} tail", "a".repeat(SESSION_TITLE_MAX_CHARS - 1));
let title = sanitize_session_title(&raw).expect("title survives sanitizing");
assert_eq!(title, "a".repeat(SESSION_TITLE_MAX_CHARS - 1));
}

#[test]
fn compose_session_title_qualifies_the_agent_name_with_the_channel() {
assert_eq!(
compose_session_title("Fizz", Some("buzz-dev")),
"Fizz · #buzz-dev"
);
}

#[test]
fn compose_session_title_falls_back_to_bare_agent_name_without_a_channel() {
assert_eq!(compose_session_title("Fizz", None), "Fizz");
assert_eq!(compose_session_title("Fizz", Some(" ")), "Fizz");
}

#[test]
fn compose_session_title_truncates_the_channel_and_keeps_the_agent_name() {
let channel = "c".repeat(200);
let title = compose_session_title("Fizz", Some(&channel));
assert_eq!(title.chars().count(), SESSION_TITLE_MAX_CHARS);
assert!(title.starts_with("Fizz · #c"));
}

#[test]
fn compose_session_title_drops_the_channel_when_the_agent_name_fills_the_cap() {
let agent = "a".repeat(SESSION_TITLE_MAX_CHARS);
assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent);
}
}
5 changes: 4 additions & 1 deletion crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1535,6 +1535,7 @@ async fn tokio_main() -> Result<()> {
turn_liveness_interval: Duration::from_secs(config.turn_liveness_secs),
dedup_mode: config.dedup_mode,
system_prompt: config.system_prompt.clone(),
session_title: config.session_title.clone(),
team_instructions: config.team_instructions.clone(),
base_prompt: if config.no_base_prompt {
None
Expand Down Expand Up @@ -4026,7 +4027,7 @@ async fn run_models(args: ModelsArgs) -> Result<()> {
// so shutdown() runs on all paths (success, error, timeout).
let protocol_result = tokio::time::timeout(MODELS_TIMEOUT, async {
let init = client.initialize().await?;
let session = client.session_new_full(&cwd, vec![], None).await?;
let session = client.session_new_full(&cwd, vec![], None, None).await?;
Ok::<_, acp::AcpError>((init, session))
})
.await;
Expand Down Expand Up @@ -4973,6 +4974,7 @@ mod build_mcp_servers_tests {
typing_enabled: true,
memory_enabled: false,
model: None,
session_title: None,
permission_mode: config::PermissionMode::BypassPermissions,
respond_to: config::RespondTo::Anyone,
respond_to_allowlist: std::collections::HashSet::new(),
Expand Down Expand Up @@ -5139,6 +5141,7 @@ mod error_outcome_emission_tests {
typing_enabled: true,
memory_enabled: false,
model: None,
session_title: None,
permission_mode: config::PermissionMode::BypassPermissions,
respond_to: config::RespondTo::Anyone,
respond_to_allowlist: HashSet::new(),
Expand Down
Loading
Loading