okami.mom/albedo

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

nekomimi.petrelease reattached kernels no session owns, and rest the tui status poll while idle39972a8

main
9.6 KiB240 linesraw
1// The chat header drops whole parts as the terminal narrows instead of
2// cutting one short, and a remote workspace's host is never one of them.
3// The layout depends only on the width, so the rule is checked across every
4// width here; an e2e would see one terminal size.
5package tui
6
7import (
8	"strings"
9	"testing"
10	"time"
11
12	"albedo/cli/internal/daemon"
13	"albedo/cli/internal/daemon/protocol"
14	"github.com/charmbracelet/x/ansi"
15)
16
17func TestHeaderKeepsTheWorkspaceNameAndModelWhole(t *testing.T) {
18	m := newTestChatModel(t, &daemon.Session{ID: "s"})
19	m.AgentName, m.Workspace, m.Model, m.Effort = "albedo", "/src/proj/tangled", "claude-opus-4-6", "high"
20	m.Glances = []PageGlance{
21		{Title: "pending work", Rows: []PageRow{{ID: "1", Text: "x"}}},
22		{Title: "open vents", Rows: []PageRow{{ID: "2", Text: "y"}}},
23	}
24	const model = "claude-opus-4-6:high"
25	for width := 1; width <= 120; width++ {
26		header := ansi.Strip(m.header(width))
27		if got := ansi.StringWidth(header); got > width {
28			t.Fatalf("width %d: header %q is %d wide", width, header, got)
29		}
30		if width >= len(model) && !strings.Contains(header, model) {
31			t.Fatalf("width %d: header %q cut the model", width, header)
32		}
33		if width >= len("tangled")+len(model)+3 && !strings.Contains(header, "tangled") {
34			t.Fatalf("width %d: header %q cut the workspace name", width, header)
35		}
36	}
37	if header := ansi.Strip(m.header(100)); !strings.Contains(header, "pending work 1  open vents 1") {
38		t.Fatalf("a wide header without a sidebar should count every glance: %q", header)
39	}
40}
41
42func TestRemoteHeaderKeepsTheHostWholeAndFoldsOnlyThePath(t *testing.T) {
43	label := "chernobog"
44	m := newTestChatModel(t, &daemon.Session{
45		ID:        "s",
46		Workspace: "mayer@chernobog:/home/mayer/proj/albedo",
47		Location:  &daemon.Location{Label: &label},
48	})
49	m.AgentName, m.Model = "albedo", "claude-opus-4-6"
50	const model = "claude-opus-4-6"
51	if header := ansi.Strip(m.header(120)); !strings.Contains(header, "✦ albedo on chernobog:/home/mayer/proj/albedo") {
52		t.Fatalf("a wide header should name the host and the whole path: %q", header)
53	}
54	for width := 1; width <= 120; width++ {
55		header := ansi.Strip(m.header(width))
56		if got := ansi.StringWidth(header); got > width {
57			t.Fatalf("width %d: header %q is %d wide", width, header, got)
58		}
59		if strings.Contains(header, "mayer@") {
60			t.Fatalf("width %d: header %q shows the user the label left out", width, header)
61		}
62		if strings.Contains(header, "/") && !strings.Contains(header, "chernobog:") {
63			t.Fatalf("width %d: header %q shows the path without its host", width, header)
64		}
65		if width >= len("chernobog:albedo")+len(model)+3 && !strings.Contains(header, "chernobog:") {
66			t.Fatalf("width %d: header %q dropped the host", width, header)
67		}
68	}
69}
70
71func TestRemoteHeaderFoldsUnderTheHostsHomeAndSaysWhileConnecting(t *testing.T) {
72	label := "chernobog"
73	m := newTestChatModel(t, &daemon.Session{
74		ID:        "s",
75		Workspace: "mayer@chernobog:/home/mayer/proj/albedo",
76		Location:  &daemon.Location{Label: &label},
77	})
78	m.AgentName, m.Model = "albedo", "claude-opus-4-6"
79	m.Status = daemon.AgentStatus{Idle: true, KernelLink: "booting"}
80	if got := m.statusLine(); got != "connecting to chernobog…" {
81		t.Fatalf("a booting remote kernel reads %q", got)
82	}
83	m.Status.KernelStep = "staging"
84	if got := m.statusLine(); got != "copying the kernel to chernobog…" {
85		t.Fatalf("a first boot on the host reads %q", got)
86	}
87	m.Status.KernelStep = ""
88	// an idle session animates too, with the face the picker shows for the host
89	m.ProgressFrame = 1
90	if view := ansi.Strip(m.View()); !m.animating() || !strings.Contains(view, "connecting to chernobog… "+connectingFace("chernobog", 1)) {
91		t.Fatalf("connecting should animate (animating %v):\n%s", m.animating(), view)
92	}
93	m.Status.KernelLink, m.hostHome = "attached", "/home/mayer"
94	if header := ansi.Strip(m.header(120)); !strings.Contains(header, "✦ albedo on chernobog:proj/albedo") {
95		t.Fatalf("the path should fold under the host's own home: %q", header)
96	}
97	if got := m.statusLine(); strings.Contains(got, "connecting") {
98		t.Fatalf("an attached kernel still reads %q", got)
99	}
100	m.Status.KernelLink = "lost"
101	if got := m.statusLine(); got != "kernel on chernobog lost · the next turn starts a fresh one" {
102		t.Fatalf("a lost kernel reads %q", got)
103	}
104}
105
106func TestBackgroundJobsRenderInHeaderStatusAndSidebar(t *testing.T) {
107	m := newTestChatModel(t, &daemon.Session{ID: "s"})
108	m.AgentName, m.Workspace, m.Model = "albedo", "/src/proj/albedo", "claude-opus-4-6"
109	m.Status = daemon.AgentStatus{
110		Idle:       true,
111		KernelJobs: new(int64(1)),
112		RunningJobs: []protocol.KernelJob{
113			{ID: "job1", Command: "go test ./..."},
114		},
115	}
116
117	if got := m.statusLine(); got != "1 background job running · go test ./..." {
118		t.Fatalf("expected statusLine to show 1 running job, got %q", got)
119	}
120	if !m.animating() {
121		t.Fatalf("expected model to animate when background jobs are running")
122	}
123
124	header := ansi.Strip(m.header(120))
125	if !strings.Contains(header, "background jobs 1") {
126		t.Fatalf("expected header to count running jobs glance, got %q", header)
127	}
128
129	m.Glances = []PageGlance{{Title: "open vents", Rows: []PageRow{{ID: "v1", Text: "a vent"}}}}
130	m.SetSize(140, 30)
131	view := ansi.Strip(m.View())
132	if !strings.Contains(view, "background jobs") || !strings.Contains(view, "go test ./...") {
133		t.Fatalf("expected sidebar to show running background job, got:\n%s", view)
134	}
135
136	if len(m.Glances) != 1 || m.Glances[0].Title != "open vents" {
137		t.Fatal("rendering jobs changed extension glances")
138	}
139	for width := 1; width <= 140; width++ {
140		m.SetSize(width, 30)
141		if got := ansi.StringWidth(m.header(width)); got > width {
142			t.Fatalf("job header at width %d exceeds its bounds: %d", width, got)
143		}
144	}
145	m.Glances = nil
146	m.Status.KernelJobs = nil
147	m.Status.RunningJobs = nil
148	if m.animating() || m.statusLine() != "" || len(m.activeGlances()) != 0 {
149		t.Fatal("unknown jobs must not claim that background work is running")
150	}
151
152	m.Status.KernelJobs = new(int64(2))
153	m.Status.RunningJobs = []protocol.KernelJob{
154		{ID: "job1", Command: "sleep 10"},
155		{ID: "job2", Command: "cargo build"},
156	}
157	if got := m.statusLine(); got != "2 background jobs running · sleep 10" {
158		t.Fatalf("expected statusLine to show 2 running jobs, got %q", got)
159	}
160
161	m.Status.KernelJobs = new(int64(0))
162	m.Status.RunningJobs = nil
163	if got := m.statusLine(); got != "" {
164		t.Fatalf("expected empty statusLine when idle with no jobs, got %q", got)
165	}
166}
167
168func TestBackgroundJobsRefreshWithoutStartingATurn(t *testing.T) {
169	m := newTestChatModel(t, &daemon.Session{ID: "s"})
170	m.SetSize(80, 30)
171	status := daemon.AgentStatus{Idle: true, KernelJobs: new(int64(1)),
172		RunningJobs: []protocol.KernelJob{{ID: "j", Command: "sleep 10"}}}
173	m, cmd := m.Update(ChatStatusMsg{SessionID: m.SessionID, Generation: m.Generation,
174		Revision: m.statusRevision, Status: &status})
175	if !m.animating() || cmd == nil || !strings.Contains(m.statusLine(), "sleep 10") {
176		t.Fatal("idle background job did not start its status animation")
177	}
178	status.KernelJobs, status.RunningJobs = new(int64(0)), nil
179	m, _ = m.Update(ChatStatusMsg{SessionID: m.SessionID, Generation: m.Generation,
180		Revision: m.statusRevision, Status: &status})
181	if m.animating() || m.statusLine() != "" || len(m.activeGlances()) != 0 {
182		t.Fatal("finished jobs left idle job chrome behind")
183	}
184}
185
186func TestStatusPollingRestsWhileIdleAndKeepsOneLoop(t *testing.T) {
187	// The loop count and cadence are invisible over HTTP; an extra status read
188	// once forked a second 750ms loop that never ended.
189	m := newTestChatModel(t, &daemon.Session{ID: "s"})
190	idle := daemon.AgentStatus{Idle: true}
191	m, _ = m.Update(ChatStatusMsg{SessionID: m.SessionID, Generation: m.Generation,
192		Revision: m.statusRevision, Status: &idle})
193	stale := m.statusPoll
194	m, _ = m.Update(ChatStatusMsg{SessionID: m.SessionID, Generation: m.Generation,
195		Revision: m.statusRevision, Status: &idle})
196	if !m.statusPollResting {
197		t.Fatal("an idle session kept the fast status poll")
198	}
199	if _, cmd := m.Update(ChatStatusPollMsg{SessionID: m.SessionID, Generation: m.Generation, Poll: stale}); cmd != nil {
200		t.Fatal("a superseded poll read status again")
201	}
202	m, cmd := m.Update(ChatStreamEventMsg{SessionID: m.SessionID, Generation: m.Generation,
203		Event: daemon.StreamEvent{Type: daemon.EventNote}})
204	if m.statusPollResting || cmd == nil {
205		t.Fatal("live activity did not wake the resting status poll")
206	}
207}
208
209func TestYoungJobsStayOutOfTheBackgroundChrome(t *testing.T) {
210	// The threshold is render logic over a started_at the daemon reports; an
211	// e2e would have to wait out the grace to see either side of it.
212	m := newTestChatModel(t, &daemon.Session{ID: "s"})
213	m.SetSize(140, 30)
214	m.Status = daemon.AgentStatus{
215		Idle:       true,
216		KernelJobs: new(int64(1)),
217		RunningJobs: []protocol.KernelJob{
218			{ID: "job1", Command: "rg pattern", StartedAt: time.Now().UnixMilli()},
219		},
220	}
221
222	if m.animating() || m.statusLine() != "" || len(m.activeGlances()) != 0 {
223		t.Fatal("a job inside its grace is not background work yet")
224	}
225	if got := m.runningJobCommand(); got != "rg pattern" {
226		t.Fatalf("the action row still names the job a running cell is on: %q", got)
227	}
228
229	// one aged job shows, one live remote job the list cannot name still counts
230	m.Status.KernelJobs = new(int64(3))
231	m.Status.RunningJobs = append(m.Status.RunningJobs,
232		protocol.KernelJob{ID: "job2", Command: "cargo build", StartedAt: time.Now().Add(-10 * time.Second).UnixMilli()})
233	if got := m.statusLine(); got != "2 background jobs running · cargo build" {
234		t.Fatalf("aged jobs count and lead the idle status line, got %q", got)
235	}
236	view := ansi.Strip(m.View())
237	if strings.Contains(view, "rg pattern") || !strings.Contains(view, "cargo build") {
238		t.Fatalf("the sidebar hides the young job and shows the aged one:\n%s", view)
239	}
240}