v0.17.0 949637b

resident_journal_test.go RawHistoryFolder

1package subagents
2
3import (
4	"encoding/json"
5	"os"
6	"path/filepath"
7	"runtime"
8	"strings"
9	"testing"
10	"time"
11	"unicode/utf8"
12
13	"github.com/bnema/zut/packages/core"
14	"github.com/bnema/zut/packages/provider"
15)
16
17func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testing.T) {
18	root := t.TempDir()
19	journal, err := OpenResidentJournal(root, "child-1")
20	if err != nil {
21		t.Fatalf("OpenResidentJournal: %v", err)
22	}
23	defer journal.Close()
24
25	spec := ResidentChildSpec{
26		ID:              "child-1",
27		SessionID:       "child-session-1",
28		ParentSessionID: "parent-session-1",
29		Provider:        "openai-codex",
30		Model:           "gpt-5.6-terra",
31		Profile:         "reviewer",
32		Tools:           []string{"read", "bash"},
33	}
34	if err := journal.Accept(spec, "review this change"); err != nil {
35		t.Fatalf("Accept: %v", err)
36	}
37
38	records, err := ReadResidentJournal(filepath.Join(root, "child-1", residentTranscriptName))
39	if err != nil {
40		t.Fatalf("ReadResidentJournal: %v", err)
41	}
42	if len(records) != 1 || records[0].Type != residentRecordAccepted {
43		t.Fatalf("records = %#v, want one accepted record", records)
44	}
45	if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Prompt != "review this change" {
46		t.Fatalf("accepted record = %#v", records[0])
47	}
48
49	meta, err := ReadResidentMetadata(filepath.Join(root, "child-1", residentMetadataName))
50	if err != nil {
51		t.Fatalf("ReadResidentMetadata: %v", err)
52	}
53	if meta.State != ResidentQueued || meta.SessionID != spec.SessionID {
54		t.Fatalf("metadata = %#v", meta)
55	}
56	info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName))
57	if err != nil {
58		t.Fatal(err)
59	}
60	if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
61		t.Fatalf("transcript permissions = %o, want no group/other access", info.Mode().Perm())
62	}
63}
64
65func TestOpenResidentJournalRejectsDotChildIDs(t *testing.T) {
66	for _, childID := range []string{".", "..", "nested/child"} {
67		t.Run(childID, func(t *testing.T) {
68			if _, err := OpenResidentJournal(t.TempDir(), childID); err == nil {
69				t.Fatalf("OpenResidentJournal(%q) succeeded", childID)
70			}
71		})
72	}
73}
74
75func TestResidentJournalPersistsFinalizedAgentEvents(t *testing.T) {
76	root := t.TempDir()
77	journal, err := OpenResidentJournal(root, "child-events")
78	if err != nil {
79		t.Fatal(err)
80	}
81	spec := ResidentChildSpec{ID: "child-events", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
82	if err := journal.Accept(spec, "task"); err != nil {
83		t.Fatal(err)
84	}
85	message := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}
86	if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: message}); err != nil {
87		t.Fatal(err)
88	}
89	if err := journal.RecordAgentEvent(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
90		t.Fatal(err)
91	}
92	if err := journal.Close(); err != nil {
93		t.Fatal(err)
94	}
95	records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
96	if err != nil {
97		t.Fatal(err)
98	}
99	if len(records) != 3 || records[1].Type != residentRecordAssistant || len(records[1].Message) == 0 || records[2].Type != residentRecordToolCall || records[2].ToolName != "bash" {
100		t.Fatalf("records = %#v", records)
101	}
102}
103
104func TestReconcileResidentJournalInterruptsStartedTurnWithoutReplay(t *testing.T) {
105	root := t.TempDir()
106	journal, err := OpenResidentJournal(root, "child-2")
107	if err != nil {
108		t.Fatal(err)
109	}
110	spec := ResidentChildSpec{ID: "child-2", SessionID: "child-session-2", InitialTurnID: "initial-turn", Provider: "openai", Model: "gpt-5"}
111	if err := journal.Accept(spec, "do work"); err != nil {
112		t.Fatal(err)
113	}
114	if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
115		t.Fatal(err)
116	}
117	if err := journal.Close(); err != nil {
118		t.Fatal(err)
119	}
120
121	metadata, err := ReconcileResidentJournal(filepath.Join(root, "child-2"))
122	if err != nil {
123		t.Fatalf("ReconcileResidentJournal: %v", err)
124	}
125	if metadata.State != ResidentInterrupted {
126		t.Fatalf("state = %q, want interrupted", metadata.State)
127	}
128	records, err := ReadResidentJournal(filepath.Join(root, "child-2", residentTranscriptName))
129	if err != nil {
130		t.Fatal(err)
131	}
132	if got := records[len(records)-1].Type; got != residentRecordInterrupted {
133		t.Fatalf("last record = %q, want interruption", got)
134	}
135	if _, err := ReconcileResidentJournal(filepath.Join(root, "child-2")); err != nil {
136		t.Fatalf("repeat reconciliation: %v", err)
137	}
138	repeated, err := ReadResidentJournal(filepath.Join(root, "child-2", residentTranscriptName))
139	if err != nil {
140		t.Fatal(err)
141	}
142	if len(repeated) != len(records) {
143		t.Fatalf("repeat reconciliation appended records: %d -> %d", len(records), len(repeated))
144	}
145}
146
147func TestReconcileResidentJournalInterruptsQueuedInitialTurnOnce(t *testing.T) {
148	root := t.TempDir()
149	journal, err := OpenResidentJournal(root, "queued-child")
150	if err != nil {
151		t.Fatal(err)
152	}
153	spec := ResidentChildSpec{ID: "queued-child", SessionID: "queued-session", InitialTurnID: "initial-turn", Provider: "openai", Model: "gpt-5"}
154	if err := journal.Accept(spec, "do work"); err != nil {
155		t.Fatal(err)
156	}
157	if err := journal.Close(); err != nil {
158		t.Fatal(err)
159	}
160	for attempt := 0; attempt < 2; attempt++ {
161		metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
162		if err != nil || metadata.State != ResidentInterrupted {
163			t.Fatalf("reconcile %d = (%#v, %v)", attempt, metadata, err)
164		}
165	}
166	records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
167	if err != nil {
168		t.Fatal(err)
169	}
170	if len(records) != 2 || records[1].Type != residentRecordInterrupted || records[1].TurnID != spec.InitialTurnID {
171		t.Fatalf("queued initial records = %#v", records)
172	}
173}
174
175func TestReconcileResidentJournalRepairsOnlyDanglingToolCalls(t *testing.T) {
176	root := t.TempDir()
177	journal, err := OpenResidentJournal(root, "tool-pair")
178	if err != nil {
179		t.Fatal(err)
180	}
181	spec := ResidentChildSpec{ID: "tool-pair", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
182	if err := journal.Accept(spec, "task"); err != nil {
183		t.Fatal(err)
184	}
185	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
186		t.Fatal(err)
187	}
188	if err := journal.RecordAgentEvent(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
189		t.Fatal(err)
190	}
191	if err := journal.Close(); err != nil {
192		t.Fatal(err)
193	}
194	if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err != nil {
195		t.Fatal(err)
196	}
197	records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
198	if err != nil {
199		t.Fatal(err)
200	}
201	for _, record := range records {
202		if record.Type == residentRecordToolResult && record.ToolID == "call-1" {
203			return
204		}
205	}
206	t.Fatalf("no repaired tool result in %#v", records)
207}
208
209func TestReconcileResidentJournalRepairsTerminalDanglingCallWithoutInterruptingChild(t *testing.T) {
210	root := t.TempDir()
211	journal, err := OpenResidentJournal(root, "terminal-tool-pair")
212	if err != nil {
213		t.Fatal(err)
214	}
215	spec := ResidentChildSpec{ID: "terminal-tool-pair", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
216	if err := journal.Accept(spec, "task"); err != nil {
217		t.Fatal(err)
218	}
219	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
220		t.Fatal(err)
221	}
222	if err := journal.RecordAgentEvent(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
223		t.Fatal(err)
224	}
225	if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
226		t.Fatal(err)
227	}
228	if err := journal.Close(); err != nil {
229		t.Fatal(err)
230	}
231	metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
232	if err != nil {
233		t.Fatal(err)
234	}
235	if metadata.State != ResidentIdle {
236		t.Fatalf("state = %q, want idle", metadata.State)
237	}
238	records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
239	if err != nil {
240		t.Fatal(err)
241	}
242	if got := records[len(records)-1]; got.Type != residentRecordToolResult || got.ToolID != "call-1" {
243		t.Fatalf("last record = %#v, want repaired tool result", got)
244	}
245}
246
247func TestReconcileResidentJournalRebuildsMissingTerminalResult(t *testing.T) {
248	root := t.TempDir()
249	journal, err := OpenResidentJournal(root, "terminal-result")
250	if err != nil {
251		t.Fatal(err)
252	}
253	spec := ResidentChildSpec{ID: "terminal-result", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
254	if err := journal.Accept(spec, "task"); err != nil {
255		t.Fatal(err)
256	}
257	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
258		t.Fatal(err)
259	}
260	if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "recovered final answer"}}}}); err != nil {
261		t.Fatal(err)
262	}
263	if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
264		t.Fatal(err)
265	}
266	if err := journal.Close(); err != nil {
267		t.Fatal(err)
268	}
269	resultPath := filepath.Join(root, spec.ID, residentResultName)
270	if err := os.WriteFile(resultPath, []byte("not json"), 0o600); err != nil {
271		t.Fatal(err)
272	}
273	if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err != nil {
274		t.Fatal(err)
275	}
276	result, err := ReadResidentResult(resultPath)
277	if err != nil {
278		t.Fatal(err)
279	}
280	if result.TurnID != "turn-1" || result.State != ResidentIdle || result.Summary != "recovered final answer" {
281		t.Fatalf("result = %#v", result)
282	}
283}
284
285func TestReconcileResidentJournalPreservesTerminalLifecycleTime(t *testing.T) {
286	root := t.TempDir()
287	journal, err := OpenResidentJournal(root, "terminal-time")
288	if err != nil {
289		t.Fatal(err)
290	}
291	spec := ResidentChildSpec{ID: "terminal-time", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
292	if err := journal.Accept(spec, "task"); err != nil {
293		t.Fatal(err)
294	}
295	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
296		t.Fatal(err)
297	}
298	if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
299		t.Fatal(err)
300	}
301	if err := journal.Close(); err != nil {
302		t.Fatal(err)
303	}
304	records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
305	if err != nil {
306		t.Fatal(err)
307	}
308	want := records[len(records)-1].Time
309	metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
310	if err != nil {
311		t.Fatal(err)
312	}
313	if !metadata.UpdatedAt.Equal(want) {
314		t.Fatalf("updated_at = %s, want terminal record time %s", metadata.UpdatedAt, want)
315	}
316}
317
318func TestResidentJournalStoresBoundedFinalAssistantSummary(t *testing.T) {
319	root := t.TempDir()
320	journal, err := OpenResidentJournal(root, "final-summary")
321	if err != nil {
322		t.Fatal(err)
323	}
324	defer journal.Close()
325	spec := ResidentChildSpec{ID: "final-summary", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
326	if err := journal.Accept(spec, "task"); err != nil {
327		t.Fatal(err)
328	}
329	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
330		t.Fatal(err)
331	}
332	if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "the child final answer"}}}}); err != nil {
333		t.Fatal(err)
334	}
335	if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
336		t.Fatal(err)
337	}
338	result, err := ReadResidentResult(filepath.Join(root, spec.ID, residentResultName))
339	if err != nil {
340		t.Fatal(err)
341	}
342	if result.Summary != "the child final answer" {
343		t.Fatalf("result summary = %q", result.Summary)
344	}
345}
346
347func TestTruncateResidentResultSummaryPreservesUTF8Boundary(t *testing.T) {
348	if residentResultSummaryBytes != 256<<10 {
349		t.Fatalf("summary limit = %d, want %d", residentResultSummaryBytes, 256<<10)
350	}
351	text := strings.Repeat("é", residentResultSummaryBytes)
352	summary := truncateResidentResultSummary(text)
353	if !utf8.ValidString(summary) || !strings.HasSuffix(summary, "…") || len(summary) > residentResultSummaryBytes {
354		t.Fatalf("summary = %q", summary[:min(len(summary), 32)])
355	}
356}
357
358func TestReconcileResidentJournalRejectsConflictingToolPairs(t *testing.T) {
359	for _, tc := range []struct {
360		name    string
361		records []residentRecord
362		want    string
363	}{
364		{
365			name:    "orphan-result",
366			records: []residentRecord{{Version: residentJournalVersion, Type: residentRecordToolResult, ToolID: "missing", ToolResult: json.RawMessage(`{"is_error":true}`)}},
367			want:    "orphan tool result",
368		},
369		{
370			name: "duplicate-result",
371			records: []residentRecord{
372				{Version: residentJournalVersion, Type: residentRecordToolCall, ToolID: "call-1", ToolName: "bash", ToolArgs: json.RawMessage(`{"command":"pwd"}`)},
373				{Version: residentJournalVersion, Type: residentRecordToolResult, ToolID: "call-1", ToolResult: json.RawMessage(`{"is_error":false}`)},
374				{Version: residentJournalVersion, Type: residentRecordToolResult, ToolID: "call-1", ToolResult: json.RawMessage(`{"is_error":false}`)},
375			},
376			want: "duplicate tool result",
377		},
378	} {
379		t.Run(tc.name, func(t *testing.T) {
380			root := t.TempDir()
381			journal, err := OpenResidentJournal(root, "corrupt")
382			if err != nil {
383				t.Fatal(err)
384			}
385			spec := ResidentChildSpec{ID: "corrupt", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
386			if err := journal.Accept(spec, "task"); err != nil {
387				t.Fatal(err)
388			}
389			for _, record := range tc.records {
390				record.Time = time.Now().UTC()
391				if err := journal.appendSync(record); err != nil {
392					t.Fatal(err)
393				}
394			}
395			if err := journal.Close(); err != nil {
396				t.Fatal(err)
397			}
398			if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err == nil || !strings.Contains(err.Error(), tc.want) {
399				t.Fatalf("ReconcileResidentJournal error = %v, want %q", err, tc.want)
400			}
401		})
402	}
403}

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 477

↑↓ moveenter openesc close