From f220b5c47b7fcc46468d864eb5a49f031c8d3794 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 24 Mar 2026 15:54:45 -0300 Subject: [PATCH 01/14] refactor(web): rename text generation setting copy --- apps/web/src/routes/_chat.settings.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 3e92891a54..26185af6cb 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -376,8 +376,12 @@ function SettingsRouteView() { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), - ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(isGitWritingModelDirty ? ["Text generation model"] : []), ...(areProviderSettingsDirty ? ["Providers"] : []), + ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 + ? ["Custom models"] + : []), + ...(isInstallSettingsDirty ? ["Provider installs"] : []), ]; const openKeybindingsFile = useCallback(() => { @@ -813,7 +817,7 @@ function SettingsRouteView() { /> Date: Tue, 24 Mar 2026 15:54:49 -0300 Subject: [PATCH 02/14] feat(orchestration): plumb text generation model through turn starts --- apps/server/src/orchestration/decider.ts | 7 +++++ apps/web/src/components/ChatView.tsx | 3 ++ packages/contracts/src/orchestration.test.ts | 31 ++++++++++++++++++++ packages/contracts/src/orchestration.ts | 8 +++++ 4 files changed, 49 insertions(+) diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c4cfd2314b..4eb01fe84a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -329,6 +329,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), + ...(command.textGenerationModel !== undefined + ? { textGenerationModel: command.textGenerationModel } + : {}), + ...(command.providerOptions !== undefined + ? { providerOptions: command.providerOptions } + : {}), + assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 849f59e088..589df6d48c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2652,6 +2652,9 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, modelSelection: selectedModelSelection, + textGenerationModel: settings.textGenerationModelSelection.model, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 19cef5a392..93caf053f0 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -255,6 +255,25 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => }), ); +it.effect("accepts a text generation model in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-text-model", + threadId: "thread-1", + message: { + messageId: "msg-text-model", + role: "user", + text: "hello", + attachments: [], + }, + textGenerationModel: "gpt-5.4-mini", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.textGenerationModel, "gpt-5.4-mini"); + }), +); + it.effect("accepts a source proposed plan reference in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ @@ -314,6 +333,18 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe }), ); +it.effect("decodes thread.turn-start-requested text generation model when present", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartRequestedPayload({ + threadId: "thread-2", + messageId: "msg-2", + textGenerationModel: "gpt-5.4-mini", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.textGenerationModel, "gpt-5.4-mini"); + }), +); + it.effect("decodes latest turn source proposed plan metadata when present", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationLatestTurn({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 0b40bb6fdf..be59896760 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -385,6 +385,9 @@ export const ThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(ChatAttachment), }), modelSelection: Schema.optional(ModelSelection), + textGenerationModel: Schema.optional(TrimmedNonEmptyString), + providerOptions: Schema.optional(ProviderStartOptions), + assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -404,6 +407,9 @@ const ClientThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(UploadChatAttachment), }), modelSelection: Schema.optional(ModelSelection), + textGenerationModel: Schema.optional(TrimmedNonEmptyString), + providerOptions: Schema.optional(ProviderStartOptions), + assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, sourceProposedPlan: Schema.optional(SourceProposedPlanReference), @@ -681,6 +687,8 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, modelSelection: Schema.optional(ModelSelection), + textGenerationModel: Schema.optional(TrimmedNonEmptyString), + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( From e0d971e5a0fbb54640c39f524f6c0fb56d5d9408 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 24 Mar 2026 15:55:04 -0300 Subject: [PATCH 03/14] feat(server): generate first-turn thread titles --- .../OrchestrationEngineHarness.integration.ts | 3 +- .../git/Layers/CodexTextGeneration.test.ts | 21 ++++ .../src/git/Layers/CodexTextGeneration.ts | 82 ++++++++++++- apps/server/src/git/Layers/GitManager.test.ts | 19 +++ .../server/src/git/Services/TextGeneration.ts | 20 ++++ .../Layers/ProviderCommandReactor.test.ts | 111 +++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 100 ++++++++++++---- 7 files changed, 330 insertions(+), 26 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 115d18d02b..1a8f802d73 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -306,7 +306,8 @@ export const makeOrchestrationIntegrationHarness = ( Effect.succeed({ branch: input.newBranch }), } as unknown as GitCoreShape); const textGenerationLayer = Layer.succeed(TextGeneration, { - generateBranchName: () => Effect.succeed({ branch: null }), + generateBranchName: () => Effect.succeed({ branch: "update" }), + generateThreadTitle: () => Effect.succeed({ title: "New thread" }), } as unknown as TextGenerationShape); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 1b07d87d90..7c8a57e82f 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -358,6 +358,27 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("generates thread titles and trims them for sidebar use", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: + ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate websocket reconnect regressions after a worktree restore.", + }); + + expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); + }), + ), + ); + it.effect("omits attachment metadata section when no attachments are provided", () => withFakeCodexEnv( { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 8f332bf13e..007b6b5319 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -11,6 +11,10 @@ import { ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../Errors.ts"; import { type BranchNameGenerationInput, + type BranchNameGenerationResult, + type CommitMessageGenerationResult, + type PrContentGenerationResult, + type ThreadTitleGenerationResult, type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; @@ -31,6 +35,22 @@ import { ServerSettingsService } from "../../serverSettings.ts"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; +function sanitizeThreadTitle(raw: string): string { + const normalized = raw + .trim() + .split(/\r?\n/g)[0] + ?.trim() + .replace(/^['"`]+|['"`]+$/g, "") + .trim() + .replace(/\s+/g, " "); + if (!normalized || normalized.trim().length === 0) { + return "New thread"; + } + if (normalized.length <= 50) { + return normalized; + } + return `${normalized.slice(0, 47).trimEnd()}...`; +} const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -83,7 +103,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); const materializeImageAttachments = ( - _operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", + _operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", attachments: BranchNameGenerationInput["attachments"], ): Effect.Effect => Effect.gen(function* () { @@ -124,7 +148,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { cleanupPaths = [], modelSelection, }: { - operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; cwd: string; prompt: string; outputSchemaJson: S; @@ -363,10 +391,60 @@ const makeCodexTextGeneration = Effect.gen(function* () { }; }); + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = (input) => { + return Effect.gen(function* () { + const { imagePaths } = yield* materializeImageAttachments( + "generateThreadTitle", + input.attachments, + ); + const attachmentLines = (input.attachments ?? []).map( + (attachment) => + `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + + const promptSections = [ + "You write concise thread titles for coding conversations.", + "Return a JSON object with key: title.", + "Rules:", + "- Title should summarize the user's request, not restate it verbatim.", + "- Keep it short and specific (3-8 words).", + "- Avoid quotes, filler, prefixes, and trailing punctuation.", + "- If images are attached, use them as primary context for visual/UI issues.", + "", + "User message:", + limitSection(input.message, 8_000), + ]; + if (attachmentLines.length > 0) { + promptSections.push( + "", + "Attachment metadata:", + limitSection(attachmentLines.join("\n"), 4_000), + ); + } + const prompt = promptSections.join("\n"); + + const generated = yield* runCodexJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: Schema.Struct({ + title: Schema.String, + }), + imagePaths, + ...(input.model ? { model: input.model } : {}), + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + }; + return { generateCommitMessage, generatePrContent, generateBranchName, + generateThreadTitle, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 57d6853c4a..3a22dae673 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -65,6 +65,10 @@ interface FakeGitTextGeneration { cwd: string; message: string; }) => Effect.Effect<{ branch: string }, TextGenerationError>; + generateThreadTitle: (input: { + cwd: string; + message: string; + }) => Effect.Effect<{ title: string }, TextGenerationError>; } type FakePullRequest = NonNullable; @@ -168,6 +172,10 @@ function createTextGeneration(overrides: Partial = {}): T Effect.succeed({ branch: "update-workflow", }), + generateThreadTitle: () => + Effect.succeed({ + title: "Update workflow", + }), ...overrides, }; @@ -205,6 +213,17 @@ function createTextGeneration(overrides: Partial = {}): T }), ), ), + generateThreadTitle: (input) => + implementation.generateThreadTitle(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), }; } diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index e9f2230f43..e8e2955aca 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -61,12 +61,25 @@ export interface BranchNameGenerationResult { branch: string; } +export interface ThreadTitleGenerationInput { + cwd: string; + message: string; + attachments?: ReadonlyArray | undefined; + /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ + model?: string; +} + +export interface ThreadTitleGenerationResult { + title: string; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, ): Promise; generatePrContent(input: PrContentGenerationInput): Promise; generateBranchName(input: BranchNameGenerationInput): Promise; + generateThreadTitle(input: ThreadTitleGenerationInput): Promise; } /** @@ -93,6 +106,13 @@ export interface TextGenerationShape { readonly generateBranchName: ( input: BranchNameGenerationInput, ) => Effect.Effect; + + /** + * Generate a concise thread title from a user's first message. + */ + readonly generateThreadTitle: ( + input: ThreadTitleGenerationInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 834ab9be9e..f32921659a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -176,7 +176,7 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); - const generateBranchName = vi.fn(() => + const generateBranchName = vi.fn((_) => Effect.fail( new TextGenerationError({ operation: "generateBranchName", @@ -184,6 +184,14 @@ describe("ProviderCommandReactor", () => { }), ), ); + const generateThreadTitle = vi.fn((_) => + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "disabled in test harness", + }), + ), + ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -213,7 +221,10 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), Layer.provideMerge( - Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), + Layer.succeed(TextGeneration, { + generateBranchName, + generateThreadTitle, + } as unknown as TextGenerationShape), ), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), @@ -264,6 +275,7 @@ describe("ProviderCommandReactor", () => { stopSession, renameBranch, generateBranchName, + generateThreadTitle, stateDir, drain, }; @@ -308,6 +320,101 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("generates a thread title on the first turn using the text generation model", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + harness.generateThreadTitle.mockImplementation((input: unknown) => + Effect.succeed({ + title: + typeof input === "object" && + input !== null && + "model" in input && + typeof input.model === "string" + ? `Title via ${input.model}` + : "Generated title", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-title"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-title"), + role: "user", + text: "Please investigate reconnect failures after restarting the session.", + attachments: [], + }, + textGenerationModel: "gpt-5.4-mini", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({ + model: "gpt-5.4-mini", + message: "Please investigate reconnect failures after restarting the session.", + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.title).toBe("Title via gpt-5.4-mini"); + }); + + it("reuses the text generation model for automatic worktree branch naming", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-branch"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "t3code/1234abcd", + worktreePath: "/tmp/provider-project-worktree", + }), + ); + + harness.generateBranchName.mockImplementation((input: unknown) => + Effect.succeed({ + branch: + typeof input === "object" && + input !== null && + "model" in input && + typeof input.model === "string" + ? `feature/${input.model}` + : "feature/generated", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-branch-model"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-branch-model"), + role: "user", + text: "Add a safer reconnect backoff.", + attachments: [], + }, + textGenerationModel: "gpt-5.4-mini", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ + model: "gpt-5.4-mini", + message: "Add a safer reconnect backoff.", + }); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 7c522e5799..32339a765d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -400,9 +400,9 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly branch: string | null; readonly worktreePath: string | null; - readonly messageId: string; readonly messageText: string; readonly attachments?: ReadonlyArray; + readonly textGenerationModel?: string; }) { if (!input.branch || !input.worktreePath) { return; @@ -411,28 +411,21 @@ const make = Effect.gen(function* () { return; } - const thread = yield* resolveThread(input.threadId); - if (!thread) { - return; - } - - const userMessages = thread.messages.filter((message) => message.role === "user"); - if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) { - return; - } - const oldBranch = input.branch; const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = + const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; const generated = yield* textGeneration.generateBranchName({ cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - modelSelection, + modelSelection: { + ...textGenerationModelSelection, + model: input.textGenerationModel ?? textGenerationModelSelection.model, + }, }); if (!generated) return; @@ -459,6 +452,50 @@ const make = Effect.gen(function* () { ); }); + const maybeGenerateThreadTitleForFirstTurn = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly messageText: string; + readonly attachments?: ReadonlyArray; + readonly textGenerationModel?: string; + }) { + const attachments = input.attachments ?? []; + const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; + yield* textGeneration + .generateThreadTitle({ + cwd: input.cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + model: input.textGenerationModel ?? textGenerationModelSelection.model, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("provider command reactor failed to generate thread title", { + threadId: input.threadId, + cwd: input.cwd, + reason: error.message, + }), + ), + Effect.flatMap((generated) => { + if (!generated) return Effect.void; + + return orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("thread-title-rename"), + threadId: input.threadId, + title: generated.title, + }); + }), + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to rename thread title", { + threadId: input.threadId, + cwd: input.cwd, + cause: Cause.pretty(cause), + }), + ), + ); + }); + const processTurnStartRequested = Effect.fnUntraced(function* ( event: Extract, ) { @@ -485,14 +522,35 @@ const make = Effect.gen(function* () { return; } - yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ - threadId: event.payload.threadId, - branch: thread.branch, - worktreePath: thread.worktreePath, - messageId: message.id, - messageText: message.text, - ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), - }).pipe(Effect.forkScoped); + const isFirstUserMessageTurn = + thread.messages.filter((entry) => entry.role === "user").length === 1; + if (isFirstUserMessageTurn) { + const generationCwd = + resolveThreadWorkspaceCwd({ + thread, + projects: (yield* orchestrationEngine.getReadModel()).projects, + }) ?? process.cwd(); + const generationInput = { + messageText: message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(event.payload.textGenerationModel !== undefined + ? { textGenerationModel: event.payload.textGenerationModel } + : {}), + }; + + yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + threadId: event.payload.threadId, + branch: thread.branch, + worktreePath: thread.worktreePath, + ...generationInput, + }).pipe(Effect.forkScoped); + + yield* maybeGenerateThreadTitleForFirstTurn({ + threadId: event.payload.threadId, + cwd: generationCwd, + ...generationInput, + }).pipe(Effect.forkScoped); + } yield* sendTurnForThread({ threadId: event.payload.threadId, From 075a272cd9f239349ce886070b6b6c972b4eeeab Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 24 Mar 2026 17:03:47 -0300 Subject: [PATCH 04/14] test(server): fix thread title expectation --- apps/server/src/git/Layers/CodexTextGeneration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 7c8a57e82f..0c055c3018 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -374,7 +374,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { message: "Please investigate websocket reconnect regressions after a worktree restore.", }); - expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); + expect(generated.title).toBe("Investigate websocket reconnect regressions after..."); }), ), ); From 0505b5340a81fe2d78f91665e6034c73ff082afc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 24 Mar 2026 17:18:56 -0300 Subject: [PATCH 05/14] fix(threads): address review follow-ups --- .../src/git/Layers/CodexTextGeneration.test.ts | 2 +- .../Layers/ProviderCommandReactor.test.ts | 7 +++++++ apps/web/src/components/ChatView.tsx | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 0c055c3018..7c8a57e82f 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -374,7 +374,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { message: "Please investigate websocket reconnect regressions after a worktree restore.", }); - expect(generated.title).toBe("Investigate websocket reconnect regressions after..."); + expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); }), ), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f32921659a..eb9a8af14f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -359,6 +359,13 @@ describe("ProviderCommandReactor", () => { message: "Please investigate reconnect failures after restarting the session.", }); + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.title === + "Title via gpt-5.4-mini" + ); + }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); expect(thread?.title).toBe("Title via gpt-5.4-mini"); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 589df6d48c..2adcd7c50f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2936,6 +2936,9 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + textGenerationModel: settings.textGenerationModelSelection.model, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan @@ -2983,8 +2986,11 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelSelection, selectedProvider, selectedProviderModels, + providerOptionsForDispatch, setComposerDraftInteractionMode, setThreadError, + settings.enableAssistantStreaming, + settings.textGenerationModelSelection.model, selectedModel, ], ); @@ -3011,7 +3017,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, - models: selectedProviderModels, effort: selectedPromptEffort, text: implementationPrompt, }); @@ -3051,6 +3056,9 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + textGenerationModel: settings.textGenerationModelSelection.model, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, @@ -3102,7 +3110,9 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedPromptEffort, selectedModelSelection, selectedProvider, - selectedProviderModels, + settings.enableAssistantStreaming, + providerOptionsForDispatch, + settings.textGenerationModelSelection.model, syncServerReadModel, selectedModel, ]); From 14fe096f1132a415f2f8aa0866c15f74c37a1119 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 24 Mar 2026 17:38:56 -0300 Subject: [PATCH 06/14] fix(server): handle blank normalized thread titles --- .../git/Layers/CodexTextGeneration.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 7c8a57e82f..cbad60d157 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -379,6 +379,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("falls back when thread title normalization becomes whitespace-only", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: ' """ """ ', + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + }); + + expect(generated.title).toBe("New thread"); + }), + ), + ); + it.effect("omits attachment metadata section when no attachments are provided", () => withFakeCodexEnv( { From 2688b30bc306e7f66d41b7fb4c9e35d3a172d6d9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 25 Mar 2026 10:16:52 -0300 Subject: [PATCH 07/14] fix(server): trim thread titles after quote removal --- .../git/Layers/CodexTextGeneration.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index cbad60d157..8ed41a96eb 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -399,6 +399,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("trims whitespace exposed after quote removal in thread titles", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: ` "' hello world '" `, + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + }); + + expect(generated.title).toBe("hello world"); + }), + ), + ); + it.effect("omits attachment metadata section when no attachments are provided", () => withFakeCodexEnv( { From 6cc189a307657106a8520e0d822a655538beb465 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 25 Mar 2026 10:16:54 -0300 Subject: [PATCH 08/14] refactor(web): centralize text generation model selection --- apps/web/src/components/ChatView.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2adcd7c50f..34178196f0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -643,6 +643,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); + const selectedTextGenerationModel = settings.textGenerationModelSelection.model; + const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; @@ -2652,7 +2654,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, modelSelection: selectedModelSelection, - textGenerationModel: settings.textGenerationModelSelection.model, + textGenerationModel: selectedTextGenerationModel, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -2936,7 +2938,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - textGenerationModel: settings.textGenerationModelSelection.model, + textGenerationModel: selectedTextGenerationModel, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -2990,8 +2992,8 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftInteractionMode, setThreadError, settings.enableAssistantStreaming, - settings.textGenerationModelSelection.model, selectedModel, + selectedTextGenerationModel, ], ); @@ -3056,7 +3058,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - textGenerationModel: settings.textGenerationModelSelection.model, + textGenerationModel: selectedTextGenerationModel, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -3112,7 +3114,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, settings.enableAssistantStreaming, providerOptionsForDispatch, - settings.textGenerationModelSelection.model, + selectedTextGenerationModel, syncServerReadModel, selectedModel, ]); From c40ebb68776ac56d0992fba2dd6be44afbb14de7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 00:04:15 -0300 Subject: [PATCH 09/14] chore(rebase): resolve main conflicts --- apps/server/src/git/Layers/ClaudeTextGeneration.ts | 10 ++++++++++ apps/server/src/git/Layers/CodexTextGeneration.ts | 14 +++++++++----- .../server/src/git/Layers/RoutingTextGeneration.ts | 1 + 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 6ffedbf7b4..7e69814818 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -298,10 +298,20 @@ const makeClaudeTextGeneration = Effect.gen(function* () { }; }); + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "ClaudeTextGeneration.generateThreadTitle", + )(function* () { + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Thread title generation is only supported through Codex.", + }); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, + generateThreadTitle, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 007b6b5319..6326232ff8 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -3,7 +3,10 @@ import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { CodexModelSelection } from "@t3tools/contracts"; +import { + CodexModelSelection, + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, +} from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -11,9 +14,6 @@ import { ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../Errors.ts"; import { type BranchNameGenerationInput, - type BranchNameGenerationResult, - type CommitMessageGenerationResult, - type PrContentGenerationResult, type ThreadTitleGenerationResult, type TextGenerationShape, TextGeneration, @@ -24,6 +24,7 @@ import { buildPrContentPrompt, } from "../Prompts.ts"; import { + limitSection, normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, @@ -431,7 +432,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { title: Schema.String, }), imagePaths, - ...(input.model ? { model: input.model } : {}), + modelSelection: { + provider: "codex", + model: input.model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, + }, }); return { diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 7915131385..c3602458a0 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -47,6 +47,7 @@ const makeRoutingTextGeneration = Effect.gen(function* () { route(input.modelSelection.provider).generateCommitMessage(input), generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input), generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input), + generateThreadTitle: (input) => codex.generateThreadTitle(input), } satisfies TextGenerationShape; }); From 56a1225e4ebf7c8f42cbb3f1f812fbe4ad6a45c5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 00:34:42 -0300 Subject: [PATCH 10/14] test(server): fix branch naming model assertion --- .../Layers/ProviderCommandReactor.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index eb9a8af14f..58aa0c39ea 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -390,9 +390,12 @@ describe("ProviderCommandReactor", () => { branch: typeof input === "object" && input !== null && - "model" in input && - typeof input.model === "string" - ? `feature/${input.model}` + "modelSelection" in input && + typeof input.modelSelection === "object" && + input.modelSelection !== null && + "model" in input.modelSelection && + typeof input.modelSelection.model === "string" + ? `feature/${input.modelSelection.model}` : "feature/generated", }), ); @@ -417,7 +420,9 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.generateBranchName.mock.calls.length === 1); expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ - model: "gpt-5.4-mini", + modelSelection: { + model: "gpt-5.4-mini", + }, message: "Add a safer reconnect backoff.", }); }); From 4ad369f5e74fc8f66fc02d395598b513793171c5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 19:49:48 -0300 Subject: [PATCH 11/14] chore(rebase): refresh main conflict resolution --- .../Layers/ProviderCommandReactor.ts | 3 +-- apps/server/src/orchestration/decider.ts | 1 + apps/web/src/components/ChatView.tsx | 24 ++++++++++++++++++- apps/web/src/routes/_chat.settings.tsx | 4 ---- packages/contracts/src/orchestration.ts | 16 +++++++++++++ 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 32339a765d..58cc8bc124 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -415,8 +415,7 @@ const make = Effect.gen(function* () { const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection } = - yield* serverSettingsService.getSettings; + const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; const generated = yield* textGeneration.generateBranchName({ cwd, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4eb01fe84a..52988b4c07 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -14,6 +14,7 @@ import { } from "./commandInvariants.ts"; const nowIso = () => new Date().toISOString(); +const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; const defaultMetadata: Omit = { eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 34178196f0..ece22807f8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -644,7 +644,27 @@ export default function ChatView({ threadId }: ChatViewProps) { [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); const selectedTextGenerationModel = settings.textGenerationModelSelection.model; - const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); + const providerOptionsForDispatch = useMemo(() => { + const providerOptions = { + ...(settings.providers.codex.binaryPath || settings.providers.codex.homePath + ? { + codex: { + binaryPath: settings.providers.codex.binaryPath, + homePath: settings.providers.codex.homePath, + }, + } + : {}), + ...(settings.providers.claudeAgent.binaryPath + ? { + claudeAgent: { + binaryPath: settings.providers.claudeAgent.binaryPath, + }, + } + : {}), + }; + + return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; + }, [settings]); const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; @@ -3019,6 +3039,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, effort: selectedPromptEffort, text: implementationPrompt, }); @@ -3112,6 +3133,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedPromptEffort, selectedModelSelection, selectedProvider, + selectedProviderModels, settings.enableAssistantStreaming, providerOptionsForDispatch, selectedTextGenerationModel, diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 26185af6cb..d6630cea5c 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -378,10 +378,6 @@ function SettingsRouteView() { : []), ...(isGitWritingModelDirty ? ["Text generation model"] : []), ...(areProviderSettingsDirty ? ["Providers"] : []), - ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 - ? ["Custom models"] - : []), - ...(isInstallSettingsDirty ? ["Provider installs"] : []), ]; const openKeybindingsFile = useCallback(() => { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index be59896760..7140c95539 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -11,6 +11,7 @@ import { ProjectId, ProviderItemId, ThreadId, + TrimmedString, TrimmedNonEmptyString, TurnId, } from "./baseSchemas"; @@ -42,6 +43,21 @@ export const ProviderSandboxMode = Schema.Literals([ "danger-full-access", ]); export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; + +export const ProviderStartOptions = Schema.Struct({ + codex: Schema.optional( + Schema.Struct({ + binaryPath: TrimmedString, + homePath: TrimmedString, + }), + ), + claudeAgent: Schema.optional( + Schema.Struct({ + binaryPath: TrimmedString, + }), + ), +}); +export type ProviderStartOptions = typeof ProviderStartOptions.Type; export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const CodexModelSelection = Schema.Struct({ From 2c2d0dbf9898c20ced8db4efaed0088d6251500c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 20:00:04 -0300 Subject: [PATCH 12/14] fix(server): catch title generation settings failures --- .../Layers/ProviderCommandReactor.ts | 50 ++++++++----------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 58cc8bc124..a3e3702958 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -459,40 +459,32 @@ const make = Effect.gen(function* () { readonly textGenerationModel?: string; }) { const attachments = input.attachments ?? []; - const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; - yield* textGeneration - .generateThreadTitle({ + yield* Effect.gen(function* () { + const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; + + const generated = yield* textGeneration.generateThreadTitle({ cwd: input.cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), model: input.textGenerationModel ?? textGenerationModelSelection.model, - }) - .pipe( - Effect.catch((error) => - Effect.logWarning("provider command reactor failed to generate thread title", { - threadId: input.threadId, - cwd: input.cwd, - reason: error.message, - }), - ), - Effect.flatMap((generated) => { - if (!generated) return Effect.void; - - return orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: serverCommandId("thread-title-rename"), - threadId: input.threadId, - title: generated.title, - }); + }); + if (!generated) return; + + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("thread-title-rename"), + threadId: input.threadId, + title: generated.title, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to generate or rename thread title", { + threadId: input.threadId, + cwd: input.cwd, + cause: Cause.pretty(cause), }), - Effect.catchCause((cause) => - Effect.logWarning("provider command reactor failed to rename thread title", { - threadId: input.threadId, - cwd: input.cwd, - cause: Cause.pretty(cause), - }), - ), - ); + ), + ); }); const processTurnStartRequested = Effect.fnUntraced(function* ( From 2cc648e70438d426f9c4320a0bd09a4963ab012a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 20:00:09 -0300 Subject: [PATCH 13/14] fix(web): only send non-default provider options --- apps/web/src/components/ChatView.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ece22807f8..39d478346c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,6 +22,7 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -646,7 +647,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedTextGenerationModel = settings.textGenerationModelSelection.model; const providerOptionsForDispatch = useMemo(() => { const providerOptions = { - ...(settings.providers.codex.binaryPath || settings.providers.codex.homePath + ...(settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || + settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath ? { codex: { binaryPath: settings.providers.codex.binaryPath, @@ -654,7 +656,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }, } : {}), - ...(settings.providers.claudeAgent.binaryPath + ...(settings.providers.claudeAgent.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath ? { claudeAgent: { binaryPath: settings.providers.claudeAgent.binaryPath, From c4160b6eaada06c900e9a866a33e08166a833a0d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 20:01:45 -0300 Subject: [PATCH 14/14] style(web): format provider options condition --- apps/web/src/components/ChatView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 39d478346c..b8df251942 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -647,7 +647,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedTextGenerationModel = settings.textGenerationModelSelection.model; const providerOptionsForDispatch = useMemo(() => { const providerOptions = { - ...(settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || + ...(settings.providers.codex.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath ? { codex: {