okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
cc6ab84
main
1//go:build unix 2 3// Package e2e drives a real daemon through the CLI and TUI with a scripted 4// model provider. Scenarios isolate provider routes and workspaces; most run 5// in parallel. The suite uses Unix signals for daemon teardown and PID checks. 6package e2e 7 8import ( 9"bytes" 10"context" 11"encoding/json" 12"errors" 13"fmt" 14"io" 15"net/http" 16"net/http/httptest" 17"os" 18"os/exec" 19"path/filepath" 20"runtime" 21"slices" 22"strconv" 23"strings" 24"sync" 25"syscall" 26"testing" 27"time" 28 29"albedo/cli/internal/config" 30"albedo/cli/internal/daemon" 31"albedo/cli/internal/localdaemon" 32) 33 34// suite is the process-wide fixture: one daemon, one provider server, one 35// hermetic home. A restart scenario must update daemonPID. 36var suite struct { 37contract * wireContract 38conn * daemon.Connection 39provider * fakeProvider 40root string // the albedo repository 41cli string // the freshly built CLI binary 42home string // hermetic ALBEDO_HOME 43env []string 44daemonPID int 45// remoteHome is the loopback ssh's remote HOME. 46remoteHome string 47} 48 49func TestMain (m * testing.M ) { 50started := time .Now () 51shutdown ,err := bootSuite () 52if err != nil { 53fmt .Fprintln (os .Stderr ,"go e2e setup failed:" ,err ) 54if shutdown != nil { 55// The daemon may already be running; take it down either way. 56report ,ok := shutdown () 57fmt .Fprintf (os .Stderr ,"go e2e teardown after failed setup: %s\n" ,report ) 58if ! ok { 59fmt .Fprintln (os .Stderr ,"go e2e teardown failed; a daemon may be leaked" ) 60 } 61 } 62os .Exit (1 ) 63 } 64code := m .Run () 65report ,ok := shutdown () 66suite .contract .mu .Lock () 67if failures := suite .contract .failures ;len (failures )!= 0 { 68for _ ,failure := range failures { 69fmt .Fprintln (os .Stderr ,"OpenAPI:" ,failure ) 70 } 71code = 1 72 } 73suite .contract .mu .Unlock () 74fmt .Printf ("go e2e suite: %s, %s\n" ,time .Since (started ).Round (time .Millisecond ),report ) 75if ! ok { 76code = 1 77 } 78os .Exit (code ) 79} 80 81func bootSuite () (func () (string ,bool ),error ) { 82root ,err := repoRoot () 83if err != nil { 84return nil ,err 85 } 86suite .contract ,err = loadWireContract (root ) 87if err != nil { 88return nil ,fmt .Errorf ("loading OpenAPI contract: %w" ,err ) 89 } 90temp ,err := os .MkdirTemp ("" ,"albedo-go-e2e-" ) 91if err != nil { 92return nil ,err 93 } 94teardown := func () (string ,bool ) {return teardownSuite (temp ) } 95 96home := filepath .Join (temp ,"home" ) 97userHome := filepath .Join (temp ,"user-home" ) 98scratch := filepath .Join (temp ,"tmp" ) 99for _ ,dir := range []string {home ,userHome ,scratch } { 100if mkdirErr := os .MkdirAll (dir ,0o700 );mkdirErr != nil { 101return teardown ,mkdirErr 102 } 103 } 104 105// Build the exact CLI the scenarios drive, so the suite never depends on 106// (or races with) a stale cli/bin/albedo. 107suite .cli = filepath .Join (temp ,"bin" ,"albedo" ) 108build := exec .Command ("go" ,"build" ,"-o" ,suite .cli ,"./cmd/albedo" ) 109build .Dir = filepath .Join (root ,"cli" ) 110if out ,buildErr := build .CombinedOutput ();buildErr != nil { 111return teardown ,fmt .Errorf ("building ./cmd/albedo: %w\n%s" ,buildErr ,out ) 112 } 113 114launcher ,err := snapshotDaemon (root ,filepath .Join (temp ,"daemon" )) 115if err != nil { 116return teardown ,err 117 } 118 119sshBin ,err := loopbackSSH (temp ) 120if err != nil { 121return teardown ,err 122 } 123suite .root ,suite .home = root ,home 124suite .env = hermeticEnv (root ,home ,userHome ,scratch ,launcher ,sshBin ) 125suite .provider = newFakeProvider () 126// Both explicit and background catalogue refreshes stay within the 127// fixture. An empty catalogue leaves native model inference observable. 128extensionConfig ,marshalErr := json .Marshal (map [string ]any {"models" :map [string ]any {"refreshHours" :0 ,"url" :suite .provider .server .URL + "/models-dev" },"cacheTtl" :map [string ]any {"url" :nil }}) 129if marshalErr != nil { 130return teardown ,marshalErr 131 } 132if writeErr := os .WriteFile (filepath .Join (home ,"extensions.json" ),extensionConfig ,0o600 );writeErr != nil { 133return teardown ,writeErr 134 } 135if writeErr := os .WriteFile (filepath .Join (home ,"models.json" ), []byte (`{}` ),0o600 );writeErr != nil { 136return teardown ,writeErr 137 } 138 139if _ ,stderr ,startupErr := runCLI ("sessions" );startupErr != nil { 140return teardown ,bootFailure (startupErr ,stderr ) 141 } 142snap ,err := awaitDaemon (home ) 143if err != nil { 144return teardown ,err 145 } 146suite .conn ,err = daemon .Attach (context .Background (),snap ,func (ctx context.Context ) (daemon.ConnectionSnapshot ,error ) { 147return localdaemon .Rediscover (ctx ,home ) 148 }) 149if err != nil { 150return teardown ,err 151 } 152suite .daemonPID = snap .Pid 153client := suite .conn .HTTPClient () 154client .Transport = contractTransport {upstream :client .Transport ,contract :suite .contract } 155return teardown ,nil 156} 157 158// teardownSuite shuts the daemon down, waits for its process to exit, kills it 159// if it must, and removes the hermetic tree. A surviving daemon fails the run, 160// because it would poison the next one. 161func teardownSuite (temp string ) (string ,bool ) { 162var report []string 163ok := true 164switch snap ,err := readDaemonSnapshot (suite .home ); { 165case err != nil : 166report = append (report ,"daemon record unreadable: " + err .Error ()) 167if suite .daemonPID != 0 { 168ok = false 169if ! awaitExit (suite .daemonPID ,500 * time .Millisecond ) { 170_ = syscall .Kill (suite .daemonPID ,syscall .SIGKILL ) 171 } 172 } 173case suite .conn == nil : 174// Setup never reached the daemon; the parent watcher takes it down 175// when this process exits. 176report = append (report ,fmt .Sprintf ("daemon %d recorded but never connected" ,snap .Pid )) 177default : 178ctx ,cancel := context .WithTimeout (context .Background (),15 * time .Second ) 179defer cancel () 180if err := daemon .StopDaemon (ctx ,suite .conn );err != nil { 181report = append (report ,fmt .Sprintf ("shutdown request failed: %v" ,err )) 182 } 183if awaitExit (snap .Pid ,15 * time .Second ) { 184report = append (report ,fmt .Sprintf ("daemon %d exited cleanly" ,snap .Pid )) 185 }else { 186_ = syscall .Kill (snap .Pid ,syscall .SIGKILL ) 187ok = false 188report = append (report ,fmt .Sprintf ("daemon %d ignored shutdown and was killed" ,snap .Pid )) 189 } 190 } 191if suite .provider != nil { 192suite .provider .close () 193 } 194_ = os .RemoveAll (temp ) 195return strings .Join (report ,"; " ),ok 196} 197 198// awaitExit polls until the process is gone. 199func awaitExit (pid int ,within time.Duration )bool { 200deadline := time .Now ().Add (within ) 201for time .Now ().Before (deadline ) { 202if err := syscall .Kill (pid ,0 );errors .Is (err ,syscall .ESRCH ) { 203return true 204 } 205time .Sleep (100 * time .Millisecond ) 206 } 207return false 208} 209 210// bootFailure wraps a setup error with the daemon log, the only record of a 211// daemon that died before it could publish daemon.json. 212func bootFailure (err error ,cliStderr string )error { 213log ,_ := os .ReadFile (filepath .Join (suite .home ,"daemon.log" )) 214return fmt .Errorf ("%w\ncli: %s\ndaemon.log: %s" ,err ,cliStderr ,log ) 215} 216 217// awaitDaemon waits for the booting daemon to publish daemon.json. 218func awaitDaemon (home string ) (daemon.ConnectionSnapshot ,error ) { 219deadline := time .Now ().Add (2 * time .Minute ) 220for time .Now ().Before (deadline ) { 221if snap ,err := readDaemonSnapshot (home );err == nil { 222return snap ,nil 223 } 224time .Sleep (100 * time .Millisecond ) 225 } 226return daemon.ConnectionSnapshot {},bootFailure (errors .New ("daemon did not publish daemon.json" ),"" ) 227} 228 229func readDaemonSnapshot (home string ) (daemon.ConnectionSnapshot ,error ) { 230data ,err := os .ReadFile (filepath .Join (home ,"daemon.json" )) 231if err != nil { 232return daemon.ConnectionSnapshot {},err 233 } 234var snap daemon.ConnectionSnapshot 235if err := json .Unmarshal (data ,& snap );err != nil { 236return daemon.ConnectionSnapshot {},err 237 } 238if snap .Port < 1 { 239return daemon.ConnectionSnapshot {},fmt .Errorf ("daemon.json has no port: %s" ,data ) 240 } 241return snap ,nil 242} 243 244func repoRoot () (string ,error ) { 245_ ,file ,_ ,ok := runtime .Caller (0 ) 246if ! ok { 247return "" ,errors .New ("cannot locate the e2e source file" ) 248 } 249for dir := filepath .Dir (file ); ;dir = filepath .Dir (dir ) { 250if _ ,err := os .Stat (filepath .Join (dir ,"gleam.toml" ));err == nil { 251return dir ,nil 252 } 253if parent := filepath .Dir (dir );parent == dir { 254return "" ,fmt .Errorf ("no albedo repository above %s" ,filepath .Dir (file )) 255 } 256 } 257} 258 259// snapshotDaemon returns the launcher the daemon boots from instead of 260// `gleam run`: test.sh's snapshot when it passes ALBEDO_TEST_DAEMON, else a 261// fresh one in out (see test/snapshot-daemon.sh). 262func snapshotDaemon (root ,out string ) (string ,error ) { 263if launcher := os .Getenv ("ALBEDO_TEST_DAEMON" );launcher != "" { 264return launcher ,nil 265 } 266launcher ,err := exec .Command (filepath .Join (root ,"test" ,"snapshot-daemon.sh" ),out ).Output () 267if err != nil { 268if exit ,ok := errors.AsType [* exec.ExitError ](err );ok { 269return "" ,fmt .Errorf ("snapshotting the daemon: %w\n%s" ,err ,exit .Stderr ) 270 } 271return "" ,fmt .Errorf ("snapshotting the daemon: %w" ,err ) 272 } 273return strings .TrimSpace (string (launcher )),nil 274} 275 276// hermeticEnv builds the environment for every CLI invocation and the daemon 277// it spawns: no real home, no inherited albedo state, temporary files inside 278// the suite's tree, the snapshot's launcher, and the parent watcher. 279func hermeticEnv (root ,home ,userHome ,scratch ,daemonExe ,sshBin string ) []string { 280var env []string 281for _ ,kv := range os .Environ () { 282name ,value ,_ := strings .Cut (kv ,"=" ) 283if name == "HOME" || name == "TMPDIR" || name == "ERL_FLAGS" || strings .HasPrefix (name ,"ALBEDO_" ) { 284continue 285 } 286if name == "PATH" { 287kv = "PATH=" + sshBin + string (os .PathListSeparator )+ value 288 } 289env = append (env ,kv ) 290 } 291return append (env , 292"HOME=" + userHome , 293"TMPDIR=" + scratch , 294"ALBEDO_HOME=" + home , 295"ALBEDO_PARENT_PID=" + strconv .Itoa (os .Getpid ()), 296"ALBEDO_ROOT=" + root , 297"ALBEDO_DAEMON=" + daemonExe , 298"ALBEDO_NO_BROWSER=1" , 299// Two schedulers keep a booting test VM from pinning every core. 300"ERL_FLAGS=+S 2:2 +SDcpu 2:2 +sbwt none +sbwtdcpu none +sbwtdio none" , 301 ) 302} 303 304// loopbackSSH puts an ssh on the daemon's PATH that runs every command here, 305// under its own "remote" HOME (suite.remoteHome), for scenarios with a remote 306// workspace; the real ssh, and its masters, are never reached. Host `nohost` 307// cannot be reached. 308func loopbackSSH (temp string ) (string ,error ) { 309bin ,remoteBin := filepath .Join (temp ,"ssh-bin" ),filepath .Join (temp ,"remote-bin" ) 310suite .remoteHome = filepath .Join (temp ,"remote-home" ) 311for _ ,dir := range []string {bin ,remoteBin ,suite .remoteHome } { 312if err := os .MkdirAll (dir ,0o700 );err != nil { 313return "" ,err 314 } 315 } 316python ,err := exec .LookPath ("python3" ) 317if err != nil { 318return "" ,err 319 } 320remotePath := "'" + strings .ReplaceAll (remoteBin + string (os .PathListSeparator )+ os .Getenv ("PATH" ),"'" ,"'\"'\"'" )+ "'" 321ssh := `#!/bin/sh 322while [ $# -gt 0 ]; do 323case "$1" in 324-o|-O|-l|-p|-i|-F|-J|-S) shift 2 ;; 325-*) shift ;; 326*) break ;; 327esac 328done 329target=$1 330shift 331case "$target" in 332stage-ready|stage-failure) 333[ $# -eq 0 ] && exit 0 334gate="` + suite .remoteHome + `/$target" 335case "$*" in 336*"tar -C"*) 337touch "$gate.staging" 338while [ ! -f "$gate.release-stage" ]; do sleep 0.02; done 339if [ "$target" = stage-failure ]; then cat >/dev/null; echo "fixture staging failed" >&2; exit 1; fi 340;; 341*"uname -s"*) 342touch "$gate.connecting" 343while [ ! -f "$gate.release-connect" ]; do sleep 0.02; done 344printf 'Linux\nx86_64\n` + suite .remoteHome + `\n1\n3.11.17\nmissing\n' 345exit 0 ;; 346esac ;; 347nohost) echo "ssh: connect to host nohost port 22: Connection refused" >&2; exit 255 ;; 348lockedhost) echo "lockedhost: Permission denied (publickey)." >&2; exit 255 ;; 349esac 350exec env -i HOME=` + suite .remoteHome + ` PATH=` + remotePath + ` SHELL=` + remoteBin + `/loginsh /bin/sh -c "$*" 351` 352// a login shell that sources no profile, which would put the system 353// python ahead of the linked one 354loginsh := "#!/bin/sh\n[ \"$1\" = -l ] && shift\nexec /bin/sh \"$@\"\n" 355if err := os .WriteFile (filepath .Join (bin ,"ssh" ), []byte (ssh ),0o755 );err != nil { 356return "" ,err 357 } 358if err := os .WriteFile (filepath .Join (remoteBin ,"loginsh" ), []byte (loginsh ),0o755 );err != nil { 359return "" ,err 360 } 361return bin ,os .Symlink (python ,filepath .Join (remoteBin ,"python3" )) 362} 363 364// runCLI runs the built binary the way a user would and returns its output. 365func runCLI (args ... string ) (string ,string ,error ) { 366ctx ,cancel := context .WithTimeout (context .Background (),90 * time .Second ) 367defer cancel () 368cmd := exec .CommandContext (ctx ,suite .cli ,args ... ) 369cmd .Dir = suite .root 370cmd .Env = suite .env 371var stdout ,stderr bytes.Buffer 372cmd .Stdout ,cmd .Stderr = & stdout ,& stderr 373err := cmd .Run () 374return stdout .String (),stderr .String (),err 375} 376 377// fakeProvider scripts the model network. Each scenario registers its own 378// route, so one shared httptest server isolates scenarios instead of a daemon 379// per test. 380type fakeProvider struct { 381server * httptest.Server 382byName map [string ]* fakeRoute // by provider profile name 383byRoute map [string ]* fakeRoute // by path segment under /t/ 384seq int 385 386mu sync.Mutex 387} 388 389// fakeRoute is one scripted profile: a reply function and every request the 390// daemon sent to it. 391type fakeRoute struct { 392reply func (request map [string ]any )string 393requests []map [string ]any // {authorization, model, body} 394mu sync.Mutex 395} 396 397func newFakeProvider ()* fakeProvider { 398p := & fakeProvider { 399byName :make (map [string ]* fakeRoute ), 400byRoute :make (map [string ]* fakeRoute ), 401 } 402mux := http .NewServeMux () 403mux .HandleFunc ("/t/" ,p .serve ) 404mux .HandleFunc ("/models-dev" ,func (w http.ResponseWriter ,r * http.Request ) { 405_ = json .NewEncoder (w ).Encode (map [string ]any {}) 406 }) 407p .server = httptest .NewServer (mux ) 408return p 409} 410 411func (p * fakeProvider )close () {p .server .Close () } 412 413// addProfile registers a scripted route and saves it through the daemon, 414// which owns config.json and makes the saved profile active; earlier profiles 415// stay configured so their sessions keep working. 416func (p * fakeProvider )addProfile (name string ,reply func (map [string ]any )string )error { 417p .mu .Lock () 418p .seq ++ 419segment := strconv .Itoa (p .seq ) 420route := & fakeRoute {reply :reply } 421p .byName [name ]= route 422p .byRoute [segment ]= route 423p .mu .Unlock () 424return saveAndSelectProvider (context .Background (),suite .conn ,name , config.Settings { 425Extension :"openai" , 426BaseURL :p .server .URL + "/t/" + segment , 427APIKey :"fixture-key" , 428Model :"fixture-model" , 429Protocol :"chat_completions" , 430 }) 431} 432 433// requests returns what the daemon sent to the named profile. 434func (p * fakeProvider )requests (name string ) []map [string ]any { 435p .mu .Lock () 436route := p .byName [name ] 437p .mu .Unlock () 438if route == nil { 439return nil 440 } 441route .mu .Lock () 442defer route .mu .Unlock () 443return slices .Clone (route .requests ) 444} 445 446func (p * fakeProvider )routeFor (path string )* fakeRoute { 447segment ,_ ,_ := strings .Cut (strings .TrimPrefix (path ,"/t/" ),"/" ) 448p .mu .Lock () 449defer p .mu .Unlock () 450return p .byRoute [segment ] 451} 452 453func (p * fakeProvider )serve (w http.ResponseWriter ,r * http.Request ) { 454route := p .routeFor (r .URL .Path ) 455if route == nil { 456http .NotFound (w ,r ) 457return 458 } 459switch r .Method { 460case http .MethodGet : 461// The models catalog endpoint; scenarios script none. 462w .Header ().Set ("Content-Type" ,"application/json" ) 463_ ,_ = w .Write ([]byte ("{}" )) 464case http .MethodPost : 465route .serveChat (w ,r ) 466default : 467http .NotFound (w ,r ) 468 } 469} 470 471// serveChat records the request and streams it a scripted reply in the chat 472// completions SSE wire format the daemon parses. 473func (r * fakeRoute )serveChat (w http.ResponseWriter ,req * http.Request ) { 474body ,err := io .ReadAll (io .LimitReader (req .Body ,4 << 20 )) 475if err != nil { 476http .Error (w ,err .Error (),http .StatusBadRequest ) 477return 478 } 479var decoded map [string ]any 480_ = json .Unmarshal (body ,& decoded )// record even a malformed body 481 482r .mu .Lock () 483r .requests = append (r .requests ,map [string ]any { 484"authorization" :req .Header .Get ("Authorization" ), 485"model" :decoded ["model" ], 486"body" :decoded , 487 }) 488reply := r .reply 489r .mu .Unlock () 490 491text := "fixture reply" 492if reply != nil { 493text = reply (decoded ) 494 } 495 496w .Header ().Set ("Content-Type" ,"text/event-stream" ) 497flusher ,_ := w .(http.Flusher ) 498emit := func (delta map [string ]any ,finish string ) { 499choice := map [string ]any {"index" :0 ,"delta" :delta ,"finish_reason" :nil } 500if finish != "" { 501choice ["finish_reason" ]= finish 502 } 503chunk ,_ := json .Marshal (map [string ]any {"id" :"fixture" ,"choices" : []any {choice }}) 504fmt .Fprintf (w ,"data: %s\n\n" ,chunk ) 505if flusher != nil { 506flusher .Flush () 507 } 508 } 509if code ,ok := strings .CutPrefix (text ,pythonCallMark );ok { 510args ,_ := json .Marshal (map [string ]string {"code" :code }) 511emit (map [string ]any {"tool_calls" : []any {map [string ]any { 512"index" :0 ,"id" :"fixture-call" ,"type" :"function" , 513"function" :map [string ]any {"name" :"python" ,"arguments" :string (args )}, 514 }}},"" ) 515emit (map [string ]any {},"tool_calls" ) 516fmt .Fprint (w ,"data: [DONE]\n\n" ) 517return 518 } 519for _ ,chunk := range chunks (text ,20 ) { 520emit (map [string ]any {"content" :chunk },"" ) 521 } 522emit (map [string ]any {},"stop" ) 523fmt .Fprint (w ,"data: [DONE]\n\n" ) 524} 525 526// --- scenario helpers; new scenarios in this package build on these --- 527 528// conn returns the shared daemon connection and fails if the daemon under it 529// is no longer the one this suite booted. 530func conn (t * testing.T )* daemon.Connection { 531t .Helper () 532snap ,err := readDaemonSnapshot (suite .home ) 533if err != nil { 534t .Fatalf ("daemon record: %v" ,err ) 535 } 536if snap .Pid != suite .daemonPID { 537t .Fatalf ("daemon %d replaced the suite daemon %d" ,snap .Pid ,suite .daemonPID ) 538 } 539return suite .conn 540} 541 542// cli runs the built binary and fails the test when it exits non-zero. 543func cli (t * testing.T ,args ... string )string { 544t .Helper () 545stdout ,stderr ,err := runCLI (args ... ) 546if err != nil { 547t .Fatalf ("albedo %s: %v\nstdout: %s\nstderr: %s" ,strings .Join (args ," " ),err ,stdout ,stderr ) 548 } 549return stdout 550} 551 552// newSession creates a session on the test's own provider route, so tests 553// running side by side never depend on which profile is active. 554func newSession (t * testing.T ,workspace string )string { 555t .Helper () 556created ,err := daemon .CreateSession (context .Background (),conn (t ), daemon.CreateSessionRequest {Workspace :workspace ,Provider :t .Name ()}) 557if err != nil || created .ID == "" { 558t .Fatalf ("creating a session on %s: %v" ,t .Name (),err ) 559 } 560return created .ID 561} 562 563// cliSession creates a session through the CLI, exactly as a user would, on 564// whichever profile is active. 565func cliSession (t * testing.T ,workspace string )string { 566t .Helper () 567var created struct { 568Session string `json:"session"` 569 } 570if err := json .Unmarshal ([]byte (cli (t ,"new" ,workspace )),& created );err != nil || created .Session == "" { 571t .Fatalf ("albedo new did not report a session: %v" ,err ) 572 } 573return created .Session 574} 575 576// providerRoute registers and selects a profile named after the test. Call it 577// before t.Parallel(): Go finishes these selections and all serial tests before 578// resuming parallel bodies. Those bodies must not depend on the active profile 579// or change shared settings. Tests that use defaults, log in, change global 580// extensions, or restart the daemon stay serial. 581func providerRoute (t * testing.T ,reply func (map [string ]any )string )string { 582t .Helper () 583if err := suite .provider .addProfile (t .Name (),reply );err != nil { 584t .Fatalf ("provider route: %v" ,err ) 585 } 586return t .Name () 587} 588 589// waitIdle waits for the turn to reach the provider before accepting idle: 590// a newly submitted turn can be queued while its kernel boots. 591func waitIdle (t * testing.T ,session ,profile string ,wantRequests int ) { 592t .Helper () 593connection := conn (t ) 594deadline := time .Now ().Add (60 * time .Second ) 595for { 596status ,err := daemon .NewChatClient (connection ,session ).GetStatus (context .Background ()) 597if err != nil { 598t .Fatalf ("status of %s: %v" ,session ,err ) 599 } 600seen := len (suite .provider .requests (profile )) 601if ! status .Running && seen >= wantRequests { 602return 603 } 604if time .Now ().After (deadline ) { 605t .Fatalf ("session %s never settled after %d provider requests (got %d)" , 606session ,wantRequests ,seen ) 607 } 608time .Sleep (100 * time .Millisecond ) 609 } 610} 611 612// durableHistorySnapshot reads the daemon's canonical durable history resource. 613func durableHistorySnapshot (t * testing.T ,session string ) []map [string ]any { 614t .Helper () 615page ,err := daemon .NewChatClient (conn (t ),session ).History (t .Context (),0 ,200 ) 616if err != nil { 617t .Fatal (err ) 618 } 619events := []map [string ]any {} 620for _ ,event := range page .Events { 621events = append (events ,map [string ]any {"type" :string (event .Type ),"text" :event .Text }) 622 } 623return events 624} 625 626// eventText collects the text of every event of one kind. 627func eventText (events []map [string ]any ,kind string ) []string { 628var texts []string 629for _ ,event := range events { 630if event ["type" ]== kind { 631if text ,_ := event ["text" ].(string );text != "" { 632texts = append (texts ,text ) 633 } 634 } 635 } 636return texts 637} 638 639// echoReply scripts a provider that echoes the prompt back as assistant text, 640// so an assertion on the transcript proves the prompt crossed the whole stack. 641func echoReply (request map [string ]any )string { 642if prompt := lastUserText (request );prompt != "" { 643return "echo: " + prompt 644 } 645return "fixture reply" 646} 647 648// pythonCallMark starts a scripted reply that is a python tool call; see 649// pythonCall. 650const pythonCallMark = "\x00python:" 651 652// pythonCall scripts a reply that calls the python tool with code instead of 653// answering in text. 654func pythonCall (code string )string {return pythonCallMark + code } 655 656// toolAnswered reports whether request carries a tool result, so a scripted 657// reply can answer once its call has run. 658func toolAnswered (request map [string ]any )bool { 659messages ,_ := request ["messages" ].([]any ) 660return slices .ContainsFunc (messages ,func (raw any )bool { 661message ,_ := raw .(map [string ]any ) 662return message ["role" ]== "tool" 663 }) 664} 665 666// lastUserText finds the prompt the daemon forwarded. 667func lastUserText (request map [string ]any )string { 668messages ,_ := request ["messages" ].([]any ) 669for _ ,raw := range slices .Backward (messages ) { 670message ,_ := raw .(map [string ]any ) 671if message ["role" ]!= "user" { 672continue 673 } 674switch content := message ["content" ].(type ) { 675case string : 676return content 677case []any : 678var parts []string 679for _ ,part := range content { 680if item ,ok := part .(map [string ]any );ok { 681if text ,_ := item ["text" ].(string );text != "" { 682parts = append (parts ,text ) 683 } 684 } 685 } 686return strings .Join (parts ," " ) 687 } 688 } 689return "" 690} 691 692// chunks splits text into stream-sized pieces. 693func chunks (text string ,size int ) []string { 694var parts []string 695for len (text )> size { 696parts = append (parts ,text [:size ]) 697text = text [size :] 698 } 699return append (parts ,text ) 700} 701 702// A test profile edit observes its validator under the same lock as its write. 703// Concurrent test profiles remain independent without silently rebasing intent. 704var sharedSettingsMu sync.Mutex 705 706func saveAndSelectProvider (ctx context.Context ,connection * daemon.Connection ,name string ,profile config.Settings )error { 707sharedSettingsMu .Lock () 708defer sharedSettingsMu .Unlock () 709settings ,err := daemon .GetSettings (ctx ,connection ) 710if err != nil { 711return err 712 } 713return daemon .SaveAndSelectProvider (ctx ,connection ,name ,profile ,settings .ETags ["providers" ]) 714} 715func updateMCP (ctx context.Context ,connection * daemon.Connection ,session string ,request daemon.MCPUpdateRequest ) (daemon.ReloadResult ,error ) { 716sharedSettingsMu .Lock () 717defer sharedSettingsMu .Unlock () 718settings ,err := daemon .GetSettings (ctx ,connection ) 719if err != nil { 720return daemon.ReloadResult {},err 721 } 722request .ETag = settings .ETags ["mcp" ] 723request .StoredSecrets = settings .Credentials .MCP [request .Name ] 724return daemon .SaveMCP (ctx ,connection ,session ,request ) 725} 726func enableExtension (t * testing.T ,connection * daemon.Connection ,session ,name string ) { 727t .Helper () 728sharedSettingsMu .Lock () 729defer sharedSettingsMu .Unlock () 730settings ,err := daemon .GetSettings (t .Context (),connection ) 731if err != nil { 732t .Fatal (err ) 733 } 734if _ ,err := daemon .SelectExtension (t .Context (),connection ,session , daemon.ExtensionSelectionRequest {Name :name ,Scope :"global" ,Enabled :new (true ),ETag :settings .ETags ["extensions" ]});err != nil { 735t .Fatal (err ) 736 } 737current ,err := daemon .GetSession (t .Context (),connection ,session ) 738if err != nil { 739t .Fatal (err ) 740 } 741if _ ,err := daemon .SelectExtension (t .Context (),connection ,session , daemon.ExtensionSelectionRequest {Name :name ,Scope :"session" ,Enabled :new (true ),ETag :current .ETag });err != nil { 742t .Fatal (err ) 743 } 744if _ ,err := daemon .ReloadSession (t .Context (),connection ,session , daemon.ReloadRequest {Target :"both" });err != nil { 745t .Fatal (err ) 746 } 747} 748func sessionCondition (t * testing.T ,connection * daemon.Connection ,id string ) daemon.SessionCondition { 749t .Helper () 750current ,err := daemon .GetSession (t .Context (),connection ,id ) 751if err != nil { 752t .Fatal (err ) 753 } 754return daemon.SessionCondition {ETag :current .ETag ,FamilyRevision :current .FamilyRevision } 755}