From 064f6c37bd45cab331bc263db6a82949b6996258 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Tue, 9 Jun 2026 09:29:27 -0700 Subject: [PATCH 1/4] refactor: simplify session diff handling by removing deprecated fallback logic and pre-1.16 compatibility layers --- docs/INLINE_DIFF_POLICY.md | 5 +- .../relay/api/session/SessionApiClient.kt | 26 +---- .../relay/core/OpenCodeCoreService.kt | 6 -- .../opencode/relay/ipc/OpenCodeEvent.kt | 12 +-- .../ashotn/opencode/relay/ipc/SseClient.kt | 55 +--------- .../relay/api/session/SessionApiClientTest.kt | 102 ++++-------------- .../opencode/relay/ipc/SseClientTest.kt | 31 ------ 7 files changed, 29 insertions(+), 208 deletions(-) diff --git a/docs/INLINE_DIFF_POLICY.md b/docs/INLINE_DIFF_POLICY.md index 185e7db..849d407 100644 --- a/docs/INLINE_DIFF_POLICY.md +++ b/docs/INLINE_DIFF_POLICY.md @@ -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 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 b3b5d99..d03c108 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 @@ -86,6 +86,10 @@ class SessionApiClient( 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 @@ -97,23 +101,7 @@ class SessionApiClient( 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(OpenCodeEvent.SessionDiff(sessionId, files)) }.withParseContext(endpoint) } } @@ -121,7 +109,6 @@ class SessionApiClient( } fun fetchSessionMessageDiffSnapshot(port: Int, sessionId: String): ApiResult { - // OpenCode >= 1.16 stores diffs on message summaries instead of the session. return when (val refsResult = fetchSessionMessageDiffRefs(port, sessionId)) { is ApiResult.Failure -> refsResult is ApiResult.Success -> { @@ -244,9 +231,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 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 fccf6ce..542c11d 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt @@ -166,13 +166,7 @@ class OpenCodeCoreService(private val project: Project) : Disposable { refreshSessionHierarchyAsync(generation) } - is OpenCodeEvent.TurnPatch -> { - // OpenCode < 1.16 path: patch parts only scope files; session.diff carries the content. - recordTurnFiles(event.sessionId, event.files, generation, "turn.patch.committed") - } - is OpenCodeEvent.MessageDiffAvailable -> { - // OpenCode >= 1.16 path: fetch the message-specific diff, then use the same apply pipeline. handleMessageDiffAvailable(event, generation) } 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 bf56b46..6115ad1 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ipc/OpenCodeEvent.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ipc/OpenCodeEvent.kt @@ -28,12 +28,6 @@ sealed class OpenCodeEvent { /** session lifecycle changed — refresh session hierarchy metadata. */ data class SessionLifecycleChanged(val sessionId: String) : OpenCodeEvent() - /** message.part.updated with type == "patch". */ - data class TurnPatch( - val sessionId: String, - val files: List, // absolute paths - ) : OpenCodeEvent() - /** message.updated for a user message whose summary contains per-message diffs. */ data class MessageDiffAvailable( val sessionId: String, @@ -53,9 +47,9 @@ sealed class OpenCodeEvent { ) : OpenCodeEvent() /** - * session.diff — fired after every tool execution with the cumulative diff - * for the session. The payload contains patch-based entries; the plugin - * reconstructs before/after text and stores a typed [SessionDiffStatus]. + * Diff snapshot fetched from the OpenCode HTTP API. The payload contains + * patch-based entries; the plugin reconstructs before/after text and stores + * a typed [SessionDiffStatus]. */ data class SessionDiff( val sessionId: String, 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 c176728..a0f3813 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt @@ -1,7 +1,6 @@ package com.ashotn.opencode.relay.ipc import com.ashotn.opencode.relay.api.event.EventStreamClient -import com.ashotn.opencode.relay.util.getIntOrNull import com.ashotn.opencode.relay.util.getObjectOrNull import com.ashotn.opencode.relay.util.getStringOrNull import com.google.gson.JsonObject @@ -144,15 +143,10 @@ class SseClient( val event: OpenCodeEvent? = when (type) { "server.connected" -> OpenCodeEvent.ServerConnected "server.heartbeat", "sync", "project.updated" -> null - "session.diff" -> parseSessionDiff(properties) - "session.idle" -> null - // OpenCode >= 1.16 exposes turn diffs through user message summaries. "message.updated" -> parseMessageUpdated(properties) // 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) - // OpenCode < 1.16 emitted patch parts that scoped touched files for session.diff. - "message.part.updated" -> parseMessagePartUpdated(properties) "mcp.tools.changed" -> parseMcpToolsChanged(properties) "permission.asked" -> parsePermissionAsked(properties) "permission.replied" -> parsePermissionReplied(properties) @@ -195,38 +189,6 @@ class SseClient( runCatching { Path.of(directory).toRealPath() } .getOrElse { Path.of(directory).toAbsolutePath().normalize() } - private fun parseSessionDiff(props: JsonObject): OpenCodeEvent.SessionDiff? { - val sessionId = props.getStringOrNull("sessionID") - if (sessionId == null) { - log.debug("SseClient: skip session.diff reason=missingSessionId") - return null - } - val diffArray = props.getAsJsonArray("diff") - if (diffArray == null) { - log.debug("SseClient: skip session.diff reason=missingDiffArray session=$sessionId") - return null - } - val files = diffArray.mapNotNull { elem -> - if (!elem.isJsonObject) return@mapNotNull null - val obj = elem.asJsonObject - - val file = obj.getStringOrNull("file") ?: return@mapNotNull null - val diffText = PatchDiffTextParser.parse(obj) - val additions = obj.getIntOrNull("additions") ?: 0 - val deletions = obj.getIntOrNull("deletions") ?: 0 - val statusRaw = obj.getStringOrNull("status") ?: "modified" - val status = SessionDiffStatus.fromWire(statusRaw) - if (status == SessionDiffStatus.UNKNOWN) { - log.warn("SseClient: unknown session.diff status '$statusRaw' for file=$file") - } - - OpenCodeEvent.SessionDiffFile(file, diffText.before, diffText.after, additions, deletions, status) - } - - log.debug("SseClient: parsed session.diff session=$sessionId fileCount=${files.size}") - return OpenCodeEvent.SessionDiff(sessionId, files) - } - private fun parsePermissionReplied(props: JsonObject): OpenCodeEvent.PermissionReplied? { val requestId = props.getStringOrNull("requestID") ?: return null val sessionId = props.getStringOrNull("sessionID") ?: return null @@ -295,22 +257,7 @@ class SseClient( return OpenCodeEvent.SessionLifecycleChanged(sessionId) } - private fun parseMessagePartUpdated(props: JsonObject): OpenCodeEvent.TurnPatch? { - val part = props.getObjectOrNull("part") ?: return null - if (part.getStringOrNull("type") != "patch") return null - - val sessionId = part.getStringOrNull("sessionID") ?: return null - val files = part.getAsJsonArray("files") - ?.mapNotNull { element -> - if (!element.isJsonPrimitive) return@mapNotNull null - element.asString - } - ?: emptyList() - - return OpenCodeEvent.TurnPatch(sessionId, files) - } - - // OpenCode >= 1.16: message.updated only tells us which user message has diffs; + // 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 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 32be150..85a55c6 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 @@ -64,7 +64,7 @@ class SessionApiClientTest { } val client = SessionApiClient() - val result = client.fetchSessionDiffSnapshot(port, "ses_1") + val result = client.fetchSessionDiffSnapshot(port, "ses_1", "msg_1") val success = assertIs>(result) assertEquals("ses_1", success.value.sessionId) @@ -84,7 +84,7 @@ class SessionApiClientTest { } val client = SessionApiClient() - val result = client.fetchSessionDiffSnapshot(port, "ses_1") + val result = client.fetchSessionDiffSnapshot(port, "ses_1", "msg_1") val success = assertIs>(result) assertEquals("ses_1", success.value.sessionId) @@ -102,91 +102,11 @@ class SessionApiClientTest { } val client = SessionApiClient() - val result = client.fetchSessionDiffSnapshot(port, "ses_1") + val result = client.fetchSessionDiffSnapshot(port, "ses_1", "msg_1") val failure = assertIs(result) val error = assertIs(failure.error) - assertEquals("GET /session/ses_1/diff: Expected JSON array", error.message) - } - } - - @Test - fun `fetchSessionDiffSnapshot keeps old session diff compatibility before message diff fallback`() { - withTestServer { server, port -> - val diffQueries = mutableListOf() - server.createContext("/session/ses_1/diff") { exchange -> - diffQueries.add(exchange.requestURI.rawQuery) - val body = if (exchange.requestURI.rawQuery == null) { - "[]" - } else { - """ - [ - { - "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)) } - } - server.createContext("/session/ses_1/message") { exchange -> - val body = """ - [ - { - "info": { - "id": "msg_user", - "role": "user", - "summary": { "diffs": [{ "file": "a.txt" }] } - } - }, - { - "info": { - "id": "msg_assistant", - "role": "assistant", - "summary": { "diffs": [{ "file": "assistant.txt" }] } - } - } - ] - """.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(listOf(null, "messageID=msg_user"), diffQueries) - assertEquals(listOf("a.txt"), success.value.files.map { it.file }) - } - } - - @Test - fun `fetchSessionDiffSnapshot returns empty diff when message fallback is unsupported`() { - for (statusCode in listOf(404, 405)) { - withTestServer { server, port -> - server.createContext("/session/ses_1/diff") { exchange -> - val body = "[]" - 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 -> - exchange.sendResponseHeaders(statusCode, -1) - exchange.close() - } - - val client = SessionApiClient() - val result = client.fetchSessionDiffSnapshot(port, "ses_1") - - val success = assertIs>(result) - assertEquals("ses_1", success.value.sessionId) - assertEquals(emptyList(), success.value.files) - } + assertEquals("GET /session/ses_1/diff?messageID=msg_1: Expected JSON array", error.message) } } @@ -213,6 +133,13 @@ class SessionApiClientTest { 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)) } + } val client = SessionApiClient() val result = client.fetchFileDiffPreview(port, "ses_1", "/repo", "/repo/a.txt") @@ -241,6 +168,13 @@ class SessionApiClientTest { 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)) } + } val client = SessionApiClient() val result = client.fetchFileDiffPreview(port, "ses_1", "/repo", "/repo/missing.txt") 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 12b085f..da0f106 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/ipc/SseClientTest.kt @@ -5,7 +5,6 @@ import org.junit.Test import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger -import kotlin.test.assertFalse import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue @@ -98,36 +97,6 @@ class SseClientTest { } } - @Test - fun `ignores deprecated session idle event`() { - withTestServer { server, port -> - server.createContext("/global/event") { exchange -> - val body = """ - data: {"directory":"/project","payload":{"type":"session.idle","properties":{"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 eventReceived = CountDownLatch(1) - val client = SseClient( - port = port, - directory = "/project", - onEvent = { eventReceived.countDown() }, - ) - - try { - client.start() - assertFalse(eventReceived.await(400, TimeUnit.MILLISECONDS)) - } finally { - client.stop() - } - } - } - @Test fun `parses wrapped session lifecycle events`() { withTestServer { server, port -> From e6f2c0df4fb2e32ada7a5cf99abf987598f71a55 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Tue, 9 Jun 2026 09:45:46 -0700 Subject: [PATCH 2/4] refactor: remove session diff handling and cleanup related API usage --- .../integration/OpenCodeTestEventCollector.kt | 27 +-- .../relay/api/session/SessionEndpoints.kt | 6 +- .../opencode/relay/core/EventReducer.kt | 1 - .../ashotn/opencode/relay/core/StateStore.kt | 1 - .../relay/core/SessionDiffPipelineTest.kt | 187 ------------------ .../relay/core/SessionOrderingTest.kt | 2 - 6 files changed, 5 insertions(+), 219 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 ca82e14..0dc0fdc 100644 --- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt +++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEventCollector.kt @@ -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 = synchronized(lock) { @@ -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() - .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() + .filter { it.sessionId == sessionId } if (matching.size >= atLeastCount) matching.last() else null } @@ -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)" 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 f820647..8296a4d 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 @@ -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") } diff --git a/src/main/kotlin/com/ashotn/opencode/relay/core/EventReducer.kt b/src/main/kotlin/com/ashotn/opencode/relay/core/EventReducer.kt index 43674a3..f96e984 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/EventReducer.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/EventReducer.kt @@ -117,7 +117,6 @@ internal class EventReducer { val revision = stateStore.reserveRevisionForSessionDiffApply( stateLock = stateLock, sessionId = sessionId, - fromHistory = fromHistory, expectedGeneration = generation, currentGeneration = currentGeneration, ) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/core/StateStore.kt b/src/main/kotlin/com/ashotn/opencode/relay/core/StateStore.kt index 5cf848a..f15fe16 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/StateStore.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/StateStore.kt @@ -41,7 +41,6 @@ internal class StateStore { fun reserveRevisionForSessionDiffApply( stateLock: Any, sessionId: String, - fromHistory: Boolean, expectedGeneration: Long, currentGeneration: () -> Long, ): Long? = synchronized(stateLock) { 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 6579881..6f47dd6 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt @@ -6,7 +6,6 @@ import com.ashotn.opencode.relay.ipc.OpenCodeEvent import com.ashotn.opencode.relay.ipc.SessionDiffStatus import org.junit.Test import kotlin.test.assertEquals -import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -20,55 +19,6 @@ class SessionDiffPipelineTest { private val h = DiffPipelineHarness() - // ------------------------------------------------------------------------- - // A file in turnScope that is absent from the server's session.diff payload - // must be treated as resolved back to baseline — its hunks and lastAfter - // must be cleared, not left stale. If they are left stale, the next turn - // will compute its diff against wrong content and show incorrect changes. - // - // MANUAL VERIFICATION: - // 1. Ask the AI to write some content to a file. - // 2. In a new turn, ask the AI to delete all content from that file. - // No diff should be visible in the editor after this turn. - // 3. In a new turn, ask the AI to write new content to the same file. - // Only a green addition should be visible — no red deletion. - // ------------------------------------------------------------------------- - @Test - fun `empty server diff for scoped file should clear existing hunks`() { - val file = "note.md" - - // Turn 1: add content - h.disk[h.abs(file)] = "line1\n" - h.commitTurnPatch(listOf(file)) - h.applySessionDiff(listOf(file to SessionDiffStatus.MODIFIED)) - assertEquals(setOf(h.abs(file)), h.hunkFiles(), "turn 1: file should be tracked") - - // Turn 2: add more - h.disk[h.abs(file)] = "line1\nline2\n" - h.commitTurnPatch(listOf(file)) - h.applySessionDiff(listOf(file to SessionDiffStatus.MODIFIED)) - assertEquals(setOf(h.abs(file)), h.hunkFiles(), "turn 2: file should still be tracked") - - // Turn 3: remove all — server sends 0 files in session.diff - h.disk[h.abs(file)] = "" - h.commitTurnPatch(listOf(file)) - h.applySessionDiff(emptyList()) - - assertTrue( - h.hunkFiles().isEmpty(), - "turn 3: hunkFiles should be empty after empty diff for scoped file, got: ${h.hunkFiles()}" - ) - assertTrue(h.liveHunkFiles().isEmpty(), "turn 3: liveHunkFiles should be empty") - - // Turn 4: add content again — baseline must be "" not stale "line1\nline2\n" - h.disk[h.abs(file)] = "line3\n" - h.commitTurnPatch(listOf(file)) - h.applySessionDiff(listOf(file to SessionDiffStatus.MODIFIED)) - - assertEquals(setOf(h.abs(file)), h.hunkFiles(), "turn 4: file should be tracked again") - assertEquals("", h.baseline(file), "turn 4: baseline should be '' (reset in turn 3), not stale lastAfter") - } - // ------------------------------------------------------------------------- // A file whose current disk content matches its effective baseline is not // a live AI change, even if the server reports it as ADDED. It must not be @@ -126,141 +76,6 @@ class SessionDiffPipelineTest { assertEquals(listOf("1"), hunk.addedLines, "addedLines should contain the new content") } - // ------------------------------------------------------------------------- - // A session.diff with no preceding turn.patch must be ignored. turn.patch - // establishes the scope of files the current turn is allowed to touch; without - // it there is no safe boundary and applying the diff could corrupt state for - // files the current turn never intended to modify. - // - // MANUAL VERIFICATION: No visible UI effect. With OPENCODE_DIFF_TRACE=1 - // the skipped event will log skipReason=UNSCOPED_LIVE in the trace file. - // ------------------------------------------------------------------------- - @Test - fun `session diff without turn patch is ignored`() { - val file = "note.md" - h.disk[h.abs(file)] = "content\n" - val result = h.applySessionDiff(listOf(file to SessionDiffStatus.MODIFIED)) - assertNull(result, "diff without turn scope should be skipped") - assertTrue(h.hunkFiles().isEmpty(), "no hunks without turn scope") - } - - // ------------------------------------------------------------------------- - // On Windows, turn.patch reports absolute paths such as C:\project\src\Main.kt, - // while session.diff may report project-relative paths such as src/Main.kt. - // The normalized turn scope must match the normalized session.diff file; - // otherwise the file is treated as out-of-scope and the UI never updates. - // ------------------------------------------------------------------------- - @Test - fun `windows absolute turn patch scopes relative session diff`() { - val projectBase = "C:/Users/VM/project" - val generation = 1L - val sessionId = "ses_test" - val file = "src/Main.kt" - val absFile = "$projectBase/$file" - val lowerAbsFile = "$projectBase/src/main.kt" - val disk = mutableMapOf(absFile to "fun main() {}\n") - val stateStore = StateStore() - val stateLock = Any() - val eventReducer = EventReducer() - - val touchedPaths = eventReducer.reduceTurnPatchTouchedPaths( - projectBase = projectBase, - files = listOf("c:\\users\\vm\\project\\src\\main.kt"), - ) - assertTrue(absFile in touchedPaths, "Windows turn.patch scope should match post-root casing differences") - eventReducer.commitTurnPatch( - stateStore = stateStore, - stateLock = stateLock, - sessionId = sessionId, - touchedPaths = touchedPaths, - generation = generation, - currentGeneration = { generation }, - ) - - val decision = eventReducer.beginSessionDiffApply( - stateStore = stateStore, - stateLock = stateLock, - sessionId = sessionId, - fromHistory = false, - generation = generation, - currentGeneration = { generation }, - ) - assertTrue(decision.shouldApply, "Windows turn.patch should create a usable turn scope") - - val revision = decision.revision!! - val prepareSnapshot = stateStore.snapshotSessionDiffPrepareState( - stateLock = stateLock, - sessionId = sessionId, - expectedGeneration = generation, - currentGeneration = { generation }, - )!! - - val computer = SessionDiffApplyComputer( - contentReader = { absPath -> disk[absPath] ?: "" }, - hunkComputer = { fileDiff, sid -> - if (fileDiff.before == fileDiff.after) emptyList() - else listOf(DiffHunk(fileDiff.file, 0, emptyList(), listOf(fileDiff.after), sid)) - }, - log = NoOpLogger, - tracer = NoOpDiffTracer, - ) - - val computedState = computer.compute( - projectBase = projectBase, - event = OpenCodeEvent.SessionDiff( - sessionId = sessionId, - files = listOf( - OpenCodeEvent.SessionDiffFile( - file = file, - before = "", - after = "fun main() {}\n", - additions = 1, - deletions = 0, - status = SessionDiffStatus.MODIFIED, - ) - ), - ), - fromHistory = false, - turnScope = decision.turnScope, - previousAfterByFile = prepareSnapshot.previousAfterByFile, - ) - - val result = stateStore.commitSessionDiffApply( - stateLock = stateLock, - sessionId = sessionId, - revision = revision, - fromHistory = false, - computedState = computedState, - nowMillis = 0L, - expectedGeneration = generation, - currentGeneration = { generation }, - ) - - assertEquals( - setOf(absFile), - result?.changedFiles, - "Windows turn.patch scope should match relative session.diff file" - ) - assertTrue( - lowerAbsFile in (result?.changedFiles ?: emptySet()), - "changedFiles should use Windows path identity" - ) - assertEquals( - setOf(absFile), - stateStore.hunksBySessionAndFile[sessionId]?.keys, - "file should be tracked after scoped Windows diff", - ) - assertTrue( - stateStore.liveHunksBySessionAndFile[sessionId]?.get(lowerAbsFile)?.isNotEmpty() == true, - "live hunk lookup should match Windows post-root casing differences", - ) - assertEquals( - "", - stateStore.baselineBeforeBySessionAndFile[sessionId]?.get(lowerAbsFile), - "baseline lookup should match Windows post-root casing differences", - ) - } - // ------------------------------------------------------------------------- // When the AI intentionally empties a file (replaces all content with an // empty string), the "After AI" panel in the diff viewer must show an empty @@ -495,7 +310,6 @@ class SessionDiffPipelineTest { val revision = freshStore.reserveRevisionForSessionDiffApply( stateLock = freshLock, sessionId = h.sessionId, - fromHistory = true, expectedGeneration = h.generation, currentGeneration = { h.generation }, )!! @@ -824,7 +638,6 @@ class SessionDiffPipelineTest { val revision = stateStore.reserveRevisionForSessionDiffApply( stateLock = stateLock, sessionId = sessionId, - fromHistory = true, expectedGeneration = generation, currentGeneration = { generation }, )!! 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 43b4fc6..b579348 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/core/SessionOrderingTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/core/SessionOrderingTest.kt @@ -108,7 +108,6 @@ class SessionOrderingTest { val revision = store.reserveRevisionForSessionDiffApply( stateLock = stateLock, sessionId = sid, - fromHistory = true, expectedGeneration = generation, currentGeneration = { generation }, )!! @@ -379,7 +378,6 @@ class SessionOrderingTest { val revision = store.reserveRevisionForSessionDiffApply( stateLock = stateLock, sessionId = sessionId, - fromHistory = false, expectedGeneration = generation, currentGeneration = { generation }, )!! From 2827e94218b3bec85c07986069ed286eb5edd638 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Tue, 9 Jun 2026 12:54:09 -0700 Subject: [PATCH 3/4] refactor: unify session diff handling by introducing `SessionDiffSnapshot` and removing legacy APIs --- .../integration/diff/OpenCodeDiffLiveTest.kt | 28 ++-- .../relay/api/session/SessionApiClient.kt | 25 ++-- .../opencode/relay/api/session/SessionDiff.kt | 18 +++ .../opencode/relay/core/EventReducer.kt | 107 --------------- .../relay/core/OpenCodeCoreService.kt | 110 +++------------- .../relay/core/SessionDiffApplyComputer.kt | 63 +-------- .../ashotn/opencode/relay/core/StateStore.kt | 52 -------- .../opencode/relay/ipc/OpenCodeEvent.kt | 21 --- .../relay/api/session/SessionApiClientTest.kt | 7 +- .../relay/core/SessionDiffPipelineTest.kt | 122 ++++++++++-------- .../relay/core/SessionOrderingTest.kt | 18 +-- .../relay/lifecycle/ResetConnectionTest.kt | 12 +- .../relay/core/CoreDiffStateHarness.kt | 36 +----- .../relay/core/DiffPipelineHarness.kt | 56 ++------ 14 files changed, 162 insertions(+), 513 deletions(-) create mode 100644 src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionDiff.kt 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 4dbe2f8..62ccd55 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 @@ -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 @@ -34,7 +36,7 @@ class OpenCodeDiffLiveTest( private data class ChildDiffs( val sessions: List, - val diffsBySessionId: Map, + val diffsBySessionId: Map, val fileToChildSessionId: Map, val diffSummaryRoleByFile: Map, ) @@ -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>( + val finalDiff = assertIs>( 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 } @@ -441,7 +447,7 @@ class OpenCodeDiffLiveTest( ) assertFileText(file, aiContent) - val serverDiff = assertIs>( + val serverDiff = assertIs>( sessionClient.fetchSessionDiffSnapshot(server.port, sessionId), ).value assertTrue( @@ -534,8 +540,8 @@ class OpenCodeDiffLiveTest( port: Int, sessionId: String, relativePath: String, - ): OpenCodeEvent.SessionDiffFile { - val diff = assertIs>( + ): SessionDiffFile { + val diff = assertIs>( sessionClient.fetchSessionDiffSnapshot(port, sessionId), ).value val diffFile = diff.files.firstOrNull { it.file == relativePath } @@ -553,7 +559,7 @@ class OpenCodeDiffLiveTest( ): ChildDiffs { val deadline = System.currentTimeMillis() + timeoutMs var lastSessions: List = emptyList() - var lastDiffsBySessionId: Map = emptyMap() + var lastDiffsBySessionId: Map = emptyMap() while (System.currentTimeMillis() < deadline) { val sessions = when (val hierarchy = sessionClient.fetchSessionHierarchy(port)) { @@ -639,7 +645,7 @@ class OpenCodeDiffLiveTest( projectBase: String, rootSessionId: String, sessions: List, - diffsBySessionId: Map, + diffsBySessionId: Map, ): CoreDiffStateHarness.VisibleState { val harness = CoreDiffStateHarness(projectBase) diffsBySessionId.forEach { (diffSessionId, diff) -> @@ -684,7 +690,7 @@ class OpenCodeDiffLiveTest( private fun assertInlineDiffFromServerPayload( repoRoot: String, sessionId: String, - diffFile: OpenCodeEvent.SessionDiffFile, + diffFile: SessionDiffFile, expectedRemoved: String, expectedAdded: String, ) { @@ -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)) @@ -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)) 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 d03c108..f6a38fe 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 @@ -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 @@ -85,7 +84,7 @@ class SessionApiClient( port: Int, sessionId: String, messageId: String? = null, - ): ApiResult { + ): ApiResult { if (messageId == null) { return fetchSessionMessageDiffSnapshot(port, sessionId) } @@ -96,33 +95,33 @@ class SessionApiClient( 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()) } - ApiResult.Success(OpenCodeEvent.SessionDiff(sessionId, files)) + ApiResult.Success(SessionDiffSnapshot(sessionId, files)) }.withParseContext(endpoint) } } } } - fun fetchSessionMessageDiffSnapshot(port: Int, sessionId: String): ApiResult { + 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(OpenCodeEvent.SessionDiff(sessionId, emptyList())) + if (messageIds.isEmpty()) return ApiResult.Success(SessionDiffSnapshot(sessionId, emptyList())) - val mergedFilesByPath = linkedMapOf() + 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(OpenCodeEvent.SessionDiff(sessionId, mergedFilesByPath.values.toList())) + ApiResult.Success(SessionDiffSnapshot(sessionId, mergedFilesByPath.values.toList())) } } } @@ -186,7 +185,7 @@ 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) @@ -194,7 +193,7 @@ class SessionApiClient( 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, @@ -205,8 +204,8 @@ class SessionApiClient( } private fun mergeDiffFiles( - mergedFilesByPath: MutableMap, - files: List, + mergedFilesByPath: MutableMap, + files: List, ) { for (file in files) { val existing = mergedFilesByPath[file.file] diff --git a/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionDiff.kt b/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionDiff.kt new file mode 100644 index 0000000..4a1c6c2 --- /dev/null +++ b/src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionDiff.kt @@ -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, +) + +data class SessionDiffFile( + val file: String, // project-relative path + val before: String, + val after: String, + val additions: Int, + val deletions: Int, + val status: SessionDiffStatus, +) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/core/EventReducer.kt b/src/main/kotlin/com/ashotn/opencode/relay/core/EventReducer.kt index f96e984..8db23f4 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/EventReducer.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/EventReducer.kt @@ -1,27 +1,8 @@ package com.ashotn.opencode.relay.core import com.ashotn.opencode.relay.util.TextUtil -import com.ashotn.opencode.relay.util.createPathIdentitySet -import com.ashotn.opencode.relay.util.toAbsolutePath internal class EventReducer { - enum class SessionDiffSkipReason { - UNSCOPED_LIVE, - STALE_OR_ALREADY_LOADED, - } - - private data class SessionDiffScopeDecision( - val shouldApply: Boolean, - val turnScope: Set?, - ) - - data class SessionDiffApplyDecision( - val shouldApply: Boolean, - val turnScope: Set?, - val revision: Long?, - val skipReason: SessionDiffSkipReason? = null, - ) - data class ReconcileDecision( val updatedHunks: Map>, val updatedDeleted: Set, @@ -29,26 +10,6 @@ internal class EventReducer { val removedPaths: Set, ) - fun reduceTurnPatchTouchedPaths(projectBase: String, files: List): Set = - createPathIdentitySet(projectBase, files.map { path -> toAbsolutePath(projectBase, path) }) - - fun commitTurnPatch( - stateStore: StateStore, - stateLock: Any, - sessionId: String, - touchedPaths: Set, - generation: Long, - currentGeneration: () -> Long, - ): Boolean { - return stateStore.commitTurnPatch( - stateLock = stateLock, - sessionId = sessionId, - touchedPaths = touchedPaths, - expectedGeneration = generation, - currentGeneration = currentGeneration, - ) - } - fun commitSessionBusy( stateStore: StateStore, stateLock: Any, @@ -68,74 +29,6 @@ internal class EventReducer { ) } - private fun reduceSessionDiffScope( - stateStore: StateStore, - stateLock: Any, - sessionId: String, - fromHistory: Boolean, - generation: Long, - currentGeneration: () -> Long, - ): SessionDiffScopeDecision { - val turnScope = stateStore.consumeTurnScopeForDiff( - stateLock = stateLock, - sessionId = sessionId, - fromHistory = fromHistory, - expectedGeneration = generation, - currentGeneration = currentGeneration, - ) - if (!fromHistory && turnScope == null) { - return SessionDiffScopeDecision(shouldApply = false, turnScope = null) - } - return SessionDiffScopeDecision(shouldApply = true, turnScope = turnScope) - } - - fun beginSessionDiffApply( - stateStore: StateStore, - stateLock: Any, - sessionId: String, - fromHistory: Boolean, - generation: Long, - currentGeneration: () -> Long, - ): SessionDiffApplyDecision { - val scopeDecision = reduceSessionDiffScope( - stateStore = stateStore, - stateLock = stateLock, - sessionId = sessionId, - fromHistory = fromHistory, - generation = generation, - currentGeneration = currentGeneration, - ) - if (!scopeDecision.shouldApply) { - return SessionDiffApplyDecision( - shouldApply = false, - turnScope = null, - revision = null, - skipReason = SessionDiffSkipReason.UNSCOPED_LIVE, - ) - } - - val revision = stateStore.reserveRevisionForSessionDiffApply( - stateLock = stateLock, - sessionId = sessionId, - expectedGeneration = generation, - currentGeneration = currentGeneration, - ) - if (revision == null) { - return SessionDiffApplyDecision( - shouldApply = false, - turnScope = scopeDecision.turnScope, - revision = null, - skipReason = SessionDiffSkipReason.STALE_OR_ALREADY_LOADED, - ) - } - - return SessionDiffApplyDecision( - shouldApply = true, - turnScope = scopeDecision.turnScope, - revision = revision, - ) - } - fun reduceReconcile( currentHunks: Map>, currentDeleted: Set, 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 542c11d..cd9356b 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt @@ -3,6 +3,7 @@ package com.ashotn.opencode.relay.core import com.ashotn.opencode.relay.OpenCodePlugin import com.ashotn.opencode.relay.api.session.Session import com.ashotn.opencode.relay.api.session.SessionApiClient +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.core.session.PendingSessionSelection @@ -157,7 +158,6 @@ class OpenCodeCoreService(private val project: Project) : Disposable { if (generation != lifecycleGeneration.get()) return when (event) { is OpenCodeEvent.ServerConnected -> Unit - is OpenCodeEvent.SessionDiff -> handleSessionDiff(event, fromHistory = false, generation = generation) is OpenCodeEvent.SessionStatus -> { applySessionStatus(event.sessionId, event.status, generation) } @@ -179,39 +179,6 @@ class OpenCodeCoreService(private val project: Project) : Disposable { } } - private fun recordTurnFiles( - sessionId: String, - files: List, - generation: Long, - traceKind: String, - ): Boolean { - val projectBase = project.basePath - if (projectBase == null) { - log.debug("OpenCodeCoreService: skip turn scope reason=missingProjectBase session=$sessionId generation=$generation") - return false - } - val touchedPaths = eventReducer.reduceTurnPatchTouchedPaths(projectBase, files) - if (generation != lifecycleGeneration.get()) return false - val committed = eventReducer.commitTurnPatch( - stateStore = stateStore, - stateLock = stateLock, - sessionId = sessionId, - touchedPaths = touchedPaths, - generation = generation, - currentGeneration = { lifecycleGeneration.get() }, - ) - if (!committed) return false - trace(traceKind) { - mapOf( - "sessionId" to sessionId, - "touchedPaths" to touchedPaths.toList(), - "generation" to generation, - ) - } - log.debug("OpenCodeCoreService: turn scope recorded session=$sessionId touchedFileCount=${touchedPaths.size} generation=$generation") - return true - } - private fun handleMessageDiffAvailable(event: OpenCodeEvent.MessageDiffAvailable, generation: Long) { if (generation != lifecycleGeneration.get()) return if (!messageDiffFetchCoalescer.tryStart(event)) { @@ -257,19 +224,10 @@ class OpenCodeCoreService(private val project: Project) : Disposable { return@executeOnPooledThread } - if (!recordTurnFiles( - event.sessionId, - eventDiff.files.map { it.file }, - generation, - "message.diff.scope.committed" - ) - ) { - 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", ) - handleSessionDiff(eventDiff.copy(isMessageScoped = true), fromHistory = false, generation = generation) + handleSessionDiff(eventDiff, fromHistory = false, generation = generation) } catch (e: Exception) { log.debug( "OpenCodeCoreService: failed message diff load session=${event.sessionId} message=${event.messageId} port=$currentPort generation=$generation", @@ -332,56 +290,37 @@ class OpenCodeCoreService(private val project: Project) : Disposable { refreshSessionHierarchyAsync(generation) } - private fun handleSessionDiff(event: OpenCodeEvent.SessionDiff, fromHistory: Boolean, generation: Long) { + private fun handleSessionDiff(event: SessionDiffSnapshot, fromHistory: Boolean, generation: Long) { if (generation != lifecycleGeneration.get()) return val projectBase = project.basePath if (projectBase == null) { - log.debug("OpenCodeCoreService: skip session.diff reason=missingProjectBase session=${event.sessionId} generation=$generation") + log.debug("OpenCodeCoreService: skip diff.apply reason=missingProjectBase session=${event.sessionId} generation=$generation") return } val trackLiveApply = !fromHistory if (trackLiveApply) beginLiveSessionDiffApply(event.sessionId) - val applyDecision = eventReducer.beginSessionDiffApply( - stateStore = stateStore, + val revision = stateStore.reserveRevisionForSessionDiffApply( stateLock = stateLock, sessionId = event.sessionId, - fromHistory = fromHistory, - generation = generation, + expectedGeneration = generation, currentGeneration = { lifecycleGeneration.get() }, ) - trace("session.diff.decision", fromHistory = fromHistory) { + trace("diff.apply.decision", fromHistory = fromHistory) { mapOf( "sessionId" to event.sessionId, "fromHistory" to fromHistory, "fileCount" to event.files.size, - "shouldApply" to applyDecision.shouldApply, - "skipReason" to applyDecision.skipReason?.name, - "revision" to applyDecision.revision, - "turnScope" to applyDecision.turnScope?.toList(), + "shouldApply" to (revision != null), + "skipReason" to if (revision == null) "staleOrAlreadyLoaded" else null, + "revision" to revision, "generation" to generation, ) } - if (!applyDecision.shouldApply) { - if (trackLiveApply) finishLiveSessionDiffApply(event.sessionId) - when (applyDecision.skipReason) { - EventReducer.SessionDiffSkipReason.UNSCOPED_LIVE -> { - log.debug("OpenCodeCoreService: skip session.diff reason=unscopedLive session=${event.sessionId} generation=$generation") - } - - EventReducer.SessionDiffSkipReason.STALE_OR_ALREADY_LOADED -> { - log.debug("OpenCodeCoreService: skip session.diff reason=historyAlreadyLoaded session=${event.sessionId} generation=$generation") - } - - null -> { - } - } - return - } - val turnScope = applyDecision.turnScope - val revision = applyDecision.revision ?: run { + if (revision == null) { if (trackLiveApply) finishLiveSessionDiffApply(event.sessionId) + log.debug("OpenCodeCoreService: skip diff.apply reason=staleOrAlreadyLoaded session=${event.sessionId} generation=$generation") return } @@ -390,22 +329,13 @@ class OpenCodeCoreService(private val project: Project) : Disposable { if (generation != lifecycleGeneration.get()) return@executeOnPooledThread log.debug( - "OpenCodeCoreService: apply session.diff session=${event.sessionId} revision=$revision fileCount=${event.files.size} fromHistory=$fromHistory generation=$generation", + "OpenCodeCoreService: apply diff.apply session=${event.sessionId} revision=$revision fileCount=${event.files.size} fromHistory=$fromHistory generation=$generation", ) - val prepareSnapshot = stateStore.snapshotSessionDiffPrepareState( - stateLock = stateLock, - sessionId = event.sessionId, - expectedGeneration = generation, - currentGeneration = { lifecycleGeneration.get() }, - ) ?: return@executeOnPooledThread - val computedState = sessionDiffApplyComputer.compute( projectBase = projectBase, event = event, fromHistory = fromHistory, - turnScope = turnScope, - previousAfterByFile = prepareSnapshot.previousAfterByFile, ) if (generation != lifecycleGeneration.get()) return@executeOnPooledThread @@ -428,8 +358,8 @@ class OpenCodeCoreService(private val project: Project) : Disposable { currentGeneration = { lifecycleGeneration.get() }, ) if (commitResult == null) { - log.debug("OpenCodeCoreService: skip session.diff reason=staleApply session=${event.sessionId} revision=$revision generation=$generation") - trace("session.diff.commitSkipped", fromHistory = fromHistory) { + log.debug("OpenCodeCoreService: skip diff.apply reason=staleApply session=${event.sessionId} revision=$revision generation=$generation") + trace("diff.apply.commitSkipped", fromHistory = fromHistory) { mapOf( "sessionId" to event.sessionId, "revision" to revision, @@ -457,7 +387,7 @@ class OpenCodeCoreService(private val project: Project) : Disposable { val filesToRefresh = changedFiles + previousLiveVisibleFiles + nextLiveVisibleFiles log.debug( - "OpenCodeCoreService: applied session.diff session=${event.sessionId} revision=$revision trackedFileCount=${computedState.newHunksByFile.size} deletedFileCount=${computedState.newDeleted.size} addedFileCount=${computedState.newAdded.size} changedFileCount=${changedFiles.size} refreshedFileCount=${filesToRefresh.size} generation=$generation", + "OpenCodeCoreService: applied diff.apply session=${event.sessionId} revision=$revision trackedFileCount=${computedState.newHunksByFile.size} deletedFileCount=${computedState.newDeleted.size} addedFileCount=${computedState.newAdded.size} changedFileCount=${changedFiles.size} refreshedFileCount=${filesToRefresh.size} generation=$generation", ) if (generation != lifecycleGeneration.get()) return@executeOnPooledThread @@ -572,16 +502,13 @@ class OpenCodeCoreService(private val project: Project) : Disposable { fromHistory: Boolean, changedFiles: Set, ) { - trace("session.diff.committed", fromHistory = fromHistory) { + trace("diff.apply.committed", fromHistory = fromHistory) { // Only record file names and byte-length summaries — never store full file content // in a trace field, as that causes unbounded memory growth during active sessions. val stateSnapshot = synchronized(stateLock) { val baselineSizes = stateStore.baselineBeforeBySessionAndFile[sessionId] ?.mapValues { (_, v) -> v.length } ?: emptyMap() - val lastAfterSizes = stateStore.lastAfterBySessionAndFile[sessionId] - ?.mapValues { (_, v) -> v.length } - ?: emptyMap() mapOf( "hunkFiles" to (stateStore.hunksBySessionAndFile[sessionId]?.keys?.sorted() ?: emptyList()), "liveHunkFiles" to (stateStore.liveHunksBySessionAndFile[sessionId]?.keys?.sorted() @@ -589,7 +516,6 @@ class OpenCodeCoreService(private val project: Project) : Disposable { "deletedFiles" to (stateStore.deletedBySession[sessionId]?.sorted() ?: emptyList()), "addedFiles" to (stateStore.addedBySession[sessionId]?.sorted() ?: emptyList()), "baselineBeforeSizeByFile" to baselineSizes, - "lastAfterSizeByFile" to lastAfterSizes, ) } mapOf( @@ -729,8 +655,6 @@ class OpenCodeCoreService(private val project: Project) : Disposable { stateStore.deletedBySession.remove(sessionId) stateStore.addedBySession.remove(sessionId) stateStore.baselineBeforeBySessionAndFile.remove(sessionId) - stateStore.lastAfterBySessionAndFile.remove(sessionId) - stateStore.pendingTurnFilesBySession.remove(sessionId) stateStore.messageSummaryFileCountBySession.remove(sessionId) stateStore.messageSummaryFileCountUpdatedAtBySession.remove(sessionId) historicalDiffLoadedSessions.remove(sessionId) diff --git a/src/main/kotlin/com/ashotn/opencode/relay/core/SessionDiffApplyComputer.kt b/src/main/kotlin/com/ashotn/opencode/relay/core/SessionDiffApplyComputer.kt index faaeaaa..3bd7b1c 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/SessionDiffApplyComputer.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/SessionDiffApplyComputer.kt @@ -1,7 +1,7 @@ package com.ashotn.opencode.relay.core import com.ashotn.opencode.relay.api.session.FileDiff -import com.ashotn.opencode.relay.ipc.OpenCodeEvent +import com.ashotn.opencode.relay.api.session.SessionDiffSnapshot import com.ashotn.opencode.relay.ipc.SessionDiffStatus import com.ashotn.opencode.relay.util.TextUtil import com.ashotn.opencode.relay.util.createPathIdentityMap @@ -18,38 +18,20 @@ internal class SessionDiffApplyComputer( ) { fun compute( projectBase: String, - event: OpenCodeEvent.SessionDiff, + event: SessionDiffSnapshot, fromHistory: Boolean, - turnScope: Set?, - previousAfterByFile: Map, ): StateStore.SessionDiffComputedState { val newHunksByFile = createPathIdentityMap>(projectBase) val newDeleted = createPathIdentitySet(projectBase) val newAdded = createPathIdentitySet(projectBase) val newBaselineByFile = createPathIdentityMap(projectBase) val processedPaths = createPathIdentitySet(projectBase) - val previousAfterByPath = createPathIdentityMap(projectBase).apply { putAll(previousAfterByFile) } - val nextAfterByFile = createPathIdentityMap(projectBase).apply { putAll(previousAfterByFile) } - var outOfScopeCount = 0 var baselineMatchCount = 0 var zeroHunkCount = 0 val analyses = if (tracer.enabled) mutableListOf>() else null for (diffFile in event.files) { val absPath = toAbsolutePath(projectBase, diffFile.file) - - if (!fromHistory && turnScope != null && absPath !in turnScope) { - outOfScopeCount += 1 - analyses?.add( - mapOf( - "absPath" to absPath, - "status" to diffFile.status.name, - "inScope" to false, - ), - ) - continue - } - processedPaths.add(absPath) if (!fromHistory) { @@ -57,25 +39,17 @@ internal class SessionDiffApplyComputer( } val actualAfter = contentReader(absPath) - val effectiveBefore = if (fromHistory || event.isMessageScoped) { - diffFile.before - } else { - previousAfterByPath[absPath] ?: diffFile.before - } + val effectiveBefore = diffFile.before val hasContentChange = TextUtil.normalizeContent(effectiveBefore) != TextUtil.normalizeContent(actualAfter) - nextAfterByFile[absPath] = actualAfter - if (!hasContentChange) { baselineMatchCount += 1 analyses?.add( mapOf( "absPath" to absPath, "status" to diffFile.status.name, - "inScope" to true, - "previousAfterSize" to previousAfterByPath[absPath]?.length, "effectiveBeforeSize" to effectiveBefore.length, "actualAfterSize" to actualAfter.length, "contentChanged" to false, @@ -101,8 +75,6 @@ internal class SessionDiffApplyComputer( mapOf( "absPath" to absPath, "status" to diffFile.status.name, - "inScope" to true, - "previousAfterSize" to previousAfterByPath[absPath]?.length, "effectiveBeforeSize" to effectiveBefore.length, "actualAfterSize" to actualAfter.length, "contentChanged" to true, @@ -127,8 +99,6 @@ internal class SessionDiffApplyComputer( mapOf( "absPath" to absPath, "status" to diffFile.status.name, - "inScope" to true, - "previousAfterSize" to previousAfterByPath[absPath]?.length, "effectiveBeforeSize" to effectiveBefore.length, "actualAfterSize" to actualAfter.length, "contentChanged" to true, @@ -137,41 +107,19 @@ internal class SessionDiffApplyComputer( ) } - // Any file that was in turnScope but absent from the server diff has been resolved back to - // baseline. Mark it as processed (with no hunks) so that mergeMapByProcessedPaths evicts - // the stale state rather than preserving it. - if (!fromHistory && turnScope != null) { - for (scopedPath in turnScope) { - if (scopedPath !in processedPaths) { - processedPaths.add(scopedPath) - nextAfterByFile[scopedPath] = contentReader(scopedPath) - analyses?.add( - mapOf( - "absPath" to scopedPath, - "status" to "CLEARED", - "inScope" to true, - "absentFromServerDiff" to true, - ), - ) - } - } - } - log.debug( - "SessionDiffApplyComputer: compute session.diff session=${event.sessionId} fileCount=${event.files.size} emittedFileCount=${newHunksByFile.size} baselineMatchedFileCount=$baselineMatchCount zeroHunkFileCount=$zeroHunkCount outOfScopeFileCount=$outOfScopeCount fromHistory=$fromHistory", + "SessionDiffApplyComputer: compute diff.apply session=${event.sessionId} fileCount=${event.files.size} emittedFileCount=${newHunksByFile.size} baselineMatchedFileCount=$baselineMatchCount zeroHunkFileCount=$zeroHunkCount fromHistory=$fromHistory", ) if (tracer.enabled && (!fromHistory || tracer.includeHistory)) { tracer.record( - kind = "session.diff.compute", + kind = "diff.apply.compute", fields = mapOf( "sessionId" to event.sessionId, "fromHistory" to fromHistory, - "turnScope" to turnScope?.toList(), "inputFileCount" to event.files.size, "emittedFileCount" to newHunksByFile.size, "baselineMatchCount" to baselineMatchCount, "zeroHunkCount" to zeroHunkCount, - "outOfScopeCount" to outOfScopeCount, "analyses" to (analyses ?: emptyList()), ), ) @@ -179,7 +127,6 @@ internal class SessionDiffApplyComputer( return StateStore.SessionDiffComputedState( projectBase = projectBase, - nextAfterByFile = nextAfterByFile, processedPaths = processedPaths, newHunksByFile = newHunksByFile, newDeleted = newDeleted, diff --git a/src/main/kotlin/com/ashotn/opencode/relay/core/StateStore.kt b/src/main/kotlin/com/ashotn/opencode/relay/core/StateStore.kt index f15fe16..10d1278 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/core/StateStore.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/core/StateStore.kt @@ -7,7 +7,6 @@ import java.util.concurrent.ConcurrentHashMap internal class StateStore { data class SessionDiffComputedState( val projectBase: String, - val nextAfterByFile: MutableMap, val processedPaths: Set, // Paths touched by the current apply pass. val newHunksByFile: Map>, val newDeleted: Set, @@ -31,8 +30,6 @@ internal class StateStore { val deletedBySession = ConcurrentHashMap>() val addedBySession = ConcurrentHashMap>() val baselineBeforeBySessionAndFile = ConcurrentHashMap>() - val lastAfterBySessionAndFile = ConcurrentHashMap>() - val pendingTurnFilesBySession = ConcurrentHashMap>() val messageSummaryFileCountBySession = ConcurrentHashMap() val messageSummaryFileCountUpdatedAtBySession = ConcurrentHashMap() @@ -60,10 +57,6 @@ internal class StateStore { val currentBaselines: Map, ) - data class SessionDiffPrepareSnapshot( - val previousAfterByFile: Map, - ) - private data class SessionStateSnapshot( val hunks: Map>, val liveHunks: Map>, @@ -76,20 +69,6 @@ internal class StateStore { diffApplyRevisionBySession[sessionId] ?: 0L } - fun snapshotSessionDiffPrepareState( - stateLock: Any, - sessionId: String, - expectedGeneration: Long, - currentGeneration: () -> Long, - ): SessionDiffPrepareSnapshot? = synchronized(stateLock) { - if (expectedGeneration != currentGeneration()) { - return@synchronized null - } - SessionDiffPrepareSnapshot( - previousAfterByFile = (lastAfterBySessionAndFile[sessionId] ?: emptyMap()).toMap(), - ) - } - fun snapshotSessionReconcileState( stateLock: Any, sessionId: String, @@ -110,34 +89,6 @@ internal class StateStore { ) } - fun commitTurnPatch( - stateLock: Any, - sessionId: String, - touchedPaths: Set, - expectedGeneration: Long, - currentGeneration: () -> Long, - ): Boolean = synchronized(stateLock) { - if (expectedGeneration != currentGeneration()) { - return@synchronized false - } - pendingTurnFilesBySession[sessionId] = touchedPaths - true - } - - fun consumeTurnScopeForDiff( - stateLock: Any, - sessionId: String, - fromHistory: Boolean, - expectedGeneration: Long, - currentGeneration: () -> Long, - ): Set? = synchronized(stateLock) { - if (expectedGeneration != currentGeneration()) { - return@synchronized null - } - if (fromHistory) return@synchronized null - pendingTurnFilesBySession.remove(sessionId) - } - fun commitSessionBusy( stateLock: Any, sessionId: String, @@ -198,7 +149,6 @@ internal class StateStore { return@synchronized null } - lastAfterBySessionAndFile[sessionId] = computedState.nextAfterByFile val previousState = SessionStateSnapshot( hunks = hunksBySessionAndFile[sessionId] ?: emptyMap(), liveHunks = liveHunksBySessionAndFile[sessionId] ?: emptyMap(), @@ -340,8 +290,6 @@ internal class StateStore { deletedBySession.clear() addedBySession.clear() baselineBeforeBySessionAndFile.clear() - lastAfterBySessionAndFile.clear() - pendingTurnFilesBySession.clear() messageSummaryFileCountBySession.clear() messageSummaryFileCountUpdatedAtBySession.clear() diffApplyRevisionBySession.clear() 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 6115ad1..998ff94 100644 --- a/src/main/kotlin/com/ashotn/opencode/relay/ipc/OpenCodeEvent.kt +++ b/src/main/kotlin/com/ashotn/opencode/relay/ipc/OpenCodeEvent.kt @@ -46,27 +46,6 @@ sealed class OpenCodeEvent { val reply: PermissionReply, ) : OpenCodeEvent() - /** - * Diff snapshot fetched from the OpenCode HTTP API. The payload contains - * patch-based entries; the plugin reconstructs before/after text and stores - * a typed [SessionDiffStatus]. - */ - data class SessionDiff( - val sessionId: String, - val files: List, - /** OpenCode >= 1.16 diff fetched with messageID; use server `before` as the live turn baseline. */ - val isMessageScoped: Boolean = false, - ) : OpenCodeEvent() - - data class SessionDiffFile( - val file: String, // project-relative path - val before: String, - val after: String, - val additions: Int, - val deletions: Int, - val status: SessionDiffStatus, - ) - /** * 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. 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 85a55c6..4282dc2 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 @@ -3,7 +3,6 @@ package com.ashotn.opencode.relay.api.session import com.ashotn.opencode.relay.api.transport.ApiError import com.ashotn.opencode.relay.api.transport.ApiResult import com.ashotn.opencode.relay.api.withTestServer -import com.ashotn.opencode.relay.ipc.OpenCodeEvent import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -66,7 +65,7 @@ class SessionApiClientTest { val client = SessionApiClient() val result = client.fetchSessionDiffSnapshot(port, "ses_1", "msg_1") - val success = assertIs>(result) + val success = assertIs>(result) assertEquals("ses_1", success.value.sessionId) assertEquals(1, success.value.files.size) assertEquals("a.txt", success.value.files.first().file) @@ -76,7 +75,7 @@ class SessionApiClientTest { } @Test - fun `fetchSessionDiffSnapshot returns empty event for empty body`() { + fun `fetchSessionDiffSnapshot returns empty snapshot for empty body`() { withTestServer { server, port -> server.createContext("/session/ses_1/diff") { exchange -> exchange.sendResponseHeaders(204, -1) @@ -86,7 +85,7 @@ class SessionApiClientTest { val client = SessionApiClient() val result = client.fetchSessionDiffSnapshot(port, "ses_1", "msg_1") - val success = assertIs>(result) + val success = assertIs>(result) assertEquals("ses_1", success.value.sessionId) assertEquals(0, success.value.files.size) } 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 6f47dd6..b949679 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt @@ -1,8 +1,9 @@ package com.ashotn.opencode.relay.core import com.ashotn.opencode.relay.api.session.Session +import com.ashotn.opencode.relay.api.session.SessionDiffFile +import com.ashotn.opencode.relay.api.session.SessionDiffSnapshot import com.ashotn.opencode.relay.api.session.SessionTime -import com.ashotn.opencode.relay.ipc.OpenCodeEvent import com.ashotn.opencode.relay.ipc.SessionDiffStatus import org.junit.Test import kotlin.test.assertEquals @@ -12,7 +13,7 @@ import kotlin.test.assertTrue * End-to-end tests for the diff pipeline using [DiffPipelineHarness]. * * Each test covers a distinct behaviour or regression. The harness simulates the full - * pipeline (turn.patch → session.diff → state commit) without requiring the IntelliJ + * pipeline (message-scoped diff → state commit) without requiring the IntelliJ * platform — disk content is a plain in-memory map and hunk computation is a fake. */ class SessionDiffPipelineTest { @@ -32,7 +33,6 @@ class SessionDiffPipelineTest { fun `baseline matching added file with no content should not count toward trackedFileCount`() { val file = "note.md" h.disk[h.abs(file)] = "" - h.commitTurnPatch(listOf(file)) h.applySessionDiff(listOf(file to SessionDiffStatus.ADDED)) assertTrue(h.addedFiles().isEmpty(), "baseline-matching file should not be in addedFiles") @@ -57,13 +57,11 @@ class SessionDiffPipelineTest { // Turn 1: file created empty — no hunk expected h.disk[h.abs(file)] = "" - h.commitTurnPatch(listOf(file)) h.applySessionDiff(listOf(file to SessionDiffStatus.ADDED)) assertTrue(h.hunksFor(file).isEmpty(), "turn 1: no hunk for empty file") // Turn 2: content written — hunk must have no removedLines h.disk[h.abs(file)] = "1" - h.commitTurnPatch(listOf(file)) h.applySessionDiff(listOf(file to SessionDiffStatus.ADDED)) val hunks = h.hunksFor(file) @@ -117,7 +115,6 @@ class SessionDiffPipelineTest { ) zeroHunkHarness.disk[zeroHunkHarness.abs(file)] = "line1\n" - zeroHunkHarness.commitTurnPatch(listOf(file)) zeroHunkHarness.applySessionDiff( files = listOf(file to SessionDiffStatus.MODIFIED), ) @@ -152,7 +149,6 @@ class SessionDiffPipelineTest { // AI writes content to the previously empty file h.disk[h.abs(file)] = aiContent - h.commitTurnPatch(listOf(file)) h.applySessionDiff( files = listOf(file to SessionDiffStatus.ADDED), ) @@ -190,7 +186,7 @@ class SessionDiffPipelineTest { h.disk[h.abs(file)] = original h.applyHistoricalSessionDiffFiles( listOf( - OpenCodeEvent.SessionDiffFile( + SessionDiffFile( file = h.abs(file), before = original, after = aiContent, @@ -217,12 +213,32 @@ class SessionDiffPipelineTest { val aiContent = "hello\n" h.disk[h.abs(file)] = aiContent - h.commitTurnPatch(listOf(file)) - h.applySessionDiff(listOf(file to SessionDiffStatus.MODIFIED)) + h.applySessionDiffFiles( + listOf( + SessionDiffFile( + file = h.abs(file), + before = "", + after = aiContent, + additions = 1, + deletions = 0, + status = SessionDiffStatus.MODIFIED, + ) + ) + ) h.disk[h.abs(file)] = "" - h.commitTurnPatch(listOf(file)) - h.applySessionDiff(listOf(file to SessionDiffStatus.MODIFIED)) + h.applySessionDiffFiles( + listOf( + SessionDiffFile( + file = h.abs(file), + before = aiContent, + after = "", + additions = 0, + deletions = 1, + status = SessionDiffStatus.MODIFIED, + ) + ) + ) val hunks = h.hunksFor(file) assertEquals(1, hunks.size, "later AI deletion should produce a deletion hunk") @@ -251,16 +267,14 @@ class SessionDiffPipelineTest { // Initial AI turn makes the file visible in the session list. h.disk[h.abs(file)] = aiContent - h.commitTurnPatch(listOf(file)) h.applySessionDiff(listOf(file to SessionDiffStatus.MODIFIED)) assertEquals(1, h.trackedFileCount(), "file should be tracked after the first AI edit") // A later live message diff reports the same file, but local disk already // matches the latest AI content known to the plugin. - h.commitTurnPatch(listOf(file)) h.applySessionDiffFiles( listOf( - OpenCodeEvent.SessionDiffFile( + SessionDiffFile( file = h.abs(file), before = original, after = aiContent, @@ -269,7 +283,6 @@ class SessionDiffPipelineTest { status = SessionDiffStatus.MODIFIED, ) ), - isMessageScoped = true, ) assertEquals( @@ -298,7 +311,6 @@ class SessionDiffPipelineTest { // Simulate a live turn that produced inline highlights before restart. h.disk[h.abs(file)] = "line1\n" - h.commitTurnPatch(listOf(file)) h.applySessionDiff(listOf(file to SessionDiffStatus.MODIFIED)) assertEquals(setOf(h.abs(file)), h.liveHunkFiles(), "pre-restart: live highlights present") @@ -313,12 +325,6 @@ class SessionDiffPipelineTest { expectedGeneration = h.generation, currentGeneration = { h.generation }, )!! - val prepareSnapshot = freshStore.snapshotSessionDiffPrepareState( - stateLock = freshLock, - sessionId = h.sessionId, - expectedGeneration = h.generation, - currentGeneration = { h.generation }, - )!! val computer = SessionDiffApplyComputer( contentReader = { absPath -> h.disk[absPath] ?: "" }, hunkComputer = { fileDiff, sid -> @@ -328,10 +334,10 @@ class SessionDiffPipelineTest { log = NoOpLogger, tracer = NoOpDiffTracer, ) - val event = OpenCodeEvent.SessionDiff( + val event = SessionDiffSnapshot( sessionId = h.sessionId, files = listOf( - OpenCodeEvent.SessionDiffFile( + SessionDiffFile( file = h.abs(file), before = "", after = "line1\n", @@ -345,8 +351,6 @@ class SessionDiffPipelineTest { projectBase = h.projectBase, event = event, fromHistory = true, - turnScope = null, - previousAfterByFile = prepareSnapshot.previousAfterByFile, ) freshStore.commitSessionDiffApply( stateLock = freshLock, @@ -393,16 +397,13 @@ class SessionDiffPipelineTest { // Turn 1: touch note1.md h.disk[h.abs(file1)] = "line1\n" - h.commitTurnPatch(listOf(file1)) h.applySessionDiff(listOf(file1 to SessionDiffStatus.MODIFIED)) assertEquals(setOf(h.abs(file1)), h.liveHunkFiles(), "after turn 1: only note1.md should be live") - // Turn 2: touch note2.md only — server diff now reports both files cumulatively + // Turn 2: message diff reports note2.md only. h.disk[h.abs(file2)] = "line2\n" - h.commitTurnPatch(listOf(file2)) h.applySessionDiff( listOf( - file1 to SessionDiffStatus.MODIFIED, file2 to SessionDiffStatus.MODIFIED, ) ) @@ -411,14 +412,12 @@ class SessionDiffPipelineTest { h.liveHunkFiles(), "after turn 2: only note2.md should be live — note1.md was not touched this turn", ) + assertEquals(2, h.trackedFileCount(), "after turn 2: cumulative file state should retain note1.md") - // Turn 3: touch note3.md only — server diff now reports all three files cumulatively + // Turn 3: message diff reports note3.md only. h.disk[h.abs(file3)] = "line3\n" - h.commitTurnPatch(listOf(file3)) h.applySessionDiff( listOf( - file1 to SessionDiffStatus.MODIFIED, - file2 to SessionDiffStatus.MODIFIED, file3 to SessionDiffStatus.ADDED, ) ) @@ -427,6 +426,7 @@ class SessionDiffPipelineTest { h.liveHunkFiles(), "after turn 3: only note3.md should be live — note1.md and note2.md were not touched this turn", ) + assertEquals(3, h.trackedFileCount(), "after turn 3: cumulative file state should retain older files") } // ------------------------------------------------------------------------- @@ -438,13 +438,12 @@ class SessionDiffPipelineTest { fun `historical diff apply preserves existing live hunks`() { val file = "note.md" h.disk[h.abs(file)] = "line1\n" - h.commitTurnPatch(listOf(file)) h.applySessionDiff(listOf(file to SessionDiffStatus.MODIFIED)) assertEquals(setOf(h.abs(file)), h.liveHunkFiles(), "live turn should create inline hunks") h.applyHistoricalSessionDiffFiles( listOf( - OpenCodeEvent.SessionDiffFile( + SessionDiffFile( file = h.abs(file), before = "", after = "line1\n", @@ -506,10 +505,10 @@ class SessionDiffPipelineTest { harness.applyLiveMessageDiff( sessionId = childSessionId, - diff = OpenCodeEvent.SessionDiff( + diff = SessionDiffSnapshot( sessionId = childSessionId, files = listOf( - OpenCodeEvent.SessionDiffFile( + SessionDiffFile( file = file, before = "", after = content, @@ -559,9 +558,8 @@ class SessionDiffPipelineTest { } // ------------------------------------------------------------------------- - // Each turn's diff must be computed relative to the file content at the - // start of that turn, not the original file. The pipeline advances the - // baseline (effectiveBefore) to lastAfter after each turn. If it does not, + // Each turn's diff must be computed relative to the server-provided file + // content at the start of that turn, not the original file. If it does not, // lines changed in earlier turns will appear highlighted in later turns. // // MANUAL VERIFICATION: @@ -574,13 +572,33 @@ class SessionDiffPipelineTest { val file = "note.md" h.disk[h.abs(file)] = "line1\n" - h.commitTurnPatch(listOf(file)) - h.applySessionDiff(listOf(file to SessionDiffStatus.MODIFIED)) + h.applySessionDiffFiles( + listOf( + SessionDiffFile( + file = h.abs(file), + before = "", + after = "line1\n", + additions = 1, + deletions = 0, + status = SessionDiffStatus.MODIFIED, + ) + ) + ) assertEquals("", h.baseline(file), "turn 1 baseline should be empty (file was new)") h.disk[h.abs(file)] = "line1\nline2\n" - h.commitTurnPatch(listOf(file)) - h.applySessionDiff(listOf(file to SessionDiffStatus.MODIFIED)) + h.applySessionDiffFiles( + listOf( + SessionDiffFile( + file = h.abs(file), + before = "line1\n", + after = "line1\nline2\n", + additions = 1, + deletions = 0, + status = SessionDiffStatus.MODIFIED, + ) + ) + ) assertEquals("line1\n", h.baseline(file), "turn 2 baseline should be turn 1's final content") } @@ -641,16 +659,10 @@ class SessionDiffPipelineTest { expectedGeneration = generation, currentGeneration = { generation }, )!! - val prepareSnapshot = stateStore.snapshotSessionDiffPrepareState( - stateLock = stateLock, - sessionId = sessionId, - expectedGeneration = generation, - currentGeneration = { generation }, - )!! - val event = OpenCodeEvent.SessionDiff( + val event = SessionDiffSnapshot( sessionId = sessionId, files = listOf( - OpenCodeEvent.SessionDiffFile( + SessionDiffFile( file = absFile, before = serverBefore, // server's authoritative original after = currentContent, @@ -664,8 +676,6 @@ class SessionDiffPipelineTest { projectBase = projectBase, event = event, fromHistory = true, - turnScope = null, - previousAfterByFile = prepareSnapshot.previousAfterByFile, ) stateStore.commitSessionDiffApply( stateLock = stateLock, 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 b579348..9e30856 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/core/SessionOrderingTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/core/SessionOrderingTest.kt @@ -1,6 +1,8 @@ package com.ashotn.opencode.relay.core import com.ashotn.opencode.relay.api.session.Session +import com.ashotn.opencode.relay.api.session.SessionDiffFile +import com.ashotn.opencode.relay.api.session.SessionDiffSnapshot import com.ashotn.opencode.relay.api.session.SessionTime import com.ashotn.opencode.relay.ipc.OpenCodeEvent import com.ashotn.opencode.relay.ipc.SessionDiffStatus @@ -112,17 +114,10 @@ class SessionOrderingTest { currentGeneration = { generation }, )!! - val prepareSnapshot = store.snapshotSessionDiffPrepareState( - stateLock = stateLock, - sessionId = sid, - expectedGeneration = generation, - currentGeneration = { generation }, - )!! - - val event = OpenCodeEvent.SessionDiff( + val event = SessionDiffSnapshot( sessionId = sid, files = listOf( - OpenCodeEvent.SessionDiffFile( + SessionDiffFile( file = absPath, before = "", after = "ai content", @@ -137,8 +132,6 @@ class SessionOrderingTest { projectBase = projectBase, event = event, fromHistory = true, - turnScope = null, - previousAfterByFile = prepareSnapshot.previousAfterByFile, ) store.commitSessionDiffApply( @@ -343,7 +336,7 @@ class SessionOrderingTest { // arrive after session.status idle, but it must not re-mark the session busy. // // REGRESSION: live diff commits wrote busyBySession[sessionId] = true. If the - // final idle status was already processed, a subsequent session.diff/message + // final idle status was already processed, a subsequent message diff apply // diff apply left the session list stuck showing "running...". // ------------------------------------------------------------------------- @Test @@ -389,7 +382,6 @@ class SessionOrderingTest { fromHistory = false, computedState = StateStore.SessionDiffComputedState( projectBase = projectBase, - nextAfterByFile = mutableMapOf(filePath to "Goodbye World\n"), processedPaths = setOf(filePath), newHunksByFile = mapOf( filePath to listOf( diff --git a/src/test/kotlin/com/ashotn/opencode/relay/lifecycle/ResetConnectionTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/lifecycle/ResetConnectionTest.kt index f4f795a..6c25e64 100644 --- a/src/test/kotlin/com/ashotn/opencode/relay/lifecycle/ResetConnectionTest.kt +++ b/src/test/kotlin/com/ashotn/opencode/relay/lifecycle/ResetConnectionTest.kt @@ -11,7 +11,7 @@ import kotlin.test.assertTrue * Verifies that [StateStore.resetState] — the core of the "Reset Connection" * action — clears all accumulated client-side state after real diff activity. * - * The test populates the store via the full pipeline (turn.patch → session.diff) + * The test populates the store via the full message-diff pipeline * to ensure resetState() is exercised against realistic data, not an empty store. */ class ResetConnectionTest { @@ -37,7 +37,6 @@ class ResetConnectionTest { h.disk[h.abs("src/Util.kt")] = "fun util() {}\n" h.disk[h.abs("src/New.kt")] = "" - h.commitTurnPatch(listOf("src/Main.kt", "src/Util.kt", "src/New.kt")) h.applySessionDiff( listOf( "src/Main.kt" to SessionDiffStatus.MODIFIED, @@ -46,17 +45,12 @@ class ResetConnectionTest { ) ) - // Also set a selected session and a pending turn patch to verify those are cleared + // Also set a selected session to verify scalar state is cleared. h.selectCurrentSession() - h.commitTurnPatch(listOf("src/Main.kt")) // Pre-condition: store has real data assertTrue(h.hunkFiles().isNotEmpty(), "pre-condition: hunkFiles should be populated") assertEquals(h.sessionId, h.selectedSessionId(), "pre-condition: selectedSessionId should be set") - assertTrue( - h.hasPendingTurnFiles(), - "pre-condition: pendingTurnFilesBySession should be populated" - ) // Act: reset (mirrors what stopListening() calls internally) h.resetState() @@ -85,7 +79,6 @@ class ResetConnectionTest { // First round of activity h.disk[h.abs("note.md")] = "original\n" - h.commitTurnPatch(listOf("note.md")) h.applySessionDiff(listOf("note.md" to SessionDiffStatus.MODIFIED)) assertEquals(setOf(h.abs("note.md")), h.hunkFiles(), "pre-reset: file should be tracked") @@ -95,7 +88,6 @@ class ResetConnectionTest { // Second round of activity after reset — must work as if starting fresh h.disk[h.abs("note.md")] = "new content\n" - h.commitTurnPatch(listOf("note.md")) val result = h.applySessionDiff(listOf("note.md" to SessionDiffStatus.MODIFIED)) assertEquals(setOf(h.abs("note.md")), h.hunkFiles(), "post-reset activity: file should be tracked again") diff --git a/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/CoreDiffStateHarness.kt b/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/CoreDiffStateHarness.kt index 14d1a9d..6b2a96b 100644 --- a/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/CoreDiffStateHarness.kt +++ b/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/CoreDiffStateHarness.kt @@ -1,9 +1,8 @@ package com.ashotn.opencode.relay.core import com.ashotn.opencode.relay.api.session.Session +import com.ashotn.opencode.relay.api.session.SessionDiffSnapshot import com.ashotn.opencode.relay.core.session.SessionScopeResolver -import com.ashotn.opencode.relay.ipc.OpenCodeEvent -import com.ashotn.opencode.relay.util.toAbsolutePath /** * JVM-only harness for applying real OpenCode diff payloads to the plugin's core @@ -47,47 +46,26 @@ class CoreDiffStateHarness( fun applyLiveMessageDiff( sessionId: String, - diff: OpenCodeEvent.SessionDiff, + diff: SessionDiffSnapshot, readContent: (String) -> String, ) { contentReader = readContent - val touchedPaths = eventReducer.reduceTurnPatchTouchedPaths( - projectBase = projectBase, - files = diff.files.map { it.file }, - ) - stateStore.commitTurnPatch( + val revision = stateStore.reserveRevisionForSessionDiffApply( stateLock = stateLock, sessionId = sessionId, - touchedPaths = touchedPaths, expectedGeneration = generation, currentGeneration = { generation }, ) - val decision = eventReducer.beginSessionDiffApply( - stateStore = stateStore, - stateLock = stateLock, - sessionId = sessionId, - fromHistory = false, - generation = generation, - currentGeneration = { generation }, - ) - check(decision.shouldApply) { "core reducer skipped live diff for $sessionId: ${decision.skipReason}" } - val prepareSnapshot = stateStore.snapshotSessionDiffPrepareState( - stateLock = stateLock, - sessionId = sessionId, - expectedGeneration = generation, - currentGeneration = { generation }, - ) ?: error("missing prepare snapshot for $sessionId") + check(revision != null) { "core state skipped live diff for $sessionId" } val computedState = computer.compute( projectBase = projectBase, - event = diff.copy(isMessageScoped = true), + event = diff, fromHistory = false, - turnScope = decision.turnScope, - previousAfterByFile = prepareSnapshot.previousAfterByFile, ) val commitResult = stateStore.commitSessionDiffApply( stateLock = stateLock, sessionId = sessionId, - revision = decision.revision ?: error("missing revision for $sessionId"), + revision = revision, fromHistory = false, computedState = computedState, nowMillis = System.currentTimeMillis(), @@ -143,7 +121,5 @@ class CoreDiffStateHarness( ) } - fun absPath(file: String): String = toAbsolutePath(projectBase, file) - private fun contentLines(content: String): List = if (content.isEmpty()) emptyList() else content.lines() } diff --git a/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/DiffPipelineHarness.kt b/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/DiffPipelineHarness.kt index 6a04f5d..2a6f8cf 100644 --- a/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/DiffPipelineHarness.kt +++ b/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/DiffPipelineHarness.kt @@ -1,9 +1,9 @@ package com.ashotn.opencode.relay.core import com.ashotn.opencode.relay.api.session.FileDiff -import com.ashotn.opencode.relay.ipc.OpenCodeEvent +import com.ashotn.opencode.relay.api.session.SessionDiffFile +import com.ashotn.opencode.relay.api.session.SessionDiffSnapshot import com.ashotn.opencode.relay.ipc.SessionDiffStatus -import com.ashotn.opencode.relay.util.TextUtil import com.ashotn.opencode.relay.util.createPathIdentityMap import com.ashotn.opencode.relay.util.toProjectRelativePath import com.intellij.openapi.diagnostic.Logger @@ -15,7 +15,6 @@ import com.intellij.openapi.diagnostic.Logger * ``` * val h = DiffPipelineHarness() * h.disk[h.abs("note.md")] = "content\n" - * h.commitTurnPatch(listOf("note.md")) * h.applySessionDiff(listOf("note.md" to SessionDiffStatus.MODIFIED)) * ``` */ @@ -57,22 +56,11 @@ class DiffPipelineHarness( /** Converts a relative path to an absolute path under [projectBase]. */ fun abs(relPath: String): String = "$projectBase/$relPath" - fun commitTurnPatch(relPaths: List) { - eventReducer.commitTurnPatch( - stateStore = stateStore, - stateLock = stateLock, - sessionId = sessionId, - touchedPaths = eventReducer.reduceTurnPatchTouchedPaths(projectBase, relPaths), - generation = generation, - currentGeneration = { generation }, - ) - } - fun applySessionDiff( files: List>, ): ApplySessionDiffResult? { val eventFiles = files.map { (relPath, status) -> - OpenCodeEvent.SessionDiffFile( + SessionDiffFile( file = abs(relPath), before = "", after = "", @@ -85,58 +73,42 @@ class DiffPipelineHarness( } fun applySessionDiffFiles( - files: List, - isMessageScoped: Boolean = false, + files: List, ): ApplySessionDiffResult? { - return applySessionDiffFiles(files, fromHistory = false, isMessageScoped = isMessageScoped) + return applySessionDiffFiles(files, fromHistory = false) } fun applyHistoricalSessionDiffFiles( - files: List, + files: List, ): ApplySessionDiffResult? { - return applySessionDiffFiles(files, fromHistory = true, isMessageScoped = false) + return applySessionDiffFiles(files, fromHistory = true) } private fun applySessionDiffFiles( - files: List, + files: List, fromHistory: Boolean, - isMessageScoped: Boolean, ): ApplySessionDiffResult? { - val decision = eventReducer.beginSessionDiffApply( - stateStore = stateStore, - stateLock = stateLock, - sessionId = sessionId, - fromHistory = fromHistory, - generation = generation, - currentGeneration = { generation }, - ) - if (!decision.shouldApply) return null - val revision = decision.revision!! - val turnScope = decision.turnScope - - val prepareSnapshot = stateStore.snapshotSessionDiffPrepareState( + val revision = stateStore.reserveRevisionForSessionDiffApply( stateLock = stateLock, sessionId = sessionId, expectedGeneration = generation, currentGeneration = { generation }, - ) ?: return null + ) + if (revision == null) return null - val event = OpenCodeEvent.SessionDiff( + val event = SessionDiffSnapshot( sessionId = sessionId, files = files.map { diffFile -> diffFile.copy( file = diffFile.file.toProjectRelativePath(projectBase), ) }, - isMessageScoped = isMessageScoped, ) val computedState = computer.compute( projectBase = projectBase, event = event, fromHistory = fromHistory, - turnScope = turnScope, - previousAfterByFile = prepareSnapshot.previousAfterByFile, ) return stateStore.commitSessionDiffApply( @@ -184,8 +156,6 @@ class DiffPipelineHarness( fun selectedSessionId(): String? = stateStore.selectedSessionId - fun hasPendingTurnFiles(): Boolean = stateStore.pendingTurnFilesBySession.isNotEmpty() - fun resetState() { stateStore.resetState() } @@ -230,5 +200,3 @@ object NoOpLogger : Logger() { override fun warn(message: String, t: Throwable?) = Unit override fun error(message: String, t: Throwable?, vararg details: String) = Unit } - -fun normalizeTestContent(content: String): String = TextUtil.normalizeContent(content) From 71082733372608b5f9b45930f913f4701593be93 Mon Sep 17 00:00:00 2001 From: Ashot Nazaryan Date: Thu, 11 Jun 2026 15:02:26 -0700 Subject: [PATCH 4/4] chore: update OpenCode CLI requirement to 1.16.0 and note breaking change in changelog --- CHANGELOG.md | 5 +++++ README.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6817c5a..e0ceafb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +

Breaking Changes

+
    +
  • Drop support for OpenCode versions earlier than 1.16.0.
  • +
+ ## [1.4.0] - 2026-06-09

Changed

diff --git a/README.md b/README.md index d89b0cd..b99c307 100644 --- a/README.md +++ b/README.md @@ -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)