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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## [Unreleased]

<p><strong>Breaking Changes</strong></p>
<ul>
<li>Drop support for OpenCode versions earlier than 1.16.0.</li>
</ul>

## [1.4.0] - 2026-06-09

<p><strong>Changed</strong></p>
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,4 @@ Responses are sent back to the server immediately.
## Requirements

- A JetBrains IDE based on IntelliJ Platform 2024.3.7 or later
- [OpenCode CLI](https://opencode.ai/docs) version `1.4.0+` installed and on `PATH` (or the path configured in settings)
- [OpenCode CLI](https://opencode.ai/docs) version `1.16.0+` installed and on `PATH` (or the path configured in settings)
5 changes: 2 additions & 3 deletions docs/INLINE_DIFF_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ Concurrent sessions show their activity in the session list (busy badge, file co
Within the selected session, only files touched in the **latest committed turn** are highlighted.
Files that were modified in earlier turns of the same session must not remain highlighted.

The OpenCode server always returns a cumulative `session.diff` across the entire session. The
plugin must scope inline rendering to the current turn's file set (`turn.patch` scope), ignoring
all prior turns.
OpenCode message diff updates are scoped to the user message that produced them. The plugin must
use those message-scoped diffs for inline rendering so prior turns do not remain highlighted.

### Rule 3 — Selecting a session swaps inline context immediately

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,8 @@ class OpenCodeTestEventCollector(
sessionStatusCountLocked(sessionId, status)
}

fun sessionDiffCount(sessionId: String): Int = synchronized(lock) {
events.count { it is OpenCodeEvent.SessionDiff && it.sessionId == sessionId }
}

fun diffSignalCount(sessionId: String): Int = synchronized(lock) {
events.count {
(it is OpenCodeEvent.SessionDiff && it.sessionId == sessionId) ||
(it is OpenCodeEvent.MessageDiffAvailable && it.sessionId == sessionId)
}
events.count { it is OpenCodeEvent.MessageDiffAvailable && it.sessionId == sessionId }
}

fun messageDiffEvents(sessionId: String): List<OpenCodeEvent.MessageDiffAvailable> = synchronized(lock) {
Expand Down Expand Up @@ -87,19 +80,10 @@ class OpenCodeTestEventCollector(
if (matching.size >= atLeastCount) matching.last() else null
}

fun awaitSessionDiff(sessionId: String, atLeastCount: Int, timeoutMs: Long = 15_000): OpenCodeEvent.SessionDiff =
awaitEvent(timeoutMs, "session.diff for $sessionId count >= $atLeastCount") { recordedEvents ->
val matching = recordedEvents.filterIsInstance<OpenCodeEvent.SessionDiff>()
.filter { it.sessionId == sessionId }
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.filter {
(it is OpenCodeEvent.SessionDiff && it.sessionId == sessionId) ||
(it is OpenCodeEvent.MessageDiffAvailable && it.sessionId == sessionId)
}
val matching = recordedEvents.filterIsInstance<OpenCodeEvent.MessageDiffAvailable>()
.filter { it.sessionId == sessionId }
if (matching.size >= atLeastCount) matching.last() else null
}

Expand All @@ -112,11 +96,6 @@ class OpenCodeTestEventCollector(
"session.status(sessionId=${event.sessionId}, type=${event.status.name.lowercase()})"
}

is OpenCodeEvent.SessionDiff -> {
val files = event.files.joinToString(",") { it.file.substringAfterLast('/') }
"session.diff(sessionId=${event.sessionId}, files=$files)"
}

is OpenCodeEvent.MessageDiffAvailable -> {
val files = event.files.joinToString(",") { it.substringAfterLast('/') }
"message.diff(sessionId=${event.sessionId}, messageId=${event.messageId}, files=$files)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import com.ashotn.opencode.relay.integration.OpenCodeTestServer
import com.ashotn.opencode.relay.integration.OpenCodeTestVersions
import com.ashotn.opencode.relay.api.session.Session
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.core.CoreDiffStateHarness
import com.ashotn.opencode.relay.core.DiffPipelineHarness
Expand Down Expand Up @@ -34,7 +36,7 @@ class OpenCodeDiffLiveTest(

private data class ChildDiffs(
val sessions: List<Session>,
val diffsBySessionId: Map<String, OpenCodeEvent.SessionDiff>,
val diffsBySessionId: Map<String, SessionDiffSnapshot>,
val fileToChildSessionId: Map<String, String>,
val diffSummaryRoleByFile: Map<String, String>,
)
Expand Down Expand Up @@ -386,11 +388,15 @@ class OpenCodeDiffLiveTest(
assertFileText(environment.repoRoot.resolve("pkg/bravo.py"), "def value():\n return \"bravo-new\"\n")
assertFileText(environment.repoRoot.resolve("pkg/charlie.py"), "def value():\n return \"charlie-new\"\n")

val finalDiff = assertIs<ApiResult.Success<OpenCodeEvent.SessionDiff>>(
val finalDiff = assertIs<ApiResult.Success<SessionDiffSnapshot>>(
sessionClient.fetchSessionDiffSnapshot(server.port, sessionId),
).value
val finalFiles = finalDiff.files.map { normalizeDiffPath(environment.repoRoot, it.file) }.toSet()
assertEquals(expectedFiles, finalFiles.intersect(expectedFiles), "final server diff should include all Python files")
assertEquals(
expectedFiles,
finalFiles.intersect(expectedFiles),
"final server diff should include all Python files"
)

val latestLoadedFiles = snapshots
.groupBy { it.messageId }
Expand Down Expand Up @@ -441,7 +447,7 @@ class OpenCodeDiffLiveTest(
)
assertFileText(file, aiContent)

val serverDiff = assertIs<ApiResult.Success<OpenCodeEvent.SessionDiff>>(
val serverDiff = assertIs<ApiResult.Success<SessionDiffSnapshot>>(
sessionClient.fetchSessionDiffSnapshot(server.port, sessionId),
).value
assertTrue(
Expand Down Expand Up @@ -534,8 +540,8 @@ class OpenCodeDiffLiveTest(
port: Int,
sessionId: String,
relativePath: String,
): OpenCodeEvent.SessionDiffFile {
val diff = assertIs<ApiResult.Success<OpenCodeEvent.SessionDiff>>(
): SessionDiffFile {
val diff = assertIs<ApiResult.Success<SessionDiffSnapshot>>(
sessionClient.fetchSessionDiffSnapshot(port, sessionId),
).value
val diffFile = diff.files.firstOrNull { it.file == relativePath }
Expand All @@ -553,7 +559,7 @@ class OpenCodeDiffLiveTest(
): ChildDiffs {
val deadline = System.currentTimeMillis() + timeoutMs
var lastSessions: List<Session> = emptyList()
var lastDiffsBySessionId: Map<String, OpenCodeEvent.SessionDiff> = emptyMap()
var lastDiffsBySessionId: Map<String, SessionDiffSnapshot> = emptyMap()

while (System.currentTimeMillis() < deadline) {
val sessions = when (val hierarchy = sessionClient.fetchSessionHierarchy(port)) {
Expand Down Expand Up @@ -639,7 +645,7 @@ class OpenCodeDiffLiveTest(
projectBase: String,
rootSessionId: String,
sessions: List<Session>,
diffsBySessionId: Map<String, OpenCodeEvent.SessionDiff>,
diffsBySessionId: Map<String, SessionDiffSnapshot>,
): CoreDiffStateHarness.VisibleState {
val harness = CoreDiffStateHarness(projectBase)
diffsBySessionId.forEach { (diffSessionId, diff) ->
Expand Down Expand Up @@ -684,7 +690,7 @@ class OpenCodeDiffLiveTest(
private fun assertInlineDiffFromServerPayload(
repoRoot: String,
sessionId: String,
diffFile: OpenCodeEvent.SessionDiffFile,
diffFile: SessionDiffFile,
expectedRemoved: String,
expectedAdded: String,
) {
Expand All @@ -709,8 +715,6 @@ class OpenCodeDiffLiveTest(
)
},
)
harness.disk[harness.abs(relativeFile)] = diffFile.before
harness.commitTurnPatch(listOf(relativeFile))
harness.disk[harness.abs(relativeFile)] = diffFile.after
harness.applySessionDiffFiles(listOf(diffFile))

Expand All @@ -724,7 +728,7 @@ class OpenCodeDiffLiveTest(

private fun assertPreviewMatchesServerDiff(
preview: SessionApiClient.FileDiffPreview,
diffFile: OpenCodeEvent.SessionDiffFile,
diffFile: SessionDiffFile,
) {
assertEquals(normalizeNewlinesOnly(diffFile.before), normalizeNewlinesOnly(preview.before))
assertEquals(normalizeNewlinesOnly(diffFile.after), normalizeNewlinesOnly(preview.after))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import com.ashotn.opencode.relay.api.transport.mapJsonArrayResponse
import com.ashotn.opencode.relay.api.transport.mapJsonObjectResponse
import com.ashotn.opencode.relay.api.transport.parseBooleanResponse
import com.ashotn.opencode.relay.api.transport.withParseContext
import com.ashotn.opencode.relay.ipc.OpenCodeEvent
import com.ashotn.opencode.relay.ipc.SessionDiffStatus
import com.ashotn.opencode.relay.ipc.PatchDiffTextParser
import com.ashotn.opencode.relay.ipc.SessionDiffStatus
import com.ashotn.opencode.relay.util.getIntOrNull
import com.ashotn.opencode.relay.util.getObjectOrNull
import com.ashotn.opencode.relay.util.getStringOrNull
Expand Down Expand Up @@ -85,57 +84,44 @@ class SessionApiClient(
port: Int,
sessionId: String,
messageId: String? = null,
): ApiResult<OpenCodeEvent.SessionDiff> {
): ApiResult<SessionDiffSnapshot> {
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(OpenCodeEvent.SessionDiff(sessionId, emptyList()))
ApiResult.Success(SessionDiffSnapshot(sessionId, emptyList()))
} else {
transport.mapJsonArrayResponse(ApiResult.Success(body)) { diffArray ->
val files =
diffArray.mapNotNull { element -> parseSessionDiffFile(element.asJsonObjectOrNull()) }
// OpenCode < 1.16 returns session-level diffs here. OpenCode >= 1.16 returns []
// unless a messageID is provided, so fall back to per-message diffs for callers.
if (messageId == null && files.isEmpty()) {
when (val messageDiff = fetchSessionMessageDiffSnapshot(port, sessionId)) {
is ApiResult.Failure -> {
if (messageDiff.error.isUnsupportedMessageFallback()) {
ApiResult.Success(OpenCodeEvent.SessionDiff(sessionId, emptyList()))
} else {
messageDiff
}
}

is ApiResult.Success -> messageDiff
}
} else {
ApiResult.Success(OpenCodeEvent.SessionDiff(sessionId, files))
}
ApiResult.Success(SessionDiffSnapshot(sessionId, files))
}.withParseContext(endpoint)
}
}
}
}

fun fetchSessionMessageDiffSnapshot(port: Int, sessionId: String): ApiResult<OpenCodeEvent.SessionDiff> {
// OpenCode >= 1.16 stores diffs on message summaries instead of the session.
fun fetchSessionMessageDiffSnapshot(port: Int, sessionId: String): ApiResult<SessionDiffSnapshot> {
return when (val refsResult = fetchSessionMessageDiffRefs(port, sessionId)) {
is ApiResult.Failure -> refsResult
is ApiResult.Success -> {
val messageIds = refsResult.value
if (messageIds.isEmpty()) return ApiResult.Success(OpenCodeEvent.SessionDiff(sessionId, emptyList()))
if (messageIds.isEmpty()) return ApiResult.Success(SessionDiffSnapshot(sessionId, emptyList()))

val mergedFilesByPath = linkedMapOf<String, OpenCodeEvent.SessionDiffFile>()
val mergedFilesByPath = linkedMapOf<String, SessionDiffFile>()
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(OpenCodeEvent.SessionDiff(sessionId, mergedFilesByPath.values.toList()))
ApiResult.Success(SessionDiffSnapshot(sessionId, mergedFilesByPath.values.toList()))
}
}
}
Expand Down Expand Up @@ -199,15 +185,15 @@ class SessionApiClient(
)
}

private fun parseSessionDiffFile(obj: JsonObject?): OpenCodeEvent.SessionDiffFile? {
private fun parseSessionDiffFile(obj: JsonObject?): SessionDiffFile? {
if (obj == null) return null
val file = obj.getStringOrNull("file") ?: return null
val diffText = PatchDiffTextParser.parse(obj)
val additions = obj.getIntOrNull("additions") ?: 0
val deletions = obj.getIntOrNull("deletions") ?: 0
val status = SessionDiffStatus.fromWire(obj.getStringOrNull("status") ?: "unknown")

return OpenCodeEvent.SessionDiffFile(
return SessionDiffFile(
file = file,
before = diffText.before,
after = diffText.after,
Expand All @@ -218,8 +204,8 @@ class SessionApiClient(
}

private fun mergeDiffFiles(
mergedFilesByPath: MutableMap<String, OpenCodeEvent.SessionDiffFile>,
files: List<OpenCodeEvent.SessionDiffFile>,
mergedFilesByPath: MutableMap<String, SessionDiffFile>,
files: List<SessionDiffFile>,
) {
for (file in files) {
val existing = mergedFilesByPath[file.file]
Expand All @@ -244,9 +230,6 @@ class SessionApiClient(
else -> next
}

private fun ApiError.isUnsupportedMessageFallback(): Boolean =
this is ApiError.HttpError && (statusCode == 404 || statusCode == 405)

private fun com.google.gson.JsonElement?.asJsonObjectOrNull(): JsonObject? =
this?.takeIf { it.isJsonObject }?.asJsonObject

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.ashotn.opencode.relay.api.session

import com.ashotn.opencode.relay.ipc.SessionDiffStatus

/** Diff snapshot fetched from the OpenCode HTTP API. */
data class SessionDiffSnapshot(
val sessionId: String,
val files: List<SessionDiffFile>,
)

data class SessionDiffFile(
val file: String, // project-relative path
val before: String,
val after: String,
val additions: Int,
val deletions: Int,
val status: SessionDiffStatus,
)
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,8 @@ internal object SessionEndpoints {

fun list(): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/session")

fun diff(sessionId: String, messageId: String? = null): ApiEndpoint {
val query = messageId
?.let { "?messageID=${URLEncoder.encode(it, StandardCharsets.UTF_8)}" }
?: ""
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")
}

Expand Down
Loading
Loading