authorgravatar for gosarmarcel7@gmail.commaco <gosarmarcel7@gmail.com> 2026-09-26 09:28:01+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-26 02:28:01-05:00
log2679d279ce4b4879290bc756cf219b10f77e1873
tree6babde3c5e0da51b303179c6b0444b84d45ca0df
parentdab9561cae8817bea24442c1dc7e28996de9c52e
signature Signed by PGP key B5690EEEBB952194

fix(server): let OpenCode generate session titles (#13368)

Co-authored-by: Yash Singh <saiansh2525@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>

2 files changed, 104 insertions(+), 1 deletions(-)

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+84
...@@ -891,8 +891,92 @@ describe("ProviderCommandReactor", () => {...@@ -891,8 +891,92 @@ describe("ProviderCommandReactor", () => {
891 expect(thread?.session?.threadId).toBe("thread-1");891 expect(thread?.session?.threadId).toBe("thread-1");
892 expect(thread?.session?.status).toBe("starting");892 expect(thread?.session?.status).toBe("starting");
893 expect(thread?.session?.runtimeMode).toBe("approval-required");893 expect(thread?.session?.runtimeMode).toBe("approval-required");
894 expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title");
894 });895 });
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
896 effectIt.effect("projects inline context before sending the provider turn", () =>980 effectIt.effect("projects inline context before sending the provider turn", () =>
897 Effect.gen(function* () {981 Effect.gen(function* () {
898 const harness = yield* Effect.promise(() => createHarness());982 const harness = yield* Effect.promise(() => createHarness());
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+20-1
...@@ -575,6 +575,9 @@ const make = Effect.gen(function* () {...@@ -575,6 +575,9 @@ const make = Effect.gen(function* () {
575 options?: {575 options?: {
576 readonly modelSelection?: ModelSelection;576 readonly modelSelection?: ModelSelection;
577 readonly pendingTurnStart?: boolean;577 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;
578 },581 },
579 ) {582 ) {
580 const thread = yield* resolveThreadShell(threadId);583 const thread = yield* resolveThreadShell(threadId);
...@@ -712,6 +715,15 @@ const make = Effect.gen(function* () {...@@ -712,6 +715,15 @@ const make = Effect.gen(function* () {
712 .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd })715 .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd })
713 .pipe(Effect.forkDetach)716 .pipe(Effect.forkDetach)
714 : Effect.void;717 : 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
716 const startProviderSession = (input?: {728 const startProviderSession = (input?: {
717 readonly resumeCursor?: unknown;729 readonly resumeCursor?: unknown;
...@@ -723,7 +735,7 @@ const make = Effect.gen(function* () {...@@ -723,7 +735,7 @@ const make = Effect.gen(function* () {
723 ...(preferredProvider ? { provider: preferredProvider } : {}),735 ...(preferredProvider ? { provider: preferredProvider } : {}),
724 providerInstanceId: desiredInstanceId,736 providerInstanceId: desiredInstanceId,
725 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),737 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
726 ...(thread.title ? { title: thread.title } : {}),738 ...(sessionTitle ? { title: sessionTitle } : {}),
727 modelSelection: desiredModelSelection,739 modelSelection: desiredModelSelection,
728 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),740 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
729 runtimeMode: desiredRuntimeMode,741 runtimeMode: desiredRuntimeMode,
...@@ -839,6 +851,7 @@ const make = Effect.gen(function* () {...@@ -839,6 +851,7 @@ const make = Effect.gen(function* () {
839 readonly modelSelection?: ModelSelection;851 readonly modelSelection?: ModelSelection;
840 readonly interactionMode?: "default" | "plan";852 readonly interactionMode?: "default" | "plan";
841 readonly createdAt: string;853 readonly createdAt: string;
854 readonly titleSeed?: string;
842 }) {855 }) {
843 const thread = yield* resolveThreadShell(input.threadId);856 const thread = yield* resolveThreadShell(input.threadId);
844 if (!thread) {857 if (!thread) {
...@@ -848,6 +861,7 @@ const make = Effect.gen(function* () {...@@ -848,6 +861,7 @@ const make = Effect.gen(function* () {
848 }861 }
849 yield* ensureSessionForThread(input.threadId, input.createdAt, {862 yield* ensureSessionForThread(input.threadId, input.createdAt, {
850 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),863 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
864 ...(input.titleSeed !== undefined ? { titleSeed: input.titleSeed } : {}),
851 pendingTurnStart: true,865 pendingTurnStart: true,
852 });866 });
853 if (input.modelSelection !== undefined) {867 if (input.modelSelection !== undefined) {
...@@ -1488,6 +1502,11 @@ const make = Effect.gen(function* () {...@@ -1488,6 +1502,11 @@ const make = Effect.gen(function* () {
1488 : {}),1502 : {}),
1489 interactionMode: event.payload.interactionMode,1503 interactionMode: event.payload.interactionMode,
1490 createdAt: event.payload.createdAt,1504 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 : {}),
1491 }).pipe(1510 }).pipe(
1492 Effect.asSome,1511 Effect.asSome,
1493 Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))),1512 Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))),