Files
Sybil-2/web/src/lib/agent-room-presentation.ts
T

126 lines
6.0 KiB
TypeScript
Raw Normal View History

import type { AgentRoomEvent, AgentRoomState } from './agent-room';
import type { Message } from './api';
/** Select transcript rows only; keep tool entries in room state for replay and activity. */
export function agentTranscriptMessages(messages: readonly Message[]): Message[] {
return messages.filter(message => message.role !== 'system' && message.role !== 'tool');
}
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<string, unknown> => message.metadata && typeof message.metadata === 'object'
&& !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {};
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<typeof setTimeout>),
};
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.
},
};
}