okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
f51cc1a
main
1//go:build unix 2 3// Model and extension selection cross the composer, keyboard, and daemon 4// session. Stubbed command replies cannot catch a dropped request or reload. 5package e2e 6 7import ( 8"slices" 9"strings" 10"testing" 11 12"albedo/cli/internal/daemon" 13"albedo/cli/internal/tui" 14 15 tea"charm.land/bubbletea/v2" 16"github.com/charmbracelet/x/ansi" 17) 18 19func TestTUIEffortSelectorCommitsThroughTheDaemon (t * testing.T ) { 20providerRoute (t ,echoReply ) 21session := daemonSession (t ,newSession (t ,t .TempDir ())) 22if _ ,err := daemon .SelectModel (t .Context (),conn (t ),session .ID , daemon.ModelSelectionRequest {Model :"o3" ,Effort :"high" ,ETag :session .ETag });err != nil { 23t .Fatal (err ) 24 } 25session = daemonSession (t ,session .ID ) 26d := driveTUI (t ,& session ) 27 28// Selecting a model goes through the real composer: enter dispatches the 29// command, the app asks the daemon, and the answer lands in the model. 30d .App .Chat .TextArea .SetValue ("/model o3-mini" ) 31dispatched ,ok := d .Key (tea .KeyEnter ).(tui.ChatExecuteCommandMsg ) 32if ! ok || dispatched .Name != "/model" || dispatched .Args != "o3-mini" { 33t .Fatalf ("enter did not dispatch /model: %#v" ,dispatched ) 34 } 35d .Dispatch (dispatched ) 36if d .App .ActiveSession == nil || d .App .ActiveSession .Model != "o3-mini" || d .App .ActiveSession .Effort != "high" { 37t .Fatalf ("the daemon's model switch did not land:\n%s" ,d .View ()) 38 } 39 40// /effort arrives as the message the command menu submits for it; the 41// daemon, not a stub, decides which tiers the model offers and which one 42// is current. 43d .Dispatch (tui.ChatExecuteCommandMsg {Name :"/effort" }) 44opened := d .View () 45for _ ,tier := range []string {"low" ,"medium" ,"high" } { 46if ! strings .Contains (opened ,tier ) { 47t .Fatalf ("the selector is missing tier %q:\n%s" ,tier ,opened ) 48 } 49 } 50if ! strings .Contains (opened ,"Reasoning effort" )|| ! strings .Contains (opened ,"[high]" ) { 51t .Fatalf ("the daemon's current tier is not selected:\n%s" ,opened ) 52 } 53if strings .Contains (opened ,"› " ) { 54t .Fatal ("the composer must be hidden while choosing effort" ) 55 } 56 57// Even a narrow selector stays inside the terminal and Escape restores the 58// composer without changing the daemon. 59d .Update (tea.WindowSizeMsg {Width :28 ,Height :14 }) 60for line := range strings .SplitSeq (d .View (),"\n" ) { 61if width := ansi .StringWidth (line );width > 28 { 62t .Fatalf ("effort selector rendered a %d-cell line at width 28: %q" ,width ,line ) 63 } 64 } 65d .Key (tea .KeyEscape ) 66if view := d .View ();strings .Contains (view ,"Reasoning effort" )|| ! strings .Contains (view ,"› " ) { 67t .Fatalf ("Escape did not restore the composer:\n%s" ,view ) 68 } 69d .Update (tea.WindowSizeMsg {Width :80 ,Height :22 }) 70d .Dispatch (tui.ChatExecuteCommandMsg {Name :"/effort" }) 71 72// The keyboard alone moves the selection and commits it. 73d .Key (tea .KeyLeft ) 74if ! strings .Contains (d .View (),"[medium]" ) { 75t .Fatalf ("left did not select medium:\n%s" ,d .View ()) 76 } 77committed ,ok := d .Key (tea .KeyEnter ).(tui.ChatExecuteCommandMsg ) 78if ! ok || committed .Name != "/effort" || committed .Args != "medium" { 79t .Fatalf ("enter did not commit the selected tier: %#v" ,committed ) 80 } 81d .Dispatch (committed ) 82settled := d .View () 83if strings .Contains (settled ,"Reasoning effort" ) { 84t .Fatalf ("the selector stayed open after committing:\n%s" ,settled ) 85 } 86if ! strings .Contains (settled ,"Effort saved." )|| ! strings .Contains (settled ,"› " ) { 87t .Fatalf ("the daemon's answer did not reach the chat:\n%s" ,settled ) 88 } 89if d .App .ActiveSession .Effort != "medium" { 90t .Fatalf ("the chat kept effort %q" ,d .App .ActiveSession .Effort ) 91 } 92 93// The truth at the end is the daemon's session record. 94session = daemonSession (t ,d .App .ActiveSession .ID ) 95if session .Model != "o3-mini" || session .Effort != "medium" { 96t .Fatalf ("session %s kept model %q effort %q, want o3-mini at medium" ,session .ID ,session .Model ,session .Effort ) 97 } 98} 99 100// Every prompt retitles the session. The chat keeps the configuration it 101// observed before that, so a retitle must not make /effort a conflict. 102func TestTUIEffortAfterAPromptRetitlesTheSession (t * testing.T ) { 103profile := providerRoute (t ,echoReply ) 104session := daemonSession (t ,newSession (t ,t .TempDir ())) 105if _ ,err := daemon .SelectModel (t .Context (),conn (t ),session .ID , daemon.ModelSelectionRequest {Model :"o3" ,Effort :"low" ,ETag :session .ETag });err != nil { 106t .Fatal (err ) 107 } 108session = daemonSession (t ,session .ID ) 109d := driveTUI (t ,& session ) 110defer d .App .Chat .Close () 111if _ ,err := daemon .NewChatClient (conn (t ),session .ID ).Send (t .Context (),"a prompt that names the session" ,nil );err != nil { 112t .Fatal (err ) 113 } 114waitIdle (t ,session .ID ,profile ,1 ) 115if retitled := daemonSession (t ,session .ID );retitled .Title == session .Title { 116t .Fatalf ("the prompt kept the title %q" ,retitled .Title ) 117 } 118 119d .Dispatch (tui.ChatExecuteCommandMsg {Name :"/effort" ,Args :"high" }) 120if view := d .View ();! strings .Contains (view ,"Effort saved." ) { 121t .Fatalf ("/effort after a prompt failed:\n%s" ,view ) 122 } 123if session := daemonSession (t ,session .ID );session .Effort != "high" { 124t .Fatalf ("session kept effort %q, want high" ,session .Effort ) 125 } 126} 127 128// Text after a command that declares no arguments is a prompt that starts with 129// the command's name; the command would only reject it. 130func TestTUISendsTextAfterAnArgumentlessCommandAsAPrompt (t * testing.T ) { 131profile := providerRoute (t ,echoReply ) 132t .Parallel () 133session := daemonSession (t ,newSession (t ,t .TempDir ())) 134// A session lists its commands once its first turn has loaded them. 135if _ ,err := daemon .NewChatClient (conn (t ),session .ID ).Send (t .Context (),"hello" ,nil );err != nil { 136t .Fatal (err ) 137 } 138waitIdle (t ,session .ID ,profile ,1 ) 139d := driveTUI (t ,& session ) 140defer d .App .Chat .Close () 141d .Dispatch (tui.CapabilityPageChangedMsg {}) 142jobs := slices .IndexFunc (d .App .Chat .CommandMenu .Catalog ,func (command daemon.SessionCommand )bool {return command .Name == "/jobs" }) 143if jobs < 0 || len (d .App .Chat .CommandMenu .Catalog [jobs ].Arguments )!= 0 { 144t .Fatalf ("the daemon's catalog has no argumentless /jobs: %+v" ,d .App .Chat .CommandMenu .Catalog ) 145 } 146d .connected () 147 148d .App .Chat .TextArea .SetValue ("/jobs is broken" ) 149var admitted tui.ChatTurnSentMsg 150for _ ,message := range d .results (d .Update (tea.KeyPressMsg {Code :tea .KeyEnter })) { 151if sent ,ok := message .(tui.ChatTurnSentMsg );ok { 152admitted = sent 153d .Update (sent ) 154 } 155 } 156if admitted .Err != nil || ! admitted .OK { 157t .Fatalf ("enter did not send the prompt: %+v\n%s" ,admitted ,d .View ()) 158 } 159waitIdle (t ,session .ID ,profile ,2 ) 160if reply := strings .Join (eventText (durableHistorySnapshot (t ,session .ID ),"message" ),"\n" );! strings .Contains (reply ,"echo: /jobs is broken" ) { 161t .Fatalf ("the model did not receive the whole prompt; got:\n%s" ,reply ) 162 } 163} 164 165func TestTUIWebhooksShortcutOpensSettingsWithoutACatalog (t * testing.T ) { 166providerRoute (t ,echoReply ) 167session := daemonSession (t ,newSession (t ,t .TempDir ())) 168if _ ,err := daemon .SelectExtension (t .Context (),conn (t ),session .ID , daemon.ExtensionSelectionRequest {Name :"webhooks" ,Scope :"session" ,Enabled :new (false ),ETag :session .ETag });err != nil { 169t .Fatal (err ) 170 } 171session = daemonSession (t ,session .ID ) 172d := driveTUI (t ,& session ) 173defer d .App .Chat .Close () 174d .connected () 175 176d .Type ("/web" ) 177if view := d .View ();! strings .Contains (view ,"/webhooks" ) { 178t .Fatalf ("the command menu has no webhooks settings shortcut:\n%s" ,view ) 179 } 180d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 181if d .App .State != tui .AppStateWebhooksPage || ! strings .Contains (d .App .WebhooksPage .Error ,"webhooks are off" ) { 182t .Fatalf ("the disabled extension did not open its settings guidance:\n%s" ,d .View ()) 183 } 184d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEscape }) 185if d .App .State != tui .AppStateChat { 186t .Fatalf ("Escape did not return to chat:\n%s" ,d .View ()) 187 } 188 189enableExtension (t ,conn (t ),session .ID ,"webhooks" ) 190d .Type ("/webhooks" ) 191// Dismiss completion so Enter exercises local command recognition too. 192d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEscape }) 193d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 194if d .App .State != tui .AppStateWebhooksPage || d .App .WebhooksPage .Error != "" || d .App .WebhooksPage .Loading { 195t .Fatalf ("the typed shortcut did not load webhooks settings:\n%s" ,d .View ()) 196 } 197} 198 199func TestTUIExtensionPickerSwitchesCompactionStrategies (t * testing.T ) { 200profile := providerRoute (t ,echoReply ) 201t .Parallel () 202session := daemonSession (t ,newSession (t ,t .TempDir ())) 203d := driveTUI (t ,& session ) 204defer d .App .Chat .Close () 205for turn ,name := range []string {"lcm" ,"rolling" ,"lcm" } { 206d .Dispatch (tui.ChatExecuteCommandMsg {Name :"/extensions" }) 207picker := d .App .ExtensionPicker 208index := - 1 209for i ,item := range picker .Extensions { 210if item .Name == name { 211index = i 212if item .Enabled { 213t .Fatalf ("strategy %s is already enabled before its switch" ,name ) 214 } 215 } 216 } 217if index < 0 { 218t .Fatalf ("strategy %s is missing from the actual extension picker" ,name ) 219 } 220for d .App .ExtensionPicker .Cursor < index { 221d .Dispatch (tea.KeyPressMsg {Code :tea .KeyDown }) 222 } 223for d .App .ExtensionPicker .Cursor > index { 224d .Dispatch (tea.KeyPressMsg {Code :tea .KeyUp }) 225 } 226d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter ,Mod :tea .ModShift }) 227d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 228if picker := d .App .ExtensionPicker ;picker .Error != "" || picker .Confirming ()|| ! strings .Contains (picker .Notice ,"Reload completed." ) { 229t .Fatalf ("strategy %s did not apply through the picker:\n%s" ,name ,d .View ()) 230 } 231configuration ,err := daemon .GetSessionConfiguration (t .Context (),conn (t ),session .ID ) 232if err != nil { 233t .Fatal (err ) 234 } 235for _ ,strategy := range []string {"lcm" ,"rolling" } { 236chosen := configuration .Value .Selection .Extensions [strategy ] 237if enabled := chosen != nil && * chosen ;enabled != (strategy == name ) { 238t .Fatalf ("single %s toggle left competing selection %+v" ,name ,configuration .Value .Selection .Extensions ) 239 } 240 } 241client := daemon .NewChatClient (conn (t ),session .ID ) 242if _ ,err := client .Send (t .Context (),"use the selected strategy" ,nil );err != nil { 243t .Fatal (err ) 244 } 245waitIdle (t ,session .ID ,profile ,turn + 1 ) 246prepared ,err := daemon .GetContextSnapshot (t .Context (),conn (t ),session .ID ) 247if err != nil || prepared .State != "ready" || prepared .Compaction .Strategy != name { 248t .Fatalf ("turn did not use selected strategy %s: %+v, %v" ,name ,prepared ,err ) 249 } 250if turn == 0 { 251current := daemonSession (t ,session .ID ) 252if _ ,err := daemon .RenameSession (t .Context (),conn (t ),session .ID ,"strategy review" ,current .ETag );err != nil { 253t .Fatal (err ) 254 } 255// Another client edited this session while the picker was open. 256// A stale confirmation refreshes the choices without replaying it. 257d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter ,Mod :tea .ModShift }) 258d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 259picker := d .App .ExtensionPicker 260if picker .Loading || picker .Saving || picker .Confirming ()|| picker .Error != "" || ! strings .Contains (picker .Notice ,"Review the refreshed choices" ) { 261t .Fatalf ("stale selection did not return to review:\n%s" ,d .View ()) 262 } 263configuration ,err := daemon .GetSessionConfiguration (t .Context (),conn (t ),session .ID ) 264if err != nil || configuration .Value .Selection .Extensions [name ]== nil || !* configuration .Value .Selection .Extensions [name ] { 265t .Fatalf ("stale confirmation changed the saved strategy: %+v, %v" ,configuration ,err ) 266 } 267 } 268d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEscape }) 269 } 270}