| ... | @@ -58,6 +58,7 @@ import type * as EffectAcpSchema from "effect-acp/compat"; | ... | @@ -58,6 +58,7 @@ import type * as EffectAcpSchema from "effect-acp/compat"; |
| 58 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; | 58 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 59 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; | 59 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 60 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | 60 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| | 61 | import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; |
| 61 | import { | 62 | import { |
| 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 | ); |
| 882 | | 883 | |
| | 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; |