From 5ebba5e4197ab2dfd5fe8da7812be535f4747ce7 Mon Sep 17 00:00:00 2001 From: maco Date: Fri, 25 Sep 2026 06:20:20 +0000 Subject: [PATCH 1/2] fix(server): let OpenCode generate session titles OpenCode skips SessionPrompt.ensureTitle when session.create already has a title. T3 was sending the prompt seed, so those sessions never got a generated title. Only a user rename is forwarded now. Fixes #13247 --- .../Layers/ProviderCommandReactor.test.ts | 90 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 21 ++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 254aec4bde7e..a65329707e96 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -891,6 +891,96 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.status).toBe("starting"); expect(thread?.session?.runtimeMode).toBe("approval-required"); + expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title"); + }); + + it("forwards only a user-renamed title when starting a provider session", async () => { + const harness = await createHarness({ initialTitle: "Add a progressive blur as you scroll" }); + const now = "2026-01-01T00:00:00.000Z"; + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const startTurn = (threadId: string, text: string, titleSeed: string) => + Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-title-${threadId}`), + threadId: ThreadId.make(threadId), + message: { + messageId: asMessageId(`message-${threadId}`), + role: "user", + text, + attachments: [], + }, + titleSeed, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await startTurn( + "thread-1", + "Add a progressive blur as you scroll", + "Add a progressive blur as you scroll", + ); + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title"); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-renamed"), + threadId: ThreadId.make("thread-renamed"), + projectId: asProjectId("project-1"), + title: "New thread", + modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-rename"), + threadId: ThreadId.make("thread-renamed"), + title: "Keep this name", + }), + ); + await startTurn("thread-renamed", "hello there", "hello there"); + await waitFor(() => harness.startSession.mock.calls.length === 2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ title: "Keep this name" }); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-seeded"), + threadId: ThreadId.make("thread-seeded"), + projectId: asProjectId("project-1"), + title: "New thread", + modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-autotitle"), + threadId: ThreadId.make("thread-seeded"), + title: "hello there", + }), + ); + await startTurn("thread-seeded", "hello there", "hello there"); + await waitFor(() => harness.startSession.mock.calls.length === 3); + expect(harness.startSession.mock.calls[2]?.[1]).not.toHaveProperty("title"); }); effectIt.effect("projects inline context before sending the provider turn", () => diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index f6794135a925..77022a8518d7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -575,6 +575,9 @@ const make = Effect.gen(function* () { options?: { readonly modelSelection?: ModelSelection; readonly pendingTurnStart?: boolean; + // First-turn prompt seed. A manual title that still equals this seed was + // written by the client's auto-title, not a user rename. + readonly titleSeed?: string; }, ) { const thread = yield* resolveThreadShell(threadId); @@ -712,6 +715,15 @@ const make = Effect.gen(function* () { .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd }) .pipe(Effect.forkDetach) : Effect.void; + // OpenCode skips SessionPrompt.ensureTitle when session.create already has + // a title. Prompt seeds and "New thread" are not user titles, so omit them + // and let the provider generate one. A real rename is source "manual" and + // differs from the first-turn prompt seed (the web client writes that seed + // through thread.meta.update, which also marks the title manual). + const manualTitle = thread.titleState?.source === "manual" ? thread.title.trim() : ""; + const promptSeed = options?.titleSeed?.trim(); + const sessionTitle = + manualTitle.length > 0 && manualTitle !== promptSeed ? thread.title : undefined; const startProviderSession = (input?: { readonly resumeCursor?: unknown; @@ -723,7 +735,7 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - ...(thread.title ? { title: thread.title } : {}), + ...(sessionTitle ? { title: sessionTitle } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, @@ -839,6 +851,7 @@ const make = Effect.gen(function* () { readonly modelSelection?: ModelSelection; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; + readonly titleSeed?: string; }) { const thread = yield* resolveThreadShell(input.threadId); if (!thread) { @@ -848,6 +861,7 @@ const make = Effect.gen(function* () { } yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + ...(input.titleSeed !== undefined ? { titleSeed: input.titleSeed } : {}), pendingTurnStart: true, }); if (input.modelSelection !== undefined) { @@ -1488,6 +1502,11 @@ const make = Effect.gen(function* () { : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, + // Later turns must not reuse the current title as titleSeed. Only the + // first prompt seed should suppress a not-yet-renamed session title. + ...(!hasOtherUserMessages && event.payload.titleSeed !== undefined + ? { titleSeed: event.payload.titleSeed } + : {}), }).pipe( Effect.asSome, Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))), From d1676f8ed3f74969e0e4437ec1ebe686b4ee726d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 26 Sep 2026 02:19:57 -0500 Subject: [PATCH 2/2] test(server): run the session title test through Effect The new title-forwarding test added Effect.runPromise calls past this file's lint ceiling. Co-authored-by: Cursor --- .../Layers/ProviderCommandReactor.test.ts | 76 +++++++++---------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a65329707e96..87f9bd03d46c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -894,15 +894,17 @@ describe("ProviderCommandReactor", () => { expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title"); }); - it("forwards only a user-renamed title when starting a provider session", async () => { - const harness = await createHarness({ initialTitle: "Add a progressive blur as you scroll" }); - const now = "2026-01-01T00:00:00.000Z"; - const modelSelection = { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }; - const startTurn = (threadId: string, text: string, titleSeed: string) => - Effect.runPromise( + effectIt.effect("forwards only a user-renamed title when starting a provider session", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ initialTitle: "Add a progressive blur as you scroll" }), + ); + const now = "2026-01-01T00:00:00.000Z"; + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const startTurn = (threadId: string, text: string, titleSeed: string) => harness.engine.dispatch({ type: "thread.turn.start", commandId: CommandId.make(`cmd-title-${threadId}`), @@ -917,19 +919,17 @@ describe("ProviderCommandReactor", () => { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, - }), - ); + }); - await startTurn( - "thread-1", - "Add a progressive blur as you scroll", - "Add a progressive blur as you scroll", - ); - await waitFor(() => harness.startSession.mock.calls.length === 1); - expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title"); + yield* startTurn( + "thread-1", + "Add a progressive blur as you scroll", + "Add a progressive blur as you scroll", + ); + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1)); + expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title"); - await Effect.runPromise( - harness.engine.dispatch({ + yield* harness.engine.dispatch({ type: "thread.create", commandId: CommandId.make("cmd-thread-create-renamed"), threadId: ThreadId.make("thread-renamed"), @@ -941,22 +941,18 @@ describe("ProviderCommandReactor", () => { branch: null, worktreePath: null, createdAt: now, - }), - ); - await Effect.runPromise( - harness.engine.dispatch({ + }); + yield* harness.engine.dispatch({ type: "thread.meta.update", commandId: CommandId.make("cmd-thread-rename"), threadId: ThreadId.make("thread-renamed"), title: "Keep this name", - }), - ); - await startTurn("thread-renamed", "hello there", "hello there"); - await waitFor(() => harness.startSession.mock.calls.length === 2); - expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ title: "Keep this name" }); + }); + yield* startTurn("thread-renamed", "hello there", "hello there"); + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 2)); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ title: "Keep this name" }); - await Effect.runPromise( - harness.engine.dispatch({ + yield* harness.engine.dispatch({ type: "thread.create", commandId: CommandId.make("cmd-thread-create-seeded"), threadId: ThreadId.make("thread-seeded"), @@ -968,20 +964,18 @@ describe("ProviderCommandReactor", () => { branch: null, worktreePath: null, createdAt: now, - }), - ); - await Effect.runPromise( - harness.engine.dispatch({ + }); + yield* harness.engine.dispatch({ type: "thread.meta.update", commandId: CommandId.make("cmd-thread-autotitle"), threadId: ThreadId.make("thread-seeded"), title: "hello there", - }), - ); - await startTurn("thread-seeded", "hello there", "hello there"); - await waitFor(() => harness.startSession.mock.calls.length === 3); - expect(harness.startSession.mock.calls[2]?.[1]).not.toHaveProperty("title"); - }); + }); + yield* startTurn("thread-seeded", "hello there", "hello there"); + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 3)); + expect(harness.startSession.mock.calls[2]?.[1]).not.toHaveProperty("title"); + }), + ); effectIt.effect("projects inline context before sending the provider turn", () => Effect.gen(function* () {