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