@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
820 lines • 40.7 kB
JavaScript
import { AgentClient } from './client.js';
import { reasoningDurationMetadata } from './reasoning/timing.js';
const DEFAULT_RECONNECT = {
maxAttempts: 6,
baseDelayMs: 500,
maxDelayMs: 8000,
};
/**
* The tool name BOTH elicitation surfaces persist their question set under (core's
* `ASK_TOOL_NAME`), so a synthesized part and a replayed `tool-ask` row are the same part type.
* Spelled out rather than imported: every other core import in this package is type-only, and a
* value import would pull the agent loop into a browser bundle for a three-letter string.
*/
const ASK_TOOL_NAME = 'ask';
/**
* AI SDK v7 `ChatTransport` for the Adonis agent backend. POSTs
* `/agent/chat`, parses the backend's `meta` + `{delta}` + `done` SSE
* frames, and re-emits them as the v7 UI-message chunk stream
* (`start` → `text-start` → `text-delta`* → `text-end` → `finish`).
*
* The backend hydrates prior history from its store, so only the latest
* user message text is sent each turn — keeping payloads tiny and
* preventing the client from corrupting replayed history.
*
* Attachments (image/PDF for a vision-capable model) ride the per-send body:
* `sendMessage({ text }, { body: { attachments: MessageAttachment[] } })`. They
* flow to the backend as the turn's `attachments`, get persisted on the user
* message, and are rendered as native model content parts. Optimistic display of
* the user's own attachment thumbnails is the consumer's concern (it stages the
* upload), the same way history rendering reads `StoredMessage.attachments`.
*
* Frames beyond text/reasoning/tools become AI SDK data parts (seen by `useChat`'s `onData`):
* - `ui` → `data-ui` part, `id` = the component id (a repeat id updates it in place)
* - `approval-requested` → `data-approval-requested` part keyed by the call id, plus the SDK's
* native `tool-approval-request` (the part moves to `approval-requested`)
* - `approval-settled` → `data-approval-settled` part keyed by the call id (who decided, through
* what, remembered or not); the outcome still rides the call's own output
* - `title`, `cancelled`, `queue` → transient `data-title` / `data-cancelled` / `data-queue` (never
* stored on the message). A send the server queued (`202`) is reported
* as a transient `data-queue` too, carrying `queuedSend`
* - any other kind → `data-<kind>` part carrying the frame minus `kind`, keyed by its `id`
* when it has one — forwarded, never dropped
* `parentId` on a tool frame rides the part's `toolMetadata` next to `toolKind`.
*/
export class AgentChatTransport {
options;
currentRunId;
currentThreadId;
/**
* One attempt at a time. The AI SDK keeps a single in-flight response per chat and decides
* whether its streamed message REPLACES the list's last entry or is appended by comparing that
* response's message id against the last entry alone. Two attempts writing into one chat
* therefore append alternating copies of each other's message, and the list ends up holding
* several entries under each id — which React renders as duplicate keys.
*/
attemptLive = false;
backend;
constructor(options = {}) {
this.options = options;
this.backend =
options.backend ??
new AgentClient({
...(options.baseUrl !== undefined ? { baseUrl: options.baseUrl } : {}),
...(options.path !== undefined ? { path: options.path } : {}),
...(options.headers !== undefined ? { headers: options.headers } : {}),
...(options.getHeaders !== undefined ? { getHeaders: options.getHeaders } : {}),
...(options.credentials !== undefined ? { credentials: options.credentials } : {}),
...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
});
}
/** Run id of the most recent stream — HITL approve/reject target this. */
get runId() {
return this.currentRunId;
}
/** Thread id of the most recent stream. */
get threadId() {
return this.currentThreadId;
}
/**
* True from the moment a send or resume attempt starts until its chunk stream terminates. A
* caller that can refuse a turn BEFORE the SDK commits to one — `useAgentChat` wrapping
* `sendMessage` — reads this; the transport itself can only refuse a resume, which is the one
* attempt the SDK lets a transport decline.
*/
get isAttemptLive() {
return this.attemptLive;
}
async sendMessages(options) {
this.options.onAttemptStart?.();
this.attemptLive = true;
try {
const lastMessage = options.messages.at(-1);
const message = lastMessage ? extractText(lastMessage) : '';
const body = {
...(this.options.agent !== undefined ? { agent: this.options.agent } : {}),
...(this.options.persona !== undefined ? { persona: this.options.persona } : {}),
...(this.options.getBody?.() ?? {}),
...(options.body ?? {}),
message,
};
const headers = headerRecord(options.headers);
const files = lastMessage ? stagedFiles(lastMessage) : [];
const response = await this.backend.openChatStream({
body,
...(files.length > 0 ? { files } : {}),
...(headers !== undefined ? { headers } : {}),
...(options.abortSignal ? { signal: options.abortSignal } : {}),
});
if (response.queued !== undefined) {
// The thread already had a turn running (another tab, another device): the server queued
// the message instead. Nothing streams; the chat learns it through a transient part.
this.attemptLive = false;
return queuedChunkStream(response.queued);
}
this.captureHeaderMeta(response);
if (response.proposalDecision !== undefined) {
this.attemptLive = false;
return proposalDecisionChunkStream(response.proposalDecision);
}
return this.toChunkStream(response, {
...(headers !== undefined ? { headers } : {}),
...(options.abortSignal ? { signal: options.abortSignal } : {}),
});
}
catch (error) {
this.attemptLive = false;
throw error;
}
}
async reconnectToStream(options) {
// Already attached: a second reconnect replays the SAME buffered frames into the SAME chat, so
// it adds a duplicate of the message the first attempt is writing rather than a second turn.
// React StrictMode runs the SDK's resume effect twice on mount, which makes this the ordinary
// case and not an edge one. `null` is the SDK's own "nothing to resume" answer and, unlike a
// throw, leaves it holding no state for an attempt that never happened.
if (this.attemptLive)
return null;
this.options.onAttemptStart?.();
const runId = this.options.getResumeRunId?.();
// No buffered run → resolve null without a network round-trip so we
// never surface a 404 from a doomed resume GET.
if (runId === undefined)
return null;
this.attemptLive = true;
try {
const headers = headerRecord(options.headers);
const response = await this.backend.resumeChatStream({
runId,
...(headers !== undefined ? { headers } : {}),
});
if (response === null) {
this.attemptLive = false;
this.options.onResumeGone?.(runId);
return null;
}
this.captureHeaderMeta(response);
this.options.onResumeAttached?.(response.runId ?? runId);
return this.toChunkStream(response, {
runId,
...(headers !== undefined ? { headers } : {}),
});
}
catch (error) {
this.attemptLive = false;
throw error;
}
}
captureHeaderMeta(response) {
if (response.runId)
this.currentRunId = response.runId;
if (response.threadId)
this.currentThreadId = response.threadId;
}
reconnectPolicy() {
const configured = this.options.reconnect;
if (configured === false)
return null;
return { ...DEFAULT_RECONNECT, ...configured };
}
recordMeta(meta) {
this.currentRunId = meta.runId;
this.currentThreadId = meta.threadId;
this.options.onMeta?.(meta);
}
/**
* Parse the backend's SSE byte stream and re-emit it as a valid v7 UI-message chunk stream.
* Recognized frames:
* - `event: meta` `data: {"runId","threadId"}` → records identity
* - `data: <AgentStreamEvent JSON>` → mapped to UI chunks (text / reasoning / tool /
* `data-*`; see {@link AgentChatTransport} docs)
* - `event: done` `data: {}` → terminates
* - `event: error` `data: {code,message}` → error chunk
*
* A run is ONE UI message with N steps. Each `step-start`/`step-finish` pair brackets a model
* call plus its tool execution; text and reasoning open lazily and close at the step boundary, so
* tool-call cards (input streaming → output) render live between the prose.
*/
toChunkStream(source, context) {
let reader = source.body.getReader();
let decoder = new TextDecoder();
let buffer = '';
// Resuming a dropped stream: the run it belongs to, and the sequence number (SSE `id:`) of the
// last frame this stream has taken in. Only a server that numbers its frames can be resumed.
let streamRunId = source.runId ?? context.runId;
let lastSeq;
let cancelled = false;
const backend = this.backend;
const policy = this.reconnectPolicy();
const notify = (state) => this.options.onConnectionChange?.(state);
const reportRunError = (failure) => {
try {
this.options.onRunError?.({
...failure,
...(streamRunId !== undefined ? { runId: streamRunId } : {}),
});
}
catch {
/* a listener that throws must not replace the stream's own terminal */
}
};
const aborted = () => cancelled || context.signal?.aborted === true;
const canResume = () => policy !== null && lastSeq !== undefined && streamRunId !== undefined && !aborted();
/**
* Re-attach after a drop: `GET …/stream?after=<lastSeq>`, backing off between attempts. The
* chunk state (open step, text run, announced calls) is kept, so the resumed frames continue
* the message instead of starting another.
*/
const resume = async () => {
const runId = streamRunId;
const after = lastSeq ?? 0;
const { maxAttempts, baseDelayMs, maxDelayMs } = policy;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
notify({ status: 'reconnecting', runId, attempt, after });
await delay(Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1)), context.signal);
if (aborted())
return 'aborted';
try {
const next = await backend.resumeChatStream({
runId,
after,
...(context.headers !== undefined ? { headers: context.headers } : {}),
...(context.signal !== undefined ? { signal: context.signal } : {}),
});
if (next === null) {
notify({ status: 'gone', runId });
return 'gone';
}
reader = next.body.getReader();
decoder = new TextDecoder();
buffer = '';
notify({ status: 'live' });
return 'resumed';
}
catch {
if (aborted())
return 'aborted';
}
}
notify({ status: 'failed', runId });
return 'failed';
};
let started = false;
let stepOpen = false;
let stepIndex = 0;
let textId = null;
let reasoningId = null;
// When the open reasoning run started, for the duration stamped on its end when the backend
// does not report one (see `closeRuns`).
let reasoningStartedAt = 0;
// Text/reasoning runs opened within the current step. A pushed UI component closes the open run
// so prose after it lands AFTER it; the next run then needs an id of its own.
let textSeq = 0;
let reasoningSeq = 0;
// Tool calls this STREAM has already announced. The AI SDK settles a tool part by looking it
// up by call id and throws — dropping the whole message — when there is none, so an outcome
// must never be the first a client hears of a call. Per stream, not per transport: a resumed
// run replays its buffered frames from the beginning.
const announced = new Set();
// What each call's tool part carries as `toolMetadata`. The SDK REPLACES a part's metadata with
// whatever the latest chunk carries, so a `parentId` learnt from `tool-input-start` would be
// wiped by the `tool-input-available` that follows unless every chunk restates the merge.
const toolMetadata = new Map();
const record = (meta) => {
streamRunId = meta.runId;
this.recordMeta(meta);
};
// Every way out of the read loop below passes through here: the attempt is over the moment its
// chunk stream terminates, and a latch left set would refuse every later turn.
const endAttempt = () => {
this.attemptLive = false;
};
return new ReadableStream({
async pull(controller) {
function ensureStarted() {
if (started)
return;
started = true;
// The run rides the message's metadata, so a later call about THIS message (feedback,
// a resync) can find the rows the server persisted for it.
// `createdAt` so a live message has a timestamp the way a replayed one does.
controller.enqueue({
type: 'start',
messageMetadata: {
...(streamRunId !== undefined ? { runId: streamRunId } : {}),
createdAt: new Date().toISOString(),
},
});
}
function openStep() {
ensureStarted();
if (stepOpen)
closeStep();
stepOpen = true;
stepIndex += 1;
textId = null;
reasoningId = null;
textSeq = 0;
reasoningSeq = 0;
controller.enqueue({ type: 'start-step' });
}
function ensureStep() {
if (!stepOpen)
openStep();
}
/**
* `reasoningMs` is the backend's own measurement (`step-finish.reasoningMs`) — the number the
* store persists, so a reloaded thread reads the same duration. Without one, the time this
* client watched the run stream, which is what the reader saw but reads ~0 on a replay.
*/
function closeRuns(reasoningMs) {
if (textId !== null) {
controller.enqueue({ type: 'text-end', id: textId });
textId = null;
}
if (reasoningId !== null) {
controller.enqueue({
type: 'reasoning-end',
id: reasoningId,
providerMetadata: reasoningDurationMetadata(reasoningMs ?? Math.max(0, Date.now() - reasoningStartedAt)),
});
reasoningId = null;
}
}
function closeStep(reasoningMs) {
if (!stepOpen)
return;
closeRuns(reasoningMs);
controller.enqueue({ type: 'finish-step' });
stepOpen = false;
}
/** Merge what this frame says about a call into what earlier frames said, and return it. */
function metadataFor(id, toolKind, parentId) {
const merged = { ...toolMetadata.get(id) };
if (toolKind !== undefined)
merged.toolKind = toolKind;
// A call's parent is fixed by the first frame that names one.
if (parentId !== undefined && merged.parentId === undefined)
merged.parentId = parentId;
if (Object.keys(merged).length === 0)
return undefined;
toolMetadata.set(id, merged);
return merged;
}
/** Forward a frame this transport has no dedicated mapping for as an AI SDK data part. */
function forwardAsData(event, transient) {
ensureStarted();
const { kind, ...data } = event;
controller.enqueue({
type: `data-${kind}`,
...(typeof data.id === 'string' ? { id: data.id } : {}),
data,
...(transient ? { transient: true } : {}),
});
}
function emit(event) {
switch (event.kind) {
case 'step-start':
openStep();
break;
case 'step-finish':
closeStep(event.reasoningMs);
// `costUsd` rides the step boundary once the backend reports it (older backends omit
// it entirely — `undefined`, never a crash). `null` means "priced provider/estimate
// unavailable", distinct from a real $0 turn — surfaced verbatim as message metadata
// (merged onto `message.metadata` by the AI SDK) so a UI can render running cost
// without polling `GET /quota`.
if (event.costUsd !== undefined) {
controller.enqueue({
type: 'message-metadata',
messageMetadata: { costUsd: event.costUsd },
});
}
break;
case 'text':
ensureStep();
if (textId === null) {
textId = textSeq === 0 ? `txt-${stepIndex}` : `txt-${stepIndex}.${textSeq}`;
textSeq += 1;
controller.enqueue({ type: 'text-start', id: textId });
}
controller.enqueue({ type: 'text-delta', id: textId, delta: event.text });
break;
case 'reasoning':
ensureStep();
if (reasoningId === null) {
reasoningId =
reasoningSeq === 0 ? `rsn-${stepIndex}` : `rsn-${stepIndex}.${reasoningSeq}`;
reasoningSeq += 1;
reasoningStartedAt = Date.now();
controller.enqueue({ type: 'reasoning-start', id: reasoningId });
}
controller.enqueue({ type: 'reasoning-delta', id: reasoningId, delta: event.text });
break;
case 'tool-input-start': {
ensureStep();
announced.add(event.id);
// `toolKind` is absent on older backends — omitted (not `undefined`-valued) so
// `toolMetadata` itself is only present when there's something to say, letting a
// UI gate approval affordances on `kind === 'action'` without hardcoding tool names.
// `parentId` nests the call under another one (see `TranscriptToolCall.children`).
const metadata = metadataFor(event.id, event.toolKind, event.parentId);
controller.enqueue({
type: 'tool-input-start',
toolCallId: event.id,
toolName: event.name,
...(metadata !== undefined ? { toolMetadata: metadata } : {}),
});
break;
}
case 'tool-input-delta':
ensureStep();
controller.enqueue({
type: 'tool-input-delta',
toolCallId: event.id,
inputTextDelta: event.delta,
});
break;
case 'tool-input-available': {
ensureStep();
announced.add(event.id);
const metadata = metadataFor(event.id, event.toolKind, event.parentId);
controller.enqueue({
type: 'tool-input-available',
toolCallId: event.id,
toolName: event.name,
input: event.input,
...(metadata !== undefined ? { toolMetadata: metadata } : {}),
});
break;
}
case 'elicitation':
ensureStep();
// An authored intake asks before the turn's first model call, so nothing announced
// the call this question set is parked under — open it here, carrying the request as
// the part's input, which is exactly what the store persists as the call's input and
// therefore what a reloaded thread replays. The model's own `ask` announced its call
// itself, and re-stating it would fire a consumer's `onToolCall` twice.
if (!announced.has(event.id)) {
announced.add(event.id);
controller.enqueue({
type: 'tool-input-available',
toolCallId: event.id,
toolName: ASK_TOOL_NAME,
input: {
...(event.request.preamble !== undefined
? { preamble: event.request.preamble }
: {}),
questions: event.request.questions,
},
});
}
break;
case 'tool-output':
ensureStep();
controller.enqueue({
type: 'tool-output-available',
toolCallId: event.id,
output: event.output,
});
break;
case 'tool-output-error':
ensureStep();
controller.enqueue({
type: 'tool-output-error',
toolCallId: event.id,
errorText: event.error,
});
break;
case 'tool-output-denied':
ensureStep();
// The SDK's own state for "a person said no": it keeps the part's input and metadata
// and only moves its state, so a card can say the decision was carried out instead of
// drawing the red treatment an error gets. The reason (when one was given) rides the
// persisted call, not this chunk — the SDK's shape carries no room for it.
controller.enqueue({ type: 'tool-output-denied', toolCallId: event.id });
break;
case 'approval-requested':
ensureStep();
// Who decides and until when rides a `data-approval-requested` part keyed by the call
// id — the SDK's approval chunk has no room for it, and restating the call's input
// chunk to widen its metadata would fire a consumer's `onToolCall` a second time.
forwardAsData(event, false);
// The SDK settles an approval against a part it already holds and throws — dropping
// the whole message — when there is none, so only a call this stream announced moves
// into the native state. The approval id IS the call id: the lib settles approvals
// by tool-call id, so a second identifier would only have to be mapped back.
if (announced.has(event.id)) {
controller.enqueue({
type: 'tool-approval-request',
approvalId: event.id,
toolCallId: event.id,
});
}
break;
case 'approval-settled':
ensureStep();
// Metadata about a call, like the request: the transcript folds it into that call's
// `approval`. The call's own state moves on its output frame, not on this one.
forwardAsData(event, false);
break;
case 'ui':
ensureStep();
// Positioned content: close the open prose so text written after the component
// renders after it instead of growing the paragraph above it.
closeRuns();
controller.enqueue({
type: 'data-ui',
id: event.id,
data: {
id: event.id,
component: event.component,
props: event.props,
...(event.version !== undefined ? { version: event.version } : {}),
...(event.toolCallId !== undefined ? { toolCallId: event.toolCallId } : {}),
// A preview of a layout the model is still writing: the final push (same id)
// replaces this part in place.
...(event.partial === true ? { partial: true } : {}),
},
});
break;
case 'message-metadata':
ensureStarted();
controller.enqueue({ type: 'message-metadata', messageMetadata: event.metadata });
break;
case 'title':
case 'cancelled':
// The thread's queue changed, or this run handed the thread to the next queued message
// (`started`). Thread-level, like the title.
case 'queue':
// Thread- and run-level facts, not message content: seen by `onData`, never stored
// as a part of the message.
forwardAsData(event, true);
break;
default:
// A kind this version does not know (a newer server, a runner that is not this
// library's loop). Forwarded rather than dropped, so a host can render it from
// `message.parts` / `onData` without waiting for a release that maps it.
// `proposal-decision` (an AG-UI text decision, re-framed) is the same transient
// `data-proposal-decision` part a native decision answers with.
forwardAsData(event, event.kind === 'proposal-decision');
break;
}
}
function finish() {
ensureStarted();
closeStep();
controller.enqueue({ type: 'finish' });
controller.close();
endAttempt();
}
function fail(errorText, start = true) {
if (start)
ensureStarted();
controller.enqueue({ type: 'error', errorText });
controller.close();
endAttempt();
}
/** The stream dropped (or ended without `done`): resume it, or report how it ended. */
async function recover(error) {
if (!canResume()) {
if (error === undefined) {
finish();
}
else {
fail(error instanceof Error ? error.message : 'Agent stream error', false);
}
return false;
}
const outcome = await resume();
if (outcome === 'resumed')
return true;
if (outcome === 'failed') {
fail('Lost the connection to the agent stream');
}
else {
// Gone: the run ended while we were away — what we have is all this stream will get,
// and the persisted thread holds the rest. Aborted: the user stopped the turn.
finish();
}
return false;
}
try {
while (true) {
let result;
try {
result = await reader.read();
}
catch (error) {
if (await recover(error))
continue;
return;
}
if (result.done) {
if (await recover(undefined))
continue;
return;
}
buffer += decoder.decode(result.value, { stream: true });
let separator = buffer.indexOf('\n\n');
while (separator !== -1) {
const rawEvent = buffer.slice(0, separator);
buffer = buffer.slice(separator + 2);
separator = buffer.indexOf('\n\n');
const frame = parseSseFrame(rawEvent);
if (frame.id !== undefined) {
// Already taken in before a reconnect — a server that ignored `after` resends it.
if (lastSeq !== undefined && frame.id <= lastSeq)
continue;
lastSeq = frame.id;
}
if (frame.event === 'done') {
finish();
return;
}
if (frame.event === 'error') {
const failure = parseErrorFrame(frame.data);
reportRunError(failure);
fail(failure.message);
return;
}
if (frame.event === 'meta') {
const meta = parseMeta(frame.data);
if (meta)
record(meta);
}
else if (frame.data) {
const event = parseEvent(frame.data);
if (event)
emit(event);
}
}
}
}
catch (error) {
fail(error instanceof Error ? error.message : 'Agent stream error', false);
}
},
cancel() {
cancelled = true;
// A stop aborts the request first, which errors the body: cancelling an errored stream
// rejects with that same AbortError, and nobody is left to hear it.
reader.cancel().catch(() => undefined);
endAttempt();
},
});
}
}
/** The chunk stream for a send the server queued: one transient `data-queue` part, then the end. */
function queuedChunkStream(queued) {
return new ReadableStream({
start(controller) {
controller.enqueue({
type: 'data-queue',
data: { queue: queued.queue, queuedSend: queued },
transient: true,
});
controller.close();
},
});
}
/** Split one `event:`/`data:` SSE block into its fields. */
function parseSseFrame(raw) {
let event;
let id;
const dataLines = [];
for (const line of raw.split('\n')) {
if (line.startsWith('event:')) {
event = line.slice('event:'.length).trim();
}
else if (line.startsWith('id:')) {
const parsed = Number(line.slice('id:'.length).trim());
if (Number.isSafeInteger(parsed) && parsed > 0)
id = parsed;
}
else if (line.startsWith('data:')) {
dataLines.push(line.slice('data:'.length).trimStart());
}
}
return {
event,
data: dataLines.length > 0 ? dataLines.join('\n') : undefined,
id,
};
}
function parseMeta(data) {
if (!data)
return null;
try {
const parsed = JSON.parse(data);
if (parsed !== null &&
typeof parsed === 'object' &&
'runId' in parsed &&
'threadId' in parsed &&
typeof parsed.runId === 'string' &&
typeof parsed.threadId === 'string') {
return { runId: parsed.runId, threadId: parsed.threadId };
}
}
catch {
/* malformed meta frame — ignore */
}
return null;
}
/** Read the backend's `event: error` frame (`{code,message}`); a malformed one is a bare failure. */
function parseErrorFrame(data) {
const fallback = { code: undefined, message: 'Agent run failed' };
if (!data)
return fallback;
try {
const parsed = JSON.parse(data);
if (parsed !== null && typeof parsed === 'object') {
const record = parsed;
return {
code: typeof record.code === 'string' && record.code !== '' ? record.code : undefined,
message: typeof record.message === 'string' && record.message !== ''
? record.message
: fallback.message,
};
}
}
catch {
/* malformed error frame — fall through to the default */
}
return fallback;
}
/** Parse a `data:` frame as an `AgentStreamEvent`. Returns null for anything without a `kind`. */
function parseEvent(data) {
try {
const parsed = JSON.parse(data);
if (parsed !== null && typeof parsed === 'object' && 'kind' in parsed) {
return parsed;
}
}
catch {
/* not an event frame — ignore */
}
return null;
}
/** Collect all text parts of a UI message into a single string. */
function extractText(message) {
let text = '';
for (const part of message.parts ?? []) {
if (part.type === 'text')
text += part.text;
}
return text;
}
/** The message's file parts that name a staged upload (`providerMetadata.agent.mediaId`). */
function stagedFiles(message) {
const files = [];
for (const part of message.parts ?? []) {
if (part.type !== 'file')
continue;
const mediaId = part.providerMetadata?.agent?.mediaId;
if (typeof mediaId !== 'string' || mediaId.length === 0)
continue;
files.push({
mediaId,
...(part.mediaType ? { contentType: part.mediaType } : {}),
...(part.filename ? { name: part.filename } : {}),
});
}
return files;
}
/** Per-request headers as the AI SDK hands them over, as a plain record (or nothing). */
function headerRecord(headers) {
if (headers === undefined)
return undefined;
return headers instanceof Headers ? Object.fromEntries(headers.entries()) : headers;
}
/** Wait `ms`, resolving early when `signal` aborts. */
function delay(ms, signal) {
if (ms <= 0 || signal?.aborted)
return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(done, ms);
function done() {
clearTimeout(timer);
signal?.removeEventListener('abort', done);
resolve();
}
signal?.addEventListener('abort', done, { once: true });
});
}
function proposalDecisionChunkStream(decision) {
return new ReadableStream({
start(controller) {
controller.enqueue({ type: 'start', messageId: `decision:${crypto.randomUUID()}` });
controller.enqueue({ type: 'data-proposal-decision', data: decision, transient: true });
if (decision.text !== undefined && decision.text.length > 0) {
controller.enqueue({ type: 'text-start', id: 'decision' });
controller.enqueue({ type: 'text-delta', id: 'decision', delta: decision.text });
controller.enqueue({ type: 'text-end', id: 'decision' });
}
controller.enqueue({ type: 'finish', finishReason: 'stop' });
controller.close();
},
});
}
//# sourceMappingURL=agent-chat-transport.js.map