返回 DeepSeek-Reasonix
session_test.go
根目录 / internal / agent / session_test.go
1 package agent
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "reasonix/internal/provider"
10 )
11
12 // NewSession
13
14 func TestNewSessionEmpty(t *testing.T) {
15 s := NewSession("")
16 if len(s.Messages) != 0 {
17 t.Errorf("empty session should have 0 messages, got %d", len(s.Messages))
18 }
19 }
20
21 func TestNewSessionWithSystem(t *testing.T) {
22 s := NewSession("You are a helpful assistant.")
23 if len(s.Messages) != 1 {
24 t.Fatalf("want 1 message, got %d", len(s.Messages))
25 }
26 if s.Messages[0].Role != provider.RoleSystem {
27 t.Errorf("role = %q, want system", s.Messages[0].Role)
28 }
29 if s.Messages[0].Content != "You are a helpful assistant." {
30 t.Errorf("content = %q", s.Messages[0].Content)
31 }
32 }
33
34 // Session.Add
35
36 func TestSessionAdd(t *testing.T) {
37 s := NewSession("")
38 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
39 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi there"})
40 if len(s.Messages) != 2 {
41 t.Fatalf("want 2 messages, got %d", len(s.Messages))
42 }
43 if s.Messages[0].Role != provider.RoleUser {
44 t.Errorf("first role = %q", s.Messages[0].Role)
45 }
46 if s.Messages[1].Role != provider.RoleAssistant {
47 t.Errorf("second role = %q", s.Messages[1].Role)
48 }
49 }
50
51 func TestSessionAddDecisionReceiptKeepsToolResultsAdjacent(t *testing.T) {
52 s := NewSession("")
53 s.Add(provider.Message{Role: provider.RoleUser, Content: "run the check"})
54 s.Add(provider.Message{
55 Role: provider.RoleAssistant,
56 ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "bash", Arguments: `{}`}},
57 })
58 receipt := &provider.DecisionReceipt{ID: "approval-1", Kind: "tool", Tool: "bash", Outcome: "allow_once"}
59
60 s.AddDecisionReceipt(receipt)
61 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "call-1", Name: "bash", Content: "ok"})
62
63 got := s.Snapshot()
64 if len(got) != 3 {
65 t.Fatalf("messages = %d, want the original three-message tool turn", len(got))
66 }
67 if len(got[1].DecisionReceipts) != 1 || got[1].DecisionReceipts[0] != receipt {
68 t.Fatalf("assistant receipts = %+v, want approval receipt", got[1].DecisionReceipts)
69 }
70 if got[2].Role != provider.RoleTool || got[2].ToolCallID != "call-1" {
71 t.Fatalf("tool result no longer follows assistant directly: %+v", got)
72 }
73 if !s.NeedsRewriteSave() {
74 t.Fatal("attaching receipt to an existing message must require a rewrite save")
75 }
76 }
77
78 // Session.HasContent
79
80 func TestHasContentEmpty(t *testing.T) {
81 s := NewSession("")
82 if s.HasContent() {
83 t.Error("empty session should not have content")
84 }
85 }
86
87 func TestHasContentSystemOnly(t *testing.T) {
88 s := NewSession("system prompt")
89 if s.HasContent() {
90 t.Error("system-only session should not have content")
91 }
92 }
93
94 func TestHasContentWithUser(t *testing.T) {
95 s := NewSession("system")
96 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
97 if !s.HasContent() {
98 t.Error("session with user message should have content")
99 }
100 }
101
102 func TestHasContentWithAssistant(t *testing.T) {
103 s := NewSession("")
104 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "response"})
105 if !s.HasContent() {
106 t.Error("session with assistant message should have content")
107 }
108 }
109
110 func TestHasContentWithTool(t *testing.T) {
111 s := NewSession("")
112 s.Add(provider.Message{Role: provider.RoleTool, Content: "result", ToolCallID: "tc1"})
113 if !s.HasContent() {
114 t.Error("session with tool message should have content")
115 }
116 }
117
118 // Session.HasSystemMessage
119
120 func TestHasSystemMessageWithSystem(t *testing.T) {
121 s := NewSession("system prompt")
122 if !s.HasSystemMessage() {
123 t.Error("session with system message should report HasSystemMessage true")
124 }
125 }
126
127 func TestHasSystemMessageWithoutSystem(t *testing.T) {
128 s := NewSession("")
129 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
130 if s.HasSystemMessage() {
131 t.Error("session without system message should report HasSystemMessage false")
132 }
133 }
134
135 func TestHasSystemMessageAfterReplaceWithoutSystem(t *testing.T) {
136 s := NewSession("system")
137 s.Add(provider.Message{Role: provider.RoleUser, Content: "kept"})
138 // Replace with messages that have no system message — simulates a
139 // compact/summarise path that failed to preserve the system prompt.
140 s.Replace([]provider.Message{
141 {Role: provider.RoleUser, Content: "replaced"},
142 })
143 if s.HasContent() {
144 // HasContent returns true because the user message exists.
145 if s.HasSystemMessage() {
146 t.Error("session replaced without system message should report HasSystemMessage false")
147 }
148 } else {
149 t.Error("session with user message should have content")
150 }
151 }
152
153 func TestHasSystemMessageCompactedKeepsSystem(t *testing.T) {
154 // This is the healthy path: compact preserves the system message at index 0.
155 s := NewSession("system")
156 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
157 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
158 s.Replace([]provider.Message{
159 {Role: provider.RoleSystem, Content: "system"},
160 {Role: provider.RoleUser, Content: "summary"},
161 })
162 if !s.HasSystemMessage() {
163 t.Error("compacted session should still have system message at index 0")
164 }
165 }
166
167 // Save / LoadSession round-trip
168
169 func TestSaveLoadSessionRoundTrip(t *testing.T) {
170 dir := t.TempDir()
171 path := filepath.Join(dir, "session.jsonl")
172
173 s := NewSession("system prompt")
174 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
175 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "world"})
176 if err := s.Save(path); err != nil {
177 t.Fatalf("save: %v", err)
178 }
179
180 loaded, err := LoadSession(path)
181 if err != nil {
182 t.Fatalf("load: %v", err)
183 }
184 if len(loaded.Messages) != 3 {
185 t.Fatalf("want 3 messages, got %d", len(loaded.Messages))
186 }
187 if loaded.Messages[0].Content != "system prompt" {
188 t.Errorf("system = %q", loaded.Messages[0].Content)
189 }
190 if loaded.Messages[1].Content != "hello" {
191 t.Errorf("user = %q", loaded.Messages[1].Content)
192 }
193 if loaded.Messages[2].Content != "world" {
194 t.Errorf("assistant = %q", loaded.Messages[2].Content)
195 }
196 }
197
198 func TestSaveEmptyPath(t *testing.T) {
199 s := NewSession("")
200 if err := s.Save(""); err == nil {
201 t.Fatal("expected error for empty path")
202 }
203 }
204
205 func TestSaveCreatesDir(t *testing.T) {
206 dir := t.TempDir()
207 path := filepath.Join(dir, "deep", "nested", "session.jsonl")
208 s := NewSession("")
209 s.Add(provider.Message{Role: provider.RoleUser, Content: "test"})
210 if err := s.Save(path); err != nil {
211 t.Fatalf("save: %v", err)
212 }
213 if _, err := os.Stat(path); err != nil {
214 t.Fatal("session file should exist")
215 }
216 }
217
218 func TestLoadSessionMissing(t *testing.T) {
219 _, err := LoadSession("/nonexistent/session.jsonl")
220 if err == nil {
221 t.Fatal("expected error for missing file")
222 }
223 if !os.IsNotExist(err) {
224 t.Errorf("error should be os.IsNotExist, got %v", err)
225 }
226 }
227
228 func TestLoadSessionMalformed(t *testing.T) {
229 dir := t.TempDir()
230 path := filepath.Join(dir, "bad.jsonl")
231 os.WriteFile(path, []byte("not valid json\n"), 0o644)
232 _, err := LoadSession(path)
233 if err == nil {
234 t.Fatal("expected error for malformed JSONL")
235 }
236 if !strings.Contains(err.Error(), "decode") {
237 t.Errorf("error should mention decode: %v", err)
238 }
239 }
240
241 // ListSessions
242
243 func TestListSessionsMissingDirReturnsNil(t *testing.T) {
244 sessions, err := ListSessions("/nonexistent/dir")
245 if err != nil {
246 t.Fatalf("expected nil error for missing dir, got %v", err)
247 }
248 if sessions != nil {
249 t.Errorf("expected nil sessions, got %v", sessions)
250 }
251 }
252
253 func TestListSessionsEmptyDir(t *testing.T) {
254 dir := t.TempDir()
255 sessions, err := ListSessions(dir)
256 if err != nil {
257 t.Fatalf("err: %v", err)
258 }
259 if len(sessions) != 0 {
260 t.Errorf("want 0 sessions, got %d", len(sessions))
261 }
262 }
263
264 func TestListSessionsSorted(t *testing.T) {
265 dir := t.TempDir()
266 // Create two sessions with different content.
267 s1 := NewSession("")
268 s1.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
269 s1.Save(filepath.Join(dir, "a.jsonl"))
270
271 s2 := NewSession("")
272 s2.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
273 s2.Save(filepath.Join(dir, "b.jsonl"))
274
275 sessions, err := ListSessions(dir)
276 if err != nil {
277 t.Fatalf("err: %v", err)
278 }
279 if len(sessions) != 2 {
280 t.Fatalf("want 2 sessions, got %d", len(sessions))
281 }
282 // Newest first.
283 if sessions[0].ModTime.Before(sessions[1].ModTime) {
284 t.Error("sessions should be sorted newest first")
285 }
286 }
287
288 func TestListSessionsSkipsEmpty(t *testing.T) {
289 dir := t.TempDir()
290 // A session with only a system prompt (no user interaction) should be skipped.
291 s := NewSession("system only")
292 s.Save(filepath.Join(dir, "empty.jsonl"))
293
294 sessions, err := ListSessions(dir)
295 if err != nil {
296 t.Fatalf("err: %v", err)
297 }
298 if len(sessions) != 0 {
299 t.Errorf("empty sessions should be skipped, got %d", len(sessions))
300 }
301 }
302
303 func TestListSessionsSkipsNonJSONL(t *testing.T) {
304 dir := t.TempDir()
305 os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("not a session"), 0o644)
306 s := NewSession("")
307 s.Add(provider.Message{Role: provider.RoleUser, Content: "real"})
308 s.Save(filepath.Join(dir, "real.jsonl"))
309
310 sessions, err := ListSessions(dir)
311 if err != nil {
312 t.Fatalf("err: %v", err)
313 }
314 if len(sessions) != 1 {
315 t.Errorf("want 1 session, got %d", len(sessions))
316 }
317 }
318
319 // previewSession
320
321 func TestPreviewSession(t *testing.T) {
322 dir := t.TempDir()
323 path := filepath.Join(dir, "session.jsonl")
324 s := NewSession("system")
325 s.Add(provider.Message{Role: provider.RoleUser, Content: "Help me debug the auth module"})
326 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Sure, let me look..."})
327 s.Save(path)
328
329 preview, turns := previewSession(path)
330 if turns != 1 {
331 t.Errorf("turns = %d, want 1", turns)
332 }
333 if !strings.Contains(preview, "debug") {
334 t.Errorf("preview = %q", preview)
335 }
336 }
337
338 func TestPreviewSessionStripsTransientReasoningLanguageBlock(t *testing.T) {
339 dir := t.TempDir()
340 path := filepath.Join(dir, "session.jsonl")
341 s := NewSession("system")
342 s.Add(provider.Message{Role: provider.RoleUser, Content: "<reasoning-language>\nVisible reasoning/thinking text preference: use Simplified Chinese.\n</reasoning-language>\n\nHelp me debug the auth module"})
343 s.Save(path)
344
345 preview, turns := previewSession(path)
346 if turns != 1 {
347 t.Errorf("turns = %d, want 1", turns)
348 }
349 if preview != "Help me debug the auth module" {
350 t.Errorf("preview = %q, want user prompt", preview)
351 }
352 }
353
354 func TestPreviewSessionStripsTransientResponseLanguageBlock(t *testing.T) {
355 dir := t.TempDir()
356 path := filepath.Join(dir, "session.jsonl")
357 s := NewSession("system")
358 s.Add(provider.Message{Role: provider.RoleUser, Content: "<response-language>\nFinal answer language preference: use English.\n</response-language>\n\nHelp me debug the auth module"})
359 s.Save(path)
360
361 preview, turns := previewSession(path)
362 if turns != 1 {
363 t.Errorf("turns = %d, want 1", turns)
364 }
365 if preview != "Help me debug the auth module" {
366 t.Errorf("preview = %q, want user prompt", preview)
367 }
368 }
369
370 func TestPreviewSessionLongMessage(t *testing.T) {
371 dir := t.TempDir()
372 path := filepath.Join(dir, "session.jsonl")
373 s := NewSession("")
374 s.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("a", 200)})
375 s.Save(path)
376
377 preview, _ := previewSession(path)
378 if len([]rune(preview)) > 80 {
379 t.Errorf("preview should be capped at 80 runes, got %d", len([]rune(preview)))
380 }
381 if !strings.HasSuffix(preview, "…") {
382 t.Errorf("truncated preview should end with …, got %q", preview)
383 }
384 }
385
386 func TestPreviewSessionMalformed(t *testing.T) {
387 dir := t.TempDir()
388 path := filepath.Join(dir, "bad.jsonl")
389 os.WriteFile(path, []byte("not json\n"), 0o644)
390 preview, turns := previewSession(path)
391 if turns != 0 {
392 t.Errorf("turns = %d, want 0", turns)
393 }
394 if preview != "" {
395 t.Errorf("preview = %q, want empty", preview)
396 }
397 }
398
399 // NewSessionPath
400
401 func TestNewSessionPath(t *testing.T) {
402 dir := t.TempDir()
403 path := NewSessionPath(dir, "deepseek-chat")
404 if !strings.HasSuffix(path, ".jsonl") {
405 t.Errorf("should end with .jsonl: %s", path)
406 }
407 if !strings.Contains(path, "deepseek-chat") {
408 t.Errorf("should contain model name: %s", path)
409 }
410 if !strings.HasPrefix(path, dir) {
411 t.Errorf("should be under dir: %s", path)
412 }
413 }
414
415 func TestNewSessionPathSanitizesSlashes(t *testing.T) {
416 path := NewSessionPath("/dir", "provider/model")
417 base := filepath.Base(path)
418 if strings.Contains(base, "/") {
419 t.Errorf("filename should not contain /: %s", base)
420 }
421 if !strings.Contains(base, "provider-model") {
422 t.Errorf("slashes should be replaced: %s", base)
423 }
424 }
425
426 func TestNewSessionPathSanitizesWindowsReservedPunctuation(t *testing.T) {
427 dir := t.TempDir()
428 path := NewSessionPath(dir, `nemotron-3-nano:30b<>"|?*`)
429 base := filepath.Base(path)
430 if strings.ContainsAny(base, `:<>"|?*`) {
431 t.Fatalf("filename contains Windows-reserved punctuation: %s", base)
432 }
433 if !strings.Contains(base, "nemotron-3-nano-30b") {
434 t.Fatalf("colon should be replaced without hiding the model hint: %s", base)
435 }
436
437 s := NewSession("")
438 s.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
439 if err := s.Save(path); err != nil {
440 t.Fatalf("save session with sanitized model filename: %v", err)
441 }
442 if _, err := os.Stat(path); err != nil {
443 t.Fatalf("stat saved session: %v", err)
444 }
445 }
446
447 func TestNewSessionPathEmptyModel(t *testing.T) {
448 path := NewSessionPath("/dir", "")
449 if !strings.Contains(path, "session") {
450 t.Errorf("empty model should use 'session' fallback: %s", path)
451 }
452 }
453
454 // rewrite-save baseline
455
456 // TestNeedsRewriteSaveFollowsSaves pins the baseline's lifecycle on the
457 // session object itself: an in-memory rewrite demands a rewrite save, every
458 // successful save re-anchors, and the baseline never moves backwards when a
459 // slower save reports an older capture.
460 func TestNeedsRewriteSaveFollowsSaves(t *testing.T) {
461 dir := t.TempDir()
462 path := filepath.Join(dir, "session.jsonl")
463 s := NewSession("sys")
464 s.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
465 if s.NeedsRewriteSave() {
466 t.Fatal("fresh session should not need a rewrite save")
467 }
468 s.IncrementRewrite()
469 if !s.NeedsRewriteSave() {
470 t.Fatal("in-memory rewrite must demand a rewrite save")
471 }
472 if err := s.Save(path); err != nil {
473 t.Fatalf("Save: %v", err)
474 }
475 if s.NeedsRewriteSave() {
476 t.Fatal("Save must re-anchor the rewrite baseline")
477 }
478 s.IncrementRewrite()
479 if err := s.SaveRewrite(path); err != nil {
480 t.Fatalf("SaveRewrite: %v", err)
481 }
482 if s.NeedsRewriteSave() {
483 t.Fatal("SaveRewrite must re-anchor the rewrite baseline")
484 }
485
486 // A slower save that captured an older rewriteVersion must not roll the
487 // baseline back below what a faster save already persisted.
488 digest, err := digestSessionMessages(s.Snapshot())
489 if err != nil {
490 t.Fatalf("digest: %v", err)
491 }
492 s.markPersisted(path, digest, 1, 1, 0)
493 if s.NeedsRewriteSave() {
494 t.Fatal("stale capture rolled the rewrite baseline backwards")
495 }
496 }
497
498 func TestUpdateToolCallPreviewPersistsAfterMidTurnSnapshot(t *testing.T) {
499 path := filepath.Join(t.TempDir(), "session.jsonl")
500 s := NewSession("system")
501 s.Add(provider.Message{Role: provider.RoleUser, Content: "edit twice"})
502 s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
503 {ID: "c1", Name: "edit_file", Arguments: `{}`},
504 {ID: "c2", Name: "edit_file", Arguments: `{}`},
505 }})
506 if err := s.SaveSnapshot(path); err != nil {
507 t.Fatalf("mid-turn snapshot: %v", err)
508 }
509
510 refreshed := provider.ToolCall{ID: "c2", Diff: "@@ -1 +1 @@\n-ready\n+done\n", Added: 1, Removed: 1}
511 if !s.UpdateToolCallPreview(refreshed) {
512 t.Fatal("matching tool call was not updated")
513 }
514 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "c1", Name: "edit_file", Content: "ready"})
515 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "c2", Name: "edit_file", Content: "done"})
516 if !s.NeedsRewriteSave() {
517 t.Fatal("mutating a snapshotted assistant message must require rewrite save")
518 }
519 if err := s.SaveRewrite(path); err != nil {
520 t.Fatalf("rewrite refreshed preview: %v", err)
521 }
522
523 loaded, err := LoadSession(path)
524 if err != nil {
525 t.Fatalf("reload: %v", err)
526 }
527 var got provider.ToolCall
528 for _, msg := range loaded.Messages {
529 for _, call := range msg.ToolCalls {
530 if call.ID == "c2" {
531 got = call
532 }
533 }
534 }
535 if got.Diff != refreshed.Diff || got.Added != 1 || got.Removed != 1 {
536 t.Fatalf("persisted preview = %+v, want %+v", got, refreshed)
537 }
538 }
539
540 func TestUpdateToolCallResolutionPersistsAfterMidTurnSnapshot(t *testing.T) {
541 path := filepath.Join(t.TempDir(), "session.jsonl")
542 s := NewSession("system")
543 s.Add(provider.Message{Role: provider.RoleUser, Content: "use MCP"})
544 s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
545 ID: "c1", Name: "use_capability",
546 Arguments: `{"action":"call","capability_id":"mcp-tool:db/write"}`,
547 }}})
548 if err := s.SaveSnapshot(path); err != nil {
549 t.Fatalf("mid-turn snapshot: %v", err)
550 }
551
552 readOnly := false
553 resolved := provider.ToolCall{
554 ID: "c1", ResolvedName: "mcp__db__write",
555 CapabilityID: "mcp-tool:db/write", ResolvedReadOnly: &readOnly,
556 }
557 if !s.UpdateToolCallResolution(resolved) {
558 t.Fatal("matching tool call resolution was not updated")
559 }
560 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "c1", Name: "use_capability", Content: "done"})
561 if !s.NeedsRewriteSave() {
562 t.Fatal("resolved metadata on a snapshotted assistant message must require rewrite save")
563 }
564 if err := s.SaveRewrite(path); err != nil {
565 t.Fatalf("rewrite resolved metadata: %v", err)
566 }
567
568 loaded, err := LoadSession(path)
569 if err != nil {
570 t.Fatalf("reload: %v", err)
571 }
572 got := loaded.Messages[2].ToolCalls[0]
573 if got.ResolvedReadOnly == nil || *got.ResolvedReadOnly ||
574 got.ResolvedName != resolved.ResolvedName || got.CapabilityID != resolved.CapabilityID {
575 t.Fatalf("persisted resolved metadata = %+v, want %+v", got, resolved)
576 }
577 }
578
579 // TestRewriteBaselineStaysWithClones: an unpersisted rewrite travels with the
580 // clone, and the source persisting later does not mark the clone's copy as
581 // saved — each session object owns its own baseline, so no swap can orphan or
582 // misattribute it.
583 func TestRewriteBaselineStaysWithClones(t *testing.T) {
584 dir := t.TempDir()
585 path := filepath.Join(dir, "session.jsonl")
586 s := NewSession("sys")
587 s.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
588 s.IncrementRewrite()
589 clone := s.CloneWithMessages(s.Snapshot())
590 if !clone.NeedsRewriteSave() {
591 t.Fatal("clone must inherit the unpersisted rewrite")
592 }
593 if err := s.Save(path); err != nil {
594 t.Fatalf("Save: %v", err)
595 }
596 if s.NeedsRewriteSave() {
597 t.Fatal("source baseline not re-anchored by save")
598 }
599 if !clone.NeedsRewriteSave() {
600 t.Fatal("saving the source must not mark the clone's rewrite persisted")
601 }
602 }
603
604 func TestHasUnsavedChangesProtectsIdleHistoryAfterSaveFailure(t *testing.T) {
605 path := filepath.Join(t.TempDir(), "session.jsonl")
606 s := NewSession("sys")
607 s.Add(provider.Message{Role: provider.RoleUser, Content: "durable"})
608 if !s.HasUnsavedChanges(path) {
609 t.Fatal("new transcript without a baseline must be considered unsaved")
610 }
611 if err := s.SaveSnapshot(path); err != nil {
612 t.Fatalf("initial save: %v", err)
613 }
614 if s.HasUnsavedChanges(path) {
615 t.Fatal("saved transcript still reported unsaved")
616 }
617
618 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "pending"})
619 if !s.HasUnsavedChanges(path) {
620 t.Fatal("in-memory suffix was not reported as unsaved")
621 }
622 // A future retry can persist the suffix; until then an idle history refresh
623 // must keep rendering the controller's copy instead of replacing it from the
624 // older checkpoint/WAL state.
625 if err := s.SaveSnapshot(path); err != nil {
626 t.Fatalf("retry save: %v", err)
627 }
628 if s.HasUnsavedChanges(path) {
629 t.Fatal("successful retry left the transcript marked unsaved")
630 }
631 }
632
633 // TestMessageRangeReturnsClampedCopy: the window is clamped to the log bounds
634 // and detached from the live slice.
635 func TestMessageRangeReturnsClampedCopy(t *testing.T) {
636 s := NewSession("sys")
637 s.Add(provider.Message{Role: provider.RoleUser, Content: "a"})
638 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "b"})
639 s.Add(provider.Message{Role: provider.RoleUser, Content: "c"})
640
641 got := s.MessageRange(1, 3)
642 if len(got) != 2 || got[0].Content != "a" || got[1].Content != "b" {
643 t.Fatalf("MessageRange(1,3) = %+v", got)
644 }
645 if got := s.MessageRange(-5, 99); len(got) != 4 {
646 t.Fatalf("clamped MessageRange = %d messages, want 4", len(got))
647 }
648 if got := s.MessageRange(3, 3); len(got) != 0 {
649 t.Fatalf("empty MessageRange = %d messages, want 0", len(got))
650 }
651 got[0].Content = "mutated"
652 if s.Messages[1].Content != "a" {
653 t.Fatal("MessageRange must return a copy")
654 }
655 }
656
657 // TestPersistedStateTracksBaseline: the exported baseline view follows saves,
658 // appends, and rewrites.
659 func TestPersistedStateTracksBaseline(t *testing.T) {
660 dir := t.TempDir()
661 path := filepath.Join(dir, "session.jsonl")
662 s := NewSession("sys")
663 s.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
664
665 if _, ok := s.PersistedState(path); ok {
666 t.Fatal("PersistedState before any save should not be anchored")
667 }
668 if err := s.Save(path); err != nil {
669 t.Fatalf("Save: %v", err)
670 }
671 ps, ok := s.PersistedState(path)
672 if !ok {
673 t.Fatal("PersistedState after save should be anchored")
674 }
675 if !ps.RevisionKnown || ps.Revision != 1 {
676 t.Fatalf("revision = %d known=%v, want 1/true", ps.Revision, ps.RevisionKnown)
677 }
678 if ps.DigestHex == "" {
679 t.Fatal("DigestHex should be populated")
680 }
681 if !ps.AppendOnlyTail || !ps.UnchangedSincePersisted {
682 t.Fatalf("right after save: AppendOnlyTail=%v Unchanged=%v, want true/true", ps.AppendOnlyTail, ps.UnchangedSincePersisted)
683 }
684
685 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "there"})
686 ps, _ = s.PersistedState(path)
687 if !ps.AppendOnlyTail || ps.UnchangedSincePersisted {
688 t.Fatalf("after append: AppendOnlyTail=%v Unchanged=%v, want true/false", ps.AppendOnlyTail, ps.UnchangedSincePersisted)
689 }
690
691 msgs := s.Snapshot()
692 s.Rewrite(msgs[:1], "compact")
693 ps, _ = s.PersistedState(path)
694 if ps.AppendOnlyTail {
695 t.Fatal("after rewrite: AppendOnlyTail should be false")
696 }
697 }
698
698 lines GO