From ec495eccef852c60e3f8164bc2c4728a4248a8b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 05:57:52 +0000 Subject: [PATCH] feat(acp-registry): Registry agents write generated text ACP Registry instances rejected every text generation request, so a thread on a Registry agent could not title itself with its own model. Each request now runs in a hidden ACP session that cancels every permission request and is deleted afterwards. For thread titles, the thread's native session is passed along; when the agent negotiated session/fork, the hidden session forks it so the agent reuses the context it already has. The thread's own session is never deleted. Registry instances also join the text generation fallback order. Co-authored-by: Raisal P Wardana Co-Authored-By: Claude Opus 5.5 --- .../ThreadTitleRegenerationService.test.ts | 3 +- .../ThreadTitleRegenerationService.ts | 12 +- .../src/provider/AcpRegistryProbe.test.ts | 77 +++++++++++++ apps/server/src/serverSettings.ts | 10 -- docs/user/providers-acp.md | 7 +- .../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 | 1 + 13 files changed, 216 insertions(+), 40 deletions(-) create mode 100644 packages/provider-acp-registry/src/server/textGeneration.ts 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. */