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