authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 22:46:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 22:46:38-07:00
logf0a47b3172c4a73e1932b23d37c3345481c5a67e
treef72792f21cca8496f702362b22e6ca3aec0817d7
parentae5fd184288aa600f5afbaaf53eeabc5610f9036
signature Signed by PGP key B5690EEEBB952194

fix(server): ACP reapplies a model after a switch away from it failed partway (#14723)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 96 insertions(+), 1 deletions(-)

apps/server/scripts/acp-mock-agent.ts+2-1
...@@ -94,6 +94,7 @@ const emitOverlappingXAiPromptCompleteOutOfOrder =...@@ -94,6 +94,7 @@ const emitOverlappingXAiPromptCompleteOutOfOrder =
94 process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1";94 process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1";
95const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1";95const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1";
96const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";96const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
97const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1";
97const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";98const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
98const omitModelConfigOption = process.env.T3_ACP_OMIT_MODEL_CONFIG_OPTION === "1";99const omitModelConfigOption = process.env.T3_ACP_OMIT_MODEL_CONFIG_OPTION === "1";
99const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;100const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
...@@ -775,7 +776,7 @@ const program = Effect.gen(function* () {...@@ -775,7 +776,7 @@ const program = Effect.gen(function* () {
775 process.exit(7);776 process.exit(7);
776 });777 });
777 }778 }
778 if (failSetConfigOption) {779 if (failSetConfigOption || (failSetModeConfigOption && request.configId === "mode")) {
779 return yield* AcpError.AcpRequestError.invalidParams(780 return yield* AcpError.AcpRequestError.invalidParams(
780 "Mock invalid params for session/set_config_option",781 "Mock invalid params for session/set_config_option",
781 {782 {
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+90
...@@ -58,6 +58,7 @@ import type * as EffectAcpSchema from "effect-acp/compat";...@@ -58,6 +58,7 @@ import type * as EffectAcpSchema from "effect-acp/compat";
58import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";58import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
59import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";59import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
60import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";60import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
61import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig";
61import {62import {
62 extractXAiAcpSubagentEndNotice,63 extractXAiAcpSubagentEndNotice,
63 extractXAiAcpSubagentUpdate,64 extractXAiAcpSubagentUpdate,
...@@ -880,6 +881,95 @@ describe("AcpAdapterV2", () => {...@@ -880,6 +881,95 @@ describe("AcpAdapterV2", () => {
880 }).pipe(Effect.provide(layerTest), Effect.scoped),881 }).pipe(Effect.provide(layerTest), Effect.scoped),
881 );882 );
882883
884 it.live("reapplies a selection after a switch away from it failed partway", () =>
885 Effect.gen(function* () {
886 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
887 const path = yield* Path.Path;
888 const mockAgentPath = yield* path.fromFileUrl(
889 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
890 );
891 const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
892 const instanceId = ProviderInstanceId.make("acp-test-partial-selection-reapply");
893 const threadId = ThreadId.make("thread-acp-partial-selection-reapply");
894 const adapter = yield* makeAcpAdapterV2({
895 instanceId,
896 flavor: {
897 driver: ACP_TEST_DRIVER,
898 capabilities: AcpProviderCapabilitiesV2,
899 makeRuntime: makeMockRuntime({
900 childProcessSpawner,
901 mockAgentPath,
902 protocolEvents,
903 // The model switch lands, then the agent rejects the mode step.
904 environment: { T3_ACP_FAIL_SET_MODE_CONFIG_OPTION: "1" },
905 }),
906 },
907 selfInvocation: yield* resolveSelfInvocation(),
908 });
909 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
910 runtimeMode: "full-access",
911 interactionMode: "default",
912 cwd: process.cwd(),
913 });
914 const selectionA = { instanceId, model: "composer-2" } as const;
915 const selectionB = {
916 instanceId,
917 model: "composer-2[fast=true]",
918 options: [{ id: ACP_SESSION_MODE_OPTION_ID, value: "code" }],
919 } as const;
920 const runtime = yield* adapter.openSession({
921 threadId,
922 providerSessionId: ProviderSessionId.make("provider-session-acp-partial-selection"),
923 modelSelection: selectionA,
924 runtimePolicy,
925 });
926 const providerThread = yield* runtime.ensureThread({
927 threadId,
928 modelSelection: selectionA,
929 runtimePolicy,
930 });
931 const now = yield* DateTime.now;
932 const turnInput = (ordinal: number, modelSelection: ModelSelection) =>
933 makeTurnInput({
934 threadId,
935 providerThread,
936 instanceId,
937 runtimePolicy,
938 now,
939 ordinal,
940 modelSelection,
941 });
942
943 const switchToB = yield* Effect.exit(runtime.startTurn(turnInput(1, selectionB)));
944 assert.isTrue(Exit.isFailure(switchToB), "the agent rejected B's mode step");
945 yield* Queue.clear(protocolEvents);
946
947 yield* runtime.startTurn(turnInput(2, selectionA));
948 yield* runtime.events.pipe(
949 Stream.takeUntil((event) => event.type === "turn.terminal"),
950 Stream.runDrain,
951 );
952 const modelSetsBeforePrompt: Array<unknown> = [];
953 while (true) {
954 const event = yield* Queue.take(protocolEvents);
955 if (event.direction !== "outgoing") continue;
956 const method = rawProtocolMethod(event);
957 if (method === "session/prompt") break;
958 if (
959 method === "session/set_config_option" &&
960 rawProtocolRequestParam(event, "configId") === "model"
961 ) {
962 modelSetsBeforePrompt.push(rawProtocolRequestParam(event, "value"));
963 }
964 }
965 assert.deepEqual(
966 modelSetsBeforePrompt,
967 ["composer-2"],
968 "the native model was left on B, so the turn on A must set it back first",
969 );
970 }).pipe(Effect.provide(layerTest), Effect.scoped),
971 );
972
883 it.effect("starts a new replay message after ACP v2 plan boundaries", () =>973 it.effect("starts a new replay message after ACP v2 plan boundaries", () =>
884 Effect.gen(function* () {974 Effect.gen(function* () {
885 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;975 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
packages/provider-acp/src/server/adapter.ts+4
...@@ -6283,6 +6283,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (...@@ -6283,6 +6283,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
6283 modelSelection: ModelSelection,6283 modelSelection: ModelSelection,
6284 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,6284 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
6285 ) {6285 ) {
6286 // A step failing below leaves the native config partly changed, so
6287 // the previously applied selection no longer describes the session.
6288 // Callers record the new selection only once this completes.
6289 yield* Ref.set(activeSelection, null);
6286 const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model;6290 const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model;
6287 let appliedModel: string | undefined;6291 let appliedModel: string | undefined;
6288 if (flavor.applyModelSelection !== undefined) {6292 if (flavor.applyModelSelection !== undefined) {