diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index f540685b79..954bc98059 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -303,7 +303,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 0170d207fe..c76d922ddf 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -293,6 +293,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 373c191236..dd1033a277 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -14,6 +14,7 @@ import { type BranchNameGenerationResult, type CommitMessageGenerationResult, type PrContentGenerationResult, + type ThreadTitleGenerationResult, type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; @@ -95,6 +96,23 @@ function sanitizePrTitle(raw: string): string { return "Update project changes"; } +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; @@ -148,7 +166,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* () { @@ -189,7 +211,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { cleanupPaths = [], model, }: { - operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; cwd: string; prompt: string; outputSchemaJson: S; @@ -462,10 +488,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 ce252f739f..c0bcfee416 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -64,6 +64,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; @@ -167,6 +171,10 @@ function createTextGeneration(overrides: Partial = {}): T Effect.succeed({ branch: "update-workflow", }), + generateThreadTitle: () => + Effect.succeed({ + title: "Update workflow", + }), ...overrides, }; @@ -204,6 +212,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 b4650ed570..05debee643 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -58,12 +58,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; } /** @@ -90,6 +103,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 b58c2522cb..e1dd285a8b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -175,7 +175,7 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); - const generateBranchName = vi.fn(() => + const generateBranchName = vi.fn((_) => Effect.fail( new TextGenerationError({ operation: "generateBranchName", @@ -183,6 +183,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 = { @@ -212,7 +220,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(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), @@ -262,6 +273,7 @@ describe("ProviderCommandReactor", () => { stopSession, renameBranch, generateBranchName, + generateThreadTitle, stateDir, drain, }; @@ -306,6 +318,108 @@ 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 && + "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 9399bcc280..e7be9180e6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -408,9 +408,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; @@ -419,16 +419,6 @@ 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 ?? []; @@ -437,13 +427,13 @@ const make = Effect.gen(function* () { cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - model: DEFAULT_GIT_TEXT_GENERATION_MODEL, + model: input.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL, }) .pipe( Effect.catch((error) => Effect.logWarning( "provider command reactor failed to generate worktree branch name; skipping rename", - { threadId: input.threadId, cwd, oldBranch, reason: error.message }, + { cwd, oldBranch, reason: error.message }, ), ), Effect.flatMap((generated) => { @@ -473,6 +463,49 @@ 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* textGeneration + .generateThreadTitle({ + cwd: input.cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + model: input.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_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, ) { @@ -499,14 +532,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 761ab56a7d..52988b4c07 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -330,6 +330,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), + ...(command.textGenerationModel !== undefined + ? { textGenerationModel: command.textGenerationModel } + : {}), ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbc887bf62..3284ce3d30 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -639,6 +639,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); + const selectedTextGenerationModel = settings.textGenerationModel || undefined; const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( @@ -2646,6 +2647,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, modelSelection: selectedModelSelection, + textGenerationModel: selectedTextGenerationModel, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -2928,6 +2930,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + textGenerationModel: selectedTextGenerationModel, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -2981,6 +2984,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError, settings.enableAssistantStreaming, selectedModel, + selectedTextGenerationModel, ], ); @@ -3045,6 +3049,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + textGenerationModel: selectedTextGenerationModel, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -3100,6 +3105,7 @@ export default function ChatView({ threadId }: ChatViewProps) { providerOptionsForDispatch, selectedProvider, settings.enableAssistantStreaming, + selectedTextGenerationModel, syncServerReadModel, selectedModel, ]); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 05fd640d0f..05ff5bbd5f 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -260,7 +260,7 @@ function SettingsRouteView() { ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ["Delete confirmation"] : []), - ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), + ...(isGitTextGenerationModelDirty ? ["Text generation model"] : []), ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 ? ["Custom models"] : []), @@ -630,12 +630,12 @@ function SettingsRouteView() { updateSettings({ textGenerationModel: defaults.textGenerationModel, @@ -654,10 +654,7 @@ function SettingsRouteView() { }); }} > - + {selectedGitTextGenerationModelLabel} 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 333d5ca1eb..b147090ecc 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -402,6 +402,7 @@ 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)), @@ -423,6 +424,7 @@ 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, @@ -702,6 +704,7 @@ 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)),