From e035e8e251d67e9b9ad4b6cffc1fa8b756cf8feb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 05:53:18 +0000 Subject: [PATCH 1/3] fix(server): thread titles use the thread's own provider and model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Titles always used the global text generation model, which defaults to the Codex instance. With that instance disabled, every title failed. - Titles default to the thread's own provider and model; an optional Title model setting (Settings → Threads, Advanced) pins one model. A managed provider allow-list clears a pinned model it does not allow. - Text generation treats a disabled selected instance as unusable and falls back to another enabled provider, so commit, PR, branch and review generation benefit too. - The context-handoff header is stripped from title input. Co-authored-by: Raisal P Wardana Co-Authored-By: Claude Opus 5.5 --- .../ContextHandoffDelivery.ts | 7 ++ .../ThreadLaunchService.test.ts | 4 +- .../ThreadTitleRegenerationService.test.ts | 34 +++++++ .../ThreadTitleRegenerationService.ts | 11 ++- apps/server/src/policy/managedProviders.ts | 3 + .../CodexTextGeneration.test.ts | 4 + .../src/textGeneration/TextGeneration.ts | 7 ++ .../components/settings/SettingsPanels.tsx | 3 + .../settings/ThreadTitleModelSettings.tsx | 99 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 7 ++ docs/user/text-generation.md | 6 +- docs/user/thread-sidebar.md | 3 + packages/contracts/src/settings.ts | 5 + packages/shared/src/serverSettings.ts | 3 + 14 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/settings/ThreadTitleModelSettings.tsx diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index 5211da2faf169..846169abbce8d 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -16,6 +16,13 @@ import { export const withInlineContext = (context: string, userText: string): string => context === "" ? userText : `${context}\n\nUser message:\n${userText}`; +/** The user's own text from a prompt `withInlineContext` built; anything else is returned as is. */ +export const withoutContextHandoff = (text: string): string => { + if (!text.startsWith("Context handoff (")) return text; + const marker = text.lastIndexOf("\n\nUser message:\n"); + return marker === -1 ? text : text.slice(marker + "\n\nUser message:\n".length); +}; + /** Persist around injection; ambiguous delivery requires a fresh native thread. Call unsent if refused before prompt consumption. */ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextHandoffs")( function* (input: { diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 33d5fb657fa7b..b7ab4e89faeff 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -15,7 +15,6 @@ import { ComposerContextId, type ChatAttachment, CommandId, - DEFAULT_SERVER_SETTINGS, GitCommandError, MessageId, ProjectId, @@ -797,9 +796,10 @@ it.effect("arms durable title generation after accepting the first message", () }); const generated = yield* threads.getThreadProjection(launched.threadId); assert.equal(generated.thread.title, "Generated title"); + // The thread titles itself with its own model. assert.deepEqual( harness.generateThreadTitle.mock.calls[0]?.[0]?.modelSelection, - DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, + modelSelection, ); const manualRequestId = CommandId.make("command:title-generation:manual"); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index d56256372f73e..277d3cea0bbd2 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -562,6 +562,40 @@ describe("ThreadTitleRegenerationService", () => { }), ); + it.effect("titles with the thread's own model, without the handoff header", () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; + const threadId = yield* createThread({ + command: "command:title:own:create", + thread: "thread:title:own", + }); + yield* recordRun({ + threadId, + runId: RunId.make("run:title:own:1"), + ordinal: 1, + user: [ + "Context handoff (full_thread_summary):", + "From thread parent, runs 1-3.", + "", + "User message:", + "Migrate sessions to Postgres", + ].join("\n"), + assistant: "Done.", + }); + const requestId = yield* armRegeneration({ command: "command:title:own:1", threadId }); + + yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } }); + + const call = harness.generateThreadTitle.mock.calls[0]?.[0]; + assert.deepEqual(call?.modelSelection, modelSelection); + assert.include(call?.message, "USER:\nMigrate sessions to Postgres"); + assert.notInclude(call?.message, "Context handoff"); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("records an unchanged outcome when the model keeps the current title", () => Effect.gen(function* () { const harness = makeHarness({ diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index d3216b1cbd938..a67214586f3ce 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -27,6 +27,7 @@ import * as ProjectStore from "./ProjectStore.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext"; +import { withoutContextHandoff } from "./ContextHandoffDelivery.ts"; export { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext"; export class ThreadTitleRegenerationService extends Context.Service< @@ -174,12 +175,16 @@ const make = Effect.gen(function* () { message === undefined ? { message: "", attachments: [] } : { - message: isAttachmentOnlyBootstrapPrompt(message.text) ? "" : message.text, + message: isAttachmentOnlyBootstrapPrompt(message.text) + ? "" + : withoutContextHandoff(message.text), attachments: message.attachments, }; } else { context = formatThreadTitleContext( - projection.messages.filter((message) => !message.streaming), + projection.messages + .filter((message) => !message.streaming) + .map((message) => ({ ...message, text: withoutContextHandoff(message.text) })), ); } if (context.message.length === 0 && context.attachments.length === 0) { @@ -210,7 +215,7 @@ const make = Effect.gen(function* () { ...(input.kind.type === "regenerate" ? { previousTitle: projection.thread.title } : {}), ...(inherited === undefined ? {} : { forkSource: inherited }), instructions: settings.threadTitleInstructions, - modelSelection: settings.textGenerationModelSelection, + modelSelection: settings.threadTitleModelSelection ?? projection.thread.modelSelection, projectId: projection.thread.projectId, }); const generatedTitle = result.title.trim(); diff --git a/apps/server/src/policy/managedProviders.ts b/apps/server/src/policy/managedProviders.ts index 059f6aeb4b782..bedbc29a45528 100644 --- a/apps/server/src/policy/managedProviders.ts +++ b/apps/server/src/policy/managedProviders.ts @@ -400,6 +400,9 @@ export function makeManagedProviders( reviewGuideModelSelection: allowedSelection(settings, settings.reviewGuideModelSelection) ? settings.reviewGuideModelSelection : null, + threadTitleModelSelection: allowedSelection(settings, settings.threadTitleModelSelection) + ? settings.threadTitleModelSelection + : null, }; cache.set(settings, result); return result; diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index e6e43d606e27f..300ac48ba9385 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -623,6 +623,7 @@ const PREFLIGHT_PRIMARY_STATES = [ "signed out, fallback failed", "cannot generate text", "model cannot generate text", + "disabled", ] as const; it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (primaryState) => @@ -667,6 +668,9 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima ...(primaryState === "signed out, fallback failed" && instanceId === fallback ? { status: "error", auth: { status: "unknown" } } : {}), + ...(primaryState === "disabled" && instanceId === primary + ? { enabled: false, status: "disabled" } + : {}), ...(primaryState === "cannot generate text" && instanceId === primary ? { supportsTextGeneration: false } : {}), diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 818afb4fae553..71e11306e8a5d 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -107,6 +107,7 @@ export const make = Effect.gen(function* () { // Only a failed probe counts: a pending one is also not installed yet. const unusable = textGeneratorCannotRun || + selected?.enabled === false || (selected?.enabled === true && (!isProviderTextGenerationCapable(selected) || selected.models.find((model) => model.slug === selection.model) @@ -152,6 +153,12 @@ export const make = Effect.gen(function* () { "Review guides need a coding agent that can read the checkout. None is ready. Pick Codex or Claude as the review guide model in Settings → Source control.", }); } + if (selected?.enabled === false && selection.instanceId === selected.instanceId) { + return yield* new TextGenerationError({ + operation, + detail: `${selected.displayName ?? selection.instanceId} is disabled and no enabled provider can write this text. Pick another model in Settings → Threads → Text generation.`, + }); + } return yield* installationGate.execute( selection.instanceId, resolveInstance(registry, operation, selection.instanceId).pipe( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ea874c2b18c30..2bfaefcb6892e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -63,6 +63,7 @@ import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeConte import { scopedIdleCompaction } from "./scopedSettings"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { ThreadTitleInstructionsSettings } from "./ThreadTitleInstructionsSettings"; +import { ThreadTitleModelSettings } from "./ThreadTitleModelSettings"; import { SnoozePresetsRow } from "./SnoozePresetsSettings"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { managedTelemetryOffMessage, usePrimaryManagedPolicy } from "../../state/managedPolicy"; @@ -2085,10 +2086,12 @@ export function ThreadsSettingsPanel() { /> + {!isProjectScope ? : null} provider.supportsTextGeneration !== false), + projectId: null, + selections: [pinned ?? settings.textGenerationModelSelection], + }); + const selection = resolveAppModelSelectionState( + { ...settings, textGenerationModelSelection: pinned ?? settings.textGenerationModelSelection }, + targets.providers, + ); + const modelDisabledReason = useScopedModelDisabledReason(settings, targets.entries); + const hasProvider = targets.entries.some((entry) => entry.enabled && entry.isAvailable); + const setModel = (next: ReturnType) => { + const reason = modelDisabledReason(next.instanceId, next.model); + if (reason) { + toastManager.add({ type: "error", title: "Title model not saved", description: reason }); + return; + } + updateSettings({ threadTitleModelSelection: next }); + }; + return ( + Connect an environment first. + ) : ( +
+ {pinned !== null && hasProvider ? ( + + setModel(createModelSelection(instanceId, model)) + } + /> + ) : null} + + updateSettings({ + threadTitleModelSelection: checked + ? createModelSelection(selection.instanceId, selection.model) + : null, + }) + } + aria-label="Use one model for thread titles" + /> +
+ ) + } + /> + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7d7939e7f21f9..62c16b6288359 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -860,6 +860,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["generated thread titles source control content default provider"], }, + { + id: "thread-title-model", + title: "Title model", + to: "/settings/threads", + scope: "environment", + searchTerms: ["generated thread titles provider model override same thread session"], + }, { id: "thread-title-instructions", title: "Thread title instructions", diff --git a/docs/user/text-generation.md b/docs/user/text-generation.md index 7aa0884fa75d0..6a54a0cd04803 100644 --- a/docs/user/text-generation.md +++ b/docs/user/text-generation.md @@ -3,7 +3,11 @@ T3 Code writes some text for you: thread titles, commit messages, pull request titles and descriptions, branch names, and voice transcript cleanup. Pick the model for this in **Settings → Threads → Text generation model**. **Settings → Source control** and -**Settings → Voice** can each pick a different one. +**Settings → Voice** can each pick a different one. Thread titles use each thread's own model +unless you turn on **Title model**. + +A disabled provider never writes this text. When the chosen one is off, T3 Code uses another +enabled provider, or tells you none is available. By default, a coding agent such as Codex or Claude writes this text, starting a session for each request. A **text generator** — a model API that only writes text — is usually faster and cheaper. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e5fe950e140a2..dc5fe62eecc4f 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -122,6 +122,9 @@ To generate a fresh title from the conversation, open a thread's menu and choose **Regenerate title**. The action is unavailable while title generation is in progress or when the connected environment needs a server update. +Each thread is titled by its own provider and model. To title every thread with one model instead, +turn on **Title model** in **Settings → Threads → Text generation**, under Advanced. + To change how titles are written, add **Thread title instructions** in **Settings → Threads → Text generation**, under Advanced, such as a length limit or an identifier to put first. They apply to new and regenerated titles. Select a project to give it its own instructions. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7bbb03595d4bb..250f6471d4c7c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1405,6 +1405,10 @@ export const ServerSettings = Schema.Struct({ speechPostProcessingModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + // Null titles a thread with its own provider and model; a selection pins every title to it. + threadTitleModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), projectAgentBrowserAccessOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), @@ -1859,6 +1863,7 @@ export const ServerSettingsPatch = Schema.Struct({ speechModelUnloadTimeout: Schema.optionalKey(SpeechModelUnloadTimeout), speechProjectCustomWords: Schema.optionalKey(SpeechCustomWords), speechPostProcessingModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + threadTitleModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), projectAgentBrowserAccessOverrides: Schema.optionalKey( Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), ), diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 023c6eb74333e..1f98b6b9e955e 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -609,6 +609,9 @@ export function applyServerSettingsPatch( ...(patch.speechPostProcessingModelSelection !== undefined ? { speechPostProcessingModelSelection: patch.speechPostProcessingModelSelection } : {}), + ...(patch.threadTitleModelSelection !== undefined + ? { threadTitleModelSelection: patch.threadTitleModelSelection } + : {}), ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), From f88294c48ed791b6266a2f21d217581b9b9967da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 06:09:38 +0000 Subject: [PATCH 2/3] fix(kiro): Kiro snapshots report no text generation Kiro rejects every app text generation request, but its snapshots did not say so, so the text generation dispatcher picked it for a Kiro thread's title instead of falling back to an enabled provider. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/KiroProvider.test.ts | 2 ++ packages/provider-kiro/src/server/status.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/KiroProvider.test.ts b/apps/server/src/provider/KiroProvider.test.ts index 1877c131e3158..a4468b465deae 100644 --- a/apps/server/src/provider/KiroProvider.test.ts +++ b/apps/server/src/provider/KiroProvider.test.ts @@ -200,6 +200,8 @@ describe("KiroProvider", () => { // The version advisory is what the compatibility check and update UI read. assert.equal(snapshot.versionAdvisory?.currentVersion, "2.27.0"); assert.isFalse(snapshot.versionAdvisory?.canUpdate); + // Kiro rejects app text generation; the dispatcher falls back only when the snapshot says so. + assert.isFalse(snapshot.supportsTextGeneration); }), ); diff --git a/packages/provider-kiro/src/server/status.ts b/packages/provider-kiro/src/server/status.ts index 46f3361147fe3..8f20877292f87 100644 --- a/packages/provider-kiro/src/server/status.ts +++ b/packages/provider-kiro/src/server/status.ts @@ -82,8 +82,8 @@ const snapshot = ( probe: ProviderProbeResult, listed?: ReadonlyArray, agents?: ProviderOptionDescriptor, -) => - buildServerProvider({ +): ServerProviderDraft => ({ + ...buildServerProvider({ // Gives the snapshot its version advisory; Kiro runs no enrichment pass. driver: KIRO_DRIVER_KIND, presentation: KIRO_PRESENTATION, @@ -93,7 +93,10 @@ const snapshot = ( // `/compact` runs `_kiro/session/compact`; see kiroCompactSession. slashCommands: [COMPACT_SLASH_COMMAND], probe, - }); + }), + // See the unsupported text generation in driver.ts; this lets the dispatcher fall back. + supportsTextGeneration: false, +}); export const buildInitialKiroProviderSnapshot = Effect.fn( "KiroProvider.buildInitialKiroProviderSnapshot", From 828cb199abbc2e4d066ebbcf44260ff68e0c32f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 06:22:11 +0000 Subject: [PATCH 3/3] fix(server): keep titles on the text generation model Titles moved to the thread's own model, which overrode users who chose a text generator for titles while settings still said it wrote them. The disabled-instance fallback already fixes the reported failure, so titles go back to the Text generation model and the Title model override goes with them. The context-handoff strip is removed too: the stored text has no reliable boundary between inherited context and the user's request, so it could drop the request itself. Co-Authored-By: Claude Opus 5.5 --- .../ContextHandoffDelivery.ts | 7 -- .../ThreadLaunchService.test.ts | 4 +- .../ThreadTitleRegenerationService.test.ts | 34 ------- .../ThreadTitleRegenerationService.ts | 11 +-- apps/server/src/policy/managedProviders.ts | 3 - .../components/settings/SettingsPanels.tsx | 3 - .../settings/ThreadTitleModelSettings.tsx | 99 ------------------- .../src/components/settings/settingsSearch.ts | 7 -- docs/user/text-generation.md | 3 +- docs/user/thread-sidebar.md | 3 - packages/contracts/src/settings.ts | 5 - packages/shared/src/serverSettings.ts | 3 - 12 files changed, 6 insertions(+), 176 deletions(-) delete mode 100644 apps/web/src/components/settings/ThreadTitleModelSettings.tsx diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index 846169abbce8d..5211da2faf169 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -16,13 +16,6 @@ import { export const withInlineContext = (context: string, userText: string): string => context === "" ? userText : `${context}\n\nUser message:\n${userText}`; -/** The user's own text from a prompt `withInlineContext` built; anything else is returned as is. */ -export const withoutContextHandoff = (text: string): string => { - if (!text.startsWith("Context handoff (")) return text; - const marker = text.lastIndexOf("\n\nUser message:\n"); - return marker === -1 ? text : text.slice(marker + "\n\nUser message:\n".length); -}; - /** Persist around injection; ambiguous delivery requires a fresh native thread. Call unsent if refused before prompt consumption. */ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextHandoffs")( function* (input: { diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index b7ab4e89faeff..33d5fb657fa7b 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -15,6 +15,7 @@ import { ComposerContextId, type ChatAttachment, CommandId, + DEFAULT_SERVER_SETTINGS, GitCommandError, MessageId, ProjectId, @@ -796,10 +797,9 @@ it.effect("arms durable title generation after accepting the first message", () }); const generated = yield* threads.getThreadProjection(launched.threadId); assert.equal(generated.thread.title, "Generated title"); - // The thread titles itself with its own model. assert.deepEqual( harness.generateThreadTitle.mock.calls[0]?.[0]?.modelSelection, - modelSelection, + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, ); const manualRequestId = CommandId.make("command:title-generation:manual"); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 277d3cea0bbd2..d56256372f73e 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -562,40 +562,6 @@ describe("ThreadTitleRegenerationService", () => { }), ); - it.effect("titles with the thread's own model, without the handoff header", () => - Effect.gen(function* () { - const harness = makeHarness(); - yield* Effect.gen(function* () { - const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; - const threadId = yield* createThread({ - command: "command:title:own:create", - thread: "thread:title:own", - }); - yield* recordRun({ - threadId, - runId: RunId.make("run:title:own:1"), - ordinal: 1, - user: [ - "Context handoff (full_thread_summary):", - "From thread parent, runs 1-3.", - "", - "User message:", - "Migrate sessions to Postgres", - ].join("\n"), - assistant: "Done.", - }); - const requestId = yield* armRegeneration({ command: "command:title:own:1", threadId }); - - yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } }); - - const call = harness.generateThreadTitle.mock.calls[0]?.[0]; - assert.deepEqual(call?.modelSelection, modelSelection); - assert.include(call?.message, "USER:\nMigrate sessions to Postgres"); - assert.notInclude(call?.message, "Context handoff"); - }).pipe(Effect.provide(harness.layer)); - }), - ); - it.effect("records an unchanged outcome when the model keeps the current title", () => Effect.gen(function* () { const harness = makeHarness({ diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index a67214586f3ce..d3216b1cbd938 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -27,7 +27,6 @@ import * as ProjectStore from "./ProjectStore.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext"; -import { withoutContextHandoff } from "./ContextHandoffDelivery.ts"; export { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext"; export class ThreadTitleRegenerationService extends Context.Service< @@ -175,16 +174,12 @@ const make = Effect.gen(function* () { message === undefined ? { message: "", attachments: [] } : { - message: isAttachmentOnlyBootstrapPrompt(message.text) - ? "" - : withoutContextHandoff(message.text), + message: isAttachmentOnlyBootstrapPrompt(message.text) ? "" : message.text, attachments: message.attachments, }; } else { context = formatThreadTitleContext( - projection.messages - .filter((message) => !message.streaming) - .map((message) => ({ ...message, text: withoutContextHandoff(message.text) })), + projection.messages.filter((message) => !message.streaming), ); } if (context.message.length === 0 && context.attachments.length === 0) { @@ -215,7 +210,7 @@ const make = Effect.gen(function* () { ...(input.kind.type === "regenerate" ? { previousTitle: projection.thread.title } : {}), ...(inherited === undefined ? {} : { forkSource: inherited }), instructions: settings.threadTitleInstructions, - modelSelection: settings.threadTitleModelSelection ?? projection.thread.modelSelection, + modelSelection: settings.textGenerationModelSelection, projectId: projection.thread.projectId, }); const generatedTitle = result.title.trim(); diff --git a/apps/server/src/policy/managedProviders.ts b/apps/server/src/policy/managedProviders.ts index bedbc29a45528..059f6aeb4b782 100644 --- a/apps/server/src/policy/managedProviders.ts +++ b/apps/server/src/policy/managedProviders.ts @@ -400,9 +400,6 @@ export function makeManagedProviders( reviewGuideModelSelection: allowedSelection(settings, settings.reviewGuideModelSelection) ? settings.reviewGuideModelSelection : null, - threadTitleModelSelection: allowedSelection(settings, settings.threadTitleModelSelection) - ? settings.threadTitleModelSelection - : null, }; cache.set(settings, result); return result; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2bfaefcb6892e..ea874c2b18c30 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -63,7 +63,6 @@ import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeConte import { scopedIdleCompaction } from "./scopedSettings"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { ThreadTitleInstructionsSettings } from "./ThreadTitleInstructionsSettings"; -import { ThreadTitleModelSettings } from "./ThreadTitleModelSettings"; import { SnoozePresetsRow } from "./SnoozePresetsSettings"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { managedTelemetryOffMessage, usePrimaryManagedPolicy } from "../../state/managedPolicy"; @@ -2086,12 +2085,10 @@ export function ThreadsSettingsPanel() { /> - {!isProjectScope ? : null} provider.supportsTextGeneration !== false), - projectId: null, - selections: [pinned ?? settings.textGenerationModelSelection], - }); - const selection = resolveAppModelSelectionState( - { ...settings, textGenerationModelSelection: pinned ?? settings.textGenerationModelSelection }, - targets.providers, - ); - const modelDisabledReason = useScopedModelDisabledReason(settings, targets.entries); - const hasProvider = targets.entries.some((entry) => entry.enabled && entry.isAvailable); - const setModel = (next: ReturnType) => { - const reason = modelDisabledReason(next.instanceId, next.model); - if (reason) { - toastManager.add({ type: "error", title: "Title model not saved", description: reason }); - return; - } - updateSettings({ threadTitleModelSelection: next }); - }; - return ( - Connect an environment first. - ) : ( -
- {pinned !== null && hasProvider ? ( - - setModel(createModelSelection(instanceId, model)) - } - /> - ) : null} - - updateSettings({ - threadTitleModelSelection: checked - ? createModelSelection(selection.instanceId, selection.model) - : null, - }) - } - aria-label="Use one model for thread titles" - /> -
- ) - } - /> - ); -} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 62c16b6288359..7d7939e7f21f9 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -860,13 +860,6 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["generated thread titles source control content default provider"], }, - { - id: "thread-title-model", - title: "Title model", - to: "/settings/threads", - scope: "environment", - searchTerms: ["generated thread titles provider model override same thread session"], - }, { id: "thread-title-instructions", title: "Thread title instructions", diff --git a/docs/user/text-generation.md b/docs/user/text-generation.md index 6a54a0cd04803..119db17d87ae9 100644 --- a/docs/user/text-generation.md +++ b/docs/user/text-generation.md @@ -3,8 +3,7 @@ T3 Code writes some text for you: thread titles, commit messages, pull request titles and descriptions, branch names, and voice transcript cleanup. Pick the model for this in **Settings → Threads → Text generation model**. **Settings → Source control** and -**Settings → Voice** can each pick a different one. Thread titles use each thread's own model -unless you turn on **Title model**. +**Settings → Voice** can each pick a different one. A disabled provider never writes this text. When the chosen one is off, T3 Code uses another enabled provider, or tells you none is available. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index dc5fe62eecc4f..e5fe950e140a2 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -122,9 +122,6 @@ To generate a fresh title from the conversation, open a thread's menu and choose **Regenerate title**. The action is unavailable while title generation is in progress or when the connected environment needs a server update. -Each thread is titled by its own provider and model. To title every thread with one model instead, -turn on **Title model** in **Settings → Threads → Text generation**, under Advanced. - To change how titles are written, add **Thread title instructions** in **Settings → Threads → Text generation**, under Advanced, such as a length limit or an identifier to put first. They apply to new and regenerated titles. Select a project to give it its own instructions. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 250f6471d4c7c..7bbb03595d4bb 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1405,10 +1405,6 @@ export const ServerSettings = Schema.Struct({ speechPostProcessingModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), - // Null titles a thread with its own provider and model; a selection pins every title to it. - threadTitleModelSelection: Schema.NullOr(ModelSelection).pipe( - Schema.withDecodingDefault(Effect.succeed(null)), - ), projectAgentBrowserAccessOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), @@ -1863,7 +1859,6 @@ export const ServerSettingsPatch = Schema.Struct({ speechModelUnloadTimeout: Schema.optionalKey(SpeechModelUnloadTimeout), speechProjectCustomWords: Schema.optionalKey(SpeechCustomWords), speechPostProcessingModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), - threadTitleModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), projectAgentBrowserAccessOverrides: Schema.optionalKey( Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), ), diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 1f98b6b9e955e..023c6eb74333e 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -609,9 +609,6 @@ export function applyServerSettingsPatch( ...(patch.speechPostProcessingModelSelection !== undefined ? { speechPostProcessingModelSelection: patch.speechPostProcessingModelSelection } : {}), - ...(patch.threadTitleModelSelection !== undefined - ? { threadTitleModelSelection: patch.threadTitleModelSelection } - : {}), ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}),