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
11 changes: 11 additions & 0 deletions cmd/mecatui/client/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ type Capabilities struct {
// applies its auto/yolo posture process-wide whatever the session mode, so the
// ui must not present a session mode as the effective permission mode there.
SessionPermissionModes bool
// RefusedModeRecovery reports the refused_mode_recovery feature: a stored
// session parked on an approval in a mode the server no longer admits
// accepts SetMode to an admitted, strictly lower mode, which cancels the
// pending turn without running it. An older server refuses that SetMode
// mid-turn.
RefusedModeRecovery bool
// ProjectTrust is "trusted" or "untrusted" for the server's launch
// workspace, CHROME ONLY: the ui's header badge. Empty on an older server.
ProjectTrust string
Expand Down Expand Up @@ -124,11 +130,16 @@ func capabilitiesWithSessionMediaFrom(caps Capabilities, sessionCaps *mecatlv1.S
// session-scoped permission modes.
const SessionPermissionModesFeature = "session_permission_modes"

// RefusedModeRecoveryFeature is the compatibility feature identifier for the
// SetMode recovery of a stored approval parked in a refused mode.
const RefusedModeRecoveryFeature = "refused_mode_recovery"

// compatibilityCapabilitiesFrom maps a compatibility response: the capability
// message plus the feature bits the ui consumes.
func compatibilityCapabilitiesFrom(resp *mecatlv1.GetCompatibilityInfoResponse) Capabilities {
caps := capabilitiesFrom(resp.GetCapabilities())
caps.SessionPermissionModes = slices.Contains(resp.GetFeatures(), SessionPermissionModesFeature)
caps.RefusedModeRecovery = slices.Contains(resp.GetFeatures(), RefusedModeRecoveryFeature)
return caps
}

Expand Down
12 changes: 10 additions & 2 deletions cmd/mecatui/client/mcp_authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,20 @@ const MCPAuthorizationPendingCode = "mcp_authorization_pending"
// deliberately ignores status text so server wording cannot change the UI flow.
func IsMCPAuthorizationPending(err error) bool {
st, ok := status.FromError(err)
if !ok || st.Code() != codes.FailedPrecondition {
return ok && st.Code() == codes.FailedPrecondition && hasErrorReason(err, MCPAuthorizationPendingCode)
}

// hasErrorReason reports whether err carries the server's stable application
// code as a mecatl ErrorInfo reason. It ignores status text so server wording
// cannot change the UI flow.
func hasErrorReason(err error, code string) bool {
st, ok := status.FromError(err)
if !ok {
return false
}
for _, detail := range st.Details() {
info, ok := detail.(*errdetails.ErrorInfo)
if ok && info.GetDomain() == "mecatl.stacklok.com" && info.GetReason() == MCPAuthorizationPendingCode {
if ok && info.GetDomain() == "mecatl.stacklok.com" && info.GetReason() == code {
return true
}
}
Expand Down
25 changes: 25 additions & 0 deletions cmd/mecatui/client/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,31 @@ func TestCreateSessionReadsSessionPermissionModesFeature(t *testing.T) {
}
}

// TestCreateSessionReadsRefusedModeRecoveryFeature pins that only the
// refused_mode_recovery feature lets the ui offer a mode change from a
// recovered pending approval.
func TestCreateSessionReadsRefusedModeRecoveryFeature(t *testing.T) {
for _, tc := range []struct {
name string
features []string
want bool
}{
{name: "server without the feature", features: []string{"server_info", SessionPermissionModesFeature}, want: false},
{name: "server advertising the feature", features: []string{"server_info", RefusedModeRecoveryFeature}, want: true},
} {
t.Run(tc.name, func(t *testing.T) {
fake := &fakeModelsClient{caps: &mecatlv1.ServerCapabilities{Posture: "trusted"}, features: tc.features}
_, caps, _, err := newFakeClient(fake).CreateSession(context.Background(), mecatlv1.PermissionMode_PERMISSION_MODE_DEFAULT, ModelSelection{})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if caps.RefusedModeRecovery != tc.want {
t.Fatalf("capabilities refusedModeRecovery=%v, want %v", caps.RefusedModeRecovery, tc.want)
}
})
}
}

// TestReasoningEffortIsZero asserts an effort-only selection is NOT zero (the client
// must still send it) while a fully-empty selection is.
func TestReasoningEffortIsZero(t *testing.T) {
Expand Down
35 changes: 33 additions & 2 deletions cmd/mecatui/client/pendingapproval.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,43 @@ const (
PendingApprovalWatchUnsupported PendingApprovalFailure = "watch_unsupported"
PendingApprovalControlsUnsupported PendingApprovalFailure = "controls_unsupported"
PendingApprovalNotRecoverable PendingApprovalFailure = "not_recoverable"
PendingApprovalModeNotAdmitted PendingApprovalFailure = "mode_not_admitted"
PendingApprovalCorrelation PendingApprovalFailure = "correlation_mismatch"
PendingApprovalUnavailable PendingApprovalFailure = "unavailable"
)

type pendingApprovalError struct {
kind PendingApprovalFailure
text string
kind PendingApprovalFailure
text string
detail string
}

func (e *pendingApprovalError) Error() string { return e.text }

// StoredModeNotAdmittedCode is the stable server application code for a
// session whose stored permission mode the server no longer admits.
const StoredModeNotAdmittedCode = "session_mode_not_admitted"

// PendingApprovalFailureDetail returns the server's reason for a
// PendingApprovalModeNotAdmitted control refusal, or "" for every other error.
// Only that category keeps it: the server is explaining why the exact ask
// cannot resume and how SetMode recovers it, which the user needs in order to
// recover.
func PendingApprovalFailureDetail(err error) string {
var target *pendingApprovalError
if errors.As(err, &target) && target.kind == PendingApprovalModeNotAdmitted {
return target.detail
}
return ""
}

// PendingApprovalModeNotAdmittedError is the PendingApprovalModeNotAdmitted
// refusal carrying the server's reason, which PendingApprovalFailureDetail
// returns.
func PendingApprovalModeNotAdmittedError(detail string) error {
return &pendingApprovalError{kind: PendingApprovalModeNotAdmitted, text: pendingApprovalFailure(PendingApprovalModeNotAdmitted).Error(), detail: detail}
}

// IsPendingApprovalFailure reports a closed recovery refusal without exposing a
// server error or tool arguments to presentation code.
func IsPendingApprovalFailure(err error, kind PendingApprovalFailure) bool {
Expand All @@ -74,6 +100,8 @@ func pendingApprovalFailure(kind PendingApprovalFailure) error {
text = "pending approval recovery controls are not supported"
case PendingApprovalNotRecoverable:
text = "pending approval is no longer recoverable because its current state changed"
case PendingApprovalModeNotAdmitted:
text = "pending approval cannot resume in the session's stored permission mode"
case PendingApprovalCorrelation:
text = "pending approval recovery correlation changed"
}
Expand All @@ -85,6 +113,9 @@ func pendingApprovalControlFailure(err error) error {
case codes.Aborted:
return pendingApprovalFailure(PendingApprovalCorrelation)
case codes.FailedPrecondition:
if hasErrorReason(err, StoredModeNotAdmittedCode) {
return PendingApprovalModeNotAdmittedError(status.Convert(err).Message())
}
return pendingApprovalFailure(PendingApprovalNotRecoverable)
case codes.Unimplemented:
return pendingApprovalFailure(PendingApprovalControlsUnsupported)
Expand Down
42 changes: 32 additions & 10 deletions cmd/mecatui/client/pendingapproval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"
"time"

"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
Expand Down Expand Up @@ -399,21 +400,31 @@ func TestResolvePendingApprovalChecksAckAndPayload(t *testing.T) {

func TestPendingApprovalControlsClassifyClosedRPCFailures(t *testing.T) {
tests := []struct {
name string
code codes.Code
kind PendingApprovalFailure
name string
code codes.Code
reason string
kind PendingApprovalFailure
}{
{"stale run", codes.Aborted, PendingApprovalCorrelation},
{"state changed", codes.FailedPrecondition, PendingApprovalNotRecoverable},
{"unsupported", codes.Unimplemented, PendingApprovalControlsUnsupported},
{"transport", codes.Unavailable, PendingApprovalUnavailable},
{"deadline", codes.DeadlineExceeded, PendingApprovalUnavailable},
{"masked absence", codes.NotFound, PendingApprovalUnavailable},
{"masked denial", codes.PermissionDenied, PendingApprovalUnavailable},
{"stale run", codes.Aborted, "", PendingApprovalCorrelation},
{"state changed", codes.FailedPrecondition, "", PendingApprovalNotRecoverable},
{"ask already resolved", codes.FailedPrecondition, "ask_not_pending", PendingApprovalNotRecoverable},
{"stored mode not admitted", codes.FailedPrecondition, StoredModeNotAdmittedCode, PendingApprovalModeNotAdmitted},
{"unsupported", codes.Unimplemented, "", PendingApprovalControlsUnsupported},
{"transport", codes.Unavailable, "", PendingApprovalUnavailable},
{"deadline", codes.DeadlineExceeded, "", PendingApprovalUnavailable},
{"masked absence", codes.NotFound, "", PendingApprovalUnavailable},
{"masked denial", codes.PermissionDenied, "", PendingApprovalUnavailable},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
serverErr := status.Error(tt.code, "private backend detail")
if tt.reason != "" {
st, err := status.New(tt.code, "private backend detail").WithDetails(&errdetails.ErrorInfo{Reason: tt.reason, Domain: "mecatl.stacklok.com"})
if err != nil {
t.Fatal(err)
}
serverErr = st.Err()
}
service := &pendingApprovalWireServer{resolveErr: serverErr, cancelErr: serverErr}
client := newPendingApprovalWireClient(t, service)
approval := PendingApproval{SessionID: "session-1", RunID: "run-1", AskID: "ask-1"}
Expand All @@ -424,6 +435,17 @@ func TestPendingApprovalControlsClassifyClosedRPCFailures(t *testing.T) {
if !IsPendingApprovalFailure(err, tt.kind) || err.Error() == "private backend detail" {
t.Fatalf("%s error = %v, want kind %q", name, err, tt.kind)
}
// Only the stored-mode refusal keeps the server's reason, behind
// an explicit accessor, so the user can see why the ask cannot
// resume and how to recover it. Any other FailedPrecondition stays
// a closed category with no server text.
wantDetail := ""
if tt.kind == PendingApprovalModeNotAdmitted {
wantDetail = "private backend detail"
}
if got := PendingApprovalFailureDetail(err); got != wantDetail {
t.Fatalf("%s detail = %q, want %q", name, got, wantDetail)
}
}
})
}
Expand Down
27 changes: 19 additions & 8 deletions cmd/mecatui/client/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,24 @@ func (c *Client) GetSession(ctx context.Context, id string) (SessionSnapshot, er
return snapshotFromWithGlobalCapabilities(resp.GetSession(), global), nil
}

// ModeResult is the authoritative session a SetMode returned: its mode and
// lifecycle state. State is "cancelled" when the server's refused-mode
// recovery cancelled a pending approval to apply the mode.
type ModeResult struct {
Mode string
State string
}

// SetMode asks the server to change the session's permission posture and returns
// the updated authoritative mode. Mid-turn changes are rejected by the server;
// callers that want next-prompt semantics should defer and retry once idle.
func (c *Client) SetMode(ctx context.Context, id, mode string) (string, error) {
// the updated authoritative mode and state. Mid-turn changes are rejected by the
// server; callers that want next-prompt semantics should defer and retry once idle.
func (c *Client) SetMode(ctx context.Context, id, mode string) (ModeResult, error) {
resp, err := c.svc.SetMode(withSessionAffinity(ctx, id), &mecatlv1.SetModeRequest{SessionId: id, Mode: ModeFromString(mode)})
if err != nil {
return "", fmt.Errorf("set mode: %w", err)
return ModeResult{}, fmt.Errorf("set mode: %w", err)
}
return snapshotFrom(resp.GetSession()).Mode, nil
snap := snapshotFrom(resp.GetSession())
return ModeResult{Mode: snap.Mode, State: snap.State}, nil
}

// ModeErrorText is the server's reason for a failed SetMode, without the
Expand Down Expand Up @@ -226,22 +235,24 @@ type SessionGetter interface {

// ModeSetter changes a server-side session's permission mode.
type ModeSetter interface {
SetMode(ctx context.Context, id, mode string) (string, error)
SetMode(ctx context.Context, id, mode string) (ModeResult, error)
}

// ModeChangedMsg carries the async result of SetModeCmd.
// ModeChangedMsg carries the async result of SetModeCmd. State is the
// session's authoritative lifecycle state after a successful change.
type ModeChangedMsg struct {
SessionID string
Requested string
Mode string
State string
Err error
}

// SetModeCmd asks the server to change a session's permission mode off the update goroutine.
func SetModeCmd(ctx context.Context, s ModeSetter, id, mode string) tea.Cmd {
return func() tea.Msg {
actual, err := s.SetMode(ctx, id, mode)
return ModeChangedMsg{SessionID: id, Requested: mode, Mode: actual, Err: err}
return ModeChangedMsg{SessionID: id, Requested: mode, Mode: actual.Mode, State: actual.State, Err: err}
}
}

Expand Down
27 changes: 27 additions & 0 deletions cmd/mecatui/client/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ type sessionCapabilitiesServer struct {
globalErr error
snapshot *mecatlv1.Session
sessionMedia *mecatlv1.SessionCapabilities
setModeReq *mecatlv1.SetModeRequest
}

func (s *sessionCapabilitiesServer) SetMode(_ context.Context, req *mecatlv1.SetModeRequest) (*mecatlv1.SetModeResponse, error) {
s.setModeReq = req
return &mecatlv1.SetModeResponse{Session: s.snapshot}, nil
}

func (s *sessionCapabilitiesServer) GetCompatibilityInfo(context.Context, *mecatlv1.GetCompatibilityInfoRequest) (*mecatlv1.GetCompatibilityInfoResponse, error) {
Expand Down Expand Up @@ -117,6 +123,27 @@ func TestGetSessionCapabilitiesFallsBackWhenCompatibilityInfoIsUnavailable(t *te
}
}

// TestSetModeReturnsServerModeAndState pins the SetMode projection the
// refused-mode recovery relies on: the server's resulting state (cancelled when
// the pending turn was dropped without running) reaches the caller alongside
// the authoritative mode.
func TestSetModeReturnsServerModeAndState(t *testing.T) {
server := &sessionCapabilitiesServer{snapshot: &mecatlv1.Session{
SessionId: "s", Mode: mecatlv1.PermissionMode_PERMISSION_MODE_ACCEPT_EDITS, State: "cancelled",
}}
cl := newSessionCapabilitiesClient(t, server)
got, err := cl.SetMode(t.Context(), "s", ModeDefaultString)
if err != nil {
t.Fatal(err)
}
if want := (ModeResult{Mode: ModeString(mecatlv1.PermissionMode_PERMISSION_MODE_ACCEPT_EDITS), State: "cancelled"}); got != want {
t.Fatalf("SetMode result = %+v, want %+v", got, want)
}
if req := server.setModeReq; req.GetSessionId() != "s" || req.GetMode() != mecatlv1.PermissionMode_PERMISSION_MODE_DEFAULT {
t.Fatalf("SetMode request = %+v", req)
}
}

// TestSnapshotFromReadsState asserts snapshotFrom projects the proto Session's
// State field (issue #245) — the picker row needs it to render an
// "open existing session" affordance. Covers the populated and nil cases.
Expand Down
4 changes: 2 additions & 2 deletions cmd/mecatui/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ func (*sessionsLaunchCreator) GetSession(context.Context, string) (client.Sessio
return client.SessionSnapshot{}, nil
}

func (*sessionsLaunchCreator) SetMode(context.Context, string, string) (string, error) {
return "", nil
func (*sessionsLaunchCreator) SetMode(context.Context, string, string) (client.ModeResult, error) {
return client.ModeResult{}, nil
}

func (*sessionsLaunchCreator) ForkSession(context.Context, string, string) (string, error) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecatui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1880,7 +1880,7 @@ func (s *sessionAdapter) GetSession(ctx context.Context, id string) (client.Sess
return s.cl.GetSession(ctx, id)
}

func (s *sessionAdapter) SetMode(ctx context.Context, id, mode string) (string, error) {
func (s *sessionAdapter) SetMode(ctx context.Context, id, mode string) (client.ModeResult, error) {
return s.cl.SetMode(ctx, id, mode)
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/mecatui/sessionadapter/sessionadapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type Client interface {
CreateSessionWithCarryover(context.Context, client.ModelSelection, string) (string, client.Capabilities, client.ResolvedModel, error)
CloseSession(context.Context, string) error
GetSession(context.Context, string) (client.SessionSnapshot, error)
SetMode(context.Context, string, string) (string, error)
SetMode(context.Context, string, string) (client.ModeResult, error)
ForkSession(context.Context, string, string, string) (string, error)
}

Expand Down Expand Up @@ -88,7 +88,7 @@ func (s *Adapter) GetSession(ctx context.Context, id string) (client.SessionSnap
}

// SetMode changes a session's permission mode.
func (s *Adapter) SetMode(ctx context.Context, id, mode string) (string, error) {
func (s *Adapter) SetMode(ctx context.Context, id, mode string) (client.ModeResult, error) {
return s.cl.SetMode(ctx, id, mode)
}

Expand Down
16 changes: 11 additions & 5 deletions cmd/mecatui/ui/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,10 @@ type fakeConv struct {
mode string
setModeErr error
setModeSeen []string
created chan struct{}
createdOnce sync.Once
// setModeState is the session state SetMode reports; empty means "idle".
setModeState string
created chan struct{}
createdOnce sync.Once
// resolvedModel is the EFFECTIVE model the fake's create response echoes back —
// the header e2e asserts it lands in m.resolvedSessionModel and renders from turn zero.
resolvedModel client.ResolvedModel
Expand Down Expand Up @@ -339,15 +341,19 @@ func (c *fakeConv) GetSession(_ context.Context, id string) (client.SessionSnaps
return client.SessionSnapshot{Mode: mode, ResolvedModel: resolved, Title: c.getSessionTitle, Capabilities: c.getSessionCaps}, nil
}

func (c *fakeConv) SetMode(_ context.Context, _ string, mode string) (string, error) {
func (c *fakeConv) SetMode(_ context.Context, _ string, mode string) (client.ModeResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.setModeSeen = append(c.setModeSeen, mode)
if c.setModeErr != nil {
return "", c.setModeErr
return client.ModeResult{}, c.setModeErr
}
c.mode = mode
return c.mode, nil
state := c.setModeState
if state == "" {
state = "idle"
}
return client.ModeResult{Mode: c.mode, State: state}, nil
}

func (c *fakeConv) setModes() []string {
Expand Down
1 change: 1 addition & 0 deletions cmd/mecatui/ui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ func helpBodyFor(th theme.Theme, caps client.Capabilities, hk helpKeys, embedded
{key: "←/→/tab", action: "choose an action; enter confirms it"},
{key: hk.toolcallsFull, action: "open full approval details when available"},
{key: hk.rawArgs, action: "show raw arguments in the full view"},
{key: hk.modeSwitch, action: "resumed request: switch to a lower mode, cancelling the request without running it", available: caps.RefusedModeRecovery, gated: true},
})

b.WriteString("\n" + heading.Render("Inspect and manage") + "\n")
Expand Down
Loading
Loading