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", () => {
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+20-1
......@@ -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()))),