121 lines
5.7 KiB
TypeScript
121 lines
5.7 KiB
TypeScript
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<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;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Pi 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.
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|