okami.mom/albedo

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

nekomimi.petfeat: background cells, jobs page, and session list fixesf65a648

main
11.1 KiB219 linesraw
1// Action-row lifetime across streaming, replay, and turn completion is not exercised by daemon E2E.
2package tui
3
4import (
5	"strings"
6	"testing"
7
8	"albedo/cli/internal/daemon"
9	"albedo/cli/internal/daemon/protocol"
10	tea "charm.land/bubbletea/v2"
11	"github.com/charmbracelet/x/ansi"
12)
13
14func TestGroupedActionRowHoldsUntilTheNextAction(t *testing.T) {
15	m := newTestChatModel(t, &daemon.Session{ID: "s"})
16	m.SetSize(100, 30)
17	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventUser, Text: "check it"})
18	progress := func(target string) {
19		m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress,
20			Progress: &daemon.ToolProgress{CallID: "progress-" + target, Name: "python", Phase: "running"}})
21	}
22	finish := func(kind, target string) {
23		m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventTool, ToolName: "python",
24			ProgressCallID: "progress-" + target,
25			ToolResult:     `{"status":"ok"}`, ToolTrace: &daemon.ToolTrace{Activities: []daemon.ToolActivity{
26				{Kind: kind, Target: target},
27			}}})
28	}
29	visible := func() string {
30		m.refreshViewportContent()
31		return ansi.Strip(m.Viewport.View())
32	}
33	occupied := func() int {
34		n := 0
35		for row := range strings.SplitSeq(visible(), "\n") {
36			if strings.TrimSpace(row) != "" {
37				n++
38			}
39		}
40		return n
41	}
42
43	progress("first.go")
44	finish("read", "first.go")
45	if got := visible(); !strings.Contains(got, "read first.go") {
46		t.Fatalf("completed read vanished: %q", got)
47	}
48
49	progress("go test")
50	before := occupied()
51	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress, Progress: nil})
52	if got := visible(); strings.Contains(got, "running python") {
53		t.Fatalf("clear progress retained the live action row: %q", got)
54	}
55	finish("run", "go test")
56	if got := visible(); !strings.Contains(got, "ran go test") || strings.Contains(got, "running python") || occupied() != before {
57		t.Fatalf("result removed the action row or changed its height: %q, before %d after %d", got, before, occupied())
58	}
59
60	progress("next.go")
61	if got := visible(); !strings.Contains(got, "running python") {
62		t.Fatalf("the new action did not replace the last: %q", got)
63	}
64	finish("read", "next.go")
65	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventThinking, Text: "**Planning the next step**\nmore thought"})
66	thinking := occupied()
67	if got := visible(); !strings.Contains(got, "more thought…") || strings.Contains(got, "Planning the next step") || strings.Contains(got, "running python") {
68		t.Fatalf("thinking did not replace the tool action with its newest line: %q", got)
69	}
70	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventUsage})
71	if got := visible(); !strings.Contains(got, "more thought") || strings.Contains(got, "more thought…") || occupied() != thinking {
72		t.Fatalf("finished thought removed its newest line: %q, before %d after %d", got, thinking, occupied())
73	}
74	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventText, Text: "here's why"})
75	if got := visible(); strings.Contains(got, "more thought") || !strings.Contains(got, "here's why") {
76		t.Fatalf("prose did not replace the thought action: %q", got)
77	}
78}
79
80func TestChatToolResultKeepsOtherInterleavedProgress(t *testing.T) {
81	m := newTestChatModel(t, &daemon.Session{ID: "s"})
82	callA := &daemon.ToolProgress{CallID: "run:1:0", ToolCallID: "native-a", Name: "python", Phase: "running"}
83	callB := &daemon.ToolProgress{CallID: "run:1:1", ToolCallID: "native-b", Name: "python", Phase: "generating", Code: &daemon.ToolCodePreview{Text: "print('b')"}}
84	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress, Progress: callA})
85	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress, Progress: callB})
86	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventTool, ProgressCallID: callA.CallID, ToolName: "python", ToolResult: "done"})
87	m.SetSize(100, 30)
88	m.refreshViewportContent()
89	if got := ansi.Strip(m.Viewport.View()); !strings.Contains(got, "generating python") || !strings.Contains(got, "print('b')") {
90		t.Fatalf("call A's result cleared call B's visible activity: %q", got)
91	}
92}
93
94func TestFinishedActionDoesNotLeakIntoVerboseOrReplayedHistory(t *testing.T) {
95	m := newTestChatModel(t, &daemon.Session{ID: "s"})
96	m.SetSize(100, 30)
97	result := daemon.StreamEvent{Type: daemon.EventTool, ToolName: "python",
98		ToolArgs: map[string]any{"code": `print("tool_source_sentinel")`}, ToolResult: `{"status":"ok"}`}
99	m.handleStreamEvent(result)
100	m.refreshViewportContent()
101	if got := ansi.Strip(m.Viewport.View()); strings.Count(got, "tool_source_sentinel") != 2 {
102		t.Fatalf("compact history and the completed action are not both visible: %q", got)
103	}
104	m.TextArea.SetValue("/verbose")
105	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
106	verboseCount := strings.Count(ansi.Strip(m.Viewport.View()), "tool_source_sentinel")
107	if verboseCount == 0 {
108		t.Fatal("verbose history hid the tool source")
109	}
110	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventTurnCompleted})
111	m.refreshViewportContent()
112	if got := ansi.Strip(m.Viewport.View()); strings.Count(got, "tool_source_sentinel") != verboseCount {
113		t.Fatalf("the completed preview duplicated the verbose tool row: %q", got)
114	}
115	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventReset})
116	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress, Replayed: true,
117		Progress: &daemon.ToolProgress{CallID: "old", Name: "python", Phase: "generating", Code: &daemon.ToolCodePreview{Text: "obsolete_live_sentinel"}}})
118	result.Replayed = true
119	m.handleStreamEvent(result)
120	m.TextArea.SetValue("/verbose")
121	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
122	if got := ansi.Strip(m.Viewport.View()); strings.Contains(got, "obsolete_live_sentinel") || strings.Count(got, "tool_source_sentinel") != 1 {
123		t.Fatalf("replaying old history produced a live action row: %q", got)
124	}
125}
126
127func TestTurnSignoffReplacesTheLastActionRow(t *testing.T) {
128	m := newTestChatModel(t, &daemon.Session{ID: "s"})
129	m.SetSize(100, 30)
130	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventUser, Text: "do it"})
131	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress,
132		Progress: &daemon.ToolProgress{CallID: "progress-1", Name: "python", Phase: "running"}})
133	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventTool, ToolName: "python",
134		ProgressCallID: "progress-1", ToolArgs: map[string]any{"code": "print(42)"}, ToolResult: `{"status":"ok"}`})
135	m.refreshViewportContent()
136	view := ansi.Strip(m.Viewport.View())
137	if strings.Count(view, "python · print(42)") != 1 || !strings.Contains(view, "python print(42)") {
138		t.Fatalf("group summary and completed action are not both visible: %q", view)
139	}
140	status := daemon.AgentStatus{Idle: true}
141	m, _ = m.Update(ChatStatusMsg{SessionID: m.SessionID, Generation: m.Generation,
142		Revision: m.statusRevision, Status: &status})
143	if strings.Contains(ansi.Strip(m.Viewport.View()), "python · print(42)") ||
144		!strings.Contains(ansi.Strip(m.Viewport.View()), "python print(42)") {
145		t.Fatalf("turn signoff left an action row behind: %q", m.Viewport.View())
146	}
147}
148
149func TestMetadataEventsKeepVisibleLiveToolActivity(t *testing.T) {
150	for _, kind := range []daemon.EventType{daemon.EventUsage, daemon.EventCommitted, daemon.EventTurnMembership, daemon.EventNote, daemon.EventCompacted} {
151		t.Run(string(kind), func(t *testing.T) {
152			m := newTestChatModel(t, &daemon.Session{ID: "s"})
153			m.SetSize(100, 30)
154			m.Flags.Tools = true
155			m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress, Progress: &daemon.ToolProgress{CallID: "active", Name: "python", Phase: "generating", Code: &daemon.ToolCodePreview{Text: "live_code_sentinel"}}})
156			m.handleStreamEvent(daemon.StreamEvent{Type: kind})
157			m.refreshViewportContent()
158			if got := ansi.Strip(m.Viewport.View()); !strings.Contains(got, "live_code_sentinel") || m.statusLine() != "generating call" {
159				t.Fatalf("%s removed visible live activity: %q, status %q", kind, got, m.statusLine())
160			}
161		})
162	}
163}
164
165func TestIdleSignoffCannotRestoreObsoleteCallAfterNextResult(t *testing.T) {
166	m := newTestChatModel(t, &daemon.Session{ID: "s"})
167	m.SetSize(100, 30)
168	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress, Progress: &daemon.ToolProgress{CallID: "old", Name: "obsolete_tool", Phase: "running"}})
169	status := daemon.AgentStatus{Idle: true}
170	m, _ = m.Update(ChatStatusMsg{SessionID: m.SessionID, Generation: m.Generation, Revision: m.statusRevision, Status: &status})
171	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress, Progress: &daemon.ToolProgress{CallID: "fresh", Name: "python", Phase: "running"}})
172	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventTool, ProgressCallID: "fresh", ToolName: "python", ToolResult: "done"})
173	m.refreshViewportContent()
174	if got := ansi.Strip(m.Viewport.View()); strings.Contains(got, "obsolete_tool") || m.statusLine() == "running obsolete_tool" {
175		t.Fatalf("idle signoff restored obsolete activity: %q", got)
176	}
177}
178
179func TestProseTakesActionSlotWithoutForgettingOtherLiveCalls(t *testing.T) {
180	m := newTestChatModel(t, &daemon.Session{ID: "s"})
181	m.SetSize(100, 30)
182	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress, Progress: &daemon.ToolProgress{CallID: "a", Name: "still_running", Phase: "running"}})
183	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventText, Text: "working on it"})
184	m.refreshViewportContent()
185	if got := ansi.Strip(m.Viewport.View()); !strings.Contains(got, "working on it") || strings.Contains(got, "running still_running") {
186		t.Fatalf("prose did not take the action slot: %q", got)
187	}
188	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress, Progress: &daemon.ToolProgress{CallID: "b", Name: "python", Phase: "running"}})
189	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventTool, ProgressCallID: "b", ToolName: "python", ToolResult: "done"})
190	m.refreshViewportContent()
191	if got := ansi.Strip(m.Viewport.View()); !strings.Contains(got, "running still_running") {
192		t.Fatalf("prose forgot call A before call B's result: %q", got)
193	}
194}
195func TestPythonActionShowsCellSourceAndRunningJobCommand(t *testing.T) {
196	progress := &daemon.ToolProgress{CallID: "cell", Name: "python", Phase: "running", Code: &daemon.ToolCodePreview{Text: "# setup\n\nprint(\"hello\")"}}
197	if got := actionLabel(progress, nil); got != `running print("hello")` {
198		t.Fatalf("cell source was not summarized: %q", got)
199	}
200	if got := pythonSourceLine(&daemon.ToolCodePreview{Text: "# only comments\n  \n"}); got != "" {
201		t.Fatalf("comment-only cell should have no summary: %q", got)
202	}
203}
204
205func TestRunningPythonJobCommandAppearsInActionAndFooter(t *testing.T) {
206	m := newTestChatModel(t, &daemon.Session{ID: "s"})
207	m.SetSize(100, 30)
208	m.Status.RunningJobs = []protocol.KernelJob{{ID: "j", Command: "./test.sh"}}
209	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventToolProgress,
210		Progress: &daemon.ToolProgress{CallID: "cell", Name: "python", Phase: "running",
211			Code: &daemon.ToolCodePreview{Text: "job = run(\"./test.sh\")\nawait job"}}})
212	m.refreshViewportContent()
213	if got := ansi.Strip(m.Viewport.View()); !strings.Contains(got, "running ./test.sh") {
214		t.Fatalf("running command missing from action: %q", got)
215	}
216	if got := m.statusLine(); got != "running" {
217		t.Fatalf("the status line repeats the action row: %q", got)
218	}
219}