From eea1571434630c67d46aad41cdd1324b84acbb93 Mon Sep 17 00:00:00 2001 From: max-metaplanet Date: Sat, 31 Oct 2026 03:51:59 -0400 Subject: [PATCH 1/2] feat/no-ticket(provider-acp-registry): enable local post-settle continuation Add a default-off enablePostSettleContinuation option to local ACP Registry provider configuration. Toggle the shared ACP adapter's existing continuation and deferred-finalization flags together, as the Grok flavor already does. Registry-source providers keep their current behavior. Cover foreground completion, late assistant output and completed continuation, plus false, omitted and registry-source configurations. Document the advanced settings.json option without adding client controls or a new workflow. Refs https://github.com/pingdotgg/t3code/issues/28026 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 225 ++++++++++++++++++ docs/user/providers-acp.md | 23 ++ .../src/server/adapter.ts | 4 + .../provider-acp-registry/src/settings.ts | 4 + 4 files changed, 256 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 7cfd1973ebed..b483a79be836 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -19,6 +19,7 @@ import * as DateTime from "effect/DateTime"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as Effect from "effect/Effect"; +import { TestClock } from "effect/testing"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -415,6 +416,230 @@ const claudeUnrelatedChildren = [ ]; describe("AcpRegistryAdapterV2", () => { + it.effect.each([ + { name: "enabled", source: "local", enablePostSettleContinuation: true, continues: true }, + { name: "disabled", source: "local", enablePostSettleContinuation: false, continues: false }, + { name: "default", source: "local", continues: false }, + { name: "registry", source: "registry", enablePostSettleContinuation: true, continues: false }, + ])("handles post-settle output with continuation $name", (testCase) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const replayDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-local-acp-late-" }); + const transcript = yield* decodeAcpReplayTranscript( + { + provider: ACP_REGISTRY_PROVIDER, + protocol: "acp.ndjson-jsonrpc", + version: "1", + scenario: "local-post-settle", + entries: [ + { + type: "expect_outbound", + frame: { kind: "request", method: "initialize", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "initialize", + result: { + protocolVersion: 1, + agentCapabilities: { loadSession: false }, + authMethods: [], + }, + }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/new", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "session/new", + result: { sessionId: "late-session" }, + }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "notification", + method: "session/update", + params: { + sessionId: "late-session", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Foreground completed." }, + }, + }, + }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "session/prompt", + result: { stopReason: "end_turn" }, + }, + }, + ], + }, + ACP_REGISTRY_PROVIDER, + ); + const replayRuntime = makeAcpReplayRuntime({ + transcript, + statusPath: path.join(replayDir, "status.json"), + scriptPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-replay-agent.ts", import.meta.url), + ), + childProcessSpawner, + fileSystem, + }); + let sessionUpdate: Parameters< + AcpSessionRuntime.AcpSessionRuntime["Service"]["handleSessionUpdate"] + >[0] = () => Effect.die("Session update handler is not installed"); + const offers: Array = []; + const instanceId = ProviderInstanceId.make("local-post-settle"); + const adapter = yield* makeAcpRegistryAdapterV2({ + instanceId, + settings: yield* decodeAcpRegistryAdapterSettings({ + source: testCase.source, + agentId: "fixture-agent", + commandPath: "local-agent", + ...("enablePostSettleContinuation" in testCase + ? { enablePostSettleContinuation: testCase.enablePostSettleContinuation } + : {}), + }), + environment: {}, + selfInvocation: yield* resolveSelfInvocation(), + continuationRequests: { + offer: (request) => + Effect.sync(() => { + offers.push(request); + }), + }, + makeRuntime: (input) => + replayRuntime(input).pipe( + Effect.map((runtime) => ({ + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + sessionUpdate = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + })), + ), + }).pipe(Effect.provide(layerInjectedRuntimeCatalog)); + const threadId = ThreadId.make("thread-local-post-settle"); + const modelSelection = { instanceId, model: "default" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: replayDir, + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("session-local-post-settle"), + modelSelection, + runtimePolicy, + }); + const projected: Array = []; + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + projected.push(event); + }).pipe(Effect.andThen(Queue.offer(events, event))), + ), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), + ); + while (true) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal") { + assert.equal(event.status, "completed"); + break; + } + } + const foreground = yield* runtime.readThreadSnapshot({ providerThread }); + assert.deepEqual( + foreground.providerTurns.map((turn) => turn.status), + ["completed"], + ); + assert.isTrue( + foreground.messages.some( + (message) => message.role === "assistant" && message.text === "Foreground completed.", + ), + ); + const lateText = "Worker tests passed after the previous turn completed."; + yield* sessionUpdate({ + sessionId: "late-session", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: lateText }, + }, + }); + assert.lengthOf(offers, testCase.continues ? 1 : 0); + if (testCase.continues) { + assert.equal(offers[0]?.threadId, threadId); + assert.isDefined(offers[0]?.dispatchIfCurrent); + yield* offers[0]!.dispatchIfCurrent!(Effect.void); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + messageText: "Background output is ready.", + }), + ); + } + yield* TestClock.adjust("3 seconds"); + const snapshot = yield* runtime.readThreadSnapshot({ providerThread }); + assert.deepEqual( + snapshot.providerTurns.map((turn) => turn.status), + testCase.continues ? ["completed", "completed"] : ["completed"], + ); + if (testCase.continues) { + assert.isFalse(yield* runtime.hasPendingBackgroundWork!); + assert.isTrue( + snapshot.messages.some( + (message) => + message.threadId === threadId && + message.role === "assistant" && + message.text === lateText, + ), + ); + } + assert.equal( + projected.some( + (event) => + event.type === "message.updated" && + event.message.role === "assistant" && + event.message.text === lateText, + ), + testCase.continues, + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it("preserves and sanitizes structured ACP errors without exposing arbitrary defects", () => { const limit = new EffectAcpErrors.AcpRequestError({ code: -31001, diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md index cf7872d8c96c..c4dc746e22ce 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -39,6 +39,29 @@ credentials and skills remain managed by the installed agent or its wrapper. Mod options come from ACP, including model IDs that contain JSON. T3 Code launches the executable directly without expanding shell expressions. +To let a long-running local command publish assistant output after a turn completes, edit the +server's `userdata/settings.json`. Set `enablePostSettleContinuation` to `true` in that provider +instance's `config`: + +```json +{ + "providerInstances": { + "my-local-agent": { + "driver": "acpRegistry", + "config": { + "source": "local", + "commandPath": "my-agent", + "enablePostSettleContinuation": true + } + } + } +} +``` + +This is off by default and applies only to local commands. Later output appears in a new run in +the same thread, which returns to idle when the output finishes. T3 Code does not send the agent +another prompt for these updates. + ## Where agents run Registry agents always run on the machine that hosts your T3 Code server. That stays true when you diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 29ae43ceffac..199e247a520f 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -208,8 +208,12 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu } : options; const copilotRouting = isCopilot ? makeCopilotSubagentRouting() : undefined; + const enablePostSettleContinuation = + options.settings.source === "local" && options.settings.enablePostSettleContinuation; const flavor: AcpAdapterV2Flavor = { driver: ACP_REGISTRY_PROVIDER, + enablePostSettleContinuation, + deferFinalizeForBackgroundWork: enablePostSettleContinuation, // Cancelling a Copilot prompt also kills the background agents it started. capabilities: isCopilot ? { diff --git a/packages/provider-acp-registry/src/settings.ts b/packages/provider-acp-registry/src/settings.ts index 7d5b4173d512..b356d17e3875 100644 --- a/packages/provider-acp-registry/src/settings.ts +++ b/packages/provider-acp-registry/src/settings.ts @@ -45,6 +45,10 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), + enablePostSettleContinuation: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), authMethodId: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ From d648176e79b430edbcc1a6f712a44cca865eba0c Mon Sep 17 00:00:00 2001 From: max-metaplanet Date: Sat, 31 Oct 2026 03:56:12 -0400 Subject: [PATCH 2/2] no-mistakes(review): Clarify local ACP continuation output compatibility --- docs/user/providers-acp.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md index c4dc746e22ce..0c40b2083de5 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -58,9 +58,10 @@ instance's `config`: } ``` -This is off by default and applies only to local commands. Later output appears in a new run in -the same thread, which returns to idle when the output finishes. T3 Code does not send the agent -another prompt for these updates. +This is off by default and applies only to local commands. The agent must stream late assistant +output as `agent_message_chunk` notifications; standalone ACP v2 `agent_message` updates do not +trigger continuation. Supported late output appears in a new run in the same thread, which returns +to idle when the output finishes. T3 Code does not send the agent another prompt for these updates. ## Where agents run