feat(core): generate session titles from first prompt

Add SessionTitle service that generates a session title from the
session's sole user message via the customizable "title" agent, then
renames the session. Runs once per session, gated on durable history
having exactly one user message (no default-title string comparison).

Wire it into SessionRunner as a background fork after the first turn's
prompt promotion makes the user message visible; forking before
promotion caused the first-message lookup to see zero rows and silently
no-op.

Closes #34364
This commit is contained in:
Dax Raad 2026-07-01 11:07:26 -04:00
commit 5710064ae1
7 changed files with 356 additions and 0 deletions

View file

@ -30,6 +30,7 @@ import { SessionHistory } from "../history"
import { SessionInput } from "../input"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionTitle } from "../title"
import { type RunError, Service } from "./index"
import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
@ -107,6 +108,12 @@ export const layer = Layer.effect(
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
// Title generation is a side effect of the first turn; it must not delay turn continuation.
// Tracked per process so repeated wakes before the second user message arrives don't
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
const titleAttempted = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
@ -394,6 +401,12 @@ export const layer = Layer.effect(
let step = 1
while (needsContinuation) {
const result = yield* runTurn(input.sessionID, promotion, step)
// Steer/queue promotion inside runTurn has already made the pending input a visible
// user message by this point, so the first-user-message check below is reliable.
if (!titleAttempted.has(input.sessionID)) {
titleAttempted.add(input.sessionID)
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
}
needsContinuation = result.needsContinuation
step = result.step + 1
promotion = "steer"
@ -428,6 +441,7 @@ export const node = makeLocationNode({
ReferenceGuidance.node,
McpGuidance.node,
SessionCompaction.node,
SessionTitle.node,
Snapshot.node,
Database.node,
],