From a54724870c6e6a81498bd52ac270df313f72bb9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Bibiano?= Date: Thu, 26 Mar 2026 10:46:15 -0300 Subject: [PATCH] fix: await session title generation before returning --- packages/opencode/src/session/prompt.ts | 11 +- packages/opencode/test/session/prompt.test.ts | 141 ++++++++++++++++++ 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b3c34539e77e..58f652bf7d7b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -294,6 +294,7 @@ export namespace SessionPrompt { let structuredOutput: unknown | undefined let step = 0 + let naming = Promise.resolve() const session = await Session.get(sessionID) while (true) { await SessionStatus.set(sessionID, { type: "busy" }) @@ -330,11 +331,15 @@ export namespace SessionPrompt { step++ if (step === 1) - ensureTitle({ + naming = ensureTitle({ session, + abort, modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, history: msgs, + }).catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") return + log.error("failed to ensure title", { error: err, sessionID }) }) const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID).catch((e) => { @@ -743,6 +748,7 @@ export namespace SessionPrompt { } continue } + await naming SessionCompaction.prune({ sessionID }) for await (const item of MessageV2.stream(sessionID)) { if (item.info.role === "user") continue @@ -1975,6 +1981,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the async function ensureTitle(input: { session: Session.Info history: MessageV2.WithParts[] + abort: AbortSignal providerID: ProviderID modelID: ModelID }) { @@ -2017,7 +2024,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the small: true, tools: {}, model, - abort: new AbortController().signal, + abort: input.abort, sessionID: input.session.id, retries: 2, messages: [ diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 7d1d42905792..64b1a53250aa 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -12,6 +12,74 @@ import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) +function stream(chunks: unknown[]) { + const payload = [...chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`), "data: [DONE]"].join("\n\n") + "\n\n" + const encoder = new TextEncoder() + return new ReadableStream({ + start(ctrl) { + ctrl.enqueue(encoder.encode(payload)) + ctrl.close() + }, + }) +} + +function response(model: string, text: string) { + return new Response( + stream([ + { + type: "response.created", + response: { + id: `${model}-response`, + created_at: Math.floor(Date.now() / 1000), + model, + service_tier: null, + }, + }, + { + type: "response.output_item.added", + output_index: 0, + item: { + type: "message", + id: `${model}-item`, + }, + }, + { + type: "response.output_text.delta", + item_id: `${model}-item`, + output_index: 0, + content_index: 0, + delta: text, + logprobs: null, + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + id: `${model}-item`, + }, + }, + { + type: "response.completed", + response: { + incomplete_details: null, + usage: { + input_tokens: 1, + input_tokens_details: null, + output_tokens: 1, + output_tokens_details: null, + }, + service_tier: null, + }, + }, + ]), + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }, + ) +} + describe("session.prompt missing file", () => { test("does not fail the prompt when a file part is missing", async () => { await using tmp = await tmpdir({ @@ -286,3 +354,76 @@ describe("session.agent-resolution", () => { }) }, 30000) }) + +describe("session title generation", () => { + test("waits for the title before returning", async () => { + const seen: string[] = [] + const server = Bun.serve({ + port: 0, + async fetch(req) { + const body = (await req.json()) as { model?: string } + const model = String(body.model) + seen.push(model) + + if (model === "gpt-5-mini") { + await Bun.sleep(200) + return response(model, "Debugging session titles") + } + + if (model === "gpt-5.2") { + return response(model, "OK") + } + + return new Response(`unexpected model: ${model}`, { status: 500 }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + config: { + enabled_providers: ["openai"], + agent: { + build: { + model: "openai/gpt-5.2", + }, + title: { + model: "openai/gpt-5-mini", + }, + }, + provider: { + openai: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const msg = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Help debug session titles" }], + }) + + expect(msg.info.role).toBe("assistant") + expect(seen).toContain("gpt-5.2") + expect(seen).toContain("gpt-5-mini") + + const info = await Session.get(session.id) + expect(Session.isDefaultTitle(info.title)).toBe(false) + expect(info.title).toBe("Debugging session titles") + }, + }) + } finally { + await Bun.sleep(250) + server.stop(true) + } + }, 30000) +})