Kaynağa Gözat

fix(session): retry failed title generation (#39748)

Kit Langton 2 hafta önce
ebeveyn
işleme
0a6a5d3e80

+ 6 - 7
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
 })
 

+ 18 - 10
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<SessionSchema.ID>()
+    // 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<SessionSchema.ID>()
     const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
     /**
      * 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) {

+ 30 - 12
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<void>
+  /** Generates a title from the session's first user message when the session remains untitled. */
+  readonly generateForFirstPrompt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@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],
 })

+ 53 - 0
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<void>()
+      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

+ 148 - 44
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<LLMEvent> = 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<void>()
+    const release = yield* Deferred.make<void>()
+    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")
   }),
 )