Skip to content
Draft
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
4 changes: 2 additions & 2 deletions cmd/mecatui/client/guardrails.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type GuardrailCoverageMsg struct {
}

// GuardrailReviewDetail is bounded owner-authorized live display detail.
type GuardrailReviewDetail struct{ ReviewID, Concern, SourceDisplay, NextAction string }
type GuardrailReviewDetail struct{ ReviewID, Concern, SourceDisplay, NextAction, OriginSessionID, ToolCallID string }

// GuardrailReviewDetailMsg carries an asynchronous detail response.
type GuardrailReviewDetailMsg struct {
Expand Down Expand Up @@ -59,7 +59,7 @@ func (c *Client) GetGuardrailReviewDetail(ctx context.Context, sessionID, review
if err != nil {
return GuardrailReviewDetail{}, err
}
return GuardrailReviewDetail{ReviewID: resp.GetReviewId(), Concern: resp.GetConcern(), SourceDisplay: resp.GetSourceDisplay(), NextAction: resp.GetNextAction()}, nil
return GuardrailReviewDetail{ReviewID: resp.GetReviewId(), Concern: resp.GetConcern(), SourceDisplay: resp.GetSourceDisplay(), NextAction: resp.GetNextAction(), OriginSessionID: resp.GetOriginSessionId(), ToolCallID: resp.GetToolCallId()}, nil
}

// GuardrailClient is the TUI's contextual guardrail status surface.
Expand Down
68 changes: 41 additions & 27 deletions cmd/mecatui/client/msgs.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,21 +145,25 @@ type MCPAuthorizationMsg struct {
AuthorizationID string
DisplayName string
CallID string
OriginSessionID string
Status string
}

// PermissionAskMsg opens the approval modal; AskID is the exact correlation key
// echoed back in ResumeApproval. RunID is the opaque exact-run identity used by
// out-of-band run controls.
// out-of-band run controls. CallID is presentation metadata only; for a surfaced
// child ask it remains scoped to that child and does not prove a root attachment.
type PermissionAskMsg struct {
RunID string
AskID string
Tool string
Args string // raw JSON
Reason string
ExpectedRunID string
Guardrail *GuardrailApprovalScope
Recovery bool
RunID string
AskID string
CallID string
OriginSessionID string
Tool string
Args string // raw JSON
Reason string
ExpectedRunID string
Guardrail *GuardrailApprovalScope
Recovery bool
}

// GuardrailApprovalScope distinguishes an outbound action approval from release
Expand Down Expand Up @@ -197,14 +201,21 @@ const (

// HookMsg is an inline hook notice. Beyond the human-readable Text it carries the
// structured Phase (lifecycle point, e.g. "PreToolUse"), the related Tool (for
// per-tool phases), and the Decision (info/blocked/modified/advisory) so the ui can
// render it distinctly from a compaction notice and colour a blocked or advisory hook.
// per-tool phases), the exact CallID, and the event's RunID/Seq identity. Decision
// (info/blocked/modified/advisory) lets the ui render it distinctly from a compaction
// notice and colour a blocked or advisory hook.
type HookMsg struct {
Text string
Phase string
Tool string
Decision HookDecision
Guardrail *GuardrailReview
Text string
Phase string
Tool string
CallID string
HookID string
Revision uint64
OriginSessionID string
RunID string
Seq int64
Decision HookDecision
Guardrail *GuardrailReview
}

// GuardrailReview is the machine-only durable review projection. Human rationale
Expand Down Expand Up @@ -758,10 +769,11 @@ type LiveReconnectedMsg struct{}
// only by the replay (log-only on the live wire). Metadata-only (gauntlet #7):
// tool name + typed verdict + ask ID + call ID. NEVER raw args.
type ApprovalMsg struct {
AskID string
Verdict string
Tool string
CallID string
AskID string
Verdict string
Tool string
CallID string
OriginSessionID, ReviewID string
}

// UserPromptMsg is the recorded user message (EvUserPrompt), relayed only by the
Expand Down Expand Up @@ -1267,7 +1279,7 @@ func EventToMsg(ev *mecatlv1.Event) tea.Msg {

func permissionAskMsg(ev *mecatlv1.Event) PermissionAskMsg {
a := ev.GetAsk()
msg := PermissionAskMsg{RunID: ev.GetRunId(), AskID: a.GetAskId(), Tool: a.GetTool(), Args: a.GetArgs(), Reason: a.GetReason(), ExpectedRunID: ev.GetRunId()}
msg := PermissionAskMsg{RunID: ev.GetRunId(), AskID: a.GetAskId(), CallID: a.GetCallId(), OriginSessionID: a.GetOriginSessionId(), Tool: a.GetTool(), Args: a.GetArgs(), Reason: a.GetReason(), ExpectedRunID: ev.GetRunId()}
if scope := a.GetGuardrail(); scope != nil {
kind := string(SessionKindUnknown)
switch scope.GetKind() {
Expand All @@ -1283,7 +1295,7 @@ func permissionAskMsg(ev *mecatlv1.Event) PermissionAskMsg {

func hookMsg(ev *mecatlv1.Event) HookMsg {
h := ev.GetHook()
msg := HookMsg{Text: ev.GetText(), Phase: h.GetPhase(), Tool: h.GetTool(), Decision: hookDecisionFrom(h.GetDecision())}
msg := HookMsg{Text: ev.GetText(), Phase: h.GetPhase(), Tool: h.GetTool(), CallID: h.GetCallId(), HookID: h.GetHookId(), Revision: h.GetRevision(), OriginSessionID: h.GetOriginSessionId(), RunID: ev.GetRunId(), Seq: ev.GetSeq(), Decision: hookDecisionFrom(h.GetDecision())}
if review := h.GetGuardrail(); review != nil {
machine := &GuardrailReview{ReviewID: review.GetReviewId(), Job: guardrailJob(review.GetJob()), Assessment: guardrailAssessment(review.GetAssessment()), Inspection: guardrailInspection(review.GetInspection()), Disposition: guardrailDisposition(review.GetDisposition()), ReasonCode: review.GetReasonCode(), RuleID: review.GetRuleId(), RuleOrigin: review.GetRuleOrigin(), CheckerProviderID: review.GetCheckerProviderId(), CheckerModelID: review.GetCheckerModelId()}
for _, ref := range review.GetConcerns() {
Expand Down Expand Up @@ -1336,7 +1348,7 @@ func advisoryEventToMsg(ev *mecatlv1.Event) tea.Msg {
return steerOutcomeMsg(ev.GetSteerOutcome())
case "authorization.required", "authorization.resolved":
a := ev.GetAuthorization()
return MCPAuthorizationMsg{AuthorizationID: a.GetAuthorizationId(), DisplayName: a.GetDisplayName(), CallID: a.GetCallId(), Status: a.GetStatus()}
return MCPAuthorizationMsg{AuthorizationID: a.GetAuthorizationId(), DisplayName: a.GetDisplayName(), CallID: a.GetCallId(), OriginSessionID: a.GetOriginSessionId(), Status: a.GetStatus()}
default:
return nil
}
Expand Down Expand Up @@ -1469,10 +1481,12 @@ func contentPartsFromProto(in []*mecatlv1.Content) []ContentBlock {
// log-only "approval" event kind.
func approvalMsg(a *mecatlv1.Approval) ApprovalMsg {
return ApprovalMsg{
AskID: a.GetAskId(),
Verdict: approvalVerdictString(a.GetVerdict()),
Tool: a.GetTool(),
CallID: a.GetCallId(),
AskID: a.GetAskId(),
Verdict: approvalVerdictString(a.GetVerdict()),
Tool: a.GetTool(),
CallID: a.GetCallId(),
OriginSessionID: a.GetOriginSessionId(),
ReviewID: a.GetReviewId(),
}
}

Expand Down
48 changes: 48 additions & 0 deletions cmd/mecatui/client/msgs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,54 @@ func TestRoutingDecisionProjectionPreservesOptionalPresence(t *testing.T) {
}
}

func TestHookAndPermissionAskCorrelationIdentity(t *testing.T) {
callID := "call:/opaque?=value"
childCallID := "child-call:/opaque?=value"
for _, tc := range []struct {
name string
ev *mecatlv1.Event
want tea.Msg
}{
{
name: "hook preserves call and event identity",
ev: &mecatlv1.Event{Type: "hook", RunId: "root-run", Seq: 42, Text: "checked", Hook: &mecatlv1.Hook{
CallId: callID, HookId: "review_1", Revision: 7, OriginSessionId: "root",
}},
want: HookMsg{Text: "checked", CallID: callID, HookID: "review_1", Revision: 7, OriginSessionID: "root", RunID: "root-run", Seq: 42, Decision: HookInfo},
},
{
name: "older server hook omits correlation identity",
ev: &mecatlv1.Event{Type: "hook", Text: "checked", Hook: &mecatlv1.Hook{}},
want: HookMsg{Text: "checked", Decision: HookInfo},
},
{
name: "permission ask preserves call identity",
ev: &mecatlv1.Event{Type: "permission.ask", RunId: "root-run", Ask: &mecatlv1.PermissionAsk{
AskId: "ask-1", CallId: &callID, OriginSessionId: "root",
}},
want: PermissionAskMsg{RunID: "root-run", ExpectedRunID: "root-run", AskID: "ask-1", CallID: callID, OriginSessionID: "root"},
},
{
name: "older server permission ask omits call identity",
ev: &mecatlv1.Event{Type: "permission.ask", Ask: &mecatlv1.PermissionAsk{AskId: "ask-1"}},
want: PermissionAskMsg{AskID: "ask-1"},
},
{
name: "surfaced child ask retains child call identity",
ev: &mecatlv1.Event{Type: "permission.ask", RunId: "child-run", Ask: &mecatlv1.PermissionAsk{
AskId: "child-ask", CallId: &childCallID, OriginSessionId: "child",
}},
want: PermissionAskMsg{RunID: "child-run", ExpectedRunID: "child-run", AskID: "child-ask", CallID: childCallID, OriginSessionID: "child"},
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := EventToMsg(tc.ev); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("EventToMsg() = %#v, want %#v", got, tc.want)
}
})
}
}

// TestEventToMsg covers the mapper over every documented event type, asserting
// both the msg variant and a representative carried field. This is the single
// translation point between proto and the ui model, so it gets exhaustive
Expand Down
34 changes: 20 additions & 14 deletions cmd/mecatui/client/pendingapproval.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ import (
// complete durable replay. Cursor remains scoped to the unfiltered watch that
// issued it; continuation filtering is therefore performed locally by RunID.
type PendingApproval struct {
SessionID string
RunID string
AskID string
Tool string
Args string
Reason string
Cursor string
SessionID string
OriginSessionID string
ToolCallID string
ReviewID string
RunID string
AskID string
Tool string
Args string
Reason string
Cursor string
}

// PendingApprovalFailure is a closed, proto-free refusal category.
Expand Down Expand Up @@ -262,13 +265,16 @@ func approvalFromEvent(sessionID, cursor string, ev *mecatlv1.Event) (PendingApp
return PendingApproval{}, false, pendingApprovalFailure(PendingApprovalMalformed)
}
approval := PendingApproval{
SessionID: sessionID,
RunID: ev.GetRunId(),
AskID: ask.GetAskId(),
Tool: ask.GetTool(),
Args: ask.GetArgs(),
Reason: ask.GetReason(),
Cursor: cursor,
SessionID: sessionID,
OriginSessionID: ask.GetOriginSessionId(),
ToolCallID: ask.GetCallId(),
ReviewID: ask.GetGuardrail().GetReviewId(),
RunID: ev.GetRunId(),
AskID: ask.GetAskId(),
Tool: ask.GetTool(),
Args: ask.GetArgs(),
Reason: ask.GetReason(),
Cursor: cursor,
}
return approval, ask.GetGuardrail() == nil && ask.GetTool() != "PresentPlan", nil
}
Expand Down
16 changes: 16 additions & 0 deletions cmd/mecatui/client/public_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package client

import (
"testing"

mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1"
)

func TestPendingApprovalExplicitOrigin(t *testing.T) {
call := "tc_child"
ev := &mecatlv1.Event{Type: "permission.ask", RunId: "root-run", Ask: &mecatlv1.PermissionAsk{AskId: "unchanged-key", Tool: "Shell", Args: `{}`, CallId: &call, OriginSessionId: "child", Guardrail: &mecatlv1.GuardrailApprovalScope{ReviewId: "review_tc_child_action"}}}
candidate, ordinary, err := approvalFromEvent("root", "cursor", ev)
if err != nil || ordinary || candidate.SessionID != "root" || candidate.OriginSessionID != "child" || candidate.ToolCallID != call || candidate.ReviewID != "review_tc_child_action" || candidate.AskID != "unchanged-key" {
t.Fatalf("recovered ask = %+v, ordinary=%v, error=%v", candidate, ordinary, err)
}
}
Loading
Loading