19 KiB
Realtime AI Agent Activity Indicator Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Provide immediate visual feedback in support chat and the operator dashboard by broadcasting real-time agent execution states ("thinking", "searching knowledge base...", "calling tool...", "running workflow...") over WebSocket during AI agent processing turns.
Architecture: The Go backend emits lightweight agent.activity domain events via WsService to the conversation's WebSocket topic at each phase of the Agent Loop (start, knowledge search, tool invocation, completion/failure). The Next.js frontend catches these events in support-chat and agent-conversations stores, rendering an animated, localized activity indicator bubble at the bottom of the message scroller until the final response message arrives.
Tech Stack: Go 1.26 (Gin, Gorilla WebSocket, Eino agent loop), React 19 / Next.js 16, TypeScript, Zustand, Lucide icons, Tailwind CSS, i18n (zh-CN, en-US, ru-RU).
Spec: Variant 2: Real-time Execution Step Statuses via WebSocket (non-token-streaming step indicators).
Global Constraints
- Backend must maintain full 3-locale i18n support (
zh-CN,en-US,ru-RU). - All new translation keys must be added symmetrically to
web/messages/zh-CN.json,web/messages/en-US.json, andweb/messages/ru-RU.json. - Event contracts must preserve existing WebSocket protocol conventions in
internal/services/ws_realtime_types.go. - Code changes must adhere to layer ownership defined in
AGENTS.md(no direct DB access from handlers, pure builders, structuredsloglogging). - No mock data or unhandled error drops; activity indicator must automatically dismiss on message arrival or conversation change.
Review Focus
- Visitor switches conversation while AI agent is thinking in previous conversation (must not leak indicator across conversations).
- AI agent encounters error or times out during execution (must clean up indicator with "failed" status and not get stuck indefinitely).
- Multi-step tool loop where several tools run sequentially (must update step text dynamically without layout flicker).
- Rapid message send right after another (activity state must reset gracefully for the new turn).
- Offline evaluation or background test runs where
req.Conversation.ID <= 0(must safely no-op without attempting WebSocket broadcast).
Task 1: Backend WebSocket Event Contract & WsService Method
Files:
- Modify:
internal/pkg/enums/im.go:240-265 - Modify:
internal/services/ws_realtime_types.go:210-250 - Modify:
internal/services/ws_service.go:460-490 - Test:
internal/services/ws_service_test.go
Interfaces:
-
Consumes:
models.Conversation,enums.IMRealtimeEventAgentActivity -
Produces:
RealtimeAgentActivityPayload{ConversationID int64, StepCode string, ToolName string, Status string, At string}RealtimeAgentActivityEventimplementingRealtimeDomainEventWsService.PublishAgentActivity(conversation *models.Conversation, stepCode, toolName, status string)
-
Step 1: Write the failing test
Add TestPublishAgentActivity in internal/services/ws_service_test.go:
func TestPublishAgentActivity(t *testing.T) {
ws := newWsService()
session := &ClientSession{
ID: "test-sess",
Topics: map[string]struct{}{"conversation:100": {}},
Send: make(chan []byte, 10),
}
ws.manager.Register(session)
conv := &models.Conversation{ID: 100}
ws.PublishAgentActivity(conv, "searching_knowledge", "", "running")
select {
case payload := <-session.Send:
var event RealtimeEvent
if err := json.Unmarshal(payload, &event); err != nil {
t.Fatalf("failed to unmarshal event: %v", err)
}
if event.Type != enums.IMRealtimeEventAgentActivity {
t.Fatalf("expected type %s, got %s", enums.IMRealtimeEventAgentActivity, event.Type)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for agent activity event")
}
}
- Step 2: Run test to verify it fails
Run: & 'C:\Program Files\Go\bin\go.exe' test ./internal/services -run TestPublishAgentActivity
Expected: FAIL (missing IMRealtimeEventAgentActivity or PublishAgentActivity)
- Step 3: Implement event types and
PublishAgentActivity
- In
internal/pkg/enums/im.go, define:IMRealtimeEventAgentActivity = "agent.activity" - In
internal/services/ws_realtime_types.go, define:type RealtimeAgentActivityPayload struct { ConversationID int64 `json:"conversationId"` StepCode string `json:"stepCode"` ToolName string `json:"toolName,omitempty"` Status string `json:"status"` At string `json:"at"` } func (RealtimeAgentActivityPayload) realtimeEventPayload() {} type RealtimeAgentActivityEvent struct { Payload RealtimeAgentActivityPayload } func (e RealtimeAgentActivityEvent) EventType() string { return enums.IMRealtimeEventAgentActivity } func (e RealtimeAgentActivityEvent) EventPayload() RealtimeEventPayload { return e.Payload } - In
internal/services/ws_service.go, add:func (s *wsService) PublishAgentActivity(conversation *models.Conversation, stepCode, toolName, status string) { if conversation == nil || conversation.ID <= 0 { return } now := time.Now() event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeAgentActivityEvent{ Payload: RealtimeAgentActivityPayload{ ConversationID: conversation.ID, StepCode: stepCode, ToolName: toolName, Status: status, At: formatWsTime(&now), }, }) s.PublishToTopics(s.routeConversationTopics(conversation), event) }
- Step 4: Run test to verify it passes
Run: & 'C:\Program Files\Go\bin\go.exe' test ./internal/services -run TestPublishAgentActivity
Expected: PASS
- Step 5: Commit
git add internal/pkg/enums/im.go internal/services/ws_realtime_types.go internal/services/ws_service.go internal/services/ws_service_test.go
git commit -m "feat(realtime): add agent.activity event and PublishAgentActivity"
Task 2: Emit Agent Activity During Loop Execution
Files:
- Modify:
internal/ai/application/runtime/agent_loop_engine.go:55-120,615-680 - Test:
internal/ai/application/runtime/agent_loop_engine_test.go
Interfaces:
-
Consumes:
RunInput.Conversation,svc.WsService.PublishAgentActivity -
Produces: Realtime step broadcasts during:
- Turn preparation / start (
stepCode: "thinking",status: "started") - Knowledge retrieval (
stepCode: "searching_knowledge",status: "running") - Tool execution (
stepCode: "calling_tool",toolName: <name>,status: "running") - Workflow execution (
stepCode: "running_workflow",toolName: <name>,status: "running") - Turn completed (
stepCode: "completed",status: "completed") - Turn failed (
stepCode: "failed",status: "failed")
- Turn preparation / start (
-
Step 1: Write the failing test
In internal/ai/application/runtime/agent_loop_engine_test.go, add test asserting that toolSearchExecutor and Run safely execute without errors when req.Conversation.ID > 0.
- Step 2: Run test to verify behavior
Run: & 'C:\Program Files\Go\bin\go.exe' test ./internal/ai/application/runtime -run TestAgentLoopEngine
Expected: PASS existing tests.
- Step 3: Implement activity emissions in
AgentLoopEngine
- In
Run(ctx context.Context, req RunInput) (*RunResult, error):- Emit start:
if req.Conversation.ID > 0 { svc.WsService.PublishAgentActivity(&req.Conversation, "thinking", "", "started") } - Before knowledge retrieval in
retrieveKnowledge:if req.Conversation.ID > 0 { svc.WsService.PublishAgentActivity(&req.Conversation, "searching_knowledge", "", "running") } - On exit (after
resolveAgentLoopReply/ error):if req.Conversation.ID > 0 { if loopErr != nil || err != nil { svc.WsService.PublishAgentActivity(&req.Conversation, "failed", "", "failed") } else { svc.WsService.PublishAgentActivity(&req.Conversation, "completed", "", "completed") } }
- Emit start:
- In
toolSearchExecutor:- When dispatching tool call:
if runInput.Conversation.ID > 0 { svc.WsService.PublishAgentActivity(&runInput.Conversation, "calling_tool", call.Name, "running") }
- When dispatching tool call:
- Step 4: Run Go package tests
Run: & 'C:\Program Files\Go\bin\go.exe' test ./internal/ai/application/runtime/...
Expected: PASS
- Step 5: Commit
git add internal/ai/application/runtime/agent_loop_engine.go internal/ai/application/runtime/agent_loop_engine_test.go
git commit -m "feat(runtime): publish realtime agent activity steps during loop execution"
Task 3: Frontend i18n Translations for Agent Activity
Files:
- Modify:
web/messages/en-US.json - Modify:
web/messages/ru-RU.json - Modify:
web/messages/zh-CN.json - Test:
web/i18n/message-integrity.test.mjs
Interfaces:
-
Consumes: i18n keys under
agentActivity.* -
Produces: Identical keys across all 3 locales:
agentActivity.thinkingagentActivity.searchingKnowledgeagentActivity.callingToolagentActivity.runningWorkflowagentActivity.default
-
Step 1: Write the failing test / check
Run message integrity test: node --test web/i18n/message-integrity.test.mjs
Expected: PASS (current baseline).
- Step 2: Add matching translation keys
- In
web/messages/en-US.json:"agentActivity": { "thinking": "AI Agent is thinking...", "searchingKnowledge": "Searching knowledge base...", "callingTool": "Executing action: {name}...", "runningWorkflow": "Running workflow: {name}...", "default": "AI Agent is working on your request..." } - In
web/messages/ru-RU.json:"agentActivity": { "thinking": "AI-ассистент думает...", "searchingKnowledge": "Поиск в базе знаний...", "callingTool": "Выполнение действия: {name}...", "runningWorkflow": "Выполнение сценария: {name}...", "default": "AI-ассистент обрабатывает запрос..." } - In
web/messages/zh-CN.json:"agentActivity": { "thinking": "AI 思考中...", "searchingKnowledge": "检索知识库中...", "callingTool": "执行工具:{name}...", "runningWorkflow": "执行流程:{name}...", "default": "AI 正在处理您的请求..." }
- Step 3: Run message integrity test
Run: node --test web/i18n/message-integrity.test.mjs
Expected: PASS (all keys match in all supported locales).
- Step 4: Commit
git add web/messages/en-US.json web/messages/ru-RU.json web/messages/zh-CN.json
git commit -m "feat(i18n): add localized strings for agent activity states"
Task 4: Frontend State & Realtime Event Integration
Files:
- Modify:
web/lib/im-realtime.ts:1-25 - Modify:
web/lib/stores/support-chat.ts:60-100,230-310,480-520 - Modify:
web/lib/stores/agent-conversations.ts:80-120,400-440 - Test:
web/lib/agent-activity-state.test.mjs
Interfaces:
-
Consumes:
ImRealtimeEnvelopewithtype: "agent.activity", payloadRealtimeAgentActivityPayload -
Produces:
- Store slice
activeAgentActivity: { conversationId: number; stepCode: string; toolName?: string } | null - Reducer/event-handler updating
activeAgentActivityonagent.activity, and clearing onmessage.created,conversation.closed, or conversation switch.
- Store slice
-
Step 1: Write the failing test
Create web/lib/agent-activity-state.test.mjs:
import test from "node:test"
import assert from "node:assert/strict"
import { resolveAgentActivityState } from "./agent-activity-state.ts"
test("sets activity state when event matches active conversation and status is running", () => {
const current = null
const event = {
conversationId: 42,
stepCode: "searching_knowledge",
status: "running",
}
const next = resolveAgentActivityState(current, 42, event)
assert.deepEqual(next, { conversationId: 42, stepCode: "searching_knowledge" })
})
test("clears activity state when status is completed or failed", () => {
const current = { conversationId: 42, stepCode: "thinking" }
const event = {
conversationId: 42,
stepCode: "completed",
status: "completed",
}
const next = resolveAgentActivityState(current, 42, event)
assert.equal(next, null)
})
test("ignores events belonging to a different conversation", () => {
const current = null
const event = {
conversationId: 99,
stepCode: "thinking",
status: "started",
}
const next = resolveAgentActivityState(current, 42, event)
assert.equal(next, null)
})
- Step 2: Run test to verify it fails
Run: node --test web/lib/agent-activity-state.test.mjs
Expected: FAIL (module not found).
- Step 3: Implement
agent-activity-state.tsand wire into stores
- Create
web/lib/agent-activity-state.tswith pure transition helper:export type AgentActivityInfo = { conversationId: number stepCode: string toolName?: string } export type AgentActivityEventPayload = { conversationId: number stepCode: string toolName?: string status: string } export function resolveAgentActivityState( current: AgentActivityInfo | null, activeConversationId: number | undefined, payload: AgentActivityEventPayload | null | undefined ): AgentActivityInfo | null { if (!payload || !activeConversationId || payload.conversationId !== activeConversationId) { return current } if (payload.status === "completed" || payload.status === "failed") { return null } return { conversationId: payload.conversationId, stepCode: payload.stepCode, toolName: payload.toolName, } } - Update
web/lib/im-realtime.tsto includeRealtimeAgentActivityPayloadinImRealtimeEnvelope. - In
web/lib/stores/support-chat.ts:- Add
activeAgentActivity: AgentActivityInfo | nulltoSupportChatStore. - In WebSocket
onMessage:- If
event.type === "agent.activity", updateactiveAgentActivityviaresolveAgentActivityState. - In
message.created, clearactiveAgentActivity: nullwhen message belongs to active conversation.
- If
- In
selectConversation, resetactiveAgentActivity: null.
- Add
- In
web/lib/stores/agent-conversations.ts:- Track
activeAgentActivitysimilarly for the operator's active conversation.
- Track
- Step 4: Run test to verify it passes
Run: node --test web/lib/agent-activity-state.test.mjs
Expected: PASS
- Step 5: Commit
git add web/lib/agent-activity-state.ts web/lib/agent-activity-state.test.mjs web/lib/im-realtime.ts web/lib/stores/support-chat.ts web/lib/stores/agent-conversations.ts
git commit -m "feat(store): handle agent.activity realtime events in support chat and agent stores"
Task 5: UI Agent Activity Bubble Components
Files:
- Create:
web/components/chat/agent-activity-bubble.tsx - Modify:
web/components/support-chat/message-list.tsx - Modify:
web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx - Test:
web/node_modules/.bin/tsc.cmd --noEmitandnode --test web/lib/*.test.mjs
Interfaces:
-
Consumes:
activeAgentActivity,useI18n() -
Produces:
<AgentActivityBubble stepCode={...} toolName={...} />component rendering:- Subtle AI avatar (
BotIconorSparklesIcon) - Animated pulse dots (
span className="animate-pulse") - Localized step label
- Subtle AI avatar (
- Seamless insertion at bottom of message list in both customer support chat and operator chat panel.
-
Step 1: Create
AgentActivityBubblecomponent
In web/components/chat/agent-activity-bubble.tsx:
"use client"
import { memo } from "react"
import { BotIcon, Loader2Icon } from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { ConversationMessageRow } from "@/components/chat/conversation-message-row"
import { ConversationMessageBubble } from "@/components/chat/conversation-message-bubble"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
export type AgentActivityBubbleProps = {
stepCode: string
toolName?: string
className?: string
}
export const AgentActivityBubble = memo(function AgentActivityBubble({
stepCode,
toolName,
className,
}: AgentActivityBubbleProps) {
const t = useI18n()
let label = t("agentActivity.default")
if (stepCode === "thinking") {
label = t("agentActivity.thinking")
} else if (stepCode === "searching_knowledge") {
label = t("agentActivity.searchingKnowledge")
} else if (stepCode === "calling_tool") {
label = t("agentActivity.callingTool", { name: toolName || "..." })
} else if (stepCode === "running_workflow") {
label = t("agentActivity.runningWorkflow", { name: toolName || "..." })
}
return (
<ConversationMessageRow
align="start"
avatar={
<Avatar className="size-8">
<AvatarFallback className="bg-primary/10 text-primary">
<BotIcon className="size-4" />
</AvatarFallback>
</Avatar>
}
contentClassName="max-w-[86%] gap-1.5"
className={cn("my-1 animate-in fade-in-50 duration-200", className)}
>
<ConversationMessageBubble
variant="system"
className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/50 px-3 py-2 text-xs text-muted-foreground shadow-sm"
>
<Loader2Icon className="size-3.5 animate-spin text-primary shrink-0" />
<span className="font-medium text-foreground/80">{label}</span>
</ConversationMessageBubble>
</ConversationMessageRow>
)
})
- Step 2: Mount bubble in
SupportChatMessageList
In web/components/support-chat/message-list.tsx:
-
Receive
agentActivity?: AgentActivityInfo | nullprop (or read fromuseSupportChatStore). -
Render
<AgentActivityBubble ... />right after the last message item insideConversationMessageScroller. -
Trigger
scrollerRef.current?.scrollToBottom()when activity state transitions from null to non-null if user was near bottom. -
Step 3: Mount bubble in operator
chat-panel.tsx
In web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx:
-
Read
activeAgentActivityfromuseAgentConversationsStore. -
Render
<AgentActivityBubble ... />inmessagesScrollerwhenactiveAgentActivitymatches current conversation. -
Step 4: Run TypeScript check and test suites
Run:
& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.jsonnode --test web/lib/*.test.mjs web/i18n/*.test.mjs& 'C:\Program Files\Go\bin\go.exe' test ./...Expected: ALL PASS with zero errors.
- Step 5: Commit
git add web/components/chat/agent-activity-bubble.tsx web/components/support-chat/message-list.tsx web/app/\(dashboard\)/dashboard/conversations/_components/chat-panel.tsx
git commit -m "feat(ui): display real-time agent activity indicators in customer and operator chat"