From 3a79fe2fdbf733fac9ee52453a34d568cc690f26 Mon Sep 17 00:00:00 2001 From: Amit Avital Date: Mon, 27 Jul 2026 18:50:49 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat(widget):=20conversation=20thread=20lis?= =?UTF-8?q?t=20=E2=80=94=20list,=20new,=20switch,=20auto-title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a user see their past conversations, start a new one, and switch between them, backed by the agent manager as the source of truth. Backend: - Repository.list_sessions(user_id) + rename_session (port, memory, sql) - GET /conversations?user_id=... returns id, title, last_message_at (most-recently-active first); ConversationService.list_conversations - thread_title() derives a title from the first user message; the service sets it on the first turn of a conversation Widget (stateless; BE owns the list and titles): - anonymous per-browser user id in localStorage, sent on create/list; an optional attribute overrides it, leaving the seam for real host identity later - AgentChatClient.listConversations + useConversation listThreads/ switchTo/startNew - header history + new-chat buttons and a slide-over thread drawer; switching loads that thread's messages and usage; drawer is inert when closed. Degrades to empty list against a backend without the endpoint Tests: listing scoped by user, auto-title, and a widget e2e for the drawer (list, switch, new chat). Co-Authored-By: Claude Opus 4.8 --- src/agent_manager/api/routes.py | 14 + src/agent_manager/api/schemas.py | 6 + src/agent_manager/api/static/widget.js | 268 +++++++++++++++--- src/agent_manager/api/static/widget.test.mjs | 1 + .../api/static/widget/api/AgentChatClient.ts | 31 +- .../api/static/widget/config/parseConfig.ts | 2 + .../widget/element/AgentChatElement.tsx | 2 +- .../api/static/widget/react/AgentChatApp.tsx | 116 +++++++- .../static/widget/react/useConversation.ts | 37 ++- .../widget/storage/conversationStorage.ts | 10 + .../api/static/widget/styles/styles.ts | 37 ++- src/agent_manager/api/static/widget/types.ts | 8 + src/agent_manager/application/service.py | 9 + src/agent_manager/domain/__init__.py | 2 + src/agent_manager/domain/models.py | 7 + src/agent_manager/domain/repository.py | 7 + .../persistence/memory_repository.py | 13 + .../persistence/sql_repository.py | 19 ++ tests/agent_manager/test_api.py | 23 +- tests/e2e/widget.spec.ts | 50 +++- 20 files changed, 608 insertions(+), 54 deletions(-) diff --git a/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index cbcfa0e3..06f10ee0 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, @@ -42,6 +43,19 @@ 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]: + 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: diff --git a/src/agent_manager/api/schemas.py b/src/agent_manager/api/schemas.py index 1c2452a2..c7356bc8 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 3b15819c..275cc38b 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) { + 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)); +} +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,29 +53065,13 @@ 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(); + const created = await client.createConversation(userId); setStoredConversationId(endpoint, created); return created; - }, [client, endpoint]); + }, [client, endpoint, userId]); const ensureId = (0, import_react9.useCallback)( async () => getStoredConversationId(endpoint) ?? startConversation(), [endpoint, startConversation] @@ -53073,9 +53121,18 @@ function useConversation(client, endpoint) { return null; } }, [client, endpoint]); + const listThreads = (0, import_react9.useCallback)( + () => client.listConversations(userId).catch(() => []), + [client, userId] + ); + const switchTo = (0, import_react9.useCallback)( + (conversationId) => setStoredConversationId(endpoint, conversationId), + [endpoint] + ); + const startNew = (0, import_react9.useCallback)(() => removeStoredConversationId(endpoint), [endpoint]); 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 +53149,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 +53189,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 +53279,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), + 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 +53430,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 +53580,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 +53749,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 +53772,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 +54093,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 +54109,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 c84172c3..d5fd1039 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 20cc8568..60f46757 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 24425c22..d9ec742b 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 510d265d..94237ecf 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 d2e0f7c8..05ae8219 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 3fab9540..9079069d 100644 --- a/src/agent_manager/api/static/widget/react/useConversation.ts +++ b/src/agent_manager/api/static/widget/react/useConversation.ts @@ -6,24 +6,37 @@ 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(); + const created = await client.createConversation(userId); setStoredConversationId(endpoint, created); return created; - }, [client, endpoint]); + }, [client, endpoint, userId]); const ensureId = useCallback( async () => getStoredConversationId(endpoint) ?? startConversation(), @@ -80,8 +93,20 @@ export function useConversation(client: AgentChatClient, endpoint: string): Conv } }, [client, endpoint]); + const listThreads = useCallback( + () => client.listConversations(userId).catch(() => []), + [client, userId], + ); + + const switchTo = useCallback( + (conversationId: string) => setStoredConversationId(endpoint, conversationId), + [endpoint], + ); + + const startNew = useCallback(() => removeStoredConversationId(endpoint), [endpoint]); + 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 c18262d7..7604ff82 100644 --- a/src/agent_manager/api/static/widget/storage/conversationStorage.ts +++ b/src/agent_manager/api/static/widget/storage/conversationStorage.ts @@ -17,3 +17,13 @@ export function setStoredConversationId( 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 ba034801..a6dc3458 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 92c06267..0f3e988f 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/service.py b/src/agent_manager/application/service.py index b5fdc8f0..3289cc1c 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, ) @@ -87,6 +89,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: @@ -127,6 +134,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( diff --git a/src/agent_manager/domain/__init__.py b/src/agent_manager/domain/__init__.py index 49ec6188..2b5d6977 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 ffe353f3..b8f6a7f7 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 108042ef..c6e34e65 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 1e13c0ff..d7656e1c 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 7d746d93..1bb3b8d4 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 694566e3..b5871804 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/e2e/widget.spec.ts b/tests/e2e/widget.spec.ts index c499eb12..7cf79994 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -2,9 +2,24 @@ import { expect, test, type Page, type Route } from "@playwright/test"; const history: Record> = {}; -async function mockConversationApi(page: Page, options: { failSend?: boolean } = {}) { +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({ @@ -476,6 +491,39 @@ 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 page.goto("/widget-demo.html"); From 0c6ed9fbf90b03adbdf245b1c33c76855c53266f Mon Sep 17 00:00:00 2001 From: Amit Avital Date: Tue, 28 Jul 2026 21:03:26 +0300 Subject: [PATCH 2/6] fix(widget): derive turn identity from the session, scope storage per user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #59, all three points: 1. The widget remembered the active conversation under a key built from the endpoint alone, so a second user on the same browser (shared machine, or a host app signing someone else in via ``) resumed the previous user's chat. The key is now `agent-chat::`. 2. `user_id` was sent when a conversation was created but not on send/stream, so the run executed with `RunContext.user_id = None` — exactly the field hooks and tools authorize on. The service now loads the session and takes the identity from it: a caller that omits user_id runs as the session's owner, and one that supplies a *different* user_id is refused with ConversationAccessDenied (403) instead of being silently rebound. The browser no longer decides who it is on a turn. 3. `GET /conversations?user_id=` stays a scoping parameter, not an authorization boundary — now stated in the route's docstring, with the place to put real auth. `_require` returns the session it already had to fetch, so the ownership check costs no extra query. Tests: a turn with no client user_id runs as the owner; a mismatched user_id raises and persists nothing. Co-Authored-By: Claude Opus 5 --- src/agent_manager/api/routes.py | 12 +++++ src/agent_manager/api/static/widget.js | 47 ++++++++++--------- .../api/static/widget/react/AgentChatApp.tsx | 2 +- .../static/widget/react/useConversation.ts | 29 +++++++----- .../widget/storage/conversationStorage.ts | 30 +++++++++--- src/agent_manager/application/__init__.py | 2 + src/agent_manager/application/service.py | 19 ++++++-- tests/agent_manager/test_service.py | 28 ++++++++++- tests/e2e/widget.spec.ts | 22 +++++++-- 9 files changed, 141 insertions(+), 50 deletions(-) diff --git a/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index 06f10ee0..ea6013f1 100644 --- a/src/agent_manager/api/routes.py +++ b/src/agent_manager/api/routes.py @@ -24,6 +24,7 @@ ToolRecord, ) from agent_manager.application import ( + ConversationAccessDenied, ConversationNotFound, ConversationService, ConversationTokenBudgetExceeded, @@ -45,6 +46,13 @@ async def create_conversation( @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( @@ -87,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 @@ -129,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/static/widget.js b/src/agent_manager/api/static/widget.js index 275cc38b..b23ec28c 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -52423,17 +52423,17 @@ var X = createLucideIcon("x", __iconNode12); var import_react10 = __toESM(require_react(), 1); // src/agent_manager/api/static/widget/storage/conversationStorage.ts -function conversationStorageKey(endpoint) { - return `agent-chat:${endpoint}`; +function conversationStorageKey(endpoint, userId) { + return `agent-chat:${endpoint}:${userId}`; } -function getStoredConversationId(endpoint, storage = localStorage) { - return storage.getItem(conversationStorageKey(endpoint)); +function getStoredConversationId(endpoint, userId, storage = localStorage) { + return storage.getItem(conversationStorageKey(endpoint, userId)); } -function setStoredConversationId(endpoint, conversationId, storage = localStorage) { - storage.setItem(conversationStorageKey(endpoint), conversationId); +function setStoredConversationId(endpoint, userId, conversationId, storage = localStorage) { + storage.setItem(conversationStorageKey(endpoint, userId), conversationId); } -function removeStoredConversationId(endpoint, storage = localStorage) { - storage.removeItem(conversationStorageKey(endpoint)); +function removeStoredConversationId(endpoint, userId, storage = localStorage) { + storage.removeItem(conversationStorageKey(endpoint, userId)); } function getOrCreateUserId(endpoint, storage = localStorage) { const key = `agent-chat:user:${endpoint}`; @@ -53069,17 +53069,17 @@ var isMissingConversation = (error) => error instanceof AgentChatHttpError && er function useConversation(client, endpoint, userId) { const startConversation = (0, import_react9.useCallback)(async () => { const created = await client.createConversation(userId); - setStoredConversationId(endpoint, created); + setStoredConversationId(endpoint, userId, created); return created; }, [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 { @@ -53103,33 +53103,36 @@ function useConversation(client, endpoint, userId) { [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, conversationId), - [endpoint] + (conversationId) => setStoredConversationId(endpoint, userId, conversationId), + [endpoint, userId] + ); + const startNew = (0, import_react9.useCallback)( + () => removeStoredConversationId(endpoint, userId), + [endpoint, userId] ); - const startNew = (0, import_react9.useCallback)(() => removeStoredConversationId(endpoint), [endpoint]); return (0, import_react9.useMemo)( () => ({ send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew }), [send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew] @@ -53309,7 +53312,7 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { { open: threadsOpen, threads, - activeId: getStoredConversationId(config.endpoint), + activeId: getStoredConversationId(config.endpoint, userId), onSelect: openThread, onNew: startNewThread, onClose: () => setThreadsOpen(false) diff --git a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx index 05ae8219..d06ccb36 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -236,7 +236,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age setThreadsOpen(false)} diff --git a/src/agent_manager/api/static/widget/react/useConversation.ts b/src/agent_manager/api/static/widget/react/useConversation.ts index 9079069d..e9bf2634 100644 --- a/src/agent_manager/api/static/widget/react/useConversation.ts +++ b/src/agent_manager/api/static/widget/react/useConversation.ts @@ -34,19 +34,19 @@ export function useConversation( ): Conversation { const startConversation = useCallback(async () => { const created = await client.createConversation(userId); - setStoredConversationId(endpoint, created); + setStoredConversationId(endpoint, userId, created); return created; }, [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) => { @@ -73,25 +73,25 @@ export function useConversation( ); 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(() => []), @@ -99,11 +99,14 @@ export function useConversation( ); const switchTo = useCallback( - (conversationId: string) => setStoredConversationId(endpoint, conversationId), - [endpoint], + (conversationId: string) => setStoredConversationId(endpoint, userId, conversationId), + [endpoint, userId], ); - const startNew = useCallback(() => removeStoredConversationId(endpoint), [endpoint]); + const startNew = useCallback( + () => removeStoredConversationId(endpoint, userId), + [endpoint, userId], + ); return useMemo( () => ({ 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 7604ff82..803d85e0 100644 --- a/src/agent_manager/api/static/widget/storage/conversationStorage.ts +++ b/src/agent_manager/api/static/widget/storage/conversationStorage.ts @@ -1,21 +1,37 @@ -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, storage: Storage = localStorage): void { - storage.removeItem(conversationStorageKey(endpoint)); +export function removeStoredConversationId( + endpoint: string, + userId: string, + storage: Storage = localStorage, +): void { + storage.removeItem(conversationStorageKey(endpoint, userId)); } export function getOrCreateUserId(endpoint: string, storage: Storage = localStorage): string { diff --git a/src/agent_manager/application/__init__.py b/src/agent_manager/application/__init__.py index 291c1a27..adad0b37 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 3289cc1c..78fafc0d 100644 --- a/src/agent_manager/application/service.py +++ b/src/agent_manager/application/service.py @@ -32,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.""" @@ -119,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) @@ -229,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/tests/agent_manager/test_service.py b/tests/agent_manager/test_service.py index fcb1a25c..b9b4e79a 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 7cf79994..7fe455f4 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -2,6 +2,20 @@ import { expect, test, type Page, type Route } from "@playwright/test"; const history: Record> = {}; +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: { @@ -397,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"); @@ -405,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"); @@ -526,8 +541,9 @@ test("thread drawer lists conversations, switches to one, and starts a new chat" 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"); @@ -535,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"); From c4f3c3c0e1937bdfc7e72ae6a4d388a299e52d2e Mon Sep 17 00:00:00 2001 From: Amit Avital Date: Mon, 27 Jul 2026 20:06:01 +0300 Subject: [PATCH 3/6] feat(widget): bouncing-dots loading that survives thread switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a bouncing-dots indicator while the assistant has no answer text yet, and keep it visible when the user leaves a streaming thread and returns. Messages now live in a per-conversation map keyed by the id captured at submit time, so switching threads only swaps the view — the in-flight stream keeps writing to its own bucket. `typing` means "stream in flight" (set at submit, cleared at completion); reduceStreamEvent is reduced to content accumulation only, fixing dots that could otherwise linger on an empty final answer. Co-Authored-By: Claude Opus 4.8 --- src/agent_manager/api/static/widget.js | 112 +++++++++++++----- .../api/static/widget/react/AgentChatApp.tsx | 97 ++++++++++----- .../api/static/widget/react/streamReducer.ts | 7 +- .../static/widget/react/useConversation.ts | 21 +++- .../api/static/widget/styles/styles.ts | 10 ++ tests/e2e/widget.spec.ts | 62 ++++++++++ 6 files changed, 239 insertions(+), 70 deletions(-) diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index b23ec28c..3ca575ba 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -53027,20 +53027,19 @@ var TOOL_STATUS = { function reduceStreamEvent(entry, event) { switch (event.type) { case "answer_delta": - return { ...entry, text: entry.text + (event.content ?? ""), typing: false }; + return { ...entry, text: entry.text + (event.content ?? "") }; case "route": - return { ...entry, route: event.route ?? entry.route, typing: false }; + return { ...entry, route: event.route ?? entry.route }; case "tool_started": case "tool_succeeded": case "tool_failed": - return { ...entry, tools: upsertTool(entry.tools ?? [], toToolRecord(event)), typing: false }; + return { ...entry, tools: upsertTool(entry.tools ?? [], toToolRecord(event)) }; case "final": return { ...entry, text: event.content ?? entry.text, route: event.route ?? entry.route, - tools: event.used_tools ?? entry.tools, - typing: false + tools: event.used_tools ?? entry.tools }; case "error": throw new Error(event.error || "stream failed"); @@ -53072,6 +53071,10 @@ function useConversation(client, endpoint, userId) { setStoredConversationId(endpoint, userId, created); return created; }, [client, endpoint, userId]); + const peekId = (0, import_react9.useCallback)( + () => getStoredConversationId(endpoint, userId), + [endpoint, userId] + ); const ensureId = (0, import_react9.useCallback)( async () => getStoredConversationId(endpoint, userId) ?? startConversation(), [endpoint, userId, startConversation] @@ -53134,8 +53137,18 @@ function useConversation(client, endpoint, userId) { [endpoint, userId] ); return (0, import_react9.useMemo)( - () => ({ send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew }), - [send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew] + () => ({ + peekId, + ensureId, + send, + stream, + loadHistory, + loadUsage, + listThreads, + switchTo, + startNew + }), + [peekId, ensureId, send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew] ); } @@ -53160,7 +53173,9 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { 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 [entriesById, setEntriesById] = (0, import_react10.useState)({}); + const [activeId, setActiveId] = (0, import_react10.useState)(""); + const entries = entriesById[activeId] ?? []; const [usage, setUsage] = (0, import_react10.useState)(null); const [threads, setThreads] = (0, import_react10.useState)([]); const [threadsOpen, setThreadsOpen] = (0, import_react10.useState)(false); @@ -53169,13 +53184,26 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { const refreshUsage = (0, import_react10.useCallback)(async () => { setUsage(await conversation.loadUsage()); }, [conversation]); + const putEntries = (0, import_react10.useCallback)( + (cid, update) => setEntriesById((prev) => ({ ...prev, [cid]: update(prev[cid] ?? []) })), + [] + ); + const loadThread = (0, import_react10.useCallback)( + async (cid) => { + const history = await conversation.loadHistory(); + putEntries(cid, () => history.map(toEntry)); + }, + [conversation, putEntries] + ); const loadHistory = (0, import_react10.useCallback)(async () => { if (loaded) return; setLoaded(true); - const history = await conversation.loadHistory(); - if (history.length) setEntries(history.map(toEntry)); + const cid = conversation.peekId(); + if (!cid) return; + setActiveId(cid); + await loadThread(cid); await refreshUsage(); - }, [conversation, loaded, refreshUsage]); + }, [conversation, loaded, loadThread, refreshUsage]); (0, import_react10.useEffect)(() => { if (inline) void loadHistory(); }, [inline, loadHistory]); @@ -53200,28 +53228,29 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { async (conversationId) => { conversation.switchTo(conversationId); setThreadsOpen(false); - const history = await conversation.loadHistory(); - setEntries(history.map(toEntry)); + setActiveId(conversationId); + if (!(conversationId in entriesById)) await loadThread(conversationId); await refreshUsage(); inputRef.current?.focus({ preventScroll: true }); }, - [conversation, refreshUsage] + [conversation, entriesById, loadThread, refreshUsage] ); const startNewThread = (0, import_react10.useCallback)(() => { conversation.startNew(); setThreadsOpen(false); - setEntries([]); + setActiveId(""); 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)); - }, []); + const replaceEntry = (0, import_react10.useCallback)( + (cid, id, entry) => putEntries(cid, (prev) => prev.map((current) => current.id === id ? entry : current)), + [putEntries] + ); const sendWithoutStreaming = (0, import_react10.useCallback)( - async (text10, entryId) => { + async (cid, text10, entryId) => { try { const answer = await conversation.send(text10); - replaceEntry(entryId, { + replaceEntry(cid, entryId, { id: entryId, role: "ai", text: answer.answer, @@ -53230,32 +53259,34 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { }); onAnswer({ visited: answer.visited ?? [], used_tools: answer.used_tools ?? [] }); } catch { - replaceEntry(entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); + replaceEntry(cid, entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); } }, [conversation, onAnswer, replaceEntry] ); const submit = (0, import_react10.useCallback)( async (text10) => { + const cid = await conversation.ensureId(); + setActiveId(cid); const pending = { id: newId(), role: "ai", text: "", typing: true }; - setEntries((prev) => [...prev, { id: newId(), role: "user", text: text10 }, pending]); + putEntries(cid, (prev) => [...prev, { id: newId(), role: "user", text: text10 }, pending]); setSending(true); try { let entry = pending; for await (const event of conversation.stream(text10)) { entry = reduceStreamEvent(entry, event); - replaceEntry(pending.id, entry); + replaceEntry(cid, pending.id, entry); } - replaceEntry(pending.id, { ...entry, typing: false }); + replaceEntry(cid, pending.id, { ...entry, typing: false }); onAnswer({ visited: entry.route ?? [], used_tools: entry.tools ?? [] }); } catch { - await sendWithoutStreaming(text10, pending.id); + await sendWithoutStreaming(cid, text10, pending.id); } finally { setSending(false); void refreshUsage(); } }, - [conversation, onAnswer, refreshUsage, replaceEntry, sendWithoutStreaming] + [conversation, onAnswer, putEntries, refreshUsage, replaceEntry, sendWithoutStreaming] ); const toggle = () => void (open ? closeChat() : openChat()); return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( @@ -53381,19 +53412,26 @@ function Launcher({ } function ChatMessage({ entry }) { const from = entry.role === "user" ? "user" : "assistant"; - if (entry.typing) { - return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Message, { from, typing: true, children: "..." }); - } if (entry.error) { return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Message, { from, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "msg-error", role: "alert", children: entry.text }) }); } if (entry.role === "user") { return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Message, { from: "user", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageContent, { children: entry.text }) }); } - return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(Message, { from: "assistant", children: [ + const thinking = Boolean(entry.typing) && !entry.text.trim(); + return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(Message, { from: "assistant", typing: thinking, children: [ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(AgentActivity, { route: entry.route, tools: entry.tools }), - /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageContent, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageResponse, { children: entry.text }) }), - entry.text.trim() ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageActions, { text: entry.text }) : null + thinking ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ThinkingDots, {}) : /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageContent, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageResponse, { children: entry.text }) }), + entry.text.trim() ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageActions, { text: entry.text }) : null + ] }) + ] }); +} +function ThinkingDots() { + return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "thinking", "aria-hidden": true, children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "thinking-dot" }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "thinking-dot" }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "thinking-dot" }) ] }); } function MessageActions({ text: text10 }) { @@ -53684,6 +53722,15 @@ function styles(config) { color: #b91c1c; border-radius: 8px; padding: 10px 12px; font-size: 13.5px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + .thinking { display: inline-flex; gap: 4px; color: #a1a1aa; } + .thinking-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; + animation: aui-dot 1.4s ease-in-out infinite; } + .thinking-dot:nth-child(2) { animation-delay: .2s; } + .thinking-dot:nth-child(3) { animation-delay: .4s; } + @keyframes aui-dot { + 0%, 100% { transform: scale(.8); opacity: .5; } + 50% { transform: scale(1.2); opacity: 1; } + } .composer { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; padding: 12px 14px; border-top: 1px solid #f0f0f1; } .input-wrap { min-width: 0; display: flex; border-radius: 20px; background: #fff; @@ -53753,6 +53800,7 @@ function styles(config) { .msg-action svg { animation: none; } .budget-ring-value, .budget-bar-fill, .budget-popover { transition: none; } .thread-drawer { transition: none; } + .thinking-dot { animation: none; } } @media (max-width: 480px) { .panel:not(.inline) { width: 100vw; height: 100dvh; max-height: 100dvh; diff --git a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx index d06ccb36..e8624fc5 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -69,7 +69,9 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age const [open, setOpen] = useState(inline); const [loaded, setLoaded] = useState(false); const [sending, setSending] = useState(false); - const [entries, setEntries] = useState([]); + const [entriesById, setEntriesById] = useState>({}); + const [activeId, setActiveId] = useState(""); + const entries = entriesById[activeId] ?? []; const [usage, setUsage] = useState(null); const [threads, setThreads] = useState([]); const [threadsOpen, setThreadsOpen] = useState(false); @@ -80,13 +82,29 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age setUsage(await conversation.loadUsage()); }, [conversation]); + const putEntries = useCallback( + (cid: string, update: (prev: MessageEntry[]) => MessageEntry[]) => + setEntriesById((prev) => ({ ...prev, [cid]: update(prev[cid] ?? []) })), + [], + ); + + const loadThread = useCallback( + async (cid: string) => { + const history = await conversation.loadHistory(); + putEntries(cid, () => history.map(toEntry)); + }, + [conversation, putEntries], + ); + const loadHistory = useCallback(async () => { if (loaded) return; setLoaded(true); - const history = await conversation.loadHistory(); - if (history.length) setEntries(history.map(toEntry)); + const cid = conversation.peekId(); + if (!cid) return; + setActiveId(cid); + await loadThread(cid); await refreshUsage(); - }, [conversation, loaded, refreshUsage]); + }, [conversation, loaded, loadThread, refreshUsage]); useEffect(() => { if (inline) void loadHistory(); @@ -117,31 +135,34 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age async (conversationId: string) => { conversation.switchTo(conversationId); setThreadsOpen(false); - const history = await conversation.loadHistory(); - setEntries(history.map(toEntry)); + setActiveId(conversationId); + // ponytail: in-session map owns in-flight streams, so only cold-load a thread we haven't opened yet. + if (!(conversationId in entriesById)) await loadThread(conversationId); await refreshUsage(); inputRef.current?.focus({ preventScroll: true }); }, - [conversation, refreshUsage], + [conversation, entriesById, loadThread, refreshUsage], ); const startNewThread = useCallback(() => { conversation.startNew(); setThreadsOpen(false); - setEntries([]); + setActiveId(""); 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))); - }, []); + const replaceEntry = useCallback( + (cid: string, id: string, entry: MessageEntry) => + putEntries(cid, (prev) => prev.map((current) => (current.id === id ? entry : current))), + [putEntries], + ); const sendWithoutStreaming = useCallback( - async (text: string, entryId: string) => { + async (cid: string, text: string, entryId: string) => { try { const answer = await conversation.send(text); - replaceEntry(entryId, { + replaceEntry(cid, entryId, { id: entryId, role: "ai", text: answer.answer, @@ -150,7 +171,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age }); onAnswer({ visited: answer.visited ?? [], used_tools: answer.used_tools ?? [] }); } catch { - replaceEntry(entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); + replaceEntry(cid, entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); } }, [conversation, onAnswer, replaceEntry], @@ -158,25 +179,27 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age const submit = useCallback( async (text: string) => { + const cid = await conversation.ensureId(); + setActiveId(cid); const pending: MessageEntry = { id: newId(), role: "ai", text: "", typing: true }; - setEntries((prev) => [...prev, { id: newId(), role: "user", text }, pending]); + putEntries(cid, (prev) => [...prev, { id: newId(), role: "user", text }, pending]); setSending(true); try { let entry = pending; for await (const event of conversation.stream(text)) { entry = reduceStreamEvent(entry, event); - replaceEntry(pending.id, entry); + replaceEntry(cid, pending.id, entry); } - replaceEntry(pending.id, { ...entry, typing: false }); + replaceEntry(cid, pending.id, { ...entry, typing: false }); onAnswer({ visited: entry.route ?? [], used_tools: entry.tools ?? [] }); } catch { - await sendWithoutStreaming(text, pending.id); + await sendWithoutStreaming(cid, text, pending.id); } finally { setSending(false); void refreshUsage(); } }, - [conversation, onAnswer, refreshUsage, replaceEntry, sendWithoutStreaming], + [conversation, onAnswer, putEntries, refreshUsage, replaceEntry, sendWithoutStreaming], ); const toggle = () => void (open ? closeChat() : openChat()); @@ -310,14 +333,6 @@ function Launcher({ function ChatMessage({ entry }: { entry: MessageEntry }) { const from = entry.role === "user" ? "user" : "assistant"; - if (entry.typing) { - return ( - - ... - - ); - } - if (entry.error) { return ( @@ -336,17 +351,35 @@ function ChatMessage({ entry }: { entry: MessageEntry }) { ); } + const thinking = Boolean(entry.typing) && !entry.text.trim(); + return ( - + - - {entry.text} - - {entry.text.trim() ? : null} + {thinking ? ( + + ) : ( + <> + + {entry.text} + + {entry.text.trim() ? : null} + + )} ); } +function ThinkingDots() { + return ( + + + + + + ); +} + function MessageActions({ text }: { text: string }) { return (
diff --git a/src/agent_manager/api/static/widget/react/streamReducer.ts b/src/agent_manager/api/static/widget/react/streamReducer.ts index adc0f825..7c798fd5 100644 --- a/src/agent_manager/api/static/widget/react/streamReducer.ts +++ b/src/agent_manager/api/static/widget/react/streamReducer.ts @@ -9,20 +9,19 @@ const TOOL_STATUS: Record = { export function reduceStreamEvent(entry: MessageEntry, event: StreamEvent): MessageEntry { switch (event.type) { case "answer_delta": - return { ...entry, text: entry.text + (event.content ?? ""), typing: false }; + return { ...entry, text: entry.text + (event.content ?? "") }; case "route": - return { ...entry, route: event.route ?? entry.route, typing: false }; + return { ...entry, route: event.route ?? entry.route }; case "tool_started": case "tool_succeeded": case "tool_failed": - return { ...entry, tools: upsertTool(entry.tools ?? [], toToolRecord(event)), typing: false }; + return { ...entry, tools: upsertTool(entry.tools ?? [], toToolRecord(event)) }; case "final": return { ...entry, text: event.content ?? entry.text, route: event.route ?? entry.route, tools: event.used_tools ?? entry.tools, - typing: false, }; case "error": throw new Error(event.error || "stream failed"); diff --git a/src/agent_manager/api/static/widget/react/useConversation.ts b/src/agent_manager/api/static/widget/react/useConversation.ts index e9bf2634..71d78e29 100644 --- a/src/agent_manager/api/static/widget/react/useConversation.ts +++ b/src/agent_manager/api/static/widget/react/useConversation.ts @@ -15,6 +15,8 @@ import type { } from "../types"; export interface Conversation { + peekId(): string | null; + ensureId(): Promise; send(text: string): Promise; stream(text: string): AsyncGenerator; loadHistory(): Promise; @@ -38,6 +40,11 @@ export function useConversation( return created; }, [client, endpoint, userId]); + const peekId = useCallback( + () => getStoredConversationId(endpoint, userId), + [endpoint, userId], + ); + const ensureId = useCallback( async () => getStoredConversationId(endpoint, userId) ?? startConversation(), [endpoint, userId, startConversation], @@ -109,7 +116,17 @@ export function useConversation( ); return useMemo( - () => ({ send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew }), - [send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew], + () => ({ + peekId, + ensureId, + send, + stream, + loadHistory, + loadUsage, + listThreads, + switchTo, + startNew, + }), + [peekId, ensureId, send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew], ); } diff --git a/src/agent_manager/api/static/widget/styles/styles.ts b/src/agent_manager/api/static/widget/styles/styles.ts index a6dc3458..dbb9fe79 100644 --- a/src/agent_manager/api/static/widget/styles/styles.ts +++ b/src/agent_manager/api/static/widget/styles/styles.ts @@ -142,6 +142,15 @@ export function styles(config: AgentChatConfig): string { color: #b91c1c; border-radius: 8px; padding: 10px 12px; font-size: 13.5px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + .thinking { display: inline-flex; gap: 4px; color: #a1a1aa; } + .thinking-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; + animation: aui-dot 1.4s ease-in-out infinite; } + .thinking-dot:nth-child(2) { animation-delay: .2s; } + .thinking-dot:nth-child(3) { animation-delay: .4s; } + @keyframes aui-dot { + 0%, 100% { transform: scale(.8); opacity: .5; } + 50% { transform: scale(1.2); opacity: 1; } + } .composer { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; padding: 12px 14px; border-top: 1px solid #f0f0f1; } .input-wrap { min-width: 0; display: flex; border-radius: 20px; background: #fff; @@ -211,6 +220,7 @@ export function styles(config: AgentChatConfig): string { .msg-action svg { animation: none; } .budget-ring-value, .budget-bar-fill, .budget-popover { transition: none; } .thread-drawer { transition: none; } + .thinking-dot { animation: none; } } @media (max-width: 480px) { .panel:not(.inline) { width: 100vw; height: 100dvh; max-height: 100dvh; diff --git a/tests/e2e/widget.spec.ts b/tests/e2e/widget.spec.ts index 7fe455f4..b1aca109 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -270,6 +270,17 @@ async function shadowClick(page: Page, selector: string, index = 0) { }, selector); } +async function shadowClickText(page: Page, selector: string, text: string, index = 0) { + const handle = await widget(page, index); + await handle.evaluate( + (element, { selector, text }) => { + const targets = Array.from(element.shadowRoot?.querySelectorAll(selector) ?? []); + targets.find((node) => node.textContent?.includes(text))?.click(); + }, + { selector, text }, + ); +} + async function shadowFocus(page: Page, selector: string, index = 0) { const handle = await widget(page, index); await handle.evaluate((element, selector) => { @@ -539,6 +550,57 @@ test("thread drawer lists conversations, switches to one, and starts a new chat" await expect.poll(() => shadowText(page, ".messages")).toContain("How can I help you today?"); }); +test("thinking dots persist when switching away from an in-flight thread and back", async ({ + page, +}) => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + + await mockConversationApi(page, { + threads: [ + { conversation_id: "conv-other", title: "Other chat", last_message_at: "2026-06-01T00:00:00Z" }, + { conversation_id: "conv-smoke", title: "Current chat", last_message_at: "2026-06-28T00:00:00Z" }, + ], + }); + await page.route("**/conversations/conv-other/messages", async (route: Route) => { + if (route.request().method() !== "GET") return route.fallback(); + await route.fulfill({ status: 200, contentType: "application/json", body: "[]" }); + }); + await page.route("**/conversations/conv-smoke/messages/stream", async (route: Route) => { + await pending; + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: [ + `event: final\ndata: ${JSON.stringify({ type: "final", content: "done", route: [], used_tools: [] })}`, + "event: done\ndata: [DONE]", + "", + ].join("\n\n"), + }); + }); + + await page.goto("/widget-demo.html"); + await shadowClick(page, ".launcher"); + await shadowFill(page, ".input", "hello"); + await shadowClick(page, ".send"); + + await expect.poll(() => shadowExists(page, ".thinking")).toBe(true); + + await shadowClick(page, '.header-btn[aria-label="Conversations"]'); + await shadowClickText(page, ".thread-item", "Other chat"); + await expect.poll(() => shadowExists(page, ".thinking")).toBe(false); + + await shadowClick(page, '.header-btn[aria-label="Conversations"]'); + await shadowClickText(page, ".thread-item", "Current chat"); + await expect.poll(() => shadowExists(page, ".thinking")).toBe(true); + + release(); + await expect.poll(() => shadowText(page, ".messages")).toContain("done"); + await expect.poll(() => shadowExists(page, ".thinking")).toBe(false); +}); + test("stale stored conversation is replaced before sending to the agent", async ({ page }) => { const calls = await mockConversationApiWithStaleConversation(page); await pinUser(page); From f529a92479a5f35e7a5ed2620ad69eea095e010a Mon Sep 17 00:00:00 2001 From: Amit Avital Date: Tue, 28 Jul 2026 21:08:04 +0300 Subject: [PATCH 4/6] fix(widget): pass the conversation id to every request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #60: stream(), send(), loadHistory() and loadUsage() each re-read the active conversation id from localStorage when they ran. A turn that started in thread A but finished after the user switched to B could retry, or refresh usage, against B. Storage now only remembers which thread was selected last; it never decides where an in-flight request goes. - Conversation methods take the id: send(id, text), stream(id, text), loadHistory(id), loadUsage(id). The app already captures the id at submit time for its per-conversation message map, so it passes that same id. - Usage moves into a per-conversation map alongside messages, so a late response cannot paint another thread's number. - Stale-conversation recovery (404 -> create a new one) now reports the replacement through `onReplaced`, and the app re-keys that thread's messages onto the id the turn actually ran under instead of leaving them stranded under the dead one. Test: a turn whose stream fails after the user switched threads retries on its own conversation, and the usage refresh follows it — both landed on the other thread before this change. Co-Authored-By: Claude Opus 5 --- src/agent_manager/api/static/widget.js | 110 ++++++++++-------- .../api/static/widget/react/AgentChatApp.tsx | 36 ++++-- .../static/widget/react/useConversation.ts | 93 ++++++++------- tests/e2e/widget.spec.ts | 58 +++++++++ 4 files changed, 198 insertions(+), 99 deletions(-) diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index 3ca575ba..ab1e6b1b 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -53065,65 +53065,71 @@ function upsertTool(tools, next2) { // src/agent_manager/api/static/widget/react/useConversation.ts var import_react9 = __toESM(require_react(), 1); var isMissingConversation = (error) => error instanceof AgentChatHttpError && error.status === 404; -function useConversation(client, endpoint, userId) { +function useConversation(client, endpoint, userId, onReplaced) { const startConversation = (0, import_react9.useCallback)(async () => { const created = await client.createConversation(userId); setStoredConversationId(endpoint, userId, created); return created; }, [client, endpoint, userId]); - const peekId = (0, import_react9.useCallback)( - () => getStoredConversationId(endpoint, userId), - [endpoint, userId] - ); + const peekId = (0, import_react9.useCallback)(() => getStoredConversationId(endpoint, userId), [endpoint, userId]); const ensureId = (0, import_react9.useCallback)( async () => getStoredConversationId(endpoint, userId) ?? startConversation(), [endpoint, userId, startConversation] ); - const restartId = (0, import_react9.useCallback)(async () => { - removeStoredConversationId(endpoint, userId); - return startConversation(); - }, [endpoint, userId, startConversation]); + const replace2 = (0, import_react9.useCallback)( + async (staleId) => { + removeStoredConversationId(endpoint, userId); + const fresh = await startConversation(); + onReplaced?.(staleId, fresh); + return fresh; + }, + [endpoint, userId, startConversation, onReplaced] + ); const send = (0, import_react9.useCallback)( - async (text10) => { + async (conversationId, text10) => { try { - return await client.sendMessage(await ensureId(), text10); + return await client.sendMessage(conversationId, text10); } catch (error) { if (!isMissingConversation(error)) throw error; - return client.sendMessage(await restartId(), text10); + return client.sendMessage(await replace2(conversationId), text10); } }, - [client, ensureId, restartId] + [client, replace2] ); const stream = (0, import_react9.useCallback)( - async function* (text10) { + async function* (conversationId, text10) { try { - yield* client.streamMessage(await ensureId(), text10); + yield* client.streamMessage(conversationId, text10); } catch (error) { if (!isMissingConversation(error)) throw error; - yield* client.streamMessage(await restartId(), text10); + yield* client.streamMessage(await replace2(conversationId), text10); } }, - [client, ensureId, restartId] + [client, replace2] + ); + const loadHistory = (0, import_react9.useCallback)( + async (conversationId) => { + try { + return await client.getMessages(conversationId); + } catch (error) { + if (isMissingConversation(error) && peekId() === conversationId) { + removeStoredConversationId(endpoint, userId); + } + return []; + } + }, + [client, endpoint, userId, peekId] + ); + const loadUsage = (0, import_react9.useCallback)( + async (conversationId) => { + try { + return await client.getUsage(conversationId); + } catch { + return null; + } + }, + [client] ); - const loadHistory = (0, import_react9.useCallback)(async () => { - const stored = getStoredConversationId(endpoint, userId); - if (!stored) return []; - try { - return await client.getMessages(stored); - } catch (error) { - if (isMissingConversation(error)) removeStoredConversationId(endpoint, userId); - return []; - } - }, [client, endpoint, userId]); - const loadUsage = (0, import_react9.useCallback)(async () => { - const stored = getStoredConversationId(endpoint, userId); - if (!stored) return null; - try { - return await client.getUsage(stored); - } catch { - return null; - } - }, [client, endpoint, userId]); const listThreads = (0, import_react9.useCallback)( () => client.listConversations(userId).catch(() => []), [client, userId] @@ -53169,28 +53175,37 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { () => 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 [entriesById, setEntriesById] = (0, import_react10.useState)({}); + const [usageById, setUsageById] = (0, import_react10.useState)({}); const [activeId, setActiveId] = (0, import_react10.useState)(""); const entries = entriesById[activeId] ?? []; - const [usage, setUsage] = (0, import_react10.useState)(null); + const usage = usageById[activeId] ?? 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 () => { - setUsage(await conversation.loadUsage()); - }, [conversation]); + const onReplaced = (0, import_react10.useCallback)((staleId, freshId) => { + setEntriesById(({ [staleId]: moved = [], ...rest }) => ({ ...rest, [freshId]: moved })); + setActiveId((current) => current === staleId ? freshId : current); + }, []); + const conversation = useConversation(client, config.endpoint, userId, onReplaced); + const refreshUsage = (0, import_react10.useCallback)( + async (cid) => { + const next2 = await conversation.loadUsage(cid); + setUsageById((prev) => ({ ...prev, [cid]: next2 })); + }, + [conversation] + ); const putEntries = (0, import_react10.useCallback)( (cid, update) => setEntriesById((prev) => ({ ...prev, [cid]: update(prev[cid] ?? []) })), [] ); const loadThread = (0, import_react10.useCallback)( async (cid) => { - const history = await conversation.loadHistory(); + const history = await conversation.loadHistory(cid); putEntries(cid, () => history.map(toEntry)); }, [conversation, putEntries] @@ -53202,7 +53217,7 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { if (!cid) return; setActiveId(cid); await loadThread(cid); - await refreshUsage(); + await refreshUsage(cid); }, [conversation, loaded, loadThread, refreshUsage]); (0, import_react10.useEffect)(() => { if (inline) void loadHistory(); @@ -53230,7 +53245,7 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { setThreadsOpen(false); setActiveId(conversationId); if (!(conversationId in entriesById)) await loadThread(conversationId); - await refreshUsage(); + await refreshUsage(conversationId); inputRef.current?.focus({ preventScroll: true }); }, [conversation, entriesById, loadThread, refreshUsage] @@ -53239,7 +53254,6 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { conversation.startNew(); setThreadsOpen(false); setActiveId(""); - setUsage(null); inputRef.current?.focus({ preventScroll: true }); }, [conversation]); const replaceEntry = (0, import_react10.useCallback)( @@ -53249,7 +53263,7 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { const sendWithoutStreaming = (0, import_react10.useCallback)( async (cid, text10, entryId) => { try { - const answer = await conversation.send(text10); + const answer = await conversation.send(cid, text10); replaceEntry(cid, entryId, { id: entryId, role: "ai", @@ -53273,7 +53287,7 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { setSending(true); try { let entry = pending; - for await (const event of conversation.stream(text10)) { + for await (const event of conversation.stream(cid, text10)) { entry = reduceStreamEvent(entry, event); replaceEntry(cid, pending.id, entry); } @@ -53283,7 +53297,7 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { await sendWithoutStreaming(cid, text10, pending.id); } finally { setSending(false); - void refreshUsage(); + void refreshUsage(cid); } }, [conversation, onAnswer, putEntries, refreshUsage, replaceEntry, sendWithoutStreaming] diff --git a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx index e8624fc5..cfc6b539 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -65,22 +65,35 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age () => 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 [entriesById, setEntriesById] = useState>({}); + const [usageById, setUsageById] = useState>({}); const [activeId, setActiveId] = useState(""); const entries = entriesById[activeId] ?? []; - const [usage, setUsage] = useState(null); + const usage = usageById[activeId] ?? null; const [threads, setThreads] = useState([]); const [threadsOpen, setThreadsOpen] = useState(false); const launcherRef = useRef(null); const inputRef = useRef(null); - const refreshUsage = useCallback(async () => { - setUsage(await conversation.loadUsage()); - }, [conversation]); + // A vanished conversation is replaced mid-turn; carry its messages onto the + // id the turn actually ran under so the view does not go blank. + const onReplaced = useCallback((staleId: string, freshId: string) => { + setEntriesById(({ [staleId]: moved = [], ...rest }) => ({ ...rest, [freshId]: moved })); + setActiveId((current) => (current === staleId ? freshId : current)); + }, []); + + const conversation = useConversation(client, config.endpoint, userId, onReplaced); + + const refreshUsage = useCallback( + async (cid: string) => { + const next = await conversation.loadUsage(cid); + setUsageById((prev) => ({ ...prev, [cid]: next })); + }, + [conversation], + ); const putEntries = useCallback( (cid: string, update: (prev: MessageEntry[]) => MessageEntry[]) => @@ -90,7 +103,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age const loadThread = useCallback( async (cid: string) => { - const history = await conversation.loadHistory(); + const history = await conversation.loadHistory(cid); putEntries(cid, () => history.map(toEntry)); }, [conversation, putEntries], @@ -103,7 +116,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age if (!cid) return; setActiveId(cid); await loadThread(cid); - await refreshUsage(); + await refreshUsage(cid); }, [conversation, loaded, loadThread, refreshUsage]); useEffect(() => { @@ -138,7 +151,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age setActiveId(conversationId); // ponytail: in-session map owns in-flight streams, so only cold-load a thread we haven't opened yet. if (!(conversationId in entriesById)) await loadThread(conversationId); - await refreshUsage(); + await refreshUsage(conversationId); inputRef.current?.focus({ preventScroll: true }); }, [conversation, entriesById, loadThread, refreshUsage], @@ -148,7 +161,6 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age conversation.startNew(); setThreadsOpen(false); setActiveId(""); - setUsage(null); inputRef.current?.focus({ preventScroll: true }); }, [conversation]); @@ -161,7 +173,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age const sendWithoutStreaming = useCallback( async (cid: string, text: string, entryId: string) => { try { - const answer = await conversation.send(text); + const answer = await conversation.send(cid, text); replaceEntry(cid, entryId, { id: entryId, role: "ai", @@ -186,7 +198,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age setSending(true); try { let entry = pending; - for await (const event of conversation.stream(text)) { + for await (const event of conversation.stream(cid, text)) { entry = reduceStreamEvent(entry, event); replaceEntry(cid, pending.id, entry); } @@ -196,7 +208,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age await sendWithoutStreaming(cid, text, pending.id); } finally { setSending(false); - void refreshUsage(); + void refreshUsage(cid); } }, [conversation, onAnswer, putEntries, refreshUsage, replaceEntry, sendWithoutStreaming], diff --git a/src/agent_manager/api/static/widget/react/useConversation.ts b/src/agent_manager/api/static/widget/react/useConversation.ts index 71d78e29..0c4efcc3 100644 --- a/src/agent_manager/api/static/widget/react/useConversation.ts +++ b/src/agent_manager/api/static/widget/react/useConversation.ts @@ -14,13 +14,19 @@ import type { ThreadSummary, } from "../types"; +/** Every request names the conversation it belongs to. + * + * Storage only remembers which thread was selected last; it never decides where + * an in-flight request lands. Switching threads mid-request would otherwise + * redirect a reply that started in A into B. + */ export interface Conversation { peekId(): string | null; ensureId(): Promise; - send(text: string): Promise; - stream(text: string): AsyncGenerator; - loadHistory(): Promise; - loadUsage(): Promise; + send(conversationId: string, text: string): Promise; + stream(conversationId: string, text: string): AsyncGenerator; + loadHistory(conversationId: string): Promise; + loadUsage(conversationId: string): Promise; listThreads(): Promise; switchTo(conversationId: string): void; startNew(): void; @@ -33,6 +39,9 @@ export function useConversation( client: AgentChatClient, endpoint: string, userId: string, + /** Told when a vanished conversation is replaced, so the caller can move that + * thread's messages onto the id the turn actually ran under. */ + onReplaced?: (staleId: string, freshId: string) => void, ): Conversation { const startConversation = useCallback(async () => { const created = await client.createConversation(userId); @@ -40,65 +49,71 @@ export function useConversation( return created; }, [client, endpoint, userId]); - const peekId = useCallback( - () => getStoredConversationId(endpoint, userId), - [endpoint, userId], - ); + const peekId = useCallback(() => getStoredConversationId(endpoint, userId), [endpoint, userId]); const ensureId = useCallback( async () => getStoredConversationId(endpoint, userId) ?? startConversation(), [endpoint, userId, startConversation], ); - const restartId = useCallback(async () => { - removeStoredConversationId(endpoint, userId); - return startConversation(); - }, [endpoint, userId, startConversation]); + const replace = useCallback( + async (staleId: string) => { + removeStoredConversationId(endpoint, userId); + const fresh = await startConversation(); + onReplaced?.(staleId, fresh); + return fresh; + }, + [endpoint, userId, startConversation, onReplaced], + ); const send = useCallback( - async (text: string) => { + async (conversationId: string, text: string) => { try { - return await client.sendMessage(await ensureId(), text); + return await client.sendMessage(conversationId, text); } catch (error) { if (!isMissingConversation(error)) throw error; - return client.sendMessage(await restartId(), text); + return client.sendMessage(await replace(conversationId), text); } }, - [client, ensureId, restartId], + [client, replace], ); const stream = useCallback( - async function* (text: string): AsyncGenerator { + async function* (conversationId: string, text: string): AsyncGenerator { try { - yield* client.streamMessage(await ensureId(), text); + yield* client.streamMessage(conversationId, text); } catch (error) { if (!isMissingConversation(error)) throw error; - yield* client.streamMessage(await restartId(), text); + yield* client.streamMessage(await replace(conversationId), text); } }, - [client, ensureId, restartId], + [client, replace], ); - const loadHistory = useCallback(async () => { - const stored = getStoredConversationId(endpoint, userId); - if (!stored) return []; - try { - return await client.getMessages(stored); - } catch (error) { - if (isMissingConversation(error)) removeStoredConversationId(endpoint, userId); - return []; - } - }, [client, endpoint, userId]); + const loadHistory = useCallback( + async (conversationId: string) => { + try { + return await client.getMessages(conversationId); + } catch (error) { + if (isMissingConversation(error) && peekId() === conversationId) { + removeStoredConversationId(endpoint, userId); + } + return []; + } + }, + [client, endpoint, userId, peekId], + ); - const loadUsage = useCallback(async () => { - const stored = getStoredConversationId(endpoint, userId); - if (!stored) return null; - try { - return await client.getUsage(stored); - } catch { - return null; - } - }, [client, endpoint, userId]); + const loadUsage = useCallback( + async (conversationId: string) => { + try { + return await client.getUsage(conversationId); + } catch { + return null; + } + }, + [client], + ); const listThreads = useCallback( () => client.listConversations(userId).catch(() => []), diff --git a/tests/e2e/widget.spec.ts b/tests/e2e/widget.spec.ts index b1aca109..0969552a 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -601,6 +601,64 @@ test("thinking dots persist when switching away from an in-flight thread and bac await expect.poll(() => shadowExists(page, ".thinking")).toBe(false); }); +test("a turn stays on its own conversation when the user switches threads mid-request", async ({ + page, +}) => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const usageCalls: string[] = []; + + const calls = await mockConversationApi(page, { + threads: [ + { conversation_id: "conv-other", title: "Other chat", last_message_at: "2026-06-01T00:00:00Z" }, + { conversation_id: "conv-smoke", title: "Current chat", last_message_at: "2026-06-28T00:00:00Z" }, + ], + }); + await page.route("**/conversations/*/usage", async (route: Route) => { + usageCalls.push(new URL(route.request().url()).pathname); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ used_tokens: 0, max_tokens: null, percent: 0, severity: "normal" }), + }); + }); + // Hold the stream open until the user has switched away, then fail it: the + // widget retries the turn without streaming, and that retry must still go to + // the conversation the turn started in. + await page.route("**/conversations/conv-smoke/messages/stream", async (route: Route) => { + await pending; + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ detail: "stream unavailable" }), + }); + }); + await page.route("**/conversations/conv-other/messages", async (route: Route) => { + if (route.request().method() !== "GET") return route.fallback(); + await route.fulfill({ status: 200, contentType: "application/json", body: "[]" }); + }); + + await page.goto("/widget-demo.html"); + await shadowClick(page, ".launcher"); + await shadowFill(page, ".input", "hello"); + await shadowClick(page, ".send"); + await expect.poll(() => shadowExists(page, ".thinking")).toBe(true); + + await shadowClick(page, '.header-btn[aria-label="Conversations"]'); + await shadowClickText(page, ".thread-item", "Other chat"); + await expect.poll(() => shadowExists(page, ".thinking")).toBe(false); + + release(); + + await expect.poll(() => calls).toContain("POST /conversations/conv-smoke/messages"); + expect(calls).not.toContain("POST /conversations/conv-other/messages"); + await expect + .poll(() => usageCalls[usageCalls.length - 1]) + .toBe("/conversations/conv-smoke/usage"); +}); + test("stale stored conversation is replaced before sending to the agent", async ({ page }) => { const calls = await mockConversationApiWithStaleConversation(page); await pinUser(page); From e1c25e972d274ebb588e3bd8cfdfe9c8126d450a Mon Sep 17 00:00:00 2001 From: rishu685 Date: Wed, 29 Jul 2026 16:56:55 +0530 Subject: [PATCH 5/6] fix(widget): show context-limit message on HTTP 429 token budget exceeded and disable composer --- src/agent_manager/api/routes.py | 18 ++++- src/agent_manager/api/static/widget.js | 56 +++++++++---- .../api/static/widget/api/AgentChatClient.ts | 31 ++++++-- .../api/static/widget/react/AgentChatApp.tsx | 30 +++++-- tests/agent_manager/test_api.py | 56 +++++++++++++ tests/e2e/widget.spec.ts | 78 +++++++++++++++++++ 6 files changed, 240 insertions(+), 29 deletions(-) diff --git a/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index ea6013f1..b1f761d2 100644 --- a/src/agent_manager/api/routes.py +++ b/src/agent_manager/api/routes.py @@ -34,6 +34,14 @@ Service = Annotated[ConversationService, Depends(get_service)] +_BUDGET_EXCEEDED_DETAIL = { + "error_type": "context_limit_exceeded", + "message": ( + "This conversation has reached its context limit." + " Start a new chat to continue." + ), +} + @router.post("/conversations", response_model=CreateConversationResponse) async def create_conversation( @@ -98,7 +106,10 @@ async def send_message( 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 + raise HTTPException( + status_code=429, + detail=_BUDGET_EXCEEDED_DETAIL, + ) from None except Exception as exc: # engine failure raise HTTPException(status_code=500, detail=str(exc)) from exc return SendMessageResponse( @@ -142,7 +153,10 @@ async def stream_message( 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 + raise HTTPException( + status_code=429, + detail=_BUDGET_EXCEEDED_DETAIL, + ) from None async def event_source() -> AsyncIterator[str]: try: diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index ab1e6b1b..765fbe8e 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -52102,12 +52102,28 @@ var import_client = __toESM(require_client(), 1); // src/agent_manager/api/static/widget/api/AgentChatClient.ts var AgentChatHttpError = class extends Error { - constructor(status) { - super(`HTTP ${status}`); + constructor(status, errorType, message) { + super(message || `HTTP ${status}`); this.status = status; + this.errorType = errorType; this.name = "AgentChatHttpError"; } }; +async function handleHttpError(response) { + let errorType; + let message; + try { + const body = await response.json(); + if (typeof body.detail === "object" && body.detail !== null) { + errorType = body.detail.error_type; + message = body.detail.message; + } else if (typeof body.detail === "string") { + message = body.detail; + } + } catch { + } + throw new AgentChatHttpError(response.status, errorType, message); +} var AgentChatClient = class { constructor(endpoint) { this.endpoint = endpoint; @@ -52119,7 +52135,7 @@ var AgentChatClient = class { body: JSON.stringify({ user_id: userId }) }); if (!response.ok) { - throw new AgentChatHttpError(response.status); + await handleHttpError(response); } const data = await response.json(); return String(data.conversation_id); @@ -52141,7 +52157,7 @@ var AgentChatClient = class { async getMessages(conversationId) { const response = await fetch(`${this.endpoint}/conversations/${conversationId}/messages`); if (!response.ok) { - throw new AgentChatHttpError(response.status); + await handleHttpError(response); } return await response.json(); } @@ -52152,7 +52168,7 @@ var AgentChatClient = class { body: JSON.stringify({ message }) }); if (!response.ok) { - throw new AgentChatHttpError(response.status); + await handleHttpError(response); } const data = await response.json(); return { @@ -52181,7 +52197,7 @@ var AgentChatClient = class { body: JSON.stringify({ message }) }); if (!response.ok) { - throw new AgentChatHttpError(response.status); + await handleHttpError(response); } if (!response.body) { throw new Error("Streaming response has no body"); @@ -53178,6 +53194,7 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { 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 [budgetExceeded, setBudgetExceeded] = (0, import_react10.useState)(false); const [entriesById, setEntriesById] = (0, import_react10.useState)({}); const [usageById, setUsageById] = (0, import_react10.useState)({}); const [activeId, setActiveId] = (0, import_react10.useState)(""); @@ -53272,8 +53289,13 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { tools: answer.used_tools }); onAnswer({ visited: answer.visited ?? [], used_tools: answer.used_tools ?? [] }); - } catch { - replaceEntry(cid, entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); + } catch (error) { + const is4xx = error instanceof AgentChatHttpError && error.status >= 400 && error.status < 500; + if (error instanceof AgentChatHttpError && error.errorType === "context_limit_exceeded") { + setBudgetExceeded(true); + } + const errorMessage = is4xx ? error.message : GENERIC_ERROR; + replaceEntry(cid, entryId, { id: entryId, role: "ai", text: errorMessage, error: true }); } }, [conversation, onAnswer, replaceEntry] @@ -53293,8 +53315,16 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { } replaceEntry(cid, pending.id, { ...entry, typing: false }); onAnswer({ visited: entry.route ?? [], used_tools: entry.tools ?? [] }); - } catch { - await sendWithoutStreaming(cid, text10, pending.id); + } catch (error) { + const is4xx = error instanceof AgentChatHttpError && error.status >= 400 && error.status < 500; + if (error instanceof AgentChatHttpError && error.errorType === "context_limit_exceeded") { + setBudgetExceeded(true); + } + if (is4xx) { + replaceEntry(cid, pending.id, { id: pending.id, role: "ai", text: error.message, error: true }); + } else { + await sendWithoutStreaming(cid, text10, pending.id); + } } finally { setSending(false); void refreshUsage(cid); @@ -53372,10 +53402,10 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { PromptInputTextarea, { "aria-label": "Message", - disabled: false, + disabled: sending || budgetExceeded, inputRef, onSubmit: () => inputRef.current?.form?.requestSubmit(), - placeholder: "Message..." + placeholder: budgetExceeded ? "Context limit reached." : "Message..." } ), /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(PromptInputFooter, { children: [ @@ -53383,7 +53413,7 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { usage ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(BudgetMeter, { usage }) : null, /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "prompt-hint", children: "Enter to send \xB7 Shift+Enter for a new line" }) ] }), - /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PromptInputSubmit, { disabled: sending }) + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PromptInputSubmit, { disabled: sending || budgetExceeded }) ] }) ] }), /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "powered", children: "Powered by Extra" }) diff --git a/src/agent_manager/api/static/widget/api/AgentChatClient.ts b/src/agent_manager/api/static/widget/api/AgentChatClient.ts index 60f46757..3679c082 100644 --- a/src/agent_manager/api/static/widget/api/AgentChatClient.ts +++ b/src/agent_manager/api/static/widget/api/AgentChatClient.ts @@ -7,12 +7,31 @@ import type { } from "../types"; export class AgentChatHttpError extends Error { - constructor(readonly status: number) { - super(`HTTP ${status}`); + constructor( + readonly status: number, + readonly errorType?: string, + message?: string, + ) { + super(message || `HTTP ${status}`); this.name = "AgentChatHttpError"; } } +async function handleHttpError(response: Response): Promise { + let errorType: string | undefined; + let message: string | undefined; + try { + const body = await response.json(); + if (typeof body.detail === "object" && body.detail !== null) { + errorType = body.detail.error_type; + message = body.detail.message; + } else if (typeof body.detail === "string") { + message = body.detail; + } + } catch {} + throw new AgentChatHttpError(response.status, errorType, message); +} + export class AgentChatClient { constructor(private readonly endpoint: string) {} @@ -23,7 +42,7 @@ export class AgentChatClient { body: JSON.stringify({ user_id: userId }), }); if (!response.ok) { - throw new AgentChatHttpError(response.status); + await handleHttpError(response); } const data = await response.json(); return String(data.conversation_id); @@ -47,7 +66,7 @@ export class AgentChatClient { async getMessages(conversationId: string): Promise { const response = await fetch(`${this.endpoint}/conversations/${conversationId}/messages`); if (!response.ok) { - throw new AgentChatHttpError(response.status); + await handleHttpError(response); } return (await response.json()) as ChatMessage[]; } @@ -59,7 +78,7 @@ export class AgentChatClient { body: JSON.stringify({ message }), }); if (!response.ok) { - throw new AgentChatHttpError(response.status); + await handleHttpError(response); } const data = await response.json(); return { @@ -90,7 +109,7 @@ export class AgentChatClient { body: JSON.stringify({ message }), }); if (!response.ok) { - throw new AgentChatHttpError(response.status); + await handleHttpError(response); } if (!response.body) { throw new Error("Streaming response has no body"); diff --git a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx index cfc6b539..fe3bd594 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -9,7 +9,7 @@ import { } from "lucide-react"; import { type Ref, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { AgentChatClient } from "../api/AgentChatClient"; +import { AgentChatHttpError, type AgentChatClient } from "../api/AgentChatClient"; import { getOrCreateUserId, getStoredConversationId } from "../storage/conversationStorage"; import type { AgentChatAnswerDetail, @@ -68,6 +68,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age const [open, setOpen] = useState(inline); const [loaded, setLoaded] = useState(false); const [sending, setSending] = useState(false); + const [budgetExceeded, setBudgetExceeded] = useState(false); const [entriesById, setEntriesById] = useState>({}); const [usageById, setUsageById] = useState>({}); const [activeId, setActiveId] = useState(""); @@ -182,8 +183,13 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age tools: answer.used_tools, }); onAnswer({ visited: answer.visited ?? [], used_tools: answer.used_tools ?? [] }); - } catch { - replaceEntry(cid, entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); + } catch (error) { + const is4xx = error instanceof AgentChatHttpError && error.status >= 400 && error.status < 500; + if (error instanceof AgentChatHttpError && error.errorType === "context_limit_exceeded") { + setBudgetExceeded(true); + } + const errorMessage = is4xx ? error.message : GENERIC_ERROR; + replaceEntry(cid, entryId, { id: entryId, role: "ai", text: errorMessage, error: true }); } }, [conversation, onAnswer, replaceEntry], @@ -204,8 +210,16 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age } replaceEntry(cid, pending.id, { ...entry, typing: false }); onAnswer({ visited: entry.route ?? [], used_tools: entry.tools ?? [] }); - } catch { - await sendWithoutStreaming(cid, text, pending.id); + } catch (error) { + const is4xx = error instanceof AgentChatHttpError && error.status >= 400 && error.status < 500; + if (error instanceof AgentChatHttpError && error.errorType === "context_limit_exceeded") { + setBudgetExceeded(true); + } + if (is4xx) { + replaceEntry(cid, pending.id, { id: pending.id, role: "ai", text: error.message, error: true }); + } else { + await sendWithoutStreaming(cid, text, pending.id); + } } finally { setSending(false); void refreshUsage(cid); @@ -289,17 +303,17 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age void submit(message.text)}> inputRef.current?.form?.requestSubmit()} - placeholder="Message..." + placeholder={budgetExceeded ? "Context limit reached." : "Message..."} />
{usage ? : null} Enter to send · Shift+Enter for a new line
- +
Powered by Extra
diff --git a/tests/agent_manager/test_api.py b/tests/agent_manager/test_api.py index b5871804..461c11b8 100644 --- a/tests/agent_manager/test_api.py +++ b/tests/agent_manager/test_api.py @@ -2,6 +2,7 @@ from __future__ import annotations +import dataclasses from collections.abc import AsyncIterator, Sequence import pytest @@ -240,3 +241,58 @@ def test_create_accepts_stable_session_and_send_accepts_user(client: TestClient) sent = client.post("/conversations/sess-1/messages", json={"message": "hello", "user_id": "u1"}) assert sent.status_code == 200 + + +class _BudgetEngine(RecordingEngine): + async def run( + self, + message: str, + *, + history: Sequence[ChatMessage] = (), + context: RunContext | None = None, + ) -> RunResult: + res = await super().run(message, history=history, context=context) + return dataclasses.replace(res, input_tokens=5, output_tokens=5) + + +def test_send_returns_429_when_token_budget_exceeded() -> None: + """send_message returns 429 when the conversation token budget is exhausted.""" + app = FastAPI() + service = ConversationService(_BudgetEngine(), MemoryRepository(), max_tokens=1) + app.state.service = service + app.include_router(router) + client = TestClient(app) + + cid = client.post("/conversations").json()["conversation_id"] + client.post(f"/conversations/{cid}/messages", json={"message": "first"}) + response = client.post(f"/conversations/{cid}/messages", json={"message": "second"}) + + assert response.status_code == 429 + detail = response.json()["detail"] + assert detail["error_type"] == "context_limit_exceeded" + assert ( + detail["message"] + == "This conversation has reached its context limit. Start a new chat to continue." + ) + + +def test_stream_returns_429_when_token_budget_exceeded() -> None: + """stream_message returns 429 when the conversation token budget is exhausted.""" + app = FastAPI() + service = ConversationService(_BudgetEngine(), MemoryRepository(), max_tokens=1) + app.state.service = service + app.include_router(router) + client = TestClient(app) + + cid = client.post("/conversations").json()["conversation_id"] + client.post(f"/conversations/{cid}/messages", json={"message": "first"}) + response = client.post(f"/conversations/{cid}/messages/stream", json={"message": "second"}) + + assert response.status_code == 429 + detail = response.json()["detail"] + assert detail["error_type"] == "context_limit_exceeded" + assert ( + detail["message"] + == "This conversation has reached its context limit. Start a new chat to continue." + ) + diff --git a/tests/e2e/widget.spec.ts b/tests/e2e/widget.spec.ts index 0969552a..72d32cc8 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -659,6 +659,84 @@ test("a turn stays on its own conversation when the user switches threads mid-re .toBe("/conversations/conv-smoke/usage"); }); +async function mockConversationApiWithTokenBudgetExceeded(page: Page) { + const calls: string[] = []; + + await page.route("**/conversations", async (route) => { + calls.push(`${route.request().method()} ${new URL(route.request().url()).pathname}`); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ conversation_id: "conv-budget", session_id: "conv-budget" }), + }); + }); + + await page.route("**/conversations/*/messages/stream", async (route: Route) => { + calls.push(`${route.request().method()} ${new URL(route.request().url()).pathname}`); + await route.fulfill({ + status: 429, + contentType: "application/json", + body: JSON.stringify({ + detail: { + error_type: "context_limit_exceeded", + message: "This conversation has reached its context limit. Start a new chat to continue.", + }, + }), + }); + }); + + await page.route("**/conversations/*/messages", async (route: Route) => { + calls.push(`${route.request().method()} ${new URL(route.request().url()).pathname}`); + if (route.request().method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + return; + } + await route.fulfill({ + status: 429, + contentType: "application/json", + body: JSON.stringify({ + detail: { + error_type: "context_limit_exceeded", + message: "This conversation has reached its context limit. Start a new chat to continue.", + }, + }), + }); + }); + + return calls; +} + +test("token budget exceeded shows context-limit message instead of generic error", async ({ + page, +}) => { + const calls = await mockConversationApiWithTokenBudgetExceeded(page); + await page.goto("/widget-demo.html"); + await shadowClick(page, ".launcher"); + await shadowFill(page, ".input", "one more message"); + await shadowClick(page, ".send"); + + await expect + .poll(() => shadowText(page, ".messages")) + .toContain("This conversation has reached its context limit. Start a new chat to continue."); + const text = await shadowText(page, ".messages"); + expect(text).not.toContain("Something went wrong. Please try again."); + expect(calls).toContain("POST /conversations/conv-budget/messages/stream"); + expect(calls).not.toContain("POST /conversations/conv-budget/messages"); + + const inputEl = page.locator("agent-chat"); + const isDisabled = await inputEl.evaluate( + (el) => !!el.shadowRoot?.querySelector(".input")?.hasAttribute("disabled"), + ); + expect(isDisabled).toBe(true); + + const placeholder = await shadowAttribute(page, ".input", "placeholder"); + expect(placeholder).toBe("Context limit reached."); +}); + test("stale stored conversation is replaced before sending to the agent", async ({ page }) => { const calls = await mockConversationApiWithStaleConversation(page); await pinUser(page); From a897dc15f6151b2963ab87b4e85fa2c01ef6e3d4 Mon Sep 17 00:00:00 2001 From: rishu685 Date: Fri, 31 Jul 2026 23:04:42 +0530 Subject: [PATCH 6/6] fix(lint): import Any and format import blocks --- src/agent_manager/api/routes.py | 3 +-- tests/agent_manager/test_service.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index d6c3daf1..dc2b9ac0 100644 --- a/src/agent_manager/api/routes.py +++ b/src/agent_manager/api/routes.py @@ -6,7 +6,7 @@ import json from collections.abc import AsyncIterator, Iterator from contextlib import contextmanager -from typing import Annotated +from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse @@ -32,7 +32,6 @@ ConversationTokenBudgetExceeded, ) - router = APIRouter() Service = Annotated[ConversationService, Depends(get_service)] diff --git a/tests/agent_manager/test_service.py b/tests/agent_manager/test_service.py index 5b8c3d22..e037f37e 100644 --- a/tests/agent_manager/test_service.py +++ b/tests/agent_manager/test_service.py @@ -13,7 +13,6 @@ ConversationNotFound, ConversationService, ) - from agent_manager.domain import Role from agent_manager.infrastructure.persistence.memory_repository import MemoryRepository from tests.agent_manager.conftest import RecordingEngine