okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
4434577
main
1//go:build unix 2 3// CLI scenarios: the built binary, driven as a user would, against the suite's 4// one real daemon. Each scenario registers its own provider route and works in 5// its own workspace; assertions read what a user observes -- command output, 6// the session listing, the transcript -- plus what the provider recorded. 7package e2e 8 9import ( 10"context" 11"encoding/json" 12"path/filepath" 13"slices" 14"strings" 15"testing" 16"time" 17 18"albedo/cli/internal/daemon" 19) 20 21func listedSessionPrefix (t * testing.T ,id string )string { 22t .Helper () 23for line := range strings .SplitSeq (cli (t ,"sessions" ),"\n" ) { 24fields := strings .Fields (line ) 25if len (fields )> 0 && len (fields [0 ])>= 8 && strings .HasPrefix (id ,fields [0 ]) { 26return fields [0 ] 27 } 28 } 29t .Fatalf ("session listing omitted %s" ,id ) 30return "" 31} 32 33// A session created by `albedo new` must be listed by `sessions --json` with 34// its workspace, model, and active provider, and `resume` must resolve both 35// the full id and a prefix back to it. This only holds when new, sessions, 36// resume, and the daemon's session registry agree. 37func TestNewSessionIsListedAndResumes (t * testing.T ) { 38profile := providerRoute (t ,echoReply ) 39workspace := t .TempDir () 40created := cliSession (t ,workspace ) 41 42listing := cli (t ,"sessions" ,"--json" ) 43var sessions []daemon.Session 44if err := json .Unmarshal ([]byte (listing ),& sessions );err != nil { 45t .Fatalf ("sessions --json: %v\n%s" ,err ,listing ) 46 } 47var listed * daemon.Session 48for i := range sessions { 49if sessions [i ].ID == created { 50listed = & sessions [i ] 51 } 52 } 53if listed == nil { 54t .Fatalf ("session %s is missing from `albedo sessions --json`:\n%s" ,created ,listing ) 55 } 56if ! samePath (listed .Workspace ,workspace ) { 57t .Errorf ("session workspace is %q, created in %q" ,listed .Workspace ,workspace ) 58 } 59if listed .Provider != profile { 60t .Errorf ("session provider is %q, active profile is %q" ,listed .Provider ,profile ) 61 } 62if listed .Model != "fixture-model" { 63t .Errorf ("session model is %q, want the configured fixture-model" ,listed .Model ) 64 } 65 66var resumed struct { 67Session string `json:"session"` 68 } 69if err := json .Unmarshal ([]byte (cli (t ,"resume" ,created )),& resumed );err != nil || resumed .Session != created { 70t .Fatalf ("albedo resume by id: session=%q err=%v" ,resumed .Session ,err ) 71 } 72if err := json .Unmarshal ([]byte (cli (t ,"resume" ,listedSessionPrefix (t ,created ))),& resumed );err != nil || resumed .Session != created { 73t .Fatalf ("albedo resume by prefix: session=%q err=%v" ,resumed .Session ,err ) 74 } 75} 76 77// A prompt sent with `albedo send` must come back as assistant text: the 78// provider configuration reaches the daemon's upstream client, the turn runs, 79// and the committed transcript records the reply. The provider's recorded 80// request is the proof the daemon actually called the model. 81func TestSendTurnReachesProviderAndTranscript (t * testing.T ) { 82profile := providerRoute (t ,echoReply ) 83t .Parallel () 84workspace := t .TempDir () 85id := newSession (t ,workspace ) 86 87prompt := "e2e turn through the cli" 88var sent daemon.SendResult 89if err := json .Unmarshal ([]byte (cli (t ,"send" ,id ,prompt )),& sent );err != nil || ! sent .OK { 90t .Fatalf ("albedo send: ok=%v err=%v" ,sent .OK ,err ) 91 } 92 93waitIdle (t ,id ,profile ,1 ) 94reply := strings .Join (eventText (durableHistorySnapshot (t ,id ),"message" ),"\n" ) 95if ! strings .Contains (reply ,"echo: " + prompt ) { 96t .Fatalf ("assistant reply is missing from the transcript; got:\n%s" ,reply ) 97 } 98 99requests := suite .provider .requests (profile ) 100if len (requests )== 0 { 101t .Fatalf ("provider profile %s received no request" ,profile ) 102 } 103request := requests [0 ] 104if request ["model" ]!= "fixture-model" { 105t .Errorf ("provider saw model %v, config specifies fixture-model" ,request ["model" ]) 106 } 107if request ["authorization" ]!= "Bearer fixture-key" { 108t .Errorf ("provider saw authorization %v" ,request ["authorization" ]) 109 } 110body ,_ := request ["body" ].(map [string ]any ) 111if forwarded := lastUserText (body );! strings .Contains (forwarded ,prompt ) { 112t .Errorf ("provider request does not carry the prompt %q: %q" ,prompt ,forwarded ) 113 } 114} 115 116// A turn driven through daemon.ChatClient -- the path the TUI takes -- must 117// stream the assistant's reply as message events and settle to idle. This is 118// the real client: agent-scoped routes, SSE replay, and status polling against 119// the live daemon. 120func TestChatClientTurnStreamsAndSettles (t * testing.T ) { 121profile := providerRoute (t ,echoReply ) 122t .Parallel () 123workspace := t .TempDir () 124id := newSession (t ,workspace ) 125 126client := daemon .NewChatClient (conn (t ),id ) 127ctx := t .Context () 128 129events := make (chan daemon.StreamEvent ,128 ) 130streamErr := make (chan error ,1 ) 131go func () { 132streamErr <- client .Stream (ctx ,0 ,func (event daemon.StreamEvent )error { 133select { 134case events <- event : 135return nil 136case <- ctx .Done (): 137return ctx .Err () 138 } 139 }) 140 }() 141 142prompt := "e2e turn through the chat client" 143if _ ,err := client .Send (ctx ,prompt ,nil );err != nil { 144t .Fatalf ("chat client send: %v" ,err ) 145 } 146 147var reply string 148deadline := time .After (30 * time .Second ) 149for reply == "" { 150select { 151case event := <- events : 152if event .Type == daemon .EventMessage { 153reply = event .Text 154 } 155case err := <- streamErr : 156t .Fatalf ("stream ended before the reply: %v" ,err ) 157case <- deadline : 158t .Fatalf ("no assistant message streamed within 30s" ) 159 } 160 } 161if ! strings .Contains (reply ,"echo: " + prompt ) { 162t .Fatalf ("streamed reply %q does not carry the scripted echo" ,reply ) 163 } 164 165waitIdle (t ,id ,profile ,1 ) 166status ,err := client .GetStatus (context .Background ()) 167if err != nil { 168t .Fatalf ("chat client status: %v" ,err ) 169 } 170if status .Running || ! status .Idle { 171t .Fatalf ("session is not idle after the turn: %+v" ,status ) 172 } 173if requests := suite .provider .requests (profile );len (requests )== 0 { 174t .Fatalf ("provider profile %s received no request" ,profile ) 175 } 176} 177 178// `albedo sessions` prints shortened IDs, so send and stop must accept them the 179// way resume does. A printed prefix is unique only among the sessions that exist 180// when it is listed, and UUIDv7 IDs created together share long prefixes, so 181// this runs serially, before parallel tests create sessions. 182func TestSendAndStopAcceptAShortenedSessionID (t * testing.T ) { 183profile := providerRoute (t ,echoReply ) 184id := newSession (t ,t .TempDir ()) 185prefix := listedSessionPrefix (t ,id ) 186 187var sent daemon.SendResult 188if err := json .Unmarshal ([]byte (cli (t ,"send" ,prefix ,"short id" )),& sent );err != nil || ! sent .OK { 189t .Fatalf ("albedo send by prefix: ok=%v err=%v" ,sent .OK ,err ) 190 } 191waitIdle (t ,id ,profile ,1 ) 192cli (t ,"stop" ,prefix ) 193 194if reply := cli (t ,"--prompt" ,"by prefix" ,"--session" ,prefix );! strings .Contains (reply ,"echo: by prefix" ) { 195t .Fatalf ("albedo --prompt --session by prefix did not reach the session: %s" ,reply ) 196 } 197} 198 199// Another agent drives a session with `sessions send` and `sessions read`: the 200// newest turns come back as text or JSON, and a count of one leaves the older 201// turn out. It addresses the session by a listed prefix, so it runs serially. 202func TestSessionsSendAndRead (t * testing.T ) { 203profile := providerRoute (t ,echoReply ) 204id := newSession (t ,t .TempDir ()) 205prefix := listedSessionPrefix (t ,id ) 206 207for i ,prompt := range []string {"first question" ,"second question" } { 208var sent daemon.SendResult 209if err := json .Unmarshal ([]byte (cli (t ,"sessions" ,"send" ,prefix ,prompt )),& sent );err != nil || ! sent .OK { 210t .Fatalf ("albedo sessions send %q: ok=%v err=%v" ,prompt ,sent .OK ,err ) 211 } 212waitIdle (t ,id ,profile ,i + 1 ) 213 } 214 215latest := cli (t ,"sessions" ,"read" ,prefix ) 216if ! strings .Contains (latest ,"[user] second question" )|| ! strings .Contains (latest ,"[assistant] echo: second question" ) { 217t .Fatalf ("read of the newest turn is missing it:\n%s" ,latest ) 218 } 219if strings .Contains (latest ,"first question" ) { 220t .Fatalf ("read of one turn includes an older one:\n%s" ,latest ) 221 } 222both := cli (t ,"sessions" ,"read" ,prefix ,"2" ) 223if ! strings .Contains (both ,"[user] first question" )|| ! strings .Contains (both ,"[assistant] echo: second question" ) { 224t .Fatalf ("read of two turns is missing one:\n%s" ,both ) 225 } 226 227var parsed struct { 228Session string `json:"session"` 229Running bool `json:"running"` 230Events []struct { 231Type string `json:"type"` 232Text string `json:"text"` 233 }`json:"events"` 234 } 235if err := json .Unmarshal ([]byte (cli (t ,"sessions" ,"read" ,id ,"1" ,"--json" )),& parsed );err != nil || parsed .Session != id || parsed .Running || len (parsed .Events )== 0 { 236t .Fatalf ("read --json: %+v err=%v" ,parsed ,err ) 237 } 238 239if _ ,stderr ,err := runCLI ("sessions" ,"read" ,id ,"0" );err == nil || ! strings .Contains (stderr ,"positive number" ) { 240t .Fatalf ("read with zero turns: err=%v stderr=%s" ,err ,stderr ) 241 } 242} 243 244// Sending to a session that does not exist must fail loudly rather than queue 245// a prompt into nothing. 246func TestSendToUnknownSessionFails (t * testing.T ) { 247t .Parallel () 248stdout ,stderr ,err := runCLI ("send" ,"e2e-no-such-session" ,"hello" ) 249if err == nil { 250t .Fatalf ("albedo send to an unknown session exited 0: %s" ,stdout ) 251 } 252if ! strings .Contains (stderr ,"no session matches" ) { 253t .Fatalf ("expected a no-match diagnostic, got stderr: %s" ,stderr ) 254 } 255} 256 257// samePath compares two paths, tolerating macOS /var -> /private/var symlinks. 258func samePath (a ,b string )bool { 259if filepath .Clean (a )== filepath .Clean (b ) { 260return true 261 } 262resolvedA ,errA := filepath .EvalSymlinks (a ) 263resolvedB ,errB := filepath .EvalSymlinks (b ) 264return errA == nil && errB == nil && resolvedA == resolvedB 265} 266 267// Session deletion requires approval before it reaches the daemon. 268func TestCLISessionDeletionRequiresConfirmation (t * testing.T ) { 269providerRoute (t ,echoReply ) 270t .Parallel () 271id := newSession (t ,t .TempDir ()) 272stdout ,stderr ,err := runCLI ("storage" ,"prune" ,"--session" ,id ) 273if err == nil || ! strings .Contains (stdout ,"Permanently delete session " + id )|| ! strings .Contains (stderr ,"no files have been removed" ) { 274t .Fatalf ("unconfirmed deletion: %v %q %q" ,err ,stdout ,stderr ) 275 } 276if daemonSession (t ,id ).ID != id { 277t .Fatal ("unconfirmed session deletion mutated daemon" ) 278 } 279cli (t ,"storage" ,"prune" ,"--session" ,id ,"--yes" ) 280if sessions := daemonSessions (t );slices .ContainsFunc (sessions ,func (session daemon.Session )bool {return session .ID == id }) { 281t .Fatal ("confirmed session was not deleted" ) 282 } 283} 284 285// Full IDs address children even though the default session listing contains roots. 286func TestCLICommandsReachAChildByItsFullID (t * testing.T ) { 287profile := providerRoute (t ,echoReply ) 288t .Parallel () 289parent := newSession (t ,t .TempDir ()) 290child ,err := daemon .CreateSession (t .Context (),conn (t ), daemon.CreateSessionRequest { 291Kind :"child" ,ParentID :parent ,Address :"cli-child" ,Name :"cli-child" ,Task :"child first turn" , 292 }) 293if err != nil { 294t .Fatal (err ) 295 } 296waitIdle (t ,child .ID ,profile ,1 ) 297if slices .ContainsFunc (daemonSessions (t ),func (session daemon.Session )bool {return session .ID == child .ID }) { 298t .Fatal ("child unexpectedly appears in the root listing" ) 299 } 300var resumed struct { 301Session string `json:"session"` 302 } 303if err := json .Unmarshal ([]byte (cli (t ,"resume" ,child .ID )),& resumed );err != nil || resumed .Session != child .ID { 304t .Fatalf ("albedo resume child by full ID: session=%q err=%v" ,resumed .Session ,err ) 305 } 306cli (t ,"send" ,child .ID ,"child second turn" ) 307waitIdle (t ,child .ID ,profile ,2 ) 308if text := cli (t ,"sessions" ,"read" ,child .ID ,"2" );! strings .Contains (text ,"[user] child first turn" )|| ! strings .Contains (text ,"[assistant] echo: <mail" )|| ! strings .Contains (text ,"echo: child second turn" ) { 309t .Fatalf ("full child ID did not reach its transcript:\n%s" ,text ) 310 } 311cli (t ,"stop" ,child .ID ) 312}