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

@ -31,6 +31,7 @@ import { ReferenceGuidance } from "./reference/guidance"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionCompaction } from "./session/compaction"
import { SessionTitle } from "./session/title"
import { SessionTodo } from "./session/todo"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
@ -86,6 +87,7 @@ export const locationServices = LayerNode.group([
McpTool.node,
SessionRunnerModel.node,
SessionCompaction.node,
SessionTitle.node,
Snapshot.node,
SessionRunnerLLM.node,
])

View file

@ -98,4 +98,22 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
)
})
/** Returns the session's sole user message, or `undefined` once a second one exists. */
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user")))
.orderBy(asc(SessionMessageTable.seq))
.limit(2)
.all()
.pipe(Effect.orDie)
if (rows.length !== 1) return undefined
const message = yield* decodeMessageRow(rows[0]).pipe(Effect.catch(() => Effect.succeed(undefined)))
return message?.type === "user" ? message : undefined
})
export * as SessionHistory from "./history"

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,
],

View file

@ -0,0 +1,105 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
const MAX_LENGTH = 100
type Dependencies = {
readonly events: EventV2.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly agents: AgentV2.Interface
readonly models: SessionRunnerModel.Interface
}
export interface Interface {
/** Generates a title from the session's first user message and renames the session. Runs at most once per session. */
readonly generateForFirstPrompt: (session: SessionSchema.Info) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionTitle") {}
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
const make = (dependencies: Dependencies) => {
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
db: Database.Interface["db"],
session: SessionSchema.Info,
) {
if (session.parentID) return
const firstUser = yield* SessionHistory.firstUserMessageIfOnly(db, session.id)
if (!firstUser) return
const agent = yield* dependencies.agents.get(AgentV2.ID.make("title"))
if (!agent) return
const model = yield* (agent.model
? dependencies.models.resolve({ ...session, model: agent.model })
: dependencies.models.resolve(session)
).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!model) return
const chunks: string[] = []
let failed = false
const streamed = yield* dependencies.llm
.stream(
LLM.request({
model,
system: agent.system,
messages: [Message.user(firstUser.text)],
tools: [],
}),
)
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
return Effect.void
}),
Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
)
if (!streamed || failed) return
const title = chunks
.join("")
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0)
if (!title) return
yield* dependencies.events.publish(SessionEvent.Renamed, {
sessionID: session.id,
timestamp: yield* DateTime.now,
title: truncate(title),
})
})
return { generateForFirstPrompt }
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service
const models = yield* SessionRunnerModel.Service
const database = yield* Database.Service
const title = make({ events, llm, agents, models })
return Service.of({
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node],
})