From 5710064ae173592c2e1568be16d3c9114f1feb1b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 1 Jul 2026 11:07:26 -0400 Subject: [PATCH] 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 --- packages/core/src/location-services.ts | 2 + packages/core/src/session/history.ts | 18 ++ packages/core/src/session/runner/llm.ts | 14 ++ packages/core/src/session/title.ts | 105 +++++++++ .../core/test/session-runner-recorded.test.ts | 2 + packages/core/test/session-runner.test.ts | 2 + packages/core/test/session-title.test.ts | 213 ++++++++++++++++++ 7 files changed, 356 insertions(+) create mode 100644 packages/core/src/session/title.ts create mode 100644 packages/core/test/session-title.test.ts diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 81654eb3a4..711c57117e 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -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, ]) diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index fb55ab0756..f72ff91ab8 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -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" diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 6d0e768dbc..bd2fec7916 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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() + const forkTitle = yield* FiberSet.makeRuntime() 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, ], diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts new file mode 100644 index 0000000000..2467197271 --- /dev/null +++ b/packages/core/src/session/title.ts @@ -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 + } + 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 +} + +export class Service extends Context.Service()("@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], +}) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index a6c86658b5..82be33fcc7 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -15,6 +15,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Snapshot } from "@opencode-ai/core/snapshot" import { SessionCompaction } from "@opencode-ai/core/session/compaction" +import { SessionTitle } from "@opencode-ai/core/session/title" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -77,6 +78,7 @@ const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(SessionCompaction.layer), + Layer.provide(SessionTitle.layer), Layer.provide(Snapshot.noopLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 1405108dc0..23c2e5099d 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -24,6 +24,7 @@ import { Snapshot } from "@opencode-ai/core/snapshot" import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionCompaction } from "@opencode-ai/core/session/compaction" +import { SessionTitle } from "@opencode-ai/core/session/title" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" @@ -232,6 +233,7 @@ const config = Layer.succeed( ) const runner = SessionRunnerLLM.layer.pipe( Layer.provide(SessionCompaction.layer), + Layer.provide(SessionTitle.layer), Layer.provide(Snapshot.noopLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts new file mode 100644 index 0000000000..9a7e9ea0de --- /dev/null +++ b/packages/core/test/session-title.test.ts @@ -0,0 +1,213 @@ +import { expect } from "bun:test" +import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm" +import { OpenAIChat } from "@opencode-ai/llm/protocols" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { Prompt } from "@opencode-ai/core/session/prompt" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionTitle } from "@opencode-ai/core/session/title" +import { SessionV2 } from "@opencode-ai/core/session" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { DateTime, Effect, Layer, Stream } from "effect" +import { testEffect } from "./lib/effect" + +let requests: LLMRequest[] = [] +const model = Model.make({ + id: "title-model", + provider: "test", + route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), +}) +const client = Layer.mock(LLMClient.Service)({ + prepare: () => Effect.die("unused"), + stream: (request: LLMRequest) => { + requests.push(request) + return Stream.make(LLMEvent.textDelta({ id: "title", text: "Generated Title\n" })) + }, + generate: () => Effect.die("unused"), +}) +const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) }) +const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) +const it = testEffect( + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + agents, + SessionTitle.layer.pipe( + Layer.provide(client), + Layer.provide(models), + Layer.provide(agents), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + ), + ), +) + +const insertSession = (id: SessionV2.ID) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id, + project_id: Project.ID.global, + slug: id, + directory: "/project", + title: "New session - fake", + version: "test", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }) + +const prompt = (sessionID: SessionV2.ID, text: string) => + Effect.gen(function* () { + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.Prompted, { + sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(0), + prompt: Prompt.make({ text }), + delivery: "steer", + }) + }) + +it.effect("generates a title from the sole user message and renames the session", () => + Effect.gen(function* () { + requests = [] + const agentService = yield* AgentV2.Service + yield* agentService.transform((editor) => { + editor.update(AgentV2.ID.make("title"), (agent) => { + agent.mode = "primary" + agent.hidden = true + agent.system = "You are a title generator." + }) + }) + const sessionID = SessionV2.ID.make("ses_title_generate") + yield* insertSession(sessionID) + yield* prompt(sessionID, "Help me debug the failing build") + + const store = yield* SessionStore.Service + const session = yield* store.get(sessionID).pipe( + Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))), + ) + const title = yield* SessionTitle.Service + yield* title.generateForFirstPrompt(session) + + expect(requests).toHaveLength(1) + expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build") + const renamed = yield* store.get(sessionID) + expect(renamed?.title).toBe("Generated Title") + }), +) + +it.effect("does not generate once a second user message exists", () => + Effect.gen(function* () { + requests = [] + const agentService = yield* AgentV2.Service + yield* agentService.transform((editor) => { + editor.update(AgentV2.ID.make("title"), (agent) => { + agent.mode = "primary" + agent.hidden = true + agent.system = "You are a title generator." + }) + }) + const sessionID = SessionV2.ID.make("ses_title_second_message") + yield* insertSession(sessionID) + yield* prompt(sessionID, "First message") + yield* prompt(sessionID, "Second message") + + const store = yield* SessionStore.Service + const session = yield* store.get(sessionID).pipe( + Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))), + ) + const title = yield* SessionTitle.Service + yield* title.generateForFirstPrompt(session) + + expect(requests).toHaveLength(0) + const untouched = yield* store.get(sessionID) + expect(untouched?.title).toBe("New session - fake") + }), +) + +it.effect("does not generate for a child session", () => + Effect.gen(function* () { + requests = [] + const agentService = yield* AgentV2.Service + yield* agentService.transform((editor) => { + editor.update(AgentV2.ID.make("title"), (agent) => { + agent.mode = "primary" + agent.hidden = true + agent.system = "You are a title generator." + }) + }) + const sessionID = SessionV2.ID.make("ses_title_child") + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + parent_id: SessionV2.ID.make("ses_title_parent"), + slug: sessionID, + directory: "/project", + title: "Child session - fake", + version: "test", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* prompt(sessionID, "Do this subtask") + + const store = yield* SessionStore.Service + const session = yield* store.get(sessionID).pipe( + Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))), + ) + const title = yield* SessionTitle.Service + yield* title.generateForFirstPrompt(session) + + expect(requests).toHaveLength(0) + }), +) + +it.effect("does not generate when the title agent is removed", () => + Effect.gen(function* () { + requests = [] + const sessionID = SessionV2.ID.make("ses_title_no_agent") + yield* insertSession(sessionID) + yield* prompt(sessionID, "Help me debug the failing build") + + const store = yield* SessionStore.Service + const session = yield* store.get(sessionID).pipe( + Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))), + ) + const title = yield* SessionTitle.Service + yield* title.generateForFirstPrompt(session) + + expect(requests).toHaveLength(0) + const untouched = yield* store.get(sessionID) + expect(untouched?.title).toBe("New session - fake") + }), +)