From cb48ac8778c314d6920b3afff978b38e26996c3b Mon Sep 17 00:00:00 2001 From: Thom Allen Date: Tue, 15 Sep 2026 13:44:18 -0600 Subject: [PATCH] fix(session): retry title generation and fall back to the session model Title generation runs once with the small model and silently fails: any error leaves the session with its default title forever, because ensureTitle only fires when the session has exactly one real user message and never falls back to the main model. - Retry title generation on later messages while the title is still default (drop the single-message guard), so transient failures recover. - Fall back to the session model (small=false) when the small-model request errors, instead of swallowing the failure. - Add TestLLMServer.titleError to simulate title request failures and cover both behaviors in the prompt test suite. Fixes #42287, #30662 --- packages/opencode/src/session/prompt.ts | 80 ++++++++++++++----- packages/opencode/test/lib/llm-server.ts | 24 ++++++ packages/opencode/test/session/prompt.test.ts | 76 ++++++++++++++++++ 3 files changed, 158 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..ad5602406f64 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -190,6 +190,39 @@ const layer = Layer.effect( return parts }) + const generateTitle = Effect.fn("SessionPrompt.generateTitle")(function* (input: { + agent: Agent.Info + user: SessionV1.User + context: SessionV1.WithParts[] + subtasks: SessionV1.SubtaskPart[] + onlySubtasks: boolean + model: Provider.Model + small: boolean + sessionID: SessionID + }) { + const msgs = input.onlySubtasks + ? [{ role: "user" as const, content: input.subtasks.map((p) => p.prompt).join("\n") }] + : yield* MessageV2.toModelMessagesEffect(input.context, input.model) + return yield* llm + .stream({ + agent: input.agent, + user: input.user, + system: [], + small: input.small, + tools: {}, + model: input.model, + sessionID: input.sessionID, + retries: 2, + messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs], + }) + .pipe( + Stream.filter(LLMEvent.is.textDelta), + Stream.map((e) => e.text), + Stream.mkString, + Effect.orDie, + ) + }) + const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: { session: Session.Info history: SessionV1.WithParts[] @@ -203,7 +236,6 @@ const layer = Layer.effect( m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic) const idx = input.history.findIndex(real) if (idx === -1) return - if (input.history.filter(real).length !== 1) return const context = input.history.slice(0, idx + 1) const firstUser = context[idx] @@ -219,27 +251,31 @@ const layer = Layer.effect( ? yield* provider.getModel(ag.model.providerID, ag.model.modelID) : ((yield* provider.getSmallModel(input.providerID)) ?? (yield* provider.getModel(input.providerID, input.modelID))) - const msgs = onlySubtasks - ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }] - : yield* MessageV2.toModelMessagesEffect(context, mdl) - const text = yield* llm - .stream({ - agent: ag, - user: firstInfo, - system: [], - small: true, - tools: {}, - model: mdl, - sessionID: input.session.id, - retries: 2, - messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs], - }) - .pipe( - Stream.filter(LLMEvent.is.textDelta), - Stream.map((e) => e.text), - Stream.mkString, - Effect.orDie, - ) + const base = { + agent: ag, + user: firstInfo, + context, + subtasks, + onlySubtasks, + sessionID: input.session.id, + } + const text = yield* generateTitle({ ...base, model: mdl, small: true }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logWarning( + "session title generation with small model failed; falling back to session model", + { + "session.id": input.session.id, + providerID: mdl.providerID, + modelID: mdl.id, + error: Cause.squash(cause), + }, + ) + const fallback = yield* provider.getModel(input.providerID, input.modelID) + return yield* generateTitle({ ...base, model: fallback, small: false }) + }), + ), + ) const cleaned = text .replace(/[\s\S]*?<\/think>\s*/g, "") .split("\n") diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 245acc7280f5..f5103fe39885 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -609,6 +609,8 @@ function isTitleRequest(body: unknown): boolean { return JSON.stringify(body).includes("Generate a title for this conversation") } +const isTitleHit = (hit: Hit): boolean => isTitleRequest(hit.body) + namespace TestLLMServer { export interface Service { readonly url: string @@ -622,6 +624,7 @@ namespace TestLLMServer { readonly reason: (value: string, opts?: { text?: string; usage?: Usage }) => Effect.Effect readonly fail: (message?: unknown) => Effect.Effect readonly error: (status: number, body: unknown) => Effect.Effect + readonly titleError: (status: number, body: unknown) => Effect.Effect readonly hang: Effect.Effect readonly hold: (value: string, wait: PromiseLike) => Effect.Effect readonly reset: Effect.Effect @@ -669,6 +672,14 @@ export class TestLLMServer extends Context.Service { + const index = list.findIndex((entry) => entry.match && entry.match(hit)) + if (index === -1) return + const first = list[index] + list = [...list.slice(0, index), ...list.slice(index + 1)] + return first.item + } + const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") { const req = yield* HttpServerRequest.HttpServerRequest const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({}))) @@ -676,6 +687,16 @@ export class TestLLMServer extends Context.Service) => + JSON.stringify(body).includes("Generate a title for this conversation") + +it.instance("title generation falls back to a second attempt when the title request errors", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({}) + + yield* llm.titleError(400, { error: { message: "title request failed" } }) + yield* llm.text("world") + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + yield* prompt.loop({ sessionID: chat.id }) + + yield* pollWithTimeout( + Effect.gen(function* () { + const session = yield* sessions.get(chat.id) + return session.title === "E2E Title" ? true : undefined + }), + "title fallback never set the session title", + ) + expect(yield* llm.pending).toBe(0) + + const titleBodies = (yield* llm.inputs).filter(isTitleBody) + expect(titleBodies).toHaveLength(2) + }), +) + +it.instance("title generation retries on a later message while the title is still default", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({}) + + yield* llm.titleError(400, { error: { message: "title request failed" } }) + yield* llm.titleError(400, { error: { message: "title request failed" } }) + yield* llm.text("world one") + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "first" }], + }) + yield* prompt.loop({ sessionID: chat.id }) + yield* llm.wait(3) + expect((yield* sessions.get(chat.id)).title).toMatch(/^New session - /) + + yield* llm.text("world two") + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "second" }], + }) + yield* prompt.loop({ sessionID: chat.id }) + + yield* pollWithTimeout( + Effect.gen(function* () { + const session = yield* sessions.get(chat.id) + return session.title === "E2E Title" ? true : undefined + }), + "title generation did not retry on a later message", + ) + expect(yield* llm.pending).toBe(0) + }), +)