test(daemon): exercise typed workflows against a separate daemon
Cover context, trees, catalogs, MCP secrets, and webhook changes. Recover rejected creation receipts without allocating a session.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368//go:build unix
// The independent adapter must carry the real daemon's responses into the TUI// without owning its launcher, workspace discovery, or wire-format decoding.package e2e
import ( "context" "encoding/json" "os" "path/filepath" "slices" "strings" "testing"
"albedo/cli/internal/config" "albedo/cli/internal/daemon" "albedo/cli/internal/tui"
tea "charm.land/bubbletea/v2")
func TestIndependentAdapterDrivesContextTreeAndSessionCatalog(t *testing.T) { profile := providerRoute(t, echoReply) before := conn(t).Snapshot() attached, err := daemon.Attach(t.Context(), before, nil) if err != nil { t.Fatal(err) } workspace := t.TempDir() skill := filepath.Join(workspace, ".agents", "skills", "adapter-review", "SKILL.md") if err := os.MkdirAll(filepath.Dir(skill), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(skill, []byte("---\nname: adapter-review\ndescription: Review an attached adapter workflow\n---\nInspect the prepared request.\n"), 0o600); err != nil { t.Fatal(err) } session, err := daemon.CreateSession(t.Context(), attached, daemon.CreateSessionRequest{Workspace: workspace, Provider: profile}) if err != nil { t.Fatal(err) } client := daemon.NewChatClient(attached, session.ID) for index, prompt := range []string{"adapter durable first turn", "inspect adapter durable history"} { if _, err := client.Send(t.Context(), prompt, nil); err != nil { t.Fatal(err) } waitIdle(t, session.ID, profile, index+1) } preview, err := daemon.GetSessionPreview(t.Context(), attached, session.ID, 20) if err != nil { t.Fatal(err) } foundPreview := false for _, item := range preview.Items { foundPreview = foundPreview || strings.Contains(item.Preview, "inspect adapter durable history") } if !foundPreview { t.Fatalf("preview omitted the completed turn: %+v", preview) } commands, err := daemon.ListSessionCommands(t.Context(), attached, session.ID) if err != nil { t.Fatal(err) } commandIndex := slices.IndexFunc(commands, func(command daemon.SessionCommand) bool { return command.Name == "/adapter-review" }) if commandIndex < 0 { t.Fatal("enabled workspace skill is missing from the command menu catalog") } skillCommand := commands[commandIndex] if !skillCommand.Skill || !skillCommand.UserTurn || skillCommand.Method == "" || skillCommand.Description != "Review an attached adapter workflow" || len(skillCommand.Arguments) != 1 { t.Fatalf("skill command lost its runnable metadata: %+v", skillCommand) } driver := driveTUIWithConnection(t, &session, attached) defer driver.App.Chat.Close() driver.Dispatch(tui.ChatOpenContextInspectorMsg{}) inspector := &driver.App.ContextInspector if inspector.Error != "" || inspector.Snapshot == nil || inspector.Snapshot.State != "ready" { t.Fatalf("prepared context did not reach inspector: %+v", inspector) } history := -1 for index, section := range inspector.Snapshot.Sections { if section.Kind == "history" && section.Pages > 0 { history = index break } } if history < 0 { t.Fatal("prepared context has no readable history section") } inspector.Cursor = history driver.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter}) if inspector.Detail == nil || inspector.Detail.Value == nil || !strings.Contains(inspector.Detail.Value.Content, "adapter durable first turn") { t.Fatalf("context detail lost prior history: %+v", inspector.Detail) } driver.Dispatch(tui.ContextDoneMsg{}) driver.Dispatch(tui.ChatOpenTreePickerMsg{}) if driver.App.TreePicker.Error != "" || len(driver.App.TreePicker.Checkpoints) < 2 { t.Fatalf("completed turns did not reach checkpoint picker: %+v", driver.App.TreePicker) } driver.Dispatch(tui.TreeCancelMsg{}) driver.Dispatch(tui.ChatOpenCapabilityPageMsg{Kind: "skills"}) page := &driver.App.CapabilityPage selected := -1 for index, item := range page.Items { if item.Candidate.Source == skill { selected = index break } } if page.Error != "" || selected < 0 || !page.Items[selected].Candidate.EffectiveEnabled { t.Fatalf("workspace skill did not reach catalog page: %+v", page) } page.Cursor = selected driver.Dispatch(tea.KeyPressMsg{Code: 's', Text: "s"}) // The app also refreshes its command menu after this save, which starts // recurring glance polls. Drive through the page's actual save and reload // completion instead of asking the synchronous driver to settle timers. queued := driver.results(driver.Update(tea.KeyPressMsg{Code: tea.KeySpace})) for step := 0; page.Saving || page.Loading; step++ { if step == 16 || len(queued) == 0 { t.Fatalf("catalog save did not finish: %+v", page) } message := queued[0] queued = queued[1:] command := driver.Update(message) if page.Saving || page.Loading { queued = append(queued, driver.results(command)...) } } catalog, err := daemon.GetCapabilityCatalog(t.Context(), attached, session.ID) if err != nil { t.Fatal(err) } disabled := false for _, candidate := range catalog.Candidates { if candidate.Source == skill { disabled = !candidate.EffectiveEnabled && candidate.SessionOverride != nil && !*candidate.SessionOverride } } if page.Error != "" || !disabled { t.Fatalf("session catalog toggle was not persisted: %+v", page) } commands, err = daemon.ListSessionCommands(t.Context(), attached, session.ID) if err != nil { t.Fatal(err) } if slices.ContainsFunc(commands, func(command daemon.SessionCommand) bool { return command.Name == "/adapter-review" }) { t.Fatal("disabled skill still appears as a runnable command") } after, err := readDaemonSnapshot(suite.home) if err != nil || after != before { t.Fatalf("independent attachment replaced its daemon: before=%+v after=%+v err=%v", before, after, err) }}
func TestTypedMCPSecretsKeepReplaceRemoveAndClear(t *testing.T) { profile := providerRoute(t, echoReply) attached, err := daemon.Attach(t.Context(), conn(t).Snapshot(), nil) if err != nil { t.Fatal(err) } session, err := daemon.CreateSession(t.Context(), attached, daemon.CreateSessionRequest{Workspace: t.TempDir(), Provider: profile}) if err != nil { t.Fatal(err) } name := "typed-secret-patch" server := config.MCPServer{Type: "http", URL: "http://127.0.0.1:1/mcp", Enabled: new(false)} t.Cleanup(func() { if _, err := daemon.SaveMCP(context.Background(), attached, session.ID, daemon.MCPUpdateRequest{Name: name, Server: nil, Secrets: daemon.MCPSecretsPatch{}}); err != nil { t.Error(err) } }) save := func(patch daemon.MCPSecretsPatch) daemon.Settings { t.Helper() if _, err := daemon.SaveMCP(t.Context(), attached, session.ID, daemon.MCPUpdateRequest{Name: name, Server: &server, Secrets: patch}); err != nil { t.Fatal(err) } settings, err := daemon.GetSettings(t.Context(), attached) if err != nil { t.Fatal(err) } return settings } save(daemon.MCPSecretsPatch{ BearerToken: new("initial-bearer"), Headers: map[string]*string{"x-retained": new("initial-header"), "x-removed": new("remove-header")}, Env: map[string]*string{"RETAINED": new("initial-env"), "REMOVED": new("remove-env")}, }) kept := save(daemon.MCPSecretsPatch{}).Credentials.MCP[name] if !kept.BearerToken || !slices.Equal(kept.Headers, []string{"x-removed", "x-retained"}) || !slices.Equal(kept.Env, []string{"REMOVED", "RETAINED"}) { t.Fatalf("omitted secret patch changed saved names: %+v", kept) } replaced := save(daemon.MCPSecretsPatch{ BearerToken: new("replacement-bearer"), Headers: map[string]*string{"x-retained": new("replacement-header"), "x-removed": nil}, Env: map[string]*string{"RETAINED": new("replacement-env"), "REMOVED": nil}, }).Credentials.MCP[name] if !replaced.BearerToken || !slices.Equal(replaced.Headers, []string{"x-retained"}) || !slices.Equal(replaced.Env, []string{"RETAINED"}) { t.Fatalf("named removal changed retained secret names: %+v", replaced) } // The fixture already owns this daemon home. Reading its stored test values // distinguishes replacement from accidental omission without exposing them // through the adapter's redacted settings response. data, err := os.ReadFile(filepath.Join(suite.home, "creds.json")) if err != nil { t.Fatal(err) } var stored struct { MCP map[string]struct { BearerToken string `json:"bearerToken"` Headers map[string]string `json:"headers"` Env map[string]string `json:"env"` } `json:"mcp"` } if err := json.Unmarshal(data, &stored); err != nil { t.Fatal(err) } values := stored.MCP[name] if values.BearerToken != "replacement-bearer" || values.Headers["x-retained"] != "replacement-header" || values.Env["RETAINED"] != "replacement-env" { t.Fatal("typed replacement patch did not update persisted test secrets") } cleared := save(daemon.MCPSecretsPatch{RemoveBearerToken: true, ClearHeaders: true, ClearEnv: true}).Credentials.MCP[name] if cleared.Any() { t.Fatalf("explicit container removal retained secret names: %+v", cleared) }}
func TestAttachedWebhookScreenCreatesConfiguresDisablesAndDeletesHook(t *testing.T) { profile := providerRoute(t, echoReply) attached, err := daemon.Attach(t.Context(), conn(t).Snapshot(), nil) if err != nil { t.Fatal(err) } session, err := daemon.CreateSession(t.Context(), attached, daemon.CreateSessionRequest{Workspace: t.TempDir(), Provider: profile}) if err != nil { t.Fatal(err) } if _, err := daemon.SelectExtension(t.Context(), attached, session.ID, daemon.ExtensionSelectionRequest{Name: "webhooks", Scope: "session", Enabled: new(true)}); err != nil { t.Fatal(err) } driver := driveTUIWithConnection(t, &session, attached) defer driver.App.Chat.Close() driver.Dispatch(tui.ChatOpenWebhooksPageMsg{}) driver.Dispatch(tea.KeyPressMsg{Code: 'n', Text: "n"}) if driver.App.WebhooksPage.Error != "" || driver.App.WebhooksPage.Form == nil { t.Fatalf("cannot open webhook creation form: %+v", driver.App.WebhooksPage) } driver.Type("attached-alert") driver.App.WebhooksPage.Form.Inputs["header"].SetValue("x-adapter-signature") driver.App.WebhooksPage.Form.Inputs["prefix"].SetValue("adapter=") driver.Dispatch(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) page := &driver.App.WebhooksPage if page.Error != "" || page.Reveal == nil || page.Reveal.Secret == "" { t.Fatalf("webhook form lost its generated signing secret: %+v", page) } listed, err := daemon.RunWebhook(t.Context(), attached, session.ID, daemon.WebhookRequest{Action: daemon.WebhookList}) if err != nil { t.Fatal(err) } var hook *daemon.Webhook for _, entry := range listed.Hooks { if entry.Hook.Session == session.ID && entry.Hook.Name == "attached-alert" { copy := entry.Hook hook = © } } if hook == nil || hook.Header != "x-adapter-signature" || hook.Prefix != "adapter=" || !hook.Enabled { t.Fatalf("form did not persist its configured hook: %+v", hook) } deleted := false t.Cleanup(func() { if deleted { return } if _, err := daemon.RunWebhook(context.Background(), attached, session.ID, daemon.WebhookRequest{Action: daemon.WebhookDelete, HookID: hook.ID}); err != nil { t.Error(err) } }) for _, request := range []daemon.WebhookRequest{ {Action: daemon.WebhookSignature, HookID: hook.ID, Header: "x-typed-signature", Prefix: "typed="}, {Action: daemon.WebhookDisable, HookID: hook.ID}, {Action: daemon.WebhookEnable, HookID: hook.ID}, } { updated, err := daemon.RunWebhook(t.Context(), attached, session.ID, request) if err != nil { t.Fatal(err) } if updated.Hook == nil || updated.Hook.ID != hook.ID || updated.Hook.Session != session.ID || updated.Hook.Revision != hook.Revision+1 || updated.Hook.Header != "x-typed-signature" || updated.Hook.Prefix != "typed=" || updated.Hook.Address == "" { t.Fatalf("typed %s result lost updated hook: %+v", request.Action, updated) } if updated.Hook.Enabled != (request.Action != daemon.WebhookDisable) { t.Fatalf("typed %s result lost enabled state: %+v", request.Action, updated.Hook) } hook = updated.Hook persisted, err := daemon.RunWebhook(t.Context(), attached, session.ID, daemon.WebhookRequest{Action: daemon.WebhookList}) if err != nil { t.Fatal(err) } found := false for _, entry := range persisted.Hooks { if entry.Hook.ID == hook.ID { found = true if entry.Hook != *hook { t.Fatalf("typed %s result differs from persisted hook: result=%+v saved=%+v", request.Action, hook, entry.Hook) } } } if !found { t.Fatalf("typed %s hook missing from list", request.Action) } } driver.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter}) selected := -1 for index, item := range page.Hooks { if item.ID == hook.ID { selected = index } } if selected < 0 { t.Fatal("created hook did not appear after secret dismissal") } page.Cursor = selected driver.Dispatch(tea.KeyPressMsg{Code: tea.KeySpace}) driver.Dispatch(tea.KeyPressMsg{Code: 'r', Text: "r"}) listed, err = daemon.RunWebhook(t.Context(), attached, session.ID, daemon.WebhookRequest{Action: daemon.WebhookList}) if err != nil { t.Fatal(err) } disabled := false for _, entry := range listed.Hooks { if entry.Hook.ID == hook.ID { disabled = !entry.Hook.Enabled } } if page.Error != "" || !disabled { t.Fatalf("disabled hook did not survive reload: %+v", page) } // Deletion returns the last saved hook, then authoritative reload removes it. for _, entry := range listed.Hooks { if entry.Hook.ID == hook.ID { hook = &entry.Hook } } removed, err := daemon.RunWebhook(t.Context(), attached, session.ID, daemon.WebhookRequest{Action: daemon.WebhookDelete, HookID: hook.ID}) if err != nil { t.Fatal(err) } if removed.Hook == nil || *removed.Hook != *hook { t.Fatalf("typed deletion lost deleted hook: result=%+v saved=%+v", removed, hook) } deleted = true driver.Dispatch(tea.KeyPressMsg{Code: 'r', Text: "r"}) listed, err = daemon.RunWebhook(t.Context(), attached, session.ID, daemon.WebhookRequest{Action: daemon.WebhookList}) if err != nil { t.Fatal(err) } for _, entry := range listed.Hooks { if entry.Hook.ID == hook.ID { t.Fatal("deleted hook still appears in authoritative list") } } if page.Error != "" { t.Fatal(page.Error) }}269 unmodified linesExpand all270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312469 unmodified linesExpand all269 unmodified linesExpand all }) }}
func TestRejectedCreationReceiptRecoversWithoutAllocatingSession(t *testing.T) { t.Parallel() workspace := t.TempDir() handle, err := daemon.NewCreation(daemon.CreateSessionRequest{Workspace: workspace, Provider: "missing-creation-profile"}) if err != nil { t.Fatal(err) } _, err = daemon.CreateSessionOperation(t.Context(), conn(t), handle) initial, ok := errors.AsType[*daemon.APIError](err) if !ok || initial.StatusCode != http.StatusBadRequest { t.Fatalf("unknown provider did not reject creation: %v", err) } // A rejected creation has a durable receipt but no session target. An // independent receipt read must recover that rejection without treating // the empty target as malformed or submitting another creation. receipt, err := daemon.ResolveOperation(t.Context(), conn(t), handle) if err != nil || receipt.Status != "rejected" || receipt.Target != "" || receipt.OperationID != handle.ID() { t.Fatalf("rejected creation receipt: %+v, %v", receipt, err) } created, err := daemon.ResolveCreation(t.Context(), conn(t), handle) recovered, ok := errors.AsType[*daemon.APIError](err) if !ok || recovered.StatusCode != initial.StatusCode || created.ID != "" { t.Fatalf("resolve rejected creation: %+v, %v", created, err) } sessions, err := daemon.ListSessions(t.Context(), conn(t)) if err != nil { t.Fatal(err) } for _, session := range sessions { if session.Workspace == workspace { t.Fatal("rejected creation allocated a session") } }}
func TestInvalidTUICreationAcknowledgementRecoversCreatedSession(t *testing.T) { profile := providerRoute(t, echoReply) for _, fault := range []acknowledgementFault{dropAcknowledgement, emptyAcknowledgement, malformedAcknowledgement, missingAcknowledgement} { t.Run(string(fault), func(t *testing.T) {469 unmodified linesExpand all