15 "github.com/stretchr/testify/require"
18 func TestRunningDaemonBackupRestoresTheChatAndLinks(t *testing.T) {
20 whatsappTestSession(t, dir, true)
22 files := map[string]string{
23 "auth.json": `{"access_token":"fake-secret"}`,
24 "telegram.json": `{"token":"fake-token","owner":123}`,
25 "config.json": `{"endpoint":"api"}`,
26 "agent/instructions.md": "Keep the instructions.\n",
27 "agent/telegram-offset.json": "23\n",
30 for name, data := range files {
31 path := filepath.Join(dir, name)
32 require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
33 require.NoError(t, os.WriteFile(path, []byte(data), 0o600))
36 memory, whatsapp, subagents := backupStartDaemon(t, dir)
37 require.True(t, whatsapp.isLinked())
39 _, err := memory.Append("note", "A durable decision.")
40 require.NoError(t, err)
42 _, err = memory.Append("note", "The next decision.")
43 require.NoError(t, err)
44 require.NoError(t, memory.Settle(t.Context()))
46 child, err := subagents.Create(t.Context())
47 require.NoError(t, err)
49 _, err = child.Append("note", "subagent")
50 require.NoError(t, err)
52 _, err = child.Append("user", "A saved task.")
53 require.NoError(t, err)
55 childText, err := child.Show()
56 require.NoError(t, err)
58 for _, excluded := range []string{"probe/skip", "agent-backup-example/skip"} {
59 path := filepath.Join(dir, excluded)
60 require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
61 require.NoError(t, os.WriteFile(path, []byte("excluded"), 0o600))
64 archive := filepath.Join(t.TempDir(), "daily.tar.gz")
65 t.Setenv("ONECHAT_DIR", dir)
67 var answer strings.Builder
69 require.NoError(t, backup(t.Context(), archive, &answer))
70 require.Regexp(t, `^`+archive+` \([0-9]+ bytes\)\n$`, answer.String())
72 info, err := os.Stat(archive)
73 require.NoError(t, err)
74 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
76 restored := t.TempDir()
77 require.NoError(t, backupRestore(t.Context(), archive, restored))
79 for name, data := range files {
80 bytes, err := os.ReadFile(filepath.Join(restored, name))
81 require.NoError(t, err)
82 require.Equal(t, data, string(bytes))
85 for _, excluded := range []string{"probe", "agent-backup-example", socketFileName, "agent/lock"} {
86 _, err = os.Stat(filepath.Join(restored, excluded))
87 require.ErrorIs(t, err, os.ErrNotExist)
90 restoredMemory, restoredWhatsApp, restoredSubagents := backupStartDaemon(t, restored)
91 require.Equal(t, memory.Len(), restoredMemory.Len())
92 require.Equal(t, memory.View(), restoredMemory.View())
94 for id := range memory.Len() {
95 expected, err := memory.Message(id)
96 require.NoError(t, err)
98 actual, err := restoredMemory.Message(id)
99 require.NoError(t, err)
100 require.Equal(t, expected, actual)
103 require.True(t, restoredWhatsApp.isLinked())
104 require.Equal(t, whatsapp.link().own, restoredWhatsApp.link().own)
106 restoredChild, err := restoredSubagents.Get(t.Context(), child.name)
107 require.NoError(t, err)
109 restoredText, err := restoredChild.Show()
110 require.NoError(t, err)
111 require.Equal(t, childText, restoredText)
114 func TestBackupKeepsPendingNotificationsAndCompletedRetryReceipts(t *testing.T) {
115 directory := t.TempDir()
116 server := newModelFake(t)
117 test := newDaemonMessageTest(t, directory, server)
118 _, err := test.messages.Notify(&NotifyInput{
121 Text: "A completed result.",
123 require.NoError(t, err)
124 require.NoError(t, test.messages.Notifications(test.loops[0]))
125 require.NoError(t, test.loops[0].turn(t.Context()))
127 _, err = test.messages.Notify(&NotifyInput{
130 Text: "A pending result.",
132 require.NoError(t, err)
134 clock := newTestClock()
135 whatsapp, _, _ := whatsappStartTest(t, directory, clock)
136 archive := filepath.Join(t.TempDir(), "events.tar.gz")
137 _, err = backupData(t.Context(), directory, archive, whatsapp, test.messages.SnapshotAgents)
138 require.NoError(t, err)
140 restored := t.TempDir()
141 require.NoError(t, backupRestore(t.Context(), archive, restored))
143 before, err := os.ReadFile(test.messages.path)
144 require.NoError(t, err)
146 after, err := os.ReadFile(filepath.Join(restored, daemonAgentDirName, "messages.json"))
147 require.NoError(t, err)
148 require.Equal(t, before, after)
150 recovered := newDaemonMessageTest(t, restored, server)
151 require.Len(t, recovered.Queue(t).Pending, 1)
152 require.Len(t, recovered.Queue(t).Notifications, 2)
154 duplicate, err := recovered.messages.Notify(&NotifyInput{
157 Text: "A completed result.",
159 require.NoError(t, err)
160 require.Equal(t, "duplicate", duplicate.Status)
163 func TestBackupRestoresEachChildWithItsOwnState(t *testing.T) {
165 daemonChildConfig(t, dir, "Garden")
166 daemonChildConfig(t, dir, "Library")
168 server := newModelFake(t)
169 clock := newTestClock()
170 agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, clock.Now, discardLog())
171 require.NoError(t, err)
174 require.NoError(t, agents.Close())
177 whatsapp, _, _ := whatsappStartTest(t, dir, clock)
179 var loops []*TurnLoop
181 for _, agent := range agents {
182 loop, err := agent.Loop(whatsapp, func(Event) {
184 require.NoError(t, err)
186 loops = append(loops, loop)
187 _, err = loop.Submit("parent", "Keep the decision of "+agent.agent.config.Name)
188 require.NoError(t, err)
189 require.NoError(t, loop.turn(t.Context()))
190 require.NoError(t, agent.memory.Settle(t.Context()))
192 subagent, err := agent.subagents.Create(t.Context())
193 require.NoError(t, err)
195 _, err = subagent.Append("user", "Keep the task of "+agent.agent.config.Name)
196 require.NoError(t, err)
199 archive := filepath.Join(t.TempDir(), "children.tar.gz")
200 _, err = backupData(t.Context(), dir, archive, whatsapp, func(ctx context.Context, snapshot func() error) error {
201 return daemonSnapshot(ctx, loops, snapshot)
203 require.NoError(t, err)
205 restored := t.TempDir()
206 require.NoError(t, backupRestore(t.Context(), archive, restored))
208 restoredAgents, err := openDaemonAgents(t.Context(), restored, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, clock.Now, discardLog())
209 require.NoError(t, err)
212 require.NoError(t, restoredAgents.Close())
215 require.Len(t, restoredAgents, 3)
217 for i, agent := range agents {
218 other := restoredAgents[i]
219 require.Equal(t, agent.agent.config, other.agent.config)
220 require.Equal(t, agent.memory.View(), other.memory.View())
221 require.Equal(t, agent.memory.Len(), other.memory.Len())
222 require.Equal(t, modelReadUsage(t, agent.client.UsagePath()), modelReadUsage(t, other.client.UsagePath()))
224 for id := range agent.memory.Len() {
225 before, err := agent.memory.Message(id)
226 require.NoError(t, err)
228 after, err := other.memory.Message(id)
229 require.NoError(t, err)
230 require.Equal(t, before, after)
233 entries, err := os.ReadDir(filepath.Join(agent.agent.dir, subagentDirName))
234 require.NoError(t, err)
235 require.Len(t, entries, 1)
237 subagent, err := other.subagents.Get(t.Context(), entries[0].Name())
238 require.NoError(t, err)
240 text, err := subagent.Show()
241 require.NoError(t, err)
242 require.Contains(t, text, "Keep the task of "+agent.agent.config.Name)
246 func TestRestoreRefusesNonemptyDataWithoutChangingIt(t *testing.T) {
248 path := filepath.Join(dir, "keep")
249 require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
251 err := backupRestore(t.Context(), "missing.tar.gz", dir)
252 require.ErrorContains(t, err, "empty data directory")
254 entries, err := os.ReadDir(dir)
255 require.NoError(t, err)
256 require.Len(t, entries, 1)
258 data, err := os.ReadFile(path)
259 require.NoError(t, err)
260 require.Equal(t, "keep", string(data))
263 func TestRestoreRefusesTheDaemonTokenLock(t *testing.T) {
265 tokens, err := lockTokens(dir)
266 require.NoError(t, err)
269 require.NoError(t, tokens.Close())
272 err = backupRestore(t.Context(), "missing.tar.gz", dir)
273 require.ErrorIs(t, err, errTokensInUse)
276 func TestUnsafeRestoreInstallsNoArchiveFiles(t *testing.T) {
277 archive := backupTestArchive(t,
280 Typeflag: tar.TypeReg,
285 Typeflag: tar.TypeReg,
291 err := backupRestore(t.Context(), archive, dir)
292 require.ErrorContains(t, err, "unsafe archive path")
294 entries, err := os.ReadDir(dir)
295 require.NoError(t, err)
296 require.Len(t, entries, 1)
297 require.Equal(t, tokenLockFileName, entries[0].Name())
300 func TestBackupNeedsARunningDaemon(t *testing.T) {
301 t.Setenv("ONECHAT_DIR", t.TempDir())
303 err := backup(t.Context(), filepath.Join(t.TempDir(), "backup.tar.gz"), io.Discard)
304 require.EqualError(t, err, "backup needs a running daemon")
307 func TestWhatsAppSessionBackupIncludesCommittedWALWrites(t *testing.T) {
309 whatsappTestSession(t, dir, true)
311 whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
312 address, err := whatsappSessionAddress(filepath.Join(dir, whatsappDirName, whatsappSessionFileName))
313 require.NoError(t, err)
315 database, err := sql.Open("sqlite", address)
316 require.NoError(t, err)
319 require.NoError(t, database.Close())
322 _, err = database.ExecContext(t.Context(), "PRAGMA journal_mode=WAL; CREATE TABLE backup_sample (value TEXT); INSERT INTO backup_sample VALUES ('saved');")
323 require.NoError(t, err)
325 target := filepath.Join(t.TempDir(), "copy", whatsappSessionFileName)
326 require.NoError(t, whatsapp.BackupSession(t.Context(), target))
328 address, err = whatsappSessionAddress(target)
329 require.NoError(t, err)
331 restored, err := sql.Open("sqlite", address)
332 require.NoError(t, err)
335 require.NoError(t, restored.Close())
340 err = restored.QueryRowContext(t.Context(), "SELECT value FROM backup_sample").Scan(&value)
341 require.NoError(t, err)
342 require.Equal(t, "saved", value)
344 info, err := os.Stat(target)
345 require.NoError(t, err)
346 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
349 func TestSessionBackupWaitsForRemovalToFinish(t *testing.T) {
351 whatsappTestSession(t, dir, true)
353 whatsapp, fake, _ := whatsappStartTest(t, dir, newTestClock())
354 disconnect := &BackupDisconnect{
355 WhatsAppClient: fake,
356 entered: make(chan struct{}),
357 release: make(chan struct{}),
360 var release sync.Once
362 defer release.Do(func() {
363 close(disconnect.release)
366 whatsapp.mutex.Lock()
368 whatsapp.client = disconnect
369 whatsapp.mutex.Unlock()
371 removed := make(chan error, 1)
373 removed <- whatsapp.removeSession()
378 target := filepath.Join(t.TempDir(), "copy", whatsappSessionFileName)
379 backedUp := make(chan error, 1)
380 started := make(chan struct{})
384 backedUp <- whatsapp.BackupSession(t.Context(), target)
388 require.Never(t, func() bool {
389 return len(backedUp) != 0
390 }, 50*time.Millisecond, time.Millisecond)
393 close(disconnect.release)
395 require.NoError(t, <-removed)
396 require.NoError(t, <-backedUp)
398 for _, path := range []string{whatsapp.sessionPath(), target} {
399 _, err := os.Stat(path)
400 require.ErrorIs(t, err, os.ErrNotExist)
404 // BackupDisconnect holds session removal in its unlocked client cleanup.
405 type BackupDisconnect struct {
407 entered chan struct{}
408 release chan struct{}
411 func (d *BackupDisconnect) Disconnect() {
416 // backupStartDaemon serves fake model and WhatsApp clients on the socket of
417 // dir. It keeps all state files under dir and holds the daemon token lock.
418 func backupStartDaemon(t *testing.T, dir string) (*Memory, *WhatsApp, *Subagents) {
421 tokens, err := lockTokens(dir)
422 require.NoError(t, err)
424 clock := newTestClock()
425 agent, err := openAgent(filepath.Join(dir, daemonAgentDirName))
426 require.NoError(t, err)
428 memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer(), discardLog())
429 require.NoError(t, err)
431 whatsapp, _, _ := whatsappStartTest(t, dir, clock)
432 subagents := newSubagents(filepath.Join(agent.dir, subagentDirName), clock.Now, subagentNameRandom)
433 schedules, err := openSchedules(filepath.Join(agent.dir, scheduleFileName), "OneChat", clock.Now, discardLog())
434 require.NoError(t, err)
436 server := newModelFake(t)
437 client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
438 listener, err := daemonListenSocket(filepath.Join(dir, socketFileName))
439 require.NoError(t, err)
441 ctx, cancel := context.WithCancel(t.Context())
442 done := make(chan error, 1)
444 done <- daemonServe(ctx, listener, nil, whatsapp, schedules, func(notify func(Event)) (*TurnLoop, error) {
445 return newTurnLoop(agent, memory, subagents, whatsapp, schedules, client, clock.Now, discardLog(), notify)
451 require.NoError(t, <-done)
452 require.NoError(t, subagents.Close())
453 require.NoError(t, memory.Close())
454 require.NoError(t, tokens.Close())
457 return memory, whatsapp, subagents