okami.mom/albedo

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

Aly Raffaufperf(tui): reuse prepared startup dataed54422

main
9.9 KiB236 linesraw
1// Lost acknowledgements can race stream echoes; a daemon E2E cannot inspect
2// whether the detached TUI restored a duplicate prompt or retained its pending image.
3package tui
4
5import (
6	"errors"
7	"fmt"
8	"io"
9	"testing"
10
11	"albedo/cli/internal/config"
12	"albedo/cli/internal/daemon"
13	tea "charm.land/bubbletea/v2"
14)
15
16func TestUncertainSubmissionRetainsPendingUntilStreamEcho(t *testing.T) {
17	for _, queued := range []bool{false, true} {
18		t.Run(fmt.Sprintf("queued=%v", queued), func(t *testing.T) {
19			m := newTestChatModel(t, &daemon.Session{ID: "s"})
20			m.SetSize(80, 30)
21			image := daemon.ImageAttachment{Data: "image"}
22			var commands []tea.Cmd
23			m.submitInput("check this "+m.Images.add(pastedImage{ImageAttachment: image}), &commands)
24			handle := m.pendingUsers[0].Handle
25			m.pendingUsers[0].Queued = queued
26			m.TextArea.Reset()
27			m.interruptDeferred = true
28
29			m, cmd := m.Update(ChatTurnSentMsg{
30				SessionID: "s", Generation: m.Generation, Prompt: "check this [Image #1]", Images: []daemon.ImageAttachment{image},
31				Handle: handle, OperationID: handle.ID(),
32				Err: fmt.Errorf("send: %w", &daemon.UncertainOutcomeError{Operation: "submit", Cause: io.ErrUnexpectedEOF, Handle: handle}),
33			})
34			if cmd == nil || m.isSending || m.interruptDeferred {
35				t.Fatal("uncertain acknowledgement must settle sending and schedule receipt recovery")
36			}
37			if m.TextArea.Value() != "" || len(m.Images.byNumber) != 0 {
38				t.Fatal("uncertain send restored a duplicate prompt or image")
39			}
40			if len(m.pendingUsers) != 1 || len(m.pendingUsers[0].Images) != 1 || m.pendingUsers[0].Images[0] != image || m.pendingUsers[0].Queued != queued {
41				t.Fatalf("lost pending submission: %+v", m.pendingUsers)
42			}
43			if len(m.Notices) != 1 || m.Notices[0].Message == "" {
44				t.Fatalf("missing uncertainty guidance: %+v", m.Notices)
45			}
46
47			for _, event := range []daemon.StreamEvent{
48				{Type: daemon.EventUser, Text: "check this", Source: "chat", OperationID: m.pendingUsers[0].OperationID},
49				{Type: daemon.EventCommitted, Seq: 1},
50			} {
51				m, _ = m.Update(ChatStreamEventMsg{SessionID: "s", Generation: m.Generation, Event: event})
52			}
53			if len(m.pendingUsers) != 0 || len(m.Notices) != 0 {
54				t.Fatalf("stream echo did not reconcile pending submission: pending=%+v notices=%+v", m.pendingUsers, m.Notices)
55			}
56			users := 0
57			for _, entry := range m.History.Entries() {
58				if entry.Kind == EntryUser && entry.Text == "check this" {
59					users++
60				}
61			}
62			if users != 1 {
63				t.Fatalf("want one committed user message, got %d", users)
64			}
65		})
66	}
67}
68
69func TestStreamEchoBeforeUncertainAcknowledgementDoesNotRestoreSubmission(t *testing.T) {
70	m := newTestChatModel(t, &daemon.Session{ID: "s"})
71	m.SetSize(80, 30)
72	image := daemon.ImageAttachment{Data: "image"}
73	var commands []tea.Cmd
74	m.submitInput("already accepted "+m.Images.add(pastedImage{ImageAttachment: image}), &commands)
75	handle := m.pendingUsers[0].Handle
76	m.TextArea.Reset()
77	for _, event := range []daemon.StreamEvent{
78		{Type: daemon.EventUser, Text: "already accepted", Source: "chat", OperationID: m.pendingUsers[0].OperationID},
79		{Type: daemon.EventCommitted, Seq: 1},
80	} {
81		m, _ = m.Update(ChatStreamEventMsg{SessionID: "s", Generation: m.Generation, Event: event})
82	}
83
84	m, cmd := m.Update(ChatTurnSentMsg{
85		SessionID: "s", Generation: m.Generation, Prompt: "already accepted [Image #1]", Images: []daemon.ImageAttachment{image},
86		Handle: handle, OperationID: handle.ID(),
87		Err: &daemon.UncertainOutcomeError{Operation: "submit", Cause: io.EOF, Handle: handle},
88	})
89	if cmd != nil || m.isSending || len(m.pendingUsers) != 0 || m.TextArea.Value() != "" || len(m.Images.byNumber) != 0 {
90		t.Fatal("late uncertain acknowledgement restored or resubmitted an echoed message")
91	}
92	users := 0
93	for _, entry := range m.History.Entries() {
94		if entry.Kind == EntryUser && entry.Text == "already accepted" {
95			users++
96		}
97	}
98	if users != 1 {
99		t.Fatalf("want one committed user message, got %d", users)
100	}
101}
102
103func TestUncertainCommandKeepsSessionAndEffort(t *testing.T) {
104	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
105	session := &daemon.Session{ID: "current", Effort: "low"}
106	m := NewAppModel(conn, Bootstrap{Settings: daemon.Settings{Profiles: config.Profiles{}}}, session, "", false, nil)
107	t.Cleanup(m.Chat.Close)
108	confirmed := m.ActiveSession
109	updated, cmd := m.Update(commandExecutedMsg{Name: "/effort", Effort: "high", Err: &daemon.UncertainOutcomeError{Operation: "command", Cause: io.EOF}})
110	m = updated.(*AppModel)
111	if cmd != nil || m.ActiveSession != confirmed || m.ActiveSession.Effort != "low" || m.Chat.Effort != "low" || m.State != AppStateChat {
112		t.Fatal("uncertain command applied an unconfirmed result or resubmitted the command")
113	}
114	if len(m.Chat.Notices) != 1 || m.Chat.Notices[0].Message == "" {
115		t.Fatalf("missing command guidance: %+v", m.Chat.Notices)
116	}
117}
118
119func TestUncertainSignInDoesNotRestartOrOpenBrowser(t *testing.T) {
120	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
121	opened := false
122	m := NewLoginModel(conn, "", func(string) { opened = true })
123	m.Step = StepOAuth
124	m, cmd := m.Update(signInStartedMsg{Gen: m.Generation, Err: &daemon.UncertainOutcomeError{Operation: "sign-in", Cause: io.EOF}})
125	if cmd != nil || opened || m.LoginID != "" || m.Error == "" {
126		t.Fatalf("uncertain sign-in restarted or lost its guidance: %q", m.Error)
127	}
128	m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
129	if cmd != nil || m.LoginID != "" {
130		t.Fatal("Enter automatically restarted an uncertain sign-in")
131	}
132}
133
134func TestInvalidSessionSnapshotKeepsConfirmedGlances(t *testing.T) {
135	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
136	m := NewAppModel(conn, Bootstrap{Settings: daemon.Settings{Profiles: config.Profiles{}}}, &daemon.Session{ID: "current"}, "", false, nil)
137	t.Cleanup(m.Chat.Close)
138	m.Chat.Glances = []PageGlance{{Title: "confirmed"}}
139	updated, _ := m.Update(ChatStatusMsg{
140		SessionID: m.Chat.SessionID, Generation: m.Chat.Generation,
141		Err: invalidResponseOutcome("/work", "page", errors.New("missing page")),
142	})
143	m = updated.(*AppModel)
144	if len(m.Chat.Glances) != 1 || m.Chat.Glances[0].Title != "confirmed" {
145		t.Fatal("invalid snapshot replaced confirmed glances")
146	}
147}
148
149func TestInvalidOpenResponseDoesNotRefreshSettings(t *testing.T) {
150	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
151	m := NewAppModel(conn, Bootstrap{Settings: daemon.Settings{Profiles: config.Profiles{}}}, &daemon.Session{ID: "current"}, "", false, nil)
152	t.Cleanup(m.Chat.Close)
153	_, cmd := m.Update(uiSavedMsg{Open: true, Err: invalidResponseOutcome("record open", "thinking", errors.New("missing preference"))})
154	if cmd != nil {
155		t.Fatal("invalid open response scheduled a settings refresh")
156	}
157}
158
159func TestUncertainWebhookStepRetainsConfirmedSecretWithoutRefresh(t *testing.T) {
160	m := loadedWebhooksPage(t)
161	m.Saving = true
162	secret := &webhookSecret{Hook: "ci", Session: "s", Secret: "confirmed-secret"}
163	m, cmd := m.Update(webhooksSavedMsg{
164		Gen: m.Generation, Reveal: secret,
165		Err: invalidResponseOutcome("/webhooks", "hook", errors.New("missing hook")),
166	})
167	if cmd != nil || m.Reveal != secret || m.Saving || m.Loading {
168		t.Fatal("uncertain later step lost the confirmed secret or scheduled a refresh")
169	}
170}
171
172func invalidResponseOutcome(operation, field string, cause error) error {
173	return &daemon.UncertainOutcomeError{Operation: operation, Cause: &daemon.ProtocolError{
174		Code: "invalid_response", Operation: operation, Field: field, Cause: cause,
175	}}
176}
177
178func TestIdenticalPendingTurnsReconcileByOperationID(t *testing.T) {
179	m := newTestChatModel(t, &daemon.Session{ID: "s"})
180	var commands []tea.Cmd
181	m.submitInput("same message", &commands)
182	first := m.pendingUsers[0].OperationID
183	m.submitInput("same message", &commands)
184	second := m.pendingUsers[1].OperationID
185	m, _ = m.Update(ChatTurnSentMsg{SessionID: "s", Generation: m.Generation, OperationID: second, Handle: m.pendingUsers[1].Handle, Queued: true, OK: true})
186	if m.pendingUsers[0].Queued || !m.pendingUsers[1].Queued {
187		t.Fatal("admission result matched the wrong identical message")
188	}
189	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventUser, Text: "same message", OperationID: second})
190	if len(m.pendingUsers) != 1 || m.pendingUsers[0].OperationID != first {
191		t.Fatal("stream echo retired the wrong identical message")
192	}
193	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventUser, Text: "same message", OperationID: first})
194	if len(m.pendingUsers) != 0 {
195		t.Fatal("original pending message was not reconciled")
196	}
197}
198
199// A timer scheduled before expiry may arrive afterward; rejecting it in the
200// model prevents the timer from issuing a receipt query.
201func TestScheduledReceiptPollStopsAfterExpiry(t *testing.T) {
202	for _, prompt := range []string{"waiting input", "."} {
203		t.Run(prompt, func(t *testing.T) {
204			m := newTestChatModel(t, &daemon.Session{ID: "s"})
205			image := daemon.ImageAttachment{Data: "image"}
206			if prompt != "." {
207				prompt += " " + m.Images.add(pastedImage{ImageAttachment: image})
208			}
209			var commands []tea.Cmd
210			m.submitInput(prompt, &commands)
211			var handle *daemon.OperationHandle
212			if prompt == "." {
213				for _, pending := range m.pendingContinuations {
214					handle = pending.Handle
215				}
216			} else {
217				handle = m.pendingUsers[0].Handle
218			}
219			tick := ChatOperationPollMsg{SessionID: "s", Generation: m.Generation, Handle: handle}
220			m, command := m.Update(ChatOperationResolvedMsg{
221				SessionID: "s", Generation: m.Generation, Handle: handle,
222				Err: &daemon.APIError{StatusCode: 410, Code: "operation_expired"},
223			})
224			if command != nil {
225				t.Fatal("expiry scheduled another receipt poll")
226			}
227			m, command = m.Update(tick)
228			if command != nil || m.operationRecoverable(handle.ID()) {
229				t.Fatal("already scheduled tick restarted expired receipt recovery")
230			}
231			if prompt != "." && (len(m.pendingUsers) != 1 || m.pendingUsers[0].Handle != handle || len(m.pendingUsers[0].Images) != 1 || m.pendingUsers[0].Images[0] != image) {
232				t.Fatal("expiry lost the original submission or attachment")
233			}
234		})
235	}
236}