+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDaemonToolsCreateAndTellAChildDuringATurn(t *testing.T) {
+ dir := t.TempDir()
+ server := newModelFake(t)
+ agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, discardLog())
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ require.NoError(t, agents.Close())
+ })
+
+ root := agents[0]
+ next := 0
+ server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ if call.body["prompt_cache_key"] != root.agent.CacheKey() {
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Approve the gate size.")}, 100, 0, 5))
+
+ return
+ }
+
+ next++
+
+ var items []any
+
+ switch next {
+ case 1:
+ items = []any{modelFunctionCallItem("fc_1", "call_1", "agent_create", `{"name":"House","responsibility":"Keep garden decisions."}`)}
+ case 2:
+ items = []any{modelFunctionCallItem("fc_2", "call_2", "tell", `{"name":"House","message":"Check the gate size."}`)}
+ case 3:
+ items = []any{modelFunctionCallItem("fc_3", "call_3", "zoom", `{"id":0,"n":1,"name":"House"}`)}
+ default:
+ items = []any{modelMessageItem("I will ask the user.")}
+ }
+
+ modelWriteEvents(w, modelCompletedEvent(items, 100, 0, 5))
+ }
+ whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock())
+ listener, err := daemonListenSocket(filepath.Join(dir, socketFileName))
+ require.NoError(t, err)
+
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+
+ done := make(chan error, 1)
+ go func() {
+ done <- daemonServe(ctx, listener, nil, whatsapp, root.schedules, func(notify func(Event)) (*TurnLoop, error) {
+ return root.Loop(whatsapp, notify)
+ })
+ }()
+
+ t.Cleanup(func() {
+ cancel()
+ require.NoError(t, <-done)
+ })
+
+ client := terminalStartTestClient(t, listener.Addr().String())
+ client.WaitFor(t, "[connected to OneChat]")
+ client.Type("Create a child and ask it about the gate.")
+ client.WaitFor(t, "[House] Approve the gate size.")
+ client.WaitFor(t, "I will ask the user.")
+ require.Contains(t, client.Output(), "Started House.")
+ require.Contains(t, client.Output(), "user: Check the gate size.")
+
+ child, err := openAgent(filepath.Join(root.agent.dir, "children", "House"))
+ require.NoError(t, err)
+ require.Equal(t, root.agent.config.Model, child.config.Model)
+ require.Equal(t, root.agent.config.Effort, child.config.Effort)
+ require.Equal(t, root.agent.config.Workdir, child.config.Workdir)
+ require.Equal(t, root.agent.config.Name, child.config.Parent)
+ require.NotEqual(t, root.agent.CacheKey(), child.CacheKey())
+
+ client.Type("Show the garden responsibility.")
+ require.Eventually(t, func() bool {
+ for i := range server.Calls() {
+ call := server.Call(i)
+ texts := turnInputTexts(t, call)
+ if len(texts) > 0 && texts[len(texts)-1] == "Show the garden responsibility." {
+ return strings.Contains(strings.Join(texts, "\n"), "Children:\nHouse: Keep garden decisions.")
+ }
+ }
+
+ return false
+ }, 5*time.Second, time.Millisecond)
+}
+
+func TestChildCreationRefusesUsedNames(t *testing.T) {
+ test := newDaemonChildrenTest(t)
+ box := test.root.tools
+ require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"OneChat","responsibility":"Other plans."}`), "already used")
+
+ require.NoDirExists(t, filepath.Join(test.root.agent.dir, "children"))
+ require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":" "}`), "responsibility")
+ require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":"Keep garden plans."}`), "Started House.")
+ require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"House","responsibility":"Other plans."}`), "already used")
+
+ test.root.runs.subagents.draw = func() string {
+ return "BlueFox"
+ }
+ subagent, err := test.root.runs.subagents.Create(t.Context())
+ require.NoError(t, err)
+ require.Equal(t, "BlueFox", subagent.name)
+ require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"BlueFox","responsibility":"Other plans."}`), "already used by a subagent")
+ require.NoError(t, test.root.runs.subagents.Reserve("Calendar"))
+ require.Contains(t, box.run(t.Context(), "agent_create", `{"name":"Calendar","responsibility":"Keep dates."}`), "already used")
+}
+
+func TestChildCreationAcceptsAreaNames(t *testing.T) {
+ testCases := []struct {
+ name string
+ child string
+ }{
+ {
+ name: "house area",
+ child: "House",
+ },
+ {
+ name: "calendar area",
+ child: "Calendar",
+ },
+ {
+ name: "two letters",
+ child: "Ab",
+ },
+ {
+ name: "capital letters after the first",
+ child: "ABcD",
+ },
+ {
+ name: "thirty-two letters",
+ child: "A" + strings.Repeat("b", 31),
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ test := newDaemonChildrenTest(t)
+ arguments, err := json.Marshal(ToolAgentCreateArguments{
+ Name: tc.child,
+ Responsibility: "Keep area decisions.",
+ })
+ require.NoError(t, err)
+ require.Contains(t, test.root.tools.run(t.Context(), "agent_create", string(arguments)), "Started "+tc.child+".")
+
+ child := test.runtime.messages.Child(test.root.agent.config.Name, tc.child)
+ require.NotNil(t, child)
+ require.Equal(t, tc.child, child.agent.config.Name)
+ require.FileExists(t, filepath.Join(child.agent.dir, "config.json"))
+ })
+ }
+}
+
+func TestChildCreationRefusesInvalidAreaNames(t *testing.T) {
+ testCases := []struct {
+ name string
+ child string
+ }{
+ {
+ name: "empty name",
+ child: "",
+ },
+ {
+ name: "one letter",
+ child: "H",
+ },
+ {
+ name: "thirty-three letters",
+ child: "A" + strings.Repeat("b", 32),
+ },
+ {
+ name: "lowercase initial",
+ child: "house",
+ },
+ {
+ name: "two words",
+ child: "My House",
+ },
+ {
+ name: "digit",
+ child: "House2",
+ },
+ {
+ name: "path",
+ child: "../House",
+ },
+ {
+ name: "punctuation",
+ child: "House-",
+ },
+ {
+ name: "non-ASCII initial",
+ child: "\u00c1rea",
+ },
+ {
+ name: "non-ASCII remainder",
+ child: "Hous\u00e9",
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ test := newDaemonChildrenTest(t)
+ arguments, err := json.Marshal(ToolAgentCreateArguments{
+ Name: tc.child,
+ Responsibility: "Keep area decisions.",
+ })
+ require.NoError(t, err)
+ 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")
+ require.NoDirExists(t, filepath.Join(test.root.agent.dir, "children"))
+ })
+ }
+}
+
+func TestFailedChildPublicationKeepsStartupAndRetryWorking(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("requires directory write permissions")
+ }
+
+ dir := t.TempDir()
+ root, err := openAgent(filepath.Join(dir, daemonAgentDirName))
+ require.NoError(t, err)
+
+ subagents := newSubagents(filepath.Join(root.dir, subagentDirName), time.Now, subagentNameRandom)
+ defer func() {
+ require.NoError(t, subagents.Close())
+ }()
+
+ children := filepath.Join(root.dir, "children")
+ require.NoError(t, os.Mkdir(children, 0o500))
+ t.Cleanup(func() {
+ require.NoError(t, os.Chmod(children, 0o700))
+ })
+
+ _, err = root.CreateChild("GreenOtter", "Keep garden plans.", subagents)
+ require.ErrorContains(t, err, "publish child directory")
+ require.NoDirExists(t, filepath.Join(children, "GreenOtter"))
+
+ staged, err := filepath.Glob(filepath.Join(root.dir, ".child-*"))
+ require.NoError(t, err)
+ require.Empty(t, staged)
+
+ server := newModelFake(t)
+ reopened, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
+ require.NoError(t, err)
+ require.Len(t, reopened, 1)
+
+ defer func() {
+ require.NoError(t, reopened.Close())
+ }()
+
+ require.NoError(t, os.Chmod(children, 0o700))
+
+ child, err := root.CreateChild("GreenOtter", "Keep garden plans.", subagents)
+ require.NoError(t, err)
+ require.Equal(t, filepath.Join(children, "GreenOtter"), child.dir)
+}
+
+func TestInterruptedChildPreparationDoesNotStopStartup(t *testing.T) {
+ dir := t.TempDir()
+ root, err := openAgent(filepath.Join(dir, daemonAgentDirName))
+ require.NoError(t, err)
+
+ staged, err := os.MkdirTemp(root.dir, ".child-*")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(staged, ".config.json.partial"), []byte("{"), 0o600))
+
+ server := newModelFake(t)
+ agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
+ require.NoError(t, err)
+
+ defer func() {
+ require.NoError(t, agents.Close())
+ }()
+
+ require.Len(t, agents, 1)
+
+ child, err := root.CreateChild("GreenOtter", "Keep garden plans.", agents[0].subagents)
+ require.NoError(t, err)
+
+ children, err := root.Children()
+ require.NoError(t, err)
+ require.Len(t, children, 1)
+ require.Equal(t, child.config, children[0].config)
+ require.DirExists(t, staged)
+}
+
+func TestChildPublicationDoesNotReplaceAnExistingDirectory(t *testing.T) {
+ test := newDaemonChildrenTest(t)
+ path := filepath.Join(test.root.agent.dir, "children", "GreenOtter")
+ require.NoError(t, os.MkdirAll(path, 0o700))
+
+ _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
+ require.ErrorContains(t, err, "publish child directory")
+ require.NoFileExists(t, filepath.Join(path, "config.json"))
+ require.NoError(t, test.root.runs.subagents.Reserve("GreenOtter"))
+ test.root.runs.subagents.Release("GreenOtter")
+}
+
+func TestChildNamesStayOutOfSubagentDraws(t *testing.T) {
+ test := newDaemonChildrenTest(t)
+ _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
+ require.NoError(t, err)
+
+ names := []string{"GreenOtter", "BlueFox"}
+ test.root.runs.subagents.draw = func() string {
+ name := names[0]
+ names = names[1:]
+
+ return name
+ }
+ subagent, err := test.root.runs.subagents.Create(t.Context())
+ require.NoError(t, err)
+ require.Equal(t, "BlueFox", subagent.name)
+}
+
+func TestChildToolsExcludeCreationAndWhatsApp(t *testing.T) {
+ test := newDaemonChildrenTest(t)
+ _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
+ require.NoError(t, err)
+
+ child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
+ require.NotNil(t, child)
+
+ names := promptToolNames(child.agent.Tools())
+ require.NotContains(t, names, "agent_create")
+ require.NotContains(t, names, "whatsapp_send")
+ require.NotContains(t, names, "whatsapp_find")
+ require.Contains(t, names, "spawn")
+ require.Contains(t, names, "schedule_set")
+ require.Contains(t, names, "schedule_list")
+ require.Contains(t, names, "schedule_remove")
+ require.Equal(t, `error: unknown tool "agent_create"`, child.tools.run(t.Context(), "agent_create", `{"name":"BlueFox","responsibility":"Other plans."}`))
+ require.Nil(t, child.tools.messages.Child("GreenOtter", "OneChat"))
+}
+
+func TestZoomReadsTheChildViewAndKeepsSubagentLogs(t *testing.T) {
+ test := newDaemonChildrenTest(t)
+ _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
+ require.NoError(t, err)
+
+ child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
+ _, err = child.memory.Append("note", "The gate is narrow.")
+ require.NoError(t, err)
+ require.NoError(t, child.memory.Settle(t.Context()))
+
+ output := test.root.tools.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"GreenOtter"}`)
+ require.Equal(t, strings.Join(child.memory.ViewLines(nil), ""), output)
+ require.Contains(t, output, "note: The gate is narrow.")
+
+ test.root.runs.subagents.draw = func() string {
+ return "BlueFox"
+ }
+ subagent, err := test.root.runs.subagents.Create(t.Context())
+ require.NoError(t, err)
+
+ _, err = subagent.Append("note", "A short task.")
+ require.NoError(t, err)
+ require.Equal(t, "0|note: A short task.", test.root.tools.run(t.Context(), "zoom", `{"id":0,"n":1,"name":"BlueFox"}`))
+}
+
+func TestRuntimeChildJoinsBackupLocksAndStopsWithDaemon(t *testing.T) {
+ test := newDaemonChildrenTest(t)
+ _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
+ require.NoError(t, err)
+
+ child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
+ _, err = child.memory.Append("note", "Keep the gate plan.")
+ require.NoError(t, err)
+
+ appended := make(chan error, 1)
+ require.NoError(t, test.runtime.messages.SnapshotAgents(t.Context(), func() error {
+ require.FileExists(t, filepath.Join(child.agent.dir, "view.json"))
+
+ go func() {
+ _, err := child.memory.Append("note", "A later plan.")
+ appended <- err
+ }()
+
+ select {
+ case err := <-appended:
+ require.FailNow(t, "child append bypasses snapshot lock", "%v", err)
+ case <-time.After(20 * time.Millisecond):
+ }
+
+ return nil
+ }))
+ require.NoError(t, <-appended)
+ test.cancel()
+ require.NoError(t, test.runtime.Close())
+
+ test.runtime.opened = nil
+ reopened, err := test.root.openChild(t.Context(), child.agent)
+ require.NoError(t, err)
+ require.Equal(t, 2, reopened.memory.Len())
+ require.NoError(t, reopened.Close())
+}
+
+func TestRootStateListsChildResponsibilitiesOnlyWhenPresent(t *testing.T) {
+ test := newDaemonChildrenTest(t)
+ before := test.root.state()
+ require.NotContains(t, before, "Children:")
+
+ _, err := test.runtime.Create("GreenOtter", "Keep garden plans.")
+ require.NoError(t, err)
+
+ _, err = test.runtime.Create("BlueFox", "Keep book notes.")
+ require.NoError(t, err)
+ require.Equal(t, before+"\n\nChildren:\nBlueFox: Keep book notes.\nGreenOtter: Keep garden plans.", test.root.state())
+
+ child := test.runtime.messages.Child(test.root.agent.config.Name, "GreenOtter")
+ require.NotContains(t, child.state(), "Children:")
+}
+
+// DaemonChildrenTest owns one root and runtime children without a root turn.
+type DaemonChildrenTest struct {
+ root *TurnLoop
+ runtime *DaemonChildren
+ server *ModelFake
+ cancel context.CancelFunc
+}
+
+func newDaemonChildrenTest(t *testing.T) *DaemonChildrenTest {
+ t.Helper()
+
+ test := &DaemonChildrenTest{
+ server: newModelFake(t),
+ }
+ agents, err := openDaemonAgents(t.Context(), t.TempDir(), test.server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
+ require.NoError(t, err)
+
+ test.root, err = agents[0].Loop(nil, func(Event) {
+ })
+ require.NoError(t, err)
+
+ messages, err := openDaemonMessages([]*TurnLoop{test.root})
+ require.NoError(t, err)
+
+ ctx, cancel := context.WithCancel(t.Context())
+ test.cancel = cancel
+ test.runtime = &DaemonChildren{
+ ctx: ctx,
+ cancel: cancel,
+ root: test.root,
+ messages: messages,
+ }
+ test.root.tools.children = test.runtime
+ t.Cleanup(func() {
+ cancel()
+ require.NoError(t, test.runtime.Close())
+ test.root.runs.Close()
+ require.NoError(t, agents.Close())
+ })
+
+ return test
+}