diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 277d3cea0bbd2..e9818be2015ca 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -562,7 +562,7 @@ describe("ThreadTitleRegenerationService", () => { }), ); - it.effect("titles with the thread's own model, without the handoff header", () => + 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* () { @@ -590,6 +590,7 @@ describe("ThreadTitleRegenerationService", () => { 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 a67214586f3ce..dbea9e1ecc510 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -148,7 +148,7 @@ const make = Effect.gen(function* () { const outcome: Outcome | { readonly status: "stale" } = 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"] }, @@ -202,6 +202,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 = @@ -215,7 +222,8 @@ 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, + ...(sourceSessionId == null ? {} : { sourceSessionId }), + modelSelection, projectId: projection.thread.projectId, }); const generatedTitle = result.title.trim(); diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index 30915b31d474f..a6c3e636661c2 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, @@ -467,6 +470,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 4dc30d29a6cff..0b4d217b05b44 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/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/packages/provider-acp-registry/src/server/driver.test.ts b/packages/provider-acp-registry/src/server/driver.test.ts index 1495cb0bb64dd..fc2b16fd1f67f 100644 --- a/packages/provider-acp-registry/src/server/driver.test.ts +++ b/packages/provider-acp-registry/src/server/driver.test.ts @@ -304,7 +304,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 2db192b9c7f96..5057fb4b2dbb1 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< @@ -232,7 +213,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, @@ -870,7 +850,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 2fcf80cbce4d9..f4a365b21fc3b 100644 --- a/packages/provider-acp-registry/src/server/probe.ts +++ b/packages/provider-acp-registry/src/server/probe.ts @@ -480,7 +480,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 1f98b6b9e955e..48e3ca6b5a7b0 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -70,6 +70,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. */