Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Let Codex and Grok own their session titles, and split the ACP title predicates

Status: implemented
Translation: pending

## Abstract

Lody wanted every builtin agent to stop carrying its own `titleGeneration`
session config and take the session title from its ACP adapter instead. An audit
of all five builtin adapters found three that already produce a usable title —
Claude, Codex, and, contrary to a first reading that inspected only our proxy,
Grok, whose official runtime generates one and pushes it, confirmed by live probe
— so all three now take the ACP title while Kimi and the DeepSeek Harness keep
the isolated generator. Codex and Grok were both doing the work twice, each
generating a title that Lody then either duplicated or discarded. Delivering this
required splitting the single `usesAcpProvidedSessionTitle()` predicate into
ownership and trust, because Codex tags its titles and emits a prompt-preview
`fallback` first while Claude and Grok push one bare authoritative title, and
conflating the two would have promoted Codex's preview to the session title. The
cost is that title wording now belongs to the adapters. Branch naming, the last
caller that could still start an isolated session, now derives its name locally,
so session titles no longer start an extra ACP agent anywhere.

## The audit

Each adapter was read at the commit this repository pins.

| Adapter | Version | Publishes a title | `_meta.lody.titleSource` | Real generation |
| --- | --- | --- | --- | --- |
| `acp-extension-claude` | 0.70.0 | yes | no `_meta` at all | yes — SDK `generate_session_title` control request |
| `acp-extension-codex` | 1.10.0 (since 1.8.0) | yes | yes, generated titles are `explicit` | yes — cheap-model turn on an ephemeral thread |
| `acp-extension-grok` | 0.1.0 (runtime 1.0.13) | yes — the runtime pushes it and the proxy forwards it | no `_meta` at all | yes — upstream `title_refresh.rs` |
| `acp-extension-kimi` | acp-server 0.0.1 | yes, but the title is the first prompt truncated to 200 chars | no `_meta` at all | no |
| `acp-extension-dsh` | 0.1.1 | no | no | no |

Two near-misses are worth recording because they change what "add title support"
would cost later. Kimi's engine already tracks
`SessionTitleKind = 'replaceable' | 'generated' | 'custom'`, and it has a real
generator (`SessionTitleService`, backed by Moonshot's managed `chat_title`
endpoint) — but the generator is reachable only from the kap-server HTTP route and
the node SDK, and the kind is discarded at the ACP boundary in
`packages/acp-server/src/events-map.ts`. The DeepSeek Harness pins
`@deepseek-ai/dsh-session-title` in its dependency closure but never mounts it in
`createDeepSeekHarnessCordisConfig`, so the plugin is inert.

Grok is the sharpest correction to an earlier reading of this audit. The adapter
is a pure stdio proxy with no title code, which is easy to mistake for "Grok has
no titles". The official `@xai-official/grok` 1.0.13 runtime pinned by
`runtime-manifest.json` in fact ships a full automatic title generator: strings in
the shipped binary include the `session_title` tool-call prompt ("Final session
title, just 5-10 word descriptive title for the session"), the failure path
"session title generation failed, falling back to truncated user text", and user
documentation stating the title is generated right after the first prompt,
regenerated over a couple of early turns, then frozen, with `/rename` and
`/rename --auto` as manual overrides. Decisively, the logic lives at
`crates/codegen/xai-grok-shell/src/session/acp_session_impl/title_refresh.rs` —
inside the ACP session implementation, alongside `goal.rs`, `mcp.rs` and
`prompt_build.rs` — so it is not TUI-only, and the runtime's ACP `SessionUpdate`
enum includes `session_info_update` with `title` and `updatedAt`.

That title does reach the ACP wire, as a pushed notification. A probe run on a
credentialed machine against runtime 1.0.13 — one short turn, then a 25s wait —
produced exactly one push per run, both talking straight to `grok agent stdio`
and routing through `acp-extension-grok`:

```json
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a08127-...",
"update":{"sessionUpdate":"session_info_update","title":"Reply with single word ok"}}}
```

The same title landed in the session's on-disk `summary.json` (`session_summary`
non-empty, so generation genuinely ran), and the proxy filtered nothing — the two
paths differed only in generated wording. Crucially the message carries **no
`_meta` at all**, so Grok has the same shape as Claude: one authoritative pushed
title with no `titleSource` to gate on. Lody therefore already receives Grok's
title today and discards it in `handleAgentSessionTitleUpdate` for want of a tag.
Only one push was observed, with no `fallback`-style preview beforehand.

The pull path does not exist in the mode Lody runs: `x.ai/session/info` answers
`-32601 Method not found` under `grok agent stdio`, direct and through the proxy
alike. The literal string is present in the shipped binary, so the method is
presumably registered on some other channel, but not on the ACP agent one. That
has a consequence beyond titles, recorded here because the evidence is in hand:
`proxy.js` issues `internalRequest('context', ...)` after `model_changed` and at
session start, and drops the reply when it is an error, so builtin Grok's
context-window usage notification is silently dead against this runtime. Fixing
that is separate work and is not attempted here.

## Decision

`usesAcpProvidedSessionTitle()` answered two different questions at three call
sites, and Codex needs opposite answers to them:

- *May Lody skip its isolated generator and hide the title config?* Yes for
Claude, Codex and Grok. This is now `acpOwnsSessionTitleGeneration()`.
- *May Lody trust a pushed title that carries no `titleSource`?* Only for Claude
and Grok, which both send a bare `session_info_update`. This is now
`trustsUntaggedAcpSessionTitle()`.

The two sets are deliberately not the same, and Codex is the reason. It emits a
`fallback` prompt-preview title before its generated `explicit` one, and
`apps/cli/src/agent/AGENTS.md` already required rejecting that preview. Keeping
one predicate and extending it to Codex would have silently made the raw first
prompt the session title — the main trap this split exists to prevent. Grok, by
contrast, was observed to push exactly one title with no preview, so it joins
Claude in the trusted-untagged set.

The `titleGeneration` config surface (schema field, settings section, CLI flags)
is deliberately left in place. Removing it would strip the cheap-model and
least-privilege-mode selection that Kimi, the DeepSeek Harness, registry and
custom providers still rely on. The config simply stops being reachable for Codex
and Grok, as it already was for Claude.

"Unreachable" has to hold on every path, not just the settings form. Branch
naming resolved the persisted `titleGeneration` for whatever agent it was naming
a branch for, so a value stored before this change would have kept steering
Claude, Codex and Grok runs after their config disappeared from the UI. Branch naming
now skips that lookup for ACP-owned agents and lets
`computeTitleGenerationDefaults()` pick from the live `configOptions` instead.

## Trade-offs and limits

Codex generates its title after the first turn completes and skips generation
entirely on resumed sessions (its internal source is `unknown` there), so a
resumed codex session no longer gets a Lody-generated title. Generation is also
best-effort inside the adapter and swallows failures without signalling the
client, so a failed generation now leaves the draft title rather than falling
back to Lody's generator.

Handing titles to the adapters also hands over their wording. None of the three
sees `DEFAULT_TITLE_GENERATION_PROMPT`, so constraints it carries — the 26-letter
English budget, the single-line rule — no longer apply to them. Grok additionally
keeps refining its title over the first few turns before freezing it, so a Grok
session title can change after it first appears.

Branch naming had to change too, or the isolated session would simply have moved
from the title path to the branch path. `maybeRenameSessionBranchFromPrompt` runs
at session-ready, before any turn, so an ACP title can never have arrived by then;
with the title path skipped it would have started its own agent, leaving worktree
sessions at exactly the same one isolated session as before.

Two options were considered and rejected. Deferring the rename until the pushed
title arrives moves a "once, at session creation" operation into the middle of a
running conversation, where a turn may already have pushed the branch or opened a
PR — `renameBranchWithAvailableSuffix` is a bare `git branch -m` with no upstream
check. Dropping branch naming entirely and relying on the injected instruction
("Name branches based on the task content", `session-execution-helpers.ts`) fails
because `buildPrompt` runs only in `startSession`, and the instruction is stripped
before storage, so it is absent from turn two onward and from every resumed
session — trading a deterministic behaviour for one whose odds fall as the session
grows. It is also injected only for `project.kind === 'github'`, while worktrees
are also created for local projects with `useWorktree`.

What actually landed is simpler: `titleToBranchName` was always a pure transform,
so the isolated agent only ever compressed the prompt into a shorter title first.
Branch naming now prefers a title that is already stored or in flight, and
otherwise converts the prompt directly. `generateBranchNameWithTimeout` no longer
takes a provider, env, launch spec or title config, and the branch path no longer
reads the agent config at all.

One deliberate behaviour change: a prompt that yields no valid name now leaves the
managed `session/<id>` branch alone instead of renaming it to `task/<timestamp>`.
Kebab conversion strips every non-ASCII character, so this is the normal outcome
for a Chinese prompt — and it was the outcome before this change too, since the
generator was asked for a title in the prompt's own language. The isolated session
those sessions paid for could never have produced a usable branch name. A timeout
now also falls back to the prompt instead of abandoning the rename.

Verification is type checks, lint, and the shared unit tests covering both
predicates, the branch-name derivation cases, and the dialog cases covering the
hidden title-generation section. The Grok behaviour rests on the live probe
described above; no live Codex, Kimi or DeepSeek session was exercised, and no
real worktree rename was driven end to end.
15 changes: 8 additions & 7 deletions apps/cli/src/agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,11 @@ context/acp-agent-edit-evidence.md; adapter repos: [apps/cli/AGENTS.md](../../AG
the cache, and requests/responses carry that id to keep configs of one provider isolated.
`ManagedRuntimeUpdateCoordinator` never hot-swaps a running ACP process, and Machine Flock
writes ignore `fetchedAt` when comparing entries.
- Builtin Claude owns session titles through ACP `session_info_update`; store them only after
`sanitizeLodyInternalInstructions`, and never start `title-generator.ts`'s isolated session
for Claude. For Codex accept only `explicit` `_meta.lody.titleSource` names, ignore its
first-prompt `fallback`, and require `_meta.lody.messagePhase === 'final_answer'`; untyped
chunks, error/warning payloads, and internal-instruction tails are never candidates.
Each isolated run owns and removes a unique temp directory; concurrent session-title and
branch-name work reuses one in-flight result.
- Builtin Claude, Codex and Grok own session titles via ACP `session_info_update`
(`acpOwnsSessionTitleGeneration()`): store them only after
`sanitizeLodyInternalInstructions`, never via `title-generator.ts`.
Claude and Grok push untagged and are trusted so (`trustsUntaggedAcpSessionTitle()`);
Codex is not — take only its `explicit` `_meta.lody.titleSource`, ignore its first-prompt
`fallback`, and require `_meta.lody.messagePhase === 'final_answer'`. Untyped chunks,
error/warning payloads, and internal-instruction tails never qualify. Each isolated run
owns and removes a temp dir; branch naming never starts one.
36 changes: 31 additions & 5 deletions apps/cli/src/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,34 @@ is a context window). Vendor model `_meta` never enters the CLI.

### Session titles

Builtin Claude owns session title generation through ACP `session_info_update`. Builtin Codex
still uses the isolated generator in `title-generator.ts`, but its adapter tags every pushed
title with `_meta.lody.titleSource`. Other providers use `title-generator.ts` /
`response-utils.ts`. The shared `usesAcpProvidedSessionTitle()` predicate hides obsolete
provider title settings only for Claude.
Builtin Claude, Codex and Grok own session title generation through ACP
`session_info_update`. acp-extension-claude asks the Agent SDK for a title via its
`generate_session_title` control request; acp-extension-codex (>= 1.8.0) runs a cheap-model
turn on an ephemeral thread and persists the result as the codex thread name; Grok's official
runtime generates the title inside its own ACP session impl
(`xai-grok-shell/src/session/acp_session_impl/title_refresh.rs`) and pushes one update per
session, which the `acp-extension-grok` proxy forwards untouched. The shared
`acpOwnsSessionTitleGeneration()` predicate keeps `title-generator.ts`'s isolated session out
of their title path and hides the obsolete provider title settings for all three.

How a title is labelled decides whether Lody may trust it, and that is a separate,
narrower set. Claude and Grok both send a bare `session_info_update` with no `_meta`, so they
need the `trustsUntaggedAcpSessionTitle()` allowlist. Codex tags every title and emits a
first-prompt `fallback` preview before its generated `explicit` one, so it must stay outside
that allowlist even though it does own its generation — otherwise the preview wins.

Branch naming never starts an isolated session. `titleToBranchName` is a pure transform, so
the only thing an agent ever added was compressing the prompt into a shorter title first.
`generateBranchNameWithTimeout` now prefers a title that is already stored or in flight for
the session and otherwise converts the prompt directly, falling back to the prompt if a
pending title misses its budget. When no valid name can be derived — kebab conversion drops
every non-ASCII character, so this is the normal outcome for a Chinese prompt — the managed
`session/<id>` branch is left alone rather than renamed to a timestamp.

Kimi and the DeepSeek Harness still use `title-generator.ts` / `response-utils.ts` and the
`titleGeneration` config. Neither gap is a missing upstream feature: Kimi's
`session_info_update` carries the first prompt truncated to 200 chars with no `_meta` while
its real `SessionTitleService` stays reachable only from kap-server and the node SDK (the
engine's `SessionMeta.titleKind` is discarded at the ACP boundary), and the DeepSeek Harness
pins `@deepseek-ai/dsh-session-title` in its dependency closure but never mounts it in
`createDeepSeekHarnessCordisConfig`.
6 changes: 3 additions & 3 deletions apps/cli/src/agent/agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
type SessionGoalContent,
type SessionTurnInputConfig,
sanitizeGoalObjective,
usesAcpProvidedSessionTitle,
trustsUntaggedAcpSessionTitle,
parseSessionNotification,
SessionContextWindowUsage,
SessionId,
Expand Down Expand Up @@ -1542,7 +1542,7 @@ export class AgentClient implements acp.Client {
return;
}

const ownsTitleGeneration = usesAcpProvidedSessionTitle(
const trustsUntaggedTitle = trustsUntaggedAcpSessionTitle(
this.options.agentConfig?.cliType,
this.options.agentConfig?.agentType
);
Expand All @@ -1556,7 +1556,7 @@ export class AgentClient implements acp.Client {
(lodyTitleMeta.success && lodyTitleMeta.data.titleSource === 'explicit') ||
(legacyCodexTitleMeta?.success === true &&
legacyCodexTitleMeta.data.titleSource === 'explicit');
if (!ownsTitleGeneration && !isExplicitProviderTitle) {
if (!trustsUntaggedTitle && !isExplicitProviderTitle) {
return;
}

Expand Down
Loading
Loading