turbo-editors/turbo-corepublic Fork 0
v0.9.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

session_test.go · 416 lines · 14.1 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 18h ago1package acp_test
2
3import (
4 "errors"
5 "strings"
6 "testing"
7
8 "codeberg.org/turbo-editors/turbo-core/acp"
9)
10
11// start returns a session talking to a fake agent over an in-memory pipe.
12func start(t *testing.T, options acp.Options) (*acp.Session, *fakeAgent) {
13 t.Helper()
14
15 agent, stream := newFakeAgent(t)
16 session := acp.NewSession(stream, acp.Agent{Name: "Bob"}, t.TempDir(), options)
17 t.Cleanup(func() { _ = session.Close() })
18 return session, agent
19}
20
21func TestTheHandshakeMakesASessionReady(t *testing.T) {
22 session, agent := start(t, acp.Options{})
23
24 agent.handshake()
25 waitFor(t, "the session to be ready", session.Ready)
26
27 if err := session.Err(); err != nil {
28 t.Fatalf("Err() = %v", err)
29 }
30 // The configured name wins: docker agent reports "docker agent", which is
31 // the runtime rather than the assistant, and the window is titled with what
32 // the user chose. Two names for one thing is worse than one.
33 if got := session.AgentName(); got != "Bob" {
34 t.Errorf("AgentName() = %q, want the configured name", got)
35 }
36 if got := session.AgentInfo().Version; got != "v1.139.0" {
37 t.Errorf("AgentInfo().Version = %q; the handshake's own details are still kept", got)
38 }
39}
40
41func TestTheClientSendsTheProtocolVersionAndItsCapabilities(t *testing.T) {
42 _, agent := start(t, acp.Options{Client: acp.Implementation{Name: "turbo-test"}})
43
44 initialize := agent.read()
45 params, _ := initialize["params"].(map[string]any)
46 if params["protocolVersion"] != float64(acp.ProtocolVersion) {
47 t.Errorf("the client asked for version %v, want %d", params["protocolVersion"], acp.ProtocolVersion)
48 }
49
50 capabilities, _ := params["clientCapabilities"].(map[string]any)
51 files, _ := capabilities["fs"].(map[string]any)
52 if files["readTextFile"] != true || files["writeTextFile"] != true {
53 t.Errorf("the client offered %v, want both file operations", capabilities)
54 }
55 // Terminal is deliberately not offered, so a conforming agent never asks.
56 if _, offered := capabilities["terminal"]; offered {
57 t.Errorf("the client offered a terminal capability it does not implement: %v", capabilities)
58 }
59 if info, _ := params["clientInfo"].(map[string]any); info["name"] != "turbo-test" {
60 t.Errorf("the client called itself %v", info)
61 }
62}
63
64func TestAnAgentSpeakingAnotherVersionIsRefusedWithAReadableReason(t *testing.T) {
65 session, agent := start(t, acp.Options{})
66
67 initialize := agent.read()
68 agent.reply(initialize["id"], `{"protocolVersion":99,"agentInfo":{"name":"future"}}`)
69
70 waitFor(t, "the session to fail", func() bool { return session.Err() != nil })
71 if got := session.Err().Error(); !strings.Contains(got, "99") {
72 t.Errorf("Err() = %q, want both versions named", got)
73 }
74 if notice := textOf(session.Entries(), acp.EntryNotice); !strings.Contains(notice, "99") {
75 t.Errorf("the window says %q; the reason must be visible in it", notice)
76 }
77}
78
79func TestAnAgentWantingALoginSaysSoRatherThanHanging(t *testing.T) {
80 session, agent := start(t, acp.Options{})
81
82 initialize := agent.read()
83 agent.reply(initialize["id"], `{"protocolVersion":1,"agentInfo":{"name":"paid"},"authMethods":[{"id":"oauth","name":"Log in with OAuth"}]}`)
84
85 waitFor(t, "the session to fail", func() bool { return session.Err() != nil })
86 if got := session.Err().Error(); !strings.Contains(got, "Log in with OAuth") {
87 t.Errorf("Err() = %q, want the method named so a user knows what to run", got)
88 }
89}
90
91func TestWhatIsTypedBeforeTheAgentIsReadyIsSentWhenItIs(t *testing.T) {
92 // The handshake may mean reaching a model, which takes seconds. Losing
93 // what somebody typed in that window would be the most annoying possible
94 // version of this.
95 session, agent := start(t, acp.Options{})
96
97 session.Prompt("early")
98 waitFor(t, "the prompt to appear in the window", func() bool {
99 return strings.Contains(textOf(session.Entries(), acp.EntryUser), "early")
100 })
101
102 id := agent.handshake()
103 prompt := agent.read()
104 if prompt["method"] != acp.MethodPrompt {
105 t.Fatalf("the client sent %v, want the held prompt", prompt["method"])
106 }
107
108 params, _ := prompt["params"].(map[string]any)
109 if params["sessionId"] != id {
110 t.Errorf("the prompt quotes session %v, want %q", params["sessionId"], id)
111 }
112 blocks, _ := params["prompt"].([]any)
113 first, _ := blocks[0].(map[string]any)
114 if first["text"] != "early" || first["type"] != "text" {
115 t.Errorf("the prompt carried %v", blocks)
116 }
117}
118
119func TestAReplyArrivesChunkByChunkAndReadsAsOneMessage(t *testing.T) {
120 // This is exactly what a real agent does: one update per token. An entry
121 // per chunk could be neither wrapped nor told apart from a code fence.
122 session, agent := start(t, acp.Options{})
123 id := agent.handshake()
124 waitFor(t, "ready", session.Ready)
125
126 session.Prompt("hello")
127 prompt := agent.read()
128
129 for _, chunk := range []string{"I", " found", " three", " files", "."} {
130 agent.update(id, `{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`+chunk+`"}}`)
131 }
132 agent.reply(prompt["id"], `{"stopReason":"end_turn"}`)
133
134 waitFor(t, "the whole reply", func() bool {
135 return textOf(session.Entries(), acp.EntryAgent) == "I found three files."
136 })
137
138 agentEntries := 0
139 for _, entry := range session.Entries() {
140 if entry.Kind == acp.EntryAgent {
141 agentEntries++
142 }
143 }
144 if agentEntries != 1 {
145 t.Errorf("five chunks became %d entries, want 1", agentEntries)
146 }
147}
148
149func TestAThoughtIsNotFoldedIntoTheReplyBesideIt(t *testing.T) {
150 session, agent := start(t, acp.Options{})
151 id := agent.handshake()
152 waitFor(t, "ready", session.Ready)
153
154 agent.update(id, `{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"let me look"}}`)
155 agent.update(id, `{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"here it is"}}`)
156
157 waitFor(t, "both", func() bool {
158 return textOf(session.Entries(), acp.EntryAgent) == "here it is"
159 })
160 if got := textOf(session.Entries(), acp.EntryThought); got != "let me look" {
161 t.Errorf("the thought reads %q", got)
162 }
163}
164
165func TestAToolCallIsOneLineThatFillsInAsItRuns(t *testing.T) {
166 // The real order, recorded from docker agent: the tool_call arrives, then
167 // a tool_call_update carrying the output, both naming the same id.
168 session, agent := start(t, acp.Options{})
169 id := agent.handshake()
170 waitFor(t, "ready", session.Ready)
171
172 agent.update(id, `{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Shell","kind":"execute","status":"pending","rawInput":{"cmd":"ls -1","cwd":".","timeout":30}}`)
173 waitFor(t, "the tool call", func() bool { return toolEntry(session, "call_1") != nil })
174
175 tool := toolEntry(session, "call_1")
176 if tool.Speaker != "Shell" {
177 t.Errorf("the tool is called %q", tool.Speaker)
178 }
179 if tool.Detail != "ls -1" {
180 t.Errorf("the detail is %q, want the command rather than the whole input object", tool.Detail)
181 }
182 if tool.Done() {
183 t.Error("a pending tool call reports itself as finished")
184 }
185
186 agent.update(id, `{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"agent.yaml\nprobe.py"}}]}`)
187 waitFor(t, "the output", func() bool {
188 tool := toolEntry(session, "call_1")
189 return tool != nil && tool.Done()
190 })
191
192 tool = toolEntry(session, "call_1")
193 if !strings.Contains(tool.Text, "agent.yaml") {
194 t.Errorf("the output is %q", tool.Text)
195 }
196 if tool.Failed() {
197 t.Error("a completed tool call reports itself as failed")
198 }
199 if count := toolEntries(session); count != 1 {
200 t.Errorf("the update made a second entry: %d tool entries, want 1", count)
201 }
202}
203
204func TestAPlanIsReplacedRatherThanRepeated(t *testing.T) {
205 // An agent republishes the whole plan every time a step changes, so
206 // appending would leave five copies of a four-line plan in the window.
207 session, agent := start(t, acp.Options{})
208 id := agent.handshake()
209 waitFor(t, "ready", session.Ready)
210
211 agent.update(id, `{"sessionUpdate":"plan","entries":[{"content":"look","status":"pending"}]}`)
212 agent.update(id, `{"sessionUpdate":"plan","entries":[{"content":"look","status":"completed"},{"content":"report","status":"pending"}]}`)
213
214 waitFor(t, "the second plan", func() bool {
215 for _, entry := range session.Entries() {
216 if entry.Kind == acp.EntryPlan && len(entry.Plan) == 2 {
217 return true
218 }
219 }
220 return false
221 })
222
223 plans := 0
224 for _, entry := range session.Entries() {
225 if entry.Kind == acp.EntryPlan {
226 plans++
227 }
228 }
229 if plans != 1 {
230 t.Errorf("there are %d plans in the window, want 1", plans)
231 }
232}
233
234func TestAnUpdateThisClientDoesNotKnowIsCountedAndIgnored(t *testing.T) {
235 // The protocol grows. An editor that stopped talking to an agent because
236 // it learnt a new message would be wrong more often than it was right.
237 session, agent := start(t, acp.Options{})
238 id := agent.handshake()
239 waitFor(t, "ready", session.Ready)
240
241 agent.update(id, `{"sessionUpdate":"something_invented_next_year","whatever":1}`)
242 agent.update(id, `{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"still here"}}`)
243
244 waitFor(t, "the message after it", func() bool {
245 return textOf(session.Entries(), acp.EntryAgent) == "still here"
246 })
247 if session.Unknown() != 1 {
248 t.Errorf("Unknown() = %d, want 1", session.Unknown())
249 }
250 if err := session.Err(); err != nil {
251 t.Errorf("an unknown update ended the session: %v", err)
252 }
253}
254
255func TestUsageAndCommandsAreKeptWithoutBeingDrawn(t *testing.T) {
256 session, agent := start(t, acp.Options{})
257 id := agent.handshake()
258 waitFor(t, "ready", session.Ready)
259
260 agent.update(id, `{"sessionUpdate":"usage_update","used":1617,"size":262144}`)
261 agent.update(id, `{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"squash the history"},{"name":"web","description":"Search the web","input":{"hint":"query to search for"}}]}`)
262
263 waitFor(t, "the usage", func() bool {
264 used, size := session.Usage()
265 return used == 1617 && size == 262144
266 })
267 waitFor(t, "the commands", func() bool { return len(session.Commands()) == 2 })
268
269 // The shape is the protocol's own: input is present exactly when the
270 // command wants something after its name, and carries the hint.
271 commands := session.Commands()
272 if commands[0].TakesInput() || commands[0].Hint() != "" {
273 t.Errorf("compact takes input: %+v", commands[0])
274 }
275 if !commands[1].TakesInput() || commands[1].Hint() != "query to search for" {
276 t.Errorf("web lost its hint: %+v", commands[1])
277 }
278 if !session.EmbedsContext() {
279 t.Error("EmbedsContext() = false after a handshake that declared embeddedContext")
280 }
281
282 if session.Unknown() != 0 {
283 t.Errorf("Unknown() = %d; these two are understood", session.Unknown())
284 }
285 for _, entry := range session.Entries() {
286 if strings.Contains(entry.Text, "262144") {
287 t.Errorf("the usage was drawn into the conversation: %q", entry.Text)
288 }
289 }
290}
291
292func TestCancellingATurnNotifiesTheAgent(t *testing.T) {
293 session, agent := start(t, acp.Options{})
294 id := agent.handshake()
295 waitFor(t, "ready", session.Ready)
296
297 session.Prompt("something long")
298 agent.read() // the prompt
299 waitFor(t, "the turn to start", session.Running)
300
301 session.Cancel()
302 cancel := agent.read()
303 if cancel["method"] != acp.MethodCancel {
304 t.Fatalf("the client sent %v, want session/cancel", cancel["method"])
305 }
306 if _, carries := cancel["id"]; carries {
307 t.Error("session/cancel was sent as a request; it is a notification")
308 }
309 params, _ := cancel["params"].(map[string]any)
310 if params["sessionId"] != id {
311 t.Errorf("the cancellation quotes session %v", params["sessionId"])
312 }
313}
314
315func TestCancellingWithNoTurnRunningSendsNothing(t *testing.T) {
316 session, agent := start(t, acp.Options{})
317 agent.handshake()
318 waitFor(t, "ready", session.Ready)
319
320 session.Cancel()
321
322 // Nothing to read means nothing was sent; a stray cancel would confuse an
323 // agent that is between turns.
324 if got := agent.methods(); len(got) != 2 {
325 t.Errorf("the client has sent %v, want only the handshake", got)
326 }
327}
328
329func TestATurnThatEndsUnusuallySaysSo(t *testing.T) {
330 session, agent := start(t, acp.Options{})
331 agent.handshake()
332 waitFor(t, "ready", session.Ready)
333
334 session.Prompt("go on")
335 prompt := agent.read()
336 agent.reply(prompt["id"], `{"stopReason":"max_tokens"}`)
337
338 waitFor(t, "the notice", func() bool {
339 return strings.Contains(textOf(session.Entries(), acp.EntryNotice), "max_tokens")
340 })
341}
342
343func TestAnOrdinaryEndOfTurnSaysNothing(t *testing.T) {
344 // A notice after every successful turn would be noise in every
345 // conversation.
346 session, agent := start(t, acp.Options{})
347 agent.handshake()
348 waitFor(t, "ready", session.Ready)
349
350 session.Prompt("go on")
351 prompt := agent.read()
352 agent.reply(prompt["id"], `{"stopReason":"end_turn"}`)
353
354 waitFor(t, "the turn to finish", func() bool { return !session.Running() })
355 if notice := textOf(session.Entries(), acp.EntryNotice); notice != "" {
356 t.Errorf("an ordinary turn left %q in the window", notice)
357 }
358}
359
360func TestAPromptIsRecordedBeforeItIsSent(t *testing.T) {
361 // The window has to show what you typed the moment you press Enter, not
362 // when the agent gets round to answering.
363 session, agent := start(t, acp.Options{})
364 agent.handshake()
365 waitFor(t, "ready", session.Ready)
366
367 session.Prompt("what does this do?")
368 if got := textOf(session.Entries(), acp.EntryUser); !strings.Contains(got, "what does this do?") {
369 t.Errorf("the window says %q straight after Prompt", got)
370 }
371}
372
373func TestAnEmptyPromptDoesNothing(t *testing.T) {
374 session, agent := start(t, acp.Options{})
375 agent.handshake()
376 waitFor(t, "ready", session.Ready)
377
378 session.Prompt("")
379 if len(session.Entries()) != 0 {
380 t.Errorf("an empty prompt left %v in the window", session.Entries())
381 }
382}
383
384func TestClosingASessionTwiceIsHarmless(t *testing.T) {
385 session, agent := start(t, acp.Options{})
386 agent.handshake()
387
388 if err := session.Close(); err != nil && !errors.Is(err, acp.ErrMessageTooLarge) {
389 t.Fatalf("Close() error = %v", err)
390 }
391 if err := session.Close(); err != nil {
392 t.Errorf("the second Close() error = %v", err)
393 }
394}
395
396// toolEntry returns the tool entry with an id, or nil.
397func toolEntry(session *acp.Session, id string) *acp.Entry {
398 for _, entry := range session.Entries() {
399 if entry.Kind == acp.EntryTool && entry.ToolCallID == id {
400 found := entry
401 return &found
402 }
403 }
404 return nil
405}
406
407// toolEntries counts the tool calls in a conversation.
408func toolEntries(session *acp.Session) int {
409 count := 0
410 for _, entry := range session.Entries() {
411 if entry.Kind == acp.EntryTool {
412 count++
413 }
414 }
415 return count
416}