v0.42.0 82578de

resident_journal_test.go RawHistoryFolder

1package subagents
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"os"
8	"path/filepath"
9	"runtime"
10	"strings"
11	"testing"
12	"time"
13	"unicode/utf8"
14
15	"github.com/bnema/zut/packages/core"
16	"github.com/bnema/zut/packages/provider"
17)
18
19func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testing.T) {
20	root := t.TempDir()
21	journal, err := OpenResidentJournal(root, "child-1")
22	if err != nil {
23		t.Fatalf("OpenResidentJournal: %v", err)
24	}
25	defer journal.Close()
26
27	spec := ResidentChildSpec{
28		ID:              "child-1",
29		SessionID:       "child-session-1",
30		RootCacheID:     "root-cache-1",
31		ParentSessionID: "parent-session-1",
32		Provider:        "openai-codex",
33		Model:           "gpt-5.6-terra",
34		Profile:         "reviewer",
35		Tools:           []string{"read", "bash"},
36	}
37	if err := journal.Accept(spec, "review this change"); err != nil {
38		t.Fatalf("Accept: %v", err)
39	}
40
41	records, err := ReadResidentJournal(filepath.Join(root, "child-1", residentTranscriptName))
42	if err != nil {
43		t.Fatalf("ReadResidentJournal: %v", err)
44	}
45	if len(records) != 1 || records[0].Type != residentRecordAccepted {
46		t.Fatalf("records = %#v, want one accepted record", records)
47	}
48	if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Spec.RootCacheID != spec.RootCacheID || records[0].Prompt != "review this change" {
49		t.Fatalf("accepted record = %#v", records[0])
50	}
51
52	meta, err := ReadResidentMetadata(filepath.Join(root, "child-1", residentMetadataName))
53	if err != nil {
54		t.Fatalf("ReadResidentMetadata: %v", err)
55	}
56	if meta.State != ResidentQueued || meta.SessionID != spec.SessionID || meta.RootCacheID != spec.RootCacheID {
57		t.Fatalf("metadata = %#v", meta)
58	}
59	info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName))
60	if err != nil {
61		t.Fatal(err)
62	}
63	if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
64		t.Fatalf("transcript permissions = %o, want no group/other access", info.Mode().Perm())
65	}
66}
67
68func TestResidentJournalReconcilesUsageMetadata(t *testing.T) {
69	root := t.TempDir()
70	journal, err := OpenResidentJournal(root, "usage-child")
71	if err != nil {
72		t.Fatal(err)
73	}
74	spec := ResidentChildSpec{ID: "usage-child", SessionID: "usage-session", Provider: "openai-codex", Model: "gpt-test"}
75	if err := journal.Accept(spec, "task"); err != nil {
76		t.Fatal(err)
77	}
78	journal.ConfigureUsage(272_000, true)
79	turn := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}
80	if err := journal.RecordAgentEvent(core.EvUsage{Usage: turn, Cumulative: turn}); err != nil {
81		t.Fatal(err)
82	}
83	if err := journal.RecordAgentEvent(core.EvUsage{Usage: provider.Usage{}, Cumulative: turn}); err != nil {
84		t.Fatal(err)
85	}
86	if err := journal.Close(); err != nil {
87		t.Fatal(err)
88	}
89	metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
90	if err != nil {
91		t.Fatal(err)
92	}
93	if metadata.Usage != turn || metadata.ContextUsed != 207_000 || metadata.ContextMax != 272_000 || !metadata.Subscription {
94		t.Fatalf("metadata = %#v, want durable usage projection", metadata)
95	}
96}
97
98func TestResidentJournalSkipsDuplicateUsageRecord(t *testing.T) {
99	root := t.TempDir()
100	journal, err := OpenResidentJournal(root, "duplicate-usage")
101	if err != nil {
102		t.Fatal(err)
103	}
104	t.Cleanup(func() { _ = journal.Close() })
105	spec := ResidentChildSpec{ID: "duplicate-usage", SessionID: "usage-session", Provider: "openai", Model: "gpt-test"}
106	if err := journal.Accept(spec, "task"); err != nil {
107		t.Fatal(err)
108	}
109	journal.ConfigureUsage(128_000, false)
110	usage := provider.Usage{InputTokens: 10_000, OutputTokens: 500}
111	event := core.EvUsage{Usage: usage, Cumulative: usage}
112	if err := journal.RecordAgentEvent(event); err != nil {
113		t.Fatal(err)
114	}
115	if err := journal.RecordAgentEvent(event); err != nil {
116		t.Fatal(err)
117	}
118
119	records, err := ReadResidentJournal(filepath.Join(journal.Dir(), residentTranscriptName))
120	if err != nil {
121		t.Fatal(err)
122	}
123	if got := len(records); got != 2 {
124		t.Fatalf("journal records = %d, want accepted record plus one usage record", got)
125	}
126}
127
128func TestResidentJournalDuplicateUsageRetriesFailedMetadataProjection(t *testing.T) {
129	root := t.TempDir()
130	journal, err := OpenResidentJournal(root, "retry-usage-projection")
131	if err != nil {
132		t.Fatal(err)
133	}
134	t.Cleanup(func() { _ = journal.Close() })
135	spec := ResidentChildSpec{ID: "retry-usage-projection", SessionID: "usage-session", Provider: "openai", Model: "gpt-test"}
136	if err := journal.Accept(spec, "task"); err != nil {
137		t.Fatal(err)
138	}
139	journal.ConfigureUsage(128_000, false)
140	metadataPath := filepath.Join(journal.Dir(), residentMetadataName)
141	originalMetadata, err := os.ReadFile(metadataPath)
142	if err != nil {
143		t.Fatal(err)
144	}
145	if err := os.WriteFile(metadataPath, []byte("not-json"), 0o600); err != nil {
146		t.Fatal(err)
147	}
148
149	usage := provider.Usage{InputTokens: 10_000, OutputTokens: 500}
150	event := core.EvUsage{Usage: usage, Cumulative: usage}
151	if err := journal.RecordAgentEvent(event); err == nil {
152		t.Fatal("first usage projection succeeded with malformed metadata")
153	}
154	if err := os.WriteFile(metadataPath, originalMetadata, 0o600); err != nil {
155		t.Fatal(err)
156	}
157	if err := journal.RecordAgentEvent(event); err != nil {
158		t.Fatalf("retry identical usage: %v", err)
159	}
160
161	records, err := ReadResidentJournal(filepath.Join(journal.Dir(), residentTranscriptName))
162	if err != nil {
163		t.Fatal(err)
164	}
165	if got := len(records); got != 2 {
166		t.Fatalf("journal records = %d, want accepted record plus one usage record", got)
167	}
168	metadata, err := ReadResidentMetadata(metadataPath)
169	if err != nil {
170		t.Fatal(err)
171	}
172	if metadata.Usage != usage || metadata.ContextUsed != usage.PromptTokens() {
173		t.Fatalf("metadata usage = %+v, context = %d; want %+v, %d", metadata.Usage, metadata.ContextUsed, usage, usage.PromptTokens())
174	}
175}
176
177func TestReconcileResidentJournalRetainsRootCacheID(t *testing.T) {
178	root := t.TempDir()
179	journal, err := OpenResidentJournal(root, "cache-child")
180	if err != nil {
181		t.Fatal(err)
182	}
183	spec := ResidentChildSpec{ID: "cache-child", SessionID: "child-session", RootCacheID: "root-cache", Provider: "openai", Model: "gpt-test"}
184	if err := journal.Accept(spec, "task"); err != nil {
185		t.Fatal(err)
186	}
187	if err := journal.Close(); err != nil {
188		t.Fatal(err)
189	}
190	metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
191	if err != nil {
192		t.Fatal(err)
193	}
194	if metadata.RootCacheID != spec.RootCacheID {
195		t.Fatalf("RootCacheID = %q, want %q", metadata.RootCacheID, spec.RootCacheID)
196	}
197}
198
199func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) {
200	root := t.TempDir()
201	journal, err := OpenResidentJournal(root, "interrupted-usage")
202	if err != nil {
203		t.Fatal(err)
204	}
205	t.Cleanup(func() { _ = journal.Close() })
206	spec := ResidentChildSpec{ID: "interrupted-usage", SessionID: "usage-session", Provider: "openai", Model: "gpt-test"}
207	if err := journal.Accept(spec, "task"); err != nil {
208		t.Fatal(err)
209	}
210	journal.ConfigureUsage(272_000, false)
211	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
212		t.Fatal(err)
213	}
214	if err := journal.RecordTurnInterrupted(spec, "turn-1"); err != nil {
215		t.Fatal(err)
216	}
217	usage := provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000}
218	if err := journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}); err != nil {
219		t.Fatal(err)
220	}
221	metadata, err := ReadResidentMetadata(filepath.Join(journal.Dir(), residentMetadataName))
222	if err != nil {
223		t.Fatal(err)
224	}
225	if metadata.State != ResidentInterrupted || metadata.Usage != usage || metadata.ContextUsed != 207_000 {
226		t.Fatalf("metadata = %#v, want interrupted state with latest usage", metadata)
227	}
228}
229
230func TestResidentProjectionSkipsIdenticalRewrite(t *testing.T) {
231	if runtime.GOOS == "windows" {
232		t.Skip("no-follow projection reads are unavailable on Windows")
233	}
234	dir := t.TempDir()
235	metadata := ResidentMetadata{
236		Version:   residentJournalVersion,
237		ID:        "projection-child",
238		SessionID: "projection-session",
239		State:     ResidentIdle,
240		UpdatedAt: time.Unix(1_700_000_000, 0).UTC(),
241	}
242	if err := writeResidentMetadata(dir, metadata); err != nil {
243		t.Fatal(err)
244	}
245	path := filepath.Join(dir, residentMetadataName)
246	oldTime := time.Unix(1_600_000_000, 0)
247	if err := os.Chtimes(path, oldTime, oldTime); err != nil {
248		t.Fatal(err)
249	}
250
251	if err := writeResidentMetadata(dir, metadata); err != nil {
252		t.Fatal(err)
253	}
254	info, err := os.Stat(path)
255	if err != nil {
256		t.Fatal(err)
257	}
258	if !info.ModTime().Equal(oldTime) {
259		t.Fatalf("identical projection rewrite changed mtime to %v", info.ModTime())
260	}
261
262	metadata.State = ResidentStopped
263	if err := writeResidentMetadata(dir, metadata); err != nil {
264		t.Fatal(err)
265	}
266	info, err = os.Stat(path)
267	if err != nil {
268		t.Fatal(err)
269	}
270	if info.ModTime().Equal(oldTime) {
271		t.Fatal("changed projection was not rewritten")
272	}
273}
274
275func TestResidentProjectionReplacesSymlinkInsteadOfFollowingIt(t *testing.T) {
276	if runtime.GOOS == "windows" {
277		t.Skip("symlink creation may require elevated privileges on Windows")
278	}
279	dir := t.TempDir()
280	metadata := ResidentMetadata{Version: residentJournalVersion, ID: "projection-child", SessionID: "projection-session", State: ResidentIdle}
281	data, err := json.Marshal(metadata)
282	if err != nil {
283		t.Fatal(err)
284	}
285	target := filepath.Join(dir, "target.json")
286	if err := os.WriteFile(target, data, 0o600); err != nil {
287		t.Fatal(err)
288	}
289	path := filepath.Join(dir, residentMetadataName)
290	if err := os.Symlink(target, path); err != nil {
291		t.Fatal(err)
292	}
293
294	if err := writeResidentMetadata(dir, metadata); err != nil {
295		t.Fatal(err)
296	}
297	info, err := os.Lstat(path)
298	if err != nil {
299		t.Fatal(err)
300	}
301	if !info.Mode().IsRegular() {
302		t.Fatalf("projection mode = %v, want regular file", info.Mode())
303	}
304}
305
306func TestResidentProjectionRepairsPermissionsForIdenticalData(t *testing.T) {
307	if runtime.GOOS == "windows" {
308		t.Skip("POSIX permission bits are not enforced on Windows")
309	}
310	dir := t.TempDir()
311	metadata := ResidentMetadata{Version: residentJournalVersion, ID: "projection-child", SessionID: "projection-session", State: ResidentIdle}
312	data, err := json.Marshal(metadata)
313	if err != nil {
314		t.Fatal(err)
315	}
316	path := filepath.Join(dir, residentMetadataName)
317	if err := os.WriteFile(path, data, 0o644); err != nil {
318		t.Fatal(err)
319	}
320	if err := os.Chmod(path, 0o644); err != nil {
321		t.Fatal(err)
322	}
323
324	if err := writeResidentMetadata(dir, metadata); err != nil {
325		t.Fatal(err)
326	}
327	info, err := os.Stat(path)
328	if err != nil {
329		t.Fatal(err)
330	}
331	if got := info.Mode().Perm(); got != 0o600 {
332		t.Fatalf("projection permissions = %o, want 600", got)
333	}
334}
335
336func TestOpenResidentJournalRejectsDotChildIDs(t *testing.T) {
337	for _, childID := range []string{".", "..", "nested/child"} {
338		t.Run(childID, func(t *testing.T) {
339			if _, err := OpenResidentJournal(t.TempDir(), childID); err == nil {
340				t.Fatalf("OpenResidentJournal(%q) succeeded", childID)
341			}
342		})
343	}
344}
345
346func TestResidentJournalPersistsFinalizedAgentEvents(t *testing.T) {
347	root := t.TempDir()
348	journal, err := OpenResidentJournal(root, "child-events")
349	if err != nil {
350		t.Fatal(err)
351	}
352	spec := ResidentChildSpec{ID: "child-events", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
353	if err := journal.Accept(spec, "task"); err != nil {
354		t.Fatal(err)
355	}
356	message := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}
357	if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: message}); err != nil {
358		t.Fatal(err)
359	}
360	if err := journal.RecordAgentEvent(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
361		t.Fatal(err)
362	}
363	if err := journal.Close(); err != nil {
364		t.Fatal(err)
365	}
366	records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
367	if err != nil {
368		t.Fatal(err)
369	}
370	if len(records) != 3 || records[1].Type != residentRecordAssistant || len(records[1].Message) == 0 || records[2].Type != residentRecordToolCall || records[2].ToolName != "bash" {
371		t.Fatalf("records = %#v", records)
372	}
373}
374
375func TestReconcileResidentJournalInterruptsStartedTurnWithoutReplay(t *testing.T) {
376	root := t.TempDir()
377	journal, err := OpenResidentJournal(root, "child-2")
378	if err != nil {
379		t.Fatal(err)
380	}
381	spec := ResidentChildSpec{ID: "child-2", SessionID: "child-session-2", InitialTurnID: "initial-turn", Provider: "openai", Model: "gpt-5"}
382	if err := journal.Accept(spec, "do work"); err != nil {
383		t.Fatal(err)
384	}
385	if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
386		t.Fatal(err)
387	}
388	if err := journal.Close(); err != nil {
389		t.Fatal(err)
390	}
391
392	metadata, err := ReconcileResidentJournal(filepath.Join(root, "child-2"))
393	if err != nil {
394		t.Fatalf("ReconcileResidentJournal: %v", err)
395	}
396	if metadata.State != ResidentInterrupted {
397		t.Fatalf("state = %q, want interrupted", metadata.State)
398	}
399	records, err := ReadResidentJournal(filepath.Join(root, "child-2", residentTranscriptName))
400	if err != nil {
401		t.Fatal(err)
402	}
403	if got := records[len(records)-1].Type; got != residentRecordInterrupted {
404		t.Fatalf("last record = %q, want interruption", got)
405	}
406	if _, err := ReconcileResidentJournal(filepath.Join(root, "child-2")); err != nil {
407		t.Fatalf("repeat reconciliation: %v", err)
408	}
409	repeated, err := ReadResidentJournal(filepath.Join(root, "child-2", residentTranscriptName))
410	if err != nil {
411		t.Fatal(err)
412	}
413	if len(repeated) != len(records) {
414		t.Fatalf("repeat reconciliation appended records: %d -> %d", len(records), len(repeated))
415	}
416}
417
418func TestReconcileResidentJournalInterruptsQueuedInitialTurnOnce(t *testing.T) {
419	root := t.TempDir()
420	journal, err := OpenResidentJournal(root, "queued-child")
421	if err != nil {
422		t.Fatal(err)
423	}
424	spec := ResidentChildSpec{ID: "queued-child", SessionID: "queued-session", InitialTurnID: "initial-turn", Provider: "openai", Model: "gpt-5"}
425	if err := journal.Accept(spec, "do work"); err != nil {
426		t.Fatal(err)
427	}
428	if err := journal.Close(); err != nil {
429		t.Fatal(err)
430	}
431	for attempt := 0; attempt < 2; attempt++ {
432		metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
433		if err != nil || metadata.State != ResidentInterrupted {
434			t.Fatalf("reconcile %d = (%#v, %v)", attempt, metadata, err)
435		}
436	}
437	records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
438	if err != nil {
439		t.Fatal(err)
440	}
441	if len(records) != 2 || records[1].Type != residentRecordInterrupted || records[1].TurnID != spec.InitialTurnID {
442		t.Fatalf("queued initial records = %#v", records)
443	}
444}
445
446func TestReconcileResidentJournalRepairsOnlyDanglingToolCalls(t *testing.T) {
447	root := t.TempDir()
448	journal, err := OpenResidentJournal(root, "tool-pair")
449	if err != nil {
450		t.Fatal(err)
451	}
452	spec := ResidentChildSpec{ID: "tool-pair", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
453	if err := journal.Accept(spec, "task"); err != nil {
454		t.Fatal(err)
455	}
456	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
457		t.Fatal(err)
458	}
459	if err := journal.RecordAgentEvent(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
460		t.Fatal(err)
461	}
462	if err := journal.Close(); err != nil {
463		t.Fatal(err)
464	}
465	if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err != nil {
466		t.Fatal(err)
467	}
468	records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
469	if err != nil {
470		t.Fatal(err)
471	}
472	for _, record := range records {
473		if record.Type == residentRecordToolResult && record.ToolID == "call-1" {
474			return
475		}
476	}
477	t.Fatalf("no repaired tool result in %#v", records)
478}
479
480func TestReconcileResidentJournalRepairsTerminalDanglingCallWithoutInterruptingChild(t *testing.T) {
481	root := t.TempDir()
482	journal, err := OpenResidentJournal(root, "terminal-tool-pair")
483	if err != nil {
484		t.Fatal(err)
485	}
486	spec := ResidentChildSpec{ID: "terminal-tool-pair", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
487	if err := journal.Accept(spec, "task"); err != nil {
488		t.Fatal(err)
489	}
490	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
491		t.Fatal(err)
492	}
493	if err := journal.RecordAgentEvent(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
494		t.Fatal(err)
495	}
496	if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
497		t.Fatal(err)
498	}
499	if err := journal.Close(); err != nil {
500		t.Fatal(err)
501	}
502	metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
503	if err != nil {
504		t.Fatal(err)
505	}
506	if metadata.State != ResidentIdle {
507		t.Fatalf("state = %q, want idle", metadata.State)
508	}
509	records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
510	if err != nil {
511		t.Fatal(err)
512	}
513	if got := records[len(records)-1]; got.Type != residentRecordToolResult || got.ToolID != "call-1" {
514		t.Fatalf("last record = %#v, want repaired tool result", got)
515	}
516}
517
518func TestReconcileResidentJournalRebuildsMissingTerminalResult(t *testing.T) {
519	root := t.TempDir()
520	journal, err := OpenResidentJournal(root, "terminal-result")
521	if err != nil {
522		t.Fatal(err)
523	}
524	spec := ResidentChildSpec{ID: "terminal-result", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
525	if err := journal.Accept(spec, "task"); err != nil {
526		t.Fatal(err)
527	}
528	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
529		t.Fatal(err)
530	}
531	if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "recovered final answer"}}}}); err != nil {
532		t.Fatal(err)
533	}
534	if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
535		t.Fatal(err)
536	}
537	if err := journal.Close(); err != nil {
538		t.Fatal(err)
539	}
540	resultPath := filepath.Join(root, spec.ID, residentResultName)
541	if err := os.WriteFile(resultPath, []byte("not json"), 0o600); err != nil {
542		t.Fatal(err)
543	}
544	if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err != nil {
545		t.Fatal(err)
546	}
547	result, err := ReadResidentResult(resultPath)
548	if err != nil {
549		t.Fatal(err)
550	}
551	if result.TurnID != "turn-1" || result.State != ResidentIdle || result.Summary != "recovered final answer" {
552		t.Fatalf("result = %#v", result)
553	}
554}
555
556func TestReconcileResidentJournalPreservesTerminalLifecycleTime(t *testing.T) {
557	root := t.TempDir()
558	journal, err := OpenResidentJournal(root, "terminal-time")
559	if err != nil {
560		t.Fatal(err)
561	}
562	spec := ResidentChildSpec{ID: "terminal-time", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
563	if err := journal.Accept(spec, "task"); err != nil {
564		t.Fatal(err)
565	}
566	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
567		t.Fatal(err)
568	}
569	if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
570		t.Fatal(err)
571	}
572	if err := journal.Close(); err != nil {
573		t.Fatal(err)
574	}
575	records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
576	if err != nil {
577		t.Fatal(err)
578	}
579	want := records[len(records)-1].Time
580	metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
581	if err != nil {
582		t.Fatal(err)
583	}
584	if !metadata.UpdatedAt.Equal(want) {
585		t.Fatalf("updated_at = %s, want terminal record time %s", metadata.UpdatedAt, want)
586	}
587}
588
589func TestResidentJournalStoresBoundedFinalAssistantSummary(t *testing.T) {
590	root := t.TempDir()
591	journal, err := OpenResidentJournal(root, "final-summary")
592	if err != nil {
593		t.Fatal(err)
594	}
595	defer journal.Close()
596	spec := ResidentChildSpec{ID: "final-summary", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
597	if err := journal.Accept(spec, "task"); err != nil {
598		t.Fatal(err)
599	}
600	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
601		t.Fatal(err)
602	}
603	if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "the child final answer"}}}}); err != nil {
604		t.Fatal(err)
605	}
606	if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
607		t.Fatal(err)
608	}
609	result, err := ReadResidentResult(filepath.Join(root, spec.ID, residentResultName))
610	if err != nil {
611		t.Fatal(err)
612	}
613	if result.Summary != "the child final answer" {
614		t.Fatalf("result summary = %q", result.Summary)
615	}
616}
617
618func TestTruncateResidentResultSummaryPreservesUTF8Boundary(t *testing.T) {
619	if residentResultSummaryBytes != 256<<10 {
620		t.Fatalf("summary limit = %d, want %d", residentResultSummaryBytes, 256<<10)
621	}
622	text := strings.Repeat("é", residentResultSummaryBytes)
623	summary := truncateResidentResultSummary(text)
624	if !utf8.ValidString(summary) || !strings.HasSuffix(summary, "…") || len(summary) > residentResultSummaryBytes {
625		t.Fatalf("summary = %q", summary[:min(len(summary), 32)])
626	}
627}
628
629func TestReconcileResidentJournalRepairsLegacyFalseRecovery(t *testing.T) {
630	root := t.TempDir()
631	journal, err := OpenResidentJournal(root, "legacy-race")
632	if err != nil {
633		t.Fatal(err)
634	}
635	spec := ResidentChildSpec{ID: "legacy-race", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
636	if err := journal.Accept(spec, "task"); err != nil {
637		t.Fatal(err)
638	}
639	if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
640		t.Fatal(err)
641	}
642	if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordToolCall, Time: time.Now().UTC(), ToolID: "call-1", ToolName: "bash", ToolArgs: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
643		t.Fatal(err)
644	}
645	if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordToolResult, Time: time.Now().UTC(), ToolID: "call-1", ToolResult: json.RawMessage(`{"Content":[{"text":"tool interrupted by resident host restart"}],"IsError":true}`)}); err != nil {
646		t.Fatal(err)
647	}
648	if err := journal.RecordTurnInterrupted(spec, spec.InitialTurnID); err != nil {
649		t.Fatal(err)
650	}
651	if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordToolResult, Time: time.Now().UTC(), ToolID: "call-1", ToolResult: json.RawMessage(`{"Content":[{"text":"ok"}],"IsError":false}`)}); err != nil {
652		t.Fatal(err)
653	}
654	if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, nil); err != nil {
655		t.Fatal(err)
656	}
657	if err := journal.Close(); err != nil {
658		t.Fatal(err)
659	}
660
661	metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
662	if err != nil {
663		t.Fatal(err)
664	}
665	if metadata.State != ResidentIdle {
666		t.Fatalf("state = %q, want idle", metadata.State)
667	}
668	records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
669	if err != nil {
670		t.Fatal(err)
671	}
672	for _, record := range records {
673		if record.Type == residentRecordInterrupted || isSyntheticInterruption(record.ToolResult) {
674			t.Fatalf("repaired records retained false recovery: %#v", record)
675		}
676	}
677	backups, err := filepath.Glob(filepath.Join(root, spec.ID, ".transcript-backup-*"))
678	if err != nil {
679		t.Fatal(err)
680	}
681	if len(backups) != 1 {
682		t.Fatalf("backups = %v, want one", backups)
683	}
684}
685
686func TestReconcileResidentJournalTranslatesV2BudgetHistory(t *testing.T) {
687	for _, tc := range []struct {
688		name    string
689		outcome string
690		want    ResidentState
691	}{
692		{name: "exhausted stop is failure", outcome: residentV2OutcomeBudgetExhausted, want: ResidentFailed},
693		{name: "completed before check is success", outcome: residentV2OutcomeCompletedBudgetExhausted, want: ResidentCompleted},
694	} {
695		t.Run(tc.name, func(t *testing.T) {
696			root := t.TempDir()
697			journal, err := OpenResidentJournal(root, "v2-child")
698			if err != nil {
699				t.Fatal(err)
700			}
701			spec := ResidentChildSpec{ID: "v2-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test", Required: true}
702			if err := journal.Accept(spec, "task"); err != nil {
703				t.Fatal(err)
704			}
705			// A historical recovery follow-up: the v2 host accepted it
706			// with a budget baseline on the turn.accepted record, then
707			// ran and finished it with the historical outcome.
708			if err := journal.AcceptFollowUp(spec, "turn-2", "continue"); err != nil {
709				t.Fatal(err)
710			}
711			if err := journal.RecordTurnStarted(spec, "turn-2"); err != nil {
712				t.Fatal(err)
713			}
714			if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnFinished, Time: time.Now().UTC(), TurnID: "turn-2", Outcome: tc.outcome}); err != nil {
715				t.Fatal(err)
716			}
717			if err := journal.Close(); err != nil {
718				t.Fatal(err)
719			}
720			downgradeJournalVersion(t, filepath.Join(root, spec.ID, residentTranscriptName))
721			transcript, err := os.ReadFile(filepath.Join(root, spec.ID, residentTranscriptName))
722			if err != nil {
723				t.Fatal(err)
724			}
725			factory := func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
726				return func(context.Context, string) error { return nil }, nil
727			}
728			manager := NewResidentManager(root, factory)
729			t.Cleanup(func() { _ = manager.Close(context.Background()) })
730			completions := make(chan ResidentCompletion, 1)
731			manager.SetCompletionObserver(func(c ResidentCompletion) { completions <- c })
732			if errs := manager.Reconcile(); len(errs) != 0 {
733				t.Fatal(errs)
734			}
735			snapshot, ok := manager.SnapshotFor(spec.ID)
736			if !ok || snapshot.State != tc.want {
737				t.Fatalf("reconciled snapshot = %#v, want state %q", snapshot, tc.want)
738			}
739			wantUnmet := 0
740			if tc.want == ResidentFailed {
741				wantUnmet = 1
742			}
743			if unmet := manager.UnmetRequired(); len(unmet) != wantUnmet {
744				t.Fatalf("unmet required = %#v, want %d", unmet, wantUnmet)
745			}
746			// Translation rebuilds projections but never rewrites the
747			// authoritative transcript.
748			after, err := os.ReadFile(filepath.Join(root, spec.ID, residentTranscriptName))
749			if err != nil || string(after) != string(transcript) {
750				t.Fatalf("reconciliation modified transcript: %v", err)
751			}
752			// An explicit follow-up resolves the history: success
753			// satisfies required work for the exhausted case too.
754			if err := manager.Resume(t.Context(), spec.ID, "finish it"); err != nil {
755				t.Fatal(err)
756			}
757			select {
758			case completion := <-completions:
759				if completion.Err != nil {
760					t.Fatal(completion.Err)
761				}
762			case <-time.After(5 * time.Second):
763				t.Fatal("no terminal notification")
764			}
765			if unmet := manager.UnmetRequired(); len(unmet) != 0 {
766				t.Fatalf("successful resume remains unmet: %#v", unmet)
767			}
768			// A second reconciliation replays nothing.
769			if errs := manager.Reconcile(); len(errs) != 0 {
770				t.Fatal(errs)
771			}
772			snapshot, ok = manager.SnapshotFor(spec.ID)
773			if !ok || snapshot.State != ResidentIdle {
774				t.Fatalf("post-resume snapshot = %#v, want idle", snapshot)
775			}
776		})
777	}
778}
779
780func TestReconcileResidentJournalRejectsUnknownTurnOutcome(t *testing.T) {
781	root := t.TempDir()
782	journal, err := OpenResidentJournal(root, "bogus-child")
783	if err != nil {
784		t.Fatal(err)
785	}
786	spec := ResidentChildSpec{ID: "bogus-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
787	if err := journal.Accept(spec, "task"); err != nil {
788		t.Fatal(err)
789	}
790	if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
791		t.Fatal(err)
792	}
793	if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnFinished, Time: time.Now().UTC(), TurnID: spec.InitialTurnID, Outcome: "bogus"}); err != nil {
794		t.Fatal(err)
795	}
796	if err := journal.Close(); err != nil {
797		t.Fatal(err)
798	}
799	if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err == nil {
800		t.Fatal("unknown turn outcome reconciled without error")
801	}
802}
803
804func TestReadResidentResultTranslatesArchivedV2BudgetExhaustion(t *testing.T) {
805	root := t.TempDir()
806	journal, err := OpenResidentJournal(root, "v2-result")
807	if err != nil {
808		t.Fatal(err)
809	}
810	spec := ResidentChildSpec{ID: "v2-result", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
811	if err := journal.Accept(spec, "task"); err != nil {
812		t.Fatal(err)
813	}
814	if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
815		t.Fatal(err)
816	}
817	if err := journal.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnFinished, Time: time.Now().UTC(), TurnID: spec.InitialTurnID, Outcome: residentV2OutcomeBudgetExhausted}); err != nil {
818		t.Fatal(err)
819	}
820	if err := journal.Close(); err != nil {
821		t.Fatal(err)
822	}
823	downgradeJournalVersion(t, filepath.Join(root, spec.ID, residentTranscriptName))
824	// An archived v2 result.json carries the historical state, handoff,
825	// and error code. The reader translates the state to failure while
826	// keeping the archived bytes readable as provenance.
827	archived, err := json.Marshal(ResidentResult{Version: residentJournalVersionV2, ID: spec.ID, TurnID: spec.InitialTurnID, State: ResidentState(residentV2OutcomeBudgetExhausted), Summary: "partial", Handoff: "archived handoff", ErrorCode: residentV2ErrorBudgetExhausted})
828	if err != nil {
829		t.Fatal(err)
830	}
831	if err := os.WriteFile(filepath.Join(root, spec.ID, residentResultName), archived, 0o600); err != nil {
832		t.Fatal(err)
833	}
834	result, err := ReadResidentResult(filepath.Join(root, spec.ID, residentResultName))
835	if err != nil {
836		t.Fatal(err)
837	}
838	if result.State != ResidentFailed || result.Handoff != "archived handoff" || result.ErrorCode != residentV2ErrorBudgetExhausted {
839		t.Fatalf("translated result = %#v", result)
840	}
841}
842
843func TestReconcileResidentJournalRejectsUnknownVersion(t *testing.T) {
844	root := t.TempDir()
845	journal, err := OpenResidentJournal(root, "future-child")
846	if err != nil {
847		t.Fatal(err)
848	}
849	spec := ResidentChildSpec{ID: "future-child", SessionID: "child-session", Provider: "openai", Model: "test"}
850	if err := journal.Accept(spec, "task"); err != nil {
851		t.Fatal(err)
852	}
853	if err := journal.Close(); err != nil {
854		t.Fatal(err)
855	}
856	transcript := filepath.Join(root, spec.ID, residentTranscriptName)
857	data, err := os.ReadFile(transcript)
858	if err != nil {
859		t.Fatal(err)
860	}
861	future := strings.Replace(string(data), `"version":3`, `"version":99`, 1)
862	if future == string(data) {
863		t.Fatal("accepted record has no journal version")
864	}
865	if err := os.WriteFile(transcript, []byte(future), 0o600); err != nil {
866		t.Fatal(err)
867	}
868	if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err == nil {
869		t.Fatal("unknown journal version reconciled without error")
870	}
871}
872
873func TestReconcileResidentJournalRejectsUnknownLaterRecordVersion(t *testing.T) {
874	root := t.TempDir()
875	journal, err := OpenResidentJournal(root, "tampered-child")
876	if err != nil {
877		t.Fatal(err)
878	}
879	spec := ResidentChildSpec{ID: "tampered-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
880	if err := journal.Accept(spec, "task"); err != nil {
881		t.Fatal(err)
882	}
883	if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
884		t.Fatal(err)
885	}
886	if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, nil); err != nil {
887		t.Fatal(err)
888	}
889	if err := journal.Close(); err != nil {
890		t.Fatal(err)
891	}
892	// Tamper only a later record: the accepted prefix stays v3, so only
893	// per-record validation can catch this corruption.
894	transcript := filepath.Join(root, spec.ID, residentTranscriptName)
895	data, err := os.ReadFile(transcript)
896	if err != nil {
897		t.Fatal(err)
898	}
899	lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
900	if len(lines) != 3 {
901		t.Fatalf("transcript lines = %d, want 3", len(lines))
902	}
903	lines[2] = strings.Replace(lines[2], `"version":3`, `"version":99`, 1)
904	if !strings.Contains(lines[2], `"version":99`) {
905		t.Fatal("finished record has no journal version")
906	}
907	if err := os.WriteFile(transcript, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
908		t.Fatal(err)
909	}
910	if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); !errors.Is(err, ErrIncompatibleResidentJournal) {
911		t.Fatalf("tampered record reconciled: %v, want incompatible version", err)
912	}
913}
914
915func TestReconcileResidentJournalAcceptsMixedV2V3ResumedJournal(t *testing.T) {
916	root := t.TempDir()
917	journal, err := OpenResidentJournal(root, "mixed-child")
918	if err != nil {
919		t.Fatal(err)
920	}
921	spec := ResidentChildSpec{ID: "mixed-child", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "test"}
922	if err := journal.Accept(spec, "task"); err != nil {
923		t.Fatal(err)
924	}
925	if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
926		t.Fatal(err)
927	}
928	if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, nil); err != nil {
929		t.Fatal(err)
930	}
931	if err := journal.Close(); err != nil {
932		t.Fatal(err)
933	}
934	// A resumed v2 journal keeps its v2 prefix on disk while the new host
935	// appends v3 records. Downgrade only the accepted record to model it.
936	transcript := filepath.Join(root, spec.ID, residentTranscriptName)
937	data, err := os.ReadFile(transcript)
938	if err != nil {
939		t.Fatal(err)
940	}
941	mixed := strings.Replace(string(data), `"version":3`, `"version":2`, 1)
942	if mixed == string(data) {
943		t.Fatal("accepted record has no journal version")
944	}
945	if err := os.WriteFile(transcript, []byte(mixed), 0o600); err != nil {
946		t.Fatal(err)
947	}
948	metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
949	if err != nil {
950		t.Fatal(err)
951	}
952	if metadata.State != ResidentIdle {
953		t.Fatalf("mixed-version state = %q, want idle", metadata.State)
954	}
955}
956
957// downgradeJournalVersion rewrites a freshly written v3 transcript into a
958// faithful v2 archive by lowering every record version and restoring the
959// historical budget bytes (baseline and spec fields) that v3 no longer
960// writes. The v3 reader must ignore those bytes structurally.
961func downgradeJournalVersion(t *testing.T, transcript string) {
962	t.Helper()
963	data, err := os.ReadFile(transcript)
964	if err != nil {
965		t.Fatal(err)
966	}
967	downgraded := strings.ReplaceAll(string(data), `"version":3`, `"version":2`)
968	if downgraded == string(data) {
969		t.Fatal("transcript has no journal version")
970	}
971	// Historical v2 bytes: a budget baseline on the follow-up turn.accepted
972	// record (written by the v2 recovery acceptance) and budget fields on
973	// the accepted spec. Neither exists in the v3 structs, so the reader
974	// must drop them without error.
975	downgraded = strings.Replace(downgraded, `"type":"turn.accepted"`, `"type":"turn.accepted","budget_baseline":100`, 1)
976	downgraded = strings.Replace(downgraded, `"provider":"openai"`, `"provider":"openai","budget_limit":100,"budget_source":"model_context"`, 1)
977	if err := os.WriteFile(transcript, []byte(downgraded), 0o600); err != nil {
978		t.Fatal(err)
979	}
980}
981
982func TestRepairLegacyFalseRecoveryRefusesAmbiguousBlock(t *testing.T) {
983	synthetic := json.RawMessage(`{"Content":[{"text":"tool interrupted by resident host restart"}],"IsError":true}`)
984	real := json.RawMessage(`{"Content":[{"text":"ok"}],"IsError":false}`)
985	records := []residentRecord{
986		{Type: residentRecordTurnStarted, TurnID: "turn-1"},
987		{Type: residentRecordToolResult, ToolID: "call-1", ToolResult: synthetic},
988		{Type: residentRecordToolResult, ToolID: "call-2", ToolResult: synthetic},
989		{Type: residentRecordInterrupted, TurnID: "turn-1"},
990		{Type: residentRecordToolResult, ToolID: "call-1", ToolResult: real},
991	}
992	if repaired, ok := repairLegacyFalseRecovery(records); ok || repaired != nil {
993		t.Fatalf("repairLegacyFalseRecovery = %#v, %v; want no repair", repaired, ok)
994	}
995}
996
997func TestRepairLegacyFalseRecoveryPreservesOtherTurnInterruption(t *testing.T) {
998	synthetic := json.RawMessage(`{"Content":[{"text":"tool interrupted by resident host restart"}],"IsError":true}`)
999	real := json.RawMessage(`{"Content":[{"text":"ok"}],"IsError":false}`)
1000	records := []residentRecord{
1001		{Type: residentRecordTurnStarted, TurnID: "turn-1"},
1002		{Type: residentRecordToolResult, ToolID: "call-1", ToolResult: synthetic},
1003		{Type: residentRecordInterrupted, TurnID: "turn-1"},
1004		{Type: residentRecordInterrupted, TurnID: "turn-2"},
1005		{Type: residentRecordToolResult, ToolID: "call-1", ToolResult: real},
1006	}
1007	if repaired, ok := repairLegacyFalseRecovery(records); ok || repaired != nil {
1008		t.Fatalf("repairLegacyFalseRecovery = %#v, %v; want no repair", repaired, ok)
1009	}
1010}
1011
1012func TestReconcileResidentJournalRejectsConflictingToolPairs(t *testing.T) {
1013	for _, tc := range []struct {
1014		name    string
1015		records []residentRecord
1016		want    string
1017	}{
1018		{
1019			name:    "orphan-result",
1020			records: []residentRecord{{Version: residentJournalVersion, Type: residentRecordToolResult, ToolID: "missing", ToolResult: json.RawMessage(`{"is_error":true}`)}},
1021			want:    "orphan tool result",
1022		},
1023		{
1024			name: "duplicate-result",
1025			records: []residentRecord{
1026				{Version: residentJournalVersion, Type: residentRecordToolCall, ToolID: "call-1", ToolName: "bash", ToolArgs: json.RawMessage(`{"command":"pwd"}`)},
1027				{Version: residentJournalVersion, Type: residentRecordToolResult, ToolID: "call-1", ToolResult: json.RawMessage(`{"is_error":false}`)},
1028				{Version: residentJournalVersion, Type: residentRecordToolResult, ToolID: "call-1", ToolResult: json.RawMessage(`{"is_error":false}`)},
1029			},
1030			want: "duplicate tool result",
1031		},
1032	} {
1033		t.Run(tc.name, func(t *testing.T) {
1034			root := t.TempDir()
1035			journal, err := OpenResidentJournal(root, "corrupt")
1036			if err != nil {
1037				t.Fatal(err)
1038			}
1039			spec := ResidentChildSpec{ID: "corrupt", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
1040			if err := journal.Accept(spec, "task"); err != nil {
1041				t.Fatal(err)
1042			}
1043			for _, record := range tc.records {
1044				record.Time = time.Now().UTC()
1045				if err := journal.appendSync(record); err != nil {
1046					t.Fatal(err)
1047				}
1048			}
1049			if err := journal.Close(); err != nil {
1050				t.Fatal(err)
1051			}
1052			if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err == nil || !strings.Contains(err.Error(), tc.want) {
1053				t.Fatalf("ReconcileResidentJournal error = %v, want %q", err, tc.want)
1054			}
1055		})
1056	}
1057}
1058
1059func TestRecordCompactedRejectsOversizedCheckpoint(t *testing.T) {
1060	root := t.TempDir()
1061	journal, err := OpenResidentJournal(root, "oversized")
1062	if err != nil {
1063		t.Fatal(err)
1064	}
1065	spec := ResidentChildSpec{ID: "oversized", SessionID: "session", Provider: "openai", Model: "gpt-5"}
1066	if err := journal.Accept(spec, "task"); err != nil {
1067		t.Fatal(err)
1068	}
1069	if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "question"}}}}); err != nil {
1070		t.Fatal(err)
1071	}
1072	oversized := []provider.Message{{
1073		Role:    provider.RoleUser,
1074		Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", residentMaxRecordBytes)}},
1075	}}
1076	if err := journal.RecordCompacted(oversized); err == nil || !strings.Contains(err.Error(), "too large") {
1077		t.Fatalf("RecordCompacted error = %v, want oversize rejection", err)
1078	}
1079	transcript := filepath.Join(journal.Dir(), residentTranscriptName)
1080	if err := journal.Close(); err != nil {
1081		t.Fatal(err)
1082	}
1083	records, err := ReadResidentJournal(transcript)
1084	if err != nil {
1085		t.Fatalf("journal unreadable after an oversized checkpoint: %v", err)
1086	}
1087	for _, record := range records {
1088		if record.Type == residentRecordCompacted {
1089			t.Fatal("oversized checkpoint was appended")
1090		}
1091	}
1092	if _, err := ReadResidentTranscriptMessages(filepath.Join(root, spec.ID)); err != nil {
1093		t.Fatalf("transcript unreadable after an oversized checkpoint: %v", err)
1094	}
1095}
1096
1097func TestReconcileResidentJournalKeepsToolBookkeepingAcrossCheckpoint(t *testing.T) {
1098	root := t.TempDir()
1099	journal, err := OpenResidentJournal(root, "checkpoint-repair")
1100	if err != nil {
1101		t.Fatal(err)
1102	}
1103	spec := ResidentChildSpec{ID: "checkpoint-repair", SessionID: "session", Provider: "openai", Model: "gpt-5"}
1104	if err := journal.Accept(spec, "task"); err != nil {
1105		t.Fatal(err)
1106	}
1107	if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "question"}}}}); err != nil {
1108		t.Fatal(err)
1109	}
1110	// The call never produced a result and compaction dropped it from the
1111	// replacement transcript.
1112	if err := journal.RecordAgentEvent(core.EvToolCall{ID: "dropped-call", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
1113		t.Fatal(err)
1114	}
1115	if err := journal.AcceptFollowUp(spec, "turn-1", "follow up"); err != nil {
1116		t.Fatal(err)
1117	}
1118	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
1119		t.Fatal(err)
1120	}
1121	if err := journal.RecordCompacted([]provider.Message{
1122		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "## Context Summary (compacted)\n\ndigest"}}},
1123	}); err != nil {
1124		t.Fatal(err)
1125	}
1126	if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
1127		t.Fatal(err)
1128	}
1129	transcript := filepath.Join(journal.Dir(), residentTranscriptName)
1130	if err := journal.Close(); err != nil {
1131		t.Fatal(err)
1132	}
1133
1134	metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
1135	if err != nil {
1136		t.Fatal(err)
1137	}
1138	if metadata.State != ResidentIdle {
1139		t.Fatalf("reconciled state = %q, want idle", metadata.State)
1140	}
1141	records, err := ReadResidentJournal(transcript)
1142	if err != nil {
1143		t.Fatal(err)
1144	}
1145	for _, record := range records {
1146		if record.Type == residentRecordToolResult {
1147			t.Fatalf("reconciliation repaired a call dropped by compaction: %#v", record)
1148		}
1149	}
1150	messages, err := ReadResidentTranscriptMessages(filepath.Join(root, spec.ID))
1151	if err != nil {
1152		t.Fatal(err)
1153	}
1154	if len(messages) != 1 || messages[0].Role != provider.RoleUser {
1155		t.Fatalf("resumed messages = %#v, want only the checkpoint summary", messages)
1156	}
1157}
1158
1159func TestRecordCompactedAttributesTheStartedTurn(t *testing.T) {
1160	root := t.TempDir()
1161	journal, err := OpenResidentJournal(root, "turn-attribution")
1162	if err != nil {
1163		t.Fatal(err)
1164	}
1165	spec := ResidentChildSpec{ID: "turn-attribution", SessionID: "session", Provider: "openai", Model: "gpt-5"}
1166	if err := journal.Accept(spec, "task"); err != nil {
1167		t.Fatal(err)
1168	}
1169	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
1170		t.Fatal(err)
1171	}
1172	// A follow-up accepted while turn-1 is still running must not steal the
1173	// checkpoint's turn attribution.
1174	if err := journal.AcceptFollowUp(spec, "turn-2", "queued follow up"); err != nil {
1175		t.Fatal(err)
1176	}
1177	if err := journal.RecordCompacted([]provider.Message{
1178		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "## Context Summary (compacted)\n\ndigest"}}},
1179	}); err != nil {
1180		t.Fatal(err)
1181	}
1182	transcript := filepath.Join(journal.Dir(), residentTranscriptName)
1183	if err := journal.Close(); err != nil {
1184		t.Fatal(err)
1185	}
1186	records, err := ReadResidentJournal(transcript)
1187	if err != nil {
1188		t.Fatal(err)
1189	}
1190	for _, record := range records {
1191		if record.Type != residentRecordCompacted {
1192			continue
1193		}
1194		if record.TurnID != "turn-1" {
1195			t.Fatalf("checkpoint turn ID = %q, want the executing turn", record.TurnID)
1196		}
1197		return
1198	}
1199	t.Fatal("journal has no compaction checkpoint")
1200}
1201
1202func TestReconcileResidentJournalIsIdempotentForCheckpointResults(t *testing.T) {
1203	root := t.TempDir()
1204	journal, err := OpenResidentJournal(root, "idempotent-checkpoint")
1205	if err != nil {
1206		t.Fatal(err)
1207	}
1208	spec := ResidentChildSpec{ID: "idempotent-checkpoint", SessionID: "session", Provider: "openai", Model: "gpt-5"}
1209	if err := journal.Accept(spec, "task"); err != nil {
1210		t.Fatal(err)
1211	}
1212	if err := journal.AcceptFollowUp(spec, "turn-1", "follow up"); err != nil {
1213		t.Fatal(err)
1214	}
1215	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
1216		t.Fatal(err)
1217	}
1218	// A checkpoint can carry a result whose call was compacted away. That is
1219	// not repairable work, so reconciliation must stay a no-op.
1220	if err := journal.RecordCompacted([]provider.Message{{
1221		Role: provider.RoleTool,
1222		Content: []provider.Content{provider.ToolResultBlock{
1223			CallID:  "compacted-call",
1224			Content: []provider.Content{provider.TextBlock{Text: "kept result"}},
1225		}},
1226	}}); err != nil {
1227		t.Fatal(err)
1228	}
1229	if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
1230		t.Fatal(err)
1231	}
1232	if err := journal.Close(); err != nil {
1233		t.Fatal(err)
1234	}
1235	first, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
1236	if err != nil {
1237		t.Fatal(err)
1238	}
1239	second, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
1240	if err != nil {
1241		t.Fatal(err)
1242	}
1243	if !first.UpdatedAt.Equal(second.UpdatedAt) {
1244		t.Fatalf("reconciliation rewrote the lifecycle time: %v then %v", first.UpdatedAt, second.UpdatedAt)
1245	}
1246	if second.State != ResidentIdle {
1247		t.Fatalf("reconciled state = %q, want idle", second.State)
1248	}
1249}
1250
1251func TestAppendSyncEnforcesTheReaderRecordBound(t *testing.T) {
1252	root := t.TempDir()
1253	journal, err := OpenResidentJournal(root, "record-bound")
1254	if err != nil {
1255		t.Fatal(err)
1256	}
1257	spec := ResidentChildSpec{ID: "record-bound", SessionID: "session", Provider: "openai", Model: "gpt-5"}
1258	if err := journal.Accept(spec, "task"); err != nil {
1259		t.Fatal(err)
1260	}
1261	_, base := residentAssistantRecordSize(t, "")
1262	if base >= residentMaxRecordBytes-1 {
1263		t.Fatalf("empty assistant record = %d bytes, too large for a boundary check", base)
1264	}
1265	// A JSON record of max-1 bytes produces a max-byte line with its newline,
1266	// which is exactly what the readers accept.
1267	padding := residentMaxRecordBytes - 1 - base
1268	borderline, borderlineSize := residentAssistantRecordSize(t, strings.Repeat("x", padding))
1269	if borderlineSize != residentMaxRecordBytes-1 {
1270		t.Fatalf("borderline record = %d bytes, want %d", borderlineSize, residentMaxRecordBytes-1)
1271	}
1272	if err := journal.appendSync(borderline); err != nil {
1273		t.Fatalf("appendSync rejected a reader-legal record: %v", err)
1274	}
1275	oversized, oversizedSize := residentAssistantRecordSize(t, strings.Repeat("x", padding+1))
1276	if oversizedSize != residentMaxRecordBytes {
1277		t.Fatalf("oversized record = %d bytes, want %d", oversizedSize, residentMaxRecordBytes)
1278	}
1279	if err := journal.appendSync(oversized); err == nil || !strings.Contains(err.Error(), "too large") {
1280		t.Fatalf("appendSync error = %v, want the reader record bound", err)
1281	}
1282	transcript := filepath.Join(journal.Dir(), residentTranscriptName)
1283	if err := journal.Close(); err != nil {
1284		t.Fatal(err)
1285	}
1286	records, err := ReadResidentJournal(transcript)
1287	if err != nil {
1288		t.Fatalf("journal unreadable after a boundary record: %v", err)
1289	}
1290	if len(records) != 2 {
1291		t.Fatalf("records = %d, want the accepted child and the borderline message", len(records))
1292	}
1293}
1294
1295func residentAssistantRecordSize(t *testing.T, text string) (residentRecord, int) {
1296	t.Helper()
1297	message, err := json.Marshal(provider.Message{
1298		Role:    provider.RoleAssistant,
1299		Content: []provider.Content{provider.TextBlock{Text: text}},
1300	})
1301	if err != nil {
1302		t.Fatal(err)
1303	}
1304	record := residentRecord{Version: residentJournalVersion, Type: residentRecordAssistant, Message: message}
1305	data, err := json.Marshal(record)
1306	if err != nil {
1307		t.Fatal(err)
1308	}
1309	return record, len(data)
1310}
1311
1312func TestReconcileResidentJournalRepairsDanglingCheckpointCall(t *testing.T) {
1313	root := t.TempDir()
1314	journal, err := OpenResidentJournal(root, "checkpoint-dangling")
1315	if err != nil {
1316		t.Fatal(err)
1317	}
1318	spec := ResidentChildSpec{ID: "checkpoint-dangling", SessionID: "session", Provider: "openai", Model: "gpt-5"}
1319	if err := journal.Accept(spec, "task"); err != nil {
1320		t.Fatal(err)
1321	}
1322	if err := journal.AcceptFollowUp(spec, "turn-1", "follow up"); err != nil {
1323		t.Fatal(err)
1324	}
1325	if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
1326		t.Fatal(err)
1327	}
1328	// The checkpoint kept a call whose result never arrived.
1329	if err := journal.RecordCompacted([]provider.Message{{
1330		Role: provider.RoleAssistant,
1331		Content: []provider.Content{provider.ToolCallBlock{
1332			ID:        "compacted-call",
1333			Name:      "bash",
1334			Arguments: json.RawMessage(`{"command":"pwd"}`),
1335		}},
1336	}}); err != nil {
1337		t.Fatal(err)
1338	}
1339	if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
1340		t.Fatal(err)
1341	}
1342	transcript := filepath.Join(journal.Dir(), residentTranscriptName)
1343	if err := journal.Close(); err != nil {
1344		t.Fatal(err)
1345	}
1346	dir := filepath.Join(root, spec.ID)
1347	if _, err := ReconcileResidentJournal(dir); err != nil {
1348		t.Fatal(err)
1349	}
1350	records, err := ReadResidentJournal(transcript)
1351	if err != nil {
1352		t.Fatal(err)
1353	}
1354	repaired := 0
1355	for _, record := range records {
1356		if record.Type != residentRecordToolResult || record.ToolID != "compacted-call" {
1357			continue
1358		}
1359		if !isSyntheticInterruption(record.ToolResult) {
1360			t.Fatalf("repaired result = %s, want the interruption result", record.ToolResult)
1361		}
1362		repaired++
1363	}
1364	if repaired != 1 {
1365		t.Fatalf("repaired results = %d, want exactly one synthetic result", repaired)
1366	}
1367	// Once the repair is durable, later reconciliations must be stable no-ops.
1368	second, err := ReconcileResidentJournal(dir)
1369	if err != nil {
1370		t.Fatal(err)
1371	}
1372	third, err := ReconcileResidentJournal(dir)
1373	if err != nil {
1374		t.Fatal(err)
1375	}
1376	if !second.UpdatedAt.Equal(third.UpdatedAt) {
1377		t.Fatalf("reconciliation kept rewriting the lifecycle time: %v then %v", second.UpdatedAt, third.UpdatedAt)
1378	}
1379	if records, err = ReadResidentJournal(transcript); err != nil {
1380		t.Fatal(err)
1381	}
1382	results := 0
1383	for _, record := range records {
1384		if record.Type == residentRecordToolResult && record.ToolID == "compacted-call" {
1385			results++
1386		}
1387	}
1388	if results != 1 {
1389		t.Fatalf("tool results = %d, want the single repaired result", results)
1390	}
1391}

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