12 "github.com/stretchr/testify/require"
15 func TestDaemonOpensAndRunsTheRootAndTwoChildren(t *testing.T) {
17 daemonChildConfig(t, dir, "Garden")
18 daemonChildConfig(t, dir, "Library")
20 server := newModelFake(t)
21 clock := newTestClock()
22 agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, clock.Now, discardLog())
23 require.NoError(t, err)
26 require.NoError(t, agents.Close())
28 require.Len(t, agents, 3)
29 require.Equal(t, []string{"Garden", "Library"}, agents.ChildNames())
31 for _, agent := range agents {
32 at := clock.Now().Add(time.Minute).Format(scheduleAtLayout)
33 _, err = agent.schedules.Set("Record the decision for "+agent.agent.config.Name, &at, nil)
34 require.NoError(t, err)
37 clock.now = clock.now.Add(time.Minute)
38 whatsapp, _, _ := whatsappStartTest(t, dir, clock)
39 listener, err := daemonListenSocket(filepath.Join(dir, socketFileName))
40 require.NoError(t, err)
42 ctx, cancel := context.WithCancel(t.Context())
45 done := make(chan error, 1)
47 done <- daemonServe(ctx, listener, nil, whatsapp, agents[0].schedules, func(notify func(Event)) (*TurnLoop, error) {
48 return agents[0].Loop(whatsapp, notify)
54 require.NoError(t, <-done)
57 require.Eventually(t, func() bool {
58 for _, agent := range agents {
59 if agent.memory.Len() != 2 {
65 }, 5*time.Second, time.Millisecond)
67 require.Equal(t, 3, server.Calls())
69 keys := make(map[string]bool)
70 for i := range server.Calls() {
71 call := server.Call(i)
72 keys[call.body["prompt_cache_key"].(string)] = true
75 require.Len(t, keys, 3)
77 for _, agent := range agents {
78 message, err := agent.memory.Message(0)
79 require.NoError(t, err)
80 require.Contains(t, message.Text, "Record the decision for "+agent.agent.config.Name)
81 require.True(t, keys[agent.agent.CacheKey()])
83 usage := modelReadUsage(t, filepath.Join(agent.agent.dir, "usage.jsonl"))
84 require.Len(t, usage, 1)
85 require.Equal(t, "first", usage[0].Turn)
86 require.Equal(t, agent.agent.config.Model, usage[0].Model)
89 terminal := terminalStartTestClient(t, listener.Addr().String())
90 terminal.WaitFor(t, "[connected to OneChat]")
91 require.NotContains(t, terminal.Output(), "Garden")
92 require.NotContains(t, terminal.Output(), "Library")
95 func TestDaemonReadyLineNamesTheChildren(t *testing.T) {
97 daemonChildConfig(t, dir, "Garden")
98 daemonChildConfig(t, dir, "Library")
99 t.Setenv("ONECHAT_DIR", dir)
103 ctx, cancel := context.WithCancel(t.Context())
106 done := make(chan error, 1)
108 done <- daemon(ctx, &out)
114 require.NoError(t, <-done)
117 require.Eventually(t, func() bool {
118 return strings.Contains(out.String(), "children=[Garden Library]")
119 }, 5*time.Second, time.Millisecond)
122 func TestChildCallsShareTheirPromptAndToolsWithCompaction(t *testing.T) {
124 daemonChildConfig(t, dir, "Garden")
126 server := newModelFake(t)
127 agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
128 require.NoError(t, err)
131 require.NoError(t, agents.Close())
135 loop, err := child.Loop(nil, func(Event) {
137 require.NoError(t, err)
138 require.Nil(t, loop.tools.whatsapp)
139 require.Contains(t, loop.tools.run(t.Context(), "whatsapp_send", `{"id":"fake","chat":"fake","text":"draft"}`), "unknown tool")
141 _, err = loop.Submit("parent", strings.Repeat("Keep a garden decision. ", nodeBytes))
142 require.NoError(t, err)
143 require.NoError(t, loop.turn(t.Context()))
144 require.NoError(t, child.memory.Settle(t.Context()))
146 require.GreaterOrEqual(t, server.Calls(), 2)
148 first := server.Call(0)
150 for i := range server.Calls() {
151 call := server.Call(i)
152 require.Equal(t, child.agent.CacheKey(), call.body["prompt_cache_key"])
153 require.Equal(t, first.body["instructions"], call.body["instructions"])
154 require.Equal(t, first.body["tools"], call.body["tools"])
155 require.NotContains(t, string(call.raw), "whatsapp_send")
156 require.NotContains(t, string(call.raw), "whatsapp_find")
159 usage := modelReadUsage(t, child.client.UsagePath())
160 require.GreaterOrEqual(t, len(usage), 2)
164 for _, record := range usage {
165 if record.Node != nil {
172 require.Positive(t, nodes)
173 require.Equal(t, 1, turns)
176 func TestBrokenChildConfigStopsDaemonStartup(t *testing.T) {
178 path := filepath.Join(dir, daemonAgentDirName, "children", "Garden")
179 require.NoError(t, os.MkdirAll(path, 0o700))
180 require.NoError(t, os.WriteFile(filepath.Join(path, "config.json"), []byte(`{"broken":true}`), 0o600))
181 t.Setenv("ONECHAT_DIR", dir)
183 err := daemon(t.Context(), io.Discard)
184 require.ErrorContains(t, err, `open child "Garden"`)
185 require.ErrorContains(t, err, `unknown field "broken"`)
187 _, err = os.Stat(filepath.Join(dir, socketFileName))
188 require.ErrorIs(t, err, os.ErrNotExist)
191 func TestInvalidChildRelationshipsStopStartup(t *testing.T) {
192 server := newModelFake(t)
193 testCases := []struct {
195 change func(*testing.T, string)
199 name: "missing config is not created",
200 change: func(t *testing.T, path string) {
201 require.NoError(t, os.Remove(filepath.Join(path, "config.json")))
203 cause: `child "Garden" config:`,
206 name: "wrong parent is rejected",
207 change: func(t *testing.T, path string) {
208 agent, err := openAgent(path)
209 require.NoError(t, err)
211 config := agent.config
212 config.Parent = "Other"
213 agentWriteTestConfig(t, path, config)
215 cause: `needs name "Garden" and parent "OneChat"`,
218 name: "a grandchild is rejected",
219 change: func(t *testing.T, path string) {
220 require.NoError(t, os.MkdirAll(filepath.Join(path, "children", "Nested"), 0o700))
222 cause: "children cannot have children",
225 name: "a reused ID is rejected",
226 change: func(t *testing.T, path string) {
227 root, err := openAgent(filepath.Dir(filepath.Dir(path)))
228 require.NoError(t, err)
230 child, err := openAgent(path)
231 require.NoError(t, err)
233 config := child.config
234 config.ID = root.config.ID
235 agentWriteTestConfig(t, path, config)
237 cause: "must have its own name and ID",
240 for _, tc := range testCases {
241 t.Run(tc.name, func(t *testing.T) {
243 path := daemonChildConfig(t, dir, "Garden")
246 _, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
247 require.ErrorContains(t, err, tc.cause)