novo agents

Reconnect a dropped stream

A stream ends for two very different reasons, and they look almost identical on the wire:

  1. The run finished: you received a data-novo-terminal chunk, then the stream closed.
  2. The connection dropped (the user closed the tab, the network blipped, the serverless function hit its timeout) but the run is still executing on Novo.

The trap: a long run routinely hits case 2. Hosts cap a streaming function (Vercel's default is ~300s / 5 min), so the server closes the SSE response while the run keeps going. If your loop treats any stream end as "done," it stops at the cut, never sees the terminal chunk, and the run looks stuck at status: "running" even though it completes and bills server-side. Decide on the terminal chunk, not on the stream ending.

The easy path

streamToCompletion() drains the run to its terminal for you, reconnecting under the hood as many times as the run needs, and returns the final text, status, and usage:

import { NovoAgents } from 'novoagents';

const novo = new NovoAgents();

const stream = await novo.threads.stream(threadId, { input });
const result = await stream.streamToCompletion(); // auto-reconnects to terminal
console.log(result.status, result.text, result.usage.costCents);

Use this whenever you just want the answer and don't need to render chunks as they arrive.

For browser applications, createNovoHttpRunSource plus createNovoRunController owns the same reconnect-to-terminal rule while adding AbortSignal cancellation, generation fencing, schema checks, and separate accepted/durable cursors:

import {
  createNovoHttpRunSource,
  createNovoRunController,
} from 'novoagents/browser';

const controller = createNovoRunController({
  source: createNovoHttpRunSource({
    url: `/api/novo-runs/${runId}/stream`,
    pollUrl: `/api/novo-runs/${runId}`,
  }),
});

await controller.start();

Use stop() to detach locally. Configure and call cancelRun() when the user intends to cancel the server run; local abort never invents a cancelled run status.

The streaming pattern

If you're rendering chunks live, drive the loop off the terminal chunk and reconnect whenever the stream ends without one, whether it ended cleanly (timeout EOF) or threw (network drop):

let lastEventId: string | undefined;
let stream = await novo.threads.stream(threadId, { input });

while (true) {
  try {
    for await (const event of stream) {
      // …handle event
      lastEventId = stream.lastEventId ?? lastEventId;
    }
  } catch (err) {
    // A dropped connection throws; a function-timeout EOF does not. Both are
    // handled the same way below: reconnect unless we actually finished.
    if (!isTransient(err)) throw err;
  }
  if (stream.reachedTerminal) break; // saw data-novo-terminal, genuinely done
  stream = await stream.reconnect({ lastEventId }); // resume after the cut
}

stream.reachedTerminal flips to true only once a data-novo-terminal chunk has been observed. A clean EOF with reachedTerminal === false means the run is still going, so reconnect. This is the one change from a naive single-for await loop, and it's the difference between capturing the result and silently losing it.

How it works

The SDK sends Last-Event-Id: <id> (the standard SSE header) when you reconnect. The cursor is the SSE frame id, separate from any id field inside the JSON event payload. Novo assigns deterministic event IDs after public stream projection and repair, so the server replays exactly the chunks after your checkpoint, in the same order. Resume is exclusive (resume-after): the frame whose id you send is not re-sent; the next frame you receive has a strictly higher id. IDs are monotonic; do not assume the next id is id + 1.

If you call stream.reconnect() without passing lastEventId, the SDK uses stream.lastEventId. If there is no last event ID yet, you get the full stream from the beginning.

Reconnecting from a different process

stream.reconnect() requires a RunStream because it has the run ID. runs.stream(runId) opens a replay stream; to resume from a specific event ID in a process that was not holding the original stream, chain reconnect and close the unconsumed replay stream:

const replay = await novo.runs.stream(runId);
const stream = await replay.reconnect({ lastEventId });
replay.close();

for await (const event of stream) { … }

Idempotent handlers

Because the server replays chunks deterministically, your stream handler must be safe to re-run for the same chunk ID. If you side-effect on a chunk (write to a DB, call a downstream API), guard by chunk ID:

for await (const event of stream) {
  const eventId = stream.lastEventId;
  if (eventId && (await alreadyProcessed(eventId))) continue;
  await handle(event);
  if (eventId) await markProcessed(eventId);
}

Reconnecting through a pause

If the run is paused at waiting_for_approval or waiting_for_input when you reconnect, you'll see the pause chunk replay first (data-novo-input-requested, with request.kind of 'approval_request' or 'question'). The run is genuinely paused; no model time is being billed. Resolve the request and the stream resumes.

What "transient" means

Reconnect whenever the stream ends without a data-novo-terminal chunk. That includes the case with no error at all: a function-timeout EOF closes the stream cleanly, so your for await finishes normally; the if (stream.reachedTerminal) break check is what catches it. On top of that, reconnect on a thrown:

  • Network errors / dropped connections.
  • NovoTimeoutError (408/504).
  • NovoServerError (5xx).

Do not retry on:

  • NovoNotFoundError: the run no longer exists.
  • NovoConflictError from a non-stream mutation, such as cancelling a run that has already terminated.
  • NovoAuthenticationError / NovoPermissionError: config problem.