Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
43f7eca
feat(session): plumb agent_definition_name field (ADR 0353, Task A)
kantord Sep 29, 2026
5ec7c75
feat(session): add write-once Authority.Ceiling and enforce it in too…
kantord Sep 29, 2026
aaa9c2e
feat(session): build agent-bound session engines from the def's own c…
kantord Sep 29, 2026
1383fbc
fix(session): wire Authority.Ceiling into the real agent-bound mint path
kantord Sep 30, 2026
32eb565
fix(agent): stop an agent-bound root engine from carrying a child-sha…
kantord Sep 30, 2026
68f7159
feat(session): resolve provider/model pair + tighten-only limits/mode…
kantord Sep 30, 2026
b89ca87
feat(session): fail closed on every agent-bound engine-rebuild path (…
kantord Sep 30, 2026
1c72ef7
feat(session): Fork/Clear preserve agent-bound identity + placement-s…
kantord Sep 30, 2026
8bd375b
chore(session): close ADR 0037 gates for AgentDefinitionName/Authorit…
kantord Sep 30, 2026
71774e5
fix(session): reject an agent-bound restore with no Authority.Ceiling…
kantord Oct 1, 2026
4f7a47c
fix(server): include agent binding in explicit-ID create idempotency …
kantord Oct 1, 2026
6065a11
fix(session): attribute an agent-bound root engine to its resolved pr…
kantord Oct 13, 2026
e12dc99
feat(mecatui): add /agent <name> to start a session bound to an agent…
kantord Oct 1, 2026
61b1fba
feat(mecatui): bare /agent opens a filterable picker of agent definit…
kantord Oct 1, 2026
7dcda05
fix(session): harden agent-bound root composition and lifecycle
kantord Oct 15, 2026
7de20ce
test(session): simplify agent identity regression fixtures
kantord Oct 15, 2026
c704ae8
Merge agent identity backend fixes into the TUI stack
kantord Oct 15, 2026
c4b5b1f
Merge main into agent identity backend
kantord Oct 15, 2026
62cdfcb
Merge refreshed agent identity backend into the TUI stack
kantord Oct 15, 2026
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
features []string
lastCreate *mecatlv1.CreateSessionRequest
Expand All @@ -40,6 +41,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 @@ -1849,6 +1849,18 @@ func (s *sessionAdapter) CreateSessionWithExecution(ctx context.Context, sel cli

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