返回 DeepSeek-Reasonix
session_display_index_test.go
根目录 / internal / agent / session_display_index_test.go
1 package agent
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "reflect"
8 "runtime"
9 "strings"
10 "testing"
11
12 "reasonix/internal/provider"
13 "reasonix/internal/store"
14 )
15
16 // displayIndexTestMessages builds a multi-turn transcript exercising every
17 // classification the index records: plain turns, a tool call + result, an
18 // image attachment, a local-only message, a steer, and a synthetic user
19 // message.
20 func displayIndexTestMessages() []provider.Message {
21 return []provider.Message{
22 {Role: provider.RoleSystem, Content: "system prompt"},
23 {Role: provider.RoleUser, Content: "first question"},
24 {Role: provider.RoleAssistant, Content: "calling a tool", ToolCalls: []provider.ToolCall{
25 {ID: "call_1", Name: "shell", Arguments: `{"cmd":"ls"}`},
26 }},
27 {Role: provider.RoleTool, ToolCallID: "call_1", Name: "shell", Content: "file.go"},
28 {Role: provider.RoleAssistant, Content: "interrupted partial", LocalOnly: true},
29 {Role: provider.RoleUser, Content: midTurnSteerMessage("hurry up")},
30 {Role: provider.RoleUser, Content: "Plan approved — plan mode is off. Implement the plan now."},
31 {Role: provider.RoleUser, Content: "second question", Images: []string{"data:image/png;base64,iVBORw0KGgo="}},
32 {Role: provider.RoleAssistant, Content: "second answer"},
33 }
34 }
35
36 func displayIndexTranscriptSize(t *testing.T, msgs []provider.Message) int64 {
37 t.Helper()
38 size := int64(0)
39 for i, m := range msgs {
40 b, err := json.Marshal(m)
41 if err != nil {
42 t.Fatalf("marshal message %d: %v", i, err)
43 }
44 size += int64(len(b)) + 1
45 }
46 return size
47 }
48
49 func TestBuildSessionDisplayIndexRoundTrip(t *testing.T) {
50 msgs := displayIndexTestMessages()
51 digest, err := digestSessionMessages(msgs)
52 if err != nil {
53 t.Fatalf("digestSessionMessages: %v", err)
54 }
55 idx := BuildSessionDisplayIndex(msgs, 7, true, digest)
56 if idx == nil {
57 t.Fatal("BuildSessionDisplayIndex returned nil")
58 }
59 if idx.MessageCount != len(msgs) || len(idx.Entries) != len(msgs) {
60 t.Fatalf("message_count = %d, entries = %d, want %d", idx.MessageCount, len(idx.Entries), len(msgs))
61 }
62 if idx.AuthoredTurns != 2 {
63 t.Fatalf("authored_turns = %d, want 2 (steer and synthetic messages are not turns)", idx.AuthoredTurns)
64 }
65 if idx.TranscriptSize != displayIndexTranscriptSize(t, msgs) {
66 t.Fatalf("transcript_size = %d, want %d", idx.TranscriptSize, displayIndexTranscriptSize(t, msgs))
67 }
68
69 path := filepath.Join(t.TempDir(), "session.display-index.json")
70 if err := WriteSessionDisplayIndex(path, idx); err != nil {
71 t.Fatalf("WriteSessionDisplayIndex: %v", err)
72 }
73 loaded, err := LoadSessionDisplayIndex(path)
74 if err != nil {
75 t.Fatalf("LoadSessionDisplayIndex: %v", err)
76 }
77 if !reflect.DeepEqual(loaded, idx) {
78 t.Fatalf("round trip mismatch:\n got %+v\nwant %+v", loaded, idx)
79 }
80 if !ValidateSessionDisplayIndex(loaded, 7, true, digest, idx.TranscriptSize) {
81 t.Fatal("ValidateSessionDisplayIndex rejected a fresh index")
82 }
83 }
84
85 func TestLoadSessionPreviewFromDisplayIndexReadsFirstAuthoredRange(t *testing.T) {
86 path := filepath.Join(t.TempDir(), "session.jsonl")
87 session := NewSession("system prompt")
88 session.Add(provider.Message{Role: provider.RoleUser, Content: "first question"})
89 session.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("answer", 10_000)})
90 session.Add(provider.Message{Role: provider.RoleUser, Content: "second question"})
91 if err := session.SaveSnapshot(path); err != nil {
92 t.Fatalf("SaveSnapshot: %v", err)
93 }
94
95 preview, ok, err := LoadSessionPreviewFromDisplayIndex(path)
96 if err != nil || !ok || preview != "first question" {
97 t.Fatalf("preview = %q, ok=%v, err=%v", preview, ok, err)
98 }
99 }
100
101 func TestSessionDisplayIndexOffsetsMatchTranscript(t *testing.T) {
102 msgs := displayIndexTestMessages()
103 path := filepath.Join(t.TempDir(), "session.jsonl")
104 if err := writeSessionMessages(path, msgs); err != nil {
105 t.Fatalf("writeSessionMessages: %v", err)
106 }
107 raw, err := os.ReadFile(path)
108 if err != nil {
109 t.Fatalf("ReadFile: %v", err)
110 }
111 digest, err := digestSessionMessages(msgs)
112 if err != nil {
113 t.Fatalf("digestSessionMessages: %v", err)
114 }
115 idx := BuildSessionDisplayIndex(msgs, 1, true, digest)
116 if idx == nil {
117 t.Fatal("BuildSessionDisplayIndex returned nil")
118 }
119 if int64(len(raw)) != idx.TranscriptSize {
120 t.Fatalf("file size = %d, transcript_size = %d", len(raw), idx.TranscriptSize)
121 }
122 for _, entry := range idx.Entries {
123 end := entry.Offset + entry.Length
124 if end > int64(len(raw)) {
125 t.Fatalf("entry %d range [%d,%d) exceeds file size %d", entry.Index, entry.Offset, end, len(raw))
126 }
127 line := raw[entry.Offset:end]
128 if line[len(line)-1] != '\n' {
129 t.Fatalf("entry %d line does not end with newline", entry.Index)
130 }
131 var m provider.Message
132 if err := json.Unmarshal(line, &m); err != nil {
133 t.Fatalf("entry %d line does not decode: %v", entry.Index, err)
134 }
135 if m.Role != msgs[entry.Index].Role {
136 t.Errorf("entry %d role = %q, want %q", entry.Index, m.Role, msgs[entry.Index].Role)
137 }
138 if want := msgs[entry.Index].Content; len(want) > 0 && !strings.HasPrefix(m.Content, want[:min(len(want), 16)]) {
139 t.Errorf("entry %d content = %q, want prefix of %q", entry.Index, m.Content, want)
140 }
141 }
142 // Spot-check the classification flags.
143 wantFlags := map[int]DisplayIndexEntry{
144 1: {Role: provider.RoleUser, AuthoredTurn: 1, StartsTurn: true},
145 2: {Role: provider.RoleAssistant, AuthoredTurn: 1, HasToolCalls: true},
146 3: {Role: provider.RoleTool, AuthoredTurn: 1, ToolResult: true},
147 4: {Role: provider.RoleAssistant, AuthoredTurn: 1, LocalOnly: true},
148 5: {Role: provider.RoleUser, AuthoredTurn: 1, Steer: true},
149 6: {Role: provider.RoleUser, AuthoredTurn: 1, Synthetic: true},
150 7: {Role: provider.RoleUser, AuthoredTurn: 2, StartsTurn: true, HasImages: true},
151 8: {Role: provider.RoleAssistant, AuthoredTurn: 2},
152 }
153 for i, want := range wantFlags {
154 got := idx.Entries[i]
155 if got.Role != want.Role || got.AuthoredTurn != want.AuthoredTurn ||
156 got.StartsTurn != want.StartsTurn || got.HasToolCalls != want.HasToolCalls ||
157 got.ToolResult != want.ToolResult || got.LocalOnly != want.LocalOnly ||
158 got.Steer != want.Steer || got.Synthetic != want.Synthetic || got.HasImages != want.HasImages {
159 t.Errorf("entry %d = %+v, want flags %+v", i, got, want)
160 }
161 }
162 }
163
164 func TestSessionDisplayIndexIncrementalAppend(t *testing.T) {
165 path := filepath.Join(t.TempDir(), "session.jsonl")
166 base := NewSession("sys")
167 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
168 if err := base.SaveSnapshot(path); err != nil {
169 t.Fatalf("SaveSnapshot base: %v", err)
170 }
171 indexPath := store.SessionDisplayIndex(path)
172 before, err := LoadSessionDisplayIndex(indexPath)
173 if err != nil {
174 t.Fatalf("LoadSessionDisplayIndex before append: %v", err)
175 }
176 if before.MessageCount != 2 {
177 t.Fatalf("message_count before append = %d, want 2", before.MessageCount)
178 }
179
180 next, err := LoadSession(path)
181 if err != nil {
182 t.Fatalf("LoadSession: %v", err)
183 }
184 next.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
185 next.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
186 if err := next.SaveSnapshot(path); err != nil {
187 t.Fatalf("SaveSnapshot append: %v", err)
188 }
189 after, err := LoadSessionDisplayIndex(indexPath)
190 if err != nil {
191 t.Fatalf("LoadSessionDisplayIndex after append: %v", err)
192 }
193 if after.MessageCount != 4 {
194 t.Fatalf("message_count after append = %d, want 4", after.MessageCount)
195 }
196 if after.Revision != before.Revision+1 {
197 t.Fatalf("revision = %d, want base %d + 1", after.Revision, before.Revision)
198 }
199 if !reflect.DeepEqual(after.Entries[:before.MessageCount], before.Entries) {
200 t.Fatalf("prefix entries changed across append:\nbefore %+v\nafter %+v", before.Entries, after.Entries[:before.MessageCount])
201 }
202 if after.Entries[3].AuthoredTurn != 2 || !after.Entries[3].StartsTurn {
203 t.Fatalf("appended user entry = %+v, want authored_turn 2 starting the turn", after.Entries[3])
204 }
205 msgs, _, _, err := loadSessionMessages(path)
206 if err != nil {
207 t.Fatalf("loadSessionMessages: %v", err)
208 }
209 digest, err := digestSessionMessages(msgs)
210 if err != nil {
211 t.Fatalf("digestSessionMessages: %v", err)
212 }
213 if !ValidateSessionDisplayIndex(after, after.Revision, true, digest, after.TranscriptSize) {
214 t.Fatal("appended index does not validate against the persisted transcript")
215 }
216 }
217
218 func TestSessionDisplayIndexRewriteInvalidates(t *testing.T) {
219 path := filepath.Join(t.TempDir(), "session.jsonl")
220 s := NewSession("sys")
221 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
222 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
223 if err := s.SaveSnapshot(path); err != nil {
224 t.Fatalf("SaveSnapshot: %v", err)
225 }
226 indexPath := store.SessionDisplayIndex(path)
227 stale, err := LoadSessionDisplayIndex(indexPath)
228 if err != nil {
229 t.Fatalf("LoadSessionDisplayIndex: %v", err)
230 }
231
232 // Rewind/compaction shape: the history shrinks, so revision and digest move.
233 s.Rewrite(s.Messages[:2], "rewind")
234 if err := s.SaveRewrite(path); err != nil {
235 t.Fatalf("SaveRewrite: %v", err)
236 }
237 revision, _, err := sessionContentRevision(path)
238 if err != nil {
239 t.Fatalf("sessionContentRevision: %v", err)
240 }
241 digest, err := digestSessionMessages(s.Messages[:2])
242 if err != nil {
243 t.Fatalf("digestSessionMessages: %v", err)
244 }
245 if ValidateSessionDisplayIndex(stale, revision, true, digest, displayIndexTranscriptSize(t, s.Messages[:2])) {
246 t.Fatal("stale index still validates after rewrite")
247 }
248 fresh, err := LoadSessionDisplayIndex(indexPath)
249 if err != nil {
250 t.Fatalf("LoadSessionDisplayIndex after rewrite: %v", err)
251 }
252 if fresh.MessageCount != 2 {
253 t.Fatalf("message_count after rewrite = %d, want 2", fresh.MessageCount)
254 }
255 if !ValidateSessionDisplayIndex(fresh, revision, true, digest, displayIndexTranscriptSize(t, s.Messages[:2])) {
256 t.Fatal("rebuilt index does not validate after rewrite")
257 }
258 }
259
260 func TestScanSessionDisplayIndexParity(t *testing.T) {
261 msgs := displayIndexTestMessages()
262 path := filepath.Join(t.TempDir(), "session.jsonl")
263 if err := writeSessionMessages(path, msgs); err != nil {
264 t.Fatalf("writeSessionMessages: %v", err)
265 }
266 digest, err := digestSessionMessages(msgs)
267 if err != nil {
268 t.Fatalf("digestSessionMessages: %v", err)
269 }
270 built := BuildSessionDisplayIndex(msgs, 3, true, digest)
271 if built == nil {
272 t.Fatal("BuildSessionDisplayIndex returned nil")
273 }
274 scanned, err := ScanSessionDisplayIndex(path)
275 if err != nil {
276 t.Fatalf("ScanSessionDisplayIndex: %v", err)
277 }
278 if !reflect.DeepEqual(scanned.Entries, built.Entries) {
279 t.Fatalf("scanner entries diverge from builder:\nscanned %+v\nbuilt %+v", scanned.Entries, built.Entries)
280 }
281 if scanned.MessageCount != built.MessageCount ||
282 scanned.AuthoredTurns != built.AuthoredTurns ||
283 scanned.TranscriptSize != built.TranscriptSize ||
284 scanned.ContentDigest != built.ContentDigest {
285 t.Fatalf("scanner header = (%d, %d, %d, %q), want (%d, %d, %d, %q)",
286 scanned.MessageCount, scanned.AuthoredTurns, scanned.TranscriptSize, scanned.ContentDigest,
287 built.MessageCount, built.AuthoredTurns, built.TranscriptSize, built.ContentDigest)
288 }
289 if scanned.RevisionKnown {
290 t.Fatal("scanned index must not claim a revision; the transcript does not carry one")
291 }
292 // A scanned index validates against the transcript it scanned.
293 if !ValidateSessionDisplayIndex(scanned, 0, false, digest, built.TranscriptSize) {
294 t.Fatal("scanned index does not validate against its own transcript")
295 }
296 }
297
298 func TestScanSessionDisplayIndexRejectsUnboundedLine(t *testing.T) {
299 path := filepath.Join(t.TempDir(), "oversized.jsonl")
300 // Keep the payload syntactically irrelevant: the scanner must reject the
301 // record before json.Unmarshal gets a chance to materialize it.
302 if err := os.WriteFile(path, append(make([]byte, sessionDisplayIndexMaxLineBytes+1), '\n'), 0o600); err != nil {
303 t.Fatalf("WriteFile: %v", err)
304 }
305 if _, err := ScanSessionDisplayIndex(path); err == nil {
306 t.Fatal("ScanSessionDisplayIndex accepted a line over the safety limit")
307 }
308 }
309
310 func TestLoadSessionDisplayIndexCorrupt(t *testing.T) {
311 dir := t.TempDir()
312 truncated := filepath.Join(dir, "truncated.display-index.json")
313 if err := os.WriteFile(truncated, []byte(`{"schema_version":1,"revision":`), 0o600); err != nil {
314 t.Fatalf("WriteFile truncated: %v", err)
315 }
316 if _, err := LoadSessionDisplayIndex(truncated); err == nil {
317 t.Fatal("LoadSessionDisplayIndex accepted truncated JSON")
318 }
319 wrongSchema := filepath.Join(dir, "schema.display-index.json")
320 if err := os.WriteFile(wrongSchema, []byte(`{"schema_version":999,"message_count":0,"entries":[]}`), 0o600); err != nil {
321 t.Fatalf("WriteFile wrong schema: %v", err)
322 }
323 if _, err := LoadSessionDisplayIndex(wrongSchema); err == nil {
324 t.Fatal("LoadSessionDisplayIndex accepted schema_version 999")
325 }
326 countMismatch := filepath.Join(dir, "count.display-index.json")
327 if err := os.WriteFile(countMismatch, []byte(`{"schema_version":1,"message_count":2,"entries":[]}`), 0o600); err != nil {
328 t.Fatalf("WriteFile count mismatch: %v", err)
329 }
330 if _, err := LoadSessionDisplayIndex(countMismatch); err == nil {
331 t.Fatal("LoadSessionDisplayIndex accepted message_count/entries mismatch")
332 }
333 badRange := filepath.Join(dir, "range.display-index.json")
334 if err := os.WriteFile(badRange, []byte(`{"schema_version":1,"transcript_size":10,"message_count":1,"entries":[{"index":0,"offset":1,"length":9}]}`), 0o600); err != nil {
335 t.Fatalf("WriteFile bad range: %v", err)
336 }
337 if _, err := LoadSessionDisplayIndex(badRange); err == nil {
338 t.Fatal("LoadSessionDisplayIndex accepted a non-contiguous offset range")
339 }
340 }
341
342 func TestRepairSessionDisplayReadModelFromAuthoritativeEventLog(t *testing.T) {
343 path := filepath.Join(t.TempDir(), "repair.jsonl")
344 s := NewSession("sys")
345 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
346 if err := s.SaveSnapshot(path); err != nil {
347 t.Fatalf("SaveSnapshot base: %v", err)
348 }
349 oldModel, err := os.ReadFile(path)
350 if err != nil {
351 t.Fatalf("read old model: %v", err)
352 }
353 oldIndex, err := os.ReadFile(store.SessionDisplayIndex(path))
354 if err != nil {
355 t.Fatalf("read old index: %v", err)
356 }
357 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "new tail"})
358 if err := s.SaveSnapshot(path); err != nil {
359 t.Fatalf("SaveSnapshot tail: %v", err)
360 }
361 if err := os.WriteFile(path, oldModel, 0o600); err != nil {
362 t.Fatalf("restore stale model: %v", err)
363 }
364 if err := os.WriteFile(store.SessionDisplayIndex(path), oldIndex, 0o600); err != nil {
365 t.Fatalf("restore stale index: %v", err)
366 }
367
368 msgs, state, repairable, err := LoadSessionDisplayMessages(path)
369 if err != nil || !repairable {
370 t.Fatalf("LoadSessionDisplayMessages = (%d, %+v, %v, %v)", len(msgs), state, repairable, err)
371 }
372 if len(msgs) != 3 || msgs[2].Content != "new tail" {
373 t.Fatalf("authoritative messages = %+v, want event-log tail", msgs)
374 }
375 if err := RepairSessionDisplayReadModel(path); err != nil {
376 t.Fatalf("RepairSessionDisplayReadModel: %v", err)
377 }
378 repaired, err := loadSessionMessagesFromJSONL(path, nil)
379 if err != nil || !reflect.DeepEqual(repaired, msgs) {
380 t.Fatalf("repaired model = %+v, err %v; want %+v", repaired, err, msgs)
381 }
382 idx, err := LoadSessionDisplayIndex(store.SessionDisplayIndex(path))
383 if err != nil {
384 t.Fatalf("LoadSessionDisplayIndex repaired: %v", err)
385 }
386 info, err := os.Stat(path)
387 if err != nil {
388 t.Fatal(err)
389 }
390 if !ValidateSessionDisplayIndex(idx, state.Revision, state.RevisionKnown, state.Digest, info.Size()) {
391 t.Fatalf("repaired index does not match read model: %+v", idx)
392 }
393 }
394
395 func TestValidateSessionDisplayIndexMismatch(t *testing.T) {
396 msgs := displayIndexTestMessages()
397 digest, err := digestSessionMessages(msgs)
398 if err != nil {
399 t.Fatalf("digestSessionMessages: %v", err)
400 }
401 size := displayIndexTranscriptSize(t, msgs)
402 idx := BuildSessionDisplayIndex(msgs, 4, true, digest)
403 if idx == nil {
404 t.Fatal("BuildSessionDisplayIndex returned nil")
405 }
406 if ValidateSessionDisplayIndex(nil, 4, true, digest, size) {
407 t.Fatal("nil index validated")
408 }
409 otherDigest, err := digestSessionMessages(msgs[:2])
410 if err != nil {
411 t.Fatalf("digestSessionMessages prefix: %v", err)
412 }
413 if ValidateSessionDisplayIndex(idx, 4, true, otherDigest, size) {
414 t.Fatal("index validated against a foreign digest")
415 }
416 if ValidateSessionDisplayIndex(idx, 5, true, digest, size) {
417 t.Fatal("index validated against a foreign revision")
418 }
419 if ValidateSessionDisplayIndex(idx, 4, true, digest, size-1) {
420 t.Fatal("index validated against a foreign transcript size")
421 }
422 if ValidateSessionDisplayIndex(idx, 0, false, digest, size) {
423 t.Fatal("index with a known revision validated as revision-unknown")
424 }
425 }
426
427 func TestWriteSessionDisplayIndexPermissions(t *testing.T) {
428 if runtime.GOOS == "windows" {
429 t.Skip("Windows does not expose POSIX permission bits")
430 }
431
432 msgs := displayIndexTestMessages()
433 digest, err := digestSessionMessages(msgs)
434 if err != nil {
435 t.Fatalf("digestSessionMessages: %v", err)
436 }
437 idx := BuildSessionDisplayIndex(msgs, 1, true, digest)
438 if idx == nil {
439 t.Fatal("BuildSessionDisplayIndex returned nil")
440 }
441 path := filepath.Join(t.TempDir(), "session.display-index.json")
442 if err := WriteSessionDisplayIndex(path, idx); err != nil {
443 t.Fatalf("WriteSessionDisplayIndex: %v", err)
444 }
445 info, err := os.Stat(path)
446 if err != nil {
447 t.Fatalf("Stat: %v", err)
448 }
449 if perm := info.Mode().Perm(); perm != 0o600 {
450 t.Fatalf("permissions = %o, want 600", perm)
451 }
452 // Rewriting an existing index keeps the tight permissions.
453 if err := WriteSessionDisplayIndex(path, idx); err != nil {
454 t.Fatalf("WriteSessionDisplayIndex rewrite: %v", err)
455 }
456 info, err = os.Stat(path)
457 if err != nil {
458 t.Fatalf("Stat after rewrite: %v", err)
459 }
460 if perm := info.Mode().Perm(); perm != 0o600 {
461 t.Fatalf("permissions after rewrite = %o, want 600", perm)
462 }
463 }
464
464 lines GO