mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Use current approval state in Assistant handoffs
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user