]> git.giacomolli.uk - onechat/blob - daemon_test.go
Select notification targets by stable ID
[onechat] / daemon_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "io"
6 "net"
7 "net/http"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/require"
15 )
16
17 func TestNotificationReceiptIsPrivateToItsSubmittingSocket(t *testing.T) {
18 server := newModelFake(t)
19 socket := daemonStartTest(t, server)
20 observer, err := net.Dial("unix", socket)
21 require.NoError(t, err)
22 require.NoError(t, observer.SetDeadline(time.Now().Add(10*time.Second)))
23
24 decoder := json.NewDecoder(observer)
25
26 var event Event
27
28 require.NoError(t, decoder.Decode(&event))
29 require.Equal(t, "ready", event.Kind)
30
31 submitter, err := net.Dial("unix", socket)
32 require.NoError(t, err)
33 require.NoError(t, submitter.SetDeadline(time.Now().Add(10*time.Second)))
34 require.NoError(t, json.NewEncoder(submitter).Encode(SocketRequest{
35 Type: "notify",
36 Notify: &NotifyInput{
37 Source: "check",
38 ID: "1",
39 Text: "A new result.",
40 },
41 }))
42
43 accepted := false
44 answer := json.NewDecoder(submitter)
45 for !accepted {
46 require.NoError(t, answer.Decode(&event))
47
48 accepted = event.Kind == "notify"
49 }
50
51 require.Equal(t, "accepted", event.Status)
52
53 for {
54 require.NoError(t, decoder.Decode(&event))
55 require.NotEqual(t, "notify", event.Kind)
56
57 if event.Kind == "idle" {
58 break
59 }
60 }
61
62 require.NoError(t, submitter.Close())
63 require.NoError(t, observer.Close())
64 }
65
66 func TestNotificationSocketRejectsMissingOrInvalidData(t *testing.T) {
67 server := newModelFake(t)
68 socket := daemonStartTest(t, server)
69 conn, err := net.Dial("unix", socket)
70 require.NoError(t, err)
71 require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second)))
72
73 decoder := json.NewDecoder(conn)
74 encoder := json.NewEncoder(conn)
75
76 var event Event
77
78 require.NoError(t, decoder.Decode(&event))
79 require.Equal(t, "ready", event.Kind)
80
81 testCases := []*NotifyInput{
82 nil,
83 {
84 Source: "invalid source",
85 ID: "1",
86 Text: "data",
87 },
88 {
89 AgentID: "Garden",
90 Source: "check",
91 ID: "1",
92 Text: "data",
93 },
94 {
95 AgentID: strings.Repeat("f", 2*agentIDBytes),
96 Source: "check",
97 ID: "1",
98 Text: "data",
99 },
100 }
101
102 for _, tc := range testCases {
103 require.NoError(t, encoder.Encode(SocketRequest{
104 Type: "notify",
105 Notify: tc,
106 }))
107 require.NoError(t, decoder.Decode(&event))
108 require.Equal(t, "error", event.Kind)
109 require.NotContains(t, event.Text, "invalid source")
110 }
111
112 _, err = io.WriteString(conn, `{"type":"notify","agent":"Garden","notify":{"source":"check","id":"1","text":"data"}}`+"\n")
113 require.NoError(t, err)
114 require.NoError(t, decoder.Decode(&event))
115 require.Equal(t, "error", event.Kind)
116
117 _, err = io.WriteString(conn, `{"type":"notify","notify":{"agent":"Garden","source":"check","id":"1","text":"data"}}`+"\n")
118 require.NoError(t, err)
119 require.NoError(t, decoder.Decode(&event))
120 require.Equal(t, "error", event.Kind)
121
122 require.Zero(t, server.Calls())
123 require.NoError(t, conn.Close())
124 }
125
126 func TestClientsSeeTheTurnAsItHappens(t *testing.T) {
127 server := newModelFake(t)
128 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
129 if call.number == 0 {
130 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"echo hi"}`)}, 100, 0, 5))
131
132 return
133 }
134
135 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It said hi.")}, 100, 0, 5))
136 }
137
138 socket := daemonStartTest(t, server)
139 first := terminalStartTestClient(t, socket)
140 second := terminalStartTestClient(t, socket)
141 first.WaitFor(t, "[connected to OneChat]")
142 second.WaitFor(t, "[connected to OneChat]")
143
144 first.Type("Run echo hi.")
145
146 for _, client := range []*TerminalTestClient{first, second} {
147 client.WaitFor(t, "[done]")
148 require.Equal(t, "[connected to OneChat]\n[working]\n[tool] shell {\"command\":\"echo hi\"}\n[result] hi\nIt said hi.\n[done]\n", client.Output())
149 }
150
151 first.Type(stopCommand)
152 first.WaitFor(t, "[error] no turn runs")
153 first.Type(stopCommand + " BlueFox")
154 first.WaitFor(t, "[error] stop subagent: no subagent BlueFox runs")
155 require.NotContains(t, second.Output(), "no turn runs")
156 require.NoError(t, first.Close())
157 }
158
159 func TestStopInterruptsTheTurn(t *testing.T) {
160 server := newModelFake(t)
161 arrived := make(chan struct{})
162 release := make(chan struct{})
163 t.Cleanup(func() {
164 close(release)
165 })
166
167 server.reply = func(_ http.ResponseWriter, _ ModelFakeCall) {
168 close(arrived)
169 <-release
170 }
171
172 socket := daemonStartTest(t, server)
173 client := terminalStartTestClient(t, socket)
174 client.Type("a long task")
175 <-arrived
176
177 late := terminalStartTestClient(t, socket)
178 late.WaitFor(t, "[connected to OneChat]\n[working]")
179
180 client.Type(stopCommand)
181 client.WaitFor(t, "[error] interrupted\n[done]")
182 }
183
184 func TestListenSocketReplacesAnOldSocketFile(t *testing.T) {
185 path := filepath.Join(t.TempDir(), socketFileName)
186 require.NoError(t, os.WriteFile(path, nil, 0o600))
187
188 listener, err := daemonListenSocket(path)
189 require.NoError(t, err)
190
191 info, err := os.Stat(path)
192 require.NoError(t, err)
193 require.Equal(t, os.ModeSocket, info.Mode().Type())
194 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
195 require.NoError(t, listener.Close())
196 }
197
198 func TestListenSocketRefusesALongPath(t *testing.T) {
199 _, err := daemonListenSocket(filepath.Join(t.TempDir(), strings.Repeat("d", 100), socketFileName))
200 require.ErrorContains(t, err, "set ONECHAT_DIR to a shorter path")
201 }
202
203 func TestDaemonWritesNoFileForALongSocketPath(t *testing.T) {
204 dir := filepath.Join(t.TempDir(), strings.Repeat("d", 100))
205 t.Setenv("ONECHAT_DIR", dir)
206
207 err := daemon(t.Context(), io.Discard)
208 require.ErrorContains(t, err, "set ONECHAT_DIR to a shorter path")
209
210 entries, err := os.ReadDir(dir)
211 require.NoError(t, err)
212 require.Empty(t, entries)
213 }
214
215 func TestDaemonLogHasATimeStampOnlyOutsideTheJournal(t *testing.T) {
216 testCases := []struct {
217 name string
218 journal string
219 line string
220 }{
221 {
222 name: "the journal adds the time itself",
223 journal: "8:12345",
224 line: `^<6>onechat: the agent is ready\n$`,
225 },
226 {
227 name: "a terminal gets the time in the line",
228 journal: "",
229 line: `^\d{4}/\d\d/\d\d \d\d:\d\d:\d\d INFO onechat: the agent is ready\n$`,
230 },
231 }
232
233 for _, tc := range testCases {
234 t.Run(tc.name, func(t *testing.T) {
235 t.Setenv("JOURNAL_STREAM", tc.journal)
236
237 var out strings.Builder
238
239 daemonLogger(&out, "onechat: ").Info("the agent is ready")
240 require.Regexp(t, tc.line, out.String())
241 })
242 }
243 }
244
245 // daemonStartTest serves a new agent on a socket in a temporary directory and
246 // returns the socket path. The commands of the agent run in a temporary
247 // directory.
248 func daemonStartTest(t *testing.T, server *ModelFake) string {
249 t.Helper()
250
251 return daemonStartTestWithBot(t, server, nil)
252 }