okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
f51cc1a
main
1//go:build unix 2 3// The independent adapter must carry the real daemon's responses into the TUI 4// without owning its launcher, workspace discovery, or wire-format decoding. 5package e2e 6 7import ( 8"context" 9"encoding/json" 10"os" 11"path/filepath" 12"slices" 13"strings" 14"testing" 15 16"albedo/cli/internal/config" 17"albedo/cli/internal/daemon" 18"albedo/cli/internal/tui" 19 20 tea"charm.land/bubbletea/v2" 21) 22 23func TestIndependentAdapterDrivesContextTreeAndSessionCatalog (t * testing.T ) { 24profile := providerRoute (t ,echoReply ) 25t .Parallel () 26before := conn (t ).Snapshot () 27attached ,err := daemon .Attach (t .Context (),before ,nil ) 28if err != nil { 29t .Fatal (err ) 30 } 31workspace := t .TempDir () 32skill := filepath .Join (workspace ,".agents" ,"skills" ,"adapter-review" ,"SKILL.md" ) 33if err := os .MkdirAll (filepath .Dir (skill ),0o700 );err != nil { 34t .Fatal (err ) 35 } 36if err := os .WriteFile (skill , []byte ("---\nname: adapter-review\ndescription: Review an attached adapter workflow\n---\nInspect the prepared request.\n" ),0o600 );err != nil { 37t .Fatal (err ) 38 } 39session ,err := daemon .CreateSession (t .Context (),attached , daemon.CreateSessionRequest {Workspace :workspace ,Provider :profile }) 40if err != nil { 41t .Fatal (err ) 42 } 43pending ,err := daemon .GetContextSnapshot (t .Context (),attached ,session .ID ) 44if err != nil { 45t .Fatalf ("read context before its first turn: %v" ,err ) 46 } 47if pending .State != "pending" || strings .TrimSpace (pending .Reason )== "" { 48t .Fatalf ("pending context lost its readable reason: %+v" ,pending ) 49 } 50if pending .SnapshotID != "" || pending .CapturedAt != nil || len (pending .Sections )!= 0 { 51t .Fatalf ("reading pending context prepared a snapshot: %+v" ,pending ) 52 } 53if requests := suite .provider .requests (profile );len (requests )!= 0 { 54t .Fatalf ("reading pending context called the provider: %d requests" ,len (requests )) 55 } 56client := daemon .NewChatClient (attached ,session .ID ) 57for index ,prompt := range []string {"adapter durable first turn" ,"inspect adapter durable history" } { 58if _ ,err := client .Send (t .Context (),prompt ,nil );err != nil { 59t .Fatal (err ) 60 } 61waitIdle (t ,session .ID ,profile ,index + 1 ) 62 } 63preview ,err := daemon .GetSessionPreview (t .Context (),attached ,session .ID ,20 ) 64if err != nil { 65t .Fatal (err ) 66 } 67foundPreview := false 68for _ ,item := range preview .Items { 69foundPreview = foundPreview || strings .Contains (item .Preview ,"inspect adapter durable history" ) 70 } 71if ! foundPreview { 72t .Fatalf ("preview omitted the completed turn: %+v" ,preview ) 73 } 74commands ,err := daemon .ListSessionCommands (t .Context (),attached ,session .ID ) 75if err != nil { 76t .Fatal (err ) 77 } 78commandIndex := slices .IndexFunc (commands ,func (command daemon.SessionCommand )bool { 79return command .Name == "/adapter-review" 80 }) 81if commandIndex < 0 { 82t .Fatal ("enabled workspace skill is missing from the command menu catalog" ) 83 } 84skillCommand := commands [commandIndex ] 85if skillCommand .Delivery != "input" || skillCommand .CommandID != "/adapter-review" || ! skillCommand .UserTurn || skillCommand .Method != "PUT" || skillCommand .Description != "Review an attached adapter workflow" || len (skillCommand .Arguments )!= 1 { 86t .Fatalf ("skill command lost its runnable metadata: %+v" ,skillCommand ) 87 } 88driver := driveTUIWithConnection (t ,& session ,attached ) 89defer driver .App .Chat .Close () 90driver .Dispatch (tui.ChatOpenContextInspectorMsg {}) 91inspector := & driver .App .ContextInspector 92if inspector .Error != "" || inspector .Snapshot == nil || inspector .Snapshot .State != "ready" { 93t .Fatalf ("prepared context did not reach inspector: %+v" ,inspector ) 94 } 95history := - 1 96for index ,section := range inspector .Snapshot .Sections { 97if section .Kind == "history" && section .Pages > 0 { 98history = index 99break 100 } 101 } 102if history < 0 { 103t .Fatal ("prepared context has no readable history section" ) 104 } 105inspector .Cursor = history 106driver .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 107if inspector .Detail == nil || inspector .Detail .Value == nil || ! strings .Contains (inspector .Detail .Value .Content ,"adapter durable first turn" ) { 108t .Fatalf ("context detail lost prior history: %+v" ,inspector .Detail ) 109 } 110driver .Dispatch (tui.ContextDoneMsg {}) 111driver .Dispatch (tui.ChatOpenTreePickerMsg {}) 112if driver .App .TreePicker .Error != "" || len (driver .App .TreePicker .Checkpoints )< 2 { 113t .Fatalf ("completed turns did not reach checkpoint picker: %+v" ,driver .App .TreePicker ) 114 } 115driver .Dispatch (tui.TreeCancelMsg {}) 116driver .Dispatch (tui.ChatOpenCapabilityPageMsg {Kind :"skills" }) 117page := & driver .App .CapabilityPage 118selected := - 1 119for index ,item := range page .Items { 120if item .Candidate .Source == skill { 121selected = index 122break 123 } 124 } 125if page .Error != "" || selected < 0 || ! page .Items [selected ].Candidate .EffectiveEnabled { 126t .Fatalf ("workspace skill did not reach catalog page: %+v" ,page ) 127 } 128page .Cursor = selected 129driver .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter ,Mod :tea .ModShift }) 130// The app also refreshes its command menu after this save, which starts 131// recurring glance polls. Drive through the page's actual save and reload 132// completion instead of asking the synchronous driver to settle timers. 133queued := driver .results (driver .Update (tea.KeyPressMsg {Code :tea .KeyEnter })) 134for step := 0 ;page .Saving || page .Loading ;step ++ { 135if step == 16 || len (queued )== 0 { 136t .Fatalf ("catalog save did not finish: %+v" ,page ) 137 } 138message := queued [0 ] 139queued = queued [1 :] 140command := driver .Update (message ) 141if page .Saving || page .Loading { 142queued = append (queued ,driver .results (command )... ) 143 } 144 } 145catalog ,err := daemon .GetCapabilityCatalog (t .Context (),attached ,session .ID ) 146if err != nil { 147t .Fatal (err ) 148 } 149disabled := false 150for _ ,candidate := range catalog .Candidates { 151if candidate .Source == skill { 152disabled = ! candidate .EffectiveEnabled && candidate .SessionOverride != nil && !* candidate .SessionOverride 153 } 154 } 155if page .Error != "" || ! disabled { 156t .Fatalf ("session catalog toggle was not persisted: %+v" ,page ) 157 } 158commands ,err = daemon .ListSessionCommands (t .Context (),attached ,session .ID ) 159if err != nil { 160t .Fatal (err ) 161 } 162if ! slices .ContainsFunc (commands ,func (command daemon.SessionCommand )bool {return command .Name == "/adapter-review" }) { 163t .Fatal ("desired skill selection prematurely replaced the loaded command catalogue" ) 164 } 165if result ,err := daemon .ReloadSession (t .Context (),attached ,session .ID , daemon.ReloadRequest {Target :"session" });err != nil || result .Session == nil || result .Session .Failure != nil { 166t .Fatalf ("reload disabled skill: %+v %v" ,result ,err ) 167 } 168commands ,err = daemon .ListSessionCommands (t .Context (),attached ,session .ID ) 169if err != nil { 170t .Fatal (err ) 171 } 172if slices .ContainsFunc (commands ,func (command daemon.SessionCommand )bool { 173return command .Name == "/adapter-review" 174 }) { 175t .Fatal ("disabled skill still appears as a runnable command" ) 176 } 177after ,err := readDaemonSnapshot (suite .home ) 178if err != nil || after .Port != before .Port || after .Pid != before .Pid || after .Token != before .Token || after .Version != before .Version || after .Build != before .Build { 179t .Fatalf ("independent attachment replaced its daemon: before=%+v after=%+v err=%v" ,before ,after ,err ) 180 } 181} 182 183func TestTUICapabilityPageEnablesAndLoadsSkillCommands (t * testing.T ) { 184providerRoute (t ,echoReply ) 185t .Parallel () 186workspace := t .TempDir () 187skill := filepath .Join (workspace ,".agents" ,"skills" ,"enable-review" ,"SKILL.md" ) 188if err := os .MkdirAll (filepath .Dir (skill ),0o700 );err != nil { 189t .Fatal (err ) 190 } 191if err := os .WriteFile (skill , []byte ("---\nname: enable-review\ndescription: Verify enabling the skills extension\n---\nReview the current task.\n" ),0o600 );err != nil { 192t .Fatal (err ) 193 } 194session := daemonSession (t ,newSession (t ,workspace )) 195if _ ,err := daemon .SelectExtension (t .Context (),conn (t ),session .ID , daemon.ExtensionSelectionRequest {Name :"skills" ,Scope :"session" ,ETag :session .ETag ,Enabled :new (false )});err != nil { 196t .Fatal (err ) 197 } 198if reloaded ,err := daemon .ReloadSession (t .Context (),conn (t ),session .ID , daemon.ReloadRequest {Target :"session" });err != nil || reloaded .Session == nil || reloaded .Session .State != "applied" { 199t .Fatalf ("disable skills before opening its page: %+v, %v" ,reloaded ,err ) 200 } 201commands ,err := daemon .ListSessionCommands (t .Context (),conn (t ),session .ID ) 202if err != nil || slices .ContainsFunc (commands ,func (command daemon.SessionCommand )bool {return command .Name == "/enable-review" }) { 203t .Fatalf ("disabled skills retained runnable command: %+v, %v" ,commands ,err ) 204 } 205session = daemonSession (t ,session .ID ) 206driver := driveTUI (t ,& session ) 207defer driver .App .Chat .Close () 208driver .Dispatch (tui.ChatOpenCapabilityPageMsg {Kind :"skills" }) 209if driver .App .CapabilityPage .ExtensionEnabled { 210t .Fatal ("page did not observe the disabled skills extension" ) 211 } 212driver .Dispatch (tea.KeyPressMsg {Code :'t' ,Mod :tea .ModCtrl }) 213if ! driver .App .CapabilityPage .Confirming () { 214t .Fatal ("ctrl+t did not request extension-enable confirmation" ) 215 } 216driver .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 217if page := driver .App .CapabilityPage ;page .Error != "" || ! page .ExtensionEnabled || ! strings .Contains (page .Notice ,"Reload completed." ) { 218t .Fatalf ("confirmed extension enable did not reload:\n%s" ,driver .View ()) 219 } 220commands ,err = daemon .ListSessionCommands (t .Context (),conn (t ),session .ID ) 221if err != nil || ! slices .ContainsFunc (commands ,func (command daemon.SessionCommand )bool {return command .Name == "/enable-review" }) { 222t .Fatalf ("enabled extension did not load the skill command: %+v, %v" ,commands ,err ) 223 } 224} 225 226func TestTypedMCPSecretsKeepReplaceRemoveAndClear (t * testing.T ) { 227profile := providerRoute (t ,echoReply ) 228attached ,err := daemon .Attach (t .Context (),conn (t ).Snapshot (),nil ) 229if err != nil { 230t .Fatal (err ) 231 } 232session ,err := daemon .CreateSession (t .Context (),attached , daemon.CreateSessionRequest {Workspace :t .TempDir (),Provider :profile }) 233if err != nil { 234t .Fatal (err ) 235 } 236httpName ,stdioName := "typed-secret-http" ,"typed-secret-stdio" 237servers := map [string ]config.MCPServer { 238httpName : {Type :"http" ,URL :"http://127.0.0.1:1/mcp" ,Enabled :new (false )}, 239stdioName : {Type :"stdio" ,Command :"python3" ,Args : []string {},Enabled :new (false )}, 240 } 241t .Cleanup (func () { 242for name := range servers { 243if _ ,err := updateMCP (context .Background (),attached ,session .ID , daemon.MCPUpdateRequest {Name :name ,Server :nil ,Secrets : daemon.MCPSecretsPatch {}});err != nil { 244t .Error (err ) 245 } 246 } 247 }) 248save := func (name string ,patch daemon.MCPSecretsPatch ) daemon.Settings { 249t .Helper () 250server := servers [name ] 251if _ ,err := updateMCP (t .Context (),attached ,session .ID , daemon.MCPUpdateRequest {Name :name ,Server :& server ,Secrets :patch });err != nil { 252t .Fatal (err ) 253 } 254settings ,err := daemon .GetSettings (t .Context (),attached ) 255if err != nil { 256t .Fatal (err ) 257 } 258return settings 259 } 260save (httpName , daemon.MCPSecretsPatch { 261BearerToken :new ("initial - bearer "), 262Headers :map [string ]* string {"x-retained" :new ("initial - header "),"x-removed" :new ("remove - header ")}, 263 }) 264save (stdioName , daemon.MCPSecretsPatch {Env :map [string ]* string {"RETAINED" :new ("initial - env "),"REMOVED" :new ("remove - env ")}}) 265keptHTTP := save (httpName , daemon.MCPSecretsPatch {}).Credentials .MCP [httpName ] 266keptStdio := save (stdioName , daemon.MCPSecretsPatch {}).Credentials .MCP [stdioName ] 267if ! keptHTTP .BearerToken || ! slices .Equal (keptHTTP .Headers , []string {"x-removed" ,"x-retained" })|| ! slices .Equal (keptStdio .Env , []string {"REMOVED" ,"RETAINED" }) { 268t .Fatalf ("omitted secret patch changed saved names: HTTP=%+v stdio=%+v" ,keptHTTP ,keptStdio ) 269 } 270replacedHTTP := save (httpName , daemon.MCPSecretsPatch { 271BearerToken :new ("replacement - bearer "), 272Headers :map [string ]* string {"x-retained" :new ("replacement - header "),"x-removed" :nil }, 273 }).Credentials .MCP [httpName ] 274replacedStdio := save (stdioName , daemon.MCPSecretsPatch {Env :map [string ]* string {"RETAINED" :new ("replacement - env "),"REMOVED" :nil }}).Credentials .MCP [stdioName ] 275if ! replacedHTTP .BearerToken || ! slices .Equal (replacedHTTP .Headers , []string {"x-retained" })|| ! slices .Equal (replacedStdio .Env , []string {"RETAINED" }) { 276t .Fatalf ("named removal changed retained secret names: HTTP=%+v stdio=%+v" ,replacedHTTP ,replacedStdio ) 277 } 278// The fixture owns this home; inspect only its test values to distinguish 279// actual replacement from omission without public secret disclosure. 280data ,err := os .ReadFile (filepath .Join (suite .home ,"creds.json" )) 281if err != nil { 282t .Fatal (err ) 283 } 284var stored struct { 285MCP map [string ]struct { 286BearerToken string `json:"bearerToken"` 287Headers map [string ]string `json:"headers"` 288Env map [string ]string `json:"env"` 289 }`json:"mcp"` 290 } 291if err := json .Unmarshal (data ,& stored );err != nil { 292t .Fatal (err ) 293 } 294if stored .MCP [httpName ].BearerToken != "replacement-bearer" || stored .MCP [httpName ].Headers ["x-retained" ]!= "replacement-header" || stored .MCP [stdioName ].Env ["RETAINED" ]!= "replacement-env" { 295t .Fatal ("typed replacement patch did not update persisted test secrets" ) 296 } 297clearedHTTP := save (httpName , daemon.MCPSecretsPatch {RemoveBearerToken :true ,ClearHeaders :true }).Credentials .MCP [httpName ] 298clearedStdio := save (stdioName , daemon.MCPSecretsPatch {ClearEnv :true }).Credentials .MCP [stdioName ] 299if clearedHTTP .Any ()|| clearedStdio .Any () { 300t .Fatalf ("explicit container removal retained secret names: HTTP=%+v stdio=%+v" ,clearedHTTP ,clearedStdio ) 301 } 302} 303 304func TestAttachedWebhookScreenCreatesConfiguresDisablesAndDeletesHook (t * testing.T ) { 305profile := providerRoute (t ,echoReply ) 306attached ,err := daemon .Attach (t .Context (),conn (t ).Snapshot (),nil ) 307if err != nil { 308t .Fatal (err ) 309 } 310session ,err := daemon .CreateSession (t .Context (),attached , daemon.CreateSessionRequest {Workspace :t .TempDir (),Provider :profile }) 311if err != nil { 312t .Fatal (err ) 313 } 314enableExtension (t ,attached ,session .ID ,"webhooks" ) 315driver := driveTUIWithConnection (t ,& session ,attached ) 316defer driver .App .Chat .Close () 317driver .Dispatch (tui.ChatOpenWebhooksPageMsg {}) 318driver .Dispatch (tea.KeyPressMsg {Code :'o' ,Mod :tea .ModCtrl }) 319if driver .App .WebhooksPage .Error != "" || driver .App .WebhooksPage .Form == nil { 320t .Fatalf ("cannot open webhook creation form: %+v" ,driver .App .WebhooksPage ) 321 } 322driver .Type ("attached-alert" ) 323driver .App .WebhooksPage .Form .Inputs ["header" ].SetValue ("x-adapter-signature" ) 324driver .App .WebhooksPage .Form .Inputs ["prefix" ].SetValue ("adapter=" ) 325driver .Dispatch (tea.KeyPressMsg {Code :'s' ,Mod :tea .ModCtrl }) 326page := & driver .App .WebhooksPage 327if page .Error != "" || page .Reveal == nil || page .Reveal .Secret == "" { 328t .Fatalf ("webhook form lost its generated signing secret: %+v" ,page ) 329 } 330listed ,err := daemon .ListWebhooks (t .Context (),attached ,session .ID ) 331if err != nil { 332t .Fatal (err ) 333 } 334var hook * daemon.Webhook 335for _ ,entry := range listed { 336if entry .Hook .Session == session .ID && entry .Hook .Name == "attached-alert" { 337copy := entry .Hook 338hook = & copy 339 } 340 } 341if hook == nil || hook .Header != "x-adapter-signature" || hook .Prefix != "adapter=" || ! hook .Enabled { 342t .Fatalf ("form did not persist its configured hook: %+v" ,hook ) 343 } 344deleted := false 345t .Cleanup (func () { 346if deleted { 347return 348 } 349if _ ,err := daemon .DeleteWebhook (context .Background (),attached ,hook .ID ,hook .ETag );err != nil { 350t .Error (err ) 351 } 352 }) 353for _ ,patch := range []daemon.WebhookPatch { 354 {SignatureHeader :new ("x - typed - signature "),SignaturePrefix :new ("typed = ")}, 355 {Enabled :new (false )}, {Enabled :new (true )}, 356 } { 357updated ,err := daemon .EditWebhook (t .Context (),attached ,hook .ID ,hook .ETag ,patch ) 358if err != nil { 359t .Fatal (err ) 360 } 361if updated .Hook == nil || updated .Hook .ID != hook .ID || updated .Hook .Session != session .ID || updated .Hook .Revision == hook .Revision || updated .Hook .Header != "x-typed-signature" || updated .Hook .Prefix != "typed=" { 362t .Fatalf ("typed edit lost updated configuration: %+v" ,updated ) 363 } 364if patch .Enabled != nil && updated .Hook .Enabled != * patch .Enabled { 365t .Fatalf ("typed edit lost enabled state: %+v" ,updated ) 366 } 367hook = updated .Hook 368persisted ,err := daemon .ListWebhooks (t .Context (),attached ,session .ID ) 369if err != nil { 370t .Fatal (err ) 371 } 372found := false 373for _ ,entry := range persisted { 374if entry .Hook .ID != hook .ID { 375continue 376 } 377found = true 378configuration := entry .Hook 379configuration .URL = "" 380configuration .Address = "" 381if configuration != * hook { 382t .Fatalf ("saved configuration differs: result=%+v saved=%+v" ,hook ,entry .Hook ) 383 } 384copy := entry .Hook 385hook = & copy 386 } 387if ! found { 388t .Fatal ("edited hook missing from list" ) 389 } 390 } 391driver .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 392selected := - 1 393for index ,item := range page .Hooks { 394if item .ID == hook .ID { 395selected = index 396 } 397 } 398if selected < 0 { 399t .Fatal ("created hook did not appear after secret dismissal" ) 400 } 401driver .Update (tea.WindowSizeMsg {Width :70 ,Height :20 }) 402for _ ,enabled := range []bool {true ,false } { 403if view := driver .View ();! strings .Contains (view ,"ctrl+t" )|| ! strings .Contains (view ,"agent access" ) { 404t .Fatalf ("agent access is hidden on a narrow webhook page:\n%s" ,view ) 405 } 406driver .Dispatch (tea.KeyPressMsg {Code :'t' ,Mod :tea .ModCtrl }) 407permission ,permissionErr := daemon .GetWebhookPermission (t .Context (),attached ,session .ID ) 408if permissionErr != nil || permission .AgentManagement != enabled || driver .App .WebhooksPage .AgentManagement != enabled { 409t .Fatalf ("agent access toggle did not persist %t: %+v, %v\n%s" ,enabled ,permission ,permissionErr ,driver .View ()) 410 } 411driver .Dispatch (tea.KeyPressMsg {Code :tea .KeyEscape }) 412driver .Dispatch (tui.ChatOpenWebhooksPageMsg {}) 413if driver .App .WebhooksPage .Error != "" || driver .App .WebhooksPage .AgentManagement != enabled { 414t .Fatalf ("reopened page lost agent access %t:\n%s" ,enabled ,driver .View ()) 415 } 416 } 417driver .Dispatch (tea.KeyPressMsg {Code :'r' ,Mod :tea .ModCtrl }) 418page .Cursor = selected 419driver .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 420driver .Dispatch (tea.KeyPressMsg {Code :'r' ,Mod :tea .ModCtrl }) 421listed ,err = daemon .ListWebhooks (t .Context (),attached ,session .ID ) 422if err != nil { 423t .Fatal (err ) 424 } 425disabled := false 426for _ ,entry := range listed { 427if entry .Hook .ID == hook .ID { 428disabled = ! entry .Hook .Enabled 429 } 430 } 431if page .Error != "" || ! disabled { 432t .Fatalf ("disabled hook did not survive reload: %+v" ,page ) 433 } 434// Deletion acknowledges its identity; authoritative reload removes it. 435for _ ,entry := range listed { 436if entry .Hook .ID == hook .ID { 437hook = & entry .Hook 438 } 439 } 440removed ,err := daemon .DeleteWebhook (t .Context (),attached ,hook .ID ,hook .ETag ) 441if err != nil { 442t .Fatal (err ) 443 } 444if removed .DeletedID != hook .ID { 445t .Fatalf ("typed deletion lost deleted hook: result=%+v saved=%+v" ,removed ,hook ) 446 } 447deleted = true 448driver .Dispatch (tea.KeyPressMsg {Code :'r' ,Mod :tea .ModCtrl }) 449listed ,err = daemon .ListWebhooks (t .Context (),attached ,session .ID ) 450if err != nil { 451t .Fatal (err ) 452 } 453for _ ,entry := range listed { 454if entry .Hook .ID == hook .ID { 455t .Fatal ("deleted hook still appears in authoritative list" ) 456 } 457 } 458if page .Error != "" { 459t .Fatal (page .Error ) 460 } 461}