diff --git a/backend/src/api/routes/__tests__/test_assistant_api.py b/backend/src/api/routes/__tests__/test_assistant_api.py index 6e936d7..7c0fa79 100644 --- a/backend/src/api/routes/__tests__/test_assistant_api.py +++ b/backend/src/api/routes/__tests__/test_assistant_api.py @@ -9,6 +9,7 @@ import os import asyncio from types import SimpleNamespace +from datetime import datetime, timedelta # Force isolated sqlite databases for test module before dependencies import. os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/ss_tools_assistant_api.db") @@ -391,4 +392,144 @@ def test_llm_validation_missing_dashboard_returns_needs_clarification(): # [/DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function] + + +# [DEF:test_list_conversations_groups_by_conversation_and_marks_archived:Function] +# @PURPOSE: Conversations endpoint must group messages and compute archived marker by inactivity threshold. +# @PRE: Fake DB contains two conversations with different update timestamps. +# @POST: Response includes both conversations with archived flag set for stale one. +def test_list_conversations_groups_by_conversation_and_marks_archived(): + _clear_assistant_state() + db = _FakeDb() + now = datetime.utcnow() + + db.add( + AssistantMessageRecord( + id="m-1", + user_id="u-admin", + conversation_id="conv-active", + role="user", + text="active chat", + created_at=now, + ) + ) + db.add( + AssistantMessageRecord( + id="m-2", + user_id="u-admin", + conversation_id="conv-old", + role="user", + text="old chat", + created_at=now - timedelta(days=assistant_module.ASSISTANT_ARCHIVE_AFTER_DAYS + 2), + ) + ) + + result = _run_async( + assistant_module.list_conversations( + page=1, + page_size=20, + include_archived=True, + search=None, + current_user=_admin_user(), + db=db, + ) + ) + + assert result["total"] == 2 + by_id = {item["conversation_id"]: item for item in result["items"]} + assert by_id["conv-active"]["archived"] is False + assert by_id["conv-old"]["archived"] is True + + +# [/DEF:test_list_conversations_groups_by_conversation_and_marks_archived:Function] + + +# [DEF:test_history_from_latest_returns_recent_page_first:Function] +# @PURPOSE: History endpoint from_latest mode must return newest page while preserving chronological order in chunk. +# @PRE: Conversation has more messages than single page size. +# @POST: First page returns latest messages and has_next indicates older pages exist. +def test_history_from_latest_returns_recent_page_first(): + _clear_assistant_state() + db = _FakeDb() + base_time = datetime.utcnow() - timedelta(minutes=10) + conv_id = "conv-paginated" + for i in range(4, -1, -1): + db.add( + AssistantMessageRecord( + id=f"msg-{i}", + user_id="u-admin", + conversation_id=conv_id, + role="user" if i % 2 == 0 else "assistant", + text=f"message-{i}", + created_at=base_time + timedelta(minutes=i), + ) + ) + + result = _run_async( + assistant_module.get_history( + page=1, + page_size=2, + conversation_id=conv_id, + from_latest=True, + current_user=_admin_user(), + db=db, + ) + ) + + assert result["from_latest"] is True + assert result["has_next"] is True + # Chunk is chronological while representing latest page. + assert [item["text"] for item in result["items"]] == ["message-3", "message-4"] + + +# [/DEF:test_history_from_latest_returns_recent_page_first:Function] + + +# [DEF:test_list_conversations_archived_only_filters_active:Function] +# @PURPOSE: archived_only mode must return only archived conversations. +# @PRE: Dataset includes one active and one archived conversation. +# @POST: Only archived conversation remains in response payload. +def test_list_conversations_archived_only_filters_active(): + _clear_assistant_state() + db = _FakeDb() + now = datetime.utcnow() + db.add( + AssistantMessageRecord( + id="m-active", + user_id="u-admin", + conversation_id="conv-active-2", + role="user", + text="active", + created_at=now, + ) + ) + db.add( + AssistantMessageRecord( + id="m-archived", + user_id="u-admin", + conversation_id="conv-archived-2", + role="user", + text="archived", + created_at=now - timedelta(days=assistant_module.ASSISTANT_ARCHIVE_AFTER_DAYS + 3), + ) + ) + + result = _run_async( + assistant_module.list_conversations( + page=1, + page_size=20, + include_archived=True, + archived_only=True, + search=None, + current_user=_admin_user(), + db=db, + ) + ) + + assert result["total"] == 1 + assert result["items"][0]["conversation_id"] == "conv-archived-2" + assert result["items"][0]["archived"] is True + + +# [/DEF:test_list_conversations_archived_only_filters_active:Function] # [/DEF:backend.src.api.routes.__tests__.test_assistant_api:Module] diff --git a/backend/src/api/routes/assistant.py b/backend/src/api/routes/assistant.py index 6efefd9..4346771 100644 --- a/backend/src/api/routes/assistant.py +++ b/backend/src/api/routes/assistant.py @@ -103,6 +103,8 @@ CONVERSATIONS: Dict[Tuple[str, str], List[Dict[str, Any]]] = {} USER_ACTIVE_CONVERSATION: Dict[str, str] = {} CONFIRMATIONS: Dict[str, ConfirmationRecord] = {} ASSISTANT_AUDIT: Dict[str, List[Dict[str, Any]]] = {} +ASSISTANT_ARCHIVE_AFTER_DAYS = 14 +ASSISTANT_MESSAGE_TTL_DAYS = 90 INTENT_PERMISSION_CHECKS: Dict[str, List[Tuple[str, str]]] = { "get_task_status": [("tasks", "READ")], @@ -334,6 +336,68 @@ def _resolve_or_create_conversation(user_id: str, conversation_id: Optional[str] # [/DEF:_resolve_or_create_conversation:Function] +# [DEF:_cleanup_history_ttl:Function] +# @PURPOSE: Enforce assistant message retention window by deleting expired rows and in-memory records. +# @PRE: db session is available and user_id references current actor scope. +# @POST: Messages older than ASSISTANT_MESSAGE_TTL_DAYS are removed from persistence and memory mirrors. +def _cleanup_history_ttl(db: Session, user_id: str): + cutoff = datetime.utcnow() - timedelta(days=ASSISTANT_MESSAGE_TTL_DAYS) + try: + query = db.query(AssistantMessageRecord).filter( + AssistantMessageRecord.user_id == user_id, + AssistantMessageRecord.created_at < cutoff, + ) + if hasattr(query, "delete"): + query.delete(synchronize_session=False) + db.commit() + except Exception as exc: + db.rollback() + logger.warning(f"[assistant.history][ttl_cleanup_failed] user={user_id} error={exc}") + + stale_keys: List[Tuple[str, str]] = [] + for key, items in CONVERSATIONS.items(): + if key[0] != user_id: + continue + kept = [] + for item in items: + created_at = item.get("created_at") + if isinstance(created_at, datetime) and created_at < cutoff: + continue + kept.append(item) + if kept: + CONVERSATIONS[key] = kept + else: + stale_keys.append(key) + for key in stale_keys: + CONVERSATIONS.pop(key, None) +# [/DEF:_cleanup_history_ttl:Function] + + +# [DEF:_is_conversation_archived:Function] +# @PURPOSE: Determine archived state for a conversation based on last update timestamp. +# @PRE: updated_at can be null for empty conversations. +# @POST: Returns True when conversation inactivity exceeds archive threshold. +def _is_conversation_archived(updated_at: Optional[datetime]) -> bool: + if not updated_at: + return False + cutoff = datetime.utcnow() - timedelta(days=ASSISTANT_ARCHIVE_AFTER_DAYS) + return updated_at < cutoff +# [/DEF:_is_conversation_archived:Function] + + +# [DEF:_coerce_query_bool:Function] +# @PURPOSE: Normalize bool-like query values for compatibility in direct handler invocations/tests. +# @PRE: value may be bool, string, or FastAPI Query metadata object. +# @POST: Returns deterministic boolean flag. +def _coerce_query_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return False +# [/DEF:_coerce_query_bool:Function] + + # [DEF:_extract_id:Function] # @PURPOSE: Extract first regex match group from text by ordered pattern list. # @PRE: patterns contain at least one capture group. @@ -1385,6 +1449,93 @@ async def cancel_operation( # [/DEF:cancel_operation:Function] +# [DEF:list_conversations:Function] +# @PURPOSE: Return paginated conversation list for current user with archived flag and last message preview. +# @PRE: Authenticated user context and valid pagination params. +# @POST: Conversations are grouped by conversation_id sorted by latest activity descending. +# @RETURN: Dict with items, paging metadata, and archive segmentation counts. +@router.get("/conversations") +async def list_conversations( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + include_archived: bool = Query(False), + archived_only: bool = Query(False), + search: Optional[str] = Query(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + with belief_scope("assistant.conversations"): + user_id = current_user.id + include_archived = _coerce_query_bool(include_archived) + archived_only = _coerce_query_bool(archived_only) + _cleanup_history_ttl(db, user_id) + + rows = ( + db.query(AssistantMessageRecord) + .filter(AssistantMessageRecord.user_id == user_id) + .order_by(desc(AssistantMessageRecord.created_at)) + .all() + ) + + summary: Dict[str, Dict[str, Any]] = {} + for row in rows: + conv_id = row.conversation_id + if not conv_id: + continue + created_at = row.created_at or datetime.utcnow() + if conv_id not in summary: + summary[conv_id] = { + "conversation_id": conv_id, + "title": "", + "updated_at": created_at, + "last_message": row.text, + "last_role": row.role, + "last_state": row.state, + "last_task_id": row.task_id, + "message_count": 0, + } + item = summary[conv_id] + item["message_count"] += 1 + if row.role == "user" and row.text and not item["title"]: + item["title"] = row.text.strip()[:80] + + items = [] + search_term = search.lower().strip() if search else "" + archived_total = sum(1 for c in summary.values() if _is_conversation_archived(c.get("updated_at"))) + active_total = len(summary) - archived_total + for conv in summary.values(): + conv["archived"] = _is_conversation_archived(conv.get("updated_at")) + if not conv.get("title"): + conv["title"] = f"Conversation {conv['conversation_id'][:8]}" + if search_term: + haystack = f"{conv.get('title', '')} {conv.get('last_message', '')}".lower() + if search_term not in haystack: + continue + if archived_only and not conv["archived"]: + continue + if not archived_only and not include_archived and conv["archived"]: + continue + updated = conv.get("updated_at") + conv["updated_at"] = updated.isoformat() if isinstance(updated, datetime) else None + items.append(conv) + + items.sort(key=lambda x: x.get("updated_at") or "", reverse=True) + total = len(items) + start = (page - 1) * page_size + page_items = items[start : start + page_size] + + return { + "items": page_items, + "total": total, + "page": page, + "page_size": page_size, + "has_next": start + page_size < total, + "active_total": active_total, + "archived_total": archived_total, + } +# [/DEF:list_conversations:Function] + + @router.get("/history") # [DEF:get_history:Function] # @PURPOSE: Retrieve paginated assistant conversation history for current user. @@ -1395,24 +1546,41 @@ async def get_history( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), conversation_id: Optional[str] = Query(None), + from_latest: bool = Query(False), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): with belief_scope("assistant.history"): user_id = current_user.id + _cleanup_history_ttl(db, user_id) conv_id = _resolve_or_create_conversation(user_id, conversation_id, db) - query = ( + base_query = ( db.query(AssistantMessageRecord) .filter( AssistantMessageRecord.user_id == user_id, AssistantMessageRecord.conversation_id == conv_id, ) - .order_by(AssistantMessageRecord.created_at.asc()) ) - total = query.count() + total = base_query.count() start = (page - 1) * page_size - rows = query.offset(start).limit(page_size).all() + if from_latest: + rows = ( + base_query + .order_by(desc(AssistantMessageRecord.created_at)) + .offset(start) + .limit(page_size) + .all() + ) + rows = list(reversed(rows)) + else: + rows = ( + base_query + .order_by(AssistantMessageRecord.created_at.asc()) + .offset(start) + .limit(page_size) + .all() + ) persistent_items = [ { @@ -1437,6 +1605,7 @@ async def get_history( "page": page, "page_size": page_size, "has_next": start + page_size < total, + "from_latest": from_latest, "conversation_id": conv_id, } # [/DEF:get_history:Function] diff --git a/frontend/src/lib/api/assistant.js b/frontend/src/lib/api/assistant.js index 60d97e0..d0e4111 100644 --- a/frontend/src/lib/api/assistant.js +++ b/frontend/src/lib/api/assistant.js @@ -39,12 +39,40 @@ export function cancelAssistantOperation(confirmationId) { // @PURPOSE: Retrieve paginated assistant conversation history. // @PRE: page/pageSize are positive integers. // @POST: Returns a paginated payload with history items. -export function getAssistantHistory(page = 1, pageSize = 20, conversationId = null) { +export function getAssistantHistory(page = 1, pageSize = 20, conversationId = null, fromLatest = false) { const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); if (conversationId) { params.append('conversation_id', conversationId); } + if (fromLatest) { + params.append('from_latest', 'true'); + } return requestApi(`/assistant/history?${params.toString()}`, 'GET'); } // [/DEF:getAssistantHistory:Function] + +// [DEF:getAssistantConversations:Function] +// @PURPOSE: Retrieve paginated conversation list for assistant sidebar/history switcher. +// @PRE: page/pageSize are positive integers. +// @POST: Returns paginated conversation summaries. +export function getAssistantConversations( + page = 1, + pageSize = 20, + includeArchived = false, + search = '', + archivedOnly = false, +) { + const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); + if (includeArchived) { + params.append('include_archived', 'true'); + } + if (archivedOnly) { + params.append('archived_only', 'true'); + } + if (search?.trim()) { + params.append('search', search.trim()); + } + return requestApi(`/assistant/conversations?${params.toString()}`, 'GET'); +} +// [/DEF:getAssistantConversations:Function] // [/DEF:frontend.src.lib.api.assistant:Module] diff --git a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte index 746e58e..7cca1e2 100644 --- a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte +++ b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte @@ -38,12 +38,27 @@ confirmAssistantOperation, cancelAssistantOperation, getAssistantHistory, + getAssistantConversations, } from '$lib/api/assistant.js'; + const HISTORY_PAGE_SIZE = 30; + const CONVERSATIONS_PAGE_SIZE = 20; + let input = ''; let loading = false; let loadingHistory = false; + let loadingMoreHistory = false; + let loadingConversations = false; let messages = []; + let conversations = []; + let conversationFilter = 'active'; + let activeConversationsTotal = 0; + let archivedConversationsTotal = 0; + let historyPage = 1; + let historyHasNext = false; + let conversationsPage = 1; + let conversationsHasNext = false; + let historyViewport = null; let initialized = false; $: isOpen = $assistantChatStore?.isOpen || false; @@ -60,11 +75,13 @@ if (loadingHistory || !isOpen) return; loadingHistory = true; try { - const history = await getAssistantHistory(1, 50, conversationId); + const history = await getAssistantHistory(1, HISTORY_PAGE_SIZE, conversationId, true); messages = (history.items || []).map((msg) => ({ ...msg, actions: msg.actions || msg.metadata?.actions || [], })); + historyPage = 1; + historyHasNext = Boolean(history.has_next); if (history.conversation_id && history.conversation_id !== conversationId) { setAssistantConversationId(history.conversation_id); } @@ -78,10 +95,82 @@ } // [/DEF:loadHistory:Function] + // [DEF:loadConversations:Function] + /** + * @PURPOSE: Load paginated conversation summaries for quick switching UI. + * @PRE: Panel is open and request not already running. + * @POST: conversations list refreshed or appended based on page. + */ + async function loadConversations(reset = false) { + if (loadingConversations || !isOpen) return; + loadingConversations = true; + try { + const page = reset ? 1 : conversationsPage + 1; + const includeArchived = conversationFilter === 'archived'; + const archivedOnly = conversationFilter === 'archived'; + const response = await getAssistantConversations( + page, + CONVERSATIONS_PAGE_SIZE, + includeArchived, + '', + archivedOnly, + ); + const rows = response.items || []; + conversations = reset ? rows : [...conversations, ...rows]; + conversationsPage = page; + conversationsHasNext = Boolean(response.has_next); + activeConversationsTotal = response.active_total ?? activeConversationsTotal; + archivedConversationsTotal = response.archived_total ?? archivedConversationsTotal; + } catch (err) { + console.error('[AssistantChatPanel][Coherence:Failed] Failed to load conversations', err); + } finally { + loadingConversations = false; + } + } + // [/DEF:loadConversations:Function] + + // [DEF:loadOlderMessages:Function] + /** + * @PURPOSE: Lazy-load older messages for active conversation when user scrolls to top. + * @PRE: History has next page and active conversation is known. + * @POST: Older messages are prepended while preserving order. + */ + async function loadOlderMessages() { + if (loadingMoreHistory || loadingHistory || !historyHasNext || !conversationId) return; + loadingMoreHistory = true; + try { + const nextPage = historyPage + 1; + const history = await getAssistantHistory(nextPage, HISTORY_PAGE_SIZE, conversationId, true); + const chunk = (history.items || []).map((msg) => ({ + ...msg, + actions: msg.actions || msg.metadata?.actions || [], + })); + const existingIds = new Set(messages.map((m) => m.message_id)); + const uniqueChunk = chunk.filter((m) => !existingIds.has(m.message_id)); + messages = [...uniqueChunk, ...messages]; + historyPage = nextPage; + historyHasNext = Boolean(history.has_next); + } catch (err) { + console.error('[AssistantChatPanel][Coherence:Failed] Failed to load older messages', err); + } finally { + loadingMoreHistory = false; + } + } + // [/DEF:loadOlderMessages:Function] + $: if (isOpen && !initialized) { + loadConversations(true); loadHistory(); } + $: if (isOpen && initialized && conversationId) { + // Re-load only when user switched to another conversation. + const currentFirstConversationId = messages.length ? messages[0].conversation_id : conversationId; + if (currentFirstConversationId !== conversationId) { + loadHistory(); + } + } + // [DEF:appendLocalUserMessage:Function] /** * @PURPOSE: Add optimistic local user message before backend response. @@ -124,6 +213,29 @@ } // [/DEF:appendAssistantResponse:Function] + function buildConversationTitle(conversation) { + if (conversation?.title?.trim()) return conversation.title.trim(); + if (!conversation?.conversation_id) return 'Conversation'; + return `Conversation ${conversation.conversation_id.slice(0, 8)}`; + } + + function setConversationFilter(filter) { + if (filter !== 'active' && filter !== 'archived') return; + if (conversationFilter === filter) return; + conversationFilter = filter; + conversations = []; + conversationsPage = 1; + conversationsHasNext = false; + loadConversations(true); + } + + function formatConversationTime(iso) { + if (!iso) return ''; + const dt = new Date(iso); + if (Number.isNaN(dt.getTime())) return ''; + return dt.toLocaleString(); + } + // [DEF:handleSend:Function] /** * @PURPOSE: Submit user command to assistant orchestration API. @@ -150,6 +262,7 @@ } appendAssistantResponse(response); + await loadConversations(true); } catch (err) { appendAssistantResponse({ response_id: `error-${Date.now()}`, @@ -164,6 +277,40 @@ } // [/DEF:handleSend:Function] + // [DEF:selectConversation:Function] + /** + * @PURPOSE: Switch active chat context to selected conversation item. + * @PRE: conversation carries valid conversation_id. + * @POST: conversationId updated and history reloaded. + */ + async function selectConversation(conversation) { + if (!conversation?.conversation_id) return; + if (conversation.conversation_id === conversationId) return; + setAssistantConversationId(conversation.conversation_id); + initialized = false; + await loadHistory(); + } + // [/DEF:selectConversation:Function] + + // [DEF:startNewConversation:Function] + /** + * @PURPOSE: Create local empty chat context that will be persisted on first message. + * @PRE: Panel is open. + * @POST: Messages reset and new conversation id bound. + */ + function startNewConversation() { + const newId = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `conv-${Date.now()}`; + setAssistantConversationId(newId); + messages = []; + historyPage = 1; + historyHasNext = false; + initialized = true; + } + // [/DEF:startNewConversation:Function] + // [DEF:handleAction:Function] /** * @PURPOSE: Execute assistant action button behavior (open task/reports, confirm, cancel). @@ -235,6 +382,21 @@ } // [/DEF:stateClass:Function] + // [DEF:handleHistoryScroll:Function] + /** + * @PURPOSE: Trigger lazy history fetch when user scroll reaches top boundary. + * @PRE: Scroll event emitted by history viewport container. + * @POST: loadOlderMessages called when boundary and more pages available. + */ + function handleHistoryScroll(event) { + const el = event.currentTarget; + if (!el || typeof el.scrollTop !== 'number') return; + if (el.scrollTop <= 16) { + loadOlderMessages(); + } + } + // [/DEF:handleHistoryScroll:Function] + onMount(() => { initialized = false; }); @@ -259,7 +421,59 @@