generative-a11y
Core

createScheduler

Schedule prepared announcements with bounded queues and an injected clock.

Signature and cleanup

Most apps should use createRuntime. Use the scheduler directly only if your app already decides which events should produce announcements.

announcement-scheduler.ts
import { createScheduler, systemClock } from "@generative-a11y/core";

const scheduler = createScheduler({
  clock: systemClock,
  minimumGapMs: 750,
  dedupeWindowMs: 4_000,
  maxQueueSize: 64,
  onAnnouncement: (intent) => console.log(intent.text),
});

scheduler.schedule({
  channel: "polite",
  text: "The report is ready.",
  sourceType: "response.completed",
  scope: "response:report-1",
  capacityPriority: "content",
});

Use scope to cancel related pending output when a response, tool, run, or step ends. Under capacity pressure, response content can replace a status update. Call scheduler.dispose() when its owner ends.

SchedulerOptions

The scheduler requires explicit timing and capacity values; it does not apply a runtime preset.

Prop

Type

ScheduleAnnouncement

A candidate describes prepared user-facing output. It must not contain raw backend data.

Prop

Type

Scheduler methods

Dispose cancels the owned timer and pending queue.

Prop

Type

Delivery pacing

The scheduler keeps a single timer aimed at the next eligible candidate. An announcement becomes eligible at its dueAt time, which is the scheduling moment plus delayMs on the injected clock. Polite announcements additionally wait for minimumGapMs to elapse since the last successful delivery, so a burst of status updates cannot machine-gun a screen reader. Assertive announcements bypass the minimum gap: when an eligible assertive and an eligible polite candidate compete, the assertive one delivers first. The gap is only updated by successful deliveries; a suppressed duplicate does not reset pacing.

pacing.ts
import { createScheduler, systemClock } from "@generative-a11y/core";

const scheduler = createScheduler({
  clock: systemClock,
  minimumGapMs: 1000,
  dedupeWindowMs: 5_000,
  maxQueueSize: 32,
  onAnnouncement: (intent) => liveRegion.textContent = intent.text,
});

// Deliver soon, but no sooner than one second after the previous announcement.
const statusId = scheduler.schedule({
  channel: "polite",
  text: "Still working on your request.",
  sourceType: "response.started",
  responseId: "response:r1",
  capacityPriority: "status",
});

// Urgent content jumps ahead of the polite queue.
scheduler.schedule({
  channel: "assertive",
  text: "Your approval is required.",
  sourceType: "approval.requested",
  interactionId: "interaction:a1",
  capacityPriority: "content",
});

Deduplication

Each delivered announcement registers a dedupe key for dedupeWindowMs. When a queued candidate later becomes eligible with the same key, delivery is suppressed and a suppressed / duplicate diagnostic is recorded. The key combines the locale with either your explicit dedupeKey or a default built from the source type, an entity identity (responseId ?? toolId ?? interactionId ?? scope ?? "global"), the channel, and the text. Passing an explicit dedupeKey lets you collapse equivalent output that differs in incidental text, while the default keeps announcements from two different responses separate even when the wording matches.

Coalescing is the queue-time counterpart. If a candidate carries a coalesceKey that matches a pending item, the pending item is replaced in place: it keeps its ID and queue position but adopts the newest candidate's text and a fresh dueAt. A merged / coalesced diagnostic records the replacement. Use coalescing for progress updates where only the latest value matters.

Capacity and eviction

The queue is bounded by maxQueueSize, a positive integer validated at construction. When a new candidate arrives at a full queue, the scheduler chooses a victim rather than growing: if any polite-channel candidate is involved, only polite candidates are eligible for eviction, which shields assertive output. Among the pool, the scheduler drops the lowest capacityPriority (status before content) and then the oldest sequence. If the new candidate itself is the victim, schedule returns undefined and records a cancelled / queue-capacity diagnostic.

cancelScope(scope) removes every pending candidate tagged with that scope, for example when a response, tool, run, or step ends and its follow-up output is no longer relevant. cancelPurposes(purposes, reason?) removes only queued candidates whose purpose (defaulting to "notice") appears in the list, with the reason defaulting to "scope-cancelled". Both emit one diagnostic per cancelled candidate. Already delivered output cannot be retracted by either method.

Diagnostics

Attach onDiagnostic to observe scheduler decisions without affecting them. Every decision carries a disposition (queued, announced, merged, suppressed, cancelled), a reason (such as scheduled, coalesced, duplicate, queue-capacity, scope-cancelled, delivery-error, or runtime-disposed), a timestamp from the injected clock, and, when relevant, a content-free announcement projection plus the correlation IDs and timing fields of the item involved.

getDiagnosticSnapshot() returns a frozen projection of the current pending queue in due-time order. It is deliberately content-free: no text, dedupe keys, or coalesce keys are exposed, so it is safe to log or render.

Diagnostic delivery is best-effort and budgeted. The scheduler drains queued diagnostics with a budget of at least 16 entries (scaling with maxQueueSize); if diagnostics are produced faster than they drain, the excess is aggregated into a single suppressed / queue-capacity diagnostic with a count. An onDiagnostic listener that throws cannot alter scheduling: the error is swallowed.

Error semantics

Construction validates its timing and capacity values up front: minimumGapMs and dedupeWindowMs must be finite non-negative numbers, and maxQueueSize must be a positive integer; violations throw a RangeError. schedule throws a RangeError when delayMs is negative or non-finite, and silently ignores candidates with blank text.

Delivery failures are contained. If onAnnouncement throws, the failed announcement is suppressed with a delivery-error diagnostic and your onDeliveryError observer is notified; an observer that throws is swallowed so it cannot strand the remaining queue. A scheduler whose onAnnouncement always throws keeps its queue moving: every queued item is attempted once, in due order, regardless of earlier failures.

Testing with a manual clock

Pair the scheduler with ManualClock from @generative-a11y/core to test pacing deterministically. Advance the clock instead of waiting, and assert on the announcements your onAnnouncement listener collects.

scheduler.test.ts
import { createScheduler, ManualClock } from "@generative-a11y/core";

const clock = new ManualClock(0);
const announced: string[] = [];
const scheduler = createScheduler({
  clock,
  minimumGapMs: 1000,
  dedupeWindowMs: 5_000,
  maxQueueSize: 8,
  onAnnouncement: (intent) => announced.push(intent.text),
});

scheduler.schedule({
  channel: "polite",
  text: "First update.",
  sourceType: "response.started",
});
scheduler.schedule({
  channel: "polite",
  text: "Second update.",
  sourceType: "response.text.delta",
});

// Nothing delivered yet: the timer waits on the manual clock.
clock.advanceBy(1);
console.log(announced); // ["First update."]

clock.advanceBy(1000);
console.log(announced); // ["First update.", "Second update."]

scheduler.dispose();