From ca05aba6ead2f4644825710c5bf6e8d5d65a2527 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <87276974+itsddvn@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:50:47 +0700 Subject: [PATCH] fix(chat): show all active agents in chat dropdown (#48) * fix(channels): start outbound dispatcher before channel check StartAll() returned early when no channels existed at boot, skipping the dispatchOutbound goroutine. Channels loaded later via Reload() assumed the dispatcher was running, causing outbound messages (agent responses) to never reach Telegram. Move dispatcher startup before the empty-channel early return so dynamically loaded channels always have a running consumer. * feat(ui): add LLM provider warning on overview page and ignore plans dir Show alert when no providers configured or all disabled, linking to provider settings. Add plans/ to .gitignore. * feat(onboard): add provider connectivity verification and placeholder seeding - Add onboard_verify.go: verify API keys via POST to chat/completions endpoint (401/403 = fatal, 400/422 = key valid, 5xx = warn) - Verify all configured providers before seeding in auto-onboard - Seed disabled placeholder providers (OpenRouter, Synthetic, AliCloud API/Sub) for UI discoverability after managed data seeding * fix: use model ID as display name in OpenAI-compatible provider list The `owned_by` field (e.g. "system") was incorrectly used as the model display name, causing all models to show as "system" in the UI dropdown for providers like AliCloud DashScope. * fix(chat): show all active agents in chat dropdown Chat agent selector showed "No agents available" because: - WS agents.list only returned in-memory router cache (empty in managed mode) - useEffect had stale [ws] dep that never re-fired after connect Frontend: switch agent-selector from WS to HTTP /v1/agents API with proper access control (ListAccessible). Backend: add store-backed agents.list for WS consumers + Router.IsRunning() helper. * fix(security): prevent agent list leaking on empty userID or store error - Return error instead of falling through to unfiltered router cache when userID is empty or DB query fails in managed mode - Add empty-string guard to isOwnerUser to prevent false owner match Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: ntduc Co-authored-by: viettranx Co-authored-by: Claude Opus 4.6 --- internal/agent/router.go | 10 ++++ internal/gateway/methods/agents.go | 58 ++++++++++++++++++- ui/web/src/components/chat/agent-selector.tsx | 41 +++++++------ 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/internal/agent/router.go b/internal/agent/router.go index 0521550e..575dda61 100644 --- a/internal/agent/router.go +++ b/internal/agent/router.go @@ -130,6 +130,16 @@ func (r *Router) ListInfo() []AgentInfo { return infos } +// IsRunning checks if a specific agent is currently running (cached in router). +func (r *Router) IsRunning(agentID string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + if entry, ok := r.agents[agentID]; ok { + return entry.agent.IsRunning() + } + return false +} + // --- Active Run Tracking (matching TS chat-abort.ts) --- // ActiveRun tracks a running agent invocation so it can be aborted via chat.abort. diff --git a/internal/gateway/methods/agents.go b/internal/gateway/methods/agents.go index 643ee62a..9d0b3565 100644 --- a/internal/gateway/methods/agents.go +++ b/internal/gateway/methods/agents.go @@ -33,6 +33,19 @@ func NewAgentsMethods(agents *agent.Router, cfg *config.Config, cfgPath, workspa return &AgentsMethods{agents: agents, cfg: cfg, cfgPath: cfgPath, workspace: workspace, agentStore: agentStore, isManaged: isManaged, interceptor: interceptor} } +// isOwnerUser checks if the given user ID is in the configured owner IDs. +func (m *AgentsMethods) isOwnerUser(userID string) bool { + if userID == "" { + return false + } + for _, id := range m.cfg.Gateway.OwnerIDs { + if id == userID { + return true + } + } + return false +} + func (m *AgentsMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodAgent, m.handleAgent) router.Register(protocol.MethodAgentWait, m.handleAgentWait) @@ -93,7 +106,50 @@ func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Clien })) } -func (m *AgentsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *AgentsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + // In managed mode, query the store so ALL accessible agents appear (not just router-cached ones). + if m.isManaged && m.agentStore != nil { + userID := client.UserID() + if userID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "user context required")) + return + } + + var agents []store.AgentData + var err error + if m.isOwnerUser(userID) { + agents, err = m.agentStore.List(ctx, "") + } else { + agents, err = m.agentStore.ListAccessible(ctx, userID) + } + if err != nil { + slog.Warn("agents.list: store query failed", "error", err) + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to list agents")) + return + } + + infos := make([]map[string]interface{}, 0, len(agents)) + for _, a := range agents { + if a.Status != store.AgentStatusActive { + continue + } + infos = append(infos, map[string]interface{}{ + "id": a.AgentKey, + "name": a.DisplayName, + "model": a.Model, + "provider": a.Provider, + "agentType": a.AgentType, + "status": a.Status, + "isRunning": m.agents.IsRunning(a.AgentKey), + }) + } + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "agents": infos, + })) + return + } + + // Standalone mode: return router-cached agents. infos := m.agents.ListInfo() client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ "agents": infos, diff --git a/ui/web/src/components/chat/agent-selector.tsx b/ui/web/src/components/chat/agent-selector.tsx index f5704164..b40f85e5 100644 --- a/ui/web/src/components/chat/agent-selector.tsx +++ b/ui/web/src/components/chat/agent-selector.tsx @@ -1,8 +1,8 @@ import { useState, useEffect } from "react"; import { Bot, ChevronDown } from "lucide-react"; -import { useWs } from "@/hooks/use-ws"; -import { Methods } from "@/api/protocol"; -import type { AgentInfo } from "@/types/agent"; +import { useHttp } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; +import type { AgentData } from "@/types/agent"; interface AgentSelectorProps { value: string; @@ -10,18 +10,23 @@ interface AgentSelectorProps { } export function AgentSelector({ value, onChange }: AgentSelectorProps) { - const ws = useWs(); - const [agents, setAgents] = useState([]); + const http = useHttp(); + const connected = useAuthStore((s) => s.connected); + const [agents, setAgents] = useState([]); const [open, setOpen] = useState(false); useEffect(() => { - if (!ws.isConnected) return; - ws.call<{ agents: AgentInfo[] }>(Methods.AGENTS_LIST) - .then((res) => setAgents(res.agents ?? [])) + if (!connected) return; + http + .get<{ agents: AgentData[] }>("/v1/agents") + .then((res) => { + const active = (res.agents ?? []).filter((a) => a.status === "active"); + setAgents(active); + }) .catch(() => {}); - }, [ws]); + }, [http, connected]); - const selected = agents.find((a) => a.id === value); + const selected = agents.find((a) => a.agent_key === value); return (
@@ -32,7 +37,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) { > - {selected?.name ?? (value || "Select agent")} + {selected?.display_name ?? selected?.agent_key ?? (value || "Select agent")} @@ -48,20 +53,22 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) { )} {agents.map((agent) => ( ))}