]> git.giacomolli.uk - onechat/blob - daemon_children_test.go
Accept owner photos and image files in Telegram
[onechat] / daemon_children_test.go
1 package main
2
3 import (
4         "context"
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 )
15
16 func TestDaemonToolsCreateAndTellAChildDuringATurn(t *testing.T) {
17         dir := t.TempDir()
18         server := newModelFake(t)
19         agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, discardLog())
20         require.NoError(t, err)
21
22         t.Cleanup(func() {
23                 require.NoError(t, agents.Close())
24         })
25
26         root := agents[0]
27         next := 0
28         server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
29                 if call.body["prompt_cache_key"] != root.agent.CacheKey() {
30                         modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Approve the gate size.")}, 100, 0, 5))
31
32                         return
33                 }
34
35                 next++
36
37                 var items []any
38
39                 switch next {
40                 case 1:
41                         items = []any{modelFunctionCallItem("fc_1", "call_1", "agent_create", `{"name":"House","responsibility":"Keep garden decisions."}`)}
42                 case 2:
43                         items = []any{modelFunctionCallItem("fc_2", "call_2", "tell", `{"name":"House","message":"Check the gate size."}`)}
44                 case 3:
45                         items = []any{modelFunctionCallItem("fc_3", "call_3", "zoom", `{"id":0,"n":1,"name":"House"}`)}
46                 default:
47                         items = []any{modelMessageItem("I will ask the user.")}
48                 }
49
50                 modelWriteEvents(w, modelCompletedEvent(items, 100, 0, 5))
51         }
52         whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
53         listener, err := daemonListenSocket(filepath.Join(dir, socketFileName))
54         require.NoError(t, err)
55
56         ctx, cancel := context.WithCancel(t.Context())
57         defer cancel()
58
59         done := make(chan error, 1)
60         go func() {
61                 done <- daemonServe(ctx, listener, nil, whatsapp, root.schedules, func(notify func(Event)) (*TurnLoop, error) {
62                         return root.Loop(whatsapp, notify)
63                 })
64         }()
65
66         t.Cleanup(func() {
67                 cancel()
68                 require.NoError(t, <-done)
69         })
70
71         client := terminalStartTestClient(t, listener.Addr().String())
72         client.WaitFor(t, "[connected to OneChat]")
73         client.Type("Create a child and ask it about the gate.")
74         client.WaitFor(t, "[House] Approve the gate size.")
75         client.WaitFor(t, "I will ask the user.")
76         require.Contains(t, client.Output(), "Started House.")
77         require.Contains(t, client.Output(), "user: Check the gate size.")
78
79         child, err := openAgent(filepath.Join(root.agent.dir, "children", "House"))
80         require.NoError(t, err)
81         require.Equal(t, root.agent.config.Model, child.config.Model)
82         require.Equal(t, root.agent.config.Effort, child.config.Effort)
83         require.Equal(t, root.agent.config.Workdir, child.config.Workdir)
84         require.Equal(t, root.agent.config.Name, child.config.Parent)
85         require.NotEqual(t, root.agent.CacheKey(), child.CacheKey())
86
87         client.Type("Show the garden responsibility.")
88         require.Eventually(t, func() bool {
89                 for i := range server.Calls() {
90                         call := server.Call(i)
91                         texts := turnInputTexts(t, call)
92                         if len(texts) > 0 && texts[len(texts)-1] == "Show the garden responsibility." {
93                                 return strings.Contains(strings.Join(texts, "\n"), "Children:\nHouse: Keep garden decisions.")
94                         }
95                 }
96
97                 return false
98         }, 5*time.Second, time.Millisecond)
99 }
100
101 func TestChildCreationRefusesUsedNames(t *testing.T) {
102         test := newDaemonChildrenTest(t)
103         box := test.root.tools
104         require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"OneChat","responsibility":"Other plans."}`), "already used")
105
106         require.NoDirExists(t, filepath.Join(test.root.agent.dir, "children"))
107         require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":" "}`), "responsibility")
108         require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":"Keep garden plans."}`), "Started House.")
109         require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":"Other plans."}`), "already used")
110
111         test.root.runs.subagents.draw = func() string {
112                 return "BlueFox"
113         }
114         subagent, err := test.root.runs.subagents.Create(t.Context())
115         require.NoError(t, err)
116         require.Equal(t, "BlueFox", subagent.name)
117         require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"BlueFox","responsibility":"Other plans."}`), "already used by a subagent")
118         require.NoError(t, test.root.runs.subagents.Reserve("Calendar"))
119         require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"Calendar","responsibility":"Keep dates."}`), "already used")
120 }
121
122 func TestChildCreationAcceptsAreaNames(t *testing.T) {
123         testCases := []struct {
124                 name  string
125                 child string
126         }{
127                 {
128                         name:  "house area",
129                         child: "House",
130                 },
131                 {
132                         name:  "calendar area",
133                         child: "Calendar",
134                 },
135                 {
136                         name:  "two letters",
137                         child: "Ab",
138                 },
139                 {
140                         name:  "capital letters after the first",
141                         child: "ABcD",
142                 },
143                 {
144                         name:  "thirty-two letters",
145                         child: "A" + strings.Repeat("b", 31),
146                 },
147         }
148         for _, tc := range testCases {
149                 t.Run(tc.name, func(t *testing.T) {
150                         test := newDaemonChildrenTest(t)
151                         arguments, err := json.Marshal(ToolAgentCreateArguments{
152                                 Name:           tc.child,
153                                 Responsibility: "Keep area decisions.",
154                         })
155                         require.NoError(t, err)
156                         require.Contains(t, test.root.tools.run(t.Context(), "agent_create", string(arguments)), "Started "+tc.child+".")
157
158                         child := test.runtime.messages.Child(test.root.agent.config.Name, tc.child)
159                         require.NotNil(t, child)
160                         require.Equal(t, tc.child, child.agent.config.Name)
161                         require.FileExists(t, filepath.Join(child.agent.dir, "config.json"))
162                 })
163         }
164 }
165
166 func TestChildCreationRefusesInvalidAreaNames(t *testing.T) {
167         testCases := []struct {
168                 name  string
169                 child string
170         }{
171                 {
172                         name:  "empty name",
173                         child: "",
174                 },
175                 {
176                         name:  "one letter",
177                         child: "H",
178                 },
179                 {
180                         name:  "thirty-three letters",
181                         child: "A" + strings.Repeat("b", 32),
182                 },
183                 {
184                         name:  "lowercase initial",
185                         child: "house",
186                 },
187                 {
188                         name:  "two words",
189                         child: "My House",
190                 },
191                 {
192                         name:  "digit",
193                         child: "House2",
194                 },
195                 {
196                         name:  "path",
197                         child: "../House",
198                 },
199                 {
200                         name:  "punctuation",
201                         child: "House-",
202                 },
203                 {
204                         name:  "non-ASCII initial",
205                         child: "\u00c1rea",
206                 },
207                 {
208                         name:  "non-ASCII remainder",
209                         child: "Hous\u00e9",
210                 },
211         }
212         for _, tc := range testCases {
213                 t.Run(tc.name, func(t *testing.T) {
214                         test := newDaemonChildrenTest(t)
215                         arguments, err := json.Marshal(ToolAgentCreateArguments{
216                                 Name:           tc.child,
217                                 Responsibility: "Keep area decisions.",
218                         })
219                         require.NoError(t, err)
220                         require.Contains(t, test.root.tools.run(t.Context(), "agent_create", string(arguments)), "name must have 2 to 32 ASCII letters and start with a capital letter")
221                         require.NoDirExists(t, filepath.Join(test.root.agent.dir, "children"))
222                 })
223         }
224 }
225
226 func TestFailedChildPublicationKeepsStartupAndRetryWorking(t *testing.T) {
227         if os.Geteuid() == 0 {
228                 t.Skip("requires directory write permissions")
229         }
230
231         dir := t.TempDir()
232         root, err := openAgent(filepath.Join(dir, daemonAgentDirName))
233         require.NoError(t, err)
234
235         subagents := newSubagents(filepath.Join(root.dir, subagentDirName), time.Now, subagentNameRandom)
236         defer func() {
237                 require.NoError(t, subagents.Close())
238         }()
239
240         children := filepath.Join(root.dir, "children")
241         require.NoError(t, os.Mkdir(children, 0o500))
242         t.Cleanup(func() {
243                 require.NoError(t, os.Chmod(children, 0o700))
244         })
245
246         _, err = root.CreateChild("GreenOtter", "Keep garden plans.", subagents)
247         require.ErrorContains(t, err, "publish child directory")
248         require.NoDirExists(t, filepath.Join(children, "GreenOtter"))
249
250         staged, err := filepath.Glob(filepath.Join(root.dir, ".child-*"))
251         require.NoError(t, err)
252         require.Empty(t, staged)
253
254         server := newModelFake(t)
255         reopened, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
256         require.NoError(t, err)
257         require.Len(t, reopened, 1)
258
259         defer func() {
260                 require.NoError(t, reopened.Close())
261         }()
262
263         require.NoError(t, os.Chmod(children, 0o700))
264
265         child, err := root.CreateChild("GreenOtter", "Keep garden plans.", subagents)
266         require.NoError(t, err)
267         require.Equal(t, filepath.Join(children, "GreenOtter"), child.dir)
268 }
269
270 func TestInterruptedChildPreparationDoesNotStopStartup(t *testing.T) {
271         dir := t.TempDir()
272         root, err := openAgent(filepath.Join(dir, daemonAgentDirName))
273         require.NoError(t, err)
274
275         staged, err := os.MkdirTemp(root.dir, ".child-*")
276         require.NoError(t, err)
277         require.NoError(t, os.WriteFile(filepath.Join(staged, ".config.json.partial"), []byte("{"), 0o600))
278
279         server := newModelFake(t)
280         agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
281         require.NoError(t, err)
282
283         defer func() {
284                 require.NoError(t, agents.Close())
285         }()
286
287         require.Len(t, agents, 1)
288
289         child, err := root.CreateChild("GreenOtter", "Keep garden plans.", agents[0].subagents)
290         require.NoError(t, err)
291
292         children, err := root.Children()
293         require.NoError(t, err)
294         require.Len(t, children, 1)
295         require.Equal(t, child.config, children[0].config)
296         require.DirExists(t, staged)
297 }
298
299 func TestChildPublicationDoesNotReplaceAnExistingDirectory(t *testing.T) {
300         test := newDaemonChildrenTest(t)
301         path := filepath.Join(test.root.agent.dir, "children", "GreenOtter")
302         require.NoError(t, os.MkdirAll(path, 0o700))
303
304         _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
305         require.ErrorContains(t, err, "publish child directory")
306         require.NoFileExists(t, filepath.Join(path, "config.json"))
307         require.NoError(t, test.root.runs.subagents.Reserve("GreenOtter"))
308         test.root.runs.subagents.Release("GreenOtter")
309 }
310
311 func TestChildNamesStayOutOfSubagentDraws(t *testing.T) {
312         test := newDaemonChildrenTest(t)
313         _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
314         require.NoError(t, err)
315
316         names := []string{"GreenOtter", "BlueFox"}
317         test.root.runs.subagents.draw = func() string {
318                 name := names[0]
319                 names = names[1:]
320
321                 return name
322         }
323         subagent, err := test.root.runs.subagents.Create(t.Context())
324         require.NoError(t, err)
325         require.Equal(t, "BlueFox", subagent.name)
326 }
327
328 func TestChildToolsExcludeCreationAndWhatsApp(t *testing.T) {
329         test := newDaemonChildrenTest(t)
330         _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
331         require.NoError(t, err)
332
333         child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
334         require.NotNil(t, child)
335
336         names := promptToolNames(child.agent.Tools())
337         require.NotContains(t, names, "agent_create")
338         require.NotContains(t, names, "whatsapp_send")
339         require.NotContains(t, names, "whatsapp_find")
340         require.Contains(t, names, "spawn")
341         require.Contains(t, names, "schedule_set")
342         require.Contains(t, names, "schedule_list")
343         require.Contains(t, names, "schedule_remove")
344         require.Equal(t, `error: unknown tool "agent_create"`, child.tools.run(t.Context(), "agent_create", `{"name":"BlueFox","responsibility":"Other plans."}`))
345         require.Nil(t, child.tools.messages.Child("GreenOtter", "OneChat"))
346 }
347
348 func TestZoomReadsTheChildViewAndKeepsSubagentLogs(t *testing.T) {
349         test := newDaemonChildrenTest(t)
350         _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
351         require.NoError(t, err)
352
353         child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
354         _, err = child.memory.Append("note", "The gate is narrow.")
355         require.NoError(t, err)
356         require.NoError(t, child.memory.Settle(t.Context()))
357
358         output := test.root.tools.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"GreenOtter"}`)
359         require.Equal(t, strings.Join(child.memory.ViewLines(nil), ""), output)
360         require.Contains(t, output, "note: The gate is narrow.")
361
362         test.root.runs.subagents.draw = func() string {
363                 return "BlueFox"
364         }
365         subagent, err := test.root.runs.subagents.Create(t.Context())
366         require.NoError(t, err)
367
368         _, err = subagent.Append("note", "A short task.")
369         require.NoError(t, err)
370         require.Equal(t, "0|note: A short task.", test.root.tools.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"BlueFox"}`))
371 }
372
373 func TestRuntimeChildJoinsBackupLocksAndStopsWithDaemon(t *testing.T) {
374         test := newDaemonChildrenTest(t)
375         _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
376         require.NoError(t, err)
377
378         child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
379         _, err = child.memory.Append("note", "Keep the gate plan.")
380         require.NoError(t, err)
381
382         appended := make(chan error, 1)
383         require.NoError(t, test.runtime.messages.SnapshotAgents(t.Context(), func() error {
384                 require.FileExists(t, filepath.Join(child.agent.dir, "view.json"))
385
386                 go func() {
387                         _, err := child.memory.Append("note", "A later plan.")
388                         appended <- err
389                 }()
390
391                 select {
392                 case err := <-appended:
393                         require.FailNow(t, "child append bypasses snapshot lock", "%v", err)
394                 case <-time.After(20 * time.Millisecond):
395                 }
396
397                 return nil
398         }))
399         require.NoError(t, <-appended)
400         test.cancel()
401         require.NoError(t, test.runtime.Close())
402
403         test.runtime.opened = nil
404         reopened, err := test.root.openChild(t.Context(), child.agent)
405         require.NoError(t, err)
406         require.Equal(t, 2, reopened.memory.Len())
407         require.NoError(t, reopened.Close())
408 }
409
410 func TestRootStateListsChildResponsibilitiesOnlyWhenPresent(t *testing.T) {
411         test := newDaemonChildrenTest(t)
412         before := test.root.state()
413         require.NotContains(t, before, "Children:")
414
415         _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
416         require.NoError(t, err)
417
418         _, err = test.runtime.Create("BlueFox", "Keep book notes.")
419         require.NoError(t, err)
420         require.Equal(t, before+"\n\nChildren:\nBlueFox: Keep book notes.\nGreenOtter: Keep garden plans.", test.root.state())
421
422         child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
423         require.NotContains(t, child.state(), "Children:")
424 }
425
426 // DaemonChildrenTest owns one root and runtime children without a root turn.
427 type DaemonChildrenTest struct {
428         root    *TurnLoop
429         runtime *DaemonChildren
430         server  *ModelFake
431         cancel  context.CancelFunc
432 }
433
434 func newDaemonChildrenTest(t *testing.T) *DaemonChildrenTest {
435         t.Helper()
436
437         test := &DaemonChildrenTest{
438                 server: newModelFake(t),
439         }
440         agents, err := openDaemonAgents(t.Context(), t.TempDir(), test.server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
441         require.NoError(t, err)
442
443         test.root, err = agents[0].Loop(nil, func(Event) {
444         })
445         require.NoError(t, err)
446
447         messages, err := openDaemonMessages([]*TurnLoop{test.root})
448         require.NoError(t, err)
449
450         ctx, cancel := context.WithCancel(t.Context())
451         test.cancel = cancel
452         test.runtime = &DaemonChildren{
453                 ctx:      ctx,
454                 cancel:   cancel,
455                 root:     test.root,
456                 messages: messages,
457         }
458         test.root.tools.children = test.runtime
459         t.Cleanup(func() {
460                 cancel()
461                 require.NoError(t, test.runtime.Close())
462                 test.root.runs.Close()
463                 require.NoError(t, agents.Close())
464         })
465
466         return test
467 }