okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
5b3f8be
main
1//go:build unix 2 3// Stream faults are injected ahead of the shared real daemon; the TUI must 4// recover its durable transcript once, then stop visibly if the wire stays bad. 5package e2e 6 7import ( 8"bufio" 9"context" 10"encoding/json" 11"errors" 12"fmt" 13"io" 14"net" 15"net/http" 16"net/http/httptest" 17"net/http/httputil" 18"net/url" 19"slices" 20"strings" 21"sync" 22"testing" 23"time" 24 25"albedo/cli/internal/daemon" 26"albedo/cli/internal/tui" 27 tea"charm.land/bubbletea/v2" 28) 29 30type streamFaultProxy struct { 31connection * daemon.Connection 32mu sync.Mutex 33cursors []string 34generations []string 35firstCursor int64 36firstGeneration string 37reconnected chan struct {} 38} 39 40func faultSessionStream (t * testing.T ,session string ,fault string )* streamFaultProxy { 41t .Helper () 42destination ,err := url .Parse (conn (t ).BaseURL ()) 43if err != nil { 44t .Fatal (err ) 45 } 46forward := httputil .NewSingleHostReverseProxy (destination ) 47direct := forward .Director 48forward .Director = func (r * http.Request ) {direct (r );r .Host = destination .Host } 49forward .FlushInterval = - 1 50proxy := & streamFaultProxy {reconnected :make (chan struct {},1 )} 51server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter ,r * http.Request ) { 52if r .URL .Path == "/sessions/" + session && r .Header .Get ("Accept" )== "text/event-stream" { 53proxy .mu .Lock () 54proxy .cursors = append (proxy .cursors ,r .URL .Query ().Get ("after_seq" )) 55proxy .generations = append (proxy .generations ,r .URL .Query ().Get ("after_generation" )) 56count := len (proxy .cursors ) 57proxy .mu .Unlock () 58if fault == "eof" || fault == "after-history" { 59if count == 1 { 60outgoing := r .Clone (r .Context ()) 61outgoing .URL .Scheme ,outgoing .URL .Host = destination .Scheme ,destination .Host 62outgoing .Host = destination .Host 63outgoing .RequestURI = "" 64response ,err := conn (t ).HTTPClient ().Do (outgoing ) 65if err != nil { 66http .Error (w ,err .Error (),http .StatusBadGateway ) 67return 68 } 69defer response .Body .Close () 70scanner := bufio .NewScanner (response .Body ) 71scanner .Buffer (make ([]byte ,4096 ),10 * 1024 * 1024 ) 72w .Header ().Set ("Content-Type" ,"text/event-stream" ) 73for scanner .Scan () { 74line := scanner .Text () 75_ ,_ = fmt .Fprintln (w ,line ) 76if strings .HasPrefix (line ,"data: " ) { 77var envelope struct { 78Cursor int64 `json:"cursor"` 79Generation string `json:"generation"` 80 } 81if err := json .Unmarshal ([]byte (strings .TrimPrefix (line ,"data: " )),& envelope );err != nil { 82t .Error (err ) 83 } 84proxy .mu .Lock () 85proxy .firstCursor = envelope .Cursor 86proxy .firstGeneration = envelope .Generation 87proxy .mu .Unlock () 88 } 89if line == "" { 90return 91 } 92 } 93if err := scanner .Err ();err != nil { 94t .Errorf ("reading injected EOF frame: %v" ,err ) 95 }else { 96t .Error ("daemon stream ended before a complete SSE frame" ) 97 } 98return 99 } 100if fault == "after-history" && count == 2 { 101w .Header ().Set ("Content-Type" ,"text/event-stream" ) 102_ ,_ = io .WriteString (w ,"data: " + `{"generation":"invalid-replacement","cursor":10,"events":[{"type":"text","sequence":10,"data":{"run_id":"run-a","message_id":"message-a","text":"poison must stay hidden"}}]}` + "\n\n" ) 103return 104 } 105select { 106case proxy .reconnected <- struct {}{}: 107default : 108 } 109 }else if count == 1 || fault == "repeated" { 110w .Header ().Set ("Content-Type" ,"text/event-stream" ) 111_ ,_ = io .WriteString (w ,"data: " + `{"generation":"fault","cursor":10,"events":[{"type":"text","sequence":9,"data":{"run_id":"run-a","message_id":"message-a","text":"poison must stay hidden"}},{"type":"thinking","sequence":10,"data":{"run_id":"run-a","message_id":"message-a","text":false}}]}` + "\n\n" ) 112return 113 } 114 } 115forward .ServeHTTP (w ,r ) 116 })) 117t .Cleanup (server .Close ) 118snapshot := conn (t ).Snapshot () 119snapshot .Port = server .Listener .Addr ().(* net.TCPAddr ).Port 120proxy .connection ,err = daemon .Attach (t .Context (),snapshot ,nil ) 121if err != nil { 122t .Fatal (err ) 123 } 124t .Cleanup (proxy .connection .HTTPClient ().CloseIdleConnections ) 125return proxy 126} 127 128func (proxy * streamFaultProxy )requests () []string { 129proxy .mu .Lock () 130defer proxy .mu .Unlock () 131return slices .Clone (proxy .cursors ) 132} 133 134// Commands run independently, like Bubble Tea: one pending stream read must 135// not prevent the status result or a terminal notice from reaching the model. 136func streamMessages (ctx context.Context ,cmd tea.Cmd ,inbox chan <- tea.Msg ) { 137if cmd == nil { 138return 139 } 140go func () { 141msg := cmd () 142if batch ,ok := msg .(tea.BatchMsg );ok { 143for _ ,next := range batch { 144streamMessages (ctx ,next ,inbox ) 145 } 146return 147 } 148if msg != nil { 149select { 150case inbox <- msg : 151case <- ctx .Done (): 152 } 153 } 154 }() 155} 156 157func pumpStreamUntil (t * testing.T ,driver * tuiDriver ,condition func (tea.Msg )bool ) tea.Msg { 158t .Helper () 159ctx ,cancel := context .WithTimeout (t .Context (),10 * time .Second ) 160defer cancel () 161inbox := make (chan tea.Msg ,32 ) 162streamMessages (ctx ,driver .App .Chat .Init (),inbox ) 163for { 164select { 165case msg := <- inbox : 166cmd := driver .Update (msg ) 167if condition (msg ) { 168return msg 169 } 170streamMessages (ctx ,cmd ,inbox ) 171case <- ctx .Done (): 172t .Fatalf ("stream did not reach expected state:\n%s" ,driver .View ()) 173 } 174 } 175} 176 177func TestTUIStreamProtocolFailureRecoversDurableHistoryOnce (t * testing.T ) { 178profile := providerRoute (t ,echoReply ) 179t .Parallel () 180session := daemonSession (t ,newSession (t ,t .TempDir ())) 181if _ ,err := daemon .NewChatClient (conn (t ),session .ID ).Send (t .Context (),"durable stream recovery" ,nil );err != nil { 182t .Fatal (err ) 183 } 184waitIdle (t ,session .ID ,profile ,1 ) 185proxy := faultSessionStream (t ,session .ID ,"once" ) 186driver := driveTUIWithConnection (t ,& session ,proxy .connection ) 187t .Cleanup (driver .App .Chat .Close ) 188driver .Update (tea.WindowSizeMsg {Width :100 ,Height :50 }) 189recoverySeen := false 190pumpStreamUntil (t ,driver ,func (msg tea.Msg )bool { 191if result ,ok := msg .(tui.ChatStreamResultMsg );ok && result .Recovering { 192recoverySeen = true 193 } 194event ,ok := msg .(tui.ChatStreamEventMsg ) 195return ok && event .Event .Type == daemon .EventMessage && strings .Contains (event .Event .Text ,"durable stream recovery" ) 196 }) 197view := driver .View () 198if ! recoverySeen || strings .Contains (view ,"poison must stay hidden" )|| strings .Count (view ,"durable stream recovery" )!= 2 { 199t .Fatalf ("reset lost or duplicated durable transcript:\n%s" ,view ) 200 } 201requests := proxy .requests () 202if len (requests )!= 2 || requests [1 ]!= "" { 203t .Fatalf ("protocol recovery did not request exactly one durable reset: %v" ,requests ) 204 } 205} 206 207func TestTUIProtocolFailureStopsUntilTheSessionIsReopened (t * testing.T ) { 208providerRoute (t ,echoReply ) 209t .Parallel () 210session := daemonSession (t ,newSession (t ,t .TempDir ())) 211proxy := faultSessionStream (t ,session .ID ,"repeated" ) 212driver := driveTUIWithConnection (t ,& session ,proxy .connection ) 213t .Cleanup (func () {driver .App .Chat .Close () }) 214terminal := pumpStreamUntil (t ,driver ,func (msg tea.Msg )bool { 215result ,ok := msg .(tui.ChatStreamResultMsg ) 216return ok && ! result .Recovering 217 }) 218failure ,ok := errors.AsType [* daemon.StreamError ](terminal .(tui.ChatStreamResultMsg ).Err ) 219if ! ok || failure .Kind != daemon .StreamProtocol || ! driver .App .Chat .Notices .HasError ()|| strings .Contains (driver .View (),"poison must stay hidden" ) { 220t .Fatalf ("protocol stop not visible or malformed text escaped:\n%s" ,driver .View ()) 221 } 222// Wait beyond both initial retry delays: a stopped attachment must stay stopped. 223time .Sleep (1600 * time .Millisecond ) 224if requests := proxy .requests ();len (requests )!= 2 { 225t .Fatalf ("poison stream retried forever: %v" ,requests ) 226 } 227if cmd := driver .Update (tui.ChatStatusPollMsg {SessionID :session .ID ,Generation :driver .App .Chat .Generation });cmd != nil { 228t .Fatal ("terminal attachment resumed status polling" ) 229 } 230driver .Update (tui.FolderOpenSessionMsg {Session :session }) 231before := driver .View () 232if cmd := driver .Update (terminal );cmd != nil || driver .View ()!= before { 233t .Fatal ("stale terminal failure affected the reopened session" ) 234 } 235} 236 237func TestTUIDeletedSessionStopsItsStreamAndStatusPolling (t * testing.T ) { 238providerRoute (t ,echoReply ) 239t .Parallel () 240session := daemonSession (t ,newSession (t ,t .TempDir ())) 241if _ ,err := daemon .DeleteSession (t .Context (),conn (t ),session .ID ,false ,sessionCondition (t ,conn (t ),session .ID ));err != nil { 242t .Fatal (err ) 243 } 244driver := driveTUI (t ,& session ) 245t .Cleanup (driver .App .Chat .Close ) 246msg := pumpStreamUntil (t ,driver ,func (msg tea.Msg )bool { 247result ,ok := msg .(tui.ChatStreamResultMsg ) 248return ok && ! result .Recovering 249 }) 250result := msg .(tui.ChatStreamResultMsg ) 251failure ,ok := errors.AsType [* daemon.StreamError ](result .Err ) 252api ,apiOK := errors.AsType [* daemon.APIError ](result .Err ) 253if ! ok || failure .Kind != daemon .StreamTerminal || ! apiOK || api .StatusCode != http .StatusGone || api .Code != "session_deleted" { 254t .Fatalf ("deleted session did not terminate: %v" ,result .Err ) 255 } 256if cmd := driver .Update (tui.ChatStatusPollMsg {SessionID :session .ID ,Generation :driver .App .Chat .Generation });cmd != nil { 257t .Fatal ("deleted session kept polling" ) 258 } 259if ! driver .App .Chat .Notices .HasError ()|| ! strings .Contains (driver .View (),api .Error ()) { 260t .Fatalf ("deleted session left no visible failure reason:\n%s" ,driver .View ()) 261 } 262} 263 264func TestTUIStreamEOFReconnectsFromConsumedCursor (t * testing.T ) { 265profile := providerRoute (t ,echoReply ) 266t .Parallel () 267session := daemonSession (t ,newSession (t ,t .TempDir ())) 268client := daemon .NewChatClient (conn (t ),session .ID ) 269if _ ,err := client .Send (t .Context (),"before EOF" ,nil );err != nil { 270t .Fatal (err ) 271 } 272waitIdle (t ,session .ID ,profile ,1 ) 273proxy := faultSessionStream (t ,session .ID ,"eof" ) 274driver := driveTUIWithConnection (t ,& session ,proxy .connection ) 275t .Cleanup (driver .App .Chat .Close ) 276driver .Update (tea.WindowSizeMsg {Width :100 ,Height :60 }) 277sent := make (chan error ,1 ) 278go func () { 279select { 280case <- proxy .reconnected : 281_ ,err := client .Send (t .Context (),"after EOF" ,nil ) 282sent <- err 283case <- t .Context ().Done (): 284sent <- t .Context ().Err () 285 } 286 }() 287pumpStreamUntil (t ,driver ,func (msg tea.Msg )bool { 288if result ,ok := msg .(tui.ChatStreamResultMsg );ok { 289t .Errorf ("ordinary EOF reported a terminal or protocol failure: %v" ,result .Err ) 290 } 291event ,ok := msg .(tui.ChatStreamEventMsg ) 292return ok && event .Event .Type == daemon .EventMessage && strings .Contains (event .Event .Text ,"after EOF" ) 293 }) 294if err := <- sent ;err != nil { 295t .Fatal (err ) 296 } 297proxy .mu .Lock () 298requests ,first := slices .Clone (proxy .cursors ),proxy .firstCursor 299generations ,firstGeneration := slices .Clone (proxy .generations ),proxy .firstGeneration 300proxy .mu .Unlock () 301if len (requests )!= 2 || requests [1 ]!= fmt .Sprint (first )|| generations [1 ]!= firstGeneration || firstGeneration == "" { 302t .Fatalf ("EOF lost consumed cursor: requests=%v, cursor=%d" ,requests ,first ) 303 } 304view := driver .View () 305if strings .Count (view ,"before EOF" )!= 2 || strings .Count (view ,"after EOF" )!= 2 { 306t .Fatalf ("EOF replay lost or duplicated rows:\n%s" ,view ) 307 } 308} 309 310func TestTUIResetReplacesHistoryAndPreservesOlderNavigation (t * testing.T ) { 311profile := providerRoute (t ,echoReply ) 312t .Parallel () 313session := daemonSession (t ,newSession (t ,t .TempDir ())) 314client := daemon .NewChatClient (conn (t ),session .ID ) 315const turns = 62 316for index := range turns { 317if _ ,err := client .Send (t .Context (),fmt .Sprintf ("history row %03d" ,index ),nil );err != nil { 318t .Fatal (err ) 319 } 320waitIdle (t ,session .ID ,profile ,index + 1 ) 321 } 322proxy := faultSessionStream (t ,session .ID ,"after-history" ) 323driver := driveTUIWithConnection (t ,& session ,proxy .connection ) 324t .Cleanup (driver .App .Chat .Close ) 325driver .Update (tea.WindowSizeMsg {Width :100 ,Height :50 }) 326recoverySeen := false 327pumpStreamUntil (t ,driver ,func (msg tea.Msg )bool { 328if result ,ok := msg .(tui.ChatStreamResultMsg );ok && result .Recovering { 329recoverySeen = true 330 } 331event ,ok := msg .(tui.ChatStreamEventMsg ) 332return recoverySeen && ok && event .Event .Type == daemon .EventMessage && strings .Contains (event .Event .Text ,"history row 061" ) 333 }) 334if requests := proxy .requests ();len (requests )!= 3 || requests [2 ]!= "" { 335t .Fatalf ("generation failure did not recover with a fresh subscription: %v" ,requests ) 336 } 337seen := map [string ]int {} 338for _ ,entry := range driver .App .Chat .History .Entries () { 339seen [entry .Text ]++ 340 } 341if seen ["history row 061" ]!= 1 || seen ["history row 000" ]!= 0 || strings .Contains (driver .View (),"poison must stay hidden" ) { 342t .Fatalf ("reset duplicated history or lost the tail boundary: %v" ,seen ) 343 } 344for range 4 { 345driver .Dispatch (tea.KeyPressMsg {Code :tea .KeyHome ,Mod :tea .ModCtrl }) 346 } 347seen = map [string ]int {} 348for _ ,entry := range driver .App .Chat .History .Entries () { 349seen [entry .Text ]++ 350 } 351if seen ["history row 000" ]!= 1 || seen ["history row 061" ]!= 1 { 352t .Fatalf ("older navigation after reset lost or duplicated rows: %v" ,seen ) 353 } 354}