/** * Transport-agnostic resilient message stream. * * Generalizes the battle-tested finite-state machine that multiple chat channel * adapters had each hand-rolled: lazy first send, debounced interim edits, * overflow chunking, and a final delivery that classifies failures into a * recovery strategy (retry-with-backoff / recreate / reformat-plain / * not-modified / last-resort fresh send) with abort-aware sleeps and an * idempotent finish. * * A {@link ChannelTransport} supplies the channel API (post / edit / classify / * render-markdown) so this class never references a concrete chat platform. It * streams text as-is — there are no "answer" / "thinking" / "final-answer" * labels, and reasoning (assistant_thought) is never rendered as prose. */ import type { AgentMessageFinishOptions, AgentMessageStream as AgentMessageStreamBase, AgentReplyPart, AgentStreamEvent } from "./index.js"; /** Opaque handle to a posted message, returned by {@link ChannelTransport.post}. */ export interface MessageRef { readonly id: string; readonly [key: string]: unknown; } /** Semantic content carried by a confirmed channel write. */ export type ChannelMessageContentKind = "status" | "answer"; /** Whether a failed native call is known not to have landed or may have landed. */ export type ChannelFailureCertainty = "not_delivered" | "unknown"; /** Durable routing decision after the stream has exhausted its own recovery. */ export type ChannelDeliveryDisposition = "retryable" | "permanent" | "unknown"; /** How a failed post/edit should be handled by {@link ResilientMessageStream}. */ export type ChannelSendOutcome = { kind: "not_modified"; failureCertainty?: ChannelFailureCertainty; } | { kind: "recreate"; failureCertainty?: ChannelFailureCertainty; } | { kind: "reformat_plain"; failureCertainty?: ChannelFailureCertainty; } | { kind: "retry"; retryAfterMs?: number; failureCertainty?: ChannelFailureCertainty; } | { kind: "fatal"; failureCertainty?: ChannelFailureCertainty; }; /** * Abstracts a chat channel's API so the resilience FSM is transport-agnostic. * Implementations wrap a concrete chat channel API client. */ export interface ChannelTransport { /** Per-message character budget for this channel. */ readonly maxMessageChars: number; /** * This channel SILENTLY breaks an oversized message into several of its own * and reports only the last fragment, rather than rejecting the write. * * It changes where the budget is enforced. Normally the budget is applied to * the source text and an oversized render is caught by the channel, which * rejects it and lets {@link ChannelSendOutcome} `reformat_plain` retry in * plain text — keeping the answer in ONE message. A channel that splits * instead never rejects, so there is nothing to fall back from: the stream has * to fit the RENDERED text to the budget itself, accepting more chunks. * * Slack sets this. Telegram does not — it rejects an over-long message, so its * plain-text fallback is the better outcome. */ readonly splitsOversizedMessages?: boolean; /** * Post a new message and return a ref usable by {@link edit}. * * `continuesMessage` marks an overflow chunk of an answer whose head already * landed at that ref. A channel with threads should attach the chunk to that * message so one answer stays one card plus its thread — and so the head, not * the last fragment, is what a later reply threads off. Channels without * threads ignore it and post a sibling message. */ post(text: string, options: { markdown: boolean; contentKind?: ChannelMessageContentKind; continuesMessage?: MessageRef; }): Promise; /** * Edit a previously posted message in place. * * A transport that cannot edit in place — one whose final answer must become a * NEW message, replacing and deleting `ref` — returns the replacement's ref so * the stream stops treating `ref` as live. Returning nothing means the edit * landed on `ref` itself, which is the ordinary case. */ edit(ref: MessageRef, text: string, options: { markdown: boolean; contentKind?: ChannelMessageContentKind; }): Promise; /** Delete a transient message, when the channel supports it. Best-effort. */ delete?(ref: MessageRef): Promise; /** Classify a post/edit failure into a recovery strategy. */ classifyError(error: unknown): ChannelSendOutcome; /** Render markdown to the channel's wire format. Defaults to identity. */ renderMarkdown?(text: string): string; /** * Show a lightweight "working" affordance without posting a chat message — * e.g. a "typing…" activity indicator or a "seen" acknowledgement. Used in * `finalOnly` mode in place of interim message edits. Best-effort; the stream * swallows failures. */ indicateActivity?(): Promise; } export interface ResilientMessageStreamLogger { debug?(message: string, metadata?: Record): void; warn?(message: string, metadata?: Record): void; error?(message: string, metadata?: Record): void; } export interface ResilientMessageStreamOptions { transport: ChannelTransport; initialStatusText?: string; editDebounceMs?: number; /** Overrides `transport.maxMessageChars` when provided. */ maxMessageChars?: number; /** Maximum retries for a *final* delivery before giving up. Default 3. */ maxSendRetries?: number; /** Upper bound on any honored `retryAfterMs`/backoff wait, in ms. Default 60000. */ retryCapMs?: number; /** Base delay for exponential backoff between final-delivery retries. Default 500. */ retryBaseDelayMs?: number; /** * Show lightweight, friendly activity hints (e.g. "Searching the web…") while * the agent works, before any answer text has arrived. Default true. */ showHints?: boolean; /** Render the final answer with `transport.renderMarkdown`. Default true. */ formatMarkdown?: boolean; /** * Deliver answer text only at finish. When hints are enabled, tool starts use * one transient cumulative status message that the final answer replaces; * other activity uses `transport.indicateActivity()` (typing/seen). Default * false. */ finalOnly?: boolean; /** Aborts in-flight retry waits (e.g. on /cancel). */ abortSignal?: AbortSignal; /** Injectable sleep so tests need not wait on real timers. */ sleep?: (ms: number, signal?: AbortSignal) => Promise; logger?: ResilientMessageStreamLogger; } /** * Raised only when a *final* delivery cannot reach the channel after retries and * the last-resort fresh send. The AI request itself already succeeded, so a * caller should treat this as a degraded delivery — never as an agent failure. */ export declare class ChannelDeliveryError extends Error { readonly cause: unknown; readonly attempts: number; readonly disposition: ChannelDeliveryDisposition; constructor(message: string, details: { cause: unknown; attempts: number; disposition?: ChannelDeliveryDisposition; }); } export interface ResilientAgentMessageStream extends AgentMessageStreamBase { status(text: string): Promise; append(delta: string): Promise; replace(text: string): Promise; event(event: AgentStreamEvent): Promise; finish(finalText?: string, options?: AgentMessageFinishOptions): Promise; } export declare class ResilientMessageStream implements ResilientAgentMessageStream { private readonly transport; private readonly initialStatusText; private readonly editDebounceMs; private readonly maxMessageChars; private readonly maxSendRetries; private readonly retryCapMs; private readonly retryBaseDelayMs; private readonly showHints; private readonly formatMarkdown; private readonly finalOnly; private lastActivityIndicatedAt; private readonly abortSignal; private readonly sleepFn; private readonly logger; private currentText; private hasAnswerText; private statusText; private sentMessage; private sendMessagePromise; private editTimer; private inFlightEdit; private lastFlushedText; private lastFlushedMarkdown; private lastFlushedContentKind; private answerDeliveryAttempted; private readonly toolActivityEntries; /** Bounded replay guard for terminal groups even after their rendered row is evicted. */ private readonly terminalSubagentIds; private dismissPromise; private finished; constructor(options: ResilientMessageStreamOptions); status(text: string): Promise; append(delta: string): Promise; replace(text: string): Promise; event(event: AgentStreamEvent): Promise; finish(finalText?: string, options?: AgentMessageFinishOptions): Promise; /** * Split the final answer so every chunk fits the budget ON THE WIRE. * * The budget belongs to the rendered text, not the source: markdown rendering * can expand what is sent (Slack escapes `&`, `<`, and `>` to entities, so an * escape-heavy code block grows several times over). Splitting the source * alone would let a chunk that "fits" arrive oversized and re-enter exactly the * channel-side splitting this budget exists to prevent. * * Rendering is applied per chunk rather than up front because splitting already * rendered text would cut through the channel's own markup. So the source * budget is shrunk by the observed expansion ratio until the rendered chunks * fit. Expansion is close enough to linear in content that this converges in * one or two passes; the loop is bounded regardless, and a non-converging case * still ends up smaller than it started rather than unbounded. * * Only channels that SILENTLY split an oversized message need this — see * {@link ChannelTransport.splitsOversizedMessages}. A channel that rejects one * instead is better served by its own plain-text fallback, which keeps the * answer in a single message rather than fragmenting it. */ private splitFinalText; /** * The chunk that renders widest, when any exceeds the budget. `undefined` * means every chunk already fits on the wire. */ private worstRenderedChunk; /** Remove a confirmed status bubble without ever deleting an answer. */ dismissTransient(): Promise; private interimDisplayText; private render; /** * Surface a "working" affordance (typing/seen) via the transport, throttled so * frequent reasoning/tool events do not spam the channel. Best-effort: failures * are logged and swallowed so an indicator hiccup never affects the run. */ private maybeIndicateActivity; private ensureMessage; private scheduleEdit; private startInFlightEdit; /** * Send `sourceText` to the channel, classifying failures and recovering where * possible. Interim edits (`final: false`) are best-effort and never throw; * final delivery retries transient failures and throws ChannelDeliveryError * only when every path is exhausted. */ private deliverText; /** * The streamed message could not be edited or recreated in place. Post the * final answer as a brand-new plain message so the user still receives it. */ private lastResortSend; /** * Deliver every overflow chunk or fail the final delivery. Once the primary * chunk has landed a later failure is necessarily ambiguous to the caller; * silently accepting it would falsely acknowledge a truncated answer. * * A continuation is rendered exactly like the head chunk, including the * markdown→plain fallback. Posting it raw would show the tail of an ordinary * answer as unrendered source — invisible while only 40,000-character answers * overflowed, glaring now that a normal answer can. */ private sendOverflowChunk; private retryDelayMs; private sleep; private cancelScheduledEdit; private appendToolActivity; /** * Record one tool call a subagent made, under that subagent's own header. * The group is created on demand so activity still renders when the parent * `Agent` call was never observed (a truncated or replayed stream). */ private appendSubagentActivity; /** Find or open the ledger group for one subagent launch. */ private subagentGroup; private findSubagentGroup; /** Replace a subagent header once its outcome is known. */ private completeSubagentGroup; private rememberTerminalSubagent; /** * Drop the oldest rendered line whenever the ledger exceeds its bound. Groups * shed their own oldest child first so a long-running subagent never evicts * unrelated top-level activity, and an emptied group is removed with its * header. */ private enforceLedgerBound; private renderedLineCount; private renderToolActivity; /** * Move a confirmed final-only status behind the human follow-up that just * steered the run. Deletion is best-effort: an ambiguous/failed delete keeps * the existing reference so the cumulative ledger is edited in place and the * final answer remains deliverable. */ private relocateTransientForLiveInput; private performDismissTransient; private awaitInFlightEdit; private assertOpen; } /** * Visible fallback for destinations that do not implement native rich-part * delivery. Adapters with a safe upload path remove successfully delivered * parts before delegating to the resilient text stream. */ export declare function appendReplyPartFallback(text: string | undefined, parts: readonly AgentReplyPart[] | undefined, policy?: AgentMessageFinishOptions["unsupportedPartFallback"]): string | undefined; //# sourceMappingURL=resilient-message-stream.d.ts.map