|
| 1 | +package tui_test |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "io" |
| 6 | + "os" |
| 7 | + "sync" |
| 8 | + "testing" |
| 9 | + |
| 10 | + tea "charm.land/bubbletea/v2" |
| 11 | + "github.com/stretchr/testify/require" |
| 12 | + |
| 13 | + "github.com/docker/docker-agent/pkg/agent" |
| 14 | + "github.com/docker/docker-agent/pkg/app" |
| 15 | + "github.com/docker/docker-agent/pkg/chat" |
| 16 | + "github.com/docker/docker-agent/pkg/config/latest" |
| 17 | + "github.com/docker/docker-agent/pkg/model/provider/base" |
| 18 | + "github.com/docker/docker-agent/pkg/modelsdev" |
| 19 | + "github.com/docker/docker-agent/pkg/runtime" |
| 20 | + "github.com/docker/docker-agent/pkg/session" |
| 21 | + "github.com/docker/docker-agent/pkg/team" |
| 22 | + "github.com/docker/docker-agent/pkg/tools" |
| 23 | + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" |
| 24 | + "github.com/docker/docker-agent/pkg/tui" |
| 25 | + "github.com/docker/docker-agent/pkg/tui/tuitest" |
| 26 | +) |
| 27 | + |
| 28 | +// This file covers the background-agent journey end to end through the real |
| 29 | +// TUI: a root agent dispatches a run_background_agent task, the worker's |
| 30 | +// sub-session runs on a detached goroutine (its events are dropped from the |
| 31 | +// live stream), and only the runtime's out-of-band forwarding can bring its |
| 32 | +// token usage back to the sidebar. Unlike the VCR-based scenarios in this |
| 33 | +// package, the model responses are scripted in-process: driving a |
| 34 | +// deterministic multi-agent tool-call flow through cassettes would couple the |
| 35 | +// test to HTTP wire details it does not care about. |
| 36 | + |
| 37 | +// scriptedStream replays a fixed sequence of streaming chunks. |
| 38 | +type scriptedStream struct { |
| 39 | + responses []chat.MessageStreamResponse |
| 40 | + idx int |
| 41 | +} |
| 42 | + |
| 43 | +func (s *scriptedStream) Recv() (chat.MessageStreamResponse, error) { |
| 44 | + if s.idx >= len(s.responses) { |
| 45 | + return chat.MessageStreamResponse{}, io.EOF |
| 46 | + } |
| 47 | + resp := s.responses[s.idx] |
| 48 | + s.idx++ |
| 49 | + return resp, nil |
| 50 | +} |
| 51 | + |
| 52 | +func (s *scriptedStream) Close() {} |
| 53 | + |
| 54 | +// scriptedProvider returns one scripted stream per model call, repeating the |
| 55 | +// last script if called more often. contextSize is exposed through |
| 56 | +// provider_opts so the runtime resolves a context limit without the |
| 57 | +// models.dev catalogue, which is what makes percentages computable. |
| 58 | +type scriptedProvider struct { |
| 59 | + id string |
| 60 | + contextSize int64 |
| 61 | + scripts [][]chat.MessageStreamResponse |
| 62 | + |
| 63 | + mu sync.Mutex |
| 64 | + call int |
| 65 | +} |
| 66 | + |
| 67 | +func (p *scriptedProvider) ID() modelsdev.ID { return modelsdev.ParseIDOrZero(p.id) } |
| 68 | + |
| 69 | +func (p *scriptedProvider) CreateChatCompletionStream(context.Context, []chat.Message, []tools.Tool) (chat.MessageStream, error) { |
| 70 | + p.mu.Lock() |
| 71 | + defer p.mu.Unlock() |
| 72 | + i := min(p.call, len(p.scripts)-1) |
| 73 | + p.call++ |
| 74 | + return &scriptedStream{responses: p.scripts[i]}, nil |
| 75 | +} |
| 76 | + |
| 77 | +func (p *scriptedProvider) BaseConfig() base.Config { |
| 78 | + return base.Config{ModelConfig: latest.ModelConfig{ |
| 79 | + ProviderOpts: map[string]any{"context_size": p.contextSize}, |
| 80 | + }} |
| 81 | +} |
| 82 | + |
| 83 | +// toolCallScript scripts one turn that calls a single tool and stops with the |
| 84 | +// given usage. |
| 85 | +func toolCallScript(callID, toolName, args string, in, out int64) []chat.MessageStreamResponse { |
| 86 | + return []chat.MessageStreamResponse{ |
| 87 | + {Choices: []chat.MessageStreamChoice{{Delta: chat.MessageDelta{ToolCalls: []tools.ToolCall{{ |
| 88 | + ID: callID, Type: "function", Function: tools.FunctionCall{Name: toolName}, |
| 89 | + }}}}}}, |
| 90 | + {Choices: []chat.MessageStreamChoice{{Delta: chat.MessageDelta{ToolCalls: []tools.ToolCall{{ |
| 91 | + ID: callID, Type: "function", Function: tools.FunctionCall{Arguments: args}, |
| 92 | + }}}}}}, |
| 93 | + { |
| 94 | + Choices: []chat.MessageStreamChoice{{FinishReason: chat.FinishReasonToolCalls}}, |
| 95 | + Usage: &chat.Usage{InputTokens: in, OutputTokens: out}, |
| 96 | + }, |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +// contentScript scripts one plain-content turn that stops with the given usage. |
| 101 | +func contentScript(content string, in, out int64) []chat.MessageStreamResponse { |
| 102 | + return []chat.MessageStreamResponse{ |
| 103 | + {Choices: []chat.MessageStreamChoice{{Delta: chat.MessageDelta{Content: content}}}}, |
| 104 | + { |
| 105 | + Choices: []chat.MessageStreamChoice{{FinishReason: chat.FinishReasonStop}}, |
| 106 | + Usage: &chat.Usage{InputTokens: in, OutputTokens: out}, |
| 107 | + }, |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +// stubModelStore keeps the runtime off the network: no models.dev lookups, no |
| 112 | +// pricing. Context limits come from provider_opts instead. |
| 113 | +type stubModelStore struct{} |
| 114 | + |
| 115 | +func (stubModelStore) GetModel(context.Context, modelsdev.ID) (*modelsdev.Model, error) { |
| 116 | + return nil, nil |
| 117 | +} |
| 118 | +func (stubModelStore) GetDatabase(context.Context) (*modelsdev.Database, error) { return nil, nil } |
| 119 | + |
| 120 | +// newBackgroundAgentTUI builds the real TUI over a real LocalRuntime whose |
| 121 | +// team is assembled programmatically: a root orchestrator with the |
| 122 | +// background_agents toolset and a worker sub-agent, both backed by scripted |
| 123 | +// providers. Tools are pre-approved so the dispatch needs no confirmation |
| 124 | +// dialog. |
| 125 | +func newBackgroundAgentTUI(t *testing.T, width, height int) *tuitest.Driver { |
| 126 | + t.Helper() |
| 127 | + |
| 128 | + isolateState(t) |
| 129 | + |
| 130 | + rootProv := &scriptedProvider{ |
| 131 | + id: "test/fake-root", |
| 132 | + contextSize: 1000, |
| 133 | + scripts: [][]chat.MessageStreamResponse{ |
| 134 | + toolCallScript("call-1", agenttool.ToolNameRunBackgroundAgent, |
| 135 | + `{"agent":"worker","task":"count the files"}`, 40, 10), |
| 136 | + // Session context tracks the LAST call's usage (its input already |
| 137 | + // spans the whole prompt), so the root settles at 100/1000 = 10%. |
| 138 | + contentScript("Dispatched the worker.", 80, 20), |
| 139 | + }, |
| 140 | + } |
| 141 | + workerProv := &scriptedProvider{ |
| 142 | + id: "test/fake-worker", |
| 143 | + contextSize: 1000, |
| 144 | + scripts: [][]chat.MessageStreamResponse{ |
| 145 | + contentScript("worker finished the count", 300, 150), |
| 146 | + }, |
| 147 | + } |
| 148 | + |
| 149 | + worker := agent.New("worker", "Count things when asked.", |
| 150 | + agent.WithModel(workerProv), |
| 151 | + agent.WithDescription("Background worker"), |
| 152 | + ) |
| 153 | + root := agent.New("root", "Dispatch work to the worker in the background.", |
| 154 | + agent.WithModel(rootProv), |
| 155 | + agent.WithDescription("Orchestrator"), |
| 156 | + agent.WithSubAgents(worker), |
| 157 | + agent.WithToolSets(agenttool.New()), |
| 158 | + ) |
| 159 | + |
| 160 | + rt, err := runtime.New(t.Context(), team.New(team.WithAgents(root, worker)), |
| 161 | + runtime.WithCurrentAgent("root"), |
| 162 | + runtime.WithSessionCompaction(false), |
| 163 | + runtime.WithModelStore(stubModelStore{}), |
| 164 | + ) |
| 165 | + require.NoError(t, err) |
| 166 | + t.Cleanup(func() { _ = rt.Close() }) |
| 167 | + |
| 168 | + application := app.New(t.Context(), rt, session.New(session.WithToolsApproved(true))) |
| 169 | + |
| 170 | + wd, _ := os.Getwd() |
| 171 | + model := tui.New(t.Context(), nil /* no spawner: single tab */, application, wd, func() {}) |
| 172 | + return tuitest.New(t, model, width, height) |
| 173 | +} |
| 174 | + |
| 175 | +// TestBackgroundAgent_PerAgentContextInSidebar drives the full journey: the |
| 176 | +// worker's usage (450 of 1000 tokens) can only reach the TUI through the |
| 177 | +// runtime's out-of-band background-event forwarding, so the 45% appearing on |
| 178 | +// the worker's roster line proves the whole chain — tool call, detached |
| 179 | +// sub-session, OnBackgroundEvent, app event stream, sidebar accounting. |
| 180 | +// The root's own line independently settles at 10% (its final turn consumed |
| 181 | +// 100 of 1000 tokens). |
| 182 | +func TestBackgroundAgent_PerAgentContextInSidebar(t *testing.T) { |
| 183 | + d := newBackgroundAgentTUI(t, 120, 40) |
| 184 | + |
| 185 | + // Both agents are listed before anything runs, with no context percent. |
| 186 | + d.WaitFor(tuitest.ContainsAll("root", "worker")). |
| 187 | + Assert(tuitest.Absent("%")) |
| 188 | + |
| 189 | + d.Type("Please dispatch the worker."). |
| 190 | + Enter(). |
| 191 | + WaitFor(tuitest.Contains("Dispatched the worker.")). |
| 192 | + WaitFor(tuitest.Contains("45%")). |
| 193 | + WaitFor(tuitest.Contains("10%")) |
| 194 | +} |
| 195 | + |
| 196 | +// TestBackgroundAgent_InspectorShowsWorkerContext opens the worker's Agent |
| 197 | +// Inspector (right-click on its roster line) after the background task |
| 198 | +// completed and checks the exact per-agent context line. |
| 199 | +func TestBackgroundAgent_InspectorShowsWorkerContext(t *testing.T) { |
| 200 | + d := newBackgroundAgentTUI(t, 120, 40) |
| 201 | + |
| 202 | + d.Type("Please dispatch the worker."). |
| 203 | + Enter(). |
| 204 | + WaitFor(tuitest.Contains("Dispatched the worker.")). |
| 205 | + WaitFor(tuitest.Contains("45%")) |
| 206 | + |
| 207 | + // "45%" renders only on the worker's roster line, so its coordinates are |
| 208 | + // a reliable right-click target for that agent (either of the entry's two |
| 209 | + // lines opens the inspector). |
| 210 | + x, y := d.MustFindText("45%") |
| 211 | + d.Send(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseRight}). |
| 212 | + Send(tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseRight}). |
| 213 | + WaitFor(tuitest.Contains("Context: 450 of 1.0K tokens (45%)")) |
| 214 | +} |
0 commit comments