diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 34291509..07c059d0 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -1,15 +1,16 @@ -from backend.config.Apps import SubApp -from backend.apps.agents.agent_manager import agent_manager -from backend.apps.agents.core.ws_manager import ws_manager -from backend.apps.agents.core.models import AgentConfig, ApprovalResponse -from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input -from contextlib import asynccontextmanager -from fastapi import WebSocket, WebSocketDisconnect, HTTPException -from fastapi.responses import JSONResponse import asyncio -import json import logging import time +from contextlib import asynccontextmanager +from typing import Any, Dict + +from fastapi import HTTPException +from typeguard import typechecked + +from backend.apps.agents.agent_manager import agent_manager +from backend.apps.agents.core.models import AgentConfig, AgentSession, ApprovalResponse +from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input +from backend.config.Apps import SubApp logger = logging.getLogger(__name__) @@ -39,10 +40,31 @@ async def agents_lifespan(): agents = SubApp("agents", agents_lifespan) +@typechecked +def p_session_list_item(session: AgentSession) -> Dict[str, Any]: + """Serialize dashboard metadata without retaining the full chat history.""" + data = session.model_dump(mode="json", exclude={"messages"}) + messages = session.messages + last_content = messages[-1].content if messages else "" + first_user_content = next( + (message.content for message in messages if message.role == "user"), + "", + ) + data.update( + messages=[], + last_message_preview=last_content[:120] if isinstance(last_content, str) else "", + first_user_message=( + first_user_content[:200] if isinstance(first_user_content, str) else "" + ), + message_count=len(messages), + ) + return data + + @agents.router.get("/sessions") async def list_sessions(dashboard_id: str = ""): sessions = agent_manager.get_all_sessions(dashboard_id=dashboard_id or None) - return {"sessions": [s.model_dump(mode="json") for s in sessions]} + return {"sessions": [p_session_list_item(s) for s in sessions]} @agents.router.get("/activity") async def agent_activity(): diff --git a/backend/tests/test_agent_session_list.py b/backend/tests/test_agent_session_list.py new file mode 100644 index 00000000..7fbefeb6 --- /dev/null +++ b/backend/tests/test_agent_session_list.py @@ -0,0 +1,59 @@ +import asyncio + +from pytest import MonkeyPatch + +from backend.apps.agents import agents as agents_module +from backend.apps.agents.core.models import AgentSession, Message + + +def test_session_list_item_replaces_messages_with_compact_metadata( + monkeypatch: MonkeyPatch, +) -> None: + first_prompt = "p" * 250 + last_reply = "r" * 150 + session = AgentSession( + name="Test session", + messages=[ + Message(role="system", content="system"), + Message(role="user", content=first_prompt), + Message(role="assistant", content=last_reply), + ], + ) + + monkeypatch.setattr( + agents_module.agent_manager, + "get_all_sessions", + lambda dashboard_id=None: [session], + ) + item = asyncio.run(agents_module.list_sessions())["sessions"][0] + + assert item["messages"] == [] + assert item["message_count"] == 3 + assert item["first_user_message"] == first_prompt[:200] + assert item["last_message_preview"] == last_reply[:120] + + +def test_session_list_item_handles_empty_and_non_text_content( + monkeypatch: MonkeyPatch, +) -> None: + sessions = [ + AgentSession(name="Empty"), + AgentSession( + name="Images", + messages=[Message(role="user", content=[{"type": "image"}])], + ), + ] + monkeypatch.setattr( + agents_module.agent_manager, + "get_all_sessions", + lambda dashboard_id=None: sessions, + ) + empty, non_text = asyncio.run(agents_module.list_sessions())["sessions"] + + assert empty["messages"] == [] + assert empty["message_count"] == 0 + assert empty["first_user_message"] == "" + assert empty["last_message_preview"] == "" + assert non_text["message_count"] == 1 + assert non_text["first_user_message"] == "" + assert non_text["last_message_preview"] == "" diff --git a/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx b/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx index 2f933f6d..65aa7383 100644 --- a/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx +++ b/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx @@ -206,20 +206,24 @@ const BrowserAgentInlineFeed: React.FC = ({ parentSessionId, browserId }) } }, [browserSessions.length, parentSessionId, dispatch]); - const sessionsWithEntries = useMemo(() => { + const sessionsWithHistoricalEntries = useMemo(() => { return browserSessions.map((session) => { const entries: FeedEntry[] = []; for (const msg of session.messages) { const entry = formatMessage(msg); if (entry) entries.push(entry); } - const stream: StreamingMessage | undefined = streamingBySession[session.id]; - if (stream?.role === 'assistant' && stream.content) { - entries.push({ type: 'thought', text: stream.content }); - } return { session, entries }; }); - }, [browserSessions, streamingBySession]); + }, [browserSessions]); + + const sessionsWithEntries = sessionsWithHistoricalEntries.map(({ session, entries }) => { + const stream: StreamingMessage | undefined = streamingBySession[session.id]; + if (stream?.role === 'assistant' && stream.content) { + return { session, entries: [...entries, { type: 'thought' as const, text: stream.content }] }; + } + return { session, entries }; + }); const totalMessages = browserSessions.reduce( (n, s) => n + s.messages.length + (streamingBySession[s.id] ? 1 : 0), diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 693e898c..2022c0c5 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -339,15 +339,20 @@ const AgentCard: React.FC = ({ return Boolean(sourceWorkflow); }, [workflowRunsMap, sourceWorkflow, session.id, session.workflow_test_state]); const hasUserPrompt = useMemo( - () => (session.messages || []).some((m) => m.role === 'user' && !m.hidden), - [session.messages], + () => session.messages.length > 0 + ? session.messages.some((m) => m.role === 'user' && !m.hidden) + : !!session.first_user_message, + [session.messages, session.first_user_message], ); + const messageCount = session.messages.length > 0 + ? session.messages.length + : session.message_count ?? 0; const isConvertBlockedByTurn = session.status !== 'completed' && session.status !== 'stopped'; const showConvertToWorkflow = !session.is_welcome_draft && !isWorkflowRunnerSession && hasUserPrompt && - (session.messages.length >= 2 || isConvertBlockedByTurn || !!workflowSuggestion); + (messageCount >= 2 || isConvertBlockedByTurn || !!workflowSuggestion); const canConvertToWorkflow = showConvertToWorkflow && !isConvertBlockedByTurn; // Curated picker label with a tidy fallback for unknowns. const friendlyModelLabel = useMemo(() => { @@ -653,7 +658,7 @@ const AgentCard: React.FC = ({ ).slice(0, 120) : lastMessage && typeof lastMessage.content === 'string' ? lastMessage.content.slice(0, 120) - : ''; + : session.last_message_preview ?? ''; const hasPending = session.pending_approvals.length > 0; const pendingReq = session.pending_approvals[0]; diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 40472bcd..1db5dfd4 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -366,7 +366,10 @@ export function useDashboardLifecycle({ if (!dash) return; if (!dash.auto_named && dash.name !== 'Untitled Dashboard') return; const hasUserMessage = Object.values(sessions).some( - (s) => s.dashboard_id === dashboardId && s.messages?.some((m) => m.role === 'user'), + (s) => s.dashboard_id === dashboardId && ( + s.messages?.some((m) => m.role === 'user') || + (s.messages.length === 0 && !!s.first_user_message) + ), ); if (!hasUserMessage) return; namedOnFirstMessageRef.current = dashboardId; diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 18c72f5b..11db25a8 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -84,6 +84,10 @@ export interface AgentSession { cost_usd: number; tokens: { input: number; output: number }; messages: AgentMessage[]; + /** Compact dashboard-list metadata; full messages are fetched when a chat opens. */ + last_message_preview?: string; + first_user_message?: string; + message_count?: number; pending_approvals: ApprovalRequest[]; branches: Record; active_branch_id: string; diff --git a/frontend/src/shared/state/sessionDisplay.ts b/frontend/src/shared/state/sessionDisplay.ts index 1ecc7db7..c68c5af3 100644 --- a/frontend/src/shared/state/sessionDisplay.ts +++ b/frontend/src/shared/state/sessionDisplay.ts @@ -36,8 +36,13 @@ export function displayChatTitle(session: AgentSession | null | undefined): stri return session.name; } const firstUserMsg = session.messages?.find((m) => m.role === 'user'); - if (firstUserMsg && typeof firstUserMsg.content === 'string') { - const truncated = truncateForTitle(firstUserMsg.content); + const firstUserContent = firstUserMsg && typeof firstUserMsg.content === 'string' + ? firstUserMsg.content + : session.messages.length === 0 + ? session.first_user_message + : undefined; + if (firstUserContent) { + const truncated = truncateForTitle(firstUserContent); if (truncated) return truncated; } return session.mode === 'view-builder' ? 'Untitled App' : SESSION_NAME_PLACEHOLDER;