package acp_test import ( "strings" "testing" "github.com/gdamore/tcell/v2" "codeberg.org/turbo-editors/turbo-core/acp" ) // typeText types each rune into the view. func typeText(view *acp.View, text string) { for _, r := range text { view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) } } // press sends one key to the view. func press(view *acp.View, key tcell.Key) bool { return view.HandleKey(tcell.NewEventKey(key, 0, tcell.ModNone)) } // labels returns what the picker lists, as its labels. func labels(view *acp.View) []string { var out []string for _, choice := range view.Choices() { out = append(out, choice.Label) } return out } // withCommands lets the agent announce two commands, one taking input, and // waits until the session has them. func withCommands(t *testing.T, view *acp.View, agent *fakeAgent) { t.Helper() id := agent.handshake() waitFor(t, "ready", view.Session().Ready) agent.update(id, `{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"squash the history"},{"name":"web","description":"Search the web","input":{"hint":"query"}}]}`) waitFor(t, "the commands", func() bool { return len(view.Session().Commands()) == 2 }) } // projectFiles is a small project for the "@" picker. func projectFiles() []acp.Mention { return []acp.Mention{ {Name: "docs/rescan.md", Path: "/src/p/docs/rescan.md"}, {Name: "internal/scanner.go", Path: "/src/p/internal/scanner.go"}, {Name: "main.go", Path: "/src/p/main.go"}, } } func TestCommandChoicesMatchThePrefixIgnoringCase(t *testing.T) { commands := []acp.Command{ {Name: "compact", Description: "squash the history"}, {Name: "web", Description: "Search the web", Input: &acp.CommandInput{Hint: "query"}}, } all := acp.CommandChoices(commands, "") if len(all) != 2 { t.Fatalf("an empty prefix lists %d commands, want both", len(all)) } if all[0].Insert != "/compact" { t.Errorf("a command taking no input inserts %q, want the name alone", all[0].Insert) } if all[1].Insert != "/web " || all[1].Hint != "query" { t.Errorf("a command taking input became %+v, want a trailing space and the hint", all[1]) } if got := acp.CommandChoices(commands, "CO"); len(got) != 1 || got[0].Label != "/compact" { t.Errorf("CommandChoices(CO) = %+v, want /compact alone", got) } if got := acp.CommandChoices(commands, "x"); len(got) != 0 { t.Errorf("CommandChoices(x) = %+v, want nothing", got) } } func TestFileChoicesPutNamesThatStartWithThePrefixFirst(t *testing.T) { got := acp.FileChoices(projectFiles(), "sc") want := []string{"@internal/scanner.go", "@docs/rescan.md"} if len(got) != len(want) { t.Fatalf("FileChoices(sc) = %+v, want %v", got, want) } for i := range want { if got[i].Label != want[i] { t.Errorf("choice %d is %q, want %q", i, got[i].Label, want[i]) } } if got[0].Insert != "@internal/scanner.go " { t.Errorf("Insert = %q, want the name and a space", got[0].Insert) } // The cap: a list nobody can choose from is cut, not scrolled. many := make([]acp.Mention, 300) for i := range many { many[i] = acp.Mention{Name: "f" + strings.Repeat("x", i%7) + ".go"} } if got := acp.FileChoices(many, ""); len(got) != 200 { t.Errorf("an empty prefix over 300 files lists %d, want the cap of 200", len(got)) } } func TestTypingASlashListsTheAgentsCommands(t *testing.T) { view, agent := newView(t, 60, 14) view.SetFocused(true) withCommands(t, view, agent) typeText(view, "/") if got := labels(view); len(got) != 2 { t.Fatalf("Choices() after / = %v, want both commands", got) } joined := strings.Join(draw(t, view, 60, 14), "\n") for _, want := range []string{" commands ", "/compact", "squash the history", "/web", ""} { if !strings.Contains(joined, want) { t.Errorf("the popup does not show %q:\n%s", want, joined) } } typeText(view, "w") if got := labels(view); len(got) != 1 || got[0] != "/web" { t.Errorf("Choices() after /w = %v, want /web alone", got) } } func TestTabCompletesTheHighlightedCommand(t *testing.T) { view, agent := newView(t, 60, 14) view.SetFocused(true) withCommands(t, view, agent) typeText(view, "/co") press(view, tcell.KeyTab) if got := view.Input(); got != "/compact" { t.Errorf("Input() = %q after Tab, want /compact with no space: it takes nothing", got) } if got := view.Choices(); len(got) != 1 { t.Errorf("the popup still lists %d after the word was completed", len(got)) } view.SetInput("") typeText(view, "/w") press(view, tcell.KeyTab) if got := view.Input(); got != "/web " { t.Errorf("Input() = %q after Tab, want /web and a space for the query", got) } if len(view.Choices()) != 0 { t.Error("the popup is still open after the space") } if len(view.Session().Entries()) != 0 { t.Error("Tab sent the prompt") } } func TestEnterCompletesAnUnfinishedCommandAndSendsAFinishedOne(t *testing.T) { view, agent := newView(t, 60, 14) view.SetFocused(true) withCommands(t, view, agent) typeText(view, "/co") press(view, tcell.KeyEnter) if got := view.Input(); got != "/compact" { t.Fatalf("Input() = %q after the first Enter, want the completed command", got) } if len(view.Session().Entries()) != 0 { t.Fatal("the first Enter sent an unfinished command") } press(view, tcell.KeyEnter) if got := view.Input(); got != "" { t.Errorf("Input() = %q after the second Enter, want the box emptied", got) } if got := textOf(view.Session().Entries(), acp.EntryUser); got != "/compact" { t.Errorf("the conversation says %q, want /compact", got) } prompt := agent.read() if prompt["method"] != acp.MethodPrompt { t.Fatalf("the client sent %v, want session/prompt", prompt["method"]) } blocks := promptBlocks(t, prompt) if len(blocks) != 1 || blocks[0]["text"] != "/compact" { t.Errorf("the command went over the wire as %v, want one text block reading /compact", blocks) } } func TestDownMovesTheHighlightAndEscapePutsTheListAway(t *testing.T) { view, agent := newView(t, 60, 14) view.SetFocused(true) withCommands(t, view, agent) typeText(view, "/") press(view, tcell.KeyDown) press(view, tcell.KeyTab) if got := view.Input(); got != "/web " { t.Errorf("Input() = %q after Down and Tab, want the second command", got) } view.SetInput("") typeText(view, "/") if !press(view, tcell.KeyEscape) { t.Fatal("Escape was not claimed with the popup open") } if got := view.Choices(); len(got) != 0 { t.Errorf("Choices() = %v after Escape, want the popup closed", got) } if got := view.Input(); got != "/" { t.Errorf("Escape changed the text to %q", got) } // It stays closed while the text stands still, and reopens once it moves. if got := view.Choices(); len(got) != 0 { t.Errorf("the popup reopened on the next frame: %v", got) } typeText(view, "c") if got := labels(view); len(got) != 1 || got[0] != "/compact" { t.Errorf("Choices() after typing on = %v, want the popup back", got) } } func TestASlashAwayFromTheStartIsJustText(t *testing.T) { view, agent := newView(t, 60, 14) view.SetFocused(true) withCommands(t, view, agent) typeText(view, "run /co") if got := view.Choices(); len(got) != 0 { t.Errorf("Choices() = %v for a slash mid-sentence, want nothing", got) } view.SetInput("") press(view, tcell.KeyEnter) // nothing to send typeText(view, "one") view.HandleKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModAlt)) typeText(view, "/co") if got := view.Choices(); len(got) != 0 { t.Errorf("Choices() = %v for a slash on the second line, want nothing", got) } } func TestWithNoCommandsKnownTabStillSwitchesPanes(t *testing.T) { // The agent has announced nothing, so "/" is a character and Tab keeps // its ordinary meaning — otherwise a picker nobody can see would eat it. view, _ := newView(t, 60, 14) view.SetFocused(true) typeText(view, "/x") if got := view.Choices(); len(got) != 0 { t.Fatalf("Choices() = %v with no commands, want nothing", got) } press(view, tcell.KeyTab) typeText(view, "y") if got := view.Input(); got != "/x" { t.Errorf("Input() = %q; Tab did not move the focus off the box", got) } } func TestTypingAnAtListsTheProjectsFiles(t *testing.T) { view, _ := newView(t, 60, 14) view.SetFocused(true) view.Files = projectFiles typeText(view, "look at @sc") want := []string{"@internal/scanner.go", "@docs/rescan.md"} if got := labels(view); strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("Choices() = %v, want %v", got, want) } joined := strings.Join(draw(t, view, 60, 14), "\n") if !strings.Contains(joined, " files ") || !strings.Contains(joined, "@internal/scanner.go") { t.Errorf("the popup is not titled files, or does not list the file:\n%s", joined) } press(view, tcell.KeyTab) if got := view.Input(); got != "look at @internal/scanner.go " { t.Errorf("Input() = %q after Tab", got) } if len(view.Choices()) != 0 { t.Error("the popup is still open after the mention was completed") } } func TestWithoutAFileListAnAtIsJustACharacter(t *testing.T) { view, _ := newView(t, 60, 14) view.SetFocused(true) typeText(view, "@sc") if got := view.Choices(); len(got) != 0 { t.Errorf("Choices() = %v with no Files, want nothing", got) } } // promptBlocks returns the content blocks of a session/prompt the agent read. func promptBlocks(t *testing.T, prompt map[string]any) []map[string]any { t.Helper() params, _ := prompt["params"].(map[string]any) raw, _ := params["prompt"].([]any) var blocks []map[string]any for _, block := range raw { m, ok := block.(map[string]any) if !ok { t.Fatalf("a prompt block is %T, want an object", block) } blocks = append(blocks, m) } return blocks } // mentioningView returns a view whose session reads files through read, and // whose project is projectFiles. func mentioningView(t *testing.T, read func(string) (string, error)) (*acp.View, *fakeAgent) { t.Helper() session, agent := start(t, acp.Options{ReadTextFile: read}) view := acp.NewView(session) view.Files = projectFiles return view, agent } func TestAMentionIsSentAsTheFilesTextWhenTheAgentEmbedsContext(t *testing.T) { var asked string read := func(path string) (string, error) { asked = path return "package internal\n", nil } view, agent := mentioningView(t, read) agent.handshake() // declares embeddedContext waitFor(t, "ready", view.Session().Ready) view.SetInput("explain @internal/scanner.go please") view.Send() blocks := promptBlocks(t, agent.read()) if len(blocks) != 3 { t.Fatalf("the prompt has %d blocks, want text, resource, text: %v", len(blocks), blocks) } if blocks[0]["type"] != "text" || blocks[0]["text"] != "explain " { t.Errorf("block 0 = %v, want the words before the mention", blocks[0]) } if blocks[2]["type"] != "text" || blocks[2]["text"] != " please" { t.Errorf("block 2 = %v, want the words after it", blocks[2]) } resource, _ := blocks[1]["resource"].(map[string]any) if blocks[1]["type"] != "resource" || resource == nil { t.Fatalf("block 1 = %v, want an embedded resource", blocks[1]) } if resource["uri"] != "file:///src/p/internal/scanner.go" { t.Errorf("uri = %v", resource["uri"]) } if resource["mimeType"] != "text/x-go" { t.Errorf("mimeType = %v, want text/x-go", resource["mimeType"]) } if resource["text"] != "package internal\n" { t.Errorf("text = %q, want what the editor read", resource["text"]) } if asked != "/src/p/internal/scanner.go" { t.Errorf("the editor was asked for %q", asked) } // The conversation keeps what was typed, name and all. if got := textOf(view.Session().Entries(), acp.EntryUser); got != "explain @internal/scanner.go please" { t.Errorf("the conversation says %q", got) } } func TestAMentionIsSentAsALinkWhenTheAgentDoesNotEmbedContext(t *testing.T) { view, agent := mentioningView(t, nil) agent.handshakeWith(`{"promptCapabilities":{"image":false}}`) waitFor(t, "ready", view.Session().Ready) if view.Session().EmbedsContext() { t.Fatal("EmbedsContext() = true for an agent that did not declare it") } view.SetInput("@main.go") view.Send() blocks := promptBlocks(t, agent.read()) if len(blocks) != 1 || blocks[0]["type"] != "resource_link" { t.Fatalf("the prompt is %v, want one resource_link", blocks) } if blocks[0]["uri"] != "file:///src/p/main.go" || blocks[0]["name"] != "main.go" { t.Errorf("the link is %v", blocks[0]) } if _, has := blocks[0]["text"]; has { t.Error("a link carries a text field") } } func TestAFileThatCannotBeReadIsSentAsALinkRatherThanDropped(t *testing.T) { read := func(string) (string, error) { return "", errReadFailed } view, agent := mentioningView(t, read) agent.handshake() waitFor(t, "ready", view.Session().Ready) view.SetInput("@main.go") view.Send() blocks := promptBlocks(t, agent.read()) if len(blocks) != 1 || blocks[0]["type"] != "resource_link" { t.Errorf("the prompt is %v, want the mention as a link", blocks) } } func TestAWordThatNamesNoFileStaysText(t *testing.T) { view, agent := mentioningView(t, nil) agent.handshake() waitFor(t, "ready", view.Session().Ready) // An address is not a file, and main.gopher is not main.go. view.SetInput("mail @bob.example.com about @main.gopher") view.Send() blocks := promptBlocks(t, agent.read()) if len(blocks) != 1 || blocks[0]["type"] != "text" { t.Errorf("the prompt is %v, want one text block", blocks) } } // errReadFailed stands in for a file the editor could not read. var errReadFailed = errString("cannot read") type errString string func (e errString) Error() string { return string(e) }