/** * Turn-based speaking manager for group channels. * * Rules: * - Humans (humanList) are never in the turn order * - Turn order is auto-populated from channel/server members minus humans * - currentSpeaker can be null (dormant state) * - When ALL agents in a cycle have NO_REPLY'd, state goes dormant (null) * - Dormant → any new message reactivates: * - If sender is NOT in turn order → current = first in list * - If sender IS in turn order → current = next after sender */ export type ChannelTurnState = { /** Ordered accountIds for this channel (auto-populated, shuffled) */ turnOrder: string[]; /** Current speaker accountId, or null if dormant */ currentSpeaker: string | null; /** Set of accountIds that have NO_REPLY'd this cycle */ noRepliedThisCycle: Set; /** Timestamp of last state change */ lastChangedAt: number; }; const channelTurns = new Map(); /** Turn timeout: if the current speaker hasn't responded, auto-advance */ const TURN_TIMEOUT_MS = 60_000; // --- helpers --- function shuffleArray(arr: T[]): T[] { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } // --- public API --- /** * Initialize or update the turn order for a channel. * Called with the list of bot accountIds (already filtered, humans excluded). */ export function initTurnOrder(channelId: string, botAccountIds: string[]): void { const existing = channelTurns.get(channelId); if (existing) { // Check if membership changed const oldSet = new Set(existing.turnOrder); const newSet = new Set(botAccountIds); const same = oldSet.size === newSet.size && [...oldSet].every(id => newSet.has(id)); if (same) return; // no change } channelTurns.set(channelId, { turnOrder: shuffleArray(botAccountIds), currentSpeaker: null, // start dormant noRepliedThisCycle: new Set(), lastChangedAt: Date.now(), }); } /** * Check if the given accountId is allowed to speak. */ export function checkTurn(channelId: string, accountId: string): { allowed: boolean; currentSpeaker: string | null; reason: string; } { const state = channelTurns.get(channelId); if (!state || state.turnOrder.length === 0) { return { allowed: true, currentSpeaker: null, reason: "no_turn_state" }; } // Not in turn order (human or unknown) → always allowed if (!state.turnOrder.includes(accountId)) { return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "not_in_turn_order" }; } // Dormant → not allowed (will be activated by onNewMessage) if (state.currentSpeaker === null) { return { allowed: false, currentSpeaker: null, reason: "dormant" }; } // Check timeout → auto-advance if (Date.now() - state.lastChangedAt > TURN_TIMEOUT_MS) { advanceTurn(channelId); // Re-check after advance const updated = channelTurns.get(channelId)!; if (updated.currentSpeaker === accountId) { return { allowed: true, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_self" }; } return { allowed: false, currentSpeaker: updated.currentSpeaker, reason: "timeout_advanced_to_other" }; } if (accountId === state.currentSpeaker) { return { allowed: true, currentSpeaker: state.currentSpeaker, reason: "is_current_speaker" }; } return { allowed: false, currentSpeaker: state.currentSpeaker, reason: "not_current_speaker" }; } /** * Called when a new message arrives in the channel. * Handles reactivation from dormant state and human-triggered resets. * * @param senderAccountId - the accountId of the message sender (could be human/bot/unknown) * @param isHuman - whether the sender is in the humanList */ export function onNewMessage(channelId: string, senderAccountId: string | undefined, isHuman: boolean): void { const state = channelTurns.get(channelId); if (!state || state.turnOrder.length === 0) return; if (isHuman) { // Human message: activate, start from first in order state.currentSpeaker = state.turnOrder[0]; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); return; } if (state.currentSpeaker !== null) { // Already active, no change needed from incoming message return; } // Dormant state + non-human message → reactivate if (senderAccountId && state.turnOrder.includes(senderAccountId)) { // Sender is in turn order → next after sender const idx = state.turnOrder.indexOf(senderAccountId); const nextIdx = (idx + 1) % state.turnOrder.length; state.currentSpeaker = state.turnOrder[nextIdx]; } else { // Sender not in turn order → start from first state.currentSpeaker = state.turnOrder[0]; } state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); } /** * Called when the current speaker finishes (end symbol detected) or says NO_REPLY. * @param wasNoReply - true if the speaker said NO_REPLY (empty/silent) * @returns the new currentSpeaker (or null if dormant) */ export function onSpeakerDone(channelId: string, accountId: string, wasNoReply: boolean): string | null { const state = channelTurns.get(channelId); if (!state) return null; if (state.currentSpeaker !== accountId) return state.currentSpeaker; // not current speaker, ignore if (wasNoReply) { state.noRepliedThisCycle.add(accountId); // Check if ALL agents have NO_REPLY'd this cycle const allNoReplied = state.turnOrder.every(id => state.noRepliedThisCycle.has(id)); if (allNoReplied) { // Go dormant state.currentSpeaker = null; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); return null; } } else { // Successful speech resets the cycle counter state.noRepliedThisCycle = new Set(); } return advanceTurn(channelId); } /** * Advance to next speaker in order. */ export function advanceTurn(channelId: string): string | null { const state = channelTurns.get(channelId); if (!state || state.turnOrder.length === 0) return null; if (state.currentSpeaker === null) return null; const idx = state.turnOrder.indexOf(state.currentSpeaker); const nextIdx = (idx + 1) % state.turnOrder.length; // Skip agents that already NO_REPLY'd this cycle let attempts = 0; let candidateIdx = nextIdx; while (state.noRepliedThisCycle.has(state.turnOrder[candidateIdx]) && attempts < state.turnOrder.length) { candidateIdx = (candidateIdx + 1) % state.turnOrder.length; attempts++; } if (attempts >= state.turnOrder.length) { // All have NO_REPLY'd state.currentSpeaker = null; state.lastChangedAt = Date.now(); return null; } state.currentSpeaker = state.turnOrder[candidateIdx]; state.lastChangedAt = Date.now(); return state.currentSpeaker; } /** * Force reset: go dormant. */ export function resetTurn(channelId: string): void { const state = channelTurns.get(channelId); if (state) { state.currentSpeaker = null; state.noRepliedThisCycle = new Set(); state.lastChangedAt = Date.now(); } } /** * Get debug info. */ export function getTurnDebugInfo(channelId: string): Record { const state = channelTurns.get(channelId); if (!state) return { channelId, hasTurnState: false }; return { channelId, hasTurnState: true, turnOrder: state.turnOrder, currentSpeaker: state.currentSpeaker, noRepliedThisCycle: [...state.noRepliedThisCycle], lastChangedAt: state.lastChangedAt, dormant: state.currentSpeaker === null, }; }