From 4cc6690fc279fb08f50a0c0193c29cdb6c8a5e14 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:41:18 +0900 Subject: [PATCH] feat(orch): define AgentState typed enum and report plan mode transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pkg/agent/loop.go | 7 +++++-- pkg/miniapp/static/index.html | 17 +++++++++++----- pkg/orch/broadcaster.go | 4 ++-- pkg/orch/reporter.go | 10 +++++----- pkg/orch/reporter_test.go | 8 ++++---- pkg/orch/state.go | 31 +++++++++++++++++++++++++++++ pkg/tools/toolloop.go | 4 ++-- pkg/tools/toolloop_reporter_test.go | 18 ++++++++--------- 8 files changed, 70 insertions(+), 29 deletions(-) create mode 100644 pkg/orch/state.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d8910d6be..ecaa35ecb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -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 diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index d7c8639b9..e51e81e9b 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -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) { diff --git a/pkg/orch/broadcaster.go b/pkg/orch/broadcaster.go index e5c8b85f8..1597a7eca 100644 --- a/pkg/orch/broadcaster.go +++ b/pkg/orch/broadcaster.go @@ -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. diff --git a/pkg/orch/reporter.go b/pkg/orch/reporter.go index b8229314d..5294ac7f9 100644 --- a/pkg/orch/reporter.go +++ b/pkg/orch/reporter.go @@ -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. diff --git a/pkg/orch/reporter_test.go b/pkg/orch/reporter_test.go index 120f03c26..e7428219a 100644 --- a/pkg/orch/reporter_test.go +++ b/pkg/orch/reporter_test.go @@ -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() diff --git a/pkg/orch/state.go b/pkg/orch/state.go new file mode 100644 index 000000000..6a77dd8ca --- /dev/null +++ b/pkg/orch/state.go @@ -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" +) diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index d8793caef..da7e06253 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -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 diff --git a/pkg/tools/toolloop_reporter_test.go b/pkg/tools/toolloop_reporter_test.go index 199aea169..3402bc13a 100644 --- a/pkg/tools/toolloop_reporter_test.go +++ b/pkg/tools/toolloop_reporter_test.go @@ -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]) } }