diff --git a/docs/tools/background-agents/index.md b/docs/tools/background-agents/index.md index e4a1b5b86..82277829d 100644 --- a/docs/tools/background-agents/index.md +++ b/docs/tools/background-agents/index.md @@ -39,6 +39,10 @@ The background agents tool lets an orchestrator dispatch work to sub-agents conc `list_background_agents` takes no parameters. +## Live status in the TUI + +While background tasks are running, the TUI sidebar shows a live **Background agents (N)** panel — one row per running task with a colored activity dot, the sub-agent's name in its accent color, and the elapsed run time. The panel appears only while at least one task is running and clears automatically as tasks finish, so an idle session shows nothing. The same live count is surfaced as a muted `+N background` suffix on the delegation breadcrumb when the orchestrator is simultaneously delegating with `transfer_task`. + ## Configuration ```yaml diff --git a/pkg/app/app.go b/pkg/app/app.go index 8fa8f85fb..87024c7cc 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -29,6 +29,7 @@ import ( "github.com/docker/docker-agent/pkg/shellpath" "github.com/docker/docker-agent/pkg/skills" "github.com/docker/docker-agent/pkg/tools" + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" skillstool "github.com/docker/docker-agent/pkg/tools/builtin/skills" mcptools "github.com/docker/docker-agent/pkg/tools/mcp" "github.com/docker/docker-agent/pkg/tui/messages" @@ -236,6 +237,24 @@ func (a *App) CurrentAgentSkills() []skills.Skill { return st.Skills() } +// backgroundAgentLister is implemented by runtimes that run background agents +// in-process (the local runtime). Remote/client runtimes don't, so the App +// reports no background agents for them. +type backgroundAgentLister interface { + BackgroundAgents() []agenttool.TaskInfo +} + +// BackgroundAgents returns a read-only snapshot of in-flight background agent +// tasks, or nil when the runtime doesn't run them in-process (e.g. remote +// runtimes). Mirrors the other read-only accessors that surface runtime state +// to the TUI, like CurrentAgentSkills. +func (a *App) BackgroundAgents() []agenttool.TaskInfo { + if l, ok := a.runtime.(backgroundAgentLister); ok { + return l.BackgroundAgents() + } + return nil +} + // ResolveSkillCommand checks if the input matches a skill slash command (e.g. /skill-name args). // If matched, it reads the skill content and returns the resolved prompt. Otherwise returns "". // diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index cac21541f..c6d3a190f 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -855,6 +855,18 @@ func (r *LocalRuntime) CurrentAgentSkillsToolset() *skills.ToolSet { return nil } +// BackgroundAgents returns a read-only snapshot of the background agent tasks +// spawned via the agent toolset, used by the TUI's live background-agent +// surface. It is exposed only on the local runtime (which runs background +// agents in-process); the App reaches it through an optional interface so +// remote runtimes report none. +func (r *LocalRuntime) BackgroundAgents() []agenttool.TaskInfo { + if r.bgAgents == nil { + return nil + } + return r.bgAgents.Snapshot() +} + // ExecuteMCPPrompt executes an MCP prompt with provided arguments and returns the content. func (r *LocalRuntime) ExecuteMCPPrompt(ctx context.Context, promptName string, arguments map[string]string) (string, error) { currentAgent := r.CurrentAgent() diff --git a/pkg/tools/builtin/agent/agent.go b/pkg/tools/builtin/agent/agent.go index 109be74b9..cee55a284 100644 --- a/pkg/tools/builtin/agent/agent.go +++ b/pkg/tools/builtin/agent/agent.go @@ -89,17 +89,27 @@ const ( taskFailed ) +// Status string values surfaced via TaskInfo.Status (returned by +// taskStatus.String). Exported so callers such as the TUI can match on a task's +// status without depending on the unexported taskStatus enum. +const ( + StatusRunning = "running" + StatusCompleted = "completed" + StatusStopped = "stopped" + StatusFailed = "failed" +) + // String returns a human-readable name for the status. func (s taskStatus) String() string { switch s { case taskRunning: - return "running" + return StatusRunning case taskCompleted: - return "completed" + return StatusCompleted case taskStopped: - return "stopped" + return StatusStopped case taskFailed: - return "failed" + return StatusFailed default: return "unknown" } @@ -225,6 +235,38 @@ func NewHandler(runner Runner) *Handler { } } +// TaskInfo is a lock-safe, read-only view of a background agent task. It exposes +// only fields fixed when the task is created plus the atomically-loaded status, +// so it can be read while the task runs without exposing the internal *task +// (whose result and output fields mutate concurrently). +type TaskInfo struct { + ID string + Agent string + Task string + Status string + StartedAt time.Time +} + +// Snapshot returns a read-only view of every tracked background agent task, +// running and finished. It reads only fields fixed at task creation plus the +// atomic status, so it is safe to call concurrently with up to +// maxConcurrentTasks running task goroutines and never exposes the internal +// *task. This is a pure read model: it does not alter task state or lifecycle. +func (h *Handler) Snapshot() []TaskInfo { + var out []TaskInfo + h.tasks.Range(func(id string, t *task) bool { + out = append(out, TaskInfo{ + ID: id, + Agent: t.agentName, + Task: t.taskDesc, + Status: t.loadStatus().String(), + StartedAt: t.startTime, + }) + return true + }) + return out +} + func newTaskID() string { return "agent_task_" + uuid.New().String() } diff --git a/pkg/tools/builtin/agent/agent_test.go b/pkg/tools/builtin/agent/agent_test.go index 2b64d319d..331f0e32f 100644 --- a/pkg/tools/builtin/agent/agent_test.go +++ b/pkg/tools/builtin/agent/agent_test.go @@ -110,6 +110,62 @@ func TestStatusToString(t *testing.T) { } } +// --- Snapshot --- + +func TestSnapshot_StatusPerTask(t *testing.T) { + cases := []struct { + name string + status taskStatus + want string + }{ + {"running", taskRunning, StatusRunning}, + {"completed", taskCompleted, StatusCompleted}, + {"stopped", taskStopped, StatusStopped}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newTestHandler() + start := time.Now() + tk := insertTask(h, "t1", "researcher", tc.status) + tk.startTime = start + + got := h.Snapshot() + require.Len(t, got, 1) + assert.Equal(t, TaskInfo{ + ID: "t1", + Agent: "researcher", + Task: "test task", + Status: tc.want, + StartedAt: start, + }, got[0]) + }) + } +} + +func TestSnapshot_AllTasks(t *testing.T) { + h := newTestHandler() + insertTask(h, "t1", "researcher", taskRunning) + insertTask(h, "t2", "writer", taskCompleted) + insertTask(h, "t3", "editor", taskStopped) + + got := h.Snapshot() + require.Len(t, got, 3, "Snapshot must return one entry per task, finished tasks included") + + statuses := make(map[string]string, len(got)) + for _, ti := range got { + statuses[ti.ID] = ti.Status + } + assert.Equal(t, map[string]string{ + "t1": StatusRunning, + "t2": StatusCompleted, + "t3": StatusStopped, + }, statuses) +} + +func TestSnapshot_Empty(t *testing.T) { + assert.Empty(t, newTestHandler().Snapshot()) +} + // --- runningTaskCount / totalTaskCount --- func TestTaskCounts(t *testing.T) { @@ -569,6 +625,14 @@ func TestHandler_ConcurrentAccess(t *testing.T) { }) } + // Snapshot reads immutable task fields plus the atomic status concurrently + // with the HandleStop CAS writes below; -race must stay clean. + for range 5 { + wg.Go(func() { + _ = h.Snapshot() + }) + } + for i := range 5 { wg.Add(1) go func(tc tools.ToolCall) { diff --git a/pkg/tui/components/sidebar/background_agents_test.go b/pkg/tui/components/sidebar/background_agents_test.go new file mode 100644 index 000000000..0277d0263 --- /dev/null +++ b/pkg/tui/components/sidebar/background_agents_test.go @@ -0,0 +1,138 @@ +package sidebar + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" + "github.com/docker/docker-agent/pkg/tui/service" +) + +func runningTask(id, agent string, startedAt time.Time) agenttool.TaskInfo { + return agenttool.TaskInfo{ + ID: id, + Agent: agent, + Task: agent + " task", + Status: agenttool.StatusRunning, + StartedAt: startedAt, + } +} + +func TestBackgroundAgentsSection_HiddenWhenEmpty(t *testing.T) { + t.Parallel() + + m := New(&service.SessionState{}).(*model) + assert.Empty(t, m.backgroundAgentsSection(40)) +} + +func TestBackgroundAgentsSection_RendersRunningRoster(t *testing.T) { + t.Parallel() + + m := New(&service.SessionState{}).(*model) + now := time.Now() + m.SetBackgroundAgents([]agenttool.TaskInfo{ + runningTask("t1", "researcher", now.Add(-90*time.Second)), + runningTask("t2", "writer", now.Add(-5*time.Second)), + }) + + out := ansi.Strip(m.backgroundAgentsSection(40)) + + assert.Contains(t, out, "Background agents (2)") + assert.Contains(t, out, "●") + assert.Contains(t, out, "researcher") + assert.Contains(t, out, "writer") + // Elapsed run time is shown per row (reusing the shared duration formatter). + assert.Contains(t, out, "1m30s") +} + +// TestSetBackgroundAgents_FiltersToRunningAndSorts verifies the read-model seam: +// finished tasks linger in the runtime snapshot but are dropped from the panel, +// and the surviving running tasks are ordered by start time so rows stay put +// across polls. +func TestSetBackgroundAgents_FiltersToRunningAndSorts(t *testing.T) { + t.Parallel() + + m := New(&service.SessionState{}).(*model) + now := time.Now() + m.SetBackgroundAgents([]agenttool.TaskInfo{ + {ID: "done", Agent: "old", Status: agenttool.StatusCompleted, StartedAt: now.Add(-time.Hour)}, + runningTask("late", "writer", now.Add(-5*time.Second)), + runningTask("early", "researcher", now.Add(-90*time.Second)), + {ID: "stopped", Agent: "cancelled", Status: agenttool.StatusStopped, StartedAt: now}, + }) + + require.Len(t, m.backgroundAgents, 2) + assert.Equal(t, "researcher", m.backgroundAgents[0].Agent) + assert.Equal(t, "writer", m.backgroundAgents[1].Agent) +} + +// TestSetBackgroundAgents_DrainsToEmpty verifies the panel hides again once the +// snapshot no longer contains running tasks. +func TestSetBackgroundAgents_DrainsToEmpty(t *testing.T) { + t.Parallel() + + m := New(&service.SessionState{}).(*model) + m.SetBackgroundAgents([]agenttool.TaskInfo{runningTask("t1", "researcher", time.Now())}) + require.Len(t, m.backgroundAgents, 1) + + m.SetBackgroundAgents([]agenttool.TaskInfo{ + {ID: "t1", Agent: "researcher", Status: agenttool.StatusCompleted, StartedAt: time.Now()}, + }) + assert.Empty(t, m.backgroundAgents) + assert.Empty(t, m.backgroundAgentsSection(40)) +} + +func TestBackgroundAgentsSection_InRenderSections(t *testing.T) { + t.Parallel() + + m := New(&service.SessionState{}).(*model) + m.SetSize(40, 100) + + without := strings.Join(m.renderSections(35), "\n") + assert.NotContains(t, without, "Background agents") + + m.SetBackgroundAgents([]agenttool.TaskInfo{runningTask("t1", "researcher", time.Now())}) + with := ansi.Strip(strings.Join(m.renderSections(35), "\n")) + assert.Contains(t, with, "Background agents (1)") + assert.Contains(t, with, "researcher") +} + +// TestDelegationBreadcrumb_AppendsBackgroundCount covers wiring the live +// background-agent count into the Phase 2 delegation breadcrumb. +func TestDelegationBreadcrumb_AppendsBackgroundCount(t *testing.T) { + t.Parallel() + + t.Run("appends +N background while delegating", func(t *testing.T) { + t.Parallel() + m := New(&service.SessionState{}).(*model) + m.agentChain = []string{"root", "librarian"} + m.SetBackgroundAgents([]agenttool.TaskInfo{ + runningTask("t1", "researcher", time.Now()), + runningTask("t2", "writer", time.Now()), + }) + + out := ansi.Strip(m.delegationBreadcrumb(80)) + assert.Contains(t, out, "root ⏵ librarian") + assert.Contains(t, out, "+2 background") + }) + + t.Run("no addendum without background agents", func(t *testing.T) { + t.Parallel() + m := New(&service.SessionState{}).(*model) + m.agentChain = []string{"root", "librarian"} + assert.NotContains(t, ansi.Strip(m.delegationBreadcrumb(80)), "background") + }) + + t.Run("no breadcrumb at depth <= 1 even with background agents", func(t *testing.T) { + t.Parallel() + m := New(&service.SessionState{}).(*model) + m.agentChain = []string{"root"} + m.SetBackgroundAgents([]agenttool.TaskInfo{runningTask("t1", "researcher", time.Now())}) + assert.Empty(t, m.delegationBreadcrumb(80)) + }) +} diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index c281df11d..54c65b509 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -21,6 +21,7 @@ import ( "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/tools" + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" "github.com/docker/docker-agent/pkg/tui/components/scrollbar" "github.com/docker/docker-agent/pkg/tui/components/scrollview" "github.com/docker/docker-agent/pkg/tui/components/spinner" @@ -54,6 +55,9 @@ type Model interface { SetAgentSwitching(switching bool) SetToolsetInfo(availableTools int, loading bool) SetSkillsInfo(availableSkills int) + // SetBackgroundAgents updates the live background-agent roster shown in the + // "Background agents (N)" section and the delegation breadcrumb's "+N" count. + SetBackgroundAgents(tasks []agenttool.TaskInfo) SetSessionStarred(starred bool) SetQueuedMessages(messages ...string) GetSize() (width, height int) @@ -141,14 +145,15 @@ type model struct { rootSessionID string // Main (top-level) session, shown when no stream is active scrollview *scrollview.Model workingDirectory string - gitBranchName string // current git branch, empty if not in a repo - queuedMessages []string // Truncated preview of queued messages - streamCancelled bool // true after ESC cancel until next StreamStartedEvent - collapsed bool // true when sidebar is collapsed - titleRegenerating bool // true when title is being regenerated by AI - titleGenerated bool // true once a title has been generated or set (hides pencil until then) - preferredWidth int // user's preferred width (persisted across collapse/expand) - editingTitle bool // true when inline title editing is active + gitBranchName string // current git branch, empty if not in a repo + queuedMessages []string // Truncated preview of queued messages + backgroundAgents []agenttool.TaskInfo // Running background-agent tasks (sorted by start time) + streamCancelled bool // true after ESC cancel until next StreamStartedEvent + collapsed bool // true when sidebar is collapsed + titleRegenerating bool // true when title is being regenerated by AI + titleGenerated bool // true once a title has been generated or set (hides pencil until then) + preferredWidth int // user's preferred width (persisted across collapse/expand) + editingTitle bool // true when inline title editing is active titleInput textinput.Model lastTitleClickTime time.Time // for double-click detection on title @@ -320,6 +325,32 @@ func (m *model) SetSkillsInfo(availableSkills int) { m.invalidateCache() } +// SetBackgroundAgents updates the live background-agent roster from a runtime +// snapshot. Only running tasks are kept (finished tasks linger in the snapshot) +// so the section drains as work completes, and they are sorted by start time so +// rows stay put across polls. It no-ops while the running set is and stays empty +// to avoid needless cache invalidation during ordinary turns. +func (m *model) SetBackgroundAgents(tasks []agenttool.TaskInfo) { + var running []agenttool.TaskInfo + for _, t := range tasks { + if t.Status == agenttool.StatusRunning { + running = append(running, t) + } + } + slices.SortFunc(running, func(a, b agenttool.TaskInfo) int { + if c := a.StartedAt.Compare(b.StartedAt); c != 0 { + return c + } + return strings.Compare(a.ID, b.ID) + }) + + if len(running) == 0 && len(m.backgroundAgents) == 0 { + return + } + m.backgroundAgents = running + m.invalidateCache() +} + // SetSessionStarred sets the starred status of the current session func (m *model) SetSessionStarred(starred bool) { m.sessionStarred = starred @@ -1020,6 +1051,7 @@ func (m *model) renderSections(contentWidth int) []string { m.buildAgentClickZones(agentSectionStart, lines) appendSection(m.toolsetInfo(contentWidth)) + appendSection(m.backgroundAgentsSection(contentWidth)) m.todoComp.SetSize(contentWidth) appendSection(strings.TrimSuffix(m.todoComp.Render(), "\n")) @@ -1277,14 +1309,25 @@ func (m *model) agentInfo(contentWidth int) string { // returns "" unless the chain is deeper than the root (len > 1). When the full // chain would exceed contentWidth the middle is elided as "root ⏵ … ⏵ leaf". // -// TODO(#3103, Appendix C.2): append a muted "+N background" count here once a -// background-task snapshot is available. +// While background agents run concurrently, a muted "+N background" addendum is +// appended to hint at the off-chain work the Background agents panel lists in +// full. The chain is fitted against the width left after the addendum so the +// count is never clipped. func (m *model) delegationBreadcrumb(contentWidth int) string { chain := m.agentChain if len(chain) <= 1 { return "" } + suffix := "" + if n := len(m.backgroundAgents); n > 0 { + suffix = styles.MutedStyle.Render(fmt.Sprintf(" +%d background", n)) + } + avail := contentWidth + if avail > 0 { + avail -= ansi.StringWidth(suffix) + } + sep := styles.MutedStyle.Render(" ⏵ ") colored := func(name string) string { return styles.AgentAccentStyleFor(name).Render(name) } @@ -1295,16 +1338,16 @@ func (m *model) delegationBreadcrumb(contentWidth int) string { b.WriteString(colored(name)) } full := b.String() - if contentWidth <= 0 || ansi.StringWidth(full) <= contentWidth { - return full + if avail <= 0 || ansi.StringWidth(full) <= avail { + return full + suffix } // Too wide: keep the root and the deepest agent, elide the middle. elided := colored(chain[0]) + sep + styles.MutedStyle.Render("…") + sep + colored(chain[len(chain)-1]) - if ansi.StringWidth(elided) <= contentWidth { - return elided + if ansi.StringWidth(elided) <= avail { + return elided + suffix } - return ansi.Truncate(full, contentWidth, "…") + return ansi.Truncate(full, avail, "…") + suffix } // hasDelegationBreadcrumb reports whether agentInfo prepends a delegation @@ -1486,6 +1529,30 @@ func (m *model) renderToggleIndicator(label, shortcut string, contentWidth int) return indicator + shortcutStyled } +// backgroundAgentsSection renders the live "Background agents (N)" panel: one +// row per running background-agent task (run_background_agent), each with a +// colored activity dot and the agent name in its accent color, plus the elapsed +// run time right-aligned and muted. It returns "" when none are running so the +// panel surfaces only while concurrent background work is in flight and drains +// as tasks finish. +func (m *model) backgroundAgentsSection(contentWidth int) string { + if len(m.backgroundAgents) == 0 { + return "" + } + + lines := make([]string, len(m.backgroundAgents)) + for i, t := range m.backgroundAgents { + accent := styles.AgentAccentStyleFor(t.Agent) + label := accent.Render("●") + " " + accent.Render(t.Agent) + elapsed := styles.MutedStyle.Render(toolcommon.FormatDuration(time.Since(t.StartedAt))) + gap := max(contentWidth-lipgloss.Width(label)-lipgloss.Width(elapsed), 1) + lines[i] = label + strings.Repeat(" ", gap) + elapsed + } + + title := fmt.Sprintf("Background agents (%d)", len(m.backgroundAgents)) + return m.renderTab(title, strings.Join(lines, "\n"), contentWidth) +} + // SetSize sets the dimensions of the component func (m *model) SetSize(width, height int) tea.Cmd { if m.width == width && m.height == height { diff --git a/pkg/tui/components/tool/backgroundagent/backgroundagent.go b/pkg/tui/components/tool/backgroundagent/backgroundagent.go new file mode 100644 index 000000000..4bcc8526c --- /dev/null +++ b/pkg/tui/components/tool/backgroundagent/backgroundagent.go @@ -0,0 +1,111 @@ +// Package backgroundagent renders the background-agent tool calls +// (run/list/view/stop) so a dispatched fleet reads as delegations rather than +// raw text blobs. It is pure presentation: no runtime behavior changes here. +package backgroundagent + +import ( + "strings" + + "charm.land/lipgloss/v2" + + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" + "github.com/docker/docker-agent/pkg/tui/components/spinner" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" + "github.com/docker/docker-agent/pkg/tui/core/layout" + "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/styles" + "github.com/docker/docker-agent/pkg/tui/types" +) + +// NewRun renders run_background_agent as a delegation-style card: +// " dispatches " plus the task text, led by a status-aware icon. +func NewRun(msg *types.Message, sessionState service.SessionStateReader) layout.Model { + return toolcommon.NewBase(msg, sessionState, renderRun) +} + +func renderRun(msg *types.Message, s spinner.Spinner, _ service.SessionStateReader, width, _ int) string { + params, err := toolcommon.ParseArgs[agenttool.RunBackgroundAgentArgs](msg.ToolCall.Function.Arguments) + if err != nil { + return "" + } + + header := styles.AgentBadgeStyleFor(msg.Sender).MarginLeft(2).Render(msg.Sender) + + " dispatches " + + styles.AgentBadgeStyleFor(params.Agent).Render(params.Agent) + + // Status-aware icon: spinner while the task runs, ✓ on success, ✗ on error. + // Single glyph (no elapsed suffix) keeps the wrap math below stable. + icon := statusIcon(msg, s) + iconWithSpace := icon + " " + iconWidth := lipgloss.Width(iconWithSpace) + + availableWidth := max(width-iconWidth, 10) + lines := toolcommon.WrapLines(params.Task, availableWidth) + + var taskContent strings.Builder + for i, line := range lines { + if i == 0 { + taskContent.WriteString(iconWithSpace) + taskContent.WriteString(styles.ToolMessageStyle.Render(line)) + } else { + // Subsequent lines indent to align with the first line's text. + taskContent.WriteString("\n") + taskContent.WriteString(strings.Repeat(" ", iconWidth)) + taskContent.WriteString(styles.ToolMessageStyle.Render(line)) + } + } + + return header + "\n\n" + taskContent.String() +} + +// NewList renders list_background_agents. There are no arguments worth +// surfacing; the tool result already lists the tasks, so the renderer shows the +// tool header plus that result. (NoArgsRenderer is avoided here because it drops +// the result, which is the only useful payload for list until the C.2 live +// "Background agents (N)" surface lands.) +func NewList(msg *types.Message, sessionState service.SessionStateReader) layout.Model { + return toolcommon.NewBase(msg, sessionState, renderList) +} + +func renderList(msg *types.Message, s spinner.Spinner, sessionState service.SessionStateReader, width, _ int) string { + result := "" + if msg.ToolStatus == types.ToolStatusCompleted || msg.ToolStatus == types.ToolStatusError { + result = msg.Content + } + return toolcommon.RenderTool(msg, s, "", result, width, sessionState.HideToolResults()) +} + +// NewView renders view_background_agent: the task id as the argument plus the +// already human-formatted result text (Handler.formatView) once it lands. +func NewView(msg *types.Message, sessionState service.SessionStateReader) layout.Model { + return toolcommon.NewBase(msg, sessionState, toolcommon.SimpleRendererWithResult( + toolcommon.ExtractField(func(a agenttool.ViewBackgroundAgentArgs) string { return a.TaskID }), + func(m *types.Message) string { return m.Content }, + )) +} + +// NewStop renders stop_background_agent: the task id as the argument plus the +// confirmation result text once it lands. +func NewStop(msg *types.Message, sessionState service.SessionStateReader) layout.Model { + return toolcommon.NewBase(msg, sessionState, toolcommon.SimpleRendererWithResult( + toolcommon.ExtractField(func(a agenttool.StopBackgroundAgentArgs) string { return a.TaskID }), + func(m *types.Message) string { return m.Content }, + )) +} + +// statusIcon picks the leading glyph for the dispatch card from the tool status: +// an animated spinner while the background agent runs, ✓ on success, ✗ on error. +// The Base spinner is ModeSpinnerOnly, so s.View() is a single 1-cell glyph whose +// width matches ✓/✗, keeping the task-text wrap math stable (no elapsed-time +// suffix, unlike toolcommon.Icon). Mirrors the transfertask renderer for visual +// consistency across delegation cards. +func statusIcon(msg *types.Message, s spinner.Spinner) string { + switch msg.ToolStatus { + case types.ToolStatusRunning, types.ToolStatusPending: + return styles.NoStyle.MarginLeft(2).Render(s.View()) + case types.ToolStatusError: + return styles.ToolErrorIcon.Render("✗") + default: // Completed and any terminal/unknown state + return styles.ToolCompletedIcon.Render("✓") + } +} diff --git a/pkg/tui/components/tool/backgroundagent/backgroundagent_test.go b/pkg/tui/components/tool/backgroundagent/backgroundagent_test.go new file mode 100644 index 000000000..a26768408 --- /dev/null +++ b/pkg/tui/components/tool/backgroundagent/backgroundagent_test.go @@ -0,0 +1,216 @@ +package backgroundagent + +import ( + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/tools" + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" + "github.com/docker/docker-agent/pkg/tui/core/layout" + "github.com/docker/docker-agent/pkg/tui/service" + "github.com/docker/docker-agent/pkg/tui/types" +) + +var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func stripANSI(s string) string { + return ansiEscape.ReplaceAllString(s, "") +} + +// brailleSpinnerRx matches any braille-range code point used by the shared +// spinner so tests are not tied to a specific animation frame. +var brailleSpinnerRx = regexp.MustCompile(`[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]`) + +func sessionState() service.SessionStateReader { + return service.NewSessionState(&session.Session{}) +} + +func toolCallMessage(name, args, result string, status types.ToolStatus) *types.Message { + return &types.Message{ + Type: types.MessageTypeToolCall, + Sender: "root", + ToolStatus: status, + Content: result, + ToolDefinition: tools.Tool{Name: name}, + ToolCall: tools.ToolCall{ + Function: tools.FunctionCall{Name: name, Arguments: args}, + }, + } +} + +func render(view layout.Model) string { + view.SetSize(80, 1) + return stripANSI(view.View()) +} + +// TestRunCard_StatusIcon locks in the dispatch card: a running or pending +// run_background_agent animates a braille spinner (never a premature ✓), while +// completed/error states show ✓/✗. The " dispatches " header and +// the task text must render in every state. +func TestRunCard_StatusIcon(t *testing.T) { + t.Parallel() + + const args = `{"agent":"researcher","task":"Investigate the flaky test"}` + + tests := []struct { + name string + status types.ToolStatus + wantGlyph string // exact glyph; empty means any braille spinner frame + notWant []string + }{ + {"running animates spinner", types.ToolStatusRunning, "", []string{"✓", "✗"}}, + {"pending animates spinner", types.ToolStatusPending, "", []string{"✓", "✗"}}, + {"completed shows check", types.ToolStatusCompleted, "✓", []string{"✗"}}, + {"error shows cross", types.ToolStatusError, "✗", []string{"✓"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := toolCallMessage(agenttool.ToolNameRunBackgroundAgent, args, "", tc.status) + out := render(NewRun(msg, sessionState())) + + assert.Regexp(t, `root\s+dispatches\s+researcher`, out, "header should name dispatcher and agent") + assert.Contains(t, out, "Investigate the flaky test", "task text should always render") + + if tc.wantGlyph == "" { + // In-flight states must show a braille spinner, not a terminal glyph. + assert.Regexp(t, brailleSpinnerRx, out, "expected braille spinner glyph for in-flight status") + assert.NotRegexp(t, brailleSpinnerRx, strings.Join(tc.notWant, ""), "test case invariant") + } else { + assert.Contains(t, out, tc.wantGlyph, "expected status glyph missing") + assert.NotRegexp(t, brailleSpinnerRx, out, "spinner glyph should not appear for terminal status") + } + for _, n := range tc.notWant { + assert.NotContains(t, out, n, "unexpected glyph %q present for status %s", n, tc.name) + } + }) + } +} + +// TestRunCard_IconWidthIsStable guards the fixed-width status-icon contract +// shared with the transfertask card: the icon is a single glyph with no +// elapsed-time suffix, so the wrapped task text keeps the same indent across +// statuses. +func TestRunCard_IconWidthIsStable(t *testing.T) { + t.Parallel() + + const args = `{"agent":"researcher","task":"Investigate the flaky test"}` + + taskColumn := func(status types.ToolStatus) int { + msg := toolCallMessage(agenttool.ToolNameRunBackgroundAgent, args, "", status) + // Layout is "header\n\n task", so the task block is the 3rd segment. + parts := strings.SplitN(render(NewRun(msg, sessionState())), "\n", 3) + require.Len(t, parts, 3, "render output must have header, blank line, and task block (status=%s)", status) + return strings.Index(parts[2], "Investigate") + } + + running := taskColumn(types.ToolStatusRunning) + assert.Positive(t, running, "task text should be indented past the icon") + assert.Equal(t, running, taskColumn(types.ToolStatusPending)) + assert.Equal(t, running, taskColumn(types.ToolStatusCompleted)) + assert.Equal(t, running, taskColumn(types.ToolStatusError)) +} + +// TestViewAndStop_ShowTaskIDAndResult verifies the view/stop renderers surface +// the task id (argument) plus the already-formatted result text once complete. +func TestViewAndStop_ShowTaskIDAndResult(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + toolName string + taskID string + result string + resultText string // a distinctive token that must survive rendering + newView func(*types.Message, service.SessionStateReader) layout.Model + }{ + { + name: "view shows id and output", + toolName: agenttool.ToolNameViewBackgroundAgent, + taskID: "agent_task_42", + result: "Status: completed\n--- Output ---\nfound the flaky test", + resultText: "found the flaky test", + newView: NewView, + }, + { + name: "stop shows id and confirmation", + toolName: agenttool.ToolNameStopBackgroundAgent, + taskID: "agent_task_7", + result: "Background agent task\nagent_task_7 stopped.", + resultText: "stopped.", + newView: NewStop, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + args := `{"task_id":"` + tc.taskID + `"}` + msg := toolCallMessage(tc.toolName, args, tc.result, types.ToolStatusCompleted) + out := render(tc.newView(msg, sessionState())) + + assert.Contains(t, out, tc.taskID, "task id should render as the argument") + assert.Contains(t, out, tc.resultText, "result text should render") + }) + } +} + +// TestList_ShowsResult verifies list_background_agents surfaces the tool result +// (the task listing) once complete, and stays quiet while still running. +func TestList_ShowsResult(t *testing.T) { + t.Parallel() + + const result = "Background Agent Tasks:\n\nID: agent_task_1\n Agent: researcher\n Status: running" + + completed := toolCallMessage(agenttool.ToolNameListBackgroundAgents, "", result, types.ToolStatusCompleted) + out := render(NewList(completed, sessionState())) + assert.Contains(t, out, "agent_task_1", "list should render the task listing result") + assert.Contains(t, out, "researcher", "list should render task details from the result") + + running := toolCallMessage(agenttool.ToolNameListBackgroundAgents, "", result, types.ToolStatusRunning) + assert.NotContains(t, render(NewList(running, sessionState())), "agent_task_1", + "result should only show once the call completes") +} + +// TestRunCard_PartialJSONArgs verifies that renderRun produces a non-empty card +// when the tool-call arguments JSON is still streaming (partial). This exercises +// the toolcommon.ParseArgs path which closes unclosed JSON before unmarshalling. +func TestRunCard_PartialJSONArgs(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args string + wantHdr string // substring that must appear in the header + }{ + { + name: "agent field complete, task still streaming", + args: `{"agent":"researcher","task":"Investig`, + wantHdr: "researcher", + }, + { + name: "agent field complete, no task yet", + args: `{"agent":"researcher"`, + wantHdr: "researcher", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + msg := toolCallMessage(agenttool.ToolNameRunBackgroundAgent, tc.args, "", types.ToolStatusPending) + out := render(NewRun(msg, sessionState())) + assert.NotEmpty(t, out, "card must render even with partial-JSON args") + assert.Contains(t, out, tc.wantHdr, "agent name must appear in card header") + }) + } +} diff --git a/pkg/tui/components/tool/factory.go b/pkg/tui/components/tool/factory.go index c553a27ca..3caf208f7 100644 --- a/pkg/tui/components/tool/factory.go +++ b/pkg/tui/components/tool/factory.go @@ -6,6 +6,7 @@ package tool import ( + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" "github.com/docker/docker-agent/pkg/tools/builtin/fetch" "github.com/docker/docker-agent/pkg/tools/builtin/filesystem" handofftool "github.com/docker/docker-agent/pkg/tools/builtin/handoff" @@ -14,6 +15,7 @@ import ( transfertasktool "github.com/docker/docker-agent/pkg/tools/builtin/transfertask" userpromptool "github.com/docker/docker-agent/pkg/tools/builtin/userprompt" "github.com/docker/docker-agent/pkg/tui/components/tool/api" + "github.com/docker/docker-agent/pkg/tui/components/tool/backgroundagent" "github.com/docker/docker-agent/pkg/tui/components/tool/defaulttool" "github.com/docker/docker-agent/pkg/tui/components/tool/directorytree" "github.com/docker/docker-agent/pkg/tui/components/tool/editfile" @@ -38,23 +40,27 @@ type builder func(msg *types.Message, sessionState service.SessionStateReader) l // builders maps a tool name (or a "category:" key) to its renderer. // Tools sharing the same visual representation point at the same builder. var builders = map[string]builder{ - transfertasktool.ToolNameTransferTask: transfertask.New, - handofftool.ToolNameHandoff: handoff.New, - filesystem.ToolNameEditFile: editfile.New, - filesystem.ToolNameWriteFile: writefile.New, - filesystem.ToolNameReadFile: readfile.New, - filesystem.ToolNameReadMultipleFiles: readmultiplefiles.New, - filesystem.ToolNameListDirectory: listdirectory.New, - filesystem.ToolNameDirectoryTree: directorytree.New, - filesystem.ToolNameSearchFilesContent: searchfilescontent.New, - shelltool.ToolNameShell: shell.New, - userpromptool.ToolNameUserPrompt: userprompt.New, - fetch.ToolNameFetch: api.New, - "category:api": api.New, - todo.ToolNameCreateTodo: todotool.New, - todo.ToolNameCreateTodos: todotool.New, - todo.ToolNameUpdateTodos: todotool.New, - todo.ToolNameListTodos: todotool.New, + transfertasktool.ToolNameTransferTask: transfertask.New, + handofftool.ToolNameHandoff: handoff.New, + agenttool.ToolNameRunBackgroundAgent: backgroundagent.NewRun, + agenttool.ToolNameListBackgroundAgents: backgroundagent.NewList, + agenttool.ToolNameViewBackgroundAgent: backgroundagent.NewView, + agenttool.ToolNameStopBackgroundAgent: backgroundagent.NewStop, + filesystem.ToolNameEditFile: editfile.New, + filesystem.ToolNameWriteFile: writefile.New, + filesystem.ToolNameReadFile: readfile.New, + filesystem.ToolNameReadMultipleFiles: readmultiplefiles.New, + filesystem.ToolNameListDirectory: listdirectory.New, + filesystem.ToolNameDirectoryTree: directorytree.New, + filesystem.ToolNameSearchFilesContent: searchfilescontent.New, + shelltool.ToolNameShell: shell.New, + userpromptool.ToolNameUserPrompt: userprompt.New, + fetch.ToolNameFetch: api.New, + "category:api": api.New, + todo.ToolNameCreateTodo: todotool.New, + todo.ToolNameCreateTodos: todotool.New, + todo.ToolNameUpdateTodos: todotool.New, + todo.ToolNameListTodos: todotool.New, } // New returns the appropriate tool view for the given message. diff --git a/pkg/tui/components/toolcommon/common.go b/pkg/tui/components/toolcommon/common.go index 94dc86c88..c6a00e41f 100644 --- a/pkg/tui/components/toolcommon/common.go +++ b/pkg/tui/components/toolcommon/common.go @@ -116,7 +116,7 @@ func Icon(msg *types.Message, inProgress spinner.Spinner) string { if msg.StartedAt != nil { elapsed := time.Since(*msg.StartedAt) if elapsed >= time.Second { - icon += " " + styles.ToolMessageStyle.Render(formatDuration(elapsed)) + icon += " " + styles.ToolMessageStyle.Render(FormatDuration(elapsed)) } } return icon @@ -146,8 +146,10 @@ func LongRunningWarning(msg *types.Message) string { return "⚠ Tool call running for over 60s. The tool may be waiting for external input. Press Esc to cancel." } -// formatDuration formats a duration as a human-readable string like "5s", "1m30s", "2m15s". -func formatDuration(d time.Duration) string { +// FormatDuration formats a duration as a compact human-readable string like +// "5s", "1m30s", "2m". Exported so other components (e.g. the sidebar's +// background-agents panel) can render elapsed times consistently. +func FormatDuration(d time.Duration) string { d = d.Truncate(time.Second) if d < time.Minute { return fmt.Sprintf("%ds", int(d.Seconds())) diff --git a/pkg/tui/components/toolcommon/common_test.go b/pkg/tui/components/toolcommon/common_test.go index d27cc2af0..290f7538f 100644 --- a/pkg/tui/components/toolcommon/common_test.go +++ b/pkg/tui/components/toolcommon/common_test.go @@ -733,9 +733,9 @@ func TestFormatDuration(t *testing.T) { } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) { - got := formatDuration(tt.d) + got := FormatDuration(tt.d) if got != tt.want { - t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want) + t.Errorf("FormatDuration(%v) = %q, want %q", tt.d, got, tt.want) } }) } diff --git a/pkg/tui/page/chat/background_agents.go b/pkg/tui/page/chat/background_agents.go new file mode 100644 index 000000000..32dbe025f --- /dev/null +++ b/pkg/tui/page/chat/background_agents.go @@ -0,0 +1,61 @@ +package chat + +import ( + "time" + + tea "charm.land/bubbletea/v2" + + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" +) + +// backgroundAgentPollInterval is the cadence at which the chat page re-reads the +// runtime's background-agent snapshot to refresh the sidebar's live status +// panel. One second keeps the elapsed times current without measurable cost. +const backgroundAgentPollInterval = time.Second + +// backgroundAgentPollMsg ticks the background-agent poll loop. +type backgroundAgentPollMsg struct{} + +// startBackgroundAgentPoll starts the background-agent poll loop unless it is +// already running. The loop self-reschedules from handleBackgroundAgentPoll and +// stops once no work remains, so it adds nothing to ordinary single-agent turns. +func (p *chatPage) startBackgroundAgentPoll() tea.Cmd { + if p.backgroundPollActive { + return nil + } + p.backgroundPollActive = true + return scheduleBackgroundAgentPoll() +} + +func scheduleBackgroundAgentPoll() tea.Cmd { + return tea.Tick(backgroundAgentPollInterval, func(time.Time) tea.Msg { + return backgroundAgentPollMsg{} + }) +} + +// handleBackgroundAgentPoll pushes the latest runtime snapshot to the sidebar +// and keeps the loop alive while a stream is working or background agents are +// still running. When both are idle it pushes the final (empty) snapshot so the +// panel clears, then stops polling until the next stream starts. +func (p *chatPage) handleBackgroundAgentPoll() tea.Cmd { + tasks := p.app.BackgroundAgents() + p.sidebar.SetBackgroundAgents(tasks) + + if p.working || hasRunningBackgroundAgent(tasks) { + return scheduleBackgroundAgentPoll() + } + p.backgroundPollActive = false + return nil +} + +// hasRunningBackgroundAgent reports whether any task in the snapshot is still +// running, gating whether the poll loop continues after the active stream ends +// (background agents can outlive the turn that spawned them). +func hasRunningBackgroundAgent(tasks []agenttool.TaskInfo) bool { + for _, t := range tasks { + if t.Status == agenttool.StatusRunning { + return true + } + } + return false +} diff --git a/pkg/tui/page/chat/background_agents_test.go b/pkg/tui/page/chat/background_agents_test.go new file mode 100644 index 000000000..53715461f --- /dev/null +++ b/pkg/tui/page/chat/background_agents_test.go @@ -0,0 +1,76 @@ +package chat + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/app" + "github.com/docker/docker-agent/pkg/session" + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" + "github.com/docker/docker-agent/pkg/tui/service" +) + +func TestHasRunningBackgroundAgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tasks []agenttool.TaskInfo + want bool + }{ + {"nil", nil, false}, + {"only finished", []agenttool.TaskInfo{ + {Status: agenttool.StatusCompleted}, + {Status: agenttool.StatusStopped}, + }, false}, + {"one running", []agenttool.TaskInfo{ + {Status: agenttool.StatusCompleted}, + {Status: agenttool.StatusRunning}, + }, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, hasRunningBackgroundAgent(tc.tasks)) + }) + } +} + +// TestStartBackgroundAgentPoll_RunsSingleLoop guards the idempotency of the poll +// starter: nested streams each call it, but only one tea.Tick loop must run. +func TestStartBackgroundAgentPoll_RunsSingleLoop(t *testing.T) { + t.Parallel() + + p := &chatPage{} + + require.NotNil(t, p.startBackgroundAgentPoll(), "first start schedules a tick") + assert.True(t, p.backgroundPollActive) + assert.Nil(t, p.startBackgroundAgentPoll(), "second start is a no-op while the loop runs") +} + +func TestHandleBackgroundAgentPoll_StopsWhenIdle(t *testing.T) { + t.Parallel() + + sess := session.New() + p := New(app.New(t.Context(), queueTestRuntime{}, sess), service.NewSessionState(sess)).(*chatPage) + p.backgroundPollActive = true + p.working = false + + assert.Nil(t, p.handleBackgroundAgentPoll(), "no stream and no background tasks stops the loop") + assert.False(t, p.backgroundPollActive) +} + +func TestHandleBackgroundAgentPoll_ContinuesWhileWorking(t *testing.T) { + t.Parallel() + + sess := session.New() + p := New(app.New(t.Context(), queueTestRuntime{}, sess), service.NewSessionState(sess)).(*chatPage) + p.backgroundPollActive = true + p.working = true + + assert.NotNil(t, p.handleBackgroundAgentPoll(), "an active stream keeps the poll loop alive") + assert.True(t, p.backgroundPollActive) +} diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index e5df89dfb..0fc78c84b 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -146,6 +146,10 @@ type chatPage struct { agentStack []string // agent per active stream level; len(agentStack)==streamDepth streamStartTime time.Time + // backgroundPollActive guards the background-agent snapshot poll so only one + // tea.Tick loop runs at a time (see background_agents.go). + backgroundPollActive bool + // Track whether we've received content from an assistant response // Used by --exit-after-response to ensure we don't exit before receiving content hasReceivedAssistantContent bool @@ -394,6 +398,10 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case msgtypes.ClearQueueMsg: return p.handleClearQueue() + case backgroundAgentPollMsg: + cmd := p.handleBackgroundAgentPoll() + return p, cmd + case msgtypes.ThemeChangedMsg: // Theme changed - forward to all child components to invalidate caches var cmds []tea.Cmd diff --git a/pkg/tui/page/chat/runtime_events.go b/pkg/tui/page/chat/runtime_events.go index 1e389a2c5..e7712a214 100644 --- a/pkg/tui/page/chat/runtime_events.go +++ b/pkg/tui/page/chat/runtime_events.go @@ -212,7 +212,10 @@ func (p *chatPage) handleStreamStarted(msg *runtime.StreamStartedEvent) tea.Cmd spinnerCmd := p.setWorking(true) pendingCmd := p.setPendingResponse(true) sidebarCmd := p.forwardToSidebar(msg) - return tea.Batch(pendingCmd, spinnerCmd, sidebarCmd) + // Begin polling the runtime's background-agent snapshot while work is in + // flight; the loop self-stops once no stream and no background task remain. + pollCmd := p.startBackgroundAgentPoll() + return tea.Batch(pendingCmd, spinnerCmd, sidebarCmd, pollCmd) } func (p *chatPage) handleAgentChoice(msg *runtime.AgentChoiceEvent) tea.Cmd {