okami.mom/albedo

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

dawntui: skills, instructions and mcp on listView with global/session toggles and a pane without enter96704f7

main
11.9 KiB249 linesraw
1// Stale replies from closed capability pages must not satisfy reopened pages,
2// and the confirm, filter, and detail pane are unexported screen state.
3// E2E cannot deterministically interleave replies across discarded views or
4// read the pane and question without a terminal.
5package tui
6
7import (
8	"albedo/cli/internal/config"
9	"albedo/cli/internal/daemon"
10	"albedo/cli/internal/testwire"
11	"encoding/json"
12	"io"
13	"net/http"
14	"strings"
15	"testing"
16
17	tea "charm.land/bubbletea/v2"
18	"github.com/charmbracelet/x/ansi"
19)
20
21func TestCapabilityStaleReplyFromReplacedInstanceIsRejected(t *testing.T) {
22	disabled := false
23	key := "draft"
24	items := []capabilityItem{{ID: "row-draft", Candidate: daemon.CatalogCandidate{PreferenceKey: &key, Valid: true, GlobalPreference: &disabled}}}
25	a := NewCapabilityPageModel(nil, "s", "skills")
26	a, _ = a.Update(capabilityLoadedMsg{Gen: a.Generation, Items: items, Revision: "rev-1"})
27	a, _ = a.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl})
28	zombie := capabilityLoadedMsg{Gen: a.Generation, Items: items, Revision: "rev-1"}
29	a, _ = a.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
30
31	b := NewCapabilityPageModel(nil, "s", "skills")
32	b, _ = b.Update(capabilityLoadedMsg{Gen: b.Generation, Items: items, Revision: "rev-1"})
33	before := b.Generation
34	b, _ = b.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
35	b, _ = b.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
36	if !b.Saving || b.Generation == before {
37		t.Fatal("a toggle must start a save under a fresh generation")
38	}
39	saveGen := b.Generation
40	b, _ = b.Update(capabilitySavedMsg{Gen: saveGen})
41	if b.Saving || !b.Loading {
42		t.Fatal("a completed save must fetch acknowledged state")
43	}
44	reloaded := capabilityLoadedMsg{Gen: b.Generation, Items: items, Revision: "rev-1"}
45	if zombie.Gen == saveGen || zombie.Gen == reloaded.Gen {
46		t.Fatal("generations collided across page instances")
47	}
48	b, _ = b.Update(zombie)
49	if !b.Loading {
50		t.Fatal("the discarded page's reply cleared the current fetch")
51	}
52	b, _ = b.Update(reloaded)
53	if b.Loading || b.enabledIn(b.Items[0], scopeGlobal) || len(b.Items) != 1 {
54		t.Fatal("the current page's acknowledged state was not accepted")
55	}
56}
57
58// The detached view owns response ordering and key routing, which daemon E2E
59// scenarios cannot observe deterministically.
60func TestCapabilityConflictRefreshesWithoutReplayingToggle(t *testing.T) {
61	var methods []string
62	conn := commandTestConnection(t, func(w http.ResponseWriter, r *http.Request) {
63		methods = append(methods, r.Method)
64		status := 200
65		var body any
66		switch {
67		case len(methods) == 1 && r.Method == http.MethodPatch:
68			if r.URL.RequestURI() != "/sessions/s?view=configuration" {
69				t.Fatalf("wrong mutation %s", r.URL)
70			}
71			status = 409
72			body = map[string]any{"type": "about:blank", "title": "Catalog changed", "status": 409, "code": "catalog_changed", "detail": "catalog changed"}
73		case r.URL.Path == "/sessions/s/catalog":
74			body = map[string]any{"discovery": map[string]any{"workspace": "/daemon-workspace", "revision": "after", "candidates": []any{protocolCandidate("row", "Updated skill", false), protocolCandidate("new-row", "New skill", true)}, "diagnostics": []any{}, "next": nil}, "discovery_failure": nil, "loaded": map[string]any{"revision": nil, "commands": []any{}, "next": nil}}
75		case r.URL.Path == "/settings":
76			body = testwire.Settings()
77		case r.URL.Path == "/sessions/s":
78			body = protocolSession("s", generationA, 0)["configuration_resource"].(map[string]any)["value"]
79		default:
80			t.Fatalf("unexpected request %s %s", r.Method, r.URL)
81		}
82		w.Header().Set("ETag", "\"after\"")
83		w.WriteHeader(status)
84		_ = json.NewEncoder(w).Encode(body)
85	})
86	key := "draft"
87	item := capabilityItem{ID: "row", Title: "Original skill", Candidate: daemon.CatalogCandidate{ID: "row", PreferenceKey: &key, Valid: true, EffectiveEnabled: true}}
88	m := NewCapabilityPageModel(conn, "s", "skills")
89	m, _ = m.Update(capabilityLoadedMsg{Gen: m.Generation, Items: []capabilityItem{item}, Revision: "before", SessionETag: "\"seen\""})
90	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift})
91	m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
92	m, cmd = m.Update(cmd())
93	if m.Saving || !m.Loading || cmd == nil || !m.enabledIn(m.Items[0], scopeSession) {
94		t.Fatal("conflict must preserve acknowledged choices and start a read")
95	}
96	m, cmd = m.Update(cmd())
97	if cmd != nil || m.Loading || m.Error != "" || m.Revision != "after" || len(m.Items) != 2 {
98		t.Fatalf("refresh did not adopt catalog %+v", m)
99	}
100	first := m.Items[1]
101	if first.ID != "row" {
102		first = m.Items[0]
103	}
104	if m.enabledIn(first, scopeSession) || first.Candidate.SessionOverride == nil {
105		t.Fatalf("fresh disabled row lost %+v", first)
106	}
107	if len(methods) != 4 || methods[0] != "PATCH" {
108		t.Fatalf("toggle replayed or missing canonical reads %v", methods)
109	}
110}
111func protocolCandidate(id, title string, enabled bool) map[string]any {
112	return map[string]any{"id": id, "kind": "skill", "title": title, "description": "", "source": "/skills/" + id, "resolved_source": nil, "preference_key": "skill:" + id, "valid": true, "eligible": enabled, "effective_enabled": enabled, "global_preference": enabled, "session_override": enabled, "shadowed_by": nil, "dependencies": []any{}, "quarantined": false, "diagnostic": nil, "extension": nil}
113}
114
115type catalogTransport func(*http.Request) (*http.Response, error)
116
117func (transport catalogTransport) RoundTrip(request *http.Request) (*http.Response, error) {
118	return transport(request)
119}
120
121func TestCapabilityToggleSubmitsRowIdentityAndAcknowledgedRevision(t *testing.T) {
122	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
123	requests := 0
124	conn.HTTPClient().Transport = catalogTransport(func(request *http.Request) (*http.Response, error) {
125		requests++
126		var body struct {
127			CatalogRevision string `json:"catalog_revision"`
128			Selection       struct {
129				Skills map[string]*bool `json:"skills"`
130			} `json:"selection"`
131		}
132		if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
133			t.Fatal(err)
134		}
135		choice := body.Selection.Skills["row-winner"]
136		if request.Method != "PATCH" || request.URL.RequestURI() != "/sessions/s?view=configuration" || request.Header.Get("If-Match") != "\"seen\"" || body.CatalogRevision != "current" || choice == nil || *choice {
137			t.Fatalf("wrong observed selection %s %+v", request.URL, body)
138		}
139		session := protocolSession("s", generationA, 0)
140		resource := session["configuration_resource"]
141		encoded, _ := json.Marshal(map[string]any{"resource": resource, "session": session, "move": nil})
142		return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(encoded)))}, nil
143	})
144	key, winner := "shared-name", "row-winner"
145	items := []capabilityItem{
146		{ID: winner, Candidate: daemon.CatalogCandidate{PreferenceKey: &key, Valid: true, EffectiveEnabled: true}},
147		{ID: "row-shadowed", Candidate: daemon.CatalogCandidate{PreferenceKey: &key, Valid: true, ShadowedBy: &winner, EffectiveEnabled: true}},
148		{ID: "row-invalid", Candidate: daemon.CatalogCandidate{Valid: false}},
149	}
150	m := NewCapabilityPageModel(conn, "s", "skills")
151	m, _ = m.Update(capabilityLoadedMsg{Gen: m.Generation, Items: items, Revision: "current", SessionETag: "\"seen\""})
152	for _, index := range []int{1, 2} {
153		m.Cursor = index
154		var cmd tea.Cmd
155		m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift})
156		if cmd != nil || m.Saving {
157			t.Fatal("shadowed or unaddressable invalid candidate accepted a toggle")
158		}
159	}
160	m.Cursor = 0
161	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift})
162	m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
163	msg := cmd().(capabilitySavedMsg)
164	if msg.Err != nil || requests != 1 || !m.Items[0].Candidate.EffectiveEnabled {
165		t.Fatalf("toggle must send once without optimistic changes: %v", msg.Err)
166	}
167}
168
169// The filter, the pane, and the confirm live in unexported listView and
170// confirm state; these read them through View and key presses, which E2E
171// cannot do without a terminal.
172func TestCapabilityFilterNarrowsRowsAndPaneShowsWithoutEnter(t *testing.T) {
173	m := NewCapabilityPageModel(nil, "s", "skills")
174	m.SetSize(120, 30)
175	m, _ = m.Update(capabilityLoadedMsg{Gen: m.Generation, ExtensionEnabled: true, Items: []capabilityItem{
176		{ID: "alpha", Title: "alpha-review", Candidate: daemon.CatalogCandidate{ID: "alpha", Source: "/skills/alpha", Description: new("checks diffs"), Valid: true}},
177		{ID: "beta", Title: "beta-notes", Candidate: daemon.CatalogCandidate{ID: "beta", Source: "/skills/beta", Description: new("writes notes"), Valid: true}},
178	}})
179	for _, r := range "beta" {
180		m, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
181	}
182	view := ansi.Strip(m.View())
183	if m.Confirming() || strings.Contains(view, "alpha-review") || !strings.Contains(view, "/skills/beta") || !strings.Contains(view, "eligible on reload") {
184		t.Fatalf("typing should narrow to beta and show its source and state in the pane:\n%s", view)
185	}
186	if item, _ := m.current(); item.ID != "beta" {
187		t.Fatalf("the filter left %q under the cursor", item.ID)
188	}
189}
190
191func TestCapabilityEnterAsksAndOnlyEnterOrEscAnswer(t *testing.T) {
192	key := "draft"
193	m := NewCapabilityPageModel(nil, "s", "skills")
194	m.SetSize(120, 30)
195	m, _ = m.Update(capabilityLoadedMsg{Gen: m.Generation, ExtensionEnabled: true, Revision: "rev", Items: []capabilityItem{
196		{ID: "one", Title: "one", Candidate: daemon.CatalogCandidate{ID: "one", PreferenceKey: &key, Valid: true, EffectiveEnabled: true}},
197		{ID: "two", Title: "two", Candidate: daemon.CatalogCandidate{ID: "two", PreferenceKey: &key, Valid: true, EffectiveEnabled: true}},
198	}})
199	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
200	if !m.Confirming() || !strings.Contains(ansi.Strip(m.View()), "Disable one globally?") {
201		t.Fatalf("enter should ask about the global default:\n%s", ansi.Strip(m.View()))
202	}
203	m, _ = m.Update(tea.KeyPressMsg{Code: 'y', Text: "y"})
204	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
205	if !m.Confirming() || m.Cursor != 0 || m.Saving {
206		t.Fatal("a stray key must neither answer nor move past the question")
207	}
208	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
209	if m.Confirming() || m.Saving {
210		t.Fatal("esc must cancel the question without saving")
211	}
212	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift})
213	if !m.Confirming() || !strings.Contains(ansi.Strip(m.View()), "for this session only") {
214		t.Fatalf("shift+enter should ask about this session only:\n%s", ansi.Strip(m.View()))
215	}
216	m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
217	if !m.Saving || cmd == nil {
218		t.Fatal("enter on the question must start the save")
219	}
220}
221
222func TestMCPPaneShowsSettingsAndKeysOpenFormAndDelete(t *testing.T) {
223	enabled := true
224	m := NewCapabilityPageModel(nil, "s", "mcp")
225	m.SetSize(120, 30)
226	m, _ = m.Update(capabilityLoadedMsg{Gen: m.Generation, ExtensionEnabled: true, Items: []capabilityItem{
227		{ID: "docs", Title: "docs", Detail: "http · https://docs.example/mcp", Server: config.MCPServer{Type: "http", URL: "https://docs.example/mcp", Enabled: &enabled}, Secrets: daemon.MCPSecretNames{BearerToken: true}},
228	}})
229	view := ansi.Strip(m.View())
230	if !strings.Contains(view, "stored privately") || !strings.Contains(view, "bearer") || !strings.Contains(view, "extensions.json") {
231		t.Fatalf("the pane should show credentials and config without enter:\n%s", view)
232	}
233	m = ctrl(m, 'e')
234	if m.Form == nil || m.Form.Editing != "docs" {
235		t.Fatal("ctrl+e should edit the highlighted server")
236	}
237	if view := ansi.Strip(m.View()); !strings.Contains(view, "edit docs") || strings.Contains(view, "Search MCP servers") {
238		t.Fatalf("the open form replaces the list with its filter hidden:\n%s", view)
239	}
240	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
241	m = ctrl(m, 'd')
242	if !m.Confirming() || !strings.Contains(ansi.Strip(m.View()), "stored credentials will also be removed") {
243		t.Fatalf("ctrl+d should ask before deleting, with its credentials warning:\n%s", ansi.Strip(m.View()))
244	}
245	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
246	if m.Confirming() {
247		t.Fatal("esc should cancel the delete question")
248	}
249}