From 5fe70046601d3f0767b48ae9e4668b90e0cb6446 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Thu, 1 Oct 2026 13:17:50 +0200 Subject: [PATCH 1/2] feat(mecatui): add /agent to start a session bound to an agent definition Gives a developer an end-to-end way to exercise ADR 0353 from the TUI. An unknown name fails loudly and recoverably. The create carries the operator's active model selection like every other create path; sending none made a def without its own model fall to the server default. Bare /agent only points at /agents; a filterable picker is not built yet. Co-Authored-By: Claude Sonnet 5.5 --- cmd/mecatui/client/agent_session.go | 25 +++++ cmd/mecatui/client/agent_session_test.go | 51 ++++++++++ cmd/mecatui/client/models_test.go | 4 + cmd/mecatui/command_test.go | 4 + cmd/mecatui/main.go | 12 +++ cmd/mecatui/ui/agent_builtin.go | 101 +++++++++++++++++++ cmd/mecatui/ui/agent_builtin_test.go | 75 ++++++++++++++ cmd/mecatui/ui/builtins.go | 16 ++- cmd/mecatui/ui/builtins_test.go | 6 +- cmd/mecatui/ui/fake_test.go | 30 ++++++ cmd/mecatui/ui/model.go | 6 ++ cmd/mecatui/ui/quit_test.go | 4 + cmd/mecatui/ui/sdk_high_level_parity_test.go | 1 + cmd/mecatui/ui/update.go | 15 +++ 14 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 cmd/mecatui/client/agent_session.go create mode 100644 cmd/mecatui/client/agent_session_test.go create mode 100644 cmd/mecatui/ui/agent_builtin.go create mode 100644 cmd/mecatui/ui/agent_builtin_test.go diff --git a/cmd/mecatui/client/agent_session.go b/cmd/mecatui/client/agent_session.go new file mode 100644 index 0000000000..d032cbcf99 --- /dev/null +++ b/cmd/mecatui/client/agent_session.go @@ -0,0 +1,25 @@ +package client + +import ( + "context" + + mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1" +) + +// CreateSessionWithAgent allocates a session bound to a named AgentDef (ADR +// 0353) instead of the default explorer session: its catalog is built +// exclusively from the resolved definition's own tools, provider/model, +// limits, and permission mode. A dedicated method, mirroring ClearSession's +// one-method-per-distinct-session-shaping-call idiom, rather than an added +// ModelSelection field — the binding is a request-shape concern +// (CreateSessionRequest.agent_definition_name), not a model selector. +// An unknown agentDefName fails with ErrInvalidArgument (IsInvalidArgument). +func (c *Client) CreateSessionWithAgent(ctx context.Context, mode mecatlv1.PermissionMode, sel ModelSelection, agentDefName string) (string, Capabilities, ResolvedModel, error) { + return c.createSession(ctx, &mecatlv1.CreateSessionRequest{ + Mode: mode, + ProviderId: sel.ProviderID, + ModelId: sel.ModelID, + ReasoningEffort: sel.ReasoningEffort, + AgentDefinitionName: agentDefName, + }) +} diff --git a/cmd/mecatui/client/agent_session_test.go b/cmd/mecatui/client/agent_session_test.go new file mode 100644 index 0000000000..e4620fd421 --- /dev/null +++ b/cmd/mecatui/client/agent_session_test.go @@ -0,0 +1,51 @@ +package client + +import ( + "context" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1" +) + +// TestCreateSessionWithAgentCarriesAgentDefinitionName asserts the proto-build +// point sets agent_definition_name from the given name (ADR 0353), leaving the +// ordinary ModelSelection fields intact alongside it. +func TestCreateSessionWithAgentCarriesAgentDefinitionName(t *testing.T) { + fake := &fakeModelsClient{} + cl := newFakeClient(fake) + + _, _, _, err := cl.CreateSessionWithAgent(context.Background(), + mecatlv1.PermissionMode_PERMISSION_MODE_DEFAULT, + ModelSelection{ProviderID: "openai", ModelID: "gpt-5"}, "release-reviewer") + if err != nil { + t.Fatalf("CreateSessionWithAgent: %v", err) + } + if fake.lastCreate.GetAgentDefinitionName() != "release-reviewer" { + t.Fatalf("request agent_definition_name = %q, want release-reviewer", fake.lastCreate.GetAgentDefinitionName()) + } + if fake.lastCreate.GetProviderId() != "openai" || fake.lastCreate.GetModelId() != "gpt-5" { + t.Fatalf("request provider:%q model:%q, want openai/gpt-5 to survive alongside the binding", + fake.lastCreate.GetProviderId(), fake.lastCreate.GetModelId()) + } +} + +// TestCreateSessionWithAgentPropagatesUnknownDefinitionError asserts an +// unknown agent_definition_name's server-side InvalidArgument rejection +// propagates and is classifiable via the existing IsInvalidArgument, exactly +// like any other malformed CreateSession request. +func TestCreateSessionWithAgentPropagatesUnknownDefinitionError(t *testing.T) { + fake := &fakeModelsClient{createErr: status.Error(codes.InvalidArgument, `unknown agent definition "nope"`)} + cl := newFakeClient(fake) + + _, _, _, err := cl.CreateSessionWithAgent(context.Background(), + mecatlv1.PermissionMode_PERMISSION_MODE_DEFAULT, ModelSelection{}, "nope") + if err == nil { + t.Fatal("CreateSessionWithAgent: want an error for an unknown definition, got nil") + } + if !IsInvalidArgument(err) { + t.Fatalf("IsInvalidArgument(%v) = false, want true", err) + } +} diff --git a/cmd/mecatui/client/models_test.go b/cmd/mecatui/client/models_test.go index ec28c78c50..ab1136bbd2 100644 --- a/cmd/mecatui/client/models_test.go +++ b/cmd/mecatui/client/models_test.go @@ -21,6 +21,7 @@ type fakeModelsClient struct { lastList *mecatlv1.ListModelsRequest createResp *mecatlv1.CreateSessionResponse + createErr error caps *mecatlv1.ServerCapabilities lastCreate *mecatlv1.CreateSessionRequest } @@ -39,6 +40,9 @@ func (f *fakeModelsClient) GetCompatibilityInfo(_ context.Context, _ *mecatlv1.G func (f *fakeModelsClient) CreateSession(_ context.Context, in *mecatlv1.CreateSessionRequest, _ ...grpc.CallOption) (*mecatlv1.CreateSessionResponse, error) { f.lastCreate = in + if f.createErr != nil { + return nil, f.createErr + } if f.createResp != nil { return f.createResp, nil } diff --git a/cmd/mecatui/command_test.go b/cmd/mecatui/command_test.go index 618399bfa8..151c2b2575 100644 --- a/cmd/mecatui/command_test.go +++ b/cmd/mecatui/command_test.go @@ -126,6 +126,10 @@ func (*sessionsLaunchCreator) ForkSession(context.Context, string, string) (stri return "", nil } +func (*sessionsLaunchCreator) CreateSessionWithAgent(context.Context, client.ModelSelection, string, string) (string, client.Capabilities, client.ResolvedModel, error) { + return "", client.Capabilities{}, client.ResolvedModel{}, nil +} + func (f *sessionsLaunchCreator) ListSessions(context.Context) ([]client.SessionListItem, error) { return f.rows, nil } diff --git a/cmd/mecatui/main.go b/cmd/mecatui/main.go index 94a8b5a451..1dcbc4a42d 100644 --- a/cmd/mecatui/main.go +++ b/cmd/mecatui/main.go @@ -1723,6 +1723,18 @@ func (s *sessionAdapter) CreateSession(ctx context.Context, sel client.ModelSele func (s *sessionAdapter) DebugTargetID() string { return s.debugTarget } +// CreateSessionWithAgent implements the ui SessionCreator's agent-bound seam +// (ADR 0353). An agent-bound session is mutually exclusive with a debug target +// server-side (AC1.5); this adapter does not attempt to combine them — the +// debug-target fields are left untouched, as agent-bound sessions are a +// developer/testing path distinct from debug sessions. +func (s *sessionAdapter) CreateSessionWithAgent(ctx context.Context, sel client.ModelSelection, mode, agentDefName string) (string, client.Capabilities, client.ResolvedModel, error) { + if mode == "" { + mode = s.mode + } + return s.cl.CreateSessionWithAgent(ctx, client.ModeFromString(mode), sel, agentDefName) +} + // CreateSessionWithCarryover implements the ui SessionCreator's carryover seam // (issue #20): like CreateSession it carries the pick + mode, but it ALSO sets // source_session_id so the server seeds the new session's history from the source. diff --git a/cmd/mecatui/ui/agent_builtin.go b/cmd/mecatui/ui/agent_builtin.go new file mode 100644 index 0000000000..4235f9524e --- /dev/null +++ b/cmd/mecatui/ui/agent_builtin.go @@ -0,0 +1,101 @@ +package ui + +import ( + "strings" + "unicode" + + tea "charm.land/bubbletea/v2" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext" +) + +// agentCommand recognizes /agent, mirroring titleCommand's bare-vs-argument +// split: whitespace (or end of input) after /agent is a bare picker-hint +// request; a non-whitespace suffix is the agent-definition name to create a +// session for immediately. The next character must be whitespace (or absent) +// so "/agents" is never misparsed as "/agent" with argument "s". +func agentCommand(text string) (name string, bare, ok bool) { + raw := strings.TrimLeftFunc(text, unicode.IsSpace) + if !strings.HasPrefix(strings.ToLower(raw), "/agent") { + return "", false, false + } + if len(raw) > len("/agent") && !unicode.IsSpace(rune(raw[len("/agent")])) { + return "", false, false + } + name = strings.TrimSpace(raw[len("/agent"):]) + return name, name == "", true +} + +// runAgentHint handles a bare /agent (no name typed), reached only via the +// command palette (dispatchBareBuiltin's own agentCommand check intercepts +// bare /agent from the prompt line first). A full filterable picker is +// deferred; for now this points at /agents (the existing read-only +// definition inventory) so the operator can find a name to type. +func (m Model) runAgentHint() (tea.Model, tea.Cmd) { + m.statusMsg = m.deps.Theme.Style("warning").Render("usage: /agent — see /agents for available definitions") + m.refreshView() + return m, nil +} + +// startAgentSession begins the /agent handoff: like restartOnModel it +// tears down any in-flight run and the old per-session client state, then +// fires createSessionWithAgentCmd off the update goroutine. Unlike a model +// switch this is not a "restart" of the current session's selection — it is a +// fresh session bound to a DIFFERENT AgentDef — so it carries no model +// selection and no persisted-selection save. +func (m Model) startAgentSession(name string) (tea.Model, tea.Cmd, bool) { + if m.phase != phaseIdle || m.deps.Session == nil { + return m, nil, true + } + m = m.endRun("") + oldID := m.sessionID + m = m.resetSession() + m = m.bindSessionID("") + m.resolvedSessionModel = client.ResolvedModel{} + m.caps = client.Capabilities{} + m.restartFailed = false + m.phase = phaseConnecting + m.statusMsg = "starting agent " + terminaltext.SanitizeSingleLine(name) + " — connecting…" + m.refreshView() + return m, tea.Batch(m.createSessionWithAgentCmd(oldID, name), m.sp.Tick), true +} + +// createSessionWithAgentCmd closes the OLD session (best-effort) then creates +// a NEW session bound to the named AgentDef, off the update goroutine. On +// success it returns the same SessionReadyMsg the ordinary connect/restart +// paths use. On failure it returns agentSessionFailedMsg (NOT +// client.ConnectErrMsg, which would drive the terminal fatal screen) — +// distinguishing an unknown definition (client.IsInvalidArgument) from a +// transient failure in the surfaced message only; both are equally +// recoverable (idle, enter-to-retry is NOT armed — the operator retypes the +// name, since unlike a model switch there is no single "the" pending +// selection to replay). +func (m Model) createSessionWithAgentCmd(oldID, name string) tea.Cmd { + deps := m.deps + mode := m.desiredMode() + // Carry the operator's active selection like every other create path: a def + // with no model of its own inherits it, whereas a zero selection would fall + // to the server default (possibly a model the account cannot afford). + sel := m.createModelSelection + return func() tea.Msg { + if oldID != "" { + _ = deps.Session.CloseSession(deps.Ctx, oldID) + } + id, caps, resolved, err := deps.Session.CreateSessionWithAgent(deps.Ctx, sel, mode, name) + if err != nil { + return agentSessionFailedMsg{err: err, name: name, unknown: client.IsInvalidArgument(err)} + } + return client.SessionReadyMsg{SessionID: id, Capabilities: caps, ResolvedModel: resolved, Mode: mode} + } +} + +// agentSessionFailedMsg reports that a /agent create FAILED. Distinct +// from client.ConnectErrMsg: its reducer leaves the app RECOVERABLE (idle, no +// session). unknown distinguishes the server's InvalidArgument (no such +// definition) from a transient failure, purely for the status copy. +type agentSessionFailedMsg struct { + err error + name string + unknown bool +} diff --git a/cmd/mecatui/ui/agent_builtin_test.go b/cmd/mecatui/ui/agent_builtin_test.go new file mode 100644 index 0000000000..6b93f876c5 --- /dev/null +++ b/cmd/mecatui/ui/agent_builtin_test.go @@ -0,0 +1,75 @@ +package ui + +import ( + "context" + "errors" + "testing" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/theme" +) + +func TestAgentCommandParsing(t *testing.T) { + for _, tc := range []struct { + in string + name string + bare, ok bool + }{ + {"/agent sfs", "sfs", false, true}, + {" /agent sfs ", "sfs", false, true}, + {"/AGENT sfs", "sfs", false, true}, + {"/agent", "", true, true}, + {"/agent ", "", true, true}, + {"/agents", "", false, false}, + {"/agentx foo", "", false, false}, + {"hello /agent sfs", "", false, false}, + } { + name, bare, ok := agentCommand(tc.in) + if name != tc.name || bare != tc.bare || ok != tc.ok { + t.Errorf("agentCommand(%q) = (%q, %v, %v), want (%q, %v, %v)", tc.in, name, bare, ok, tc.name, tc.bare, tc.ok) + } + } +} + +// The agent-bound create must carry the operator's active model selection like +// every other create path: a def without its own model inherits it, whereas a +// zero selection falls to the server default (which may be a model the account +// cannot afford — the observed 402 on max_tokens). +func TestCreateSessionWithAgentCmdCarriesActiveSelection(t *testing.T) { + conv := &fakeConv{recv: &fakeRecver{}, send: &fakeSender{}} + m := newTestModelFromDeps(Deps{ + Session: conv, Conv: conv, Theme: theme.New("aztec", theme.AztecPalette()), Ctx: context.Background(), + }) + m.createModelSelection = client.ModelSelection{ProviderID: "openai", ModelID: "gpt-5"} + + msg := m.createSessionWithAgentCmd("old-session", "devils-advocate")() + if _, ok := msg.(client.SessionReadyMsg); !ok { + t.Fatalf("msg = %T, want SessionReadyMsg", msg) + } + conv.mu.Lock() + defer conv.mu.Unlock() + if conv.agentDefRequested != "devils-advocate" { + t.Fatalf("agentDefRequested = %q, want devils-advocate", conv.agentDefRequested) + } + if conv.createdSel != (client.ModelSelection{ProviderID: "openai", ModelID: "gpt-5"}) { + t.Fatalf("createdSel = %+v, want the active selection", conv.createdSel) + } + if len(conv.closedIDs) != 1 || conv.closedIDs[0] != "old-session" { + t.Fatalf("closedIDs = %v, want the old session closed", conv.closedIDs) + } +} + +func TestCreateSessionWithAgentCmdUnknownDefinitionIsRecoverable(t *testing.T) { + conv := &fakeConv{recv: &fakeRecver{}, send: &fakeSender{}, agentDefErr: errors.New("boom")} + m := newTestModelFromDeps(Deps{ + Session: conv, Conv: conv, Theme: theme.New("aztec", theme.AztecPalette()), Ctx: context.Background(), + }) + msg := m.createSessionWithAgentCmd("", "nope")() + failed, ok := msg.(agentSessionFailedMsg) + if !ok { + t.Fatalf("msg = %T, want agentSessionFailedMsg (NOT the terminal ConnectErrMsg)", msg) + } + if failed.name != "nope" || failed.unknown { + t.Fatalf("failed = %+v, want name=nope and unknown=false for a non-InvalidArgument error", failed) + } +} diff --git a/cmd/mecatui/ui/builtins.go b/cmd/mecatui/ui/builtins.go index 060ffb210b..b9af64703a 100644 --- a/cmd/mecatui/ui/builtins.go +++ b/cmd/mecatui/ui/builtins.go @@ -178,6 +178,11 @@ func builtinCommands(caps client.Capabilities, w wiredCollaborators) []builtin { desc: "browse agent definitions", run: Model.runAgentsInv, }) + out = append(out, builtin{ + name: "agent", + desc: "start a session bound to a named agent definition", + run: Model.runAgentHint, + }) } if caps.Teams { out = append(out, builtin{ @@ -648,7 +653,7 @@ type builtinName struct { var builtinNameRegistry = []builtinName{ {name: "clear", acceptsArgs: false}, {name: "help", acceptsArgs: false}, {name: "quit", acceptsArgs: false}, {name: "title", acceptsArgs: true}, {name: "session", acceptsArgs: false}, {name: "retry", acceptsArgs: false}, {name: "toolcalls", acceptsArgs: false}, {name: "diagnostics", acceptsArgs: false}, {name: "compact", acceptsArgs: false}, - {name: "mcp", acceptsArgs: false}, {name: "agents", acceptsArgs: false}, {name: "team", acceptsArgs: false}, + {name: "mcp", acceptsArgs: false}, {name: "agents", acceptsArgs: false}, {name: "agent", acceptsArgs: true}, {name: "team", acceptsArgs: false}, {name: "skills", acceptsArgs: false}, {name: "soul", acceptsArgs: false}, {name: "memory", acceptsArgs: false}, {name: "models", acceptsArgs: false}, {name: "effort", acceptsArgs: false}, {name: "worktrees", acceptsArgs: false}, {name: "schedule", acceptsArgs: false}, {name: "sessions", acceptsArgs: false}, {name: "learning", acceptsArgs: false}, @@ -736,6 +741,15 @@ func (m Model) dispatchBareBuiltin(text string) (tea.Model, tea.Cmd, bool) { mm, cmd := m.renameTitle(title) return mm, cmd, true } + if name, bare, ok := agentCommand(text); ok { + if bare { + m.prompt.Reset() + mm, cmd := m.runAgentHint() + return mm, cmd, true + } + m.prompt.Reset() + return m.startAgentSession(name) + } trimmed := strings.TrimSpace(text) fields := strings.Fields(trimmed) if len(fields) > 1 { diff --git a/cmd/mecatui/ui/builtins_test.go b/cmd/mecatui/ui/builtins_test.go index ec599ba2e1..e4c6deaaae 100644 --- a/cmd/mecatui/ui/builtins_test.go +++ b/cmd/mecatui/ui/builtins_test.go @@ -99,7 +99,7 @@ func TestBuiltinCommandsCapsFilter(t *testing.T) { {"broker cap and reader", client.Capabilities{MCPConnectorStatus: true}, wiredCollaborators{MCPConnector: true}, []string{"clear", "help", "mcp"}}, {"agents cap but not wired", client.Capabilities{Agents: true}, wiredCollaborators{}, []string{"clear", "help"}}, {"agents wired but no cap", client.Capabilities{}, wiredCollaborators{Agents: true}, []string{"clear", "help"}}, - {"agents cap and wired", client.Capabilities{Agents: true}, wiredCollaborators{Agents: true}, []string{"clear", "help", "agents"}}, + {"agents cap and wired", client.Capabilities{Agents: true}, wiredCollaborators{Agents: true}, []string{"clear", "help", "agents", "agent"}}, {"teams only", client.Capabilities{Teams: true}, wiredCollaborators{}, []string{"clear", "help", "team"}}, {"skills cap but not wired", client.Capabilities{Skills: true}, wiredCollaborators{}, []string{"clear", "help"}}, {"skills wired but no cap", client.Capabilities{}, wiredCollaborators{Skills: true}, []string{"clear", "help"}}, @@ -131,7 +131,7 @@ func TestBuiltinCommandsCapsFilter(t *testing.T) { "all", all, wiredCollaborators{MCP: true, Agents: true, Skills: true, Soul: true, UserModel: true, Models: true, Worktrees: true, Scheduling: true, Sessions: true, Compactor: true}, - []string{"clear", "help", "compact", "mcp", "agents", "team", "skills", "soul", "memory", "models", "effort", "worktrees", "schedule", "sessions", "posture"}, + []string{"clear", "help", "compact", "mcp", "agents", "agent", "team", "skills", "soul", "memory", "models", "effort", "worktrees", "schedule", "sessions", "posture"}, }, } for _, tc := range cases { @@ -991,7 +991,7 @@ func TestDispatchBareBuiltinUnicodeWhitespaceThroughTextarea(t *testing.T) { // from the builtinCommands table AND that an unknown name is false. func TestIsKnownBuiltinName(t *testing.T) { known := []string{ - "clear", "help", "quit", "title", "session", "retry", "toolcalls", "diagnostics", "compact", "mcp", "agents", "team", "skills", "soul", "memory", + "clear", "help", "quit", "title", "session", "retry", "toolcalls", "diagnostics", "compact", "mcp", "agents", "agent", "team", "skills", "soul", "memory", "models", "effort", "worktrees", "schedule", "sessions", "learning", "learning-sensitivity", "posture", "debug-ask", } diff --git a/cmd/mecatui/ui/fake_test.go b/cmd/mecatui/ui/fake_test.go index 77d129eb42..6943d1d791 100644 --- a/cmd/mecatui/ui/fake_test.go +++ b/cmd/mecatui/ui/fake_test.go @@ -186,6 +186,15 @@ type fakeConv struct { carryoverFrom string carryoverCount int carryoverIDs []string + // agentDefRequested records the agentDefName the LAST CreateSessionWithAgent + // carried, and agentDefCount counts those calls — the /agent e2e asserts the + // typed/picked name threads into the create. agentDefErr, when non-nil, is + // returned ONLY by CreateSessionWithAgent (modelling the server's + // InvalidArgument for an unknown definition) — createErr/rejectSelector stay + // reserved for the ordinary CreateSession path. + agentDefRequested string + agentDefCount int + agentDefErr error // recreated, when non-nil, is closed on the SECOND CreateSession (the restart-now // handoff's re-create) — the deterministic signal a teatest sequences the model // switch on, output-independent. createdOnce guards `created`; a dedicated Once @@ -425,6 +434,27 @@ func (c *fakeConv) CreateSessionWithCarryover(ctx context.Context, sourceSession return c.CreateSession(ctx, sel, c.mode) } +// CreateSessionWithAgent implements the ui SessionCreator's agent-bound seam +// (ADR 0353): it records the requested def name (so the /agent e2e asserts the +// typed/picked name threads into the create) and delegates to the shared +// create body — agentDefErr, when set, fails ONLY this path (modelling the +// server's InvalidArgument for an unknown definition), distinct from +// createErr/rejectSelector. +func (c *fakeConv) CreateSessionWithAgent(ctx context.Context, sel client.ModelSelection, mode, agentDefName string) (string, client.Capabilities, client.ResolvedModel, error) { + c.mu.Lock() + c.agentDefRequested = agentDefName + c.agentDefCount++ + if mode != "" { + c.mode = mode + } + err := c.agentDefErr + c.mu.Unlock() + if err != nil { + return "", client.Capabilities{}, client.ResolvedModel{}, err + } + return c.CreateSession(ctx, sel, c.mode) +} + func (c *fakeConv) ClearSession(ctx context.Context, _ string, selector *client.WorktreeSelector) (string, client.SessionSnapshot, error) { id, caps, resolved, err := c.CreateSession(ctx, client.ModelSelection{}, c.mode) c.mu.Lock() diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index b8d4b10e8d..12ba776edd 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -96,6 +96,12 @@ type SessionCreator interface { // and model ALWAYS inherit). The caller owns closing the source session and the // GetSession refetch for the fork's resolved-model echo. ForkSession(ctx context.Context, srcID, reasoningEffort string) (string, error) + // CreateSessionWithAgent allocates a session bound to a named AgentDef (ADR + // 0353) instead of the default explorer session: the server restricts the + // catalog, provider/model, limits, and permission mode to the resolved + // definition's own values. An unknown agentDefName fails with + // client.IsInvalidArgument. + CreateSessionWithAgent(ctx context.Context, sel client.ModelSelection, mode, agentDefName string) (string, client.Capabilities, client.ResolvedModel, error) } // SelectionStore persists + loads the client-side model selection (last-used). It diff --git a/cmd/mecatui/ui/quit_test.go b/cmd/mecatui/ui/quit_test.go index f563beccd1..589203c51a 100644 --- a/cmd/mecatui/ui/quit_test.go +++ b/cmd/mecatui/ui/quit_test.go @@ -443,3 +443,7 @@ func (errSession) SetMode(_ context.Context, _, mode string) (string, error) { r func (errSession) ForkSession(_ context.Context, _, _ string) (string, error) { return "", context.DeadlineExceeded } + +func (errSession) CreateSessionWithAgent(_ context.Context, _ client.ModelSelection, _, _ string) (string, client.Capabilities, client.ResolvedModel, error) { + return "", client.Capabilities{}, client.ResolvedModel{}, context.DeadlineExceeded +} diff --git a/cmd/mecatui/ui/sdk_high_level_parity_test.go b/cmd/mecatui/ui/sdk_high_level_parity_test.go index b9c061c48c..fc56236afe 100644 --- a/cmd/mecatui/ui/sdk_high_level_parity_test.go +++ b/cmd/mecatui/ui/sdk_high_level_parity_test.go @@ -47,6 +47,7 @@ var sdkBoundaryBuiltins = map[string]sdkBoundaryRow{ applicationDetail: "The TUI chooses direct source refresh or broker workspace enrollment from server capabilities.", }, "agents": {category: sdkBacked, sdkOperation: "Client.agents.list"}, + "agent": {category: sdkBacked, sdkOperation: "Client.sessions.create"}, "team": {category: sdkBacked, sdkOperation: "Team.list"}, "skills": {category: sdkBacked, sdkOperation: "Client.skills.list"}, "soul": {category: sdkBacked, sdkOperation: "Client.soul.get"}, diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 67922331f2..9bd71dd84d 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -978,6 +978,21 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd, bool) { _ = m.prompt.Focus() m.refreshView() return m, nil, true + case agentSessionFailedMsg: + // A /agent create failed. Unlike ConnectErrMsg this is NOT terminal: + // leave the app recoverable (idle, no session). No enter-to-retry arm (unlike + // restartFailedMsg) — there is no single pending selection to replay, so the + // operator retypes /agent . + m.phase = phaseIdle + m = m.bindSessionID("") + reason := "could not start agent " + terminaltext.SanitizeSingleLine(msg.name) + ": " + terminaltext.Sanitize(msg.err.Error()) + if msg.unknown { + reason = fmt.Sprintf("unknown agent definition %q — see /agents for available definitions", terminaltext.SanitizeSingleLine(msg.name)) + } + m.statusMsg = m.deps.Theme.Style("errorText").Render(reason) + _ = m.prompt.Focus() + m.refreshView() + return m, nil, true case client.StreamErrMsg: if m.restoreRefusedApproval(msg.Err) { return m, nil, true From 4b1871c7f2502afcf92d87e6e6f468c25e9dbbe3 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Thu, 1 Oct 2026 13:38:37 +0200 Subject: [PATCH 2/2] feat(mecatui): bare /agent opens a filterable picker of agent definitions Bare /agent (typed or from the palette) now opens a modal picker listing the server's definitions by name; Enter starts a session bound to the highlighted one, using the same create path as /agent . Submitting /agent also closes the slash-command palette, which previously stayed open. Co-Authored-By: Claude Sonnet 5.5 --- cmd/mecatui/ui/agent_builtin.go | 14 +- cmd/mecatui/ui/agent_builtin_test.go | 89 +++++++++++ cmd/mecatui/ui/agent_picker.go | 223 +++++++++++++++++++++++++++ cmd/mecatui/ui/builtins.go | 12 +- cmd/mecatui/ui/update.go | 3 + 5 files changed, 324 insertions(+), 17 deletions(-) create mode 100644 cmd/mecatui/ui/agent_picker.go diff --git a/cmd/mecatui/ui/agent_builtin.go b/cmd/mecatui/ui/agent_builtin.go index 4235f9524e..a535b19ad2 100644 --- a/cmd/mecatui/ui/agent_builtin.go +++ b/cmd/mecatui/ui/agent_builtin.go @@ -11,8 +11,7 @@ import ( ) // agentCommand recognizes /agent, mirroring titleCommand's bare-vs-argument -// split: whitespace (or end of input) after /agent is a bare picker-hint -// request; a non-whitespace suffix is the agent-definition name to create a +// split: whitespace (or end of input) after /agent is a bare picker request; a non-whitespace suffix is the agent-definition name to create a // session for immediately. The next character must be whitespace (or absent) // so "/agents" is never misparsed as "/agent" with argument "s". func agentCommand(text string) (name string, bare, ok bool) { @@ -27,17 +26,6 @@ func agentCommand(text string) (name string, bare, ok bool) { return name, name == "", true } -// runAgentHint handles a bare /agent (no name typed), reached only via the -// command palette (dispatchBareBuiltin's own agentCommand check intercepts -// bare /agent from the prompt line first). A full filterable picker is -// deferred; for now this points at /agents (the existing read-only -// definition inventory) so the operator can find a name to type. -func (m Model) runAgentHint() (tea.Model, tea.Cmd) { - m.statusMsg = m.deps.Theme.Style("warning").Render("usage: /agent — see /agents for available definitions") - m.refreshView() - return m, nil -} - // startAgentSession begins the /agent handoff: like restartOnModel it // tears down any in-flight run and the old per-session client state, then // fires createSessionWithAgentCmd off the update goroutine. Unlike a model diff --git a/cmd/mecatui/ui/agent_builtin_test.go b/cmd/mecatui/ui/agent_builtin_test.go index 6b93f876c5..6bca4ebda2 100644 --- a/cmd/mecatui/ui/agent_builtin_test.go +++ b/cmd/mecatui/ui/agent_builtin_test.go @@ -3,8 +3,11 @@ package ui import ( "context" "errors" + "strings" "testing" + tea "charm.land/bubbletea/v2" + "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/theme" ) @@ -73,3 +76,89 @@ func TestCreateSessionWithAgentCmdUnknownDefinitionIsRecoverable(t *testing.T) { t.Fatalf("failed = %+v, want name=nope and unknown=false for a non-InvalidArgument error", failed) } } + +func newAgentPickModel(t *testing.T, ag client.AgentLister) (Model, *fakeConv) { + t.Helper() + conv := &fakeConv{recv: &fakeRecver{gate: make(chan struct{})}, send: &fakeSender{}, caps: client.Capabilities{Agents: true}} + m := newTestModelFromDeps(Deps{ + Session: conv, Conv: conv, Agents: ag, Theme: theme.New("aztec", theme.AztecPalette()), + Workspace: "/workspace", Mode: "default", Ctx: context.Background(), NoAltScreen: true, + }) + m = applyAll(m, + tea.WindowSizeMsg{Width: 100, Height: 30}, + client.SessionReadyMsg{SessionID: "sess-test-0001", Capabilities: conv.caps}, + ) + return m, conv +} + +// Bare /agent opens a filterable picker over the server's definitions; Enter on +// the highlighted row starts a session bound to it. +func TestAgentPickerFilterAndSelectStartsBoundSession(t *testing.T) { + m, conv := newAgentPickModel(t, sampleAgents()) + mm, cmd := m.openAgentPicker() + m = feedCmd(t, mm.(Model), cmd) + + picker, ok := m.modal.(*agentPickState) + if !ok { + t.Fatalf("modal = %T, want *agentPickState", m.modal) + } + body := stripANSIstr(m.View().Content) + if !strings.Contains(body, "scout") || !strings.Contains(body, "writer") { + t.Fatalf("picker should list both definitions, got:\n%s", body) + } + if len(picker.filtered) != 2 { + t.Fatalf("filtered = %d, want 2", len(picker.filtered)) + } + + m = typeText(t, m, "wri") + if got := m.modal.(*agentPickState).filtered; len(got) != 1 || got[0].Name != "writer" { + t.Fatalf("filter %q left %+v, want only writer", "wri", got) + } + + mm, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = feedCmd(t, mm.(Model), cmd) + conv.mu.Lock() + got := conv.agentDefRequested + conv.mu.Unlock() + if got != "writer" { + t.Fatalf("agentDefRequested = %q, want writer", got) + } + if m.modal != nil { + t.Fatalf("picker should close after a pick, modal = %T", m.modal) + } +} + +func TestAgentPickerIgnoresResultFromAnotherInstance(t *testing.T) { + st := &agentPickState{loading: true} + other := &agentPickState{} + _, handled, _ := st.HandleMsg(agentPickResultMsg{owner: other, result: client.AgentsMsg{Agents: []client.Agent{{Name: "x"}}}}) + if !handled || !st.loading || len(st.agents) != 0 { + t.Fatalf("a stale picker's result must be swallowed without updating this picker: handled=%v loading=%v agents=%v", handled, st.loading, st.agents) + } +} + +func TestAgentPickerShowsListError(t *testing.T) { + m, _ := newAgentPickModel(t, &fakeAgents{err: errors.New("boom")}) + mm, cmd := m.openAgentPicker() + m = feedCmd(t, mm.(Model), cmd) + if body := stripANSIstr(m.View().Content); !strings.Contains(body, "list agents: boom") { + t.Fatalf("picker should surface the list error, got:\n%s", body) + } +} + +// Submitting bare /agent consumes the command line, so the slash-command palette +// it opened must close too (an argument already closes the palette on typing). +func TestBareAgentCommandClosesPalette(t *testing.T) { + m, _ := newAgentPickModel(t, sampleAgents()) + m = typeText(t, m, "/agent") + if !m.palette.open { + t.Fatal("palette should be open after typing /agent") + } + mm, _, handled := m.dispatchBareBuiltin("/agent") + if !handled { + t.Fatal("dispatchBareBuiltin(/agent) not handled") + } + if mm.(Model).palette.open { + t.Fatal("palette still open after submitting /agent") + } +} diff --git a/cmd/mecatui/ui/agent_picker.go b/cmd/mecatui/ui/agent_picker.go new file mode 100644 index 0000000000..a70662548f --- /dev/null +++ b/cmd/mecatui/ui/agent_picker.go @@ -0,0 +1,223 @@ +package ui + +import ( + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" +) + +// agentPickState is the bare /agent picker: a filterable list of the server's +// agent definitions; Enter starts a session bound to the highlighted one. It is +// a modal surface modeled on the /models picker, without click regions. +type agentPickState struct { + deps surfaceDeps + loading bool + err error + agents []client.Agent + filtered []client.Agent + filter textinput.Model + list *bounded.List + intent surfaceIntent +} + +// agentPickResultMsg binds a ListAgents result to the picker instance that +// requested it; AgentsMsg itself carries no request identity. +type agentPickResultMsg struct { + owner *agentPickState + result client.AgentsMsg +} + +type agentPickSelectIntent struct{ name string } + +func (agentPickSelectIntent) isSurfaceIntent() {} + +// openAgentPicker opens the picker and starts the definition listing. +func (m Model) openAgentPicker() (tea.Model, tea.Cmd) { + if m.phase != phaseIdle || m.deps.Agents == nil { + return m, nil + } + m.prompt.Blur() + ti := textinput.New() + ti.Placeholder = "filter agents…" + ti.SetWidth(40) + ti.Focus() + st := &agentPickState{loading: true, filter: ti, list: new(bounded.List), deps: (&m).surfaceDeps()} + m.modal = st + list := client.ListAgentsCmd(m.deps.Ctx, m.deps.Agents) + return m, tea.Batch(func() tea.Msg { + return agentPickResultMsg{owner: st, result: list().(client.AgentsMsg)} + }, textinput.Blink) +} + +// applyAgentPickIntent starts the agent-bound session for a picked definition. +func (m Model) applyAgentPickIntent(intent surfaceIntent) (tea.Model, tea.Cmd, bool) { + pick, ok := intent.(agentPickSelectIntent) + if !ok { + return m, nil, false + } + mm, cmd, _ := m.startAgentSession(pick.name) + return mm, cmd, true +} + +func (s *agentPickState) Render(width, height int) (string, []ClickableRegion) { + th := s.deps.theme + prefix := []string{th.Style("askTitle").Render("Agent definitions"), s.filter.View(), ""} + switch { + case s.loading: + prefix = append(prefix, th.Style("muted").Render("loading…")) + case s.err != nil: + prefix = append(prefix, th.Style("errorText").Render("✗ list agents: "+terminaltext.Sanitize(s.err.Error()))) + case len(s.agents) == 0: + prefix = append(prefix, th.Style("muted").Render(agentsInvEmptyCopy(s.deps.caps))) + case len(s.filtered) == 0: + prefix = append(prefix, th.Style("muted").Render("no agents match "+strconv.Quote(s.filter.Value())+" — "+s.deps.marks.closeOnly+" to clear")) + } + suffix := []string{"", th.Style("muted").Render("type to filter · ↑/↓ move · " + s.deps.marks.choose + " start session · " + s.deps.marks.closeOnly + " clear filter / close")} + + budget := max(0, height-len(prefix)-len(suffix)) + list := s.listControl() + list.SetGeometry(width, budget, 3, bounded.Wrap) + list.SetItems(agentPickItems(s.filtered)) + if list.CursorID() == "" { + list.SetCursor(0) + } + view := list.ViewWithIndicators(budget, list.RevealPending()) + + lines := append([]string(nil), prefix...) + for _, row := range view.Rows { + presentation := presentListRow(row, th.Style("spinner"), th.Style("muted")) + lines = append(lines, presentation.Style.Render(presentation.Text)) + } + lines = append(lines, suffix...) + return strings.Join(lines, "\n"), nil +} + +func (s *agentPickState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { + switch { + case key.Matches(msg, s.deps.keys.Close): + if s.filter.Value() != "" { + s.filter.SetValue("") + s.syncFilter() + return nil, true, false + } + return nil, true, true + case msg.String() == keyMenuUp: + s.move(bounded.LineUp) + case msg.String() == keyMenuDown: + s.move(bounded.LineDown) + case key.Matches(msg, s.deps.keys.ScrollU): + s.move(bounded.PageUp) + case key.Matches(msg, s.deps.keys.ScrollD): + s.move(bounded.PageDown) + case key.Matches(msg, s.deps.keys.ScrollTop): + s.move(bounded.Top) + case key.Matches(msg, s.deps.keys.ScrollBottom): + s.move(bounded.End) + case key.Matches(msg, s.deps.keys.Choose): + if chosen, ok := s.chosen(); ok { + s.intent = agentPickSelectIntent{name: chosen.Name} + return nil, true, true + } + return nil, true, false + default: + var cmd tea.Cmd + s.filter, cmd = s.filter.Update(msg) + s.syncFilter() + return cmd, true, false + } + return nil, true, false +} + +func (s *agentPickState) HandleMsg(msg tea.Msg) (tea.Cmd, bool, bool) { + result, ok := msg.(agentPickResultMsg) + if !ok { + return nil, false, false + } + if result.owner != s { + return nil, true, false + } + s.loading, s.err = false, result.result.Err + if s.err == nil { + s.agents = result.result.Agents + s.syncFilter() + } + return nil, true, false +} + +func (s *agentPickState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { + if msg.Mouse().Button == tea.MouseWheelUp { + s.move(bounded.LineUp) + } else { + s.move(bounded.LineDown) + } + return nil, true +} + +func (*agentPickState) Close() {} + +func (s *agentPickState) takeSurfaceIntent() surfaceIntent { + intent := s.intent + s.intent = nil + return intent +} + +func (s *agentPickState) listControl() *bounded.List { + if s.list == nil { + s.list = new(bounded.List) + } + return s.list +} + +func (s *agentPickState) move(move bounded.Move) { + if list := s.listControl(); list.Valid() { + list.Move(move) + } +} + +func (s *agentPickState) syncFilter() { + s.filtered = filterAgentPick(s.agents, s.filter.Value()) + list := s.listControl() + list.SetItems(agentPickItems(s.filtered)) + if len(s.filtered) > 0 && list.CursorID() == "" { + list.SetCursor(0) + } +} + +func (s *agentPickState) chosen() (client.Agent, bool) { + id := s.listControl().CursorID() + for _, a := range s.filtered { + if a.Name == id { + return a, true + } + } + return client.Agent{}, false +} + +func filterAgentPick(agents []client.Agent, q string) []client.Agent { + if q == "" { + return agents + } + lq := strings.ToLower(q) + out := make([]client.Agent, 0, len(agents)) + for _, a := range agents { + if strings.Contains(strings.ToLower(a.Name), lq) { + out = append(out, a) + } + } + return out +} + +func agentPickItems(agents []client.Agent) []bounded.ListItem { + items := make([]bounded.ListItem, 0, len(agents)) + for _, a := range agents { + items = append(items, bounded.ListItem{ID: a.Name, Text: terminaltext.SanitizeSingleLine(a.Name)}) + } + return items +} diff --git a/cmd/mecatui/ui/builtins.go b/cmd/mecatui/ui/builtins.go index b9af64703a..6d6c17fa94 100644 --- a/cmd/mecatui/ui/builtins.go +++ b/cmd/mecatui/ui/builtins.go @@ -181,7 +181,7 @@ func builtinCommands(caps client.Capabilities, w wiredCollaborators) []builtin { out = append(out, builtin{ name: "agent", desc: "start a session bound to a named agent definition", - run: Model.runAgentHint, + run: Model.openAgentPicker, }) } if caps.Teams { @@ -742,12 +742,16 @@ func (m Model) dispatchBareBuiltin(text string) (tea.Model, tea.Cmd, bool) { return mm, cmd, true } if name, bare, ok := agentCommand(text); ok { + // Consuming the command line also closes its derived palette state, like + // the generic built-in path below. + m.palette.open = false + m.palette.filtered = nil + m.palette.syncList() + m.prompt.Reset() if bare { - m.prompt.Reset() - mm, cmd := m.runAgentHint() + mm, cmd := m.openAgentPicker() return mm, cmd, true } - m.prompt.Reset() return m.startAgentSession(name) } trimmed := strings.TrimSpace(text) diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 9bd71dd84d..52ccb18f3f 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -2659,6 +2659,9 @@ func (m Model) applySurfaceIntent(intent surfaceIntent) (model tea.Model, cmd te if m.applyToolcallsSurfaceIntent(intent) { return m, nil, false } + if model, cmd, handled := m.applyAgentPickIntent(intent); handled { + return model, cmd, false + } return m, nil, false }