import type { AgentRoomEvent, AgentRoomState } from './agent-room'; import type { Message } from './api'; type Scheduler = { now(): number; schedule(callback: () => void, delay: number): unknown; cancel(handle: unknown): void; }; export type AgentRoomPresenterOptions = { initial: AgentRoomState; reduceEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState; /** Publish room and transcript typing together in one UI state update. */ publish(state: AgentRoomState, isTyping: boolean): void; batchMs?: number; typingDelayMs?: number; clock?: Scheduler; }; const quietTools = new Set(['user_message', 'notify_parent', 'clock_wait', 'event_decision']); const data = (message: Message): Record => message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata) ? message.metadata as Record : {}; const entryId = (message: Message): bigint | undefined => { const value = data(message).entryId; return (typeof value === 'string' && /^\d+$/.test(value)) || (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) ? BigInt(value) : undefined; }; const toolId = (message: Message) => typeof data(message).toolCallId === 'string' ? String(data(message).toolCallId) : message.id; /** * Sybil Agent sends messages and activity as separate frames. This adapter publishes them * as one room snapshot and latches transcript typing off when a new public reply * arrives. An unchanged busy frame cannot restore the old turn's indicator. * Actual activity remains untouched for the sidebar, workers, and queue display. */ export function createAgentRoomPresenter(options: AgentRoomPresenterOptions) { const clock: Scheduler = options.clock || { now: () => Date.now(), schedule: (callback, delay) => setTimeout(callback, delay), cancel: handle => clearTimeout(handle as ReturnType), }; const batchMs = options.batchMs ?? 100; const typingDelayMs = options.typingDelayMs ?? 250; let state = options.initial; let stopped = false; let batchTimer: unknown; let typingTimer: unknown; let suppressTyping = false; let candidateSince: number | undefined; const seenTools = new Set(state.messages.filter(message => message.role === 'tool').map(toolId)); for (const tool of state.activity?.tools || []) seenTools.add(tool.id); const reconcile = () => { const work = Boolean(state.activity?.isThinking || (state.activity?.backgroundWorkers || 0) > 0); if (!work || suppressTyping) candidateSince = undefined; else if (candidateSince === undefined) candidateSince = clock.now(); }; const clearTypingTimer = () => { if (typingTimer !== undefined) clock.cancel(typingTimer); typingTimer = undefined; }; const flush = () => { if (stopped) return; if (batchTimer !== undefined) clock.cancel(batchTimer); batchTimer = undefined; clearTypingTimer(); reconcile(); const isTyping = candidateSince !== undefined && clock.now() - candidateSince >= typingDelayMs; options.publish(state, isTyping); if (candidateSince !== undefined && !isTyping) typingTimer = clock.schedule(flush, Math.max(0, typingDelayMs - (clock.now() - candidateSince))); }; const schedule = () => { reconcile(); // A fixed maximum batch window avoids starving publication during bursts. if (batchTimer === undefined) batchTimer = clock.schedule(flush, batchMs); }; const release = () => { suppressTyping = false; }; const latch = () => { suppressTyping = true; candidateSince = undefined; clearTypingTimer(); }; return { /** replay:true is for initial historical loading, not ongoing catch-up. */ push(event: AgentRoomEvent, hints: { replay?: boolean } = {}) { if (stopped) return; const before = state; const next = options.reduceEvent(before, event); // Validate/reduce before touching presentation state. state = next; if (event.type === 'messages') { const existing = new Set(before.messages.map(message => message.id)); const fresh = event.messages.filter(message => !existing.has(message.id) && (entryId(message) ?? -1n) > BigInt(before.cursor)) .sort((left, right) => (entryId(left) || 0n) < (entryId(right) || 0n) ? -1 : 1); if (!hints.replay) for (const message of fresh) { const info = data(message); if (message.role === 'assistant' && info.kind === 'message' && message.content.trim()) latch(); else if (suppressTyping && message.role === 'user') release(); else if (suppressTyping && message.role === 'tool' && info.status === 'initiated' && !quietTools.has(String(info.toolName || message.name)) && !seenTools.has(toolId(message))) release(); if (message.role === 'tool') seenTools.add(toolId(message)); } for (const message of event.messages) if (message.role === 'tool') seenTools.add(toolId(message)); } else if (event.type === 'activity') { const activity = event.activity; if (suppressTyping && (!activity.isThinking || activity.tools.some(tool => !seenTools.has(tool.id) && !quietTools.has(tool.name)))) release(); for (const tool of activity.tools) seenTools.add(tool.id); } schedule(); }, /** Read-position updates share the pending snapshot; they never lose queued frames. */ update(change: (state: AgentRoomState) => AgentRoomState) { if (stopped) return; state = change(state); schedule(); }, flush, dispose() { stopped = true; if (batchTimer !== undefined) clock.cancel(batchTimer); batchTimer = undefined; clearTypingTimer(); // Unpublished input is deliberately discarded; reconnect replays from the // last published cursor, so neither delivery nor read tracking gets ahead. }, }; }