import type { Dispatch, SetStateAction } from 'react'; import { isActiveToolStatus } from './agentPhase'; import { AUTO_GREET_MESSAGE, CLARIFYING_QUESTIONS_TOOL_NAME, nextMessageId } from './constants'; import { getMessageToolCalls } from './messageUtils'; import type { SuperagentMessage, SuperagentNativeClient, SuperagentReplyTo } from '../../types'; export function normalizeMessages(messages: SuperagentMessage[]) { return messages.map((message) => ({ ...message, content: normalizeContent(message.content), createdAt: getMessageCursor(message), })); } export function mergeMessage(messages: SuperagentMessage[], nextMessage: SuperagentMessage) { const normalized = normalizeMessages([{ ...nextMessage, created_at: nextMessage.created_at ?? Date.now(), }])[0]; if (!normalized.id) return [...messages, normalized]; const existingIndex = messages.findIndex((message) => message.id === normalized.id); if (existingIndex < 0) return [...messages, normalized]; return messages.map((message, index) => { if (index !== existingIndex) return message; // Don't let a shorter/stale assistant payload overwrite newer streamed text // the user is already reading — but still adopt the incoming row's fresh // tool_calls/status/metadata (mirrors mergeConversationSnapshot), so a tool // flipping to waiting_for_user_input/success isn't masked by the local row. if (message.role === 'assistant' && (message.content?.length ?? 0) > (normalized.content?.length ?? 0)) { return { ...normalized, content: message.content }; } return normalized; }); } export function isVisibleMessage(message: SuperagentMessage, index: number) { if (message.hidden) return false; if (message.role === 'system') return false; // Never render the phase-2 connect trigger, even if its hidden flag was lost. if (message.additional_message_params?.connect_requested_connectors) return false; return !(index === 0 && message.role === 'user' && message.content === AUTO_GREET_MESSAGE); } export function createUserMessage(content: string, options: { fileUrls?: string[]; replyTo?: SuperagentReplyTo } = {}): SuperagentMessage { const fileUrls = options.fileUrls?.filter(Boolean) ?? []; const replyTo = options.replyTo; return { id: nextMessageId(), role: 'user', content, createdAt: new Date().toISOString(), ...(fileUrls.length > 0 ? { fileUrls, file_urls: fileUrls } : {}), ...(replyTo ? { additional_message_params: { reply_to: { content: replyTo.content, message_id: replyTo.messageId } }, replyTo, } : {}), }; } export function isQueuedResponse(response: unknown) { return Boolean( response && typeof response === 'object' && 'queued' in response && (response as { queued?: unknown }).queued, ); } export function countAssistantResponses(messages: SuperagentMessage[]) { return messages.filter((message) => ( message.role === 'assistant' && !message.hidden && normalizeContent(message.content).trim().length > 0 )).length; } export function hasNewAssistantResponse(messages: SuperagentMessage[], assistantCountBefore: number) { return countAssistantResponses(messages) > assistantCountBefore; } // True when any message has a tool call that is actively executing — not just // the last row: a tool can still be running on an earlier assistant message // while a newer text row already exists. Uses the shared isActiveToolStatus // (running/in_progress/pending) so the poll/settle busy signal matches the UI's // notion of "active". "waiting" is excluded: that means the agent is paused for // user input, not still working. export function hasRunningToolCall(messages: SuperagentMessage[]) { return messages.some((message) => getMessageToolCalls(message).some((toolCall) => isActiveToolStatus(toolCall.status)), ); } // Newest assistant response only, mirroring the backend's check on the response // message: a question the user walked away from stays waiting in history forever, // and treating that as the current pause would park the queue on every later turn. function latestResponseToolCalls(messages: SuperagentMessage[]) { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (message.role === 'assistant') return getMessageToolCalls(message); } return []; } function isPendingClarification(toolCall: { name: string; status?: string }) { return toolCall.name === CLARIFYING_QUESTIONS_TOOL_NAME && toolCall.status === 'waiting_for_user_input'; } /** * The backend is holding the queue until the user answers. Read from the snapshot * rather than the agent_done payload, since the settle path is also reached with no * realtime client and via the 5s fallback when that event is missed. */ export function hasPendingClarification(messages: SuperagentMessage[]) { return latestResponseToolCalls(messages).some(isPendingClarification); } /** * The call the composer stands in for; needs an id to be submittable. Only while the * question is still the last message — once the user sends something else the card * is stale, so it steps aside instead of sitting on top of a moved-on conversation. */ export function findPendingClarification(messages: SuperagentMessage[]) { const last = messages[messages.length - 1]; if (last?.role !== 'assistant') return undefined; return getMessageToolCalls(last).find((toolCall) => isPendingClarification(toolCall) && toolCall.id); } // Merge an authoritative server snapshot with the current list, keeping a local // assistant row when it already has more text than the snapshot copy — so a // slightly stale fetch/event can't shorten streamed content the user is reading. export function mergeConversationSnapshot( current: SuperagentMessage[], incoming: SuperagentMessage[], ): SuperagentMessage[] { const currentById = new Map(current.filter((message) => message.id).map((message) => [message.id, message])); const incomingIds = new Set(incoming.map((message) => message.id).filter(Boolean)); const incomingUserContents = new Set( incoming.filter((message) => message.role === 'user').map((message) => (message.content ?? '').trim()), ); const merged = incoming.map((message) => { const local = message.id ? currentById.get(message.id) : undefined; if (local && local.role === 'assistant' && (local.content?.length ?? 0) > (message.content?.length ?? 0)) { // Keep the longer local text, but take the snapshot's fresh tool_calls / // status / metadata — otherwise a newly-`waiting_for_user_input` approval or // a completed tool would be masked by the stale local row. return { ...message, content: local.content }; } return message; }); // Keep local rows the snapshot hasn't caught up to (optimistic sends / messages // still streaming in), dropping an optimistic user echo the snapshot already // represents by content so it isn't duplicated. Callers that genuinely remove a // message (delete) must drop it from local state before refreshing, or it would // be resurrected here. const pendingLocal = current.filter((message) => message.id && !incomingIds.has(message.id) && !(message.role === 'user' && incomingUserContents.has((message.content ?? '').trim())), ); // Sort the combined set by chronological cursor instead of appending pendingLocal. // Older rows loaded via loadPrevious aren't in the backend's recent-window refresh, // so appending them would drop old history below the newest replies and break // chat order / autoscroll. Stable sort preserves equal-cursor snapshot order; // rows without a cursor (fresh optimistic sends) sort last, as newest. return [...merged, ...pendingLocal].sort((a, b) => { const ca = getMessageCursor(a); const cb = getMessageCursor(b); if (ca && cb) return ca < cb ? -1 : ca > cb ? 1 : 0; if (!ca && !cb) return 0; return ca ? -1 : 1; }); } export async function refreshConversation( apiClient: SuperagentNativeClient, conversationId: string, setMessages: Dispatch>, ) { const refreshed = await apiClient.getConversation(conversationId); const normalizedMessages = normalizeMessages(refreshed.messages ?? []); setMessages((current) => mergeConversationSnapshot(current, normalizedMessages)); return normalizedMessages; } /** * Whether the server has the message we tried to send, reconciling the thread on the way. * POST /messages is held open for the whole agent turn, so a failed send request can also * mean "accepted, then the connection died" — only the server can say which. An * unreachable server answers nothing, and that counts as not landed: showing the text * back in the composer is recoverable, silently dropping it is not. */ export async function didMessageLand( apiClient: SuperagentNativeClient, conversationId: string, messageId: string | undefined, setMessages: Dispatch>, ) { try { const refreshed = await refreshConversation(apiClient, conversationId, setMessages); return refreshed.some((message) => message.id === messageId); } catch { return false; } } export async function sendWithFallback( agentId: string, content: string, fileUrls: string[], replyTo: SuperagentReplyTo | undefined, onSendMessage: (params: { agentId: string; fileUrls?: string[]; message: string; replyTo?: SuperagentReplyTo; requestedConnectors?: string[]; connectRequested?: boolean; hidden?: boolean }) => Promise | SuperagentMessage | SuperagentMessage[] | void, setMessages: Dispatch>, setIsSending: (value: boolean) => void, optimisticMessageId?: string, connectorSeed?: { requestedConnectors?: string[]; connectRequested?: boolean; hidden?: boolean }, ): Promise<{ delivered: boolean; hasAssistantResponse: boolean }> { try { const response = await onSendMessage({ agentId, fileUrls, message: content, replyTo, // Parity with the apiClient path: forward the connector seed + hidden flag. ...(connectorSeed?.requestedConnectors?.length ? { requestedConnectors: connectorSeed.requestedConnectors } : {}), ...(connectorSeed?.connectRequested ? { connectRequested: true } : {}), ...(connectorSeed?.hidden ? { hidden: true } : {}), }); const responseMessages = Array.isArray(response) ? response : response ? [response] : []; setMessages((current) => [...current, ...responseMessages]); // `delivered` (the host handler accepted the send) is distinct from // `hasAssistantResponse` (a reply arrived synchronously): a successful send // with no immediate assistant reply must NOT look like a failure, or the // caller would wrongly restore the composer draft. return { delivered: true, hasAssistantResponse: responseMessages.some((message) => message.role === 'assistant') }; } catch (error) { // The optimistic bubble never reached the host send handler; drop it so the turn // doesn't look sent. No error bubble in the chat — the composer restores the draft when // the send reports delivered:false. Unlike the apiClient path in // useSuperagentConversation, there is no conversation to re-read here, so a failure // can't be checked against the server and is always treated as undelivered. console.error('Superagent send failed', error); if (optimisticMessageId) { setMessages((current) => current.filter((message) => message.id !== optimisticMessageId)); } return { delivered: false, hasAssistantResponse: false }; } finally { setIsSending(false); } } export function pollConversation( apiClient: SuperagentNativeClient, conversationId: string, setMessages: Dispatch>, assistantCountBefore: number, // Called once when the poll stops: `completed` is true on a genuinely finished // turn, false on the hard-cap bail-out. `pendingClarification` reports that the // turn stopped on a question, so the caller must not promote a queued follow-up. // The caller owns clearing/keeping the busy state, promoting the next queued // turn, and firing completion callbacks. onSettle: (completed: boolean, pendingClarification?: boolean) => void, ) { // Without realtime we poll for the turn to finish. Stop as soon as a new // assistant reply lands. Otherwise stop at the soft cap (~20s) only when the // agent no longer appears to be working — if a tool call is still running we // keep polling up to a hard cap (~120s) so "sending" isn't cleared mid-turn. // (A tool waiting_for_user_input is NOT "working": it needs the user, so we // let it stop and surface the approval card.) const SOFT_CAP = 10; const HARD_CAP = 60; let attempts = 0; // Tracks whether a tool was observed running during this poll. A tool-only turn // produces no new assistant *text*, so without this it would sit until the soft // cap (~20s) even after the tool finished — once we've seen a tool run and it's // no longer running, the turn is done. let sawRunningTool = false; const interval = setInterval(async () => { attempts += 1; let foundAssistantResponse = false; let agentStillRunning = false; let pendingClarification = false; try { const refreshedMessages = await refreshConversation(apiClient, conversationId, setMessages); foundAssistantResponse = hasNewAssistantResponse(refreshedMessages, assistantCountBefore); agentStillRunning = hasRunningToolCall(refreshedMessages); pendingClarification = hasPendingClarification(refreshedMessages); if (agentStillRunning) sawRunningTool = true; } catch { // Swallow transient poll failures; keep polling until a cap. } finally { // A new assistant message alone doesn't mean the turn is done: the same // snapshot can still have a running/pending tool call (tool-using turns // emit partial assistant text first). Treat the turn complete only once no // tool is running, when either new assistant text arrived, a tool ran and // has now finished (tool-only turns), or the soft cap is reached. Hard cap // is the absolute safety stop. const turnComplete = !agentStillRunning && (foundAssistantResponse || sawRunningTool || attempts >= SOFT_CAP); const shouldStop = turnComplete || attempts >= HARD_CAP; if (shouldStop) { clearInterval(interval); onSettle(turnComplete, pendingClarification); } } }, 2000); return interval; } export function pollQueuedSend( apiClient: SuperagentNativeClient, conversationId: string, queuedMessageId: string, setMessages: Dispatch>, setPollInterval: (interval: ReturnType) => void, onSettle: (completed: boolean) => void, ) { // A queued send is waiting behind another turn that holds the conversation lock. // Don't treat that turn's completion as ours: poll the backend queue until OUR // message drains (its own turn starts), then re-baseline the assistant count and // hand off to the normal response poll so we settle on THIS send's reply — not // one that landed while we were still queued. const HARD_CAP = 150; // ~5 min at 2s ticks — safety stop if the queue never drains. let attempts = 0; const interval = setInterval(async () => { attempts += 1; let stillQueued = true; try { const { messages } = await apiClient.getQueuedMessages(conversationId); stillQueued = messages.some((message) => message.id === queuedMessageId); } catch { // Swallow transient failures; keep waiting until drained or the cap. } if (stillQueued && attempts < HARD_CAP) return; clearInterval(interval); // Drained (or cap hit): our turn is now running. Re-baseline the assistant // count from the current snapshot so the response poll waits for OUR reply. let baseline = 0; try { baseline = countAssistantResponses(await refreshConversation(apiClient, conversationId, setMessages)); } catch { // Refresh failed — fall back to 0 so the response poll still settles. } setPollInterval(pollConversation(apiClient, conversationId, setMessages, baseline, onSettle)); }, 2000); return interval; } export function getMessageCursor(message?: SuperagentMessage) { if (!message) return undefined; return normalizeDate( message.createdAt ?? message.metadata?.created_date ?? message.created_at, ); } function normalizeDate(value: number | string | Date | undefined) { if (!value) return undefined; const date = value instanceof Date ? value : new Date(value); return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); } function normalizeContent(content: unknown) { if (typeof content === 'string') return content; if (content == null) return ''; try { return JSON.stringify(content, null, 2); } catch { return String(content); } }