From ed40a5ac073b0dcde13ddc85845a3836e5f20192 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 14:53:39 +0000 Subject: [PATCH 1/8] test(server): reproduce #27646 agent-initiated mode switch sticking across turns The ACP mock agent can now leave its native mode on its own during the first prompt. The new AcpAdapterV2 test shows the next turn with the same mode selection prompts without setting the mode back. Co-Authored-By: Claude Opus 5.5 --- apps/server/scripts/acp-mock-agent.ts | 13 +++ .../Adapters/AcpAdapterV2.test.ts | 87 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index a10f9855cbbbf..d6d24690156e6 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -55,6 +55,7 @@ const compactionPromptOrdinals = new Set( (process.env.T3_ACP_COMPACTION_PROMPT_ORDINALS ?? "").split(",").map(Number), ); const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1"; +const selfSwitchModeOnFirstPrompt = process.env.T3_ACP_SELF_SWITCH_MODE_ON_FIRST_PROMPT; const emitEmptyDefaultAgentOption = process.env.T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); @@ -1262,6 +1263,18 @@ const program = Effect.gen(function* () { } } + if (selfSwitchModeOnFirstPrompt !== undefined && promptCount === 1) { + currentModeId = selfSwitchModeOnFirstPrompt; + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { sessionUpdate: "current_mode_update", currentModeId }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { sessionUpdate: "config_option_update", configOptions: configOptions() }, + }); + } + if (failPrompt || promptCount === failPromptOrdinal) { return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); } diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index b924f6efb3e70..f033ead5d0092 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1168,6 +1168,93 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + // regression: #27646 an agent leaving its native mode on its own must not keep the next turn there + it.live("reapplies the selected native mode after the agent switches mode itself", () => + 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-agent-mode-drift"); + const threadId = ThreadId.make("thread-acp-agent-mode-drift"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + protocolEvents, + environment: { T3_ACP_SELF_SWITCH_MODE_ON_FIRST_PROMPT: "ask" }, + }), + }, + selfInvocation: yield* resolveSelfInvocation(), + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { + instanceId, + model: "default", + options: [{ id: "mode", value: "architect" }], + } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-agent-mode-drift"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + const runTurn = Effect.fnUntraced(function* (ordinal: number) { + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal, + modelSelection, + }), + ); + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runDrain, + ); + const modeSetsBeforePrompt: Array = []; + while (true) { + const event = yield* Queue.take(protocolEvents); + if (event.direction !== "outgoing") continue; + const method = rawProtocolMethod(event); + if (method === "session/prompt") return modeSetsBeforePrompt; + if ( + method === "session/set_config_option" && + rawProtocolRequestParam(event, "configId") === "mode" + ) { + modeSetsBeforePrompt.push(rawProtocolRequestParam(event, "value")); + } + } + }); + + assert.deepEqual(yield* runTurn(1), ["architect"]); + assert.deepEqual( + yield* runTurn(2), + ["architect"], + "the agent left architect mode during turn 1, so turn 2 must set it back before prompting", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + // regression: #21499 turns blocked only on T3 thread waits must show as waiting it.effect.each(["agent_message_chunk"] as const)( "keeps named ACP %s open while deriving thread wait execution state", From 945cb7a2ff0293f521ccbc1ae855d4a65b895427 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 15:12:26 +0000 Subject: [PATCH 2/8] fix(server): reapply an ACP agent's selected mode after it switches mode itself When the agent left its configured mode on its own (OMP leaving plan mode), later turns with an unchanged selection skipped configureSession, so the turn ran in the agent's mode while T3 showed the selected one. startTurn now also reconfigures when the live mode differs from the one configureSession left. Setting the mode-category config option now updates the runtime's mode state too; it previously stayed at the old mode, which hid a drift back to it. Co-Authored-By: Claude Opus 5.5 --- .../provider-acp/src/server/AcpSessionRuntime.ts | 12 +++++++++++- packages/provider-acp/src/server/adapter.ts | 9 +++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 300a9359e40dd..5e26ec3ff88a6 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -2031,7 +2031,17 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( "session/set_config_option", requestPayload, acp.agent.setSessionConfigOption(requestPayload), - ).pipe(Effect.tap((response) => updateConfigOptions(response))); + ).pipe( + Effect.tap((response) => updateConfigOptions(response)), + Effect.tap((response) => + existing?.category === "mode" && typeof value === "string" + ? updateCurrentModeId( + parseSessionModeState({ configOptions: response.configOptions }) + ?.currentModeId ?? value, + ) + : Effect.void, + ), + ); }), ), ), diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index c895abe3f5ef8..e2f88dfcad758 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1596,6 +1596,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }); const activeSelection = yield* Ref.make(null); const activeInteractionMode = yield* Ref.make(null); + // The mode configureSession left the agent in; the agent may leave it on its own. + const configuredModeId = yield* Ref.make(undefined); const promptInstructionStates = yield* Ref.make(new Map()); // Unique markers keep delayed prompt completions from clearing later compactions. const compactionMarkers = yield* Ref.make(new Map()); @@ -6874,10 +6876,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( nativeBuildConfigurationBySessionId.delete(startResult.sessionId); } } + const configuredModeState = yield* runtime.getModeState; + yield* Ref.set(configuredModeId, configuredModeState?.currentModeId); yield* ( flavor.onSessionConfigurationUpdate?.( yield* runtime.getConfigOptions, - yield* runtime.getModeState, + configuredModeState, (yield* Ref.get(activeSessionSetup))?.sessionSetupResult.models, ) ?? Effect.void ); @@ -7352,7 +7356,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if ( configuredSelection === null || !modelSelectionsEqual(configuredSelection, turnInput.modelSelection) || - configuredInteractionMode !== turnInput.runtimePolicy.interactionMode + configuredInteractionMode !== turnInput.runtimePolicy.interactionMode || + (yield* runtime.getModeState)?.currentModeId !== (yield* Ref.get(configuredModeId)) ) { const currentSessionSetup = yield* Ref.get(activeSessionSetup); if (currentSessionSetup === null) { From 710a9646347248c4c5a013287ad47478acbf6c32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 15:31:09 +0000 Subject: [PATCH 3/8] test(server): an unchanged ACP mode is not re-sent after the reapply Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index f033ead5d0092..e865706784024 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1252,6 +1252,7 @@ describe("AcpAdapterV2", () => { ["architect"], "the agent left architect mode during turn 1, so turn 2 must set it back before prompting", ); + assert.deepEqual(yield* runTurn(3), [], "an unchanged mode is not re-sent"); }).pipe(Effect.provide(layerTest), Effect.scoped), ); From 1d0e554305ad2bcb42391043b654ffb8ce5676c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:23:25 +0000 Subject: [PATCH 4/8] fix(acp): a mode-only update no longer hides the drift from the mode option An ACP v1 agent can report its own mode switch with current_mode_update alone. The mode config option kept the old value, so reapplying the selected mode was skipped as unchanged. Keep the option in step with the reported mode, and cover it with a v1 replay transcript. AI-assisted; verified with the tests and tools listed above. --- .../Adapters/AcpAdapterV2.test.ts | 158 +++++++++++++++++- .../src/server/AcpSessionRuntime.ts | 25 ++- 2 files changed, 180 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 5d9dd33325819..d7dc12c590e66 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -8,7 +8,11 @@ import { } from "@t3tools/provider-acp-registry/testing"; import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { AcpRegistryCatalog } from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; -import { decodeAcpReplayTranscript, makeAcpReplayRuntime } from "./AcpAdapterV2.testkit.ts"; +import { + decodeAcpReplayTranscript, + makeAcpReplayCompletenessAssertion, + makeAcpReplayRuntime, +} from "./AcpAdapterV2.testkit.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -2499,6 +2503,158 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + // regression: #27646 an ACP v1 agent can report a mode switch without a config option update + it.live("reapplies the selected native mode after a mode-only update from a v1 agent", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-mode-only-" }); + const statusPath = path.join(directory, "status.json"); + const sessionId = "mode-only-session"; + const modeOption = (currentValue: string) => ({ + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue, + options: [ + { value: "ask", name: "Ask" }, + { value: "architect", name: "Architect" }, + ], + }); + const setArchitect = [ + { + type: "expect_outbound" as const, + frame: { + kind: "request", + method: "session/set_config_option", + params: { sessionId, configId: "mode", value: "architect" }, + }, + }, + { + type: "emit_inbound" as const, + frame: { + kind: "response", + method: "session/set_config_option", + result: { configOptions: [modeOption("architect")] }, + }, + }, + ]; + const prompt = (updates: ReadonlyArray) => [ + { + type: "expect_outbound" as const, + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + ...updates.map((update) => ({ + type: "emit_inbound" as const, + frame: { kind: "notification", method: "session/update", params: { sessionId, update } }, + })), + { + type: "emit_inbound" as const, + frame: { kind: "response", method: "session/prompt", result: { stopReason: "end_turn" } }, + }, + ]; + const transcript = yield* decodeAcpReplayTranscript( + { + provider: ACP_TEST_DRIVER, + protocol: "acp.ndjson-jsonrpc", + version: "1", + scenario: "mode-only-update", + entries: [ + { + type: "expect_outbound", + frame: { kind: "request", method: "initialize", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "initialize", + result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }, + }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/new", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "session/new", + result: { sessionId, configOptions: [modeOption("ask")] }, + }, + }, + ...setArchitect, + ...prompt([{ sessionUpdate: "current_mode_update", currentModeId: "ask" }]), + ...setArchitect, + ...prompt([]), + ], + }, + ACP_TEST_DRIVER, + ); + const instanceId = ProviderInstanceId.make("acp-test-mode-only"); + const threadId = ThreadId.make("thread-acp-mode-only"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeAcpReplayRuntime({ + transcript, + statusPath, + fileSystem, + scriptPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-replay-agent.ts", import.meta.url), + ), + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + }), + }, + selfInvocation: yield* resolveSelfInvocation(), + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: directory, + }); + const modelSelection = { + instanceId, + model: "default", + options: [{ id: "mode", value: "architect" }], + } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-mode-only"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + for (const ordinal of [1, 2]) { + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal, + modelSelection, + }), + ); + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runDrain, + ); + } + yield* makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + // regression: #21499 turns blocked only on T3 thread waits must show as waiting it.effect.each(["agent_message_chunk"] as const)( "keeps named ACP %s open while deriving thread wait execution state", diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 04b159c6eece4..8e461a834c94c 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -2811,9 +2811,30 @@ const handleSessionUpdate = } const parsed = parseSessionUpdateEvent(params); if (parsed.modeId) { - yield* Ref.update(modeStateRef, (current) => + const modeId = (yield* Ref.updateAndGet(modeStateRef, (current) => current === undefined ? current : updateModeState(current, parsed.modeId!), - ); + ))?.currentModeId; + // A mode-only update leaves the mode option stale, and setConfigOption + // skips a value the option already shows, so reapplying the mode would send nothing. + const configOptions = yield* SubscriptionRef.get(configOptionsRef); + if ( + modeId !== undefined && + configOptions.some( + (option) => + option.category === "mode" && + option.type === "select" && + option.currentValue !== modeId, + ) + ) { + yield* SubscriptionRef.set( + configOptionsRef, + configOptions.map((option) => + option.category === "mode" && option.type === "select" + ? { ...option, currentValue: modeId } + : option, + ), + ); + } } for (const event of parsed.events) { if (event._tag === "ToolCallUpdated") { From 5689e51a7a3f71e8aa72bc516f75879d0c985f48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:28:13 +0000 Subject: [PATCH 5/8] test(server): cover the mode reapply with one v1 replay transcript The replay transcript already rejects any unexpected outbound frame, so a third turn proves an unchanged mode is not re-sent. That makes the mock agent self-switch test and its environment flag redundant. AI-assisted; verified with the tests and tools listed above. --- apps/server/scripts/acp-mock-agent.ts | 13 --- .../Adapters/AcpAdapterV2.test.ts | 92 +------------------ 2 files changed, 3 insertions(+), 102 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 98d36d37e2c5c..44011bc7f2d4b 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -77,7 +77,6 @@ const compactionPromptOrdinals = new Set( (process.env.T3_ACP_COMPACTION_PROMPT_ORDINALS ?? "").split(",").map(Number), ); const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1"; -const selfSwitchModeOnFirstPrompt = process.env.T3_ACP_SELF_SWITCH_MODE_ON_FIRST_PROMPT; const emitEmptyDefaultAgentOption = process.env.T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); @@ -1348,18 +1347,6 @@ const program = Effect.gen(function* () { } } - if (selfSwitchModeOnFirstPrompt !== undefined && promptCount === 1) { - currentModeId = selfSwitchModeOnFirstPrompt; - yield* agent.client.sessionUpdate({ - sessionId: requestedSessionId, - update: { sessionUpdate: "current_mode_update", currentModeId }, - }); - yield* agent.client.sessionUpdate({ - sessionId: requestedSessionId, - update: { sessionUpdate: "config_option_update", configOptions: configOptions() }, - }); - } - if (failPrompt || promptCount === failPromptOrdinal) { return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); } diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index d7dc12c590e66..c73c186800a64 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -2415,94 +2415,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - // regression: #27646 an agent leaving its native mode on its own must not keep the next turn there - it.live("reapplies the selected native mode after the agent switches mode itself", () => - 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-agent-mode-drift"); - const threadId = ThreadId.make("thread-acp-agent-mode-drift"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - protocolEvents, - environment: { T3_ACP_SELF_SWITCH_MODE_ON_FIRST_PROMPT: "ask" }, - }), - }, - selfInvocation: yield* resolveSelfInvocation(), - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { - instanceId, - model: "default", - options: [{ id: "mode", value: "architect" }], - } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-agent-mode-drift"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - const runTurn = Effect.fnUntraced(function* (ordinal: number) { - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now, - ordinal, - modelSelection, - }), - ); - yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runDrain, - ); - const modeSetsBeforePrompt: Array = []; - while (true) { - const event = yield* Queue.take(protocolEvents); - if (event.direction !== "outgoing") continue; - const method = rawProtocolMethod(event); - if (method === "session/prompt") return modeSetsBeforePrompt; - if ( - method === "session/set_config_option" && - rawProtocolRequestParam(event, "configId") === "mode" - ) { - modeSetsBeforePrompt.push(rawProtocolRequestParam(event, "value")); - } - } - }); - - assert.deepEqual(yield* runTurn(1), ["architect"]); - assert.deepEqual( - yield* runTurn(2), - ["architect"], - "the agent left architect mode during turn 1, so turn 2 must set it back before prompting", - ); - assert.deepEqual(yield* runTurn(3), [], "an unchanged mode is not re-sent"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - // regression: #27646 an ACP v1 agent can report a mode switch without a config option update it.live("reapplies the selected native mode after a mode-only update from a v1 agent", () => Effect.gen(function* () { @@ -2589,6 +2501,7 @@ describe("AcpAdapterV2", () => { ...prompt([{ sessionUpdate: "current_mode_update", currentModeId: "ask" }]), ...setArchitect, ...prompt([]), + ...prompt([]), ], }, ACP_TEST_DRIVER, @@ -2634,7 +2547,8 @@ describe("AcpAdapterV2", () => { runtimePolicy, }); const now = yield* DateTime.now; - for (const ordinal of [1, 2]) { + // Turn 2 must set architect back before prompting; turn 3 must not re-send it. + for (const ordinal of [1, 2, 3]) { yield* runtime.startTurn( makeTurnInput({ threadId, From 866db49bec2522900f680fcd919ef4800da9ea36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:12:24 +0000 Subject: [PATCH 6/8] revert(acp): stop forcing the selected mode back after the agent leaves it Reapplying the thread's Mode before the next turn re-entered OMP's plan mode after its plan was approved and executed, reopening approval for work already done. An agent that leaves plan mode on its own does so because the plan is finished, so the server cannot tell that apart from a user who still wants Plan without the agent's mode change being written back to the thread's selection. Remove the reapply and the runtime mode-state changes that only supported it. AI-assisted; verified with the tests and tools listed above. --- .../Adapters/AcpAdapterV2.test.ts | 160 +----------------- .../src/server/AcpSessionRuntime.ts | 39 +---- packages/provider-acp/src/server/adapter.ts | 9 +- 3 files changed, 6 insertions(+), 202 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index c73c186800a64..f63a220fb5191 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -8,11 +8,7 @@ import { } from "@t3tools/provider-acp-registry/testing"; import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { AcpRegistryCatalog } from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; -import { - decodeAcpReplayTranscript, - makeAcpReplayCompletenessAssertion, - makeAcpReplayRuntime, -} from "./AcpAdapterV2.testkit.ts"; +import { decodeAcpReplayTranscript, makeAcpReplayRuntime } from "./AcpAdapterV2.testkit.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -2415,160 +2411,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - // regression: #27646 an ACP v1 agent can report a mode switch without a config option update - it.live("reapplies the selected native mode after a mode-only update from a v1 agent", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-mode-only-" }); - const statusPath = path.join(directory, "status.json"); - const sessionId = "mode-only-session"; - const modeOption = (currentValue: string) => ({ - id: "mode", - name: "Mode", - category: "mode", - type: "select", - currentValue, - options: [ - { value: "ask", name: "Ask" }, - { value: "architect", name: "Architect" }, - ], - }); - const setArchitect = [ - { - type: "expect_outbound" as const, - frame: { - kind: "request", - method: "session/set_config_option", - params: { sessionId, configId: "mode", value: "architect" }, - }, - }, - { - type: "emit_inbound" as const, - frame: { - kind: "response", - method: "session/set_config_option", - result: { configOptions: [modeOption("architect")] }, - }, - }, - ]; - const prompt = (updates: ReadonlyArray) => [ - { - type: "expect_outbound" as const, - frame: { kind: "request", method: "session/prompt", params: "" }, - }, - ...updates.map((update) => ({ - type: "emit_inbound" as const, - frame: { kind: "notification", method: "session/update", params: { sessionId, update } }, - })), - { - type: "emit_inbound" as const, - frame: { kind: "response", method: "session/prompt", result: { stopReason: "end_turn" } }, - }, - ]; - const transcript = yield* decodeAcpReplayTranscript( - { - provider: ACP_TEST_DRIVER, - protocol: "acp.ndjson-jsonrpc", - version: "1", - scenario: "mode-only-update", - entries: [ - { - type: "expect_outbound", - frame: { kind: "request", method: "initialize", params: "" }, - }, - { - type: "emit_inbound", - frame: { - kind: "response", - method: "initialize", - result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }, - }, - }, - { - type: "expect_outbound", - frame: { kind: "request", method: "session/new", params: "" }, - }, - { - type: "emit_inbound", - frame: { - kind: "response", - method: "session/new", - result: { sessionId, configOptions: [modeOption("ask")] }, - }, - }, - ...setArchitect, - ...prompt([{ sessionUpdate: "current_mode_update", currentModeId: "ask" }]), - ...setArchitect, - ...prompt([]), - ...prompt([]), - ], - }, - ACP_TEST_DRIVER, - ); - const instanceId = ProviderInstanceId.make("acp-test-mode-only"); - const threadId = ThreadId.make("thread-acp-mode-only"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeAcpReplayRuntime({ - transcript, - statusPath, - fileSystem, - scriptPath: yield* path.fromFileUrl( - new URL("../../../scripts/acp-replay-agent.ts", import.meta.url), - ), - childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, - }), - }, - selfInvocation: yield* resolveSelfInvocation(), - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: directory, - }); - const modelSelection = { - instanceId, - model: "default", - options: [{ id: "mode", value: "architect" }], - } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-mode-only"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - // Turn 2 must set architect back before prompting; turn 3 must not re-send it. - for (const ordinal of [1, 2, 3]) { - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now, - ordinal, - modelSelection, - }), - ); - yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runDrain, - ); - } - yield* makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - // regression: #21499 turns blocked only on T3 thread waits must show as waiting it.effect.each(["agent_message_chunk"] as const)( "keeps named ACP %s open while deriving thread wait execution state", diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 8e461a834c94c..514ac91164153 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -2071,19 +2071,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( Effect.tap((response) => Ref.get(configOptionUpdates).pipe( Effect.flatMap((updatesNow) => - updatesNow !== updatesBefore - ? Effect.void - : updateConfigOptions(response).pipe( - Effect.andThen( - existing?.category === "mode" && typeof value === "string" - ? updateCurrentModeId( - parseSessionModeState({ - configOptions: response.configOptions, - })?.currentModeId ?? value, - ) - : Effect.void, - ), - ), + updatesNow === updatesBefore ? updateConfigOptions(response) : Effect.void, ), ), ), @@ -2811,30 +2799,9 @@ const handleSessionUpdate = } const parsed = parseSessionUpdateEvent(params); if (parsed.modeId) { - const modeId = (yield* Ref.updateAndGet(modeStateRef, (current) => + yield* Ref.update(modeStateRef, (current) => current === undefined ? current : updateModeState(current, parsed.modeId!), - ))?.currentModeId; - // A mode-only update leaves the mode option stale, and setConfigOption - // skips a value the option already shows, so reapplying the mode would send nothing. - const configOptions = yield* SubscriptionRef.get(configOptionsRef); - if ( - modeId !== undefined && - configOptions.some( - (option) => - option.category === "mode" && - option.type === "select" && - option.currentValue !== modeId, - ) - ) { - yield* SubscriptionRef.set( - configOptionsRef, - configOptions.map((option) => - option.category === "mode" && option.type === "select" - ? { ...option, currentValue: modeId } - : option, - ), - ); - } + ); } for (const event of parsed.events) { if (event._tag === "ToolCallUpdated") { diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 575db126a1d1d..a2c2c429fa88c 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1932,8 +1932,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }); const activeSelection = yield* Ref.make(null); const activeInteractionMode = yield* Ref.make(null); - // The mode configureSession left the agent in; the agent may leave it on its own. - const configuredModeId = yield* Ref.make(undefined); const promptInstructionStates = yield* Ref.make(new Map()); // Unique markers keep delayed prompt completions from clearing later compactions. const compactionMarkers = yield* Ref.make(new Map()); @@ -7468,12 +7466,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } yield* validateModelSelection(modelSelection); - const configuredModeState = yield* runtime.getModeState; - yield* Ref.set(configuredModeId, configuredModeState?.currentModeId); yield* ( flavor.onSessionConfigurationUpdate?.( yield* runtime.getConfigOptions, - configuredModeState, + yield* runtime.getModeState, (yield* Ref.get(activeSessionSetup))?.sessionSetupResult.models, ) ?? Effect.void ); @@ -8096,8 +8092,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if ( configuredSelection === null || !modelSelectionsEqual(configuredSelection, turnInput.modelSelection) || - configuredInteractionMode !== turnInput.runtimePolicy.interactionMode || - (yield* runtime.getModeState)?.currentModeId !== (yield* Ref.get(configuredModeId)) + configuredInteractionMode !== turnInput.runtimePolicy.interactionMode ) { const currentSessionSetup = yield* Ref.get(activeSessionSetup); if (currentSessionSetup === null) { From 6bb0e0a6a70e45a17237967c6a2143ba4c145c56 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:33:43 +0000 Subject: [PATCH 7/8] fix(acp): follow a mode the agent left itself so picking it again applies When an ACP agent leaves its selected native mode on its own (OMP leaves plan mode once its plan is approved), the thread kept showing the old mode. Picking it again changed nothing, so no mode change reached the agent. Forcing the old mode back on the next turn is wrong too: it reopens approval for a plan that already ran. At turn end the adapter now compares each mode option with the value configureSession left it at. When the agent changed it, the adapter adopts the agent's value as the configured selection and reports it through a new thread_model_selection.updated provider event. The ingestor writes it to the thread under the thread lock, so the Mode menu shows the agent's real mode, and picking the old mode again is a selection change that configures the session. A mode-only current_mode_update now also refreshes the mode option, so v1 agents that send no config_option_update are covered. AI-assisted; verified with the tests and tools listed above. --- .../Adapters/AcpAdapterV2.test.ts | 174 +++++++++++++++++- .../ProviderEventIngestor.test.ts | 44 +++++ .../orchestration-v2/ProviderEventIngestor.ts | 27 ++- .../orchestration-v2/RunExecutionService.ts | 2 + .../src/server/AcpSessionRuntime.ts | 19 +- packages/provider-acp/src/server/adapter.ts | 53 ++++++ .../src/server/ProviderAdapter.ts | 7 + 7 files changed, 322 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index f63a220fb5191..32f333df009df 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -8,7 +8,11 @@ import { } from "@t3tools/provider-acp-registry/testing"; import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { AcpRegistryCatalog } from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; -import { decodeAcpReplayTranscript, makeAcpReplayRuntime } from "./AcpAdapterV2.testkit.ts"; +import { + decodeAcpReplayTranscript, + makeAcpReplayCompletenessAssertion, + makeAcpReplayRuntime, +} from "./AcpAdapterV2.testkit.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -2411,6 +2415,174 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + // regression: #27646 an agent that leaves its mode itself must not be forced back, but picking + // the old mode again must reach it, even when a v1 agent reports only the mode update. + it.live("reports a mode the agent left itself so picking it again is sent", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-mode-only-" }); + const statusPath = path.join(directory, "status.json"); + const sessionId = "mode-only-session"; + const modeOption = (currentValue: string) => ({ + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue, + options: [ + { value: "ask", name: "Ask" }, + { value: "architect", name: "Architect" }, + ], + }); + const setArchitect = [ + { + type: "expect_outbound" as const, + frame: { + kind: "request", + method: "session/set_config_option", + params: { sessionId, configId: "mode", value: "architect" }, + }, + }, + { + type: "emit_inbound" as const, + frame: { + kind: "response", + method: "session/set_config_option", + result: { configOptions: [modeOption("architect")] }, + }, + }, + ]; + const prompt = (updates: ReadonlyArray) => [ + { + type: "expect_outbound" as const, + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + ...updates.map((update) => ({ + type: "emit_inbound" as const, + frame: { kind: "notification", method: "session/update", params: { sessionId, update } }, + })), + { + type: "emit_inbound" as const, + frame: { kind: "response", method: "session/prompt", result: { stopReason: "end_turn" } }, + }, + ]; + const transcript = yield* decodeAcpReplayTranscript( + { + provider: ACP_TEST_DRIVER, + protocol: "acp.ndjson-jsonrpc", + version: "1", + scenario: "mode-only-update", + entries: [ + { + type: "expect_outbound", + frame: { kind: "request", method: "initialize", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "initialize", + result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }, + }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/new", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "session/new", + result: { sessionId, configOptions: [modeOption("ask")] }, + }, + }, + ...setArchitect, + ...prompt([{ sessionUpdate: "current_mode_update", currentModeId: "ask" }]), + ...prompt([]), + ...setArchitect, + ...prompt([]), + ], + }, + ACP_TEST_DRIVER, + ); + const instanceId = ProviderInstanceId.make("acp-test-mode-only"); + const threadId = ThreadId.make("thread-acp-mode-only"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeAcpReplayRuntime({ + transcript, + statusPath, + fileSystem, + scriptPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-replay-agent.ts", import.meta.url), + ), + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + }), + }, + selfInvocation: yield* resolveSelfInvocation(), + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: directory, + }); + const architect = { + instanceId, + model: "default", + options: [{ id: "mode", value: "architect" }], + } as const; + const ask = { ...architect, options: [{ id: "mode", value: "ask" }] }; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-mode-only"), + modelSelection: architect, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: architect, + runtimePolicy, + }); + const now = yield* DateTime.now; + const reported: Array = []; + // The thread follows the agent to ask; only the user's renewed pick sets architect again. + for (const [ordinal, modelSelection] of [ + [1, architect], + [2, ask], + [3, architect], + ] as const) { + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now, + ordinal, + modelSelection, + }), + ); + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runForEach((event) => + Effect.sync(() => { + if (event.type === "thread_model_selection.updated") { + reported.push({ threadId: event.threadId, modelSelection: event.modelSelection }); + } + }), + ), + ); + } + assert.deepStrictEqual(reported, [{ threadId, modelSelection: ask }]); + yield* makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + // regression: #21499 turns blocked only on T3 thread waits must show as waiting it.effect.each(["agent_message_chunk"] as const)( "keeps named ACP %s open while deriving thread wait execution state", diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 520fea7c0575b..80949db27f6c8 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -2144,6 +2144,50 @@ layer("ProviderEventIngestorV2", (it) => { }), ); + it.effect( + "moves a thread to the selection its agent reported, once and only for its instance", + () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const rootEvent = yield* threadCreatedEvent(now); + yield* eventSink.write({ events: [rootEvent] }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: rootEvent.threadId, + }); + const ingest = (reported: ModelSelection) => + ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId: rootEvent.threadId, + event: { + type: "thread_model_selection.updated", + driver: CODEX_DRIVER, + threadId: rootEvent.threadId, + modelSelection: reported, + }, + }); + const reported = { ...modelSelection, options: [{ id: "mode", value: "default" }] }; + assert.deepEqual( + (yield* ingest(reported)).map((stored) => stored.event.type), + ["thread.model-selection-updated"], + ); + assert.deepEqual( + (yield* projectionStore.getThread(rootEvent.threadId)).modelSelection, + reported, + ); + assert.deepEqual(yield* ingest(reported), []); + assert.deepEqual( + yield* ingest({ ...reported, instanceId: ProviderInstanceId.make("other-instance") }), + [], + ); + }), + ); + it.effect( "recovers a missing Claude child once and preserves saved metadata on later recovery", () => diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 623fddb491108..2dba23fa5b82a 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -493,6 +493,29 @@ export const layer: Layer.Layer< }), ]; } + case "thread_model_selection.updated": { + const { modelSelection } = input.event; + const thread = yield* projections.getThread(input.event.threadId).pipe( + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), + }), + ); + if ( + thread === null || + thread.modelSelection.instanceId !== modelSelection.instanceId || + modelSelectionsEqual(thread.modelSelection, modelSelection) + ) + return []; + const now = yield* DateTime.now; + return [ + yield* makeDomainEvent(input, { + type: "thread.model-selection-updated", + threadId: thread.id, + payload: { ...thread, modelSelection, updatedAt: now }, + occurredAt: now, + }), + ]; + } case "provider_session.updated": return [ yield* makeDomainEvent(input, { @@ -849,7 +872,9 @@ export const layer: Layer.Layer< (effect) => input.event.type === "app_thread.created" ? threadCommands.withLock(input.event.appThread.id, effect) - : effect, + : input.event.type === "thread_model_selection.updated" + ? threadCommands.withLock(input.event.threadId, effect) + : effect, ), ), }); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index e981ab7362d2d..64f18e1da551b 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -408,6 +408,8 @@ export function routeProviderEvent( }, ]; } + case "thread_model_selection.updated": + return [event.threadId === input.threadId, state]; case "provider_thread.updated": { const belongs = state.ownedProviderThreadIds.has(event.providerThread.id) || diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 514ac91164153..068a63d300c0f 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -2798,10 +2798,25 @@ const handleSessionUpdate = ); } const parsed = parseSessionUpdateEvent(params); - if (parsed.modeId) { + const modeId = parsed.modeId; + if (modeId) { yield* Ref.update(modeStateRef, (current) => - current === undefined ? current : updateModeState(current, parsed.modeId!), + current === undefined ? current : updateModeState(current, modeId), ); + // A mode-only update would otherwise leave the mode option showing the old mode. + const configOptions = yield* SubscriptionRef.get(configOptionsRef); + const isStaleMode = (option: (typeof configOptions)[number]) => + option.category === "mode" && option.type === "select" && option.currentValue !== modeId; + if (configOptions.some(isStaleMode)) { + yield* SubscriptionRef.set( + configOptionsRef, + configOptions.map((option) => + option.category === "mode" && option.type === "select" + ? { ...option, currentValue: modeId } + : option, + ), + ); + } } for (const event of parsed.events) { if (event._tag === "ToolCallUpdated") { diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index a2c2c429fa88c..f9da1b40a0a8b 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1932,6 +1932,24 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }); const activeSelection = yield* Ref.make(null); const activeInteractionMode = yield* Ref.make(null); + // Mode values configureSession left the agent in; a later difference is the agent's own change. + const configuredModes = yield* Ref.make>(new Map()); + const readModes = Effect.gen(function* () { + const modes = new Map(); + for (const option of yield* runtime.getConfigOptions) { + if ( + option.type === "select" && + (option.category === "mode" || option.category === "collaboration_mode") + ) { + modes.set(option.id, option.currentValue); + } + } + const modeId = (yield* runtime.getModeState)?.currentModeId; + if (modeId !== undefined && !modes.has(ACP_SESSION_MODE_OPTION_ID)) { + modes.set(ACP_SESSION_MODE_OPTION_ID, modeId); + } + return modes; + }); const promptInstructionStates = yield* Ref.make(new Map()); // Unique markers keep delayed prompt completions from clearing later compactions. const compactionMarkers = yield* Ref.make(new Map()); @@ -7466,6 +7484,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } yield* validateModelSelection(modelSelection); + yield* Ref.set(configuredModes, yield* readModes); yield* ( flavor.onSessionConfigurationUpdate?.( yield* runtime.getConfigOptions, @@ -7603,6 +7622,39 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ); }); + // Re-applying the thread's mode would undo a deliberate exit, such as leaving plan mode once + // the plan was approved. Report it instead, so picking the old mode again is a real change. + const reportAgentModeChanges = Effect.fnUntraced(function* (threadId: ThreadId) { + const selection = yield* Ref.get(activeSelection); + if (selection === null || selection.options === undefined) return; + const configured = yield* Ref.get(configuredModes); + const live = yield* readModes; + let changed = false; + const options = selection.options.map((option) => { + const value = live.get(option.id); + if ( + value === undefined || + typeof option.value !== "string" || + value === configured.get(option.id) || + value === option.value + ) { + return option; + } + changed = true; + return { ...option, value }; + }); + if (!changed) return; + const reported = { ...selection, options }; + yield* Ref.set(activeSelection, reported); + yield* Ref.set(configuredModes, live); + yield* emitProviderEvent({ + type: "thread_model_selection.updated", + driver, + threadId, + modelSelection: reported, + }); + }); + const finalizeTurn = Effect.fnUntraced(function* ( context: ActiveAcpTurn, status: "completed" | "interrupted" | "failed" | "cancelled", @@ -7679,6 +7731,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }, }); } + yield* reportAgentModeChanges(context.input.threadId); const turn = providerTurnPayload(context, settledStatus, now); yield* Ref.update(providerTurns, (current) => { const updated = new Map(current); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index e925f10c8f154..00afa0261280c 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -102,6 +102,13 @@ export const ProviderAdapterV2Event = Schema.Union([ sessionScoped: Schema.optional(Schema.Boolean), retainedNativeTurnIds: Schema.optional(Schema.Array(Schema.String)), }), + /** The agent changed a selected option itself, such as leaving plan mode once its plan ran. */ + Schema.Struct({ + type: Schema.Literal("thread_model_selection.updated"), + driver: ProviderDriverKind, + threadId: ThreadId, + modelSelection: ModelSelection, + }), Schema.Struct({ type: Schema.Literal("provider_turn.updated"), driver: ProviderDriverKind, From 25baf2fb0518d839f210ae33b10a91ab6ad7f6b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:52:53 +0000 Subject: [PATCH 8/8] fix(acp): report only the modes the agent changed, not the whole selection The agent's mode report carried the selection applied at turn start, and the ingestor wrote it over the thread. A model or option the user saved during the turn was reverted to the stale one. The provider event now carries the instance and each changed option as { id, from, to }. The ingestor applies a change to the thread's current selection only where the thread still holds `from`, so newer settings and newer mode picks win. AI-assisted; verified with the tests and tools listed above. --- .../Adapters/AcpAdapterV2.test.ts | 10 ++- .../ProviderEventIngestor.test.ts | 88 +++++++++++-------- .../orchestration-v2/ProviderEventIngestor.ts | 22 +++-- packages/provider-acp/src/server/adapter.ts | 12 +-- .../src/server/ProviderAdapter.ts | 8 +- 5 files changed, 84 insertions(+), 56 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 32f333df009df..b382af3031e83 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -2572,13 +2572,19 @@ describe("AcpAdapterV2", () => { Stream.runForEach((event) => Effect.sync(() => { if (event.type === "thread_model_selection.updated") { - reported.push({ threadId: event.threadId, modelSelection: event.modelSelection }); + reported.push({ + threadId: event.threadId, + instanceId: event.instanceId, + changes: event.changes, + }); } }), ), ); } - assert.deepStrictEqual(reported, [{ threadId, modelSelection: ask }]); + assert.deepStrictEqual(reported, [ + { threadId, instanceId, changes: [{ id: "mode", from: "architect", to: "ask" }] }, + ]); yield* makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript); }).pipe(Effect.provide(layerTest), Effect.scoped), ); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 80949db27f6c8..c90e901c35a79 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -2144,48 +2144,58 @@ layer("ProviderEventIngestorV2", (it) => { }), ); - it.effect( - "moves a thread to the selection its agent reported, once and only for its instance", - () => - Effect.gen(function* () { - const now = yield* DateTime.now; - const eventSink = yield* EventSink.EventSinkV2; - const projectionStore = yield* ProjectionStore.ProjectionStoreV2; - const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const rootEvent = yield* threadCreatedEvent(now); - yield* eventSink.write({ events: [rootEvent] }); - const providerSessionId = yield* idAllocator.allocate.providerSession({ + it.effect("applies an agent's mode change without undoing newer thread settings", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const rootEvent = yield* threadCreatedEvent(now); + if (rootEvent.type !== "thread.created") throw new Error("Expected thread fixture"); + // Saved mid-turn: a newer model and effort the reporting turn never saw. + const saved = { + ...modelSelection, + model: "gpt-6.1-sol", + options: [ + { id: "effort", value: "high" }, + { id: "mode", value: "plan" }, + ], + }; + yield* eventSink.write({ + events: [{ ...rootEvent, payload: { ...rootEvent.payload, modelSelection: saved } }], + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: rootEvent.threadId, + }); + const ingest = (instanceId: ProviderInstanceId, from: string) => + ingestor.ingestNormalized({ + providerSessionId, providerInstanceId: modelSelection.instanceId, threadId: rootEvent.threadId, - }); - const ingest = (reported: ModelSelection) => - ingestor.ingestNormalized({ - providerSessionId, - providerInstanceId: modelSelection.instanceId, + event: { + type: "thread_model_selection.updated", + driver: CODEX_DRIVER, threadId: rootEvent.threadId, - event: { - type: "thread_model_selection.updated", - driver: CODEX_DRIVER, - threadId: rootEvent.threadId, - modelSelection: reported, - }, - }); - const reported = { ...modelSelection, options: [{ id: "mode", value: "default" }] }; - assert.deepEqual( - (yield* ingest(reported)).map((stored) => stored.event.type), - ["thread.model-selection-updated"], - ); - assert.deepEqual( - (yield* projectionStore.getThread(rootEvent.threadId)).modelSelection, - reported, - ); - assert.deepEqual(yield* ingest(reported), []); - assert.deepEqual( - yield* ingest({ ...reported, instanceId: ProviderInstanceId.make("other-instance") }), - [], - ); - }), + instanceId, + changes: [{ id: "mode", from, to: "default" }], + }, + }); + assert.deepEqual( + (yield* ingest(modelSelection.instanceId, "plan")).map((stored) => stored.event.type), + ["thread.model-selection-updated"], + ); + assert.deepEqual((yield* projectionStore.getThread(rootEvent.threadId)).modelSelection, { + ...saved, + options: [ + { id: "effort", value: "high" }, + { id: "mode", value: "default" }, + ], + }); + assert.deepEqual(yield* ingest(modelSelection.instanceId, "plan"), []); + assert.deepEqual(yield* ingest(ProviderInstanceId.make("other-instance"), "default"), []); + }), ); it.effect( diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 2dba23fa5b82a..70002b7b743af 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -494,24 +494,32 @@ export const layer: Layer.Layer< ]; } case "thread_model_selection.updated": { - const { modelSelection } = input.event; + const { instanceId, changes } = input.event; const thread = yield* projections.getThread(input.event.threadId).pipe( Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), }), ); - if ( - thread === null || - thread.modelSelection.instanceId !== modelSelection.instanceId || - modelSelectionsEqual(thread.modelSelection, modelSelection) - ) + const current = thread?.modelSelection; + if (thread === null || current?.instanceId !== instanceId || !current.options) { return []; + } + let changed = false; + const options = current.options.map((option) => { + const change = changes.find( + (candidate) => candidate.id === option.id && candidate.from === option.value, + ); + if (change === undefined) return option; + changed = true; + return { ...option, value: change.to }; + }); + if (!changed) return []; const now = yield* DateTime.now; return [ yield* makeDomainEvent(input, { type: "thread.model-selection-updated", threadId: thread.id, - payload: { ...thread, modelSelection, updatedAt: now }, + payload: { ...thread, modelSelection: { ...current, options }, updatedAt: now }, occurredAt: now, }), ]; diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index f9da1b40a0a8b..1607ae0d8a091 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -7629,7 +7629,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (selection === null || selection.options === undefined) return; const configured = yield* Ref.get(configuredModes); const live = yield* readModes; - let changed = false; + const changes: Array<{ id: string; from: string; to: string }> = []; const options = selection.options.map((option) => { const value = live.get(option.id); if ( @@ -7640,18 +7640,18 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) { return option; } - changed = true; + changes.push({ id: option.id, from: option.value, to: value }); return { ...option, value }; }); - if (!changed) return; - const reported = { ...selection, options }; - yield* Ref.set(activeSelection, reported); + if (changes.length === 0) return; + yield* Ref.set(activeSelection, { ...selection, options }); yield* Ref.set(configuredModes, live); yield* emitProviderEvent({ type: "thread_model_selection.updated", driver, threadId, - modelSelection: reported, + instanceId: selection.instanceId, + changes, }); }); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 00afa0261280c..589905cf9b17c 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -102,12 +102,16 @@ export const ProviderAdapterV2Event = Schema.Union([ sessionScoped: Schema.optional(Schema.Boolean), retainedNativeTurnIds: Schema.optional(Schema.Array(Schema.String)), }), - /** The agent changed a selected option itself, such as leaving plan mode once its plan ran. */ + /** The agent changed selected options itself, such as leaving plan mode once its plan ran. */ Schema.Struct({ type: Schema.Literal("thread_model_selection.updated"), driver: ProviderDriverKind, threadId: ThreadId, - modelSelection: ModelSelection, + instanceId: ProviderInstanceId, + /** Applies only where the thread still holds `from`, so newer user picks win. */ + changes: Schema.Array( + Schema.Struct({ id: Schema.String, from: Schema.String, to: Schema.String }), + ), }), Schema.Struct({ type: Schema.Literal("provider_turn.updated"),