| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/provider" |
| 11 | ) |
| 12 | |
| 13 | // seedPreviewSessions writes synthetic sessions with fresh BranchMeta |
| 14 | // sidecars so the cached-preview contract can be checked against the full |
| 15 | // decode on the same files. |
| 16 | func seedPreviewSessions(t *testing.T, dir string, n, turns int) { |
| 17 | t.Helper() |
| 18 | for i := range n { |
| 19 | path := filepath.Join(dir, fmt.Sprintf("preview-session-%03d.jsonl", i)) |
| 20 | f, err := os.Create(path) |
| 21 | if err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | enc := json.NewEncoder(f) |
| 25 | var msgs []provider.Message |
| 26 | for turn := range turns { |
| 27 | u := provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn %d: help me refactor the cold-start path for host %d", turn, i)} |
| 28 | a := provider.Message{Role: provider.RoleAssistant, Content: "assistant answer"} |
| 29 | msgs = append(msgs, u, a) |
| 30 | if err := enc.Encode(u); err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | if err := enc.Encode(a); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | } |
| 37 | if err := f.Close(); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | preview, previewTurns := SessionPreviewFromMessages(msgs) |
| 41 | digest, err := digestSessionMessages(msgs) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | meta := BranchMeta{ |
| 46 | Turns: previewTurns, |
| 47 | Preview: preview, |
| 48 | SchemaVersion: BranchMetaCountsVersion, |
| 49 | Revision: 1, |
| 50 | ContentDigest: digestString(digest), |
| 51 | } |
| 52 | stampSessionListingProjection(&meta) |
| 53 | if err := saveBranchMeta(path, meta, false); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // TestSessionPreviewCachedMatchesDecode pins the sidecar fast path to the |
| 60 | // full decode: same preview, same turn count, and a stale sidecar reports |
| 61 | // ok=false so callers (the serve /sessions listing) fall back instead of |
| 62 | // trusting outdated counts. |
| 63 | func TestSessionPreviewCachedMatchesDecode(t *testing.T) { |
| 64 | dir := t.TempDir() |
| 65 | seedPreviewSessions(t, dir, 3, 4) |
| 66 | for i := range 3 { |
| 67 | p := filepath.Join(dir, fmt.Sprintf("preview-session-%03d.jsonl", i)) |
| 68 | wantPreview, wantTurns := SessionPreview(p) |
| 69 | gotPreview, gotTurns, ok := SessionPreviewCached(p) |
| 70 | if !ok { |
| 71 | t.Fatalf("%s: sidecar not fresh", p) |
| 72 | } |
| 73 | if gotPreview != wantPreview || gotTurns != wantTurns { |
| 74 | t.Fatalf("%s: cached=(%q,%d) want decode=(%q,%d)", p, gotPreview, gotTurns, wantPreview, wantTurns) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // A stale sidecar (pre-counts schema) must report ok=false and the decode |
| 79 | // fallback still sees the real content. |
| 80 | stale := filepath.Join(dir, "stale.jsonl") |
| 81 | if err := os.WriteFile(stale, []byte(`{"role":"user","content":"hello stale"}`+"\n"), 0o644); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if err := saveBranchMeta(stale, BranchMeta{SchemaVersion: 0}, false); err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | if _, _, ok := SessionPreviewCached(stale); ok { |
| 88 | t.Fatal("stale sidecar should not report cached") |
| 89 | } |
| 90 | if _, turns := SessionPreview(stale); turns != 1 { |
| 91 | t.Fatalf("decode fallback sees turns=%d, want 1", turns) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestSessionPreviewCachedRejectsProjectionFromPreviousTranscriptGeneration(t *testing.T) { |
| 96 | dir := t.TempDir() |
| 97 | path := filepath.Join(dir, "advanced.jsonl") |
| 98 | oldMessages := []provider.Message{{Role: provider.RoleUser, Content: "old preview"}} |
| 99 | writeSessionFile(t, path, oldMessages) |
| 100 | oldDigest, err := digestSessionMessages(oldMessages) |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | meta := BranchMeta{ |
| 105 | Preview: "old preview", |
| 106 | Turns: 1, |
| 107 | SchemaVersion: BranchMetaCountsVersion, |
| 108 | Revision: 1, |
| 109 | ContentDigest: digestString(oldDigest), |
| 110 | } |
| 111 | stampSessionListingProjection(&meta) |
| 112 | if err := saveBranchMeta(path, meta, false); err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | |
| 116 | newMessages := []provider.Message{ |
| 117 | {Role: provider.RoleUser, Content: "new preview"}, |
| 118 | {Role: provider.RoleAssistant, Content: "answer"}, |
| 119 | {Role: provider.RoleUser, Content: "follow up"}, |
| 120 | } |
| 121 | writeSessionFile(t, path, newMessages) |
| 122 | newDigest, err := digestSessionMessages(newMessages) |
| 123 | if err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | if err := UpdateBranchMeta(path, false, func(current *BranchMeta) error { |
| 127 | // Simulate a durable transcript revision followed by the warn-only |
| 128 | // listing projection write failing. The old binding must remain stale. |
| 129 | current.Revision = 2 |
| 130 | current.ContentDigest = digestString(newDigest) |
| 131 | return nil |
| 132 | }); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | |
| 136 | if preview, turns, ok := SessionPreviewCached(path); ok { |
| 137 | t.Fatalf("previous-generation projection reported fresh: preview=%q turns=%d", preview, turns) |
| 138 | } |
| 139 | if preview, turns := SessionPreview(path); preview != "new preview" || turns != 2 { |
| 140 | t.Fatalf("decode fallback=(%q,%d), want (%q,2)", preview, turns, "new preview") |
| 141 | } |
| 142 | } |
| 143 |