diff --git a/src/lib/ai/coach/plans.ts b/src/lib/ai/coach/plans.ts index 070335bfe..cade32d18 100644 --- a/src/lib/ai/coach/plans.ts +++ b/src/lib/ai/coach/plans.ts @@ -276,6 +276,9 @@ async function loadRecentTurns( for (const m of [...row.messages].reverse()) { const content = decryptOrNull(m.encryptedContent); if (content === null) continue; + // #781 — skip empty bodies: a cancelled-turn marker (an empty assistant + // row closing an aborted turn) carries nothing to extract from. + if (!content.trim()) continue; turns.push({ role: m.role, content }); } return turns; diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index cad590f3b..b8dccc0a5 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -61,6 +61,12 @@ export async function checkRateLimit( * once `reset_at` has passed the counter is stale and a decrement would bleed * into the NEXT window's budget, so an expired row is left untouched. Never * drops below zero (concurrent refunds after a window flip are a no-op). + * + * Known slack: if the charge's window expires and a new request opens the + * NEXT window before a late refund lands, the refund decrements that fresh + * window instead — at most one extra slot there, only on failure paths that + * never reached the metered work. Accepted; carrying the charge's `reset_at` + * through every failure path is not worth one slot of slack. */ export async function refundRateLimit(key: string): Promise { await prisma.$executeRaw`