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") + }), +)