From 0e15f2962b42c304463cfc9f397181931d6214e3 Mon Sep 17 00:00:00 2001 From: Raisal P Wardana Date: Thu, 12 Nov 2026 09:55:57 +0700 Subject: [PATCH] fix(server): thread titles use the thread's own provider and model Title generation always used the global text generation model, which defaults to the codex instance. When that instance was disabled or signed out, every title failed silently (only a server log). - Titles default to the thread's own provider and model; an optional Title model setting overrides it. - TextGeneration.generate treats a disabled selected instance as unusable and falls back, so commit/PR/branch/review generation benefit too. - ACP Registry agents can write generated text: a hidden session that forks the thread's session when session/fork is negotiated, otherwise a one-shot session; every session opened is deleted. - A failed title leaves a system notice on the thread with the cause. - The context-handoff header is stripped from title input. --- .../ContextHandoffDelivery.ts | 7 ++ .../src/orchestration-v2/Orchestrator.ts | 34 ++++++ .../ThreadLaunchService.test.ts | 4 +- .../ThreadTitleRegenerationService.test.ts | 50 +++++++- .../ThreadTitleRegenerationService.ts | 52 +++++++-- .../src/provider/AcpRegistryProbe.test.ts | 77 +++++++++++++ apps/server/src/serverSettings.ts | 10 -- .../CodexTextGeneration.test.ts | 4 + .../src/textGeneration/TextGeneration.ts | 8 ++ .../components/settings/SettingsPanels.tsx | 3 + .../settings/ThreadTitleModelSettings.tsx | 99 ++++++++++++++++ .../src/components/settings/settingsSearch.ts | 7 ++ docs/user/providers-acp.md | 7 +- docs/user/text-generation.md | 6 +- docs/user/thread-sidebar.md | 4 + packages/contracts/src/orchestrationV2.ts | 2 + packages/contracts/src/settings.ts | 5 + .../src/server/driver.test.ts | 2 +- .../src/server/driver.ts | 27 +---- .../provider-acp-registry/src/server/probe.ts | 2 +- .../src/server/textGeneration.ts | 109 ++++++++++++++++++ packages/provider-acp-registry/src/testing.ts | 1 + .../src/server/textGeneration.ts | 2 + .../src/server/textGenerationOperations.ts | 3 + packages/shared/src/serverSettings.ts | 4 + 25 files changed, 480 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/components/settings/ThreadTitleModelSettings.tsx create mode 100644 packages/provider-acp-registry/src/server/textGeneration.ts diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index adff6f65d8e75..98fe712811463 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/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 851715cb091bc..42cb2e727e576 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4758,6 +4758,40 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); } + if ( + command.type === "thread.title.regeneration.complete" && + command.failure !== undefined && + thread.titleRegeneration?.requestId === command.requestId + ) { + yield* emit( + events, + command, + )({ + type: "turn-item.updated", + threadId: command.threadId, + providerInstanceId: updatedThread.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`title-generation-failed:${command.requestId}`), + threadId: command.threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: yield* nextTurnItemOrdinal({ thread }), + status: "failed", + title: "Title generation failed", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "system_notice", + message: `Title generation failed: ${command.failure}`, + }, + }); + } + if (command.type === "thread.archive") { const projection = yield* loadProjectionForCommand( command, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 77bc281299b40..f944038178c58 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, @@ -786,9 +785,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 c54e6e1f4815d..e2b2d2f81f863 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -11,6 +11,7 @@ import { ProviderInstanceId, ProviderThreadId, RunId, + TextGenerationError, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -518,7 +519,13 @@ describe("ThreadTitleRegenerationService", () => { it.effect("clears the marker and keeps the title when generation fails", () => Effect.gen(function* () { const harness = makeHarness({ - generateTitle: () => Effect.die(new Error("model unavailable")), + generateTitle: () => + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "401 invalid_refresh_token", + }), + ), }); yield* Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; @@ -540,6 +547,47 @@ describe("ThreadTitleRegenerationService", () => { const projection = yield* threads.getThreadProjection(threadId); assert.equal(projection.thread.title, "Seed title"); assert.isNotOk(projection.thread.titleRegeneration); + // The user sees why, not just a server log line. + const notice = projection.turnItems.find((item) => item.type === "system_notice"); + assert.equal( + notice?.type === "system_notice" ? notice.message : undefined, + "Title generation failed: 401 invalid_refresh_token", + ); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("titles with the thread's own model and session, 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.equal(call?.sourceSessionId, "native"); + assert.include(call?.message, "USER:\nMigrate sessions to Postgres"); + assert.notInclude(call?.message, "Context handoff"); }).pipe(Effect.provide(harness.layer)); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 686d182927171..255afd85ca8bf 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -8,6 +8,7 @@ import { type OrchestrationV2ConversationMessage, type RunId, type ServerSettingsError, + TextGenerationError, type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -17,6 +18,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; @@ -25,8 +27,24 @@ 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"; +// TextGenerationError's message names the operation; the user wants the provider's reason. +const isTextGenerationError = Schema.is(TextGenerationError); +const failureDetail = (error: unknown): string => { + const detail = isTextGenerationError(error) + ? error.detail + : error instanceof Error + ? error.message + : String(error); + return detail.trim().slice(0, 500) || "Unknown error."; +}; + +type TitleOutcome = + | { readonly type: "stale" } + | { readonly type: "complete"; readonly title?: string; readonly failure?: string }; + export class ThreadTitleRegenerationService extends Context.Service< ThreadTitleRegenerationService, { @@ -57,6 +75,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly requestId: CommandId; readonly title?: string; + readonly failure?: string; }) => threads .dispatch({ @@ -65,6 +84,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, requestId: input.requestId, ...(input.title === undefined ? {} : { title: input.title }), + ...(input.failure === undefined ? {} : { failure: input.failure }), }) .pipe(Effect.asVoid); @@ -110,12 +130,10 @@ const make = Effect.gen(function* () { const execute: ThreadTitleRegenerationService["Service"]["execute"] = Effect.fn( "ThreadTitleRegenerationService.execute", )(function* (input) { - const outcome: - | { readonly type: "stale" } - | { readonly type: "complete"; readonly title?: string } = yield* Effect.gen(function* () { + const outcome: TitleOutcome = yield* Effect.gen(function* () { const projection = yield* threads.getThreadRecords( input.threadId, - ["messages"], + ["messages", "providerThreads"], input.kind.type === "initial" ? { messageIds: [input.kind.messageId] } : { messageRoles: ["user", "assistant"] }, @@ -142,12 +160,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) { @@ -162,6 +184,13 @@ const make = Effect.gen(function* () { yield* serverSettings.getSettings, projection.thread.projectId, ).settings; + const modelSelection = settings.threadTitleModelSelection ?? projection.thread.modelSelection; + // Forking the thread's own session keeps the provider's cached prefix warm. + const sourceSessionId = projection.providerThreads.find( + (providerThread) => + providerThread.id === projection.thread.activeProviderThreadId && + providerThread.providerInstanceId === modelSelection.instanceId, + )?.nativeThreadRef?.nativeId; // Storage cleanup removes worktrees but keeps the path; CLIs cannot spawn in a missing cwd. const worktreePath = projection.thread.worktreePath; const worktreeExists = @@ -174,8 +203,9 @@ const make = Effect.gen(function* () { attachments: context.attachments, ...(input.kind.type === "regenerate" ? { previousTitle: projection.thread.title } : {}), ...(inherited === undefined ? {} : { forkSource: inherited }), + ...(sourceSessionId == null ? {} : { sourceSessionId }), instructions: settings.threadTitleInstructions, - modelSelection: settings.textGenerationModelSelection, + modelSelection, projectId: projection.thread.projectId, }); const generatedTitle = result.title.trim(); @@ -195,7 +225,12 @@ const make = Effect.gen(function* () { threadId: input.threadId, requestId: input.requestId, cause, - }).pipe(Effect.as({ type: "complete" as const })), + }).pipe( + Effect.as({ + type: "complete" as const, + failure: failureDetail(Cause.squash(cause)), + }), + ), ), ); @@ -205,6 +240,7 @@ const make = Effect.gen(function* () { yield* complete({ ...input, ...(outcome.title === undefined ? {} : { title: outcome.title }), + ...(outcome.failure === undefined ? {} : { failure: outcome.failure }), }); }); diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index 77f2a1ae31d44..c491b5f92f0f3 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -11,6 +11,8 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; @@ -19,6 +21,7 @@ import { acpRegistryProbeResult, listAcpRegistrySessions, logoutAcpRegistry, + makeAcpRegistryTextGeneration, normalizeAcpRegistryAuthMethods, normalizeAcpRegistryCommands, probeAcpRegistryConfiguration, @@ -435,6 +438,80 @@ describe("ACP Registry probe", () => { }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); + it.live.each([ + { name: "forks the thread's session", sourceSessionId: "thread-session", forked: true }, + { name: "opens a one-shot session", sourceSessionId: undefined, forked: false }, + ])("titles a thread through ACP: $name, then deletes what it opened", (testCase) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "acp-title-" }); + const requestLogPath = NodePath.join(directory, "requests.ndjson"); + const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ + search: () => Effect.die("unused search"), + prepare: () => Effect.die("unused prepare"), + inspect: () => Effect.die("unused inspect"), + uninstallManagedBinary: () => Effect.die("unused uninstall"), + resolve: () => + Effect.succeed({ + distribution: "local" as const, + spawn: { + command: "node", + args: [mockAgentPath], + env: { + ...process.env, + T3_ACP_SESSION_LIFECYCLE: "1", + T3_ACP_V2_MANAGEMENT: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: '{"title":"Migrate Sessions to Postgres"}', + }, + }, + }), + }); + const textGeneration = makeAcpRegistryTextGeneration( + { + instanceId, + settings: decodeSettings({ agentId: "mock-agent" }), + environment: process.env, + }, + { + catalog, + spawner: yield* ChildProcessSpawner.ChildProcessSpawner, + random: yield* SecureRandom.SecureRandom, + }, + ); + + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Move session storage from Redis to Postgres", + modelSelection: { instanceId, model: "default" }, + ...(testCase.sourceSessionId === undefined + ? {} + : { sourceSessionId: testCase.sourceSessionId }), + }); + + expect(result.title).toBe("Migrate Sessions to Postgres"); + const requests = (yield* fs.readFileString(requestLogPath)) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)); + const forks = requests.filter((request) => request.method === "session/fork"); + expect(forks.map((request) => request.params.sessionId)).toEqual( + testCase.forked ? ["thread-session"] : [], + ); + const prompted = requests.find((request) => request.method === "session/prompt"); + expect(prompted?.params.sessionId).toBe( + testCase.forked ? "thread-session-fork" : "mock-session-1", + ); + // The thread's own session is never deleted. + expect( + requests + .filter((request) => request.method === "session/delete") + .map((request) => request.params.sessionId) + .toSorted(), + ).toEqual(testCase.forked ? ["mock-session-1", "thread-session-fork"] : ["mock-session-1"]); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + it("distinguishes authentication failures from generic probe failures", () => { const advertised = normalizeAcpRegistryAuthMethods([ { id: "grok-login", name: "Log in with Grok" }, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 251becba12f6c..f8433dbbf5e51 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -495,15 +495,6 @@ function migrateLegacyProviderSettings( }; } -const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); - -function selectionSupportsTextGeneration( - settings: ServerSettings, - selection: ModelSelection, -): boolean { - return settings.providerInstances[selection.instanceId]?.driver !== ACP_REGISTRY_DRIVER; -} - type DriverAllowed = (driver: ProviderDriverKind) => boolean; const anyDriver: DriverAllowed = () => true; @@ -513,7 +504,6 @@ function resolveTextGenerationProvider( ): ServerSettings { const selection = settings.textGenerationModelSelection; return isModelSelectionProviderEnabled(settings, selection) && - selectionSupportsTextGeneration(settings, selection) && isDriverAllowed( settings.providerInstances[selection.instanceId]?.driver ?? ProviderDriverKind.make(selection.instanceId), diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index b67d3f0ac5830..de9deefbb4f3d 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -547,6 +547,7 @@ const PREFLIGHT_PRIMARY_STATES = [ "not installed, fallback disabled for project", "signed out", "signed out, fallback failed", + "disabled", ] as const; it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (primaryState) => @@ -583,6 +584,9 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima ...(primaryState.startsWith("signed out") && instanceId === primary ? { status: "error", auth: { status: "unauthenticated" } } : {}), + ...(primaryState === "disabled" && instanceId === primary + ? { enabled: false, status: "disabled" } + : {}), ...(primaryState === "signed out, fallback failed" && instanceId === fallback ? { status: "error", auth: { status: "unknown" } } : {}), diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 3042c97191cbd..c086a4f4057fa 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -108,8 +108,10 @@ export const make = Effect.gen(function* () { const now = DateTime.toEpochMillis(yield* DateTime.now); const selected = snapshots.find((provider) => provider.instanceId === selection.instanceId); // Only a failed probe counts: a pending one is also not installed yet. + // A disabled instance never runs. const unusable = textGeneratorCannotRun || + selected?.enabled === false || (selected?.enabled === true && selected.status === "error" && (!selected.installed || selected.auth.status === "unauthenticated")); @@ -148,6 +150,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 d46c1875876f4..bad2383c341bd 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"; @@ -2034,10 +2035,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 13f8c9bfc7f72..168977f479675 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -842,6 +842,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/providers-acp.md b/docs/user/providers-acp.md index 701d5638b3fd8..8a23c0de17e05 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -131,9 +131,10 @@ Checkpoint rollback restores your files as usual. ACP agents cannot rewind their so the next turn after a rollback starts a fresh agent session that no longer remembers the conversation from before the checkpoint. -Registry instances are not used for T3's app-owned text generation, such as thread titles, commit -messages, branch names, or pull request descriptions. Configure a text-generation-capable provider -for those actions. +Registry instances can write T3's generated text, such as thread titles, commit messages, branch +names, and pull request descriptions. Each request runs in a hidden session that is deleted +afterwards. A thread on a Registry agent that supports `session/fork` is titled by forking its own +session, so the agent reuses the context it already has. ## Advanced configuration 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 09ce63e2a3e65..5cc79c547b8a8 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -121,6 +121,10 @@ 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. If title generation fails, the thread shows +a notice with the reason. 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/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index afe8802139a52..149b4b8390d0d 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3163,6 +3163,8 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, requestId: CommandId, title: Schema.optional(TrimmedNonEmptyString), + /** Why generation failed; shown on the thread as a notice. */ + failure: Schema.optional(TrimmedNonEmptyString), }), Schema.Struct({ type: Schema.Literal("thread.runtime-mode.set"), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 5622b7d32a86f..cb7ab2be9794b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1382,6 +1382,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({})), ), @@ -1824,6 +1828,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/provider-acp-registry/src/server/driver.test.ts b/packages/provider-acp-registry/src/server/driver.test.ts index 2783f336ec370..def71a4ff515a 100644 --- a/packages/provider-acp-registry/src/server/driver.test.ts +++ b/packages/provider-acp-registry/src/server/driver.test.ts @@ -253,7 +253,7 @@ describe("acpRegistrySnapshotReadiness", () => { }); expect(snapshot.auth).toEqual({ status: "unknown", canLogout: false }); - expect(snapshot.supportsTextGeneration).toBe(false); + expect(snapshot.supportsTextGeneration).toBeUndefined(); expect( snapshot.models.map(({ slug, name, isCustom, isDefault }) => ({ slug, diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index 3bb96f5d4327e..6159afab8272d 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -2,7 +2,6 @@ import { officialAcpRegistryIconUrlForAgentId, ProviderDriverKind, resolveOfficialAcpRegistryIconUrl, - TextGenerationError, type AcpRegistryOperationError, type ServerProvider, type ServerProviderModel, @@ -24,7 +23,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv } from "./adapter.ts"; -import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { makeAcpRegistryTextGeneration } from "./textGeneration.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -71,24 +70,6 @@ const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ packageName: null, }); -const makeUnsupportedTextGeneration = (): ProviderTextGeneration => { - const unsupported = (operation: string) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "ACP Registry instances do not provide application text generation.", - }), - ); - return { - generateCommitMessage: () => unsupported("generateCommitMessage"), - generatePrContent: () => unsupported("generatePrContent"), - generateBranchName: () => unsupported("generateBranchName"), - generateThreadTitle: () => unsupported("generateThreadTitle"), - generateReviewGuide: () => unsupported("generateReviewGuide"), - generateTranscriptionPostProcessing: () => unsupported("generateTranscriptionPostProcessing"), - }; -}; - function modelsFromDiscovery( discovery: | Pick @@ -211,7 +192,6 @@ function baseSnapshot( ...(input.accentColor ? { accentColor: input.accentColor } : {}), ...(iconUrl ? { iconUrl } : {}), continuation: { groupKey: input.continuationKey }, - supportsTextGeneration: false, enabled: input.settings.enabled, installed: input.installed, version: input.version, @@ -830,7 +810,10 @@ export const AcpRegistryDriver: ProviderDriver provideAcpManagementServices( diff --git a/packages/provider-acp-registry/src/server/probe.ts b/packages/provider-acp-registry/src/server/probe.ts index e0b3a5a198dec..af2c79f79fbea 100644 --- a/packages/provider-acp-registry/src/server/probe.ts +++ b/packages/provider-acp-registry/src/server/probe.ts @@ -465,7 +465,7 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo const MANAGEMENT_TIMEOUT = Duration.seconds(60); -const makeAcpRegistryManagementRuntime = Effect.fn("AcpRegistryProbe.makeManagementRuntime")( +export const makeAcpRegistryManagementRuntime = Effect.fn("AcpRegistryProbe.makeManagementRuntime")( function* (input: AcpRegistryManagementInput) { const catalog = yield* AcpRegistrySupport.AcpRegistryCatalog; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/packages/provider-acp-registry/src/server/textGeneration.ts b/packages/provider-acp-registry/src/server/textGeneration.ts new file mode 100644 index 0000000000000..d8a20f16a88bc --- /dev/null +++ b/packages/provider-acp-registry/src/server/textGeneration.ts @@ -0,0 +1,109 @@ +import { TextGenerationError } from "@t3tools/contracts"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; +import { findModelConfigOption } from "@t3tools/provider-acp/server/runtimeModel"; + +import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; +import { + makeAcpRegistryManagementRuntime, + type AcpRegistryConfigurationProbeInput, +} from "./probe.ts"; + +const TIMEOUT = "3 minutes"; +const isTextGenerationError = Schema.is(TextGenerationError); + +/** + * Runs each request in a hidden ACP session that is deleted afterwards. Given the thread's + * session and a negotiated `session/fork`, it forks that session so the agent reuses its context. + */ +export const makeAcpRegistryTextGeneration = ( + input: Omit, + services: { + readonly catalog: AcpRegistrySupport.AcpRegistryCatalog["Service"]; + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly random: SecureRandom.SecureRandom["Service"]; + }, +): ProviderTextGeneration => { + const run: TextGenerationOperations.Runner = Effect.fn("AcpRegistryTextGeneration.run")( + function* (request) { + const runtime = yield* makeAcpRegistryManagementRuntime({ ...input, cwd: request.cwd }); + const output = yield* Ref.make(""); + yield* runtime.handleSessionUpdate(({ update }) => { + if (update.sessionUpdate !== "agent_message_chunk" || update.content.type !== "text") { + return Effect.void; + } + const chunk = update.content.text; + return Ref.update(output, (text) => text + chunk); + }); + // A hidden session must never act on the checkout. + yield* runtime.handleRequestPermission(() => + Effect.succeed({ outcome: { outcome: "cancelled" } } as const), + ); + // Never delete the source: only the sessions opened here. + const forget = (sessionId: string) => + Effect.addFinalizer(() => runtime.deleteSession(sessionId).pipe(Effect.ignore)); + const started = yield* runtime.start(); + yield* forget(started.sessionId); + if ( + request.sourceSessionId !== undefined && + started.initializeResult.agentCapabilities?.sessionCapabilities?.fork != null + ) { + yield* forget((yield* runtime.forkSession(request.sourceSessionId)).sessionId); + } + const model = request.modelSelection.model; + const modelOption = findModelConfigOption(yield* runtime.getConfigOptions); + if (model.length > 0 && model !== "default" && modelOption?.currentValue !== model) { + yield* ( + modelOption === undefined + ? runtime.setSessionModel(model) + : runtime.setConfigOption(modelOption.id, model, { allowUnlistedValue: true }) + ).pipe(Effect.ignore); + } + yield* runtime.prompt({ prompt: [{ type: "text", text: request.prompt }] }); + const text = (yield* Ref.get(output)).trim(); + if (text.length === 0) { + return yield* new TextGenerationError({ + operation: request.operation, + detail: "The ACP agent returned no text.", + }); + } + return yield* TextGenerationOperations.decodeJsonReply(request, "ACP agent", text); + }, + (effect, request) => + effect.pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: TIMEOUT, + orElse: () => + Effect.fail( + new TextGenerationError({ + operation: request.operation, + detail: "The ACP agent did not answer in time.", + }), + ), + }), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: request.operation, + detail: `ACP request failed: ${cause.message}`, + cause, + }), + ), + Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, services.catalog), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, services.spawner), + Effect.provideService(SecureRandom.SecureRandom, services.random), + ), + ); + return TextGenerationOperations.fromRunner( + "AcpRegistryTextGeneration", + TextGenerationOperations.withoutInvestigation("ACP Registry", run), + ); +}; diff --git a/packages/provider-acp-registry/src/testing.ts b/packages/provider-acp-registry/src/testing.ts index 4cc790443e5cc..a9dee77caad1a 100644 --- a/packages/provider-acp-registry/src/testing.ts +++ b/packages/provider-acp-registry/src/testing.ts @@ -22,3 +22,4 @@ export { type AcpRegistryAvailableCommands, type AcpRegistryLiveConfiguration, } from "./server/probe.ts"; +export { makeAcpRegistryTextGeneration } from "./server/textGeneration.ts"; diff --git a/packages/provider-core/src/server/textGeneration.ts b/packages/provider-core/src/server/textGeneration.ts index f35081c6493e6..c44b094fbeca4 100644 --- a/packages/provider-core/src/server/textGeneration.ts +++ b/packages/provider-core/src/server/textGeneration.ts @@ -71,6 +71,8 @@ export interface ThreadTitleGenerationInput { instructions?: string | undefined; modelSelection: ModelSelection; projectId?: ProjectId | null | undefined; + /** The thread's native session on the selected instance; a runner that can fork it reuses its context. */ + sourceSessionId?: string | undefined; } export interface ThreadTitleGenerationResult { diff --git a/packages/provider-core/src/server/textGenerationOperations.ts b/packages/provider-core/src/server/textGenerationOperations.ts index 60204a2d82c18..8b5ed8ee85efc 100644 --- a/packages/provider-core/src/server/textGenerationOperations.ts +++ b/packages/provider-core/src/server/textGenerationOperations.ts @@ -32,6 +32,8 @@ export interface Request { readonly attachments?: ReadonlyArray | undefined; /** Investigate cwd with read-only tools across turns; fail if the runner cannot guarantee read-only access. */ readonly investigate?: { readonly timeoutMs: number } | undefined; + /** A native session the runner may fork instead of starting cold; runners that cannot fork ignore it. */ + readonly sourceSessionId?: string | undefined; } const INVESTIGATION_TIMEOUT_MS = 15 * 60_000; @@ -155,6 +157,7 @@ export function fromRunner(name: string, run: Runner): ProviderTextGeneration { cwd: input.cwd, modelSelection: input.modelSelection, attachments: input.attachments, + sourceSessionId: input.sourceSessionId, ...buildThreadTitlePrompt({ message: input.message, projectTitle: input.projectTitle, diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 24c418decfa29..ffe9591ff0eb8 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -68,6 +68,7 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ "pi", "opencode", "antigravity", + "acpRegistry", ].map((driver) => ProviderDriverKind.make(driver)); /** Configured instances come after every default instance, so they only win when no default qualifies. */ @@ -601,6 +602,9 @@ export function applyServerSettingsPatch( ...(patch.speechPostProcessingModelSelection !== undefined ? { speechPostProcessingModelSelection: patch.speechPostProcessingModelSelection } : {}), + ...(patch.threadTitleModelSelection !== undefined + ? { threadTitleModelSelection: patch.threadTitleModelSelection } + : {}), ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}),