mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
Apply request approval mode to chat executor
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user