]> git.giacomolli.uk - onechat/blob - daemon_children_test.go
Keep test daemon sockets under the path limit
[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, "image_show")
339 require.Equal(t, `error: unknown tool "image_show"`, child.tools.run(t.Context(), "image_show", `{"image":"unknown","caption":""}`))
340 require.NotContains(t, names, "whatsapp_send")
341 require.NotContains(t, names, "whatsapp_find")
342 require.Contains(t, names, "spawn")
343 require.Contains(t, names, "schedule_set")
344 require.Contains(t, names, "schedule_list")
345 require.Contains(t, names, "schedule_remove")
346 require.Equal(t, `error: unknown tool "agent_create"`, child.tools.run(t.Context(), "agent_create", `{"name":"BlueFox","responsibility":"Other plans."}`))
347 require.Nil(t, child.tools.messages.Child("GreenOtter", "OneChat"))
348 }
349
350 func TestZoomReadsTheChildViewAndKeepsSubagentLogs(t *testing.T) {
351 test := newDaemonChildrenTest(t)
352 _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
353 require.NoError(t, err)
354
355 child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
356 _, err = child.memory.Append("note", "The gate is narrow.")
357 require.NoError(t, err)
358 require.NoError(t, child.memory.Settle(t.Context()))
359
360 output := test.root.tools.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"GreenOtter"}`)
361 require.Equal(t, strings.Join(child.memory.ViewLines(nil), ""), output)
362 require.Contains(t, output, "note: The gate is narrow.")
363
364 test.root.runs.subagents.draw = func() string {
365 return "BlueFox"
366 }
367 subagent, err := test.root.runs.subagents.Create(t.Context())
368 require.NoError(t, err)
369
370 _, err = subagent.Append("note", "A short task.")
371 require.NoError(t, err)
372 require.Equal(t, "0|note: A short task.", test.root.tools.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"BlueFox"}`))
373 }
374
375 func TestRuntimeChildJoinsBackupLocksAndStopsWithDaemon(t *testing.T) {
376 test := newDaemonChildrenTest(t)
377 _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
378 require.NoError(t, err)
379
380 child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
381 _, err = child.memory.Append("note", "Keep the gate plan.")
382 require.NoError(t, err)
383
384 appended := make(chan error, 1)
385 require.NoError(t, test.runtime.messages.SnapshotAgents(t.Context(), func() error {
386 require.FileExists(t, filepath.Join(child.agent.dir, "view.json"))
387
388 go func() {
389 _, err := child.memory.Append("note", "A later plan.")
390 appended <- err
391 }()
392
393 select {
394 case err := <-appended:
395 require.FailNow(t, "child append bypasses snapshot lock", "%v", err)
396 case <-time.After(20 * time.Millisecond):
397 }
398
399 return nil
400 }))
401 require.NoError(t, <-appended)
402 test.cancel()
403 require.NoError(t, test.runtime.Close())
404
405 test.runtime.opened = nil
406 reopened, err := test.root.openChild(t.Context(), child.agent)
407 require.NoError(t, err)
408 require.Equal(t, 2, reopened.memory.Len())
409 require.NoError(t, reopened.Close())
410 }
411
412 func TestRootStateListsChildResponsibilitiesOnlyWhenPresent(t *testing.T) {
413 test := newDaemonChildrenTest(t)
414 before := test.root.state()
415 require.NotContains(t, before, "Children:")
416
417 _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
418 require.NoError(t, err)
419
420 _, err = test.runtime.Create("BlueFox", "Keep book notes.")
421 require.NoError(t, err)
422 require.Equal(t, before+"\n\nChildren:\nBlueFox: Keep book notes.\nGreenOtter: Keep garden plans.", test.root.state())
423
424 child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
425 require.NotContains(t, child.state(), "Children:")
426 }
427
428 // DaemonChildrenTest owns one root and runtime children without a root turn.
429 type DaemonChildrenTest struct {
430 root *TurnLoop
431 runtime *DaemonChildren
432 server *ModelFake
433 cancel context.CancelFunc
434 }
435
436 func newDaemonChildrenTest(t *testing.T) *DaemonChildrenTest {
437 t.Helper()
438
439 test := &DaemonChildrenTest{
440 server: newModelFake(t),
441 }
442 agents, err := openDaemonAgents(t.Context(), t.TempDir(), test.server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
443 require.NoError(t, err)
444
445 test.root, err = agents[0].Loop(nil, func(Event) {
446 })
447 require.NoError(t, err)
448
449 messages, err := openDaemonMessages([]*TurnLoop{test.root})
450 require.NoError(t, err)
451
452 ctx, cancel := context.WithCancel(t.Context())
453 test.cancel = cancel
454 test.runtime = &DaemonChildren{
455 ctx: ctx,
456 cancel: cancel,
457 root: test.root,
458 messages: messages,
459 }
460 test.root.tools.children = test.runtime
461 t.Cleanup(func() {
462 cancel()
463 require.NoError(t, test.runtime.Close())
464 test.root.runs.Close()
465 require.NoError(t, agents.Close())
466 })
467
468 return test
469 }