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) (ResidentRuntime, error) {
726 return ResidentTurnRunner(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}