| 🛟 Updated. 28d5985 k33g 11h ago | 1 | package acp_test |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 📦 Turbo Core f3ade8d k33g 4h ago | 9 | "rickub.com/turbo-editors/turbo-core/acp" |
| 🛟 Updated. 28d5985 k33g 11h ago | 10 | ) |
| 11 | |
| 12 | func 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 | |
| 44 | func 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 | |
| 73 | func 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 | |
| 97 | func 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 | |
| 112 | func 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 | |
| 131 | func 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 | |
| 148 | func 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 | |
| 167 | func 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 | |
| 189 | func 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. |
| 209 | func 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. |
| 220 | func 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 | } |