okami.mom/albedo

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

nekomimi.petfeat(usage): time provider calls and show tok/s on the turn signoff0a3eddd

main
10.1 KiB201 linesraw
1// These tests catch mutable live-buffer ownership and chunk-boundary
2// regressions, and signoffs placed by event order. Daemon E2E cannot retain Go
3// model copies, control the exact delta boundaries received by the terminal,
4// or see rows. Copies here are retained readers; only the current update path
5// appends, settles, or discards live text.
6package tui
7
8import (
9	"slices"
10	"strings"
11	"testing"
12	"unsafe"
13
14	"albedo/cli/internal/daemon"
15)
16
17func TestObservedZeroThinkingDurationStopsLocalClock(t *testing.T) {
18	state := newTranscriptState()
19	state.apply(daemon.StreamEvent{Type: daemon.EventThinking, Text: "thought"}, "agent")
20	if state.thinkingSince.IsZero() {
21		t.Fatal("live thought did not start its local clock")
22	}
23	state.apply(daemon.StreamEvent{Type: daemon.EventThinking, ElapsedObserved: true, ElapsedMs: 0}, "agent")
24	if !state.thinkingSince.IsZero() || state.thoughtMs != 0 {
25		t.Fatal("observed zero duration was treated as an unknown duration")
26	}
27}
28
29// Completion is a separate canonical observation; the server sends no
30// synthetic interrupted event. It must settle the live tail and close the
31// turn once, including its stopped/failed footer.
32func TestCanonicalCompletionSettlesAndClosesLiveTurn(t *testing.T) {
33	for _, source := range []string{"", "interrupted", "failed"} {
34		t.Run(source, func(t *testing.T) {
35			state := newTranscriptState()
36			state.apply(daemon.StreamEvent{Type: daemon.EventUser, Text: "question", Source: "chat"}, "agent")
37			state.apply(daemon.StreamEvent{Type: daemon.EventText, Text: "answer", MessageID: "live-answer"}, "agent")
38			entries := state.apply(daemon.StreamEvent{Type: daemon.EventTurnCompleted, Source: source}, "agent")
39			if state.turn != nil || state.activeText != "" || len(entries) != 2 || entries[0].Kind != EntryAssistant || entries[0].Text != "answer" || entries[1].Kind != EntryTurnEnd {
40				t.Fatalf("completion did not settle and close the turn: %#v", entries)
41			}
42			if source == "interrupted" && entries[1].Mood != moodStopped || source == "failed" && entries[1].Mood != moodFailed {
43				t.Fatalf("completion lost its terminal outcome: %#v", entries[1])
44			}
45			if repeated := state.apply(daemon.StreamEvent{Type: daemon.EventTurnCompleted, Source: source}, "agent"); len(repeated) != 0 {
46				t.Fatalf("repeated completion created a duplicate footer: %#v", repeated)
47			}
48		})
49	}
50}
51
52func TestChatModelSnapshotsAndHistoryOwnTheirText(t *testing.T) {
53	model := newTestChatModel(t, &daemon.Session{ID: "snapshot"})
54	model.SetSize(80, 24)
55	appendThought := func(text string) {
56		model, _ = model.Update(ChatStreamEventMsg{
57			SessionID: model.SessionID, Generation: model.Generation,
58			Event: daemon.StreamEvent{Type: daemon.EventThinking, Text: text},
59		})
60	}
61	appendThought("old thought")
62	retained := model
63	oldThought := retained.renderThought()
64	appendThought(" continued")
65	continued := model
66	appendThought(strings.Repeat(" extended", 1024))
67	if retained.transcript.activeText != "old thought" || retained.renderThought() != oldThought || continued.transcript.activeText != "old thought continued" {
68		t.Fatal("append or growth changed a retained snapshot")
69	}
70	liveText := model.transcript.activeText
71	model, _ = model.Update(ChatStreamEventMsg{
72		SessionID: model.SessionID, Generation: model.Generation,
73		Event: daemon.StreamEvent{Type: daemon.EventUsage},
74	})
75	history := model.History.Entries()
76	if len(history) != 1 || history[0].Text != liveText {
77		t.Fatal("settlement lost the live text")
78	}
79	// Equal bytes alone cannot detect history retaining the buffer's spare capacity.
80	if unsafe.StringData(history[0].Text) == unsafe.StringData(liveText) {
81		t.Fatal("settled history shares live-buffer storage")
82	}
83	if model.transcript.activeBuffer != nil || model.transcript.activeText != "" {
84		t.Fatal("settlement retained the live buffer")
85	}
86	appendThought("replacement")
87	if retained.transcript.activeText != "old thought" || history[0].Text != liveText {
88		t.Fatal("new stream changed a retained snapshot or settled history")
89	}
90}
91
92func TestTranscriptDiscardPreservesRetainedReaders(t *testing.T) {
93	for _, eventType := range []daemon.EventType{daemon.EventRetry, daemon.EventReset} {
94		t.Run(string(eventType), func(t *testing.T) {
95			state := newTranscriptState()
96			state.apply(daemon.StreamEvent{Type: daemon.EventText, Text: "discarded"}, "agent")
97			retained := state
98			if entries := state.apply(daemon.StreamEvent{Type: eventType}, "agent"); len(entries) != 0 {
99				t.Fatalf("discard settled %d entries", len(entries))
100			}
101			if state.activeBuffer != nil || state.activeText != "" || state.activeKind != StreamKindNone {
102				t.Fatal("discard retained live buffer ownership")
103			}
104			state.apply(daemon.StreamEvent{Type: daemon.EventText, Text: "fresh", MessageID: "fresh-live"}, "agent")
105			state.apply(daemon.StreamEvent{Type: daemon.EventMessage, Text: "fresh", EntryID: "fresh-stored", Position: 1}, "agent")
106			entries := state.apply(daemon.StreamEvent{Type: daemon.EventCommitted, Seq: 1, ReplacesLiveIDs: []string{"fresh-live"}}, "agent")
107			if retained.activeText != "discarded" || len(entries) != 1 || entries[0].Text != "fresh" {
108				t.Fatal("discard changed a retained reader or failed to reset message deduplication")
109			}
110		})
111	}
112}
113
114func TestTranscriptSplitsStrictlyAfterAppendingPastLimit(t *testing.T) {
115	for _, eventType := range []daemon.EventType{daemon.EventText, daemon.EventThinking} {
116		t.Run(string(eventType), func(t *testing.T) {
117			state := newTranscriptState()
118			atLimit := strings.Repeat("a", MaxLiveStreamBytes)
119			entries := state.apply(daemon.StreamEvent{Type: eventType, Text: atLimit}, "agent")
120			if len(entries) != 0 || state.activeText != atLimit || state.activeBuffer == nil {
121				t.Fatal("stream settled at exactly the byte limit")
122			}
123			retained := state
124			entries = state.apply(daemon.StreamEvent{Type: eventType, Text: "b"}, "agent")
125			if len(entries) != 1 || entries[0].Text != atLimit+"b" || retained.activeText != atLimit {
126				t.Fatal("crossing split omitted the final delta or changed a retained reader")
127			}
128			if state.activeBuffer != nil || state.activeText != "" || state.activeKind != retained.activeKind {
129				t.Fatal("automatic settlement did not drop the buffer and retain stream kind")
130			}
131			oversized := strings.Repeat("c", MaxLiveStreamBytes*2+1)
132			entries = state.apply(daemon.StreamEvent{Type: eventType, Text: oversized}, "agent")
133			if len(entries) != 1 || entries[0].Text != oversized || state.activeBuffer != nil || state.activeText != "" {
134				t.Fatal("oversized delta was split internally or retained a live buffer")
135			}
136		})
137	}
138}
139
140func TestCommittedIdentityReplacesAllProvisionalChunks(t *testing.T) {
141	model := newTestChatModel(t, &daemon.Session{ID: "s"})
142	first := strings.Repeat("a", MaxLiveStreamBytes+1)
143	model.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventText, Text: first, MessageID: "live-text"})
144	model.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventThinking, Text: "thought", MessageID: "live-thought"})
145	model.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventText, Text: "second", MessageID: "live-text"})
146	model.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventUsage})
147	model.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventMessage, Text: first + "second", EntryID: "stored-text", Position: 7})
148	model.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventThinking, Text: "thought", EntryID: "stored-thought", Position: 8})
149	model.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventCommitted, Seq: 8, ReplacesAllLive: true, ReplacesLiveIDs: []string{}})
150	entries := model.History.Entries()
151	if len(entries) != 2 || entries[0].ID != "stored-text" || entries[0].Text != first+"second" || entries[1].ID != "stored-thought" || entries[1].MessageID != "" {
152		t.Fatalf("commit retained provisional chunks or lost canonical content: %#v", entries)
153	}
154}
155
156// A turn's rate is its latest timed call's: a usage record that arrives again
157// changes nothing, and a call the daemon did not time leaves the rate alone.
158func TestTurnRateIsItsLatestTimedCall(t *testing.T) {
159	rate := func(tokensPerSecond float64) *daemon.Usage {
160		return &daemon.Usage{TokensPerSecond: &tokensPerSecond}
161	}
162	state := newTranscriptState()
163	state.apply(daemon.StreamEvent{Type: daemon.EventUser, Source: "chat", Text: "hi"}, "agent")
164	state.apply(daemon.StreamEvent{Type: daemon.EventText, Text: "answer"}, "agent")
165	state.apply(daemon.StreamEvent{Type: daemon.EventUsage, Usage: rate(40)}, "agent")
166	state.apply(daemon.StreamEvent{Type: daemon.EventUsage, Usage: rate(84.2)}, "agent")
167	state.apply(daemon.StreamEvent{Type: daemon.EventUsage, Usage: rate(84.2)}, "agent")
168	state.apply(daemon.StreamEvent{Type: daemon.EventUsage, Usage: &daemon.Usage{}}, "agent")
169	entries := state.apply(daemon.StreamEvent{Type: daemon.EventTurnCompleted}, "agent")
170	if len(entries) != 1 || entries[0].Kind != EntryTurnEnd || entries[0].TokensPerSecond != 84.2 {
171		t.Fatalf("turn ended with entries %+v, want a signoff at 84.2 tok/s", entries)
172	}
173}
174
175func TestANoteTheAgentNeverAnsweredHasNoSignoff(t *testing.T) {
176	// /link and /work queue a note; it commits right before your next message.
177	entries := replayTranscript([]daemon.StreamEvent{
178		{Type: daemon.EventUser, Source: "links", Text: "linked with chernobog:~/proj/albedo", Replayed: true},
179		{Type: daemon.EventUser, Source: "chat", Text: "hi", Replayed: true},
180		{Type: daemon.EventMessage, Text: "hello", Replayed: true},
181	}, "albedo")
182	var kinds []EntryKind
183	for _, entry := range entries {
184		kinds = append(kinds, entry.Kind)
185	}
186	if want := []EntryKind{EntryUser, EntryUser, EntryAssistant}; !slices.Equal(kinds, want) {
187		t.Fatalf("entries %v, want %v", kinds, want)
188	}
189}
190
191func TestTranscriptDeduplicatedMessageKeepsCommittedIdentity(t *testing.T) {
192	state := newTranscriptState()
193	state.apply(daemon.StreamEvent{Type: daemon.EventText, Text: "answer", MessageID: "live-answer"}, "agent")
194	state.apply(daemon.StreamEvent{
195		Type: daemon.EventMessage, Text: "answer", EntryID: "message-1", Position: 7,
196	}, "agent")
197	entries := state.apply(daemon.StreamEvent{Type: daemon.EventCommitted, Seq: 7, ReplacesLiveIDs: []string{"live-answer"}}, "agent")
198	if len(entries) != 1 || entries[0].ID != "message-1" || entries[0].Seq != 7 {
199		t.Fatalf("settled message lost committed identity: %#v", entries)
200	}
201}