okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: tree and context inspector on listView, shared confirm for agent delete78011da

main
12.8 KiB329 linesraw
1// Invariants the e2e suite cannot reach, because each lives inside the
2// tui package or needs a failure the daemon never produces:
3//
4//   - frame pacing: an idle graph must not keep scheduling animation frames,
5//     a running one must resume them exactly once (wasted CPU, double speed);
6//   - normalized replacement windows must survive interleaved calls without
7//     settling another call's live preview into history;
8//   - a pending delete confirm must not outlive the agent it names, and a
9//     stray key must neither delete nor keep it by accident (the confirm
10//     answers enter and esc only);
11//   - a failed history seed must retry on the next selection, and a late
12//     error from an older generation must not unseed the live node;
13//   - overflow during a blocked mutation must preserve its later outcome and draft.
14package tui
15
16import (
17	"encoding/json"
18	"errors"
19	"net/http"
20	"net/http/httptest"
21	"net/url"
22	"strconv"
23	"strings"
24	"testing"
25	"time"
26
27	"albedo/cli/internal/daemon"
28
29	tea "charm.land/bubbletea/v2"
30
31	"github.com/charmbracelet/x/ansi"
32)
33
34func agentsFixture(t *testing.T) AgentsViewModel {
35	t.Helper()
36	m := NewAgentsViewModel(nil, "lead")
37	m.Gen = 1
38	m.streamReady = true
39	m.SetSize(120, 30)
40	lead := "lead"
41	coder := "coder"
42	m, _ = m.Update(agentsSnapshotMsg{Gen: 1, Root: "lead", Nodes: []daemon.AgentNode{
43		{Session: daemon.Session{ID: "lead", Title: "lead", Model: "gpt-6-luna"}, Name: "lead", Running: true},
44		{Session: daemon.Session{ID: "scout", Model: "gpt-6-luna"}, Parent: &lead, Name: "scout", Depth: 1},
45		{Session: daemon.Session{ID: "coder", Model: "gpt-6-luna"}, Parent: &lead, Name: "coder", Depth: 1, Running: true},
46		{Session: daemon.Session{ID: "tests", Model: "gpt-6-luna"}, Parent: &coder, Name: "tests", Depth: 2},
47	}})
48	return m
49}
50
51// frames runs cmd and counts the frame ticks it schedules.
52func frames(cmd tea.Cmd) int {
53	if cmd == nil {
54		return 0
55	}
56	switch msg := cmd().(type) {
57	case tea.BatchMsg:
58		n := 0
59		for _, c := range msg {
60			n += frames(c)
61		}
62		return n
63	case agentsFrameMsg:
64		return 1
65	}
66	return 0
67}
68
69func TestAgentsFramesStopWhenStillAndResumeOnce(t *testing.T) {
70	m := agentsFixture(t)
71	closed := make(chan tea.Msg)
72	close(closed)
73	m.events = closed
74	running := func(id string, on bool) agentsEventsMsg {
75		return agentsEventsMsg{Gen: 1, Events: []daemon.AgentEvent{{Type: "running", Session: id, Running: on}}}
76	}
77	m, _ = m.Update(running("lead", false))
78	m, _ = m.Update(running("coder", false))
79	m, cmd := m.Update(agentsFrameMsg{Gen: 1})
80	if n := frames(cmd); n != 0 {
81		t.Fatalf("a still graph scheduled %d frames, want none", n)
82	}
83	m, cmd = m.Update(running("coder", true))
84	if n := frames(cmd); n != 1 {
85		t.Fatalf("a running agent scheduled %d frames, want one", n)
86	}
87	_, cmd = m.Update(running("lead", true))
88	if n := frames(cmd); n != 0 {
89		t.Fatalf("a second runner scheduled %d more frames, want none", n)
90	}
91}
92
93func TestAgentsProgressResultKeepsOtherInterleavedCall(t *testing.T) {
94	m := agentsFixture(t)
95	m.selected = "coder"
96	update := func(event daemon.AgentEvent) {
97		m, _ = m.Update(agentsEventsMsg{Gen: 1, Events: []daemon.AgentEvent{event}})
98	}
99	callA := &daemon.ToolProgress{CallID: "run:1:0", ToolCallID: "native-a", Name: "python", Phase: "generating", Code: &daemon.ToolCodePreview{Text: "print('a')"}}
100	callB := &daemon.ToolProgress{CallID: "run:1:1", ToolCallID: "native-b", Name: "python", Phase: "generating", Code: &daemon.ToolCodePreview{Text: "print('b')"}}
101	update(daemon.AgentEvent{Type: "tool_progress", Session: "coder", Progress: callA})
102	update(daemon.AgentEvent{Type: "tool_progress", Session: "coder", Progress: callB})
103	update(daemon.AgentEvent{Type: "tool_progress", Session: "coder", Progress: &daemon.ToolProgress{
104		CallID: callA.CallID, ToolCallID: callA.ToolCallID, Name: "python", Phase: "running",
105	}})
106	update(daemon.AgentEvent{Type: "tool_progress", Session: "coder", Progress: &daemon.ToolProgress{
107		CallID: callB.CallID, ToolCallID: callB.ToolCallID, Name: "python", Phase: "generating", Code: &daemon.ToolCodePreview{Offset: 4, Text: "print('b2')"},
108	}})
109	update(daemon.AgentEvent{Type: "tool", Session: "coder", CallID: "native-a", ProgressCallID: callA.CallID, Name: "python", Output: "done"})
110	got := ansi.Strip(m.View())
111	if strings.Count(got, "print('b2')") != 1 || strings.Contains(got, "print('b')") || !strings.Contains(got, "done") {
112		t.Fatalf("call A's result lost, appended, or duplicated call B's replacement window: %q", got)
113	}
114}
115
116func TestAgentsDeleteConfirmDropsWithTheAgent(t *testing.T) {
117	m := agentsFixture(t)
118	m.selected = "coder"
119	m, _ = m.key(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl})
120	if m.confirm.target != "coder" {
121		t.Fatal("ctrl+x should ask before deleting coder")
122	}
123	m, _ = m.Update(agentsEventsMsg{Gen: 1, Events: []daemon.AgentEvent{
124		{Type: "gone", Session: "coder"},
125	}})
126	if m.confirm.asking() {
127		t.Fatalf("the confirm prompt outlived the agent it named:\n%s", ansi.Strip(m.View()))
128	}
129	if strings.Contains(ansi.Strip(m.View()), "Delete coder") {
130		t.Fatal("the view still offers to delete a vanished agent")
131	}
132	m.selected = "tests"
133	m, _ = m.key(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl})
134	if m.confirm.target != "tests" {
135		t.Fatal("ctrl+x stopped working after the prompt was dropped")
136	}
137}
138
139// A seed that fails must not lock the node out of history: the next selection
140// retries, and only the failure's own generation may unseed the node.
141func TestFailedSeedRetriesOnTheNextSelection(t *testing.T) {
142	var calls int
143	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144		calls++
145		if calls == 1 {
146			http.Error(w, `{"detail":"history is gone"}`, http.StatusInternalServerError)
147			return
148		}
149		session := protocolSession("coder", generationA, 0)
150		session["preview"].(map[string]any)["transcript_count"] = 2
151		session["history"].(map[string]any)["items"] = []any{protocolEntry("q", "user", "fan out three scouts", 1), protocolEntry("a", "assistant", "sent them off", 2)}
152		_ = json.NewEncoder(w).Encode(session)
153	}))
154	defer server.Close()
155	address, _ := url.Parse(server.URL)
156	port, _ := strconv.Atoi(address.Port())
157
158	m := agentsFixture(t)
159	m.Conn = daemon.NewConnection(daemon.ConnectionSnapshot{Port: port, Token: "t", Version: daemon.ProtocolVersion}, nil)
160	m.selected = "coder"
161
162	cmd := m.seedCmd()
163	if cmd == nil {
164		t.Fatal("an unseeded agent should ask for history")
165	}
166	msg := cmd()
167	failure, ok := msg.(agentsSeedErrMsg)
168	if !ok || failure.Err == nil {
169		t.Fatalf("the failed request should report itself, got %T", msg)
170	}
171	m, _ = m.Update(failure)
172	if m.nodes["coder"].seeded {
173		t.Fatal("a failed seed left the node marked seeded")
174	}
175	if !strings.Contains(m.notice, failure.Err.Error()) {
176		t.Fatalf("the notice should carry the failure, got %q", m.notice)
177	}
178
179	cmd = m.seedCmd()
180	if cmd == nil {
181		t.Fatal("the next selection should retry after a failed seed")
182	}
183	msg = cmd()
184	seed, ok := msg.(agentsSeedMsg)
185	if !ok {
186		t.Fatalf("the retry should answer with history, got %T", msg)
187	}
188	m, _ = m.Update(seed)
189	for _, want := range []string{"← fan out three scouts", "sent them off"} {
190		if !strings.Contains(ansi.Strip(m.View()), want) {
191			t.Fatalf("the retried seed should show %q in the tail:\n%s", want, ansi.Strip(m.View()))
192		}
193	}
194
195	// An error from an earlier generation may not unseed the live node.
196	m, _ = m.Update(agentsSeedErrMsg{Gen: m.Gen - 1, ID: "coder", Err: errors.New("late")})
197	if !m.nodes["coder"].seeded {
198		t.Fatal("a stale seed error unseeded the live node")
199	}
200	if cmd := m.seedCmd(); cmd != nil {
201		t.Fatal("a seeded node should not fetch again")
202	}
203}
204
205func TestAgentsOverflowPreservesPendingOperationOutcomeAndDraft(t *testing.T) {
206	started, release := make(chan struct{}), make(chan struct{})
207	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
208		if r.URL.Path == "/sessions" && r.Header.Get("Accept") == "text/event-stream" {
209			<-r.Context().Done()
210			return
211		}
212		if r.Method != http.MethodPut || !strings.HasPrefix(r.URL.Path, "/sessions/") {
213			t.Errorf("unexpected request: %s", r.URL.Path)
214			w.WriteHeader(404)
215			return
216		}
217		close(started)
218		<-release
219		w.WriteHeader(http.StatusForbidden)
220		_, _ = w.Write([]byte(`{"detail":"spawn refused"}`))
221	}))
222	defer server.Close()
223	defer func() {
224		select {
225		case <-release:
226		default:
227			close(release)
228		}
229	}()
230	address, _ := url.Parse(server.URL)
231	port, _ := strconv.Atoi(address.Port())
232	m := agentsFixture(t)
233	m.Conn = daemon.NewConnection(daemon.ConnectionSnapshot{Port: port}, nil)
234	for _, char := range "/spawn helper inspect files" {
235		m, _ = m.Update(tea.KeyPressMsg{Code: char, Text: string(char)})
236	}
237	var cmd tea.Cmd
238	m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
239	finished := make(chan tea.Msg, 1)
240	go func() { finished <- cmd() }()
241	select {
242	case <-started:
243	case <-time.After(5 * time.Second):
244		t.Fatal("spawn never reached server")
245	}
246	for _, char := range "next draft" {
247		m, _ = m.Update(tea.KeyPressMsg{Code: char, Text: string(char)})
248	}
249	m, _ = m.Update(agentsEventsMsg{Gen: m.Gen, Events: []daemon.AgentEvent{{Type: "overflow"}}})
250	defer func() { m.Close() }()
251	close(release)
252	select {
253	case outcome := <-finished:
254		m, _ = m.Update(outcome)
255	case <-time.After(5 * time.Second):
256		t.Fatal("spawn did not complete")
257	}
258	rendered := ansi.Strip(m.View())
259	if !strings.Contains(rendered, "spawn refused") || !strings.Contains(rendered, "next draft") {
260		t.Fatalf("overflow lost pending outcome or draft: %s", rendered)
261	}
262}
263
264func TestAgentsClosedViewRetainsInFlightOutcomeAndUnknownDecision(t *testing.T) {
265	m := agentsFixture(t)
266	_ = m.sendCmd("scout", "retained draft")
267	if len(m.pendingOperations) != 1 {
268		t.Fatal("admission was not retained before dispatch")
269	}
270	var pending agentPendingOperation
271	for _, value := range m.pendingOperations {
272		pending = value
273	}
274	gen := m.viewGen
275	m.Close()
276	m, _ = m.Update(agentsSentMsg{Gen: gen, Handle: pending.Handle, Draft: pending.Draft, Action: pending.Action, Err: &daemon.UncertainOutcomeError{Operation: "submit", Cause: errors.New("lost response")}})
277	m, _ = m.Update(agentsSentMsg{Gen: m.viewGen, Handle: pending.Handle, Draft: pending.Draft, Action: pending.Action, Recovery: true, Err: &daemon.APIError{StatusCode: 404, Code: "not_found"}})
278	if retained, ok := m.pendingOperations[pending.Handle.ID()]; !ok || retained.InFlight || m.input.Value() != "" {
279		t.Fatalf("unknown retained outcome restored a replayable draft: %#v %q", m.pendingOperations, m.input.Value())
280	}
281	m, _ = m.Update(agentsSentMsg{Gen: m.viewGen, Handle: pending.Handle, Notice: "confirmed"})
282	if len(m.pendingOperations) != 0 {
283		t.Fatal("confirmed request remained unresolved")
284	}
285}
286
287func TestAgentsTerminalEventsCannotReviveObsoleteCode(t *testing.T) {
288	for _, kind := range []string{"error", "interrupted", "closed"} {
289		t.Run(kind, func(t *testing.T) {
290			m := agentsFixture(t)
291			m.selected = "coder"
292			update := func(events ...daemon.AgentEvent) {
293				m, _ = m.Update(agentsEventsMsg{Gen: 1, Events: events})
294			}
295			update(daemon.AgentEvent{Type: "tool_progress", Session: "coder", Progress: &daemon.ToolProgress{CallID: "old", Name: "python", Phase: "generating", Code: &daemon.ToolCodePreview{Text: "obsolete_code_sentinel"}}})
296			update(daemon.AgentEvent{Type: kind, Session: "coder", Text: "failed"})
297			update(daemon.AgentEvent{Type: "tool_progress", Session: "coder", Progress: &daemon.ToolProgress{CallID: "fresh", Name: "python", Phase: "generating", Code: &daemon.ToolCodePreview{Text: "fresh_code_sentinel"}}})
298			update(daemon.AgentEvent{Type: "tool", Session: "coder", ProgressCallID: "fresh", Name: "python", Output: "finished"})
299			got := ansi.Strip(m.View())
300			if strings.Count(got, "obsolete_code_sentinel") > 1 || !strings.Contains(got, "fresh_code_sentinel") || !strings.Contains(got, "finished") {
301				t.Fatalf("%s revived obsolete code or lost the fresh result: %q", kind, got)
302			}
303		})
304	}
305}
306
307// Delete is enter and esc only: a stray key that deleted, or cancelled, on
308// the way past would lose or keep an agent without the question being read.
309func TestAgentsDeleteAsksWithEnterAndEsc(t *testing.T) {
310	m := agentsFixture(t)
311	m.selected = "coder"
312	m, _ = m.key(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl})
313	if !strings.Contains(ansi.Strip(m.View()), "Delete coder and the agent below it?") || !strings.Contains(ansi.Strip(m.View()), "esc cancel") {
314		t.Fatalf("ctrl+x should show the shared confirm:\n%s", ansi.Strip(m.View()))
315	}
316	m, cmd := m.key(tea.KeyPressMsg{Code: 'y', Text: "y"})
317	if cmd != nil || m.confirm.target != "coder" {
318		t.Fatal("a stray key answered the delete question")
319	}
320	m, cmd = m.key(tea.KeyPressMsg{Code: tea.KeyEscape})
321	if cmd != nil || m.confirm.asking() || !strings.Contains(m.notice, "kept") {
322		t.Fatalf("esc should keep the agent: notice %q", m.notice)
323	}
324	m, _ = m.key(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl})
325	m, cmd = m.key(tea.KeyPressMsg{Code: tea.KeyEnter})
326	if cmd == nil || m.confirm.asking() {
327		t.Fatal("enter should delete the agent the question named")
328	}
329}