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