Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions components/ambient-cli/cmd/acpctl/ambient/tui/fetch_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package tui

import (
"testing"

tea "github.com/charmbracelet/bubbletea"
)

// TestFetchActiveView_SessionScopingByProject verifies that the sessions view
// respects project scope on poll refresh, not just on initial navigation.
// Regression test for: project number-key switch correctly fetched project-scoped
// sessions initially, but fetchActiveView() fell through to FetchAllSessions()
// on every subsequent tick because it required currentAgentID to be set.
func TestFetchActiveView_SessionScopingByProject(t *testing.T) {
tests := []struct {
name string
currentProject string
currentAgentID string
wantScoped bool
}{
{
name: "project set via number key (no agent)",
currentProject: "hyperloop",
currentAgentID: "",
wantScoped: true,
},
{
name: "project and agent set (drill-down)",
currentProject: "hyperloop",
currentAgentID: "agent-123",
wantScoped: true,
},
{
name: "no project (global view)",
currentProject: "",
currentAgentID: "",
wantScoped: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := &scopeTrackingClient{}
m := &AppModel{
activeView: "sessions",
currentProject: tt.currentProject,
currentAgentID: tt.currentAgentID,
fetcher: fake,
}

cmd := m.fetchActiveView()
if cmd == nil {
t.Fatal("fetchActiveView() returned nil")
}
cmd()

if tt.wantScoped {
if fake.lastFetchAll {
t.Errorf("expected project-scoped fetch for %q, got FetchAllSessions", tt.currentProject)
}
if fake.lastFetchProject != tt.currentProject {
t.Errorf("expected fetch for project %q, got %q", tt.currentProject, fake.lastFetchProject)
}
} else {
if !fake.lastFetchAll {
t.Errorf("expected FetchAllSessions, got project-scoped fetch for %q", fake.lastFetchProject)
}
}
})
}
}

// TestFetchActiveView_ScheduledSessionScopingByProject verifies scheduled
// sessions respect project scope on refresh.
func TestFetchActiveView_ScheduledSessionScopingByProject(t *testing.T) {
t.Run("with project", func(t *testing.T) {
fake := &scopeTrackingClient{}
m := &AppModel{
activeView: "scheduledsessions",
currentProject: "hyperloop",
fetcher: fake,
}
cmd := m.fetchActiveView()
if cmd == nil {
t.Fatal("fetchActiveView() returned nil")
}
cmd()
if fake.lastFetchProject != "hyperloop" {
t.Errorf("expected fetch for project %q, got %q", "hyperloop", fake.lastFetchProject)
}
})

t.Run("no project returns nil", func(t *testing.T) {
fake := &scopeTrackingClient{}
m := &AppModel{
activeView: "scheduledsessions",
currentProject: "",
fetcher: fake,
}
cmd := m.fetchActiveView()
if cmd != nil {
t.Error("expected nil command for scheduledsessions with no project")
}
})
}

// TestFetchActiveView_AgentsScopingByProject verifies agents view respects
// project scope on refresh.
func TestFetchActiveView_AgentsScopingByProject(t *testing.T) {
fake := &scopeTrackingClient{}
m := &AppModel{
activeView: "agents",
currentProject: "hyperloop",
fetcher: fake,
}
cmd := m.fetchActiveView()
if cmd == nil {
t.Fatal("fetchActiveView() returned nil")
}
cmd()
if fake.lastFetchProject != "hyperloop" {
t.Errorf("expected agents fetch for project %q, got %q", "hyperloop", fake.lastFetchProject)
}
}

// scopeTrackingClient records which fetch method was called and with what scope.
type scopeTrackingClient struct {
lastFetchProject string
lastFetchAll bool
}

var _ dataFetcher = (*scopeTrackingClient)(nil)

func (c *scopeTrackingClient) FetchProjects() tea.Cmd {
return func() tea.Msg { return ProjectsMsg{} }
}

func (c *scopeTrackingClient) FetchAgents(projectID string) tea.Cmd {
c.lastFetchProject = projectID
return func() tea.Msg { return AgentsMsg{} }
}

func (c *scopeTrackingClient) FetchSessions(projectID string) tea.Cmd {
c.lastFetchProject = projectID
c.lastFetchAll = false
return func() tea.Msg { return SessionsMsg{} }
}

func (c *scopeTrackingClient) FetchAllSessions() tea.Cmd {
c.lastFetchAll = true
c.lastFetchProject = ""
return func() tea.Msg { return SessionsMsg{} }
}

func (c *scopeTrackingClient) FetchScheduledSessions(projectID string) tea.Cmd {
c.lastFetchProject = projectID
return func() tea.Msg { return ScheduledSessionsMsg{} }
}

func (c *scopeTrackingClient) FetchInbox(projectID, agentID string) tea.Cmd {
c.lastFetchProject = projectID
return func() tea.Msg { return InboxMsg{} }
}
58 changes: 46 additions & 12 deletions components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ func getEditor() string {
// AppModel — the TUI model with full navigation hierarchy
// ---------------------------------------------------------------------------

// dataFetcher is the subset of TUIClient used by fetchActiveView for polling.
// Extracted as an interface to enable unit testing of view-scoping logic.
type dataFetcher interface {
FetchProjects() tea.Cmd
FetchAgents(projectID string) tea.Cmd
FetchSessions(projectID string) tea.Cmd
FetchAllSessions() tea.Cmd
FetchScheduledSessions(projectID string) tea.Cmd
FetchInbox(projectID, agentID string) tea.Cmd
}

// AppModel is the top-level Bubbletea model for the rewritten TUI.
// It coexists with the legacy Model type in model.go until migration is
// complete.
Expand Down Expand Up @@ -188,6 +199,10 @@ type AppModel struct {
// Rate-limit backoff: skip the next poll cycle when a 429 is received.
skipNextPoll bool

// fetcher overrides client for fetchActiveView. Tests set this to a fake;
// production code leaves it nil (fetchActiveView uses m.client).
fetcher dataFetcher

// Project shortcuts for number-key switching (like k9s namespace shortcuts).
// Holds project names in alphabetical order, refreshed on ProjectsMsg.
projectShortcuts []string
Expand Down Expand Up @@ -440,36 +455,40 @@ func (m *AppModel) popView() tea.Cmd {
return m.fetchActiveView()
}

func (m *AppModel) dataFetcher() dataFetcher {
if m.fetcher != nil {
return m.fetcher
}
return m.client
}

// fetchActiveView returns a tea.Cmd to fetch data for the currently active view.
func (m *AppModel) fetchActiveView() tea.Cmd {
f := m.dataFetcher()
switch m.activeView {
case "projects":
return m.client.FetchProjects()
return f.FetchProjects()
case "agents":
if m.currentProject != "" {
return m.client.FetchAgents(m.currentProject)
return f.FetchAgents(m.currentProject)
}
// Fall back to config project if no drill-down context.
if ctx := m.config.Current(); ctx != nil && ctx.Project != "" {
return m.client.FetchAgents(ctx.Project)
return f.FetchAgents(ctx.Project)
}
return nil
case "sessions":
if m.currentAgentID != "" && m.currentProject != "" {
// Agent-scoped sessions — fetch project sessions and filter client-side
// in the handler.
return m.client.FetchSessions(m.currentProject)
if m.currentProject != "" {
return f.FetchSessions(m.currentProject)
}
// Global sessions view.
return m.client.FetchAllSessions()
return f.FetchAllSessions()
case "inbox":
if m.currentAgentID != "" && m.currentProject != "" {
return m.client.FetchInbox(m.currentProject, m.currentAgentID)
return f.FetchInbox(m.currentProject, m.currentAgentID)
}
return nil
case "scheduledsessions":
if m.currentProject != "" {
return m.client.FetchScheduledSessions(m.currentProject)
return f.FetchScheduledSessions(m.currentProject)
}
return nil
case "messages":
Expand Down Expand Up @@ -2545,6 +2564,21 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) {
)
}

if m.currentProject != "" {
// Project-scoped sessions (no specific agent).
m.sessionTable.SetScope(m.currentProject)
m.navStack = append(m.navStack[:0],
NavEntry{Kind: "projects", Scope: "all"},
NavEntry{Kind: "sessions", Scope: m.currentProject},
)
m.activeView = "sessions"
m.pollInFlight = true
return m, tea.Batch(
m.client.FetchSessions(m.currentProject),
m.setInfo("Viewing sessions in project "+m.currentProject),
)
}

// Global sessions view.
m.sessionTable.SetScope("all")
m.navStack = []NavEntry{
Expand Down
Loading