okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
78011da
main
1// Invariants the e2e suite cannot reach, because each lives inside the 2// tui package or needs a failure the daemon never produces: 3// 4// - frame pacing: an idle graph must not keep scheduling animation frames, 5// a running one must resume them exactly once (wasted CPU, double speed); 6// - normalized replacement windows must survive interleaved calls without 7// settling another call's live preview into history; 8// - a pending delete confirm must not outlive the agent it names, and a 9// stray key must neither delete nor keep it by accident (the confirm 10// answers enter and esc only); 11// - a failed history seed must retry on the next selection, and a late 12// error from an older generation must not unseed the live node; 13// - overflow during a blocked mutation must preserve its later outcome and draft. 14package tui 15 16import ( 17"encoding/json" 18"errors" 19"net/http" 20"net/http/httptest" 21"net/url" 22"strconv" 23"strings" 24"testing" 25"time" 26 27"albedo/cli/internal/daemon" 28 29 tea"charm.land/bubbletea/v2" 30 31"github.com/charmbracelet/x/ansi" 32) 33 34func agentsFixture (t * testing.T )AgentsViewModel { 35t .Helper () 36m := NewAgentsViewModel (nil ,"lead" ) 37m .Gen = 1 38m .streamReady = true 39m .SetSize (120 ,30 ) 40lead := "lead" 41coder := "coder" 42m ,_ = m .Update (agentsSnapshotMsg {Gen :1 ,Root :"lead" ,Nodes : []daemon.AgentNode { 43 {Session : daemon.Session {ID :"lead" ,Title :"lead" ,Model :"gpt-6-luna" },Name :"lead" ,Running :true }, 44 {Session : daemon.Session {ID :"scout" ,Model :"gpt-6-luna" },Parent :& lead ,Name :"scout" ,Depth :1 }, 45 {Session : daemon.Session {ID :"coder" ,Model :"gpt-6-luna" },Parent :& lead ,Name :"coder" ,Depth :1 ,Running :true }, 46 {Session : daemon.Session {ID :"tests" ,Model :"gpt-6-luna" },Parent :& coder ,Name :"tests" ,Depth :2 }, 47 }}) 48return m 49} 50 51// frames runs cmd and counts the frame ticks it schedules. 52func frames (cmd tea.Cmd )int { 53if cmd == nil { 54return 0 55 } 56switch msg := cmd ().(type ) { 57case tea.BatchMsg : 58n := 0 59for _ ,c := range msg { 60n += frames (c ) 61 } 62return n 63case agentsFrameMsg : 64return 1 65 } 66return 0 67} 68 69func TestAgentsFramesStopWhenStillAndResumeOnce (t * testing.T ) { 70m := agentsFixture (t ) 71closed := make (chan tea.Msg ) 72close (closed ) 73m .events = closed 74running := func (id string ,on bool )agentsEventsMsg { 75return agentsEventsMsg {Gen :1 ,Events : []daemon.AgentEvent {{Type :"running" ,Session :id ,Running :on }}} 76 } 77m ,_ = m .Update (running ("lead" ,false )) 78m ,_ = m .Update (running ("coder" ,false )) 79m ,cmd := m .Update (agentsFrameMsg {Gen :1 }) 80if n := frames (cmd );n != 0 { 81t .Fatalf ("a still graph scheduled %d frames, want none" ,n ) 82 } 83m ,cmd = m .Update (running ("coder" ,true )) 84if n := frames (cmd );n != 1 { 85t .Fatalf ("a running agent scheduled %d frames, want one" ,n ) 86 } 87_ ,cmd = m .Update (running ("lead" ,true )) 88if n := frames (cmd );n != 0 { 89t .Fatalf ("a second runner scheduled %d more frames, want none" ,n ) 90 } 91} 92 93func TestAgentsProgressResultKeepsOtherInterleavedCall (t * testing.T ) { 94m := agentsFixture (t ) 95m .selected = "coder" 96update := func (event daemon.AgentEvent ) { 97m ,_ = m .Update (agentsEventsMsg {Gen :1 ,Events : []daemon.AgentEvent {event }}) 98 } 99callA := & daemon.ToolProgress {CallID :"run:1:0" ,ToolCallID :"native-a" ,Name :"python" ,Phase :"generating" ,Code :& daemon.ToolCodePreview {Text :"print('a')" }} 100callB := & daemon.ToolProgress {CallID :"run:1:1" ,ToolCallID :"native-b" ,Name :"python" ,Phase :"generating" ,Code :& daemon.ToolCodePreview {Text :"print('b')" }} 101update (daemon.AgentEvent {Type :"tool_progress" ,Session :"coder" ,Progress :callA }) 102update (daemon.AgentEvent {Type :"tool_progress" ,Session :"coder" ,Progress :callB }) 103update (daemon.AgentEvent {Type :"tool_progress" ,Session :"coder" ,Progress :& daemon.ToolProgress { 104CallID :callA .CallID ,ToolCallID :callA .ToolCallID ,Name :"python" ,Phase :"running" , 105 }}) 106update (daemon.AgentEvent {Type :"tool_progress" ,Session :"coder" ,Progress :& daemon.ToolProgress { 107CallID :callB .CallID ,ToolCallID :callB .ToolCallID ,Name :"python" ,Phase :"generating" ,Code :& daemon.ToolCodePreview {Offset :4 ,Text :"print('b2')" }, 108 }}) 109update (daemon.AgentEvent {Type :"tool" ,Session :"coder" ,CallID :"native-a" ,ProgressCallID :callA .CallID ,Name :"python" ,Output :"done" }) 110got := ansi .Strip (m .View ()) 111if strings .Count (got ,"print('b2')" )!= 1 || strings .Contains (got ,"print('b')" )|| ! strings .Contains (got ,"done" ) { 112t .Fatalf ("call A's result lost, appended, or duplicated call B's replacement window: %q" ,got ) 113 } 114} 115 116func TestAgentsDeleteConfirmDropsWithTheAgent (t * testing.T ) { 117m := agentsFixture (t ) 118m .selected = "coder" 119m ,_ = m .key (tea.KeyPressMsg {Code :'x' ,Mod :tea .ModCtrl }) 120if m .confirm .target != "coder" { 121t .Fatal ("ctrl+x should ask before deleting coder" ) 122 } 123m ,_ = m .Update (agentsEventsMsg {Gen :1 ,Events : []daemon.AgentEvent { 124 {Type :"gone" ,Session :"coder" }, 125 }}) 126if m .confirm .asking () { 127t .Fatalf ("the confirm prompt outlived the agent it named:\n%s" ,ansi .Strip (m .View ())) 128 } 129if strings .Contains (ansi .Strip (m .View ()),"Delete coder" ) { 130t .Fatal ("the view still offers to delete a vanished agent" ) 131 } 132m .selected = "tests" 133m ,_ = m .key (tea.KeyPressMsg {Code :'x' ,Mod :tea .ModCtrl }) 134if m .confirm .target != "tests" { 135t .Fatal ("ctrl+x stopped working after the prompt was dropped" ) 136 } 137} 138 139// A seed that fails must not lock the node out of history: the next selection 140// retries, and only the failure's own generation may unseed the node. 141func TestFailedSeedRetriesOnTheNextSelection (t * testing.T ) { 142var calls int 143server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter ,r * http.Request ) { 144calls ++ 145if calls == 1 { 146http .Error (w ,`{"detail":"history is gone"}` ,http .StatusInternalServerError ) 147return 148 } 149session := protocolSession ("coder" ,generationA ,0 ) 150session ["preview" ].(map [string ]any )["transcript_count" ]= 2 151session ["history" ].(map [string ]any )["items" ]= []any {protocolEntry ("q" ,"user" ,"fan out three scouts" ,1 ),protocolEntry ("a" ,"assistant" ,"sent them off" ,2 )} 152_ = json .NewEncoder (w ).Encode (session ) 153 })) 154defer server .Close () 155address ,_ := url .Parse (server .URL ) 156port ,_ := strconv .Atoi (address .Port ()) 157 158m := agentsFixture (t ) 159m .Conn = daemon .NewConnection (daemon.ConnectionSnapshot {Port :port ,Token :"t" ,Version :daemon .ProtocolVersion },nil ) 160m .selected = "coder" 161 162cmd := m .seedCmd () 163if cmd == nil { 164t .Fatal ("an unseeded agent should ask for history" ) 165 } 166msg := cmd () 167failure ,ok := msg .(agentsSeedErrMsg ) 168if ! ok || failure .Err == nil { 169t .Fatalf ("the failed request should report itself, got %T" ,msg ) 170 } 171m ,_ = m .Update (failure ) 172if m .nodes ["coder" ].seeded { 173t .Fatal ("a failed seed left the node marked seeded" ) 174 } 175if ! strings .Contains (m .notice ,failure .Err .Error ()) { 176t .Fatalf ("the notice should carry the failure, got %q" ,m .notice ) 177 } 178 179cmd = m .seedCmd () 180if cmd == nil { 181t .Fatal ("the next selection should retry after a failed seed" ) 182 } 183msg = cmd () 184seed ,ok := msg .(agentsSeedMsg ) 185if ! ok { 186t .Fatalf ("the retry should answer with history, got %T" ,msg ) 187 } 188m ,_ = m .Update (seed ) 189for _ ,want := range []string {"← fan out three scouts" ,"sent them off" } { 190if ! strings .Contains (ansi .Strip (m .View ()),want ) { 191t .Fatalf ("the retried seed should show %q in the tail:\n%s" ,want ,ansi .Strip (m .View ())) 192 } 193 } 194 195// An error from an earlier generation may not unseed the live node. 196m ,_ = m .Update (agentsSeedErrMsg {Gen :m .Gen - 1 ,ID :"coder" ,Err :errors .New ("late" )}) 197if ! m .nodes ["coder" ].seeded { 198t .Fatal ("a stale seed error unseeded the live node" ) 199 } 200if cmd := m .seedCmd ();cmd != nil { 201t .Fatal ("a seeded node should not fetch again" ) 202 } 203} 204 205func TestAgentsOverflowPreservesPendingOperationOutcomeAndDraft (t * testing.T ) { 206started ,release := make (chan struct {}),make (chan struct {}) 207server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter ,r * http.Request ) { 208if r .URL .Path == "/sessions" && r .Header .Get ("Accept" )== "text/event-stream" { 209<- r .Context ().Done () 210return 211 } 212if r .Method != http .MethodPut || ! strings .HasPrefix (r .URL .Path ,"/sessions/" ) { 213t .Errorf ("unexpected request: %s" ,r .URL .Path ) 214w .WriteHeader (404 ) 215return 216 } 217close (started ) 218<- release 219w .WriteHeader (http .StatusForbidden ) 220_ ,_ = w .Write ([]byte (`{"detail":"spawn refused"}` )) 221 })) 222defer server .Close () 223defer func () { 224select { 225case <- release : 226default : 227close (release ) 228 } 229 }() 230address ,_ := url .Parse (server .URL ) 231port ,_ := strconv .Atoi (address .Port ()) 232m := agentsFixture (t ) 233m .Conn = daemon .NewConnection (daemon.ConnectionSnapshot {Port :port },nil ) 234for _ ,char := range "/spawn helper inspect files" { 235m ,_ = m .Update (tea.KeyPressMsg {Code :char ,Text :string (char )}) 236 } 237var cmd tea.Cmd 238m ,cmd = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 239finished := make (chan tea.Msg ,1 ) 240go func () {finished <- cmd () }() 241select { 242case <- started : 243case <- time .After (5 * time .Second ): 244t .Fatal ("spawn never reached server" ) 245 } 246for _ ,char := range "next draft" { 247m ,_ = m .Update (tea.KeyPressMsg {Code :char ,Text :string (char )}) 248 } 249m ,_ = m .Update (agentsEventsMsg {Gen :m .Gen ,Events : []daemon.AgentEvent {{Type :"overflow" }}}) 250defer func () {m .Close () }() 251close (release ) 252select { 253case outcome := <- finished : 254m ,_ = m .Update (outcome ) 255case <- time .After (5 * time .Second ): 256t .Fatal ("spawn did not complete" ) 257 } 258rendered := ansi .Strip (m .View ()) 259if ! strings .Contains (rendered ,"spawn refused" )|| ! strings .Contains (rendered ,"next draft" ) { 260t .Fatalf ("overflow lost pending outcome or draft: %s" ,rendered ) 261 } 262} 263 264func TestAgentsClosedViewRetainsInFlightOutcomeAndUnknownDecision (t * testing.T ) { 265m := agentsFixture (t ) 266_ = m .sendCmd ("scout" ,"retained draft" ) 267if len (m .pendingOperations )!= 1 { 268t .Fatal ("admission was not retained before dispatch" ) 269 } 270var pending agentPendingOperation 271for _ ,value := range m .pendingOperations { 272pending = value 273 } 274gen := m .viewGen 275m .Close () 276m ,_ = m .Update (agentsSentMsg {Gen :gen ,Handle :pending .Handle ,Draft :pending .Draft ,Action :pending .Action ,Err :& daemon.UncertainOutcomeError {Operation :"submit" ,Cause :errors .New ("lost response" )}}) 277m ,_ = m .Update (agentsSentMsg {Gen :m .viewGen ,Handle :pending .Handle ,Draft :pending .Draft ,Action :pending .Action ,Recovery :true ,Err :& daemon.APIError {StatusCode :404 ,Code :"not_found" }}) 278if retained ,ok := m .pendingOperations [pending .Handle .ID ()];! ok || retained .InFlight || m .input .Value ()!= "" { 279t .Fatalf ("unknown retained outcome restored a replayable draft: %#v %q" ,m .pendingOperations ,m .input .Value ()) 280 } 281m ,_ = m .Update (agentsSentMsg {Gen :m .viewGen ,Handle :pending .Handle ,Notice :"confirmed" }) 282if len (m .pendingOperations )!= 0 { 283t .Fatal ("confirmed request remained unresolved" ) 284 } 285} 286 287func TestAgentsTerminalEventsCannotReviveObsoleteCode (t * testing.T ) { 288for _ ,kind := range []string {"error" ,"interrupted" ,"closed" } { 289t .Run (kind ,func (t * testing.T ) { 290m := agentsFixture (t ) 291m .selected = "coder" 292update := func (events ... daemon.AgentEvent ) { 293m ,_ = m .Update (agentsEventsMsg {Gen :1 ,Events :events }) 294 } 295update (daemon.AgentEvent {Type :"tool_progress" ,Session :"coder" ,Progress :& daemon.ToolProgress {CallID :"old" ,Name :"python" ,Phase :"generating" ,Code :& daemon.ToolCodePreview {Text :"obsolete_code_sentinel" }}}) 296update (daemon.AgentEvent {Type :kind ,Session :"coder" ,Text :"failed" }) 297update (daemon.AgentEvent {Type :"tool_progress" ,Session :"coder" ,Progress :& daemon.ToolProgress {CallID :"fresh" ,Name :"python" ,Phase :"generating" ,Code :& daemon.ToolCodePreview {Text :"fresh_code_sentinel" }}}) 298update (daemon.AgentEvent {Type :"tool" ,Session :"coder" ,ProgressCallID :"fresh" ,Name :"python" ,Output :"finished" }) 299got := ansi .Strip (m .View ()) 300if strings .Count (got ,"obsolete_code_sentinel" )> 1 || ! strings .Contains (got ,"fresh_code_sentinel" )|| ! strings .Contains (got ,"finished" ) { 301t .Fatalf ("%s revived obsolete code or lost the fresh result: %q" ,kind ,got ) 302 } 303 }) 304 } 305} 306 307// Delete is enter and esc only: a stray key that deleted, or cancelled, on 308// the way past would lose or keep an agent without the question being read. 309func TestAgentsDeleteAsksWithEnterAndEsc (t * testing.T ) { 310m := agentsFixture (t ) 311m .selected = "coder" 312m ,_ = m .key (tea.KeyPressMsg {Code :'x' ,Mod :tea .ModCtrl }) 313if ! strings .Contains (ansi .Strip (m .View ()),"Delete coder and the agent below it?" )|| ! strings .Contains (ansi .Strip (m .View ()),"esc cancel" ) { 314t .Fatalf ("ctrl+x should show the shared confirm:\n%s" ,ansi .Strip (m .View ())) 315 } 316m ,cmd := m .key (tea.KeyPressMsg {Code :'y' ,Text :"y" }) 317if cmd != nil || m .confirm .target != "coder" { 318t .Fatal ("a stray key answered the delete question" ) 319 } 320m ,cmd = m .key (tea.KeyPressMsg {Code :tea .KeyEscape }) 321if cmd != nil || m .confirm .asking ()|| ! strings .Contains (m .notice ,"kept" ) { 322t .Fatalf ("esc should keep the agent: notice %q" ,m .notice ) 323 } 324m ,_ = m .key (tea.KeyPressMsg {Code :'x' ,Mod :tea .ModCtrl }) 325m ,cmd = m .key (tea.KeyPressMsg {Code :tea .KeyEnter }) 326if cmd == nil || m .confirm .asking () { 327t .Fatal ("enter should delete the agent the question named" ) 328 } 329}