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/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.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 1b07d87d90..8ed41a96eb 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -358,6 +358,67 @@ 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("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("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( { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 8f332bf13e..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,6 +14,7 @@ import { ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../Errors.ts"; import { type BranchNameGenerationInput, + type ThreadTitleGenerationResult, type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; @@ -20,6 +24,7 @@ import { buildPrContentPrompt, } from "../Prompts.ts"; import { + limitSection, normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, @@ -31,6 +36,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 +104,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 +149,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 +392,63 @@ 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, + modelSelection: { + provider: "codex", + model: input.model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, + }, + }); + + 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/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; }); 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..58aa0c39ea 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,113 @@ 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.", + }); + + 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"); + }); + + 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 && + "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", + }), + ); + + 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({ + modelSelection: { + 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..a3e3702958 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,20 @@ 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 } = - yield* serverSettingsService.getSettings; + 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 +451,42 @@ 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 ?? []; + 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, + }); + 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), + }), + ), + ); + }); + const processTurnStartRequested = Effect.fnUntraced(function* ( event: Extract, ) { @@ -485,14 +513,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, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c4cfd2314b..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"], @@ -329,6 +330,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..b8df251942 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"; @@ -643,6 +644,31 @@ export default function ChatView({ threadId }: ChatViewProps) { }), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); + const selectedTextGenerationModel = settings.textGenerationModelSelection.model; + const providerOptionsForDispatch = useMemo(() => { + const providerOptions = { + ...(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, + homePath: settings.providers.codex.homePath, + }, + } + : {}), + ...(settings.providers.claudeAgent.binaryPath !== + DEFAULT_UNIFIED_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"; @@ -2652,6 +2678,9 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, modelSelection: selectedModelSelection, + textGenerationModel: selectedTextGenerationModel, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2933,6 +2962,9 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + textGenerationModel: selectedTextGenerationModel, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan @@ -2980,9 +3012,12 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelSelection, selectedProvider, selectedProviderModels, + providerOptionsForDispatch, setComposerDraftInteractionMode, setThreadError, + settings.enableAssistantStreaming, selectedModel, + selectedTextGenerationModel, ], ); @@ -3048,6 +3083,9 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + textGenerationModel: selectedTextGenerationModel, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, @@ -3100,6 +3138,9 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelSelection, selectedProvider, selectedProviderModels, + settings.enableAssistantStreaming, + providerOptionsForDispatch, + selectedTextGenerationModel, syncServerReadModel, selectedModel, ]); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 3e92891a54..d6630cea5c 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -376,7 +376,7 @@ function SettingsRouteView() { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), - ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(isGitWritingModelDirty ? ["Text generation model"] : []), ...(areProviderSettingsDirty ? ["Providers"] : []), ]; @@ -813,7 +813,7 @@ function SettingsRouteView() { /> }), ); +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..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({ @@ -385,6 +401,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 +423,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 +703,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(