okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
0b9f48f
main
1// Scrollback fetching must preserve position and resume after eviction in the TUI. 2package tui 3 4import ( 5"encoding/json" 6"fmt" 7"net" 8"net/http" 9"net/http/httptest" 10"slices" 11"strings" 12"testing" 13 14"albedo/cli/internal/daemon" 15 tea"charm.land/bubbletea/v2" 16"github.com/charmbracelet/x/ansi" 17) 18 19// turn is one user message and its reply, committed as rows seq and seq+1. 20func turn (name string ,seq int64 ) []daemon.StreamEvent { 21return []daemon.StreamEvent { 22 {Type :daemon .EventUser ,Text :name ,Source :"chat" ,Replayed :true }, 23 {Type :daemon .EventCommitted ,Seq :seq ,Replayed :true }, 24 {Type :daemon .EventMessage ,Text :"reply to " + name + "\n" + strings .Repeat ("more\n" ,8 ),Replayed :true }, 25 {Type :daemon .EventCommitted ,Seq :seq + 1 ,Replayed :true }, 26 } 27} 28 29func rowOf (view ,text string )int { 30for i ,line := range strings .Split (ansi .Strip (view ),"\n" ) { 31if strings .Contains (line ,text ) { 32return i 33 } 34 } 35return - 1 36} 37 38func TestScrollingToTheTopLoadsOlderHistoryInPlace (t * testing.T ) { 39var asked []string 40server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter ,r * http.Request ) { 41asked = append (asked ,r .URL .RawQuery ) 42_ = json .NewEncoder (w ).Encode (map [string ]any {"items" : []any {protocolEntry ("q" ,"user" ,"older question" ,8 ),protocolEntry ("a" ,"assistant" ,"older answer" ,9 )},"older" :nil ,"newer" :nil ,"high_water" :9 }) 43 })) 44defer server .Close () 45conn := daemon .NewConnection (daemon.ConnectionSnapshot {Port :server .Listener .Addr ().(* net.TCPAddr ).Port },nil ) 46client := daemon .NewChatClient (conn ,"s" ) 47m := NewChatModel (& daemon.Session {ID :"s" },client ) 48t .Cleanup (m .Close ) 49m .SetSize (80 ,20 ) 50m .handleStreamEvent (daemon.StreamEvent {Type :daemon .EventReset ,Before :10 ,More :true ,Replayed :true }) 51for i ,name := range []string {"first" ,"second" ,"third" } { 52for _ ,evt := range turn (name ,int64 (10 + 2 * i )) { 53m .handleStreamEvent (evt ) 54 } 55 } 56m .refreshViewportContent () 57 58m .Follow = false 59m .scrollOffset = 1 60m .refreshViewportContent () 61m ,cmd := m .Update (tea.KeyPressMsg {Code :tea .KeyUp }) 62if cmd == nil || ! m .loadingOlder { 63t .Fatal ("reaching the top did not ask for older history" ) 64 } 65before := rowOf (m .Viewport .View (),"first" ) 66if before < 0 { 67t .Fatal ("the first turn is not on screen before loading" ) 68 } 69m ,_ = m .Update (cmd ()) 70if len (asked )!= 1 || ! strings .Contains (asked [0 ],"before=10" ) { 71t .Fatalf ("asked %v" ,asked ) 72 } 73if got := rowOf (m .Viewport .View (),"first" );got != before { 74t .Fatalf ("loading older history moved the view: row %d → %d" ,before ,got ) 75 } 76entries := m .History .Entries () 77if entries [0 ].Text != "older question" || entries [0 ].Seq != 8 { 78t .Fatalf ("older page not prepended: %+v" ,entries [0 ]) 79 } 80if _ ,more := m .olderCursor ();more { 81t .Fatal ("history start not recorded" ) 82 } 83m .scrollOffset = 0 84m .refreshViewportContent () 85if ! strings .Contains (ansi .Strip (m .Viewport .View ()),"older question" ) { 86t .Fatal ("older page not shown above" ) 87 } 88if _ ,cmd := m .Update (tea.KeyPressMsg {Code :tea .KeyUp });cmd != nil { 89t .Fatal ("asked again with nothing older" ) 90 } 91} 92 93func TestEvictedHistoryResumesAfterItsNewestRow (t * testing.T ) { 94h := NewBoundedHistory (3 ,1 << 20 ) 95for seq := int64 (1 );seq <= 3 ;seq ++ { 96h .Append (HistoryEntry {Kind :EntryAssistant ,Text :fmt .Sprint (seq )}) 97h .Stamp (seq ) 98 } 99// Two entries from row 4: the first eviction takes row 1 only. 100h .Append (HistoryEntry {Kind :EntryThinking ,Text :"4a" }) 101h .Append (HistoryEntry {Kind :EntryAssistant ,Text :"4b" }) 102h .Stamp (4 ) 103if h .EvictedThrough ()!= 2 || h .Entries ()[0 ].Text != "3" { 104t .Fatalf ("evicted through %d, first %q" ,h .EvictedThrough (),h .Entries ()[0 ].Text ) 105 } 106// Evicting into row 4 takes both of its entries. 107h .Append (HistoryEntry {Kind :EntryAssistant ,Text :"5" }) 108h .Append (HistoryEntry {Kind :EntryAssistant ,Text :"6" }) 109if h .EvictedThrough ()!= 4 || h .Entries ()[0 ].Text != "5" { 110t .Fatalf ("row 4 split: evicted through %d, first %q" ,h .EvictedThrough (),h .Entries ()[0 ].Text ) 111 } 112h .Prepend ([]HistoryEntry {{Kind :EntryUser ,Text :"old" ,Seq :4 }}) 113if h .EvictedThrough ()!= 0 || h .Entries ()[0 ].Text != "old" { 114t .Fatal ("prepend did not take over the cursor" ) 115 } 116} 117 118func TestOlderPagesReuseBlocksExactlyAsAFreshRebuildDrawsThem (t * testing.T ) { 119// Kept blocks are an invariant across every kind of entry and burst 120// boundary; an e2e sees only the screen, not which rows were reused. 121page := func (from int ) []daemon.StreamEvent { 122var events []daemon.StreamEvent 123for i := from ;i < from + 6 ;i ++ { 124id := fmt .Sprint (i ) 125at := int64 (1_000_000 + i * 60_000 ) 126events = append (events , 127 daemon.StreamEvent {Type :daemon .EventUser ,EntryID :"u" + id ,Position :int64 (i ),Text :"ask " + id ,Timestamp :& at }, 128 daemon.StreamEvent {Type :daemon .EventThinking ,EntryID :"k" + id ,Position :int64 (i ),Text :"think " + id }, 129 daemon.StreamEvent {Type :daemon .EventTool ,EntryID :"t" + id ,Position :int64 (i ),ToolName :"python" ,ToolArgs :map [string ]any {"code" :"print(" + id + ")" },ToolResult :id }, 130 daemon.StreamEvent {Type :daemon .EventMessage ,EntryID :"a" + id ,Position :int64 (i ),Text :"**answer** " + id ,Timestamp :& at }, 131 daemon.StreamEvent {Type :daemon .EventTurnCompleted ,Source :"done" }, 132 ) 133 } 134return events 135 } 136m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 137m .SetSize (100 ,30 ) 138for _ ,event := range page (100 ) { 139m .handleStreamEvent (event ) 140 } 141for from := 94 ;from >= 70 ;from -= 6 { 142m .Follow ,m .scrollOffset ,m .loadingOlder = false ,0 ,true 143m = m .showOlder (ChatOlderLoadedMsg {SessionID :m .SessionID ,Generation :m .Generation ,Page :& daemon.HistoryPage {Events :page (from ),Before :int64 (from ),More :true }}) 144reused := slices .Clone (m .settledLines ) 145m .settled = nil 146m .rebuildSettledLines () 147if ! slices .Equal (reused ,m .settledLines ) { 148t .Fatalf ("after the page from %d, reused rows differ from a fresh rebuild" ,from ) 149 } 150 } 151}