]> git.giacomolli.uk - onechat/blob - daemon_agent_test.go
Run each child with its own files and loop
[onechat] / daemon_agent_test.go
1 package main
2
3 import (
4         "context"
5         "io"
6         "os"
7         "path/filepath"
8         "strings"
9         "testing"
10         "time"
11
12         "github.com/stretchr/testify/require"
13 )
14
15 func TestDaemonOpensAndRunsTheRootAndTwoChildren(t *testing.T) {
16         dir := t.TempDir()
17         daemonChildConfig(t, dir, "Garden")
18         daemonChildConfig(t, dir, "Library")
19
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)
24
25         t.Cleanup(func() {
26                 require.NoError(t, agents.Close())
27         })
28         require.Len(t, agents, 3)
29         require.Equal(t, []string{"Garden", "Library"}, agents.ChildNames())
30
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)
35         }
36
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)
41
42         ctx, cancel := context.WithCancel(t.Context())
43         defer cancel()
44
45         done := make(chan error, 1)
46         go func() {
47                 done <- daemonServe(ctx, listener, nil, whatsapp, agents[0].schedules, func(notify func(Event)) (*TurnLoop, error) {
48                         return agents[0].Loop(whatsapp, notify)
49                 }, agents[1:]...)
50         }()
51
52         t.Cleanup(func() {
53                 cancel()
54                 require.NoError(t, <-done)
55         })
56
57         require.Eventually(t, func() bool {
58                 for _, agent := range agents {
59                         if agent.memory.Len() != 2 {
60                                 return false
61                         }
62                 }
63
64                 return true
65         }, 5*time.Second, time.Millisecond)
66
67         require.Equal(t, 3, server.Calls())
68
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
73         }
74
75         require.Len(t, keys, 3)
76
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()])
82
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)
87         }
88
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")
93 }
94
95 func TestDaemonReadyLineNamesTheChildren(t *testing.T) {
96         dir := t.TempDir()
97         daemonChildConfig(t, dir, "Garden")
98         daemonChildConfig(t, dir, "Library")
99         t.Setenv("ONECHAT_DIR", dir)
100
101         var out SyncBuffer
102
103         ctx, cancel := context.WithCancel(t.Context())
104         defer cancel()
105
106         done := make(chan error, 1)
107         go func() {
108                 done <- daemon(ctx, &out)
109         }()
110
111         t.Cleanup(func() {
112                 cancel()
113
114                 require.NoError(t, <-done)
115         })
116
117         require.Eventually(t, func() bool {
118                 return strings.Contains(out.String(), "children=[Garden Library]")
119         }, 5*time.Second, time.Millisecond)
120 }
121
122 func TestChildCallsShareTheirPromptAndToolsWithCompaction(t *testing.T) {
123         dir := t.TempDir()
124         daemonChildConfig(t, dir, "Garden")
125
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)
129
130         defer func() {
131                 require.NoError(t, agents.Close())
132         }()
133
134         child := agents[1]
135         loop, err := child.Loop(nil, func(Event) {
136         })
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")
140
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()))
145
146         require.GreaterOrEqual(t, server.Calls(), 2)
147
148         first := server.Call(0)
149
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")
157         }
158
159         usage := modelReadUsage(t, child.client.UsagePath())
160         require.GreaterOrEqual(t, len(usage), 2)
161
162         var turns, nodes int
163
164         for _, record := range usage {
165                 if record.Node != nil {
166                         nodes++
167                 } else {
168                         turns++
169                 }
170         }
171
172         require.Positive(t, nodes)
173         require.Equal(t, 1, turns)
174 }
175
176 func TestBrokenChildConfigStopsDaemonStartup(t *testing.T) {
177         dir := t.TempDir()
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)
182
183         err := daemon(t.Context(), io.Discard)
184         require.ErrorContains(t, err, `open child "Garden"`)
185         require.ErrorContains(t, err, `unknown field "broken"`)
186
187         _, err = os.Stat(filepath.Join(dir, socketFileName))
188         require.ErrorIs(t, err, os.ErrNotExist)
189 }
190
191 func TestInvalidChildRelationshipsStopStartup(t *testing.T) {
192         server := newModelFake(t)
193         testCases := []struct {
194                 name   string
195                 change func(*testing.T, string)
196                 cause  string
197         }{
198                 {
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")))
202                         },
203                         cause: `child "Garden" config:`,
204                 },
205                 {
206                         name: "wrong parent is rejected",
207                         change: func(t *testing.T, path string) {
208                                 agent, err := openAgent(path)
209                                 require.NoError(t, err)
210
211                                 config := agent.config
212                                 config.Parent = "Other"
213                                 agentWriteTestConfig(t, path, config)
214                         },
215                         cause: `needs name "Garden" and parent "OneChat"`,
216                 },
217                 {
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))
221                         },
222                         cause: "children cannot have children",
223                 },
224                 {
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)
229
230                                 child, err := openAgent(path)
231                                 require.NoError(t, err)
232
233                                 config := child.config
234                                 config.ID = root.config.ID
235                                 agentWriteTestConfig(t, path, config)
236                         },
237                         cause: "must have its own name and ID",
238                 },
239         }
240         for _, tc := range testCases {
241                 t.Run(tc.name, func(t *testing.T) {
242                         dir := t.TempDir()
243                         path := daemonChildConfig(t, dir, "Garden")
244                         tc.change(t, path)
245
246                         _, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog())
247                         require.ErrorContains(t, err, tc.cause)
248                 })
249         }
250 }