diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index 8cc4ceaed0..85114d3588 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -117,21 +117,20 @@ export const preview = Effect.fn("SessionHistory.preview")(function* ( .pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error)))) }) -/** Returns the session's sole user message, or `undefined` once a second one exists. */ -export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* ( +/** Returns the session's first user message. */ +export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, ) { - const rows = yield* db + const row = yield* db .select() .from(SessionMessageTable) .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user"))) .orderBy(asc(SessionMessageTable.seq)) - .limit(2) - .all() + .get() .pipe(Effect.orDie) - if (rows.length !== 1) return undefined - const message = yield* decodeMessageRow(rows[0]).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!row) return undefined + const message = yield* decodeMessageRow(row).pipe(Effect.catch(() => Effect.succeed(undefined))) return message?.type === "user" ? message : undefined }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 42f871e8f7..5fb61e2635 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -93,10 +93,9 @@ const layer = Layer.effect( 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 step; it must not delay step 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 titleStarted = new Set() + // Title generation is a side effect of a successful step; it must not delay continuation. + // The in-flight set coalesces overlapping steps while title presence records success durably. + const titlesRunning = new Set() const forkTitle = yield* FiberSet.makeRuntime() /** * Drains eligible manual compaction and user input until the Session becomes idle. @@ -125,7 +124,7 @@ const layer = Layer.effect( let step = 1 while (true) { const result = yield* runStep(sessionID, promotable, step) - yield* startTitleOnce(sessionID) + if (step === 1) yield* startTitle(sessionID) yield* runPendingCompaction(sessionID) if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return promotable = "steer" @@ -481,11 +480,20 @@ const layer = Layer.effect( } }) - /** Fires title generation once per process after the first step makes a user message visible. */ - const startTitleOnce = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) { - if (titleStarted.has(sessionID)) return - titleStarted.add(sessionID) - forkTitle(title.generateForFirstPrompt(yield* getSession(sessionID)).pipe(Effect.ignore)) + /** Starts one title request at a time after a successful step makes user input visible. */ + const startTitle = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) { + if (titlesRunning.has(sessionID)) return + titlesRunning.add(sessionID) + forkTitle( + title.generateForFirstPrompt(sessionID).pipe( + Effect.ignore, + Effect.ensuring( + Effect.sync(() => { + titlesRunning.delete(sessionID) + }), + ), + ), + ) }) const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index ed795477b4..f11a55e97f 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -1,7 +1,7 @@ export * as SessionTitle from "./title" import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai" -import { Context, Effect, Layer, Stream } from "effect" +import { Context, DateTime, Effect, Layer, Stream } from "effect" import { Agent } from "../agent" import { Database } from "../database/database" import { Bus } from "../bus" @@ -14,8 +14,10 @@ import { SessionModelHeaders } from "./model-headers" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { SessionUsage } from "./usage" +import { SessionStore } from "./store" const MAX_LENGTH = 100 +const titleChanged = Symbol("Session title changed") type Dependencies = { readonly app: App.Info @@ -25,24 +27,30 @@ type Dependencies = { } readonly agents: Agent.Interface readonly models: SessionRunnerModel.Interface + readonly store: SessionStore.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 + /** Generates a title from the session's first user message when the session remains untitled. */ + readonly generateForFirstPrompt: (sessionID: SessionSchema.ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/SessionTitle") {} const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`) +const isUntitled = (session: SessionSchema.Info) => + session.title === undefined || session.title === `New session - ${DateTime.formatIso(session.time.created)}` const make = (dependencies: Dependencies) => { const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* ( db: Database.Interface["db"], - session: SessionSchema.Info, + sessionID: SessionSchema.ID, ) { + const session = yield* dependencies.store.get(sessionID) + if (!session) return if (session.parentID) return - const firstUser = yield* SessionHistory.firstUserMessageIfOnly(db, session.id) + if (!isUntitled(session)) return + const firstUser = yield* SessionHistory.firstUserMessage(db, session.id) if (!firstUser) return const agent = yield* dependencies.agents.get(Agent.ID.make("title")) if (!agent) return @@ -96,10 +104,19 @@ const make = (dependencies: Dependencies) => { .map((line) => line.trim()) .find((line) => line.length > 0) if (!title) return - yield* dependencies.bus.publish(SessionEvent.Renamed, { - sessionID: session.id, - title: truncate(title), - }) + const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1 + const current = yield* dependencies.store.get(sessionID) + if (!current || !isUntitled(current)) return + yield* dependencies.bus + .publish( + SessionEvent.Renamed, + { + sessionID: session.id, + title: truncate(title), + }, + { commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) }, + ) + .pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect)))) }) return { generateForFirstPrompt } } @@ -111,11 +128,12 @@ export const layer = Layer.effect( const llm = yield* LLMClient.Service const agents = yield* Agent.Service const models = yield* SessionRunnerModel.Service + const store = yield* SessionStore.Service const database = yield* Database.Service const app = yield* App.Metadata - const title = make({ bus, llm, agents, models, app }) + const title = make({ bus, llm, agents, models, store, app }) return Service.of({ - generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session), + generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID), }) }), ) @@ -123,5 +141,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, Database.node, App.node], + deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node], }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6bbf8d90a7..110d1505a0 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -796,6 +796,59 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => }) describe("SessionRunnerLLM", () => { + it.effect("retries title generation from the first prompt after execution and title failures", () => + Effect.gen(function* () { + const session = yield* setup + const agents = yield* Agent.Service + const { db } = yield* Database.Service + yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie) + yield* agents.transform((draft) => + draft.update(Agent.ID.make("title"), (agent) => { + agent.mode = "primary" + agent.hidden = true + agent.system = "Generate a title." + }), + ) + + yield* admit(session, "First prompt") + yield* TestLLM.push(Stream.fail(invalidRequest())) + expect((yield* session.resume(sessionID).pipe(Effect.exit))._tag).toBe("Failure") + + yield* admit(session, "Second prompt") + const titleFailed = yield* Deferred.make() + yield* TestLLM.push( + TestLLM.text("Recovered", "text-recovered"), + Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe( + Stream.ensuring(Deferred.succeed(titleFailed, undefined)), + ), + ) + yield* session.resume(sessionID) + yield* Deferred.await(titleFailed) + yield* Effect.yieldNow + expect((yield* session.get(sessionID)).title).toBeUndefined() + + const bus = yield* Bus.Service + const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe( + Stream.filter((event) => event.data.sessionID === sessionID), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ) + yield* admit(session, "Third prompt") + yield* TestLLM.push( + TestLLM.text("Recovered again", "text-recovered-again"), + TestLLM.text("Generated title", "text-title"), + ) + yield* session.resume(sessionID) + yield* Fiber.join(renamed) + + expect(requests).toHaveLength(5) + expect(requests[2]?.messages).toContainEqual(Message.user("First prompt")) + expect(requests[4]?.messages).toContainEqual(Message.user("First prompt")) + expect((yield* session.get(sessionID)).title).toBe("Generated title") + }), + ) + it.effect("applies session context hooks without exposing unavailable tools", () => Effect.gen(function* () { const session = yield* setup diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 6c95cdb009..ac4e4f6097 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -20,7 +20,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { App } from "@opencode-ai/core/app" import { AbsolutePath } from "@opencode-ai/core/schema" import { Money } from "@opencode-ai/schema/money" -import { Effect, Layer, Stream } from "effect" +import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" let requests: LLMRequest[] = [] @@ -39,27 +39,30 @@ const cost = [ }, }, ] +const successfulTitle = () => + Stream.make( + LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }), + LLMEvent.stepFinish({ + index: 0, + reason: { normalized: "stop" }, + usage: { + inputTokens: 15, + outputTokens: 6, + nonCachedInputTokens: 10, + cacheReadInputTokens: 3, + cacheWriteInputTokens: 2, + reasoningTokens: 2, + }, + }), + LLMEvent.finish({ + reason: { normalized: "stop" }, + }), + ) +let titleStream: () => Stream.Stream = successfulTitle const client = Layer.mock(LLMClient.Service)({ stream: (request: LLMRequest) => { requests.push(request) - return Stream.make( - LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }), - LLMEvent.stepFinish({ - index: 0, - reason: { normalized: "stop" }, - usage: { - inputTokens: 15, - outputTokens: 6, - nonCachedInputTokens: 10, - cacheReadInputTokens: 3, - cacheWriteInputTokens: 2, - reasoningTokens: 2, - }, - }), - LLMEvent.finish({ - reason: { normalized: "stop" }, - }), - ) + return titleStream() }, generate: () => Effect.die("unused"), }) @@ -89,7 +92,7 @@ const it = testEffect( ), ) -const insertSession = (id: Session.ID) => +const insertSession = (id: Session.ID, title?: string, created?: number) => Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -105,7 +108,8 @@ const insertSession = (id: Session.ID) => project_id: Project.ID.global, slug: id, directory: "/project", - title: "New session - fake", + title, + time_created: created, version: "test", }) .onConflictDoNothing() @@ -131,6 +135,7 @@ const prompt = (sessionID: Session.ID, text: string) => it.effect("generates a title from the sole user message and renames the session", () => Effect.gen(function* () { requests = [] + titleStream = successfulTitle const agentService = yield* Agent.Service yield* agentService.transform((editor) => { editor.update(Agent.ID.make("title"), (agent) => { @@ -144,11 +149,8 @@ it.effect("generates a title from the sole user message and renames the session" 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) + yield* title.generateForFirstPrompt(sessionID) expect(requests).toHaveLength(1) expect(requests[0]?.http?.headers).toEqual({ @@ -167,9 +169,10 @@ it.effect("generates a title from the sole user message and renames the session" }), ) -it.effect("does not generate once a second user message exists", () => +it.effect("generates from the first user message after later messages exist", () => Effect.gen(function* () { requests = [] + titleStream = successfulTitle const agentService = yield* Agent.Service yield* agentService.transform((editor) => { editor.update(Agent.ID.make("title"), (agent) => { @@ -184,21 +187,46 @@ it.effect("does not generate once a second user message exists", () => 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) + yield* title.generateForFirstPrompt(sessionID) - expect(requests).toHaveLength(0) - const untouched = yield* store.get(sessionID) - expect(untouched?.title).toBe("New session - fake") + expect(requests).toHaveLength(1) + expect(JSON.stringify(requests[0]?.messages)).toContain("First message") + expect(JSON.stringify(requests[0]?.messages)).not.toContain("Second message") + expect((yield* store.get(sessionID))?.title).toBe("Generated Title") + }), +) + +it.effect("retries a legacy persisted fallback title", () => + Effect.gen(function* () { + requests = [] + titleStream = successfulTitle + const agentService = yield* Agent.Service + yield* agentService.transform((editor) => { + editor.update(Agent.ID.make("title"), (agent) => { + agent.mode = "primary" + agent.hidden = true + agent.system = "You are a title generator." + }) + }) + const sessionID = Session.ID.make("ses_title_legacy") + const created = Date.parse("2026-07-30T18:45:03.662Z") + yield* insertSession(sessionID, "New session - 2026-07-30T18:45:03.662Z", created) + yield* prompt(sessionID, "Retry the legacy title") + + const title = yield* SessionTitle.Service + yield* title.generateForFirstPrompt(sessionID) + + const store = yield* SessionStore.Service + expect(requests).toHaveLength(1) + expect((yield* store.get(sessionID))?.title).toBe("Generated Title") }), ) it.effect("does not generate for a child session", () => Effect.gen(function* () { requests = [] + titleStream = successfulTitle const agentService = yield* Agent.Service yield* agentService.transform((editor) => { editor.update(Agent.ID.make("title"), (agent) => { @@ -223,7 +251,6 @@ it.effect("does not generate for a child session", () => parent_id: Session.ID.make("ses_title_parent"), slug: sessionID, directory: "/project", - title: "Child session - fake", version: "test", }) .onConflictDoNothing() @@ -231,12 +258,8 @@ it.effect("does not generate for a child session", () => .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) + yield* title.generateForFirstPrompt(sessionID) expect(requests).toHaveLength(0) }), @@ -245,19 +268,100 @@ it.effect("does not generate for a child session", () => it.effect("does not generate when the title agent is removed", () => Effect.gen(function* () { requests = [] + titleStream = successfulTitle const sessionID = Session.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) + yield* title.generateForFirstPrompt(sessionID) expect(requests).toHaveLength(0) const untouched = yield* store.get(sessionID) - expect(untouched?.title).toBe("New session - fake") + expect(untouched?.title).toBeUndefined() + }), +) + +it.effect("does not overwrite an explicit title", () => + Effect.gen(function* () { + requests = [] + titleStream = successfulTitle + const sessionID = Session.ID.make("ses_title_explicit") + yield* insertSession(sessionID) + yield* prompt(sessionID, "Help me debug the failing build") + const events = yield* Bus.Service + yield* events.publish(SessionEvent.Renamed, { sessionID, title: "New session - 2099-01-01T00:00:00.000Z" }) + + const title = yield* SessionTitle.Service + yield* title.generateForFirstPrompt(sessionID) + + const store = yield* SessionStore.Service + expect(requests).toHaveLength(0) + expect((yield* store.get(sessionID))?.title).toBe("New session - 2099-01-01T00:00:00.000Z") + }), +) + +it.effect("retries after a failed title request", () => + Effect.gen(function* () { + requests = [] + const agentService = yield* Agent.Service + yield* agentService.transform((editor) => { + editor.update(Agent.ID.make("title"), (agent) => { + agent.mode = "primary" + agent.hidden = true + agent.system = "You are a title generator." + }) + }) + const sessionID = Session.ID.make("ses_title_retry") + yield* insertSession(sessionID) + yield* prompt(sessionID, "Retry this title") + const title = yield* SessionTitle.Service + titleStream = () => Stream.make(LLMEvent.providerError({ message: "Provider unavailable" })) + + yield* title.generateForFirstPrompt(sessionID) + titleStream = successfulTitle + yield* title.generateForFirstPrompt(sessionID) + + const store = yield* SessionStore.Service + expect(requests).toHaveLength(2) + expect((yield* store.get(sessionID))?.title).toBe("Generated Title") + }), +) + +it.effect("preserves a manual rename completed while generation is in flight", () => + Effect.gen(function* () { + requests = [] + const agentService = yield* Agent.Service + yield* agentService.transform((editor) => { + editor.update(Agent.ID.make("title"), (agent) => { + agent.mode = "primary" + agent.hidden = true + agent.system = "You are a title generator." + }) + }) + const sessionID = Session.ID.make("ses_title_manual_rename") + yield* insertSession(sessionID) + yield* prompt(sessionID, "Generate this title") + const started = yield* Deferred.make() + const release = yield* Deferred.make() + titleStream = () => + Stream.unwrap( + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(successfulTitle()), + ), + ) + const title = yield* SessionTitle.Service + const fiber = yield* title.generateForFirstPrompt(sessionID).pipe(Effect.forkScoped) + yield* Deferred.await(started) + const events = yield* Bus.Service + yield* events.publish(SessionEvent.Renamed, { sessionID, title: "Manual title" }) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(fiber) + + const store = yield* SessionStore.Service + expect(requests).toHaveLength(1) + expect((yield* store.get(sessionID))?.title).toBe("Manual title") }), )