v0.45.0 cb619f7

session_phase_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close