Skip to content

feat: title new sessions from _meta.sessionTitle - #920

Open
wpfleger96 wants to merge 1 commit into
agentclientprotocol:mainfrom
wpfleger96:wpfleger/session-title-from-meta
Open

feat: title new sessions from _meta.sessionTitle#920
wpfleger96 wants to merge 1 commit into
agentclientprotocol:mainfrom
wpfleger96:wpfleger/session-title-from-meta

Conversation

@wpfleger96

Copy link
Copy Markdown

An ACP client that spawns several sessions against one agent has no harness-neutral way to label them. The SDK-shaped _meta.claudeCode.options.title already reaches Options.title through the ...userProvidedOptions spread, but that spelling is Claude-specific — a client driving Claude Code alongside other ACP agents has to special-case it. This maps the neutral _meta.sessionTitle onto the same SDK option so one wire contract works across adapters.

Options.title is the SDK's own creation affordance: the CLI persists it as a custom-title entry and sets its auto-title suppression latch, so the title survives the first turn instead of being replaced by the generated summary. Nothing downstream needs to change — maybeUpdateSessionTitle already prefers customTitle over summary, so the turn-end session_info_update carries the client's title verbatim.

Behavior

_meta.sessionTitle _meta.claudeCode.options.title options.title
"Fix the login bug" "Fix the login bug"
"harness-neutral" "sdk-shaped" "sdk-shaped"
absent / null / non-string / blank omitted
  • The SDK-shaped spelling wins. It is the more specific request and already-released behavior, so inverting the precedence would be a change for existing callers. Resolution is userProvidedOptions?.title ?? sanitizedSessionTitle, applied after the spread.
  • Creation only. Applied when creating or forking; a plain resume is excluded, using the same resume === undefined || forkSession === true predicate the adapter already uses to allocate options.sessionId. The SDK ignores title on resume regardless, but gating makes the contract explicit.
  • The existing sanitizeTitle is reused as-is, so client titles obey the same whitespace and 256-character contract as SDK summaries rather than acquiring a second title contract. A title that sanitizes to empty is omitted, letting the SDK auto-generate instead of receiving an empty string.
  • A non-string value is ignored rather than rejected, matching how the other _meta reads in this file treat malformed input.

Related

  • codex-acp#338 — sibling adapter, same _meta.sessionTitle contract for Codex
  • block/buzz#3028 — a client that sends _meta.sessionTitle on every session/new

ACP clients that manage their own session naming had no harness-neutral
way to name a Claude Code session; only the SDK-shaped
`_meta.claudeCode.options.title` spelling worked, which every other
harness would have to special-case. Map the neutral `_meta.sessionTitle`
onto the SDK's `Options.title` so one wire contract works across
adapters.

The SDK-shaped spelling still wins when both are supplied — it is the
more specific request and already-released behavior. Applied only when
creating or forking, since the SDK ignores `title` on resume, and reused
the adapter's existing `sanitizeTitle` so there is one title contract.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96 added a commit to block/buzz that referenced this pull request Jul 27, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant