[aidan] fix/chat-history: stop retaining unopened histories

This commit is contained in:
abccodes
2026-07-13 21:28:09 -07:00
parent 65f57cda7f
commit a422d4d79e
7 changed files with 125 additions and 23 deletions
+32 -10
View File
@@ -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():
+59
View File
@@ -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"] == ""
@@ -206,20 +206,24 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ 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),
@@ -339,15 +339,20 @@ const AgentCard: React.FC<Props> = ({
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<Props> = ({
).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];
@@ -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;
+4
View File
@@ -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<string, MessageBranch>;
active_branch_id: string;
+7 -2
View File
@@ -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;