fix: route /plan <task> through LLM queue for AI interview flow

Previously /plan <task> was handled entirely on the fast path, so the
LLM never received the message and never started the interview.  Now
handlePlanCommand returns (_, false) for new plans, and the new
expandPlanCommand writes the interview seed to MEMORY.md and rewrites
the message content before it enters the LLM queue — matching the
existing expandSkillCommand pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-21 01:12:35 +09:00
parent f3fcb19aa2
commit 433cacef39
2 changed files with 120 additions and 50 deletions

View file

@ -347,10 +347,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
} }
// Expand /skill command: inject SKILL.md content into message, then continue to LLM // Expand /skill command: inject SKILL.md content into message, then continue to LLM
var skillCompact string var expansionCompact string
if expanded, compact, ok := al.expandSkillCommand(msg); ok { if expanded, compact, ok := al.expandSkillCommand(msg); ok {
msg.Content = expanded msg.Content = expanded
skillCompact = compact expansionCompact = compact
}
// Expand /plan <task>: write interview seed, rewrite for LLM interview
if expanded, compact, ok := al.expandPlanCommand(msg); ok {
msg.Content = expanded
expansionCompact = compact
} }
// Check for commands // Check for commands
@ -391,7 +397,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
Channel: msg.Channel, Channel: msg.Channel,
ChatID: msg.ChatID, ChatID: msg.ChatID,
UserMessage: msg.Content, UserMessage: msg.Content,
HistoryMessage: skillCompact, HistoryMessage: expansionCompact,
DefaultResponse: "I've completed processing but have no response to give.", DefaultResponse: "I've completed processing but have no response to give.",
EnableSummary: true, EnableSummary: true,
SendResponse: false, SendResponse: false,
@ -1285,7 +1291,8 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
return al.handleSkillsCommand(), true return al.handleSkillsCommand(), true
case "/plan": case "/plan":
return al.handlePlanCommand(args), true resp, handled := al.handlePlanCommand(args)
return resp, handled
} }
return "", false return "", false
@ -1397,94 +1404,145 @@ func (al *AgentLoop) handleSkillsCommand() string {
return sb.String() return sb.String()
} }
// handlePlanCommand handles /plan subcommands. // handlePlanCommand handles /plan subcommands that can be resolved instantly.
func (al *AgentLoop) handlePlanCommand(args []string) string { // Returns (response, handled). For "/plan <task>" (new plan), it returns
// ("", false) so the message falls through to the LLM queue, where
// expandPlanCommand writes the seed and rewrites the content.
func (al *AgentLoop) handlePlanCommand(args []string) (string, bool) {
agent := al.registry.GetDefaultAgent() agent := al.registry.GetDefaultAgent()
if agent == nil { if agent == nil {
return "No agent configured." return "No agent configured.", true
} }
if len(args) == 0 { if len(args) == 0 {
// /plan — show current plan // /plan — show current plan
return agent.ContextBuilder.FormatPlanDisplay() return agent.ContextBuilder.FormatPlanDisplay(), true
} }
sub := args[0] sub := args[0]
switch sub { switch sub {
case "clear": case "clear":
if !agent.ContextBuilder.HasActivePlan() { if !agent.ContextBuilder.HasActivePlan() {
return "No active plan to clear." return "No active plan to clear.", true
} }
if err := agent.ContextBuilder.ClearMemory(); err != nil { if err := agent.ContextBuilder.ClearMemory(); err != nil {
return fmt.Sprintf("Error clearing plan: %v", err) return fmt.Sprintf("Error clearing plan: %v", err), true
} }
return "Plan cleared." return "Plan cleared.", true
case "done": case "done":
if !agent.ContextBuilder.HasActivePlan() { if !agent.ContextBuilder.HasActivePlan() {
return "No active plan." return "No active plan.", true
} }
if len(args) < 2 { if len(args) < 2 {
return "Usage: /plan done <step number>" return "Usage: /plan done <step number>", true
} }
stepNum, err := strconv.Atoi(args[1]) stepNum, err := strconv.Atoi(args[1])
if err != nil || stepNum < 1 { if err != nil || stepNum < 1 {
return "Step number must be a positive integer." return "Step number must be a positive integer.", true
} }
phase := agent.ContextBuilder.GetCurrentPhase() phase := agent.ContextBuilder.GetCurrentPhase()
if err := agent.ContextBuilder.MarkStep(phase, stepNum); err != nil { if err := agent.ContextBuilder.MarkStep(phase, stepNum); err != nil {
return fmt.Sprintf("Error: %v", err) return fmt.Sprintf("Error: %v", err), true
} }
return fmt.Sprintf("Marked step %d in phase %d as done.", stepNum, phase) return fmt.Sprintf("Marked step %d in phase %d as done.", stepNum, phase), true
case "add": case "add":
if !agent.ContextBuilder.HasActivePlan() { if !agent.ContextBuilder.HasActivePlan() {
return "No active plan." return "No active plan.", true
} }
if len(args) < 2 { if len(args) < 2 {
return "Usage: /plan add <step description>" return "Usage: /plan add <step description>", true
} }
desc := strings.Join(args[1:], " ") desc := strings.Join(args[1:], " ")
phase := agent.ContextBuilder.GetCurrentPhase() phase := agent.ContextBuilder.GetCurrentPhase()
if err := agent.ContextBuilder.AddStep(phase, desc); err != nil { if err := agent.ContextBuilder.AddStep(phase, desc); err != nil {
return fmt.Sprintf("Error: %v", err) return fmt.Sprintf("Error: %v", err), true
} }
return fmt.Sprintf("Added step to phase %d: %s", phase, desc) return fmt.Sprintf("Added step to phase %d: %s", phase, desc), true
case "start": case "start":
if !agent.ContextBuilder.HasActivePlan() { if !agent.ContextBuilder.HasActivePlan() {
return "No active plan." return "No active plan.", true
} }
if agent.ContextBuilder.GetPlanStatus() != "interviewing" { if agent.ContextBuilder.GetPlanStatus() != "interviewing" {
return "Plan is already executing." return "Plan is already executing.", true
} }
if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil { if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil {
return fmt.Sprintf("Error: %v", err) return fmt.Sprintf("Error: %v", err), true
} }
return "Plan status changed to executing." return "Plan status changed to executing.", true
case "next": case "next":
if !agent.ContextBuilder.HasActivePlan() { if !agent.ContextBuilder.HasActivePlan() {
return "No active plan." return "No active plan.", true
} }
if err := agent.ContextBuilder.AdvancePhase(); err != nil { if err := agent.ContextBuilder.AdvancePhase(); err != nil {
return fmt.Sprintf("Error: %v", err) return fmt.Sprintf("Error: %v", err), true
} }
phase := agent.ContextBuilder.GetCurrentPhase() phase := agent.ContextBuilder.GetCurrentPhase()
return fmt.Sprintf("Advanced to phase %d.", phase) return fmt.Sprintf("Advanced to phase %d.", phase), true
default: default:
// /plan <task description> — start new plan // /plan <task description> — start new plan
// Block if a plan is already active (fast-path error).
if agent.ContextBuilder.HasActivePlan() { if agent.ContextBuilder.HasActivePlan() {
return "A plan is already active. Use /plan clear first." return "A plan is already active. Use /plan clear first.", true
} }
task := strings.Join(args, " ") // Not handled here — let the message flow to the LLM queue.
// expandPlanCommand will write the seed and rewrite the content.
return "", false
}
}
// expandPlanCommand detects "/plan <task>" (new plan start) and:
// - writes the interview seed to MEMORY.md
// - rewrites the message content for the LLM
// - returns a compact form for session history
//
// This follows the same pattern as expandSkillCommand: the message is
// rewritten before reaching the LLM, so the AI sees the task description
// while the system prompt contains the interview guide.
func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) {
content := strings.TrimSpace(msg.Content)
if !strings.HasPrefix(content, "/plan ") {
return "", "", false
}
task := strings.TrimSpace(content[6:]) // len("/plan ") == 6
if task == "" {
return "", "", false
}
// Known subcommands are handled by handlePlanCommand (fast path).
firstWord := strings.Fields(task)[0]
switch firstWord {
case "clear", "done", "add", "start", "next":
return "", "", false
}
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "", "", false
}
// If a plan is already active, don't expand — handleCommand will
// catch it and return the error on the fast path.
if agent.ContextBuilder.HasActivePlan() {
return "", "", false
}
// Write the interview seed
seed := BuildInterviewSeed(task) seed := BuildInterviewSeed(task)
if err := agent.ContextBuilder.WriteMemory(seed); err != nil { if err := agent.ContextBuilder.WriteMemory(seed); err != nil {
return fmt.Sprintf("Error creating plan: %v", err) return "", "", false
}
return fmt.Sprintf("Plan started: %s\nStatus: interviewing\n\nI'll ask you some questions to build a detailed plan.", task)
} }
// Expanded: the task description goes to LLM.
// The system prompt already contains the interview guide.
expanded = task
compact = fmt.Sprintf("[Plan: %s]", utils.Truncate(task, 80))
return expanded, compact, true
} }
// extractPeer extracts the routing peer from inbound message metadata. // extractPeer extracts the routing peer from inbound message metadata.

View file

@ -917,21 +917,30 @@ func TestPlanCommand_StartNewPlan(t *testing.T) {
al, cleanup := newTestAgentLoop(t) al, cleanup := newTestAgentLoop(t)
defer cleanup() defer cleanup()
response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Set up monitoring"}) // /plan <task> should NOT be handled by handleCommand — it falls through
if !handled { // to the LLM queue via expandPlanCommand.
t.Fatal("expected /plan to be handled") _, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Set up monitoring"})
if handled {
t.Fatal("expected /plan <task> NOT to be handled (should fall through to LLM)")
} }
if !strings.Contains(response, "Plan started") {
t.Errorf("expected 'Plan started', got %q", response) // expandPlanCommand writes the seed and rewrites the message
msg := bus.InboundMessage{Content: "/plan Set up monitoring"}
expanded, compact, ok := al.expandPlanCommand(msg)
if !ok {
t.Fatal("expected expandPlanCommand to succeed")
} }
if !strings.Contains(response, "Set up monitoring") { if expanded != "Set up monitoring" {
t.Errorf("expected task in response, got %q", response) t.Errorf("expected expanded = 'Set up monitoring', got %q", expanded)
}
if !strings.Contains(compact, "Set up monitoring") {
t.Errorf("expected compact to contain task, got %q", compact)
} }
// Verify plan was created // Verify plan was created
agent := al.registry.GetDefaultAgent() agent := al.registry.GetDefaultAgent()
if !agent.ContextBuilder.HasActivePlan() { if !agent.ContextBuilder.HasActivePlan() {
t.Error("expected active plan after /plan start") t.Error("expected active plan after expandPlanCommand")
} }
if status := agent.ContextBuilder.GetPlanStatus(); status != "interviewing" { if status := agent.ContextBuilder.GetPlanStatus(); status != "interviewing" {
t.Errorf("expected 'interviewing', got %q", status) t.Errorf("expected 'interviewing', got %q", status)
@ -942,11 +951,14 @@ func TestPlanCommand_StartBlockedByExisting(t *testing.T) {
al, cleanup := newTestAgentLoop(t) al, cleanup := newTestAgentLoop(t)
defer cleanup() defer cleanup()
// Start first plan // Start first plan via expandPlanCommand
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan First task"}) al.expandPlanCommand(bus.InboundMessage{Content: "/plan First task"})
// Try to start another // Try to start another — handleCommand should block it on the fast path
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Second task"}) response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Second task"})
if !handled {
t.Fatal("expected second /plan to be handled (blocked)")
}
if !strings.Contains(response, "already active") { if !strings.Contains(response, "already active") {
t.Errorf("expected 'already active', got %q", response) t.Errorf("expected 'already active', got %q", response)
} }
@ -957,7 +969,7 @@ func TestPlanCommand_Clear(t *testing.T) {
defer cleanup() defer cleanup()
// Start plan then clear // Start plan then clear
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"}) al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"})
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"})
if !strings.Contains(response, "Plan cleared") { if !strings.Contains(response, "Plan cleared") {
t.Errorf("expected 'Plan cleared', got %q", response) t.Errorf("expected 'Plan cleared', got %q", response)
@ -984,7 +996,7 @@ func TestPlanCommand_Start(t *testing.T) {
defer cleanup() defer cleanup()
// Create interviewing plan // Create interviewing plan
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"}) al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"})
// Transition to executing // Transition to executing
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"})
@ -1003,7 +1015,7 @@ func TestPlanCommand_StartAlreadyExecuting(t *testing.T) {
defer cleanup() defer cleanup()
// Create interviewing plan then start // Create interviewing plan then start
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"}) al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"})
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"})
// Try start again // Try start again
@ -1044,7 +1056,7 @@ func TestPlanCommand_DoneInvalidStep(t *testing.T) {
al, cleanup := newTestAgentLoop(t) al, cleanup := newTestAgentLoop(t)
defer cleanup() defer cleanup()
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Test task"}) al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"})
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done abc"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done abc"})
if !strings.Contains(response, "positive integer") { if !strings.Contains(response, "positive integer") {
t.Errorf("expected step validation error, got %q", response) t.Errorf("expected step validation error, got %q", response)