| author | |
| committer | |
| log | 2679d279ce4b4879290bc756cf219b10f77e1873 |
| tree | 6babde3c5e0da51b303179c6b0444b84d45ca0df |
| parent | dab9561cae8817bea24442c1dc7e28996de9c52e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 895 | 896 | ||
| 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; | ||
| 715 | 727 | ||
| 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()))), |