1package subagents
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"
13)
14
15func TestReadResidentHistoryReturnsCompleteFinalizedItems(t *testing.T) {
16 root := t.TempDir()
17 journal, err := OpenResidentJournal(root, "history-child")
18 if err != nil {
19 t.Fatal(err)
20 }
21 spec := ResidentChildSpec{ID: "history-child", SessionID: "history-session", Provider: "openai", Model: "gpt-5"}
22 if err := journal.Accept(spec, "task"); err != nil {
23 t.Fatal(err)
24 }
25 if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "question"}}}}); err != nil {
26 t.Fatal(err)
27 }
28 if err := journal.RecordAgentEvent(core.EvToolCall{ID: "call-1", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
29 t.Fatal(err)
30 }
31 if err := journal.RecordAgentEvent(core.EvToolResult{ID: "call-1", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "/repo"}}}}); err != nil {
32 t.Fatal(err)
33 }
34 if err := journal.Close(); err != nil {
35 t.Fatal(err)
36 }
37 history, err := ReadResidentHistory(filepath.Join(root, spec.ID), 10)
38 if err != nil {
39 t.Fatal(err)
40 }
41 if len(history) != 3 || history[1].ToolName != "bash" || string(history[1].ToolArgs) != `{"command":"pwd"}` || len(history[2].ToolResult) == 0 {
42 t.Fatalf("history = %#v", history)
43 }
44 if _, err := ResidentHistoryDir(root, "../escape"); err == nil {
45 t.Fatal("ResidentHistoryDir accepted traversal")
46 }
47}
48
49func TestResidentHistoryMessagesRestoresToolCallsAndResults(t *testing.T) {
50 assistant, err := json.Marshal(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"pwd"}`)}}})
51 if err != nil {
52 t.Fatal(err)
53 }
54 result, err := json.Marshal(core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "/repo"}}})
55 if err != nil {
56 t.Fatal(err)
57 }
58 messages, err := ResidentHistoryMessages([]ResidentHistoryItem{
59 {Type: residentRecordAssistant, Message: assistant},
60 {Type: residentRecordToolCall, ToolID: "call-1", ToolName: "bash", ToolArgs: json.RawMessage(`{"command":"pwd"}`)},
61 {Type: residentRecordToolResult, ToolID: "call-1", ToolResult: result},
62 })
63 if err != nil {
64 t.Fatal(err)
65 }
66 if len(messages) != 2 {
67 t.Fatalf("messages = %#v", messages)
68 }
69 call, ok := messages[0].Content[0].(provider.ToolCallBlock)
70 if !ok || call.ID != "call-1" {
71 t.Fatalf("assistant call = %#v", messages[0])
72 }
73 tool, ok := messages[1].Content[0].(provider.ToolResultBlock)
74 if !ok || tool.CallID != "call-1" || len(tool.Content) != 1 {
75 t.Fatalf("tool message = %#v", messages[1])
76 }
77}
78
79func TestReadResidentTranscriptMessagesRestoresFinalizedConversation(t *testing.T) {
80 root := t.TempDir()
81 journal, err := OpenResidentJournal(root, "transcript")
82 if err != nil {
83 t.Fatal(err)
84 }
85 spec := ResidentChildSpec{ID: "transcript", SessionID: "session", Provider: "openai", Model: "gpt-5"}
86 if err := journal.Accept(spec, "task"); err != nil {
87 t.Fatal(err)
88 }
89 if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "question"}}}}); err != nil {
90 t.Fatal(err)
91 }
92 if err := journal.Close(); err != nil {
93 t.Fatal(err)
94 }
95 messages, err := ReadResidentTranscriptMessages(filepath.Join(root, spec.ID))
96 if err != nil {
97 t.Fatal(err)
98 }
99 if len(messages) != 1 || messages[0].Role != provider.RoleUser {
100 t.Fatalf("messages = %#v", messages)
101 }
102}
103
104func TestReadResidentHistoryPageKeepsToolGroupsTogether(t *testing.T) {
105 root := t.TempDir()
106 journal, err := OpenResidentJournal(root, "history-page")
107 if err != nil {
108 t.Fatal(err)
109 }
110 spec := ResidentChildSpec{ID: "history-page", SessionID: "history-session", Provider: "openai", Model: "gpt-5"}
111 if err := journal.Accept(spec, "task"); err != nil {
112 t.Fatal(err)
113 }
114 for _, group := range []struct{ id, text string }{{"old", "old answer"}, {"new", "new answer"}} {
115 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: group.text}}}}); err != nil {
116 t.Fatal(err)
117 }
118 if err := journal.RecordAgentEvent(core.EvToolCall{ID: group.id, Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
119 t.Fatal(err)
120 }
121 if err := journal.RecordAgentEvent(core.EvToolResult{ID: group.id, Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "/repo"}}}}); err != nil {
122 t.Fatal(err)
123 }
124 }
125 if err := journal.Close(); err != nil {
126 t.Fatal(err)
127 }
128
129 page, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), "", 2)
130 if err != nil {
131 t.Fatal(err)
132 }
133 if len(page.Items) != 3 || page.Items[0].Type != residentRecordAssistant || page.Items[1].ToolID != "new" || page.Items[2].ToolID != "new" {
134 t.Fatalf("latest page split a tool group: %#v", page.Items)
135 }
136 if page.OlderCursor == "" {
137 t.Fatal("latest page has no cursor for the older group")
138 }
139 older, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), page.OlderCursor, 2)
140 if err != nil {
141 t.Fatal(err)
142 }
143 if len(older.Items) != 3 || older.Items[0].Type != residentRecordAssistant || older.Items[1].ToolID != "old" || older.Items[2].ToolID != "old" || older.OlderCursor != "" {
144 t.Fatalf("older page = %#v", older)
145 }
146}
147
148func TestReadResidentHistoryPageDefersIncompleteTailAndInvalidatesReplacedJournal(t *testing.T) {
149 root := t.TempDir()
150 journal, err := OpenResidentJournal(root, "history-cursor")
151 if err != nil {
152 t.Fatal(err)
153 }
154 spec := ResidentChildSpec{ID: "history-cursor", SessionID: "history-session", Provider: "openai", Model: "gpt-5"}
155 if err := journal.Accept(spec, "task"); err != nil {
156 t.Fatal(err)
157 }
158 if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "saved"}}}}); err != nil {
159 t.Fatal(err)
160 }
161 if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "newer"}}}}); err != nil {
162 t.Fatal(err)
163 }
164 if err := journal.Close(); err != nil {
165 t.Fatal(err)
166 }
167 path := filepath.Join(root, spec.ID, residentTranscriptName)
168 f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
169 if err != nil {
170 t.Fatal(err)
171 }
172 if _, err := f.WriteString(`{"type":"message.assistant"}`); err != nil {
173 t.Fatal(err)
174 }
175 if err := f.Close(); err != nil {
176 t.Fatal(err)
177 }
178 page, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), "", 1)
179 if err != nil {
180 t.Fatal(err)
181 }
182 if len(page.Items) != 1 || page.Items[0].Type != residentRecordUser {
183 t.Fatalf("incomplete tail leaked into page: %#v", page.Items)
184 }
185 if page.OlderCursor == "" {
186 t.Fatal("page has no cursor before replacement")
187 }
188
189 if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil {
190 t.Fatal(err)
191 }
192 if _, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), page.OlderCursor, 10); err == nil {
193 t.Fatal("replaced journal cursor was accepted")
194 }
195}
196
197func TestReadResidentHistoryPageCursorSurvivesJournalAppend(t *testing.T) {
198 root := t.TempDir()
199 journal, err := OpenResidentJournal(root, "history-append")
200 if err != nil {
201 t.Fatal(err)
202 }
203 spec := ResidentChildSpec{ID: "history-append", SessionID: "history-session", Provider: "openai", Model: "gpt-5"}
204 if err := journal.Accept(spec, "task"); err != nil {
205 t.Fatal(err)
206 }
207 for _, text := range []string{"first", "second"} {
208 if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}}); err != nil {
209 t.Fatal(err)
210 }
211 }
212 if err := journal.Close(); err != nil {
213 t.Fatal(err)
214 }
215 page, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), "", 1)
216 if err != nil || page.OlderCursor == "" {
217 t.Fatalf("latest page = %#v, err=%v", page, err)
218 }
219 journal, err = OpenResidentJournal(root, spec.ID)
220 if err != nil {
221 t.Fatal(err)
222 }
223 if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "third"}}}}); err != nil {
224 t.Fatal(err)
225 }
226 if err := journal.Close(); err != nil {
227 t.Fatal(err)
228 }
229 older, err := ReadResidentHistoryPage(filepath.Join(root, spec.ID), page.OlderCursor, 1)
230 if err != nil || len(older.Items) != 1 {
231 t.Fatalf("older page = %#v, err=%v", older, err)
232 }
233}
234
235func TestResidentManagerHistoryRejectsPathTraversal(t *testing.T) {
236 manager := NewResidentManager(t.TempDir(), func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error) {
237 return func(context.Context, string) error { return nil }, nil
238 })
239 if _, err := manager.History("../escape", 10); err == nil {
240 t.Fatal("History accepted traversal")
241 }
242 for _, childID := range []string{"../escape", "..", ".", "sub/child"} {
243 if _, err := manager.HistoryPage(childID, "", 10); err == nil {
244 t.Fatalf("HistoryPage(%q) accepted traversal", childID)
245 }
246 }
247}
248
249func TestReadResidentTranscriptMessagesReplaysCompactionCheckpoint(t *testing.T) {
250 root := t.TempDir()
251 journal, err := OpenResidentJournal(root, "compacted")
252 if err != nil {
253 t.Fatal(err)
254 }
255 spec := ResidentChildSpec{ID: "compacted", SessionID: "compacted-session", Provider: "openai", Model: "gpt-5"}
256 if err := journal.Accept(spec, "task"); err != nil {
257 t.Fatal(err)
258 }
259 if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old question"}}}}); err != nil {
260 t.Fatal(err)
261 }
262 if err := journal.RecordAgentEvent(core.EvToolCall{ID: "dropped-call", Name: "bash", Args: json.RawMessage(`{"command":"pwd"}`)}); err != nil {
263 t.Fatal(err)
264 }
265 if err := journal.RecordAgentEvent(core.EvToolResult{ID: "dropped-call", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "/repo"}}}}); err != nil {
266 t.Fatal(err)
267 }
268 if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
269 t.Fatal(err)
270 }
271 if err := journal.RecordCompacted([]provider.Message{
272 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "## Context Summary (compacted)\n\ndigest"}}},
273 }); err != nil {
274 t.Fatal(err)
275 }
276 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "after checkpoint"}}}}); err != nil {
277 t.Fatal(err)
278 }
279 transcript := filepath.Join(journal.Dir(), residentTranscriptName)
280 if err := journal.Close(); err != nil {
281 t.Fatal(err)
282 }
283
284 records, err := ReadResidentJournal(transcript)
285 if err != nil {
286 t.Fatal(err)
287 }
288 var checkpoints []residentRecord
289 for _, record := range records {
290 if record.Type == residentRecordCompacted {
291 checkpoints = append(checkpoints, record)
292 }
293 }
294 if len(checkpoints) != 1 || checkpoints[0].TurnID != "turn-1" || len(checkpoints[0].Messages) != 1 {
295 t.Fatalf("checkpoints = %#v, want one attributed to the active turn", checkpoints)
296 }
297
298 dir := filepath.Join(root, spec.ID)
299 messages, err := ReadResidentTranscriptMessages(dir)
300 if err != nil {
301 t.Fatal(err)
302 }
303 if len(messages) != 2 || messages[0].Role != provider.RoleUser || messages[1].Role != provider.RoleAssistant {
304 t.Fatalf("resumed messages = %#v, want the checkpoint followed by the later reply", messages)
305 }
306 if text := joinedResidentMessageText(messages); strings.Contains(text, "old question") {
307 t.Fatalf("resume replayed pre-checkpoint content: %q", text)
308 }
309 if !strings.Contains(joinedResidentMessageText(messages[:1]), "Context Summary (compacted)") {
310 t.Fatalf("resume lost the checkpoint summary: %#v", messages[0])
311 }
312 for _, message := range messages {
313 for _, content := range message.Content {
314 if call, ok := content.(provider.ToolCallBlock); ok {
315 t.Fatalf("resume re-synthesized compacted tool call %q", call.ID)
316 }
317 }
318 }
319
320 // The UI pager keeps the append-only view: a checkpoint is not a history
321 // item, so paging still reaches the pre-checkpoint log.
322 page, err := ReadResidentHistoryPage(dir, "", 10)
323 if err != nil {
324 t.Fatal(err)
325 }
326 if page.OlderCursor == "" {
327 t.Fatal("page has no cursor for the pre-checkpoint log")
328 }
329 older, err := ReadResidentHistoryPage(dir, page.OlderCursor, 10)
330 if err != nil {
331 t.Fatal(err)
332 }
333 if len(older.Items) != 3 || older.Items[0].Type != residentRecordUser || !strings.Contains(string(older.Items[0].Message), "old question") {
334 t.Fatalf("older page = %#v, want the append-only log before the checkpoint", older.Items)
335 }
336 for _, item := range append(append([]ResidentHistoryItem(nil), page.Items...), older.Items...) {
337 if item.Type == residentRecordCompacted {
338 t.Fatalf("pager exposed a compaction checkpoint: %#v", item)
339 }
340 }
341}
342
343func TestReadResidentTranscriptMessagesRejectsInvalidCheckpoint(t *testing.T) {
344 for _, tc := range []struct {
345 name string
346 line string
347 }{
348 {name: "empty", line: `{"version":3,"type":"child.compacted","time":"2026-01-01T00:00:00Z"}`},
349 {name: "undecodable", line: `{"version":3,"type":"child.compacted","time":"2026-01-01T00:00:00Z","messages":["not a message"]}`},
350 } {
351 t.Run(tc.name, func(t *testing.T) {
352 root := t.TempDir()
353 journal, err := OpenResidentJournal(root, "bad-checkpoint")
354 if err != nil {
355 t.Fatal(err)
356 }
357 spec := ResidentChildSpec{ID: "bad-checkpoint", SessionID: "session", Provider: "openai", Model: "gpt-5"}
358 if err := journal.Accept(spec, "task"); err != nil {
359 t.Fatal(err)
360 }
361 if err := journal.RecordAgentEvent(core.EvUserMessage{Message: provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "question"}}}}); err != nil {
362 t.Fatal(err)
363 }
364 dir := journal.Dir()
365 if err := journal.Close(); err != nil {
366 t.Fatal(err)
367 }
368 path := filepath.Join(dir, residentTranscriptName)
369 file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
370 if err != nil {
371 t.Fatal(err)
372 }
373 if _, err := file.WriteString(tc.line + "\n"); err != nil {
374 t.Fatal(err)
375 }
376 if err := file.Close(); err != nil {
377 t.Fatal(err)
378 }
379 if _, err := ReadResidentTranscriptMessages(dir); err == nil {
380 t.Fatal("invalid checkpoint was accepted")
381 }
382 if _, err := ReconcileResidentJournal(dir); err == nil {
383 t.Fatal("reconciliation accepted an invalid checkpoint")
384 }
385 })
386 }
387}
388
389func joinedResidentMessageText(messages []provider.Message) string {
390 var text strings.Builder
391 for _, message := range messages {
392 for _, content := range message.Content {
393 if block, ok := content.(provider.TextBlock); ok {
394 text.WriteString(block.Text)
395 text.WriteString("\n")
396 }
397 }
398 }
399 return text.String()
400}