| 🛟 Updated. 28d5985 k33g 20h ago | 1 | package app |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 📦 Turbo Core f3ade8d k33g 13h ago | 9 | "rickub.com/turbo-editors/turbo-core/acp" |
| 10 | "rickub.com/turbo-editors/turbo-core/ui" |
| 🛟 Updated. 28d5985 k33g 20h ago | 11 | ) |
| 12 | |
| 13 | // newAgentsApp returns an editor whose project holds the given acp.toml, and |
| 14 | // whose user-level one is empty. |
| 15 | func newAgentsApp(t *testing.T, contents string) (*App, string) { |
| 16 | t.Helper() |
| 17 | |
| 18 | a, root := newProjectApp(t) |
| 19 | t.Setenv(testProfile().DirEnvVar(), t.TempDir()) |
| 20 | |
| 21 | if contents != "" { |
| 22 | path := acp.ProjectPath(testProfile(), root) |
| 23 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 24 | t.Fatalf("making %s: %v", filepath.Dir(path), err) |
| 25 | } |
| 26 | if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { |
| 27 | t.Fatalf("writing %s: %v", path, err) |
| 28 | } |
| 29 | } |
| 30 | return a, root |
| 31 | } |
| 32 | |
| 33 | // agentMenuLabels returns what the Agent menu offers. |
| 34 | func agentMenuLabels(a *App) []string { |
| 35 | var out []string |
| 36 | for _, item := range a.agentItems() { |
| 37 | if item.Separator { |
| 38 | continue |
| 39 | } |
| 40 | out = append(out, ui.PlainLabel(item.Label)) |
| 41 | } |
| 42 | return out |
| 43 | } |
| 44 | |
| 45 | func TestTheAgentMenuIsOnTheBarWithNoAgentsConfigured(t *testing.T) { |
| 46 | // It has to be: Create agents file is reachable from nowhere else. |
| 47 | a, _ := newAgentsApp(t, "") |
| 48 | |
| 49 | found := false |
| 50 | for _, menu := range a.menu.Menus() { |
| 51 | if ui.PlainLabel(menu.Label) == "Agent" { |
| 52 | found = true |
| 53 | } |
| 54 | } |
| 55 | if !found { |
| 56 | t.Fatalf("the bar is %v, with no Agent menu", barLabels(a)) |
| 57 | } |
| 58 | |
| 59 | labels := agentMenuLabels(a) |
| 60 | if len(labels) == 0 || !strings.Contains(labels[0], "no agents") { |
| 61 | t.Errorf("the menu offers %v, want it to say there are none", labels) |
| 62 | } |
| 63 | if !strings.Contains(strings.Join(labels, " "), "Create agents file") { |
| 64 | t.Errorf("the menu offers %v, want Create agents file in it", labels) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestTheAgentMenuListsEachConfiguredAgent(t *testing.T) { |
| 69 | a, _ := newAgentsApp(t, "[[agent]]\nname=\"Bob\"\ncommand=\"cat\"\n\n[[agent]]\nname=\"Alice\"\ncommand=\"cat\"\n") |
| 70 | |
| 71 | labels := agentMenuLabels(a) |
| 72 | joined := strings.Join(labels, " | ") |
| 73 | if !strings.Contains(joined, "Bob") || !strings.Contains(joined, "Alice") { |
| 74 | t.Errorf("the menu offers %v, want both agents", labels) |
| 75 | } |
| 76 | if labels[0] != "Bob" { |
| 77 | t.Errorf("the menu starts with %q, want the file's own order", labels[0]) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | func TestABrokenAgentsFileShowsAsAGreyedLineRatherThanAnEmptyMenu(t *testing.T) { |
| 82 | // A silent drop looks exactly like having no agents configured. |
| 83 | a, _ := newAgentsApp(t, "[[agent]\nbroken\n") |
| 84 | |
| 85 | labels := agentMenuLabels(a) |
| 86 | if len(labels) == 0 || !strings.Contains(labels[0], "error") { |
| 87 | t.Errorf("the menu offers %v, want it to say the file is broken", labels) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func TestTheAgentMenuIsRebuiltFromTheFileEachTimeItOpens(t *testing.T) { |
| 92 | // Editing acp.toml must never need a restart, which is the whole reason |
| 93 | // the menu has an OnOpen. |
| 94 | a, root := newAgentsApp(t, "[[agent]]\nname=\"Bob\"\ncommand=\"cat\"\n") |
| 95 | |
| 96 | path := acp.ProjectPath(testProfile(), root) |
| 97 | if err := os.WriteFile(path, []byte("[[agent]]\nname=\"Carol\"\ncommand=\"cat\"\n"), 0o644); err != nil { |
| 98 | t.Fatalf("rewriting the file: %v", err) |
| 99 | } |
| 100 | |
| 101 | if got := strings.Join(agentMenuLabels(a), " "); !strings.Contains(got, "Carol") { |
| 102 | t.Errorf("the menu offers %v after the file changed", got) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestCreateAgentsFileWritesItAndOpensIt(t *testing.T) { |
| 107 | a, root := newAgentsApp(t, "") |
| 108 | |
| 109 | a.CreateAgentsFile() |
| 110 | |
| 111 | path := acp.ProjectPath(testProfile(), root) |
| 112 | if _, err := os.Stat(path); err != nil { |
| 113 | t.Fatalf("the file was not written: %v", err) |
| 114 | } |
| 115 | if a.desktop.Active() == nil { |
| 116 | t.Fatal("the created file was not opened") |
| 117 | } |
| 118 | if got := a.desktop.Active().Title(); !strings.Contains(got, acp.FileName) { |
| 119 | t.Errorf("the window in front is %q, want the agents file", got) |
| 120 | } |
| 121 | if a.hasNoAgentsFile() { |
| 122 | t.Error("hasNoAgentsFile() is still true after one was created") |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestTheCreateItemIsGreyedOutOnceThereIsAFile(t *testing.T) { |
| 127 | a, _ := newAgentsApp(t, "[[agent]]\nname=\"Bob\"\ncommand=\"cat\"\n") |
| 128 | |
| 129 | for _, item := range a.agentItems() { |
| 130 | if ui.PlainLabel(item.Label) != "Create agents file" { |
| 131 | continue |
| 132 | } |
| 133 | if item.Enabled == nil || item.Enabled() { |
| 134 | t.Error("Create agents file is offered although the project has one") |
| 135 | } |
| 136 | return |
| 137 | } |
| 138 | t.Fatal("the menu has no Create agents file item") |
| 139 | } |
| 140 | |
| 141 | func TestAgentStatusNamesBothFilesAndEveryAgent(t *testing.T) { |
| 142 | // This is the answer to "why will my agent not start", so it has to say |
| 143 | // what was read and what the command line actually came out as. |
| 144 | a, root := newAgentsApp(t, "[[agent]]\nname=\"Bob\"\ncommand=\"docker\"\nargs=[\"agent\",\"serve\",\"acp\"]\n") |
| 145 | |
| 146 | report := a.agentReport() |
| 147 | for _, want := range []string{ |
| 148 | acp.ProjectPath(testProfile(), root), |
| 149 | acp.UserPath(testProfile()), |
| 150 | "Bob", |
| 151 | "docker agent serve acp", |
| 152 | } { |
| 153 | if !strings.Contains(report, want) { |
| 154 | t.Errorf("the status never mentions %q:\n%s", want, report) |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestAgentStatusSaysWhyABrokenFileIsBroken(t *testing.T) { |
| 160 | a, _ := newAgentsApp(t, "[[agent]]\nname=\"Bob\"\n") |
| 161 | |
| 162 | if report := a.agentReport(); !strings.Contains(report, "has no command") { |
| 163 | t.Errorf("the status does not say what is wrong:\n%s", report) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | func TestOpeningAnAgentThatIsNotConfiguredSaysSo(t *testing.T) { |
| 168 | a, _ := newAgentsApp(t, "") |
| 169 | |
| 170 | a.NewAgent("Nobody") |
| 171 | |
| 172 | if a.Modals() == 0 { |
| 173 | t.Fatal("nothing was said about an agent that does not exist") |
| 174 | } |
| 175 | if len(a.agents) != 0 { |
| 176 | t.Errorf("a window was opened for an agent that does not exist") |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func TestAnAgentWindowOpensAndClosesWithoutAskingAnything(t *testing.T) { |
| 181 | // `cat` is an agent that never answers the handshake, which is exactly the |
| 182 | // case that must not hang the editor: the window opens, says it is |
| 183 | // starting, and closes cleanly. |
| 184 | a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") |
| 185 | |
| 186 | a.NewAgent("Echo") |
| 187 | |
| 188 | if len(a.agents) != 1 { |
| 189 | t.Fatalf("%d agent windows are open, want 1", len(a.agents)) |
| 190 | } |
| 191 | window := a.desktop.Active() |
| 192 | if window == nil { |
| 193 | t.Fatal("no window came to the front") |
| 194 | } |
| 195 | if !a.isAgentWindow(window) { |
| 196 | t.Error("the window is not recognised as an agent's") |
| 197 | } |
| 198 | if got := window.Title(); !strings.Contains(got, "Echo") { |
| 199 | t.Errorf("the window is called %q", got) |
| 200 | } |
| 201 | |
| 202 | // Closing asks nothing: a conversation is a running process, not unsaved |
| 203 | // work — the same bargain a terminal makes. |
| 204 | a.CloseFile() |
| 205 | |
| 206 | if a.Modals() != 0 { |
| 207 | t.Error("closing an agent window asked a question") |
| 208 | } |
| 209 | if len(a.agents) != 0 { |
| 210 | t.Errorf("%d agent windows are still open", len(a.agents)) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestLeavingTheEditorStopsEveryAgent(t *testing.T) { |
| 215 | a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") |
| 216 | |
| 217 | a.NewAgent("Echo") |
| 218 | a.NewAgent("Echo") |
| 219 | if len(a.agents) != 2 { |
| 220 | t.Fatalf("%d windows are open, want two independent conversations", len(a.agents)) |
| 221 | } |
| 222 | |
| 223 | a.closeAgents() |
| 224 | |
| 225 | if len(a.agents) != 0 { |
| 226 | t.Errorf("%d agents survived the editor", len(a.agents)) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | func TestAnAgentWindowIsNotTakenForAFile(t *testing.T) { |
| 231 | // The file actions must leave it alone: ActiveView is what they all go |
| 232 | // through, and a conversation has no buffer. |
| 233 | a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") |
| 234 | |
| 235 | a.NewAgent("Echo") |
| 236 | |
| 237 | if a.ActiveView() != nil { |
| 238 | t.Error("an agent window was taken for an editing one") |
| 239 | } |
| 240 | a.SaveFile() // must not panic, and must not open anything |
| 241 | if a.Modals() != 0 { |
| 242 | t.Error("saving with an agent window in front opened a dialog") |
| 243 | } |
| 244 | } |