Skip to content
Open
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
25 changes: 25 additions & 0 deletions cmd/mecatui/client/agent_session.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
51 changes: 51 additions & 0 deletions cmd/mecatui/client/agent_session_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions cmd/mecatui/client/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type fakeModelsClient struct {
lastList *mecatlv1.ListModelsRequest

createResp *mecatlv1.CreateSessionResponse
createErr error
caps *mecatlv1.ServerCapabilities
lastCreate *mecatlv1.CreateSessionRequest
}
Expand All @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/mecatui/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions cmd/mecatui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
89 changes: 89 additions & 0 deletions cmd/mecatui/ui/agent_builtin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 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
}

// startAgentSession begins the /agent <name> 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 <name> 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
}
164 changes: 164 additions & 0 deletions cmd/mecatui/ui/agent_builtin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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"
)

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)
}
}

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")
}
}
Loading
Loading