diff --git a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/bridge/SessionOption.java b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/bridge/SessionOption.java
index 5bfc09f8d..a73b6b98a 100644
--- a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/bridge/SessionOption.java
+++ b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/bridge/SessionOption.java
@@ -47,6 +47,25 @@ public SessionOption(@NotNull String key, @NotNull String displayName, @NotNull
this(key, displayName, values, labels, null);
}
+ /**
+ * Returns the value the session options menu should tick-mark: the
+ * agent-reported live value when the agent has one, otherwise the
+ * locally stored pick.
+ *
+ *
Agents that echo option state (OpenCode v2's
+ * {@code session/set_config_option} response is the only state channel —
+ * it pushes no {@code config_option_update} notifications) can change an
+ * option's current value out from under a stored pick, e.g. effort
+ * resetting to the model default after a model switch. Preferring the
+ * live value keeps the checkmark truthful; the stored pick remains the
+ * fallback before the agent has reported anything.
+ */
+ @NotNull
+ public String effectiveValue(@Nullable String stored) {
+ if (initialValue != null && !initialValue.isEmpty()) return initialValue;
+ return stored != null ? stored : "";
+ }
+
/**
* Returns the label shown in the dropdown for a given raw value.
* Empty string maps to "Default"; non-empty values use the {@link #labels} map if present,
diff --git a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/AbstractClient.java b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/AbstractClient.java
index 37c9bbf17..68a3c3950 100644
--- a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/AbstractClient.java
+++ b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/AbstractClient.java
@@ -316,6 +316,19 @@ public boolean supportsModelGrouping() {
return false;
}
+ /**
+ * Whether switching the primary agent to {@code slug} can be applied to the live session
+ * without restarting the agent process. When {@code true}, the UI applies the selection
+ * immediately via {@link #setCurrentAgentSlug(String)} (whose change hook pushes it to the
+ * agent); when {@code false}, the UI falls back to the process-restart flow. Slug-aware so
+ * clients that can live-switch only a subset of their advertised agents (e.g. OpenCode v2,
+ * where only the {@code build}/{@code plan} session modes are switchable) can answer per
+ * selection.
+ */
+ public boolean canSwitchAgentLive(@Nullable String slug) {
+ return false;
+ }
+
// ─── Session Options ─────────────────────────────
/**
diff --git a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/AcpClient.java b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/AcpClient.java
index 61fc23f7d..8ab878dbb 100644
--- a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/AcpClient.java
+++ b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/AcpClient.java
@@ -1450,7 +1450,46 @@ public final void setConfigOption(@NotNull String sessionId, @NotNull String con
params.addProperty(KEY_SESSION_ID, sessionId);
params.addProperty("configId", configId);
params.addProperty("value", valueId);
- transport.sendRequest("session/set_config_option", params);
+ CompletableFuture future = transport.sendRequest("session/set_config_option", params);
+ // Some agents (notably OpenCode v2) do NOT push a config_option_update
+ // notification after a change; instead they echo the updated option
+ // state (full configOptions array, including cross-effects such as
+ // effort resetting on a model switch) in the response. Apply that echo
+ // so the local option state never goes stale — otherwise the session
+ // options menu keeps showing the pre-change selection until the next
+ // session/new. Rejected options (JSON-RPC error, e.g. -32602) complete
+ // the future exceptionally and are skipped, leaving state untouched.
+ future.whenComplete(this::applyConfigOptionEcho);
+ }
+
+ /**
+ * Applies the {@code configOptions} array echoed in a successful
+ * {@code session/set_config_option} response to the local option state.
+ * Shared by {@link #setConfigOption} and by subclasses that send their own
+ * config-option requests (e.g. OpenCode v2 routes model changes through
+ * {@code session/set_config_option} via {@link #sendSetModel}) so no
+ * config-changing path misses the echo. Null/exceptional results are
+ * ignored; parse failures are logged and leave state untouched.
+ */
+ protected final void applyConfigOptionEcho(JsonElement result, Throwable err) {
+ if (err != null || result == null || !result.isJsonObject()) return;
+ try {
+ JsonObject res = result.getAsJsonObject();
+ if (!res.has("configOptions") || !res.get("configOptions").isJsonArray()) return;
+ List parsed = new ArrayList<>();
+ for (JsonElement e : res.getAsJsonArray("configOptions")) {
+ if (e.isJsonObject()) {
+ NewSessionResponse.SessionConfigOption opt =
+ messageParser.parseConfigOption(e.getAsJsonObject());
+ if (opt != null) parsed.add(opt);
+ }
+ }
+ if (!parsed.isEmpty()) {
+ updateConfigOptionsFromNotification(parsed);
+ }
+ } catch (Exception e) {
+ LOG.warn(displayName() + ": failed to apply set_config_option echo", e);
+ }
}
@Override
diff --git a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/AcpMessageParser.java b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/AcpMessageParser.java
index 647c0383a..9e3c80663 100644
--- a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/AcpMessageParser.java
+++ b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/AcpMessageParser.java
@@ -475,7 +475,7 @@ private SessionUpdate.ConfigOptionsChanged parseConfigOptionUpdate(JsonObject pa
return new SessionUpdate.ConfigOptionsChanged(options);
}
- private static NewSessionResponse.SessionConfigOption parseConfigOption(JsonObject obj) {
+ static NewSessionResponse.SessionConfigOption parseConfigOption(JsonObject obj) {
String id = getStringOrNull(obj, "id");
String label = resolveLabel(obj, id);
String selectedValueId = getStringOrNull(obj, "selectedValueId");
diff --git a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/OpenCodeClient.java b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/OpenCodeClient.java
index cafb9b101..c45865276 100644
--- a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/OpenCodeClient.java
+++ b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/client/acp/OpenCodeClient.java
@@ -2,6 +2,7 @@
import com.github.catatafishen.agentbridge.bridge.EntryData;
import com.github.catatafishen.agentbridge.client.AbstractClient;
+import com.github.catatafishen.agentbridge.client.acp.transport.JsonRpcTransport;
import com.github.catatafishen.agentbridge.services.AgentProfile;
import com.github.catatafishen.agentbridge.services.AgentProfileManager;
import com.github.catatafishen.agentbridge.settings.ShellEnvironment;
@@ -74,6 +75,15 @@ public OpenCodeClient(Project project) {
super(project);
}
+ /**
+ * Test constructor allowing transport injection, mirroring
+ * {@link AcpClient#AcpClient(Project, JsonRpcTransport)}. Package-private:
+ * production code must use {@link #OpenCodeClient(Project)}.
+ */
+ OpenCodeClient(Project project, JsonRpcTransport transport) {
+ super(project, transport);
+ }
+
@Override
public String agentId() {
return AGENT_ID;
@@ -94,6 +104,75 @@ public boolean supportsModelGrouping() {
return true;
}
+ /**
+ * OpenCode v2 exposes its session modes ({@code build}, {@code plan}) as the {@code mode}
+ * config option, and a mode switch applies to the live session via
+ * {@code session/set_config_option(configId=mode)} (verified against v2.0.27: the agent's
+ * self-reported mode changes in-session). Verified NOT switchable: v2 rejects
+ * {@code general}/{@code explore} with {@code -32602 mode not found} (they are Task-tool
+ * subagents, not primary modes), and v1 has no config-option channel, so both keep the
+ * process-restart flow.
+ */
+ @Override
+ public boolean canSwitchAgentLive(@Nullable String slug) {
+ return isOpenCodeV2OrLater() && isSelectableOpenCodeMode(slug);
+ }
+
+ /**
+ * Whether {@code slug} is one of the mode values OpenCode advertised in its {@code mode}
+ * config option (kept fresh by the set_config_option echo). Only advertised values are
+ * valid arguments to a mode switch.
+ */
+ private boolean isSelectableOpenCodeMode(@Nullable String slug) {
+ if (slug == null || slug.isBlank()) {
+ return false;
+ }
+ return getAvailableConfigOptions().stream()
+ .filter(o -> "mode".equals(o.id()))
+ .flatMap(o -> o.values().stream())
+ .anyMatch(v -> slug.equals(v.id()));
+ }
+
+ /**
+ * Pushes an agent selection made while a session is live to OpenCode v2 via the {@code mode}
+ * config option (the echo application keeps local option state in sync). No-op on v1 or when
+ * there is no active session (the selection is then applied by {@link #onSessionCreated}).
+ */
+ @Override
+ protected void onAgentSlugChanged(@Nullable String slug) {
+ if (!canSwitchAgentLive(slug)) {
+ return;
+ }
+ String sessionId = getCurrentSessionId();
+ if (sessionId != null && !sessionId.isBlank()) {
+ setConfigOption(sessionId, "mode", slug);
+ }
+ }
+
+ /**
+ * Applies an agent selection stored before the session existed (the process-restart flow
+ * persists it, and {@code ActiveAgentManager} re-applies it on start). Sends the stored slug
+ * as the session mode when it differs from the mode the session already runs.
+ */
+ @Override
+ protected void onSessionCreated(String sessionId) {
+ if (!isOpenCodeV2OrLater()) {
+ return;
+ }
+ String slug = getCurrentAgentSlug();
+ if (!isSelectableOpenCodeMode(slug)) {
+ return;
+ }
+ String currentMode = getAvailableConfigOptions().stream()
+ .filter(o -> "mode".equals(o.id()))
+ .map(o -> o.selectedValueId())
+ .filter(v -> v != null && !v.isBlank())
+ .findFirst().orElse(null);
+ if (!slug.equals(currentMode)) {
+ setConfigOption(sessionId, "mode", slug);
+ }
+ }
+
@Override
public List getAvailableAgents() {
List agents = new ArrayList<>(builtInAgents());
@@ -396,6 +475,7 @@ protected void sendSetModel(String sessionId, String modelId) {
params.addProperty("configId", "model");
params.addProperty("value", modelId);
transport.sendRequest("session/set_config_option", params)
+ .whenComplete(this::applyConfigOptionEcho)
.get(SET_CONFIG_OPTION_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS);
} catch (java.util.concurrent.ExecutionException e) {
if (e.getCause() instanceof com.github.catatafishen.agentbridge.client.acp.transport.JsonRpcException jre
@@ -431,7 +511,7 @@ protected void sendSetModel(String sessionId, String modelId) {
* versions return {@code true}: the method-not-found fallback in
* {@link #sendSetModel} recovers a misdetected old build.
*/
- static boolean isOpenCodeV2OrLater(@org.jetbrains.annotations.Nullable String version) {
+ static boolean isOpenCodeV2OrLater(@Nullable String version) {
int[] parsed = KiroClient.parseVersion(version);
return parsed == null || parsed[0] >= 2;
}
diff --git a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/ui/ChatToolWindowContent.kt b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/ui/ChatToolWindowContent.kt
index ef1cb6495..2cece3b10 100644
--- a/plugin-core/src/main/java/com/github/catatafishen/agentbridge/ui/ChatToolWindowContent.kt
+++ b/plugin-core/src/main/java/com/github/catatafishen/agentbridge/ui/ChatToolWindowContent.kt
@@ -2219,7 +2219,23 @@ class ChatToolWindowContent(
}
override fun actionPerformed(e: AnActionEvent) {
- if (agent.slug() != currentSlug) restartWithNewAgent(agent.slug())
+ if (agent.slug() != currentSlug) {
+ // Agents that can apply the switch to the live session (e.g. OpenCode v2
+ // via the mode config option) do so without a process restart; the
+ // selection is persisted so future sessions start with it too.
+ val liveSwitch = try {
+ agentManager.client.canSwitchAgentLive(agent.slug())
+ } catch (_: Exception) {
+ false
+ }
+ if (liveSwitch) {
+ agentManager.settings.setSelectedAgent(agent.slug())
+ agentManager.client.setCurrentAgentSlug(agent.slug())
+ statusBanner?.showInfo("Switched to ${agent.name()}")
+ } else {
+ restartWithNewAgent(agent.slug())
+ }
+ }
}
})
}
@@ -2292,7 +2308,12 @@ class ChatToolWindowContent(
for (option in options) {
group.addSeparator(option.displayName)
val stored = agentManager.settings.getSessionOptionValue(option.key)
- val current = stored.ifEmpty { option.initialValue ?: "" }
+ // Prefer the agent-reported live value over the stored pick: the
+ // set_config_option echo (the only state channel on OpenCode v2)
+ // carries cross-effects such as effort resetting to the model
+ // default after a model switch — the stored pick would keep
+ // tick-marking the stale pre-switch value.
+ val current = option.effectiveValue(stored)
for (value in option.values) {
group.add(createSessionOptionValueAction(option, value, current))
}
diff --git a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/bridge/SessionOptionEffectiveValueTest.java b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/bridge/SessionOptionEffectiveValueTest.java
new file mode 100644
index 000000000..1285a0aa5
--- /dev/null
+++ b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/bridge/SessionOptionEffectiveValueTest.java
@@ -0,0 +1,54 @@
+package com.github.catatafishen.agentbridge.bridge;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Pins {@link SessionOption#effectiveValue(String)}: the session options menu
+ * tick-marks the agent-reported live value when the agent has one (kept fresh
+ * by the session/set_config_option echo on OpenCode v2) and falls back to the
+ * locally stored pick only before the agent has reported anything. This is
+ * what makes the model-switch effort reset visible in the menu instead of a
+ * stale stored selection.
+ */
+class SessionOptionEffectiveValueTest {
+
+ private static SessionOption effort(String initialValue) {
+ return new SessionOption("effort", "Effort",
+ List.of("low", "medium", "high", "default"), null, initialValue);
+ }
+
+ @Test
+ void liveAgentValueWinsOverStoredPick() {
+ // User clicked "high" earlier (stored), then a model switch reset the
+ // agent's effort to its default — the echo reports "default".
+ assertEquals("default", effort("default").effectiveValue("high"));
+ }
+
+ @Test
+ void liveAgentValueMatchesOwnClickAfterEcho() {
+ // Click "high" -> echo reports "high": tick stays on the clicked value.
+ assertEquals("high", effort("high").effectiveValue("high"));
+ }
+
+ @Test
+ void storedPickIsFallbackBeforeAgentReports() {
+ // No live value yet (pre-session or agent without echo): stored pick wins.
+ assertEquals("high", effort(null).effectiveValue("high"));
+ }
+
+ @Test
+ void emptyLiveValueFallsBackToStored() {
+ // A blank live value carries no information; treat it as absent.
+ assertEquals("high", effort("").effectiveValue("high"));
+ }
+
+ @Test
+ void nothingStoredAndNothingLiveIsEmpty() {
+ assertEquals("", effort(null).effectiveValue(null));
+ assertEquals("", effort(null).effectiveValue(""));
+ }
+}
diff --git a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/client/acp/AcpClientEchoGuardTest.java b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/client/acp/AcpClientEchoGuardTest.java
new file mode 100644
index 000000000..19796e230
--- /dev/null
+++ b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/client/acp/AcpClientEchoGuardTest.java
@@ -0,0 +1,105 @@
+package com.github.catatafishen.agentbridge.client.acp;
+
+import com.github.catatafishen.agentbridge.client.acp.transport.JsonRpcTransport;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Guards the generic echo application in {@link AcpClient#setConfigOption} for agents other
+ * than OpenCode: the hook runs for every ACP agent, so a rejected option (JSON-RPC error),
+ * a response without a {@code configOptions} key, or malformed entries must leave the local
+ * option state untouched. A successful echo replaces state by id (idempotent).
+ */
+class AcpClientEchoGuardTest {
+
+ private static final class StubTransport extends JsonRpcTransport {
+ final List> futures = new java.util.ArrayList<>();
+
+ @Override
+ public CompletableFuture sendRequest(String method, JsonObject params) {
+ CompletableFuture future = new CompletableFuture<>();
+ futures.add(future);
+ return future;
+ }
+ }
+
+ /** Minimal concrete client; the package-private constructor injects the stub transport. */
+ private static final class TestableAcpClient extends AcpClient {
+ TestableAcpClient(StubTransport transport) {
+ super(null, transport);
+ }
+
+ @Override public String agentId() { return "test"; }
+ @Override public String displayName() { return "Test"; }
+ @Override protected List buildCommand(String cwd, int mcpPort) { return List.of(); }
+ @Override protected boolean isMcpToolTitle(@org.jetbrains.annotations.NotNull String protocolTitle) { return false; }
+ }
+
+ private static void setOption(AcpClient client, StubTransport transport, String configId, String value) {
+ client.setConfigOption("ses-1", configId, value);
+ }
+
+ @Test
+ void successfulEchoReplacesStateByIdempotently() {
+ StubTransport transport = new StubTransport();
+ TestableAcpClient client = new TestableAcpClient(transport);
+
+ setOption(client, transport, "effort", "low");
+ transport.futures.get(0).complete(JsonParser.parseString(
+ "{\"configOptions\":[{\"id\":\"effort\",\"name\":\"Effort\",\"category\":\"thought_level\"," +
+ "\"type\":\"select\",\"currentValue\":\"low\"," +
+ "\"options\":[{\"value\":\"low\",\"name\":\"Low\"},{\"value\":\"high\",\"name\":\"High\"}]}]}"));
+ assertEquals(1, client.getAvailableConfigOptions().size());
+ assertEquals("low", client.getAvailableConfigOptions().get(0).selectedValueId());
+
+ // second echo with the same option replaces, does not append
+ setOption(client, transport, "effort", "high");
+ transport.futures.get(1).complete(JsonParser.parseString(
+ "{\"configOptions\":[{\"id\":\"effort\",\"name\":\"Effort\",\"category\":\"thought_level\"," +
+ "\"type\":\"select\",\"currentValue\":\"high\"," +
+ "\"options\":[{\"value\":\"low\",\"name\":\"Low\"},{\"value\":\"high\",\"name\":\"High\"}]}]}"));
+ assertEquals(1, client.getAvailableConfigOptions().size());
+ assertEquals("high", client.getAvailableConfigOptions().get(0).selectedValueId());
+ }
+
+ @Test
+ void rejectedOptionLeavesStateUntouched() {
+ StubTransport transport = new StubTransport();
+ TestableAcpClient client = new TestableAcpClient(transport);
+
+ setOption(client, transport, "effort", "low");
+ transport.futures.get(0).completeExceptionally(new RuntimeException("simulated -32602"));
+ assertEquals(0, client.getAvailableConfigOptions().size(),
+ "an exceptionally-completed response (rejected option) must not touch state");
+ }
+
+ @Test
+ void responseWithoutConfigOptionsLeavesStateUntouched() {
+ StubTransport transport = new StubTransport();
+ TestableAcpClient client = new TestableAcpClient(transport);
+
+ setOption(client, transport, "effort", "low");
+ transport.futures.get(0).complete(JsonParser.parseString("{}").getAsJsonObject());
+ assertEquals(0, client.getAvailableConfigOptions().size(),
+ "agents that answer without an echo must not have their state touched");
+ }
+
+ @Test
+ void malformedEntriesAreSkipped() {
+ StubTransport transport = new StubTransport();
+ TestableAcpClient client = new TestableAcpClient(transport);
+
+ setOption(client, transport, "effort", "low");
+ transport.futures.get(0).complete(JsonParser.parseString(
+ "{\"configOptions\":[1, \"x\", null]}"));
+ assertEquals(0, client.getAvailableConfigOptions().size(),
+ "non-object entries must be skipped");
+ }
+}
diff --git a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/client/acp/OpenCodeClientAgentSwitchTest.java b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/client/acp/OpenCodeClientAgentSwitchTest.java
new file mode 100644
index 000000000..705df231e
--- /dev/null
+++ b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/client/acp/OpenCodeClientAgentSwitchTest.java
@@ -0,0 +1,168 @@
+package com.github.catatafishen.agentbridge.client.acp;
+
+import com.github.catatafishen.agentbridge.acp.protocol.InitializeResponse;
+import com.github.catatafishen.agentbridge.client.AbstractClient;
+import com.github.catatafishen.agentbridge.client.acp.transport.JsonRpcTransport;
+import com.intellij.openapi.project.Project;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Pins the OpenCode v2 live agent switch: the {@code build}/{@code plan} session modes are
+ * exposed as the {@code mode} config option and a switch applies to the live session via
+ * {@code session/set_config_option(configId=mode)} (verified against v2.0.27: the agent's
+ * self-reported mode changes in-session; {@code general}/{@code explore} are rejected with
+ * -32602 "mode not found" and are Task-tool subagents, not primary modes). The menu must not
+ * restart the process for switchable slugs, and a selection stored before the session existed
+ * is applied on session creation.
+ */
+class OpenCodeClientAgentSwitchTest {
+
+ /** Records every request; each response future is pre-completed from a script. */
+ private static final class StubTransport extends JsonRpcTransport {
+ static final class Call {
+ final String method;
+ final JsonObject params;
+ Call(String method, JsonObject params) { this.method = method; this.params = params; }
+ }
+
+ final List calls = new ArrayList<>();
+ private final java.util.Deque scriptedResults = new java.util.ArrayDeque<>();
+
+ void scriptResult(String json) {
+ scriptedResults.add(JsonParser.parseString(json));
+ }
+
+ @Override
+ public CompletableFuture sendRequest(String method, JsonObject params) {
+ calls.add(new Call(method, params));
+ CompletableFuture future = new CompletableFuture<>();
+ future.complete(scriptedResults.isEmpty() ? new JsonObject() : scriptedResults.pop());
+ return future;
+ }
+
+ int countOf(String method) {
+ return (int) calls.stream().filter(c -> c.method.equals(method)).count();
+ }
+ }
+
+ /** Echo carrying the v2 mode option (values build/plan, current build) + effort. */
+ private static final String MODE_ECHO =
+ "{\"configOptions\":[" +
+ "{\"id\":\"mode\",\"name\":\"Session Mode\",\"category\":\"mode\",\"type\":\"select\"," +
+ "\"currentValue\":\"build\",\"options\":[{\"value\":\"build\",\"name\":\"Build\"},{\"value\":\"plan\",\"name\":\"Plan\"}]}," +
+ "{\"id\":\"effort\",\"name\":\"Effort\",\"category\":\"thought_level\",\"type\":\"select\"," +
+ "\"currentValue\":\"default\",\"options\":[{\"value\":\"low\",\"name\":\"Low\"},{\"value\":\"default\",\"name\":\"Default\"}]}" +
+ "]}";
+
+ private static OpenCodeClient client(StubTransport transport, String version) throws Exception {
+ OpenCodeClient client = new OpenCodeClient(mock(Project.class), transport);
+ Field capabilities = AcpClient.class.getDeclaredField("capabilities");
+ capabilities.setAccessible(true);
+ capabilities.set(client, new InitializeResponse(
+ 1, new InitializeResponse.AgentInfo("OpenCode", null, version), null, null));
+ return client;
+ }
+
+ private static void setActiveSession(AbstractClient client, String sessionId) throws Exception {
+ Field session = AbstractClient.class.getDeclaredField("currentSessionId");
+ session.setAccessible(true);
+ session.set(client, sessionId);
+ }
+
+ /** Seeds the option state the way a real session/new + echo would. */
+ private static void seedModeState(OpenCodeClient client, StubTransport transport) {
+ transport.scriptResult(MODE_ECHO);
+ client.setConfigOption("ses-1", "mode", "build");
+ }
+
+ @Test
+ void v2LiveSwitchSendsModeConfigOption() throws Exception {
+ StubTransport transport = new StubTransport();
+ OpenCodeClient client = client(transport, "2.0.27");
+ seedModeState(client, transport);
+ setActiveSession(client, "ses-1");
+ transport.calls.clear();
+
+ assertTrue(client.canSwitchAgentLive("plan"));
+ client.setCurrentAgentSlug("plan");
+
+ assertEquals(1, transport.countOf("session/set_config_option"));
+ StubTransport.Call call = transport.calls.get(0);
+ assertEquals("ses-1", call.params.get("sessionId").getAsString());
+ assertEquals("mode", call.params.get("configId").getAsString());
+ assertEquals("plan", call.params.get("value").getAsString());
+ }
+
+ @Test
+ void nonModeSlugsAreNotLiveSwitchable() throws Exception {
+ StubTransport transport = new StubTransport();
+ OpenCodeClient client = client(transport, "2.0.27");
+ seedModeState(client, transport);
+ setActiveSession(client, "ses-1");
+ transport.calls.clear();
+
+ // general/explore are Task-tool subagents on v2, not primary modes
+ assertFalse(client.canSwitchAgentLive("general"));
+ assertFalse(client.canSwitchAgentLive("explore"));
+ client.setCurrentAgentSlug("general");
+ assertEquals(0, transport.calls.size(), "no request may be sent for a non-mode slug");
+ }
+
+ @Test
+ void v1KeepsRestartFlow() throws Exception {
+ StubTransport transport = new StubTransport();
+ OpenCodeClient client = client(transport, "1.179.7");
+ seedModeState(client, transport);
+ setActiveSession(client, "ses-1");
+ transport.calls.clear();
+
+ assertFalse(client.canSwitchAgentLive("plan"));
+ client.setCurrentAgentSlug("plan");
+ assertEquals(0, transport.calls.size(), "v1 has no config-option channel");
+ }
+
+ @Test
+ void selectionBeforeSessionIsAppliedOnSessionCreated() throws Exception {
+ StubTransport transport = new StubTransport();
+ OpenCodeClient client = client(transport, "2.0.27");
+ seedModeState(client, transport);
+ transport.calls.clear(); // drop the seeding call
+ // no active session yet
+ client.setCurrentAgentSlug("plan");
+ assertEquals(0, transport.countOf("session/set_config_option"),
+ "no live session yet: the switch must be deferred, not sent");
+
+ client.onSessionCreated("ses-2");
+ assertEquals(1, transport.countOf("session/set_config_option"));
+ StubTransport.Call call = transport.calls.stream()
+ .filter(c -> c.method.equals("session/set_config_option")).findFirst().orElseThrow();
+ assertEquals("ses-2", call.params.get("sessionId").getAsString());
+ assertEquals("plan", call.params.get("value").getAsString());
+ }
+
+ @Test
+ void sessionAlreadyRunningSelectedModeSendsNothing() throws Exception {
+ StubTransport transport = new StubTransport();
+ OpenCodeClient client = client(transport, "2.0.27");
+ seedModeState(client, transport); // mode option current = build
+ transport.calls.clear();
+
+ client.setCurrentAgentSlug("build"); // matches the session's reported mode
+ client.onSessionCreated("ses-3");
+ assertEquals(0, transport.calls.size(),
+ "redundant mode switch must not be sent");
+ }
+}
diff --git a/plugin-core/src/test/java/com/github/catatafishen/agentbridge/client/acp/OpenCodeClientModelEchoTest.java b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/client/acp/OpenCodeClientModelEchoTest.java
new file mode 100644
index 000000000..c4e31b3eb
--- /dev/null
+++ b/plugin-core/src/test/java/com/github/catatafishen/agentbridge/client/acp/OpenCodeClientModelEchoTest.java
@@ -0,0 +1,108 @@
+package com.github.catatafishen.agentbridge.client.acp;
+
+import com.github.catatafishen.agentbridge.acp.protocol.InitializeResponse;
+import com.github.catatafishen.agentbridge.client.AbstractClient;
+import com.github.catatafishen.agentbridge.client.acp.transport.JsonRpcTransport;
+import com.intellij.openapi.project.Project;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.concurrent.CompletableFuture;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Pins that a model change on OpenCode v2 — which the plugin routes through
+ * {@code session/set_config_option(configId=model)} because v2 removed
+ * {@code session/set_model} — applies the {@code configOptions} array echoed
+ * in the response to the local option state. OpenCode v2 never pushes
+ * {@code config_option_update} notifications; the echo is the only state
+ * channel, and it carries cross-effects such as effort resetting to the
+ * model's default after a model switch. Without applying it, the session
+ * options menu keeps showing the pre-switch (stale) selection.
+ */
+class OpenCodeClientModelEchoTest {
+
+ /**
+ * Transport stub: records the last request and hands out a pre-completed
+ * future as the response, so {@code sendSetModel}'s blocking wait and the
+ * echo application both run synchronously.
+ */
+ private static final class StubTransport extends JsonRpcTransport {
+ String lastMethod;
+ JsonObject lastParams;
+ CompletableFuture nextResponse = new CompletableFuture<>();
+
+ @Override
+ public CompletableFuture sendRequest(String method, JsonObject params) {
+ this.lastMethod = method;
+ this.lastParams = params;
+ return nextResponse;
+ }
+ }
+
+ private static void reportVersion(AbstractClient client, String version) throws Exception {
+ Field capabilities = AcpClient.class.getDeclaredField("capabilities");
+ capabilities.setAccessible(true);
+ capabilities.set(client, new InitializeResponse(
+ 1, new InitializeResponse.AgentInfo("OpenCode", null, version), null, null));
+ }
+
+ @Test
+ void modelChangeRoutesThroughConfigOptionAndAppliesEcho() throws Exception {
+ StubTransport transport = new StubTransport();
+ OpenCodeClient client = new OpenCodeClient(mock(Project.class), transport);
+ reportVersion(client, "2.0.27");
+
+ // Echo of session/set_config_option(model): model switched to m-b AND
+ // the effort option reset to the new model's default (cross-effect).
+ String echo = "{\"configOptions\":[" +
+ "{\"id\":\"model\",\"name\":\"Model\",\"category\":\"model\",\"type\":\"select\"," +
+ "\"currentValue\":\"m-b\",\"options\":[{\"value\":\"m-a\",\"name\":\"A\"},{\"value\":\"m-b\",\"name\":\"B\"}]}," +
+ "{\"id\":\"effort\",\"name\":\"Effort\",\"description\":\"Available effort levels for this model\"," +
+ "\"category\":\"thought_level\",\"type\":\"select\",\"currentValue\":\"default\"," +
+ "\"options\":[{\"value\":\"low\",\"name\":\"Low\"},{\"value\":\"medium\",\"name\":\"Medium\"}," +
+ "{\"value\":\"high\",\"name\":\"High\"},{\"value\":\"default\",\"name\":\"Default\"}]}" +
+ "]}";
+ transport.nextResponse.complete(JsonParser.parseString(echo).getAsJsonObject());
+
+ client.setModel("ses-1", "m-b");
+
+ // Wire shape: v2 routes model changes through the config option.
+ assertEquals("session/set_config_option", transport.lastMethod);
+ assertEquals("ses-1", transport.lastParams.get("sessionId").getAsString());
+ assertEquals("model", transport.lastParams.get("configId").getAsString());
+ assertEquals("m-b", transport.lastParams.get("value").getAsString());
+
+ // The echoed state (including the effort reset) reached the option state
+ // the session options menu reads: initialValue is the agent-reported
+ // current value shown when the user has no stored pick.
+ var effort = client.listSessionOptions().stream()
+ .filter(o -> o.key().equals("effort"))
+ .findFirst().orElse(null);
+ assertNotNull(effort, "effort option must be listed after the echo");
+ assertEquals("default", effort.initialValue(),
+ "model switch must reset effort per the echoed cross-effect");
+ assertTrue(effort.values().containsAll(java.util.List.of("low", "medium", "high", "default")));
+ }
+
+ @Test
+ void v1ModelChangeUsesLegacyMethod() throws Exception {
+ StubTransport transport = new StubTransport();
+ OpenCodeClient client = new OpenCodeClient(mock(Project.class), transport);
+ reportVersion(client, "1.179.7");
+
+ transport.nextResponse.complete(JsonParser.parseString("{}").getAsJsonObject());
+
+ client.setModel("ses-1", "m-a");
+
+ assertEquals("session/set_model", transport.lastMethod);
+ assertEquals("m-a", transport.lastParams.get("modelId").getAsString());
+ }
+}