Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down Expand Up @@ -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));
Expand Down
12 changes: 10 additions & 2 deletions apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down Expand Up @@ -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 =
Expand All @@ -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();
Expand Down
77 changes: 77 additions & 0 deletions apps/server/src/provider/AcpRegistryProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +21,7 @@ import {
acpRegistryProbeResult,
listAcpRegistrySessions,
logoutAcpRegistry,
makeAcpRegistryTextGeneration,
normalizeAcpRegistryAuthMethods,
normalizeAcpRegistryCommands,
probeAcpRegistryConfiguration,
Expand Down Expand Up @@ -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" },
Expand Down
10 changes: 0 additions & 10 deletions apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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),
Expand Down
7 changes: 4 additions & 3 deletions docs/user/providers-acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/provider-acp-registry/src/server/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 5 additions & 22 deletions packages/provider-acp-registry/src/server/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
officialAcpRegistryIconUrlForAgentId,
ProviderDriverKind,
resolveOfficialAcpRegistryIconUrl,
TextGenerationError,
type AcpRegistryOperationError,
type ServerProvider,
type ServerProviderModel,
Expand All @@ -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";
Expand Down Expand Up @@ -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<
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -870,7 +850,10 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD
Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache)),
),
orchestrationAdapter,
textGeneration: makeUnsupportedTextGeneration(),
textGeneration: makeAcpRegistryTextGeneration(
{ instanceId, settings: effectiveConfig, environment: processEnvironment },
{ catalog, spawner, random },
),
acpSessionManagement: {
listSessions: ({ cwd, cursor }) =>
provideAcpManagementServices(
Expand Down
2 changes: 1 addition & 1 deletion packages/provider-acp-registry/src/server/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
109 changes: 109 additions & 0 deletions packages/provider-acp-registry/src/server/textGeneration.ts
Original file line number Diff line number Diff line change
@@ -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<AcpRegistryConfigurationProbeInput, "cwd">,
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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High server/textGeneration.ts:50

If an ACP agent advertises session deletion but never answers session/delete, this finalizer waits forever, so Effect.scoped never completes and the three-minute request timeout cannot return; the agent process remains retained. deleteSession has no deadline, and finalizers run uninterruptibly, so bound this cleanup independently, as the probe cleanup does.

-        Effect.addFinalizer(() => runtime.deleteSession(sessionId).pipe(Effect.ignore));
+        Effect.addFinalizer(() =>
+          runtime.deleteSession(sessionId).pipe(
+            Effect.timeoutOrElse({ duration: "5 seconds", orElse: () => Effect.void }),
+            Effect.ignore,
+          ),
+        );
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/provider-acp-registry/src/server/textGeneration.ts around line 50:

If an ACP agent advertises session deletion but never answers `session/delete`, this finalizer waits forever, so `Effect.scoped` never completes and the three-minute request timeout cannot return; the agent process remains retained. `deleteSession` has no deadline, and finalizers run uninterruptibly, so bound this cleanup independently, as the probe cleanup does.

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),
);
};
1 change: 1 addition & 0 deletions packages/provider-acp-registry/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export {
type AcpRegistryAvailableCommands,
type AcpRegistryLiveConfiguration,
} from "./server/probe.ts";
export { makeAcpRegistryTextGeneration } from "./server/textGeneration.ts";
2 changes: 2 additions & 0 deletions packages/provider-core/src/server/textGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading