Buffers rapid direct-message bursts per conversation, aggregates them into one chat endpoint request, handles messages that arrive while the chat endpoint is processing, and interrupts unsent outbound replies when the user sends a new message during delivery.
Conversation state lives behind the ConversationStore interface. In
production, RedisConversationStore stores JSON records with
CONVERSATION_TTL_SECONDS; locally and in tests, InMemoryConversationStore
provides the same behavior without Redis durability.
The state machine uses four states:
idle- no active buffered turn or outbound queue.buffering- inbound messages are collecting behind a debounce timer.processing- the buffer is being sent to the chat endpoint.sending- outbound messages are being delivered one at a time.
When the first direct inbound arrives in idle, the conversation transitions to
buffering and schedules a timer. Additional inbounds during buffering append
to inboundBuffer and reset the timer with an exponential delay:
BUFFER_BASE_TIMEOUT_MS * BUFFER_GROWTH_FACTOR ^ (messageCount - 1)
The delay is capped by BUFFER_MAX_TIMEOUT_MS and can include bounded jitter
from BUFFER_NOISE_MAX_DEVIATION. Redis deployments schedule the timer through
BullMQ. In-memory deployments use setTimeout.
When the timer fires, the buffer moves to processing. The chat request keeps a
top-level backward-compatible message field by joining buffered text with:
\n---\n
It also sends structured messages[] with per-message Sendblue metadata, plus
conversation, identity, typing, and sendblue context objects (see
conversation-state.md for the full request shape).
The conversation key is one record across iMessage, RCS, SMS, and downgraded
states — buffer state is preserved across channel transitions in a single
direct conversation record.
If a new inbound arrives during processing, it is stored in lateArrivals.
After the current chat call returns, the agent re-enters buffering, merges
late arrivals into the next buffer, and retries processing up to
MAX_REPROCESS_ATTEMPTS. Within that limit, this prevents stale replies from
being sent when a user adds context during response generation.
If a new inbound arrives during sending, unsent queued replies are cancelled,
their content is truncated to CANCELLED_MESSAGE_MAX_LENGTH, and the new
inbound starts a fresh buffer. The current outbound message cannot be recalled
after it has already been sent to Sendblue, but remaining queued messages are
not sent.
| File | Role |
|---|---|
src/conversation/agent.ts |
State transitions, late arrivals, interruptions |
src/conversation/buffering.ts |
Buffer delay calculation and truncation helper |
src/conversation/chat-request.ts |
Aggregation and chat request assembly |
src/conversation/scheduler.ts |
BullMQ and in-memory buffer timers |
src/conversation/store.ts |
In-memory conversation/dedupe/outbound mapping store |
src/conversation/redis-store.ts |
Redis-backed store |
src/conversation/types.ts |
Conversation record and direct-key types |
REDIS_URLCONVERSATION_TTL_SECONDSDEDUPE_TTL_SECONDSBUFFER_BASE_TIMEOUT_MSBUFFER_GROWTH_FACTORBUFFER_MAX_TIMEOUT_MSBUFFER_NOISE_MAX_DEVIATIONMAX_REPROCESS_ATTEMPTSCANCELLED_MESSAGE_MAX_LENGTHBUFFER_QUEUE_NAME
- Cancelled outbound messages are currently stored in state but are not yet forwarded to the chat endpoint as explicit interruption context.
- Late arrivals beyond
MAX_REPROCESS_ATTEMPTSare currently discarded when the queued response begins sending; the implementation does not yet model a separate deferred turn queue. - BullMQ timer concurrency is currently fixed in code.
- In-memory timers and stores cannot coordinate horizontally scaled instances.