diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 4fe64e96e..06751f81f 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -39,9 +39,10 @@ type AgentLoop struct { stats *stats.Tracker // nil when --stats not passed running atomic.Bool summarizing sync.Map - fallback *providers.FallbackChain - channelManager *channels.Manager - providerCache map[string]providers.LLMProvider + fallback *providers.FallbackChain + channelManager *channels.Manager + providerCache map[string]providers.LLMProvider + planStartPending bool // set by /plan start to trigger LLM execution } // processOptions configures how a message is processed @@ -204,6 +205,23 @@ func (al *AgentLoop) Run(ctx context.Context) error { SkipPlaceholder: true, }) } + // /plan start sets the flag — enqueue a synthetic message so + // the LLM worker actually begins executing the plan. + if al.planStartPending { + al.planStartPending = false + select { + case llmQueue <- bus.InboundMessage{ + Channel: msg.Channel, + ChatID: msg.ChatID, + SenderID: msg.SenderID, + SessionKey: msg.SessionKey, + Content: "The plan has been approved. Begin executing.", + Metadata: msg.Metadata, + }: + case <-ctx.Done(): + return nil + } + } continue } @@ -1644,6 +1662,7 @@ func (al *AgentLoop) handlePlanCommand(args []string) (string, bool) { if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil { return fmt.Sprintf("Error: %v", err), true } + al.planStartPending = true return "Plan approved. Executing.", true case "next": diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 84a3b4866..a507351e5 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1044,6 +1044,11 @@ func TestPlanCommand_Start(t *testing.T) { if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" { t.Errorf("expected 'executing', got %q", status) } + + // planStartPending must be set so Run() enqueues an LLM trigger + if !al.planStartPending { + t.Error("expected planStartPending to be true after /plan start") + } } func TestPlanCommand_StartFromReview(t *testing.T) { @@ -1065,6 +1070,10 @@ func TestPlanCommand_StartFromReview(t *testing.T) { if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" { t.Errorf("expected 'executing', got %q", status) } + + if !al.planStartPending { + t.Error("expected planStartPending to be true after /plan start from review") + } } func TestPlanCommand_StartNoPhases(t *testing.T) { @@ -1084,6 +1093,10 @@ func TestPlanCommand_StartNoPhases(t *testing.T) { if status := agent.ContextBuilder.GetPlanStatus(); status != "interviewing" { t.Errorf("expected status to remain 'interviewing', got %q", status) } + + if al.planStartPending { + t.Error("planStartPending must not be set when start is rejected (no phases)") + } } func TestPlanCommand_StartAlreadyExecuting(t *testing.T) { @@ -1097,11 +1110,18 @@ func TestPlanCommand_StartAlreadyExecuting(t *testing.T) { _ = agent.ContextBuilder.WriteMemory(plan) al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) - // Try start again + // Clear the flag from the first call (simulating Run() consuming it) + al.planStartPending = false + + // Try start again — should be rejected response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) if !strings.Contains(response, "already executing") { t.Errorf("expected 'already executing', got %q", response) } + + if al.planStartPending { + t.Error("planStartPending must not be set when plan is already executing") + } } func TestPlanCommand_Done(t *testing.T) {