13 "github.com/stretchr/testify/require"
16 func TestDaemonToolsCreateAndTellAChildDuringATurn(t *testing.T) {
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)
23 require.NoError(t, agents.Close())
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))
41 items = []any{modelFunctionCallItem("fc_1", "call_1", "agent_create", `{"name":"House","responsibility":"Keep garden decisions."}`)}
43 items = []any{modelFunctionCallItem("fc_2", "call_2", "tell", `{"name":"House","message":"Check the gate size."}`)}
45 items = []any{modelFunctionCallItem("fc_3", "call_3", "zoom", `{"id":0,"n":1,"name":"House"}`)}
47 items = []any{modelMessageItem("I will ask the user.")}
50 modelWriteEvents(w, modelCompletedEvent(items, 100, 0, 5))
53 whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
54 listener, err := daemonListenSocket(filepath.Join(dir, socketFileName))
55 require.NoError(t, err)
57 ctx, cancel := context.WithCancel(t.Context())
60 done := make(chan error, 1)
62 done <- daemonServe(ctx, listener, nil, whatsapp, root.schedules, func(notify func(Event)) (*TurnLoop, error) {
63 return root.Loop(whatsapp, notify)
69 require.NoError(t, <-done)
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.")
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())
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.")
99 }, 5*time.Second, time.Millisecond)
102 func TestRuntimeChildAcceptsNotificationsByItsPermanentID(t *testing.T) {
103 test := newDaemonChildrenTest(t)
104 test.server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
105 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
108 _, err := test.runtime.Create("Garden", "Keep garden plans.")
109 require.NoError(t, err)
111 child := test.runtime.messages.Child(test.root.agent.config.Name, "Garden")
112 require.NotNil(t, child)
114 receipt, err := test.runtime.messages.Notify(&NotifyInput{
115 AgentID: child.agent.config.ID,
118 Text: "A new result.",
120 require.NoError(t, err)
121 require.Equal(t, "accepted", receipt.Status)
122 require.Eventually(t, func() bool {
123 return child.memory.Len() == 1
124 }, 10*time.Second, time.Millisecond)
125 require.Zero(t, test.root.memory.Len())
127 logged, err := child.memory.Message(0)
128 require.NoError(t, err)
129 require.Equal(t, "work", logged.Kind)
130 require.Contains(t, logged.Text, "Untrusted external data")
133 func TestChildCreationRefusesUsedNames(t *testing.T) {
134 test := newDaemonChildrenTest(t)
135 box := test.root.tools
136 require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"OneChat","responsibility":"Other plans."}`), "already used")
138 require.NoDirExists(t, filepath.Join(test.root.agent.dir, "children"))
139 require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":" "}`), "responsibility")
140 require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":"Keep garden plans."}`), "Started House.")
141 require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":"Other plans."}`), "already used")
143 test.root.runs.subagents.draw = func() string {
147 subagent, err := test.root.runs.subagents.Create(t.Context())
148 require.NoError(t, err)
149 require.Equal(t, "BlueFox", subagent.name)
150 require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"BlueFox","responsibility":"Other plans."}`), "already used by a subagent")
151 require.NoError(t, test.root.runs.subagents.Reserve("Calendar"))
152 require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"Calendar","responsibility":"Keep dates."}`), "already used")
155 func TestChildCreationAcceptsAreaNames(t *testing.T) {
156 testCases := []struct {
161 name: "accepts house area",
165 name: "accepts calendar area",
169 name: "accepts two letters",
173 name: "accepts capital letters after the first",
177 name: "accepts thirty-two letters",
178 child: "A" + strings.Repeat("b", 31),
182 for _, tc := range testCases {
183 t.Run(tc.name, func(t *testing.T) {
184 test := newDaemonChildrenTest(t)
185 arguments, err := json.Marshal(ToolAgentCreateArguments{
187 Responsibility: "Keep area decisions.",
189 require.NoError(t, err)
190 require.Contains(t, test.root.tools.run(t.Context(), "agent_create", string(arguments)), "Started "+tc.child+".")
192 child := test.runtime.messages.Child(test.root.agent.config.Name, tc.child)
193 require.NotNil(t, child)
194 require.Equal(t, tc.child, child.agent.config.Name)
195 require.FileExists(t, filepath.Join(child.agent.dir, "config.json"))
200 func TestChildCreationRefusesInvalidAreaNames(t *testing.T) {
201 testCases := []struct {
206 name: "refuses empty name",
210 name: "refuses one letter",
214 name: "refuses thirty-three letters",
215 child: "A" + strings.Repeat("b", 32),
218 name: "refuses lowercase initial",
222 name: "refuses two words",
226 name: "refuses digit",
230 name: "refuses path",
234 name: "refuses punctuation",
238 name: "refuses non-ASCII initial",
242 name: "refuses non-ASCII remainder",
247 for _, tc := range testCases {
248 t.Run(tc.name, func(t *testing.T) {
249 test := newDaemonChildrenTest(t)
250 arguments, err := json.Marshal(ToolAgentCreateArguments{
252 Responsibility: "Keep area decisions.",
254 require.NoError(t, err)
255 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")
256 require.NoDirExists(t, filepath.Join(test.root.agent.dir, "children"))
261 func TestFailedChildPublicationKeepsStartupAndRetryWorking(t *testing.T) {
262 if os.Geteuid() == 0 {
263 t.Skip("requires directory write permissions")
267 root, err := openAgent(filepath.Join(dir, daemonAgentDirName))
268 require.NoError(t, err)
270 subagents := newSubagents(filepath.Join(root.dir, subagentDirName), time.Now, subagentNameRandom)
272 require.NoError(t, subagents.Close())
275 children := filepath.Join(root.dir, "children")
276 require.NoError(t, os.Mkdir(children, 0o500))
278 require.NoError(t, os.Chmod(children, 0o700))
281 _, err = root.CreateChild("GreenOtter", "Keep garden plans.", subagents)
282 require.ErrorContains(t, err, "publish child directory")
283 require.NoDirExists(t, filepath.Join(children, "GreenOtter"))
285 staged, err := filepath.Glob(filepath.Join(root.dir, ".child-*"))
286 require.NoError(t, err)
287 require.Empty(t, staged)
289 server := newModelFake(t)
290 reopened, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
291 require.NoError(t, err)
292 require.Len(t, reopened, 1)
295 require.NoError(t, reopened.Close())
298 require.NoError(t, os.Chmod(children, 0o700))
300 child, err := root.CreateChild("GreenOtter", "Keep garden plans.", subagents)
301 require.NoError(t, err)
302 require.Equal(t, filepath.Join(children, "GreenOtter"), child.dir)
305 func TestInterruptedChildPreparationDoesNotStopStartup(t *testing.T) {
307 root, err := openAgent(filepath.Join(dir, daemonAgentDirName))
308 require.NoError(t, err)
310 staged, err := os.MkdirTemp(root.dir, ".child-*")
311 require.NoError(t, err)
312 require.NoError(t, os.WriteFile(filepath.Join(staged, ".config.json.partial"), []byte("{"), 0o600))
314 server := newModelFake(t)
315 agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
316 require.NoError(t, err)
319 require.NoError(t, agents.Close())
322 require.Len(t, agents, 1)
324 child, err := root.CreateChild("GreenOtter", "Keep garden plans.", agents[0].subagents)
325 require.NoError(t, err)
327 children, err := root.Children()
328 require.NoError(t, err)
329 require.Len(t, children, 1)
330 require.Equal(t, child.config, children[0].config)
331 require.DirExists(t, staged)
334 func TestChildPublicationDoesNotReplaceAnExistingDirectory(t *testing.T) {
335 test := newDaemonChildrenTest(t)
336 path := filepath.Join(test.root.agent.dir, "children", "GreenOtter")
337 require.NoError(t, os.MkdirAll(path, 0o700))
339 _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
340 require.ErrorContains(t, err, "publish child directory")
341 require.NoFileExists(t, filepath.Join(path, "config.json"))
342 require.NoError(t, test.root.runs.subagents.Reserve("GreenOtter"))
343 test.root.runs.subagents.Release("GreenOtter")
346 func TestChildNamesStayOutOfSubagentDraws(t *testing.T) {
347 test := newDaemonChildrenTest(t)
348 _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
349 require.NoError(t, err)
351 names := []string{"GreenOtter", "BlueFox"}
352 test.root.runs.subagents.draw = func() string {
359 subagent, err := test.root.runs.subagents.Create(t.Context())
360 require.NoError(t, err)
361 require.Equal(t, "BlueFox", subagent.name)
364 func TestChildToolsExcludeCreationAndWhatsApp(t *testing.T) {
365 test := newDaemonChildrenTest(t)
366 _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
367 require.NoError(t, err)
369 child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
370 require.NotNil(t, child)
372 names := promptToolNames(child.agent.Tools())
373 require.NotContains(t, names, "agent_create")
374 require.NotContains(t, names, "image_show")
375 require.Equal(t, `error: unknown tool "image_show"`, child.tools.run(t.Context(), "image_show", `{"image":"unknown","caption":""}`))
376 require.NotContains(t, names, "whatsapp_send")
377 require.NotContains(t, names, "whatsapp_find")
378 require.Contains(t, names, "spawn")
379 require.Contains(t, names, "schedule_set")
380 require.Contains(t, names, "schedule_list")
381 require.Contains(t, names, "schedule_remove")
382 require.Equal(t, `error: unknown tool "agent_create"`, child.tools.run(t.Context(), "agent_create", `{"name":"BlueFox","responsibility":"Other plans."}`))
383 require.Nil(t, child.tools.messages.Child("GreenOtter", "OneChat"))
386 func TestZoomReadsTheChildViewAndKeepsSubagentLogs(t *testing.T) {
387 test := newDaemonChildrenTest(t)
388 _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
389 require.NoError(t, err)
391 child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
392 _, err = child.memory.Append("note", "The gate is narrow.")
393 require.NoError(t, err)
394 require.NoError(t, child.memory.Settle(t.Context()))
396 output := test.root.tools.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"GreenOtter"}`)
397 require.Equal(t, strings.Join(child.memory.ViewLines(nil), ""), output)
398 require.Contains(t, output, "note: The gate is narrow.")
400 test.root.runs.subagents.draw = func() string {
404 subagent, err := test.root.runs.subagents.Create(t.Context())
405 require.NoError(t, err)
407 _, err = subagent.Append("note", "A short task.")
408 require.NoError(t, err)
409 require.Equal(t, "0|note: A short task.", test.root.tools.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"BlueFox"}`))
412 func TestRuntimeChildJoinsBackupLocksAndStopsWithDaemon(t *testing.T) {
413 test := newDaemonChildrenTest(t)
414 _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
415 require.NoError(t, err)
417 child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
418 _, err = child.memory.Append("note", "Keep the gate plan.")
419 require.NoError(t, err)
421 appended := make(chan error, 1)
422 require.NoError(t, test.runtime.messages.SnapshotAgents(t.Context(), func() error {
423 require.FileExists(t, filepath.Join(child.agent.dir, "view.json"))
426 _, err := child.memory.Append("note", "A later plan.")
431 case err := <-appended:
432 require.FailNow(t, "child append bypasses snapshot lock", "%v", err)
433 case <-time.After(20 * time.Millisecond):
438 require.NoError(t, <-appended)
440 require.NoError(t, test.runtime.Close())
442 test.runtime.opened = nil
443 reopened, err := test.root.openChild(t.Context(), child.agent)
444 require.NoError(t, err)
445 require.Equal(t, 2, reopened.memory.Len())
446 require.NoError(t, reopened.Close())
449 func TestRootStateListsChildResponsibilitiesOnlyWhenPresent(t *testing.T) {
450 test := newDaemonChildrenTest(t)
451 before := test.root.state()
452 require.NotContains(t, before, "Children:")
454 _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
455 require.NoError(t, err)
457 _, err = test.runtime.Create("BlueFox", "Keep book notes.")
458 require.NoError(t, err)
459 require.Equal(t, before+"\n\nChildren:\nBlueFox: Keep book notes.\nGreenOtter: Keep garden plans.", test.root.state())
461 child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
462 require.NotContains(t, child.state(), "Children:")
465 // DaemonChildrenTest owns one root and runtime children without a root turn.
466 type DaemonChildrenTest struct {
468 runtime *DaemonChildren
470 cancel context.CancelFunc
473 func newDaemonChildrenTest(t *testing.T) *DaemonChildrenTest {
476 test := &DaemonChildrenTest{
477 server: newModelFake(t),
480 agents, err := openDaemonAgents(t.Context(), t.TempDir(), test.server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
481 require.NoError(t, err)
483 test.root, err = agents[0].Loop(nil, func(Event) {
485 require.NoError(t, err)
487 messages, err := openDaemonMessages([]*TurnLoop{test.root})
488 require.NoError(t, err)
490 ctx, cancel := context.WithCancel(t.Context())
492 test.runtime = &DaemonChildren{
499 test.root.tools.children = test.runtime
502 require.NoError(t, test.runtime.Close())
503 test.root.runs.Close()
504 require.NoError(t, agents.Close())