diff --git a/docs/memory/feature-flows/public-agent-links.md b/docs/memory/feature-flows/public-agent-links.md index 9aa6fefa5..bae2e40e8 100644 --- a/docs/memory/feature-flows/public-agent-links.md +++ b/docs/memory/feature-flows/public-agent-links.md @@ -1,6 +1,6 @@ # Feature Flow: Public Agent Links (12.2) -**Last Updated**: 2026-04-13 +**Last Updated**: 2026-04-29 ## Overview @@ -37,13 +37,15 @@ Public Agent Links allow agent owners to generate shareable URLs that enable una | Backend API | +-------------------------------------------------------------------+ | routers/public_links.py routers/public.py | -| (Authenticated) (Unauthenticated) | +| (Authenticated) (Unauthenticated + JWT optional) | | | | - CRUD endpoints - Link validation | | - Owner verification - Email verification | | - Usage stats - Public chat (async mode) | | - SSE stream proxy (THINK-001) | | - Execution status polling | +| - Session list (JWT, #587) | +| - Session detail (JWT, #587) | +-------------------------------------------------------------------+ | +------------------------+------------------------+ @@ -272,6 +274,8 @@ import { getStatusFromStreamEvent, MIN_LABEL_DISPLAY_MS, HEARTBEAT_TIMEOUT_MS } | `GET /api/public/intro/{token}` | `public.py:374` | `get_agent_intro()` | | `GET /api/public/executions/{token}/{execution_id}/stream` | `public.py:676` | `public_stream_execution()` (THINK-001 SSE proxy) | | `GET /api/public/executions/{token}/{execution_id}/status` | `public.py:735` | `public_execution_status()` (THINK-001 polling) | +| `GET /api/public/sessions/{token}` | `public.py` | `list_public_sessions()` — JWT required; returns caller's last 20 sessions for this agent link with `preview` field (#587) | +| `GET /api/public/sessions/{token}/{session_id}` | `public.py` | `get_public_session()` — JWT required; returns session detail with messages; validates session belongs to caller and correct agent (#587) | ### Database Operations @@ -1772,10 +1776,185 @@ Summarization is triggered every 5th message per `(agent_name, user_email)` pair --- +## Chat History for Logged-In Users (#587) + +**Status**: Implemented (2026-04-29) + +Logged-in Trinity users visiting a public chat link can browse and replay their own past chat sessions. Anonymous users see no change to the existing UI. + +### Design Principles + +- The public link token remains the agent-access credential; the JWT identifies which user's sessions to return. +- Past sessions are opened in **read-only mode** — the chat input is hidden to prevent false continuity with the live agent context. +- Access does not require `agent_sharing` membership; token + JWT is sufficient. +- Only sessions created while logged in are visible ("Logged-in chats only" — anonymous sessions are excluded). + +### Data Flow + +``` +Logged-in user opens /chat/{token} + | + v +PublicChat.vue checks authStore.isAuthenticated + -> true: renders ChatHistoryDropdown in header + | + v +User clicks history button + | + v +ChatHistoryDropdown fetches + GET /api/public/sessions/{token} + Authorization: Bearer + | + v +Backend: validate token -> resolve agent_name + SELECT last 20 chat_sessions WHERE + agent_name = ? AND user_id = ? + ORDER BY last_message_at DESC + Includes preview (last message snippet, 120 chars) + | + v +Dropdown renders session list + - Formatted date (Today / Yesterday / 3d ago / Apr 5) + - Message count + - Preview snippet + | + v +User clicks a session + | + v +ChatHistoryDropdown fetches + GET /api/public/sessions/{token}/{session_id} + Authorization: Bearer + | + v +Backend: validate token -> resolve agent_name + Verify session.agent_name == agent_name + Verify session.user_id == current_user.id + Return session with messages + | + v +PublicChat.vue: handleHistorySessionSelected({ messages, session }) + viewingHistorySession = session + messages loaded in read-only mode + ChatInput hidden + Amber banner shown: "Viewing past session — Return to current chat" + | + v +User clicks "Return to current chat" + | + v +exitHistoryView(): + viewingHistorySession = null + reload current live session or fetchIntro() + ChatInput restored +``` + +### Backend Implementation + +**Two new endpoints in `src/backend/routers/public.py`:** + +`GET /api/public/sessions/{token}` — list sessions: +1. Validate public link token (same `is_link_valid()` check used throughout the module). +2. Resolve `agent_name` from the link row. +3. Require JWT (`current_user` dependency — not optional). +4. Query `chat_sessions` for rows matching `agent_name` and `user_id`, ordered by `last_message_at DESC`, limit 20. +5. For each session, compute `preview` by reading the most recent `chat_messages` row (`role = 'assistant'` preferred) and truncating to 120 chars. +6. Return list of session dicts (id, started_at, last_message_at, message_count, preview). + +`GET /api/public/sessions/{token}/{session_id}` — session detail: +1. Validate public link token. +2. Resolve `agent_name`. +3. Require JWT. +4. Fetch session by `session_id`; return 404 if not found. +5. Assert `session.agent_name == agent_name` and `session.user_id == current_user.id`; return 403 otherwise. +6. Fetch all messages for the session ordered by `timestamp ASC`. +7. Return session metadata + messages array. + +**No new database tables or migrations.** Both endpoints reuse the existing `chat_sessions` and `chat_messages` tables (documented in the main Database Schema section of `architecture.md`). + +### Frontend Layer + +#### New Component: `ChatHistoryDropdown.vue` + +**File**: `src/frontend/src/components/chat/ChatHistoryDropdown.vue` + +| Prop / Emit | Type | Description | +|-------------|------|-------------| +| `token` prop | String | Public link token (used in API calls) | +| `session-selected` emit | `{ messages, session }` | Fired when user selects a session | + +**Key behaviors:** +- On open: fetches `GET /api/public/sessions/{token}` using `authStore.authHeader`. +- Renders a dropdown list of sessions, each showing formatted date, message count, and preview snippet. +- Click-outside handler closes the dropdown. +- Date formatting: "Today", "Yesterday", "3d ago", or locale short date (e.g., "Apr 5"). + +**Imports** (added to `src/frontend/src/components/chat/index.js`): +```javascript +export { default as ChatHistoryDropdown } from './ChatHistoryDropdown.vue' +``` + +#### Changes to `PublicChat.vue` + +**New imports:** +```javascript +import { useAuthStore } from '../stores/auth' +import { ChatHistoryDropdown } from '../components/chat' +``` + +**New state:** +```javascript +const authStore = useAuthStore() +const viewingHistorySession = ref(null) // non-null = read-only history mode +``` + +**New methods:** + +| Method | Description | +|--------|-------------| +| `handleHistorySessionSelected({ messages, session })` | Sets `viewingHistorySession`, replaces displayed messages with the past session's messages | +| `exitHistoryView()` | Clears `viewingHistorySession`; reloads current live session via `loadHistory()` or falls back to `fetchIntro()` | + +**Template changes:** +- Header: `` +- Amber read-only banner: shown when `viewingHistorySession` is set; includes "Return to current chat" button that calls `exitHistoryView()`. +- `` wrapped in `v-if="!viewingHistorySession"` — hidden during history replay. + +### Access Control Summary + +| Condition | Sessions endpoint behavior | +|-----------|---------------------------| +| No JWT | 401 Unauthorized | +| Valid JWT, valid token | Returns caller's own sessions only | +| Valid JWT, wrong user's session_id | 403 Forbidden | +| Valid JWT, session belongs to different agent | 403 Forbidden | + +### Error Handling + +| Error Case | HTTP Status | Notes | +|------------|-------------|-------| +| Invalid / disabled / expired public link token | 404 | Same as all other `/api/public/*` endpoints | +| No JWT provided | 401 | `Depends(get_current_user)` rejects unauthenticated requests | +| Session not found | 404 | Unknown `session_id` | +| Session belongs to wrong user or agent | 403 | Ownership mismatch | + +### Files + +| File | Description | +|------|-------------| +| `src/backend/routers/public.py` | Two new endpoint handlers: `list_public_sessions()`, `get_public_session()` | +| `src/frontend/src/components/chat/ChatHistoryDropdown.vue` | New dropdown component (new file) | +| `src/frontend/src/components/chat/index.js` | Exports `ChatHistoryDropdown` | +| `src/frontend/src/views/PublicChat.vue` | Imports `useAuthStore`, `ChatHistoryDropdown`; new `viewingHistorySession` ref; `handleHistorySessionSelected()`, `exitHistoryView()` methods; conditional header, banner, input | + +--- + ## Revision History | Date | Changes | |------|---------| +| 2026-04-29 | **#587 Chat History for Logged-In Users**: Two new JWT-authenticated endpoints (`GET /api/public/sessions/{token}`, `GET /api/public/sessions/{token}/{session_id}`) in `public.py`. New `ChatHistoryDropdown.vue` component. `PublicChat.vue` gains `viewingHistorySession` ref, `handleHistorySessionSelected()`, `exitHistoryView()`, amber read-only banner, and hidden `ChatInput` while in history mode. No new DB tables — reuses `chat_sessions`/`chat_messages`. | | 2026-04-27 | **fix #539 Context duplication**: `build_public_chat_context()` was called AFTER `add_public_chat_message(role="user")`, causing the current user message to appear twice in every agent prompt (once in "Previous conversation:", once in "Current message:"). Fixed by swapping the call order — context built first from prior history, user message stored after. Added 6 unit tests in `tests/unit/test_public_chat_context.py`. Updated PUB-005 data flow and backend implementation step ordering to reflect correct call order. | | 2026-02-19 | **CHAT-001 Shared Components Refactor**: PublicChat.vue now uses shared components from `components/chat/` (ChatMessages, ChatInput, ChatBubble, ChatLoadingIndicator). Shared with new ChatPanel.vue authenticated chat. Updated method line numbers, added Shared Chat Components section. File now 611 lines. | | 2026-02-18 | **Tab consolidation**: Public Links tab removed from AgentDetail.vue. PublicLinksPanel now embedded within SharingPanel.vue (lines 82-83, 92), accessible via "Sharing" tab. Updated Entry Points, Components table, Frontend Files table, and Related Flows sections. | diff --git a/src/backend/routers/public.py b/src/backend/routers/public.py index e08df5b0d..9b42d0c1f 100644 --- a/src/backend/routers/public.py +++ b/src/backend/routers/public.py @@ -12,7 +12,7 @@ import httpx import logging from typing import Optional, List -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel @@ -26,6 +26,8 @@ PublicChatResponse, PublicChatMessage ) +from dependencies import get_current_user +from models import User from routers.auth import check_login_rate_limit, record_login_attempt, get_redis_client from services.docker_service import get_agent_container from services.email_service import email_service @@ -1075,3 +1077,74 @@ async def public_execution_status( "response": execution.response if execution.status in ("success", "failed") else None, "error": execution.error if execution.status == "failed" else None, } + + +@router.get("/sessions/{token}") +async def get_public_link_sessions( + token: str, + limit: int = 20, + current_user: User = Depends(get_current_user) +): + """ + List the authenticated user's chat sessions for the agent behind this public link. + + Requires JWT. Returns the caller's own sessions ordered most-recent first, + capped at `limit` (default 20). Does not require agent sharing — the public + link token acts as the access credential for this read-only history view. + """ + link = _validate_public_link(token) + agent_name = link["agent_name"] + + sessions = db.get_agent_chat_sessions( + agent_name=agent_name, + user_id=current_user.id, + ) + page = sessions[:limit] + + result = [] + for s in page: + entry = s.model_dump() + # Attach a preview from the most recent message in the session + recent = db.get_chat_messages(s.id, limit=1) + entry["preview"] = recent[0].content[:120] if recent else None + result.append(entry) + + return { + "session_count": len(result), + "sessions": result, + } + + +@router.get("/sessions/{token}/{session_id}") +async def get_public_link_session_detail( + token: str, + session_id: str, + limit: int = 100, + current_user: User = Depends(get_current_user) +): + """ + Get messages for a specific chat session via a public link token. + + Requires JWT. The session must belong to the authenticated user and to + the agent referenced by the public link token. + """ + link = _validate_public_link(token) + agent_name = link["agent_name"] + + session = db.get_chat_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + if session.agent_name != agent_name: + raise HTTPException(status_code=403, detail="Session does not belong to this agent") + + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="You don't have access to this session") + + messages = db.get_chat_messages(session_id, limit=limit) + + return { + "session": session.model_dump(), + "message_count": len(messages), + "messages": [m.model_dump() for m in messages], + } diff --git a/src/frontend/src/components/chat/ChatHistoryDropdown.vue b/src/frontend/src/components/chat/ChatHistoryDropdown.vue new file mode 100644 index 000000000..ff9102d76 --- /dev/null +++ b/src/frontend/src/components/chat/ChatHistoryDropdown.vue @@ -0,0 +1,158 @@ + + + diff --git a/src/frontend/src/components/chat/index.js b/src/frontend/src/components/chat/index.js index 90cec155d..f3714cc89 100644 --- a/src/frontend/src/components/chat/index.js +++ b/src/frontend/src/components/chat/index.js @@ -3,3 +3,4 @@ export { default as ChatLoadingIndicator } from './ChatLoadingIndicator.vue' export { default as ChatInput } from './ChatInput.vue' export { default as ChatMessages } from './ChatMessages.vue' export { default as ChatEmptyState } from './ChatEmptyState.vue' +export { default as ChatHistoryDropdown } from './ChatHistoryDropdown.vue' diff --git a/src/frontend/src/views/PublicChat.vue b/src/frontend/src/views/PublicChat.vue index 13858b022..f838b2ed0 100644 --- a/src/frontend/src/views/PublicChat.vue +++ b/src/frontend/src/views/PublicChat.vue @@ -10,18 +10,27 @@ Trinity - - + +
+ + + + +
@@ -197,6 +206,22 @@
+ +
+ + Viewing past session — read only + + +
+ {{ chatError }}

- + route.params.token) +const authStore = useAuthStore() // State const loading = ref(true) @@ -292,6 +320,9 @@ const isVerified = computed(() => !linkInfo.value?.require_email || !!sessionTok const chatSessionId = ref(localStorage.getItem(`public_chat_session_id_${token.value}`) || '') const historyLoading = ref(false) +// Read-only history view (when a past session is loaded from ChatHistoryDropdown) +const viewingHistorySession = ref(null) + // Chat const message = ref('') const messages = ref([]) @@ -554,6 +585,26 @@ const confirmNewConversation = async () => { } } +// Load a past session in read-only view (from ChatHistoryDropdown) +const handleHistorySessionSelected = ({ messages: sessionMessages, session }) => { + viewingHistorySession.value = session + messages.value = sessionMessages + chatError.value = null +} + +// Exit read-only history view and return to live chat +const exitHistoryView = async () => { + viewingHistorySession.value = null + messages.value = [] + introFetched.value = false + const hasHistory = await loadHistory() + if (!hasHistory) { + await fetchIntro() + } else { + introFetched.value = true + } +} + // THINK-001: Update loading text with minimum display time to prevent flicker const updateLoadingText = (newText) => { if (!newText) return diff --git a/tests/registry.json b/tests/registry.json index 7bcf7403b..cb4c281a7 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -202,6 +202,13 @@ "added": "2026-04-23", "categories": ["backend", "unit", "lifecycle", "file-sharing"], "description": "check_public_folder_mount_matches truth table: enabled+mounted → True, enabled+unmounted → False (needs recreation to attach), disabled+mounted → False (needs recreation to detach), disabled+unmounted → True. Adversarial cases: similar paths (/public-backup, /public/inner) don't match, missing 'Mounts' key handled, flag re-read each call, other mounts (shared-out, shared-in/*, workspace) don't interfere (9 tests)" + }, + { + "file": "test_public_chat_history.py", + "feature": "Issue #587", + "added": "2026-04-29", + "categories": ["backend", "api", "public", "chat"], + "description": "Tests for GET /api/public/sessions/{token} and GET /api/public/sessions/{token}/{session_id} — auth requirements, 404 on invalid tokens, response shape, limit param" } ] } diff --git a/tests/test_public_chat_history.py b/tests/test_public_chat_history.py new file mode 100644 index 000000000..df9ac54a9 --- /dev/null +++ b/tests/test_public_chat_history.py @@ -0,0 +1,103 @@ +""" +Tests for public chat history endpoints (issue #587). + +Run with: pytest tests/test_public_chat_history.py -v +""" +import os +import pytest +import httpx + +BASE_URL = os.getenv("TRINITY_API_URL", "http://localhost:8000") + + +@pytest.fixture +def auth_headers(): + """Get JWT auth headers for authenticated requests.""" + password = os.getenv("TRINITY_TEST_PASSWORD", "password") + response = httpx.post( + f"{BASE_URL}/api/token", + data={"username": "admin", "password": password}, + ) + if response.status_code != 200: + pytest.skip("Could not authenticate — check admin credentials") + token = response.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +class TestPublicSessionsEndpoints: + """Tests for GET /api/public/sessions/{token} and /{token}/{session_id}.""" + + def test_sessions_requires_auth(self): + """List sessions endpoint must return 401/403 without JWT.""" + response = httpx.get(f"{BASE_URL}/api/public/sessions/some-token") + assert response.status_code in (401, 403) + + def test_sessions_invalid_link_returns_404(self, auth_headers): + """Invalid public link token returns 404 even when authenticated.""" + response = httpx.get( + f"{BASE_URL}/api/public/sessions/definitely-not-a-real-token-xyz", + headers=auth_headers, + ) + assert response.status_code == 404 + + def test_session_detail_requires_auth(self): + """Session detail endpoint must return 401/403 without JWT.""" + response = httpx.get(f"{BASE_URL}/api/public/sessions/some-token/some-session-id") + assert response.status_code in (401, 403) + + def test_session_detail_invalid_link_returns_404(self, auth_headers): + """Invalid public link token on detail endpoint returns 404.""" + response = httpx.get( + f"{BASE_URL}/api/public/sessions/definitely-not-a-real-token-xyz/some-id", + headers=auth_headers, + ) + assert response.status_code == 404 + + def test_sessions_returns_list_shape(self, auth_headers): + """Authenticated call with a valid public link returns proper list shape. + + This test requires at least one public link in the system. It skips if + no public links exist rather than failing. + """ + # Discover any public link from the agent list + agents_resp = httpx.get(f"{BASE_URL}/api/agents", headers=auth_headers) + if agents_resp.status_code != 200: + pytest.skip("Could not list agents") + + agents = agents_resp.json() + if not agents: + pytest.skip("No agents available") + + agent_name = agents[0]["name"] + links_resp = httpx.get( + f"{BASE_URL}/api/agents/{agent_name}/public-links", + headers=auth_headers, + ) + if links_resp.status_code != 200: + pytest.skip(f"Could not get public links for agent {agent_name}") + + links = links_resp.json().get("links", []) + if not links: + pytest.skip(f"No public links for agent {agent_name}") + + token = links[0]["token"] + response = httpx.get( + f"{BASE_URL}/api/public/sessions/{token}", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "sessions" in data + assert "session_count" in data + assert isinstance(data["sessions"], list) + assert data["session_count"] == len(data["sessions"]) + + def test_sessions_limit_param(self, auth_headers): + """limit query param is accepted without error (validation test).""" + # Any valid-looking token; will 404, but limit param should not cause 422 + response = httpx.get( + f"{BASE_URL}/api/public/sessions/not-a-real-token?limit=5", + headers=auth_headers, + ) + # 404 = token invalid (expected). 422 = validation error (not expected). + assert response.status_code != 422