Files
buzz/desktop/src-tauri/src/managed_agents/spawn_hash.rs
Will Pfleger f2fe3b63c2 feat(acp): title agent sessions from the agent and channel name (#3028)
ACP harnesses that name a session from the first text they receive all
land in the same place: every managed Buzz agent opens with the
identical `[Base] You are operating inside the Buzz platform…` framing,
so the harness session list shows a wall of indistinguishable rows.
Because sessions are keyed per channel, one agent active in several
channels produces several of them.

This sends the name out of band instead. `session/new` carries
`_meta.sessionTitle` with `Agent · #channel`, composed from the agent's
`display_name` (or its unique `name` handle) and the channel it is
serving. The prompt is untouched — no tokens spent, no perturbation of
the prompt contract, and nothing new for the desktop observer's section
parsing to handle.

The mechanism is harness-agnostic: Buzz sends the field on every ACP
`session/new` regardless of which harness is behind it, and adapters
that don't read it ignore it per spec.

## Inert until a consuming adapter ships

ACP adapters ignore `_meta` members they do not recognize, so against an
adapter with no reader a Buzz session gets no title and nothing else
changes. Three adapter halves consume it — Codex, Goose, and Claude Code
(linked below); this half and each reader are only useful together, and
each reader lands independently.

No version floor is added. `codex_adapter_is_outdated_with_path` already
gates codex-acp on major version `>= 1`
(`desktop/src-tauri/src/managed_agents/discovery.rs:1276-1284`) and this
feature needs nothing above that — an older adapter is not broken by the
extra member, it simply ignores it.

## What changes

**`crates/buzz-acp`** owns sanitization and composition.
`sanitize_session_title` collapses whitespace, drops control characters,
and caps at `SESSION_TITLE_MAX_CHARS` (80) by character, not byte, so a
multi-byte character cannot be split. `compose_session_title` truncates
only the channel part against that cap, so the agent name always
survives; when the agent name alone fills the cap the channel is dropped
rather than the name. `session_new_full` sets `_meta.sessionTitle` when
a title exists and omits `_meta` entirely when it does not, since an
adapter may distinguish an absent member from a null one.

**`desktop/src-tauri`** only resolves and exports.
`resolve_session_title` picks `display_name` or falls back to `name`,
and `spawn_agent_child` writes it to `BUZZ_ACP_SESSION_TITLE` — or
removes the variable when neither candidate yields anything printable.

DMs, unresolved channels, and heartbeat sessions get the bare agent name
with no channel suffix.

## Four properties that are easy to remove by accident

**Control characters are stripped at the desktop boundary, not in the
harness.** An interior NUL cannot cross the environment boundary at all
— `Command::env` fails the entire spawn rather than passing it through.
Deferring the strip to `buzz-acp` would let a corrupted display name
turn display chrome into a spawn failure. A display name that is *only*
control characters falls back to `name`.

**The title is hashed into `spawn_config_hash`.** Without it, renaming
an agent left the running process with a stale title and no restart
badge. The hash runs the same `resolve_session_title` the spawn writes,
and skips it when a user env override shadows `BUZZ_ACP_SESSION_TITLE` —
spawn writes the title *before* the layered user env, so the override is
what actually runs, and it already reaches the hash through
`descriptor.env`. Hashing the record-derived value under an override
would badge a rename that changes nothing.

**One channel resolve serves both consumers.**
`resolve_new_session_channel_context` returns `(is_dm, title_channel)`
from a single metadata lookup, feeding both the canvas block's DM check
and the title. `ChannelInfoResolver` caches only `Some`, so two
independent calls against an unresolvable channel pay the full
`fetch_channel_info` retry sequence twice — two timeouts plus a retry
delay each — directly in front of `session/new`, precisely when the
relay is already degraded.

**The `"unknown"` channel name is treated as absent.**
`fetch_channel_info` substitutes the literal `"unknown"` for a metadata
event with no `name` tag. Composing that sentinel would title every
unnamed channel `Agent · #unknown`, reintroducing the exact collision
the suffix exists to remove while naming a channel something it isn't.
The startup cache already refuses `channel_type == "unknown"` for the
same reason.

Closes #2334

Related — the adapter halves that consume `_meta.sessionTitle`:

-
[codex-acp#338](https://github.com/agentclientprotocol/codex-acp/pull/338)
— Codex
-
[aaif-goose/goose#10712](https://github.com/aaif-goose/goose/pull/10712)
— Goose
-
[claude-agent-acp#920](https://github.com/agentclientprotocol/claude-agent-acp/pull/920)
— Claude Code

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-07-27 12:46:43 -04:00

161 lines
7.8 KiB
Rust

//! Spawn-time config hash for the restart-required badge.
//!
//! [`spawn_config_hash`] digests the *effective spawned values* — what a
//! process launch of `record` would actually receive — so the UI can compare
//! a running process's hash (stamped on [`super::ManagedAgentProcess`] at
//! spawn) against a recomputation from current disk state and show a
//! "restart required" badge only when a restart would change what runs.
//!
//! Scope rules (decided in #centralize-personas-and-agents, revised in PR
//! #1602 review):
//! - Inputs mirror what a start would actually run: the start/restore paths
//! re-snapshot the linked persona's prompt/model/provider/env onto the
//! record immediately before spawning (`start_local_agent_with_preflight`,
//! `restore_managed_agents_on_launch`), so persona edits to those fields DO
//! apply on a plain restart and are hashed via the same prospective
//! re-snapshot. Harness command, args/mcp, env layering, and the record
//! fields the spawn env writes read are hashed as spawn resolves them.
//! - The relay URL is hashed in resolved form (`effective_agent_relay_url`):
//! every record spawns against the active workspace relay (legacy per-record
//! pins are ignored), so a workspace relay change means a restart would
//! change what runs.
//! - Channel membership is not an input: agents pick up channel changes live
//! (#1468), never via restart.
//!
//! The hash never crosses a process or persistence boundary, so
//! `DefaultHasher` (not stable across Rust releases) is sufficient.
use std::hash::{DefaultHasher, Hash, Hasher};
use super::{
effective_config::{resolve_effective_config, EffectiveConfigResult},
known_acp_runtime, normalize_agent_args,
persona_events::preview_prospective_persona_snapshot,
runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR},
types::{AgentDefinition, ManagedAgentRecord, TeamRecord},
GlobalAgentConfig,
};
/// Resolve the current instructions for this instance's deployment-time team binding.
/// A deleted team deliberately degrades to no team section.
pub(crate) fn effective_team_instructions(
record: &ManagedAgentRecord,
teams: &[TeamRecord],
) -> Option<String> {
teams
.iter()
.find(|team| Some(team.id.as_str()) == record.team_id.as_deref())
.and_then(|team| team.instructions.as_deref())
.map(str::trim)
.filter(|instructions| !instructions.is_empty())
.map(str::to_string)
}
/// Digest the effective spawn configuration of `record` under the current
/// `personas`, resolving a blank record relay against `workspace_relay`.
/// Pure — no `AppHandle`, no disk, no keyring.
pub(crate) fn spawn_config_hash(
record: &ManagedAgentRecord,
personas: &[AgentDefinition],
teams: &[TeamRecord],
workspace_relay: &str,
global: &GlobalAgentConfig,
) -> u64 {
// Prospective re-snapshot: apply the same `apply_persona_snapshot` the
// start/restore paths run right before spawning, so the hash covers what a
// restart would actually run. Idempotent, so the spawn-time stamp
// (post-snapshot record) and later recomputes (persisted record) agree
// when nothing changed. The persona env itself reaches the hash through
// the descriptor's layered env below; `persona_source_version` is set on
// the clone but is not a hash input.
let record = preview_prospective_persona_snapshot(record, personas);
let record = &record;
// Resolve command, args, and env via the single typed descriptor — same path
// as spawn_agent_child. Dangling harness id falls back to the infallible
// record_agent_command (no-op: a dangling harness can't be spawned, so the
// hash never matters for that agent).
let descriptor =
crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global)
.unwrap_or_else(|_| {
let cmd = crate::managed_agents::record_agent_command(record, personas);
let args = normalize_agent_args(&cmd, record.agent_args.clone());
crate::managed_agents::readiness::EffectiveHarnessDescriptor {
command: cmd,
args,
env: Default::default(),
}
});
let runtime_meta = known_acp_runtime(&descriptor.command);
let mut hasher = DefaultHasher::new();
// Harness identity and derivations (live-persona-resolved, like spawn).
record.acp_command.hash(&mut hasher);
descriptor.command.hash(&mut hasher);
descriptor.args.hash(&mut hasher);
runtime_meta
.and_then(|r| r.mcp_command)
.unwrap_or("")
.hash(&mut hasher);
// Effective env layering (baked floor → runtime metadata → definition env
// → global → persona → agent). BTreeMap iteration is ordered, deterministic.
descriptor.env.hash(&mut hasher);
// Record fields the spawn env writes read directly. The relay is hashed
// resolved: every record spawns on the workspace relay (legacy pins
// ignored), so a workspace relay change must trip the badge.
crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher);
// Team instructions use the same resolver as spawn.
effective_team_instructions(record, teams).hash(&mut hasher);
// Prompt, model, and provider all come from ONE `resolve_effective_config`
// call — the SAME resolve `spawn_agent_child` performs for the env write,
// so env write and this badge cannot disagree. An orphaned link (missing
// definition) hashes as if all three were absent: `spawn_agent_child`
// refuses to spawn an orphan regardless, so this is a display-only
// convenience, not the spawn gate.
let (resolved_prompt, resolved_model, resolved_provider) =
match resolve_effective_config(record, personas, global) {
EffectiveConfigResult::Resolved(cfg) => {
(cfg.system_prompt.value, cfg.model.value, cfg.provider.value)
}
EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None),
};
resolved_prompt.hash(&mut hasher);
resolved_model.hash(&mut hasher);
resolved_provider.hash(&mut hasher);
// Session title: the same resolve `spawn_agent_child` performs for its env
// write, so a rename raises the restart badge. Skipped when a user env
// override shadows it — spawn writes the title BEFORE the user env layer,
// so the override is what actually runs, and it already reaches this hash
// through `descriptor.env` above. Hashing the record-derived value under an
// override would badge a rename that changes nothing.
let effective_session_title = (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR))
.then(|| resolve_session_title(record.display_name.as_deref(), &record.name))
.flatten();
effective_session_title.hash(&mut hasher);
record.auth_tag.hash(&mut hasher);
record.respond_to.as_str().hash(&mut hasher);
// The allowlist is hashed as the env receives it: spawn sets
// BUZZ_ACP_RESPOND_TO_ALLOWLIST only in allowlist mode, and normalized
// (trim/lowercase/dedup via `validate_respond_to_allowlist`) — so edits
// that don't survive normalization, or edits while another mode is
// active, must not badge. A list spawn would reject hashes raw: the
// stamped hash comes from a successful spawn, so any invalid edit
// correctly compares unequal.
if record.respond_to == super::types::RespondTo::Allowlist {
super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)
.unwrap_or_else(|_| record.respond_to_allowlist.clone())
.hash(&mut hasher);
}
record.idle_timeout_seconds.hash(&mut hasher);
record.max_turn_duration_seconds.hash(&mut hasher);
record.parallelism.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests;