Apply request approval mode to chat executor

This commit is contained in:
rcourtman
2026-05-07 01:37:30 +01:00
parent 2c913fac8e
commit c389e403ca
5 changed files with 135 additions and 0 deletions
@@ -271,6 +271,10 @@ runtime cost control, and shared AI transport surfaces.
backend/API execution boundary, does not mutate the user's persistent AI
control setting, and prevents product-originated Patrol action context from
becoming silent command authority.
The chat runtime must apply any request-local autonomous-mode override to
both the per-request `AgenticLoop` and the cloned `PulseToolExecutor`;
persistent autonomous settings must not leak into scoped approval-required
handoffs through executor state.
The Assistant drawer may also render an attached context briefing for that
handoff, but the briefing is runtime context visibility only: it must not
mutate chat control settings, execute tools, or reveal raw command payloads.
+3
View File
@@ -568,6 +568,9 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac
if req.AutonomousMode != nil {
autonomousMode = *req.AutonomousMode
}
if executor != nil {
executor.SetAutonomousMode(autonomousMode)
}
selectedModel = configuredModel
if overrideModel != "" {
selectedModel = overrideModel
@@ -3,9 +3,11 @@ package chat
import (
"context"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
@@ -19,6 +21,27 @@ type stubServiceProvider struct {
streamFn func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error
}
type recordingAgentServer struct {
calls atomic.Int32
}
func (s *recordingAgentServer) GetConnectedAgents() []agentexec.ConnectedAgent {
return []agentexec.ConnectedAgent{{
AgentID: "agent-1",
Hostname: "node-1",
}}
}
func (s *recordingAgentServer) ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) {
s.calls.Add(1)
return &agentexec.CommandResultPayload{
RequestID: cmd.RequestID,
Success: true,
Stdout: "executed",
ExitCode: 0,
}, nil
}
type handoffUnifiedProvider struct {
resources map[unifiedresources.ResourceType][]unifiedresources.Resource
}
@@ -182,6 +205,100 @@ func TestService_ExecuteStream_Success(t *testing.T) {
}
}
func TestService_ExecuteStream_RequestAutonomousOverrideUpdatesToolExecutor(t *testing.T) {
installTestApprovalStore(t, nil)
tmpDir := t.TempDir()
store, err := NewSessionStore(tmpDir)
if err != nil {
t.Fatalf("failed to create session store: %v", err)
}
store.GetSessionFSM("sess-request-override").State = StateReading
agentServer := &recordingAgentServer{}
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{
Policy: agentexec.DefaultPolicy(),
AgentServer: agentServer,
ControlLevel: tools.ControlLevelAutonomous,
})
executor.SetContext("agent", "agent-1", true)
providerCallCount := 0
provider := &stubServiceProvider{
streamFn: func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error {
providerCallCount++
if providerCallCount == 1 {
callback(providers.StreamEvent{
Type: "tool_start",
Data: providers.ToolStartEvent{ID: "call-restart", Name: "pulse_control"},
})
callback(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{
ToolCalls: []providers.ToolCall{{
ID: "call-restart",
Name: "pulse_control",
Input: map[string]interface{}{
"type": "command",
"command": "systemctl restart nginx",
},
}},
},
})
return nil
}
callback(providers.StreamEvent{
Type: "content",
Data: providers.ContentEvent{Text: "done"},
})
callback(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{InputTokens: 1, OutputTokens: 1},
})
return nil
},
}
svc := &Service{
cfg: &config.AIConfig{
ChatModel: "openai:test",
ControlLevel: config.ControlLevelAutonomous,
},
sessions: store,
executor: executor,
provider: provider,
started: true,
autonomousMode: true,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
approvalRequiredMode := false
approvalSeen := false
err = svc.ExecuteStream(ctx, ExecuteRequest{
SessionID: "sess-request-override",
Prompt: "restart nginx",
AutonomousMode: &approvalRequiredMode,
MaxTurns: 2,
}, func(event StreamEvent) {
if event.Type == "approval_needed" {
approvalSeen = true
cancel()
}
})
if err == nil || !strings.Contains(err.Error(), "context canceled") {
t.Fatalf("expected request to stop while waiting for approval, got %v", err)
}
if !approvalSeen {
t.Fatal("expected approval_needed event from request-local approval mode")
}
if got := agentServer.calls.Load(); got != 0 {
t.Fatalf("expected command not to execute before approval, got %d executions", got)
}
}
func TestService_ExecuteStream_HandoffContextIsModelOnly(t *testing.T) {
actionNow := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
actionPlan := testHandoffActionPlan(actionNow)
+6
View File
@@ -747,6 +747,12 @@ func (e *PulseToolExecutor) SetContext(targetType, targetID string, autonomous b
e.isAutonomous = autonomous
}
// SetAutonomousMode updates only the execution mode, preserving any
// session-scoped target context already attached to this executor clone.
func (e *PulseToolExecutor) SetAutonomousMode(enabled bool) {
e.isAutonomous = enabled
}
// SetOrgID sets the org scope used when creating approval records.
func (e *PulseToolExecutor) SetOrgID(orgID string) {
e.orgID = normalizeExecutorOrgID(orgID)
@@ -38,6 +38,11 @@ func TestPulseToolExecutor_Setters(t *testing.T) {
assert.Equal(t, "101", exec.targetID)
assert.True(t, exec.isAutonomous)
exec.SetAutonomousMode(false)
assert.Equal(t, "vm", exec.targetType)
assert.Equal(t, "101", exec.targetID)
assert.False(t, exec.isAutonomous)
exec.SetControlLevel(ControlLevelControlled)
assert.Equal(t, ControlLevelControlled, exec.controlLevel)