]> git.giacomolli.uk - onechat/blob - terminal_test.go
Add local context to memory operation failures
[onechat] / terminal_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "io"
6 "net"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/stretchr/testify/require"
13 "golang.org/x/sys/unix"
14 )
15
16 func TestClientEndsWhenTheDaemonEnds(t *testing.T) {
17 dir := t.TempDir()
18 listener, err := daemonListenSocket(filepath.Join(dir, socketFileName))
19 require.NoError(t, err)
20
21 accepted := make(chan net.Conn)
22 go func() {
23 conn, err := listener.Accept()
24 if err == nil {
25 accepted <- conn
26 }
27 }()
28
29 conn, err := net.Dial("unix", filepath.Join(dir, socketFileName))
30 require.NoError(t, err)
31
32 in, _ := io.Pipe()
33 done := make(chan error)
34 go func() {
35 done <- terminalSession(t.Context(), conn, in, io.Discard)
36 }()
37
38 require.NoError(t, (<-accepted).Close())
39 require.ErrorIs(t, <-done, errTerminalDaemonClosed)
40 require.NoError(t, listener.Close())
41 }
42
43 func TestEventText(t *testing.T) {
44 testCases := []struct {
45 name string
46 event Event
47 text string
48 }{
49 {
50 name: "keeps the complete reply text",
51 event: Event{
52 Kind: "reply",
53 Text: "Done.",
54 },
55 text: "Done.",
56 },
57 {
58 name: "hides the current terminal user message",
59 event: Event{
60 Kind: "user",
61 Text: "typed",
62 Source: terminalSource,
63 },
64 text: "",
65 },
66 {
67 name: "marks a user message from another source",
68 event: Event{
69 Kind: "user",
70 Text: "from the phone",
71 Source: "telegram",
72 },
73 text: "[telegram] from the phone",
74 },
75 {
76 name: "marks the start of a subagent",
77 event: Event{
78 Kind: "subagent",
79 Text: "GreenOtter",
80 },
81 text: "[GreenOtter] started",
82 },
83 {
84 name: "keeps the complete subagent report",
85 event: Event{
86 Kind: "work",
87 Text: "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")",
88 Source: "GreenOtter",
89 Status: subagentFinished,
90 },
91 text: "[GreenOtter]\nThere are 3 files.\n\nFull chat: zoom(\"GreenOtter\")",
92 },
93 {
94 name: "clips a long tool result with its omitted count",
95 event: Event{
96 Kind: "echo",
97 Text: strings.Repeat("line\n", 12),
98 },
99 text: "[result] " + strings.Repeat("line\n", 9) + "line\n[2 more lines]",
100 },
101 }
102
103 for _, tc := range testCases {
104 t.Run(tc.name, func(t *testing.T) {
105 require.Equal(t, tc.text, terminalEventText(tc.event))
106 })
107 }
108 }
109
110 func TestTerminalEventsKeepMarkdownInNonterminalOutput(t *testing.T) {
111 var out strings.Builder
112
113 text := "# Heading\n\n**bold** and `code`\nA | B\n--- | ---\nx | y"
114 terminalTestShowEvents(t, &out, Event{
115 Kind: "reply",
116 Text: text,
117 }, Event{
118 Kind: "work",
119 Text: "[BlueBird]\n" + text,
120 })
121
122 require.Equal(t, text+"\n[BlueBird]\n"+text+"\n", out.String())
123 }
124
125 func TestTerminalFileOutputKeepsMarkdown(t *testing.T) {
126 out, err := os.CreateTemp(t.TempDir(), "output")
127 require.NoError(t, err)
128
129 defer func() {
130 require.NoError(t, out.Close())
131 }()
132
133 text := "**plain**\n---\n`code`"
134 terminalTestShowEvents(t, out, Event{
135 Kind: "reply",
136 Text: text,
137 })
138
139 require.Zero(t, terminalColumns(out))
140
141 contents, err := os.ReadFile(out.Name())
142 require.NoError(t, err)
143 require.Equal(t, text+"\n", string(contents))
144 }
145
146 func TestTerminalStylesOnlyRepliesAndWork(t *testing.T) {
147 fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
148 require.NoError(t, err)
149
150 defer func() {
151 require.NoError(t, unix.Close(fd))
152 }()
153
154 require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 12}))
155
156 out := TerminalTestOutput{fd: fd}
157 require.Equal(t, 12, terminalColumns(&out))
158 terminalTestShowEvents(t, &out,
159 Event{Kind: "busy"},
160 Event{
161 Kind: "reply",
162 Text: "**first next last**",
163 },
164 Event{
165 Kind: "work",
166 Text: "[BlueBird]\n# Report",
167 },
168 Event{
169 Kind: "tool",
170 Text: "**raw tool**",
171 },
172 Event{
173 Kind: "echo",
174 Text: "**raw result**",
175 },
176 Event{
177 Kind: "error",
178 Text: "**raw error**",
179 },
180 Event{
181 Kind: "notice",
182 Text: "**raw notice**",
183 },
184 Event{
185 Kind: "user",
186 Text: "**raw input**",
187 Source: "telegram",
188 },
189 Event{
190 Kind: "ready",
191 Text: "**raw agent**",
192 },
193 Event{
194 Kind: "subagent",
195 Text: "BlueBird",
196 },
197 Event{Kind: "busy"},
198 Event{Kind: "idle"},
199 )
200
201 require.Equal(t, "[working]\n\x1b[1mfirst next\x1b[0m\n\x1b[1mlast\x1b[0m\n[BlueBird]\n\x1b[1mReport\x1b[0m\n"+
202 "[tool] **raw tool**\n[result] **raw result**\n[error] **raw error**\n**raw notice**\n[telegram] **raw input**\n"+
203 "[connected to **raw agent**]\n[BlueBird] started\n[working]\n[done]\n> ", out.String())
204 }
205
206 func TestTerminalZeroWidthUsesEightyAndReadsChanges(t *testing.T) {
207 fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
208 require.NoError(t, err)
209
210 defer func() {
211 require.NoError(t, unix.Close(fd))
212 }()
213
214 out := TerminalTestOutput{fd: fd}
215 require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 0}))
216 require.Equal(t, 80, terminalColumns(&out))
217 terminalTestShowEvents(t, &out, Event{
218 Kind: "reply",
219 Text: strings.Repeat("a", 81),
220 })
221
222 require.Equal(t, strings.Repeat("a", 80)+"\na\n> ", out.String())
223 require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 40}))
224 require.Equal(t, 40, terminalColumns(&out))
225 }
226
227 func TestTerminalPromptsAfterIdleEventsAndClearsBeforeShownEvents(t *testing.T) {
228 fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
229 require.NoError(t, err)
230
231 defer func() {
232 require.NoError(t, unix.Close(fd))
233 }()
234
235 out := TerminalTestOutput{fd: fd}
236 terminalTestShowEvents(t, &out,
237 Event{
238 Kind: "ready",
239 Text: "Example",
240 },
241 Event{
242 Kind: "user",
243 Source: terminalSource,
244 Text: "hidden",
245 },
246 Event{Kind: "unknown"},
247 Event{
248 Kind: "notice",
249 Text: "Idle notice",
250 },
251 Event{
252 Kind: "work",
253 Text: "**Idle report**",
254 },
255 Event{Kind: "busy"},
256 Event{
257 Kind: "notice",
258 Text: "Busy notice",
259 },
260 Event{
261 Kind: "reply",
262 Text: "**Answer**",
263 },
264 Event{Kind: "idle"},
265 Event{
266 Kind: "error",
267 Text: "Idle error",
268 },
269 )
270
271 require.Equal(t, "[connected to Example]\n> "+
272 "\r\x1b[2KIdle notice\n> "+
273 "\r\x1b[2K\x1b[1mIdle report\x1b[0m\n> "+
274 "\r\x1b[2K[working]\nBusy notice\n\x1b[1mAnswer\x1b[0m\n[done]\n> "+
275 "\r\x1b[2K[error] Idle error\n> ", out.String())
276 }
277
278 func TestTerminalNonterminalEventsHaveNoPromptOrClearCommands(t *testing.T) {
279 var out strings.Builder
280
281 terminalTestShowEvents(t, &out,
282 Event{
283 Kind: "ready",
284 Text: "Example",
285 },
286 Event{
287 Kind: "notice",
288 Text: "Idle notice",
289 },
290 Event{Kind: "busy"},
291 Event{
292 Kind: "notice",
293 Text: "Busy notice",
294 },
295 Event{
296 Kind: "reply",
297 Text: "**Answer**",
298 },
299 Event{Kind: "idle"},
300 Event{
301 Kind: "notice",
302 Text: "Idle again",
303 },
304 )
305
306 require.Equal(t, "[connected to Example]\nIdle notice\n[working]\nBusy notice\n**Answer**\n[done]\nIdle again\n", out.String())
307 }
308
309 // TerminalTestOutput captures text while it supplies a test terminal
310 // descriptor.
311 type TerminalTestOutput struct {
312 strings.Builder
313 fd int
314 }
315
316 func (o *TerminalTestOutput) Fd() uintptr {
317 return uintptr(o.fd)
318 }
319
320 func terminalTestShowEvents(t *testing.T, out io.Writer, events ...Event) {
321 t.Helper()
322
323 server, client := net.Pipe()
324 done := make(chan error, 1)
325 go func() {
326 done <- terminalShowEvents(client, out)
327 }()
328
329 encoder := json.NewEncoder(server)
330 for _, event := range events {
331 require.NoError(t, encoder.Encode(event))
332 }
333
334 require.NoError(t, server.Close())
335 require.NoError(t, <-done)
336 require.NoError(t, client.Close())
337 }