UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

1,172 lines • 54.5 kB
import { useChat } from '@ai-sdk/react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { uiActionText } from '../../genui/actions.js';
import { AgentChatTransport, } from './agent-chat-transport.js';
import { useActionProposals } from './approvals/use-action-proposals.js';
import { attachmentFile } from './attachments/files.js';
import { useAttachments, } from './attachments/use-attachments.js';
import { AgentBackendUnsupportedError, requireBackendMethod, } from './backend.js';
import { backgroundRunsFromThread } from './background-runs.js';
import { useModels } from './catalog/use-models.js';
import { useToolCatalog } from './presentation/use-tool-catalog.js';
import { useAgentBackend, useAgentUiCapabilities } from './provider.js';
import { useQuota } from './quota/use-quota.js';
import { storedMessageToUiMessage } from './stored-message-to-ui-message.js';
import { storedThreadToUiMessages, } from './stored-thread-to-ui-messages.js';
import { notifyThreads } from './threads/threads-events.js';
import { useChatTranscript, } from './transcript/use-chat-transcript.js';
const EMPTY_QUEUE = { items: [], paused: null };
let localQueueSequence = 0;
/** The user message a queued message becomes once its turn starts. */
function queuedUserMessage(turn) {
    return {
        id: turn.messageId,
        role: 'user',
        metadata: { createdAt: new Date().toISOString() },
        parts: [
            ...turn.attachments.map((attachment) => ({
                type: 'file',
                mediaType: attachment.contentType,
                filename: attachment.name,
                url: attachment.url,
                providerMetadata: { agent: { mediaId: attachment.mediaId } },
            })),
            ...(turn.text.length > 0 ? [{ type: 'text', text: turn.text }] : []),
        ],
    };
}
/** The text and staged files of whatever `sendMessage` was handed, for a send that is queued. */
function draftOf(message) {
    if (typeof message !== 'object' || message === null) {
        return { text: '', attachments: [] };
    }
    const draft = message;
    let text = typeof draft.text === 'string' ? draft.text : '';
    if (text === '' && Array.isArray(draft.parts)) {
        text = draft.parts
            .filter((part) => part.type === 'text' && typeof part.text === 'string')
            .map((part) => part.text)
            .join('');
    }
    const files = Array.isArray(draft.files)
        ? draft.files
        : Array.isArray(draft.parts)
            ? draft.parts.filter((part) => part.type === 'file')
            : [];
    const attachments = files.flatMap((file) => {
        const mediaId = file.providerMetadata?.agent
            ?.mediaId;
        return typeof mediaId === 'string'
            ? [
                {
                    mediaId,
                    url: String(file.url ?? ''),
                    contentType: String(file.mediaType ?? 'application/octet-stream'),
                    name: String(file.filename ?? mediaId),
                },
            ]
            : [];
    });
    return { text, attachments };
}
function lastUserIndex(messages) {
    for (let index = messages.length - 1; index >= 0; index -= 1) {
        if (messages[index]?.role === 'user')
            return index;
    }
    return -1;
}
let localChatSequence = 0;
function localChatId() {
    localChatSequence += 1;
    return `local-chat-${localChatSequence}`;
}
/**
 * The agent chat, wired: the AI SDK v7 `useChat` over `AgentChatTransport`, a thread's history
 * loaded and a streaming turn re-attached on its own, a quota send gate, a model picker, a headless
 * composer with attachments, and a transcript whose approve/reject/answer/skip/stop/fork/regenerate
 * are already bound. Every piece is also a hook of its own (`useChatTranscript`, `useAttachments`,
 * `useModels`, `useQuota`, `useThreads`) for a host that wants to wire it differently.
 */
export function useAgentChat(options = {}) {
    const providerCapabilities = useAgentUiCapabilities();
    const capabilitiesRef = useRef(providerCapabilities);
    capabilitiesRef.current = providerCapabilities;
    const latest = useRef(options);
    latest.current = options;
    // Identity-stable: the first backend this chat mounted with is the one it keeps.
    const resolved = useAgentBackend(options.backend);
    const client = useRef(resolved).current;
    // The thread the backend created for a threadless chat (or a fork moved to), captured from the
    // `meta` frame. Every send after the first reuses it; an explicit `threadId` option always wins.
    const createdThreadId = useRef(undefined);
    // Which conversation `useChat` holds. A new `threadId` is a different conversation — except the
    // one this chat itself created, which a host passes back once it syncs its URL.
    const chatIdRef = useRef(options.threadId ?? localChatId());
    const boundThreadRef = useRef(options.threadId);
    if (options.threadId !== boundThreadRef.current) {
        boundThreadRef.current = options.threadId;
        const adopting = options.threadId !== undefined && options.threadId === createdThreadId.current;
        if (!adopting) {
            chatIdRef.current = options.threadId ?? localChatId();
            createdThreadId.current = undefined;
        }
    }
    const chatId = chatIdRef.current;
    const [runId, setRunId] = useState(options.resumeRunId);
    const runIdRef = useRef(runId);
    runIdRef.current = runId;
    // The run id learned (via the `meta` frame) during the CURRENT in-flight attempt only — reset by
    // the transport's `onAttemptStart`. `onRunSettled` reads THIS so an attempt that fails before
    // learning a run id never misattributes its outcome to the previous run.
    const settlingRunIdRef = useRef(undefined);
    // The run id discovered from the thread's `activeRunId`, read by the transport's
    // `getResumeRunId` alongside the explicit `resumeRunId` option.
    const autoResumeRunIdRef = useRef(undefined);
    // The loaded thread's active run, if any — `null` once read with none.
    const [activeRunId, setActiveRunId] = useState(null);
    // The thread whose history read has settled (loaded or failed). The history is loading until it
    // is the bound thread — derived, so it already reads `true` on the first render, before the
    // effect below has asked for anything, and a page never flashes its empty state.
    const [historyFor, setHistoryFor] = useState(undefined);
    const [historyError, setHistoryError] = useState(null);
    // How the last run's stream said it failed (`event: error`), until the next attempt starts.
    const [runError, setRunError] = useState(null);
    // The model pinned on the loaded thread.
    const [threadModel, setThreadModel] = useState(null);
    // The persona pinned on the thread — read with its history, and mirrored from each send that
    // names one. `null` → none pinned (the agent's default persona applies).
    const [threadPersona, setThreadPersona] = useState(null);
    const [pickedModel, setPickedModel] = useState(undefined);
    const pickedModelRef = useRef(pickedModel);
    pickedModelRef.current = pickedModel;
    // The agent is locked to one model (read off the catalog once it loads).
    const lockedRef = useRef(false);
    // A pin asked for before the chat had a thread — sent once the first send creates one.
    const pendingPin = useRef(undefined);
    // One-shot flag read (and cleared) by the transport's getBody so the next send carries
    // `regenerate: true` — telling the backend to re-run the last exchange instead of appending.
    const regenerateNext = useRef(false);
    const currentThreadId = useCallback(() => latest.current.threadId ?? createdThreadId.current, []);
    // ---- queue state (declared early: the transport's callbacks feed it) ------------------------
    const [queueState, setQueueState] = useState(EMPTY_QUEUE);
    const queueStateRef = useRef(queueState);
    const applyQueue = useCallback((next) => {
        queueStateRef.current = next;
        setQueueState(next);
    }, []);
    // Sends on their way to the server, shown in the queue until it answers.
    const [pendingSends, setPendingSends] = useState([]);
    const [queueError, setQueueError] = useState(null);
    // The queued message the running turn handed the thread to — attached to once that turn settles.
    const handoff = useRef(undefined);
    // A plain send the server queued instead of starting (another tab's turn was running).
    const queuedSend = useRef(undefined);
    const proposalRefreshRef = useRef(async () => { });
    // Queue sends made before a new chat's first turn named its thread.
    const threadWaiters = useRef([]);
    /** The body fields every send carries: agent, persona, thread, model, page context. */
    const turnBody = useCallback(() => {
        const current = latest.current;
        // The server pins a persona a send names; mirror it so a picker reads the thread's state.
        if (current.persona !== undefined)
            setThreadPersona(current.persona);
        const pageContext = current.getPageContext?.() ?? null;
        const threadId = currentThreadId();
        // A locked agent runs on its own model: a pick is not sent (the server would refuse it).
        const model = current.model ?? (lockedRef.current ? undefined : pickedModelRef.current);
        return {
            // Read per send: a host that switches agents before the first message (a picker on a new
            // chat) sends the one picked now, not the one this chat mounted with.
            ...(current.agent !== undefined ? { agent: current.agent } : {}),
            ...(current.persona !== undefined ? { persona: current.persona } : {}),
            ...(threadId !== undefined ? { threadId } : {}),
            ...(model !== undefined ? { model } : {}),
            ...(pageContext ? { pageContext } : {}),
            ...((current.uiCapabilities ?? capabilitiesRef.current) === undefined
                ? {}
                : { uiCapabilities: current.uiCapabilities ?? capabilitiesRef.current }),
        };
    }, [currentThreadId]);
    // Where the live stream stands — `reconnecting` while the transport retries a dropped one.
    const [connection, setConnection] = useState({ status: 'live' });
    // The run ended while the stream was away: what streamed is incomplete, the thread is not.
    const resyncAfterSettle = useRef(false);
    // Identity-stable: per-render config is read through `latest`.
    // biome-ignore lint/correctness/useExhaustiveDependencies: stable by design
    const transport = useMemo(() => {
        function onMeta(meta) {
            settlingRunIdRef.current = meta.runId;
            setRunId(meta.runId);
            if (meta.threadId) {
                const waiting = threadWaiters.current;
                threadWaiters.current = [];
                for (const resolve of waiting)
                    resolve(meta.threadId);
            }
            if (latest.current.threadId === undefined &&
                meta.threadId &&
                createdThreadId.current !== meta.threadId) {
                createdThreadId.current = meta.threadId;
                const pin = pendingPin.current;
                if (pin !== undefined) {
                    pendingPin.current = undefined;
                    void client.updateThread(meta.threadId, { model: pin.model }).catch(() => undefined);
                }
                latest.current.onThreadCreated?.(meta.threadId);
                notifyThreads(client, { type: 'changed' });
            }
        }
        return new AgentChatTransport({
            backend: client,
            ...(options.reconnect !== undefined ? { reconnect: options.reconnect } : {}),
            onConnectionChange: (state) => {
                if (state.status === 'gone')
                    resyncAfterSettle.current = true;
                setConnection(state.status === 'gone' ? { status: 'live' } : state);
            },
            ...(options.agent !== undefined ? { agent: options.agent } : {}),
            getBody: () => {
                const regenerate = regenerateNext.current;
                regenerateNext.current = false;
                return { ...turnBody(), ...(regenerate ? { regenerate: true } : {}) };
            },
            getResumeRunId: () => latest.current.resumeRunId ?? autoResumeRunIdRef.current,
            // A stream attached to after a reload carries no `meta` frame: without this the chat would
            // be reading a run it cannot name, and `cancel()` would stop nothing.
            onResumeAttached: (resumedRunId) => {
                settlingRunIdRef.current = resumedRunId;
                setRunId(resumedRunId);
            },
            onMeta,
            onAttemptStart: () => {
                settlingRunIdRef.current = undefined;
                setRunError(null);
            },
            onRunError: (failure) => setRunError(failure),
            // A queued turn this chat went to attach to had already finished: read what it wrote.
            onResumeGone: () => {
                void resyncFromThread();
            },
        });
    }, []);
    // Assigned once `refreshBackground` exists below.
    const backgroundRef = useRef(() => Promise.resolve());
    const chat = useChat({
        transport,
        id: chatId,
        resume: options.resumeRunId !== undefined,
        ...(options.initialMessages !== undefined ? { messages: options.initialMessages } : {}),
        onData: (part) => {
            latest.current.onData?.(part);
            if (part.type === 'data-proposal-decision')
                void proposalRefreshRef.current();
            if (part.type === 'data-queue') {
                onQueueData(part.data);
            }
            if (part.type === 'data-title') {
                const title = part.data?.title;
                if (typeof title === 'string') {
                    latest.current.onTitle?.(title);
                    const threadId = currentThreadId();
                    if (threadId !== undefined)
                        notifyThreads(client, { type: 'title', threadId, title });
                }
            }
        },
        onFinish: ({ isError }) => {
            latest.current.onFinish?.();
            if (latest.current.background === true) {
                void backgroundRef.current();
            }
            const settledRunId = settlingRunIdRef.current;
            if (settledRunId !== undefined) {
                latest.current.onRunSettled?.({
                    runId: settledRunId,
                    status: isError ? 'failed' : 'completed',
                });
                notifyThreads(client, { type: 'changed' });
            }
            setConnection({ status: 'live' });
            const queuedInstead = queuedSend.current;
            if (queuedInstead !== undefined) {
                queuedSend.current = undefined;
                // The SDK already showed the send as a user message; it is waiting in the queue instead.
                chatRef.current.setMessages((current) => {
                    const last = lastUserIndex(current);
                    return last === -1 ? current : current.slice(0, last);
                });
            }
            const next = handoff.current;
            handoff.current = undefined;
            if (next !== undefined) {
                attachTo(next);
                return;
            }
            if (resyncAfterSettle.current) {
                resyncAfterSettle.current = false;
                void resyncFromThread();
            }
        },
    });
    const chatRef = useRef(chat);
    chatRef.current = chat;
    /** A `data-queue` part: the queue as it now stands, and maybe the turn this run handed over to. */
    function onQueueData(data) {
        const frame = (data ?? {});
        const before = queueStateRef.current;
        if (frame.queue !== undefined)
            applyQueue(frame.queue);
        const started = frame.started;
        // A run's own stream may open by announcing itself (a queue kicked while idle): not a handoff.
        if (started !== undefined && started.runId !== settlingRunIdRef.current) {
            const waiting = before.items.find((item) => item.id === started.messageId);
            handoff.current = {
                ...started,
                text: waiting?.content ?? '',
                attachments: waiting?.attachments ?? [],
            };
        }
        if (frame.queuedSend !== undefined) {
            queuedSend.current = frame.queuedSend;
            if (frame.queuedSend.runId !== undefined) {
                handoff.current = {
                    messageId: frame.queuedSend.messageId,
                    runId: frame.queuedSend.runId,
                    ...draftOf(chatRef.current.messages[lastUserIndex(chatRef.current.messages)]),
                };
            }
        }
    }
    /**
     * Show a queued message as the user message it now is, and attach to the turn it started. After
     * the SDK has fully settled the previous response — resuming from inside its `onFinish` would
     * have that response's cleanup clobber this one.
     */
    function attachTo(turn) {
        setTimeout(() => {
            if (turn.text.length > 0 || turn.attachments.length > 0) {
                chatRef.current.setMessages((current) => current.some((message) => message.id === turn.messageId)
                    ? current
                    : [...current, queuedUserMessage(turn)]);
            }
            else {
                // Queued somewhere this chat never saw: the thread has it, once the turn has run.
                resyncAfterSettle.current = true;
            }
            autoResumeRunIdRef.current = turn.runId;
            void chatRef.current.resumeStream();
        }, 0);
    }
    /**
     * An action's outcome was just written to the thread: the server may go on from it with a turn
     * this chat did not start (a sign-in that completed elsewhere continues the request). Look for it
     * for a few seconds; found running → stream it, then show the thread as stored (its new user
     * message included); already over → show the thread as stored.
     */
    async function followServerTurn() {
        const threadId = currentThreadId();
        if (threadId === undefined)
            return;
        const shown = chatRef.current.messages.length;
        for (let attempt = 0; attempt < 8; attempt++) {
            if (chatRef.current.status !== 'ready' && chatRef.current.status !== 'error')
                return;
            let thread;
            try {
                thread = await client.getThread(threadId);
            }
            catch {
                return;
            }
            if (currentThreadId() !== threadId)
                return;
            const active = thread?.activeRunId ?? null;
            if (active !== null) {
                setActiveRunId(active);
                autoResumeRunIdRef.current = active;
                resyncAfterSettle.current = true;
                void chatRef.current.resumeStream();
                return;
            }
            const stored = Array.isArray(thread?.messages) ? thread.messages : [];
            if (attempt > 0 && stored.length > shown) {
                chatRef.current.setMessages(storedThreadToUiMessages(stored));
                void proposalRefreshRef.current?.();
                return;
            }
            await new Promise((resolve) => setTimeout(resolve, 750));
        }
    }
    /** Replace the transcript with the persisted thread — after a run finished while we were away. */
    async function resyncFromThread() {
        const threadId = currentThreadId();
        if (threadId === undefined)
            return;
        try {
            const thread = await client.getThread(threadId);
            chatRef.current.setMessages(storedThreadToUiMessages(thread.messages));
        }
        catch {
            /* best-effort: the partial answer stays until the next load */
        }
    }
    // A new conversation starts from rest: nothing of the previous thread's run state carries over.
    const firstChatId = useRef(chatId);
    // biome-ignore lint/correctness/useExhaustiveDependencies: `applyQueue` is stable; `chatId` names the conversation
    useEffect(() => {
        if (chatId === firstChatId.current)
            return;
        firstChatId.current = chatId;
        setRunId(undefined);
        setActiveRunId(null);
        setThreadModel(null);
        setThreadPersona(null);
        setPickedModel(undefined);
        setBackgroundRuns([]);
        setHistoryError(null);
        setRunError(null);
        autoResumeRunIdRef.current = undefined;
        pendingPin.current = undefined;
        applyQueue(EMPTY_QUEUE);
        setPendingSends([]);
        setQueueError(null);
        handoff.current = undefined;
        queuedSend.current = undefined;
    }, [chatId]);
    // A thread's history and its still-streaming turn, in one read. Re-runs per conversation; a
    // stale response (the thread changed mid-flight) is dropped.
    // biome-ignore lint/correctness/useExhaustiveDependencies: `chatId` names the conversation
    useEffect(() => {
        const threadId = options.threadId;
        if (threadId === undefined)
            return;
        // Adopted: this chat created the thread, and its messages are the live ones.
        if (threadId === createdThreadId.current)
            return;
        const current = latest.current;
        const wantHistory = current.history !== false && current.initialMessages === undefined;
        const wantResume = current.resume !== false && current.resumeRunId === undefined;
        if (!wantHistory && !wantResume)
            return;
        let cancelled = false;
        client
            .getThread(threadId)
            .then((thread) => {
            if (cancelled || thread == null)
                return;
            const active = thread.activeRunId ?? null;
            const stored = Array.isArray(thread.messages) ? thread.messages : [];
            setActiveRunId(active);
            setThreadModel(thread.model ?? null);
            setThreadPersona(thread.persona ?? null);
            setHistoryError(null);
            const queue = thread.queue ?? EMPTY_QUEUE;
            applyQueue(queue);
            // Messages left waiting with nothing running and nothing pausing them — the process
            // that would have started them went away. Start the head now.
            if (queue.items.length > 0 &&
                queue.paused === null &&
                active === null &&
                typeof client.resumeQueue === 'function') {
                void resumeQueueRef.current().catch(() => undefined);
            }
            const resuming = wantResume && active !== null;
            if (wantHistory && chatRef.current.messages.length === 0) {
                // The run being resumed replays from its first frame, so its rows are left to the stream.
                const rows = resuming
                    ? stored.filter((message) => !(message.role === 'assistant' && message.runId === active))
                    : stored;
                chatRef.current.setMessages(storedThreadToUiMessages(rows));
            }
            if (resuming) {
                autoResumeRunIdRef.current = active;
                void chatRef.current.resumeStream();
            }
        }, (error) => {
            if (cancelled)
                return;
            setHistoryError(error instanceof Error ? error : new Error(String(error)));
        })
            .finally(() => {
            if (!cancelled)
                setHistoryFor(threadId);
        });
        return () => {
            cancelled = true;
        };
    }, [client, chatId, options.threadId]);
    const isLoadingHistory = options.threadId !== undefined &&
        options.threadId !== createdThreadId.current &&
        options.history !== false &&
        options.initialMessages === undefined &&
        historyFor !== options.threadId;
    /**
     * One turn in flight per chat. The SDK commits its single in-flight response BEFORE it reaches
     * the transport, so a second overlapping turn cannot be untangled downstream. The ref rather than
     * `chat.status`: a double-submitted composer arrives in the same tick, before status moves.
     */
    const turnInFlight = useRef(false);
    const isTurnInFlight = useCallback(() => turnInFlight.current || transport.isAttemptLive, [transport]);
    // ---- quota ---------------------------------------------------------------------------------
    const quota = useQuota({
        backend: client,
        enabled: options.quota !== false && typeof client.getQuota === 'function',
    });
    const blocked = options.blocked !== undefined ? options.blocked : quota.blocked;
    const blockedRef = useRef(blocked);
    blockedRef.current = blocked;
    // ---- queue ---------------------------------------------------------------------------------
    const whileRunning = typeof client.enqueueMessage === 'function' ? (options.whileRunning ?? 'queue') : 'block';
    const whileRunningRef = useRef(whileRunning);
    whileRunningRef.current = whileRunning;
    const canQueueRef = useRef(false);
    canQueueRef.current = typeof client.enqueueMessage === 'function';
    /** This chat's thread — waiting for a new chat's first turn to name it, when one is in flight. */
    const threadForQueue = useCallback(async () => {
        const known = currentThreadId();
        if (known !== undefined)
            return known;
        if (!isTurnInFlight())
            throw new Error('queue: this chat has no thread yet');
        return new Promise((resolve) => threadWaiters.current.push(resolve));
    }, [currentThreadId, isTurnInFlight]);
    /** Run a queue operation, keeping the last failure on `chat.queue.error`. */
    const queueCall = useCallback(async (run) => {
        try {
            const result = await run();
            setQueueError(null);
            return result;
        }
        catch (error) {
            setQueueError(error instanceof Error ? error : new Error(String(error)));
            throw error;
        }
    }, []);
    // biome-ignore lint/correctness/useExhaustiveDependencies: `attachTo` reads refs only
    const enqueue = useCallback(async (text, { attachments = [], mode = 'queue', } = {}) => {
        const block = blockedRef.current;
        if (block != null)
            throw new QuotaBlockedError(block);
        const send = client.enqueueMessage;
        if (typeof send !== 'function')
            throw new AgentBackendUnsupportedError('enqueueMessage');
        localQueueSequence += 1;
        const localId = `local-queued-${localQueueSequence}`;
        setPendingSends((current) => [
            ...current,
            {
                id: localId,
                text,
                attachments,
                files: attachments.map(attachmentFile),
                state: 'sending',
                interrupt: mode === 'interrupt',
                createdAt: new Date().toISOString(),
            },
        ]);
        try {
            await queueCall(async () => {
                const threadId = await threadForQueue();
                const result = await send.call(client, {
                    body: {
                        ...turnBody(),
                        threadId,
                        message: text,
                        mode,
                        ...(attachments.length > 0
                            ? { attachments: attachments.map(({ mediaId }) => ({ mediaId })) }
                            : {}),
                    },
                });
                applyQueue(result.queue);
                if (result.runId !== undefined) {
                    // Nothing was running after all: it started at once.
                    const turn = { messageId: result.messageId, runId: result.runId, text, attachments };
                    if (isTurnInFlight())
                        handoff.current = turn;
                    else
                        attachTo(turn);
                }
            });
        }
        finally {
            setPendingSends((current) => current.filter((item) => item.id !== localId));
        }
    }, [client, queueCall, threadForQueue, turnBody, applyQueue, isTurnInFlight]);
    const sendMessage = useCallback(async (message, options) => {
        const block = blockedRef.current;
        if (block != null)
            throw new QuotaBlockedError(block);
        // `mode` is this hook's, not the SDK's: it never reaches the request.
        const { mode: ownMode, ...sdkOptions } = options ?? {};
        if (isTurnInFlight()) {
            // Mid-turn: the message waits in the thread's queue — unless this chat blocks instead. A
            // backend that cannot queue blocks whatever was asked for.
            const mode = canQueueRef.current ? (ownMode ?? whileRunningRef.current) : 'block';
            if (mode === 'block')
                return;
            const draft = draftOf(message);
            await enqueue(draft.text, { attachments: draft.attachments, mode });
            return;
        }
        turnInFlight.current = true;
        try {
            await chatRef.current.sendMessage(message, options === undefined ? undefined : sdkOptions);
        }
        finally {
            turnInFlight.current = false;
        }
    }, [isTurnInFlight, enqueue]);
    /**
     * Send a UI action (a sandbox's `agent.send`, an A2UI button) as the next user message: what the
     * user said, then the action and its values (`uiActionText`). Hand it to `GenuiActionProvider`.
     */
    const sendUiAction = useCallback((action) => sendMessage({ text: uiActionText(action) }), [sendMessage]);
    const removeQueued = useCallback(async (id) => {
        const previous = queueStateRef.current;
        applyQueue({ ...previous, items: previous.items.filter((item) => item.id !== id) });
        try {
            applyQueue(await queueCall(() => requireBackendMethod(client, 'removeQueuedMessage')(id)));
        }
        catch (error) {
            applyQueue(previous);
            throw error;
        }
    }, [client, queueCall, applyQueue]);
    // biome-ignore lint/correctness/useExhaustiveDependencies: `attachTo` reads refs only
    const interruptQueued = useCallback(async (id) => {
        const previous = queueStateRef.current;
        const moving = previous.items.find((item) => item.id === id);
        if (moving !== undefined) {
            // Shown where it will be at once: at the head, as the interrupt, the pause lifted.
            applyQueue({
                paused: null,
                items: [
                    { ...moving, interrupt: true },
                    ...previous.items.filter((item) => item.id !== id),
                ],
            });
        }
        try {
            const result = await queueCall(() => requireBackendMethod(client, 'interruptQueuedMessage')(id));
            applyQueue({ items: result.items, paused: result.paused });
            if (result.runId !== undefined) {
                // Nothing was running: it started at once.
                const turn = {
                    messageId: result.runId,
                    runId: result.runId,
                    text: moving?.content ?? '',
                    attachments: moving?.attachments ?? [],
                };
                if (isTurnInFlight())
                    handoff.current = turn;
                else
                    attachTo(turn);
            }
        }
        catch (error) {
            applyQueue(previous);
            throw error;
        }
    }, [client, queueCall, applyQueue, isTurnInFlight]);
    const editQueued = useCallback(async (id, text) => {
        applyQueue(await queueCall(() => requireBackendMethod(client, 'updateQueuedMessage')(id, { message: text })));
    }, [client, queueCall, applyQueue]);
    const moveQueued = useCallback(async (id, index) => {
        const previous = queueStateRef.current;
        const moving = previous.items.find((item) => item.id === id);
        if (moving !== undefined) {
            const rest = previous.items.filter((item) => item.id !== id);
            rest.splice(Math.max(0, Math.min(rest.length, index)), 0, moving);
            applyQueue({ ...previous, items: rest });
        }
        try {
            applyQueue(await queueCall(() => requireBackendMethod(client, 'updateQueuedMessage')(id, { position: index })));
        }
        catch (error) {
            applyQueue(previous);
            throw error;
        }
    }, [client, queueCall, applyQueue]);
    const clearQueue = useCallback(async () => {
        const threadId = currentThreadId();
        if (threadId === undefined)
            return;
        applyQueue(await queueCall(() => requireBackendMethod(client, 'clearQueue')(threadId)));
    }, [client, queueCall, currentThreadId, applyQueue]);
    // biome-ignore lint/correctness/useExhaustiveDependencies: `attachTo` reads refs only
    const resumeQueue = useCallback(async () => {
        const threadId = currentThreadId();
        if (threadId === undefined)
            return;
        const before = queueStateRef.current;
        const result = await queueCall(() => requireBackendMethod(client, 'resumeQueue')(threadId));
        applyQueue({ items: result.items, paused: result.paused });
        if (result.runId !== undefined) {
            const head = before.items.find((item) => item.id === result.runId) ?? before.items[0];
            const turn = {
                messageId: result.runId,
                runId: result.runId,
                text: head?.content ?? '',
                attachments: head?.attachments ?? [],
            };
            if (isTurnInFlight())
                handoff.current = turn;
            else
                attachTo(turn);
        }
    }, [client, queueCall, currentThreadId, applyQueue, isTurnInFlight]);
    const resumeQueueRef = useRef(resumeQueue);
    resumeQueueRef.current = resumeQueue;
    const queuedItems = [
        ...pendingSends.filter((item) => item.interrupt),
        ...queueState.items.map((item) => ({
            id: item.id,
            text: item.content,
            attachments: item.attachments ?? [],
            files: (item.attachments ?? []).map(attachmentFile),
            state: 'queued',
            interrupt: item.interrupt === true,
            createdAt: item.createdAt,
        })),
        ...pendingSends.filter((item) => !item.interrupt),
    ];
    const queue = {
        items: queuedItems,
        paused: queueState.paused,
        isSupported: typeof client.enqueueMessage === 'function',
        add: enqueue,
        remove: removeQueued,
        interrupt: interruptQueued,
        edit: editQueued,
        move: moveQueued,
        clear: clearQueue,
        resume: resumeQueue,
        error: queueError,
    };
    const addToolResult = useCallback(({ tool, toolCallId, output }) => chatRef.current.addToolResult({
        tool,
        toolCallId,
        output,
    }), []);
    // ---- background runs -----------------------------------------------------------------------
    const [backgroundRuns, setBackgroundRuns] = useState([]);
    /** Re-derive what is running in the background, and pull in anything that has landed since. */
    const refreshBackground = useCallback(async () => {
        const threadId = currentThreadId();
        if (threadId === undefined) {
            return;
        }
        const thread = await client.getThread(threadId);
        const runs = backgroundRunsFromThread(thread.messages);
        setBackgroundRuns(runs);
        const landed = runs
            .map((run) => run.message)
            .filter((message) => message !== undefined);
        if (landed.length === 0) {
            return;
        }
        // Appended rather than re-seeding the list: the session's own messages hold live stream state
        // that the persisted rows do not carry.
        chatRef.current.setMessages((current) => {
            const known = new Set(current.map((message) => message.id));
            const missing = landed.filter((message) => !known.has(message.id));
            return missing.length === 0
                ? current
                : [...current, ...missing.map((message) => storedMessageToUiMessage(message))];
        });
    }, [client, currentThreadId]);
    backgroundRef.current = refreshBackground;
    const isWorking = backgroundRuns.some((run) => run.status === 'running');
    // biome-ignore lint/correctness/useExhaustiveDependencies: `chatId` names the conversation
    useEffect(() => {
        if (options.background !== true || options.threadId === undefined) {
            return;
        }
        void refreshBackground();
    }, [options.background, options.threadId, chatId, refreshBackground]);
    useEffect(() => {
        const every = latest.current.backgroundPollMs ?? 5000;
        if (latest.current.background !== true || !isWorking || every <= 0) {
            return;
        }
        const timer = setInterval(() => void refreshBackground(), every);
        return () => clearInterval(timer);
    }, [isWorking, refreshBackground]);
    const proposals = useActionProposals(client, options.proposals === false ||
        (options.proposals !== true && options.history === false && options.resume === false)
        ? undefined
        : currentThreadId(), (update) => chatRef.current.setMessages(update), `${chat.status}:${connection.status}`, options.proposalPollMs, () => void followServerTurn());
    proposalRefreshRef.current = proposals.refresh;
    // ---- actions -------------------------------------------------------------------------------
    const cancel = useCallback(async () => {
        // Close the SSE on the client first so the UI flips out of streaming, then hard-abort
        // server-side (a late terminal step can outlive the connection close alone).
        chatRef.current.stop();
        const active = runIdRef.current;
        if (active) {
            try {
                await client.cancelStream(active);
            }
            catch {
                /* best-effort — the SDK stop already flipped the UI */
            }
        }
        // The stream that would have carried the `queue` frame was closed above, so a queue the stop
        // just paused is read back: otherwise its messages keep saying "up next" with nothing coming.
        const threadId = currentThreadId();
        if (threadId !== undefined &&
            queueStateRef.current.items.length > 0 &&
            typeof client.getQueue === 'function') {
            try {
                applyQueue(await client.getQueue(threadId));
            }
            catch {
                /* best-effort — the next thread read brings it */
            }
        }
    }, [client, currentThreadId, applyQueue]);
    // Approve / reject / answer / skip route by tool-call id alone — the server derives the run
    // awaiting it (a sub-agent's own run when the call belongs to a delegated agent).
    const approve = useCallback(async ({ toolCallId, target, remember, via }) => {
        if (target?.kind === 'proposal') {
            const threadId = target.threadId ?? currentThreadId();
            if (threadId === undefined)
                throw new Error('A proposal decision requires its thread scope');
            await requireBackendMethod(client, 'approveActionProposal')({
                threadId,
                proposalId: target.proposalId,
                ...(remember === true ? { remember: true } : {}),
            });
            await proposals.refresh();
            return;
        }
        await requireBackendMethod(client, 'approveToolCall')({
            toolCallId,
            ...(remember === true ? { remember: true } : {}),
            ...(via !== undefined ? { via } : {}),
        });
    }, [client, currentThreadId, proposals.refresh]);
    const reject = useCallback(async ({ toolCallId, target, reason, via }) => {
        if (target?.kind === 'proposal') {
            const threadId = target.threadId ?? currentThreadId();
            if (threadId === undefined)
                throw new Error('A proposal decision requires its thread scope');
            await requireBackendMethod(client, 'rejectActionProposal')({
                threadId,
                proposalId: target.proposalId,
                ...(reason === undefined ? {} : { reason }),
            });
            await proposals.refresh();
            return;
        }
        await requireBackendMethod(client, 'rejectToolCall')({
            toolCallId,
            ...(reason !== undefined ? { reason } : {}),
            ...(via !== undefined ? { via } : {}),
        });
    }, [client, currentThreadId, proposals.refresh]);
    const answer = useCallback(async ({ toolCallId, answers, via }) => {
        await requireBackendMethod(client, 'answerToolCall')({
            toolCallId,
            ...(answers !== undefined ? { answers } : {}),
            ...(via !== undefined ? { via } : {}),
        });
    }, [client]);
    const skip = useCallback(async ({ toolCallId, via }) => {
        await requireBackendMethod(client, 'skipToolCall')({ toolCallId, ...(via !== undefined ? { via } : {}) });
    }, [client]);
    // Re-run the last exchange: flag the next request as a regenerate (so the backend truncates and
    // re-answers instead of appending) and let the SDK re-issue it.
    const regenerate = useCallback((_input) => {
        const block = blockedRef.current;
        if (block != null)
            throw new QuotaBlockedError(block);
        if (isTurnInFlight())
            return;
        regenerateNext.current = true;
        turnInFlight.current = true;
        void chatRef.current.regenerate().finally(() => {
            turnInFlight.current = false;
        });
    }, [isTurnInFlight]);
    /**
     * The persisted id of a message on screen. A message streamed in this session carries the
     * client's own id, so the row it became is found by the run that wrote it.
     */
    const storedIdOf = useCallback(async (messageId, threadId) => {
        const message = chatRef.current.messages.find((candidate) => candidate.id === messageId);
        const runOf = message?.metadata?.runId;
        if (runOf === undefined)
            return messageId;
        const thread = await client.getThread(threadId);
        const row = [...thread.messages]
            .reverse()
            .find((candidate) => candidate.role === 'assistant' && candidate.runId === runOf);
        return row?.id ?? messageId;
    }, [client]);
    /**
     * Branch the conversation at `messageId` into a new thread (`onThreadCreated` gets its id).
     * Defaults to this chat's thread.
     */
    const fork = useCallback(async ({ messageId, threadId, }) => {
        const source = threadId ?? currentThreadId();
        if (source === undefined)
            throw new Error('fork: this chat has no thread yet');
        const stored = await storedIdOf(messageId, source);
        const forked = await requireBackendMethod(client, 'forkFromMessage')(source, stored);
        notifyThreads(client, { type: 'changed' });
        latest.current.onThreadCreated?.(forked.id);
        return forked;
    }, [client, currentThreadId, storedIdOf]);
    /** Delete `messageId` and everything after it on the thread (default: this chat's). */
    const truncateFrom = useCallback(async ({ messageId, threadId }) => {
        const target = threadId ?? currentThreadId();
        if (target === undefined)
            throw new Error('truncateFrom: this chat has no thread yet');
        const stored = await storedIdOf(messageId, target);
        await requireBackendMethod(client, 'truncateFromMessage')(target, stored);
    }, [client, currentThreadId, storedIdOf]);
    /** Make a transient thread (default: this chat's) a regular one, listed by `useThreads`. */
    const promote = useCallback(async ({ threadId } = {}) => {
        const target = threadId ?? currentThreadId();
        if (target === undefined)
            throw new Error('promote: this chat has no thread yet');
        await requireBackendMethod(client, 'promoteThread')(target);
        notifyThreads(client, { type: 'changed' });
    }, [client, currentThreadId]);
    // ---- models --------------------------------------------------------------------------------
    const [modelsWanted, setModelsWanted] = useState(false);
    const modelsWantedRef = useRef(modelsWanted);
    modelsWantedRef.current = modelsWanted;
    const catalog = useModels({
        backend: client,
        ...(options.agent !== undefined ? { agent: options.agent } : {}),
        enabled: modelsWanted && typeof client.listModels === 'function',
    });
    const wantModels = useCallback(() => {
        if (modelsWantedRef.current)
            return;
        modelsWantedRef.current = true;
        // After render: the getter may be read while a child renders.
        queueMicrotask(() => setModelsWanted(true));
    }, []);
    lockedRef.current = catalog.locked !== null;
    const selectModel = useCallback((id) => {
        if (!lockedRef.current)
            setPickedModel(id);
    }, []);
    const pinToThread = useCallback(async (model) => {
        const threadId = currentThreadId();
        setThreadModel(model);
        // The pin is what later turns run on: a pick would override it on every send.
        setPickedModel(undefined);
        if (threadId === undefined) {
            pendingPin.current = { model };
            return;
        }
        await client.updateThread(threadId, { model });
        notifyThreads(client, { type: 'changed' });
    }, [client, currentThreadId]);
    const models = {
        get list() {
            wantModels();
            return catalog.models;
        },
        get providers() {
            wantModels();
            return catalog.providers;
        },
        selected: catalog.locked?.model ??
            options.model ??
            pickedModel ??
            threadModel ??
            catalog.defaultModel ??
            null,
        pinned: threadModel,
        get defaultModel() {
            wantModels();
            return catalog.defaultModel;
        },
        get locked() {
            wantModels();
            return catalog.locked;
        },
        select: selectModel,
        pinToThread,
        isLoading: modelsWanted && catalog.isLoading,
        error: catalog.error,
    };
    // A dropped stream the transport is retrying reads as its own status; every other moment is the
    // SDK's. `reconnecting` is still a busy status to the transcript model (see `ChatStatus`).
    const status = connection.status === 'reconnecting' ? 'reconnecting' : chat.status;
    const isBusy = status === 'submitted' || status === 'streaming' || status === 'reconnecting';
    const isBusyRef = useRef(isBusy);
    isBusyRef.current = isBusy;
    // ---- composer ------------------------------------------------------------------------------
    const files = useAttachments({ ...options.composer, backend: client });
    const [text, setText] = useState('');
    const blockedBy = blocked != null
        ? 'quota'
        : files.isUploading
            ? 'uploading'
            : isBusy && whileRunning === 'block'
                ? 'busy'
                : text.trim().length === 0 && files.refs.length === 0
                    ? 'empty'
                    : null;
    const composerRef = useRef({ text, files, blockedBy });
    composerRef.current = { text, files, blockedBy };
    const submit = useCallback(async (options) => {
        const current = composerRef.current;
        // A mode named for this call is an answer to "a turn is running": it lifts that one block.
        const asked = canQueueRef.current ? options?.mode : undefined;
        if (current.blockedBy !== null && !(current.blockedBy === 'busy' && asked !== undefined)) {
            return;
        }
        if (current.text.trim().length === 0 && current.files.refs.length === 0)
            return;
        const refs = current.files.refs;
        // The sent message shows its files right away, as the reloaded thread will (`messageFiles`).
        const files = current.files.items.flatMap((item) => item.status === 'ready' && item.attachment !== undefined
            ? [
                {
                    type: 'file',
                    mediaType: item.attachment.contentType,
                    filename: item.attachment.name,
                    url: item.attachment.url,
                    providerMetadata: { agent: { mediaId: item.attachment.mediaId } },
                },
            ]
            : []);
        const draft = current.text.trim();
        setText('');
        current.files.clear();
        const mode = asked ?? whileRunningRef.current;
        if (mode !== 'block' && (isTurnInFlight() || isBusyRef.current)) {
            // Mid-turn: wait in the thread's queue (or, `interrupt`, cut in) instead of refusing.
            const attachments = current.files.items.flatMap((item) => item.status === 'ready' && item.attachment !== undefined ? [item.attachment] : []);
            await enqueue(draft, { attachments, mode });
            return;
        }
        await sendMessage(files.length > 0 ? { text: draft, files } : { text: draft }, refs.length > 0 ? { body: { attachments: refs } } : undefined);
    }, [sendMessage, enqueue, isTurnInFlight]);
    const composer = {
        text,
        setText,
        files,
        canSend: blockedBy === null,
        blockedBy,
        submit,
    };
    // ---- transcript ----------------------------------------------------------------------------
    const ownCatalog = options.transcript?.toolCatalog;
    const tools = useToolCatalog({
        backend: client,
        ...(options.agent !== undefined ? { agent: options.agent } : {}),
        enabled: ownCatalog === undefined && typeof client.listTools === 'function',
    });
    const canFork = typeof client.forkFromMessage === 'function';
    const transcript = useChatTranscript({
        messages: chat.messages,
        status,
        onStop: cancel,
        onApprove: approve,
        onReject: reject,
        onAnswer: answer,
        onSkip: skip,
        regeneratable: true,
        onRegenerate: regenerate,
        ...(canFork
            ? { onFork: (input) => fork(input).then(() => undefined) }
            : {}),
        ...(ownCatalog === undefined ? { toolCatalog: tools.catalog } : {}),
        queue: { items: queue.items, paused: queue.paused, remove: removeQueued },
        ...options.transcript,
    });
    const background = {
        runs: backgroundRuns,
        isWorking,
        refresh: refreshBackground,
    };
    return {
        ...chat,
        status,
        /** Where the live stream stands — see {@link StreamConnectionState}. */
        connection,
        sendMessage,
        /** Send a UI action as the next user message (see `GenuiActionProvider`). */
        sendUiAction,
        addToolResult,
        /** Sub-agents this conversation started and did not wait for. */
        background,
        proposals,
        /**
         * Look for a turn the server started on this thread (after an action's outcome — a sign-in that
         * finished elsewhere continues the request) and stream it. Called on its own when this chat's
         * proposals see a new outcome; an app with its own proposals list calls it when it does.
         */
        followServerTurn,
        runId,
        /** The loaded thread's running turn, or `null` once read with none. */
        activeRunId,
        /** The thread's history is being read. */
        isLoadingHistory,
        /** Reading the thread's history failed. */
        historyError,
        /**
         * How the last run's stream said it failed — the `event: error` frame's `code` and `message` —
         * or `null`. Cleared when the next attempt starts and when the chat moves to another thread.
         * `chat.error` still carries the message; this is where the `code` is, so the app can word each
         * failure itself (`replay_diverged`, `model_no_output`, `run_failed`, `quota_exceeded`, …).
         */
        runError,
        /**
         * The thread this chat is on right now: the `threadId` option, else the one the backend created
         * on the first send. A getter, because the created id is learned mid-stream.
         */
        getThreadId: currentThreadId,
        /** The backend this chat talks to — the same one every other hook under the provider uses. */
        backend: client,
        /** The transcript model, with every action already bound to this chat. */
        transcript,
        /** A headless composer: draft, files, send gate, `submit()`. */
        composer,
        /**
         * Messages sent while a turn was running, waiting their turn (server-side): list, edit, move,
         * remove, clear, and resume a paused queue.
         */
        queue,
        /** The model picker: `list`, `selected`, `select(id)`, `pinToThread(id)`. */
        models,
        /**
         * The persona pinned on this thread (`ThreadSummary.persona`), or `null` when none is — what a
         * persona picker on a reopened thread starts from (`?? agent.defaultPersona`).
         */
        threadPersona,
        /** The caller's budget (`GET <base>/quota`); `quota.blocked` gates sends. */
        quota,
        /** The window blocking sends right now (the `blocked` option, else the quota report's). */
        blocked,
        cancel,
        approve,
        reject,
        answer,
        skip,
        regenerate,
        fork,
        truncateFrom,
        promote,
    };
}
/** A send refused on the client because a quota window is exhausted. */
export class QuotaBlockedError extends Error {
    block;
    constructor(block) {
        super(block.reason ?? `The ${block.period === 'day' ? 'daily' : 'monthly'} quota is used up`);
        this.block = block;
        this.name = 'QuotaBlockedError';
    }
}
//# sourceMappingURL=use-agent-chat.js.map