Skip to content

Commit c3fabc2

Browse files
committed
feat: add SnapshotDiffTextParser for unified patch handling in OpenCode 1.4+
1 parent 3dacd11 commit c3fabc2

6 files changed

Lines changed: 198 additions & 11 deletions

File tree

src/main/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClient.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.ashotn.opencode.relay.api.transport.withParseContext
1010
import com.ashotn.opencode.relay.util.toAbsolutePath
1111
import com.ashotn.opencode.relay.ipc.OpenCodeEvent
1212
import com.ashotn.opencode.relay.ipc.SessionDiffStatus
13+
import com.ashotn.opencode.relay.ipc.SnapshotDiffTextParser
1314
import com.ashotn.opencode.relay.util.getIntOrNull
1415
import com.ashotn.opencode.relay.util.getObjectOrNull
1516
import com.ashotn.opencode.relay.util.getStringOrNull
@@ -55,16 +56,15 @@ class SessionApiClient(
5556
val obj = element.asJsonObject
5657

5758
val file = obj.getStringOrNull("file") ?: return@mapNotNull null
58-
val before = obj.getStringOrNull("before") ?: ""
59-
val after = obj.getStringOrNull("after") ?: ""
59+
val diffText = SnapshotDiffTextParser.parse(obj)
6060
val additions = obj.getIntOrNull("additions") ?: 0
6161
val deletions = obj.getIntOrNull("deletions") ?: 0
6262
val status = SessionDiffStatus.fromWire(obj.getStringOrNull("status") ?: "unknown")
6363

6464
OpenCodeEvent.SessionDiffFile(
6565
file = file,
66-
before = before,
67-
after = after,
66+
before = diffText.before,
67+
after = diffText.after,
6868
additions = additions,
6969
deletions = deletions,
7070
status = status,
@@ -147,10 +147,11 @@ class SessionApiClient(
147147
if (!diffElement.isJsonObject) return@mapNotNull null
148148
val diffObj = diffElement.asJsonObject
149149
val file = diffObj.getStringOrNull("file") ?: return@mapNotNull null
150+
val diffText = SnapshotDiffTextParser.parse(diffObj)
150151
FileDiff(
151152
file = file,
152-
before = diffObj.getStringOrNull("before") ?: "",
153-
after = diffObj.getStringOrNull("after") ?: "",
153+
before = diffText.before,
154+
after = diffText.after,
154155
additions = diffObj.getIntOrNull("additions") ?: 0,
155156
deletions = diffObj.getIntOrNull("deletions") ?: 0,
156157
)

src/main/kotlin/com/ashotn/opencode/relay/ipc/OpenCodeEvent.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ sealed class OpenCodeEvent {
4141

4242
/**
4343
* session.diff — fired after every tool execution with the cumulative diff
44-
* for the session. Each entry carries before/after content and a typed
45-
* [SessionDiffStatus].
44+
* for the session. OpenCode 1.4.x sends patch-based entries; the plugin
45+
* reconstructs before/after snapshot text and stores a typed [SessionDiffStatus].
4646
*/
4747
data class SessionDiff(
4848
val sessionId: String,
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package com.ashotn.opencode.relay.ipc
2+
3+
import com.ashotn.opencode.relay.util.getStringOrNull
4+
import com.google.gson.JsonObject
5+
6+
internal data class SnapshotDiffText(
7+
val before: String,
8+
val after: String,
9+
)
10+
11+
internal object SnapshotDiffTextParser {
12+
fun parse(obj: JsonObject): SnapshotDiffText {
13+
// OpenCode before 1.4.0 sent full snapshot text in `before`/`after`.
14+
val before = obj.getStringOrNull("before")
15+
val after = obj.getStringOrNull("after")
16+
if (before != null || after != null) {
17+
return SnapshotDiffText(before = before ?: "", after = after ?: "")
18+
}
19+
20+
// OpenCode 1.4.0+ sends only a unified diff in `patch`, so rebuild both sides.
21+
val patch = obj.getStringOrNull("patch") ?: return SnapshotDiffText(before = "", after = "")
22+
return parseUnifiedPatch(patch)
23+
}
24+
25+
private fun parseUnifiedPatch(patch: String): SnapshotDiffText {
26+
// OpenCode emits full-file unified diffs here, not minimal-context patches.
27+
if (patch.isEmpty()) return SnapshotDiffText(before = "", after = "")
28+
29+
val before = StringBuilder()
30+
val after = StringBuilder()
31+
var inHunk = false
32+
var lastPrefix: Char? = null
33+
34+
for (rawLine in patch.split('\n')) {
35+
val line = rawLine.removeSuffix("\r")
36+
when {
37+
line.startsWith("@@ ") -> {
38+
inHunk = true
39+
lastPrefix = null
40+
}
41+
42+
!inHunk -> Unit
43+
44+
line == "\\ No newline at end of file" -> {
45+
when (lastPrefix) {
46+
' ' -> {
47+
trimTrailingNewline(before)
48+
trimTrailingNewline(after)
49+
}
50+
51+
'-' -> trimTrailingNewline(before)
52+
'+' -> trimTrailingNewline(after)
53+
}
54+
}
55+
56+
line.isEmpty() -> Unit
57+
58+
else -> {
59+
val prefix = line.first()
60+
val content = line.drop(1)
61+
when (prefix) {
62+
' ' -> appendPatchLine(before, after, content)
63+
'-' -> before.append(content).append('\n')
64+
'+' -> after.append(content).append('\n')
65+
}
66+
if (prefix == ' ' || prefix == '-' || prefix == '+') {
67+
lastPrefix = prefix
68+
}
69+
}
70+
}
71+
}
72+
73+
return SnapshotDiffText(before = before.toString(), after = after.toString())
74+
}
75+
76+
private fun appendPatchLine(before: StringBuilder, after: StringBuilder, content: String) {
77+
before.append(content).append('\n')
78+
after.append(content).append('\n')
79+
}
80+
81+
private fun trimTrailingNewline(text: StringBuilder) {
82+
if (text.isNotEmpty() && text.last() == '\n') {
83+
text.setLength(text.length - 1)
84+
}
85+
}
86+
}

src/main/kotlin/com/ashotn/opencode/relay/ipc/SseClient.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,7 @@ class SseClient(
180180
val obj = elem.asJsonObject
181181

182182
val file = obj.getStringOrNull("file") ?: return@mapNotNull null
183-
val before = obj.getStringOrNull("before") ?: ""
184-
val after = obj.getStringOrNull("after") ?: ""
183+
val diffText = SnapshotDiffTextParser.parse(obj)
185184
val additions = obj.getIntOrNull("additions") ?: 0
186185
val deletions = obj.getIntOrNull("deletions") ?: 0
187186
val statusRaw = obj.getStringOrNull("status") ?: "modified"
@@ -190,7 +189,7 @@ class SseClient(
190189
log.warn("SseClient: unknown session.diff status '$statusRaw' for file=$file")
191190
}
192191

193-
OpenCodeEvent.SessionDiffFile(file, before, after, additions, deletions, status)
192+
OpenCodeEvent.SessionDiffFile(file, diffText.before, diffText.after, additions, deletions, status)
194193
}
195194

196195
log.debug("SseClient: parsed session.diff session=$sessionId fileCount=${files.size}")

src/test/kotlin/com/ashotn/opencode/relay/api/session/SessionApiClientTest.kt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,47 @@ class SessionApiClientTest {
109109
}
110110
}
111111

112+
// -------------------------------------------------------------------------
113+
// When the user opens the 3-panel diff viewer for a file the AI changed,
114+
// the "Before AI" and "After AI" panels must show the AI's actual change
115+
// even when OpenCode returns snapshot diffs in the 1.4.0 patch-only format.
116+
// Blank side panels hide what the AI changed and make the diff viewer useless.
117+
//
118+
// MANUAL VERIFICATION:
119+
// 1. Ask the AI to modify an existing file.
120+
// 2. In the OpenCode tool window, double-click that file.
121+
// 3. The "Before AI" and "After AI" panels must both show content.
122+
// If both panels are blank while the middle panel shows the live file,
123+
// this invariant is violated.
124+
// -------------------------------------------------------------------------
125+
@Test
126+
fun `fetchFileDiffPreview reconstructs preview from patch-only snapshot diff`() {
127+
withTestServer { server, port ->
128+
server.createContext("/session/ses_1/diff") { exchange ->
129+
val body = """
130+
[
131+
{
132+
"file": "a.txt",
133+
"patch": "Index: a.txt\n===================================================================\n--- a.txt\t\n+++ a.txt\t\n@@ -1 +1 @@\n-old\n+new\n",
134+
"additions": 1,
135+
"deletions": 1,
136+
"status": "modified"
137+
}
138+
]
139+
""".trimIndent()
140+
exchange.sendResponseHeaders(200, body.toByteArray(Charsets.UTF_8).size.toLong())
141+
exchange.responseBody.use { it.write(body.toByteArray(Charsets.UTF_8)) }
142+
}
143+
144+
val client = SessionApiClient()
145+
val result = client.fetchFileDiffPreview(port, "ses_1", "/repo", "/repo/a.txt")
146+
147+
val success = assertIs<ApiResult.Success<SessionApiClient.FileDiffPreview?>>(result)
148+
assertEquals("old\n", success.value?.before)
149+
assertEquals("new\n", success.value?.after)
150+
}
151+
}
152+
112153
@Test
113154
fun `fetchFileDiffPreview returns matching diff preview`() {
114155
withTestServer { server, port ->
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.ashotn.opencode.relay.ipc
2+
3+
import com.google.gson.JsonParser
4+
import org.junit.Test
5+
import kotlin.test.assertEquals
6+
7+
class SnapshotDiffTextParserTest {
8+
9+
@Test
10+
fun `parses legacy before after snapshot diff`() {
11+
val obj = JsonParser.parseString(
12+
"""
13+
{
14+
"file": "a.txt",
15+
"before": "old",
16+
"after": "new"
17+
}
18+
""".trimIndent()
19+
).asJsonObject
20+
21+
val diffText = SnapshotDiffTextParser.parse(obj)
22+
23+
assertEquals("old", diffText.before)
24+
assertEquals("new", diffText.after)
25+
}
26+
27+
@Test
28+
fun `reconstructs snapshot text from patch only diff`() {
29+
val obj = JsonParser.parseString(
30+
"""
31+
{
32+
"file": "a.txt",
33+
"patch": "Index: a.txt\n===================================================================\n--- a.txt\t\n+++ a.txt\t\n@@ -1 +1 @@\n-old\n+new\n"
34+
}
35+
""".trimIndent()
36+
).asJsonObject
37+
38+
val diffText = SnapshotDiffTextParser.parse(obj)
39+
40+
assertEquals("old\n", diffText.before)
41+
assertEquals("new\n", diffText.after)
42+
}
43+
44+
@Test
45+
fun `preserves no newline at end of file marker`() {
46+
val obj = JsonParser.parseString(
47+
"""
48+
{
49+
"file": "a.txt",
50+
"patch": "Index: a.txt\n===================================================================\n--- a.txt\t\n+++ a.txt\t\n@@ -1 +1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n"
51+
}
52+
""".trimIndent()
53+
).asJsonObject
54+
55+
val diffText = SnapshotDiffTextParser.parse(obj)
56+
57+
assertEquals("old", diffText.before)
58+
assertEquals("new", diffText.after)
59+
}
60+
}

0 commit comments

Comments
 (0)