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()); + } +}