package acp_test import ( "os" "path/filepath" "strings" "testing" "rickub.com/turbo-editors/turbo-core/acp" ) func TestAPermissionIsHandedOverAndAnsweredLater(t *testing.T) { // The whole shape of this feature: the request arrives on the reading // goroutine, and the answer comes from a dialog somebody has to look at, // several turns of an event loop later. asked := make(chan *acp.Permission, 1) session, agent := start(t, acp.Options{OnPermission: func(p *acp.Permission) { asked <- p }}) id := agent.handshake() waitFor(t, "ready", session.Ready) // The real shape, recorded from docker agent: the permission request uses // the agent's own id space, and arrives *before* the tool_call update. 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"}]}}`) permission := <-asked if permission.Title != "Shell" { t.Errorf("the dialog would say %q", permission.Title) } if permission.Detail != "ls -1" { t.Errorf("the detail is %q, want the command a person has to judge", permission.Detail) } if len(permission.Options) != 3 { t.Fatalf("the agent offered %d options, %v", len(permission.Options), permission.Options) } permission.Answer("allow") reply := agent.read() if got := replyOutcome(t, reply); got["outcome"] != acp.OutcomeSelected || got["optionId"] != "allow" { t.Errorf("the client answered %v", got) } } func TestAPermissionIsAnsweredOnlyOnce(t *testing.T) { // A window closing tears down a dialog that may already have been // answered, and two responses carrying one id would desynchronise the // agent. asked := make(chan *acp.Permission, 1) session, agent := start(t, acp.Options{OnPermission: func(p *acp.Permission) { asked <- p }}) id := agent.handshake() waitFor(t, "ready", session.Ready) 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"}]}}`) permission := <-asked permission.Answer("allow") first := replyOutcome(t, agent.read()) permission.Answer("allow") permission.Cancel() if first["optionId"] != "allow" { t.Errorf("the first answer was %v", first) } // Anything more would be read here; the agent sends nothing else, so a // second frame can only have come from the client. agent.update(id, `{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"done"}}`) waitFor(t, "the message after it", func() bool { return textOf(session.Entries(), acp.EntryAgent) == "done" }) } func TestARejectedPermissionUsesTheAgentsOwnRejectOption(t *testing.T) { // Escape has to mean something the agent understands. Inventing an id it // does not know leaves it as stuck as saying nothing at all. permission := &acp.Permission{Options: []acp.PermissionOption{ {OptionID: "allow", Kind: acp.OptionAllowOnce}, {OptionID: "always", Kind: acp.OptionAllowAlways}, {OptionID: "skip", Kind: acp.OptionRejectOnce}, }} if got := permission.RejectOption(); got != "skip" { t.Errorf("RejectOption() = %q, want the agent's reject_once", got) } // An agent that labels nothing still has to be answerable; the last option // is by convention its most refusing one. unlabelled := &acp.Permission{Options: []acp.PermissionOption{{OptionID: "yes"}, {OptionID: "no"}}} if got := unlabelled.RejectOption(); got != "no" { t.Errorf("RejectOption() = %q, want the last option", got) } if got := (&acp.Permission{}).RejectOption(); got != "" { t.Errorf("RejectOption() = %q with no options at all", got) } } func TestAnAgentAskingWithNobodyToAskIsCancelledRatherThanLeftWaiting(t *testing.T) { // A session with no OnPermission — which is what every test that does not // care about permissions is. An agent waiting for an answer that can never // come would simply stop, with nothing said anywhere. session, agent := start(t, acp.Options{}) id := agent.handshake() waitFor(t, "ready", session.Ready) agent.send(`{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"` + id + `","toolCall":{"title":"Shell"},"options":[{"optionId":"allow","name":"Allow"}]}}`) if got := replyOutcome(t, agent.read()); got["outcome"] != acp.OutcomeCancelled { t.Errorf("the client answered %v, want cancelled", got) } } func TestTheAgentReadsTheBufferRatherThanTheDisk(t *testing.T) { // The bargain this feature makes: you are asking about the edit you just // made, so the agent must see it. session, agent := start(t, acp.Options{ ReadTextFile: func(path string) (string, error) { return "what the editor can see in " + filepath.Base(path), nil }, }) id := agent.handshake() waitFor(t, "ready", session.Ready) agent.send(`{"jsonrpc":"2.0","id":1,"method":"fs/read_text_file","params":{"sessionId":"` + id + `","path":"/src/main.go"}}`) result := replyResult(t, agent.read()) if got := result["content"]; got != "what the editor can see in main.go" { t.Errorf("the agent was given %q", got) } } func TestTheAgentMayAskForPartOfAFile(t *testing.T) { // Line numbers are one-based in this protocol, which is the kind of // off-by-one that survives testing until somebody asks for line 1. whole := "one\ntwo\nthree\nfour\n" session, agent := start(t, acp.Options{ ReadTextFile: func(string) (string, error) { return whole, nil }, }) id := agent.handshake() waitFor(t, "ready", session.Ready) agent.send(`{"jsonrpc":"2.0","id":1,"method":"fs/read_text_file","params":{"sessionId":"` + id + `","path":"/f","line":2,"limit":2}}`) if got := replyResult(t, agent.read())["content"]; got != "two\nthree\n" { t.Errorf("the agent was given %q, want the second and third lines", got) } } func TestAFailureToReadReachesTheAgentAsAnError(t *testing.T) { session, agent := start(t, acp.Options{ ReadTextFile: func(string) (string, error) { return "", os.ErrNotExist }, }) id := agent.handshake() waitFor(t, "ready", session.Ready) agent.send(`{"jsonrpc":"2.0","id":1,"method":"fs/read_text_file","params":{"sessionId":"` + id + `","path":"/gone"}}`) reply := agent.read() failure, ok := reply["error"].(map[string]any) if !ok { t.Fatalf("the client answered %v, want an error", reply) } if !strings.Contains(strings.ToLower(failure["message"].(string)), "not exist") { t.Errorf("the error reads %v", failure["message"]) } } func TestTheAgentWritesThroughTheEditor(t *testing.T) { written := make(chan [2]string, 1) session, agent := start(t, acp.Options{ WriteTextFile: func(path, content string) error { written <- [2]string{path, content} return nil }, }) id := agent.handshake() waitFor(t, "ready", session.Ready) agent.send(`{"jsonrpc":"2.0","id":1,"method":"fs/write_text_file","params":{"sessionId":"` + id + `","path":"/src/main.go","content":"package main\n"}}`) got := <-written if got[0] != "/src/main.go" || got[1] != "package main\n" { t.Errorf("the editor was asked to write %q into %q", got[1], got[0]) } if _, isError := agent.read()["error"]; isError { t.Error("a successful write was answered with an error") } } func TestAMethodThisClientDoesNotImplementIsRefusedPlainly(t *testing.T) { // terminal/* is not advertised, so a conforming agent never asks — but one // that does must be told, not left waiting. session, agent := start(t, acp.Options{}) id := agent.handshake() waitFor(t, "ready", session.Ready) agent.send(`{"jsonrpc":"2.0","id":1,"method":"terminal/create","params":{"sessionId":"` + id + `"}}`) failure, ok := agent.read()["error"].(map[string]any) if !ok { t.Fatal("want an error object") } if failure["message"] != "terminal/create" { t.Errorf("the error names %v, want the method", failure["message"]) } } // replyResult returns a reply's result object, failing the test if there is // none. func replyResult(t *testing.T, reply map[string]any) map[string]any { t.Helper() result, ok := reply["result"].(map[string]any) if !ok { t.Fatalf("the client answered %v, want a result", reply) } return result } // replyOutcome returns the outcome object inside a permission answer. func replyOutcome(t *testing.T, reply map[string]any) map[string]any { t.Helper() outcome, ok := replyResult(t, reply)["outcome"].(map[string]any) if !ok { t.Fatalf("the client answered %v, want an outcome", reply) } return outcome }