Files
agent-desk-ng/docs/superpowers/plans/2026-10-14-realtime-agent-activity.md

514 lines
19 KiB
Markdown

# 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`, and `web/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, structured `slog` logging).
- 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}`
- `RealtimeAgentActivityEvent` implementing `RealtimeDomainEvent`
- `WsService.PublishAgentActivity(conversation *models.Conversation, stepCode, toolName, status string)`
- [ ] **Step 1: Write the failing test**
Add `TestPublishAgentActivity` in `internal/services/ws_service_test.go`:
```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`**
1. In `internal/pkg/enums/im.go`, define:
```go
IMRealtimeEventAgentActivity = "agent.activity"
```
2. In `internal/services/ws_realtime_types.go`, define:
```go
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
}
```
3. In `internal/services/ws_service.go`, add:
```go
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**
```bash
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"`)
- [ ] **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`**
1. In `Run(ctx context.Context, req RunInput) (*RunResult, error)`:
- Emit start:
```go
if req.Conversation.ID > 0 {
svc.WsService.PublishAgentActivity(&req.Conversation, "thinking", "", "started")
}
```
- Before knowledge retrieval in `retrieveKnowledge`:
```go
if req.Conversation.ID > 0 {
svc.WsService.PublishAgentActivity(&req.Conversation, "searching_knowledge", "", "running")
}
```
- On exit (after `resolveAgentLoopReply` / error):
```go
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")
}
}
```
2. In `toolSearchExecutor`:
- When dispatching tool call:
```go
if runInput.Conversation.ID > 0 {
svc.WsService.PublishAgentActivity(&runInput.Conversation, "calling_tool", call.Name, "running")
}
```
- [ ] **Step 4: Run Go package tests**
Run: `& 'C:\Program Files\Go\bin\go.exe' test ./internal/ai/application/runtime/...`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
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.thinking`
- `agentActivity.searchingKnowledge`
- `agentActivity.callingTool`
- `agentActivity.runningWorkflow`
- `agentActivity.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**
1. In `web/messages/en-US.json`:
```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..."
}
```
2. In `web/messages/ru-RU.json`:
```json
"agentActivity": {
"thinking": "AI-ассистент думает...",
"searchingKnowledge": "Поиск в базе знаний...",
"callingTool": "Выполнение действия: {name}...",
"runningWorkflow": "Выполнение сценария: {name}...",
"default": "AI-ассистент обрабатывает запрос..."
}
```
3. In `web/messages/zh-CN.json`:
```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**
```bash
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: `ImRealtimeEnvelope` with `type: "agent.activity"`, payload `RealtimeAgentActivityPayload`
- Produces:
- Store slice `activeAgentActivity: { conversationId: number; stepCode: string; toolName?: string } | null`
- Reducer/event-handler updating `activeAgentActivity` on `agent.activity`, and clearing on `message.created`, `conversation.closed`, or conversation switch.
- [ ] **Step 1: Write the failing test**
Create `web/lib/agent-activity-state.test.mjs`:
```javascript
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.ts` and wire into stores**
1. Create `web/lib/agent-activity-state.ts` with pure transition helper:
```typescript
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,
}
}
```
2. Update `web/lib/im-realtime.ts` to include `RealtimeAgentActivityPayload` in `ImRealtimeEnvelope`.
3. In `web/lib/stores/support-chat.ts`:
- Add `activeAgentActivity: AgentActivityInfo | null` to `SupportChatStore`.
- In WebSocket `onMessage`:
- If `event.type === "agent.activity"`, update `activeAgentActivity` via `resolveAgentActivityState`.
- In `message.created`, clear `activeAgentActivity: null` when message belongs to active conversation.
- In `selectConversation`, reset `activeAgentActivity: null`.
4. In `web/lib/stores/agent-conversations.ts`:
- Track `activeAgentActivity` similarly for the operator's active conversation.
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test web/lib/agent-activity-state.test.mjs`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
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 --noEmit` and `node --test web/lib/*.test.mjs`
**Interfaces:**
- Consumes: `activeAgentActivity`, `useI18n()`
- Produces:
- `<AgentActivityBubble stepCode={...} toolName={...} />` component rendering:
- Subtle AI avatar (`BotIcon` or `SparklesIcon`)
- Animated pulse dots (`span className="animate-pulse"`)
- Localized step label
- Seamless insertion at bottom of message list in both customer support chat and operator chat panel.
- [ ] **Step 1: Create `AgentActivityBubble` component**
In `web/components/chat/agent-activity-bubble.tsx`:
```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 | null` prop (or read from `useSupportChatStore`).
- Render `<AgentActivityBubble ... />` right after the last message item inside `ConversationMessageScroller`.
- 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 `activeAgentActivity` from `useAgentConversationsStore`.
- Render `<AgentActivityBubble ... />` in `messagesScroller` when `activeAgentActivity` matches current conversation.
- [ ] **Step 4: Run TypeScript check and test suites**
Run:
1. `& "web/node_modules/.bin/tsc.cmd" --noEmit --project web/tsconfig.json`
2. `node --test web/lib/*.test.mjs web/i18n/*.test.mjs`
3. `& 'C:\Program Files\Go\bin\go.exe' test ./...`
Expected: ALL PASS with zero errors.
- [ ] **Step 5: Commit**
```bash
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"
```