Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
{
Expand Down
90 changes: 90 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<EffectAcpProtocol.AcpProtocolLogEvent>();
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<unknown> = [];
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;
Expand Down
4 changes: 4 additions & 0 deletions packages/provider-acp/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading