1package subagents
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10 "time"
11 "unicode/utf8"
12
13 "github.com/bnema/zut/packages/core"
14 "github.com/bnema/zut/packages/provider"
15)
16
17func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testing.T) {
18 root := t.TempDir()
19 journal, err := OpenResidentJournal(root, "child-1")
20 if err != nil {
21 t.Fatalf("OpenResidentJournal: %v", err)
22 }
23 defer journal.Close()
24
25 spec := ResidentChildSpec{
26 ID: "child-1",
27 SessionID: "child-session-1",
28 RootCacheID: "root-cache-1",
29 ParentSessionID: "parent-session-1",
30 Provider: "openai-codex",
31 Model: "gpt-5.6-terra",
32 Profile: "reviewer",
33 Tools: []string{"read", "bash"},
34 }
35 if err := journal.Accept(spec, "review this change"); err != nil {
36 t.Fatalf("Accept: %v", err)
37 }
38
39 records, err := ReadResidentJournal(filepath.Join(root, "child-1", residentTranscriptName))
40 if err != nil {
41 t.Fatalf("ReadResidentJournal: %v", err)
42 }
43 if len(records) != 1 || records[0].Type != residentRecordAccepted {
44 t.Fatalf("records = %#v, want one accepted record", records)
45 }
46 if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Spec.RootCacheID != spec.RootCacheID || records[0].Prompt != "review this change" {
47 t.Fatalf("accepted record = %#v", records[0])
48 }
49
50 meta, err := ReadResidentMetadata(filepath.Join(root, "child-1", residentMetadataName))
51 if err != nil {
52 t.Fatalf("ReadResidentMetadata: %v", err)
53 }
54 if meta.State != ResidentQueued || meta.SessionID != spec.SessionID || meta.RootCacheID != spec.RootCacheID {
55 t.Fatalf("metadata = %#v", meta)
56 }
57 info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName))
58 if err != nil {
59 t.Fatal(err)
60 }
61 if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
62 t.Fatalf("transcript permissions = %o, want no group/other access", info.Mode().Perm())
63 }
64}
65
66func TestResidentJournalReconcilesUsageMetadata(t *testing.T) {
67 root := t.TempDir()
68 journal, err := OpenResidentJournal(root, "usage-child")
69 if err != nil {
70 t.Fatal(err)
71 }
72 spec := ResidentChildSpec{ID: "usage-child", SessionID: "usage-session", Provider: "openai-codex", Model: "gpt-test"}
73 if err := journal.Accept(spec, "task"); err != nil {
74 t.Fatal(err)
75 }
76 journal.ConfigureUsage(272_000, true)
77 turn := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}
78 if err := journal.RecordAgentEvent(core.EvUsage{Usage: turn, Cumulative: turn}); err != nil {
79 t.Fatal(err)
80 }
81 if err := journal.RecordAgentEvent(core.EvUsage{Usage: provider.Usage{}, Cumulative: turn}); err != nil {
82 t.Fatal(err)
83 }
84 if err := journal.Close(); err != nil {
85 t.Fatal(err)
86 }
87 metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
88 if err != nil {
89 t.Fatal(err)
90 }
91 if metadata.Usage != turn || metadata.ContextUsed != 207_000 || metadata.ContextMax != 272_000 || !metadata.Subscription {
92 t.Fatalf("metadata = %#v, want durable usage projection", metadata)
93 }
94}
95
96func TestResidentJournalSkipsDuplicateUsageRecord(t *testing.T) {
97 root := t.TempDir()
98 journal, err := OpenResidentJournal(root, "duplicate-usage")
99 if err != nil {
100 t.Fatal(err)
101 }
102 t.Cleanup(func() { _ = journal.Close() })
103 spec := ResidentChildSpec{ID: "duplicate-usage", SessionID: "usage-session", Provider: "openai", Model: "gpt-test"}
104 if err := journal.Accept(spec, "task"); err != nil {
105 t.Fatal(err)
106 }
107 journal.ConfigureUsage(128_000, false)
108 usage := provider.Usage{InputTokens: 10_000, OutputTokens: 500}
109 event := core.EvUsage{Usage: usage, Cumulative: usage}
110 if err := journal.RecordAgentEvent(event); err != nil {
111 t.Fatal(err)
112 }
113 if err := journal.RecordAgentEvent(event); err != nil {
114 t.Fatal(err)
115 }
116
117 records, err := ReadResidentJournal(filepath.Join(journal.Dir(), residentTranscriptName))
118 if err != nil {
119 t.Fatal(err)
120 }
121 if got := len(records); got != 2 {
122 t.Fatalf("journal records = %d, want accepted record plus one usage record", got)
123 }
124}
125
126func TestResidentJournalDuplicateUsageRetriesFailedMetadataProjection(t *testing.T) {
127 root := t.TempDir()
128 journal, err := OpenResidentJournal(root, "retry-usage-projection")
129 if err != nil {
130 t.Fatal(err)
131 }
132 t.Cleanup(func() { _ = journal.Close() })
133 spec := ResidentChildSpec{ID: "retry-usage-projection", SessionID: "usage-session", Provider: "openai", Model: "gpt-test"}
134 if err := journal.Accept(spec, "task"); err != nil {
135 t.Fatal(err)
136 }
137 journal.ConfigureUsage(128_000, false)
138 metadataPath := filepath.Join(journal.Dir(), residentMetadataName)
139 originalMetadata, err := os.ReadFile(metadataPath)
140 if err != nil {
141 t.Fatal(err)
142 }
143 if err := os.WriteFile(metadataPath, []byte("not-json"), 0o600); err != nil {
144 t.Fatal(err)
145 }
146
147 usage := provider.Usage{InputTokens: 10_000, OutputTokens: 500}
148 event := core.EvUsage{Usage: usage, Cumulative: usage}
149 if err := journal.RecordAgentEvent(event); err == nil {
150 t.Fatal("first usage projection succeeded with malformed metadata")
151 }
152 if err := os.WriteFile(metadataPath, originalMetadata, 0o600); err != nil {
153 t.Fatal(err)
154 }
155 if err := journal.RecordAgentEvent(event); err != nil {
156 t.Fatalf("retry identical usage: %v", err)
157 }
158
159 records, err := ReadResidentJournal(filepath.Join(journal.Dir(), residentTranscriptName))
160 if err != nil {
161 t.Fatal(err)
162 }
163 if got := len(records); got != 2 {
164 t.Fatalf("journal records = %d, want accepted record plus one usage record", got)
165 }
166 metadata, err := ReadResidentMetadata(metadataPath)
167 if err != nil {
168 t.Fatal(err)
169 }
170 if metadata.Usage != usage || metadata.ContextUsed != usage.PromptTokens() {
171 t.Fatalf("metadata usage = %+v, context = %d; want %+v, %d", metadata.Usage, metadata.ContextUsed, usage, usage.PromptTokens())
172 }
173}
174
175func TestReconcileResidentJournalRetainsRootCacheID(t *testing.T) {
176 root := t.TempDir()
177 journal, err := OpenResidentJournal(root, "cache-child")
178 if err != nil {
179 t.Fatal(err)
180 }
181 spec := ResidentChildSpec{ID: "cache-child", SessionID: "child-session", RootCacheID: "root-cache", Provider: "openai", Model: "gpt-test"}
182 if err := journal.Accept(spec, "task"); err != nil {
183 t.Fatal(err)
184 }
185 if err := journal.Close(); err != nil {
186 t.Fatal(err)
187 }
188 metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
189 if err != nil {
190 t.Fatal(err)
191 }
192 if metadata.RootCacheID != spec.RootCacheID {
193 t.Fatalf("RootCacheID = %q, want %q", metadata.RootCacheID, spec.RootCacheID)
194 }
195}
196
197func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) {
198 root := t.TempDir()
199 journal, err := OpenResidentJournal(root, "interrupted-usage")
200 if err != nil {
201 t.Fatal(err)
202 }
203 t.Cleanup(func() { _ = journal.Close() })
204 spec := ResidentChildSpec{ID: "interrupted-usage", SessionID: "usage-session", Provider: "openai", Model: "gpt-test"}
205 if err := journal.Accept(spec, "task"); err != nil {
206 t.Fatal(err)
207 }
208 journal.ConfigureUsage(272_000, false)
209 if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
210 t.Fatal(err)
211 }
212 if err := journal.RecordTurnInterrupted(spec, "turn-1"); err != nil {
213 t.Fatal(err)
214 }
215 usage := provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000}
216 if err := journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}); err != nil {
217 t.Fatal(err)
218 }
219 metadata, err := ReadResidentMetadata(filepath.Join(journal.Dir(), residentMetadataName))
220 if err != nil {
221 t.Fatal(err)
222 }
223 if metadata.State != ResidentInterrupted || metadata.Usage != usage || metadata.ContextUsed != 207_000 {
224 t.Fatalf("metadata = %#v, want interrupted state with latest usage", metadata)
225 }
226}
227
228func TestResidentProjectionSkipsIdenticalRewrite(t *testing.T) {
229 if runtime.GOOS == "windows" {
230 t.Skip("no-follow projection reads are unavailable on Windows")
231 }
232 dir := t.TempDir()
233 metadata := ResidentMetadata{
234 Version: residentJournalVersion,
235 ID: "projection-child",
236 SessionID: "projection-session",
237 State: ResidentIdle,
238 UpdatedAt: time.Unix(1_700_000_000, 0).UTC(),
239 }
240 if err := writeResidentMetadata(dir, metadata); err != nil {
241 t.Fatal(err)
242 }
243 path := filepath.Join(dir, residentMetadataName)
244 oldTime := time.Unix(1_600_000_000, 0)
245 if err := os.Chtimes(path, oldTime, oldTime); err != nil {
246 t.Fatal(err)
247 }
248
249 if err := writeResidentMetadata(dir, metadata); err != nil {
250 t.Fatal(err)
251 }
252 info, err := os.Stat(path)
253 if err != nil {
254 t.Fatal(err)
255 }
256 if !info.ModTime().Equal(oldTime) {
257 t.Fatalf("identical projection rewrite changed mtime to %v", info.ModTime())
258 }
259
260 metadata.State = ResidentStopped
261 if err := writeResidentMetadata(dir, metadata); err != nil {
262 t.Fatal(err)
263 }
264 info, err = os.Stat(path)
265 if err != nil {
266 t.Fatal(err)
267 }
268 if info.ModTime().Equal(oldTime) {
269 t.Fatal("changed projection was not rewritten")
270 }
271}
272
273func TestResidentProjectionReplacesSymlinkInsteadOfFollowingIt(t *testing.T) {
274 if runtime.GOOS == "windows" {
275 t.Skip("symlink creation may require elevated privileges on Windows")
276 }
277 dir := t.TempDir()
278 metadata := ResidentMetadata{Version: residentJournalVersion, ID: "projection-child", SessionID: "projection-session", State: ResidentIdle}
279 data, err := json.Marshal(metadata)
280 if err != nil {
281 t.Fatal(err)
282 }
283 target := filepath.Join(dir, "target.json")
284 if err := os.WriteFile(target, data, 0o600); err != nil {
285 t.Fatal(err)
286 }
287 path := filepath.Join(dir, residentMetadataName)
288 if err := os.Symlink(target, path); err != nil {
289 t.Fatal(err)
290 }
291
292 if err := writeResidentMetadata(dir, metadata); err != nil {
293 t.Fatal(err)
294 }
295 info, err := os.Lstat(path)
296 if err != nil {
297 t.Fatal(err)
298 }
299 if !info.Mode().IsRegular() {
300 t.Fatalf("projection mode = %v, want regular file", info.Mode())
301 }
302}
303
304func TestResidentProjectionRepairsPermissionsForIdenticalData(t *testing.T) {
305 if runtime.GOOS == "windows" {
306 t.Skip("POSIX permission bits are not enforced on Windows")
307 }
308 dir := t.TempDir()
309 metadata := ResidentMetadata{Version: residentJournalVersion, ID: "projection-child", SessionID: "projection-session", State: ResidentIdle}
310 data, err := json.Marshal(metadata)
311 if err != nil {
312 t.Fatal(err)
313 }
314 path := filepath.Join(dir, residentMetadataName)
315 if err := os.WriteFile(path, data, 0o644); err != nil {
316 t.Fatal(err)
317 }
318 if err := os.Chmod(path, 0o644); err != nil {
319 t.Fatal(err)
320 }
321
322 if err := writeResidentMetadata(dir, metadata); err != nil {
323 t.Fatal(err)
324 }
325 info, err := os.Stat(path)
326 if err != nil {
327 t.Fatal(err)
328 }
329 if got := info.Mode().Perm(); got != 0o600 {
330 t.Fatalf("projection permissions = %o, want 600", got)
331 }
332}
333
334func TestOpenResidentJournalRejectsDotChildIDs(t *testing.T) {
335 for _, childID := range []string{".", "..", "nested/child"} {
336 t.Run(childID, func(t *testing.T) {
337 if _, err := OpenResidentJournal(t.TempDir(), childID); err == nil {
338 t.Fatalf("OpenResidentJournal(%q) succeeded", childID)
339 }
340 })
341 }
342}
343
344func TestResidentJournalPersistsFinalizedAgentEvents(t *testing.T) {
345 root := t.TempDir()
346 journal, err := OpenResidentJournal(root, "child-events")
347 if err != nil {
348 t.Fatal(err)
349 }
350 spec := ResidentChildSpec{ID: "child-events", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
351 if err := journal.Accept(spec, "task"); err != nil {
352 t.Fatal(err)
353 }
354 message := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}
355 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: message}); err != nil {
356 t.Fatal(err)
357 }
358 if err := journal.RecordAgentEvent(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
359 t.Fatal(err)
360 }
361 if err := journal.Close(); err != nil {
362 t.Fatal(err)
363 }
364 records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
365 if err != nil {
366 t.Fatal(err)
367 }
368 if len(records) != 3 || records[1].Type != residentRecordAssistant || len(records[1].Message) == 0 || records[2].Type != residentRecordToolCall || records[2].ToolName != "bash" {
369 t.Fatalf("records = %#v", records)
370 }
371}
372
373func TestReconcileResidentJournalInterruptsStartedTurnWithoutReplay(t *testing.T) {
374 root := t.TempDir()
375 journal, err := OpenResidentJournal(root, "child-2")
376 if err != nil {
377 t.Fatal(err)
378 }
379 spec := ResidentChildSpec{ID: "child-2", SessionID: "child-session-2", InitialTurnID: "initial-turn", Provider: "openai", Model: "gpt-5"}
380 if err := journal.Accept(spec, "do work"); err != nil {
381 t.Fatal(err)
382 }
383 if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
384 t.Fatal(err)
385 }
386 if err := journal.Close(); err != nil {
387 t.Fatal(err)
388 }
389
390 metadata, err := ReconcileResidentJournal(filepath.Join(root, "child-2"))
391 if err != nil {
392 t.Fatalf("ReconcileResidentJournal: %v", err)
393 }
394 if metadata.State != ResidentInterrupted {
395 t.Fatalf("state = %q, want interrupted", metadata.State)
396 }
397 records, err := ReadResidentJournal(filepath.Join(root, "child-2", residentTranscriptName))
398 if err != nil {
399 t.Fatal(err)
400 }
401 if got := records[len(records)-1].Type; got != residentRecordInterrupted {
402 t.Fatalf("last record = %q, want interruption", got)
403 }
404 if _, err := ReconcileResidentJournal(filepath.Join(root, "child-2")); err != nil {
405 t.Fatalf("repeat reconciliation: %v", err)
406 }
407 repeated, err := ReadResidentJournal(filepath.Join(root, "child-2", residentTranscriptName))
408 if err != nil {
409 t.Fatal(err)
410 }
411 if len(repeated) != len(records) {
412 t.Fatalf("repeat reconciliation appended records: %d -> %d", len(records), len(repeated))
413 }
414}
415
416func TestReconcileResidentJournalInterruptsQueuedInitialTurnOnce(t *testing.T) {
417 root := t.TempDir()
418 journal, err := OpenResidentJournal(root, "queued-child")
419 if err != nil {
420 t.Fatal(err)
421 }
422 spec := ResidentChildSpec{ID: "queued-child", SessionID: "queued-session", InitialTurnID: "initial-turn", Provider: "openai", Model: "gpt-5"}
423 if err := journal.Accept(spec, "do work"); err != nil {
424 t.Fatal(err)
425 }
426 if err := journal.Close(); err != nil {
427 t.Fatal(err)
428 }
429 for attempt := 0; attempt < 2; attempt++ {
430 metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
431 if err != nil || metadata.State != ResidentInterrupted {
432 t.Fatalf("reconcile %d = (%#v, %v)", attempt, metadata, err)
433 }
434 }
435 records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
436 if err != nil {
437 t.Fatal(err)
438 }
439 if len(records) != 2 || records[1].Type != residentRecordInterrupted || records[1].TurnID != spec.InitialTurnID {
440 t.Fatalf("queued initial records = %#v", records)
441 }
442}
443
444func TestReconcileResidentJournalRepairsOnlyDanglingToolCalls(t *testing.T) {
445 root := t.TempDir()
446 journal, err := OpenResidentJournal(root, "tool-pair")
447 if err != nil {
448 t.Fatal(err)
449 }
450 spec := ResidentChildSpec{ID: "tool-pair", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
451 if err := journal.Accept(spec, "task"); err != nil {
452 t.Fatal(err)
453 }
454 if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
455 t.Fatal(err)
456 }
457 if err := journal.RecordAgentEvent(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
458 t.Fatal(err)
459 }
460 if err := journal.Close(); err != nil {
461 t.Fatal(err)
462 }
463 if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err != nil {
464 t.Fatal(err)
465 }
466 records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
467 if err != nil {
468 t.Fatal(err)
469 }
470 for _, record := range records {
471 if record.Type == residentRecordToolResult && record.ToolID == "call-1" {
472 return
473 }
474 }
475 t.Fatalf("no repaired tool result in %#v", records)
476}
477
478func TestReconcileResidentJournalRepairsTerminalDanglingCallWithoutInterruptingChild(t *testing.T) {
479 root := t.TempDir()
480 journal, err := OpenResidentJournal(root, "terminal-tool-pair")
481 if err != nil {
482 t.Fatal(err)
483 }
484 spec := ResidentChildSpec{ID: "terminal-tool-pair", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
485 if err := journal.Accept(spec, "task"); err != nil {
486 t.Fatal(err)
487 }
488 if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
489 t.Fatal(err)
490 }
491 if err := journal.RecordAgentEvent(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
492 t.Fatal(err)
493 }
494 if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
495 t.Fatal(err)
496 }
497 if err := journal.Close(); err != nil {
498 t.Fatal(err)
499 }
500 metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
501 if err != nil {
502 t.Fatal(err)
503 }
504 if metadata.State != ResidentIdle {
505 t.Fatalf("state = %q, want idle", metadata.State)
506 }
507 records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
508 if err != nil {
509 t.Fatal(err)
510 }
511 if got := records[len(records)-1]; got.Type != residentRecordToolResult || got.ToolID != "call-1" {
512 t.Fatalf("last record = %#v, want repaired tool result", got)
513 }
514}
515
516func TestReconcileResidentJournalRebuildsMissingTerminalResult(t *testing.T) {
517 root := t.TempDir()
518 journal, err := OpenResidentJournal(root, "terminal-result")
519 if err != nil {
520 t.Fatal(err)
521 }
522 spec := ResidentChildSpec{ID: "terminal-result", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
523 if err := journal.Accept(spec, "task"); err != nil {
524 t.Fatal(err)
525 }
526 if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
527 t.Fatal(err)
528 }
529 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "recovered final answer"}}}}); err != nil {
530 t.Fatal(err)
531 }
532 if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
533 t.Fatal(err)
534 }
535 if err := journal.Close(); err != nil {
536 t.Fatal(err)
537 }
538 resultPath := filepath.Join(root, spec.ID, residentResultName)
539 if err := os.WriteFile(resultPath, []byte("not json"), 0o600); err != nil {
540 t.Fatal(err)
541 }
542 if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err != nil {
543 t.Fatal(err)
544 }
545 result, err := ReadResidentResult(resultPath)
546 if err != nil {
547 t.Fatal(err)
548 }
549 if result.TurnID != "turn-1" || result.State != ResidentIdle || result.Summary != "recovered final answer" {
550 t.Fatalf("result = %#v", result)
551 }
552}
553
554func TestReconcileResidentJournalPreservesTerminalLifecycleTime(t *testing.T) {
555 root := t.TempDir()
556 journal, err := OpenResidentJournal(root, "terminal-time")
557 if err != nil {
558 t.Fatal(err)
559 }
560 spec := ResidentChildSpec{ID: "terminal-time", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
561 if err := journal.Accept(spec, "task"); err != nil {
562 t.Fatal(err)
563 }
564 if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
565 t.Fatal(err)
566 }
567 if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
568 t.Fatal(err)
569 }
570 if err := journal.Close(); err != nil {
571 t.Fatal(err)
572 }
573 records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
574 if err != nil {
575 t.Fatal(err)
576 }
577 want := records[len(records)-1].Time
578 metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
579 if err != nil {
580 t.Fatal(err)
581 }
582 if !metadata.UpdatedAt.Equal(want) {
583 t.Fatalf("updated_at = %s, want terminal record time %s", metadata.UpdatedAt, want)
584 }
585}
586
587func TestResidentJournalStoresBoundedFinalAssistantSummary(t *testing.T) {
588 root := t.TempDir()
589 journal, err := OpenResidentJournal(root, "final-summary")
590 if err != nil {
591 t.Fatal(err)
592 }
593 defer journal.Close()
594 spec := ResidentChildSpec{ID: "final-summary", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
595 if err := journal.Accept(spec, "task"); err != nil {
596 t.Fatal(err)
597 }
598 if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
599 t.Fatal(err)
600 }
601 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "the child final answer"}}}}); err != nil {
602 t.Fatal(err)
603 }
604 if err := journal.RecordTurnFinished(spec, "turn-1", nil); err != nil {
605 t.Fatal(err)
606 }
607 result, err := ReadResidentResult(filepath.Join(root, spec.ID, residentResultName))
608 if err != nil {
609 t.Fatal(err)
610 }
611 if result.Summary != "the child final answer" {
612 t.Fatalf("result summary = %q", result.Summary)
613 }
614}
615
616func TestTruncateResidentResultSummaryPreservesUTF8Boundary(t *testing.T) {
617 if residentResultSummaryBytes != 256<<10 {
618 t.Fatalf("summary limit = %d, want %d", residentResultSummaryBytes, 256<<10)
619 }
620 text := strings.Repeat("é", residentResultSummaryBytes)
621 summary := truncateResidentResultSummary(text)
622 if !utf8.ValidString(summary) || !strings.HasSuffix(summary, "…") || len(summary) > residentResultSummaryBytes {
623 t.Fatalf("summary = %q", summary[:min(len(summary), 32)])
624 }
625}
626
627func TestReconcileResidentJournalRepairsLegacyFalseRecovery(t *testing.T) {
628 root := t.TempDir()
629 journal, err := OpenResidentJournal(root, "legacy-race")
630 if err != nil {
631 t.Fatal(err)
632 }
633 spec := ResidentChildSpec{ID: "legacy-race", SessionID: "child-session", InitialTurnID: "turn-1", Provider: "openai", Model: "gpt-5"}
634 if err := journal.Accept(spec, "task"); err != nil {
635 t.Fatal(err)
636 }
637 if err := journal.RecordTurnStarted(spec, spec.InitialTurnID); err != nil {
638 t.Fatal(err)
639 }
640 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 {
641 t.Fatal(err)
642 }
643 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 {
644 t.Fatal(err)
645 }
646 if err := journal.RecordTurnInterrupted(spec, spec.InitialTurnID); err != nil {
647 t.Fatal(err)
648 }
649 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 {
650 t.Fatal(err)
651 }
652 if err := journal.RecordTurnFinished(spec, spec.InitialTurnID, nil); err != nil {
653 t.Fatal(err)
654 }
655 if err := journal.Close(); err != nil {
656 t.Fatal(err)
657 }
658
659 metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
660 if err != nil {
661 t.Fatal(err)
662 }
663 if metadata.State != ResidentIdle {
664 t.Fatalf("state = %q, want idle", metadata.State)
665 }
666 records, err := ReadResidentJournal(filepath.Join(root, spec.ID, residentTranscriptName))
667 if err != nil {
668 t.Fatal(err)
669 }
670 for _, record := range records {
671 if record.Type == residentRecordInterrupted || isSyntheticInterruption(record.ToolResult) {
672 t.Fatalf("repaired records retained false recovery: %#v", record)
673 }
674 }
675 backups, err := filepath.Glob(filepath.Join(root, spec.ID, ".transcript-backup-*"))
676 if err != nil {
677 t.Fatal(err)
678 }
679 if len(backups) != 1 {
680 t.Fatalf("backups = %v, want one", backups)
681 }
682}
683
684func TestRepairLegacyFalseRecoveryRefusesAmbiguousBlock(t *testing.T) {
685 synthetic := json.RawMessage(`{"Content":[{"text":"tool interrupted by resident host restart"}],"IsError":true}`)
686 real := json.RawMessage(`{"Content":[{"text":"ok"}],"IsError":false}`)
687 records := []residentRecord{
688 {Type: residentRecordTurnStarted, TurnID: "turn-1"},
689 {Type: residentRecordToolResult, ToolID: "call-1", ToolResult: synthetic},
690 {Type: residentRecordToolResult, ToolID: "call-2", ToolResult: synthetic},
691 {Type: residentRecordInterrupted, TurnID: "turn-1"},
692 {Type: residentRecordToolResult, ToolID: "call-1", ToolResult: real},
693 }
694 if repaired, ok := repairLegacyFalseRecovery(records); ok || repaired != nil {
695 t.Fatalf("repairLegacyFalseRecovery = %#v, %v; want no repair", repaired, ok)
696 }
697}
698
699func TestRepairLegacyFalseRecoveryPreservesOtherTurnInterruption(t *testing.T) {
700 synthetic := json.RawMessage(`{"Content":[{"text":"tool interrupted by resident host restart"}],"IsError":true}`)
701 real := json.RawMessage(`{"Content":[{"text":"ok"}],"IsError":false}`)
702 records := []residentRecord{
703 {Type: residentRecordTurnStarted, TurnID: "turn-1"},
704 {Type: residentRecordToolResult, ToolID: "call-1", ToolResult: synthetic},
705 {Type: residentRecordInterrupted, TurnID: "turn-1"},
706 {Type: residentRecordInterrupted, TurnID: "turn-2"},
707 {Type: residentRecordToolResult, ToolID: "call-1", ToolResult: real},
708 }
709 if repaired, ok := repairLegacyFalseRecovery(records); ok || repaired != nil {
710 t.Fatalf("repairLegacyFalseRecovery = %#v, %v; want no repair", repaired, ok)
711 }
712}
713
714func TestReconcileResidentJournalRejectsConflictingToolPairs(t *testing.T) {
715 for _, tc := range []struct {
716 name string
717 records []residentRecord
718 want string
719 }{
720 {
721 name: "orphan-result",
722 records: []residentRecord{{Version: residentJournalVersion, Type: residentRecordToolResult, ToolID: "missing", ToolResult: json.RawMessage(`{"is_error":true}`)}},
723 want: "orphan tool result",
724 },
725 {
726 name: "duplicate-result",
727 records: []residentRecord{
728 {Version: residentJournalVersion, Type: residentRecordToolCall, ToolID: "call-1", ToolName: "bash", ToolArgs: json.RawMessage(`{"command":"pwd"}`)},
729 {Version: residentJournalVersion, Type: residentRecordToolResult, ToolID: "call-1", ToolResult: json.RawMessage(`{"is_error":false}`)},
730 {Version: residentJournalVersion, Type: residentRecordToolResult, ToolID: "call-1", ToolResult: json.RawMessage(`{"is_error":false}`)},
731 },
732 want: "duplicate tool result",
733 },
734 } {
735 t.Run(tc.name, func(t *testing.T) {
736 root := t.TempDir()
737 journal, err := OpenResidentJournal(root, "corrupt")
738 if err != nil {
739 t.Fatal(err)
740 }
741 spec := ResidentChildSpec{ID: "corrupt", SessionID: "child-session", Provider: "openai", Model: "gpt-5"}
742 if err := journal.Accept(spec, "task"); err != nil {
743 t.Fatal(err)
744 }
745 for _, record := range tc.records {
746 record.Time = time.Now().UTC()
747 if err := journal.appendSync(record); err != nil {
748 t.Fatal(err)
749 }
750 }
751 if err := journal.Close(); err != nil {
752 t.Fatal(err)
753 }
754 if _, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)); err == nil || !strings.Contains(err.Error(), tc.want) {
755 t.Fatalf("ReconcileResidentJournal error = %v, want %q", err, tc.want)
756 }
757 })
758 }
759}