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