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.

permission_test.go · 228 lines · 8.6 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 10h ago1package acp_test
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "codeberg.org/turbo-editors/turbo-core/acp"
10)
11
12func TestAPermissionIsHandedOverAndAnsweredLater(t *testing.T) {
13 // The whole shape of this feature: the request arrives on the reading
14 // goroutine, and the answer comes from a dialog somebody has to look at,
15 // several turns of an event loop later.
16 asked := make(chan *acp.Permission, 1)
17 session, agent := start(t, acp.Options{OnPermission: func(p *acp.Permission) { asked <- p }})
18 id := agent.handshake()
19 waitFor(t, "ready", session.Ready)
20
21 // The real shape, recorded from docker agent: the permission request uses
22 // the agent's own id space, and arrives *before* the tool_call update.
23 agent.send(`{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"` + id + `","toolCall":{"toolCallId":"call_1","title":"Shell","kind":"execute","status":"pending","rawInput":{"cmd":"ls -1","cwd":".","timeout":30}},"options":[{"kind":"allow_once","name":"Allow this action","optionId":"allow"},{"kind":"allow_always","name":"Allow and remember my choice","optionId":"allow-always"},{"kind":"reject_once","name":"Skip this action","optionId":"reject"}]}}`)
24
25 permission := <-asked
26 if permission.Title != "Shell" {
27 t.Errorf("the dialog would say %q", permission.Title)
28 }
29 if permission.Detail != "ls -1" {
30 t.Errorf("the detail is %q, want the command a person has to judge", permission.Detail)
31 }
32 if len(permission.Options) != 3 {
33 t.Fatalf("the agent offered %d options, %v", len(permission.Options), permission.Options)
34 }
35
36 permission.Answer("allow")
37
38 reply := agent.read()
39 if got := replyOutcome(t, reply); got["outcome"] != acp.OutcomeSelected || got["optionId"] != "allow" {
40 t.Errorf("the client answered %v", got)
41 }
42}
43
44func TestAPermissionIsAnsweredOnlyOnce(t *testing.T) {
45 // A window closing tears down a dialog that may already have been
46 // answered, and two responses carrying one id would desynchronise the
47 // agent.
48 asked := make(chan *acp.Permission, 1)
49 session, agent := start(t, acp.Options{OnPermission: func(p *acp.Permission) { asked <- p }})
50 id := agent.handshake()
51 waitFor(t, "ready", session.Ready)
52
53 agent.send(`{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"` + id + `","toolCall":{"title":"Shell"},"options":[{"kind":"allow_once","name":"Allow","optionId":"allow"}]}}`)
54
55 permission := <-asked
56 permission.Answer("allow")
57 first := replyOutcome(t, agent.read())
58
59 permission.Answer("allow")
60 permission.Cancel()
61
62 if first["optionId"] != "allow" {
63 t.Errorf("the first answer was %v", first)
64 }
65 // Anything more would be read here; the agent sends nothing else, so a
66 // second frame can only have come from the client.
67 agent.update(id, `{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"done"}}`)
68 waitFor(t, "the message after it", func() bool {
69 return textOf(session.Entries(), acp.EntryAgent) == "done"
70 })
71}
72
73func TestARejectedPermissionUsesTheAgentsOwnRejectOption(t *testing.T) {
74 // Escape has to mean something the agent understands. Inventing an id it
75 // does not know leaves it as stuck as saying nothing at all.
76 permission := &acp.Permission{Options: []acp.PermissionOption{
77 {OptionID: "allow", Kind: acp.OptionAllowOnce},
78 {OptionID: "always", Kind: acp.OptionAllowAlways},
79 {OptionID: "skip", Kind: acp.OptionRejectOnce},
80 }}
81 if got := permission.RejectOption(); got != "skip" {
82 t.Errorf("RejectOption() = %q, want the agent's reject_once", got)
83 }
84
85 // An agent that labels nothing still has to be answerable; the last option
86 // is by convention its most refusing one.
87 unlabelled := &acp.Permission{Options: []acp.PermissionOption{{OptionID: "yes"}, {OptionID: "no"}}}
88 if got := unlabelled.RejectOption(); got != "no" {
89 t.Errorf("RejectOption() = %q, want the last option", got)
90 }
91
92 if got := (&acp.Permission{}).RejectOption(); got != "" {
93 t.Errorf("RejectOption() = %q with no options at all", got)
94 }
95}
96
97func TestAnAgentAskingWithNobodyToAskIsCancelledRatherThanLeftWaiting(t *testing.T) {
98 // A session with no OnPermission — which is what every test that does not
99 // care about permissions is. An agent waiting for an answer that can never
100 // come would simply stop, with nothing said anywhere.
101 session, agent := start(t, acp.Options{})
102 id := agent.handshake()
103 waitFor(t, "ready", session.Ready)
104
105 agent.send(`{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"` + id + `","toolCall":{"title":"Shell"},"options":[{"optionId":"allow","name":"Allow"}]}}`)
106
107 if got := replyOutcome(t, agent.read()); got["outcome"] != acp.OutcomeCancelled {
108 t.Errorf("the client answered %v, want cancelled", got)
109 }
110}
111
112func TestTheAgentReadsTheBufferRatherThanTheDisk(t *testing.T) {
113 // The bargain this feature makes: you are asking about the edit you just
114 // made, so the agent must see it.
115 session, agent := start(t, acp.Options{
116 ReadTextFile: func(path string) (string, error) {
117 return "what the editor can see in " + filepath.Base(path), nil
118 },
119 })
120 id := agent.handshake()
121 waitFor(t, "ready", session.Ready)
122
123 agent.send(`{"jsonrpc":"2.0","id":1,"method":"fs/read_text_file","params":{"sessionId":"` + id + `","path":"/src/main.go"}}`)
124
125 result := replyResult(t, agent.read())
126 if got := result["content"]; got != "what the editor can see in main.go" {
127 t.Errorf("the agent was given %q", got)
128 }
129}
130
131func TestTheAgentMayAskForPartOfAFile(t *testing.T) {
132 // Line numbers are one-based in this protocol, which is the kind of
133 // off-by-one that survives testing until somebody asks for line 1.
134 whole := "one\ntwo\nthree\nfour\n"
135 session, agent := start(t, acp.Options{
136 ReadTextFile: func(string) (string, error) { return whole, nil },
137 })
138 id := agent.handshake()
139 waitFor(t, "ready", session.Ready)
140
141 agent.send(`{"jsonrpc":"2.0","id":1,"method":"fs/read_text_file","params":{"sessionId":"` + id + `","path":"/f","line":2,"limit":2}}`)
142
143 if got := replyResult(t, agent.read())["content"]; got != "two\nthree\n" {
144 t.Errorf("the agent was given %q, want the second and third lines", got)
145 }
146}
147
148func TestAFailureToReadReachesTheAgentAsAnError(t *testing.T) {
149 session, agent := start(t, acp.Options{
150 ReadTextFile: func(string) (string, error) { return "", os.ErrNotExist },
151 })
152 id := agent.handshake()
153 waitFor(t, "ready", session.Ready)
154
155 agent.send(`{"jsonrpc":"2.0","id":1,"method":"fs/read_text_file","params":{"sessionId":"` + id + `","path":"/gone"}}`)
156
157 reply := agent.read()
158 failure, ok := reply["error"].(map[string]any)
159 if !ok {
160 t.Fatalf("the client answered %v, want an error", reply)
161 }
162 if !strings.Contains(strings.ToLower(failure["message"].(string)), "not exist") {
163 t.Errorf("the error reads %v", failure["message"])
164 }
165}
166
167func TestTheAgentWritesThroughTheEditor(t *testing.T) {
168 written := make(chan [2]string, 1)
169 session, agent := start(t, acp.Options{
170 WriteTextFile: func(path, content string) error {
171 written <- [2]string{path, content}
172 return nil
173 },
174 })
175 id := agent.handshake()
176 waitFor(t, "ready", session.Ready)
177
178 agent.send(`{"jsonrpc":"2.0","id":1,"method":"fs/write_text_file","params":{"sessionId":"` + id + `","path":"/src/main.go","content":"package main\n"}}`)
179
180 got := <-written
181 if got[0] != "/src/main.go" || got[1] != "package main\n" {
182 t.Errorf("the editor was asked to write %q into %q", got[1], got[0])
183 }
184 if _, isError := agent.read()["error"]; isError {
185 t.Error("a successful write was answered with an error")
186 }
187}
188
189func TestAMethodThisClientDoesNotImplementIsRefusedPlainly(t *testing.T) {
190 // terminal/* is not advertised, so a conforming agent never asks — but one
191 // that does must be told, not left waiting.
192 session, agent := start(t, acp.Options{})
193 id := agent.handshake()
194 waitFor(t, "ready", session.Ready)
195
196 agent.send(`{"jsonrpc":"2.0","id":1,"method":"terminal/create","params":{"sessionId":"` + id + `"}}`)
197
198 failure, ok := agent.read()["error"].(map[string]any)
199 if !ok {
200 t.Fatal("want an error object")
201 }
202 if failure["message"] != "terminal/create" {
203 t.Errorf("the error names %v, want the method", failure["message"])
204 }
205}
206
207// replyResult returns a reply's result object, failing the test if there is
208// none.
209func replyResult(t *testing.T, reply map[string]any) map[string]any {
210 t.Helper()
211
212 result, ok := reply["result"].(map[string]any)
213 if !ok {
214 t.Fatalf("the client answered %v, want a result", reply)
215 }
216 return result
217}
218
219// replyOutcome returns the outcome object inside a permission answer.
220func replyOutcome(t *testing.T, reply map[string]any) map[string]any {
221 t.Helper()
222
223 outcome, ok := replyResult(t, reply)["outcome"].(map[string]any)
224 if !ok {
225 t.Fatalf("the client answered %v, want an outcome", reply)
226 }
227 return outcome
228}