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
1 change: 1 addition & 0 deletions pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,7 @@ func toolsetStatusFor(ts tools.ToolSet) tools.ToolsetStatus {
// earlier if Start failed.
status.State = lifecycleStateForUnsupervised(ts)
}
_, status.Restartable = tools.As[tools.Restartable](ts)
status.Name = nameFor(ts, status.Description)
return status
}
Expand Down
17 changes: 17 additions & 0 deletions pkg/runtime/toolsetstatus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@ func TestToolsetStatusFor_NoStatableMeansReady(t *testing.T) {
assert.Equal(t, 0, got.RestartCount)
require.NoError(t, got.LastError)
assert.Equal(t, "filesystem", got.Description)
assert.False(t, got.Restartable, "toolset without Restart() must report Restartable=false")
}

func TestToolsetStatusFor_RestartableReportsTrue(t *testing.T) {
t.Parallel()

ts := &restartableToolset{desc: "mcp(stdio cmd=foo)", state: lifecycle.StateInfo{State: lifecycle.StateReady}}
got := toolsetStatusFor(ts)
assert.True(t, got.Restartable)
}

func TestToolsetStatusFor_NonRestartableReportsFalse(t *testing.T) {
t.Parallel()

ts := &statefulToolset{desc: "filesystem", info: lifecycle.StateInfo{State: lifecycle.StateReady}}
got := toolsetStatusFor(ts)
assert.False(t, got.Restartable)
}

// TestToolsetStatusFor_UnwrapsStartable verifies the inner Statable is
Expand Down
5 changes: 5 additions & 0 deletions pkg/tools/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,9 @@ type ToolsetStatus struct {
// RestartCount is the number of supervisor restarts since the last
// successful Ready transition. Zero for toolsets without a supervisor.
RestartCount int
// Restartable reports whether the toolset implements Restartable
// (typically supervisor-backed MCP/LSP toolsets). Surfaces such as the
// /toolset-restart completion popup use this to annotate, not hide,
// entries that cannot actually be restarted.
Restartable bool
}
78 changes: 78 additions & 0 deletions pkg/tui/commands/commands.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package commands

import (
"cmp"
"context"
"fmt"
"slices"
Expand All @@ -11,6 +12,7 @@ import (
"github.com/docker/docker-agent/pkg/app"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/feedback"
"github.com/docker/docker-agent/pkg/tools"
mcptools "github.com/docker/docker-agent/pkg/tools/mcp"
"github.com/docker/docker-agent/pkg/tui/components/toolcommon"
"github.com/docker/docker-agent/pkg/tui/core"
Expand All @@ -26,6 +28,18 @@ type Category struct {
Commands []Item
}

// ArgumentCandidate is one completable value for a command's argument,
// e.g. a toolset name for /toolset-restart. It is intentionally free of any
// completion-UI or app-domain types so pkg/tui/commands stays decoupled from
// both pkg/tui/components/completion and pkg/app.
type ArgumentCandidate struct {
Label string
Description string
// Disabled marks a candidate that is shown for context but cannot be
// submitted (e.g. a non-restartable toolset for /toolset-restart).
Disabled bool
}

// Item represents a single command in the palette
type Item struct {
ID string
Expand All @@ -38,6 +52,11 @@ type Item struct {
// Immediate marks commands that should run as soon as they are submitted
// instead of being treated as ordinary queued chat input.
Immediate bool
// CompleteArgument, when set, returns the candidates for this command's
// argument. Called lazily at completion-popup-open time so results
// reflect current runtime state (e.g. toolset lifecycle). Nil for
// commands with no argument completion.
CompleteArgument func() []ArgumentCandidate
}

func builtInSessionCommands() []Item {
Expand Down Expand Up @@ -577,13 +596,72 @@ func newMCPPromptItem(promptName string, promptInfo mcptools.PromptInfo) Item {
}
}

// toolsetStatusSource is the minimal surface toolsetRestartCandidates needs.
// *app.App satisfies it; tests can supply a stub instead of constructing a
// full application.
type toolsetStatusSource interface {
CurrentAgentToolsetStatuses() []tools.ToolsetStatus
}

// toolsetRestartCandidates returns one ArgumentCandidate per toolset of the
// current agent, in declaration order, deduplicated by name (preferring the
// restartable entry when duplicate names disagree — display names are not
// guaranteed unique, mirroring runtime.RestartToolset's own matching).
// Non-restartable toolsets are marked Disabled rather than omitted, so the
// popup teaches users the full toolset inventory and why a restart isn't
// offered for some of them.
func toolsetRestartCandidates(source toolsetStatusSource) []ArgumentCandidate {
statuses := source.CurrentAgentToolsetStatuses()
byName := make(map[string]tools.ToolsetStatus, len(statuses))
order := make([]string, 0, len(statuses))
for _, s := range statuses {
if s.Name == "" {
continue
}
if existing, ok := byName[s.Name]; !ok {
byName[s.Name] = s
order = append(order, s.Name)
} else if !existing.Restartable && s.Restartable {
byName[s.Name] = s
}
}

candidates := make([]ArgumentCandidate, 0, len(order))
for _, name := range order {
s := byName[name]
candidates = append(candidates, ArgumentCandidate{
Label: s.Name,
Description: cmp.Or(s.Kind, "Built-in") + " · " + s.State.String(),
Disabled: !s.Restartable,
})
}
return candidates
}

// attachToolsetRestartCompletion wires the toolset-name argument completer
// onto the /toolset-restart item. Attached post-hoc (rather than inline in
// builtInSessionCommands) so that function stays free of any status-source
// dependency.
func attachToolsetRestartCompletion(items []Item, source toolsetStatusSource) {
for i := range items {
if items[i].ID != "session.toolset.restart" {
continue
}
items[i].CompleteArgument = func() []ArgumentCandidate {
return toolsetRestartCandidates(source)
}
return
}
}

// BuildCommandCategories builds the list of command categories for the command palette
func BuildCommandCategories(ctx context.Context, application *app.App) []Category {
// Get session commands and filter based on model capabilities
sessionCommands := builtInSessionCommands()
if !application.SnapshotsEnabled() {
sessionCommands = removeByIDs(sessionCommands, snapshotCommandIDs)
}
attachToolsetRestartCompletion(sessionCommands, application)

categories := []Category{
{
Expand Down
121 changes: 121 additions & 0 deletions pkg/tui/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/tools/lifecycle"
"github.com/docker/docker-agent/pkg/tui/messages"
)

Expand Down Expand Up @@ -277,3 +279,122 @@ func TestSettingsCommandLabel(t *testing.T) {
assert.Equal(t, "settings.open", settings.ID)
assert.Equal(t, "Settings", settings.Label)
}

// stubToolsetStatusSource is a minimal toolsetStatusSource for exercising
// toolsetRestartCandidates without a real *app.App.
type stubToolsetStatusSource struct {
statuses []tools.ToolsetStatus
}

func (s stubToolsetStatusSource) CurrentAgentToolsetStatuses() []tools.ToolsetStatus {
return s.statuses
}

func TestToolsetRestartCandidates_AllShownWithDisabledFlag(t *testing.T) {
t.Parallel()

source := stubToolsetStatusSource{statuses: []tools.ToolsetStatus{
{Name: "github", Kind: "MCP", State: lifecycle.StateReady, Restartable: true},
{Name: "filesystem", State: lifecycle.StateReady, Restartable: false},
}}

candidates := toolsetRestartCandidates(source)
require.Len(t, candidates, 2)

assert.Equal(t, "github", candidates[0].Label)
assert.False(t, candidates[0].Disabled)
assert.Contains(t, candidates[0].Description, "MCP")

assert.Equal(t, "filesystem", candidates[1].Label)
assert.True(t, candidates[1].Disabled, "non-restartable toolsets must be shown, not hidden")
assert.Contains(t, candidates[1].Description, "Built-in")
}

func TestToolsetRestartCandidates_DedupePrefersRestartable(t *testing.T) {
t.Parallel()

source := stubToolsetStatusSource{statuses: []tools.ToolsetStatus{
{Name: "dup", State: lifecycle.StateStopped, Restartable: false},
{Name: "dup", State: lifecycle.StateReady, Restartable: true},
}}

candidates := toolsetRestartCandidates(source)
require.Len(t, candidates, 1, "duplicate names must be deduplicated")
assert.False(t, candidates[0].Disabled, "the restartable entry must win over the non-restartable duplicate")
}

func TestToolsetRestartCandidates_EmptyNameSkipped(t *testing.T) {
t.Parallel()

source := stubToolsetStatusSource{statuses: []tools.ToolsetStatus{{Name: "", Restartable: true}}}
assert.Empty(t, toolsetRestartCandidates(source))
}

func TestAttachToolsetRestartCompletion(t *testing.T) {
t.Parallel()

items := builtInSessionCommands()
source := stubToolsetStatusSource{statuses: []tools.ToolsetStatus{
{Name: "github", Restartable: true},
}}
attachToolsetRestartCompletion(items, source)

var restart, other *Item
for i := range items {
switch items[i].ID {
case "session.toolset.restart":
restart = &items[i]
case "session.exit":
other = &items[i]
}
}
require.NotNil(t, restart)
require.NotNil(t, restart.CompleteArgument, "the restart item must get a completer")
candidates := restart.CompleteArgument()
require.Len(t, candidates, 1)
assert.Equal(t, "github", candidates[0].Label)

require.NotNil(t, other)
assert.Nil(t, other.CompleteArgument, "commands without a provider keep a nil CompleteArgument")
}

// TestAttachToolsetRestartCompletion_QueriesFreshOnEachCall is a regression
// test for bug 2 (PR #3728): the attached CompleteArgument closure must query
// the toolset status source live on every call, not capture a snapshot at
// attach time. Otherwise the first Tab after "/toolset-restart " shows
// whatever toolsets existed when the command list was built, not the
// current ones.
func TestAttachToolsetRestartCompletion_QueriesFreshOnEachCall(t *testing.T) {
t.Parallel()

items := builtInSessionCommands()
source := &stubToolsetStatusSource{statuses: []tools.ToolsetStatus{
{Name: "github", State: lifecycle.StateReady, Restartable: true},
}}
attachToolsetRestartCompletion(items, source)

var restart *Item
for i := range items {
if items[i].ID == "session.toolset.restart" {
restart = &items[i]
}
}
require.NotNil(t, restart)

first := restart.CompleteArgument()
require.Len(t, first, 1)
assert.Equal(t, "github", first[0].Label)

// The toolset status source changes after the completer was attached
// (e.g. the agent restarted with a different toolset set). A second call
// must reflect that change rather than replaying the first snapshot.
source.statuses = []tools.ToolsetStatus{
{Name: "filesystem", State: lifecycle.StateReady, Restartable: true},
{Name: "github", State: lifecycle.StateReady, Restartable: true},
}

second := restart.CompleteArgument()
require.Len(t, second, 2, "must reflect the current toolset set, not the one captured at attach time")
assert.Equal(t, "filesystem", second[0].Label)
assert.Equal(t, "github", second[1].Label)
}
Loading
Loading