diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 5548bebac84c0e949c8eef6b1635eae24a04eb87..dc85161ad351982ef64dfab5a0c275b0bc7971d1 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -94,6 +94,7 @@ const emitOverlappingXAiPromptCompleteOutOfOrder = process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; +const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const omitModelConfigOption = process.env.T3_ACP_OMIT_MODEL_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; @@ -775,7 +776,7 @@ const program = Effect.gen(function* () { process.exit(7); }); } - if (failSetConfigOption) { + if (failSetConfigOption || (failSetModeConfigOption && request.configId === "mode")) { return yield* AcpError.AcpRequestError.invalidParams( "Mock invalid params for session/set_config_option", { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 369f1c29a569cd52cc22bb5c04777966d5f4c64c..e8ae5a0c9dd072c5d504da87b189fb40d29fc2eb 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -58,6 +58,7 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; import { extractXAiAcpSubagentEndNotice, extractXAiAcpSubagentUpdate, @@ -880,6 +881,95 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.live("reapplies a selection after a switch away from it failed partway", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.unbounded(); + const instanceId = ProviderInstanceId.make("acp-test-partial-selection-reapply"); + const threadId = ThreadId.make("thread-acp-partial-selection-reapply"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + protocolEvents, + // The model switch lands, then the agent rejects the mode step. + environment: { T3_ACP_FAIL_SET_MODE_CONFIG_OPTION: "1" }, + }), + }, + selfInvocation: yield* resolveSelfInvocation(), + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const selectionA = { instanceId, model: "composer-2" } as const; + const selectionB = { + instanceId, + model: "composer-2[fast=true]", + options: [{ id: ACP_SESSION_MODE_OPTION_ID, value: "code" }], + } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-partial-selection"), + modelSelection: selectionA, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: selectionA, + runtimePolicy, + }); + const now = yield* DateTime.now; + const turnInput = (ordinal: number, modelSelection: ModelSelection) => + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal, + modelSelection, + }); + + const switchToB = yield* Effect.exit(runtime.startTurn(turnInput(1, selectionB))); + assert.isTrue(Exit.isFailure(switchToB), "the agent rejected B's mode step"); + yield* Queue.clear(protocolEvents); + + yield* runtime.startTurn(turnInput(2, selectionA)); + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runDrain, + ); + const modelSetsBeforePrompt: Array = []; + while (true) { + const event = yield* Queue.take(protocolEvents); + if (event.direction !== "outgoing") continue; + const method = rawProtocolMethod(event); + if (method === "session/prompt") break; + if ( + method === "session/set_config_option" && + rawProtocolRequestParam(event, "configId") === "model" + ) { + modelSetsBeforePrompt.push(rawProtocolRequestParam(event, "value")); + } + } + assert.deepEqual( + modelSetsBeforePrompt, + ["composer-2"], + "the native model was left on B, so the turn on A must set it back first", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("starts a new replay message after ACP v2 plan boundaries", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index c1208b49556e80f6b4a83288232d0167b75aea69..6e125b2318cfc34d8d6d63696e44f9aab649e434 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -6283,6 +6283,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( modelSelection: ModelSelection, runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, ) { + // A step failing below leaves the native config partly changed, so + // the previously applied selection no longer describes the session. + // Callers record the new selection only once this completes. + yield* Ref.set(activeSelection, null); const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; let appliedModel: string | undefined; if (flavor.applyModelSelection !== undefined) {