2026-10-14 12:20:11 -07:00
import type { Message } from "./api" ;
2026-10-14 17:25:52 -07:00
import { parseAgentAttachment , type AgentAttachment , type AgentAttachmentCapability } from "./agent-attachments.ts" ;
2026-10-14 22:03:21 -07:00
import { agentPushCursor , agentPushSourceId , agentPushUuid } from "./agent-push.ts" ;
2026-10-25 10:15:35 -07:00
import { agentMessageTime , compareAgentMessages , normalizeAgentMessage , UNKNOWN_AGENT_TIME } from "./agent-messages.ts" ;
2026-10-14 12:20:11 -07:00
export type AgentActivity = {
state : "idle" | "thinking" | "tool" | "waiting" | "error" ;
isThinking : boolean ;
queued : number ;
backgroundWorkers : number ;
tools : Array < { id : string ; name : string ; status : string ; output? : string } > ;
queuedMessages : Message [];
wait ?: { due : number ; reason? : string };
error? : string ;
};
export type AgentMessagesPage = {
conversationId : string ; messages : Message []; cursor : string | null ;
beforeCursor : string | null ; hasMore : boolean ;
};
export type AgentDescriptor = {
id : string ; title : string ; model : string ; activity : AgentActivity ;
2026-10-20 14:14:09 -07:00
websocket : string ; protocol : string ; capabilities ?: { attachments? : AgentAttachmentCapability ; assistantImages ?: { version : 1 } };
2026-10-14 12:20:11 -07:00
};
2026-10-14 22:03:21 -07:00
export type AgentReadState = { cursor : string ; updatedAt : string | null ; sourceId? : string | null };
2026-10-14 12:20:11 -07:00
export type AgentRoomState = {
conversationId : string | null ; messages : Message []; cursor : string ;
readCursor : string ; activity : AgentActivity | null ;
2026-10-16 21:18:15 -07:00
historyLoaded : boolean ; beforeCursor : string | null ; hasOlder : boolean ;
2026-10-14 22:03:21 -07:00
/** undefined before the first read snapshot; null pins a legacy source-less session. */
sourceId? : string | null ;
2026-10-14 12:20:11 -07:00
};
export type AgentRoomEvent =
| ({ type : "messages" } & AgentMessagesPage )
| { type : "activity" ; conversationId : string ; activity : AgentActivity }
| { type : "ready" ; conversationId? : string }
2026-10-14 22:03:21 -07:00
| ({ type : "read_state" } & AgentReadState )
2026-10-14 12:20:11 -07:00
| { type : "pong" | "heartbeat" }
| { type : "error" ; error? : string ; message? : string };
const object = ( value : unknown ) : Record < string , unknown > | undefined => value !== null && typeof value === "object" && ! Array . isArray ( value )
? value as Record < string , unknown > : undefined ;
const defined = ( value : Record < string , unknown >) => Object . fromEntries ( Object . entries ( value ). filter (([, item ]) => item !== undefined ));
export function normalizeAgentCursor ( value : unknown ) : string {
if ( typeof value !== "string" || ! /^\d+$/ . test ( value )) throw new Error ( "Invalid agent message cursor." );
return BigInt ( value ). toString ();
}
export function advanceAgentCursor ( current : string , incoming : string ) : string {
const a = normalizeAgentCursor ( current ), b = normalizeAgentCursor ( incoming );
return BigInt ( a ) >= BigInt ( b ) ? a : b ;
}
function metadata ( message : Message ) { return object ( message . metadata ); }
function messageCursor ( message : Message ) : string | null {
const entryId = metadata ( message ) ? . entryId ;
if ( typeof entryId === "number" && Number . isSafeInteger ( entryId ) && entryId >= 0 ) return String ( entryId );
return typeof entryId === "string" && /^\d+$/ . test ( entryId ) ? normalizeAgentCursor ( entryId ) : null ;
}
function mergeMessage ( previous : Message , incoming : Message ) : Message {
const prior = metadata ( previous ), next = metadata ( incoming );
// A delayed activity snapshot must not demote an already placed user message.
if ( prior ? . status === "complete" && next ? . status === "queued" ) return previous ;
2026-10-25 10:15:35 -07:00
const priorTime = agentMessageTime ( previous ), nextTime = agentMessageTime ( incoming );
if ( prior ? . kind !== "tool_call" || next ? . kind !== "tool_call" ) return { ... previous , ... incoming ,
createdAt : nextTime ?? priorTime ?? UNKNOWN_AGENT_TIME };
2026-10-14 12:20:11 -07:00
const terminal = prior . status === "completed" || prior . status === "failed" ;
const staleStart = terminal && next . status === "initiated" ;
const merged = staleStart ? { ... defined ( next ), ... defined ( prior ) } : { ... defined ( prior ), ... defined ( next ) };
if ( ! next . startedAt && prior . startedAt ) merged . startedAt = prior . startedAt ;
const oldArgs = object ( prior . args ), newArgs = object ( next . args );
if ( oldArgs && ( ! newArgs || Object . keys ( newArgs ). length === 0 )) merged . args = oldArgs ;
return { ... previous , ... incoming , ...( staleStart ? { content : previous.content , name : previous.name } : {}),
2026-10-25 10:15:35 -07:00
createdAt : priorTime && nextTime ? ( priorTime < nextTime ? priorTime : nextTime ) : priorTime ?? nextTime ?? UNKNOWN_AGENT_TIME , metadata : merged };
2026-10-14 12:20:11 -07:00
}
/** Replays and separate tool-start/result pages update stable rows instead of duplicating them. */
export function mergeAgentMessages ( previous : readonly Message [], incoming : readonly Message []) : Message [] {
2026-10-25 10:15:35 -07:00
const messages = new Map ( previous . map ( normalizeAgentMessage ). map ( message => [ message . id , message ]));
for ( const message of incoming . map ( normalizeAgentMessage )) {
2026-10-14 12:20:11 -07:00
const prior = messages . get ( message . id );
messages . set ( message . id , prior ? mergeMessage ( prior , message ) : message );
}
2026-10-25 10:15:35 -07:00
return [... messages . values ()]. sort ( compareAgentMessages );
}
function parseAgentPage ( value : unknown ) : AgentMessagesPage {
const page = object ( value );
if ( ! page || typeof page . conversationId !== "string" || ! Array . isArray ( page . messages ) || typeof page . hasMore !== "boolean" ) throw new Error ( "Invalid agent messages frame." );
if ( page . cursor !== null ) normalizeAgentCursor ( page . cursor );
if ( page . beforeCursor != null ) normalizeAgentCursor ( page . beforeCursor );
return { ... page , beforeCursor : page.beforeCursor ?? null , messages : page.messages.map ( normalizeAgentMessage ) } as AgentMessagesPage ;
}
function parseAgentActivity ( value : unknown ) : AgentActivity {
const activity = object ( value );
if ( ! activity || ( activity . tools !== undefined && ! Array . isArray ( activity . tools ))
|| ( activity . queuedMessages !== undefined && ! Array . isArray ( activity . queuedMessages ))) throw new Error ( "Invalid agent activity frame." );
return { ... activity , tools : activity.tools ?? [],
queuedMessages : (( activity . queuedMessages ?? []) as unknown []). map ( normalizeAgentMessage ) } as AgentActivity ;
2026-10-14 12:20:11 -07:00
}
export const createAgentRoomState = () : AgentRoomState => ({
conversationId : null , messages : [], cursor : "0" , readCursor : "0" , activity : null ,
2026-10-16 21:18:15 -07:00
historyLoaded : false , beforeCursor : null , hasOlder : false ,
2026-10-14 12:20:11 -07:00
});
2026-10-14 22:03:21 -07:00
export class AgentReadSourceChangedError extends Error {
constructor () { super ( "Agent history changed. Reload the page before marking messages read." ); this . name = "AgentReadSourceChangedError" ; }
}
function readSource ( state : Pick < AgentRoomState , "sourceId" | "cursor" | "readCursor" | "messages" >, read : AgentReadState ) : string | null {
if ( read . sourceId != null && ! agentPushSourceId ( read . sourceId )) throw new AgentReadSourceChangedError ();
const incoming = read . sourceId ?? null ;
if ( state . sourceId !== undefined && state . sourceId !== incoming ) throw new AgentReadSourceChangedError ();
// A source cannot be attached retroactively to already rendered unknown history.
if ( state . sourceId === undefined && incoming !== null && ( state . cursor !== "0" || state . readCursor !== "0" || state . messages . length > 0 )) throw new AgentReadSourceChangedError ();
return incoming ;
}
2026-10-14 12:20:11 -07:00
export function applyAgentPage ( state : AgentRoomState , page : AgentMessagesPage ) : AgentRoomState {
2026-10-25 10:15:35 -07:00
page = parseAgentPage ( page );
2026-10-14 12:20:11 -07:00
if ( state . conversationId !== null && state . conversationId !== page . conversationId ) throw new Error ( "Agent conversation changed; reload the page." );
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
const cursor = page . cursor === null ? state.cursor : advanceAgentCursor ( state . cursor , page . cursor );
return { ... state , conversationId : page.conversationId , messages : mergeAgentMessages ( state . messages , page . messages ), cursor };
}
2026-10-16 21:18:15 -07:00
/** Recent/older windows have their own boundary; live replay never moves it. */
export function applyAgentHistoryPage ( state : AgentRoomState , page : AgentMessagesPage , before? : string ) : AgentRoomState {
// A retired or repeated older request cannot overwrite a newer window.
if ( before !== undefined && before !== state . beforeCursor ) return state ;
const boundary = page . beforeCursor === null ? null : normalizeAgentCursor ( page . beforeCursor );
const cursor = page . cursor === null ? null : normalizeAgentCursor ( page . cursor );
if ( page . hasMore && ( boundary === null || boundary === "0"
|| ( before !== undefined && BigInt ( boundary ) >= BigInt ( before )))) throw new Error ( "Agent older history did not advance its cursor." );
if ( before !== undefined && (( cursor !== null && BigInt ( cursor ) >= BigInt ( before ))
|| ( boundary !== null && BigInt ( boundary ) > BigInt ( before )))) throw new Error ( "Agent older history returned an invalid boundary." );
const next = applyAgentPage ( state , page );
return { ... next , historyLoaded : true , beforeCursor : boundary , hasOlder : page.hasMore ,
// Older snapshots must not replace newer socket edits or advance delivery/read state.
...( before === undefined ? {} : { cursor : state.cursor , messages : mergeAgentMessages ( page . messages , state . messages ) }),
};
}
export function agentUnreadCountIsPartial ( state : AgentRoomState ) : boolean {
return state . hasOlder && state . beforeCursor !== null && BigInt ( state . readCursor ) + 1 n < BigInt ( state . beforeCursor );
}
2026-10-14 12:20:11 -07:00
export function applyAgentReadState ( state : AgentRoomState , read : AgentReadState ) : AgentRoomState {
2026-10-14 22:03:21 -07:00
const sourceId = readSource ( state , read );
return { ... state , sourceId , readCursor : advanceAgentCursor ( state . readCursor , read . cursor ) };
2026-10-14 12:20:11 -07:00
}
export function applyAgentEvent ( state : AgentRoomState , event : AgentRoomEvent ) : AgentRoomState {
if ( event . type === "messages" ) return applyAgentPage ( state , event );
2026-10-14 22:03:21 -07:00
if ( event . type === "read_state" ) return applyAgentReadState ( state , event );
2026-10-14 12:20:11 -07:00
if ( event . type !== "activity" ) return state ;
if ( state . conversationId !== null && state . conversationId !== event . conversationId ) throw new Error ( "Agent activity belongs to a different conversation." );
2026-10-25 10:15:35 -07:00
return { ... state , conversationId : event.conversationId , activity : parseAgentActivity ( event . activity ) };
2026-10-14 12:20:11 -07:00
}
export function agentUnreadCount ( state : AgentRoomState ) : number {
return state . messages . filter ( message => {
if ( message . role !== "assistant" || metadata ( message ) ? . kind !== "message" ) return false ;
const cursor = messageCursor ( message );
return cursor !== null && BigInt ( cursor ) > BigInt ( state . readCursor );
}). length ;
}
export function agentDisplayMessages ( state : AgentRoomState ) : Message [] {
const committed = new Set ( state . messages . map ( message => message . id ));
return mergeAgentMessages ( state . messages , ( state . activity ? . queuedMessages || []). filter ( message => ! committed . has ( message . id )));
}
export function parseAgentRoomEvent ( raw : string ) : AgentRoomEvent | null {
const event = object ( JSON . parse ( raw ));
if ( ! event || typeof event . type !== "string" ) throw new Error ( "Invalid agent room frame." );
2026-10-14 22:03:21 -07:00
if ( ! [ "messages" , "activity" , "ready" , "pong" , "heartbeat" , "error" , "read_state" ]. includes ( event . type )) return null ;
if ( event . type === "read_state" && ( ! agentPushCursor ( event . cursor )
|| ( event . updatedAt !== null && typeof event . updatedAt !== "string" )
|| ( event . sourceId !== undefined && event . sourceId !== null && ! agentPushSourceId ( event . sourceId )))) throw new Error ( "Invalid Agent read frame." );
2026-10-14 12:20:11 -07:00
if ( event . type === "messages" ) {
2026-10-25 10:15:35 -07:00
return { ... parseAgentPage ( event ), type : "messages" };
}
if ( event . type === "activity" ) {
if ( typeof event . conversationId !== "string" ) throw new Error ( "Invalid agent activity frame." );
return { ... event , activity : parseAgentActivity ( event . activity ) } as AgentRoomEvent ;
2026-10-14 12:20:11 -07:00
}
return event as AgentRoomEvent ;
}
2026-10-14 22:03:21 -07:00
export function agentAuthFrame ( token : string | null , cursor : string , presenceSessionId? : string ) : string {
return JSON . stringify ({ type : "auth" , token , after : normalizeAgentCursor ( cursor ),
2026-10-14 23:06:34 -07:00
clientKind : "web" , ...( agentPushUuid ( presenceSessionId ) ? { presenceSessionId } : {}) });
2026-10-14 12:20:11 -07:00
}
2026-10-14 17:25:52 -07:00
export type AgentSubmission = { id : number ; status : "queued" | "placed" | "done" | "unanswered" ; messageId : string ; attachmentIds? : string [] };
2026-10-14 12:20:11 -07:00
export type AgentRoomClientOptions = { baseUrl? : string ; token ?: () => string | null ; fetch? : typeof globalThis . fetch };
export class AgentRoomClient {
private readonly baseUrl : string ;
private readonly fetcher : typeof globalThis . fetch ;
private readonly token : () => string | null ;
2026-10-20 14:14:09 -07:00
private activeDownloads = 0 ;
private readonly downloadWaiters = new Set < () => void > ();
2026-10-14 12:20:11 -07:00
constructor ( options : AgentRoomClientOptions = {}) {
this . baseUrl = ( options . baseUrl || "/api" ). replace ( /\/+$/ , "" );
this . fetcher = ( options . fetch || globalThis . fetch ). bind ( globalThis );
this . token = options . token || (() => null );
}
private async request < T >( path : string , init : RequestInit = {}) : Promise < T > {
const headers = new Headers ( init . headers );
const token = this . token ();
if ( token ) headers . set ( "Authorization" , `Bearer ${ token } ` );
2026-10-14 17:25:52 -07:00
if ( init . body !== undefined && ! ( init . body instanceof FormData )) headers . set ( "Content-Type" , "application/json" );
2026-10-14 12:20:11 -07:00
const response = await this . fetcher ( ` ${ this . baseUrl } /v1/agent ${ path } ` , { ... init , headers });
let value : unknown ;
try { value = await response . json (); }
2026-10-14 22:03:21 -07:00
catch { throw Object . assign ( new Error ( `Agent API returned ${ response . status } without a JSON response.` ), { status : response.status }); }
2026-10-14 12:20:11 -07:00
if ( ! response . ok ) {
const error = object ( value );
2026-10-14 22:03:21 -07:00
throw Object . assign ( new Error ( typeof error ? . message === "string" ? error.message : typeof error ? . error === "string" ? error . error : `Agent API returned ${ response . status } .` ), { status : response.status });
2026-10-14 12:20:11 -07:00
}
return value as T ;
}
2026-10-25 10:15:35 -07:00
async descriptor ( signal? : AbortSignal ) : Promise < AgentDescriptor > {
const value = object ( await this . request ( "" , { signal }));
if ( ! value || typeof value . id !== "string" ) throw new Error ( "Invalid agent descriptor." );
return { ... value , activity : parseAgentActivity ( value . activity ) } as AgentDescriptor ;
2026-10-14 12:20:11 -07:00
}
2026-10-25 10:15:35 -07:00
async messages ( after : string , signal? : AbortSignal ) : Promise < AgentMessagesPage > {
return parseAgentPage ( await this . request ( `/messages? ${ new URLSearchParams ({ after : normalizeAgentCursor ( after ), limit : "200" } )}` , { signal }));
}
async history ( before? : string , signal? : AbortSignal ) : Promise < AgentMessagesPage > {
return parseAgentPage ( await this . request ( `/messages? ${ new URLSearchParams ({ ...( before === undefined ? { } : { before: normalizeAgentCursor(before) }), limit: "100" })}` , { signal }));
2026-10-16 21:18:15 -07:00
}
2026-10-14 12:20:11 -07:00
readState ( signal? : AbortSignal ) : Promise < AgentReadState > { return this . request ( "/read-state" , { signal }); }
2026-10-14 22:03:21 -07:00
markRead ( cursor : string , signal? : AbortSignal , sourceId? : string | null ) : Promise < AgentReadState > {
return this . request ( "/read-state" , { method : "PUT" , body : JSON.stringify ({ cursor : normalizeAgentCursor ( cursor ), ...( agentPushSourceId ( sourceId ) ? { sourceId } : {}) }), signal });
2026-10-14 12:20:11 -07:00
}
2026-10-14 17:25:52 -07:00
async submit ( content : string , requestId : string , attachments : AgentAttachment [] = []) : Promise < AgentSubmission > {
const receipt = object ( await this . request < unknown >( "/messages" , { method : "POST" , body : JSON.stringify ({ content , requestId , ...( attachments . length ? { attachments : attachments.map (({ id }) => ({ id })) } : {}) }) }));
2026-10-14 12:20:11 -07:00
// An HTTP success alone does not acknowledge delivery. Keep the original
// outbox receipt key retryable if a proxy or upstream returns malformed data.
if ( ! receipt || typeof receipt . id !== "number" || ! Number . isSafeInteger ( receipt . id ) || receipt . id <= 0
|| typeof receipt . status !== "string" || ! [ "queued" , "placed" , "done" , "unanswered" ]. includes ( receipt . status )
2026-10-14 17:25:52 -07:00
|| receipt . messageId !== `inbound:sybil: ${ requestId } `
|| ( attachments . length && JSON . stringify ( receipt . attachmentIds ) !== JSON . stringify ( attachments . map ( file => file . id )))) throw new Error ( "Agent API returned an invalid message receipt. Retry to confirm delivery." );
2026-10-14 12:20:11 -07:00
return receipt as AgentSubmission ;
}
2026-10-14 17:25:52 -07:00
async upload ( file : File , uploadId : string , signal? : AbortSignal ) : Promise < AgentAttachment > {
const body = new FormData (); body . append ( "uploadId" , uploadId ); body . append ( "file" , file , file . name );
const value = await this . request < { attachment : unknown } > ( "/attachments" , { method : "POST" , body , signal });
return parseAgentAttachment ( value . attachment );
}
async download ( attachment : AgentAttachment , signal? : AbortSignal ) : Promise < Blob > {
const file = parseAgentAttachment ( attachment ), headers = new Headers (), token = this . token ();
2026-10-20 14:14:09 -07:00
signal ? . throwIfAborted ();
while ( this . activeDownloads >= 2 ) {
await new Promise < void >(( resolve , reject ) => {
const cleanup = () => { this . downloadWaiters . delete ( wake ); signal ? . removeEventListener ( "abort" , abort ); };
const wake = () => { cleanup (); resolve (); };
const abort = () => { cleanup (); reject ( signal ? . reason || new Error ( "Download canceled." )); };
this . downloadWaiters . add ( wake ); signal ? . addEventListener ( "abort" , abort , { once : true });
if ( signal ? . aborted ) abort ();
});
signal ? . throwIfAborted ();
}
this . activeDownloads ++ ;
try {
if ( token !== this . token ()) throw new Error ( "Authentication changed. Retry the download." );
if ( token ) headers . set ( "Authorization" , `Bearer ${ token } ` );
const response = await this . fetcher ( ` ${ this . baseUrl } /v1/agent/attachments/ ${ encodeURIComponent ( file . id ) } /content` , { headers , signal , cache : "no-store" , redirect : "error" });
if ( ! response . ok ) { await response . body ? . cancel (); throw new Error ( "Attachment download failed. Retry the download." ); }
const reader = response . body ? . getReader ();
if ( ! reader ) throw new Error ( "Attachment download was incomplete." );
const bytes = new Uint8Array ( file . sizeBytes ); let size = 0 ;
try {
for (;;) {
const { done , value } = await reader . read (); if ( done ) break ;
signal ? . throwIfAborted ();
if ( size + value . length > bytes . length ) throw new Error ( "Attachment download exceeds its size limit." );
bytes . set ( value , size ); size += value . length ;
}
} finally { await reader . cancel (). catch (() => {}); reader . releaseLock (); }
if ( size !== file . sizeBytes ) throw new Error ( "Attachment download was incomplete." );
const sha256 = [... new Uint8Array ( await crypto . subtle . digest ( "SHA-256" , bytes ))]. map ( n => n . toString ( 16 ). padStart ( 2 , "0" )). join ( "" );
if ( sha256 !== file . sha256 ) throw new Error ( "Attachment checksum did not match." );
if ( file . kind === "image" && response . headers . get ( "content-type" ) ? . split ( ";" )[ 0 ]. trim () !== file . mimeType )
throw new Error ( "Attachment image type did not match." );
signal ? . throwIfAborted ();
if ( token !== this . token ()) throw new Error ( "Authentication changed. Retry the download." );
return new Blob ([ bytes ], { type : response . headers . get ( "content-type" ) || "application/octet-stream" });
} finally {
this . activeDownloads -- ;
this . downloadWaiters . values (). next (). value ? .();
}
2026-10-14 17:25:52 -07:00
}
2026-10-14 12:20:11 -07:00
websocketUrl ( pageUrl : string ) : string {
const url = new URL ( ` ${ this . baseUrl } /v1/agent/ws` , pageUrl );
url . protocol = url . protocol === "https:" ? "wss:" : "ws:" ;
return url . toString ();
}
}
/** Read every newer page, including pages containing only invisible internal entries. */
export async function syncAgentHistory ( client : Pick < AgentRoomClient , "messages" >, from : string ,
apply : ( page : AgentMessagesPage ) => void , signal? : AbortSignal ) : Promise < void > {
let cursor = normalizeAgentCursor ( from );
while ( ! signal ? . aborted ) {
const page = await client . messages ( cursor , signal );
if ( signal ? . aborted ) return ;
const next = normalizeAgentCursor ( page . cursor ?? cursor );
if ( BigInt ( next ) < BigInt ( cursor ) || ( page . hasMore && next === cursor )) throw new Error ( "Agent history did not advance its cursor." );
apply ( page );
cursor = next ;
if ( ! page . hasMore ) return ;
}
}
// Retained only to migrate receipt IDs from the former admission sender.
export const AGENT_ADMISSION_STORAGE_KEY = "sybil.agent.pending-admissions.v1" ;
/** Only successfully persisted, explicitly observed cursors become read state. */
export function createAgentReadWriter ( options : {
current : () => string ; save : ( cursor : string ) => Promise < AgentReadState >; apply : ( read : AgentReadState ) => void ;
}) {
let desired = "0" ;
2026-10-14 22:03:21 -07:00
let stopped = false ;
2026-10-14 12:20:11 -07:00
let running : Promise < void > | undefined ;
2026-10-14 22:03:21 -07:00
const cancel = () => { stopped = true ; desired = "0" ; };
2026-10-14 12:20:11 -07:00
const flush = () : Promise < void > => {
2026-10-14 22:03:21 -07:00
if ( stopped ) return Promise . resolve ();
2026-10-14 12:20:11 -07:00
if ( running ) return running ;
running = ( async () => {
2026-10-14 22:03:21 -07:00
while ( ! stopped && BigInt ( desired ) > BigInt ( options . current ())) {
2026-10-14 12:20:11 -07:00
const captured = desired ;
const saved = await options . save ( captured );
2026-10-14 22:03:21 -07:00
if ( stopped ) return ;
try {
if ( BigInt ( normalizeAgentCursor ( saved . cursor )) < BigInt ( captured )) throw new Error ( "Agent read state did not acknowledge the seen messages." );
options . apply ( saved );
// A rejected/source-mismatched acknowledgment must never become a PUT loop.
if ( BigInt ( options . current ()) < BigInt ( captured )) throw new Error ( "Agent read state did not acknowledge the seen messages." );
} catch ( error ) { cancel (); throw error ; }
2026-10-14 12:20:11 -07:00
}
})(). finally (() => { running = undefined ; });
return running ;
};
2026-10-14 22:03:21 -07:00
return { request ( cursor : string ) { if ( ! stopped ) desired = advanceAgentCursor ( desired , cursor ); return flush (); }, flush , cancel };
}
/** Pin reads to the source that owns this rendered room, including queued writes. */
export function createAgentReadSession ( options : {
initial : AgentRoomState ;
save : ( cursor : string , sourceId : string | null ) => Promise < AgentReadState >;
apply : ( read : AgentReadState ) => void ;
invalidated ?: ( error : AgentReadSourceChangedError ) => void ;
}) {
let binding = options . initial . sourceId , acknowledged = options . initial . readCursor ;
let failure : AgentReadSourceChangedError | undefined ;
const invalidate = () => {
if ( ! failure ) { failure = new AgentReadSourceChangedError (); writer . cancel (); options . invalidated ? .( failure ); }
return failure ;
};
const receive = ( read : AgentReadState ) => {
if ( failure ) throw failure ;
try {
const nextSource = readSource ({ ... options . initial , sourceId : binding , readCursor : acknowledged }, read );
const nextCursor = advanceAgentCursor ( acknowledged , read . cursor );
binding = nextSource ; acknowledged = nextCursor ;
options . apply ({ ... read , sourceId : binding });
} catch ( error ) { if ( error instanceof AgentReadSourceChangedError ) throw invalidate (); throw error ; }
};
const writer = createAgentReadWriter ({
current : () => acknowledged ,
save : async cursor => {
if ( failure || binding === undefined ) throw invalidate ();
const source = binding ;
try { return await options . save ( cursor , source ); }
catch ( error ) {
if ( error instanceof AgentReadSourceChangedError || object ( error ) ? . status === 409 ) throw invalidate ();
throw error ;
}
},
apply : receive ,
});
return {
receive ,
request ( cursor : string , renderedSourceId : string | null | undefined ) {
if ( failure ) return Promise . reject ( failure );
if ( binding === undefined || renderedSourceId !== binding ) return Promise . reject ( invalidate ());
return writer . request ( cursor );
},
flush : writer.flush ,
cancel : writer.cancel ,
sourceId : () => binding ,
readCursor : () => acknowledged ,
};
2026-10-14 12:20:11 -07:00
}