| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/provider" |
| 11 | ) |
| 12 | |
| 13 | func writeSessionFile(t *testing.T, path string, msgs []provider.Message) { |
| 14 | t.Helper() |
| 15 | f, err := os.Create(path) |
| 16 | if err != nil { |
| 17 | t.Fatalf("create %s: %v", path, err) |
| 18 | } |
| 19 | defer f.Close() |
| 20 | enc := json.NewEncoder(f) |
| 21 | for _, m := range msgs { |
| 22 | if err := enc.Encode(m); err != nil { |
| 23 | t.Fatalf("encode: %v", err) |
| 24 | } |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | // SessionPreviewFromMessages must match a from-disk decode byte-for-byte, since |
| 29 | // Session.Save persists exactly the messages it is handed. |
| 30 | func TestSessionPreviewFromMessagesMatchesDecode(t *testing.T) { |
| 31 | dir := t.TempDir() |
| 32 | path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl") |
| 33 | msgs := []provider.Message{ |
| 34 | {Role: provider.RoleSystem, Content: "you are helpful"}, |
| 35 | {Role: provider.RoleUser, Content: "first question about the bug"}, |
| 36 | {Role: provider.RoleAssistant, Content: "here is an answer", ReasoningContent: "thinking"}, |
| 37 | {Role: provider.RoleUser, Content: "follow up"}, |
| 38 | {Role: provider.RoleAssistant, Content: "more"}, |
| 39 | } |
| 40 | writeSessionFile(t, path, msgs) |
| 41 | |
| 42 | filePreview, fileTurns := previewSession(path) |
| 43 | memPreview, memTurns := SessionPreviewFromMessages(msgs) |
| 44 | if fileTurns != memTurns || filePreview != memPreview { |
| 45 | t.Fatalf("mismatch: file=(%q,%d) mem=(%q,%d)", filePreview, fileTurns, memPreview, memTurns) |
| 46 | } |
| 47 | if memTurns != 2 { |
| 48 | t.Fatalf("expected 2 user turns, got %d", memTurns) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // When the sidecar records Turns/Preview, ListSessions must trust them and not |
| 53 | // re-derive from the .jsonl. We prove that by planting counts that disagree with |
| 54 | // the file: if ListSessions returns the planted values, it used the sidecar. |
| 55 | func TestListSessionsUsesSidecarWithoutDecoding(t *testing.T) { |
| 56 | dir := t.TempDir() |
| 57 | path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl") |
| 58 | writeSessionFile(t, path, []provider.Message{ |
| 59 | {Role: provider.RoleUser, Content: "real content in the file"}, |
| 60 | {Role: provider.RoleAssistant, Content: "a"}, |
| 61 | }) |
| 62 | // Sidecar deliberately disagrees with the file (3 turns, custom preview). |
| 63 | if err := UpdateSessionMeta(path, "", "cached preview line", 3, true); err != nil { |
| 64 | t.Fatalf("UpdateSessionMeta: %v", err) |
| 65 | } |
| 66 | |
| 67 | infos, err := ListSessions(dir) |
| 68 | if err != nil { |
| 69 | t.Fatalf("ListSessions: %v", err) |
| 70 | } |
| 71 | if len(infos) != 1 { |
| 72 | t.Fatalf("expected 1 session, got %d", len(infos)) |
| 73 | } |
| 74 | if infos[0].Turns != 3 || infos[0].Preview != "cached preview line" { |
| 75 | t.Fatalf("expected sidecar values (3, %q), got (%d, %q)", "cached preview line", infos[0].Turns, infos[0].Preview) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | // A legacy session whose sidecar has no recorded turn count must be decoded once |
| 80 | // and then backfilled, so the second listing reads the sidecar. |
| 81 | func TestListSessionsBackfillsLegacySession(t *testing.T) { |
| 82 | dir := t.TempDir() |
| 83 | path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl") |
| 84 | writeSessionFile(t, path, []provider.Message{ |
| 85 | {Role: provider.RoleUser, Content: "legacy question"}, |
| 86 | {Role: provider.RoleAssistant, Content: "answer"}, |
| 87 | {Role: provider.RoleUser, Content: "again"}, |
| 88 | {Role: provider.RoleAssistant, Content: "ok"}, |
| 89 | }) |
| 90 | // Sidecar exists but predates the counts (no Turns/Preview), with a fixed |
| 91 | // UpdatedAt we expect backfill to preserve. |
| 92 | updated := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) |
| 93 | if err := SaveBranchMetaPreserveUpdated(path, BranchMeta{ID: BranchID(path), CreatedAt: updated, UpdatedAt: updated, Scope: "global"}); err != nil { |
| 94 | t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err) |
| 95 | } |
| 96 | |
| 97 | infos, err := ListSessions(dir) |
| 98 | if err != nil { |
| 99 | t.Fatalf("ListSessions: %v", err) |
| 100 | } |
| 101 | if len(infos) != 1 || infos[0].Turns != 2 { |
| 102 | t.Fatalf("expected 1 session with 2 turns, got %+v", infos) |
| 103 | } |
| 104 | |
| 105 | // Backfill must have written the counts into the sidecar without bumping |
| 106 | // activity time (ordering must stay stable). |
| 107 | meta, ok, err := LoadBranchMeta(path) |
| 108 | if err != nil || !ok { |
| 109 | t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err) |
| 110 | } |
| 111 | if meta.Turns != 2 || meta.Preview != "legacy question" { |
| 112 | t.Fatalf("backfill missing: turns=%d preview=%q", meta.Turns, meta.Preview) |
| 113 | } |
| 114 | if !meta.UpdatedAt.Equal(updated) { |
| 115 | t.Fatalf("backfill bumped UpdatedAt: got %v want %v", meta.UpdatedAt, updated) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // A counts-authoritative sidecar (SchemaVersion stamped) that records Turns == 0 |
| 120 | // must be trusted as empty and skipped WITHOUT decoding the .jsonl. We prove the |
| 121 | // version gate by planting a file that actually has content but a meta claiming |
| 122 | // it is empty: if the session is decoded it would be listed; trusting the meta |
| 123 | // skips it. |
| 124 | func TestListSessionsTrustsRecordedEmptyWithoutDecoding(t *testing.T) { |
| 125 | dir := t.TempDir() |
| 126 | path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl") |
| 127 | writeSessionFile(t, path, []provider.Message{ |
| 128 | {Role: provider.RoleUser, Content: "real content the meta will lie about"}, |
| 129 | {Role: provider.RoleAssistant, Content: "a"}, |
| 130 | }) |
| 131 | if err := SaveBranchMeta(path, BranchMeta{ID: BranchID(path), Turns: 0, SchemaVersion: BranchMetaCountsVersion}); err != nil { |
| 132 | t.Fatalf("SaveBranchMeta: %v", err) |
| 133 | } |
| 134 | |
| 135 | infos, err := ListSessions(dir) |
| 136 | if err != nil { |
| 137 | t.Fatalf("ListSessions: %v", err) |
| 138 | } |
| 139 | if len(infos) != 0 { |
| 140 | t.Fatalf("a counts-authoritative empty session should be skipped without decoding; got %d", len(infos)) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // A genuinely-empty legacy session (no counts-aware meta) must be decoded once |
| 145 | // and then recorded as authoritative-empty, so later listings trust it instead |
| 146 | // of re-decoding the .jsonl on every refresh (the Turns == 0 overload bug). |
| 147 | func TestListSessionsRecordsEmptyLegacySessionOnce(t *testing.T) { |
| 148 | dir := t.TempDir() |
| 149 | path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl") |
| 150 | writeSessionFile(t, path, []provider.Message{ |
| 151 | {Role: provider.RoleSystem, Content: "system prompt"}, |
| 152 | {Role: provider.RoleAssistant, Content: "a greeting with no user turn"}, |
| 153 | }) |
| 154 | |
| 155 | infos, err := ListSessions(dir) |
| 156 | if err != nil { |
| 157 | t.Fatalf("ListSessions: %v", err) |
| 158 | } |
| 159 | if len(infos) != 0 { |
| 160 | t.Fatalf("empty session must not be listed; got %d", len(infos)) |
| 161 | } |
| 162 | |
| 163 | meta, ok, err := LoadBranchMeta(path) |
| 164 | if err != nil || !ok { |
| 165 | t.Fatalf("empty session should have been stamped: ok=%v err=%v", ok, err) |
| 166 | } |
| 167 | if meta.SchemaVersion != BranchMetaCountsVersion || meta.Turns != 0 { |
| 168 | t.Fatalf("empty session not recorded as authoritative-empty: version=%d turns=%d", meta.SchemaVersion, meta.Turns) |
| 169 | } |
| 170 | } |
| 171 |