okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
53cd699
main
1//go:build unix 2 3// A read through a ChatClient built before a real daemon restart refreshes 4// its connection before another turn without losing the first turn's transcript, 5// and an open session stream follows the session to the restarted daemon. 6// A fake daemon cannot exercise process lifetime or on-disk session persistence. 7package e2e 8 9import ( 10"context" 11"fmt" 12"strings" 13"testing" 14"time" 15 16"albedo/cli/internal/daemon" 17"albedo/cli/internal/tui" 18 19 tea"charm.land/bubbletea/v2" 20) 21 22// restartDaemon replaces the suite daemon under the same hermetic home and 23// records its new pid for later scenarios. 24func restartDaemon (t * testing.T ) daemon.ConnectionSnapshot { 25t .Helper () 26old := suite .daemonPID 27 28ctx ,cancel := context .WithTimeout (context .Background (),15 * time .Second ) 29defer cancel () 30if err := daemon .StopDaemon (ctx ,conn (t ));err != nil { 31t .Fatalf ("shutdown request: %v" ,err ) 32 } 33if ! awaitExit (old ,15 * time .Second ) { 34t .Fatalf ("daemon %d did not exit after shutdown" ,old ) 35 } 36 37if _ ,stderr ,err := runCLI ("sessions" );err != nil { 38t .Fatalf ("rebooting the daemon: %v\n%s" ,err ,stderr ) 39 } 40deadline := time .Now ().Add (2 * time .Minute ) 41for { 42snap ,err := readDaemonSnapshot (suite .home ) 43if err == nil && snap .Pid != old { 44suite .daemonPID = snap .Pid 45return snap 46 } 47if time .Now ().After (deadline ) { 48t .Fatalf ("no daemon other than %d published daemon.json after the restart" ,old ) 49 } 50time .Sleep (100 * time .Millisecond ) 51 } 52} 53 54// TestChatClientReadReconnectsAfterDaemonRestart recovers a read before 55// sending another turn through the same client and persisted session. 56func TestChatClientReadReconnectsAfterDaemonRestart (t * testing.T ) { 57profile := providerRoute (t ,echoReply ) 58workspace := t .TempDir () 59id := newSession (t ,workspace ) 60configured := daemonSession (t ,id ) 61if _ ,err := daemon .SelectModel (t .Context (),conn (t ),id , daemon.ModelSelectionRequest {Model :"o3" ,Effort :"high" ,ETag :configured .ETag });err != nil { 62t .Fatalf ("select a model with a default effort: %v" ,err ) 63 } 64configured = daemonSession (t ,id ) 65if configured .Effort != "high" { 66t .Fatalf ("selected effort is %q, want high" ,configured .Effort ) 67 } 68if _ ,err := daemon .SelectEffort (t .Context (),conn (t ),configured ,"" );err != nil { 69t .Fatalf ("clear the effort preference: %v" ,err ) 70 } 71if cleared := daemonSession (t ,id );cleared .Effort != "" { 72t .Fatalf ("cleared effort is %q" ,cleared .Effort ) 73 } 74 75// Reads and turns use the same client across the restart so they exercise 76// connection recovery rather than a new attachment. 77shared := conn (t ) 78client := daemon .NewChatClient (shared ,id ) 79 80prompt1 := "turn before the restart" 81if _ ,err := client .Send (context .Background (),prompt1 ,nil );err != nil { 82t .Fatalf ("first send: %v" ,err ) 83 } 84waitIdle (t ,id ,profile ,1 ) 85 86oldSnap := shared .Snapshot () 87snap := restartDaemon (t ) 88if reopened := daemonSession (t ,id );reopened .Effort != "" { 89t .Fatalf ("restart replaced the cleared effort with %q" ,reopened .Effort ) 90 } 91 92page ,err := client .History (context .Background (),0 ,120 ) 93if err != nil || len (page .Events )== 0 { 94t .Fatalf ("read recovery after restart: %+v, %v" ,page ,err ) 95 } 96 97prompt2 := "turn after the restart" 98sent ,err := client .Send (context .Background (),prompt2 ,nil ) 99if err != nil || sent == nil || ! sent .OK { 100t .Fatalf ("second send through the same client after the restart: ok=%v err=%v" ,sent != nil && sent .OK ,err ) 101 } 102waitIdle (t ,id ,profile ,2 ) 103if continued := daemonSession (t ,id );continued .Effort != "" { 104t .Fatalf ("resumed turn replaced the cleared effort with %q" ,continued .Effort ) 105 } 106 107// The shared connection must now describe the restarted daemon, not the 108// one it was built on. 109current := shared .Snapshot () 110if current .Pid != snap .Pid { 111t .Errorf ("connection pid is %d, restarted daemon is %d" ,current .Pid ,snap .Pid ) 112 } 113if current .Token != snap .Token { 114t .Errorf ("connection token is not the restarted daemon's" ) 115 } 116if want := fmt .Sprintf ("http://127.0.0.1:%d" ,snap .Port );shared .BaseURL ()!= want { 117t .Errorf ("connection base url is %s, want %s" ,shared .BaseURL (),want ) 118 } 119if current .Token == oldSnap .Token { 120t .Errorf ("the token never changed, so this run did not exercise a real restart" ) 121 } 122 123// Both turns crossed the whole stack to the provider. 124requests := suite .provider .requests (profile ) 125if len (requests )< 2 { 126t .Fatalf ("provider profile %s saw %d requests, want one per turn" ,profile ,len (requests )) 127 } 128body ,_ := requests [0 ]["body" ].(map [string ]any ) 129if forwarded := lastUserText (body );! strings .Contains (forwarded ,prompt1 ) { 130t .Errorf ("first provider request does not carry the first prompt %q: %q" ,prompt1 ,forwarded ) 131 } 132body ,_ = requests [len (requests )- 1 ]["body" ].(map [string ]any ) 133if forwarded := lastUserText (body );! strings .Contains (forwarded ,prompt2 ) { 134t .Errorf ("last provider request does not carry the post-restart prompt: %q" ,forwarded ) 135 } 136 137// The transcript survived the restart in the same session. 138reply := strings .Join (eventText (durableHistorySnapshot (t ,id ),"message" ),"\n" ) 139if ! strings .Contains (reply ,"echo: " + prompt1 )|| ! strings .Contains (reply ,"echo: " + prompt2 ) { 140t .Fatalf ("transcript after the restart is missing a turn; got:\n%s" ,reply ) 141 } 142} 143 144// A stopped turn survives a daemon restart as idle-but-interrupted. Reopening 145// it must accept ordinary input without a slash command changing its phase. 146func TestTUIReopensInterruptedSessionWithoutCompacting (t * testing.T ) { 147entered ,release := make (chan struct {}),make (chan struct {}) 148defer close (release ) 149profile := providerRoute (t ,func (request map [string ]any )string { 150if lastUserText (request )== "stop this turn" { 151close (entered ) 152<- release 153 } 154return echoReply (request ) 155 }) 156id := newSession (t ,t .TempDir ()) 157client := daemon .NewChatClient (conn (t ),id ) 158ctx ,cancel := context .WithTimeout (context .Background (),30 * time .Second ) 159defer cancel () 160if _ ,err := client .Send (ctx ,"stop this turn" ,nil );err != nil { 161t .Fatal (err ) 162 } 163select { 164case <- entered : 165case <- ctx .Done (): 166t .Fatal ("the turn never reached the provider" ) 167 } 168if interrupted ,err := daemon .InterruptSession (ctx ,conn (t ),id );err != nil || ! interrupted { 169t .Fatalf ("interrupt: %v, %v" ,interrupted ,err ) 170 } 171waitIdle (t ,id ,profile ,1 ) 172restartDaemon (t ) 173status ,err := daemon .NewChatClient (conn (t ),id ).GetStatus (ctx ) 174if err != nil || ! status .Idle || status .Phase == nil || * status .Phase != daemon .PhaseIdle { 175t .Fatalf ("reopened status: %+v, %v" ,status ,err ) 176 } 177 178session := daemonSession (t ,id ) 179d := driveTUI (t ,& session ) 180defer d .App .Chat .Close () 181page ,err := client .History (ctx ,0 ,120 ) 182if err != nil { 183t .Fatal (err ) 184 } 185for _ ,event := range page .Events { 186d .Update (tui.ChatStreamEventMsg { 187SessionID :id ,Generation :d .App .Chat .Generation ,Event :event , 188 }) 189 } 190if ! strings .Contains (d .View (),"stop this turn" ) { 191t .Fatal ("the interrupted transcript did not load" ) 192 } 193d .connected () 194d .App .Chat .TextArea .SetValue ("continue without compacting" ) 195command := d .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 196if command == nil { 197t .Fatal ("the composer refused the new prompt" ) 198 } 199for _ ,msg := range d .results (command ) { 200if _ ,ok := msg .(tui.ChatTurnSentMsg );ok { 201d .Update (msg ) 202 } 203 } 204waitIdle (t ,id ,profile ,2 ) 205replies := strings .Join (eventText (durableHistorySnapshot (t ,id ),"message" ),"\n" ) 206if ! strings .Contains (replies ,"echo: continue without compacting" ) { 207t .Fatalf ("the reopened session did not answer: %q" ,replies ) 208 } 209} 210 211// The daemon closes its sessions before it stops listening, so an open stream 212// sees its session stop first. That is a restart, not a failed session: the 213// chat keeps its stream and shows the next turn from the restarted daemon. 214func TestTUIStreamFollowsSessionAcrossDaemonRestart (t * testing.T ) { 215providerRoute (t ,echoReply ) 216session := daemonSession (t ,newSession (t ,t .TempDir ())) 217driver := driveTUI (t ,& session ) 218t .Cleanup (driver .App .Chat .Close ) 219ctx ,cancel := context .WithTimeout (t .Context (),2 * time .Minute ) 220defer cancel () 221inbox := make (chan tea.Msg ,32 ) 222pump := func (until func (daemon.StreamEvent )bool ) { 223t .Helper () 224for { 225select { 226case msg := <- inbox : 227if result ,ok := msg .(tui.ChatStreamResultMsg );ok { 228t .Fatalf ("the restart stopped the stream: %v" ,result .Err ) 229 } 230streamMessages (ctx ,driver .Update (msg ),inbox ) 231if event ,ok := msg .(tui.ChatStreamEventMsg );ok && until (event .Event ) { 232return 233 } 234case <- ctx .Done (): 235t .Fatalf ("stream did not reach expected state:\n%s" ,driver .View ()) 236 } 237 } 238 } 239streamMessages (ctx ,driver .App .Chat .Init (),inbox ) 240pump (func (event daemon.StreamEvent )bool {return event .Type == daemon .EventReset }) 241 242restartDaemon (t ) 243if _ ,err := daemon .NewChatClient (conn (t ),session .ID ).Send (ctx ,"turn after the restart" ,nil );err != nil { 244t .Fatal (err ) 245 } 246pump (func (event daemon.StreamEvent )bool { 247return event .Type == daemon .EventMessage && strings .Contains (event .Text ,"echo: turn after the restart" ) 248 }) 249}