diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a9edf838ca8c..0740324c138b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -336,7 +336,7 @@ export namespace SessionPrompt { step++ if (step === 1) - ensureTitle({ + await ensureTitle({ session, modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, @@ -1983,6 +1983,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: ProviderID modelID: ModelID }) { + function fallback(msg: MessageV2.WithParts) { + const line = msg.parts + .filter((part) => part.type === "text") + .flatMap((part) => part.text.split("\n")) + .map((line) => line.trim()) + .find((line) => line.length > 0) + if (!line) return + const title = line + .replace(/[\s\-:;,.!?]+$/g, "") + .split(/\s+/) + .filter(Boolean) + .slice(0, 5) + .join(" ") + if (!title) return + return Session.setTitle({ sessionID: input.session.id, title: title.length > 100 ? title.substring(0, 97) + "..." : title }).catch((err) => { + if (NotFoundError.isInstance(err)) return + throw err + }) + } + if (input.session.parentID) return if (!Session.isDefaultTitle(input.session.title)) return @@ -2052,5 +2072,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } catch (error) { log.error("failed to generate title", { error }) } + + return fallback(firstRealUser) } } diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 51d2e11941ae..307693b70320 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,5 +1,5 @@ import path from "path" -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, test } from "bun:test" import { NamedError } from "@opencode-ai/util/error" import { fileURLToPath } from "url" import { Instance } from "../../src/project/instance" @@ -9,84 +9,19 @@ import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" +import { LLM } from "../../src/session/llm" +import { Filesystem } from "../../src/util/filesystem" Log.init({ print: false }) -function defer() { - let resolve!: (value: T | PromiseLike) => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -function chat(text: string) { - const payload = - [ - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { role: "assistant" } }], - })}`, - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { content: text } }], - })}`, - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: {}, finish_reason: "stop" }], - })}`, - "data: [DONE]", - ].join("\n\n") + "\n\n" - - const encoder = new TextEncoder() - return new ReadableStream({ - start(ctrl) { - ctrl.enqueue(encoder.encode(payload)) - ctrl.close() - }, - }) -} - -function hanging(ready: () => void) { - const encoder = new TextEncoder() - let timer: ReturnType | undefined - const first = - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { role: "assistant" } }], - })}` + "\n\n" - const rest = - [ - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { content: "late" } }], - })}`, - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: {}, finish_reason: "stop" }], - })}`, - "data: [DONE]", - ].join("\n\n") + "\n\n" - - return new ReadableStream({ - start(ctrl) { - ctrl.enqueue(encoder.encode(first)) - ready() - timer = setTimeout(() => { - ctrl.enqueue(encoder.encode(rest)) - ctrl.close() - }, 10000) - }, - cancel() { - if (timer) clearTimeout(timer) - }, - }) +async function loadFixture(providerID: string, modelID: string) { + const fixturePath = path.join(import.meta.dir, "../tool/fixtures/models-api.json") + const data = await Filesystem.readJson }>>(fixturePath) + const provider = data[providerID] + if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`) + const model = provider.models[modelID] + if (!model) throw new Error(`Missing model in fixture: ${modelID}`) + return model } describe("session.prompt missing file", () => { @@ -516,3 +451,73 @@ describe("session.agent-resolution", () => { }) }, 30000) }) + +describe("session title fallback", () => { + test("falls back to the first few words when title generation returns nothing", async () => { + const model = await loadFixture("openai", "gpt-5.2") + const stream = spyOn(LLM, "stream") + .mockResolvedValueOnce({ + text: Promise.resolve(""), + } as Awaited>) + .mockResolvedValueOnce({ + fullStream: (async function* () { + yield { type: "start" } + yield { + type: "finish-step", + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + providerMetadata: {}, + } + yield { type: "finish" } + })(), + } as unknown as Awaited>) + + await using tmp = await tmpdir({ + git: true, + config: { + enabled_providers: ["openai"], + provider: { + openai: { + options: { + apiKey: "test-openai-key", + models: { + "gpt-5.2": model, + }, + }, + }, + }, + agent: { + build: { + model: "openai/gpt-5.2", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + + await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "All of my sessions are names new session is the a telemetry package sideffect?" }], + }) + + expect(stream).toHaveBeenCalledTimes(2) + + let info = await Session.get(session.id) + for (let i = 0; i < 20 && info?.title !== "All of my sessions are"; i++) { + await new Promise((resolve) => setTimeout(resolve, 50)) + info = await Session.get(session.id) + } + expect(info?.title).toBe("All of my sessions are") + + await Session.remove(session.id) + }, + }) + + stream.mockRestore() + }) +})