diff --git a/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index cbcfa0e..ea6013f 100644 --- a/src/agent_manager/api/routes.py +++ b/src/agent_manager/api/routes.py @@ -13,6 +13,7 @@ from agent_engine.runtime.streaming import RunStreamEvent from agent_manager.api.deps import get_service from agent_manager.api.schemas import ( + ConversationSummary, CreateConversationRequest, CreateConversationResponse, MessageOut, @@ -23,6 +24,7 @@ ToolRecord, ) from agent_manager.application import ( + ConversationAccessDenied, ConversationNotFound, ConversationService, ConversationTokenBudgetExceeded, @@ -42,6 +44,26 @@ async def create_conversation( return CreateConversationResponse(conversation_id=session_id, session_id=session_id) +@router.get("/conversations", response_model=list[ConversationSummary]) +async def list_conversations(service: Service, user_id: str) -> list[ConversationSummary]: + """List a user's conversations. + + `user_id` is a caller-supplied identifier, not an authenticated principal — + it scopes the listing, it does not authorize it. A deployment that needs a + real boundary puts auth in front of this router (or overrides `get_service`) + and derives the id from the verified credential instead of the query string. + """ + sessions = await service.list_conversations(user_id) + return [ + ConversationSummary( + conversation_id=s.session_id, + title=s.title, + last_message_at=s.last_message_at, + ) + for s in sessions + ] + + @router.get("/conversations/{conversation_id}/messages", response_model=list[MessageOut]) async def list_messages(conversation_id: str, service: Service) -> list[MessageOut]: try: @@ -73,6 +95,8 @@ async def send_message( result = await service.send(conversation_id, body.message, user_id=body.user_id) except ConversationNotFound as exc: raise HTTPException(status_code=404, detail="conversation not found") from exc + except ConversationAccessDenied: + raise HTTPException(status_code=403, detail="conversation owned by another user") from None except ConversationTokenBudgetExceeded: raise HTTPException(status_code=429, detail="conversation token budget exceeded") from None except Exception as exc: # engine failure @@ -115,6 +139,8 @@ async def stream_message( first = None except ConversationNotFound as exc: raise HTTPException(status_code=404, detail="conversation not found") from exc + except ConversationAccessDenied: + raise HTTPException(status_code=403, detail="conversation owned by another user") from None except ConversationTokenBudgetExceeded: raise HTTPException(status_code=429, detail="conversation token budget exceeded") from None diff --git a/src/agent_manager/api/schemas.py b/src/agent_manager/api/schemas.py index 1c2452a..c7356bc 100644 --- a/src/agent_manager/api/schemas.py +++ b/src/agent_manager/api/schemas.py @@ -23,6 +23,12 @@ class CreateConversationResponse(BaseModel): session_id: str +class ConversationSummary(BaseModel): + conversation_id: str + title: str | None = None + last_message_at: datetime | None = None + + class MessageOut(BaseModel): role: Role content: str diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index 3b15819..b23ec28 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -52060,7 +52060,8 @@ var DEFAULT_CONFIG = { greeting: "", position: "bottom-right", avatar: "", - mode: "floating" + mode: "floating", + user: "" }; var HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; function normalizeEndpoint(value) { @@ -52084,7 +52085,8 @@ function parseConfig(element7, scriptOrigin) { greeting: element7.getAttribute("greeting") || DEFAULT_CONFIG.greeting, position: safePosition(element7.getAttribute("position")), avatar: element7.getAttribute("avatar") || DEFAULT_CONFIG.avatar, - mode: safeMode(element7.getAttribute("mode")) + mode: safeMode(element7.getAttribute("mode")), + user: element7.getAttribute("user") || DEFAULT_CONFIG.user }; } function applyConfigAttributes(element7, config) { @@ -52110,14 +52112,32 @@ var AgentChatClient = class { constructor(endpoint) { this.endpoint = endpoint; } - async createConversation() { - const response = await fetch(`${this.endpoint}/conversations`, { method: "POST" }); + async createConversation(userId) { + const response = await fetch(`${this.endpoint}/conversations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: userId }) + }); if (!response.ok) { throw new AgentChatHttpError(response.status); } const data = await response.json(); return String(data.conversation_id); } + async listConversations(userId) { + const url = `${this.endpoint}/conversations?user_id=${encodeURIComponent(userId)}`; + const response = await fetch(url); + if (!response.ok) { + throw new AgentChatHttpError(response.status); + } + const data = await response.json(); + if (!Array.isArray(data)) return []; + return data.map((thread) => ({ + conversation_id: String(thread.conversation_id), + title: thread.title ?? null, + last_message_at: thread.last_message_at ?? null + })); + } async getMessages(conversationId) { const response = await fetch(`${this.endpoint}/conversations/${conversationId}/messages`); if (!response.ok) { @@ -52346,8 +52366,16 @@ var __iconNode7 = [ ]; var Copy = createLucideIcon("copy", __iconNode7); -// node_modules/lucide-react/dist/esm/icons/send.mjs +// node_modules/lucide-react/dist/esm/icons/history.mjs var __iconNode8 = [ + ["path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8", key: "1357e3" }], + ["path", { d: "M3 3v5h5", key: "1xhq8a" }], + ["path", { d: "M12 7v5l4 2", key: "1fdv2h" }] +]; +var History = createLucideIcon("history", __iconNode8); + +// node_modules/lucide-react/dist/esm/icons/send.mjs +var __iconNode9 = [ [ "path", { @@ -52357,10 +52385,23 @@ var __iconNode8 = [ ], ["path", { d: "m21.854 2.147-10.94 10.939", key: "12cjpa" }] ]; -var Send = createLucideIcon("send", __iconNode8); +var Send = createLucideIcon("send", __iconNode9); + +// node_modules/lucide-react/dist/esm/icons/square-pen.mjs +var __iconNode10 = [ + ["path", { d: "M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7", key: "1m0v6g" }], + [ + "path", + { + d: "M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z", + key: "ohrbg2" + } + ] +]; +var SquarePen = createLucideIcon("square-pen", __iconNode10); // node_modules/lucide-react/dist/esm/icons/wrench.mjs -var __iconNode9 = [ +var __iconNode11 = [ [ "path", { @@ -52369,18 +52410,41 @@ var __iconNode9 = [ } ] ]; -var Wrench = createLucideIcon("wrench", __iconNode9); +var Wrench = createLucideIcon("wrench", __iconNode11); // node_modules/lucide-react/dist/esm/icons/x.mjs -var __iconNode10 = [ +var __iconNode12 = [ ["path", { d: "M18 6 6 18", key: "1bl5f8" }], ["path", { d: "m6 6 12 12", key: "d8bk6v" }] ]; -var X = createLucideIcon("x", __iconNode10); +var X = createLucideIcon("x", __iconNode12); // src/agent_manager/api/static/widget/react/AgentChatApp.tsx var import_react10 = __toESM(require_react(), 1); +// src/agent_manager/api/static/widget/storage/conversationStorage.ts +function conversationStorageKey(endpoint, userId) { + return `agent-chat:${endpoint}:${userId}`; +} +function getStoredConversationId(endpoint, userId, storage = localStorage) { + return storage.getItem(conversationStorageKey(endpoint, userId)); +} +function setStoredConversationId(endpoint, userId, conversationId, storage = localStorage) { + storage.setItem(conversationStorageKey(endpoint, userId), conversationId); +} +function removeStoredConversationId(endpoint, userId, storage = localStorage) { + storage.removeItem(conversationStorageKey(endpoint, userId)); +} +function getOrCreateUserId(endpoint, storage = localStorage) { + const key = `agent-chat:user:${endpoint}`; + let id = storage.getItem(key); + if (!id) { + id = crypto.randomUUID(); + storage.setItem(key, id); + } + return id; +} + // src/agent_manager/api/static/widget/react/shadcnAiElements.tsx var import_react8 = __toESM(require_react(), 1); @@ -53001,37 +53065,21 @@ function upsertTool(tools, next2) { // src/agent_manager/api/static/widget/react/useConversation.ts var import_react9 = __toESM(require_react(), 1); - -// src/agent_manager/api/static/widget/storage/conversationStorage.ts -function conversationStorageKey(endpoint) { - return `agent-chat:${endpoint}`; -} -function getStoredConversationId(endpoint, storage = localStorage) { - return storage.getItem(conversationStorageKey(endpoint)); -} -function setStoredConversationId(endpoint, conversationId, storage = localStorage) { - storage.setItem(conversationStorageKey(endpoint), conversationId); -} -function removeStoredConversationId(endpoint, storage = localStorage) { - storage.removeItem(conversationStorageKey(endpoint)); -} - -// src/agent_manager/api/static/widget/react/useConversation.ts var isMissingConversation = (error) => error instanceof AgentChatHttpError && error.status === 404; -function useConversation(client, endpoint) { +function useConversation(client, endpoint, userId) { const startConversation = (0, import_react9.useCallback)(async () => { - const created = await client.createConversation(); - setStoredConversationId(endpoint, created); + const created = await client.createConversation(userId); + setStoredConversationId(endpoint, userId, created); return created; - }, [client, endpoint]); + }, [client, endpoint, userId]); const ensureId = (0, import_react9.useCallback)( - async () => getStoredConversationId(endpoint) ?? startConversation(), - [endpoint, startConversation] + async () => getStoredConversationId(endpoint, userId) ?? startConversation(), + [endpoint, userId, startConversation] ); const restartId = (0, import_react9.useCallback)(async () => { - removeStoredConversationId(endpoint); + removeStoredConversationId(endpoint, userId); return startConversation(); - }, [endpoint, startConversation]); + }, [endpoint, userId, startConversation]); const send = (0, import_react9.useCallback)( async (text10) => { try { @@ -53055,27 +53103,39 @@ function useConversation(client, endpoint) { [client, ensureId, restartId] ); const loadHistory = (0, import_react9.useCallback)(async () => { - const stored = getStoredConversationId(endpoint); + const stored = getStoredConversationId(endpoint, userId); if (!stored) return []; try { return await client.getMessages(stored); } catch (error) { - if (isMissingConversation(error)) removeStoredConversationId(endpoint); + if (isMissingConversation(error)) removeStoredConversationId(endpoint, userId); return []; } - }, [client, endpoint]); + }, [client, endpoint, userId]); const loadUsage = (0, import_react9.useCallback)(async () => { - const stored = getStoredConversationId(endpoint); + const stored = getStoredConversationId(endpoint, userId); if (!stored) return null; try { return await client.getUsage(stored); } catch { return null; } - }, [client, endpoint]); + }, [client, endpoint, userId]); + const listThreads = (0, import_react9.useCallback)( + () => client.listConversations(userId).catch(() => []), + [client, userId] + ); + const switchTo = (0, import_react9.useCallback)( + (conversationId) => setStoredConversationId(endpoint, userId, conversationId), + [endpoint, userId] + ); + const startNew = (0, import_react9.useCallback)( + () => removeStoredConversationId(endpoint, userId), + [endpoint, userId] + ); return (0, import_react9.useMemo)( - () => ({ send, stream, loadHistory, loadUsage }), - [send, stream, loadHistory, loadUsage] + () => ({ send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew }), + [send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew] ); } @@ -53092,12 +53152,18 @@ var toEntry = (message) => ({ }); function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { const inline = config.mode === "inline"; - const conversation = useConversation(client, config.endpoint); + const userId = (0, import_react10.useMemo)( + () => config.user || getOrCreateUserId(config.endpoint), + [config.user, config.endpoint] + ); + const conversation = useConversation(client, config.endpoint, userId); const [open, setOpen] = (0, import_react10.useState)(inline); const [loaded, setLoaded] = (0, import_react10.useState)(false); const [sending, setSending] = (0, import_react10.useState)(false); const [entries, setEntries] = (0, import_react10.useState)([]); const [usage, setUsage] = (0, import_react10.useState)(null); + const [threads, setThreads] = (0, import_react10.useState)([]); + const [threadsOpen, setThreadsOpen] = (0, import_react10.useState)(false); const launcherRef = (0, import_react10.useRef)(null); const inputRef = (0, import_react10.useRef)(null); const refreshUsage = (0, import_react10.useCallback)(async () => { @@ -53126,6 +53192,28 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { setOpen(false); launcherRef.current?.focus({ preventScroll: true }); }, [inline]); + const openThreads = (0, import_react10.useCallback)(async () => { + setThreads(await conversation.listThreads()); + setThreadsOpen(true); + }, [conversation]); + const openThread = (0, import_react10.useCallback)( + async (conversationId) => { + conversation.switchTo(conversationId); + setThreadsOpen(false); + const history = await conversation.loadHistory(); + setEntries(history.map(toEntry)); + await refreshUsage(); + inputRef.current?.focus({ preventScroll: true }); + }, + [conversation, refreshUsage] + ); + const startNewThread = (0, import_react10.useCallback)(() => { + conversation.startNew(); + setThreadsOpen(false); + setEntries([]); + setUsage(null); + inputRef.current?.focus({ preventScroll: true }); + }, [conversation]); const replaceEntry = (0, import_react10.useCallback)((id, entry) => { setEntries((prev) => prev.map((current) => current.id === id ? entry : current)); }, []); @@ -53194,11 +53282,42 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { role: inline ? "region" : "dialog", children: [ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("header", { className: "header", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( + "button", + { + "aria-label": "Conversations", + className: "header-btn", + onClick: () => void openThreads(), + type: "button", + children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(History, { "aria-hidden": true }) + } + ), /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "dot", style: avatarStyle(config.avatar) }), /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "title", id: titleId, children: config.title }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( + "button", + { + "aria-label": "New chat", + className: "header-btn", + onClick: startNewThread, + type: "button", + children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SquarePen, { "aria-hidden": true }) + } + ), !inline ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { "aria-label": "Close chat", className: "close", onClick: closeChat, type: "button", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(X, { "aria-hidden": true }) }) : null ] }), /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "body", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( + ThreadDrawer, + { + open: threadsOpen, + threads, + activeId: getStoredConversationId(config.endpoint, userId), + onSelect: openThread, + onNew: startNewThread, + onClose: () => setThreadsOpen(false) + } + ), /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Conversation, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(ConversationContent, { children: [ entries.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Welcome, { title: config.greeting || DEFAULT_GREETING }) : null, entries.map((entry) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ChatMessage, { entry }, entry.id)) @@ -53314,6 +53433,39 @@ function Welcome({ title }) { /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "welcome-title", children: title }) ] }); } +function ThreadDrawer({ + open, + threads, + activeId, + onSelect, + onNew, + onClose +}) { + return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: `thread-drawer${open ? " open" : ""}`, inert: !open, children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "thread-drawer-head", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { children: "Chats" }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { "aria-label": "Close conversations", className: "header-btn", onClick: onClose, type: "button", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(X, { "aria-hidden": true }) }) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("button", { className: "thread-new", onClick: onNew, type: "button", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SquarePen, { "aria-hidden": true }), + "New chat" + ] }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "thread-list", children: [ + threads.map((thread) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( + "button", + { + className: `thread-item${thread.conversation_id === activeId ? " active" : ""}`, + "aria-current": thread.conversation_id === activeId, + onClick: () => onSelect(thread.conversation_id), + type: "button", + children: thread.title || "New chat" + }, + thread.conversation_id + )), + threads.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "thread-empty", children: "No conversations yet" }) : null + ] }) + ] }); +} var RING_SIZE = 18; var RING_STROKE = 2.5; var RING_RADIUS = (RING_SIZE - RING_STROKE) / 2; @@ -53431,18 +53583,48 @@ function styles(config) { from { opacity: 0; transform: translateY(8px); } } .panel.inline { position: static; opacity: 1; transform: none; pointer-events: auto; box-shadow: 0 4px 18px rgba(0,0,0,.12); } - .header { background: #fff; color: #18181b; padding: 14px 18px; font-weight: 600; - font-size: 14px; display: flex; align-items: center; gap: 10px; + .header { background: #fff; color: #18181b; padding: 12px 12px 12px 14px; font-weight: 600; + font-size: 14px; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid #f0f0f1; } .header .dot { width: 22px; height: 22px; border-radius: 50%; flex: 0 0 auto; background: ${config.color}; background-size: cover; background-position: center; } .header .title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .header-btn { background: transparent; border: 0; color: #71717a; cursor: pointer; + padding: 0; width: 30px; height: 30px; border-radius: 8px; flex: 0 0 auto; + display: flex; align-items: center; justify-content: center; + transition: background .12s, color .12s; } + .header-btn:hover { background: #f4f4f5; color: #18181b; } + .header-btn svg { width: 17px; height: 17px; } .close { background: transparent; border: 0; color: #71717a; cursor: pointer; padding: 0; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background .12s; } .close:hover { background: #f4f4f5; color: #18181b; } .close svg { width: 16px; height: 16px; } - .body { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #fff; } + .body { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; background: #fff; } + .thread-drawer { position: absolute; inset: 0; z-index: 3; background: #fff; + display: flex; flex-direction: column; transform: translateX(-100%); + opacity: 0; pointer-events: none; + transition: transform .25s cubic-bezier(.32,.72,0,1), opacity .25s ease; } + .thread-drawer.open { transform: none; opacity: 1; pointer-events: auto; } + .thread-drawer-head { display: flex; align-items: center; justify-content: space-between; + padding: 10px 12px 10px 18px; font-weight: 600; font-size: 14px; color: #18181b; + border-bottom: 1px solid #f0f0f1; } + .thread-new { display: flex; align-items: center; gap: 8px; margin: 12px 14px 4px; + padding: 10px 14px; border: 1px solid #e4e4e7; border-radius: 12px; background: #fff; + color: #18181b; font-size: 14px; font-weight: 500; cursor: pointer; font-family: inherit; + transition: background .12s; } + .thread-new:hover { background: #f4f4f5; } + .thread-new svg { width: 16px; height: 16px; flex: 0 0 auto; } + .thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 6px 10px 14px; + display: flex; flex-direction: column; gap: 2px; + scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; } + .thread-item { text-align: left; border: 0; background: transparent; cursor: pointer; + padding: 10px 12px; border-radius: 10px; font-size: 13.5px; color: #3f3f46; + font-family: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: background .12s; } + .thread-item:hover { background: #f4f4f5; } + .thread-item.active { background: #f4f4f5; color: #18181b; font-weight: 600; } + .thread-empty { color: #a1a1aa; font-size: 13px; text-align: center; padding: 24px 12px; margin: 0; } .messages { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; } .messages::-webkit-scrollbar { width: 10px; } @@ -53570,6 +53752,7 @@ function styles(config) { .welcome { animation: none; } .msg-action svg { animation: none; } .budget-ring-value, .budget-bar-fill, .budget-popover { transition: none; } + .thread-drawer { transition: none; } } @media (max-width: 480px) { .panel:not(.inline) { width: 100vw; height: 100dvh; max-height: 100dvh; @@ -53592,7 +53775,7 @@ var AgentChatElement = class extends HTMLElement { this.titleId = `${this.widgetId}-title`; } static get observedAttributes() { - return ["endpoint", "title", "color", "greeting", "position", "avatar", "mode"]; + return ["endpoint", "title", "color", "greeting", "position", "avatar", "mode", "user"]; } connectedCallback() { if (this.connected) return; @@ -53913,6 +54096,14 @@ lucide-react/dist/esm/icons/copy.mjs: * See the LICENSE file in the root directory of this source tree. *) +lucide-react/dist/esm/icons/history.mjs: + (** + * @license lucide-react v1.22.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + *) + lucide-react/dist/esm/icons/send.mjs: (** * @license lucide-react v1.22.0 - ISC @@ -53921,6 +54112,14 @@ lucide-react/dist/esm/icons/send.mjs: * See the LICENSE file in the root directory of this source tree. *) +lucide-react/dist/esm/icons/square-pen.mjs: + (** + * @license lucide-react v1.22.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + *) + lucide-react/dist/esm/icons/wrench.mjs: (** * @license lucide-react v1.22.0 - ISC diff --git a/src/agent_manager/api/static/widget.test.mjs b/src/agent_manager/api/static/widget.test.mjs index c84172c..d5fd103 100644 --- a/src/agent_manager/api/static/widget.test.mjs +++ b/src/agent_manager/api/static/widget.test.mjs @@ -260,6 +260,7 @@ assert.equal(customElements.defineCount, definesBefore, "defineAgentChat is idem position: "bottom-right", avatar: "", mode: "floating", + user: "", }); } diff --git a/src/agent_manager/api/static/widget/api/AgentChatClient.ts b/src/agent_manager/api/static/widget/api/AgentChatClient.ts index 20cc856..60f4675 100644 --- a/src/agent_manager/api/static/widget/api/AgentChatClient.ts +++ b/src/agent_manager/api/static/widget/api/AgentChatClient.ts @@ -1,4 +1,10 @@ -import type { ChatMessage, TokenBudget, SendMessageResponse, StreamEvent } from "../types"; +import type { + ChatMessage, + TokenBudget, + SendMessageResponse, + StreamEvent, + ThreadSummary, +} from "../types"; export class AgentChatHttpError extends Error { constructor(readonly status: number) { @@ -10,8 +16,12 @@ export class AgentChatHttpError extends Error { export class AgentChatClient { constructor(private readonly endpoint: string) {} - async createConversation(): Promise { - const response = await fetch(`${this.endpoint}/conversations`, { method: "POST" }); + async createConversation(userId: string): Promise { + const response = await fetch(`${this.endpoint}/conversations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: userId }), + }); if (!response.ok) { throw new AgentChatHttpError(response.status); } @@ -19,6 +29,21 @@ export class AgentChatClient { return String(data.conversation_id); } + async listConversations(userId: string): Promise { + const url = `${this.endpoint}/conversations?user_id=${encodeURIComponent(userId)}`; + const response = await fetch(url); + if (!response.ok) { + throw new AgentChatHttpError(response.status); + } + const data = await response.json(); + if (!Array.isArray(data)) return []; + return data.map((thread) => ({ + conversation_id: String(thread.conversation_id), + title: thread.title ?? null, + last_message_at: thread.last_message_at ?? null, + })); + } + async getMessages(conversationId: string): Promise { const response = await fetch(`${this.endpoint}/conversations/${conversationId}/messages`); if (!response.ok) { diff --git a/src/agent_manager/api/static/widget/config/parseConfig.ts b/src/agent_manager/api/static/widget/config/parseConfig.ts index 24425c2..d9ec742 100644 --- a/src/agent_manager/api/static/widget/config/parseConfig.ts +++ b/src/agent_manager/api/static/widget/config/parseConfig.ts @@ -7,6 +7,7 @@ export const DEFAULT_CONFIG: Omit = { position: "bottom-right", avatar: "", mode: "floating", + user: "", }; const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; @@ -37,6 +38,7 @@ export function parseConfig(element: HTMLElement, scriptOrigin: string): AgentCh position: safePosition(element.getAttribute("position")), avatar: element.getAttribute("avatar") || DEFAULT_CONFIG.avatar, mode: safeMode(element.getAttribute("mode")), + user: element.getAttribute("user") || DEFAULT_CONFIG.user, }; } diff --git a/src/agent_manager/api/static/widget/element/AgentChatElement.tsx b/src/agent_manager/api/static/widget/element/AgentChatElement.tsx index 510d265..94237ec 100644 --- a/src/agent_manager/api/static/widget/element/AgentChatElement.tsx +++ b/src/agent_manager/api/static/widget/element/AgentChatElement.tsx @@ -10,7 +10,7 @@ let nextWidgetId = 0; export class AgentChatElement extends HTMLElement { static get observedAttributes(): string[] { - return ["endpoint", "title", "color", "greeting", "position", "avatar", "mode"]; + return ["endpoint", "title", "color", "greeting", "position", "avatar", "mode", "user"]; } private config!: AgentChatConfig; diff --git a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx index d2e0f7c..d06ccb3 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -1,13 +1,23 @@ -import { BotIcon, CheckIcon, ChevronDownIcon, CopyIcon, XIcon } from "lucide-react"; -import { type Ref, useCallback, useEffect, useRef, useState } from "react"; +import { + BotIcon, + CheckIcon, + ChevronDownIcon, + CopyIcon, + HistoryIcon, + SquarePenIcon, + XIcon, +} from "lucide-react"; +import { type Ref, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AgentChatClient } from "../api/AgentChatClient"; +import { getOrCreateUserId, getStoredConversationId } from "../storage/conversationStorage"; import type { AgentChatAnswerDetail, AgentChatConfig, ChatMessage, TokenBudget, MessageEntry, + ThreadSummary, ToolRecord, } from "../types"; import { @@ -51,12 +61,18 @@ export interface AgentChatAppProps { export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: AgentChatAppProps) { const inline = config.mode === "inline"; - const conversation = useConversation(client, config.endpoint); + const userId = useMemo( + () => config.user || getOrCreateUserId(config.endpoint), + [config.user, config.endpoint], + ); + const conversation = useConversation(client, config.endpoint, userId); const [open, setOpen] = useState(inline); const [loaded, setLoaded] = useState(false); const [sending, setSending] = useState(false); const [entries, setEntries] = useState([]); const [usage, setUsage] = useState(null); + const [threads, setThreads] = useState([]); + const [threadsOpen, setThreadsOpen] = useState(false); const launcherRef = useRef(null); const inputRef = useRef(null); @@ -92,6 +108,31 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age launcherRef.current?.focus({ preventScroll: true }); }, [inline]); + const openThreads = useCallback(async () => { + setThreads(await conversation.listThreads()); + setThreadsOpen(true); + }, [conversation]); + + const openThread = useCallback( + async (conversationId: string) => { + conversation.switchTo(conversationId); + setThreadsOpen(false); + const history = await conversation.loadHistory(); + setEntries(history.map(toEntry)); + await refreshUsage(); + inputRef.current?.focus({ preventScroll: true }); + }, + [conversation, refreshUsage], + ); + + const startNewThread = useCallback(() => { + conversation.startNew(); + setThreadsOpen(false); + setEntries([]); + setUsage(null); + inputRef.current?.focus({ preventScroll: true }); + }, [conversation]); + const replaceEntry = useCallback((id: string, entry: MessageEntry) => { setEntries((prev) => prev.map((current) => (current.id === id ? entry : current))); }, []); @@ -164,10 +205,26 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age role={inline ? "region" : "dialog"} >
+ {config.title} + {!inline ? (
+ setThreadsOpen(false)} + /> {entries.length === 0 ? ( @@ -348,6 +413,51 @@ function Welcome({ title }: { title: string }) { ); } +function ThreadDrawer({ + open, + threads, + activeId, + onSelect, + onNew, + onClose, +}: { + open: boolean; + threads: ThreadSummary[]; + activeId: string | null; + onSelect: (conversationId: string) => void; + onNew: () => void; + onClose: () => void; +}) { + return ( +
+
+ Chats + +
+ +
+ {threads.map((thread) => ( + + ))} + {threads.length === 0 ?

No conversations yet

: null} +
+
+ ); +} + const RING_SIZE = 18; const RING_STROKE = 2.5; const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2; diff --git a/src/agent_manager/api/static/widget/react/useConversation.ts b/src/agent_manager/api/static/widget/react/useConversation.ts index 3fab954..e9bf263 100644 --- a/src/agent_manager/api/static/widget/react/useConversation.ts +++ b/src/agent_manager/api/static/widget/react/useConversation.ts @@ -6,34 +6,47 @@ import { removeStoredConversationId, setStoredConversationId, } from "../storage/conversationStorage"; -import type { ChatMessage, TokenBudget, SendMessageResponse, StreamEvent } from "../types"; +import type { + ChatMessage, + TokenBudget, + SendMessageResponse, + StreamEvent, + ThreadSummary, +} from "../types"; export interface Conversation { send(text: string): Promise; stream(text: string): AsyncGenerator; loadHistory(): Promise; loadUsage(): Promise; + listThreads(): Promise; + switchTo(conversationId: string): void; + startNew(): void; } const isMissingConversation = (error: unknown): boolean => error instanceof AgentChatHttpError && error.status === 404; -export function useConversation(client: AgentChatClient, endpoint: string): Conversation { +export function useConversation( + client: AgentChatClient, + endpoint: string, + userId: string, +): Conversation { const startConversation = useCallback(async () => { - const created = await client.createConversation(); - setStoredConversationId(endpoint, created); + const created = await client.createConversation(userId); + setStoredConversationId(endpoint, userId, created); return created; - }, [client, endpoint]); + }, [client, endpoint, userId]); const ensureId = useCallback( - async () => getStoredConversationId(endpoint) ?? startConversation(), - [endpoint, startConversation], + async () => getStoredConversationId(endpoint, userId) ?? startConversation(), + [endpoint, userId, startConversation], ); const restartId = useCallback(async () => { - removeStoredConversationId(endpoint); + removeStoredConversationId(endpoint, userId); return startConversation(); - }, [endpoint, startConversation]); + }, [endpoint, userId, startConversation]); const send = useCallback( async (text: string) => { @@ -60,28 +73,43 @@ export function useConversation(client: AgentChatClient, endpoint: string): Conv ); const loadHistory = useCallback(async () => { - const stored = getStoredConversationId(endpoint); + const stored = getStoredConversationId(endpoint, userId); if (!stored) return []; try { return await client.getMessages(stored); } catch (error) { - if (isMissingConversation(error)) removeStoredConversationId(endpoint); + if (isMissingConversation(error)) removeStoredConversationId(endpoint, userId); return []; } - }, [client, endpoint]); + }, [client, endpoint, userId]); const loadUsage = useCallback(async () => { - const stored = getStoredConversationId(endpoint); + const stored = getStoredConversationId(endpoint, userId); if (!stored) return null; try { return await client.getUsage(stored); } catch { return null; } - }, [client, endpoint]); + }, [client, endpoint, userId]); + + const listThreads = useCallback( + () => client.listConversations(userId).catch(() => []), + [client, userId], + ); + + const switchTo = useCallback( + (conversationId: string) => setStoredConversationId(endpoint, userId, conversationId), + [endpoint, userId], + ); + + const startNew = useCallback( + () => removeStoredConversationId(endpoint, userId), + [endpoint, userId], + ); return useMemo( - () => ({ send, stream, loadHistory, loadUsage }), - [send, stream, loadHistory, loadUsage], + () => ({ send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew }), + [send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew], ); } diff --git a/src/agent_manager/api/static/widget/storage/conversationStorage.ts b/src/agent_manager/api/static/widget/storage/conversationStorage.ts index c18262d..803d85e 100644 --- a/src/agent_manager/api/static/widget/storage/conversationStorage.ts +++ b/src/agent_manager/api/static/widget/storage/conversationStorage.ts @@ -1,19 +1,45 @@ -export function conversationStorageKey(endpoint: string): string { - return `agent-chat:${endpoint}`; +/** Which chat is open, remembered per endpoint *and* per user. + * + * Scoping by user matters: one browser can serve several people (a shared + * machine, or a host app that signs a new user in via ``). + * Keyed by endpoint alone, the next user would resume the previous user's + * conversation. + */ +export function conversationStorageKey(endpoint: string, userId: string): string { + return `agent-chat:${endpoint}:${userId}`; } -export function getStoredConversationId(endpoint: string, storage: Storage = localStorage): string | null { - return storage.getItem(conversationStorageKey(endpoint)); +export function getStoredConversationId( + endpoint: string, + userId: string, + storage: Storage = localStorage, +): string | null { + return storage.getItem(conversationStorageKey(endpoint, userId)); } export function setStoredConversationId( endpoint: string, + userId: string, conversationId: string, storage: Storage = localStorage, ): void { - storage.setItem(conversationStorageKey(endpoint), conversationId); + storage.setItem(conversationStorageKey(endpoint, userId), conversationId); +} + +export function removeStoredConversationId( + endpoint: string, + userId: string, + storage: Storage = localStorage, +): void { + storage.removeItem(conversationStorageKey(endpoint, userId)); } -export function removeStoredConversationId(endpoint: string, storage: Storage = localStorage): void { - storage.removeItem(conversationStorageKey(endpoint)); +export function getOrCreateUserId(endpoint: string, storage: Storage = localStorage): string { + const key = `agent-chat:user:${endpoint}`; + let id = storage.getItem(key); + if (!id) { + id = crypto.randomUUID(); + storage.setItem(key, id); + } + return id; } diff --git a/src/agent_manager/api/static/widget/styles/styles.ts b/src/agent_manager/api/static/widget/styles/styles.ts index ba03480..a6dc345 100644 --- a/src/agent_manager/api/static/widget/styles/styles.ts +++ b/src/agent_manager/api/static/widget/styles/styles.ts @@ -41,18 +41,48 @@ export function styles(config: AgentChatConfig): string { from { opacity: 0; transform: translateY(8px); } } .panel.inline { position: static; opacity: 1; transform: none; pointer-events: auto; box-shadow: 0 4px 18px rgba(0,0,0,.12); } - .header { background: #fff; color: #18181b; padding: 14px 18px; font-weight: 600; - font-size: 14px; display: flex; align-items: center; gap: 10px; + .header { background: #fff; color: #18181b; padding: 12px 12px 12px 14px; font-weight: 600; + font-size: 14px; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid #f0f0f1; } .header .dot { width: 22px; height: 22px; border-radius: 50%; flex: 0 0 auto; background: ${config.color}; background-size: cover; background-position: center; } .header .title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .header-btn { background: transparent; border: 0; color: #71717a; cursor: pointer; + padding: 0; width: 30px; height: 30px; border-radius: 8px; flex: 0 0 auto; + display: flex; align-items: center; justify-content: center; + transition: background .12s, color .12s; } + .header-btn:hover { background: #f4f4f5; color: #18181b; } + .header-btn svg { width: 17px; height: 17px; } .close { background: transparent; border: 0; color: #71717a; cursor: pointer; padding: 0; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background .12s; } .close:hover { background: #f4f4f5; color: #18181b; } .close svg { width: 16px; height: 16px; } - .body { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #fff; } + .body { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; background: #fff; } + .thread-drawer { position: absolute; inset: 0; z-index: 3; background: #fff; + display: flex; flex-direction: column; transform: translateX(-100%); + opacity: 0; pointer-events: none; + transition: transform .25s cubic-bezier(.32,.72,0,1), opacity .25s ease; } + .thread-drawer.open { transform: none; opacity: 1; pointer-events: auto; } + .thread-drawer-head { display: flex; align-items: center; justify-content: space-between; + padding: 10px 12px 10px 18px; font-weight: 600; font-size: 14px; color: #18181b; + border-bottom: 1px solid #f0f0f1; } + .thread-new { display: flex; align-items: center; gap: 8px; margin: 12px 14px 4px; + padding: 10px 14px; border: 1px solid #e4e4e7; border-radius: 12px; background: #fff; + color: #18181b; font-size: 14px; font-weight: 500; cursor: pointer; font-family: inherit; + transition: background .12s; } + .thread-new:hover { background: #f4f4f5; } + .thread-new svg { width: 16px; height: 16px; flex: 0 0 auto; } + .thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 6px 10px 14px; + display: flex; flex-direction: column; gap: 2px; + scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; } + .thread-item { text-align: left; border: 0; background: transparent; cursor: pointer; + padding: 10px 12px; border-radius: 10px; font-size: 13.5px; color: #3f3f46; + font-family: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: background .12s; } + .thread-item:hover { background: #f4f4f5; } + .thread-item.active { background: #f4f4f5; color: #18181b; font-weight: 600; } + .thread-empty { color: #a1a1aa; font-size: 13px; text-align: center; padding: 24px 12px; margin: 0; } .messages { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; } .messages::-webkit-scrollbar { width: 10px; } @@ -180,6 +210,7 @@ export function styles(config: AgentChatConfig): string { .welcome { animation: none; } .msg-action svg { animation: none; } .budget-ring-value, .budget-bar-fill, .budget-popover { transition: none; } + .thread-drawer { transition: none; } } @media (max-width: 480px) { .panel:not(.inline) { width: 100vw; height: 100dvh; max-height: 100dvh; diff --git a/src/agent_manager/api/static/widget/types.ts b/src/agent_manager/api/static/widget/types.ts index 92c0626..0f3e988 100644 --- a/src/agent_manager/api/static/widget/types.ts +++ b/src/agent_manager/api/static/widget/types.ts @@ -10,6 +10,7 @@ export interface AgentChatConfig { position: AgentChatPosition; avatar: string; mode: AgentChatMode; + user: string; } export interface AgentChatConfigInput { @@ -20,6 +21,13 @@ export interface AgentChatConfigInput { position?: string; avatar?: string; mode?: string; + user?: string; +} + +export interface ThreadSummary { + conversation_id: string; + title: string | null; + last_message_at: string | null; } export interface ChatMessage { diff --git a/src/agent_manager/application/__init__.py b/src/agent_manager/application/__init__.py index 291c1a2..adad0b3 100644 --- a/src/agent_manager/application/__init__.py +++ b/src/agent_manager/application/__init__.py @@ -1,6 +1,7 @@ """Application layer: use cases orchestrating the domain and its ports.""" from agent_manager.application.service import ( + ConversationAccessDenied, ConversationNotFound, ConversationService, ConversationTokenBudgetExceeded, @@ -8,6 +9,7 @@ ) __all__ = [ + "ConversationAccessDenied", "ConversationNotFound", "ConversationService", "ConversationTokenBudgetExceeded", diff --git a/src/agent_manager/application/service.py b/src/agent_manager/application/service.py index b5fdc8f..78fafc0 100644 --- a/src/agent_manager/application/service.py +++ b/src/agent_manager/application/service.py @@ -19,10 +19,12 @@ from agent_manager.application.context import build_history from agent_manager.domain import ( ConversationMessage, + ConversationSession, Message, Repository, Role, TokenBudgetUsage, + thread_title, ) @@ -30,6 +32,10 @@ class ConversationNotFound(Exception): """Raised when an operation targets a conversation id that does not exist.""" +class ConversationAccessDenied(Exception): + """Raised when a caller acts on a conversation owned by a different user.""" + + class ConversationTokenBudgetExceeded(Exception): """Raised when a conversation's lifetime token budget is exhausted.""" @@ -87,6 +93,11 @@ async def usage(self, conversation_id: str) -> TokenBudgetUsage: used = await self._repository.get_token_usage(conversation_id) return TokenBudgetUsage.from_totals(used, self._max_tokens) + async def list_conversations( + self, user_id: str, *, limit: int = 50 + ) -> list[ConversationSession]: + return await self._repository.list_sessions(user_id, limit=limit) + async def send( self, conversation_id: str, text: str, *, user_id: str | None = None ) -> RunResult: @@ -112,7 +123,14 @@ async def prepare_turn( user_id: str | None = None, ) -> PreparedConversationTurn: """Persist a user message and return its isolated prior model context.""" - await self._require(conversation_id) + session = await self._require(conversation_id) + # The stored session owns the identity of the turn — not the caller. A + # client that omits user_id still runs as the session's owner (hooks and + # tools authorize on RunContext.user_id), and one that sends a different + # user_id is refused rather than silently rebound. + if session.user_id and user_id and user_id != session.user_id: + raise ConversationAccessDenied(conversation_id) + user_id = session.user_id or user_id if user_id: await self._repository.upsert_user(user_id) @@ -127,6 +145,8 @@ async def prepare_turn( max_messages=self._window, max_chars=self._max_chars, ) + if not prior_context.messages: + await self._repository.rename_session(conversation_id, thread_title(text)) run_id = uuid.uuid4().hex now = datetime.now(UTC) await self._repository.append_message( @@ -220,6 +240,8 @@ async def stream( snapshot_ttl_seconds=self._snapshot_ttl_seconds, ) - async def _require(self, conversation_id: str) -> None: - if not await self._repository.conversation_exists(conversation_id): + async def _require(self, conversation_id: str) -> ConversationSession: + session = await self._repository.get_session(conversation_id) + if session is None: raise ConversationNotFound(conversation_id) + return session diff --git a/src/agent_manager/domain/__init__.py b/src/agent_manager/domain/__init__.py index 49ec618..2b5d697 100644 --- a/src/agent_manager/domain/__init__.py +++ b/src/agent_manager/domain/__init__.py @@ -10,6 +10,7 @@ Role, TokenBudgetUsage, User, + thread_title, ) from agent_manager.domain.repository import Repository @@ -24,4 +25,5 @@ "Role", "TokenBudgetUsage", "User", + "thread_title", ] diff --git a/src/agent_manager/domain/models.py b/src/agent_manager/domain/models.py index ffe353f..b8f6a7f 100644 --- a/src/agent_manager/domain/models.py +++ b/src/agent_manager/domain/models.py @@ -99,6 +99,13 @@ class ConversationContext: snapshot: ConversationSnapshot | None = None +def thread_title(content: str, *, limit: int = 48) -> str: + text = " ".join(content.split()) + if not text: + return "New chat" + return text if len(text) <= limit else text[: limit - 1].rstrip() + "…" + + class BudgetSeverity(StrEnum): NORMAL = "normal" WARNING = "warning" diff --git a/src/agent_manager/domain/repository.py b/src/agent_manager/domain/repository.py index 108042e..c6e34e6 100644 --- a/src/agent_manager/domain/repository.py +++ b/src/agent_manager/domain/repository.py @@ -48,6 +48,13 @@ async def create_session( @abstractmethod async def get_session(self, session_id: str) -> ConversationSession | None: ... + @abstractmethod + async def list_sessions(self, user_id: str, *, limit: int = 50) -> list[ConversationSession]: + """A user's sessions, most-recently-active first.""" + + @abstractmethod + async def rename_session(self, session_id: str, title: str) -> None: ... + @abstractmethod async def append_message( self, diff --git a/src/agent_manager/infrastructure/persistence/memory_repository.py b/src/agent_manager/infrastructure/persistence/memory_repository.py index 1e13c0f..d7656e1 100644 --- a/src/agent_manager/infrastructure/persistence/memory_repository.py +++ b/src/agent_manager/infrastructure/persistence/memory_repository.py @@ -4,6 +4,7 @@ import uuid from copy import deepcopy +from dataclasses import replace from datetime import UTC, datetime from typing import Any @@ -18,6 +19,8 @@ User, ) +_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) + class MemoryRepository(Repository): def __init__(self) -> None: @@ -94,6 +97,16 @@ async def create_session( async def get_session(self, session_id: str) -> ConversationSession | None: return self._sessions.get(session_id) + async def list_sessions(self, user_id: str, *, limit: int = 50) -> list[ConversationSession]: + sessions = [s for s in self._sessions.values() if s.user_id == user_id] + sessions.sort(key=lambda s: s.last_message_at or s.created_at or _EPOCH, reverse=True) + return sessions[:limit] + + async def rename_session(self, session_id: str, title: str) -> None: + session = self._sessions.get(session_id) + if session is not None: + self._sessions[session_id] = replace(session, title=title) + async def create_conversation(self) -> str: return (await self.create_session()).session_id diff --git a/src/agent_manager/infrastructure/persistence/sql_repository.py b/src/agent_manager/infrastructure/persistence/sql_repository.py index 7d746d9..1bb3b8d 100644 --- a/src/agent_manager/infrastructure/persistence/sql_repository.py +++ b/src/agent_manager/infrastructure/persistence/sql_repository.py @@ -123,6 +123,25 @@ async def get_session(self, session_id: str) -> ConversationSession | None: row = await session.get(ConversationSessionRow, session_id) return _session(row) if row else None + async def list_sessions(self, user_id: str, *, limit: int = 50) -> list[ConversationSession]: + stmt = ( + select(ConversationSessionRow) + .where(ConversationSessionRow.user_id == user_id) + .order_by(col(ConversationSessionRow.last_message_at).desc()) + .limit(limit) + ) + async with self._sessions() as session: + rows = (await session.exec(stmt)).all() + return [_session(row) for row in rows] + + async def rename_session(self, session_id: str, title: str) -> None: + async with self._sessions() as session: + row = await session.get(ConversationSessionRow, session_id) + if row is not None: + row.title = title + session.add(row) + await session.commit() + # `create_conversation`/`add_message` are not overridden here: the # `Repository` base class already provides them as thin aliases over # `create_session`/`append_message` (see domain/repository.py), which is diff --git a/tests/agent_manager/test_api.py b/tests/agent_manager/test_api.py index 694566e..b587180 100644 --- a/tests/agent_manager/test_api.py +++ b/tests/agent_manager/test_api.py @@ -14,7 +14,7 @@ from agent_engine.runtime.streaming import RunStreamEvent from agent_manager.api.routes import router from agent_manager.application import ConversationService -from agent_manager.domain import TokenBudgetUsage +from agent_manager.domain import TokenBudgetUsage, thread_title from agent_manager.infrastructure.persistence.memory_repository import MemoryRepository from tests.agent_manager.conftest import RecordingEngine @@ -41,6 +41,27 @@ def test_create_send_history_round_trip(client: TestClient) -> None: ] +def test_list_conversations_returns_titled_threads_scoped_to_user(client: TestClient) -> None: + a = client.post("/conversations", json={"user_id": "u1"}).json()["conversation_id"] + client.post(f"/conversations/{a}/messages", json={"message": "first thread"}) + b = client.post("/conversations", json={"user_id": "u1"}).json()["conversation_id"] + client.post(f"/conversations/{b}/messages", json={"message": "second thread"}) + + threads = client.get("/conversations", params={"user_id": "u1"}).json() + assert {t["conversation_id"]: t["title"] for t in threads} == { + a: "first thread", + b: "second thread", + } + assert client.get("/conversations", params={"user_id": "u2"}).json() == [] + + +def test_thread_title_collapses_whitespace_and_truncates() -> None: + assert thread_title(" hi there ") == "hi there" + assert thread_title("") == "New chat" + truncated = thread_title("x" * 60) + assert len(truncated) == 48 and truncated.endswith("…") + + def test_unknown_conversation_returns_404(client: TestClient) -> None: assert client.get("/conversations/nope/messages").status_code == 404 assert client.post("/conversations/nope/messages", json={"message": "x"}).status_code == 404 diff --git a/tests/agent_manager/test_service.py b/tests/agent_manager/test_service.py index fcb1a25..b9b4e79 100644 --- a/tests/agent_manager/test_service.py +++ b/tests/agent_manager/test_service.py @@ -7,7 +7,11 @@ import pytest from agent_engine.engine.types import ChatMessage, ChatRole -from agent_manager.application import ConversationNotFound, ConversationService +from agent_manager.application import ( + ConversationAccessDenied, + ConversationNotFound, + ConversationService, +) from agent_manager.domain import Role from agent_manager.infrastructure.persistence.memory_repository import MemoryRepository from tests.agent_manager.conftest import RecordingEngine @@ -80,6 +84,28 @@ async def test_send_uses_stable_session_and_unique_run_id() -> None: assert contexts[0].run_id != contexts[1].run_id +async def test_turn_runs_as_the_session_owner_when_the_caller_sends_no_user_id() -> None: + """A client that only knows the conversation id must not run as nobody — + hooks and tools authorize on RunContext.user_id.""" + service, engine = _service() + cid = await service.create(user_id="u1", session_id="sess-1") + + await service.send(cid, "hi") + + assert [ctx.user_id for ctx in engine.contexts if ctx] == ["u1"] + + +async def test_turn_refuses_a_user_id_that_does_not_own_the_conversation() -> None: + service, engine = _service() + cid = await service.create(user_id="u1", session_id="sess-1") + + with pytest.raises(ConversationAccessDenied): + await service.send(cid, "hi", user_id="u2") + + assert engine.contexts == [] + assert await service.history(cid) == [] + + async def test_service_creates_user_and_session_metadata() -> None: service, _ = _service() cid = await service.create(user_id="u1", session_id="sess-1") diff --git a/tests/e2e/widget.spec.ts b/tests/e2e/widget.spec.ts index c499eb1..7fe455f 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -2,9 +2,38 @@ import { expect, test, type Page, type Route } from "@playwright/test"; const history: Record> = {}; -async function mockConversationApi(page: Page, options: { failSend?: boolean } = {}) { +const ENDPOINT = "http://127.0.0.1:8123"; +const E2E_USER = "e2e-user"; +// Conversation storage is scoped by user, so a test that reads or seeds it has +// to know which user the widget will pick. Pin the anonymous id it would +// otherwise generate. +const CONVERSATION_KEY = `agent-chat:${ENDPOINT}:${E2E_USER}`; + +async function pinUser(page: Page) { + await page.addInitScript( + ([endpoint, user]) => localStorage.setItem(`agent-chat:user:${endpoint}`, user), + [ENDPOINT, E2E_USER], + ); +} + +async function mockConversationApi( + page: Page, + options: { + failSend?: boolean; + threads?: Array<{ conversation_id: string; title: string | null; last_message_at: string | null }>; + } = {}, +) { const calls: string[] = []; + await page.route(/\/conversations\?/, async (route) => { + calls.push(`GET ${new URL(route.request().url()).pathname}`); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(options.threads ?? []), + }); + }); + await page.route("**/conversations", async (route) => { calls.push(`${route.request().method()} ${new URL(route.request().url()).pathname}`); await route.fulfill({ @@ -382,6 +411,7 @@ test("sending a message calls backend, renders assistant answer, stores conversa page, }) => { const calls = await mockConversationApi(page); + await pinUser(page); await page.goto("/widget-demo.html"); await shadowClick(page, ".launcher"); await shadowFill(page, ".input", "hello browser"); @@ -390,7 +420,7 @@ test("sending a message calls backend, renders assistant answer, stores conversa await expect.poll(() => shadowText(page, ".messages")).toContain("Echo: hello browser"); await expect.poll(() => shadowActiveMatches(page, ".input")).toBe(true); await expect - .poll(() => page.evaluate(() => localStorage.getItem("agent-chat:http://127.0.0.1:8123"))) + .poll(() => page.evaluate((key) => localStorage.getItem(key), CONVERSATION_KEY)) .toBe("conv-smoke"); expect(calls).toContain("POST /conversations"); expect(calls).toContain("POST /conversations/conv-smoke/messages/stream"); @@ -476,10 +506,44 @@ test("budget meter stays hidden when no budget is configured", async ({ page }) await expect.poll(() => shadowExists(page, ".budget-meter")).toBe(false); }); +test("thread drawer lists conversations, switches to one, and starts a new chat", async ({ page }) => { + await mockConversationApi(page, { + threads: [ + { conversation_id: "conv-old", title: "Older chat", last_message_at: "2026-06-01T00:00:00Z" }, + ], + }); + await page.route("**/conversations/conv-old/messages", async (route: Route) => { + if (route.request().method() !== "GET") return route.fallback(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { role: "user", content: "old question", created_at: "2026-06-01T00:00:00Z" }, + { role: "assistant", content: "old answer", created_at: "2026-06-01T00:00:00Z" }, + ]), + }); + }); + + await page.goto("/widget-demo.html"); + await shadowClick(page, ".launcher"); + + await shadowClick(page, '.header-btn[aria-label="Conversations"]'); + await expect.poll(() => shadowClassContains(page, ".thread-drawer", "open")).toBe(true); + await expect.poll(() => shadowText(page, ".thread-item")).toContain("Older chat"); + + await shadowClick(page, ".thread-item"); + await expect.poll(() => shadowText(page, ".messages")).toContain("old answer"); + await expect.poll(() => shadowClassContains(page, ".thread-drawer", "open")).toBe(false); + + await shadowClick(page, '.header-btn[aria-label="New chat"]'); + await expect.poll(() => shadowText(page, ".messages")).toContain("How can I help you today?"); +}); + test("stale stored conversation is replaced before sending to the agent", async ({ page }) => { const calls = await mockConversationApiWithStaleConversation(page); + await pinUser(page); await page.goto("/widget-demo.html"); - await page.evaluate(() => localStorage.setItem("agent-chat:http://127.0.0.1:8123", "conv-stale")); + await page.evaluate((key) => localStorage.setItem(key, "conv-stale"), CONVERSATION_KEY); await shadowClick(page, ".launcher"); await shadowFill(page, ".input", "recover please"); @@ -487,7 +551,7 @@ test("stale stored conversation is replaced before sending to the agent", async await expect.poll(() => shadowText(page, ".messages")).toContain("Recovered: recover please"); await expect - .poll(() => page.evaluate(() => localStorage.getItem("agent-chat:http://127.0.0.1:8123"))) + .poll(() => page.evaluate((key) => localStorage.getItem(key), CONVERSATION_KEY)) .toBe("conv-fresh"); expect(calls).toContain("GET /conversations/conv-stale/messages"); expect(calls).toContain("POST /conversations");