diff --git a/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index edde0d63..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 @@ -37,11 +37,19 @@ Service = Annotated[ConversationService, Depends(get_service)] CallerId = Annotated[str | None, Depends(get_caller_id)] -_HTTP_ERRORS: dict[type[Exception], tuple[int, str]] = { +_BUDGET_EXCEEDED_DETAIL = { + "error_type": "context_limit_exceeded", + "message": ( + "This conversation has reached its context limit." + " Start a new chat to continue." + ), +} + +_HTTP_ERRORS: dict[type[Exception], tuple[int, Any]] = { ConversationNotFound: (404, "conversation not found"), ConversationAccessDenied: (403, "conversation owned by another user"), ConversationAlreadyExists: (409, "conversation id already taken"), - ConversationTokenBudgetExceeded: (429, "conversation token budget exceeded"), + ConversationTokenBudgetExceeded: (429, _BUDGET_EXCEEDED_DETAIL), } @@ -54,6 +62,7 @@ def _as_http_error() -> Iterator[None]: raise HTTPException(status_code=status, detail=detail) from None + @router.post("/conversations", response_model=CreateConversationResponse) async def create_conversation( service: Service, caller_id: CallerId, body: CreateConversationRequest | None = None @@ -67,6 +76,7 @@ async def create_conversation( @router.get("/conversations", response_model=list[ConversationSummary]) async def list_conversations(service: Service, caller_id: CallerId) -> list[ConversationSummary]: sessions = await service.list_conversations(caller_id) + return [ ConversationSummary( conversation_id=s.session_id, @@ -109,6 +119,7 @@ async def send_message( result = await service.send(conversation_id, body.message, caller_id=caller_id) except HTTPException: raise + except Exception as exc: # engine failure raise HTTPException(status_code=500, detail=str(exc)) from exc return SendMessageResponse( @@ -149,6 +160,7 @@ async def stream_message( except StopAsyncIteration: first = None + async def event_source() -> AsyncIterator[str]: try: if first is not None: diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index 89ee7c56..4742fba6 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 { /** `userId` identifies the caller on every request. It is not a credential — * see `get_caller_id` on the server for how a deployment makes it one. */ @@ -52118,7 +52134,7 @@ var AgentChatClient = class { async request(path2, init) { const response = await fetch(`${this.endpoint}${path2}`, { ...init, headers: this.headers }); if (!response.ok) { - throw new AgentChatHttpError(response.status); + await handleHttpError(response); } return response; } @@ -53012,20 +53028,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"); @@ -53051,61 +53066,71 @@ function upsertTool(tools, next2) { // src/agent_manager/api/static/widget/react/useConversation.ts var import_react9 = __toESM(require_react(), 1); var isUnusableConversation = (error) => error instanceof AgentChatHttpError && (error.status === 404 || error.status === 403); -function useConversation(client, endpoint, userId) { +function useConversation(client, endpoint, userId, onReplaced) { const startConversation = (0, import_react9.useCallback)(async () => { const created = await client.createConversation(); 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] ); - 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 (!isUnusableConversation(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 (!isUnusableConversation(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 (isUnusableConversation(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 (isUnusableConversation(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().catch(() => []), [client]); const switchTo = (0, import_react9.useCallback)( (conversationId) => setStoredConversationId(endpoint, userId, conversationId), @@ -53116,8 +53141,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] ); } @@ -53141,26 +53176,51 @@ function AgentChatApp({ titleId }) { const inline = config.mode === "inline"; - 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 [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)(""); + const entries = entriesById[activeId] ?? []; + 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(cid); + 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)); - await refreshUsage(); - }, [conversation, loaded, refreshUsage]); + const cid = conversation.peekId(); + if (!cid) return; + setActiveId(cid); + await loadThread(cid); + await refreshUsage(cid); + }, [conversation, loaded, loadThread, refreshUsage]); (0, import_react10.useEffect)(() => { if (inline) void loadHistory(); }, [inline, loadHistory]); @@ -53185,28 +53245,28 @@ function AgentChatApp({ async (conversationId) => { conversation.switchTo(conversationId); setThreadsOpen(false); - const history = await conversation.loadHistory(); - setEntries(history.map(toEntry)); - await refreshUsage(); + setActiveId(conversationId); + if (!(conversationId in entriesById)) await loadThread(conversationId); + await refreshUsage(conversationId); inputRef.current?.focus({ preventScroll: true }); }, - [conversation, refreshUsage] + [conversation, entriesById, loadThread, refreshUsage] ); const startNewThread = (0, import_react10.useCallback)(() => { conversation.startNew(); setThreadsOpen(false); - setEntries([]); - setUsage(null); + setActiveId(""); 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, { + const answer = await conversation.send(cid, text10); + replaceEntry(cid, entryId, { id: entryId, role: "ai", text: answer.answer, @@ -53214,33 +53274,48 @@ function AgentChatApp({ tools: answer.used_tools }); onAnswer({ visited: answer.visited ?? [], used_tools: answer.used_tools ?? [] }); - } catch { - replaceEntry(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] ); 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)) { + for await (const event of conversation.stream(cid, 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); + } 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(); + void refreshUsage(cid); } }, - [conversation, onAnswer, refreshUsage, replaceEntry, sendWithoutStreaming] + [conversation, onAnswer, putEntries, refreshUsage, replaceEntry, sendWithoutStreaming] ); const toggle = () => void (open ? closeChat() : openChat()); return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( @@ -53312,10 +53387,10 @@ function AgentChatApp({ 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: [ @@ -53323,7 +53398,7 @@ function AgentChatApp({ 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" }) @@ -53366,19 +53441,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 }) { @@ -53669,6 +53751,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; @@ -53738,6 +53829,8 @@ 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/api/AgentChatClient.ts b/src/agent_manager/api/static/widget/api/AgentChatClient.ts index 714d0722..d5ad1740 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 { private readonly headers: Record; @@ -27,8 +46,9 @@ export class AgentChatClient { private async request(path: string, init?: RequestInit): Promise { const response = await fetch(`${this.endpoint}${path}`, { ...init, headers: this.headers }); + if (!response.ok) { - throw new AgentChatHttpError(response.status); + await handleHttpError(response); } return response; } @@ -41,6 +61,7 @@ export class AgentChatClient { async listConversations(): Promise { const response = await this.request("/conversations"); + const data = await response.json(); if (!Array.isArray(data)) return []; return data.map((thread) => ({ @@ -52,6 +73,7 @@ export class AgentChatClient { async getMessages(conversationId: string): Promise { const response = await this.request(`/conversations/${conversationId}/messages`); + return (await response.json()) as ChatMessage[]; } @@ -60,6 +82,7 @@ export class AgentChatClient { method: "POST", body: JSON.stringify({ message }), }); + const data = await response.json(); return { answer: String(data.answer || ""), @@ -84,6 +107,7 @@ export class AgentChatClient { method: "POST", body: JSON.stringify({ message }), }); + 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 aa3c14e1..195c0fb7 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -7,10 +7,11 @@ import { SquarePenIcon, XIcon, } from "lucide-react"; -import { type Ref, useCallback, useEffect, useRef, useState } from "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 { getStoredConversationId } from "../storage/conversationStorage"; + import type { AgentChatAnswerDetail, AgentChatConfig, @@ -69,28 +70,61 @@ export function AgentChatApp({ titleId, }: AgentChatAppProps) { const inline = config.mode === "inline"; - 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 [budgetExceeded, setBudgetExceeded] = useState(false); + const [entriesById, setEntriesById] = useState>({}); + const [usageById, setUsageById] = useState>({}); + const [activeId, setActiveId] = useState(""); + const entries = entriesById[activeId] ?? []; + 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[]) => + setEntriesById((prev) => ({ ...prev, [cid]: update(prev[cid] ?? []) })), + [], + ); + + const loadThread = useCallback( + async (cid: string) => { + const history = await conversation.loadHistory(cid); + 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)); - await refreshUsage(); - }, [conversation, loaded, refreshUsage]); + const cid = conversation.peekId(); + if (!cid) return; + setActiveId(cid); + await loadThread(cid); + await refreshUsage(cid); + }, [conversation, loaded, loadThread, refreshUsage]); useEffect(() => { if (inline) void loadHistory(); @@ -121,31 +155,35 @@ export function AgentChatApp({ async (conversationId: string) => { conversation.switchTo(conversationId); setThreadsOpen(false); - const history = await conversation.loadHistory(); - setEntries(history.map(toEntry)); - await refreshUsage(); + 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(conversationId); inputRef.current?.focus({ preventScroll: true }); }, - [conversation, refreshUsage], + [conversation, entriesById, loadThread, refreshUsage], + ); const startNewThread = useCallback(() => { conversation.startNew(); setThreadsOpen(false); - setEntries([]); - setUsage(null); + setActiveId(""); 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, { + const answer = await conversation.send(cid, text); + replaceEntry(cid, entryId, { id: entryId, role: "ai", text: answer.answer, @@ -153,8 +191,13 @@ export function AgentChatApp({ tools: answer.used_tools, }); onAnswer({ visited: answer.visited ?? [], used_tools: answer.used_tools ?? [] }); - } catch { - replaceEntry(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], @@ -162,25 +205,35 @@ export function AgentChatApp({ 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)) { + for await (const event of conversation.stream(cid, 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); + } 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(); + void refreshUsage(cid); } }, - [conversation, onAnswer, refreshUsage, replaceEntry, sendWithoutStreaming], + [conversation, onAnswer, putEntries, refreshUsage, replaceEntry, sendWithoutStreaming], ); const toggle = () => void (open ? closeChat() : openChat()); @@ -258,17 +311,17 @@ export function AgentChatApp({ 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
@@ -314,14 +367,6 @@ function Launcher({ function ChatMessage({ entry }: { entry: MessageEntry }) { const from = entry.role === "user" ? "user" : "assistant"; - if (entry.typing) { - return ( - - ... - - ); - } - if (entry.error) { return ( @@ -340,17 +385,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 825cbc6e..080e0f09 100644 --- a/src/agent_manager/api/static/widget/react/useConversation.ts +++ b/src/agent_manager/api/static/widget/react/useConversation.ts @@ -14,11 +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 { - send(text: string): Promise; - stream(text: string): AsyncGenerator; - loadHistory(): Promise; - loadUsage(): Promise; + peekId(): string | null; + ensureId(): 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; @@ -35,6 +43,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(); @@ -42,60 +53,71 @@ 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], ); - 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 (!isUnusableConversation(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 (!isUnusableConversation(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 (isUnusableConversation(error)) removeStoredConversationId(endpoint, userId); - return []; - } - }, [client, endpoint, userId]); + const loadHistory = useCallback( + async (conversationId: string) => { + try { + return await client.getMessages(conversationId); + } catch (error) { + if (isUnusableConversation(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().catch(() => []), [client]); @@ -110,7 +132,18 @@ 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..bc5623e6 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,8 @@ 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/src/agent_manager/application/service.py b/src/agent_manager/application/service.py index 7a14d67f..af4e1c2e 100644 --- a/src/agent_manager/application/service.py +++ b/src/agent_manager/application/service.py @@ -257,7 +257,6 @@ async def _require(self, conversation_id: str) -> ConversationSession: if session is None: raise ConversationNotFound(conversation_id) return session - async def _authorize(self, conversation_id: str, caller_id: str | None) -> ConversationSession: """Resolve a conversation the caller owns. diff --git a/tests/agent_manager/test_api.py b/tests/agent_manager/test_api.py index fa8951db..e97dc5a5 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 @@ -292,3 +293,58 @@ def test_create_accepts_a_stable_session_id_owned_by_the_caller(client: TestClie sent = client.post("/conversations/sess-1/messages", json={"message": "hello"}, headers=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 dbe6a055..bb5a409a 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -25,6 +25,15 @@ async function mockConversationApi( ) { 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) => { const method = route.request().method(); calls.push(`${method} ${new URL(route.request().url()).pathname}`); @@ -266,6 +275,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) => { @@ -535,6 +555,193 @@ 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("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"); +}); + +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("a stored conversation owned by another caller is replaced, not retried forever", async ({ page, }) => { @@ -558,6 +765,7 @@ test("a stored conversation owned by another caller is replaced, not retried for }); 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");