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)
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/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/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 b3b5d99..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,57 +84,44 @@ class SessionApiClient(
port: Int,
sessionId: String,
messageId: String? = null,
- ): ApiResult {
+ ): ApiResult {
+ if (messageId == null) {
+ return fetchSessionMessageDiffSnapshot(port, sessionId)
+ }
+
val endpoint = SessionEndpoints.diff(sessionId, messageId)
return when (val response = transport.get(port = port, path = endpoint.path)) {
is ApiResult.Failure -> response
is ApiResult.Success -> {
val body = response.value
if (body.isNullOrBlank()) {
- ApiResult.Success(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 {
- // OpenCode >= 1.16 stores diffs on message summaries instead of the session.
+ 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()))
}
}
}
@@ -199,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)
@@ -207,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,
@@ -218,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]
@@ -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
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/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..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,75 +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,
- fromHistory = fromHistory,
- 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 fccf6ce..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)
}
@@ -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)
}
@@ -185,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)) {
@@ -263,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",
@@ -338,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
}
@@ -396,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
@@ -434,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,
@@ -463,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
@@ -578,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()
@@ -595,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(
@@ -735,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