Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.</p>
*/
@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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonElement> 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<NewSessionResponse.SessionConfigOption> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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<AbstractClient.AgentMode> getAvailableAgents() {
List<AbstractClient.AgentMode> agents = new ArrayList<>(builtInAgents());
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
}
})
}
Expand Down Expand Up @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(""));
}
}
Loading
Loading