okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
5b3f8be
main
1//go:build unix 2 3// Expired receipts stop recovery without losing the original intent. The real 4// daemon admits each request before the proxy discards its acknowledgement. 5package e2e 6 7import ( 8"errors" 9"fmt" 10"net/http" 11"slices" 12"strings" 13"sync" 14"testing" 15"time" 16 17"albedo/cli/internal/daemon" 18"albedo/cli/internal/tui" 19 tea"charm.land/bubbletea/v2" 20) 21 22func operationRequests (proxy * acknowledgementProxy ) []string { 23proxy .mu .Lock () 24defer proxy .mu .Unlock () 25return slices .DeleteFunc (slices .Clone (proxy .requests ),func (request string )bool { 26if strings .Contains (request ,"/visits/" ) { 27return true 28 } 29if strings .HasPrefix (request ,"PUT /sessions/" ) { 30return false 31 } 32if strings .HasPrefix (request ,"GET /sessions/" ) { 33return ! slices .Contains (proxy .requests ,"PUT " + strings .TrimPrefix (request ,"GET " )) 34 } 35return true 36 }) 37} 38 39func TestTUIExpiredSubmissionsRemainUnresolvedAcrossNavigation (t * testing.T ) { 40entered ,release := make (chan struct {}),make (chan struct {}) 41released := false 42defer func () { 43if ! released { 44close (release ) 45 } 46 }() 47var enteredOnce sync.Once 48profile := providerRoute (t ,func (request map [string ]any )string { 49if lastUserText (request )== "hold expired submissions" { 50enteredOnce .Do (func () {close (entered ) }) 51<- release 52 } 53return echoReply (request ) 54 }) 55t .Parallel () 56session := daemonSession (t ,newSession (t ,t .TempDir ())) 57other := daemonSession (t ,newSession (t ,t .TempDir ())) 58if _ ,err := daemon .NewChatClient (conn (t ),session .ID ).Send (t .Context (),"hold expired submissions" ,nil );err != nil { 59t .Fatal (err ) 60 } 61select { 62case <- entered : 63case <- time .After (10 * time .Second ): 64t .Fatal ("active turn never started" ) 65 } 66proxy := cutAcknowledgement (t ,"/sessions/" + session .ID + "/inputs/" ,expiredAcknowledgement ) 67driver := driveTUIWithConnection (t ,& session ,proxy .connection ) 68t .Cleanup (func () {driver .App .Chat .Close () }) 69driver .Update (tea.WindowSizeMsg {Width :120 ,Height :100 }) 70driver .connected () 71var handles []* daemon.OperationHandle 72for index := range tui .MaxPendingUsers + 1 { 73prompt := fmt .Sprintf ("unresolved user %d" ,index ) 74driver .App .Chat .TextArea .SetValue (prompt ) 75if index == 0 { 76driver .pasteImage (& daemon.ImageAttachment {Data :"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC" ,ImageMetadata : daemon.ImageMetadata {MimeType :daemon .ImagePNG ,Width :1 ,Height :1 ,Bytes :69 }}) 77 } 78var sent tui.ChatTurnSentMsg 79for _ ,message := range driver .results (driver .Update (tea.KeyPressMsg {Code :tea .KeyEnter })) { 80if result ,ok := message .(tui.ChatTurnSentMsg );ok { 81sent = result 82 } 83if cmd := driver .Update (message );cmd != nil { 84if _ ,ok := message .(tui.ChatTurnSentMsg );ok { 85t .Fatalf ("expired submission scheduled recovery: %T" ,message ) 86 } 87 } 88 } 89uncertainty ,ok := errors.AsType [* daemon.UncertainOutcomeError ](sent .Err ) 90if ! ok || uncertainty .Handle != sent .Handle || ! daemon .IsOperationExpired (sent .Err )|| sent .Handle == nil { 91t .Fatalf ("expired outcome lost original handle: %+v" ,sent ) 92 } 93handles = append (handles ,sent .Handle ) 94 } 95driver .App .Chat .TextArea .SetValue ("." ) 96for _ ,message := range driver .results (driver .Update (tea.KeyPressMsg {Code :tea .KeyEnter })) { 97if sent ,ok := message .(tui.ChatTurnSentMsg );ok { 98if sent .Handle == nil || ! daemon .IsOperationExpired (sent .Err ) { 99t .Fatalf ("expired continuation: %+v" ,sent ) 100 } 101handles = append (handles ,sent .Handle ) 102 } 103if cmd := driver .Update (message );cmd != nil { 104if _ ,ok := message .(tui.ChatTurnSentMsg );ok { 105t .Fatal ("expired continuation scheduled recovery" ) 106 } 107 } 108 } 109if len (handles )!= tui .MaxPendingUsers + 2 { 110t .Fatal ("expired turns consumed pending capacity" ) 111 } 112before := operationRequests (proxy ) 113for _ ,handle := range handles { 114if cmd := driver .Update (tui.ChatOperationPollMsg {SessionID :session .ID ,Generation :driver .App .Chat .Generation ,Handle :handle });cmd != nil { 115t .Fatal ("expired operation resumed polling" ) 116 } 117 } 118driver .Update (tui.FolderOpenSessionMsg {Session :other }) 119for _ ,message := range driver .results (driver .Update (tui.FolderOpenSessionMsg {Session :session })) { 120driver .Update (message ) 121 } 122driver .Update (tea.WindowSizeMsg {Width :120 ,Height :100 }) 123view := driver .View () 124for _ ,handle := range handles { 125if ! strings .Contains (view ,handle .ID ()) { 126t .Fatalf ("navigation lost unresolved identity %s:\n%s" ,handle .ID (),view ) 127 } 128 } 129if after := operationRequests (proxy );! slices .Equal (before ,after ) { 130t .Fatalf ("navigation resumed recovery: before=%v after=%v" ,before ,after ) 131 } 132if len (before )!= 2 * len (handles ) { 133t .Fatalf ("expiry retried HTTP admission: %v" ,before ) 134 } 135// A durable event can still reconcile an outcome whose receipt has expired. 136close (release ) 137released = true 138waitIdle (t ,session .ID ,profile ,2 ) 139history ,err := daemon .NewChatClient (conn (t ),session .ID ).History (t .Context (),0 ,120 ) 140if err != nil { 141t .Fatal (err ) 142 } 143matched := false 144for _ ,event := range history .Events { 145if event .Type == daemon .EventUser && event .OperationID == handles [0 ].ID () { 146matched = true 147if len (event .Images )!= 1 || event .Images [0 ].Width != 1 || event .Images [0 ].Height != 1 { 148t .Fatalf ("expired image metadata lost: %+v" ,event ) 149 } 150driver .Update (tui.ChatStreamEventMsg {SessionID :session .ID ,Generation :driver .App .Chat .Generation ,Event :event }) 151 } 152 } 153if ! matched { 154for _ ,event := range history .Events { 155t .Logf ("durable event %s input=%s images=%d" ,event .Type ,event .OperationID ,len (event .Images )) 156 } 157t .Fatal ("durable history did not retain the expired input identity" ) 158 } 159if strings .Contains (driver .View (),handles [0 ].ID ()) { 160t .Fatalf ("durable user echo did not reconcile expired row:\n%s" ,driver .View ()) 161 } 162 163} 164 165func TestTUICreationExpiryStopsRecoveryWithoutSwitchingSession (t * testing.T ) { 166providerRoute (t ,echoReply ) 167// Default-changing tests finish before parallel bodies. These creations 168// send no turns and do not assert which provider the daemon selects. 169t .Parallel () 170initial := daemonSession (t ,newSession (t ,t .TempDir ())) 171other := daemonSession (t ,newSession (t ,t .TempDir ())) 172proxy := cutAcknowledgement (t ,"/sessions" ,unresolvedAcknowledgement ) 173driver := driveTUIWithConnection (t ,& initial ,proxy .connection ) 174t .Cleanup (func () {driver .App .Chat .Close () }) 175uncertain := driver .Send (tui.FolderNewSessionMsg {Workspace :t .TempDir ()}) 176driver .Update (tui.FolderOpenSessionMsg {Session :other }) 177recovery := driver .Update (uncertain ) 178if recovery == nil { 179t .Fatal ("uncertain creation lost recovery" ) 180 } 181proxy .allowReceipt .Store (true ) 182proxy .expireReceipt .Store (true ) 183var tick tea.Msg 184for _ ,message := range driver .results (recovery ) { 185tick = message 186response := driver .Send (message ) 187if cmd := driver .Update (response );cmd != nil { 188t .Fatal ("expired creation scheduled further recovery" ) 189 } 190 } 191if driver .App .ActiveSession .ID != other .ID || ! strings .Contains (driver .View (),"expired" ) { 192t .Fatalf ("creation expiry switched session or lost notice:\n%s" ,driver .View ()) 193 } 194before := operationRequests (proxy ) 195operationID := strings .TrimPrefix (before [len (before )- 1 ],"GET /sessions/" ) 196if ! strings .Contains (driver .View (),operationID ) { 197t .Fatal ("expired creation notice lost its operation identity" ) 198 } 199if tick == nil || driver .Update (tick )!= nil { 200t .Fatal ("scheduled creation tick restarted expired lookup" ) 201 } 202if after := operationRequests (proxy );! slices .Equal (before ,after ) { 203t .Fatal ("creation expiry resumed HTTP recovery" ) 204 } 205} 206 207func TestTUIRejectedSubmissionRestoresPromptAndImage (t * testing.T ) { 208providerRoute (t ,echoReply ) 209t .Parallel () 210driver := newTUIDriver (t ) 211t .Cleanup (func () {driver .App .Chat .Close () }) 212driver .connected () 213image := daemon.ImageAttachment {Data :"invalid" ,ImageMetadata : daemon.ImageMetadata {MimeType :daemon .ImagePNG ,Width :1 ,Height :1 ,Bytes :1 }} 214driver .App .Chat .TextArea .SetValue ("restore this rejected image " ) 215driver .pasteImage (& image ) 216send := func () tui.ChatTurnSentMsg { 217var sent tui.ChatTurnSentMsg 218for _ ,message := range driver .results (driver .Update (tea.KeyPressMsg {Code :tea .KeyEnter })) { 219if result ,ok := message .(tui.ChatTurnSentMsg );ok { 220sent = result 221 } 222driver .Update (message ) 223 } 224return sent 225 } 226sent := send () 227api ,ok := errors.AsType [* daemon.APIError ](sent .Err ) 228if ! ok || api .StatusCode != http .StatusBadRequest || sent .Handle == nil { 229t .Fatalf ("real daemon did not reject image: %+v" ,sent ) 230 } 231if sent .Prompt != "restore this rejected image [Image #1]" || driver .App .Chat .TextArea .Value ()!= sent .Prompt { 232t .Fatalf ("rejection lost the prompt: %q" ,driver .App .Chat .TextArea .Value ()) 233 } 234// Sending the restored prompt again carries the restored image. 235if again := send ();len (again .Images )!= 1 || again .Images [0 ]!= image { 236t .Fatalf ("rejection lost the attachment: %+v" ,again .Images ) 237 } 238// A following valid submission demonstrates that the rejected row left no pending intent. 239driver .App .Chat .TextArea .SetValue ("valid after rejection" ) 240for _ ,message := range driver .results (driver .Update (tea.KeyPressMsg {Code :tea .KeyEnter })) { 241if result ,ok := message .(tui.ChatTurnSentMsg );ok && result .Err != nil { 242t .Fatal (result .Err ) 243 } 244 } 245}