feat(orch): define AgentState typed enum and report plan mode transitions

- Add pkg/orch/state.go with AgentState string type and constants:
  idle, waiting, toolcall, plan_interviewing, plan_review, plan_executing, plan_completed
- Update AgentReporter interface and all implementations to use AgentState instead of raw strings
- Add ReportStateChange calls in loop.go at plan review/executing/completed transitions
- Update index.html statusText/glow for all 4 plan states (📋🔍▶️)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-25 11:41:18 +09:00
parent 3356993775
commit dac5d98229
8 changed files with 70 additions and 29 deletions

View file

@ -1091,6 +1091,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
agent.Sessions.AddMessage(opts.SessionKey, "user", rejectionMsg)
} else {
_ = agent.ContextBuilder.SetPlanStatus("review")
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStatePlanReview, "")
if !constants.IsInternalChannel(opts.Channel) {
planDisplay := agent.ContextBuilder.FormatPlanDisplay()
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
@ -1112,6 +1113,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
_ = agent.ContextBuilder.SetCurrentPhase(total)
if preStatus != "completed" {
_ = agent.ContextBuilder.SetPlanStatus("completed")
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStatePlanCompleted, "")
// Deactivate worktree on plan completion
commitMsg := "plan: " + agent.ContextBuilder.Memory().GetPlanTaskName()
@ -2070,7 +2072,7 @@ func (al *AgentLoop) runLLMIteration(
}
// Report waiting state to canvas before each LLM call.
al.reporter().ReportStateChange(opts.SessionKey, "waiting", "")
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateWaiting, "")
// Retry loop for context/token errors
maxRetries := 2
@ -2462,7 +2464,7 @@ func (al *AgentLoop) runLLMIteration(
}
// Report toolcall state to canvas.
al.reporter().ReportStateChange(opts.SessionKey, "toolcall", tc.Name)
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateToolCall, tc.Name)
toolStart := time.Now()
toolCtx := ctx
@ -3362,6 +3364,7 @@ func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string
if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil {
return fmt.Sprintf("Error: %v", err), true
}
al.reporter().ReportStateChange(sessionKey, orch.AgentStatePlanExecuting, "")
al.planStartPending = true
clearHistory := len(args) > 1 && args[1] == "clear"
al.planClearHistory = clearHistory

View file

@ -1849,10 +1849,14 @@ function _orchSyncBadge(id, state, alive) {
function _orchSetState(c, state, tool) {
c.state=state; _orchSyncBadge(c.id, state, c.alive);
if (c === _orchConductor) {
if (state==='waiting') c.statusText='🤔';
else if (state==='toolcall') c.statusText='⌨';
else if (state==='user_waiting') c.statusText='⏳';
else c.statusText=null;
if (state==='waiting') c.statusText='🤔';
else if (state==='toolcall') c.statusText='⌨';
else if (state==='user_waiting') c.statusText='⏳';
else if (state==='plan_interviewing') c.statusText='📋';
else if (state==='plan_review') c.statusText='🔍';
else if (state==='plan_executing') c.statusText='▶️';
else if (state==='plan_completed') c.statusText='✅';
else c.statusText=null;
}
}
function _orchMoveTo(c, pos, cb) { c.target=pos; c._onArrive=cb||null; }
@ -1959,9 +1963,12 @@ function _orchDrawChar(c) {
} else if (c.state==='waiting'){
orchCtx.fillStyle='rgba(96,165,250,0.25)'; orchCtx.beginPath();
orchCtx.arc(cx,cy,11,0,Math.PI*2); orchCtx.fill();
} else if (c.state==='user_waiting'){
} else if (c.state==='user_waiting' || c.state==='plan_review'){
orchCtx.fillStyle='rgba(167,139,250,0.18)'; orchCtx.beginPath();
orchCtx.arc(cx,cy,10,0,Math.PI*2); orchCtx.fill();
} else if (c.state==='plan_executing'){
orchCtx.fillStyle='rgba(74,222,128,0.18)'; orchCtx.beginPath();
orchCtx.arc(cx,cy,10,0,Math.PI*2); orchCtx.fill();
}
orchCtx.font='18px serif'; orchCtx.textAlign='center'; orchCtx.textBaseline='middle';
if (c.facing === -1) {

View file

@ -89,8 +89,8 @@ func (b *Broadcaster) ReportSpawn(id, label, task string) {
}
// ReportStateChange implements AgentReporter.
func (b *Broadcaster) ReportStateChange(id, state, tool string) {
b.Publish(Event{Type: "agent_state", ID: id, State: state, Tool: tool})
func (b *Broadcaster) ReportStateChange(id string, state AgentState, tool string) {
b.Publish(Event{Type: "agent_state", ID: id, State: string(state), Tool: tool})
}
// ReportConversation implements AgentReporter.

View file

@ -4,17 +4,17 @@ package orch
// Both Broadcaster (real events) and noopReporter (disabled) implement this.
type AgentReporter interface {
ReportSpawn(id, label, task string)
ReportStateChange(id, state, tool string)
ReportStateChange(id string, state AgentState, tool string)
ReportConversation(from, to, text string)
ReportGC(id, reason string)
}
type noopReporter struct{}
func (n *noopReporter) ReportSpawn(id, label, task string) {}
func (n *noopReporter) ReportStateChange(id, state, tool string) {}
func (n *noopReporter) ReportConversation(from, to, text string) {}
func (n *noopReporter) ReportGC(id, reason string) {}
func (n *noopReporter) ReportSpawn(id, label, task string) {}
func (n *noopReporter) ReportStateChange(id string, state AgentState, tool string) {}
func (n *noopReporter) ReportConversation(from, to, text string) {}
func (n *noopReporter) ReportGC(id, reason string) {}
// Noop is the AgentReporter to use when orchestration is disabled.
// Allows nil-free code in callers.

View file

@ -10,8 +10,8 @@ var _ AgentReporter = (*Broadcaster)(nil)
// orchestration mode.
func TestNoop_AllMethods_NoPanic(t *testing.T) {
Noop.ReportSpawn("id", "label", "task")
Noop.ReportStateChange("id", "waiting", "")
Noop.ReportStateChange("id", "toolcall", "bash")
Noop.ReportStateChange("id", AgentStateWaiting, "")
Noop.ReportStateChange("id", AgentStateToolCall, "bash")
Noop.ReportConversation("conductor", "sub-1", "do something")
Noop.ReportGC("id", "completed")
}
@ -49,9 +49,9 @@ func TestBroadcaster_ReportStateChange_MapsToAgentStateEvent(t *testing.T) {
b.ReportSpawn("agent-1", "coder", "implement it")
<-sub.Ch // consume spawn
b.ReportStateChange("agent-1", "toolcall", "bash")
b.ReportStateChange("agent-1", AgentStateToolCall, "bash")
ev := <-sub.Ch
if ev.Type != "agent_state" || ev.State != "toolcall" || ev.Tool != "bash" {
if ev.Type != "agent_state" || ev.State != string(AgentStateToolCall) || ev.Tool != "bash" {
t.Fatalf("unexpected event: %+v", ev)
}
snap := b.Snapshot()

31
pkg/orch/state.go Normal file
View file

@ -0,0 +1,31 @@
package orch
// AgentState is a typed string representing the lifecycle state of an agent session.
// Values are sent as-is over the WebSocket event stream to the Mini App canvas.
type AgentState string
const (
// AgentStateIdle is the resting state, set automatically by Broadcaster on spawn.
AgentStateIdle AgentState = "idle"
// AgentStateWaiting means the agent is waiting for an LLM response.
AgentStateWaiting AgentState = "waiting"
// AgentStateToolCall means the agent is executing a tool.
// The tool name is carried in the Tool field of the event.
AgentStateToolCall AgentState = "toolcall"
// AgentStatePlanInterviewing means the conductor is in plan-mode interview phase,
// clarifying goals and constraints with the user.
AgentStatePlanInterviewing AgentState = "plan_interviewing"
// AgentStatePlanReview means the conductor has submitted a plan and is waiting
// for user approval before executing.
AgentStatePlanReview AgentState = "plan_review"
// AgentStatePlanExecuting means the conductor is executing an approved plan.
AgentStatePlanExecuting AgentState = "plan_executing"
// AgentStatePlanCompleted means all plan steps have been completed.
AgentStatePlanCompleted AgentState = "plan_completed"
)

View file

@ -75,7 +75,7 @@ func RunToolLoop(
llmOpts = map[string]any{}
}
// 3. Call LLM (hook: waiting for response)
reporter.ReportStateChange(config.AgentID, "waiting", "")
reporter.ReportStateChange(config.AgentID, orch.AgentStateWaiting, "")
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
if err != nil {
logger.ErrorCF("toolloop", "LLM call failed",
@ -143,7 +143,7 @@ func RunToolLoop(
"tool": tc.Name,
"iteration": iteration,
})
reporter.ReportStateChange(config.AgentID, "toolcall", tc.Name)
reporter.ReportStateChange(config.AgentID, orch.AgentStateToolCall, tc.Name)
// Execute tool (no async callback for subagents - they run independently)
var toolResult *ToolResult

View file

@ -17,14 +17,14 @@ type reporterSpy struct {
}
type spyCall struct {
state string
state orch.AgentState
tool string
}
func (r *reporterSpy) ReportSpawn(id, label, task string) {}
func (r *reporterSpy) ReportConversation(from, to, text string) {}
func (r *reporterSpy) ReportGC(id, reason string) {}
func (r *reporterSpy) ReportStateChange(id, state, tool string) {
func (r *reporterSpy) ReportSpawn(id, label, task string) {}
func (r *reporterSpy) ReportConversation(from, to, text string) {}
func (r *reporterSpy) ReportGC(id, reason string) {}
func (r *reporterSpy) ReportStateChange(id string, state orch.AgentState, tool string) {
r.mu.Lock()
r.calls = append(r.calls, spyCall{state, tool})
r.mu.Unlock()
@ -117,7 +117,7 @@ func TestToolLoop_Reporter_WaitingBeforeLLM(t *testing.T) {
if len(calls) == 0 {
t.Fatal("expected at least one ReportStateChange call")
}
if calls[0].state != "waiting" {
if calls[0].state != orch.AgentStateWaiting {
t.Fatalf("first call must be state=waiting, got %+v", calls[0])
}
}
@ -152,13 +152,13 @@ func TestToolLoop_Reporter_ToolcallOrderedAfterWaiting(t *testing.T) {
if len(calls) < 3 {
t.Fatalf("expected at least 3 calls, got %d: %+v", len(calls), calls)
}
if calls[0].state != "waiting" {
if calls[0].state != orch.AgentStateWaiting {
t.Fatalf("calls[0] must be waiting, got %+v", calls[0])
}
if calls[1].state != "toolcall" || calls[1].tool != "echo_tool" {
if calls[1].state != orch.AgentStateToolCall || calls[1].tool != "echo_tool" {
t.Fatalf("calls[1] must be toolcall(echo_tool), got %+v", calls[1])
}
if calls[2].state != "waiting" {
if calls[2].state != orch.AgentStateWaiting {
t.Fatalf("calls[2] must be waiting (2nd LLM iteration), got %+v", calls[2])
}
}