feat(acp): title new sessions from _meta.sessionTitle - #10712
Conversation
ACP clients that manage many concurrent sessions have no way to label them: goose names a session from its recipe title or falls back to "New Chat", so every client-created session looks alike in session lists. Read a harness-neutral `_meta.sessionTitle` on `session/new` and use it as the session name. A recipe title still wins — it is a server-side declaration, and deferring to it preserves today's behavior exactly. The client title is recorded via `user_provided_name`, which marks the session user-set so `maybe_update_name` does not replace it with a generated one. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6e12e0327e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| Some((recipe, _)) if !recipe.title.trim().is_empty() => recipe.title.clone(), | ||
| _ => "New Chat".to_string(), | ||
| }; | ||
| let meta = new_session_meta_fields(args.meta.as_ref(), recipe.as_ref())?; |
There was a problem hiding this comment.
Add the new title path to the self-test recipe
This introduces new ACP session-creation behavior, but the commit does not update goose-self-test.yaml; a repo-wide search for sessionTitle finds coverage only in the Rust handler and tests. Add an appropriate self-test scenario so the feature receives the repository-required end-to-end validation.
AGENTS.md reference: AGENTS.md:L70-L71
Useful? React with 👍 / 👎.
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](agentclientprotocol/codex-acp#338) — Codex - [aaif-goose/goose#10712](aaif-goose/goose#10712) — Goose - [claude-agent-acp#920](agentclientprotocol/claude-agent-acp#920) — Claude Code --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…k#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 block#2334 Related — the adapter halves that consume `_meta.sessionTitle`: - [codex-acp#338](agentclientprotocol/codex-acp#338) — Codex - [aaif-goose/goose#10712](aaif-goose/goose#10712) — Goose - [claude-agent-acp#920](agentclientprotocol/claude-agent-acp#920) — Claude Code --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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](agentclientprotocol/codex-acp#338) — Codex - [aaif-goose/goose#10712](aaif-goose/goose#10712) — Goose - [claude-agent-acp#920](agentclientprotocol/claude-agent-acp#920) — Claude Code --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
ACP clients that spawn Goose sessions had no way to name them:
handle_new_sessionhardcoded"New Chat"whenever a recipe did not supply a title, so every client-managed session showed the same placeholder until Goose's own auto-namer replaced it after the first turn.This reads the harness-neutral
_meta.sessionTitlethat ACP clients already send onsession/newand applies it as the session name.Behavior
_meta.sessionTitleuserSetName"Duncan in #general"Duncan in #generaltrue"Duncan in #general""Recipe title"Recipe titlefalsenull/ blankNew Chatfalseinvalid_paramssessionTitlereplaces only the"New Chat"fallback, so no existing recipe user sees a behavior change.user_provided_name, which setsuser_set_name = true. That is load-bearing rather than incidental:SessionManager::maybe_update_namereturns early for user-set names, so without it Goose's auto-namer would overwrite the client's title at the end of the first turn.sessionTitleis read unconditionally via the existingmeta_stringhelper, so a non-string value is rejected withinvalid_paramseven in a request where a recipe title would have won.on_rename_session, the other client-title entry point, applies none either; a bound belongs in the update builder so both doors agree, not in one path.NewSessionMetaFieldsbundlesproject_idand the newclient_titlerather than threading an eighth positional parameter throughfinish_new_session_setup/configure_new_session.Creation-time titles ride the existing response path —
build_session_infoalready surfacessession.nameasSessionInfo.title, so no new notification is needed.Related
_meta.sessionTitleon everysession/new_metacontract for Codex