Skip to content

Commit fada753

Browse files
committed
refactor: diff preview to use server snapshot and remove stale cache test
1 parent e78bcb0 commit fada753

8 files changed

Lines changed: 33 additions & 82 deletions

File tree

.opencode/skills/writing-test-with-trace/SKILL.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,29 @@ captures the issue. The test must fail before any fix is applied.
1212

1313
# Trace files
1414

15-
Trace files are located in `/tmp/opencode-diff-traces`. List the directory first to find the most
16-
recent file (sorted by modification time). Check its line count with `wc -l` before reading. If it
17-
is small (under a few hundred lines), read it in full with `cat`. If it is large, use `grep` to
18-
filter by the `kind` values relevant to the issue, or read the tail with `tail -n N` to focus on
19-
the most recent events.
15+
Use a explore sub-agent to find the most recent trace
16+
17+
Trace files are written under Java's temp directory, not necessarily literal `/tmp`. The code uses
18+
`Path.of(System.getProperty("java.io.tmpdir"), "opencode-diff-traces")`.
19+
20+
On macOS this often resolves to a sandboxed path such as
21+
`/var/folders/.../T/opencode-diff-traces`, even though users may describe it as `/tmp`. Do not
22+
assume `/tmp/opencode-diff-traces` exists before checking how `java.io.tmpdir` was resolved for the
23+
running IDE instance.
24+
25+
If the user ran the plugin from Gradle's `Run Plugin` configuration, check the sandbox IDE log
26+
first to find the exact trace file path. In this repo that is typically
27+
`build/idea-sandbox/IU-*/log/idea.log`. Search for:
28+
29+
- `JsonlDiffTracer: tracing enabled file=`
30+
- `failed to create trace directory`
31+
- `failed to open trace file`
32+
33+
Use that log line to locate the exact JSONL trace file before reading it. Once you know the
34+
directory, list it to find the most recent file (sorted by modification time). Check its line count
35+
with `wc -l` before reading. If it is small (under a few hundred lines), read it in full with
36+
`cat`. If it is large, use `grep` to filter by the `kind` values relevant to the issue, or read the
37+
tail with `tail -n N` to focus on the most recent events.
2038

2139
Each line is a JSON object. Read events in `seq` order. Each event has a `kind`, a `sessionId`
2240
inside `fields`, and additional fields that vary by kind. Read the fields that are present and
@@ -40,6 +58,7 @@ is already available. Use what fits; build what doesn't exist yet.
4058
## Test structure
4159

4260
Each test must have:
61+
4362
1. A block comment stating the **invariant** being enforced.
4463
2. A **MANUAL VERIFICATION** section: numbered, concrete steps the user can follow in the running
4564
plugin to confirm the fix. Do not start with "Run the plugin". Example:
@@ -56,12 +75,14 @@ The comment must describe the **user-visible invariant** — what the user expec
5675
implementation mechanism that enforces it. Code changes; the user expectation does not.
5776

5877
**Wrong** (describes implementation):
78+
5979
```
6080
// Child session liveHunks are keyed by child session ID; the family query must include active
6181
// child sessions regardless of whether parentBySessionId has been populated yet.
6282
```
6383

6484
**Right** (describes user expectation):
85+
6586
```
6687
// When the AI delegates work to parallel sub-agents, every file touched by any sub-agent must
6788
// show a green highlight in the editor. All files must be visible simultaneously.

opencode.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"agent": {
1515
"explore": {
1616
"mode": "subagent",
17-
"model": "anthropic/claude-haiku-4-5"
17+
"model": "openai/gpt-5.4-mini"
1818
}
1919
}
2020
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class SessionApiClient(
2424

2525
data class FileDiffPreview(
2626
val before: String,
27-
val after: String?,
27+
val after: String,
2828
)
2929

3030
fun createSession(port: Int): ApiResult<CreatedSession> {

src/main/kotlin/com/ashotn/opencode/relay/core/OpenCodeCoreService.kt

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,6 @@ class OpenCodeCoreService(private val project: Project) : Disposable {
565565
stateStore.addedBySession.remove(sessionId)
566566
stateStore.baselineBeforeBySessionAndFile.remove(sessionId)
567567
stateStore.lastAfterBySessionAndFile.remove(sessionId)
568-
stateStore.serverAfterBySessionAndFile.remove(sessionId)
569568
stateStore.pendingTurnFilesBySession.remove(sessionId)
570569
historicalDiffLoadedSessions.remove(sessionId)
571570

@@ -656,16 +655,15 @@ class OpenCodeCoreService(private val project: Project) : Disposable {
656655
fun allTrackedFiles(): Set<String> = synchronized(stateLock) { visibleFilesLocked() }
657656

658657
fun getFileDiffPreview(filePath: String, onResult: (FileDiffPreview?) -> Unit) {
659-
val (sessionId, currentPort, storedServerAfter) = synchronized(stateLock) {
658+
val (sessionId, currentPort) = synchronized(stateLock) {
660659
val candidateSessionIds = queryService.previewCandidateSessionIds(
661660
familySessionIds = { familySessionIdsLocked() },
662661
updatedAtBySession = stateStore.updatedAtBySession,
663662
)
664663
val sid = candidateSessionIds.firstOrNull { sessionId ->
665664
stateStore.baselineBeforeBySessionAndFile[sessionId]?.containsKey(filePath) == true
666665
} ?: return onResult(null)
667-
val serverAfter = stateStore.serverAfterBySessionAndFile[sid]?.get(filePath)
668-
Triple(sid, port, serverAfter)
666+
sid to port
669667
}
670668

671669
if (currentPort <= 0) return onResult(null)
@@ -687,16 +685,12 @@ class OpenCodeCoreService(private val project: Project) : Disposable {
687685
onResult(null)
688686
return@executeOnPooledThread
689687
}
690-
691-
// Prefer the in-memory server-reported AI after (captured at SSE event time);
692-
// fall back to the REST snapshot's after field.
693-
val aiAfter = storedServerAfter ?: preview.after ?: documentSyncService.readCurrentContent(filePath)
694688
onResult(
695689
FileDiffPreview(
696690
filePath = filePath,
697691
sessionId = sessionId,
698692
before = preview.before,
699-
aiAfter = aiAfter,
693+
aiAfter = preview.after,
700694
)
701695
)
702696
}

src/main/kotlin/com/ashotn/opencode/relay/core/SessionDiffApplyComputer.kt

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ internal class SessionDiffApplyComputer(
2525
val newDeleted = HashSet<String>()
2626
val newAdded = HashSet<String>()
2727
val newBaselineByFile = HashMap<String, String>()
28-
val newServerAfterByFile = HashMap<String, String>()
2928
val processedPaths = HashSet<String>()
3029
val nextAfterByFile = previousAfterByFile.toMutableMap()
3130
var outOfScopeCount = 0
@@ -50,11 +49,6 @@ internal class SessionDiffApplyComputer(
5049

5150
processedPaths.add(absPath)
5251

53-
// Capture the server's intended "after AI" content for the diff viewer.
54-
// Always record it — including empty string — so that a turn which
55-
// intentionally empties a file clears any stale value from a prior turn.
56-
newServerAfterByFile[absPath] = diffFile.after
57-
5852
if (!fromHistory) {
5953
onFileProcessing?.invoke(absPath, diffFile.status)
6054
}
@@ -197,7 +191,6 @@ internal class SessionDiffApplyComputer(
197191
newDeleted = newDeleted,
198192
newAdded = newAdded,
199193
newBaselineByFile = newBaselineByFile,
200-
newServerAfterByFile = newServerAfterByFile,
201194
)
202195
}
203-
}
196+
}

src/main/kotlin/com/ashotn/opencode/relay/core/StateStore.kt

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ internal class StateStore {
1010
val newDeleted: Set<String>,
1111
val newAdded: Set<String>,
1212
val newBaselineByFile: Map<String, String>,
13-
val newServerAfterByFile: Map<String, String>,
1413
)
1514

1615
data class SessionDiffCommitResult(
@@ -30,9 +29,6 @@ internal class StateStore {
3029
val addedBySession = ConcurrentHashMap<String, Set<String>>()
3130
val baselineBeforeBySessionAndFile = ConcurrentHashMap<String, Map<String, String>>()
3231
val lastAfterBySessionAndFile = ConcurrentHashMap<String, MutableMap<String, String>>()
33-
34-
/** The server's intended "after AI" content per file per session, sourced from SessionDiffFile.after. */
35-
val serverAfterBySessionAndFile = ConcurrentHashMap<String, Map<String, String>>()
3632
val pendingTurnFilesBySession = ConcurrentHashMap<String, Set<String>>()
3733

3834
private val diffApplyRevisionBySession = ConcurrentHashMap<String, Long>()
@@ -202,16 +198,6 @@ internal class StateStore {
202198
}
203199

204200
lastAfterBySessionAndFile[sessionId] = computedState.nextAfterByFile
205-
206-
// Merge server-intended "after AI" content — kept for diff viewer display.
207-
val previousServerAfter = serverAfterBySessionAndFile[sessionId] ?: emptyMap()
208-
serverAfterBySessionAndFile[sessionId] = mergeMapByProcessedPaths(
209-
previous = previousServerAfter,
210-
processedPaths = computedState.processedPaths,
211-
next = computedState.newServerAfterByFile,
212-
replaceAll = fromHistory,
213-
)
214-
215201
val previousState = SessionStateSnapshot(
216202
hunks = hunksBySessionAndFile[sessionId] ?: emptyMap(),
217203
liveHunks = liveHunksBySessionAndFile[sessionId] ?: emptyMap(),
@@ -332,7 +318,6 @@ internal class StateStore {
332318
addedBySession.clear()
333319
baselineBeforeBySessionAndFile.clear()
334320
lastAfterBySessionAndFile.clear()
335-
serverAfterBySessionAndFile.clear()
336321
pendingTurnFilesBySession.clear()
337322
diffApplyRevisionBySession.clear()
338323
selectedSessionId = null

src/test/kotlin/com/ashotn/opencode/relay/core/DiffPipelineHarness.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ internal class DiffPipelineHarness(
6161

6262
fun applySessionDiff(
6363
files: List<Pair<String, SessionDiffStatus>>,
64-
serverAfterByFile: Map<String, String> = emptyMap(),
6564
): StateStore.SessionDiffCommitResult? {
6665
val decision = eventReducer.beginSessionDiffApply(
6766
stateStore = stateStore,
@@ -88,7 +87,7 @@ internal class DiffPipelineHarness(
8887
OpenCodeEvent.SessionDiffFile(
8988
file = abs(relPath),
9089
before = "",
91-
after = serverAfterByFile[abs(relPath)] ?: "",
90+
after = "",
9291
additions = 0,
9392
deletions = 0,
9493
status = status,
@@ -141,7 +140,4 @@ internal class DiffPipelineHarness(
141140

142141
fun baseline(relPath: String): String? =
143142
stateStore.baselineBeforeBySessionAndFile[sessionId]?.get(abs(relPath))
144-
145-
fun serverAfter(relPath: String): String? =
146-
stateStore.serverAfterBySessionAndFile[sessionId]?.get(abs(relPath))
147143
}

src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -317,42 +317,6 @@ class SessionDiffPipelineTest {
317317
// 4. The "After AI" panel must be empty.
318318
// If it still shows the joke from turn 1, this invariant is violated.
319319
// -------------------------------------------------------------------------
320-
@Test
321-
fun `After AI panel must be empty when AI empties a file`() {
322-
val file = "note.md"
323-
val originalContent = "# Notes\n\nSome original text.\n"
324-
val withJoke = originalContent + "\nWhy do Java developers wear glasses? Because they can't C#.\n"
325-
326-
// Turn 1: AI adds a joke — server sends non-empty after
327-
h.disk[h.abs(file)] = withJoke
328-
h.commitTurnPatch(listOf(file))
329-
h.applySessionDiff(
330-
files = listOf(file to SessionDiffStatus.MODIFIED),
331-
serverAfterByFile = mapOf(h.abs(file) to withJoke),
332-
)
333-
assertEquals(
334-
withJoke, h.serverAfter(file),
335-
"after turn 1: serverAfter should hold the joke content"
336-
)
337-
338-
// Turn 2: AI empties the file — server sends empty string as after
339-
h.disk[h.abs(file)] = ""
340-
h.commitTurnPatch(listOf(file))
341-
h.applySessionDiff(
342-
files = listOf(file to SessionDiffStatus.MODIFIED),
343-
serverAfterByFile = mapOf(h.abs(file) to ""),
344-
)
345-
346-
// The "After AI" content shown in the diff viewer must be empty string,
347-
// not the stale joke content from turn 1.
348-
assertEquals(
349-
"",
350-
h.serverAfter(file),
351-
"After AI panel must show empty string when AI emptied the file, " +
352-
"but serverAfter still holds stale content: '${h.serverAfter(file)}'",
353-
)
354-
}
355-
356320
// -------------------------------------------------------------------------
357321
// A file the AI modified must still appear in the session's file count even
358322
// when the diff computation throws an unexpected error. If hunk computation
@@ -382,7 +346,6 @@ class SessionDiffPipelineTest {
382346
zeroHunkHarness.commitTurnPatch(listOf(file))
383347
zeroHunkHarness.applySessionDiff(
384348
files = listOf(file to SessionDiffStatus.MODIFIED),
385-
serverAfterByFile = mapOf(zeroHunkHarness.abs(file) to "line1\n"),
386349
)
387350

388351
// The file was reported as MODIFIED — it must still be counted.
@@ -421,7 +384,6 @@ class SessionDiffPipelineTest {
421384
h.commitTurnPatch(listOf(file))
422385
h.applySessionDiff(
423386
files = listOf(file to SessionDiffStatus.MODIFIED),
424-
serverAfterByFile = mapOf(h.abs(file) to aiContent),
425387
)
426388
assertEquals(1, h.trackedFileCount(), "file should be tracked after AI wrote to it")
427389

0 commit comments

Comments
 (0)