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, "image_show") require.Equal(t, `error: unknown tool "image_show"`, child.tools.run(t.Context(), "image_show", `{"image":"unknown","caption":""}`)) 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 }