Use current approval state in Assistant handoffs

This commit is contained in:
rcourtman
2026-05-07 13:09:42 +01:00
parent ead83d19ba
commit a3973f19ee
6 changed files with 162 additions and 5 deletions
@@ -279,7 +279,11 @@ runtime cost control, and shared AI transport surfaces.
and action counts, a primary resource label, last-known approval/action
status, risk level, and timestamp, but it must not expose model-only
handoff text, action preflight/result bodies, remediation descriptions, raw
commands, or approval command payloads.
commands, or approval command payloads. Its `requires_approval` field is a
current operator-decision flag only: pending approval states may set it, but
approved, denied, rejected, executing, completed, failed, expired, or
otherwise historical action references must remain action context without
being relabeled as requiring approval.
When the Assistant drawer restores any session from that `handoff_summary`,
it must restore the scoped request-local approval boundary as well as the
safe visible briefing: the next chat turn must carry
@@ -186,7 +186,10 @@ work extends shared components instead of creating new local variants.
`handoffContext`, `handoffResources`, and `handoffActions` while preserving
the safe visible briefing and scoped approval-required posture, so later
turns rely on backend session hydration instead of resending stale browser
context.
context. The drawer must treat `handoff_summary.requires_approval` as a
current pending-decision flag, not a historical action marker, so completed
or rejected handoff actions render as action context rather than pending
approval.
9. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `unified-resources`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary.
10. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `unified-resources`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary.
That shared boundary must preserve `availability` as the agentless
@@ -700,6 +700,67 @@ describe('AIChat', () => {
expect(restoredContext.handoffActions).toBeUndefined();
});
it('shows completed Patrol actions as action context instead of pending approval', async () => {
mockAIChatAPI.listSessions.mockResolvedValue([
{
id: 's-patrol-complete',
title: 'Completed remediation follow-up',
created_at: '',
updated_at: '',
message_count: 6,
handoff_summary: {
kind: 'patrol_finding',
finding_id: 'finding-complete',
has_model_context: true,
resource_count: 1,
primary_resource: {
id: 'host:web-server',
name: 'web-server',
type: 'host',
node: 'pve-1',
},
action_count: 1,
requires_approval: false,
last_known_approval_status: 'approved',
last_known_action_state: 'completed',
last_known_action_risk: 'medium',
},
},
]);
renderChat();
await waitFor(() => {
expect(mockAIChatAPI.listSessions).toHaveBeenCalled();
});
fireEvent.click(screen.getByTitle('Pulse Assistant sessions'));
await waitFor(() => {
expect(screen.getByText('Pulse Patrol')).toBeInTheDocument();
expect(screen.getByText('Action context')).toBeInTheDocument();
expect(screen.getByText(/approval approved/)).toBeInTheDocument();
expect(screen.queryByText('Approval required')).not.toBeInTheDocument();
});
fireEvent.click(screen.getByText('Completed remediation follow-up'));
await waitFor(() => {
expect(mockAiChatStore.setContext).toHaveBeenCalledWith(
expect.objectContaining({
findingId: 'finding-complete',
autonomousMode: false,
context: expect.objectContaining({
requiresApproval: false,
lastKnownActionState: 'completed',
}),
briefing: expect.objectContaining({
actionLabel: 'Governed action context',
commandSummary: expect.stringContaining('action completed'),
}),
}),
);
});
});
it('keeps restored Patrol handoffs approval-bound without queued actions', async () => {
mockAIAPI.getSettings.mockResolvedValue({
model: 'gpt-4',
@@ -268,8 +268,8 @@ func TestService_ListSessionsRefreshesHandoffActionSummary(t *testing.T) {
if summary.LastKnownActionState != string(unifiedresources.ActionStateCompleted) {
t.Fatalf("action state = %q, want completed", summary.LastKnownActionState)
}
if !summary.RequiresApproval || summary.ActionCount != 1 {
t.Fatalf("action summary = %#v, want approval-required action context", summary)
if summary.RequiresApproval || summary.ActionCount != 1 {
t.Fatalf("action summary = %#v, want completed action context without approval requirement", summary)
}
actions, err := store.GetModelHandoffActions(session.ID)
+20 -1
View File
@@ -151,6 +151,25 @@ const (
sessionHandoffKindScopedContext = "scoped_context"
)
func handoffActionCurrentlyRequiresApproval(action HandoffAction) bool {
approvalStatus := strings.ToLower(strings.TrimSpace(action.ApprovalStatus))
actionState := strings.ToLower(strings.TrimSpace(action.ActionState))
if approvalStatus == "pending" && !action.ApprovalConsumed {
return true
}
switch actionState {
case "pending_approval", "awaiting_approval":
return approvalStatus == "" || approvalStatus == "pending"
case "approved", "rejected", "executing", "completed", "failed", "planned":
return false
}
if approvalStatus != "" || strings.TrimSpace(action.ApprovalID) != "" {
return false
}
return action.ActionRequiresApproval
}
func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHandoffSummary {
if modelContextEmpty(modelContext) {
return nil
@@ -189,7 +208,7 @@ func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHando
summary.PrimaryResource = &primaryResource
}
for _, action := range actions {
if !summary.RequiresApproval && (action.ActionRequiresApproval || strings.TrimSpace(action.ApprovalID) != "") {
if !summary.RequiresApproval && handoffActionCurrentlyRequiresApproval(action) {
summary.RequiresApproval = true
}
if summary.LastKnownApprovalStatus == "" {
@@ -419,6 +419,76 @@ func TestSessionStore_ListIncludesSafeHandoffSummary(t *testing.T) {
}
}
func TestModelContextHandoffSummaryRequiresApprovalOnlyForCurrentPendingApproval(t *testing.T) {
tests := []struct {
name string
action HandoffAction
requiresApproval bool
}{
{
name: "pending approval",
action: HandoffAction{
ApprovalID: "approval-pending",
ApprovalStatus: "pending",
ActionState: "pending_approval",
ActionRequiresApproval: true,
},
requiresApproval: true,
},
{
name: "legacy awaiting approval state",
action: HandoffAction{
ApprovalID: "approval-awaiting",
ActionState: "awaiting_approval",
ActionRequiresApproval: true,
},
requiresApproval: true,
},
{
name: "approved completed action",
action: HandoffAction{
ApprovalID: "approval-approved",
ApprovalStatus: "approved",
ActionState: "completed",
ActionRequiresApproval: true,
},
requiresApproval: false,
},
{
name: "denied historical action",
action: HandoffAction{
ApprovalID: "approval-denied",
ApprovalStatus: "denied",
ActionState: "rejected",
ActionRequiresApproval: true,
},
requiresApproval: false,
},
{
name: "approval reference without current pending state",
action: HandoffAction{
ApprovalID: "approval-unknown",
ActionRequiresApproval: true,
},
requiresApproval: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary := modelContextHandoffSummary(&sessionModelContext{
HandoffActions: []HandoffAction{tt.action},
})
if summary == nil {
t.Fatalf("summary is nil")
}
if summary.RequiresApproval != tt.requiresApproval {
t.Fatalf("requires approval = %v, want %v; summary=%#v", summary.RequiresApproval, tt.requiresApproval, summary)
}
})
}
}
func TestSessionStore_ClearModelHandoffContext(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {