diff --git a/lib/project-loop.js b/lib/project-loop.js index d9822c2e..4dc8feaa 100644 --- a/lib/project-loop.js +++ b/lib/project-loop.js @@ -1441,6 +1441,10 @@ function attachLoop(ctx) { for (var di = 0; di < sessionIds.length; di++) { sm.deleteSessionQuiet(sessionIds[di]); } + // lr-0827ba (PEACHES follow-up): notify clients of exactly which + // sessions were deleted so per-session client state (rate-limit-state.js) + // can be pruned instead of leaking a background timer forever. + if (sessionIds.length > 0) sm.broadcastSessionDeleted(sessionIds); loopRegistry.remove(loopIdToDel); sm.broadcastSessionList(); return true; diff --git a/lib/project.js b/lib/project.js index fe9a2a58..ef9306f5 100644 --- a/lib/project.js +++ b/lib/project.js @@ -689,6 +689,7 @@ function createProjectContext(opts) { text: text, resetsAt: sendsAt, scheduledAt: Date.now(), + localId: session.localId, }; sm.sendAndRecord(session, schedEntry); session.scheduledMessage = { @@ -698,7 +699,7 @@ function createProjectContext(opts) { session.scheduledMessage = null; if (session.destroying) return; console.log("[project] Scheduled message firing for session " + session.localId); - sm.sendAndRecord(session, { type: "scheduled_message_sent" }); + sm.sendAndRecord(session, { type: "scheduled_message_sent", localId: session.localId }); var schedUserMsg = { type: "user_message", text: text, _ts: Date.now() }; // lr-2ea2a7: routes through the shared history-cap helper (see grep-guard test). sm.recordHistoryEntry(session, schedUserMsg, true); @@ -719,7 +720,7 @@ function createProjectContext(opts) { clearTimeout(session.scheduledMessage.timer); session.scheduledMessage = null; session.rateLimitAutoContinuePending = false; - sm.sendAndRecord(session, { type: "scheduled_message_cancelled" }); + sm.sendAndRecord(session, { type: "scheduled_message_cancelled", localId: session.localId }); } } diff --git a/lib/public/modules/app-messages.js b/lib/public/modules/app-messages.js index 2f9578d7..22a171fc 100644 --- a/lib/public/modules/app-messages.js +++ b/lib/public/modules/app-messages.js @@ -44,7 +44,7 @@ import { updateProjectList, resetClientState, showUpdateAvailable, handleRemoveP import { updateHistorySentinel, prependOlderHistory } from './app-header.js'; import { hideHomeHub, handleHubSchedules, handleHubRecentSessions } from './app-home-hub.js'; import { openDm, enterDmMode, exitDmMode, appendDmMessage, showDmTypingIndicator } from './app-dm.js'; -import { handleRateLimitEvent, updateRateLimitUsage, addScheduledMessageBubble, removeScheduledMessageBubble, handleFastModeState } from './app-rate-limit.js'; +import { handleRateLimitEvent, updateRateLimitUsage, addScheduledMessageBubble, removeScheduledMessageBubble, clearScheduledMessage, handleFastModeState, restoreRateLimitStateForSession, forgetSessionRateLimitState } from './app-rate-limit.js'; import { handleRemoteCursorMove, handleRemoteCursorLeave, handleRemoteSelection, clearRemoteCursors } from './app-cursors.js'; import { showLoopBanner, updateLoopBanner, updateLoopInputVisibility, updateLoopPendingBanner, showRalphApprovalBar, updateRalphApprovalStatus, openRalphPreviewModal, showExecModal, updateExecModalStatus } from './app-loop-ui.js'; import { handleSkillInstallWs } from './app-skills-install.js'; @@ -435,6 +435,18 @@ export function processMessage(msg) { if (!store.get('dmMode')) handleInputSync(msg.text); break; + case "session_deleted": + // lr-0827ba (PEACHES follow-up): a session was actually deleted (not + // merely switched away from) — prune its per-session rate-limit / + // scheduled-message arming state, canceling any background reset + // timer, instead of leaking it forever. session_list alone never + // told the client WHICH id(s) disappeared, so nothing could target + // this cleanup before this message existed. + (msg.ids || []).forEach(function (deletedId) { + forgetSessionRateLimitState(deletedId); + }); + break; + case "session_list": renderSessionList(msg.sessions || []); handlePaletteSessionSwitch(); @@ -558,6 +570,11 @@ export function processMessage(msg) { // Session presence is now tracked server-side (user-presence.json) clearRemoteCursors(); resetClientState(); + // lr-0827ba: redraw the incoming session's own armed rate-limit / + // scheduled-message state (arming happens per-session in the + // background regardless of focus — see rate-limit-state.js) instead + // of relying on chat-history replay to happen to repaint it. + restoreRateLimitStateForSession(store.get('activeSessionId')); updateLoopInputVisibility(msg.loop); // Restore input area visibility (may have been hidden by auth_required) @@ -964,19 +981,23 @@ export function processMessage(msg) { break; case "scheduled_message_queued": - addScheduledMessageBubble(msg.text, msg.resetsAt); - setScheduleBtnDisabled(true); + // lr-0827ba: msg.localId identifies the session this schedule + // actually belongs to — may not be the currently focused session. + addScheduledMessageBubble(msg.text, msg.resetsAt, msg.localId); + if (msg.localId == null || msg.localId === store.get('activeSessionId')) setScheduleBtnDisabled(true); break; case "scheduled_message_sent": - removeScheduledMessageBubble(); - setScheduleBtnDisabled(false); - setStatus("processing"); + clearScheduledMessage(msg.localId); + if (msg.localId == null || msg.localId === store.get('activeSessionId')) { + setScheduleBtnDisabled(false); + setStatus("processing"); + } break; case "scheduled_message_cancelled": - removeScheduledMessageBubble(); - setScheduleBtnDisabled(false); + clearScheduledMessage(msg.localId); + if (msg.localId == null || msg.localId === store.get('activeSessionId')) setScheduleBtnDisabled(false); break; case "auto_continue_scheduled": diff --git a/lib/public/modules/app-rate-limit.js b/lib/public/modules/app-rate-limit.js index ccdf6ee8..64d88c0f 100644 --- a/lib/public/modules/app-rate-limit.js +++ b/lib/public/modules/app-rate-limit.js @@ -6,22 +6,37 @@ import { store } from './store.js'; import { getWs } from './ws-ref.js'; import { addToMessages, scrollToBottom } from './app-rendering.js'; import { userAvatarUrl } from './avatar.js'; -import { setScheduleDelayMs, clearScheduleDelay } from './input.js'; +import { clearScheduleDelay, setScheduleDelayForSession, clearScheduleDelayForSession, restoreScheduleDelayUi } from './input.js'; import { isSonnetModel, isOpusModel } from './settings-defaults.js'; +import { + getRateLimitResetsAt, setRateLimitResetsAt, + getRateLimitResetTimer, setRateLimitResetTimer, + getRateLimitResetState, + setScheduledMsg, clearScheduledMsg, getScheduledMsg, + clearSession as clearSessionRateLimitState, +} from './rate-limit-state.js'; // --- Module-owned state --- +// lr-0827ba: rateLimitResetsAt / rateLimitResetTimer / rateLimitResetState / +// the scheduled-message bubble text are now owned per-session by +// rate-limit-state.js (see imports above) — arming one project's rate-limit +// auto-schedule no longer clobbers another project's armed state. The +// variables below remain module-scoped because they are references to the +// single visible DOM element/timer for whichever session is currently +// focused; they get rebuilt/rebound whenever the focused session changes. var rateLimitCountdownTimer = null; var rateLimitIndicatorEl = null; -var rateLimitResetsAt = null; -var rateLimitResetTimer = null; var rateLimitUsageEl = null; -var rateLimitResetState = {}; var rateLimitTickTimer = null; var _rateLimitUsageLastHtml = null; // guard against redundant innerHTML + refreshIcons() var scheduledMsgEl = null; var scheduledCountdownTimer = null; var fastModeIndicatorEl = null; +function currentSessionId() { + return store.get('activeSessionId'); +} + // --- Internal helpers --- function getVendorUsageMeta(vendor) { @@ -158,11 +173,12 @@ function tickRateLimitUsage() { if (!rateLimitUsageEl) return; var parts = []; var types = ["five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"]; + var resetState = getRateLimitResetState(currentSessionId()); for (var i = 0; i < types.length; i++) { - var entry = rateLimitResetState[types[i]]; + var entry = resetState[types[i]]; if (!entry || !entry.resetsAt) continue; var timeStr = formatResetTime(entry.resetsAt); - if (!timeStr) { delete rateLimitResetState[types[i]]; continue; } + if (!timeStr) { delete resetState[types[i]]; continue; } parts.push(rateLimitTypeShortLabel(types[i]) + " resets " + timeStr); } var newHtml; @@ -201,18 +217,31 @@ export function handleRateLimitEvent(msg) { var typeLabel = rateLimitTypeLabel(msg.rateLimitType); var popoverText = ""; + // lr-0827ba: the event's own session (server-stamped localId) is the one + // that gets its schedule armed, NOT necessarily the currently-focused + // session — a rate limit hit in a background session must still arm that + // session's auto-schedule and keep its reset timer running, exactly like + // server-side session.scheduledMessage already does. Fall back to the + // active session for events from servers/replays that predate the localId + // stamp (older recorded history entries). + var eventSessionId = msg.localId != null ? msg.localId : currentSessionId(); + var isActiveSession = eventSessionId === currentSessionId(); + if (isRejected && msg.resetsAt) { // Check if already expired (history replay) — skip popover if (msg.resetsAt < Date.now()) { - updateRateLimitIndicator(msg); + if (isActiveSession) updateRateLimitIndicator(msg); return; } - popoverText = typeLabel + " limit exceeded"; - updateRateLimitIndicator(msg); - startRateLimitCountdown(null, msg.resetsAt, null); - // Track rate limit reset time - rateLimitResetsAt = msg.resetsAt; - if (rateLimitResetTimer) clearTimeout(rateLimitResetTimer); + if (isActiveSession) { + popoverText = typeLabel + " limit exceeded"; + updateRateLimitIndicator(msg); + startRateLimitCountdown(null, msg.resetsAt, null); + } + // Track rate limit reset time for the event's own session. + setRateLimitResetsAt(eventSessionId, msg.resetsAt); + var existingTimer = getRateLimitResetTimer(eventSessionId); + if (existingTimer) clearTimeout(existingTimer); // Auto-switch input to schedule mode: any message typed will be queued for after reset. // Model-aware: a seven_day_sonnet rejection should not schedule when the user is on Opus // (and vice-versa), since those messages would send fine against the other model's quota. @@ -235,20 +264,21 @@ export function handleRateLimitEvent(msg) { } } if (shouldSchedule) { - setScheduleDelayMs(delayUntilReset + 60000); // +1min buffer after reset + setScheduleDelayForSession(eventSessionId, delayUntilReset + 60000); // +1min buffer after reset } } // Only arm the reset timer when we actually entered schedule mode; otherwise we'd // clear unrelated scheduled state on reset. if (shouldSchedule) { - rateLimitResetTimer = setTimeout(function () { - rateLimitResetsAt = null; - rateLimitResetTimer = null; - // Clear schedule mode when rate limit resets - clearScheduleDelay(); - }, msg.resetsAt - Date.now() + 1000); + setRateLimitResetTimer(eventSessionId, setTimeout(function () { + setRateLimitResetsAt(eventSessionId, null); + setRateLimitResetTimer(eventSessionId, null); + // Clear schedule mode when rate limit resets — for the event's own + // session, whether or not it's the one currently focused. + clearScheduleDelayForSession(eventSessionId); + }, msg.resetsAt - Date.now() + 1000)); } - } else { + } else if (isActiveSession) { var pct = msg.utilization ? Math.round(msg.utilization * 100) : null; popoverText = typeLabel + " warning" + (pct ? " (" + pct + "% used)" : ""); updateRateLimitIndicator(msg); @@ -258,9 +288,19 @@ export function handleRateLimitEvent(msg) { } export function updateRateLimitUsage(msg) { + // lr-0827ba: rate_limit_usage is a project-wide broadcast (account-wide + // usage, not session content), but only repaint the top-bar widget when + // the event belongs to the session currently in view, so a background + // session's usage tick doesn't visibly flicker the widget for whatever + // session the user is actually looking at. Reset-time bookkeeping itself + // is still tracked per-session (via rate-limit-state.js) so switching back + // into that session redraws with its own latest data. + var eventSessionId = msg.localId != null ? msg.localId : currentSessionId(); + var resetState = getRateLimitResetState(eventSessionId); if (msg.rateLimitType && msg.resetsAt) { - rateLimitResetState[msg.rateLimitType] = { resetsAt: msg.resetsAt, status: msg.status }; + resetState[msg.rateLimitType] = { resetsAt: msg.resetsAt, status: msg.status }; } + if (eventSessionId !== currentSessionId()) return; var topBarActions = document.querySelector("#top-bar .top-bar-actions"); if (!topBarActions) return; @@ -279,7 +319,7 @@ export function updateRateLimitUsage(msg) { var parts = []; var types = ["five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"]; for (var i = 0; i < types.length; i++) { - var entry = rateLimitResetState[types[i]]; + var entry = resetState[types[i]]; if (!entry || !entry.resetsAt) continue; var timeStr = formatResetTime(entry.resetsAt); if (!timeStr) continue; @@ -313,7 +353,17 @@ export function updateRateLimitUsage(msg) { } } -export function addScheduledMessageBubble(text, resetsAt) { +// lr-0827ba: msg carries the server-stamped localId of the session the +// scheduled message actually belongs to. Persist the queued text/resetsAt +// for that session regardless of focus (so switching back in later can +// redraw it), but only render the visible bubble into the DOM when the +// event's session is the one currently in view — otherwise a background +// project's scheduled message would render into whatever session's message +// list happens to be on screen. +export function addScheduledMessageBubble(text, resetsAt, sessionId) { + var targetSessionId = sessionId != null ? sessionId : currentSessionId(); + setScheduledMsg(targetSessionId, text, resetsAt); + if (targetSessionId !== currentSessionId()) return; removeScheduledMessageBubble(); var isChannel = document.body.classList.contains("wide-view"); var wrap = document.createElement("div"); @@ -475,6 +525,10 @@ export function addScheduledMessageBubble(text, resetsAt) { scheduledCountdownTimer = setInterval(updateCountdown, 1000); } +// Clears the visible DOM bubble/countdown only — does not touch the +// per-session persisted state. Used internally when re-rendering the bubble +// for the same session (addScheduledMessageBubble) and by +// clearScheduledMessage() below. export function removeScheduledMessageBubble() { if (scheduledMsgEl) { scheduledMsgEl.remove(); @@ -486,6 +540,16 @@ export function removeScheduledMessageBubble() { } } +// lr-0827ba: clears both the persisted per-session scheduled-message state +// and (if that session is currently focused) the visible bubble. sessionId +// defaults to the currently active session for existing call sites that +// don't yet carry a server-stamped localId (older history entries). +export function clearScheduledMessage(sessionId) { + var targetSessionId = sessionId != null ? sessionId : currentSessionId(); + clearScheduledMsg(targetSessionId); + if (targetSessionId === currentSessionId()) removeScheduledMessageBubble(); +} + export function handleFastModeState(state) { var statusArea = document.querySelector(".title-bar-content .status"); if (!statusArea) return; @@ -515,15 +579,52 @@ export function handleFastModeState(state) { export function getScheduledMsgEl() { return scheduledMsgEl; } +// lr-0827ba: this used to also null out the bare module-scoped +// rateLimitResetsAt/rateLimitResetTimer/rateLimitResetState variables — +// which meant switching projects silently canceled the OUTGOING session's +// rate-limit reset timer and armed schedule, exactly the bug this task +// fixes. Reset timers and armed state are per-session now (rate-limit-state.js) +// and must keep running in the background regardless of focus, mirroring +// server-side session.scheduledMessage. This function now only tears down +// the visible DOM/UI-timer for whichever session was previously focused — +// it does not touch any session's persisted arming state. Callers switching +// into a different session must call restoreRateLimitStateForSession() for +// the new session afterward to redraw its own state (see app-messages.js +// session_switched handler). export function resetRateLimitState() { clearRateLimitIndicator(); if (rateLimitCountdownTimer) { clearInterval(rateLimitCountdownTimer); rateLimitCountdownTimer = null; } if (rateLimitTickTimer) { clearInterval(rateLimitTickTimer); rateLimitTickTimer = null; } - if (rateLimitResetTimer) { clearTimeout(rateLimitResetTimer); rateLimitResetTimer = null; } - rateLimitResetsAt = null; - rateLimitResetState = {}; // Remove the usage link element entirely so it is rebuilt fresh for the new // project (correct vendor href, correct reset times). if (rateLimitUsageEl) { rateLimitUsageEl.remove(); rateLimitUsageEl = null; } if (fastModeIndicatorEl) { fastModeIndicatorEl.remove(); fastModeIndicatorEl = null; } + removeScheduledMessageBubble(); +} + +// Called when a session is actually destroyed/removed (not merely +// unfocused) so its background reset timer is canceled and its entry is +// dropped instead of leaking forever. Wired from app-messages.js's +// session_deleted handler (server-stamped ids from lib/sessions.js' +// broadcastSessionDeleted, lr-0827ba PEACHES follow-up). +export function forgetSessionRateLimitState(sessionId) { + clearSessionRateLimitState(sessionId); +} + +// lr-0827ba: "redraw on switch-in" — restores the newly-focused session's +// already-armed indicator/schedule-button/scheduled-message-bubble state +// instead of relying on the unreliable chat-history-replay side effect that +// happened to repaint these before. Call after resetRateLimitState() once +// the new session is focused (store.activeSessionId already updated). +export function restoreRateLimitStateForSession(sessionId) { + restoreScheduleDelayUi(); + var resetsAt = getRateLimitResetsAt(sessionId); + if (resetsAt && resetsAt > Date.now()) { + updateRateLimitIndicator({ status: "rejected", resetsAt: resetsAt }); + startRateLimitCountdown(null, resetsAt, null); + } + var queued = getScheduledMsg(sessionId); + if (queued && queued.resetsAt > Date.now()) { + addScheduledMessageBubble(queued.text, queued.resetsAt, sessionId); + } } diff --git a/lib/public/modules/input.js b/lib/public/modules/input.js index 12263c0c..01fec669 100644 --- a/lib/public/modules/input.js +++ b/lib/public/modules/input.js @@ -5,6 +5,7 @@ import { checkForMention, showMentionMenu, hideMentionMenu, isMentionMenuVisible import { initAtAgents, showAgentMenu, hideAgentMenu, isAgentMenuActive, agentMenuKeydown, requestProjectAgents } from './at-agents.js'; import { store } from './store.js'; import { sendWsQuiet } from './ws-ref.js'; +import { getScheduleDelayMs, setScheduleDelayMs as setSessionScheduleDelayMs } from './rate-limit-state.js'; var ctx; @@ -17,10 +18,18 @@ var slashActiveIdx = -1; var slashFiltered = []; var isComposing = false; var isRemoteInput = false; -var scheduleDelayMs = 0; // 0 = no schedule, >0 = delay in ms +// lr-0827ba: schedule-delay arming is per-session state, owned by +// rate-limit-state.js and keyed by the currently active session (not a bare +// module-scoped variable — that was the root cause of arming in one project +// silently overwriting another project's armed state, since the SPA never +// reloads the page on project switch). var _setScheduleDelayFn = null; // set by initInput var slashMenuBound = false; +function currentSessionId() { + return store.get('activeSessionId'); +} + export function hasSendableContent() { return !!( (ctx && ctx.inputEl && ctx.inputEl.value.trim()) || @@ -30,18 +39,39 @@ export function hasSendableContent() { ); } -export function getScheduleDelay() { - return scheduleDelayMs; +// Returns the armed schedule delay for the currently active session (0 = no +// schedule armed). Pass an explicit sessionId to check a session other than +// the active one (e.g. redrawing on switch-in). +export function getScheduleDelay(sessionId) { + return getScheduleDelayMs(sessionId === undefined ? currentSessionId() : sessionId); } +// Arms (or disarms, with ms=0) the schedule delay for a specific session. +// Only touches the visible schedule button when sessionId is the currently +// focused session — arming a background session (e.g. a rate-limit hit in a +// project the user isn't looking at) must not repaint the UI for whatever +// session IS focused (lr-0827ba). +export function setScheduleDelayForSession(sessionId, ms) { + setSessionScheduleDelayMs(sessionId, ms); + if (sessionId === currentSessionId() && _setScheduleDelayFn) _setScheduleDelayFn(ms); +} + +// Arms (or disarms, with ms=0) the schedule delay for the currently active +// session and updates the visible button state. export function setScheduleDelayMs(ms) { - scheduleDelayMs = ms; - // Trigger visual update if initInput has been called - if (_setScheduleDelayFn) _setScheduleDelayFn(ms); + setScheduleDelayForSession(currentSessionId(), ms); } -export function clearScheduleDelay() { - scheduleDelayMs = 0; +// Redraws the schedule button to reflect a session's already-armed delay +// without re-triggering any arm/disarm side effects — used when switching +// back into a session that has a background-armed schedule (lr-0827ba). +export function restoreScheduleDelayUi() { + if (_setScheduleDelayFn) _setScheduleDelayFn(getScheduleDelay()); +} + +export function clearScheduleDelayForSession(sessionId) { + setSessionScheduleDelayMs(sessionId, 0); + if (sessionId !== currentSessionId()) return; var btn = document.getElementById("schedule-btn"); if (btn) { btn.classList.remove("schedule-active", "schedule-expanded"); @@ -53,6 +83,10 @@ export function clearScheduleDelay() { } } +export function clearScheduleDelay() { + clearScheduleDelayForSession(currentSessionId()); +} + export function setScheduleBtnDisabled(disabled) { var btn = document.getElementById("schedule-btn"); if (!btn) return; @@ -224,8 +258,9 @@ export function sendMessage() { } // Scheduled message: queue message with timer delay - if (scheduleDelayMs > 0) { - var resetsAt = Date.now() + scheduleDelayMs; + var _armedDelayMs = getScheduleDelay(); + if (_armedDelayMs > 0) { + var resetsAt = Date.now() + _armedDelayMs; ctx.ws.send(JSON.stringify({ type: "schedule_message", text: text || "", resetsAt: resetsAt })); clearScheduleDelay(); ctx.inputEl.value = ""; @@ -870,7 +905,7 @@ export function initInput(_ctx) { } function setScheduleDelay(ms) { - scheduleDelayMs = ms; + setSessionScheduleDelayMs(currentSessionId(), ms); if (!scheduleBtn) return; collapseScheduleBtn(); if (ms > 0) { @@ -938,7 +973,7 @@ export function initInput(_ctx) { if (scheduleBtn) { scheduleBtn.addEventListener("click", function (e) { e.stopPropagation(); - if (scheduleDelayMs > 0) { + if (getScheduleDelayMs(currentSessionId()) > 0) { setScheduleDelay(0); return; } diff --git a/lib/public/modules/rate-limit-state.js b/lib/public/modules/rate-limit-state.js new file mode 100644 index 00000000..f49fa3be --- /dev/null +++ b/lib/public/modules/rate-limit-state.js @@ -0,0 +1,133 @@ +// rate-limit-state.js - Per-session rate-limit / schedule-message arming state. +// +// lr-0827ba: this state was previously a handful of bare module-scoped +// variables in input.js and app-rate-limit.js (scheduleDelayMs, +// rateLimitResetsAt, rateLimitResetTimer, rateLimitResetState, scheduled +// message bubble text/resetsAt). Because the SPA does not reload the page +// when the user switches projects, all sessions across all projects shared +// that single set of variables — arming a schedule in a second project +// silently clobbered the first project's armed state. +// +// This module replaces those bare variables with a small state map keyed by +// session localId (the same id the server uses as its session-map key, see +// lib/sessions.js and the `id` field on `session_switched`), mirroring the +// existing sessionDrafts precedent in app-messages.js/app.js. Each session's +// entry keeps running in the background (including its reset setTimeout) +// even while a different session is focused, mirroring server-side behavior +// (session.scheduledMessage in lib/project.js is already correctly +// per-session). +// +// DOM-free by design so it can be imported and unit-tested directly under +// plain Node (see test/rate-limit-state.test.js), following the same +// DOM-free-sibling-module convention as sticky-notes-fmt.js. + +var _bySession = Object.create(null); + +function emptyEntry() { + return { + // Auto-schedule arming (input.js sendMessage() consumes this). + scheduleDelayMs: 0, + // Rate-limit-driven auto-schedule bookkeeping (app-rate-limit.js). + rateLimitResetsAt: null, + rateLimitResetTimer: null, + rateLimitResetState: {}, + // Scheduled-message bubble (queued user message awaiting send). + scheduledMsgText: null, + scheduledMsgResetsAt: null, + }; +} + +function getEntry(sessionId) { + if (sessionId == null) return emptyEntry(); + if (!_bySession[sessionId]) _bySession[sessionId] = emptyEntry(); + return _bySession[sessionId]; +} + +// --- Schedule delay (auto-send arming) --- + +export function getScheduleDelayMs(sessionId) { + return getEntry(sessionId).scheduleDelayMs; +} + +export function setScheduleDelayMs(sessionId, ms) { + if (sessionId == null) return; + getEntry(sessionId).scheduleDelayMs = ms; +} + +// --- Rate-limit reset bookkeeping --- + +export function getRateLimitResetsAt(sessionId) { + return getEntry(sessionId).rateLimitResetsAt; +} + +export function setRateLimitResetsAt(sessionId, resetsAt) { + if (sessionId == null) return; + getEntry(sessionId).rateLimitResetsAt = resetsAt; +} + +export function getRateLimitResetTimer(sessionId) { + return getEntry(sessionId).rateLimitResetTimer; +} + +export function setRateLimitResetTimer(sessionId, timer) { + if (sessionId == null) return; + getEntry(sessionId).rateLimitResetTimer = timer; +} + +export function getRateLimitResetState(sessionId) { + return getEntry(sessionId).rateLimitResetState; +} + +// --- Scheduled-message bubble --- + +export function getScheduledMsg(sessionId) { + var e = getEntry(sessionId); + if (e.scheduledMsgText == null) return null; + return { text: e.scheduledMsgText, resetsAt: e.scheduledMsgResetsAt }; +} + +export function setScheduledMsg(sessionId, text, resetsAt) { + if (sessionId == null) return; + var e = getEntry(sessionId); + e.scheduledMsgText = text; + e.scheduledMsgResetsAt = resetsAt; +} + +export function clearScheduledMsg(sessionId) { + if (sessionId == null) return; + var e = getEntry(sessionId); + e.scheduledMsgText = null; + e.scheduledMsgResetsAt = null; +} + +// --- Whole-session lifecycle --- + +// True if this session has any armed/queued state worth restoring on +// switch-in (used to decide whether a redraw is needed). +export function hasArmedState(sessionId) { + if (sessionId == null) return false; + var e = _bySession[sessionId]; + if (!e) return false; + return e.scheduleDelayMs > 0 || e.scheduledMsgText != null; +} + +// Clears a single session's state entirely, including canceling its +// background reset timer. Call when a session is actually destroyed/closed, +// NOT on a mere project/session switch (switching away must leave the timer +// running in the background so it fires even while unfocused). +export function clearSession(sessionId) { + if (sessionId == null) return; + var e = _bySession[sessionId]; + if (e && e.rateLimitResetTimer) clearTimeout(e.rateLimitResetTimer); + delete _bySession[sessionId]; +} + +// Test/diagnostic helper: wipes all tracked sessions. Not used in production +// code paths (there is no "reset everything" client action), only in tests +// that need isolation between cases. +export function _resetAllForTest() { + for (var k in _bySession) { + if (_bySession[k] && _bySession[k].rateLimitResetTimer) clearTimeout(_bySession[k].rateLimitResetTimer); + } + _bySession = Object.create(null); +} diff --git a/lib/sdk-message-processor.js b/lib/sdk-message-processor.js index 05f806ed..10273e9a 100644 --- a/lib/sdk-message-processor.js +++ b/lib/sdk-message-processor.js @@ -760,13 +760,17 @@ function attachMessageProcessor(ctx) { var info = parsed.rateLimitInfo; console.log("[sdk-bridge] rate_limit_event for session " + session.localId + ": status=" + info.status + " resetsAt=" + info.resetsAt + " isUsingOverage=" + info.isUsingOverage + " isProcessing=" + session.isProcessing); - // Broadcast reset time for top-bar usage link + // Broadcast reset time for top-bar usage link. This is a project-wide + // broadcast (not sendAndRecord), but still stamp localId so a client + // with multiple sessions open across tabs can tell which session's + // query actually triggered this usage update. if (info.rateLimitType && info.resetsAt) { send({ type: "rate_limit_usage", rateLimitType: info.rateLimitType, resetsAt: info.resetsAt * 1000, status: info.status, + localId: session.localId, }); } @@ -780,6 +784,7 @@ function attachMessageProcessor(ctx) { utilization: info.utilization || null, isUsingOverage: info.isUsingOverage || false, blocksCurrentModel: bucketBlocksModel(info.rateLimitType, sm && sm.currentModel), + localId: session.localId, }); // Track rejection for auto-continue / scheduled message support if (info.status === "rejected" && info.resetsAt) { diff --git a/lib/sessions.js b/lib/sessions.js index dc95f621..ed489ebe 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -1011,6 +1011,18 @@ function createSessionManager(opts) { } } + // lr-0827ba (PEACHES follow-up): explicit notification that a session was + // deleted, carrying its localId, so the client can prune any per-session + // state it tracks (e.g. rate-limit-state.js's armed-schedule map and + // background reset timers) instead of leaking it forever. Prior to this, + // the only client-observable signal was a fresh session_list broadcast + // that simply omitted the deleted id — nothing told the client WHICH id(s) + // disappeared, so nothing could target cleanup at them. + function broadcastSessionDeleted(localIds) { + var ids = Array.isArray(localIds) ? localIds : [localIds]; + send({ type: "session_deleted", ids: ids }); + } + function deleteSession(localId, targetWs) { var session = sessions.get(localId); if (!session) return; @@ -1043,6 +1055,7 @@ function createSessionManager(opts) { } sessions.delete(localId); + broadcastSessionDeleted(localId); if (activeSessionId === localId) { var remaining = [...sessions.keys()]; @@ -1105,6 +1118,7 @@ function createSessionManager(opts) { if (ids[j] === activeSessionId) deletedActive = true; deleteSessionQuiet(ids[j]); } + broadcastSessionDeleted(ids); if (sessions.size === 0) { createSession(null, targetWs); @@ -1482,6 +1496,7 @@ function createSessionManager(opts) { resumeSession: resumeSession, mapSessionForClient: mapSessionForClient, broadcastSessionList: broadcastSessionList, + broadcastSessionDeleted: broadcastSessionDeleted, getTotalUnread: function (ws) { var unreadMap = (ws && ws._clayUnread) ? ws._clayUnread : {}; var total = 0; diff --git a/test/rate-limit-per-session-lr-0827ba.test.js b/test/rate-limit-per-session-lr-0827ba.test.js new file mode 100644 index 00000000..d2baef8b --- /dev/null +++ b/test/rate-limit-per-session-lr-0827ba.test.js @@ -0,0 +1,137 @@ +// Regression coverage for lr-0827ba (GitHub issue #328): rate-limit +// auto-schedule arming state was global to the browser tab instead of +// per-session, so arming it in a second project silently overwrote/lost +// the first project's armed state. +// +// Server-side session.scheduledMessage (lib/project.js) was already +// correctly per-session — these tests cover the two other pieces of the +// fix: +// +// 1. Server-side WS payloads (rate_limit, rate_limit_usage, +// scheduled_message_queued/sent/cancelled) are stamped with the +// session's localId so a client can route/correlate events correctly. +// 2. Client-side dispatch (app-messages.js) and the session-switch redraw +// hook (app-rate-limit.js / app-messages.js) route on that localId +// instead of always assuming "the currently focused session". +// +// lib/project.js, lib/sdk-message-processor.js, and app-messages.js/ +// app-rate-limit.js are not importable directly under plain Node (DOM + +// live server dependencies), so — matching the existing convention in +// test/frontend-state-correlation-lr-fb49.test.js — these are source-text +// regression checks asserting the fix is present. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var path = require("path"); + +function readMod(rel) { + return fs.readFileSync(path.join(__dirname, "..", rel), "utf8"); +} + +var PROJECT_JS = readMod("lib/project.js"); +var SDK_MESSAGE_PROCESSOR_JS = readMod("lib/sdk-message-processor.js"); +var APP_MESSAGES_JS = readMod("lib/public/modules/app-messages.js"); +var APP_RATE_LIMIT_JS = readMod("lib/public/modules/app-rate-limit.js"); + +// --------------------------------------------------------------------------- +// Server-side: WS payloads stamped with session localId +// --------------------------------------------------------------------------- + +test("lib/project.js: scheduled_message_queued is stamped with localId: session.localId", function () { + var idx = PROJECT_JS.indexOf('type: "scheduled_message_queued"'); + assert.ok(idx !== -1, "expected the scheduled_message_queued entry to still exist"); + var block = PROJECT_JS.slice(idx, idx + 200); + assert.match(block, /localId:\s*session\.localId/, "scheduled_message_queued must carry localId: session.localId so the client can route it to the right session"); +}); + +test("lib/project.js: scheduled_message_sent is stamped with localId: session.localId", function () { + var idx = PROJECT_JS.indexOf('sm.sendAndRecord(session, { type: "scheduled_message_sent"'); + assert.ok(idx !== -1, "expected the scheduled_message_sent send to still exist"); + var block = PROJECT_JS.slice(idx, idx + 120); + assert.match(block, /localId:\s*session\.localId/, "scheduled_message_sent must carry localId: session.localId"); +}); + +test("lib/project.js: scheduled_message_cancelled is stamped with localId: session.localId", function () { + var idx = PROJECT_JS.indexOf('sm.sendAndRecord(session, { type: "scheduled_message_cancelled"'); + assert.ok(idx !== -1, "expected the scheduled_message_cancelled send to still exist"); + var block = PROJECT_JS.slice(idx, idx + 120); + assert.match(block, /localId:\s*session\.localId/, "scheduled_message_cancelled must carry localId: session.localId"); +}); + +test("lib/sdk-message-processor.js: rate_limit_usage broadcast is stamped with localId: session.localId", function () { + var idx = SDK_MESSAGE_PROCESSOR_JS.indexOf('type: "rate_limit_usage"'); + assert.ok(idx !== -1, "expected the rate_limit_usage send to still exist"); + var block = SDK_MESSAGE_PROCESSOR_JS.slice(idx, idx + 250); + assert.match(block, /localId:\s*session\.localId/, "rate_limit_usage must carry localId: session.localId even though it is a project-wide broadcast"); +}); + +test("lib/sdk-message-processor.js: rate_limit event is stamped with localId: session.localId", function () { + var idx = SDK_MESSAGE_PROCESSOR_JS.indexOf('type: "rate_limit",'); + assert.ok(idx !== -1, "expected the rate_limit sendAndRecord call to still exist"); + var block = SDK_MESSAGE_PROCESSOR_JS.slice(idx, idx + 500); + assert.match(block, /localId:\s*session\.localId/, "rate_limit must carry localId: session.localId"); +}); + +// --------------------------------------------------------------------------- +// Client-side: dispatch routes on msg.localId rather than assuming focus +// --------------------------------------------------------------------------- + +test("app-messages.js: scheduled_message_queued passes msg.localId through to addScheduledMessageBubble", function () { + var idx = APP_MESSAGES_JS.indexOf('case "scheduled_message_queued":'); + assert.ok(idx !== -1); + var block = APP_MESSAGES_JS.slice(idx, idx + 300); + assert.match( + block, + /addScheduledMessageBubble\(msg\.text,\s*msg\.resetsAt,\s*msg\.localId\)/, + "scheduled_message_queued must forward msg.localId so a background session's scheduled message doesn't render into the focused session's message list" + ); +}); + +test("app-messages.js: scheduled_message_sent/cancelled route through clearScheduledMessage(msg.localId)", function () { + var sentIdx = APP_MESSAGES_JS.indexOf('case "scheduled_message_sent":'); + var cancelIdx = APP_MESSAGES_JS.indexOf('case "scheduled_message_cancelled":'); + assert.ok(sentIdx !== -1 && cancelIdx !== -1); + assert.match(APP_MESSAGES_JS.slice(sentIdx, sentIdx + 250), /clearScheduledMessage\(msg\.localId\)/); + assert.match(APP_MESSAGES_JS.slice(cancelIdx, cancelIdx + 200), /clearScheduledMessage\(msg\.localId\)/); +}); + +test("app-messages.js: session_switched calls restoreRateLimitStateForSession after resetClientState (redraw-on-switch-in hook)", function () { + var idx = APP_MESSAGES_JS.indexOf('case "session_switched":'); + assert.ok(idx !== -1); + var switchedInIdx = APP_MESSAGES_JS.indexOf('break;', idx); + var block = APP_MESSAGES_JS.slice(idx, switchedInIdx); + var resetIdx = block.indexOf("resetClientState();"); + var restoreIdx = block.indexOf("restoreRateLimitStateForSession("); + assert.ok(resetIdx !== -1, "expected resetClientState() call inside session_switched"); + assert.ok(restoreIdx !== -1, "expected restoreRateLimitStateForSession(...) call inside session_switched"); + assert.ok(restoreIdx > resetIdx, "restoreRateLimitStateForSession must run AFTER resetClientState so it redraws into the freshly-cleared DOM for the newly active session, not the outgoing one"); +}); + +// --------------------------------------------------------------------------- +// Client-side: background sessions keep their timers running across switches +// --------------------------------------------------------------------------- + +test("app-rate-limit.js: resetRateLimitState no longer cancels a session's background rate-limit reset timer", function () { + var idx = APP_RATE_LIMIT_JS.indexOf("export function resetRateLimitState()"); + assert.ok(idx !== -1); + var endIdx = APP_RATE_LIMIT_JS.indexOf("\n}", idx); + var block = APP_RATE_LIMIT_JS.slice(idx, endIdx); + assert.doesNotMatch( + block, + /rateLimitResetTimer\s*=\s*null/, + "resetRateLimitState must not null out a bare rateLimitResetTimer any more — that used to cancel the OUTGOING session's " + + "background reset timer on every switch, which is exactly the lr-0827ba bug (arming state didn't survive a project switch)" + ); +}); + +test("app-rate-limit.js: handleRateLimitEvent arms the event's own session (eventSessionId), not unconditionally the focused one", function () { + var idx = APP_RATE_LIMIT_JS.indexOf("export function handleRateLimitEvent(msg)"); + assert.ok(idx !== -1); + var block = APP_RATE_LIMIT_JS.slice(idx, idx + 3500); + assert.match(block, /var eventSessionId\s*=\s*msg\.localId\s*!=\s*null\s*\?\s*msg\.localId\s*:\s*currentSessionId\(\)/); + assert.match(block, /setRateLimitResetsAt\(eventSessionId,/); + assert.match(block, /setScheduleDelayForSession\(eventSessionId,/); +}); diff --git a/test/rate-limit-state.test.js b/test/rate-limit-state.test.js new file mode 100644 index 00000000..b8a00704 --- /dev/null +++ b/test/rate-limit-state.test.js @@ -0,0 +1,155 @@ +// Regression coverage for lr-0827ba: rate-limit auto-schedule arming state +// was global to the browser tab (bare module-scoped variables in input.js / +// app-rate-limit.js) instead of per-session, so arming a schedule in one +// project silently clobbered another project's armed state. +// +// rate-limit-state.js is DOM-free (see sticky-notes-fmt.js for the same +// convention), so these tests import and exercise the real production +// module directly rather than asserting against its source text. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + getScheduleDelayMs, + setScheduleDelayMs, + getRateLimitResetsAt, + setRateLimitResetsAt, + getRateLimitResetTimer, + setRateLimitResetTimer, + getRateLimitResetState, + getScheduledMsg, + setScheduledMsg, + clearScheduledMsg, + hasArmedState, + clearSession, + _resetAllForTest, +} from "../lib/public/modules/rate-limit-state.js"; + +test.beforeEach(() => { + _resetAllForTest(); +}); + +// ============================================================ +// Per-session isolation of armed state +// ============================================================ + +test("arming a schedule delay in session A does not affect session B", () => { + setScheduleDelayMs("session-A", 600000); + assert.equal(getScheduleDelayMs("session-A"), 600000); + assert.equal(getScheduleDelayMs("session-B"), 0, "session B must start unarmed"); + + setScheduleDelayMs("session-B", 900000); + assert.equal(getScheduleDelayMs("session-A"), 600000, "session A must still be armed after B is armed"); + assert.equal(getScheduleDelayMs("session-B"), 900000); +}); + +test("both sessions' armed state survives after arming a second, later session", () => { + setScheduleDelayMs("proj-1-session", 300000); + setScheduledMsg("proj-1-session", "continue please", Date.now() + 300000); + + setScheduleDelayMs("proj-2-session", 120000); + setScheduledMsg("proj-2-session", "keep going", Date.now() + 120000); + + assert.equal(getScheduleDelayMs("proj-1-session"), 300000); + assert.ok(getScheduledMsg("proj-1-session")); + assert.equal(getScheduledMsg("proj-1-session").text, "continue please"); + + assert.equal(getScheduleDelayMs("proj-2-session"), 120000); + assert.ok(getScheduledMsg("proj-2-session")); + assert.equal(getScheduledMsg("proj-2-session").text, "keep going"); +}); + +test("rate limit reset bookkeeping (resetsAt, timer, per-type reset state) is isolated per session", () => { + var timerA = setTimeout(function () {}, 1000000); + var timerB = setTimeout(function () {}, 1000000); + try { + setRateLimitResetsAt("A", 111); + setRateLimitResetTimer("A", timerA); + getRateLimitResetState("A").five_hour = { resetsAt: 111 }; + + setRateLimitResetsAt("B", 222); + setRateLimitResetTimer("B", timerB); + getRateLimitResetState("B").seven_day = { resetsAt: 222 }; + + assert.equal(getRateLimitResetsAt("A"), 111); + assert.equal(getRateLimitResetTimer("A"), timerA); + assert.deepEqual(getRateLimitResetState("A"), { five_hour: { resetsAt: 111 } }); + + assert.equal(getRateLimitResetsAt("B"), 222); + assert.equal(getRateLimitResetTimer("B"), timerB); + assert.deepEqual(getRateLimitResetState("B"), { seven_day: { resetsAt: 222 } }); + } finally { + clearTimeout(timerA); + clearTimeout(timerB); + } +}); + +// ============================================================ +// Stale-state cleanup on session teardown +// ============================================================ + +test("clearSession removes a session's entry entirely and clears its background timer", () => { + var fired = false; + var timer = setTimeout(function () { fired = true; }, 50); + setScheduleDelayMs("dead-session", 60000); + setScheduledMsg("dead-session", "hello", Date.now() + 60000); + setRateLimitResetTimer("dead-session", timer); + + assert.ok(hasArmedState("dead-session")); + + clearSession("dead-session"); + + assert.equal(hasArmedState("dead-session"), false, "cleared session must report no armed state"); + assert.equal(getScheduleDelayMs("dead-session"), 0, "cleared session must read back as unarmed (fresh entry)"); + assert.equal(getScheduledMsg("dead-session"), null); + assert.equal(getRateLimitResetTimer("dead-session"), null, "clearSession must reset the timer handle for a fresh entry"); + + return new Promise(function (resolve) { + setTimeout(function () { + assert.equal(fired, false, "clearSession must have canceled the background reset timer"); + resolve(); + }, 100); + }); +}); + +test("clearScheduledMsg only clears the bubble, leaving the schedule delay untouched", () => { + setScheduleDelayMs("s1", 600000); + setScheduledMsg("s1", "queued text", Date.now() + 600000); + + clearScheduledMsg("s1"); + + assert.equal(getScheduledMsg("s1"), null); + assert.equal(getScheduleDelayMs("s1"), 600000, "clearing the bubble must not clear the armed delay"); +}); + +// ============================================================ +// hasArmedState / localId-style keying +// ============================================================ + +test("hasArmedState is false for an untouched session id and true once armed", () => { + assert.equal(hasArmedState("never-touched"), false); + setScheduleDelayMs("never-touched", 60000); + assert.equal(hasArmedState("never-touched"), true); +}); + +test("session ids are looked up by strict key identity — numeric localId and its string form are distinct sessions", () => { + // localId is server-stamped as a number (session.localId); the client's + // activeSessionId store value may be read/written as a number too. Using + // a plain object as the backing map means numeric and string keys collide + // (JS object keys are always strings) — document that behavior explicitly + // so callers always pass the same type consistently (the activeSessionId + // as stored, without re-stringifying/re-parsing). + setScheduleDelayMs(42, 60000); + assert.equal(getScheduleDelayMs(42), 60000); + assert.equal(getScheduleDelayMs("42"), 60000, "object-key coercion means 42 and \"42\" address the same entry"); +}); + +test("null/undefined session id is a no-op for setters and reads back as unarmed", () => { + setScheduleDelayMs(null, 60000); + setScheduleDelayMs(undefined, 60000); + assert.equal(getScheduleDelayMs(null), 0); + assert.equal(getScheduleDelayMs(undefined), 0); + assert.equal(hasArmedState(null), false); + assert.equal(hasArmedState(undefined), false); +}); diff --git a/test/session-deleted-rate-limit-cleanup-lr-0827ba.test.js b/test/session-deleted-rate-limit-cleanup-lr-0827ba.test.js new file mode 100644 index 00000000..664a4b5c --- /dev/null +++ b/test/session-deleted-rate-limit-cleanup-lr-0827ba.test.js @@ -0,0 +1,183 @@ +"use strict"; +// Regression coverage for a PEACHES follow-up finding on lr-0827ba (PR #340, +// GitHub issue #328): forgetSessionRateLimitState()/clearSession() existed +// but were never called on session deletion, so rate-limit-state.js's +// per-session map and any pending background reset-timer setTimeout leaked +// forever every time a session was deleted. +// +// Fix: lib/sessions.js gained broadcastSessionDeleted(localIds), called from +// deleteSession() (single) and deleteSessionsBulk() (batch); lib/project-loop.js's +// delete_loop_group handler calls it too. The client (app-messages.js) dispatches +// the new "session_deleted" WS message to forgetSessionRateLimitState() for each id. +// +// Server-side pieces are covered here with real createSessionManager (matching +// the existing session-lifecycle-lr-e0de.test.js convention: a fake `send` +// captures broadcasts, no inline reimplementation of production logic). +// Client-side dispatch wiring is covered as a source-text check (app-messages.js +// is DOM/live-WS coupled and not importable under plain Node, matching the +// frontend-state-correlation-lr-fb49.test.js convention) plus a direct, +// DOM-free exercise of rate-limit-state.js's clearSession — the actual cleanup +// logic forgetSessionRateLimitState() delegates to. + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var path = require("path"); +var os = require("os"); + +function makeTempHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-test-")); +} + +function makeSessionManager(tmpHome, sendSpy) { + ["../lib/config", "../lib/sessions", "../lib/utils"].forEach(function (m) { + try { delete require.cache[require.resolve(m)]; } catch (_) {} + }); + var origHome = process.env.CLAGENTIC_HOME; + process.env.CLAGENTIC_HOME = tmpHome; + var sessions; + try { + sessions = require("../lib/sessions"); + } finally { + if (origHome === undefined) delete process.env.CLAGENTIC_HOME; + else process.env.CLAGENTIC_HOME = origHome; + } + return sessions.createSessionManager({ + cwd: tmpHome, + send: sendSpy, + sendTo: function () {}, + sendEach: function () {}, + }); +} + +// --------------------------------------------------------------------------- +// Server-side: deleteSession / deleteSessionsBulk broadcast session_deleted +// --------------------------------------------------------------------------- + +test("lib/sessions.js: deleteSession broadcasts session_deleted with the deleted localId", function () { + var tmpHome = makeTempHome(); + var sent = []; + var sm = makeSessionManager(tmpHome, function (obj) { sent.push(obj); }); + + var sess = sm.createSessionRaw({}); + var localId = sess.localId; + // Give it a second session too, so deleting the first isn't "delete the + // only session" (which takes the createSession(null, ...) fallback path). + sm.createSessionRaw({}); + + sent.length = 0; // ignore anything sent by createSessionRaw itself + sm.deleteSession(localId, null); + + var deletedMsgs = sent.filter(function (m) { return m.type === "session_deleted"; }); + assert.equal(deletedMsgs.length, 1, "expected exactly one session_deleted broadcast"); + assert.deepEqual(deletedMsgs[0].ids, [localId], "session_deleted must carry the deleted session's localId"); +}); + +test("lib/sessions.js: deleteSessionsBulk broadcasts session_deleted with all deleted localIds", function () { + var tmpHome = makeTempHome(); + var sent = []; + var sm = makeSessionManager(tmpHome, function (obj) { sent.push(obj); }); + + var a = sm.createSessionRaw({}); + var b = sm.createSessionRaw({}); + var c = sm.createSessionRaw({}); + + sent.length = 0; + sm.deleteSessionsBulk([a.localId, b.localId], null); + + var deletedMsgs = sent.filter(function (m) { return m.type === "session_deleted"; }); + assert.equal(deletedMsgs.length, 1, "expected exactly one session_deleted broadcast for the whole batch"); + assert.deepEqual( + deletedMsgs[0].ids.slice().sort(), + [a.localId, b.localId].sort(), + "session_deleted must carry every id actually deleted in the batch" + ); + // c was never targeted for deletion — must not appear. + assert.ok(deletedMsgs[0].ids.indexOf(c.localId) === -1); +}); + +test("lib/sessions.js: deleteSessionsBulk does not broadcast session_deleted when nothing was actually deletable", function () { + var tmpHome = makeTempHome(); + var sent = []; + var sm = makeSessionManager(tmpHome, function (obj) { sent.push(obj); }); + + sm.createSessionRaw({}); + sent.length = 0; + + // Non-existent localId — nothing should be deleted or broadcast. + sm.deleteSessionsBulk([999999], null); + + var deletedMsgs = sent.filter(function (m) { return m.type === "session_deleted"; }); + assert.equal(deletedMsgs.length, 0, "no session_deleted broadcast when the batch had no real deletions"); +}); + +// --------------------------------------------------------------------------- +// Client-side: app-messages.js dispatches session_deleted to +// forgetSessionRateLimitState, and lib/project-loop.js's delete_loop_group +// handler also broadcasts session_deleted (source-text checks — these +// modules are DOM/live-WS coupled and not importable under plain Node, +// matching the frontend-state-correlation-lr-fb49.test.js convention). +// --------------------------------------------------------------------------- + +function readMod(rel) { + return fs.readFileSync(path.join(__dirname, "..", rel), "utf8"); +} + +test("app-messages.js: session_deleted dispatch calls forgetSessionRateLimitState for each deleted id", function () { + var src = readMod("lib/public/modules/app-messages.js"); + var idx = src.indexOf('case "session_deleted":'); + assert.ok(idx !== -1, "expected a session_deleted case in the WS message switch"); + var endIdx = src.indexOf("case \"session_list\":", idx); + assert.ok(endIdx !== -1 && endIdx > idx); + var block = src.slice(idx, endIdx); + assert.match( + block, + /forgetSessionRateLimitState\(deletedId\)/, + "session_deleted must call forgetSessionRateLimitState for every deleted id, or its background reset timer leaks forever" + ); + assert.match(src, /forgetSessionRateLimitState/, "forgetSessionRateLimitState must be imported from app-rate-limit.js"); +}); + +test("lib/project-loop.js: delete_loop_group broadcasts session_deleted for every session it removes", function () { + var src = readMod("lib/project-loop.js"); + var idx = src.indexOf('msg.type === "delete_loop_group"'); + assert.ok(idx !== -1); + var block = src.slice(idx, idx + 800); + assert.match( + block, + /sm\.broadcastSessionDeleted\(sessionIds\)/, + "delete_loop_group must broadcast session_deleted for the sessions it deletes, matching deleteSession/deleteSessionsBulk" + ); +}); + +// --------------------------------------------------------------------------- +// End-to-end (DOM-free): the actual cleanup logic forgetSessionRateLimitState +// delegates to — rate-limit-state.js's clearSession — really does cancel the +// background timer and drop the entry (already covered in isolation by +// rate-limit-state.test.js; re-asserted here scoped to the deletion scenario +// specifically, since that's the defect PEACHES flagged). +// --------------------------------------------------------------------------- + +test("rate-limit-state.js: clearSession (forgetSessionRateLimitState's delegate) cancels the timer and drops the entry for a deleted session", async function () { + var mod = await import("../lib/public/modules/rate-limit-state.js"); + mod._resetAllForTest(); + + var fired = false; + var timer = setTimeout(function () { fired = true; }, 60); + mod.setScheduleDelayMs("deleted-session-42", 300000); + mod.setRateLimitResetTimer("deleted-session-42", timer); + + assert.ok(mod.hasArmedState("deleted-session-42")); + + mod.clearSession("deleted-session-42"); + + assert.equal(mod.hasArmedState("deleted-session-42"), false); + assert.equal(mod.getScheduleDelayMs("deleted-session-42"), 0); + + await new Promise(function (resolve) { + setTimeout(function () { + assert.equal(fired, false, "the deleted session's background reset timer must have been canceled, not left to fire later"); + resolve(); + }, 100); + }); +});