package app import ( "os" "path/filepath" "strings" "testing" "rickub.com/turbo-editors/turbo-core/acp" "rickub.com/turbo-editors/turbo-core/ui" ) // newAgentsApp returns an editor whose project holds the given acp.toml, and // whose user-level one is empty. func newAgentsApp(t *testing.T, contents string) (*App, string) { t.Helper() a, root := newProjectApp(t) t.Setenv(testProfile().DirEnvVar(), t.TempDir()) if contents != "" { path := acp.ProjectPath(testProfile(), root) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("making %s: %v", filepath.Dir(path), err) } if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { t.Fatalf("writing %s: %v", path, err) } } return a, root } // agentMenuLabels returns what the Agent menu offers. func agentMenuLabels(a *App) []string { var out []string for _, item := range a.agentItems() { if item.Separator { continue } out = append(out, ui.PlainLabel(item.Label)) } return out } func TestTheAgentMenuIsOnTheBarWithNoAgentsConfigured(t *testing.T) { // It has to be: Create agents file is reachable from nowhere else. a, _ := newAgentsApp(t, "") found := false for _, menu := range a.menu.Menus() { if ui.PlainLabel(menu.Label) == "Agent" { found = true } } if !found { t.Fatalf("the bar is %v, with no Agent menu", barLabels(a)) } labels := agentMenuLabels(a) if len(labels) == 0 || !strings.Contains(labels[0], "no agents") { t.Errorf("the menu offers %v, want it to say there are none", labels) } if !strings.Contains(strings.Join(labels, " "), "Create agents file") { t.Errorf("the menu offers %v, want Create agents file in it", labels) } } func TestTheAgentMenuListsEachConfiguredAgent(t *testing.T) { a, _ := newAgentsApp(t, "[[agent]]\nname=\"Bob\"\ncommand=\"cat\"\n\n[[agent]]\nname=\"Alice\"\ncommand=\"cat\"\n") labels := agentMenuLabels(a) joined := strings.Join(labels, " | ") if !strings.Contains(joined, "Bob") || !strings.Contains(joined, "Alice") { t.Errorf("the menu offers %v, want both agents", labels) } if labels[0] != "Bob" { t.Errorf("the menu starts with %q, want the file's own order", labels[0]) } } func TestABrokenAgentsFileShowsAsAGreyedLineRatherThanAnEmptyMenu(t *testing.T) { // A silent drop looks exactly like having no agents configured. a, _ := newAgentsApp(t, "[[agent]\nbroken\n") labels := agentMenuLabels(a) if len(labels) == 0 || !strings.Contains(labels[0], "error") { t.Errorf("the menu offers %v, want it to say the file is broken", labels) } } func TestTheAgentMenuIsRebuiltFromTheFileEachTimeItOpens(t *testing.T) { // Editing acp.toml must never need a restart, which is the whole reason // the menu has an OnOpen. a, root := newAgentsApp(t, "[[agent]]\nname=\"Bob\"\ncommand=\"cat\"\n") path := acp.ProjectPath(testProfile(), root) if err := os.WriteFile(path, []byte("[[agent]]\nname=\"Carol\"\ncommand=\"cat\"\n"), 0o644); err != nil { t.Fatalf("rewriting the file: %v", err) } if got := strings.Join(agentMenuLabels(a), " "); !strings.Contains(got, "Carol") { t.Errorf("the menu offers %v after the file changed", got) } } func TestCreateAgentsFileWritesItAndOpensIt(t *testing.T) { a, root := newAgentsApp(t, "") a.CreateAgentsFile() path := acp.ProjectPath(testProfile(), root) if _, err := os.Stat(path); err != nil { t.Fatalf("the file was not written: %v", err) } if a.desktop.Active() == nil { t.Fatal("the created file was not opened") } if got := a.desktop.Active().Title(); !strings.Contains(got, acp.FileName) { t.Errorf("the window in front is %q, want the agents file", got) } if a.hasNoAgentsFile() { t.Error("hasNoAgentsFile() is still true after one was created") } } func TestTheCreateItemIsGreyedOutOnceThereIsAFile(t *testing.T) { a, _ := newAgentsApp(t, "[[agent]]\nname=\"Bob\"\ncommand=\"cat\"\n") for _, item := range a.agentItems() { if ui.PlainLabel(item.Label) != "Create agents file" { continue } if item.Enabled == nil || item.Enabled() { t.Error("Create agents file is offered although the project has one") } return } t.Fatal("the menu has no Create agents file item") } func TestAgentStatusNamesBothFilesAndEveryAgent(t *testing.T) { // This is the answer to "why will my agent not start", so it has to say // what was read and what the command line actually came out as. a, root := newAgentsApp(t, "[[agent]]\nname=\"Bob\"\ncommand=\"docker\"\nargs=[\"agent\",\"serve\",\"acp\"]\n") report := a.agentReport() for _, want := range []string{ acp.ProjectPath(testProfile(), root), acp.UserPath(testProfile()), "Bob", "docker agent serve acp", } { if !strings.Contains(report, want) { t.Errorf("the status never mentions %q:\n%s", want, report) } } } func TestAgentStatusSaysWhyABrokenFileIsBroken(t *testing.T) { a, _ := newAgentsApp(t, "[[agent]]\nname=\"Bob\"\n") if report := a.agentReport(); !strings.Contains(report, "has no command") { t.Errorf("the status does not say what is wrong:\n%s", report) } } func TestOpeningAnAgentThatIsNotConfiguredSaysSo(t *testing.T) { a, _ := newAgentsApp(t, "") a.NewAgent("Nobody") if a.Modals() == 0 { t.Fatal("nothing was said about an agent that does not exist") } if len(a.agents) != 0 { t.Errorf("a window was opened for an agent that does not exist") } } func TestAnAgentWindowOpensAndClosesWithoutAskingAnything(t *testing.T) { // `cat` is an agent that never answers the handshake, which is exactly the // case that must not hang the editor: the window opens, says it is // starting, and closes cleanly. a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") a.NewAgent("Echo") if len(a.agents) != 1 { t.Fatalf("%d agent windows are open, want 1", len(a.agents)) } window := a.desktop.Active() if window == nil { t.Fatal("no window came to the front") } if !a.isAgentWindow(window) { t.Error("the window is not recognised as an agent's") } if got := window.Title(); !strings.Contains(got, "Echo") { t.Errorf("the window is called %q", got) } // Closing asks nothing: a conversation is a running process, not unsaved // work — the same bargain a terminal makes. a.CloseFile() if a.Modals() != 0 { t.Error("closing an agent window asked a question") } if len(a.agents) != 0 { t.Errorf("%d agent windows are still open", len(a.agents)) } } func TestLeavingTheEditorStopsEveryAgent(t *testing.T) { a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") a.NewAgent("Echo") a.NewAgent("Echo") if len(a.agents) != 2 { t.Fatalf("%d windows are open, want two independent conversations", len(a.agents)) } a.closeAgents() if len(a.agents) != 0 { t.Errorf("%d agents survived the editor", len(a.agents)) } } func TestAnAgentWindowIsNotTakenForAFile(t *testing.T) { // The file actions must leave it alone: ActiveView is what they all go // through, and a conversation has no buffer. a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") a.NewAgent("Echo") if a.ActiveView() != nil { t.Error("an agent window was taken for an editing one") } a.SaveFile() // must not panic, and must not open anything if a.Modals() != 0 { t.Error("saving with an agent window in front opened a dialog") } }