]> git.giacomolli.uk - onechat/blob - daemon_agent_test.go
7b1407b220e1586daf31d0c6b908a6e8a9fcd19a
[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 }