From 65f0489f36e40af9060e9c43d83ac34a8fa8b903 Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Sun, 18 Oct 2026 21:45:48 -0500 Subject: [PATCH 01/12] feat: OpenCode v2 detection, auth, transport, and server lifecycle (P1) - OpenCodeChecker: parse v2 --version output ('opencode v2.0.32'); gate on major >= 2 and the 'serve' subcommand; report v1 installs as IncompatibleVersion so the UI can show 'OpenCode 2.x required' instead of the misleading 'OpenCode not found' (AshotN/OpenCode-Relay#12) - Transport: unwrap v2 {data: ...} envelopes in the response parsers; health/readiness via GET /api/info (ServerInfo); SSE stream at /api/event - Server lifecycle: v2 serve flags only (--port/--hostname/--cors, no mdns); always launch with OPENCODE_PASSWORD (generated or reused); background service auto-detection from XDG_STATE_HOME/opencode/service.json with pid-verified /api/info health check; plugin connects to the detected service port via OpenCodePlugin.connectionPort - Auth: Basic opencode: on all routes; fixed 'opencode' username; v1 OPENCODE_SERVER_* env vars replaced by OPENCODE_PASSWORD - New tool window screen for detected-but-unsupported 1.x installs Verified against a live OpenCode 2.0.32 background service (pid-matched /api/info handshake) and the v2.0.32 OpenAPI spec. --- .../ashotn/opencode/relay/OpenCodeChecker.kt | 140 +++++++++++------- .../OpenCodeExecutableResolutionState.kt | 6 + .../ashotn/opencode/relay/OpenCodePlugin.kt | 98 ++++++++++-- .../opencode/relay/OpenCodeStartupActivity.kt | 12 +- .../ashotn/opencode/relay/ServerManager.kt | 46 +++--- .../relay/actions/McpServersAction.kt | 2 +- .../relay/actions/OpenBrowserAction.kt | 2 +- .../relay/actions/OpenTerminalAction.kt | 2 +- .../relay/api/event/EventStreamClient.kt | 2 +- .../relay/api/health/HealthApiClient.kt | 35 ++++- .../relay/api/health/HealthEndpoints.kt | 2 +- .../opencode/relay/api/health/ServerInfo.kt | 8 + .../api/service/OpenCodeBackgroundService.kt | 130 ++++++++++++++++ .../api/transport/BooleanResponseParser.kt | 6 +- .../opencode/relay/api/transport/Envelope.kt | 19 +++ .../relay/api/transport/JsonResponseParser.kt | 42 +++++- .../api/transport/OpenCodeHttpTransport.kt | 2 +- .../ashotn/opencode/relay/ipc/SseClient.kt | 4 +- .../relay/settings/OpenCodeServerAuth.kt | 62 ++++++-- .../settings/OpenCodeSettingsConfigurable.kt | 66 +++++---- .../relay/terminal/ClassicTuiPanel.kt | 2 +- .../relay/terminal/ReworkedTuiPanel.kt | 2 +- .../toolwindow/IncompatibleVersionPanel.kt | 74 +++++++++ .../relay/toolwindow/InstalledPanel.kt | 11 +- .../toolwindow/JetBrainsMcpWarningPanel.kt | 2 +- .../toolwindow/OpenCodeToolWindowPanel.kt | 1 + .../opencode/relay/OpenCodeCheckerTest.kt | 85 +++++++++++ .../opencode/relay/ServerManagerTest.kt | 7 - .../relay/api/event/EventStreamClientTest.kt | 2 +- .../relay/api/health/HealthApiClientTest.kt | 40 ++++- .../relay/api/session/SessionApiClientTest.kt | 4 +- .../opencode/relay/ipc/SseClientTest.kt | 8 +- .../relay/settings/OpenCodeServerAuthTest.kt | 68 +++++---- .../OpenCodeSettingsConfigurableTest.kt | 13 +- 34 files changed, 789 insertions(+), 216 deletions(-) create mode 100644 src/main/kotlin/com/ashotn/opencode/relay/api/health/ServerInfo.kt create mode 100644 src/main/kotlin/com/ashotn/opencode/relay/api/service/OpenCodeBackgroundService.kt create mode 100644 src/main/kotlin/com/ashotn/opencode/relay/api/transport/Envelope.kt create mode 100644 src/main/kotlin/com/ashotn/opencode/relay/toolwindow/IncompatibleVersionPanel.kt create mode 100644 src/test/kotlin/com/ashotn/opencode/relay/OpenCodeCheckerTest.kt diff --git a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeChecker.kt b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeChecker.kt index b6a6bf8..52a3a3c 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeChecker.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeChecker.kt @@ -8,10 +8,54 @@ import kotlin.concurrent.thread data class OpenCodeInfo(val path: String, val version: String) +/** + * Result of searching for an `opencode` executable. + */ +sealed interface OpenCodeExecutableDetection { + /** A supported OpenCode 2.x executable was found. */ + data class Found(val info: OpenCodeInfo) : OpenCodeExecutableDetection + + /** + * An executable was found but it is an unsupported major version (OpenCode 1.x). + * [info.version] carries the detected version for display. + */ + data class IncompatibleVersion(val info: OpenCodeInfo) : OpenCodeExecutableDetection + + /** No usable executable was found. */ + data object None : OpenCodeExecutableDetection +} + +internal data class OpenCodeVersion(val major: Int, val semVer: String) + +private val openCodeVersionPattern = Regex("""v?(\d+)\.(\d+)\.(\d+)(?:[-+][\w.]+)?""") +private val requiredHelpCommands = listOf("serve") + +/** + * Parses the output of `opencode --version`. + * + * v1 prints a bare semver ("1.4.0"); v2 prints "opencode v2.0.32". + * Returns null when no semver can be extracted. + */ +internal fun parseOpenCodeVersion(output: String): OpenCodeVersion? { + val match = openCodeVersionPattern.find(output) ?: return null + val major = match.groupValues[1].toIntOrNull() ?: return null + return OpenCodeVersion(major = major, semVer = match.value.removePrefix("v")) +} + +/** + * Checks that the `--help` output documents the subcommands the plugin relies on. + * v2 lists bare subcommand names (e.g. `serve`); v1 listed them as "opencode serve". + */ +internal fun hasRequiredHelpCommands(helpOutput: String): Boolean = + requiredHelpCommands.all { command -> + Regex("\\b${Regex.escape(command)}\\b").containsMatchIn(helpOutput.lowercase()) + } + object OpenCodeChecker { private val log = logger() - private val requiredHelpCommands = listOf("opencode serve", "opencode attach") + + private const val MIN_SUPPORTED_MAJOR = 2 private const val COMMAND_TIMEOUT_SECONDS = 10L private const val OUTPUT_JOIN_TIMEOUT_MILLIS = 1_000L @@ -81,18 +125,17 @@ object OpenCodeChecker { } /** - * Returns an [OpenCodeInfo] containing the resolved path and version of the `opencode` - * executable, or null if no valid executable is found. + * Searches for an `opencode` executable and validates it against the OpenCode 2.x + * requirements (version output + `serve` subcommand). * - * If [userProvidedPath] is non-blank, it is validated (file exists, is runnable on this OS, and - * responds to `--version` and `--help`). If it does not pass validation, a warning is - * logged and null is returned immediately (auto-resolve is NOT attempted). - * - * If [userProvidedPath] is blank or null, the auto-resolve strategy is used: - * PATH entries are searched first, followed by common install locations. The first - * candidate that passes all validation gates is returned. + * If [userProvidedPath] is non-blank, it is validated in isolation (auto-resolve is + * NOT attempted when it fails). If it is blank or null, the auto-resolve strategy + * is used: PATH entries are searched first, followed by common install locations. + * During auto-resolve, OpenCode 1.x candidates are skipped so a newer install + * elsewhere can still be found; when only 1.x candidates exist, the last one is + * reported as [OpenCodeExecutableDetection.IncompatibleVersion]. */ - fun findExecutable(userProvidedPath: String? = null): OpenCodeInfo? { + fun findExecutable(userProvidedPath: String? = null): OpenCodeExecutableDetection { val normalizedUserProvidedPath = normalizeUserProvidedPath(userProvidedPath) ?: return autoResolve() val file = File(normalizedUserProvidedPath) @@ -101,14 +144,14 @@ object OpenCodeChecker { "OpenCode executable at user-provided path is invalid: $normalizedUserProvidedPath " + "(exists=${file.exists()}, isFile=${file.isFile}, canExecute=${file.canExecute()}, os=${SystemInfo.OS_NAME})" ) - return null + return OpenCodeExecutableDetection.None } - val absolutePath = file.absolutePath - return validateCandidate(absolutePath) ?: run { - log.warn("OpenCode executable at user-provided path failed validation: $absolutePath") - null + val detection = validateCandidate(file.absolutePath) + if (detection !is OpenCodeExecutableDetection.Found) { + log.warn("OpenCode executable at user-provided path failed validation: ${file.absolutePath} ($detection)") } + return detection } // NVM keeps packages under each installed Node version, so scan versions/node/*/bin/opencode. @@ -127,7 +170,7 @@ object OpenCodeChecker { .toList() } - private fun autoResolve(): OpenCodeInfo? { + private fun autoResolve(): OpenCodeExecutableDetection { val executableNames = if (SystemInfo.isWindows) { listOf("opencode", "opencode.cmd", "opencode-cli.exe") @@ -135,6 +178,8 @@ object OpenCodeChecker { listOf("opencode") } + var incompatible: OpenCodeExecutableDetection.IncompatibleVersion? = null + val pathEnv = OpenCodeProcessEnvironment.pathEnvironmentValue() if (pathEnv.isNullOrBlank()) { log.debug("PATH environment variable is empty; skipping PATH scan") @@ -143,8 +188,11 @@ object OpenCodeChecker { if (dir.isBlank()) continue for (executableName in executableNames) { val candidate = File(dir, executableName) - if (isCandidateFile(candidate)) { - validateCandidate(candidate.absolutePath)?.let { return it } + if (!isCandidateFile(candidate)) continue + when (val detection = validateCandidate(candidate.absolutePath)) { + is OpenCodeExecutableDetection.Found -> return detection + is OpenCodeExecutableDetection.IncompatibleVersion -> incompatible = detection + OpenCodeExecutableDetection.None -> Unit } } } @@ -152,12 +200,15 @@ object OpenCodeChecker { for (path in osSpecificInstallLocations) { val candidate = File(path) - if (isCandidateFile(candidate)) { - validateCandidate(candidate.absolutePath)?.let { return it } + if (!isCandidateFile(candidate)) continue + when (val detection = validateCandidate(candidate.absolutePath)) { + is OpenCodeExecutableDetection.Found -> return detection + is OpenCodeExecutableDetection.IncompatibleVersion -> incompatible = detection + OpenCodeExecutableDetection.None -> Unit } } - return null + return incompatible ?: OpenCodeExecutableDetection.None } private fun normalizeUserProvidedPath(path: String?): String? { @@ -179,14 +230,22 @@ object OpenCodeChecker { } } - private fun validateCandidate(path: String): OpenCodeInfo? { - val version = getVersion(path) ?: return null - val helpOutput = getHelpOutput(path) ?: return null - if (!hasRequiredHelpCommands(helpOutput, path)) return null - return OpenCodeInfo(path, version) + private fun validateCandidate(path: String): OpenCodeExecutableDetection { + val version = getVersion(path) ?: return OpenCodeExecutableDetection.None + val info = OpenCodeInfo(path, version.semVer) + if (version.major < MIN_SUPPORTED_MAJOR) { + log.warn("OpenCode $version.semVer at $path is not supported; OpenCode 2.x is required") + return OpenCodeExecutableDetection.IncompatibleVersion(info) + } + val helpOutput = getHelpOutput(path) ?: return OpenCodeExecutableDetection.None + if (!hasRequiredHelpCommands(helpOutput)) { + log.warn("OpenCode --help output missing required commands for: $path") + return OpenCodeExecutableDetection.None + } + return OpenCodeExecutableDetection.Found(info) } - private fun getVersion(path: String): String? { + private fun getVersion(path: String): OpenCodeVersion? { val result = runCommand(path, "--version") ?: return null if (result.timedOut) { @@ -206,13 +265,10 @@ object OpenCodeChecker { return null } - val startsWithSemVer = Regex("^\\d+\\.\\d+\\.\\d+.*").matches(output) - if (!startsWithSemVer) { - log.warn("OpenCode --version output did not start with semantic version for: $path. Output: '$output'") - return null + return parseOpenCodeVersion(output) ?: run { + log.warn("Could not parse OpenCode version for: $path. Output: '$output'") + null } - - return output } private fun getHelpOutput(path: String): String? { @@ -270,20 +326,4 @@ object OpenCodeChecker { null } } - - private fun hasRequiredHelpCommands(helpOutput: String, path: String): Boolean { - val normalizedOutput = helpOutput.lowercase() - val missingCommands = requiredHelpCommands.filterNot { normalizedOutput.contains(it) } - if (missingCommands.isNotEmpty()) { - log.warn( - "OpenCode --help output missing required commands for: $path. Missing: ${ - missingCommands.joinToString( - ", " - ) - }" - ) - return false - } - return true - } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeExecutableResolutionState.kt b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeExecutableResolutionState.kt index d7f6c19..4096c6e 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeExecutableResolutionState.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeExecutableResolutionState.kt @@ -4,4 +4,10 @@ sealed interface OpenCodeExecutableResolutionState { data object Resolving : OpenCodeExecutableResolutionState data class Resolved(val info: OpenCodeInfo) : OpenCodeExecutableResolutionState data object NotFound : OpenCodeExecutableResolutionState + + /** + * An executable was found, but it is an unsupported major version + * (OpenCode 1.x) — the plugin requires OpenCode 2.x. + */ + data class IncompatibleVersion(val info: OpenCodeInfo) : OpenCodeExecutableResolutionState } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt index 1dead46..2c6eb66 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt @@ -1,7 +1,10 @@ package com.ashotn.opencode.relay +import com.ashotn.opencode.relay.api.service.OpenCodeBackgroundService +import com.ashotn.opencode.relay.api.service.OpenCodeBackgroundServiceDetector import com.ashotn.opencode.relay.core.OpenCodeCoreService import com.ashotn.opencode.relay.settings.OpenCodeSettings +import com.ashotn.opencode.relay.settings.OpenCodeServerAuth import com.ashotn.opencode.relay.tui.OpenCodeTuiClient import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -84,8 +87,11 @@ class OpenCodePlugin(private val project: Project) : Disposable { private fun resolveExecutableState(): OpenCodeExecutableResolutionState { val userPath = OpenCodeSettings.getInstance(project).executablePath.takeIf { it.isNotBlank() } - val info = OpenCodeChecker.findExecutable(userPath) - return info?.let(OpenCodeExecutableResolutionState::Resolved) ?: OpenCodeExecutableResolutionState.NotFound + return when (val detection = OpenCodeChecker.findExecutable(userPath)) { + is OpenCodeExecutableDetection.Found -> OpenCodeExecutableResolutionState.Resolved(detection.info) + is OpenCodeExecutableDetection.IncompatibleVersion -> OpenCodeExecutableResolutionState.IncompatibleVersion(detection.info) + OpenCodeExecutableDetection.None -> OpenCodeExecutableResolutionState.NotFound + } } private fun publishExecutableResolution(state: OpenCodeExecutableResolutionState) { @@ -101,6 +107,56 @@ class OpenCodePlugin(private val project: Project) : Disposable { } } + @Volatile + private var detectedService: OpenCodeBackgroundService? = null + + /** + * The port used for all API connections: the detected background service's + * port when one is running, otherwise the configured server port. + */ + val connectionPort: Int + get() = detectedService?.port ?: OpenCodeSettings.getInstance(project).serverPort + + /** + * Looks for a running OpenCode 2.x background service (started via + * `opencode service start`). When one is found, its password is loaded into + * [OpenCodeServerAuth] and the plugin connects to its port. + * + * Detection runs at most once per plugin lifetime; [onDetected] is invoked + * with the detected service (or null) once the outcome is known. + */ + fun detectBackgroundService(onDetected: ((OpenCodeBackgroundService?) -> Unit)? = null) { + detectedService?.let { service -> + onDetected?.invoke(service) + return + } + + val application = ApplicationManager.getApplication() + if (application.isDispatchThread) { + application.executeOnPooledThread { detectBackgroundServiceInternal(onDetected) } + return + } + detectBackgroundServiceInternal(onDetected) + } + + private fun detectBackgroundServiceInternal(onDetected: ((OpenCodeBackgroundService?) -> Unit)?) { + if (isInactive()) return + + val service = runCatching { OpenCodeBackgroundServiceDetector().detect() } + .onFailure { log.warn("Background service detection failed", it) } + .getOrNull() + if (isInactive()) return + + if (service != null) { + OpenCodeServerAuth.getInstance(project).setPasswordTransiently(service.password) + detectedService = service + log.info("Detected OpenCode background service at ${service.url}") + startPolling(service.port) + checkPort(service.port) + } + onDetected?.invoke(service) + } + @Volatile private var overrideState: ServerState? = null val serverState: ServerState get() = overrideState ?: serverManager.serverState @@ -133,11 +189,20 @@ class OpenCodePlugin(private val project: Project) : Disposable { submitConnectionSyncTask { when (state) { ServerState.READY -> { - val port = OpenCodeSettings.getInstance(project).serverPort - attachRelayConnections(port) + attachRelayConnections(connectionPort) } - ServerState.STOPPED, ServerState.PORT_CONFLICT, ServerState.AUTH_REQUIRED -> { + ServerState.STOPPED -> { + if (!serverManager.ownsProcess) { + // The external target we were connected to is gone; fall + // back to the configured port for the next connection. + detectedService = null + } + detachRelayConnections() + retargetPolling() + } + + ServerState.PORT_CONFLICT, ServerState.AUTH_REQUIRED -> { detachRelayConnections() } @@ -146,6 +211,13 @@ class OpenCodePlugin(private val project: Project) : Disposable { } } + /** Re-points port polling at the current [connectionPort]. */ + private fun retargetPolling() { + val port = connectionPort + startPolling(port) + checkPort(port) + } + fun startPolling(port: Int, intervalSeconds: Long = 10L) = serverManager.startPolling(port, intervalSeconds) fun checkPort(port: Int) = serverManager.checkPort(port) @@ -155,28 +227,30 @@ class OpenCodePlugin(private val project: Project) : Disposable { log.warn("startServer() called but executable resolution is not in the resolved state") return } + // We are about to own the server process, so a previously detected + // background service is no longer the connection target. + detectedService = null serverManager.startServer(port, resolvedExecutableInfo.path) } fun stopServer() = serverManager.stopServer() /** - * Tears down the current core/TUI connections and re-attaches to the given [port]. - * Used when the user changes the port setting while attached to an external process - * (i.e. we don't own the server process). + * Tears down the current core/TUI connections and re-attaches to the current + * [connectionPort]. Used when the user changes the port setting while attached + * to an external process (i.e. we don't own the server process). */ - fun reattach(port: Int) { + fun reattach() { ApplicationManager.getApplication().executeOnPooledThread { if (isInactive()) return@executeOnPooledThread detachRelayConnections() if (isInactive()) return@executeOnPooledThread - startPolling(port) - checkPort(port) + retargetPolling() } } fun resetConnection() { - val port = OpenCodeSettings.getInstance(project).serverPort + val port = connectionPort overrideState = ServerState.RESETTING broadcastState(ServerState.RESETTING) val submitted = submitConnectionSyncTask { diff --git a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeStartupActivity.kt b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeStartupActivity.kt index 8ca55a2..cfc9fee 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeStartupActivity.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeStartupActivity.kt @@ -10,9 +10,15 @@ class OpenCodeStartupActivity : ProjectActivity, DumbAware { override suspend fun execute(project: Project) { val plugin = OpenCodePlugin.getInstance(project) plugin.resolveExecutable { state -> - val settings = OpenCodeSettings.getInstance(project) - if (settings.autoStartOnStartup && state is OpenCodeExecutableResolutionState.Resolved) { - plugin.startServer(settings.serverPort) + if (state !is OpenCodeExecutableResolutionState.Resolved) return@resolveExecutable + + // Connect to a running background service when present; only spawn a + // plugin-owned server when there is nothing to connect to. + plugin.detectBackgroundService { service -> + val settings = OpenCodeSettings.getInstance(project) + if (service == null && settings.autoStartOnStartup) { + plugin.startServer(settings.serverPort) + } } } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt b/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt index 11e375d..d18ccea 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ServerManager.kt @@ -28,10 +28,10 @@ enum class ServerState { /** Initial state - no check has completed yet. */ UNKNOWN, - /** We launched the process and are waiting for /global/health to return 200. */ + /** We launched the process and are waiting for /api/info to return 200. */ STARTING, - /** /global/health returned 200 - server is fully ready. */ + /** /api/info returned 200 - server is fully ready. */ READY, /** Stop was requested and shutdown is in progress. */ @@ -398,9 +398,14 @@ class ServerManager( } } + /** + * Builds the arguments for `opencode serve`. + * + * OpenCode 2.x only supports `--port`, `--hostname`, and `--cors` + * (the v1 `--mdns`/`--mdns-domain` flags were removed). + */ internal fun buildServeArguments(settings: OpenCodeSettings, port: Int): List { val hostname = settings.serverHostname.trim() - val mdnsDomain = settings.serverMdnsDomain.trim() return buildList { add("serve") add("--port") @@ -409,13 +414,6 @@ class ServerManager( add("--hostname") add(hostname) } - if (settings.serverMdnsEnabled) { - add("--mdns") - if (mdnsDomain.isNotEmpty()) { - add("--mdns-domain") - add(mdnsDomain) - } - } settings.serverCorsOrigins .lineSequence() .map(String::trim) @@ -455,8 +453,10 @@ class ServerManager( try { val settings = OpenCodeSettings.getInstance(project) + // OpenCode 2.x servers always require authentication, so a launch + // password is always supplied via OPENCODE_PASSWORD. val environmentVariables = - settings.processEnvironmentVariables(serverAuth.serverLaunchEnvironmentVariables()) + settings.processEnvironmentVariables(serverAuth.launchEnvironmentVariables()) val serveArguments = buildServeArguments(settings, port) val command = if (SystemInfo.isWindows) { @@ -539,24 +539,12 @@ class ServerManager( SwingUtilities.invokeLater { if (isInactive()) return@invokeLater - when (e) { - is OpenCodeServerAuth.MissingServerLaunchAuthCredentialsException -> { - showNotification( - "Failed to start OpenCode server", - "Server authentication is enabled, but the password is missing. Re-enter it in Settings | OpenCode Relay.", - NotificationType.ERROR, - ) - } - - else -> { - val message = e.message ?: e.javaClass.simpleName - showNotification( - "Failed to start OpenCode server", - "Could not launch the OpenCode server process: $message", - NotificationType.ERROR, - ) - } - } + val message = e.message ?: e.javaClass.simpleName + showNotification( + "Failed to start OpenCode server", + "Could not launch the OpenCode server process: $message", + NotificationType.ERROR, + ) applyState(ServerState.STOPPED) } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersAction.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersAction.kt index 773083b..a295445 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersAction.kt @@ -25,7 +25,7 @@ class McpServersAction(private val project: Project) : AnAction(), DumbAware { } override fun actionPerformed(e: AnActionEvent) { - val port = OpenCodeSettings.getInstance(project).serverPort + val port = OpenCodePlugin.getInstance(project).connectionPort val content = McpServersPopupPanel(project, port) val popup = JBPopupFactory.getInstance() diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenBrowserAction.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenBrowserAction.kt index 82fcc3a..ef77a77 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenBrowserAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenBrowserAction.kt @@ -25,7 +25,7 @@ class OpenBrowserAction(private val project: Project) : AnAction(), DumbAware { } override fun actionPerformed(e: AnActionEvent) { - val port = OpenCodeSettings.getInstance(project).serverPort + val port = OpenCodePlugin.getInstance(project).connectionPort BrowserUtil.browse(URI(serverUrl(port))) } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt index 5c99a19..d14f3b9 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt @@ -37,7 +37,7 @@ class OpenTerminalAction(private val project: Project) : AnAction(), DumbAware { if (plugin.serverState != ServerState.READY) return val executablePath = plugin.openCodeInfo?.path ?: return - val url = serverUrl(settings.serverPort) + val url = serverUrl(OpenCodePlugin.getInstance(project).connectionPort) launchExternalTerminal(executablePath, url) } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClient.kt index 4dd4ec3..2b94ff6 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClient.kt @@ -42,7 +42,7 @@ class EventStreamClient( } private fun subscribe(port: Int): EventSubscription { - val connection = URI("http://localhost:$port/global/event").toURL().openConnection() as HttpURLConnection + val connection = URI("http://localhost:$port/api/event").toURL().openConnection() as HttpURLConnection connection.requestMethod = "GET" connection.setRequestProperty("Accept", "text/event-stream") connection.setRequestProperty("Cache-Control", "no-cache") diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/health/HealthApiClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/health/HealthApiClient.kt index d70e8cb..49099b0 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/health/HealthApiClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/health/HealthApiClient.kt @@ -1,7 +1,14 @@ package com.ashotn.opencode.relay.api.health +import com.ashotn.opencode.relay.api.transport.ApiError import com.ashotn.opencode.relay.api.transport.ApiResult import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport +import com.ashotn.opencode.relay.api.transport.map +import com.ashotn.opencode.relay.api.transport.parseJsonObjectResponse +import com.ashotn.opencode.relay.api.transport.withParseContext +import com.ashotn.opencode.relay.util.getIntOrNull +import com.ashotn.opencode.relay.util.getStringOrNull +import com.google.gson.JsonObject class HealthApiClient( private val transport: OpenCodeHttpTransport = OpenCodeHttpTransport( @@ -9,11 +16,31 @@ class HealthApiClient( defaultReadTimeoutMs = 1_000, ), ) { - fun isHealthy(port: Int): ApiResult { + /** + * Fetches `GET /api/info` (the v2 health/readiness endpoint). + * A 2xx response means the server is up; the payload is parsed into [ServerInfo]. + */ + fun fetchInfo(port: Int): ApiResult { val endpoint = HealthEndpoints.check() - return when (val result = transport.get(port = port, path = endpoint.path)) { - is ApiResult.Failure -> result - is ApiResult.Success -> ApiResult.Success(true) + val rawResponse = transport.get(port = port, path = endpoint.path) + return when (val parsed = transport.parseJsonObjectResponse(rawResponse).withParseContext(endpoint)) { + is ApiResult.Failure -> parsed + is ApiResult.Success -> parseInfo(parsed.value) } } + + fun isHealthy(port: Int): ApiResult = fetchInfo(port).map { true } + + private fun parseInfo(info: JsonObject): ApiResult { + val version = info.getStringOrNull("version") + ?: return ApiResult.Failure(ApiError.ParseError("Missing 'version' in server info")) + val pid = info.getIntOrNull("pid") + ?: return ApiResult.Failure(ApiError.ParseError("Missing 'pid' in server info")) + val urls = info.get("urls") + ?.takeIf { it.isJsonArray } + ?.asJsonArray + ?.mapNotNull { item -> item.takeIf { it.isJsonPrimitive }?.asString } + .orEmpty() + return ApiResult.Success(ServerInfo(version = version, pid = pid, urls = urls)) + } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/health/HealthEndpoints.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/health/HealthEndpoints.kt index 01d7e48..558d688 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/health/HealthEndpoints.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/health/HealthEndpoints.kt @@ -4,5 +4,5 @@ import com.ashotn.opencode.relay.api.transport.ApiEndpoint import com.ashotn.opencode.relay.api.transport.HttpMethod internal object HealthEndpoints { - fun check(): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/global/health") + fun check(): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/api/info") } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/health/ServerInfo.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/health/ServerInfo.kt new file mode 100644 index 0000000..3345d51 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/health/ServerInfo.kt @@ -0,0 +1,8 @@ +package com.ashotn.opencode.relay.api.health + +/** Response of `GET /api/info` — the v2 readiness/health endpoint. */ +data class ServerInfo( + val version: String, + val pid: Int, + val urls: List, +) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/service/OpenCodeBackgroundService.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/service/OpenCodeBackgroundService.kt new file mode 100644 index 0000000..08d6cb5 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/service/OpenCodeBackgroundService.kt @@ -0,0 +1,130 @@ +package com.ashotn.opencode.relay.api.service + +import com.ashotn.opencode.relay.api.health.HealthApiClient +import com.ashotn.opencode.relay.api.transport.ApiResult +import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport +import com.ashotn.opencode.relay.settings.OpenCodeSettings +import com.ashotn.opencode.relay.settings.OpenCodeServerAuth +import com.ashotn.opencode.relay.util.getStringOrNull +import com.ashotn.opencode.relay.util.getIntOrNull +import com.google.gson.JsonParser +import com.intellij.openapi.diagnostic.logger +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.charset.StandardCharsets + +/** + * A running OpenCode 2.x background service (started via `opencode service start`). + * + * [port] is parsed from [url] and is the port the plugin should connect to. + */ +data class OpenCodeBackgroundService( + val url: String, + val port: Int, + val password: String, +) + +/** + * Detects a running OpenCode 2.x background service. + * + * The service registration lives in the OpenCode state directory + * (`$XDG_STATE_HOME/opencode/service.json`, falling back to + * `~/.local/state/opencode/service.json` — the same layout v2 uses on all platforms). + * A registration is only accepted when an authenticated `GET /api/info` against it + * succeeds and the reported pid matches the registered pid (v2's own discovery + * applies the same stale-file guard). + */ +class OpenCodeBackgroundServiceDetector( + stateFileProvider: () -> Path = { defaultServiceStateFile() }, +) { + private val stateFile = stateFileProvider() + private val log = logger() + + fun detect(): OpenCodeBackgroundService? { + val registration = readRegistration() ?: return null + val port = portOfUrl(registration.url) ?: run { + log.debug("Background service registration has no usable port: ${registration.url}") + return null + } + + val credentials = OpenCodeServerAuth.BasicAuthCredentials( + username = OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME, + password = registration.password, + ) + val probeTransport = OpenCodeHttpTransport( + defaultConnectTimeoutMs = 1_000, + defaultReadTimeoutMs = 1_000, + authorizationHeaderProvider = { credentials.authorizationHeader() }, + ) + val info = HealthApiClient(transport = probeTransport).fetchInfo(port) + return when (info) { + is ApiResult.Failure -> { + log.debug("Background service at ${registration.url} failed health check: ${info.error}") + null + } + + is ApiResult.Success -> { + if (info.value.pid != registration.pid) { + log.debug( + "Background service registration is stale (registered pid ${registration.pid}, " + + "server reports ${info.value.pid})", + ) + return null + } + OpenCodeBackgroundService( + url = registration.url, + port = port, + password = registration.password, + ) + } + } + } + + private data class Registration( + val url: String, + val pid: Int, + val password: String, + ) + + private fun readRegistration(): Registration? { + if (!Files.isRegularFile(stateFile) || !Files.isReadable(stateFile)) return null + + val root = try { + JsonParser.parseString(Files.readString(stateFile, StandardCharsets.UTF_8)) + } catch (e: Exception) { + log.debug("Failed to parse background service state file: $stateFile", e) + return null + } + if (!root.isJsonObject) return null + + val state = root.asJsonObject + val url = state.getStringOrNull("url") + val pid = state.getIntOrNull("pid") + val password = state.getStringOrNull("password") + if (url.isNullOrBlank() || pid == null || password.isNullOrBlank()) { + log.debug("Background service state file is missing url/pid/password: $stateFile") + return null + } + return Registration(url = url, pid = pid, password = password) + } + + private fun portOfUrl(url: String): Int? = try { + URI(url).port.takeIf { it in 1..65535 } + } catch (e: IllegalArgumentException) { + null + } + + companion object { + /** + * The service state file location used by OpenCode 2.x on all platforms: + * `$XDG_STATE_HOME/opencode/service.json` or `~/.local/state/opencode/service.json`. + */ + fun defaultServiceStateFile(): Path { + val stateHome = System.getenv("XDG_STATE_HOME")?.takeIf { it.isNotBlank() }?.let(Paths::get) + ?: Paths.get(System.getProperty("user.home"), ".local", "state") + return stateHome.resolve("opencode").resolve("service.json") + } + } +} diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/transport/BooleanResponseParser.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/BooleanResponseParser.kt index a4f85b4..3352b1e 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/transport/BooleanResponseParser.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/BooleanResponseParser.kt @@ -18,9 +18,9 @@ fun OpenCodeHttpTransport.parseBooleanResponse( when (val elementResult = parseJsonElement(body)) { is ApiResult.Failure -> elementResult is ApiResult.Success -> { - val element = elementResult.value - if (element.isJsonPrimitive && element.asJsonPrimitive.isBoolean) { - ApiResult.Success(element.asBoolean) + val payload = elementResult.value.unwrapData() + if (payload.isJsonPrimitive && payload.asJsonPrimitive.isBoolean) { + ApiResult.Success(payload.asBoolean) } else { ApiResult.Failure(ApiError.ParseError(errorMessage)) } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/transport/Envelope.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/Envelope.kt new file mode 100644 index 0000000..3cc6ef1 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/Envelope.kt @@ -0,0 +1,19 @@ +package com.ashotn.opencode.relay.api.transport + +import com.google.gson.JsonElement + +/** + * Unwraps the OpenCode v2 response envelope. + * + * Most v2 routes wrap their payload in a `{"data": ...}` object, but a few + * (e.g. `GET /api/info`) return the payload directly. This returns the `data` + * member when present and the receiver itself otherwise. + * + * Composite envelopes that carry sibling fields alongside `data` + * (e.g. `GET /api/session` returns `{data, cursor}`) should not be unwrapped + * blindly — read the siblings explicitly via [OpenCodeHttpTransport.parseJsonEnvelope]. + */ +fun JsonElement.unwrapData(): JsonElement { + if (!isJsonObject) return this + return asJsonObject.get("data") ?: this +} diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/transport/JsonResponseParser.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/JsonResponseParser.kt index fbddcf4..ceecbc2 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/transport/JsonResponseParser.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/JsonResponseParser.kt @@ -3,13 +3,47 @@ package com.ashotn.opencode.relay.api.transport import com.google.gson.JsonArray import com.google.gson.JsonObject +/** + * Parses the JSON object payload of a response. + * + * Unwraps the v2 `{"data": ...}` envelope when present (see [unwrapData]). + * Routes returning composite envelopes with sibling fields + * (e.g. `GET /api/session` -> `{data, cursor}`) should use [parseJsonEnvelope]. + */ fun OpenCodeHttpTransport.parseJsonObjectResponse( response: ApiResult, +): ApiResult = when (response) { + is ApiResult.Failure -> response + is ApiResult.Success -> when (val parsed = parseJsonObject(response.value)) { + is ApiResult.Failure -> parsed + is ApiResult.Success -> { + val payload = parsed.value.unwrapData() + if (payload.isJsonObject) { + ApiResult.Success(payload.asJsonObject) + } else { + ApiResult.Failure(ApiError.ParseError("Expected JSON object payload")) + } + } + } +} + +/** + * Parses the raw response envelope without unwrapping `data`. + * Intended for composite v2 envelopes such as `{data, cursor}` or `{location, data}`. + */ +fun OpenCodeHttpTransport.parseJsonEnvelope( + response: ApiResult, ): ApiResult = when (response) { is ApiResult.Failure -> response is ApiResult.Success -> parseJsonObject(response.value) } +/** + * Parses the JSON array payload of a response. + * + * In v2, arrays arrive wrapped as `{"data": [...]}`. A bare array at the root + * is accepted defensively. + */ fun OpenCodeHttpTransport.parseJsonArrayResponse( response: ApiResult, ): ApiResult = when (response) { @@ -17,11 +51,11 @@ fun OpenCodeHttpTransport.parseJsonArrayResponse( is ApiResult.Success -> when (val rootResult = parseJsonElement(response.value)) { is ApiResult.Failure -> rootResult is ApiResult.Success -> { - val root = rootResult.value - if (root.isJsonArray) { - ApiResult.Success(root.asJsonArray) + val payload = rootResult.value.unwrapData() + if (payload.isJsonArray) { + ApiResult.Success(payload.asJsonArray) } else { - ApiResult.Failure(ApiError.ParseError("Expected JSON array")) + ApiResult.Failure(ApiError.ParseError("Expected JSON array payload")) } } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransport.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransport.kt index aa3c8b4..49f3d0b 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransport.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransport.kt @@ -18,7 +18,7 @@ class OpenCodeHttpTransport( private val httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofMillis(defaultConnectTimeoutMs.toLong())) - // OpenCode 1.4 closes cleartext HTTP/2 upgrade attempts before sending headers. + // OpenCode 2.x closes cleartext HTTP/2 upgrade attempts before sending headers. .version(HttpClient.Version.HTTP_1_1) .build() diff --git a/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt index a0f3813..41941f8 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt @@ -16,7 +16,7 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean /** - * Connects to the OpenCode server's durable SSE event stream (`GET /global/event`) and + * Connects to the OpenCode server's durable SSE event stream (`GET /api/event`) and * dispatches parsed [OpenCodeEvent]s to a listener on a background thread. * * - Automatically reconnects on disconnect (with bounded backoff). @@ -103,7 +103,7 @@ class SseClient( } private fun connect() { - log.info("SseClient: connecting to /global/event on port $port") + log.info("SseClient: connecting to /api/event on port $port") val dataBuffer = StringBuilder() eventStreamClient.consume(port) { line -> if (Thread.currentThread().isInterrupted) return@consume diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt index 891eedb..6960f53 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt @@ -14,6 +14,7 @@ import java.util.Base64 import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.RejectedExecutionException +import java.security.SecureRandom import java.util.concurrent.atomic.AtomicLong import javax.swing.SwingUtilities @@ -33,10 +34,6 @@ class OpenCodeServerAuth(private val project: Project) : Disposable { loadPasswordInBackground() } - class MissingServerLaunchAuthCredentialsException : IllegalStateException( - "Server launch authentication is enabled but credentials are incomplete", - ) - data class BasicAuthCredentials( val username: String, val password: String, @@ -46,9 +43,13 @@ class OpenCodeServerAuth(private val project: Project) : Disposable { return "Basic $token" } + /** + * Environment variables that authenticate a process against an OpenCode 2.x server. + * v2 reads the server password from `OPENCODE_PASSWORD` (the v1 + * `OPENCODE_SERVER_PASSWORD` variable is no longer recognized). + */ fun environmentVariables(): Map = mapOf( - "OPENCODE_SERVER_USERNAME" to username, - "OPENCODE_SERVER_PASSWORD" to password, + "OPENCODE_PASSWORD" to password, ) } @@ -89,6 +90,17 @@ class OpenCodeServerAuth(private val project: Project) : Disposable { } } + /** + * Updates the in-memory password cache without persisting to the IDE credential + * store. Used for passwords sourced from the OpenCode background service state + * file, which is re-read on every IDE startup. + */ + fun setPasswordTransiently(password: String) { + passwordRevision.incrementAndGet() + cachedPassword = password + passwordLoaded.countDown() + } + fun setPasswordAndWait(password: String): Boolean { val revision = passwordRevision.incrementAndGet() passwordLoaded.countDown() @@ -130,10 +142,11 @@ class OpenCodeServerAuth(private val project: Project) : Disposable { val password = password() if (password.isEmpty()) return null - val username = OpenCodeSettings.getInstance(project).serverAuthUsername - .trim() - .ifEmpty { OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME } - return BasicAuthCredentials(username = username, password = password) + // OpenCode 2.x always authenticates as the fixed "opencode" user. + return BasicAuthCredentials( + username = OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME, + password = password, + ) } fun connectionAuthorizationHeader(): String? = connectionCredentials()?.authorizationHeader() @@ -141,11 +154,30 @@ class OpenCodeServerAuth(private val project: Project) : Disposable { fun connectionEnvironmentVariables(): Map = connectionCredentials()?.environmentVariables().orEmpty() - fun serverLaunchEnvironmentVariables(): Map { - val settings = OpenCodeSettings.getInstance(project) - if (!settings.protectPluginLaunchedServerWithAuth) return emptyMap() - return connectionCredentials()?.environmentVariables() - ?: throw MissingServerLaunchAuthCredentialsException() + /** + * Environment variables for a server process launched by the plugin. + * + * OpenCode 2.x servers always require authentication, so a launch password is + * guaranteed: the stored password is reused, or a fresh one is generated. + */ + fun launchEnvironmentVariables(): Map = + mapOf("OPENCODE_PASSWORD" to ensureLaunchPassword()) + + /** + * Returns the password the next plugin-launched server should use, + * generating and storing one when none is available. + */ + fun ensureLaunchPassword(): String { + val stored = password() + if (stored.isNotEmpty()) return stored + val generated = generatePassword() + setPassword(generated) + return generated + } + + private fun generatePassword(): String { + val bytes = ByteArray(32).also { SecureRandom().nextBytes(it) } + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) } private fun attributes(): CredentialAttributes = diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt index 68b4ec3..0091daf 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt @@ -1,8 +1,8 @@ package com.ashotn.opencode.relay.settings import com.ashotn.opencode.relay.OpenCodeChecker +import com.ashotn.opencode.relay.OpenCodeExecutableDetection import com.ashotn.opencode.relay.OpenCodeExecutableResolutionState -import com.ashotn.opencode.relay.OpenCodeInfo import com.ashotn.opencode.relay.OpenCodePlugin import com.ashotn.opencode.relay.core.EditorDiffRenderer import com.ashotn.opencode.relay.settings.OpenCodeSettings.TerminalEngine @@ -56,7 +56,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : internal lateinit var serverMdnsDomainField: JBTextField internal lateinit var autoStartOnStartupCheckBox: JCheckBox - internal var executableResolver: (String?) -> OpenCodeInfo? = { path -> OpenCodeChecker.findExecutable(path) } + internal var executableResolver: (String?) -> OpenCodeExecutableDetection = { path -> OpenCodeChecker.findExecutable(path) } override fun createPanel(): DialogPanel { loadPendingFromPersisted() @@ -99,7 +99,10 @@ class OpenCodeSettingsConfigurable(private val project: Project) : } ApplicationManager.getApplication().executeOnPooledThread { - val autoResolvedPath = OpenCodeChecker.findExecutable()?.path ?: return@executeOnPooledThread + val autoResolved = OpenCodeChecker.findExecutable() + val autoResolvedPath = + (autoResolved as? OpenCodeExecutableDetection.Found)?.info?.path + ?: return@executeOnPooledThread ApplicationManager.getApplication().invokeLater { if (project.isDisposed) return@invokeLater if (executablePathField.text.isBlank()) { @@ -181,7 +184,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : group("Environment") { row("Variables:") { cell(serverEnvironmentVariablesEditor.panel) - .comment("Additional environment variables passed to OpenCode processes launched by the plugin. `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` are managed above.") + .comment("Additional environment variables passed to OpenCode processes launched by the plugin. `OPENCODE_PASSWORD` is managed above.") .align(AlignX.FILL) .resizableColumn() } @@ -323,12 +326,9 @@ class OpenCodeSettingsConfigurable(private val project: Project) : val settingsChanged = newSettings != oldSettings val newPassword = currentServerAuthPassword() val passwordChanged = newPassword != oldPassword - val connectionAuthChanged = oldSettings.serverAuthUsername != newSettings.serverAuthUsername || passwordChanged - val oldLaunchAuthEnabled = oldSettings.protectPluginLaunchedServerWithAuth && oldPassword.isNotEmpty() - val newLaunchAuthEnabled = newSettings.protectPluginLaunchedServerWithAuth && newPassword.isNotEmpty() - val launchAuthChanged = - oldLaunchAuthEnabled != newLaunchAuthEnabled || - (newLaunchAuthEnabled && connectionAuthChanged) + // OpenCode 2.x always authenticates; any credential change affects the + // connection to a server we do not own. + val connectionAuthChanged = passwordChanged val newPort = newSettings.serverPort val newPath = newSettings.executablePath val portChanged = newPort != oldSettings.serverPort @@ -338,22 +338,36 @@ class OpenCodeSettingsConfigurable(private val project: Project) : if (!settingsChanged && !passwordChanged && !shouldUpdateExecutableResolution) return val mustConfirmStop = - plugin.isRunning && plugin.ownsProcess && (portChanged || pathChanged || launchAuthChanged) + plugin.isRunning && plugin.ownsProcess && (portChanged || pathChanged || connectionAuthChanged) val mustReattach = plugin.isRunning && !plugin.ownsProcess && portChanged val mustResetConnection = plugin.isRunning && !plugin.ownsProcess && !portChanged && connectionAuthChanged var resolvedState = oldResolutionState if (shouldUpdateExecutableResolution) { val userProvidedPath = newPath.takeIf { it.isNotBlank() } - val detectedExecutableInfo = detectExecutableInfo(userProvidedPath) - if (userProvidedPath != null && detectedExecutableInfo == null) { - throw ConfigurationException( - "Could not find a valid OpenCode executable. Check the path and try again.", - ) + val detection = detectExecutable(userProvidedPath) + when (detection) { + is OpenCodeExecutableDetection.Found -> resolvedState = OpenCodeExecutableResolutionState.Resolved(detection.info) + + is OpenCodeExecutableDetection.IncompatibleVersion -> { + if (userProvidedPath != null) { + throw ConfigurationException( + "OpenCode 2.x is required, but $userProvidedPath is OpenCode ${detection.info.version}. " + + "Upgrade it or point to an OpenCode 2.x executable.", + ) + } + resolvedState = OpenCodeExecutableResolutionState.IncompatibleVersion(detection.info) + } + + OpenCodeExecutableDetection.None -> { + if (userProvidedPath != null) { + throw ConfigurationException( + "Could not find a valid OpenCode executable. Check the path and try again.", + ) + } + resolvedState = OpenCodeExecutableResolutionState.NotFound + } } - resolvedState = - detectedExecutableInfo?.let(OpenCodeExecutableResolutionState::Resolved) - ?: OpenCodeExecutableResolutionState.NotFound } if (mustConfirmStop && !confirmStopServerRestart()) { @@ -371,12 +385,12 @@ class OpenCodeSettingsConfigurable(private val project: Project) : when { mustConfirmStop -> plugin.stopServer() - mustReattach -> plugin.reattach(newPort) + mustReattach -> plugin.reattach() mustResetConnection -> plugin.resetConnection() } if (connectionAuthChanged && !plugin.ownsProcess && !mustReattach && !mustResetConnection) { - plugin.checkPort(newPort) + plugin.checkPort(plugin.connectionPort) } if (shouldUpdateExecutableResolution && resolvedState != oldResolutionState) { @@ -391,17 +405,17 @@ class OpenCodeSettingsConfigurable(private val project: Project) : EditorDiffRenderer.getInstance(project).onSettingsChanged() } - private fun detectExecutableInfo(userProvidedPath: String?): OpenCodeInfo? { - val detectedInfoRef = AtomicReference() + private fun detectExecutable(userProvidedPath: String?): OpenCodeExecutableDetection { + val detectionRef = AtomicReference() ProgressManager.getInstance().runProcessWithProgressSynchronously( { - detectedInfoRef.set(executableResolver(userProvidedPath)) + detectionRef.set(executableResolver(userProvidedPath)) }, "Resolving OpenCode...", false, project, ) - return detectedInfoRef.get() + return detectionRef.get() ?: OpenCodeExecutableDetection.None } private fun confirmStopServerRestart(): Boolean = @@ -520,7 +534,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : private fun validateReservedServerAuthEnvironmentVariables( environmentVariables: List, ) { - val reservedNames = setOf("OPENCODE_SERVER_USERNAME", "OPENCODE_SERVER_PASSWORD") + val reservedNames = setOf("OPENCODE_PASSWORD") val reservedName = environmentVariables .firstOrNull { it.name.uppercase() in reservedNames } ?.name diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt index 6f5fed9..acae320 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt @@ -94,7 +94,7 @@ class ClassicTuiPanel( listOf( executablePath, "attach", - serverUrl(OpenCodeSettings.getInstance(project).serverPort), + serverUrl(OpenCodePlugin.getInstance(project).connectionPort), ), environmentVariables, ) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt index 6afc8af..83e5d1f 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt @@ -86,7 +86,7 @@ class ReworkedTuiPanel( listOf( executablePath, "attach", - serverUrl(OpenCodeSettings.getInstance(project).serverPort), + serverUrl(OpenCodePlugin.getInstance(project).connectionPort), ), environmentVariables, ) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/IncompatibleVersionPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/IncompatibleVersionPanel.kt new file mode 100644 index 0000000..25ed9a0 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/IncompatibleVersionPanel.kt @@ -0,0 +1,74 @@ +package com.ashotn.opencode.relay.toolwindow + +import com.ashotn.opencode.relay.OpenCodeInfo +import com.intellij.ide.BrowserUtil +import com.intellij.icons.AllIcons +import com.intellij.ui.HyperlinkLabel +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.Font +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.JPanel +import javax.swing.SwingConstants + +/** + * Shown when an OpenCode executable is installed but it is an unsupported 1.x version. + */ +class IncompatibleVersionPanel(openCodeInfo: OpenCodeInfo) : JPanel(GridBagLayout()) { + + companion object { + private const val MIGRATION_URL = "https://opencode.ai/v2/docs/migrate-v1" + } + + init { + val gbc = GridBagConstraints().apply { + gridx = 0 + fill = GridBagConstraints.HORIZONTAL + anchor = GridBagConstraints.CENTER + insets = JBUI.insets(JBUI.scale(4), JBUI.scale(16), JBUI.scale(4), JBUI.scale(16)) + } + + val base = UIUtil.getLabelFont() + + val emojiLabel = JBLabel(AllIcons.General.Warning, SwingConstants.CENTER).apply { + alignmentX = CENTER_ALIGNMENT + } + + val titleLabel = JBLabel("OpenCode 2.x required", SwingConstants.CENTER).apply { + font = base.deriveFont(Font.BOLD, base.size * 2f) + alignmentX = CENTER_ALIGNMENT + } + + val subtitleLabel = JBLabel( + "
Detected OpenCode ${escapeHtml(openCodeInfo.version)} at
" + + "${escapeHtml(openCodeInfo.path)}
" + + "Upgrade to OpenCode 2.0 or newer to continue.
", + SwingConstants.CENTER + ).apply { + font = base.deriveFont(base.size * 1.1f) + foreground = JBUI.CurrentTheme.Label.disabledForeground() + alignmentX = CENTER_ALIGNMENT + } + + val linkLabel = HyperlinkLabel("Upgrade OpenCode").apply { + font = base.deriveFont(Font.BOLD, base.size * 1.1f) + alignmentX = CENTER_ALIGNMENT + addHyperlinkListener { BrowserUtil.browse(MIGRATION_URL) } + } + + gbc.gridy = 0; add(emojiLabel, gbc) + gbc.gridy = 1; gbc.insets = JBUI.insets(JBUI.scale(8), JBUI.scale(16), JBUI.scale(4), JBUI.scale(16)) + add(titleLabel, gbc) + gbc.gridy = 2; gbc.insets = JBUI.insets(JBUI.scale(4), JBUI.scale(16), JBUI.scale(4), JBUI.scale(16)) + add(subtitleLabel, gbc) + gbc.gridy = 3; gbc.insets = JBUI.insets(JBUI.scale(16), JBUI.scale(16), JBUI.scale(4), JBUI.scale(16)) + add(linkLabel, gbc) + } + + private fun escapeHtml(value: String): String = value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") +} diff --git a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/InstalledPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/InstalledPanel.kt index 29d9b19..b5a7628 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/InstalledPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/InstalledPanel.kt @@ -116,8 +116,9 @@ class InstalledPanel( plugin.addListener(this) onStateChanged(plugin.serverState) - plugin.checkPort(settings.serverPort) - plugin.startPolling(settings.serverPort) + plugin.detectBackgroundService() + plugin.checkPort(plugin.connectionPort) + plugin.startPolling(plugin.connectionPort) project.messageBus.connect(this).subscribe( OpenCodeInfoChangedListener.TOPIC, @@ -133,8 +134,8 @@ class InstalledPanel( OpenCodeSettingsChangedListener.TOPIC, OpenCodeSettingsChangedListener { oldSettings, newSettings -> if (oldSettings.serverPort != newSettings.serverPort) { - plugin.startPolling(newSettings.serverPort) - plugin.checkPort(newSettings.serverPort) + plugin.checkPort(plugin.connectionPort) + plugin.startPolling(plugin.connectionPort) } onStateChanged(plugin.serverState) } @@ -151,7 +152,7 @@ class InstalledPanel( } override fun onStateChanged(state: ServerState) { - val port = settings.serverPort + val port = plugin.connectionPort when (state) { ServerState.UNKNOWN -> { portStatusLabel.text = "Checking…" diff --git a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/JetBrainsMcpWarningPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/JetBrainsMcpWarningPanel.kt index 59fcb84..94e4580 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/JetBrainsMcpWarningPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/JetBrainsMcpWarningPanel.kt @@ -158,7 +158,7 @@ class JetBrainsMcpWarningPanel( if (plugin.serverState != ServerState.READY) return null - val openCodeMcpEntries = when (val result = mcpService.listMcpEntries(settings.serverPort)) { + val openCodeMcpEntries = when (val result = mcpService.listMcpEntries(OpenCodePlugin.getInstance(project).connectionPort)) { is ApiResult.Success -> result.value is ApiResult.Failure -> return null } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt index a6113c6..b1c4728 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt @@ -254,6 +254,7 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou val screen = when (val state = plugin.executableResolutionState) { OpenCodeExecutableResolutionState.Resolving -> ResolvingExecutablePanel() OpenCodeExecutableResolutionState.NotFound -> NotInstalledPanel() + is OpenCodeExecutableResolutionState.IncompatibleVersion -> IncompatibleVersionPanel(state.info) is OpenCodeExecutableResolutionState.Resolved -> InstalledPanel(project, slotDisposable, state.info) } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/OpenCodeCheckerTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/OpenCodeCheckerTest.kt new file mode 100644 index 0000000..806f581 --- /dev/null +++ b/src/test/kotlin/com/ashotn/opencode/relay/OpenCodeCheckerTest.kt @@ -0,0 +1,85 @@ +package com.ashotn.opencode.relay + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OpenCodeCheckerTest { + + @Test + fun `parses v2 version output with opencode prefix`() { + val version = parseOpenCodeVersion("opencode v2.0.32") + + assertEquals(2, version?.major) + assertEquals("2.0.32", version?.semVer) + } + + @Test + fun `parses bare semver version output`() { + val version = parseOpenCodeVersion("2.1.0") + + assertEquals(2, version?.major) + assertEquals("2.1.0", version?.semVer) + } + + @Test + fun `parses v1 version output`() { + val version = parseOpenCodeVersion("1.4.0") + + assertEquals(1, version?.major) + assertEquals("1.4.0", version?.semVer) + } + + @Test + fun `parses version with pre-release suffix`() { + val version = parseOpenCodeVersion("opencode v2.1.0-beta.1") + + assertEquals(2, version?.major) + assertEquals("2.1.0-beta.1", version?.semVer) + } + + @Test + fun `returns null for unparsable version output`() { + assertNull(parseOpenCodeVersion("opencode")) + assertNull(parseOpenCodeVersion("unknown")) + assertNull(parseOpenCodeVersion("")) + } + + @Test + fun `help output with bare serve subcommand passes the gate`() { + val help = """ + SUBCOMMANDS + run Run OpenCode with a message + session Manage sessions + service Manage the background server + serve Start the v2 API and web server + """.trimIndent() + + assertTrue(hasRequiredHelpCommands(help)) + } + + @Test + fun `help output with v1 style command listing passes the gate`() { + val help = """ + COMMANDS + opencode serve Start the server + opencode attach Attach to a running server + """.trimIndent() + + assertTrue(hasRequiredHelpCommands(help)) + } + + @Test + fun `help output without serve fails the gate`() { + val help = """ + SUBCOMMANDS + run Run OpenCode with a message + service Manage the background server + """.trimIndent() + + // "service" contains "serve" but not as a standalone word. + assertFalse(hasRequiredHelpCommands(help)) + } +} diff --git a/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt index 88ad1b4..764f212 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/ServerManagerTest.kt @@ -14,8 +14,6 @@ class ServerManagerTest : BasePlatformTestCase() { fun testBuildServeArgumentsIncludesHostnameAndCorsOrigins() { val settings = OpenCodeSettings.getInstance(project) settings.serverHostname = "0.0.0.0" - settings.serverMdnsEnabled = true - settings.serverMdnsDomain = "relay.local" settings.serverCorsOrigins = "http://localhost:5173\nhttps://app.example.com" val manager = ServerManager(project) { } @@ -27,9 +25,6 @@ class ServerManagerTest : BasePlatformTestCase() { "4096", "--hostname", "0.0.0.0", - "--mdns", - "--mdns-domain", - "relay.local", "--cors", "http://localhost:5173", "--cors", @@ -45,8 +40,6 @@ class ServerManagerTest : BasePlatformTestCase() { fun testBuildServeArgumentsOmitsBlankHostnameAndCorsOrigins() { val settings = OpenCodeSettings.getInstance(project) settings.serverHostname = " " - settings.serverMdnsEnabled = false - settings.serverMdnsDomain = " " settings.serverCorsOrigins = "\n \n" val manager = ServerManager(project) { } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClientTest.kt index 1160237..2e77e96 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClientTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/api/event/EventStreamClientTest.kt @@ -10,7 +10,7 @@ class EventStreamClientTest { @Test fun `consume throws authentication exception on unauthorized response`() { withTestServer { server, port -> - server.createContext("/global/event") { exchange -> + server.createContext("/api/event") { exchange -> val body = "Unauthorized" exchange.sendResponseHeaders(401, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/api/health/HealthApiClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/api/health/HealthApiClientTest.kt index a430802..3f118e2 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/api/health/HealthApiClientTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/api/health/HealthApiClientTest.kt @@ -11,8 +11,8 @@ class HealthApiClientTest { @Test fun `isHealthy returns success true on 2xx`() { withTestServer { server, port -> - server.createContext("/global/health") { exchange -> - val body = "ok" + server.createContext("/api/info") { exchange -> + val body = """{"version":"2.0.32","pid":42,"urls":["http://127.0.0.1:4096"]}""" exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } } @@ -28,7 +28,7 @@ class HealthApiClientTest { @Test fun `isHealthy returns failure on non-2xx`() { withTestServer { server, port -> - server.createContext("/global/health") { exchange -> + server.createContext("/api/info") { exchange -> val body = "down" exchange.sendResponseHeaders(503, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } @@ -41,4 +41,38 @@ class HealthApiClientTest { } } + @Test + fun `fetchInfo parses server info payload`() { + withTestServer { server, port -> + server.createContext("/api/info") { exchange -> + val body = """{"version":"2.0.32","pid":42,"urls":["http://127.0.0.1:4096"],"paths":{"tmp":"/tmp"}}""" + exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + + val client = HealthApiClient() + val result = client.fetchInfo(port) + + val success = assertIs>(result) + assertEquals("2.0.32", success.value.version) + assertEquals(42, success.value.pid) + assertEquals(listOf("http://127.0.0.1:4096"), success.value.urls) + } + } + + @Test + fun `fetchInfo fails when required fields are missing`() { + withTestServer { server, port -> + server.createContext("/api/info") { exchange -> + val body = """{"urls":["http://127.0.0.1:4096"]}""" + exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + + val client = HealthApiClient() + val result = client.fetchInfo(port) + + assertIs(result) + } + } } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt index 4282dc2..1ed26e6 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt @@ -105,7 +105,7 @@ class SessionApiClientTest { val failure = assertIs(result) val error = assertIs(failure.error) - assertEquals("GET /session/ses_1/diff?messageID=msg_1: Expected JSON array", error.message) + assertEquals("GET /session/ses_1/diff?messageID=msg_1: Expected JSON array payload", error.message) } } @@ -248,7 +248,7 @@ class SessionApiClientTest { val failure = assertIs(result) val error = assertIs(failure.error) - assertEquals("GET /session: Expected JSON array", error.message) + assertEquals("GET /session: Expected JSON array payload", error.message) } } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt index da0f106..1185939 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt @@ -15,7 +15,7 @@ class SseClientTest { fun `reports authentication failure once and stops retrying`() { withTestServer { server, port -> val requests = AtomicInteger(0) - server.createContext("/global/event") { exchange -> + server.createContext("/api/event") { exchange -> requests.incrementAndGet() val body = "Unauthorized" exchange.sendResponseHeaders(401, body.toByteArray(Charsets.UTF_8).size.toLong()) @@ -46,7 +46,7 @@ class SseClientTest { @Test fun `parses busy retry and idle session status events`() { withTestServer { server, port -> - server.createContext("/global/event") { exchange -> + server.createContext("/api/event") { exchange -> val body = """ data: {"directory":"/other","payload":{"type":"session.status","properties":{"sessionID":"ses_1","status":{"type":"busy"}}}} @@ -100,7 +100,7 @@ class SseClientTest { @Test fun `parses wrapped session lifecycle events`() { withTestServer { server, port -> - server.createContext("/global/event") { exchange -> + server.createContext("/api/event") { exchange -> val body = """ data: {"directory":"/project","payload":{"type":"session.created","properties":{"info":{"id":"ses_created"}}}} @@ -143,7 +143,7 @@ class SseClientTest { @Test fun `parses message diff availability with summary files`() { withTestServer { server, port -> - server.createContext("/global/event") { exchange -> + server.createContext("/api/event") { exchange -> val body = """ data: {"directory":"/project","payload":{"type":"message.updated","properties":{"sessionID":"ses_1","info":{"id":"msg_1","role":"user","summary":{"diffs":[{"file":"pkg/alpha.py"},{"file":"pkg/bravo.py"}]}}}}} diff --git a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt index 75e5f0b..db1a81b 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt @@ -1,68 +1,74 @@ package com.ashotn.opencode.relay.settings import com.intellij.testFramework.fixtures.BasePlatformTestCase -import kotlin.test.assertFailsWith +import java.util.Base64 +import kotlin.test.assertTrue class OpenCodeServerAuthTest : BasePlatformTestCase() { override fun tearDown() { try { - val settings = OpenCodeSettings.getInstance(project) - settings.serverAuthUsername = OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME - settings.protectPluginLaunchedServerWithAuth = false OpenCodeServerAuth.getInstance(project).setPassword("") } finally { super.tearDown() } } - fun testConnectionAuthorizationHeaderUsesConfiguredCredentials() { + fun testConnectionAuthorizationHeaderUsesFixedOpenCodeUsername() { val settings = OpenCodeSettings.getInstance(project) val auth = OpenCodeServerAuth.getInstance(project) + // OpenCode 2.x always authenticates as the fixed "opencode" user; + // a configured username must not leak into the header. settings.serverAuthUsername = "alice" auth.setPassword("secret") - assertEquals("Basic YWxpY2U6c2VjcmV0", auth.connectionAuthorizationHeader()) + val expectedToken = Base64.getEncoder() + .encodeToString("opencode:secret".toByteArray()) + assertEquals("Basic $expectedToken", auth.connectionAuthorizationHeader()) assertEquals( - mapOf( - "OPENCODE_SERVER_USERNAME" to "alice", - "OPENCODE_SERVER_PASSWORD" to "secret", - ), + mapOf("OPENCODE_PASSWORD" to "secret"), auth.connectionEnvironmentVariables(), ) } - fun testServerLaunchEnvironmentVariablesRespectProtectionToggle() { - val settings = OpenCodeSettings.getInstance(project) + fun testConnectionAuthorizationHeaderIsNullWithoutPassword() { val auth = OpenCodeServerAuth.getInstance(project) + auth.setPassword("") - settings.serverAuthUsername = "alice" - auth.setPassword("secret") + assertNull(auth.connectionAuthorizationHeader()) + assertTrue(auth.connectionEnvironmentVariables().isEmpty()) + } - settings.protectPluginLaunchedServerWithAuth = false - assertTrue(auth.serverLaunchEnvironmentVariables().isEmpty()) + fun testLaunchEnvironmentVariablesReuseStoredPassword() { + val auth = OpenCodeServerAuth.getInstance(project) + auth.setPassword("stored") - settings.protectPluginLaunchedServerWithAuth = true - assertEquals( - mapOf( - "OPENCODE_SERVER_USERNAME" to "alice", - "OPENCODE_SERVER_PASSWORD" to "secret", - ), - auth.serverLaunchEnvironmentVariables(), - ) + assertEquals(mapOf("OPENCODE_PASSWORD" to "stored"), auth.launchEnvironmentVariables()) + assertEquals("stored", auth.password()) } - fun testServerLaunchEnvironmentVariablesFailWhenProtectionEnabledAndPasswordMissing() { - val settings = OpenCodeSettings.getInstance(project) + fun testLaunchEnvironmentVariablesGeneratePasswordWhenNoneStored() { val auth = OpenCodeServerAuth.getInstance(project) + auth.setPassword("") - settings.serverAuthUsername = "alice" + val environmentVariables = auth.launchEnvironmentVariables() + + val generated = environmentVariables.getValue("OPENCODE_PASSWORD") + assertTrue(generated.length >= 32, "generated password should be reasonably long: $generated") + assertEquals(generated, auth.password()) + } + + fun testSetPasswordTransientlyUpdatesCache() { + val auth = OpenCodeServerAuth.getInstance(project) auth.setPassword("") - settings.protectPluginLaunchedServerWithAuth = true - assertFailsWith { - auth.serverLaunchEnvironmentVariables() - } + auth.setPasswordTransiently("transient") + + assertEquals("transient", auth.password()) + assertEquals( + "Basic " + Base64.getEncoder().encodeToString("opencode:transient".toByteArray()), + auth.connectionAuthorizationHeader(), + ) } } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt index bb873d6..0c78fdf 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt @@ -1,5 +1,6 @@ package com.ashotn.opencode.relay.settings +import com.ashotn.opencode.relay.OpenCodeExecutableDetection import com.ashotn.opencode.relay.OpenCodeExecutableResolutionState import com.ashotn.opencode.relay.OpenCodeInfo import com.ashotn.opencode.relay.OpenCodePlugin @@ -263,7 +264,7 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { runOnEdt { configurable.serverEnvironmentVariablesModel.setItems( - listOf(OpenCodeSettings.EnvironmentVariable("OPENCODE_SERVER_PASSWORD", "secret")) + listOf(OpenCodeSettings.EnvironmentVariable("OPENCODE_PASSWORD", "secret")) ) } @@ -308,7 +309,7 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { ) val configurable = OpenCodeSettingsConfigurable(project).apply { - executableResolver = { null } + executableResolver = { OpenCodeExecutableDetection.None } } try { @@ -331,7 +332,7 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { settings.executablePath = "C:/existing/opencode" val configurable = OpenCodeSettingsConfigurable(project).apply { - executableResolver = { null } + executableResolver = { OpenCodeExecutableDetection.None } } try { @@ -359,7 +360,7 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { val configurable = OpenCodeSettingsConfigurable(project).apply { executableResolver = { resolveCalls.incrementAndGet() - null + OpenCodeExecutableDetection.None } } @@ -387,7 +388,7 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { val configurable = OpenCodeSettingsConfigurable(project).apply { executableResolver = { resolveCalls.incrementAndGet() - null + OpenCodeExecutableDetection.None } } @@ -417,7 +418,7 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { val configurable = OpenCodeSettingsConfigurable(project).apply { executableResolver = { resolveCalls.incrementAndGet() - OpenCodeInfo("C:/resolved/opencode", "1.2.3") + OpenCodeExecutableDetection.Found(OpenCodeInfo("C:/resolved/opencode", "1.2.3")) } } From c3d324e21beb20a8b5d69ac1d608028ec6346f4d Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Sun, 18 Oct 2026 23:14:49 -0500 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20OpenCode=20v2=20core=20panel=20?= =?UTF-8?q?=E2=80=94=20sessions,=20permissions,=20diffs,=20and=20events=20?= =?UTF-8?q?(P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the session/permission/MCP/config API clients and the SSE event pipeline for the OpenCode 2.x server API: - Sessions: /api/session routes with cursor pagination (server caps pages at 10), server-side directory filter, flat v2 session shape (location.directory, no version/summary/share), create/prompt/delete via POST /api/session/{id}/prompt {text} replacing prompt_async. - Diffs: per-turn diff routes; latest-turn snapshot (GET /api/session/{id}/diff) as the live snapshot, cumulative snapshot via a single from/to user-turn range request; assistant-message snapshot.files replace the v1 user-message summary.diffs for file counts. - Permissions: GET/POST /api/session/{id}/permission[/{requestID}/reply] with {decision} body (once/always/reject unchanged on the wire). - MCP: GET /api/mcp composite envelope with nested status objects (adds PENDING, drops NEEDS_CLIENT_REGISTRATION), connect/disconnect via /api/experimental/mcp/{name}/... - Config: /api/config now returns layered config documents; MCP servers live under info.mcp.servers with a disabled flag. - Events: v2 SSE envelope {id, created, type, location?, data} with location.directory scoping; session.execution.started -> busy, execution succeeded/failed/interrupted -> SessionExecutionFinished (drives the latest-turn diff fetch), session.idle -> idle, mcp.status.changed replaces mcp.tools.changed. - Core service: live turn diff fetch replaces per-message diff flow (coalescer generalized to key-based), hasMessages/trackedFileCount derived from local diff state and loaded file counts. - Removed v1 DTO fields (Session.version/summary/share, MessageDiffAvailable). - Tests: session/permission/SSE/coalescer suites rewritten for v2 shapes; liveTest event collector and prompt calls updated. --- .../integration/OpenCodeTestEventCollector.kt | 20 +- .../integration/diff/OpenCodeDiffLiveTest.kt | 15 +- .../session/OpenCodeSessionStateLiveTest.kt | 7 +- .../relay/actions/McpServersPopupPanel.kt | 4 +- .../relay/api/config/ConfigApiClient.kt | 117 +++-- .../relay/api/config/ConfigEndpoints.kt | 2 +- .../opencode/relay/api/mcp/McpApiClient.kt | 69 ++- .../opencode/relay/api/mcp/McpEndpoints.kt | 14 +- .../opencode/relay/api/mcp/McpService.kt | 3 +- .../api/permission/PermissionApiClient.kt | 9 +- .../api/permission/PermissionEndpoints.kt | 2 +- .../opencode/relay/api/session/Session.kt | 14 - .../relay/api/session/SessionApiClient.kt | 402 ++++++++++-------- .../relay/api/session/SessionEndpoints.kt | 42 +- .../relay/core/MessageDiffFetchCoalescer.kt | 54 ++- .../relay/core/OpenCodeCoreService.kt | 60 +-- .../opencode/relay/core/QueryService.kt | 15 +- .../relay/core/session/SessionInfo.kt | 2 +- .../opencode/relay/ipc/McpChangedListener.kt | 8 +- .../opencode/relay/ipc/OpenCodeEvent.kt | 35 +- .../ashotn/opencode/relay/ipc/SseClient.kt | 148 ++++--- .../opencode/relay/tui/OpenCodeTuiClient.kt | 11 +- .../api/permission/PermissionApiClientTest.kt | 19 +- .../relay/api/session/SessionApiClientTest.kt | 379 +++++++++++++---- .../core/MessageDiffFetchCoalescerTest.kt | 65 +-- .../MessageSummaryFileCountLoadPlannerTest.kt | 22 +- .../relay/core/SessionDiffPipelineTest.kt | 3 - .../relay/core/SessionOrderingTest.kt | 18 - .../core/session/SessionScopeResolverTest.kt | 6 - .../opencode/relay/ipc/SseClientTest.kt | 205 ++++++++- 30 files changed, 1119 insertions(+), 651 deletions(-) diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt index 0dc0fdc..2033bbb 100644 --- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt +++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt @@ -42,13 +42,8 @@ class OpenCodeTestEventCollector( sessionStatusCountLocked(sessionId, status) } - fun diffSignalCount(sessionId: String): Int = synchronized(lock) { - events.count { it is OpenCodeEvent.MessageDiffAvailable && it.sessionId == sessionId } - } - - fun messageDiffEvents(sessionId: String): List = synchronized(lock) { - events.filterIsInstance() - .filter { it.sessionId == sessionId } + fun executionFinishedCount(sessionId: String): Int = synchronized(lock) { + events.count { it is OpenCodeEvent.SessionExecutionFinished && it.sessionId == sessionId } } fun awaitIdleStatus(sessionId: String, atLeastCount: Int, timeoutMs: Long = 15_000): OpenCodeEvent.SessionStatus = @@ -80,9 +75,9 @@ class OpenCodeTestEventCollector( if (matching.size >= atLeastCount) matching.last() else null } - fun awaitDiffSignal(sessionId: String, atLeastCount: Int, timeoutMs: Long = 15_000): OpenCodeEvent = - awaitEvent(timeoutMs, "diff signal for $sessionId count >= $atLeastCount") { recordedEvents -> - val matching = recordedEvents.filterIsInstance() + fun awaitExecutionFinished(sessionId: String, atLeastCount: Int, timeoutMs: Long = 15_000): OpenCodeEvent = + awaitEvent(timeoutMs, "execution finished for $sessionId count >= $atLeastCount") { recordedEvents -> + val matching = recordedEvents.filterIsInstance() .filter { it.sessionId == sessionId } if (matching.size >= atLeastCount) matching.last() else null } @@ -96,10 +91,7 @@ class OpenCodeTestEventCollector( "session.status(sessionId=${event.sessionId}, type=${event.status.name.lowercase()})" } - is OpenCodeEvent.MessageDiffAvailable -> { - val files = event.files.joinToString(",") { it.substringAfterLast('/') } - "message.diff(sessionId=${event.sessionId}, messageId=${event.messageId}, files=$files)" - } + is OpenCodeEvent.SessionExecutionFinished -> "execution.finished(sessionId=${event.sessionId})" is OpenCodeEvent.SessionLifecycleChanged -> "session.lifecycle(sessionId=${event.sessionId})" diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt index 31b0721..5ae5403 100644 --- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt +++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt @@ -38,9 +38,6 @@ class OpenCodeDiffLiveTest( fun versions(): List> = OpenCodeTestVersions.all().map { arrayOf(it) } } - private val providerId = "github-copilot" - private val modelId = "gpt-5-mini" - @Test fun `creates and updates hello txt through real opencode server`() { withLiveSession(version) { environment, server, sessionClient, events, sessionId -> @@ -164,9 +161,9 @@ class OpenCodeDiffLiveTest( "expected at least one child session to own the edits, got ${childDiffs.fileToChildSessionId}", ) assertEquals( - expectedFiles.keys.associateWith { "user" }, + expectedFiles.keys.associateWith { "assistant" }, childDiffs.diffSummaryRoleByFile, - "child diff summaries should be carried by user messages, matching the parser filter", + "child diff summaries should be carried by assistant message snapshots, matching the parser filter", ) } } @@ -217,17 +214,15 @@ class OpenCodeDiffLiveTest( text: String, ) { val nextIdleCount = events.sessionIdleCount(sessionId) + 1 - val nextDiffSignalCount = events.diffSignalCount(sessionId) + 1 + val nextExecutionFinishedCount = events.executionFinishedCount(sessionId) + 1 assertIs>( - sessionClient.promptTextAsync( + sessionClient.promptText( port = port, sessionId = sessionId, - providerId = providerId, - modelId = modelId, text = text, ), ) - events.awaitDiffSignal(sessionId, nextDiffSignalCount, timeoutMs = turnTimeoutMs) + events.awaitExecutionFinished(sessionId, nextExecutionFinishedCount, timeoutMs = turnTimeoutMs) events.awaitIdleStatus(sessionId, nextIdleCount, timeoutMs = turnTimeoutMs) } diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt index 733b673..6565a24 100644 --- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt +++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt @@ -22,9 +22,6 @@ class OpenCodeSessionStateLiveTest( fun versions(): List> = OpenCodeTestVersions.all().map { arrayOf(it) } } - private val providerId = "github-copilot" - private val modelId = "gpt-5-mini" - @Test fun `real server emits session lifecycle and status events`() { OpenCodeTestEnvironmentFactory.create(version).use { environment -> @@ -51,11 +48,9 @@ class OpenCodeSessionStateLiveTest( ) + 1 assertIs>( - sessionClient.promptTextAsync( + sessionClient.promptText( port = server.port, sessionId = sessionId, - providerId = providerId, - modelId = modelId, text = "Reply with exactly: pong\nDo not edit files or use tools.", ), ) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersPopupPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersPopupPanel.kt index 51f9f13..3d11772 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersPopupPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/McpServersPopupPanel.kt @@ -221,14 +221,12 @@ class McpServersPopupPanel( private fun statusTooltipFor(entry: McpEntry) = when { entry.isLoading -> "Updating…" entry.isConnected -> "Connected" + entry.connectionStatus == McpConnectionStatus.PENDING -> "Connecting…" entry.connectionStatus == McpConnectionStatus.DISABLED -> "Disabled" entry.connectionStatus == McpConnectionStatus.FAILED -> "Failed: ${entry.connectionError ?: "unknown error"}" entry.connectionStatus == McpConnectionStatus.NEEDS_AUTH -> "Needs authentication" - entry.connectionStatus == McpConnectionStatus.NEEDS_CLIENT_REGISTRATION -> - "Needs client registration" - entry.connectionStatus == null -> "Not yet connected" else -> entry.connectionStatus.name.lowercase().replace('_', ' ') } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/config/ConfigApiClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/config/ConfigApiClient.kt index d04309e..b2d7346 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/config/ConfigApiClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/config/ConfigApiClient.kt @@ -2,7 +2,7 @@ package com.ashotn.opencode.relay.api.config import com.ashotn.opencode.relay.api.transport.ApiResult import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport -import com.ashotn.opencode.relay.api.transport.mapJsonObjectResponse +import com.ashotn.opencode.relay.api.transport.mapJsonArrayResponse import com.ashotn.opencode.relay.api.transport.withParseContext import com.ashotn.opencode.relay.util.getObjectOrNull import com.ashotn.opencode.relay.util.getStringOrNull @@ -29,53 +29,86 @@ class ConfigApiClient( ) : McpServerConfig() } + /** + * Reads the configured MCP servers from `GET /api/config`. + * + * v2 returns a list of layered config documents + * (`[{"type": "document", "path": ..., "info": {}}]`); MCP servers live in + * `info.mcp.servers` (falling back to `info.mcp` for configs without the wrapper) + * and are disabled via a `disabled` flag instead of v1's `enabled` flag. + * Entries are merged across documents with later documents winning. + */ fun getMcpServers(port: Int): ApiResult> { val endpoint = ConfigEndpoints.get() val response = transport.get(port = port, path = endpoint.path) - return transport.mapJsonObjectResponse(response) { root: JsonObject -> - val mcpObj = root.getObjectOrNull("mcp") ?: return@mapJsonObjectResponse ApiResult.Success(emptyList()) - val servers = mcpObj.entrySet().mapNotNull { entry -> - val name = entry.key - val element = entry.value - if (!element.isJsonObject) return@mapNotNull null - val obj = element.asJsonObject - val enabled = obj.get("enabled") - ?.takeIf { it.isJsonPrimitive } - ?.asBoolean - ?: true + return transport.mapJsonArrayResponse(response) { documents -> + val serversByName = LinkedHashMap() + for (document in documents) { + if (!document.isJsonObject) continue + val info = document.asJsonObject.getObjectOrNull("info") ?: continue + val servers = mcpServersOf(info) + if (servers == null) continue + for ((name, config) in servers) { + serversByName[name] = config + } + } + ApiResult.Success(serversByName.values.toList()) + }.withParseContext(endpoint) + } + + private fun mcpServersOf(info: JsonObject): Map? { + val mcpObj = info.getObjectOrNull("mcp") ?: return null + // v2 wraps the server map in a `servers` key; accept a bare map defensively. + val serversObj = mcpObj.getObjectOrNull("servers") ?: return mcpServersFromMap(mcpObj) + return mcpServersFromMap(serversObj) + } - when (obj.getStringOrNull("type")) { - "local" -> { - val command = obj.get("command") - ?.takeIf { it.isJsonArray } - ?.asJsonArray - ?.mapNotNull { e -> e.takeIf { it.isJsonPrimitive }?.asString } - ?: emptyList() - val environment = obj.getObjectOrNull("environment") - ?.entrySet() - ?.associate { e -> e.key to e.value.asString } - ?: emptyMap() - McpServerConfig.Local( - name = name, - command = command, - environment = environment, - enabled = enabled, - ) - } + private fun mcpServersFromMap(serversObj: JsonObject): Map { + val servers = LinkedHashMap() + for ((name, element) in serversObj.entrySet()) { + if (!element.isJsonObject) continue + val config = parseMcpServerConfig(name, element.asJsonObject) ?: continue + servers[name] = config + } + return servers + } - "remote" -> { - val url = obj.getStringOrNull("url") ?: return@mapNotNull null - McpServerConfig.Remote( - name = name, - url = url, - enabled = enabled, - ) - } + private fun parseMcpServerConfig(name: String, obj: JsonObject): McpServerConfig? { + val enabled = obj.get("disabled") + ?.takeIf { it.isJsonPrimitive } + ?.asBoolean + ?.let { !it } + ?: true - else -> null - } + return when (obj.getStringOrNull("type")) { + "local" -> { + val command = obj.get("command") + ?.takeIf { it.isJsonArray } + ?.asJsonArray + ?.mapNotNull { e -> e.takeIf { it.isJsonPrimitive }?.asString } + ?: emptyList() + val environment = obj.getObjectOrNull("environment") + ?.entrySet() + ?.associate { e -> e.key to e.value.asString } + ?: emptyMap() + McpServerConfig.Local( + name = name, + command = command, + environment = environment, + enabled = enabled, + ) } - ApiResult.Success(servers) - }.withParseContext(endpoint) + + "remote" -> { + val url = obj.getStringOrNull("url") ?: return null + McpServerConfig.Remote( + name = name, + url = url, + enabled = enabled, + ) + } + + else -> null + } } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/config/ConfigEndpoints.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/config/ConfigEndpoints.kt index 1a1a8a2..5495a12 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/config/ConfigEndpoints.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/config/ConfigEndpoints.kt @@ -4,5 +4,5 @@ import com.ashotn.opencode.relay.api.transport.ApiEndpoint import com.ashotn.opencode.relay.api.transport.HttpMethod internal object ConfigEndpoints { - fun get(): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/config") + fun get(): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/api/config") } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpApiClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpApiClient.kt index ac62c73..650db6e 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpApiClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpApiClient.kt @@ -2,8 +2,10 @@ package com.ashotn.opencode.relay.api.mcp import com.ashotn.opencode.relay.api.transport.ApiResult import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport -import com.ashotn.opencode.relay.api.transport.mapJsonObjectResponse +import com.ashotn.opencode.relay.api.transport.flatMap +import com.ashotn.opencode.relay.api.transport.map import com.ashotn.opencode.relay.api.transport.parseBooleanResponse +import com.ashotn.opencode.relay.api.transport.parseJsonEnvelope import com.ashotn.opencode.relay.api.transport.withParseContext import com.ashotn.opencode.relay.util.getStringOrNull import com.google.gson.JsonObject @@ -14,16 +16,16 @@ class McpApiClient( data class McpServer( val name: String, val status: McpConnectionStatus, - /** Present when status is [McpConnectionStatus.FAILED] or [McpConnectionStatus.NEEDS_CLIENT_REGISTRATION]. */ + /** Present when status is [McpConnectionStatus.FAILED] or [McpConnectionStatus.NEEDS_AUTH]. */ val error: String? = null, ) enum class McpConnectionStatus { CONNECTED, + PENDING, DISABLED, FAILED, NEEDS_AUTH, - NEEDS_CLIENT_REGISTRATION, UNKNOWN, } @@ -44,27 +46,48 @@ class McpApiClient( fun listServers(port: Int): ApiResult> { val endpoint = McpEndpoints.status() val response = transport.get(port = port, path = endpoint.path) - return transport.mapJsonObjectResponse(response) { root: JsonObject -> - val servers = root.entrySet().map { entry -> - val name = entry.key - val element = entry.value - if (!element.isJsonObject) { - return@map McpServer(name = name, status = McpConnectionStatus.UNKNOWN) + // v2 returns {location, data: [Mcp.Server]}; the composite envelope keeps + // `data` alongside sibling fields, so it is not unwrapped automatically. + return transport.parseJsonEnvelope(response) + .map { root -> root.get("data") } + .flatMap { dataElement -> + val servers = if (dataElement != null && dataElement.isJsonArray) { + dataElement.asJsonArray.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + parseServer(element.asJsonObject) + } + } else { + emptyList() } - val obj = element.asJsonObject - val statusRaw = obj.getStringOrNull("status") - val error = obj.getStringOrNull("error") - val status = when (statusRaw) { - "connected" -> McpConnectionStatus.CONNECTED - "disabled" -> McpConnectionStatus.DISABLED - "failed" -> McpConnectionStatus.FAILED - "needs_auth" -> McpConnectionStatus.NEEDS_AUTH - "needs_client_registration" -> McpConnectionStatus.NEEDS_CLIENT_REGISTRATION - else -> McpConnectionStatus.UNKNOWN - } - McpServer(name = name, status = status, error = error) + ApiResult.Success(servers) } - ApiResult.Success(servers) - }.withParseContext(endpoint) + .withParseContext(endpoint) + } + + private fun parseServer(obj: JsonObject): McpServer? { + val name = obj.getStringOrNull("name") ?: return null + val statusObj = obj.get("status") + if (statusObj == null) return McpServer(name = name, status = McpConnectionStatus.UNKNOWN) + + // v2 status is a nested object: {status: "...", error?}. + val statusRaw = if (statusObj.isJsonObject) { + statusObj.asJsonObject.getStringOrNull("status") + } else { + statusObj.takeIf { it.isJsonPrimitive }?.asString + } + val error = if (statusObj.isJsonObject) { + statusObj.asJsonObject.getStringOrNull("error") + } else { + null + } + val status = when (statusRaw) { + "connected" -> McpConnectionStatus.CONNECTED + "pending" -> McpConnectionStatus.PENDING + "disabled" -> McpConnectionStatus.DISABLED + "failed" -> McpConnectionStatus.FAILED + "needs_auth" -> McpConnectionStatus.NEEDS_AUTH + else -> McpConnectionStatus.UNKNOWN + } + return McpServer(name = name, status = status, error = error) } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpEndpoints.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpEndpoints.kt index af6bcc3..442f305 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpEndpoints.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpEndpoints.kt @@ -2,9 +2,17 @@ package com.ashotn.opencode.relay.api.mcp import com.ashotn.opencode.relay.api.transport.ApiEndpoint import com.ashotn.opencode.relay.api.transport.HttpMethod +import java.net.URLEncoder +import java.nio.charset.StandardCharsets internal object McpEndpoints { - fun status(): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/mcp") - fun connect(name: String): ApiEndpoint = ApiEndpoint(method = HttpMethod.POST, path = "/mcp/$name/connect") - fun disconnect(name: String): ApiEndpoint = ApiEndpoint(method = HttpMethod.POST, path = "/mcp/$name/disconnect") + fun status(): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/api/mcp") + + fun connect(name: String): ApiEndpoint = + ApiEndpoint(method = HttpMethod.POST, path = "/api/experimental/mcp/${encode(name)}/connect") + + fun disconnect(name: String): ApiEndpoint = + ApiEndpoint(method = HttpMethod.POST, path = "/api/experimental/mcp/${encode(name)}/disconnect") + + private fun encode(name: String): String = URLEncoder.encode(name, StandardCharsets.UTF_8) } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpService.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpService.kt index 208727e..185df53 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpService.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/mcp/McpService.kt @@ -12,7 +12,7 @@ import java.util.concurrent.CopyOnWriteArraySet * Unified view of a configured MCP server, combining: * - [config]: what the user has in their opencode config (`/config`) * - [connectionStatus]: the live runtime status from the OpenCode server (`/mcp`) - * - [connectionError]: error message when [connectionStatus] is FAILED or NEEDS_CLIENT_REGISTRATION + * - [connectionError]: error message when [connectionStatus] is FAILED or NEEDS_AUTH * - [isLoading]: true while a connect/disconnect call is in-flight for this server * * A server present in config but absent from the runtime status map will have @@ -33,6 +33,7 @@ data class McpEntry( val hasIssue: Boolean get() = config.enabled && connectionStatus != null && connectionStatus != McpApiClient.McpConnectionStatus.CONNECTED + && connectionStatus != McpApiClient.McpConnectionStatus.PENDING && connectionStatus != McpApiClient.McpConnectionStatus.DISABLED } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/permission/PermissionApiClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/permission/PermissionApiClient.kt index 1723cd2..1834b66 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/permission/PermissionApiClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/permission/PermissionApiClient.kt @@ -9,9 +9,16 @@ import com.google.gson.JsonObject class PermissionApiClient( private val transport: OpenCodeHttpTransport = OpenCodeHttpTransport(), ) { + /** + * Replies to a pending permission request. + * + * v2 uses `POST /api/session/{id}/permission/{requestID}/reply` with a + * `{"decision": "once"|"always"|"reject"}` body (v1 posted `{"response": ...}` to + * `/session/{id}/permissions/{id}`). + */ fun reply(port: Int, sessionId: String, permissionId: String, response: String): ApiResult { val endpoint = PermissionEndpoints.reply(sessionId, permissionId) - val payload = JsonObject().also { it.addProperty("response", response) } + val payload = JsonObject().also { it.addProperty("decision", response) } val rawResponse = transport.post( port = port, path = endpoint.path, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/permission/PermissionEndpoints.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/permission/PermissionEndpoints.kt index 9f18ea7..20841c5 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/permission/PermissionEndpoints.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/permission/PermissionEndpoints.kt @@ -5,5 +5,5 @@ import com.ashotn.opencode.relay.api.transport.HttpMethod internal object PermissionEndpoints { fun reply(sessionId: String, permissionId: String): ApiEndpoint = - ApiEndpoint(method = HttpMethod.POST, path = "/session/$sessionId/permissions/$permissionId") + ApiEndpoint(method = HttpMethod.POST, path = "/api/session/$sessionId/permission/$permissionId/reply") } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/session/Session.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/session/Session.kt index f663d5b..8b09716 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/session/Session.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/session/Session.kt @@ -6,10 +6,7 @@ data class Session( val directory: String?, val parentID: String?, val title: String?, - val version: String?, val time: SessionTime, - val summary: SessionSummary?, - val share: SessionShare?, ) data class SessionTime( @@ -18,17 +15,6 @@ data class SessionTime( val compacting: Long?, ) -data class SessionSummary( - val additions: Int, - val deletions: Int, - val files: Int, - val diffs: List?, -) - -data class SessionShare( - val url: String?, -) - data class FileDiff( val file: String, val before: String, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt index f6a38fe..6057224 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt @@ -1,11 +1,15 @@ package com.ashotn.opencode.relay.api.session +import com.ashotn.opencode.relay.api.transport.ApiEndpoint import com.ashotn.opencode.relay.api.transport.ApiError import com.ashotn.opencode.relay.api.transport.ApiResult import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport -import com.ashotn.opencode.relay.api.transport.mapJsonArrayResponse +import com.ashotn.opencode.relay.api.transport.flatMap +import com.ashotn.opencode.relay.api.transport.map import com.ashotn.opencode.relay.api.transport.mapJsonObjectResponse import com.ashotn.opencode.relay.api.transport.parseBooleanResponse +import com.ashotn.opencode.relay.api.transport.parseJsonArrayResponse +import com.ashotn.opencode.relay.api.transport.parseJsonEnvelope import com.ashotn.opencode.relay.api.transport.withParseContext import com.ashotn.opencode.relay.ipc.PatchDiffTextParser import com.ashotn.opencode.relay.ipc.SessionDiffStatus @@ -15,11 +19,22 @@ import com.ashotn.opencode.relay.util.getStringOrNull import com.ashotn.opencode.relay.util.pathsEqual import com.ashotn.opencode.relay.util.toAbsolutePath import com.google.gson.JsonArray +import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.intellij.openapi.diagnostic.logger +import java.net.URLEncoder +import java.nio.charset.StandardCharsets class SessionApiClient( private val transport: OpenCodeHttpTransport = OpenCodeHttpTransport(), ) { + companion object { + private val log = logger() + + private const val PAGE_SIZE = 100 + private const val MAX_PAGES = 50 + } + data class CreatedSession( val sessionId: String, ) @@ -48,27 +63,13 @@ class SessionApiClient( }.withParseContext(endpoint) } - fun promptTextAsync( - port: Int, - sessionId: String, - providerId: String, - modelId: String, - text: String, - ): ApiResult { - val endpoint = SessionEndpoints.promptAsync(sessionId) - val parts = JsonArray().apply { - add(JsonObject().apply { - addProperty("type", "text") - addProperty("text", text) - }) - } - val payload = JsonObject().apply { - add("model", JsonObject().apply { - addProperty("providerID", providerId) - addProperty("modelID", modelId) - }) - add("parts", parts) - } + /** + * Enqueues a user prompt on the session's durable inbox and starts its execution. + * Replaces the v1 `prompt_async` route (v2 `POST /api/session/{id}/prompt`). + */ + fun promptText(port: Int, sessionId: String, text: String): ApiResult { + val endpoint = SessionEndpoints.prompt(sessionId) + val payload = JsonObject().also { it.addProperty("text", text) } val response = transport.post( port = port, path = endpoint.path, @@ -80,50 +81,46 @@ class SessionApiClient( }.withParseContext(endpoint) } - fun fetchSessionDiffSnapshot( + /** + * Snapshot of the newest user turn's file changes. + * + * In OpenCode 2.x the per-message diff route is gone; `GET /api/session/{id}/diff` + * with no query returns the turn of the newest user message, which is exactly the + * "live" snapshot the inline diff rendering needs. + */ + fun fetchLatestTurnDiffSnapshot( port: Int, sessionId: String, - messageId: String? = null, ): ApiResult { - if (messageId == null) { - return fetchSessionMessageDiffSnapshot(port, sessionId) - } - - val endpoint = SessionEndpoints.diff(sessionId, messageId) - return when (val response = transport.get(port = port, path = endpoint.path)) { - is ApiResult.Failure -> response - is ApiResult.Success -> { - val body = response.value - if (body.isNullOrBlank()) { - ApiResult.Success(SessionDiffSnapshot(sessionId, emptyList())) - } else { - transport.mapJsonArrayResponse(ApiResult.Success(body)) { diffArray -> - val files = - diffArray.mapNotNull { element -> parseSessionDiffFile(element.asJsonObjectOrNull()) } - ApiResult.Success(SessionDiffSnapshot(sessionId, files)) - }.withParseContext(endpoint) - } - } - } + val endpoint = SessionEndpoints.diffLatestTurn(sessionId) + return fetchDiffFiles(port, endpoint) + .map { files -> SessionDiffSnapshot(sessionId, files) } + .withParseContext(endpoint) } - fun fetchSessionMessageDiffSnapshot(port: Int, sessionId: String): ApiResult { - return when (val refsResult = fetchSessionMessageDiffRefs(port, sessionId)) { - is ApiResult.Failure -> refsResult - is ApiResult.Success -> { - val messageIds = refsResult.value - if (messageIds.isEmpty()) return ApiResult.Success(SessionDiffSnapshot(sessionId, emptyList())) - - val mergedFilesByPath = linkedMapOf() - for (messageId in messageIds) { - when (val diffResult = fetchSessionDiffSnapshot(port, sessionId, messageId)) { - is ApiResult.Failure -> return diffResult - is ApiResult.Success -> mergeDiffFiles(mergedFilesByPath, diffResult.value.files) - } - } - ApiResult.Success(SessionDiffSnapshot(sessionId, mergedFilesByPath.values.toList())) + /** + * Cumulative snapshot across all user turns of the session. + * + * v2 diffs are per-turn: `from`/`to` select a range of user-message turns, so the + * cumulative view is a single range request from the first to the last user + * message. Sessions without user messages fall back to the latest-turn snapshot. + */ + fun fetchSessionDiffSnapshot(port: Int, sessionId: String): ApiResult { + val endpoint = when (val userMessageIds = fetchUserMessageIds(port, sessionId)) { + is ApiResult.Failure -> return userMessageIds + is ApiResult.Success -> when { + userMessageIds.value.isEmpty() -> SessionEndpoints.diffLatestTurn(sessionId) + userMessageIds.value.size == 1 -> SessionEndpoints.diffTurn(sessionId, userMessageIds.value.first()) + else -> SessionEndpoints.diffRange( + sessionId = sessionId, + fromUserMessageId = userMessageIds.value.first(), + toUserMessageId = userMessageIds.value.last(), + ) } } + return fetchDiffFiles(port, endpoint) + .map { files -> SessionDiffSnapshot(sessionId, files) } + .withParseContext(endpoint) } fun fetchFileDiffPreview( @@ -144,43 +141,168 @@ class SessionApiClient( } } - private fun fetchSessionMessageDiffRefs(port: Int, sessionId: String): ApiResult> { - return when (val summariesResult = fetchSessionMessageDiffSummaries(port, sessionId)) { - is ApiResult.Failure -> summariesResult - is ApiResult.Success -> ApiResult.Success( - summariesResult.value.mapNotNull { summary -> - summary.messageId?.takeIf { summary.role == "user" && summary.files.isNotEmpty() } - }, - ) + /** + * Per-message summaries used to estimate how many files a session touched. + * + * v2 messages are flat objects; file changes are reported on assistant messages + * via `snapshot.files`. + */ + fun fetchSessionMessageDiffSummaries(port: Int, sessionId: String): ApiResult> { + val endpoint = SessionEndpoints.messages(sessionId) + return walkPages(port, endpoint.path) { cursor -> pageQuery(cursor = cursor) } + .flatMap { messages -> + val summaries = messages.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + parseMessageDiffSummary(element.asJsonObject) + } + ApiResult.Success(summaries) + } + .withParseContext(endpoint) + } + + fun fetchSessionHierarchy(port: Int, directory: String? = null): ApiResult> { + val endpoint = SessionEndpoints.list() + return walkPages(port, endpoint.path) { cursor -> pageQuery(directory = directory, cursor = cursor) } + .flatMap { sessionElements -> + val sessions = mutableListOf() + for (element in sessionElements) { + if (!element.isJsonObject) continue + when (val result = parseSession(element.asJsonObject)) { + is ApiResult.Failure -> return@flatMap result + is ApiResult.Success -> sessions.add(result.value) + } + } + ApiResult.Success(sessions) + } + .withParseContext(endpoint) + } + + fun deleteSession(port: Int, sessionId: String): ApiResult { + val endpoint = SessionEndpoints.delete(sessionId) + val response = transport.delete(port = port, path = endpoint.path) + return transport.parseBooleanResponse(response, emptyBodyValue = true) + .withParseContext(endpoint) + } + + fun updateSession(port: Int, sessionId: String, title: String): ApiResult { + val endpoint = SessionEndpoints.update(sessionId) + val payload = JsonObject().also { it.addProperty("title", title) } + val response = transport.patch(port = port, path = endpoint.path, payload = payload.toString()) + return when (response) { + is ApiResult.Failure -> response + is ApiResult.Success -> ApiResult.Success(Unit) + }.withParseContext(endpoint) + } + + private fun fetchDiffFiles(port: Int, endpoint: ApiEndpoint): ApiResult> { + val response = transport.get(port = port, path = endpoint.path) + // Some server versions answer empty for diff routes; treat that as an empty + // snapshot rather than a parse failure. + val arrayResult: ApiResult = + if (response is ApiResult.Success && response.value.isNullOrBlank()) { + ApiResult.Success(JsonArray()) + } else { + transport.parseJsonArrayResponse(response) + } + // Parse context is applied by the callers, which own the endpoint. + return arrayResult.flatMap { diffArray -> + val files = diffArray.mapNotNull { element -> parseSessionDiffFile(element.asJsonObjectOrNull()) } + ApiResult.Success(files) } } - fun fetchSessionMessageDiffSummaries(port: Int, sessionId: String): ApiResult> { + private fun fetchUserMessageIds(port: Int, sessionId: String): ApiResult> { val endpoint = SessionEndpoints.messages(sessionId) - val response = transport.get(port = port, path = endpoint.path) - return transport.mapJsonArrayResponse(response) { messagesArray -> - val summaries = messagesArray.mapNotNull { element -> - val messageObj = element.asJsonObjectOrNull() ?: return@mapNotNull null - val info = messageObj.getObjectOrNull("info") ?: return@mapNotNull null - parseMessageDiffSummary(info) + return walkPages(port, endpoint.path) { cursor -> pageQuery(cursor = cursor) } + .flatMap { messages -> + val ids = messages.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + val message = element.asJsonObject + if (message.getStringOrNull("type") != "user") return@mapNotNull null + message.getStringOrNull("id")?.takeIf { it.isNotBlank() } + } + ApiResult.Success(ids) } - ApiResult.Success(summaries) - }.withParseContext(endpoint) + .withParseContext(endpoint) + } + + private data class Page( + val items: List, + val nextCursor: String?, + ) + + /** + * v2 lists are cursor-paginated (`{data: [...], cursor: {next, previous}}`) with a + * small default page size, so every list fetch walks pages until the cursor is gone. + */ + private fun walkPages(port: Int, basePath: String, pageQuery: (String?) -> String): ApiResult> { + val allItems = mutableListOf() + var cursor: String? = null + var pagesFetched = 0 + + while (true) { + if (pagesFetched >= MAX_PAGES) { + log.debug("SessionApiClient: stopping pagination at $MAX_PAGES pages for $basePath") + break + } + val page = when (val pageResult = fetchPage(port, basePath + pageQuery(cursor))) { + is ApiResult.Failure -> return pageResult + is ApiResult.Success -> pageResult.value + } + allItems += page.items + pagesFetched++ + cursor = page.nextCursor + if (cursor.isNullOrBlank()) break + } + + return ApiResult.Success(allItems) + } + + private fun fetchPage(port: Int, path: String): ApiResult { + val response = transport.get(port = port, path = path) + return when (val rootResult = transport.parseJsonEnvelope(response)) { + is ApiResult.Failure -> rootResult + is ApiResult.Success -> { + val root = rootResult.value + val items = root.get("data") + ?.takeIf { it.isJsonArray } + ?.asJsonArray + ?.toList() + .orEmpty() + val nextCursor = root.get("cursor") + ?.takeIf { it.isJsonObject } + ?.asJsonObject + ?.getStringOrNull("next") + ApiResult.Success(Page(items = items, nextCursor = nextCursor)) + } + } } - private fun parseMessageDiffSummary(info: JsonObject): SessionMessageDiffSummary? { - val diffs = info.getObjectOrNull("summary") - ?.get("diffs") + private fun pageQuery(directory: String? = null, limit: Int = PAGE_SIZE, cursor: String? = null): String { + val params = mutableListOf() + directory?.takeIf { it.isNotBlank() } + ?.let { params.add("directory=${URLEncoder.encode(it, StandardCharsets.UTF_8)}") } + params.add("limit=$limit") + cursor?.takeIf { it.isNotBlank() } + ?.let { params.add("cursor=${URLEncoder.encode(it, StandardCharsets.UTF_8)}") } + return "?${params.joinToString("&")}" + } + + private fun parseMessageDiffSummary(message: JsonObject): SessionMessageDiffSummary? { + if (message.getStringOrNull("type") != "assistant") return null + val files = message.getObjectOrNull("snapshot") + ?.get("files") ?.takeIf { it.isJsonArray } ?.asJsonArray + ?.mapNotNull { element -> + if (!element.isJsonPrimitive) return@mapNotNull null + element.asString.takeIf { it.isNotBlank() } + } ?: return null - val files = diffs.mapNotNull { diffElement -> - diffElement.asJsonObjectOrNull()?.getStringOrNull("file") - } if (files.isEmpty()) return null return SessionMessageDiffSummary( - messageId = info.getStringOrNull("id"), - role = info.getStringOrNull("role"), + messageId = message.getStringOrNull("id"), + role = "assistant", files = files, ) } @@ -203,52 +325,6 @@ class SessionApiClient( ) } - private fun mergeDiffFiles( - mergedFilesByPath: MutableMap, - files: List, - ) { - for (file in files) { - val existing = mergedFilesByPath[file.file] - if (existing == null) { - mergedFilesByPath[file.file] = file - continue - } - mergedFilesByPath[file.file] = existing.copy( - after = file.after, - additions = existing.additions + file.additions, - deletions = existing.deletions + file.deletions, - status = mergeStatus(existing.status, file.status), - ) - } - } - - // Build one final status from multiple message diffs for the same file. - // Example: ADDED in turn 1 + MODIFIED in turn 2 should still show as ADDED. - private fun mergeStatus(existing: SessionDiffStatus, next: SessionDiffStatus): SessionDiffStatus = when { - next == SessionDiffStatus.UNKNOWN -> existing - existing == SessionDiffStatus.ADDED && next != SessionDiffStatus.DELETED -> SessionDiffStatus.ADDED - else -> next - } - - private fun com.google.gson.JsonElement?.asJsonObjectOrNull(): JsonObject? = - this?.takeIf { it.isJsonObject }?.asJsonObject - - fun fetchSessionHierarchy(port: Int): ApiResult> { - val endpoint = SessionEndpoints.list() - val response = transport.get(port = port, path = endpoint.path) - return transport.mapJsonArrayResponse(response) { sessionArray -> - val sessions = mutableListOf() - for (element in sessionArray) { - if (!element.isJsonObject) continue - when (val result = parseSession(element.asJsonObject)) { - is ApiResult.Failure -> return@mapJsonArrayResponse result - is ApiResult.Success -> sessions.add(result.value) - } - } - ApiResult.Success(sessions) - }.withParseContext(endpoint) - } - private fun parseSession(obj: JsonObject): ApiResult { val id = obj.getStringOrNull("id") if (id.isNullOrBlank()) { @@ -256,10 +332,11 @@ class SessionApiClient( } val projectID = obj.getStringOrNull("projectID") - val directory = obj.getStringOrNull("directory") val parentID = obj.getStringOrNull("parentID") val title = obj.getStringOrNull("title") - val version = obj.getStringOrNull("version") + // v2 reports the session location as {directory, workspaceID?}; v1 had a flat + // `directory` field. + val directory = obj.getObjectOrNull("location")?.getStringOrNull("directory") val timeObj = obj.getObjectOrNull("time") val timeCreated = timeObj?.get("created") @@ -268,43 +345,7 @@ class SessionApiClient( val timeUpdated = timeObj?.get("updated") ?.takeIf { it.isJsonPrimitive } ?.let { runCatching { it.asLong }.getOrNull() } ?: 0L - val timeCompacting = timeObj?.get("compacting") - ?.takeIf { it.isJsonPrimitive } - ?.let { runCatching { it.asLong }.getOrNull() } - val sessionTime = SessionTime(created = timeCreated, updated = timeUpdated, compacting = timeCompacting) - - val summaryObj = obj.getObjectOrNull("summary") - val summary = if (summaryObj != null) { - val additions = summaryObj.getIntOrNull("additions") ?: 0 - val deletions = summaryObj.getIntOrNull("deletions") ?: 0 - val files = summaryObj.getIntOrNull("files") ?: 0 - val diffsArray = summaryObj.get("diffs") - ?.takeIf { it.isJsonArray } - ?.asJsonArray - val diffs = diffsArray?.mapNotNull { diffElement -> - if (!diffElement.isJsonObject) return@mapNotNull null - val diffObj = diffElement.asJsonObject - val file = diffObj.getStringOrNull("file") ?: return@mapNotNull null - val diffText = PatchDiffTextParser.parse(diffObj) - FileDiff( - file = file, - before = diffText.before, - after = diffText.after, - additions = diffObj.getIntOrNull("additions") ?: 0, - deletions = diffObj.getIntOrNull("deletions") ?: 0, - ) - } - SessionSummary(additions = additions, deletions = deletions, files = files, diffs = diffs) - } else { - null - } - - val shareObj = obj.getObjectOrNull("share") - val share = if (shareObj != null) { - SessionShare(url = shareObj.getStringOrNull("url")) - } else { - null - } + val sessionTime = SessionTime(created = timeCreated, updated = timeUpdated, compacting = null) return ApiResult.Success( Session( @@ -313,28 +354,11 @@ class SessionApiClient( directory = directory, parentID = parentID, title = title, - version = version, time = sessionTime, - summary = summary, - share = share, - ) + ), ) } - fun deleteSession(port: Int, sessionId: String): ApiResult { - val endpoint = SessionEndpoints.delete(sessionId) - val response = transport.delete(port = port, path = endpoint.path) - return transport.parseBooleanResponse(response).withParseContext(endpoint) - } - - fun updateSession(port: Int, sessionId: String, title: String): ApiResult { - val endpoint = SessionEndpoints.update(sessionId) - val payload = JsonObject().apply { - addProperty("title", title) - } - val response = transport.patch(port = port, path = endpoint.path, payload = payload.toString()) - return transport.mapJsonObjectResponse(response) { parseSession(it) } - .withParseContext(endpoint) - } - + private fun JsonElement.asJsonObjectOrNull(): JsonObject? = + if (isJsonObject) asJsonObject else null } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionEndpoints.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionEndpoints.kt index 8296a4d..e8ba5a1 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionEndpoints.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionEndpoints.kt @@ -6,22 +6,44 @@ import java.net.URLEncoder import java.nio.charset.StandardCharsets internal object SessionEndpoints { - fun create(): ApiEndpoint = ApiEndpoint(method = HttpMethod.POST, path = "/session") + fun create(): ApiEndpoint = ApiEndpoint(method = HttpMethod.POST, path = "/api/session") + fun prompt(sessionId: String): ApiEndpoint = + ApiEndpoint(method = HttpMethod.POST, path = "/api/session/$sessionId/prompt") - fun promptAsync(sessionId: String): ApiEndpoint = - ApiEndpoint(method = HttpMethod.POST, path = "/session/$sessionId/prompt_async") + fun list(): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/api/session") - fun list(): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/session") + /** + * Diff of the newest user turn. v2 defaults `from` to the turn of the newest + * user message, so no query is needed. + */ + fun diffLatestTurn(sessionId: String): ApiEndpoint = + ApiEndpoint(method = HttpMethod.GET, path = "/api/session/$sessionId/diff") - fun diff(sessionId: String, messageId: String): ApiEndpoint { - val query = "?messageID=${URLEncoder.encode(messageId, StandardCharsets.UTF_8)}" - return ApiEndpoint(method = HttpMethod.GET, path = "/session/$sessionId/diff$query") + /** Diff of the turn that starts at the given user message. */ + fun diffTurn(sessionId: String, fromUserMessageId: String): ApiEndpoint { + val query = "?from=${encode(fromUserMessageId)}" + return ApiEndpoint(method = HttpMethod.GET, path = "/api/session/$sessionId/diff$query") } - fun messages(sessionId: String): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/session/$sessionId/message") + /** Diff of the range of user-message turns from [fromUserMessageId] through [toUserMessageId]. */ + fun diffRange( + sessionId: String, + fromUserMessageId: String, + toUserMessageId: String, + ): ApiEndpoint { + val query = "?from=${encode(fromUserMessageId)}&to=${encode(toUserMessageId)}" + return ApiEndpoint(method = HttpMethod.GET, path = "/api/session/$sessionId/diff$query") + } + + private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) + + fun messages(sessionId: String): ApiEndpoint = + ApiEndpoint(method = HttpMethod.GET, path = "/api/session/$sessionId/message") - fun delete(sessionId: String): ApiEndpoint = ApiEndpoint(method = HttpMethod.DELETE, path = "/session/$sessionId") + fun delete(sessionId: String): ApiEndpoint = + ApiEndpoint(method = HttpMethod.DELETE, path = "/api/session/$sessionId") - fun update(sessionId: String): ApiEndpoint = ApiEndpoint(method = HttpMethod.PATCH, path = "/session/$sessionId") + fun update(sessionId: String): ApiEndpoint = + ApiEndpoint(method = HttpMethod.PATCH, path = "/api/session/$sessionId") } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/core/MessageDiffFetchCoalescer.kt b/src/main/kotlin/com/ashotn/opencode/relay/core/MessageDiffFetchCoalescer.kt index e133433..fc3dd3a 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/MessageDiffFetchCoalescer.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/MessageDiffFetchCoalescer.kt @@ -1,42 +1,60 @@ package com.ashotn.opencode.relay.core -import com.ashotn.opencode.relay.ipc.OpenCodeEvent - +/** + * Coalesces concurrent diff snapshot fetches so at most one fetch runs per key. + * + * In OpenCode 2.x the live snapshot is the latest turn of a session (no per-message + * diff events), so [liveTurnKey] keys fetches by session. When a fetch is already + * in flight for a key, [tryStart] reports that and the caller may rely on + * [finish] to learn that a follow-up fetch is warranted. + */ internal class MessageDiffFetchCoalescer { - private val lock = Any() - private val inFlight = mutableSetOf() - private val pendingByKey = mutableMapOf() - fun key(event: OpenCodeEvent.MessageDiffAvailable): String = key(event.sessionId, event.messageId) + companion object { + private const val LIVE_TURN = "latest" - fun key(sessionId: String, messageId: String): String = "$sessionId:$messageId" + /** Key used for the latest-turn snapshot fetch of a session. */ + fun liveTurnKey(sessionId: String): String = key(sessionId, LIVE_TURN) - fun tryStart(event: OpenCodeEvent.MessageDiffAvailable): Boolean = synchronized(lock) { - val key = key(event) + fun key(sessionId: String, turn: String): String = "$sessionId:$turn" + } + + private val lock = Any() + private val inFlight = mutableSetOf() + private val pendingKeys = mutableSetOf() + + fun tryStart(key: String): Boolean = synchronized(lock) { if (inFlight.add(key)) { true } else { - pendingByKey[key] = event + pendingKeys.add(key) false } } - fun finish(key: String, allowPending: Boolean): OpenCodeEvent.MessageDiffAvailable? = synchronized(lock) { - val pending = if (allowPending) pendingByKey.remove(key) else null - if (pending == null) { - inFlight.remove(key) - if (!allowPending) pendingByKey.remove(key) + /** + * Releases the key and reports whether a follow-up fetch is pending for it. + * When [allowPending] is false (the listener generation went stale), pending + * requests are discarded instead. + */ + fun finish(key: String, allowPending: Boolean): Boolean = synchronized(lock) { + inFlight.remove(key) + if (allowPending) { + // Set.remove reports whether a follow-up fetch was pending. + pendingKeys.remove(key) + } else { + pendingKeys.remove(key) + false } - pending } fun cancel(key: String) = synchronized(lock) { inFlight.remove(key) - pendingByKey.remove(key) + pendingKeys.remove(key) } fun clear() = synchronized(lock) { inFlight.clear() - pendingByKey.clear() + pendingKeys.clear() } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt b/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt index cd9356b..10d4d97 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt @@ -32,7 +32,6 @@ internal fun selectMessageSummaryFileCountLoadBatch( maxBatchSize: Int, shouldLoad: (Session) -> Boolean, ): List = sessions.asSequence() - .filter { it.summary != null } .filter(shouldLoad) .sortedByDescending { it.time.updated } .take(maxBatchSize) @@ -166,11 +165,11 @@ class OpenCodeCoreService(private val project: Project) : Disposable { refreshSessionHierarchyAsync(generation) } - is OpenCodeEvent.MessageDiffAvailable -> { - handleMessageDiffAvailable(event, generation) + is OpenCodeEvent.SessionExecutionFinished -> { + scheduleLiveTurnDiffFetch(event.sessionId, generation) } - is OpenCodeEvent.McpToolsChanged -> { + is OpenCodeEvent.McpStatusChanged -> { project.messageBus.syncPublisher(McpChangedListener.TOPIC).onMcpChanged() } @@ -179,24 +178,22 @@ class OpenCodeCoreService(private val project: Project) : Disposable { } } - private fun handleMessageDiffAvailable(event: OpenCodeEvent.MessageDiffAvailable, generation: Long) { + /** + * Fetches and applies the latest-turn diff snapshot after a session execution + * finished. Coalesced per session so bursty execution end events trigger at most + * one in-flight fetch. + */ + private fun scheduleLiveTurnDiffFetch(sessionId: String, generation: Long) { if (generation != lifecycleGeneration.get()) return - if (!messageDiffFetchCoalescer.tryStart(event)) { - log.debug("OpenCodeCoreService: defer message diff reason=alreadyInFlight session=${event.sessionId} message=${event.messageId} generation=$generation") + val key = MessageDiffFetchCoalescer.liveTurnKey(sessionId) + if (!messageDiffFetchCoalescer.tryStart(key)) { + log.debug("OpenCodeCoreService: defer live turn diff reason=alreadyInFlight session=$sessionId generation=$generation") return } - scheduleMessageDiffFetch(event, messageDiffFetchCoalescer.key(event), generation) - } - - private fun scheduleMessageDiffFetch( - event: OpenCodeEvent.MessageDiffAvailable, - key: String, - generation: Long, - ) { val currentPort = port if (currentPort <= 0) { - log.debug("OpenCodeCoreService: skip message diff reason=missingPort session=${event.sessionId} message=${event.messageId} generation=$generation") + log.debug("OpenCodeCoreService: skip live turn diff reason=missingPort session=$sessionId generation=$generation") messageDiffFetchCoalescer.cancel(key) return } @@ -204,14 +201,13 @@ class OpenCodeCoreService(private val project: Project) : Disposable { ApplicationManager.getApplication().executeOnPooledThread { try { if (generation != lifecycleGeneration.get()) return@executeOnPooledThread - val eventDiff = when (val result = sessionApiClient.fetchSessionDiffSnapshot( + val snapshot = when (val result = sessionApiClient.fetchLatestTurnDiffSnapshot( port = currentPort, - sessionId = event.sessionId, - messageId = event.messageId, + sessionId = sessionId, )) { is ApiResult.Failure -> { log.debug( - "OpenCodeCoreService: skip message diff reason=requestFailed session=${event.sessionId} message=${event.messageId} port=$currentPort generation=$generation error=${result.error}", + "OpenCodeCoreService: skip live turn diff reason=requestFailed session=$sessionId port=$currentPort generation=$generation error=${result.error}", ) return@executeOnPooledThread } @@ -219,18 +215,18 @@ class OpenCodeCoreService(private val project: Project) : Disposable { is ApiResult.Success -> result.value } if (generation != lifecycleGeneration.get()) return@executeOnPooledThread - if (eventDiff.files.isEmpty()) { - log.debug("OpenCodeCoreService: skip message diff reason=emptySnapshot session=${event.sessionId} message=${event.messageId} generation=$generation") + if (snapshot.files.isEmpty()) { + log.debug("OpenCodeCoreService: skip live turn diff reason=emptySnapshot session=$sessionId generation=$generation") return@executeOnPooledThread } log.debug( - "OpenCodeCoreService: apply message diff session=${event.sessionId} message=${event.messageId} eventFiles=${event.files} fileCount=${eventDiff.files.size} files=${eventDiff.files.map { it.file }} generation=$generation", + "OpenCodeCoreService: apply live turn diff session=$sessionId fileCount=${snapshot.files.size} files=${snapshot.files.map { it.file }} generation=$generation", ) - handleSessionDiff(eventDiff, fromHistory = false, generation = generation) + handleSessionDiff(snapshot, fromHistory = false, generation = generation) } catch (e: Exception) { log.debug( - "OpenCodeCoreService: failed message diff load session=${event.sessionId} message=${event.messageId} port=$currentPort generation=$generation", + "OpenCodeCoreService: failed live turn diff load session=$sessionId port=$currentPort generation=$generation", e, ) } finally { @@ -238,9 +234,9 @@ class OpenCodeCoreService(private val project: Project) : Disposable { key = key, allowPending = generation == lifecycleGeneration.get(), ) - if (pending != null) { - log.debug("OpenCodeCoreService: refetch pending message diff session=${pending.sessionId} message=${pending.messageId} generation=$generation") - scheduleMessageDiffFetch(pending, key, generation) + if (pending && generation == lifecycleGeneration.get()) { + log.debug("OpenCodeCoreService: refetch pending live turn diff session=$sessionId generation=$generation") + scheduleLiveTurnDiffFetch(sessionId, generation) } } } @@ -841,8 +837,9 @@ class OpenCodeCoreService(private val project: Project) : Disposable { is ApiResult.Success -> result.value } + // v2 reports changed files on assistant messages (snapshot.files). val fileCount = summaries - .filter { it.role == "user" } + .filter { it.role == "assistant" } .flatMap { it.files } .toSet() .size @@ -923,7 +920,10 @@ class OpenCodeCoreService(private val project: Project) : Disposable { try { if (generation != lifecycleGeneration.get()) return@executeOnPooledThread - val sessions: List = when (val result = sessionApiClient.fetchSessionHierarchy(currentPort)) { + val sessions: List = when (val result = sessionApiClient.fetchSessionHierarchy( + port = currentPort, + directory = project.basePath, + )) { is ApiResult.Success -> result.value is ApiResult.Failure -> { log.debug( diff --git a/src/main/kotlin/com/ashotn/opencode/relay/core/QueryService.kt b/src/main/kotlin/com/ashotn/opencode/relay/core/QueryService.kt index 067ea68..2af2b5d 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/QueryService.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/QueryService.kt @@ -83,20 +83,19 @@ internal class QueryService { val localTrackedFileCount = ((hunksBySessionAndFile[sessionId]?.keys ?: emptySet()) + (addedBySession[sessionId] ?: emptySet()) + (deletedBySession[sessionId] ?: emptySet())).size + // v2 sessions carry no server-side summary; the file count loaded + // from assistant message snapshots and local diff state are the + // available signals that a session has content. + val loadedFileCount = messageSummaryFileCountBySession[sessionId] + val hasMessages = hasLocalDiffState || (loadedFileCount ?: 0) > 0 SessionInfo( sessionId = sessionId, parentSessionId = session?.parentID, title = session?.title ?: sessionId.take(12), isBusy = busyBySession[sessionId] == true, - trackedFileCount = if (hasLocalDiffState) { - localTrackedFileCount - } else if (session?.summary == null) { - 0 - } else { - messageSummaryFileCountBySession[sessionId] - }, + trackedFileCount = if (hasLocalDiffState) localTrackedFileCount else (loadedFileCount ?: 0), updatedAtMillis = updatedAtBySession[sessionId] ?: 0L, - hasMessages = session?.summary != null, + hasMessages = hasMessages, ) } .sortedWith( diff --git a/src/main/kotlin/com/ashotn/opencode/relay/core/session/SessionInfo.kt b/src/main/kotlin/com/ashotn/opencode/relay/core/session/SessionInfo.kt index febd423..5da0c5e 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/session/SessionInfo.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/session/SessionInfo.kt @@ -7,6 +7,6 @@ data class SessionInfo( val isBusy: Boolean, val trackedFileCount: Int?, val updatedAtMillis: Long, - /** True if the server has reported a summary for this session, indicating it has messages. */ + /** True if the session has content — local diff state or a loaded file count. */ val hasMessages: Boolean, ) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/ipc/McpChangedListener.kt b/src/main/kotlin/com/ashotn/opencode/relay/ipc/McpChangedListener.kt index 0fb5260..82a8256 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ipc/McpChangedListener.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ipc/McpChangedListener.kt @@ -3,11 +3,9 @@ package com.ashotn.opencode.relay.ipc import com.intellij.util.messages.Topic /** - * Published on the project message bus when an MCP server reports its tool list - * has changed ([OpenCodeEvent.McpToolsChanged] via SSE). - * - * This is the best available proxy for MCP connection status changes, since the - * server does not emit a dedicated connect/disconnect event. + * Published on the project message bus when an MCP server's connection status + * has changed ([OpenCodeEvent.McpStatusChanged] via SSE, sourced from the + * server's `mcp.status.changed` event). */ fun interface McpChangedListener { diff --git a/src/main/kotlin/com/ashotn/opencode/relay/ipc/OpenCodeEvent.kt b/src/main/kotlin/com/ashotn/opencode/relay/ipc/OpenCodeEvent.kt index 998ff94..f8b6d0d 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ipc/OpenCodeEvent.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ipc/OpenCodeEvent.kt @@ -1,7 +1,7 @@ package com.ashotn.opencode.relay.ipc /** - * Typed representation of SSE events received from the OpenCode server. + * Typed representation of SSE events received from the OpenCode 2.x server. * * Only the subset of events relevant to the plugin are modelled here. * Unknown event types are silently discarded by [SseClient]. @@ -12,7 +12,10 @@ sealed class OpenCodeEvent { data object ServerConnected : OpenCodeEvent() /** - * Canonical session execution state from session.status. + * Canonical session execution state. + * + * Sourced from `session.status` (`data.status.type`), `session.idle`, + * and `session.execution.started`. */ data class SessionStatus( val sessionId: String, @@ -25,16 +28,18 @@ sealed class OpenCodeEvent { RETRY, } + /** + * A session execution finished (`session.execution.succeeded`, + * `session.execution.failed`, or `session.execution.interrupted`). + * + * In OpenCode 2.x the per-message diff summary of v1 is gone, so the end of an + * execution is the signal that the latest turn's diff snapshot may have changed. + */ + data class SessionExecutionFinished(val sessionId: String) : OpenCodeEvent() + /** session lifecycle changed — refresh session hierarchy metadata. */ data class SessionLifecycleChanged(val sessionId: String) : OpenCodeEvent() - /** message.updated for a user message whose summary contains per-message diffs. */ - data class MessageDiffAvailable( - val sessionId: String, - val messageId: String, - val files: List = emptyList(), - ) : OpenCodeEvent() - /** * permission.replied — fired when any client (plugin, TUI, CLI) resolves a * pending permission request. Lets us drain the queue even when the reply @@ -47,20 +52,20 @@ sealed class OpenCodeEvent { ) : OpenCodeEvent() /** - * mcp.tools.changed — fired when an MCP server reports its tool list has changed. - * Used as a proxy signal that an MCP server's connection status may have changed. + * mcp.status.changed — fired when an MCP server's connection status changed. + * Used as the signal that the MCP server list may need a refresh. */ - data class McpToolsChanged(val serverName: String) : OpenCodeEvent() + data class McpStatusChanged(val serverName: String) : OpenCodeEvent() /** * permission.asked — fired when OpenCode needs the user to approve a tool * call before it proceeds. OpenCode blocks until a reply is posted to - * POST /permission/:requestId/reply. + * POST /api/session/:sessionID/permission/:requestID/reply. * * @param requestId Unique permission request ID (per_ prefix) * @param sessionId Session that triggered the request - * @param permission Human-readable permission name, e.g. "execute_bash_command" - * @param patterns Glob patterns describing what will be affected + * @param permission The permission action, e.g. "external_directory" (v2 `action`) + * @param patterns Resource globs describing what will be affected (v2 `resources`) * @param metadata Extra context from the tool, e.g. the shell command being run */ data class PermissionAsked( diff --git a/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt index 41941f8..e5dffd6 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt @@ -16,13 +16,19 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean /** + * Consumes the OpenCode 2.x SSE event stream. + * * Connects to the OpenCode server's durable SSE event stream (`GET /api/event`) and - * dispatches parsed [OpenCodeEvent]s to a listener on a background thread. + * dispatches typed [OpenCodeEvent]s to [onEvent]. The client transparently reconnects + * with exponential backoff when the stream drops; a 401 response stops the loop and + * reports the failure via [onAuthenticationFailure] (the caller decides how to recover). * - * - Automatically reconnects on disconnect (with bounded backoff). - * - Stops cleanly when [stop] is called. + * Event envelope (v2): each `data:` line carries a single JSON object of the shape + * `{"id", "created", "type", "location"?, "data": {...}}`. Events whose + * `location.directory` differs from [directory] are dropped (the stream is global to + * the server, but this plugin only tracks one project). * - * Thread safety: [start] and [stop] may be called from any thread. + * All callbacks ([onEvent]) are invoked on the client's internal reader thread. */ class SseClient( private val port: Int, @@ -136,20 +142,36 @@ class SseClient( val rootElement = JsonParser.parseString(json) if (!rootElement.isJsonObject) return - val root = unwrapGlobalEvent(rootElement.asJsonObject) ?: return + val root = rootElement.asJsonObject + if (!isForThisDirectory(root)) return + val type = root.getStringOrNull("type") ?: return - val properties = root.getObjectOrNull("properties") ?: JsonObject() + val payload = root.getObjectOrNull("data") ?: JsonObject() val event: OpenCodeEvent? = when (type) { "server.connected" -> OpenCodeEvent.ServerConnected - "server.heartbeat", "sync", "project.updated" -> null - "message.updated" -> parseMessageUpdated(properties) + "session.status" -> parseSessionStatus(payload) + // session.idle is a standalone idle marker in v2. + "session.idle" -> parseSessionIdle(payload) + // The start of an execution is a reliable busy signal. + "session.execution.started" -> parseExecutionStarted(payload) + // A finished execution is when the latest turn's diff can change. + "session.execution.succeeded", + "session.execution.failed", + "session.execution.interrupted", + -> parseExecutionFinished(payload) // Any lifecycle change can alter the session list/order/title, so all refresh the hierarchy. - "session.created", "session.updated", "session.deleted" -> parseSessionLifecycleChanged(properties) - "session.status" -> parseSessionStatus(properties) - "mcp.tools.changed" -> parseMcpToolsChanged(properties) - "permission.asked" -> parsePermissionAsked(properties) - "permission.replied" -> parsePermissionReplied(properties) + "session.created", + "session.deleted", + "session.forked", + "session.moved", + "session.renamed", + "session.metadata.updated", + -> parseSessionLifecycleChanged(payload) + "permission.asked" -> parsePermissionAsked(payload) + "permission.replied" -> parsePermissionReplied(payload) + "mcp.status.changed" -> parseMcpStatusChanged(payload) + "project.updated" -> null else -> { if (unknownEventTypes.add(type)) { log.warn("SseClient: unknown event type ignored: $type") @@ -166,20 +188,15 @@ class SseClient( } } - private fun isExpectedDisconnect(e: Exception): Boolean { - if (e !is IOException) return false - val message = e.message?.lowercase() ?: return false - return message.contains("stream is closed") || message.contains("socket closed") - } - - private fun unwrapGlobalEvent(root: JsonObject): JsonObject? { - val payload = root.getObjectOrNull("payload") ?: return root - val eventDirectory = root.getStringOrNull("directory") + /** + * v2 events carry an optional `location.directory`. When both the event and this + * client have a directory, events from other directories are dropped. + */ + private fun isForThisDirectory(root: JsonObject): Boolean { + val eventDirectory = root.getObjectOrNull("location")?.getStringOrNull("directory") val expectedDirectory = directory - if (eventDirectory != null && expectedDirectory != null && !sameDirectory(eventDirectory, expectedDirectory)) { - return null - } - return payload + if (eventDirectory == null || expectedDirectory == null) return true + return sameDirectory(eventDirectory, expectedDirectory) } private fun sameDirectory(left: String, right: String): Boolean = @@ -189,10 +206,10 @@ class SseClient( runCatching { Path.of(directory).toRealPath() } .getOrElse { Path.of(directory).toAbsolutePath().normalize() } - private fun parsePermissionReplied(props: JsonObject): OpenCodeEvent.PermissionReplied? { - val requestId = props.getStringOrNull("requestID") ?: return null - val sessionId = props.getStringOrNull("sessionID") ?: return null - val replyRaw = props.getStringOrNull("reply") ?: return null + private fun parsePermissionReplied(payload: JsonObject): OpenCodeEvent.PermissionReplied? { + val requestId = payload.getStringOrNull("requestID") ?: return null + val sessionId = payload.getStringOrNull("sessionID") ?: return null + val replyRaw = payload.getStringOrNull("reply") ?: return null val reply = PermissionReply.fromWire(replyRaw) if (reply == null) { log.warn("SseClient: unknown permission reply '$replyRaw' for requestID=$requestId") @@ -201,12 +218,14 @@ class SseClient( return OpenCodeEvent.PermissionReplied(requestId, sessionId, reply) } - private fun parsePermissionAsked(props: JsonObject): OpenCodeEvent.PermissionAsked? { - val id = props.getStringOrNull("id") ?: return null - val sessionId = props.getStringOrNull("sessionID") ?: return null - val permission = props.getStringOrNull("permission") ?: return null + private fun parsePermissionAsked(payload: JsonObject): OpenCodeEvent.PermissionAsked? { + val id = payload.getStringOrNull("id") ?: return null + val sessionId = payload.getStringOrNull("sessionID") ?: return null + // v2 names the fields `action` and `resources`; the plugin model keeps the + // v1-oriented names `permission` and `patterns`. + val permission = payload.getStringOrNull("action") ?: return null - val patterns = props.getAsJsonArray("patterns") + val patterns = payload.getAsJsonArray("resources") ?.mapNotNull { element -> if (!element.isJsonPrimitive) return@mapNotNull null element.asString @@ -214,7 +233,7 @@ class SseClient( ?: emptyList() val metadata = mutableMapOf() - props.getAsJsonObject("metadata") + payload.getAsJsonObject("metadata") ?.entrySet() ?.forEach { (key, value) -> if (!value.isJsonPrimitive) return@forEach @@ -231,9 +250,9 @@ class SseClient( return OpenCodeEvent.PermissionAsked(id, sessionId, permission, patterns, metadata) } - private fun parseSessionStatus(props: JsonObject): OpenCodeEvent.SessionStatus? { - val sessionId = props.getStringOrNull("sessionID") ?: return null - val status = props.get("status") ?: return null + private fun parseSessionStatus(payload: JsonObject): OpenCodeEvent.SessionStatus? { + val sessionId = payload.getStringOrNull("sessionID") ?: return null + val status = payload.get("status") ?: return null val statusType = status.takeIf { it.isJsonObject } ?.asJsonObject ?.getStringOrNull("type") @@ -250,40 +269,35 @@ class SseClient( return OpenCodeEvent.SessionStatus(sessionId, parsedStatus) } - private fun parseSessionLifecycleChanged(props: JsonObject): OpenCodeEvent.SessionLifecycleChanged? { - val sessionId = props.getStringOrNull("sessionID") - ?: props.getObjectOrNull("info")?.getStringOrNull("id") - ?: return null - return OpenCodeEvent.SessionLifecycleChanged(sessionId) + private fun parseSessionIdle(payload: JsonObject): OpenCodeEvent.SessionStatus? { + val sessionId = payload.getStringOrNull("sessionID") ?: return null + return OpenCodeEvent.SessionStatus(sessionId, OpenCodeEvent.SessionStatusType.IDLE) } - // message.updated only tells us which user message has diffs; - // the actual patch content is fetched later via /session/{id}/diff?messageID=... - private fun parseMessageUpdated(props: JsonObject): OpenCodeEvent.MessageDiffAvailable? { - val info = props.getObjectOrNull("info") ?: return null - if (info.getStringOrNull("role") != "user") return null + private fun parseExecutionStarted(payload: JsonObject): OpenCodeEvent.SessionStatus? { + val sessionId = payload.getStringOrNull("sessionID") ?: return null + return OpenCodeEvent.SessionStatus(sessionId, OpenCodeEvent.SessionStatusType.BUSY) + } - val sessionId = props.getStringOrNull("sessionID") - ?: info.getStringOrNull("sessionID") - ?: return null - val messageId = info.getStringOrNull("id") ?: return null - val diffs = info.getObjectOrNull("summary") - ?.get("diffs") - ?.takeIf { it.isJsonArray } - ?.asJsonArray - ?: return null - val files = diffs.mapNotNull { element -> - if (!element.isJsonObject) return@mapNotNull null - element.asJsonObject.getStringOrNull("file")?.takeIf { it.isNotBlank() } - } - if (files.isEmpty()) return null + private fun parseExecutionFinished(payload: JsonObject): OpenCodeEvent.SessionExecutionFinished? { + val sessionId = payload.getStringOrNull("sessionID") ?: return null + return OpenCodeEvent.SessionExecutionFinished(sessionId) + } + + private fun parseSessionLifecycleChanged(payload: JsonObject): OpenCodeEvent.SessionLifecycleChanged? { + val sessionId = payload.getStringOrNull("sessionID") ?: return null + return OpenCodeEvent.SessionLifecycleChanged(sessionId) + } - return OpenCodeEvent.MessageDiffAvailable(sessionId, messageId, files) + private fun parseMcpStatusChanged(payload: JsonObject): OpenCodeEvent.McpStatusChanged? { + val server = payload.getStringOrNull("server") ?: return null + return OpenCodeEvent.McpStatusChanged(server) } - private fun parseMcpToolsChanged(props: JsonObject): OpenCodeEvent.McpToolsChanged? { - val server = props.getStringOrNull("server") ?: return null - return OpenCodeEvent.McpToolsChanged(server) + private fun isExpectedDisconnect(e: Exception): Boolean { + if (e !is IOException) return false + val message = e.message?.lowercase() ?: return false + return message.contains("stream is closed") || message.contains("socket closed") } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt index 06b4044..6245219 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt @@ -183,7 +183,10 @@ class OpenCodeTuiClient(private val project: Project) { } /** - * Renames a session via PATCH /session/{sessionId}. + * Renames a session via PATCH /api/session/{sessionId}. + * + * v2 returns 204 without a body, so the updated title is picked up by the + * hierarchy refresh that follows. */ fun renameSession(sessionId: String, newTitle: String, onResult: (success: Boolean, error: String?) -> Unit) { val currentPort = port @@ -195,11 +198,7 @@ class OpenCodeTuiClient(private val project: Project) { ApplicationManager.getApplication().executeOnPooledThread { val coreService = OpenCodeCoreService.getInstance(project) when (val result = sessionApiClient.updateSession(currentPort, sessionId, newTitle)) { - is ApiResult.Success -> { - coreService.updateSessionState(result.value) - onResult(true, null) - } - + is ApiResult.Success -> onResult(true, null) is ApiResult.Failure -> onResult(false, apiErrorMessage(result.error)) } coreService.refreshSessionHierarchy() diff --git a/src/test/kotlin/com/ashotn/opencode/relay/api/permission/PermissionApiClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/api/permission/PermissionApiClientTest.kt index ad83fdb..0fcd5db 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/api/permission/PermissionApiClientTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/api/permission/PermissionApiClientTest.kt @@ -11,41 +11,40 @@ import kotlin.test.assertTrue class PermissionApiClientTest { @Test - fun `reply sends response payload and parses boolean`() { + fun `reply sends decision payload and parses empty 204 as success`() { withTestServer { server, port -> var requestBody = "" - server.createContext("/session/ses_1/permissions/per_1") { exchange -> + server.createContext("/api/session/ses_1/permission/per_1/reply") { exchange -> requestBody = exchange.requestBody.bufferedReader(Charsets.UTF_8).use { it.readText() } - val body = "true" - exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) - exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + exchange.sendResponseHeaders(204, -1) + exchange.close() } val client = PermissionApiClient() - val result = client.reply(port, "ses_1", "per_1", "allow") + val result = client.reply(port, "ses_1", "per_1", "once") val success = assertIs>(result) assertEquals(true, success.value) - assertTrue(requestBody.contains("\"response\":\"allow\"")) + assertTrue(requestBody.contains("\"decision\":\"once\"")) } } @Test fun `reply returns failure for invalid payload`() { withTestServer { server, port -> - server.createContext("/session/ses_1/permissions/per_1") { exchange -> + server.createContext("/api/session/ses_1/permission/per_1/reply") { exchange -> val body = "{\"ok\":\"yes\"}" exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } } val client = PermissionApiClient() - val result = client.reply(port, "ses_1", "per_1", "allow") + val result = client.reply(port, "ses_1", "per_1", "once") val failure = assertIs(result) val error = assertIs(failure.error) assertEquals( - "POST /session/ses_1/permissions/per_1: Expected boolean permission response payload", + "POST /api/session/ses_1/permission/per_1/reply: Expected boolean permission response payload", error.message, ) } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt index 1ed26e6..5c26182 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt @@ -7,14 +7,15 @@ import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertTrue class SessionApiClientTest { @Test - fun `createSession parses id field`() { + fun `createSession parses id from data envelope`() { withTestServer { server, port -> - server.createContext("/session") { exchange -> - val body = "{\"id\":\"ses_123\"}" + server.createContext("/api/session") { exchange -> + val body = "{\"data\":{\"id\":\"ses_123\"}}" exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } } @@ -30,8 +31,8 @@ class SessionApiClientTest { @Test fun `createSession returns failure when id field is missing`() { withTestServer { server, port -> - server.createContext("/session") { exchange -> - val body = "{\"sessionID\":\"ses_456\"}" + server.createContext("/api/session") { exchange -> + val body = "{\"data\":{\"sessionID\":\"ses_456\"}}" exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } } @@ -44,26 +45,63 @@ class SessionApiClientTest { } @Test - fun `fetchSessionDiffSnapshot parses patch based diff files`() { + fun `createSession returns failure on non-2xx`() { + withTestServer { server, port -> + server.createContext("/api/session") { exchange -> + val body = "{\"error\":\"boom\"}" + exchange.sendResponseHeaders(500, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + + val client = SessionApiClient() + val result = client.createSession(port) + + assertIs(result) + } + } + + @Test + fun `promptText posts text payload`() { + withTestServer { server, port -> + var requestBody = "" + server.createContext("/api/session/ses_1/prompt") { exchange -> + requestBody = exchange.requestBody.bufferedReader(Charsets.UTF_8).use { it.readText() } + val body = "{\"data\":{\"id\":\"msg_1\",\"type\":\"user\"}}" + exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + + val client = SessionApiClient() + val result = client.promptText(port, "ses_1", "hello world") + + assertIs>(result) + assertTrue(requestBody.contains("\"text\":\"hello world\"")) + } + } + + @Test + fun `fetchLatestTurnDiffSnapshot parses patch based diff files`() { withTestServer { server, port -> - server.createContext("/session/ses_1/diff") { exchange -> + server.createContext("/api/session/ses_1/diff") { exchange -> val body = """ - [ - { - "file": "a.txt", - "patch": "Index: a.txt\n===================================================================\n--- a.txt\t\n+++ a.txt\t\n@@ -1 +1 @@\n-old\n+new\n", - "additions": 1, - "deletions": 1, - "status": "modified" - } - ] + { + "data": [ + { + "file": "a.txt", + "patch": "Index: a.txt\n===================================================================\n--- a.txt\t\n+++ a.txt\t\n@@ -1 +1 @@\n-old\n+new\n", + "additions": 1, + "deletions": 1, + "status": "modified" + } + ] + } """.trimIndent() exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } } val client = SessionApiClient() - val result = client.fetchSessionDiffSnapshot(port, "ses_1", "msg_1") + val result = client.fetchLatestTurnDiffSnapshot(port, "ses_1") val success = assertIs>(result) assertEquals("ses_1", success.value.sessionId) @@ -75,15 +113,15 @@ class SessionApiClientTest { } @Test - fun `fetchSessionDiffSnapshot returns empty snapshot for empty body`() { + fun `fetchLatestTurnDiffSnapshot returns empty snapshot for empty body`() { withTestServer { server, port -> - server.createContext("/session/ses_1/diff") { exchange -> + server.createContext("/api/session/ses_1/diff") { exchange -> exchange.sendResponseHeaders(204, -1) exchange.close() } val client = SessionApiClient() - val result = client.fetchSessionDiffSnapshot(port, "ses_1", "msg_1") + val result = client.fetchLatestTurnDiffSnapshot(port, "ses_1") val success = assertIs>(result) assertEquals("ses_1", success.value.sessionId) @@ -92,49 +130,138 @@ class SessionApiClientTest { } @Test - fun `fetchSessionDiffSnapshot returns failure on malformed array payload`() { + fun `fetchLatestTurnDiffSnapshot returns failure on malformed array payload`() { withTestServer { server, port -> - server.createContext("/session/ses_1/diff") { exchange -> - val body = "{\"not\":\"array\"}" + server.createContext("/api/session/ses_1/diff") { exchange -> + val body = "{\"data\":{\"not\":\"array\"}}" exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } } val client = SessionApiClient() - val result = client.fetchSessionDiffSnapshot(port, "ses_1", "msg_1") + val result = client.fetchLatestTurnDiffSnapshot(port, "ses_1") val failure = assertIs(result) val error = assertIs(failure.error) - assertEquals("GET /session/ses_1/diff?messageID=msg_1: Expected JSON array payload", error.message) + assertEquals("GET /api/session/ses_1/diff: Expected JSON array payload", error.message) } } - // ------------------------------------------------------------------------- - // When the user opens the 3-panel diff viewer for a file the AI changed, - // the "Before AI" and "After AI" panels must show the AI's actual change. - // Blank side panels hide what the AI changed and make the diff viewer useless. - // ------------------------------------------------------------------------- @Test - fun `fetchFileDiffPreview reconstructs preview from patch-only diff`() { + fun `fetchSessionDiffSnapshot fetches first to last user turn range`() { withTestServer { server, port -> - server.createContext("/session/ses_1/diff") { exchange -> + val diffQueries = mutableListOf() + server.createContext("/api/session/ses_1/message") { exchange -> val body = """ - [ + { + "data": [ + {"id": "msg_u1", "type": "user", "text": "first"}, + {"id": "msg_a1", "type": "assistant", "content": []}, + {"id": "msg_u2", "type": "user", "text": "second"} + ], + "cursor": {"next": null, "previous": null} + } + """.trimIndent() + exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + server.createContext("/api/session/ses_1/diff") { exchange -> + diffQueries.add(exchange.requestURI.query.orEmpty()) + val body = """ + { + "data": [ + { + "file": "a.txt", + "patch": "Index: a.txt\n===================================================================\n--- /dev/null\n+++ a.txt\n@@ -0,0 +1 @@\n+new\n", + "additions": 1, + "deletions": 0, + "status": "added" + } + ] + } + """.trimIndent() + exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + + val client = SessionApiClient() + val result = client.fetchSessionDiffSnapshot(port, "ses_1") + + val success = assertIs>(result) + assertEquals(1, success.value.files.size) + assertEquals("a.txt", success.value.files.first().file) + assertEquals(listOf("from=msg_u1&to=msg_u2"), diffQueries) + } + } + + @Test + fun `fetchSessionDiffSnapshot falls back to latest turn when session has no user messages`() { + withTestServer { server, port -> + val diffQueries = mutableListOf() + server.createContext("/api/session/ses_1/message") { exchange -> + val body = """ + { + "data": [ + {"id": "msg_a1", "type": "assistant", "content": []} + ], + "cursor": {"next": null, "previous": null} + } + """.trimIndent() + exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + server.createContext("/api/session/ses_1/diff") { exchange -> + diffQueries.add(exchange.requestURI.query) + val body = """ + {"data": [ { "file": "a.txt", - "patch": "Index: a.txt\n===================================================================\n--- a.txt\t\n+++ a.txt\t\n@@ -1 +1 @@\n-old\n+new\n", + "patch": "Index: a.txt\n===================================================================\n--- /dev/null\n+++ a.txt\n@@ -0,0 +1 @@\n+new\n", "additions": 1, - "deletions": 1, - "status": "modified" + "deletions": 0, + "status": "added" } - ] + ]} + """.trimIndent() + exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + + val client = SessionApiClient() + val result = client.fetchSessionDiffSnapshot(port, "ses_1") + + val success = assertIs>(result) + assertEquals(1, success.value.files.size) + assertEquals(listOf(null), diffQueries) + } + } + + @Test + fun `fetchFileDiffPreview reconstructs preview from patch-only diff`() { + withTestServer { server, port -> + server.createContext("/api/session/ses_1/message") { exchange -> + val body = """ + { + "data": [ + {"id": "msg_u1", "type": "user", "text": "first"} + ], + "cursor": {"next": null, "previous": null} + } """.trimIndent() exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } } - server.createContext("/session/ses_1/message") { exchange -> + server.createContext("/api/session/ses_1/diff") { exchange -> val body = """ - [{"info":{"id":"msg_1","role":"user","summary":{"diffs":[{"file":"a.txt"}]}}}] + {"data": [ + { + "file": "a.txt", + "patch": "Index: a.txt\n===================================================================\n--- a.txt\t\n+++ a.txt\t\n@@ -1 +1 @@\n-old\n+new\n", + "additions": 1, + "deletions": 1, + "status": "modified" + } + ]} """.trimIndent() exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } @@ -152,9 +279,21 @@ class SessionApiClientTest { @Test fun `fetchFileDiffPreview returns success null when file is not in diff`() { withTestServer { server, port -> - server.createContext("/session/ses_1/diff") { exchange -> + server.createContext("/api/session/ses_1/message") { exchange -> + val body = """ + { + "data": [ + {"id": "msg_u1", "type": "user", "text": "first"} + ], + "cursor": {"next": null, "previous": null} + } + """.trimIndent() + exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + server.createContext("/api/session/ses_1/diff") { exchange -> val body = """ - [ + {"data": [ { "file": "a.txt", "patch": "Index: a.txt\n===================================================================\n--- a.txt\t\n+++ a.txt\t\n@@ -1 +1 @@\n-old\n+new\n", @@ -162,14 +301,7 @@ class SessionApiClientTest { "deletions": 1, "status": "modified" } - ] - """.trimIndent() - exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) - exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } - } - server.createContext("/session/ses_1/message") { exchange -> - val body = """ - [{"info":{"id":"msg_1","role":"user","summary":{"diffs":[{"file":"a.txt"}]}}}] + ]} """.trimIndent() exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } @@ -184,61 +316,97 @@ class SessionApiClientTest { } @Test - fun `fetchSessionHierarchy projects metadata`() { + fun `fetchSessionHierarchy projects metadata and sends directory query`() { withTestServer { server, port -> - server.createContext("/session") { exchange -> + var query: String? = null + server.createContext("/api/session") { exchange -> + query = exchange.requestURI.query val body = """ - [ - { - "id": "ses_1", - "title": "Root", - "time": { "created": 100, "updated": 123 } - }, - { - "id": "ses_2", - "parentID": "ses_1", - "title": "Child" - } - ] + { + "data": [ + { + "id": "ses_1", + "projectID": "prj_1", + "title": "Root", + "location": {"directory": "/repo"}, + "time": { "created": 100, "updated": 123 } + }, + { + "id": "ses_2", + "parentID": "ses_1", + "title": "Child" + } + ], + "cursor": {"next": null, "previous": null} + } """.trimIndent() exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } } val client = SessionApiClient() - val result = client.fetchSessionHierarchy(port) + val result = client.fetchSessionHierarchy(port, directory = "/repo") val success = assertIs>>(result) assertEquals(2, success.value.size) val ses1 = success.value.first { it.id == "ses_1" } val ses2 = success.value.first { it.id == "ses_2" } assertEquals("Root", ses1.title) + assertEquals("/repo", ses1.directory) assertEquals(123L, ses1.time.updated) assertEquals("ses_1", ses2.parentID) + // URI.getQuery() returns the decoded form of the query component. + val sentQuery = checkNotNull(query) + assertTrue(sentQuery.contains("directory=/repo"), "expected directory query, got $sentQuery") + assertTrue(sentQuery.contains("limit=100"), "expected page size query, got $sentQuery") } } @Test - fun `createSession returns failure on non-2xx`() { + fun `fetchSessionHierarchy follows cursor pagination`() { withTestServer { server, port -> - server.createContext("/session") { exchange -> - val body = "{\"error\":\"boom\"}" - exchange.sendResponseHeaders(500, body.toByteArray(Charsets.UTF_8).size.toLong()) - exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + val cursors = mutableListOf() + server.createContext("/api/session") { exchange -> + val sentQuery = exchange.requestURI.query.orEmpty() + cursors.add(if (sentQuery.contains("cursor=")) sentQuery.substringAfter("cursor=") else null) + val body = if (cursors.size == 1) { + """ + { + "data": [ + {"id": "ses_1", "title": "First page"} + ], + "cursor": {"next": "cursor_1", "previous": null} + } + """ + } else { + """ + { + "data": [ + {"id": "ses_2", "title": "Second page"} + ], + "cursor": {"next": null, "previous": "cursor_1"} + } + """ + } + val trimmed = body.trimIndent() + exchange.sendResponseHeaders(200, trimmed.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(trimmed.toByteArray(Charsets.UTF_8)) } } val client = SessionApiClient() - val result = client.createSession(port) + val result = client.fetchSessionHierarchy(port) - assertIs(result) + val success = assertIs>>(result) + assertEquals(listOf("ses_1", "ses_2"), success.value.map { it.id }) + assertEquals(listOf(null, "cursor_1"), cursors) } } @Test fun `fetchSessionHierarchy returns failure on malformed payload`() { withTestServer { server, port -> - server.createContext("/session") { exchange -> - val body = "{\"not\":\"an array\"}" + server.createContext("/api/session") { exchange -> + val body = "[1,2,3]" exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } } @@ -248,7 +416,70 @@ class SessionApiClientTest { val failure = assertIs(result) val error = assertIs(failure.error) - assertEquals("GET /session: Expected JSON array payload", error.message) + assertEquals("GET /api/session: Expected JSON object", error.message) + } + } + + @Test + fun `fetchSessionMessageDiffSummaries maps assistant snapshot files`() { + withTestServer { server, port -> + server.createContext("/api/session/ses_1/message") { exchange -> + val body = """ + { + "data": [ + {"id": "msg_u1", "type": "user", "text": "first"}, + {"id": "msg_a1", "type": "assistant", "content": [], "snapshot": {"files": ["a.txt", "b.txt"]}} + ], + "cursor": {"next": null, "previous": null} + } + """.trimIndent() + exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + + val client = SessionApiClient() + val result = client.fetchSessionMessageDiffSummaries(port, "ses_1") + + val success = assertIs>>(result) + assertEquals(1, success.value.size) + val summary = success.value.first() + assertEquals("msg_a1", summary.messageId) + assertEquals("assistant", summary.role) + assertEquals(listOf("a.txt", "b.txt"), summary.files) + } + } + + @Test + fun `deleteSession parses empty 204 as success`() { + withTestServer { server, port -> + server.createContext("/api/session/ses_1") { exchange -> + exchange.sendResponseHeaders(204, -1) + exchange.close() + } + + val client = SessionApiClient() + val result = client.deleteSession(port, "ses_1") + + val success = assertIs>(result) + assertEquals(true, success.value) + } + } + + @Test + fun `updateSession sends title payload`() { + withTestServer { server, port -> + var requestBody = "" + server.createContext("/api/session/ses_1") { exchange -> + requestBody = exchange.requestBody.bufferedReader(Charsets.UTF_8).use { it.readText() } + exchange.sendResponseHeaders(204, -1) + exchange.close() + } + + val client = SessionApiClient() + val result = client.updateSession(port, "ses_1", "New Title") + + assertIs>(result) + assertTrue(requestBody.contains("\"title\":\"New Title\"")) } } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/core/MessageDiffFetchCoalescerTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/core/MessageDiffFetchCoalescerTest.kt index fcc3d9e..e83bd4d 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/core/MessageDiffFetchCoalescerTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/core/MessageDiffFetchCoalescerTest.kt @@ -1,63 +1,42 @@ package com.ashotn.opencode.relay.core -import com.ashotn.opencode.relay.ipc.OpenCodeEvent import org.junit.Test -import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue class MessageDiffFetchCoalescerTest { + @Test - fun `duplicate in-flight event is returned once as latest pending event`() { + fun `duplicate in-flight fetch is coalesced and reported as pending`() { val coalescer = MessageDiffFetchCoalescer() - val first = event(files = listOf("first.kt")) - val pendingOld = event(files = listOf("old.kt")) - val pendingLatest = event(files = listOf("latest.kt")) - val key = coalescer.key(first) - - assertTrue(coalescer.tryStart(first)) - assertFalse(coalescer.tryStart(pendingOld)) - assertFalse(coalescer.tryStart(pendingLatest)) - - assertEquals(pendingLatest, coalescer.finish(key, allowPending = true)) - assertNull(coalescer.finish(key, allowPending = true)) - assertTrue(coalescer.tryStart(first)) + val key = MessageDiffFetchCoalescer.liveTurnKey("ses_1") + + assertTrue(coalescer.tryStart(key)) + assertFalse(coalescer.tryStart(key)) + + assertTrue(coalescer.finish(key, allowPending = true)) + assertFalse(coalescer.finish(key, allowPending = true)) + assertTrue(coalescer.tryStart(key)) } @Test - fun `pending duplicate can continue until no pending updates remain`() { + fun `pending fetch is discarded when generation is stale`() { val coalescer = MessageDiffFetchCoalescer() - val first = event(messageId = "msg", files = listOf("first.kt")) - val second = event(messageId = "msg", files = listOf("second.kt")) - val third = event(messageId = "msg", files = listOf("third.kt")) - val key = coalescer.key(first) - - assertTrue(coalescer.tryStart(first)) - assertFalse(coalescer.tryStart(second)) - assertEquals(second, coalescer.finish(key, allowPending = true)) - - assertFalse(coalescer.tryStart(third)) - assertEquals(third, coalescer.finish(key, allowPending = true)) - assertNull(coalescer.finish(key, allowPending = true)) + val key = MessageDiffFetchCoalescer.liveTurnKey("ses_1") + + assertTrue(coalescer.tryStart(key)) + assertFalse(coalescer.tryStart(key)) + assertFalse(coalescer.finish(key, allowPending = false)) + assertFalse(coalescer.finish(key, allowPending = true)) + assertTrue(coalescer.tryStart(key)) } @Test - fun `non-duplicate events start independently`() { + fun `distinct keys start independently`() { val coalescer = MessageDiffFetchCoalescer() - assertTrue(coalescer.tryStart(event(sessionId = "ses_1", messageId = "msg_1"))) - assertTrue(coalescer.tryStart(event(sessionId = "ses_1", messageId = "msg_2"))) - assertTrue(coalescer.tryStart(event(sessionId = "ses_2", messageId = "msg_1"))) + assertTrue(coalescer.tryStart(MessageDiffFetchCoalescer.liveTurnKey("ses_1"))) + assertTrue(coalescer.tryStart(MessageDiffFetchCoalescer.liveTurnKey("ses_2"))) + assertTrue(coalescer.tryStart(MessageDiffFetchCoalescer.key("ses_1", "msg_1"))) } - - private fun event( - sessionId: String = "ses", - messageId: String = "msg", - files: List = emptyList(), - ) = OpenCodeEvent.MessageDiffAvailable( - sessionId = sessionId, - messageId = messageId, - files = files, - ) } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/core/MessageSummaryFileCountLoadPlannerTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/core/MessageSummaryFileCountLoadPlannerTest.kt index 18a271a..d98fa08 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/core/MessageSummaryFileCountLoadPlannerTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/core/MessageSummaryFileCountLoadPlannerTest.kt @@ -1,7 +1,6 @@ package com.ashotn.opencode.relay.core import com.ashotn.opencode.relay.api.session.Session -import com.ashotn.opencode.relay.api.session.SessionSummary import com.ashotn.opencode.relay.api.session.SessionTime import org.junit.Test import kotlin.test.assertEquals @@ -9,13 +8,15 @@ import kotlin.test.assertEquals class MessageSummaryFileCountLoadPlannerTest { @Test - fun `message summary file count loads only newest eligible summary sessions up to batch size`() { + fun `message summary file count loads only newest eligible sessions up to batch size`() { + // In OpenCode 2.x sessions carry no server-side summary; eligibility is + // decided purely by the caller's predicate. val sessions = listOf( - session("newest", updated = 50, summarized = true), - session("plain", updated = 100, summarized = false), - session("loaded", updated = 60, summarized = true), - session("middle", updated = 30, summarized = true), - session("newer", updated = 45, summarized = true), + session("newest", updated = 50), + session("plain", updated = 100), + session("loaded", updated = 60), + session("middle", updated = 30), + session("newer", updated = 45), ) val selected = selectMessageSummaryFileCountLoadBatch( @@ -23,18 +24,15 @@ class MessageSummaryFileCountLoadPlannerTest { maxBatchSize = 2, ) { session -> session.id != "loaded" } - assertEquals(listOf("newest", "newer"), selected.map { it.id }) + assertEquals(listOf("plain", "newest"), selected.map { it.id }) } - private fun session(id: String, updated: Long, summarized: Boolean): Session = Session( + private fun session(id: String, updated: Long): Session = Session( id = id, projectID = null, directory = null, parentID = null, title = id, - version = null, time = SessionTime(created = 0, updated = updated, compacting = null), - summary = if (summarized) SessionSummary(additions = 0, deletions = 0, files = 0, diffs = null) else null, - share = null, ) } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt index dcad491..e12ab75 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt @@ -554,9 +554,6 @@ class SessionDiffPipelineTest { directory = null, parentID = parentId, title = id, - version = null, time = SessionTime(created = 0L, updated = 0L, compacting = null), - summary = null, - share = null, ) } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/core/SessionOrderingTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/core/SessionOrderingTest.kt index 9e30856..eb919af 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/core/SessionOrderingTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/core/SessionOrderingTest.kt @@ -208,10 +208,7 @@ class SessionOrderingTest { directory = null, parentID = null, title = "Newest session", - version = null, time = SessionTime(0L, tNew, null), - summary = null, - share = null ), sessionMid to Session( id = sessionMid, @@ -219,10 +216,7 @@ class SessionOrderingTest { directory = null, parentID = null, title = "Middle session", - version = null, time = SessionTime(0L, tMid, null), - summary = null, - share = null ), sessionOld to Session( id = sessionOld, @@ -230,10 +224,7 @@ class SessionOrderingTest { directory = null, parentID = null, title = "Oldest session", - version = null, time = SessionTime(0L, tOld, null), - summary = null, - share = null ), ), busyBySession = emptyMap(), @@ -298,10 +289,7 @@ class SessionOrderingTest { directory = null, parentID = null, title = "Session A", - version = null, time = SessionTime(0L, 2000L, null), - summary = null, - share = null ), sessionB to Session( id = sessionB, @@ -309,10 +297,7 @@ class SessionOrderingTest { directory = null, parentID = null, title = "Session B", - version = null, time = SessionTime(0L, 1000L, null), - summary = null, - share = null ), ), busyBySession = store.busyBySession, @@ -412,10 +397,7 @@ class SessionOrderingTest { directory = null, parentID = null, title = "Late diff session", - version = null, time = SessionTime(0L, 2_000L, null), - summary = null, - share = null, ) ), busyBySession = store.busyBySession, diff --git a/src/test/kotlin/com/ashotn/opencode/relay/core/session/SessionScopeResolverTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/core/session/SessionScopeResolverTest.kt index c7e4adb..aa64109 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/core/session/SessionScopeResolverTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/core/session/SessionScopeResolverTest.kt @@ -22,10 +22,7 @@ class SessionScopeResolverTest { directory = null, parentID = null, title = "Session 1", - version = null, time = SessionTime(0L, 0L, null), - summary = null, - share = null ), session2 to Session( id = session2, @@ -33,10 +30,7 @@ class SessionScopeResolverTest { directory = null, parentID = null, title = "Session 2", - version = null, time = SessionTime(0L, 0L, null), - summary = null, - share = null ), ) diff --git a/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt index 1185939..3909b33 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt @@ -44,17 +44,17 @@ class SseClientTest { } @Test - fun `parses busy retry and idle session status events`() { + fun `parses busy retry and idle session status events with directory filtering`() { withTestServer { server, port -> server.createContext("/api/event") { exchange -> val body = """ - data: {"directory":"/other","payload":{"type":"session.status","properties":{"sessionID":"ses_1","status":{"type":"busy"}}}} + data: {"id":"evt_1","created":1,"type":"session.status","location":{"directory":"/other"},"data":{"sessionID":"ses_1","status":{"type":"busy"}}} - data: {"directory":"/project/","payload":{"type":"session.status","properties":{"sessionID":"ses_1","status":{"type":"busy"}}}} + data: {"id":"evt_2","created":2,"type":"session.status","location":{"directory":"/project/"},"data":{"sessionID":"ses_1","status":{"type":"busy"}}} - data: {"directory":"/project","payload":{"type":"session.status","properties":{"sessionID":"ses_1","status":{"type":"retry","attempt":1,"message":"retrying","next":123}}}} + data: {"id":"evt_3","created":3,"type":"session.status","location":{"directory":"/project"},"data":{"sessionID":"ses_1","status":{"type":"retry","attempt":1,"message":"retrying","next":123}}} - data: {"directory":"/project","payload":{"type":"session.status","properties":{"sessionID":"ses_1","status":{"type":"idle"}}}} + data: {"id":"evt_4","created":4,"type":"session.status","location":{"directory":"/project"},"data":{"sessionID":"ses_1","status":{"type":"idle"}}} """.trimIndent() val bytes = body.toByteArray(Charsets.UTF_8) @@ -98,15 +98,15 @@ class SseClientTest { } @Test - fun `parses wrapped session lifecycle events`() { + fun `parses session lifecycle events`() { withTestServer { server, port -> server.createContext("/api/event") { exchange -> val body = """ - data: {"directory":"/project","payload":{"type":"session.created","properties":{"info":{"id":"ses_created"}}}} + data: {"id":"evt_1","created":1,"type":"session.created","location":{"directory":"/project"},"data":{"sessionID":"ses_created","projectID":"prj_1","slug":"s","location":{"directory":"/project"}}} - data: {"directory":"/project","payload":{"type":"session.updated","properties":{"sessionID":"ses_updated"}}} + data: {"id":"evt_2","created":2,"type":"session.renamed","location":{"directory":"/project"},"data":{"sessionID":"ses_renamed"}} - data: {"directory":"/project","payload":{"type":"session.deleted","properties":{"sessionID":"ses_deleted"}}} + data: {"id":"evt_3","created":3,"type":"session.deleted","location":{"directory":"/project"},"data":{"sessionID":"ses_deleted"}} """.trimIndent() val bytes = body.toByteArray(Charsets.UTF_8) @@ -136,16 +136,67 @@ class SseClientTest { } val sessionIds = synchronized(received) { received.map { it.sessionId } } - assertEquals(listOf("ses_created", "ses_updated", "ses_deleted"), sessionIds) + assertEquals(listOf("ses_created", "ses_renamed", "ses_deleted"), sessionIds) } } @Test - fun `parses message diff availability with summary files`() { + fun `maps execution started and finished events to busy status and finished signal`() { withTestServer { server, port -> server.createContext("/api/event") { exchange -> val body = """ - data: {"directory":"/project","payload":{"type":"message.updated","properties":{"sessionID":"ses_1","info":{"id":"msg_1","role":"user","summary":{"diffs":[{"file":"pkg/alpha.py"},{"file":"pkg/bravo.py"}]}}}}} + data: {"id":"evt_1","created":1,"type":"session.execution.started","location":{"directory":"/project"},"data":{"sessionID":"ses_1","userMessageID":"msg_u1"}} + + data: {"id":"evt_2","created":2,"type":"session.execution.succeeded","location":{"directory":"/project"},"data":{"sessionID":"ses_1","assistantMessageID":"msg_a1"}} + + data: {"id":"evt_3","created":3,"type":"session.idle","location":{"directory":"/project"},"data":{"sessionID":"ses_1"}} + + """.trimIndent() + val bytes = body.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "text/event-stream") + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + val received = mutableListOf() + val latch = CountDownLatch(3) + val client = SseClient( + port = port, + directory = "/project", + onEvent = { event -> + synchronized(received) { + received.add(event) + } + latch.countDown() + }, + ) + + try { + client.start() + assertTrue(latch.await(2, TimeUnit.SECONDS), "expected three execution events") + } finally { + client.stop() + } + + val events = synchronized(received) { received.toList() } + val busy = assertIs(events[0]) + assertEquals(OpenCodeEvent.SessionStatusType.BUSY, busy.status) + assertEquals("ses_1", busy.sessionId) + + val finished = assertIs(events[1]) + assertEquals("ses_1", finished.sessionId) + + val idle = assertIs(events[2]) + assertEquals(OpenCodeEvent.SessionStatusType.IDLE, idle.status) + } + } + + @Test + fun `parses permission asked with v2 action and resources fields`() { + withTestServer { server, port -> + server.createContext("/api/event") { exchange -> + val body = """ + data: {"id":"evt_1","created":1,"type":"permission.asked","location":{"directory":"/project"},"data":{"id":"per_1","sessionID":"ses_1","action":"external_directory","resources":["/etc/*"],"metadata":{"command":"cat /etc/passwd"}}} """.trimIndent() val bytes = body.toByteArray(Charsets.UTF_8) @@ -154,14 +205,14 @@ class SseClientTest { exchange.responseBody.use { it.write(bytes) } } - val received = mutableListOf() + val received = mutableListOf() val latch = CountDownLatch(1) val client = SseClient( port = port, directory = "/project", onEvent = { event -> synchronized(received) { - received.add(assertIs(event)) + received.add(assertIs(event)) } latch.countDown() }, @@ -169,15 +220,135 @@ class SseClientTest { try { client.start() - assertTrue(latch.await(2, TimeUnit.SECONDS), "expected message diff event") + assertTrue(latch.await(2, TimeUnit.SECONDS), "expected permission asked event") } finally { client.stop() } val event = synchronized(received) { received.single() } + assertEquals("per_1", event.requestId) assertEquals("ses_1", event.sessionId) - assertEquals("msg_1", event.messageId) - assertEquals(listOf("pkg/alpha.py", "pkg/bravo.py"), event.files) + assertEquals("external_directory", event.permission) + assertEquals(listOf("/etc/*"), event.patterns) + assertEquals(mapOf("command" to "cat /etc/passwd"), event.metadata) + } + } + + @Test + fun `parses permission replied`() { + withTestServer { server, port -> + server.createContext("/api/event") { exchange -> + val body = """ + data: {"id":"evt_1","created":1,"type":"permission.replied","location":{"directory":"/project"},"data":{"sessionID":"ses_1","requestID":"per_1","reply":"always"}} + + """.trimIndent() + val bytes = body.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "text/event-stream") + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + val received = mutableListOf() + val latch = CountDownLatch(1) + val client = SseClient( + port = port, + directory = "/project", + onEvent = { event -> + synchronized(received) { + received.add(assertIs(event)) + } + latch.countDown() + }, + ) + + try { + client.start() + assertTrue(latch.await(2, TimeUnit.SECONDS), "expected permission replied event") + } finally { + client.stop() + } + + val event = synchronized(received) { received.single() } + assertEquals("per_1", event.requestId) + assertEquals("ses_1", event.sessionId) + assertEquals(PermissionReply.ALWAYS, event.reply) + } + } + + @Test + fun `parses mcp status changed`() { + withTestServer { server, port -> + server.createContext("/api/event") { exchange -> + val body = """ + data: {"id":"evt_1","created":1,"type":"mcp.status.changed","location":{"directory":"/project"},"data":{"server":"jetbrains"}} + + """.trimIndent() + val bytes = body.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "text/event-stream") + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + val received = mutableListOf() + val latch = CountDownLatch(1) + val client = SseClient( + port = port, + directory = "/project", + onEvent = { event -> + synchronized(received) { + received.add(assertIs(event)) + } + latch.countDown() + }, + ) + + try { + client.start() + assertTrue(latch.await(2, TimeUnit.SECONDS), "expected mcp status changed event") + } finally { + client.stop() + } + + val event = synchronized(received) { received.single() } + assertEquals("jetbrains", event.serverName) + } + } + + @Test + fun `parses server connected`() { + withTestServer { server, port -> + server.createContext("/api/event") { exchange -> + val body = """ + data: {"id":"evt_1","created":1,"type":"server.connected","data":{}} + + """.trimIndent() + val bytes = body.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "text/event-stream") + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + val received = mutableListOf() + val latch = CountDownLatch(1) + val client = SseClient( + port = port, + onEvent = { event -> + synchronized(received) { + received.add(event) + } + latch.countDown() + }, + ) + + try { + client.start() + assertTrue(latch.await(2, TimeUnit.SECONDS), "expected server connected event") + } finally { + client.stop() + } + + val event = synchronized(received) { received.single() } + assertIs(event) } } } From b1ae72144260635b85316381660aaf180c9ad0af Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Mon, 19 Oct 2026 00:15:01 -0500 Subject: [PATCH 03/12] docs: refresh AGENTS.md and README.md for the OpenCode 2.x migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md: - Replace stale jetbrains_*/webstorm_* tool names with the current idea.* MCP namespace (idea.read_file, idea.search_file, idea.build_project, idea.get_run_configurations, ...) and note idea.execute_tool as the dynamic fallback. - Record the OpenCode 2.x-only policy (v1 rejection, Basic auth on /api/*, OPENCODE_PASSWORD for plugin-launched servers). - Record build rules: JDK 21 requirement, the Kotlin 2.1.0 daemon crash on JDK 27 with the JAVA_HOME workaround, unit vs liveTest usage. - Record the project's adapted Kotlin style (best-practice-kotlin skill: no !!, sealed result/event models, read-only collections at boundaries). README.md: - Requirements now require OpenCode 2.x; 1.x installs are detected and reported with an upgrade message. - Server lifecycle: document background-service auto-detection (service.json state file, pid-verified) and v2 authentication behavior. - Session list is scoped to the project's sessions. - Mark the TUI features (Send Selection, New Session) as in progress for the 2.x rework — they still target v1 /tui/* endpoints. - JetBrains MCP example uses the 2.x config format (mcp.servers wrapper, stream URL) and mentions the MCP Servers status action. --- AGENTS.md | 62 +++++++++++++++++++++++++++++++++++++++++-------------- README.md | 34 +++++++++++++++++++++--------- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa1f745..c05c9ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,21 +6,51 @@ This repo is a JetBrains IDE plugin that integrates the OpenCode AI coding assis provides a tool window, settings panel, and lifecycle management for launching and interacting with the OpenCode CLI from within the IDE. +## OpenCode 2.x Only + +The plugin speaks the OpenCode 2.x HTTP/SSE API exclusively: + +- OpenCode 1.x executables are detected at startup and reported with a dedicated "OpenCode 2.x required" message. + Do not reintroduce v1 code paths, endpoints (`/session`, `/tui/*`, `prompt_async`, ...), or dual-version logic. +- All `/api/*` requests carry Basic auth (`opencode:`). Servers the plugin launches receive their + password via the `OPENCODE_PASSWORD` environment variable. +- v2 API facts were verified against a live v2 server (OpenAPI spec with 119 paths) and the official v2 client + types. When in doubt, trust the live server over memory. + ## Prefer JetBrains Tools -When searching the code base or attempting to run a build -Take a look at the available run configurations(`webstorm_get_run_configurations`) -Always prefer `jetbrains_*` tools over -generic alternatives when both are available: - -| Task | Prefer | Over | -|-----------------------|-----------------------------------------------------------------------------|----------------------------| -| Read a file | `jetbrains_get_file_text_by_path` | `Read` / `cat` | -| Create a file | `jetbrains_create_new_file` | `Write` / `echo` | -| Find files by name | `jetbrains_find_files_by_name_keyword` | `Glob` / `find` | -| Find files by pattern | `jetbrains_find_files_by_glob` | `Glob` / `find` | -| Search content | `jetbrains_search_in_files_by_text` or `jetbrains_search_in_files_by_regex` | `Grep` / `rg` / `grep` | -| Browse directory | `jetbrains_list_directory_tree` | `ls` / `find` | -| Rename symbols | `jetbrains_rename_refactoring` | text search-replace | -| Check file errors | `jetbrains_get_file_problems` | manual inspection | -| Build project | `jetbrains_build_project` | `./gradlew build` via Bash | \ No newline at end of file +When searching the code base or attempting to run a build, take a look at the available run configurations +(`idea.get_run_configurations`). Always prefer `idea.*` tools over generic alternatives when both are available: + +| Task | Prefer | Over | +|-----------------------|---------------------------------------------|----------------------------| +| Read a file | `idea.read_file` | `Read` / `cat` | +| Create a file | `idea.create_new_file` | `Write` / `echo` | +| Find files by pattern | `idea.search_file` (glob) | `Glob` / `find` | +| Search content | `idea.search_text` or `idea.search_regex` | `Grep` / `rg` / `grep` | +| Browse directory | `idea.list_directory_tree` | `ls` / `find` | +| Rename symbols | `idea.rename_refactoring` | text search-replace | +| Check file errors | `idea.get_file_problems` | manual inspection | +| Build project | `idea.build_project` | `./gradlew build` via Bash | + +The tools are exposed under the `idea` MCP namespace (catalog paths look like `tools.idea.read_file`). For IDE +capabilities not listed above, use `idea.execute_tool` to invoke the underlying IDE MCP tool dynamically. + +## Build + +- Build with **JDK 21** (project targets JVM 21, Kotlin 2.1.0). The Kotlin 2.1.0 Gradle daemon crashes on JDK 27 + (the system default on the dev machine) with `IllegalArgumentException: 27` from `JavaVersion.parse`. + If a build fails that way: `export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 && ./gradlew ...`, then + `./gradlew --stop` to kill a poisoned daemon. +- Unit tests: `./gradlew test`. +- Live integration tests live in `src/liveTest` and only run when the `OPENCODE_TEST_VERSIONS` environment + variable lists OpenCode **2.x** versions to test against. + +## Kotlin Style + +New and changed Kotlin follows the `best-practice-kotlin` skill, adapted to this project: + +- Kotlin 2.1.0, JVM 21, 4-space indentation +- No `!!`; sealed types for result/event models (`ApiResult`, `OpenCodeEvent`) +- Read-only collection types (`List`, `Map`, `Set`) at API boundaries +- No new lint tooling (the project does not run ktlint/detekt) diff --git a/README.md b/README.md index 9773350..527c218 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,19 @@ The plugin can launch, monitor, and stop the OpenCode server for you. - **Start / Stop** — Start the server from the tool window with a single click. The plugin spawns `opencode serve --port ` in the project directory. -- **Auto-detect a running server** — If OpenCode is already running on the configured port (e.g. started from the - terminal), the plugin connects to it automatically without taking ownership of the process. +- **Auto-detect a running server** — If OpenCode's background service is running (detected from its + `service.json` state file and verified against the live process) or a server is already listening on the + configured port (e.g. started from the terminal), the plugin connects to it automatically without taking + ownership of the process. +- **Authentication** — OpenCode 2.x servers require authentication. For servers the plugin launches, it generates + a password and passes it via the `OPENCODE_PASSWORD` environment variable; for detected services it reads the + password from the service state file. Once the server is running the plugin connects over SSE (Server-Sent Events) to receive real-time events. ### Session list -The **Session changes** panel lists all root sessions. Each row shows: +The **Session changes** panel lists the project's root sessions. Each row shows: - Session title and a short description - A spinner while the session is actively running @@ -81,6 +86,9 @@ Double-clicking a file in the modified-files list opens a standard JetBrains **d ### Send selection to TUI +> **In progress** — the TUI integration is being reworked for the OpenCode 2.x server; this feature is +> temporarily unavailable. + Share code from the editor directly with OpenCode's prompt input: - Select any code in the editor @@ -98,6 +106,9 @@ The **Open in Browser** toolbar button opens the OpenCode web UI in your default ### New session in TUI +> **In progress** — the TUI integration is being reworked for the OpenCode 2.x server; this feature is +> temporarily unavailable. + The **New Session** toolbar button creates a new OpenCode session and immediately switches the active TUI instance to it. @@ -114,12 +125,14 @@ Responses are sent back to the server immediately. ## Requirements - A JetBrains IDE based on IntelliJ Platform 2025.3 or later -- [OpenCode CLI](https://opencode.ai/docs) version `1.16.0+` installed and on `PATH` (or the path configured in - settings) +- [OpenCode CLI](https://opencode.ai/docs) version `2.x` installed and on `PATH` (or the path configured in + settings). OpenCode 1.x is not supported — the plugin detects it and explains that OpenCode 2.x is required. ## JetBrains MCP OpenCode Relay will warn you when JetBrains MCP is disabled or when a running OpenCode server is not connected to it. +The **MCP Servers** action shows each configured server's connection status and lets you connect or disconnect it +from the IDE. **It is highly recommended to enable JetBrains MCP for the best experience.** @@ -130,16 +143,17 @@ To enable JetBrains MCP: 3. Add a `jetbrains` MCP server to your `opencode.json`. 4. Restart the OpenCode server so it reloads the config. -Basic `opencode.json` example: +Basic `opencode.json` example (OpenCode 2.x config format): ```json { "$schema": "https://opencode.ai/config.json", "mcp": { - "jetbrains": { - "type": "remote", - "url": "http://localhost:64342/sse", - "enabled": true + "servers": { + "jetbrains": { + "type": "remote", + "url": "http://localhost:64342/stream" + } } } } From 7fe4fe65abd17a699371caf6c72dd4605b94aea8 Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Mon, 19 Oct 2026 00:30:16 -0500 Subject: [PATCH 04/12] docs: correct build JDK guidance (21 and 25 installed; daemon JVM nuance) --- AGENTS.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c05c9ae..cfe53e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,10 +38,13 @@ capabilities not listed above, use `idea.execute_tool` to invoke the underlying ## Build -- Build with **JDK 21** (project targets JVM 21, Kotlin 2.1.0). The Kotlin 2.1.0 Gradle daemon crashes on JDK 27 - (the system default on the dev machine) with `IllegalArgumentException: 27` from `JavaVersion.parse`. - If a build fails that way: `export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 && ./gradlew ...`, then - `./gradlew --stop` to kill a poisoned daemon. +- Build with **JDK 21** (`/usr/lib/jvm/java-21-openjdk-amd64`; project targets JVM 21, Kotlin 2.1.0). + OpenJDK 21 and 25 are installed on the dev machine, and the system default `java` resolves to 25. + The Kotlin 2.1.0 compiler crashes on JDK 25+ with `IllegalArgumentException: 25...` from + `JavaVersion.parse` ("Internal compiler error") — the release predates those JDKs. +- The crash follows the **Gradle daemon's** JVM (the Kotlin compiler runs inside the daemon). If a + JDK 25 daemon is already running, setting `JAVA_HOME` alone does not fix the build — run + `./gradlew --stop` first, then `export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 && ./gradlew ...`. - Unit tests: `./gradlew test`. - Live integration tests live in `src/liveTest` and only run when the `OPENCODE_TEST_VERSIONS` environment variable lists OpenCode **2.x** versions to test against. From 1b0eb579433b3db651913b4023bde7f29e849286 Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Mon, 19 Oct 2026 05:34:33 -0500 Subject: [PATCH 05/12] fix: v2 turn-end idle derivation and chronological diff-range ordering v2 does not emit session.idle or session.status when a turn completes; only session.execution.{succeeded,failed,interrupted} marks the end of a turn (verified live against v2.0.32). SseClient now derives the IDLE status from the finished execution, keeping explicit idle/status parsing for forward compatibility. The v2 message list arrives newest-first, but the diff range route requires from to precede to (otherwise 400 InvalidRequestError). fetchUserMessageIds now sorts by time.created ascending. Session creation also gains v2 session-scoped permission rules and an optional model reference (the v1 OPENCODE_PERMISSION env var is gone). --- .../relay/api/session/SessionApiClient.kt | 76 +++++++++++++++++-- .../ashotn/opencode/relay/ipc/SseClient.kt | 14 ++++ .../relay/api/session/SessionApiClientTest.kt | 8 +- .../opencode/relay/ipc/SseClientTest.kt | 49 +++++++++++- 4 files changed, 136 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt index 6057224..26f052d 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt @@ -50,9 +50,62 @@ class SessionApiClient( val files: List, ) - fun createSession(port: Int): ApiResult { + /** + * A single session-scoped permission rule (v2 `Permission.Rule`). + * `effect` is one of `"allow"`, `"deny"`, `"ask"`. + */ + data class PermissionRule( + val action: String, + val resource: String, + val effect: String, + ) + + /** + * A model reference for session creation (v2 `Model.Ref`). + */ + data class SessionModelRef( + val providerID: String, + val modelID: String, + ) + + /** + * Creates a session, optionally with explicit session-scoped permission rules + * and a pinned model. + * + * OpenCode 2.x removed the v1 `OPENCODE_PERMISSION` environment variable; + * session permissions are provided here instead of via server config. + */ + fun createSession( + port: Int, + permissions: List? = null, + model: SessionModelRef? = null, + ): ApiResult { val endpoint = SessionEndpoints.create() - val response = transport.post(port = port, path = endpoint.path, payload = JsonObject().toString()) + val payload = JsonObject().also { body -> + permissions.orEmpty().takeIf { it.isNotEmpty() }?.let { rules -> + val rulesArray = JsonArray() + rules.forEach { rule -> + rulesArray.add( + JsonObject().also { + it.addProperty("action", rule.action) + it.addProperty("resource", rule.resource) + it.addProperty("effect", rule.effect) + }, + ) + } + body.add("permissions", rulesArray) + } + model?.let { ref -> + body.add( + "model", + JsonObject().also { + it.addProperty("providerID", ref.providerID) + it.addProperty("id", ref.modelID) + }, + ) + } + } + val response = transport.post(port = port, path = endpoint.path, payload = payload.toString()) return transport.mapJsonObjectResponse(response) { sessionObj -> val id = sessionObj.getStringOrNull("id") if (id.isNullOrBlank()) { @@ -211,6 +264,12 @@ class SessionApiClient( } } + /** + * User message IDs of the session in chronological order (oldest first). + * + * The v2 message list arrives newest-first, but the diff range route requires + * `from` to precede `to`, so IDs are sorted by `time.created` ascending. + */ private fun fetchUserMessageIds(port: Int, sessionId: String): ApiResult> { val endpoint = SessionEndpoints.messages(sessionId) return walkPages(port, endpoint.path) { cursor -> pageQuery(cursor = cursor) } @@ -219,9 +278,16 @@ class SessionApiClient( if (!element.isJsonObject) return@mapNotNull null val message = element.asJsonObject if (message.getStringOrNull("type") != "user") return@mapNotNull null - message.getStringOrNull("id")?.takeIf { it.isNotBlank() } - } - ApiResult.Success(ids) + val id = message.getStringOrNull("id")?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + val created = message.getAsJsonObject("time") + ?.get("created") + ?.takeIf { it.isJsonPrimitive } + ?.asLong + ?: Long.MIN_VALUE + created to id + }.sortedBy { it.first } + ApiResult.Success(ids.map { it.second }) } .withParseContext(endpoint) } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt index e5dffd6..3bbd482 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt @@ -182,12 +182,26 @@ class SseClient( if (event != null) { onEvent(event) + derivedIdleStatus(event)?.let { onEvent(it) } } } catch (e: Exception) { log.warn("SseClient: failed to parse event jsonLength=${json.length}", e) } } + /** + * v2 does not emit `session.idle` or `session.status` when a turn completes — + * only `session.execution.{succeeded,failed,interrupted}` marks the end of a + * turn (verified live against v2.0.32) — so the idle status is derived from + * the finished execution. + */ + private fun derivedIdleStatus(event: OpenCodeEvent): OpenCodeEvent.SessionStatus? = + when (event) { + is OpenCodeEvent.SessionExecutionFinished -> + OpenCodeEvent.SessionStatus(event.sessionId, OpenCodeEvent.SessionStatusType.IDLE) + else -> null + } + /** * v2 events carry an optional `location.directory`. When both the event and this * client have a directory, events from other directories are dropped. diff --git a/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt index 5c26182..f403960 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt @@ -151,13 +151,15 @@ class SessionApiClientTest { fun `fetchSessionDiffSnapshot fetches first to last user turn range`() { withTestServer { server, port -> val diffQueries = mutableListOf() + // v2 message lists arrive newest-first with `time.created`; the diff + // range route requires `from` (oldest) to precede `to` (newest). server.createContext("/api/session/ses_1/message") { exchange -> val body = """ { "data": [ - {"id": "msg_u1", "type": "user", "text": "first"}, - {"id": "msg_a1", "type": "assistant", "content": []}, - {"id": "msg_u2", "type": "user", "text": "second"} + {"id": "msg_u2", "type": "user", "text": "second", "time": {"created": 300}}, + {"id": "msg_a2", "type": "assistant", "content": [], "time": {"created": 250}}, + {"id": "msg_u1", "type": "user", "text": "first", "time": {"created": 100}} ], "cursor": {"next": null, "previous": null} } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt index 3909b33..1893e54 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt @@ -141,16 +141,16 @@ class SseClientTest { } @Test - fun `maps execution started and finished events to busy status and finished signal`() { + fun `maps execution started and finished events to busy status, finished signal, and derived idle`() { withTestServer { server, port -> + // v2 turn stream: v2.0.32 emits no `session.idle` or `session.status` + // around turn completion, only the execution lifecycle events. server.createContext("/api/event") { exchange -> val body = """ data: {"id":"evt_1","created":1,"type":"session.execution.started","location":{"directory":"/project"},"data":{"sessionID":"ses_1","userMessageID":"msg_u1"}} data: {"id":"evt_2","created":2,"type":"session.execution.succeeded","location":{"directory":"/project"},"data":{"sessionID":"ses_1","assistantMessageID":"msg_a1"}} - data: {"id":"evt_3","created":3,"type":"session.idle","location":{"directory":"/project"},"data":{"sessionID":"ses_1"}} - """.trimIndent() val bytes = body.toByteArray(Charsets.UTF_8) exchange.responseHeaders.add("Content-Type", "text/event-stream") @@ -186,8 +186,51 @@ class SseClientTest { val finished = assertIs(events[1]) assertEquals("ses_1", finished.sessionId) + // Idle is derived from the finished execution because v2 emits no idle event. val idle = assertIs(events[2]) assertEquals(OpenCodeEvent.SessionStatusType.IDLE, idle.status) + assertEquals("ses_1", idle.sessionId) + } + } + + @Test + fun `still maps explicit session idle event to idle status`() { + withTestServer { server, port -> + server.createContext("/api/event") { exchange -> + val body = """ + data: {"id":"evt_1","created":1,"type":"session.idle","location":{"directory":"/project"},"data":{"sessionID":"ses_1"}} + + """.trimIndent() + val bytes = body.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "text/event-stream") + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + val received = mutableListOf() + val latch = CountDownLatch(1) + val client = SseClient( + port = port, + directory = "/project", + onEvent = { event -> + synchronized(received) { + received.add(event) + } + latch.countDown() + }, + ) + + try { + client.start() + assertTrue(latch.await(2, TimeUnit.SECONDS), "expected idle event") + } finally { + client.stop() + } + + val events = synchronized(received) { received.toList() } + val idle = assertIs(events[0]) + assertEquals(OpenCodeEvent.SessionStatusType.IDLE, idle.status) + assertEquals("ses_1", idle.sessionId) } } From 7ec83b5fd9de979836b532ac228a62e4918af62a Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Mon, 19 Oct 2026 05:34:44 -0500 Subject: [PATCH 06/12] test: rework liveTest for OpenCode 2.x - v2 servers always require Basic auth: the factory generates a client-side OPENCODE_PASSWORD (same design as the plugin launch flow) and the health check, session client, and SSE collector carry it. - v2 removed the OPENCODE_PERMISSION env var; sessions are created with explicit permission rules and an optional pinned model (OPENCODE_TEST_MODEL). - OPENCODE_TEST_CONFIG_JSON writes the provider definition as the global config in the isolated scenario: providers defined only in the user's own opencode.json are otherwise invisible to the test server and turns fail with provider.no-route / deprecated-catalog errors. - OPENCODE_TEST_BIN selects a pre-installed opencode binary on machines without node/npm (its --version must still match the test list). - Diff tests are resilient to small local models completing a turn without doing the work: bounded prompt resubmission with an explicit retry note, generous turn timeouts, and tolerant trailing-newline assertions. - build.gradle.kts forwards the new variables; AGENTS.md documents the liveTest environment, including the Gradle daemon env fix. --- AGENTS.md | 25 +++- build.gradle.kts | 11 +- .../OpenCodeTestEnvironmentFactory.kt | 138 ++++++++++++++++-- .../integration/OpenCodeTestEventCollector.kt | 2 + .../integration/diff/OpenCodeDiffLiveTest.kt | 122 +++++++++++++--- .../OpenCodeTestEnvironmentFactoryTest.kt | 6 + .../session/OpenCodeSessionStateLiveTest.kt | 17 ++- 7 files changed, 284 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cfe53e1..5479853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,15 +39,32 @@ capabilities not listed above, use `idea.execute_tool` to invoke the underlying ## Build - Build with **JDK 21** (`/usr/lib/jvm/java-21-openjdk-amd64`; project targets JVM 21, Kotlin 2.1.0). - OpenJDK 21 and 25 are installed on the dev machine, and the system default `java` resolves to 25. - The Kotlin 2.1.0 compiler crashes on JDK 25+ with `IllegalArgumentException: 25...` from - `JavaVersion.parse` ("Internal compiler error") — the release predates those JDKs. + JDK 21 is required: the Kotlin 2.1.0 compiler crashes on JDK 25+ with `IllegalArgumentException: 25...` + from `JavaVersion.parse` ("Internal compiler error") — the release predates those JDKs. - The crash follows the **Gradle daemon's** JVM (the Kotlin compiler runs inside the daemon). If a JDK 25 daemon is already running, setting `JAVA_HOME` alone does not fix the build — run `./gradlew --stop` first, then `export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 && ./gradlew ...`. - Unit tests: `./gradlew test`. - Live integration tests live in `src/liveTest` and only run when the `OPENCODE_TEST_VERSIONS` environment - variable lists OpenCode **2.x** versions to test against. + variable lists OpenCode **2.x** versions to test against. They also require `OPENCODE_TEST_AUTH_JSON` + (OpenCode provider `auth.json` content) and, by default, `npm` to install each version. On machines + without node/npm (the dev box has none — OpenCode is a standalone native binary there), set + `OPENCODE_TEST_BIN` to a pre-installed `opencode` binary; its `--version` must match the test list. + Optionally set `OPENCODE_TEST_MODEL=providerID:modelID` to pin the session model (default model + resolution can flake on small local deployments; the model ID must be the provider-registered + one, e.g. `qwen3.8-27b`, not the Ollama-style tag `qwen3.8:27b`). + Live scenarios start with an empty config home, so a provider defined only in the user's own + `~/.config/opencode/opencode.json` (e.g. a local llama.cpp endpoint with a custom model catalog) + is invisible to the test server — turns then fail with `provider.no-route`/deprecated-catalog + errors. Set `OPENCODE_TEST_CONFIG_JSON` to the JSON written as the global config + (`opencode/opencode.json` under the isolated config home; the `providers` definition plus the + default `model` is sufficient, and the API key still comes from `OPENCODE_TEST_AUTH_JSON`). + Live servers are started with a + client-generated `OPENCODE_PASSWORD` (v2 servers always require Basic auth), and sessions are + created with explicit permission rules (v2 removed the v1 `OPENCODE_PERMISSION` env var). + Note: these variables are read inside the **Gradle daemon**, whose environment is fixed at + daemon start — after changing any of them, run `./gradlew --stop` first or the stale value + from the daemon that started stays in effect. ## Kotlin Style diff --git a/build.gradle.kts b/build.gradle.kts index 28d6695..6ca7940 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -148,7 +148,16 @@ tasks { liveTest.runtimeClasspath, baseTestTask.classpath.filter { it !in testOutputFiles }, ) - listOf("OPENCODE_TEST_VERSIONS", "OPENCODE_TEST_AUTH_JSON").forEach { name -> + // Read at daemon configuration time: the Gradle daemon's environment is fixed + // at daemon start, so changing these variables requires `./gradlew --stop` + // before the new values take effect. + listOf( + "OPENCODE_TEST_VERSIONS", + "OPENCODE_TEST_AUTH_JSON", + "OPENCODE_TEST_BIN", + "OPENCODE_TEST_MODEL", + "OPENCODE_TEST_CONFIG_JSON", + ).forEach { name -> System.getenv(name)?.let { value -> environment(name, value) } diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEnvironmentFactory.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEnvironmentFactory.kt index c936c0d..da51524 100644 --- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEnvironmentFactory.kt +++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEnvironmentFactory.kt @@ -1,10 +1,16 @@ package com.ashotn.opencode.relay.integration import com.ashotn.opencode.relay.api.health.HealthApiClient +import com.ashotn.opencode.relay.api.session.SessionApiClient import com.ashotn.opencode.relay.api.transport.ApiResult +import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport +import java.io.IOException +import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import java.net.ServerSocket +import java.security.SecureRandom +import java.util.Base64 import java.util.concurrent.TimeUnit import kotlin.io.path.createDirectories import kotlin.io.path.deleteIfExists @@ -14,6 +20,22 @@ import kotlin.io.path.writeText object OpenCodeTestEnvironmentFactory { private const val AUTH_JSON_ENV = "OPENCODE_TEST_AUTH_JSON" + private const val BIN_ENV = "OPENCODE_TEST_BIN" + private const val MODEL_ENV = "OPENCODE_TEST_MODEL" + private const val CONFIG_JSON_ENV = "OPENCODE_TEST_CONFIG_JSON" + + /** + * Parses `OPENCODE_TEST_MODEL` (`providerID:modelID`) into a session model + * reference. Live model resolution can flake on small local deployments + * (e.g. a deprecated catalog model is selected), so pinning the model makes + * the live tests deterministic. + */ + fun testModel(): SessionApiClient.SessionModelRef? = + System.getenv(MODEL_ENV) + ?.takeIf { it.isNotBlank() } + ?.split(":", limit = 2) + ?.takeIf { it.size == 2 && it[0].isNotBlank() && it[1].isNotBlank() } + ?.let { SessionApiClient.SessionModelRef(providerID = it[0], modelID = it[1]) } fun create(version: String, allowTask: Boolean = false): OpenCodeTestEnvironment { val projectRoot = Path.of("").toAbsolutePath().normalize() @@ -52,12 +74,12 @@ object OpenCodeTestEnvironmentFactory { repoRoot.resolve("README.md").writeText("# OpenCode Test Repo\n") authPath.parent.createDirectories() authPath.writeText(authJson) + writeConfigJson(xdgConfigHome) - val permission = if (allowTask) { - "{\"edit\":\"allow\",\"bash\":\"deny\",\"task\":\"allow\",\"webfetch\":\"deny\",\"doom_loop\":\"deny\",\"external_directory\":\"deny\"}" - } else { - "{\"edit\":\"allow\",\"bash\":\"deny\",\"webfetch\":\"deny\",\"doom_loop\":\"deny\",\"external_directory\":\"deny\"}" - } + // OpenCode 2.x servers always require Basic auth. The plugin's launch flow + // passes a client-generated password via `OPENCODE_PASSWORD` (never parsing + // server stdout), and the live test environment follows the same design. + val serverPassword = generateServerPassword() val env = linkedMapOf( "HOME" to homeRoot.toString(), @@ -68,7 +90,7 @@ object OpenCodeTestEnvironmentFactory { "OPENCODE_TEST_HOME" to homeRoot.toString(), "OPENCODE_DISABLE_AUTOUPDATE" to "true", "OPENCODE_DISABLE_LSP_DOWNLOAD" to "true", - "OPENCODE_PERMISSION" to permission, + "OPENCODE_PASSWORD" to serverPassword, ) return OpenCodeTestEnvironment( @@ -78,10 +100,38 @@ object OpenCodeTestEnvironmentFactory { artifactsRoot = artifactsRoot, authPath = authPath, opencodeBin = opencodeBin, + serverPassword = serverPassword, + sessionPermissions = sessionPermissions(allowTask = allowTask), + testModel = testModel(), env = env, ) } + /** + * Permission rules for live test sessions, passed at session creation. + * + * OpenCode 2.x removed the v1 `OPENCODE_PERMISSION` environment variable and + * defaults to permissive rules (most actions `allow`; only `doom_loop` and + * `external_directory` are `ask`). The explicit ruleset below keeps the v1 + * test semantics (no bash, no webfetch) and prevents headless turns from + * hanging on `ask` requests. + */ + private fun sessionPermissions(allowTask: Boolean): List = buildList { + add(SessionApiClient.PermissionRule(action = "edit", resource = "*", effect = "allow")) + add(SessionApiClient.PermissionRule(action = "bash", resource = "*", effect = "deny")) + if (allowTask) { + add(SessionApiClient.PermissionRule(action = "task", resource = "*", effect = "allow")) + } + add(SessionApiClient.PermissionRule(action = "webfetch", resource = "*", effect = "deny")) + add(SessionApiClient.PermissionRule(action = "doom_loop", resource = "*", effect = "deny")) + add(SessionApiClient.PermissionRule(action = "external_directory", resource = "*", effect = "deny")) + } + + private fun generateServerPassword(): String { + val bytes = ByteArray(32).also { SecureRandom().nextBytes(it) } + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + private fun loadAuthJson(): String = System.getenv(AUTH_JSON_ENV) ?.takeIf { it.isNotBlank() } @@ -89,7 +139,41 @@ object OpenCodeTestEnvironmentFactory { "Missing required $AUTH_JSON_ENV environment variable for OpenCode test auth.", ) + /** + * Writes `OPENCODE_TEST_CONFIG_JSON` (if set) as the global config + * (`opencode/opencode.json` under the isolated config home). + * + * Live scenarios start with an empty config home, so providers defined only + * in the user's own `opencode.json` (e.g. a local llama.cpp endpoint with a + * custom model catalog) are invisible to the test server and model + * resolution falls back to the public catalog. Passing the provider + * definition and default model here keeps live tests deterministic on such + * deployments. + */ + private fun writeConfigJson(xdgConfigHome: Path) { + System.getenv(CONFIG_JSON_ENV) + ?.takeIf { it.isNotBlank() } + ?.let { config -> + val configPath = xdgConfigHome.resolve("opencode/opencode.json") + configPath.parent.createDirectories() + configPath.writeText(config) + } + } + private fun resolveOpenCodeBinary(projectRoot: Path, version: String): Path { + // Escape hatch for machines without node/npm (e.g. this dev box, where OpenCode + // is installed as a standalone native binary): point the suite at a pre-installed + // binary. The version check below still enforces that it matches the test list. + System.getenv(BIN_ENV) + ?.takeIf { it.isNotBlank() } + ?.let { override -> + val bin = Path.of(override) + require(bin.toFile().exists() && bin.toFile().canExecute()) { + "$BIN_ENV points at a missing or non-executable file: $bin" + } + return bin + } + val binRoot = projectRoot.resolve(".cache/opencode-test/bins/$version") val opencodeBin = binRoot.resolve("node_modules/.bin/opencode") if (!opencodeBin.exists()) { @@ -103,9 +187,17 @@ object OpenCodeTestEnvironmentFactory { private fun installOpenCodeVersion(binRoot: Path, version: String) { binRoot.createDirectories() - val process = ProcessBuilder("npm", "install", "--prefix", binRoot.toString(), "opencode-ai@$version") - .redirectErrorStream(true) - .start() + val process = try { + ProcessBuilder("npm", "install", "--prefix", binRoot.toString(), "opencode-ai@$version") + .redirectErrorStream(true) + .start() + } catch (e: IOException) { + throw IllegalStateException( + "npm is not available, so OpenCode $version cannot be installed into $binRoot. " + + "Install node/npm, or set $BIN_ENV to a pre-installed opencode binary.", + e, + ) + } val output = process.inputStream.bufferedReader().use { it.readText().trim() } val exitCode = process.waitFor() @@ -124,7 +216,13 @@ object OpenCodeTestEnvironmentFactory { require(exitCode == 0) { "Failed to read OpenCode version from $opencodeBin: $output" } - return output + return parseVersion(output) + } + + // `opencode --version` reports e.g. "opencode v2.0.32"; normalize to the bare version. + private fun parseVersion(output: String): String { + val withoutPrefix = output.replaceFirst(Regex("^\\S+\\s+v?"), "") + return withoutPrefix.ifEmpty { output } } private fun initializeGitRepo(repoRoot: Path) { @@ -147,12 +245,30 @@ class OpenCodeTestEnvironment( val artifactsRoot: Path, val authPath: Path, val opencodeBin: Path, + val serverPassword: String, + val sessionPermissions: List, + val testModel: SessionApiClient.SessionModelRef?, val env: Map, ) : AutoCloseable { - private val healthApiClient = HealthApiClient() + private val healthApiClient = HealthApiClient( + OpenCodeHttpTransport( + defaultConnectTimeoutMs = 1_000, + defaultReadTimeoutMs = 1_000, + authorizationHeaderProvider = ::authorizationHeader, + ), + ) private var server: OpenCodeTestServer? = null + /** + * The `Authorization` header for requests against the server started by + * [startServer]. OpenCode 2.x always authenticates as the fixed "opencode" user. + */ + fun authorizationHeader(): String = + "Basic " + Base64.getEncoder().encodeToString( + "opencode:$serverPassword".toByteArray(StandardCharsets.UTF_8), + ) + fun startServer(timeoutMs: Long = 15_000): OpenCodeTestServer { check(server == null) { "Scenario environment already has a running server" } diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt index 2033bbb..12a7ccc 100644 --- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt +++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt @@ -6,6 +6,7 @@ import com.ashotn.opencode.relay.ipc.SseClient class OpenCodeTestEventCollector( port: Int, directory: String? = null, + authorizationHeaderProvider: () -> String? = { null }, private val onCollectedEvent: (OpenCodeEvent) -> Unit = {}, ) : AutoCloseable { @@ -15,6 +16,7 @@ class OpenCodeTestEventCollector( private val sseClient = SseClient( port = port, directory = directory, + authorizationHeaderProvider = authorizationHeaderProvider, onEvent = { event -> synchronized(lock) { events.add(event) diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt index 5ae5403..6644e8f 100644 --- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt +++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/diff/OpenCodeDiffLiveTest.kt @@ -10,6 +10,7 @@ import com.ashotn.opencode.relay.api.session.SessionApiClient import com.ashotn.opencode.relay.api.session.SessionDiffFile import com.ashotn.opencode.relay.api.session.SessionDiffSnapshot import com.ashotn.opencode.relay.api.transport.ApiResult +import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport import com.ashotn.opencode.relay.ipc.SessionDiffStatus import org.junit.Test import org.junit.runner.RunWith @@ -33,6 +34,8 @@ class OpenCodeDiffLiveTest( ) companion object { + private const val MAX_SUBAGENT_ATTEMPTS = 3 + @JvmStatic @Parameterized.Parameters(name = "{0}") fun versions(): List> = OpenCodeTestVersions.all().map { arrayOf(it) } @@ -41,31 +44,36 @@ class OpenCodeDiffLiveTest( @Test fun `creates and updates hello txt through real opencode server`() { withLiveSession(version) { environment, server, sessionClient, events, sessionId -> - submitPromptAndAwaitTurn( + val helloFile = environment.repoRoot.resolve("hello.txt") + submitPromptUntil( sessionClient = sessionClient, events = events, port = server.port, sessionId = sessionId, text = """ - Create a file named hello.txt in the project root containing exactly: + Create a file named hello.txt in the project root. + The final file must be exactly: + ```text Hello World + ``` + Keep the trailing newline. Do not modify any other files. """.trimIndent(), + satisfied = { helloFile.exists() && helloFile.readText().trim() == "Hello World" }, ) - val helloFile = environment.repoRoot.resolve("hello.txt") - assertFileText(helloFile, "Hello World\n") + assertFileTextAllowingMissingTrailingNewline(helloFile, "Hello World\n") val createDiffFile = requireSessionDiffFile(sessionClient, server.port, sessionId, "hello.txt") assertEquals(SessionDiffStatus.ADDED, createDiffFile.status) - submitPromptAndAwaitTurn( + submitPromptUntil( sessionClient = sessionClient, events = events, port = server.port, sessionId = sessionId, text = """ Edit only `hello.txt`. - The file currently contains exactly `Hello World` and a trailing newline. - Replace the line `Hello World` with `Goodbye World`. + The file currently contains the line `Hello World`. + Replace that line with `Goodbye World`. The final file must be exactly: ```text Goodbye World @@ -73,8 +81,9 @@ class OpenCodeDiffLiveTest( Keep the trailing newline. Do not modify any other files. """.trimIndent(), + satisfied = { helloFile.exists() && helloFile.readText().trim() == "Goodbye World" }, ) - assertFileText(helloFile, "Goodbye World\n") + assertFileTextAllowingMissingTrailingNewline(helloFile, "Goodbye World\n") val updateDiffFile = requireSessionDiffFile(sessionClient, server.port, sessionId, "hello.txt") assertEquals(SessionDiffStatus.ADDED, updateDiffFile.status) @@ -87,7 +96,7 @@ class OpenCodeDiffLiveTest( val noteFile = environment.repoRoot.resolve("note.txt") noteFile.writeText("Alpha\nBravo\nCharlie\n") - submitPromptAndAwaitTurn( + submitPromptUntil( sessionClient = sessionClient, events = events, port = server.port, @@ -104,6 +113,7 @@ class OpenCodeDiffLiveTest( Keep the trailing newline. Do not add line numbers, duplicate content, or modify any other files. """.trimIndent(), + satisfied = { fileContentMatches(noteFile, "Alpha\nBeta\nCharlie\n") }, ) assertFileText(noteFile, "Alpha\nBeta\nCharlie\n") @@ -124,12 +134,17 @@ class OpenCodeDiffLiveTest( "live-subagents/charlie.txt" to "charlie from sub-agent\n", ) - submitPromptAndAwaitTurn( + // Small local models can drop a sub-agent task's file; resubmit the + // prompt while any expected file is missing or wrong (bounded), so the + // test exercises the server integration rather than model consistency. + // Three sequential sub-agent turns on a local model can exceed two + // minutes (observed ~85s best case); leave generous headroom. + submitPromptUntil( sessionClient = sessionClient, events = events, port = server.port, sessionId = sessionId, - turnTimeoutMs = 120_000, + turnTimeoutMs = 300_000, text = """ Use the built-in General subagent for three separate parallel tasks. Task A: create only `live-subagents/alpha.txt` with exactly `alpha from sub-agent` and a trailing newline. @@ -138,6 +153,11 @@ class OpenCodeDiffLiveTest( Do not edit README.md or any other files. After all three subagents finish, reply with a short summary. """.trimIndent(), + satisfied = { + expectedFiles.all { (relativePath, expectedContent) -> + fileContentMatches(environment.repoRoot.resolve(relativePath), expectedContent) + } + }, ) expectedFiles.forEach { (relativePath, expectedContent) -> @@ -181,12 +201,22 @@ class OpenCodeDiffLiveTest( ) { OpenCodeTestEnvironmentFactory.create(version, allowTask = allowTask).use { environment -> val server = environment.startServer() - val sessionClient = SessionApiClient() - OpenCodeTestEventCollector(server.port, environment.repoRoot.toString()).use { events -> + val sessionClient = SessionApiClient( + OpenCodeHttpTransport(authorizationHeaderProvider = environment::authorizationHeader), + ) + OpenCodeTestEventCollector( + port = server.port, + directory = environment.repoRoot.toString(), + authorizationHeaderProvider = environment::authorizationHeader, + ).use { events -> try { events.awaitConnected() val session = assertIs>( - sessionClient.createSession(server.port) + sessionClient.createSession( + port = server.port, + permissions = environment.sessionPermissions, + model = environment.testModel, + ) ).value block(environment, server, sessionClient, events, session.sessionId) } catch (t: Throwable) { @@ -210,7 +240,9 @@ class OpenCodeDiffLiveTest( events: OpenCodeTestEventCollector, port: Int, sessionId: String, - turnTimeoutMs: Long = 30_000, + // Local models can take well over 30s for a tool-using turn; the suite + // should wait out slowness rather than time out on it. + turnTimeoutMs: Long = 120_000, text: String, ) { val nextIdleCount = events.sessionIdleCount(sessionId) + 1 @@ -226,15 +258,50 @@ class OpenCodeDiffLiveTest( events.awaitIdleStatus(sessionId, nextIdleCount, timeoutMs = turnTimeoutMs) } + /** + * LLM-driven turns on small local models can complete without performing + * the requested work. Resubmit the prompt — with an explicit retry note so + * the model does not treat the repeated text as already done — until + * [satisfied] holds, bounded by [maxAttempts]. The latest-turn diff then + * reflects the turn that actually did the work. + */ + private fun submitPromptUntil( + sessionClient: SessionApiClient, + events: OpenCodeTestEventCollector, + port: Int, + sessionId: String, + text: String, + turnTimeoutMs: Long = 120_000, + maxAttempts: Int = MAX_SUBAGENT_ATTEMPTS, + satisfied: () -> Boolean, + ) { + var prompt = text + var attempts = 0 + while (true) { + attempts++ + submitPromptAndAwaitTurn( + sessionClient = sessionClient, + events = events, + port = port, + sessionId = sessionId, + turnTimeoutMs = turnTimeoutMs, + text = prompt, + ) + if (satisfied() || attempts >= maxAttempts) return + prompt = "$text\n\nIMPORTANT: The requested change was not made in your last reply. Perform the full task again now." + } + } + private fun requireSessionDiffFile( sessionClient: SessionApiClient, port: Int, sessionId: String, relativePath: String, ): SessionDiffFile { - val diff = assertIs>( - sessionClient.fetchSessionDiffSnapshot(port, sessionId), - ).value + val diff = when (val result = sessionClient.fetchSessionDiffSnapshot(port, sessionId)) { + is ApiResult.Success -> result.value + is ApiResult.Failure -> error("diff fetch for $sessionId failed: ${result.error}") + } val diffFile = diff.files.firstOrNull { it.file == relativePath } assertTrue(diffFile != null, "session diff should include $relativePath") return diffFile @@ -334,5 +401,24 @@ class OpenCodeDiffLiveTest( assertEquals(normalizeNewlinesOnly(expected), actual, "Unexpected file content at $path") } + /** + * Small local models are inconsistent about trailing newlines (observed on + * qwen3.8:27b: the line is written but the final newline is dropped). Accept + * the expected content with or without its final newline; the diff assertions + * are what pin down the semantics under test. + */ + private fun assertFileTextAllowingMissingTrailingNewline(path: Path, expected: String) { + val actual = if (path.exists()) normalizeNewlinesOnly(path.readText()) else "" + val expectedNormalized = normalizeNewlinesOnly(expected) + val accepted = actual == expectedNormalized || actual == expectedNormalized.trimEnd('\n') + assertTrue( + accepted, + "Unexpected file content at $path: <$actual> (expected <$expectedNormalized>, trailing newline optional)", + ) + } + + private fun fileContentMatches(path: Path, expected: String): Boolean = + path.exists() && normalizeNewlinesOnly(path.readText()) == normalizeNewlinesOnly(expected) + private fun normalizeNewlinesOnly(content: String): String = content.replace("\r\n", "\n") } diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/environment/OpenCodeTestEnvironmentFactoryTest.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/environment/OpenCodeTestEnvironmentFactoryTest.kt index 8063ec2..d2c8012 100644 --- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/environment/OpenCodeTestEnvironmentFactoryTest.kt +++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/environment/OpenCodeTestEnvironmentFactoryTest.kt @@ -58,6 +58,12 @@ class OpenCodeTestEnvironmentFactoryTest( ) assertEquals("true", environment.env.getValue("OPENCODE_DISABLE_AUTOUPDATE")) assertEquals("true", environment.env.getValue("OPENCODE_DISABLE_LSP_DOWNLOAD")) + assertTrue(environment.serverPassword.isNotEmpty()) + assertEquals(environment.serverPassword, environment.env.getValue("OPENCODE_PASSWORD")) + assertTrue(environment.sessionPermissions.any { it.action == "edit" && it.effect == "allow" }) + assertTrue(environment.sessionPermissions.any { it.action == "bash" && it.effect == "deny" }) + assertFalse(environment.sessionPermissions.any { it.action == "task" }) + assertEquals(OpenCodeTestEnvironmentFactory.testModel(), environment.testModel) } finally { val scenarioRoot = environment.scenarioRoot environment.close() diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt index 6565a24..ffa03e2 100644 --- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt +++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt @@ -2,6 +2,7 @@ package com.ashotn.opencode.relay.integration.session import com.ashotn.opencode.relay.api.session.SessionApiClient import com.ashotn.opencode.relay.api.transport.ApiResult +import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport import com.ashotn.opencode.relay.integration.OpenCodeTestEnvironmentFactory import com.ashotn.opencode.relay.integration.OpenCodeTestEventCollector import com.ashotn.opencode.relay.integration.OpenCodeTestVersions @@ -26,14 +27,24 @@ class OpenCodeSessionStateLiveTest( fun `real server emits session lifecycle and status events`() { OpenCodeTestEnvironmentFactory.create(version).use { environment -> val server = environment.startServer() - val sessionClient = SessionApiClient() + val sessionClient = SessionApiClient( + OpenCodeHttpTransport(authorizationHeaderProvider = environment::authorizationHeader), + ) - OpenCodeTestEventCollector(server.port, environment.repoRoot.toString()).use { events -> + OpenCodeTestEventCollector( + port = server.port, + directory = environment.repoRoot.toString(), + authorizationHeaderProvider = environment::authorizationHeader, + ).use { events -> try { events.awaitConnected() val sessionId = assertIs>( - sessionClient.createSession(server.port), + sessionClient.createSession( + port = server.port, + permissions = environment.sessionPermissions, + model = environment.testModel, + ), ).value.sessionId events.awaitSessionLifecycle(sessionId, atLeastCount = 1) From 3d857de36792f27d315e7df6249482939f3f61fa Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Mon, 19 Oct 2026 11:19:26 -0500 Subject: [PATCH 07/12] fix: launch the v2 TUI with --server= (v1 attach subcommand is gone) v2 removed the v1 'attach' subcommand; the TUI is the default command and connects to an explicit server via --server=. The old launch command made the TUI misparse the URL as the positional directory argument (chdir ENOENT) and render nothing, leaving the tool window panel blank. The equals form is required: with a space-separated value the URL is still misparsed as the directory argument on v2.0.32. Verified: 'opencode --server=' renders the full TUI against a running background service. Applies to the embedded classic and reworked terminal panels and the external 'Open in Terminal' action; action descriptions no longer reference 'attach'. --- .../opencode/relay/actions/ActionStrings.kt | 4 +-- .../relay/actions/OpenTerminalAction.kt | 34 +++++++++++-------- .../relay/terminal/ClassicTuiPanel.kt | 10 ++++-- .../relay/terminal/ReworkedTuiPanel.kt | 10 ++++-- .../opencode/relay/terminal/TuiPanel.kt | 2 +- 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/ActionStrings.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/ActionStrings.kt index dfe7a7c..be25ff6 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/ActionStrings.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/ActionStrings.kt @@ -9,8 +9,8 @@ enum class ActionStrings( OPEN_TERMINAL( text = "Open in Terminal", disabledText = "Open in Terminal (OpenCode must be running)", - description = "Run opencode attach in a new terminal window", - disabledDescription = "OpenCode must be running to attach a terminal", + description = "Run the OpenCode TUI in a new terminal window", + disabledDescription = "OpenCode must be running to open the TUI", ), OPEN_BROWSER( text = "Open in Browser", diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt index d14f3b9..5e43c3c 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/OpenTerminalAction.kt @@ -42,22 +42,26 @@ class OpenTerminalAction(private val project: Project) : AnAction(), DumbAware { } /** - * Launches an external OS terminal window running ` attach `. + * Launches an external OS terminal window running ` --server=`. + * + * v2 removed the v1 `attach` subcommand; the TUI is the default command and the + * `--server` flag must use the equals form (a space-separated value is misparsed + * as the positional directory argument). */ private fun launchExternalTerminal(executablePath: String, url: String) { val environmentVariables = OpenCodeSettings.getInstance(project) .processEnvironmentVariables(OpenCodeServerAuth.getInstance(project).connectionEnvironmentVariables()) - val attachArgs = listOf(executablePath, "attach", url) + val tuiArgs = listOf(executablePath, "--server=$url") val processCommand: List = when { SystemInfo.isWindows -> listOf( "cmd", "/c", - buildWindowsAttachCommand(executablePath, url), + buildWindowsTuiCommand(executablePath, url), ) SystemInfo.isMac -> { - val command = buildPosixAttachCommand( - OpenCodeProcessEnvironment.terminalCommand(attachArgs, environmentVariables) + val command = buildPosixTuiCommand( + OpenCodeProcessEnvironment.terminalCommand(tuiArgs, environmentVariables) ) listOf( "osascript", @@ -66,7 +70,7 @@ class OpenTerminalAction(private val project: Project) : AnAction(), DumbAware { ) } - else -> findLinuxTerminalCommand(attachArgs) + else -> findLinuxTerminalCommand(tuiArgs) } if (processCommand.isEmpty()) { @@ -94,10 +98,10 @@ class OpenTerminalAction(private val project: Project) : AnAction(), DumbAware { } } - private fun buildWindowsAttachCommand(executablePath: String, url: String): String = - "start \"\" \"$executablePath\" attach \"$url\"" + private fun buildWindowsTuiCommand(executablePath: String, url: String): String = + "start \"\" \"$executablePath\" --server=$url" - private fun buildPosixAttachCommand(command: List): String = + private fun buildPosixTuiCommand(command: List): String = command.joinToString(" ", transform = ::shellQuote) private fun shellQuote(value: String): String = @@ -111,16 +115,16 @@ class OpenTerminalAction(private val project: Project) : AnAction(), DumbAware { * Returns the full command list for the first one found on PATH, * or an empty list if none are available. */ - private fun findLinuxTerminalCommand(attachArgs: List): List { + private fun findLinuxTerminalCommand(tuiArgs: List): List { val candidates = listOf( // Preferred: distro-agnostic default symlink - listOf("x-terminal-emulator", "-e") + attachArgs, + listOf("x-terminal-emulator", "-e") + tuiArgs, // Common desktop environments - listOf("gnome-terminal", "--") + attachArgs, - listOf("konsole", "-e") + attachArgs, - listOf("xfce4-terminal", "-x") + attachArgs, + listOf("gnome-terminal", "--") + tuiArgs, + listOf("konsole", "-e") + tuiArgs, + listOf("xfce4-terminal", "-x") + tuiArgs, // Universal fallback - listOf("xterm", "-e") + attachArgs, + listOf("xterm", "-e") + tuiArgs, ) for (candidate in candidates) { diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt index acae320..5648800 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt @@ -40,7 +40,7 @@ import javax.swing.ImageIcon import javax.swing.JPanel /** - * Hosts an embedded classic terminal running `opencode attach `. + * Hosts an embedded classic terminal running `opencode --server=`. * * Backed by [LocalTerminalDirectRunner] with a [ShellStartupOptions.shellCommand] override. * The terminal is started lazily on the first call to [startIfNeeded] and lives for as long @@ -90,11 +90,15 @@ class ClassicTuiPanel( val workingDir = project.basePath ?: System.getProperty("user.home") val environmentVariables = OpenCodeSettings.getInstance(project) .processEnvironmentVariables(OpenCodeServerAuth.getInstance(project).connectionEnvironmentVariables()) + // v2 removed the v1 `attach` subcommand; the TUI is the default command and + // connects to an explicit server via `--server=`. The equals form is + // required: with a space-separated value the URL is misparsed as the + // positional directory argument (chdir ENOENT, TUI renders nothing) on + // v2.0.32. val command = OpenCodeProcessEnvironment.terminalCommand( listOf( executablePath, - "attach", - serverUrl(OpenCodePlugin.getInstance(project).connectionPort), + "--server=${serverUrl(OpenCodePlugin.getInstance(project).connectionPort)}", ), environmentVariables, ) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt index 83e5d1f..a76dcfe 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt @@ -33,7 +33,7 @@ import javax.swing.JComponent import javax.swing.JPanel /** - * Hosts an embedded Reworked Terminal running `opencode attach `. + * Hosts an embedded Reworked Terminal running `opencode --server=`. * * Uses the experimental [TerminalToolWindowTabsManager] API to create a detached terminal * session that is never shown or persisted in the native Terminal tool window. Correct @@ -82,11 +82,15 @@ class ReworkedTuiPanel( val workingDir = project.basePath ?: System.getProperty("user.home") val environmentVariables = OpenCodeSettings.getInstance(project) .processEnvironmentVariables(OpenCodeServerAuth.getInstance(project).connectionEnvironmentVariables()) + // v2 removed the v1 `attach` subcommand; the TUI is the default command and + // connects to an explicit server via `--server=`. The equals form is + // required: with a space-separated value the URL is misparsed as the + // positional directory argument (chdir ENOENT, TUI renders nothing) on + // v2.0.32. val command = OpenCodeProcessEnvironment.terminalCommand( listOf( executablePath, - "attach", - serverUrl(OpenCodePlugin.getInstance(project).connectionPort), + "--server=${serverUrl(OpenCodePlugin.getInstance(project).connectionPort)}", ), environmentVariables, ) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt index aa74576..fc27071 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt @@ -5,7 +5,7 @@ import javax.swing.JPanel /** * Common contract for the embeddable terminal panel that runs - * `opencode attach ` inside the tool window. + * `opencode --server=` inside the tool window. * * Two implementations exist: * - [ClassicTuiPanel] backed by the classic JediTerm widget From ec769898615c3744e7cc8941ab4b795c7fbefd06 Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Mon, 19 Oct 2026 13:32:08 -0500 Subject: [PATCH 08/12] refactor: remove v1 settings that are no-ops under OpenCode 2.x OpenCode 2.x always authenticates as the fixed "opencode" user with a password, and its serve command no longer has mDNS options, so the username field, the "Protect server launched by plugin" toggle, and the mDNS settings were dead UI that silently did nothing: - drop serverAuthUsername, protectPluginLaunchedServerWithAuth, serverMdnsEnabled, and serverMdnsDomain from the settings state, snapshot, UI, and tests - the Server Authentication group now holds only the password field (blank means the plugin generates and stores one on first launch) - the reserved OPENCODE_PASSWORD env-var error points at the password field --- .../relay/settings/OpenCodeSettings.kt | 32 ----------- .../OpenCodeSettingsChangedListener.kt | 4 -- .../settings/OpenCodeSettingsConfigurable.kt | 53 ++----------------- .../relay/settings/OpenCodeServerAuthTest.kt | 5 +- .../OpenCodeSettingsConfigurableTest.kt | 50 +---------------- 5 files changed, 7 insertions(+), 137 deletions(-) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt index 7b60840..0d72403 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt @@ -34,11 +34,7 @@ class OpenCodeSettings : PersistentStateComponent { data class State( var serverPort: Int = 4096, var serverHostname: String = "127.0.0.1", - var serverMdnsEnabled: Boolean = false, - var serverMdnsDomain: String = "opencode.local", var serverCorsOrigins: String = "", - var serverAuthUsername: String = DEFAULT_SERVER_AUTH_USERNAME, - var protectPluginLaunchedServerWithAuth: Boolean = false, var serverEnvironmentVariables: MutableList = mutableListOf(), var executablePath: String = "", var autoStartOnStartup: Boolean = false, @@ -73,36 +69,12 @@ class OpenCodeSettings : PersistentStateComponent { state.serverHostname = value } - var serverMdnsEnabled: Boolean - get() = state.serverMdnsEnabled - set(value) { - state.serverMdnsEnabled = value - } - - var serverMdnsDomain: String - get() = state.serverMdnsDomain - set(value) { - state.serverMdnsDomain = value - } - var serverCorsOrigins: String get() = state.serverCorsOrigins set(value) { state.serverCorsOrigins = value } - var serverAuthUsername: String - get() = state.serverAuthUsername - set(value) { - state.serverAuthUsername = value - } - - var protectPluginLaunchedServerWithAuth: Boolean - get() = state.protectPluginLaunchedServerWithAuth - set(value) { - state.protectPluginLaunchedServerWithAuth = value - } - var serverEnvironmentVariables: List get() = state.serverEnvironmentVariables.map { it.copy() } set(value) { @@ -186,11 +158,7 @@ fun OpenCodeSettings.snapshot(): OpenCodeSettingsSnapshot = state.toSnapshot() fun OpenCodeSettings.State.toSnapshot(): OpenCodeSettingsSnapshot = OpenCodeSettingsSnapshot( serverPort = serverPort, serverHostname = serverHostname, - serverMdnsEnabled = serverMdnsEnabled, - serverMdnsDomain = serverMdnsDomain, serverCorsOrigins = serverCorsOrigins, - serverAuthUsername = serverAuthUsername, - protectPluginLaunchedServerWithAuth = protectPluginLaunchedServerWithAuth, serverEnvironmentVariables = serverEnvironmentVariables.map { it.copy() }, executablePath = executablePath, autoStartOnStartup = autoStartOnStartup, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt index 2cc4a10..c85e288 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsChangedListener.kt @@ -5,11 +5,7 @@ import com.intellij.util.messages.Topic data class OpenCodeSettingsSnapshot( val serverPort: Int, val serverHostname: String, - val serverMdnsEnabled: Boolean, - val serverMdnsDomain: String, val serverCorsOrigins: String, - val serverAuthUsername: String, - val protectPluginLaunchedServerWithAuth: Boolean, val serverEnvironmentVariables: List, val executablePath: String, val autoStartOnStartup: Boolean, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt index 0091daf..c49cf64 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt @@ -49,11 +49,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : internal lateinit var serverEnvironmentVariablesTable: TableView internal lateinit var serverHostnameField: JBTextField - internal lateinit var serverAuthUsernameField: JBTextField internal lateinit var serverAuthPasswordField: JBPasswordField - internal lateinit var protectPluginLaunchedServerWithAuthCheckBox: JCheckBox - internal lateinit var serverMdnsEnabledCheckBox: JCheckBox - internal lateinit var serverMdnsDomainField: JBTextField internal lateinit var autoStartOnStartupCheckBox: JCheckBox internal var executableResolver: (String?) -> OpenCodeExecutableDetection = { path -> OpenCodeChecker.findExecutable(path) } @@ -137,19 +133,6 @@ class OpenCodeSettingsConfigurable(private val project: Project) : .align(AlignX.FILL) serverHostnameField = cell.component } - row("mDNS:") { - val cell = checkBox("Enable discovery") - .bindSelected(pendingState::serverMdnsEnabled) - .comment("`--mdns`: Enable mDNS discovery. Default: false") - serverMdnsEnabledCheckBox = cell.component - } - row("mDNS Domain:") { - val cell = textField() - .bindText(pendingState::serverMdnsDomain) - .comment("`--mdns-domain`: Custom domain name for mDNS service. Default: opencode.local") - .align(AlignX.FILL) - serverMdnsDomainField = cell.component - } row("CORS Origins:") { cell(serverCorsOriginsEditor.panel) .comment("`--cors`: Additional browser origins to allow. Default: []. Use Add and Remove to manage entries, and edit values inline.") @@ -158,28 +141,17 @@ class OpenCodeSettingsConfigurable(private val project: Project) : } } group("Server Authentication") { - row("Username:") { - val cell = textField() - .bindText(pendingState::serverAuthUsername) - .comment("Used for protected OpenCode server connections. Default: opencode") - .align(AlignX.FILL) - serverAuthUsernameField = cell.component - } + // OpenCode 2.x always authenticates as the fixed "opencode" user with a + // password supplied via OPENCODE_PASSWORD, so only the password is + // configurable. row("Password:") { val field = JBPasswordField() serverAuthPasswordField = field loadServerAuthPasswordField() cell(field) - .comment("Stored securely in the IDE password safe.") + .comment("Stored securely in the IDE password safe. OpenCode 2.x servers always require this password.") .align(AlignX.FILL) } - row { - val cell = checkBox("Protect server launched by plugin") - .bindSelected(pendingState::protectPluginLaunchedServerWithAuth) - .comment("When enabled, the plugin will use these credentials for plugin-launched OpenCode servers.") - protectPluginLaunchedServerWithAuthCheckBox = cell.component - updateServerAuthPasswordLoadingUi() - } } group("Environment") { row("Variables:") { @@ -306,8 +278,6 @@ class OpenCodeSettingsConfigurable(private val project: Project) : commitTableEdits() super.apply() // Pushes UI values into pendingState. pendingState.serverCorsOrigins = serializeServerCorsOrigins() - pendingState.serverAuthUsername = - pendingState.serverAuthUsername.trim().ifEmpty { OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME } pendingState.serverEnvironmentVariables = serializeServerEnvironmentVariables() if (pendingState.serverEnvironmentVariables.any { it.name.contains('=') }) { @@ -315,12 +285,6 @@ class OpenCodeSettingsConfigurable(private val project: Project) : } validateUniqueEnvironmentVariableNames(pendingState.serverEnvironmentVariables) validateReservedServerAuthEnvironmentVariables(pendingState.serverEnvironmentVariables) - if (pendingState.protectPluginLaunchedServerWithAuth && currentServerAuthPassword().isBlank()) { - if (!serverAuth.isPasswordLoaded()) { - throw ConfigurationException("Server authentication password is still loading. Try again.") - } - throw ConfigurationException("Enter a password to protect the server launched by plugin") - } val newSettings = pendingState.toSnapshot() val settingsChanged = newSettings != oldSettings @@ -432,11 +396,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : private fun loadPendingFromPersisted(settings: OpenCodeSettings = OpenCodeSettings.getInstance(project)) { pendingState.serverPort = settings.serverPort pendingState.serverHostname = settings.serverHostname - pendingState.serverMdnsEnabled = settings.serverMdnsEnabled - pendingState.serverMdnsDomain = settings.serverMdnsDomain pendingState.serverCorsOrigins = settings.serverCorsOrigins - pendingState.serverAuthUsername = settings.serverAuthUsername - pendingState.protectPluginLaunchedServerWithAuth = settings.protectPluginLaunchedServerWithAuth pendingState.serverEnvironmentVariables = settings.serverEnvironmentVariables.map { it.copy() }.toMutableList() pendingState.executablePath = settings.executablePath pendingState.autoStartOnStartup = settings.autoStartOnStartup @@ -504,9 +464,6 @@ class OpenCodeSettingsConfigurable(private val project: Project) : if (::serverAuthPasswordField.isInitialized) { serverAuthPasswordField.isEnabled = !serverAuthPasswordLoading } - if (::protectPluginLaunchedServerWithAuthCheckBox.isInitialized) { - protectPluginLaunchedServerWithAuthCheckBox.isEnabled = !serverAuthPasswordLoading - } } private fun saveServerAuthPassword(serverAuth: OpenCodeServerAuth, password: String): Boolean { @@ -540,7 +497,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) : ?.name ?: return throw ConfigurationException( - "Use the Server Authentication fields instead of $reservedName", + "Use the Server Authentication password instead of $reservedName", ) } diff --git a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt index db1a81b..4eb5a98 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuthTest.kt @@ -15,12 +15,9 @@ class OpenCodeServerAuthTest : BasePlatformTestCase() { } fun testConnectionAuthorizationHeaderUsesFixedOpenCodeUsername() { - val settings = OpenCodeSettings.getInstance(project) val auth = OpenCodeServerAuth.getInstance(project) - // OpenCode 2.x always authenticates as the fixed "opencode" user; - // a configured username must not leak into the header. - settings.serverAuthUsername = "alice" + // OpenCode 2.x always authenticates as the fixed "opencode" user. auth.setPassword("secret") val expectedToken = Base64.getEncoder() diff --git a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt index 0c78fdf..a3fd760 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurableTest.kt @@ -20,8 +20,6 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { override fun tearDown() { try { val settings = OpenCodeSettings.getInstance(project) - settings.serverAuthUsername = OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME - settings.protectPluginLaunchedServerWithAuth = false settings.autoStartOnStartup = false OpenCodeServerAuth.getInstance(project).setPassword("") } finally { @@ -125,8 +123,6 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { runOnEdt { configurable.serverHostnameField.text = "0.0.0.0" - configurable.serverMdnsEnabledCheckBox.isSelected = true - configurable.serverMdnsDomainField.text = "relay.local" configurable.serverCorsOriginsModel.setItems( listOf( OpenCodeSettingsConfigurable.CorsOriginRow("http://localhost:5173"), @@ -137,8 +133,6 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { } assertEquals("0.0.0.0", settings.serverHostname) - assertTrue(settings.serverMdnsEnabled) - assertEquals("relay.local", settings.serverMdnsDomain) assertEquals("http://localhost:5173\nhttps://app.example.com", settings.serverCorsOrigins) } finally { runOnEdt { configurable.disposeUIResources() } @@ -154,58 +148,16 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { getOnEdt { configurable.createComponent() } runOnEdt { - configurable.serverAuthUsernameField.text = "alice" configurable.serverAuthPasswordField.text = "secret" - configurable.protectPluginLaunchedServerWithAuthCheckBox.isSelected = true configurable.apply() } - assertEquals("alice", settings.serverAuthUsername) - assertTrue(settings.protectPluginLaunchedServerWithAuth) assertEquals("secret", serverAuth.password()) } finally { runOnEdt { configurable.disposeUIResources() } } } - fun testApplyDefaultsBlankServerAuthUsername() { - val settings = OpenCodeSettings.getInstance(project) - val configurable = OpenCodeSettingsConfigurable(project) - - try { - getOnEdt { configurable.createComponent() } - - runOnEdt { - configurable.serverAuthUsernameField.text = " " - configurable.apply() - } - - assertEquals(OpenCodeSettings.DEFAULT_SERVER_AUTH_USERNAME, settings.serverAuthUsername) - } finally { - runOnEdt { configurable.disposeUIResources() } - } - } - - fun testApplyRejectsProtectedServerWithoutPassword() { - val configurable = OpenCodeSettingsConfigurable(project) - - try { - getOnEdt { configurable.createComponent() } - - runOnEdt { - configurable.protectPluginLaunchedServerWithAuthCheckBox.isSelected = true - configurable.serverAuthPasswordField.text = "" - } - - val exception = assertFailsWith { - runOnEdt { configurable.apply() } - } - assertTrue(exception.localizedMessage.orEmpty().contains("protect the server launched by plugin")) - } finally { - runOnEdt { configurable.disposeUIResources() } - } - } - fun testApplyCommitsActiveCorsCellEdit() { val settings = OpenCodeSettings.getInstance(project) val configurable = OpenCodeSettingsConfigurable(project) @@ -271,7 +223,7 @@ class OpenCodeSettingsConfigurableTest : BasePlatformTestCase() { val exception = assertFailsWith { runOnEdt { configurable.apply() } } - assertTrue(exception.localizedMessage.orEmpty().contains("Server Authentication fields")) + assertTrue(exception.localizedMessage.orEmpty().contains("Server Authentication password")) } finally { runOnEdt { configurable.disposeUIResources() } } From 2e778655c00a68f3311d36c58acf11a67a6c2ce6 Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Mon, 19 Oct 2026 13:32:20 -0500 Subject: [PATCH 09/12] docs: refresh README, AGENTS.md, and CHANGELOG for the v2 migration - README: the session list panel is labeled "Sessions" in the UI, not "Session changes" - AGENTS.md: record the v2 TUI launch form (opencode --server=; the space-separated value is misparsed as a directory) so the fix does not regress - CHANGELOG: fill in the [Unreleased] section describing the v2 migration --- AGENTS.md | 3 +++ CHANGELOG.md | 24 ++++++++++++++++++++++++ README.md | 2 +- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5479853..0b8701f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,9 @@ The plugin speaks the OpenCode 2.x HTTP/SSE API exclusively: Do not reintroduce v1 code paths, endpoints (`/session`, `/tui/*`, `prompt_async`, ...), or dual-version logic. - All `/api/*` requests carry Basic auth (`opencode:`). Servers the plugin launches receive their password via the `OPENCODE_PASSWORD` environment variable. +- The embedded TUI is launched as `opencode --server=`. v2 removed the v1 `attach` subcommand, and the + equals form is required: a space-separated `--server ` is misparsed as the positional directory argument + (chdir ENOENT, the TUI hangs rendering nothing). - v2 API facts were verified against a live v2 server (OpenAPI spec with 119 paths) and the official v2 client types. When in doubt, trust the live server over memory. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0808e..536b191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## [Unreleased] +

Added

+
    +
  • Support for the OpenCode 2.x server: Basic authentication (OPENCODE_PASSWORD), the durable /api/event stream, session-scoped permission rules, and per-turn diff snapshots.
  • +
  • Automatic detection of OpenCode's background service via its service.json state file, with a live-process check and password read from the same file.
  • +
+ +

Breaking Changes

+
    +
  • OpenCode 1.x is no longer supported. 1.x executables are detected at startup and the tool window asks for an upgrade to OpenCode 2.x.
  • +
+ +

Changed

+
    +
  • Server authentication now always uses the fixed opencode user. The settings panel no longer exposes a username or a "Protect server launched by plugin" toggle.
  • +
  • Drop the mDNS launch options (--mdns, --mdns-domain); OpenCode 2.x no longer supports them.
  • +
+ +

Fixed

+
    +
  • Launch the embedded and external TUI with opencode --server=<url> instead of the removed v1 attach subcommand.
  • +
  • Derive session idle state from execution-finished events, which is what OpenCode 2.x emits.
  • +
  • Request cumulative session diffs with chronologically ordered turn ranges, as required by the v2 diff API.
  • +
+ ## [3.0.0] - 2026-08-30

Added

diff --git a/README.md b/README.md index 527c218..8984766 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Once the server is running the plugin connects over SSE (Server-Sent Events) to ### Session list -The **Session changes** panel lists the project's root sessions. Each row shows: +The **Sessions** panel lists the project's root sessions. Each row shows: - Session title and a short description - A spinner while the session is actively running From 9a3cdde08f4ff54521fe4b058ef933eff470abb6 Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Mon, 19 Oct 2026 14:19:34 -0500 Subject: [PATCH 10/12] build: upgrade Gradle wrapper to 9.8.1 --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2a84e18..1a31fd1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 3fc06c92effa86bbd7cd42f2d38e13e3c99be8ae Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Mon, 19 Oct 2026 15:20:23 -0500 Subject: [PATCH 11/12] fix: derive diff-range order from server arrival order; document v2 migration edge cases - fetchUserMessageIds: reverse arrival order instead of sorting by time.created (stable sort kept newest-first order on timestamp ties, inverting from/to and hitting the 400 InvalidRequestError from the diff range route) - OpenCodePlugin: correct detectBackgroundService caching KDoc (negatives deliberately re-probed so mid-session service start is picked up) - OpenCodeServerAuth: document transient-password shadowing semantics - AGENTS.md: record the /tui/* v1-route exception with the v2.0.32 OpenAPI evidence (no TUI-control routes exist in v2) --- AGENTS.md | 5 +++ .../ashotn/opencode/relay/OpenCodePlugin.kt | 8 +++- .../relay/api/session/SessionApiClient.kt | 22 +++++----- .../relay/settings/OpenCodeServerAuth.kt | 7 ++++ .../relay/api/session/SessionApiClientTest.kt | 40 +++++++++++++++++++ 5 files changed, 68 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0b8701f..a799cdc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,11 @@ The plugin speaks the OpenCode 2.x HTTP/SSE API exclusively: (chdir ENOENT, the TUI hangs rendering nothing). - v2 API facts were verified against a live v2 server (OpenAPI spec with 119 paths) and the official v2 client types. When in doubt, trust the live server over memory. +- **Known, documented exception:** the TUI "Send Selection" / "New Session" features still call the v1 + `/tui/append-prompt` and `/tui/select-session` routes. v2 has no TUI-control routes (verified in the v2.0.32 + OpenAPI spec — 119 paths, nothing tui-related; the only prompt route is `POST /api/session/{sessionID}/prompt`), + so these features fail gracefully until a v2 rework lands (marked "in progress" in the README). This is a + conscious deviation, not drift — do not extend `/tui/*` usage; replace it with the v2 rework. ## Prefer JetBrains Tools diff --git a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt index 2c6eb66..906e8f3 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodePlugin.kt @@ -122,8 +122,12 @@ class OpenCodePlugin(private val project: Project) : Disposable { * `opencode service start`). When one is found, its password is loaded into * [OpenCodeServerAuth] and the plugin connects to its port. * - * Detection runs at most once per plugin lifetime; [onDetected] is invoked - * with the detected service (or null) once the outcome is known. + * A detected service is cached for the plugin lifetime; negative outcomes + * are deliberately NOT cached, so each call re-probes (a state-file read + * plus a short authenticated `/api/info` handshake) and a background + * service started mid-session is picked up on the next call. + * [onDetected] is invoked with the detected service (or null) once the + * outcome is known. */ fun detectBackgroundService(onDetected: ((OpenCodeBackgroundService?) -> Unit)? = null) { detectedService?.let { service -> diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt index 26f052d..783ef14 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt @@ -267,8 +267,12 @@ class SessionApiClient( /** * User message IDs of the session in chronological order (oldest first). * - * The v2 message list arrives newest-first, but the diff range route requires - * `from` to precede `to`, so IDs are sorted by `time.created` ascending. + * The v2 message list arrives newest-first and its order is the authoritative + * turn order, so IDs are collected in arrival order and reversed. Sorting by + * `time.created` instead is unsafe: messages sharing a timestamp keep their + * newest-first order under a stable sort, which would invert the `from`/`to` + * range the diff route requires (`from` before `to`, otherwise 400 + * InvalidRequestError). */ private fun fetchUserMessageIds(port: Int, sessionId: String): ApiResult> { val endpoint = SessionEndpoints.messages(sessionId) @@ -278,16 +282,10 @@ class SessionApiClient( if (!element.isJsonObject) return@mapNotNull null val message = element.asJsonObject if (message.getStringOrNull("type") != "user") return@mapNotNull null - val id = message.getStringOrNull("id")?.takeIf { it.isNotBlank() } - ?: return@mapNotNull null - val created = message.getAsJsonObject("time") - ?.get("created") - ?.takeIf { it.isJsonPrimitive } - ?.asLong - ?: Long.MIN_VALUE - created to id - }.sortedBy { it.first } - ApiResult.Success(ids.map { it.second }) + message.getStringOrNull("id")?.takeIf { it.isNotBlank() } + } + // Arrival order is newest-first; the diff range wants oldest first. + ApiResult.Success(ids.reversed()) } .withParseContext(endpoint) } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt index 6960f53..90d46ff 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeServerAuth.kt @@ -94,6 +94,13 @@ class OpenCodeServerAuth(private val project: Project) : Disposable { * Updates the in-memory password cache without persisting to the IDE credential * store. Used for passwords sourced from the OpenCode background service state * file, which is re-read on every IDE startup. + * + * For the rest of the session the transient password shadows any password + * stored in the IDE password safe: all connections (API, SSE, launched + * processes via [ensureLaunchPassword]) use it, while the settings UI keeps + * showing the stored password. The shadow ends when the project service is + * disposed, at which point the stored password (or a generated one) applies + * again. */ fun setPasswordTransiently(password: String) { passwordRevision.incrementAndGet() diff --git a/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt index f403960..75dbfa1 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt @@ -196,6 +196,46 @@ class SessionApiClientTest { } } + @Test + fun `fetchSessionDiffSnapshot orders tied user messages by arrival order, not timestamp`() { + withTestServer { server, port -> + val diffQueries = mutableListOf() + // Two user messages share a `time.created` timestamp. The list still + // arrives newest-first, so the older message is the last one listed; + // the range must follow that arrival order — a stable timestamp sort + // would keep the newer message first and invert `from`/`to` (400 + // InvalidRequestError from the server). + server.createContext("/api/session/ses_1/message") { exchange -> + val body = """ + { + "data": [ + {"id": "msg_u2", "type": "user", "text": "second", "time": {"created": 100}}, + {"id": "msg_a1", "type": "assistant", "content": [], "time": {"created": 100}}, + {"id": "msg_u1", "type": "user", "text": "first", "time": {"created": 100}} + ], + "cursor": {"next": null, "previous": null} + } + """.trimIndent() + exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + server.createContext("/api/session/ses_1/diff") { exchange -> + diffQueries.add(exchange.requestURI.query.orEmpty()) + val body = """ + {"data": []} + """.trimIndent() + exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) + exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + + val client = SessionApiClient() + val result = client.fetchSessionDiffSnapshot(port, "ses_1") + + assertIs>(result) + assertEquals(listOf("from=msg_u1&to=msg_u2"), diffQueries) + } + } + @Test fun `fetchSessionDiffSnapshot falls back to latest turn when session has no user messages`() { withTestServer { server, port -> From dc9110a8014e09e7e0aae24465d8dea2a52a2dbc Mon Sep 17 00:00:00 2001 From: caleb Shingledecker Date: Mon, 19 Oct 2026 19:30:49 -0500 Subject: [PATCH 12/12] fix: restore New Session, session switching, and Send Selection/File for v2 OpenCode 2.x removed the v1 /tui/ control routes, so the TUI is now steered through its embedded terminal instead: - "Send Selection" / "Send File" paste an @path#L... reference into the prompt input via bracketed paste (TuiPanel.appendToPrompt) - New Session / session-list clicks create or reuse a session via POST /api/session (reusing the newest empty root session) and relaunch the embedded TUI against --session= (TuiPanel.relaunchForSession) - TuiPanel: startIfNeeded takes an initialSessionId; add appendToPrompt and relaunchForSession to both panel implementations - OpenCodeTuiClient: drop TuiApiClient and drive the TuiPanel registered by the tool window (setTuiPanel); guard actions on the inline terminal setting; reuse-check now requires a root session - Remove TuiApiClient, TuiEndpoints, and TuiApiClientTest - README/CHANGELOG/AGENTS.md/API_ARCHITECTURE: document the terminal-based TUI control --- AGENTS.md | 14 +- CHANGELOG.md | 1 + README.md | 23 ++- docs/API_ARCHITECTURE.md | 2 +- .../opencode/relay/actions/SendFileAction.kt | 4 +- .../relay/actions/SendSelectionAction.kt | 6 +- .../opencode/relay/api/tui/TuiApiClient.kt | 25 --- .../opencode/relay/api/tui/TuiEndpoints.kt | 10 - .../relay/terminal/ClassicTuiPanel.kt | 26 ++- .../relay/terminal/ReworkedTuiPanel.kt | 28 ++- .../opencode/relay/terminal/TuiPanel.kt | 31 +++- .../toolwindow/OpenCodeToolWindowPanel.kt | 6 + .../opencode/relay/tui/OpenCodeTuiClient.kt | 173 +++++++++--------- .../relay/api/tui/TuiApiClientTest.kt | 110 ----------- 14 files changed, 184 insertions(+), 275 deletions(-) delete mode 100644 src/main/kotlin/com/ashotn/opencode/relay/api/tui/TuiApiClient.kt delete mode 100644 src/main/kotlin/com/ashotn/opencode/relay/api/tui/TuiEndpoints.kt delete mode 100644 src/test/kotlin/com/ashotn/opencode/relay/api/tui/TuiApiClientTest.kt diff --git a/AGENTS.md b/AGENTS.md index a799cdc..384dfb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,14 +16,16 @@ The plugin speaks the OpenCode 2.x HTTP/SSE API exclusively: password via the `OPENCODE_PASSWORD` environment variable. - The embedded TUI is launched as `opencode --server=`. v2 removed the v1 `attach` subcommand, and the equals form is required: a space-separated `--server ` is misparsed as the positional directory argument - (chdir ENOENT, the TUI hangs rendering nothing). + (chdir ENOENT, the TUI hangs rendering nothing). `--session=` (also equals form) opens/continues a + specific session. - v2 API facts were verified against a live v2 server (OpenAPI spec with 119 paths) and the official v2 client types. When in doubt, trust the live server over memory. -- **Known, documented exception:** the TUI "Send Selection" / "New Session" features still call the v1 - `/tui/append-prompt` and `/tui/select-session` routes. v2 has no TUI-control routes (verified in the v2.0.32 - OpenAPI spec — 119 paths, nothing tui-related; the only prompt route is `POST /api/session/{sessionID}/prompt`), - so these features fail gracefully until a v2 rework lands (marked "in progress" in the README). This is a - conscious deviation, not drift — do not extend `/tui/*` usage; replace it with the v2 rework. +- v2 has **no TUI-control routes** (verified in the v2.0.32 OpenAPI spec — 119 paths, nothing tui-related; the + only prompt route is `POST /api/session/{sessionID}/prompt`). TUI control goes through the embedded terminal + instead: session switching ("New Session", session-list clicks) relaunches the TUI with `--session=` + (the session itself is created via `POST /api/session`, reusing the newest empty one when present), and + "Send Selection" / "Send File" paste a file reference into the prompt input via terminal bracketed paste. + Do not reintroduce the v1 `/tui/*` HTTP routes. ## Prefer JetBrains Tools diff --git a/CHANGELOG.md b/CHANGELOG.md index 536b191..5c00dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@
  • Launch the embedded and external TUI with opencode --server=<url> instead of the removed v1 attach subcommand.
  • Derive session idle state from execution-finished events, which is what OpenCode 2.x emits.
  • Request cumulative session diffs with chronologically ordered turn ranges, as required by the v2 diff API.
  • +
  • Restore New Session, session switching (session-list clicks), and Send Selection / Send File to TUI, which relied on the removed v1 /tui/ control routes. Sessions are now created via POST /api/session (reusing the newest empty session when present) and the embedded TUI is relaunched against them with --session; references are pasted into the embedded terminal's prompt input.
  • ## [3.0.0] - 2026-08-30 diff --git a/README.md b/README.md index 8984766..de41e6f 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ The **Sessions** panel lists the project's root sessions. Each row shows: - A spinner while the session is actively running - The number of files being tracked in that session -Clicking a session row makes it the active session; the file list updates to show only that session's changes. +Clicking a session row makes it the active session — the embedded TUI switches to it (by restarting with +`--session`) and the file list updates to show only that session's changes. ### Modified-files list @@ -86,31 +87,33 @@ Double-clicking a file in the modified-files list opens a standard JetBrains **d ### Send selection to TUI -> **In progress** — the TUI integration is being reworked for the OpenCode 2.x server; this feature is -> temporarily unavailable. - Share code from the editor directly with OpenCode's prompt input: - Select any code in the editor - Use **Send Selection to OpenCode** from the right-click context menu -- The selection is sent as a fenced code block (with filename and line range) to the active TUI session's prompt input - buffer +- The selection is appended to the embedded TUI's prompt input as a file reference + (`@path#Lstart[-end]`) — no file content is embedded, OpenCode resolves the file and line range itself when + you submit The text is appended — not submitted — so you retain full control before pressing Enter in the TUI. You can also send file and folder paths to the TUI by right-clicking them in the project view. +These actions paste into the embedded OpenCode terminal, so the OpenCode Relay tool window with the inline +terminal must be open. + ### Open in browser The **Open in Browser** toolbar button opens the OpenCode web UI in your default browser. ### New session in TUI -> **In progress** — the TUI integration is being reworked for the OpenCode 2.x server; this feature is -> temporarily unavailable. - The **New Session** toolbar button creates a new OpenCode session and immediately switches the active TUI instance to -it. +it. If the most recent session is already empty (no messages, no tracked files), it is reused instead of +accumulating blank sessions. + +OpenCode 2.x exposes no non-interactive route for pointing the TUI at a session, so the embedded TUI is restarted +with the new session (`opencode --session=`); the session itself is created via the `POST /api/session` API. ### Permission prompts diff --git a/docs/API_ARCHITECTURE.md b/docs/API_ARCHITECTURE.md index 245ee71..00c81e1 100644 --- a/docs/API_ARCHITECTURE.md +++ b/docs/API_ARCHITECTURE.md @@ -28,7 +28,7 @@ Rules: - Use shared transport parsers (`parseBooleanResponse`, `parseJsonObjectResponse`, `parseJsonArrayResponse`) for mechanical parsing. - Apply `withParseContext(endpoint)` at API client boundaries when returning `ApiResult<*>` to callers. -- Define endpoints in domain-grouped endpoint factory files next to each API client (for example `api/session/SessionEndpoints.kt`, `api/tui/TuiEndpoints.kt`) and pass the resulting endpoint object to `withParseContext(endpoint)`. +- Define endpoints in domain-grouped endpoint factory files next to each API client (for example `api/session/SessionEndpoints.kt`, `api/health/HealthEndpoints.kt`) and pass the resulting endpoint object to `withParseContext(endpoint)`. - `ApiEndpoint` context must remain `METHOD /path` format (for example: `GET /session`, `POST /session/{sessionId}/permissions/{permissionId}`). - Keep parser error messages generic; endpoint attribution is added by `withParseContext(...)`. - Do not use `withParseContext(...)` to wrap non-parse concerns (`HttpError`, `NetworkError`). diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/SendFileAction.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/SendFileAction.kt index a877031..abd1e53 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/SendFileAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/SendFileAction.kt @@ -17,8 +17,8 @@ import com.intellij.openapi.project.DumbAware import com.intellij.openapi.vfs.VirtualFile /** - * Action that appends an @path file reference to the TUI's prompt input buffer via - * POST /tui/append-prompt. + * Action that appends an @path file reference to the TUI's prompt input buffer by pasting it + * into the embedded terminal. * * Editor action that sends a reference to the file currently open in the editor. * diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/SendSelectionAction.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/SendSelectionAction.kt index a5b40b1..6d1e084 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/actions/SendSelectionAction.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/SendSelectionAction.kt @@ -17,13 +17,13 @@ import com.intellij.openapi.project.DumbAware /** * Editor action that appends an @path#Lstart or @path#Lstart-end file reference to the TUI's prompt - * input buffer via POST /tui/append-prompt. + * input buffer by pasting it into the embedded terminal. * * Sends a reference only — no file content is embedded in the prompt. OpenCode resolves * the file and line range itself when the user submits. * - * The OpenCode server provides no session-scoping for this endpoint — the reference lands - * in whichever session the TUI currently has open. The plugin has no control over that. + * The reference lands in whichever session the TUI currently has open — the plugin has no + * control over that (OpenCode 2.x exposes no per-session prompt-append route). * * Does NOT create a message or trigger a model response; the user still submits manually. */ diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/tui/TuiApiClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/tui/TuiApiClient.kt deleted file mode 100644 index 76b2be0..0000000 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/tui/TuiApiClient.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.ashotn.opencode.relay.api.tui - -import com.ashotn.opencode.relay.api.transport.ApiResult -import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport -import com.ashotn.opencode.relay.api.transport.parseBooleanResponse -import com.ashotn.opencode.relay.api.transport.withParseContext -import com.google.gson.JsonObject - -class TuiApiClient( - private val transport: OpenCodeHttpTransport = OpenCodeHttpTransport(), -) { - fun appendPrompt(port: Int, text: String): ApiResult { - val endpoint = TuiEndpoints.appendPrompt() - val payload = JsonObject().also { it.addProperty("text", text) } - val response = transport.post(port = port, path = endpoint.path, payload = payload.toString()) - return transport.parseBooleanResponse(response).withParseContext(endpoint) - } - - fun selectSession(port: Int, sessionId: String): ApiResult { - val endpoint = TuiEndpoints.selectSession() - val payload = JsonObject().also { it.addProperty("sessionID", sessionId) } - val response = transport.post(port = port, path = endpoint.path, payload = payload.toString()) - return transport.parseBooleanResponse(response).withParseContext(endpoint) - } -} diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/tui/TuiEndpoints.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/tui/TuiEndpoints.kt deleted file mode 100644 index a693ee9..0000000 --- a/src/main/kotlin/com/ashotn/opencode/relay/api/tui/TuiEndpoints.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.ashotn.opencode.relay.api.tui - -import com.ashotn.opencode.relay.api.transport.ApiEndpoint -import com.ashotn.opencode.relay.api.transport.HttpMethod - -internal object TuiEndpoints { - fun appendPrompt(): ApiEndpoint = ApiEndpoint(method = HttpMethod.POST, path = "/tui/append-prompt") - - fun selectSession(): ApiEndpoint = ApiEndpoint(method = HttpMethod.POST, path = "/tui/select-session") -} diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt index 5648800..ee3b6e0 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt @@ -69,9 +69,11 @@ class ClassicTuiPanel( * Creates and embeds the terminal (once). Safe to call multiple times — * subsequent calls are no-ops while a session is alive. * + * [initialSessionId], when provided, opens that session via `--session=`. + * * Must be called on the EDT. */ - override fun startIfNeeded() { + override fun startIfNeeded(initialSessionId: String?) { if (terminalWidget != null) return //For test @@ -92,16 +94,15 @@ class ClassicTuiPanel( .processEnvironmentVariables(OpenCodeServerAuth.getInstance(project).connectionEnvironmentVariables()) // v2 removed the v1 `attach` subcommand; the TUI is the default command and // connects to an explicit server via `--server=`. The equals form is - // required: with a space-separated value the URL is misparsed as the + // required: with a space-separated value the value is misparsed as the // positional directory argument (chdir ENOENT, TUI renders nothing) on - // v2.0.32. - val command = OpenCodeProcessEnvironment.terminalCommand( - listOf( - executablePath, - "--server=${serverUrl(OpenCodePlugin.getInstance(project).connectionPort)}", - ), - environmentVariables, + // v2.0.32. --session (equals form too) opens/continues a specific session. + val args = mutableListOf( + executablePath, + "--server=${serverUrl(OpenCodePlugin.getInstance(project).connectionPort)}", ) + initialSessionId?.takeIf { it.isNotBlank() }?.let { args.add("--session=$it") } + val command = OpenCodeProcessEnvironment.terminalCommand(args, environmentVariables) val runner = ClipboardAwareTerminalRunner(project) val startupOptions = ShellStartupOptions.Builder() @@ -155,6 +156,13 @@ class ClassicTuiPanel( terminalWidget?.preferredFocusableComponent?.requestFocusInWindow() } + override fun appendToPrompt(text: String): Boolean { + val panel = terminalPanel ?: return false + panel.requestFocusInWindow() + sendBracketedPaste(panel, text) + return true + } + override val isStarted: Boolean get() = terminalWidget != null override fun stop() = tearDown() diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt index a76dcfe..299fe3e 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt @@ -67,9 +67,11 @@ class ReworkedTuiPanel( * Creates and embeds the terminal (once). Safe to call multiple times — * subsequent calls are no-ops while a session is alive. * + * [initialSessionId], when provided, opens that session via `--session=`. + * * Must be called on the EDT. */ - override fun startIfNeeded() { + override fun startIfNeeded(initialSessionId: String?) { if (terminalView != null) return try { @@ -84,16 +86,15 @@ class ReworkedTuiPanel( .processEnvironmentVariables(OpenCodeServerAuth.getInstance(project).connectionEnvironmentVariables()) // v2 removed the v1 `attach` subcommand; the TUI is the default command and // connects to an explicit server via `--server=`. The equals form is - // required: with a space-separated value the URL is misparsed as the + // required: with a space-separated value the value is misparsed as the // positional directory argument (chdir ENOENT, TUI renders nothing) on - // v2.0.32. - val command = OpenCodeProcessEnvironment.terminalCommand( - listOf( - executablePath, - "--server=${serverUrl(OpenCodePlugin.getInstance(project).connectionPort)}", - ), - environmentVariables, + // v2.0.32. --session (equals form too) opens/continues a specific session. + val args = mutableListOf( + executablePath, + "--server=${serverUrl(OpenCodePlugin.getInstance(project).connectionPort)}", ) + initialSessionId?.takeIf { it.isNotBlank() }?.let { args.add("--session=$it") } + val command = OpenCodeProcessEnvironment.terminalCommand(args, environmentVariables) val manager = TerminalToolWindowTabsManager.getInstance(project) val tabBuilder = manager.createTabBuilder() @@ -199,6 +200,15 @@ class ReworkedTuiPanel( view.preferredFocusableComponent.requestFocusInWindow() } + override fun appendToPrompt(text: String): Boolean { + val view = terminalView ?: return false + view.preferredFocusableComponent.requestFocusInWindow() + view.coroutineScope.launch { + view.createSendTextBuilder().useBracketedPasteMode().send(text) + } + return true + } + /** True while a terminal session is live. */ override val isStarted: Boolean get() = terminalView != null diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt index fc27071..58214f2 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt @@ -13,6 +13,10 @@ import javax.swing.JPanel * * The active implementation is chosen by * [com.ashotn.opencode.relay.settings.OpenCodeSettings.terminalEngine]. + * + * OpenCode 2.x removed the v1 `/tui/` control routes, so the TUI is steered through its own + * terminal: text is pasted into the prompt input ([appendToPrompt]) and a session is switched + * by relaunching the TUI against `--session=` ([relaunchForSession]). */ interface TuiPanel : Disposable { /** The Swing component to embed in the tool window. */ @@ -22,14 +26,33 @@ interface TuiPanel : Disposable { val isStarted: Boolean /** - * Creates and starts the terminal session if one is not already running. - * Must be called on the EDT. Safe to call multiple times. + * Creates and starts the terminal session if one is not already running, optionally + * opening [initialSessionId] (via `--session`). Must be called on the EDT. Safe to call + * multiple times. */ - fun startIfNeeded() + fun startIfNeeded(initialSessionId: String? = null) - /** Stops the running session (if any). */ + /** Stops the running session (if any). Must be called on the EDT. */ fun stop() /** Requests focus for the terminal input widget. */ fun focusTerminal() + + /** + * Inserts [text] into the TUI prompt input via bracketed paste, without submitting it + * (the user still presses Enter to run it). Must be called on the EDT. + * + * Returns true if the terminal is live and the text was sent. + */ + fun appendToPrompt(text: String): Boolean + + /** + * Restarts the TUI pointed at [sessionId] by stopping the current session and relaunching + * with `--session=`. This is the v2 way to switch the active TUI session — the TUI + * has no non-interactive "go to session" command. Must be called on the EDT. + */ + fun relaunchForSession(sessionId: String) { + stop() + startIfNeeded(sessionId) + } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt index b1c4728..cf74de5 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt @@ -17,6 +17,7 @@ import com.ashotn.opencode.relay.settings.effectiveForIde import com.ashotn.opencode.relay.terminal.ClassicTuiPanel import com.ashotn.opencode.relay.terminal.ReworkedTuiPanel import com.ashotn.opencode.relay.terminal.TuiPanel +import com.ashotn.opencode.relay.tui.OpenCodeTuiClient import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project @@ -145,6 +146,9 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou ) buildContent() + // Expose the embedded terminal to the TUI service so session switching and prompt + // injection can drive it (OpenCode 2.x has no /tui/ control routes). + OpenCodeTuiClient.getInstance(project).setTuiPanel(tuiPanel) } private fun requestSyncCard() { @@ -248,6 +252,7 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou splitPane.bottomComponent = newPanel.component tuiPanel = newPanel activeTuiEngine = configuredEngine + OpenCodeTuiClient.getInstance(project).setTuiPanel(newPanel) } private fun buildContent() { @@ -280,6 +285,7 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou override fun dispose() { if (disposed) return disposed = true + OpenCodeTuiClient.getInstance(project).setTuiPanel(null) plugin.removeListener(serverStateListener) } } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt b/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt index 6245219..6c069ca 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/tui/OpenCodeTuiClient.kt @@ -5,22 +5,24 @@ import com.ashotn.opencode.relay.api.transport.ApiError import com.ashotn.opencode.relay.api.transport.ApiResult import com.ashotn.opencode.relay.api.transport.OpenCodeHttpTransport import com.ashotn.opencode.relay.api.transport.isAuthenticationFailure -import com.ashotn.opencode.relay.api.tui.TuiApiClient import com.ashotn.opencode.relay.core.OpenCodeCoreService import com.ashotn.opencode.relay.settings.OpenCodeServerAuth +import com.ashotn.opencode.relay.settings.OpenCodeSettings +import com.ashotn.opencode.relay.terminal.TuiPanel import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.project.Project /** - * Project-level service for sending commands to the running OpenCode TUI via HTTP. + * Project-level service for driving the running OpenCode TUI. * - * This is intentionally separate from [com.ashotn.opencode.relay.core.OpenCodeCoreService], - * which owns the SSE connection and diff state. TUI interactions (appending prompt text, - * switching sessions) are UI/action concerns and belong here. + * OpenCode 2.x removed the v1 `/tui/` control routes, so the TUI is steered through its own + * terminal instead: references are pasted into the prompt input ([appendToTuiPrompt]) and a + * session is switched by relaunching the TUI against `--session=` ([selectTuiSession]). * - * The active server port is supplied by callers via [setPort]; it is updated by - * [com.ashotn.opencode.relay.OpenCodePlugin] when the server starts and clears when it stops. + * The [TuiPanel] that hosts the terminal is registered by + * [com.ashotn.opencode.relay.toolwindow.OpenCodeToolWindowPanel] via [setTuiPanel]. The active + * server port is supplied via [setPort] by [com.ashotn.opencode.relay.OpenCodePlugin]. */ @Service(Service.Level.PROJECT) class OpenCodeTuiClient(private val project: Project) { @@ -28,70 +30,74 @@ class OpenCodeTuiClient(private val project: Project) { private val serverAuth = OpenCodeServerAuth.getInstance(project) private val transport = OpenCodeHttpTransport(authorizationHeaderProvider = serverAuth::connectionAuthorizationHeader) private val sessionApiClient = SessionApiClient(transport) - private val tuiApiClient = TuiApiClient(transport) @Volatile private var port: Int = 0 + @Volatile + private var tuiPanel: TuiPanel? = null + fun setPort(port: Int) { this.port = port } + /** Registers the embedded terminal panel. Must be called on the EDT. */ + fun setTuiPanel(panel: TuiPanel?) { + this.tuiPanel = panel + } + /** - * Appends [text] to the TUI's prompt input buffer via POST /tui/append-prompt. - * Runs the HTTP call on a pooled thread and invokes [onResult] with (success, errorMessage). + * Appends [text] to the TUI's prompt input by pasting it into the embedded terminal + * (bracketed paste), without submitting it. Runs on the EDT. */ fun appendToTuiPrompt(text: String, onResult: (success: Boolean, error: String?) -> Unit) { - val currentPort = port - if (currentPort <= 0) { - onResult(false, "OpenCode server is not running") + val panel = tuiPanel + if (panel == null) { + onResult(false, "OpenCode TUI is not available") return } - - ApplicationManager.getApplication().executeOnPooledThread { - when (val result = tuiApiClient.appendPrompt(currentPort, text)) { - is ApiResult.Success -> { - if (result.value) onResult(true, null) - else onResult(false, "Request was rejected by server") - } - - is ApiResult.Failure -> onResult(false, apiErrorMessage(result.error)) - } + tuiUnavailableReason()?.let { + onResult(false, it) + return + } + if (!panel.isStarted) { + onResult(false, "The embedded OpenCode TUI isn't running — open the OpenCode Relay tool window.") + return + } + if (panel.appendToPrompt(text)) { + onResult(true, null) + } else { + onResult(false, "Could not send the reference to the OpenCode TUI.") } } /** - * Tells the TUI to navigate to [sessionId] via POST /tui/select-session. - * Runs the HTTP call on a pooled thread and invokes [onResult] with (success, errorMessage). + * Switches the TUI to [sessionId] by relaunching it against `--session=`. Runs on the + * EDT. The caller is responsible for updating the plugin's local session selection. */ fun selectTuiSession(sessionId: String, onResult: ((success: Boolean, error: String?) -> Unit)? = null) { - val currentPort = port - if (currentPort <= 0) { - onResult?.invoke(false, "OpenCode server is not running") + val panel = tuiPanel + if (panel == null) { + onResult?.invoke(false, "OpenCode TUI is not available") return } - - ApplicationManager.getApplication().executeOnPooledThread { - when (val result = tuiApiClient.selectSession(currentPort, sessionId)) { - is ApiResult.Success -> { - if (result.value) onResult?.invoke(true, null) - else onResult?.invoke(false, "Request was rejected by server") - } - - is ApiResult.Failure -> onResult?.invoke(false, apiErrorMessage(result.error)) - } + tuiUnavailableReason()?.let { + onResult?.invoke(false, it) + return } + panel.relaunchForSession(sessionId) + onResult?.invoke(true, null) } /** - * Creates a new root session via POST /session and switches the TUI to it via - * POST /tui/select-session. + * Creates a new root session via POST /api/session and switches the TUI to it. * - * If the newest known session has no messages, no tracked files, and is not busy, - * we reuse it instead of creating a new one — there is no point in accumulating - * blank sessions. The [onResult] callback receives reused=true in that case. + * If the newest known session has no messages, no tracked files, and is not busy, we reuse + * it instead of creating a new one — there is no point in accumulating blank sessions. The + * [onResult] callback receives reused=true in that case. * - * Runs all work on a pooled thread and invokes [onResult] from that thread. + * The session is created on a pooled thread; the TUI relaunch, the local selection, and + * [onResult] all run on the EDT. */ fun createSessionAndSelectInTui( onResult: (success: Boolean, sessionId: String?, reused: Boolean, error: String?) -> Unit, @@ -101,64 +107,51 @@ class OpenCodeTuiClient(private val project: Project) { onResult(false, null, false, "OpenCode server is not running") return } + val panel = tuiPanel + if (panel == null) { + onResult(false, null, false, "OpenCode TUI is not available") + return + } + tuiUnavailableReason()?.let { + onResult(false, null, false, it) + return + } val diffService = OpenCodeCoreService.getInstance(project) ApplicationManager.getApplication().executeOnPooledThread { val existingEmpty = diffService.listSessions() .maxByOrNull { it.updatedAtMillis } - ?.takeIf { !it.hasMessages && it.trackedFileCount == 0 && !it.isBusy } + ?.takeIf { it.parentSessionId == null && !it.hasMessages && it.trackedFileCount == 0 && !it.isBusy } + val targetSessionId: String + val reused: Boolean if (existingEmpty != null) { - selectTuiSession(existingEmpty.sessionId) { selectSuccess, selectError -> - if (!selectSuccess) { - onResult(false, existingEmpty.sessionId, true, selectError) - return@selectTuiSession + targetSessionId = existingEmpty.sessionId + reused = true + } else { + targetSessionId = when (val created = sessionApiClient.createSession(currentPort)) { + is ApiResult.Success -> created.value.sessionId + is ApiResult.Failure -> { + ApplicationManager.getApplication().invokeLater { + if (!project.isDisposed) onResult(false, null, false, apiErrorMessage(created.error)) + } + return@executeOnPooledThread } - diffService.selectSession(existingEmpty.sessionId) - onResult(true, existingEmpty.sessionId, true, null) - } - return@executeOnPooledThread - } - - createSession { success, sessionId, error -> - if (!success || sessionId.isNullOrBlank()) { - onResult(false, sessionId, false, error) - return@createSession - } - - selectTuiSession(sessionId) { selectSuccess, selectError -> - if (!selectSuccess) { - onResult(false, sessionId, false, selectError) - return@selectTuiSession - } - - diffService.selectSession(sessionId) - onResult(true, sessionId, false, null) } + reused = false } - } - } - /** - * Creates a new session via POST /session and returns the created session ID. - */ - private fun createSession(onResult: (success: Boolean, sessionId: String?, error: String?) -> Unit) { - val currentPort = port - if (currentPort <= 0) { - onResult(false, null, "OpenCode server is not running") - return - } - - ApplicationManager.getApplication().executeOnPooledThread { - when (val createResult = sessionApiClient.createSession(currentPort)) { - is ApiResult.Success -> onResult(true, createResult.value.sessionId, null) - is ApiResult.Failure -> onResult(false, null, apiErrorMessage(createResult.error)) + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + panel.relaunchForSession(targetSessionId) + diffService.selectSession(targetSessionId) + onResult(true, targetSessionId, reused, null) } } } /** - * Deletes a session via DELETE /session/{sessionId}. + * Deletes a session via DELETE /api/session/{sessionId}. * On success, removes the session from local state so the UI updates immediately. */ fun deleteSession(sessionId: String, onResult: (success: Boolean, error: String?) -> Unit) { @@ -215,6 +208,14 @@ class OpenCodeTuiClient(private val project: Project) { is ApiError.ParseError -> error.message } + /** A reason the TUI can't be driven, or null when it is. */ + private fun tuiUnavailableReason(): String? = + if (OpenCodeSettings.getInstance(project).inlineTerminalEnabled) { + null + } else { + "Enable the embedded OpenCode terminal in Settings to use this feature." + } + companion object { fun getInstance(project: Project): OpenCodeTuiClient = project.getService(OpenCodeTuiClient::class.java) diff --git a/src/test/kotlin/com/ashotn/opencode/relay/api/tui/TuiApiClientTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/api/tui/TuiApiClientTest.kt deleted file mode 100644 index aeec13c..0000000 --- a/src/test/kotlin/com/ashotn/opencode/relay/api/tui/TuiApiClientTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.ashotn.opencode.relay.api.tui - -import com.ashotn.opencode.relay.api.transport.ApiError -import com.ashotn.opencode.relay.api.transport.ApiResult -import com.ashotn.opencode.relay.api.withTestServer -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertTrue - -class TuiApiClientTest { - - @Test - fun `appendPrompt parses boolean response and sends text payload`() { - withTestServer { server, port -> - var method = "" - var bodyReceived = "" - server.createContext("/tui/append-prompt") { exchange -> - method = exchange.requestMethod - bodyReceived = exchange.requestBody.bufferedReader(Charsets.UTF_8).use { it.readText() } - val body = "true" - exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) - exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } - } - - val client = TuiApiClient() - val result = client.appendPrompt(port, "hello") - - val success = assertIs>(result) - assertEquals(true, success.value) - assertEquals("POST", method) - assertTrue(bodyReceived.contains("\"text\":\"hello\"")) - } - } - - @Test - fun `selectSession parses boolean response and sends sessionID`() { - withTestServer { server, port -> - var method = "" - var bodyReceived = "" - server.createContext("/tui/select-session") { exchange -> - method = exchange.requestMethod - bodyReceived = exchange.requestBody.bufferedReader(Charsets.UTF_8).use { it.readText() } - val body = "true" - exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) - exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } - } - - val client = TuiApiClient() - val result = client.selectSession(port, "ses_1") - - val success = assertIs>(result) - assertEquals(true, success.value) - assertEquals("POST", method) - assertTrue(bodyReceived.contains("\"sessionID\":\"ses_1\"")) - } - } - - @Test - fun `returns failure for non-boolean payload on append`() { - withTestServer { server, port -> - server.createContext("/tui/append-prompt") { exchange -> - val body = "{\"ok\":\"yes\"}" - exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) - exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } - } - - val client = TuiApiClient() - val result = client.appendPrompt(port, "hello") - - assertIs(result) - } - } - - @Test - fun `returns failure for unknown success payload on selectSession`() { - withTestServer { server, port -> - server.createContext("/tui/select-session") { exchange -> - val body = "{\"status\":\"ok\"}" - exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong()) - exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) } - } - - val client = TuiApiClient() - val result = client.selectSession(port, "ses_1") - - val failure = assertIs(result) - val error = assertIs(failure.error) - assertEquals("POST /tui/select-session: Expected boolean response payload", error.message) - } - } - - @Test - fun `returns failure for empty response body on selectSession`() { - withTestServer { server, port -> - server.createContext("/tui/select-session") { exchange -> - exchange.sendResponseHeaders(204, -1) - exchange.close() - } - - val client = TuiApiClient() - val result = client.selectSession(port, "ses_1") - - val failure = assertIs(result) - val error = assertIs(failure.error) - assertEquals("POST /tui/select-session: Expected boolean response payload", error.message) - } - } - -}