1package core
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "github.com/bnema/zut/packages/provider"
10)
11
12func phaseOf(block provider.Content) string {
13 tb, ok := block.(provider.TextBlock)
14 if !ok {
15 return "<non-text>"
16 }
17 return tb.Phase
18}
19
20func appendPhasedAssistant(t *testing.T, s *Session) {
21 t.Helper()
22 if err := s.AppendMessage(provider.Message{
23 Role: provider.RoleAssistant,
24 Content: []provider.Content{
25 provider.TextBlock{Text: "working on it", Phase: provider.TextPhaseCommentary},
26 provider.TextBlock{Text: "done", Phase: provider.TextPhaseFinal},
27 provider.TextBlock{Text: "plain"},
28 },
29 }); err != nil {
30 t.Fatal(err)
31 }
32}
33
34func TestSessionPhaseOldUnphasedFixture(t *testing.T) {
35 raw := `{"role":"assistant","content":[{"text":"hello"}],"time":"2026-01-01T00:00:00Z"}`
36 msg, err := DecodeMessageJSON([]byte(raw))
37 if err != nil {
38 t.Fatalf("DecodeMessageJSON: %v", err)
39 }
40 if len(msg.Content) != 1 {
41 t.Fatalf("content = %#v, want 1 block", msg.Content)
42 }
43 if got := phaseOf(msg.Content[0]); got != "" {
44 t.Fatalf("phase = %q, want empty", got)
45 }
46}
47
48func TestSessionPhaseMixedRoundTrip(t *testing.T) {
49 root := t.TempDir()
50 cwd := t.TempDir()
51 s, err := NewSession(root, cwd, "provider", "model", "test")
52 if err != nil {
53 t.Fatal(err)
54 }
55 appendPhasedAssistant(t, s)
56 path := s.Path
57 if err := s.Close(); err != nil {
58 t.Fatal(err)
59 }
60 reopened, msgs, err := OpenSession(path)
61 if err != nil {
62 t.Fatalf("OpenSession: %v", err)
63 }
64 defer reopened.Close()
65 if len(msgs) != 1 || len(msgs[0].Content) != 3 {
66 t.Fatalf("msgs = %#v, want 1 message with 3 blocks", msgs)
67 }
68 want := []string{provider.TextPhaseCommentary, provider.TextPhaseFinal, ""}
69 for i, w := range want {
70 if got := phaseOf(msgs[0].Content[i]); got != w {
71 t.Fatalf("block %d phase = %q, want %q", i, got, w)
72 }
73 }
74}
75
76func TestSessionPhaseResumeFork(t *testing.T) {
77 root := t.TempDir()
78 cwd := t.TempDir()
79 s, err := NewSession(root, cwd, "provider", "model", "test")
80 if err != nil {
81 t.Fatal(err)
82 }
83 if err := s.AppendMessage(provider.Message{
84 Role: provider.RoleUser,
85 Content: []provider.Content{provider.TextBlock{Text: "hi"}},
86 }); err != nil {
87 t.Fatal(err)
88 }
89 appendPhasedAssistant(t, s)
90 parentPath := s.Path
91 if err := s.Close(); err != nil {
92 t.Fatal(err)
93 }
94
95 // Resume: reopening the same file preserves phases.
96 reopened, msgs, err := OpenSession(parentPath)
97 if err != nil {
98 t.Fatalf("OpenSession: %v", err)
99 }
100 reopened.Close()
101 if len(msgs) != 2 || len(msgs[1].Content) != 3 {
102 t.Fatalf("resume msgs = %#v, want 2 messages", msgs)
103 }
104 if got := phaseOf(msgs[1].Content[0]); got != provider.TextPhaseCommentary {
105 t.Fatalf("resume phase = %q, want commentary", got)
106 }
107
108 // Fork: branching preserves phases.
109 branchPath, err := BranchSession(parentPath, root, cwd, "test", 2)
110 if err != nil {
111 t.Fatalf("BranchSession: %v", err)
112 }
113 branch, branchMsgs, err := OpenSession(branchPath)
114 if err != nil {
115 t.Fatalf("OpenSession(branch): %v", err)
116 }
117 defer branch.Close()
118 if len(branchMsgs) != 2 || len(branchMsgs[1].Content) != 3 {
119 t.Fatalf("fork msgs = %#v, want 2 messages", branchMsgs)
120 }
121 want := []string{provider.TextPhaseCommentary, provider.TextPhaseFinal, ""}
122 for i, w := range want {
123 if got := phaseOf(branchMsgs[1].Content[i]); got != w {
124 t.Fatalf("fork block %d phase = %q, want %q", i, got, w)
125 }
126 }
127}
128
129func TestSessionPhasePortableImportExport(t *testing.T) {
130 root := t.TempDir()
131 cwd := t.TempDir()
132 s, err := NewSession(root, cwd, "provider", "model", "test")
133 if err != nil {
134 t.Fatal(err)
135 }
136 appendPhasedAssistant(t, s)
137 srcPath := s.Path
138 if err := s.Close(); err != nil {
139 t.Fatal(err)
140 }
141 exportPath := filepath.Join(t.TempDir(), "export.json")
142 out, err := ExportSession(srcPath, exportPath)
143 if err != nil {
144 t.Fatalf("ExportSession: %v", err)
145 }
146 importedPath, err := ImportSession(out, root, cwd, "test")
147 if err != nil {
148 t.Fatalf("ImportSession: %v", err)
149 }
150 imported, msgs, err := OpenSession(importedPath)
151 if err != nil {
152 t.Fatalf("OpenSession(imported): %v", err)
153 }
154 defer imported.Close()
155 if len(msgs) != 1 || len(msgs[0].Content) != 3 {
156 t.Fatalf("imported msgs = %#v, want 1 message with 3 blocks", msgs)
157 }
158 want := []string{provider.TextPhaseCommentary, provider.TextPhaseFinal, ""}
159 for i, w := range want {
160 if got := phaseOf(msgs[0].Content[i]); got != w {
161 t.Fatalf("imported block %d phase = %q, want %q", i, got, w)
162 }
163 }
164}
165
166func TestSessionPhaseRetainedProjection(t *testing.T) {
167 msgs := []provider.Message{
168 {Role: provider.RoleAssistant, Content: []provider.Content{
169 provider.TextBlock{Text: "progress", Phase: provider.TextPhaseCommentary},
170 provider.TextBlock{Text: "answer", Phase: provider.TextPhaseFinal},
171 }},
172 }
173 projected := projectProviderMessages(msgs)
174 if len(projected) != 1 || len(projected[0].Content) != 2 {
175 t.Fatalf("projected = %#v, want 2 retained blocks", projected)
176 }
177 if got := phaseOf(projected[0].Content[0]); got != provider.TextPhaseCommentary {
178 t.Fatalf("projected[0] phase = %q, want commentary", got)
179 }
180 if got := phaseOf(projected[0].Content[1]); got != provider.TextPhaseFinal {
181 t.Fatalf("projected[1] phase = %q, want final_answer", got)
182 }
183 // Original must not be mutated by projection.
184 if got := phaseOf(msgs[0].Content[0]); got != provider.TextPhaseCommentary {
185 t.Fatalf("original mutated: phase = %q", got)
186 }
187}
188
189func TestSessionPhaseUnknownPreserved(t *testing.T) {
190 root := t.TempDir()
191 cwd := t.TempDir()
192 s, err := NewSession(root, cwd, "provider", "model", "test")
193 if err != nil {
194 t.Fatal(err)
195 }
196 if err := s.AppendMessage(provider.Message{
197 Role: provider.RoleAssistant,
198 Content: []provider.Content{provider.TextBlock{Text: "odd", Phase: "mystery"}},
199 }); err != nil {
200 t.Fatal(err)
201 }
202 path := s.Path
203 if err := s.Close(); err != nil {
204 t.Fatal(err)
205 }
206 // Raw file must carry the unrecognized phase verbatim.
207 data, err := os.ReadFile(path)
208 if err != nil {
209 t.Fatal(err)
210 }
211 var found bool
212 f, err := os.Open(path)
213 if err != nil {
214 t.Fatal(err)
215 }
216 defer f.Close()
217 if err := forEachJSONLLine(f, func(line []byte) error {
218 var row struct {
219 Message *json.RawMessage `json:"message"`
220 }
221 if err := json.Unmarshal(line, &row); err != nil || row.Message == nil {
222 return nil
223 }
224 var msg struct {
225 Content []map[string]any `json:"content"`
226 }
227 if err := json.Unmarshal(*row.Message, &msg); err != nil {
228 return nil
229 }
230 for _, c := range msg.Content {
231 if c["phase"] == "mystery" {
232 found = true
233 }
234 }
235 return nil
236 }); err != nil {
237 t.Fatal(err)
238 }
239 if !found {
240 t.Fatalf("unrecognized phase not persisted in %s", data)
241 }
242 reopened, msgs, err := OpenSession(path)
243 if err != nil {
244 t.Fatalf("OpenSession: %v", err)
245 }
246 defer reopened.Close()
247 if len(msgs) != 1 || phaseOf(msgs[0].Content[0]) != "mystery" {
248 t.Fatalf("msgs = %#v, want preserved mystery phase", msgs)
249 }
250}