]> git.giacomolli.uk - onechat/blob - whatsapp_import_test.go
Import WhatsApp history into a child
[onechat] / whatsapp_import_test.go
1 package main
2
3 import (
4         "archive/zip"
5         "encoding/json"
6         "net/http"
7         "os"
8         "path/filepath"
9         "strings"
10         "testing"
11         "time"
12
13         "github.com/stretchr/testify/require"
14         "go.mau.fi/whatsmeow/types"
15 )
16
17 func TestWhatsAppImportReadsMadeUpExport(t *testing.T) {
18         path := whatsappImportSample(t)
19         location := time.FixedZone("TEST", -3*60*60)
20         chat, messages, err := whatsappImportRead(path, "Sam Owner", location)
21         require.NoError(t, err)
22         require.Equal(t, "Book Club", chat.Name)
23         require.True(t, chat.Group)
24         require.Len(t, messages, 19)
25         require.Equal(t, "Book Club", messages[0].Sender)
26         require.False(t, messages[0].Mine)
27         require.Equal(t, "2025-05-20T12:35:39-03:00", messages[0].Time.Format(time.RFC3339))
28         require.Contains(t, messages[0].Text, "end-to-end encrypted")
29         require.Equal(t, "image omitted", messages[5].Text)
30         require.True(t, messages[6].Mine)
31         require.Equal(t, "~\u202fBen Carter", messages[7].Sender)
32         require.Equal(t, "Sure.\nThe room costs $40.00 more.\nI will send the booking tomorrow. 👍🏻", messages[7].Text)
33         require.Contains(t, messages[8].Text, "<This message was edited>")
34         require.Equal(t, "Booking.pdf • 3 pages document omitted", messages[9].Text)
35         require.Equal(t, "audio omitted", messages[10].Text)
36         require.True(t, messages[11].Mine)
37         require.Equal(t, "This message was deleted.", messages[11].Text)
38         require.Equal(t, "video omitted", messages[12].Text)
39         require.Equal(t, "Contact card omitted", messages[13].Text)
40         require.Equal(t, "sticker omitted", messages[14].Text)
41         require.Contains(t, messages[15].Text, "changed the group name")
42         require.Equal(t, "A line with [brackets] and: a colon in the middle", messages[16].Text)
43
44         for _, message := range messages {
45                 require.NotContains(t, message.Text, "\r")
46                 require.NotContains(t, message.Text, "\u200e")
47                 require.NotContains(t, message.Sender, "\u200e")
48         }
49 }
50
51 func TestWhatsAppImportSplitsDaysAndMapsOnlyOwner(t *testing.T) {
52         path := whatsappImportSample(t)
53         names := map[string]WhatsAppChat{
54                 "Book Club": {
55                         ID:    "example@g.us",
56                         Name:  "Book Club",
57                         Group: true,
58                 },
59         }
60
61         plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, names, "Sam Owner", time.UTC)
62         require.NoError(t, err)
63         require.Len(t, plan.added, 5)
64         require.Equal(t, "2025-05-20T13:06:44Z", plan.added[0].Date)
65         require.Equal(t, "2025-05-21T09:23:00Z", plan.added[1].Date)
66         require.Equal(t, "2025-12-31T23:59:59Z", plan.added[3].Date)
67         require.Equal(t, "2026-01-01T00:00:01Z", plan.added[4].Date)
68         require.True(t, strings.HasPrefix(plan.added[0].Text, "[WhatsApp] Book Club, group example@g.us, Tuesday 2025-05-20 (imported)\n"))
69         require.Contains(t, plan.added[0].Text, "12:35 Book Club: Messages and calls")
70         require.Contains(t, plan.added[0].Text, "13:02 You: Nice.")
71         require.NotContains(t, plan.added[0].Text, "Sam Owner:")
72         require.Contains(t, plan.added[0].Text, "13:05 ~\u202fBen Carter: Sure.\nThe room costs")
73         require.NotContains(t, plan.added[0].Text, whatsappBatchEmptyReply)
74         require.Equal(t, "2025-05-20T12:35:39Z", plan.first.Format(time.RFC3339))
75         require.Equal(t, "2026-01-01T00:00:01Z", plan.last.Format(time.RFC3339))
76 }
77
78 func TestWhatsAppImportSkipsHeldDaysAndMessagesAfterLiveCutoff(t *testing.T) {
79         path := whatsappImportSample(t)
80         old := []DatedMessage{
81                 {
82                         Kind: "work",
83                         Text: "[WhatsApp] Book Club, private chat other@lid, Sunday 2025-06-08\n10:00 Ada: Later",
84                         Date: "2025-06-08T10:01:00Z",
85                 },
86                 {
87                         Kind: "work",
88                         Text: "[WhatsApp] Book Club, private chat other@lid, Tuesday 2025-06-03\n18:41 Ada: First\n18:42 Ada: Last",
89                         Date: "2025-06-03T18:43:00Z",
90                 },
91                 {
92                         Kind: "work",
93                         Text: "[WhatsApp] Book Club, Tuesday 2025-05-20 (imported)\n12:35 Book Club: System",
94                         Date: "2025-05-20T13:06:44Z",
95                 },
96         }
97
98         plan, err := whatsappImportPlan(t.Context(), []string{path}, old, nil, map[string]WhatsAppChat{}, "Sam Owner", time.UTC)
99         require.NoError(t, err)
100         require.Equal(t, "2025-06-03T18:41:00Z", plan.cutoff.Format(time.RFC3339))
101         require.Equal(t, 1, plan.skipped)
102         require.Len(t, plan.added, 2)
103         require.Equal(t, "2025-06-03T18:40:05Z", plan.added[1].Date)
104         require.NotContains(t, plan.added[1].Text, "18:41")
105         require.NotContains(t, plan.added[1].Text, ", group ")
106         require.Equal(t, len(old)+2, plan.messages)
107 }
108
109 func TestWhatsAppImportCutoffUsesLocalTimeAndExcludesBoundary(t *testing.T) {
110         path := whatsappImportZip(t, "Local", "[03/06/2025, 18:40:59] Ada: Before\n[03/06/2025, 18:41:00] Ada: Boundary\n[03/06/2025, 18:41:01] Ada: After\n")
111         old := []DatedMessage{{
112                 Kind: "work",
113                 Text: "[WhatsApp] Local, group live@g.us, Tuesday 2025-06-03\n18:41 Ada: First",
114                 Date: "2025-06-04T01:00:00Z",
115         }}
116
117         plan, err := whatsappImportPlan(t.Context(), []string{path}, old, nil, map[string]WhatsAppChat{}, "", time.FixedZone("TEST", -3*60*60))
118         require.NoError(t, err)
119         require.Equal(t, "2025-06-03T18:41:00-03:00", plan.cutoff.Format(time.RFC3339))
120         require.Len(t, plan.added, 1)
121         require.Contains(t, plan.added[0].Text, "Before")
122         require.NotContains(t, plan.added[0].Text, "Boundary")
123         require.NotContains(t, plan.added[0].Text, "After")
124 }
125
126 func TestWhatsAppImportKeepsSystemSenderEvenWithOwnerName(t *testing.T) {
127         path := whatsappImportZip(t, "Owner", "[01/01/2025, 00:00:00] Owner: System line\n")
128         _, messages, err := whatsappImportRead(path, "Owner", time.UTC)
129         require.NoError(t, err)
130         require.False(t, messages[0].Mine)
131 }
132
133 func TestWhatsAppImportRefusesOverlappingInputs(t *testing.T) {
134         path := whatsappImportSample(t)
135         _, err := whatsappImportPlan(t.Context(), []string{path, path}, nil, nil, map[string]WhatsAppChat{}, "", time.UTC)
136         require.ErrorContains(t, err, "inputs overlap")
137 }
138
139 func TestWhatsAppImportSkipsAllDaysOnALaterStep(t *testing.T) {
140         path := whatsappImportSample(t)
141         first, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, nil, "Sam Owner", time.UTC)
142         require.NoError(t, err)
143
144         second, err := whatsappImportPlan(t.Context(), []string{path}, first.added, nil, nil, "Sam Owner", time.UTC)
145         require.NoError(t, err)
146         require.Empty(t, second.added)
147         require.Equal(t, 5, second.skipped)
148         require.True(t, second.cutoff.IsZero())
149 }
150
151 func TestWhatsAppImportMergesChatsByDate(t *testing.T) {
152         later := whatsappImportZip(t, "Later", "[02/01/2025, 08:00:00] Ada: Later\n")
153         earlier := whatsappImportZip(t, "Earlier", "[01/01/2025, 08:00:00] Bea: Earlier\n")
154         plan, err := whatsappImportPlan(t.Context(), []string{later, earlier}, nil, nil, nil, "", time.UTC)
155         require.NoError(t, err)
156         require.Len(t, plan.added, 2)
157         require.Contains(t, plan.added[0].Text, "[WhatsApp] Earlier,")
158         require.Contains(t, plan.added[1].Text, "[WhatsApp] Later,")
159 }
160
161 func TestWhatsAppImportRefusesMissingOrDuplicateChatText(t *testing.T) {
162         testCases := []struct {
163                 name    string
164                 members []string
165                 want    string
166         }{
167                 {
168                         name:    "missing chat text is refused",
169                         members: []string{"other.txt"},
170                         want:    "_chat.txt is missing",
171                 },
172                 {
173                         name:    "duplicate chat text is refused",
174                         members: []string{"_chat.txt", "nested/_chat.txt"},
175                         want:    "more than one _chat.txt",
176                 },
177         }
178
179         for _, tc := range testCases {
180                 t.Run(tc.name, func(t *testing.T) {
181                         path := filepath.Join(t.TempDir(), "Example.zip")
182                         file, err := os.Create(path)
183                         require.NoError(t, err)
184
185                         archive := zip.NewWriter(file)
186                         for _, member := range tc.members {
187                                 _, err = archive.Create(member)
188                                 require.NoError(t, err)
189                         }
190
191                         require.NoError(t, archive.Close())
192                         require.NoError(t, file.Close())
193
194                         _, _, err = whatsappImportRead(path, "", time.UTC)
195                         require.ErrorContains(t, err, tc.want)
196                 })
197         }
198 }
199
200 func TestWhatsAppImportRefusesMalformedExports(t *testing.T) {
201         testCases := []struct {
202                 name string
203                 text string
204                 want string
205         }{
206                 {
207                         name: "text before the first date is refused",
208                         text: "not a message\n",
209                         want: "before the first",
210                 },
211                 {
212                         name: "invalid local dates are refused",
213                         text: "[31/02/2025, 10:00:00] Ada: Invalid\n",
214                         want: "parse export local date",
215                 },
216                 {
217                         name: "exports with no dated messages are refused",
218                         text: "",
219                         want: "no dated messages",
220                 },
221         }
222
223         for _, tc := range testCases {
224                 t.Run(tc.name, func(t *testing.T) {
225                         _, _, err := whatsappImportRead(whatsappImportZip(t, "Example", tc.text), "", time.UTC)
226                         require.ErrorContains(t, err, tc.want)
227                 })
228         }
229 }
230
231 func TestWhatsAppImportPlanCommandDoesNotChangeAgentOrCallModel(t *testing.T) {
232         agent := newRebuildTestAgent(t)
233         dir := filepath.Dir(agent.dir)
234         t.Setenv("ONECHAT_DIR", dir)
235
236         // A plan does not need model settings or credentials.
237         require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
238         require.NoError(t, os.WriteFile(filepath.Join(dir, "auth.json"), []byte("not credentials"), 0o600))
239
240         rebuildTestLog(t, agent.dir, []DatedMessage{{
241                 Kind: "user",
242                 Text: "Current",
243                 Date: "2026-11-01T00:00:00Z",
244         }})
245
246         before := rebuildTestSnapshot(t, agent.dir)
247
248         var report strings.Builder
249
250         require.NoError(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &report))
251         require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
252
253         for _, want := range []string{"chats: 1", "Book Club", "batches: 5", "dates: ", "cutoff: none", "bytes: ", "messages: 6", "estimated calls: 8 (about 1.3 per message, limit 1500)", "owner: unknown"} {
254                 require.Contains(t, report.String(), want)
255         }
256
257         require.NotContains(t, report.String(), "end-to-end encrypted")
258         require.NotContains(t, report.String(), "Sam Owner")
259
260         lock, err := lockTokens(dir)
261         require.NoError(t, err)
262         require.ErrorIs(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), errTokensInUse)
263         require.NoError(t, lock.Close())
264         require.NoError(t, os.WriteFile(filepath.Join(agent.dir, "work.json"), []byte(`{"running":["BlueBird"]}`), 0o600))
265
266         require.ErrorContains(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), "unfinished work")
267 }
268
269 func TestWhatsAppImportDoesNotRebuildWhenAllDaysAreHeld(t *testing.T) {
270         agent := newRebuildTestAgent(t)
271         dir := filepath.Dir(agent.dir)
272         t.Setenv("ONECHAT_DIR", dir)
273
274         path := whatsappImportSample(t)
275         plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, nil, "", time.UTC)
276         require.NoError(t, err)
277
278         rebuildTestLog(t, agent.dir, plan.added)
279         require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
280
281         before := rebuildTestSnapshot(t, agent.dir)
282
283         var report strings.Builder
284
285         require.NoError(t, whatsappImport(t.Context(), []string{path}, &report))
286         require.Contains(t, report.String(), "batches: 0")
287         require.Contains(t, report.String(), "estimated calls: 0")
288         require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
289 }
290
291 func TestWhatsAppImportReadsOnlyLocalSessionNames(t *testing.T) {
292         dir := t.TempDir()
293         whatsappTestSession(t, dir, true)
294
295         session := &WhatsApp{
296                 dir: filepath.Join(dir, whatsappDirName),
297         }
298
299         err := session.openSession(t.Context())
300         require.NoError(t, err)
301
302         session.device.PushName = "Sam Owner"
303         session.device.Account.Details = []byte{}
304         require.NoError(t, session.device.Save(t.Context()))
305         require.NoError(t, session.device.Contacts.PutContactName(t.Context(), whatsappTestOwn, "", "Owner"))
306         require.NoError(t, session.device.Contacts.PutContactName(t.Context(), session.device.LID.ToNonAD(), "", "Owner LID"))
307
308         friend := types.NewJID("friend", types.HiddenUserServer)
309         require.NoError(t, session.device.Contacts.PutContactName(t.Context(), friend, "Ada", "Ada Example"))
310         require.NoError(t, session.device.Contacts.PutContactName(t.Context(), types.NewJID("other", types.HiddenUserServer), "Ada", ""))
311         require.NoError(t, session.container.Close())
312
313         names, owner, err := whatsappImportNames(t.Context(), dir)
314         require.NoError(t, err)
315         require.Equal(t, "Sam Owner", owner)
316         require.Equal(t, "friend@lid", names["Ada Example"].ID)
317         require.Empty(t, names["Ada"].ID)
318         require.NotContains(t, names, "Owner")
319         require.NotContains(t, names, "Owner LID")
320 }
321
322 func TestWhatsAppImportCutoffUsesEachChatAndBothLogs(t *testing.T) {
323         path := whatsappImportZip(t, "Plans", "[03/01/2025, 09:59:59] Ada: Before\n[03/01/2025, 10:00:00] Ada: Boundary\n[03/01/2025, 10:01:00] Ada: After\n")
324         target := []DatedMessage{{
325                 Kind: "work",
326                 Text: "[WhatsApp] Plans, group example@g.us, Friday 2025-01-03\n10:00 Ada: Target first\n10:30 Ada: Target last",
327                 Date: "2025-01-03T10:30:00Z",
328         }}
329
330         root := []DatedMessage{
331                 {
332                         Kind: "work",
333                         Text: "[WhatsApp] Other, group other@g.us, Wednesday 2025-01-01\n08:00 Bea: Other chat",
334                         Date: "2025-01-01T08:00:00Z",
335                 },
336                 {
337                         Kind: "work",
338                         Text: "[WhatsApp] Plans, group example@g.us, Saturday 2025-01-04\n10:00 Ada: Later batch",
339                         Date: "2025-01-04T10:00:00Z",
340                 },
341         }
342
343         testCases := []struct {
344                 name   string
345                 target []DatedMessage
346                 root   []DatedMessage
347         }{
348                 {
349                         name:   "target holds the first live batch",
350                         target: target,
351                         root:   root,
352                 },
353                 {
354                         name:   "root holds the first live batch",
355                         target: root,
356                         root:   target,
357                 },
358         }
359
360         for _, tc := range testCases {
361                 t.Run(tc.name, func(t *testing.T) {
362                         plan, err := whatsappImportPlan(t.Context(), []string{path}, tc.target, tc.root, nil, "", time.UTC)
363                         require.NoError(t, err)
364                         require.Equal(t, "2025-01-03T10:00:00Z", plan.cutoff.Format(time.RFC3339))
365                         require.Len(t, plan.added, 1)
366                         require.Contains(t, plan.added[0].Text, "Before")
367                         require.NotContains(t, plan.added[0].Text, "Boundary")
368                         require.NotContains(t, plan.added[0].Text, "After")
369                 })
370         }
371 }
372
373 func TestWhatsAppImportSelectsOnlyAnExistingDirectChild(t *testing.T) {
374         root := newRebuildTestAgent(t)
375         dir := filepath.Dir(root.dir)
376         t.Setenv("ONECHAT_DIR", dir)
377         daemonChildConfig(t, dir, "House")
378
379         before := rebuildTestSnapshot(t, root.dir)
380         for _, name := range []string{"Missing", "../House", root.config.Name} {
381                 err := whatsappImport(t.Context(), []string{"-n", "--agent", name, whatsappImportSample(t)}, &strings.Builder{})
382                 require.ErrorContains(t, err, "does not exist")
383                 require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
384         }
385 }
386
387 func TestWhatsAppImportParsesGNUFlags(t *testing.T) {
388         testCases := []struct {
389                 name string
390                 args []string
391                 want WhatsAppImportOptions
392         }{
393                 {
394                         name: "default target remains root",
395                         args: []string{"One.zip", "Two.zip"},
396                         want: WhatsAppImportOptions{paths: []string{"One.zip", "Two.zip"}},
397                 },
398                 {
399                         name: "long flag takes a separate child name",
400                         args: []string{"-n", "--agent", "House", "One.zip"},
401                         want: WhatsAppImportOptions{
402                                 planOnly: true,
403                                 agent:    "House",
404                                 paths:    []string{"One.zip"},
405                         },
406                 },
407                 {
408                         name: "flags can follow paths",
409                         args: []string{"One.zip", "--agent=House", "-n"},
410                         want: WhatsAppImportOptions{
411                                 planOnly: true,
412                                 agent:    "House",
413                                 paths:    []string{"One.zip"},
414                         },
415                 },
416                 {
417                         name: "double dash ends flag parsing",
418                         args: []string{"-n", "--", "-Example.zip"},
419                         want: WhatsAppImportOptions{
420                                 planOnly: true,
421                                 paths:    []string{"-Example.zip"},
422                         },
423                 },
424         }
425
426         for _, tc := range testCases {
427                 t.Run(tc.name, func(t *testing.T) {
428                         options, err := whatsappImportParse(tc.args)
429                         require.NoError(t, err)
430                         require.Equal(t, tc.want, options)
431                 })
432         }
433 }
434
435 func TestWhatsAppImportRefusesInvalidFlags(t *testing.T) {
436         testCases := []struct {
437                 name string
438                 args []string
439                 want string
440         }{
441                 {
442                         name: "missing exports are refused",
443                         args: []string{"-n", "--agent", "House"},
444                         want: "usage:",
445                 },
446                 {
447                         name: "missing child name is refused",
448                         args: []string{"--agent"},
449                         want: "needs a child name",
450                 },
451                 {
452                         name: "empty child name is refused",
453                         args: []string{"--agent=", "One.zip"},
454                         want: "needs a child name",
455                 },
456                 {
457                         name: "single dash long flag is refused",
458                         args: []string{"-agent", "House", "One.zip"},
459                         want: "unknown import flag",
460                 },
461         }
462
463         for _, tc := range testCases {
464                 t.Run(tc.name, func(t *testing.T) {
465                         _, err := whatsappImportParse(tc.args)
466                         require.ErrorContains(t, err, tc.want)
467                 })
468         }
469 }
470
471 func TestWhatsAppImportChildPlanReadsRootCutoffButNotRootImportedDays(t *testing.T) {
472         root := newRebuildTestAgent(t)
473         dir := filepath.Dir(root.dir)
474         t.Setenv("ONECHAT_DIR", dir)
475
476         childDir := daemonChildConfig(t, dir, "House")
477         rebuildTestLog(t, root.dir, []DatedMessage{
478                 {
479                         Kind: "work",
480                         Text: "[WhatsApp] Plans, Wednesday 2025-01-01 (imported)\n08:00 Ada: Root history",
481                         Date: "2025-01-01T08:00:00Z",
482                 },
483                 {
484                         Kind: "work",
485                         Text: "[WhatsApp] Plans, group example@g.us, Friday 2025-01-03\n10:00 Ada: Root live\n10:20 Ada: Last",
486                         Date: "2025-01-03T10:20:00Z",
487                 },
488         })
489         rebuildTestLog(t, childDir, []DatedMessage{
490                 {
491                         Kind: "work",
492                         Text: "[WhatsApp] Plans, Thursday 2025-01-02 (imported)\n08:00 Ada: Child history",
493                         Date: "2025-01-02T08:00:00Z",
494                 },
495                 {
496                         Kind: "work",
497                         Text: "[WhatsApp] Plans, group example@g.us, Saturday 2025-01-04\n10:00 Ada: Child live",
498                         Date: "2025-01-04T10:00:00Z",
499                 },
500         })
501
502         path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: Root history\n[02/01/2025, 08:00:00] Ada: Child history\n[03/01/2025, 09:59:59] Ada: Before\n[03/01/2025, 10:00:00] Ada: Boundary\n[03/01/2025, 10:01:00] Ada: After\n")
503         before := rebuildTestSnapshot(t, root.dir)
504
505         var report strings.Builder
506
507         require.NoError(t, whatsappImport(t.Context(), []string{"-n", "--agent", "House", path}, &report))
508         require.Contains(t, report.String(), "agent: House\n")
509         require.Contains(t, report.String(), "batches: 2 (1 chat-days skipped)")
510         require.Contains(t, report.String(), "2025-01-03T10:00:00")
511         require.Contains(t, report.String(), "messages: 4 (merged log)")
512         require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
513
514         report.Reset()
515         require.NoError(t, whatsappImport(t.Context(), []string{"-n", path}, &report))
516         require.Contains(t, report.String(), "agent: "+root.config.Name+"\n")
517         require.Contains(t, report.String(), "batches: 2 (1 chat-days skipped)")
518         require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
519 }
520
521 func TestWhatsAppImportChildRebuildUsesOwnPromptAndCache(t *testing.T) {
522         root := newRebuildTestAgent(t)
523         dir := filepath.Dir(root.dir)
524         childDir := daemonChildConfig(t, dir, "House")
525         daemonChildConfig(t, dir, "Calendar")
526         require.NoError(t, os.WriteFile(filepath.Join(childDir, "instructions.md"), []byte("Keep room dimensions exact."), 0o600))
527         rebuildTestLog(t, root.dir, []DatedMessage{{
528                 Kind: "user",
529                 Text: "Keep the root history.",
530                 Date: "2025-02-01T00:00:00Z",
531         }})
532         rebuildTestLog(t, childDir, []DatedMessage{{
533                 Kind: "user",
534                 Text: "Keep the child history.",
535                 Date: "2025-02-02T00:00:00Z",
536         }})
537
538         child, err := whatsappImportAgent(root.dir, "House")
539         require.NoError(t, err)
540
541         old, err := rebuildReadLog(t.Context(), child.dir)
542         require.NoError(t, err)
543
544         rootLog, err := rebuildReadLog(t.Context(), root.dir)
545         require.NoError(t, err)
546
547         path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: "+strings.Repeat("The room is ready. ", 80)+"\n")
548         plan, err := whatsappImportPlan(t.Context(), []string{path}, old, rootLog, nil, "", time.UTC)
549         require.NoError(t, err)
550
551         before := rebuildTestSnapshot(t, child.dir)
552         sibling := rebuildTestSnapshot(t, filepath.Join(root.dir, "children", "Calendar"))
553         server := newModelFake(t)
554         server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
555                 children, err := root.Children()
556                 if err != nil || len(children) != 2 {
557                         w.WriteHeader(http.StatusBadRequest)
558
559                         return
560                 }
561
562                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The room is ready.")}, 100, 0, 5))
563         }
564
565         client := newModelClient(server.URL, ModelAPIKey(modelTestKey), child.CacheKey(), filepath.Join(t.TempDir(), "usage.jsonl"))
566         backup, err := rebuild(t.Context(), child, plan.added, client, newCompactor(client, child.Prompt, defaultConfig().Compactor, child.Tools()), &strings.Builder{})
567         require.NoError(t, err)
568         require.Equal(t, before, rebuildTestSnapshot(t, backup))
569         require.Equal(t, root.dir, filepath.Dir(backup))
570         require.Equal(t, sibling, rebuildTestSnapshot(t, filepath.Join(root.dir, "children", "Calendar")))
571
572         children, err := root.Children()
573         require.NoError(t, err)
574         require.Len(t, children, 2)
575
576         entries, err := rebuildReadLog(t.Context(), child.dir)
577         require.NoError(t, err)
578         require.Equal(t, append(plan.added, old...), entries)
579
580         unchanged, err := rebuildReadLog(t.Context(), root.dir)
581         require.NoError(t, err)
582         require.Equal(t, rootLog, unchanged)
583
584         prompt, err := child.Prompt()
585         require.NoError(t, err)
586         require.NotEqual(t, root.CacheKey(), child.CacheKey())
587         require.Positive(t, server.Calls())
588
589         for i := range server.Calls() {
590                 call := server.Call(i)
591                 require.Equal(t, child.CacheKey(), call.body["prompt_cache_key"])
592                 require.Equal(t, prompt, call.body["instructions"])
593                 require.Contains(t, prompt, "Keep room dimensions exact.")
594                 require.NotContains(t, string(call.raw), `"name":"whatsapp_send"`)
595         }
596 }
597
598 func TestWhatsAppImportChildFailureKeepsConfiguredChildren(t *testing.T) {
599         root := newRebuildTestAgent(t)
600         daemonChildConfig(t, filepath.Dir(root.dir), "House")
601
602         child, err := whatsappImportAgent(root.dir, "House")
603         require.NoError(t, err)
604
605         before := rebuildTestSnapshot(t, root.dir)
606         server := newModelFake(t)
607         server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
608                 w.WriteHeader(http.StatusBadRequest)
609         }
610
611         client := newModelClient(server.URL, ModelAPIKey(modelTestKey), child.CacheKey(), filepath.Join(t.TempDir(), "usage.jsonl"))
612         _, err = rebuild(t.Context(), child, []DatedMessage{{
613                 Kind: "work",
614                 Text: strings.Repeat("The plan is ready. ", 80),
615                 Date: "2025-01-01T08:00:00Z",
616         }}, client, newCompactor(client, child.Prompt, defaultConfig().Compactor, child.Tools()), &strings.Builder{})
617         require.ErrorContains(t, err, "rebuild")
618         require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
619
620         children, err := root.Children()
621         require.NoError(t, err)
622         require.Len(t, children, 1)
623 }
624
625 func TestWhatsAppImportRefusesPendingDeliveriesAndKeepsRestartSafe(t *testing.T) {
626         testCases := []struct {
627                 name  string
628                 child bool
629         }{
630                 {
631                         name:  "pending child delivery keeps its log ID",
632                         child: true,
633                 },
634                 {
635                         name: "pending root report keeps its log ID",
636                 },
637         }
638
639         for _, tc := range testCases {
640                 t.Run(tc.name, func(t *testing.T) {
641                         dir := t.TempDir()
642                         t.Setenv("ONECHAT_DIR", dir)
643
644                         server := newModelFake(t)
645                         server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
646                                 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
647                         }
648
649                         test := newDaemonMessageTest(t, dir, server)
650                         index := 0
651                         args := []string{whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: The room is ready.\n")}
652                         if tc.child {
653                                 index = 1
654                                 args = append([]string{"--agent", "Garden"}, args...)
655
656                                 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
657                         } else {
658                                 require.NoError(t, test.messages.Finish(test.loops[1], nil, "The plan is ready."))
659                         }
660
661                         queue := test.Queue(t)
662                         require.Len(t, queue.Pending, 1)
663                         require.NotNil(t, queue.Pending[0].LogID)
664
665                         before, err := test.agents[index].memory.Message(*queue.Pending[0].LogID)
666                         require.NoError(t, err)
667                         test.Close(t)
668
669                         root := filepath.Join(dir, "agent")
670                         snapshot := rebuildTestSnapshot(t, root)
671                         require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("invalid model settings"), 0o600))
672
673                         for _, planOnly := range []bool{true, false} {
674                                 input := append([]string(nil), args...)
675                                 if planOnly {
676                                         input = append([]string{"-n"}, input...)
677                                 }
678
679                                 err = whatsappImport(t.Context(), input, &strings.Builder{})
680                                 require.ErrorContains(t, err, "has pending deliveries")
681                                 require.Equal(t, snapshot, rebuildTestSnapshot(t, root))
682                                 require.Zero(t, server.Calls())
683                         }
684
685                         restarted := newDaemonMessageTest(t, dir, server)
686                         require.Equal(t, queue, restarted.Queue(t))
687                         require.Equal(t, 1, restarted.agents[index].memory.Len())
688
689                         after, err := restarted.agents[index].memory.Message(before.ID)
690                         require.NoError(t, err)
691                         require.Equal(t, before, after)
692                         restarted.Start(t, index)
693                         restarted.events[index].WaitFor(t, "idle", 1)
694                         require.Empty(t, restarted.Queue(t).Pending)
695                         require.Equal(t, 1, server.Calls())
696                         restarted.Close(t)
697
698                         again := newDaemonMessageTest(t, dir, server)
699                         require.False(t, again.loops[index].waiting())
700                         require.Equal(t, 1, server.Calls())
701                 })
702         }
703 }
704
705 func TestWhatsAppImportPendingDeliveriesBlockOnlyTheirTarget(t *testing.T) {
706         dir := t.TempDir()
707         t.Setenv("ONECHAT_DIR", dir)
708
709         server := newModelFake(t)
710         test := newDaemonMessageTest(t, dir, server)
711         require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
712
713         queue := test.Queue(t)
714         test.Close(t)
715
716         path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: The room is ready.\n")
717         for _, name := range []string{"", "Library"} {
718                 args := []string{"-n", path}
719                 if name != "" {
720                         args = append(args, "--agent", name)
721                 }
722
723                 require.NoError(t, whatsappImport(t.Context(), args, &strings.Builder{}))
724         }
725
726         queue.Pending[0].LogID = nil
727         data, err := json.Marshal(queue)
728         require.NoError(t, err)
729         require.NoError(t, os.WriteFile(filepath.Join(dir, "agent", "messages.json"), data, 0o600))
730         require.ErrorContains(t, whatsappImport(t.Context(), []string{"-n", "--agent", "Garden", path}, &strings.Builder{}), "has pending deliveries")
731         require.Zero(t, server.Calls())
732 }
733
734 func TestWhatsAppImportDefaultPlanDoesNotCreateAgent(t *testing.T) {
735         dir := t.TempDir()
736         t.Setenv("ONECHAT_DIR", dir)
737
738         var report strings.Builder
739
740         require.NoError(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &report))
741         require.Contains(t, report.String(), "agent: OneChat\n")
742
743         _, err := os.Stat(filepath.Join(dir, "agent"))
744         require.ErrorIs(t, err, os.ErrNotExist)
745 }
746
747 func whatsappImportSample(t *testing.T) string {
748         t.Helper()
749
750         data, err := os.ReadFile(filepath.Join("testdata", "whatsapp-export.zip"))
751         require.NoError(t, err)
752
753         path := filepath.Join(t.TempDir(), "WhatsApp Chat - Book Club.zip")
754         require.NoError(t, os.WriteFile(path, data, 0o600))
755
756         return path
757 }
758
759 func whatsappImportZip(t *testing.T, name, text string) string {
760         t.Helper()
761
762         path := filepath.Join(t.TempDir(), "WhatsApp Chat - "+name+".zip")
763         file, err := os.Create(path)
764         require.NoError(t, err)
765
766         archive := zip.NewWriter(file)
767         writer, err := archive.Create("_chat.txt")
768         require.NoError(t, err)
769
770         _, err = writer.Write([]byte(text))
771         require.NoError(t, err)
772         require.NoError(t, archive.Close())
773         require.NoError(t, file.Close())
774
775         return path
776 }