Skip to content

Commit 2679d27

Browse files
macodev00Yash-Singh1cursoragent
authored
fix(server): let OpenCode generate session titles (#13368)
Co-authored-by: Yash Singh <saiansh2525@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dab9561 commit 2679d27

2 files changed

Lines changed: 104 additions & 1 deletion

File tree

‎apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,8 +891,92 @@ describe("ProviderCommandReactor", () => {
891891
expect(thread?.session?.threadId).toBe("thread-1");
892892
expect(thread?.session?.status).toBe("starting");
893893
expect(thread?.session?.runtimeMode).toBe("approval-required");
894+
expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title");
894895
});
895896

897+
effectIt.effect("forwards only a user-renamed title when starting a provider session", () =>
898+
Effect.gen(function* () {
899+
const harness = yield* Effect.promise(() =>
900+
createHarness({ initialTitle: "Add a progressive blur as you scroll" }),
901+
);
902+
const now = "2026-01-01T00:00:00.000Z";
903+
const modelSelection = {
904+
instanceId: ProviderInstanceId.make("codex"),
905+
model: "gpt-5-codex",
906+
};
907+
const startTurn = (threadId: string, text: string, titleSeed: string) =>
908+
harness.engine.dispatch({
909+
type: "thread.turn.start",
910+
commandId: CommandId.make(`cmd-title-${threadId}`),
911+
threadId: ThreadId.make(threadId),
912+
message: {
913+
messageId: asMessageId(`message-${threadId}`),
914+
role: "user",
915+
text,
916+
attachments: [],
917+
},
918+
titleSeed,
919+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
920+
runtimeMode: "approval-required",
921+
createdAt: now,
922+
});
923+
924+
yield* startTurn(
925+
"thread-1",
926+
"Add a progressive blur as you scroll",
927+
"Add a progressive blur as you scroll",
928+
);
929+
yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1));
930+
expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title");
931+
932+
yield* harness.engine.dispatch({
933+
type: "thread.create",
934+
commandId: CommandId.make("cmd-thread-create-renamed"),
935+
threadId: ThreadId.make("thread-renamed"),
936+
projectId: asProjectId("project-1"),
937+
title: "New thread",
938+
modelSelection,
939+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
940+
runtimeMode: "approval-required",
941+
branch: null,
942+
worktreePath: null,
943+
createdAt: now,
944+
});
945+
yield* harness.engine.dispatch({
946+
type: "thread.meta.update",
947+
commandId: CommandId.make("cmd-thread-rename"),
948+
threadId: ThreadId.make("thread-renamed"),
949+
title: "Keep this name",
950+
});
951+
yield* startTurn("thread-renamed", "hello there", "hello there");
952+
yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 2));
953+
expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ title: "Keep this name" });
954+
955+
yield* harness.engine.dispatch({
956+
type: "thread.create",
957+
commandId: CommandId.make("cmd-thread-create-seeded"),
958+
threadId: ThreadId.make("thread-seeded"),
959+
projectId: asProjectId("project-1"),
960+
title: "New thread",
961+
modelSelection,
962+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
963+
runtimeMode: "approval-required",
964+
branch: null,
965+
worktreePath: null,
966+
createdAt: now,
967+
});
968+
yield* harness.engine.dispatch({
969+
type: "thread.meta.update",
970+
commandId: CommandId.make("cmd-thread-autotitle"),
971+
threadId: ThreadId.make("thread-seeded"),
972+
title: "hello there",
973+
});
974+
yield* startTurn("thread-seeded", "hello there", "hello there");
975+
yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 3));
976+
expect(harness.startSession.mock.calls[2]?.[1]).not.toHaveProperty("title");
977+
}),
978+
);
979+
896980
effectIt.effect("projects inline context before sending the provider turn", () =>
897981
Effect.gen(function* () {
898982
const harness = yield* Effect.promise(() => createHarness());

‎apps/server/src/orchestration/Layers/ProviderCommandReactor.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,9 @@ const make = Effect.gen(function* () {
575575
options?: {
576576
readonly modelSelection?: ModelSelection;
577577
readonly pendingTurnStart?: boolean;
578+
// First-turn prompt seed. A manual title that still equals this seed was
579+
// written by the client's auto-title, not a user rename.
580+
readonly titleSeed?: string;
578581
},
579582
) {
580583
const thread = yield* resolveThreadShell(threadId);
@@ -712,6 +715,15 @@ const make = Effect.gen(function* () {
712715
.refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd })
713716
.pipe(Effect.forkDetach)
714717
: Effect.void;
718+
// OpenCode skips SessionPrompt.ensureTitle when session.create already has
719+
// a title. Prompt seeds and "New thread" are not user titles, so omit them
720+
// and let the provider generate one. A real rename is source "manual" and
721+
// differs from the first-turn prompt seed (the web client writes that seed
722+
// through thread.meta.update, which also marks the title manual).
723+
const manualTitle = thread.titleState?.source === "manual" ? thread.title.trim() : "";
724+
const promptSeed = options?.titleSeed?.trim();
725+
const sessionTitle =
726+
manualTitle.length > 0 && manualTitle !== promptSeed ? thread.title : undefined;
715727

716728
const startProviderSession = (input?: {
717729
readonly resumeCursor?: unknown;
@@ -723,7 +735,7 @@ const make = Effect.gen(function* () {
723735
...(preferredProvider ? { provider: preferredProvider } : {}),
724736
providerInstanceId: desiredInstanceId,
725737
...(effectiveCwd ? { cwd: effectiveCwd } : {}),
726-
...(thread.title ? { title: thread.title } : {}),
738+
...(sessionTitle ? { title: sessionTitle } : {}),
727739
modelSelection: desiredModelSelection,
728740
...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
729741
runtimeMode: desiredRuntimeMode,
@@ -839,6 +851,7 @@ const make = Effect.gen(function* () {
839851
readonly modelSelection?: ModelSelection;
840852
readonly interactionMode?: "default" | "plan";
841853
readonly createdAt: string;
854+
readonly titleSeed?: string;
842855
}) {
843856
const thread = yield* resolveThreadShell(input.threadId);
844857
if (!thread) {
@@ -848,6 +861,7 @@ const make = Effect.gen(function* () {
848861
}
849862
yield* ensureSessionForThread(input.threadId, input.createdAt, {
850863
...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
864+
...(input.titleSeed !== undefined ? { titleSeed: input.titleSeed } : {}),
851865
pendingTurnStart: true,
852866
});
853867
if (input.modelSelection !== undefined) {
@@ -1488,6 +1502,11 @@ const make = Effect.gen(function* () {
14881502
: {}),
14891503
interactionMode: event.payload.interactionMode,
14901504
createdAt: event.payload.createdAt,
1505+
// Later turns must not reuse the current title as titleSeed. Only the
1506+
// first prompt seed should suppress a not-yet-renamed session title.
1507+
...(!hasOtherUserMessages && event.payload.titleSeed !== undefined
1508+
? { titleSeed: event.payload.titleSeed }
1509+
: {}),
14911510
}).pipe(
14921511
Effect.asSome,
14931512
Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))),

0 commit comments

Comments
 (0)