+package main
+
+import (
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDaemonOpensAndRunsTheRootAndTwoChildren(t *testing.T) {
+ dir := t.TempDir()
+ daemonChildConfig(t, dir, "Garden")
+ daemonChildConfig(t, dir, "Library")
+
+ server := newModelFake(t)
+ clock := newTestClock()
+ agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, clock.Now, discardLog())
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ require.NoError(t, agents.Close())
+ })
+ require.Len(t, agents, 3)
+ require.Equal(t, []string{"Garden", "Library"}, agents.ChildNames())
+
+ for _, agent := range agents {
+ at := clock.Now().Add(time.Minute).Format(scheduleAtLayout)
+ _, err = agent.schedules.Set("Record the decision for "+agent.agent.config.Name, &at, nil)
+ require.NoError(t, err)
+ }
+
+ clock.now = clock.now.Add(time.Minute)
+ whatsapp, _, _ := whatsappStartTest(t, dir, clock)
+ 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, agents[0].schedules, func(notify func(Event)) (*TurnLoop, error) {
+ return agents[0].Loop(whatsapp, notify)
+ }, agents[1:]...)
+ }()
+
+ t.Cleanup(func() {
+ cancel()
+ require.NoError(t, <-done)
+ })
+
+ require.Eventually(t, func() bool {
+ for _, agent := range agents {
+ if agent.memory.Len() != 2 {
+ return false
+ }
+ }
+
+ return true
+ }, 5*time.Second, time.Millisecond)
+
+ require.Equal(t, 3, server.Calls())
+
+ keys := make(map[string]bool)
+ for i := range server.Calls() {
+ call := server.Call(i)
+ keys[call.body["prompt_cache_key"].(string)] = true
+ }
+
+ require.Len(t, keys, 3)
+
+ for _, agent := range agents {
+ message, err := agent.memory.Message(0)
+ require.NoError(t, err)
+ require.Contains(t, message.Text, "Record the decision for "+agent.agent.config.Name)
+ require.True(t, keys[agent.agent.CacheKey()])
+
+ usage := modelReadUsage(t, filepath.Join(agent.agent.dir, "usage.jsonl"))
+ require.Len(t, usage, 1)
+ require.Equal(t, "first", usage[0].Turn)
+ require.Equal(t, agent.agent.config.Model, usage[0].Model)
+ }
+
+ terminal := terminalStartTestClient(t, listener.Addr().String())
+ terminal.WaitFor(t, "[connected to OneChat]")
+ require.NotContains(t, terminal.Output(), "Garden")
+ require.NotContains(t, terminal.Output(), "Library")
+}
+
+func TestDaemonReadyLineNamesTheChildren(t *testing.T) {
+ dir := t.TempDir()
+ daemonChildConfig(t, dir, "Garden")
+ daemonChildConfig(t, dir, "Library")
+ t.Setenv("ONECHAT_DIR", dir)
+
+ var out SyncBuffer
+
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+
+ done := make(chan error, 1)
+ go func() {
+ done <- daemon(ctx, &out)
+ }()
+
+ t.Cleanup(func() {
+ cancel()
+
+ require.NoError(t, <-done)
+ })
+
+ require.Eventually(t, func() bool {
+ return strings.Contains(out.String(), "children=[Garden Library]")
+ }, 5*time.Second, time.Millisecond)
+}
+
+func TestChildCallsShareTheirPromptAndToolsWithCompaction(t *testing.T) {
+ dir := t.TempDir()
+ daemonChildConfig(t, dir, "Garden")
+
+ 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())
+ }()
+
+ child := agents[1]
+ loop, err := child.Loop(nil, func(Event) {
+ })
+ require.NoError(t, err)
+ require.Nil(t, loop.tools.whatsapp)
+ require.Contains(t, loop.tools.run(t.Context(), "whatsapp_send", `{"id":"fake","chat":"fake","text":"draft"}`), "unknown tool")
+
+ _, err = loop.Submit("parent", strings.Repeat("Keep a garden decision. ", nodeBytes))
+ require.NoError(t, err)
+ require.NoError(t, loop.turn(t.Context()))
+ require.NoError(t, child.memory.Settle(t.Context()))
+
+ require.GreaterOrEqual(t, server.Calls(), 2)
+
+ first := server.Call(0)
+
+ for i := range server.Calls() {
+ call := server.Call(i)
+ require.Equal(t, child.agent.CacheKey(), call.body["prompt_cache_key"])
+ require.Equal(t, first.body["instructions"], call.body["instructions"])
+ require.Equal(t, first.body["tools"], call.body["tools"])
+ require.NotContains(t, string(call.raw), "whatsapp_send")
+ require.NotContains(t, string(call.raw), "whatsapp_find")
+ }
+
+ usage := modelReadUsage(t, child.client.UsagePath())
+ require.GreaterOrEqual(t, len(usage), 2)
+
+ var turns, nodes int
+
+ for _, record := range usage {
+ if record.Node != nil {
+ nodes++
+ } else {
+ turns++
+ }
+ }
+
+ require.Positive(t, nodes)
+ require.Equal(t, 1, turns)
+}
+
+func TestBrokenChildConfigStopsDaemonStartup(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, daemonAgentDirName, "children", "Garden")
+ require.NoError(t, os.MkdirAll(path, 0o700))
+ require.NoError(t, os.WriteFile(filepath.Join(path, "config.json"), []byte(`{"broken":true}`), 0o600))
+ t.Setenv("ONECHAT_DIR", dir)
+
+ err := daemon(t.Context(), io.Discard)
+ require.ErrorContains(t, err, `open child "Garden"`)
+ require.ErrorContains(t, err, `unknown field "broken"`)
+
+ _, err = os.Stat(filepath.Join(dir, socketFileName))
+ require.ErrorIs(t, err, os.ErrNotExist)
+}
+
+func TestInvalidChildRelationshipsStopStartup(t *testing.T) {
+ server := newModelFake(t)
+ testCases := []struct {
+ name string
+ change func(*testing.T, string)
+ cause string
+ }{
+ {
+ name: "missing config is not created",
+ change: func(t *testing.T, path string) {
+ require.NoError(t, os.Remove(filepath.Join(path, "config.json")))
+ },
+ cause: `child "Garden" config:`,
+ },
+ {
+ name: "wrong parent is rejected",
+ change: func(t *testing.T, path string) {
+ agent, err := openAgent(path)
+ require.NoError(t, err)
+
+ config := agent.config
+ config.Parent = "Other"
+ agentWriteTestConfig(t, path, config)
+ },
+ cause: `needs name "Garden" and parent "OneChat"`,
+ },
+ {
+ name: "a grandchild is rejected",
+ change: func(t *testing.T, path string) {
+ require.NoError(t, os.MkdirAll(filepath.Join(path, "children", "Nested"), 0o700))
+ },
+ cause: "children cannot have children",
+ },
+ {
+ name: "a reused ID is rejected",
+ change: func(t *testing.T, path string) {
+ root, err := openAgent(filepath.Dir(filepath.Dir(path)))
+ require.NoError(t, err)
+
+ child, err := openAgent(path)
+ require.NoError(t, err)
+
+ config := child.config
+ config.ID = root.config.ID
+ agentWriteTestConfig(t, path, config)
+ },
+ cause: "must have its own name and ID",
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ path := daemonChildConfig(t, dir, "Garden")
+ tc.change(t, path)
+
+ _, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
+ require.ErrorContains(t, err, tc.cause)
+ })
+ }
+}