switchroom 0.19.23 → 0.19.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/agent-scheduler/index.js +18 -7
  2. package/dist/auth-broker/index.js +117 -33
  3. package/dist/cli/autoaccept-poll.js +0 -1
  4. package/dist/cli/drive-write-pretool.mjs +5 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -0
  6. package/dist/cli/notion-write-pretool.mjs +18 -6
  7. package/dist/cli/switchroom.js +2916 -1481
  8. package/dist/host-control/main.js +116 -34
  9. package/dist/vault/approvals/kernel-server.js +115 -33
  10. package/dist/vault/broker/server.js +281 -76
  11. package/examples/switchroom.yaml +1 -1
  12. package/package.json +1 -1
  13. package/profiles/_base/start.sh.hbs +52 -13
  14. package/profiles/_shared/dev-protocol.md.hbs +3 -4
  15. package/skills/dev-protocol/SKILL.md +22 -15
  16. package/skills/switchroom-health/SKILL.md +19 -0
  17. package/skills/switchroom-release/SKILL.md +2 -1
  18. package/skills/switchroom-status/SKILL.md +1 -1
  19. package/telegram-plugin/auth-snapshot-format.ts +9 -2
  20. package/telegram-plugin/dist/gateway/gateway.js +6925 -6734
  21. package/telegram-plugin/gateway/gateway.ts +34 -35
  22. package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
  23. package/telegram-plugin/gateway/outbound-send-path.ts +53 -21
  24. package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
  25. package/telegram-plugin/gateway/turn-end.ts +1 -1
  26. package/telegram-plugin/quota-bar-format.ts +4 -1
  27. package/telegram-plugin/reply-owner-resolve.ts +110 -9
  28. package/telegram-plugin/send-gate-degraded.test.ts +45 -16
  29. package/telegram-plugin/send-gate.ts +185 -24
  30. package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
  31. package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
  32. package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
  33. package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
  34. package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
  35. package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
  36. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
  37. package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
  38. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
  39. package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
  41. package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
  42. package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
  43. package/vendor/hindsight-memory/scripts/recall.py +503 -252
  44. package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
  45. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
  46. package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
  48. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
  49. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
  52. package/vendor/hindsight-memory/tests/test_content.py +218 -0
@@ -478,6 +478,16 @@ interface MessageEditState {
478
478
  * edit budget. Bounded by the budget cap; empty when the backstop is disabled.
479
479
  */
480
480
  editWindowTs: number[]
481
+ /**
482
+ * Set by the driver while it waits out a NON-critical admission, and fired by
483
+ * `handleEdit` when a `critical` edit is queued for this message meanwhile
484
+ * (#3716). Since cosmetic edits stopped shedding they now occupy the driver,
485
+ * so without this a critical edit arriving one tick after the driver dequeued
486
+ * a cosmetic one would inherit its unbounded wait — re-introducing the
487
+ * multi-hour reply wedge the critical fail-fast path exists to prevent.
488
+ * Null whenever no interruptible wait is in flight.
489
+ */
490
+ criticalWake: (() => void) | null
481
491
  }
482
492
 
483
493
  function hashPayload(payload: unknown): string {
@@ -669,6 +679,7 @@ export function createSendGate(config: SendGateConfig): SendGate {
669
679
  running: false,
670
680
  suppressedUntilMs: 0,
671
681
  editWindowTs: [],
682
+ criticalWake: null,
672
683
  }
673
684
  perMessage.set(key, state)
674
685
  }
@@ -826,9 +837,17 @@ export function createSendGate(config: SendGateConfig): SendGate {
826
837
  * without consuming if admission cannot happen before it — the caller drops
827
838
  * the call as stale (part3-design §2).
828
839
  */
829
- async function admitLoop(buckets: TokenBucket[], deadline?: number): Promise<boolean> {
840
+ async function admitLoop(
841
+ buckets: TokenBucket[],
842
+ deadline?: number,
843
+ interrupt?: Interrupt,
844
+ ): Promise<boolean> {
830
845
  let counted = false
831
846
  for (;;) {
847
+ // Checked BEFORE the consume below so an interrupted admission never
848
+ // spends a token nobody uses (a leaked token would permanently shrink the
849
+ // bucket's effective rate).
850
+ if (interrupt?.fired) return false
832
851
  const now = clock.now()
833
852
  let wait = 0
834
853
  for (const b of buckets) wait = Math.max(wait, b.msUntilAvailable(now))
@@ -841,13 +860,17 @@ export function createSendGate(config: SendGateConfig): SendGate {
841
860
  counters.queued++
842
861
  counted = true
843
862
  }
844
- await clock.sleep(deadline !== undefined ? Math.min(wait, deadline - now) : wait)
863
+ const nap = clock.sleep(deadline !== undefined ? Math.min(wait, deadline - now) : wait)
864
+ // An open flood window makes `wait` as long as the whole ban, so the nap
865
+ // must be raced rather than awaited outright — otherwise the interrupt is
866
+ // not observed until the ban has already elapsed.
867
+ await (interrupt ? Promise.race([nap, interrupt.promise]) : nap)
845
868
  }
846
869
  }
847
870
 
848
871
  /** Back-compat unbounded admission (used by the per-message edit driver). */
849
- function admit(buckets: TokenBucket[]): Promise<boolean> {
850
- return admitLoop(buckets)
872
+ function admit(buckets: TokenBucket[], interrupt?: Interrupt): Promise<boolean> {
873
+ return admitLoop(buckets, undefined, interrupt)
851
874
  }
852
875
 
853
876
  // Serializes `critical` sends that must probe the API during an open (short)
@@ -870,6 +893,36 @@ export function createSendGate(config: SendGateConfig): SendGate {
870
893
  })()
871
894
  }
872
895
 
896
+ /**
897
+ * One-shot abort handle for an in-flight admission wait (#3716). `fired` is
898
+ * the synchronous check (so the loop can bail before consuming a token) and
899
+ * `promise` is the async wake (so a nap covering a whole flood ban is cut
900
+ * short rather than run to completion).
901
+ */
902
+ interface Interrupt {
903
+ fired: boolean
904
+ promise: Promise<void>
905
+ }
906
+
907
+ /** Create an `Interrupt` plus the trigger that fires it exactly once. */
908
+ function makeInterrupt(): { interrupt: Interrupt; fire: () => void } {
909
+ let resolve!: () => void
910
+ const interrupt: Interrupt = {
911
+ fired: false,
912
+ promise: new Promise<void>((r) => {
913
+ resolve = r
914
+ }),
915
+ }
916
+ return {
917
+ interrupt,
918
+ fire: () => {
919
+ if (interrupt.fired) return
920
+ interrupt.fired = true
921
+ resolve()
922
+ },
923
+ }
924
+ }
925
+
873
926
  type AdmitOutcome =
874
927
  | { result: 'ok' }
875
928
  | { result: 'shed' }
@@ -1038,7 +1091,24 @@ export function createSendGate(config: SendGateConfig): SendGate {
1038
1091
  }
1039
1092
  // outcome.result === 'ok' → admitPriority already consumed the buckets.
1040
1093
  } else {
1041
- await admit(bucketsFor(opts))
1094
+ // #3716: this wait is UNBOUNDED, and since cosmetic edits stopped
1095
+ // shedding it is now reachable for them too — so a `critical` edit
1096
+ // arriving while we sit here must be able to jump the queue instead
1097
+ // of inheriting a whole flood ban's worth of waiting. Yield to it and
1098
+ // re-loop; `p` is superseded rather than dropped, so no state is lost
1099
+ // and its callers ride the send that actually goes out.
1100
+ const { interrupt, fire } = makeInterrupt()
1101
+ state.criticalWake = fire
1102
+ let admitted: boolean
1103
+ try {
1104
+ admitted = await admit(bucketsFor(opts), interrupt)
1105
+ } finally {
1106
+ state.criticalWake = null
1107
+ }
1108
+ if (!admitted) {
1109
+ supersede(state, p)
1110
+ continue
1111
+ }
1042
1112
  }
1043
1113
  // Reserve the send-start time BEFORE awaiting the network so the floor
1044
1114
  // is measured from send start (matches the per-message serialization).
@@ -1084,6 +1154,70 @@ export function createSendGate(config: SendGateConfig): SendGate {
1084
1154
  }
1085
1155
  }
1086
1156
 
1157
+ /**
1158
+ * Hand a dequeued-but-unsent edit back to the queue after the driver yielded
1159
+ * to a higher-priority one (#3716). `p` is by definition OLDER than whatever
1160
+ * is queued now, so last-write-wins says its payload loses — but its callers
1161
+ * must still settle, so they ride the newer edit's result exactly as if they
1162
+ * had coalesced onto it in the first place. With nothing queued (the wake
1163
+ * raced an empty slot) `p` simply goes back.
1164
+ */
1165
+ function supersede(state: MessageEditState, p: PendingEdit): void {
1166
+ const next = state.pending
1167
+ if (!next) {
1168
+ state.pending = p
1169
+ return
1170
+ }
1171
+ next.priorityClass = maxPriority(next.priorityClass, p.priorityClass)
1172
+ next.promise.then(p.resolve, p.reject)
1173
+ }
1174
+
1175
+ /**
1176
+ * The condition that used to trigger the cosmetic-edit shed: no token in some
1177
+ * bucket, or a window covering this message. Kept as the trigger — what
1178
+ * changed in #3716 is the CONSEQUENCE (see `settleForCaller`).
1179
+ */
1180
+ function editUnderPressure(state: MessageEditState, opts: SendGateOpts, now: number): boolean {
1181
+ if (state.suppressedUntilMs > now) return true
1182
+ for (const b of bucketsFor(opts)) {
1183
+ if (b.msUntilAvailable(now) > 0) return true
1184
+ }
1185
+ return false
1186
+ }
1187
+
1188
+ /**
1189
+ * What a queued edit returns to the caller that submitted it (#3716).
1190
+ *
1191
+ * Normally: the shared pending promise, settling with the coalesced send's
1192
+ * outcome. Callers rely on that to pace their own painting, so this is the
1193
+ * path whenever the gate is keeping up.
1194
+ *
1195
+ * Under pressure, a `cosmetic` edit instead settles its caller as soon as it
1196
+ * is QUEUED. Callers serialize their own flushes, so a best-effort paint
1197
+ * parked behind a flood ban would hold a subsequent critical edit hostage
1198
+ * UPSTREAM of the gate — where the fail-fast path can never see it — wedging
1199
+ * the reply path for the length of the ban. Releasing the caller keeps that
1200
+ * path free while the edit itself stays queued and still lands carrying the
1201
+ * newest payload. This is the same trigger the old shed used; the difference
1202
+ * is that the state survives instead of being discarded.
1203
+ */
1204
+ function settleForCaller<T>(
1205
+ state: MessageEditState,
1206
+ pending: PendingEdit,
1207
+ opts: SendGateOpts,
1208
+ priority: PriorityClass,
1209
+ now: number,
1210
+ ): Promise<T> {
1211
+ if (priority !== 'cosmetic' || !editUnderPressure(state, opts, now)) {
1212
+ return pending.promise as Promise<T>
1213
+ }
1214
+ // Nobody may be left awaiting the shared promise, so absorb a rejection here
1215
+ // to keep a failed background paint from surfacing as an unhandled rejection.
1216
+ // (A later `critical`/`useful` coalesce still attaches its own handlers.)
1217
+ void pending.promise.catch(() => {})
1218
+ return Promise.resolve(undefined as unknown as T)
1219
+ }
1220
+
1087
1221
  function handleEdit<T>(fn: () => Promise<T>, opts: SendGateOpts): Promise<T> {
1088
1222
  const messageId = opts.messageId as number
1089
1223
  const key = messageKey(opts.chat_id, messageId)
@@ -1093,22 +1227,39 @@ export function createSendGate(config: SendGateConfig): SendGate {
1093
1227
  maybeEvict(now, existing === undefined)
1094
1228
  const state = existing ?? messageState(key)
1095
1229
 
1096
- // Cosmetic edits (part3-design §2) shed under pressure — a flood window
1097
- // covering the scope is open, or a bucket has no token free right now. A
1098
- // dropped edit costs nothing: the next edit carries the full state. Only an
1099
- // EXPLICITLY-cosmetic edit sheds; untagged edits keep PR 1's coalescing
1100
- // behaviour (default = useful), so this never changes an untagged caller.
1101
- if ((opts.priorityClass ?? 'useful') === 'cosmetic') {
1102
- let wait = 0
1103
- for (const b of bucketsFor(opts)) wait = Math.max(wait, b.msUntilAvailable(now))
1104
- const msgWait = state.suppressedUntilMs > now ? state.suppressedUntilMs - now : 0
1105
- if (wait > 0 || msgWait > 0) {
1106
- counters.shed++
1107
- // Distinguishable from the no-op drop below (undefined): a shed did
1108
- // NOT land, and edit-driving callers must be able to tell (F1).
1109
- return Promise.resolve(SEND_GATE_SHED as unknown as T)
1110
- }
1111
- }
1230
+ // Cosmetic edits COALESCE under pressure; they are never shed (#3716).
1231
+ //
1232
+ // This previously dropped a cosmetic edit outright whenever a bucket had no
1233
+ // free token or a flood window covered the scope, on the reasoning that "a
1234
+ // dropped edit costs nothing: the next edit carries the full state". That
1235
+ // holds for every edit in a burst EXCEPT the last one — and the last one is
1236
+ // the only edit whose state is still on screen when the burst ends. Dropping
1237
+ // it strands the card on a stale body indefinitely (a progress card frozen
1238
+ // mid-run, a finished task still rendered as running). Pressure is exactly
1239
+ // when bursts end, so the drop was biased toward stranding precisely the
1240
+ // edits that mattered.
1241
+ //
1242
+ // Falling through to the last-write-wins coalescing below loses nothing and
1243
+ // costs no extra flood budget: N queued edits collapse into ONE send that
1244
+ // carries the newest payload, and the driver already paces that send against
1245
+ // the token buckets, the per-message edit floor, and the rolling per-window
1246
+ // cosmetic budget — which DEFERS an over-budget edit rather than dropping it.
1247
+ // Flood safety comes from that pacing, never from discarding state.
1248
+ //
1249
+ // Cost of the change: under a long flood window a cosmetic edit now waits in
1250
+ // the driver instead of resolving immediately as SEND_GATE_SHED. The pending
1251
+ // slot is one-per-message and LRU-bounded by `maxMessageStates`, and the edit
1252
+ // that eventually lands carries the newest state rather than a stale one.
1253
+ // `SEND_GATE_SHED` remains the contract for NON-edit cosmetic sends (typing,
1254
+ // reactions), which carry no state worth preserving.
1255
+ //
1256
+ // What that cost must NOT become is a wait the CALLER inherits. Callers
1257
+ // serialize their own flushes (draft-stream awaits the in-flight paint before
1258
+ // starting the next), so a cosmetic edit that blocked its caller for a whole
1259
+ // flood ban would hold the critical finalize behind it — and the finalize
1260
+ // would never reach the gate to fail fast, wedging the reply path for hours.
1261
+ // That is precisely the failure the gate exists to prevent, so a cosmetic
1262
+ // edit settles its caller as soon as it is QUEUED (see `settleForCaller`).
1112
1263
 
1113
1264
  // N1: the coalesce (last-write-wins) check runs BEFORE the no-op skip. When
1114
1265
  // a distinct edit is already queued, the newest payload always replaces it —
@@ -1135,7 +1286,10 @@ export function createSendGate(config: SendGateConfig): SendGate {
1135
1286
  state.pending.hash = hash
1136
1287
  state.pending.fn = fn as () => Promise<unknown>
1137
1288
  }
1138
- return state.pending.promise as Promise<T>
1289
+ // The queued edit is critical now — cut short any non-critical admission
1290
+ // the driver is waiting out for this message (#3716).
1291
+ if (state.pending.priorityClass === 'critical') state.criticalWake?.()
1292
+ return settleForCaller<T>(state, state.pending, opts, opts.priorityClass ?? 'useful', now)
1139
1293
  }
1140
1294
 
1141
1295
  // No edit queued → a repeat of the last payload we actually sent is a plain
@@ -1164,8 +1318,15 @@ export function createSendGate(config: SendGateConfig): SendGate {
1164
1318
  priorityClass: opts.priorityClass ?? 'useful',
1165
1319
  }
1166
1320
  state.pending = pending
1167
- if (!state.running) void drive(state, opts)
1168
- return promise as Promise<T>
1321
+ if (state.running) {
1322
+ // A driver is mid-flight. If it is waiting out a non-critical admission
1323
+ // and THIS edit is critical, wake it so the critical work does not queue
1324
+ // behind a cosmetic edit's unbounded wait (#3716).
1325
+ if (pending.priorityClass === 'critical') state.criticalWake?.()
1326
+ } else {
1327
+ void drive(state, opts)
1328
+ }
1329
+ return settleForCaller<T>(state, pending, opts, pending.priorityClass, now)
1169
1330
  }
1170
1331
 
1171
1332
  async function gate<T>(fn: () => Promise<T>, opts?: SendGateOpts): Promise<T> {
@@ -239,10 +239,10 @@ describe('activity card ↔ send gate (#3620)', () => {
239
239
  }
240
240
  })
241
241
 
242
- it('a shed card edit is not treated as painted — the newest render survives for the next drain', async () => {
242
+ it('a card edit starved of tokens is HELD, not shed — the newest render still lands (#3716)', async () => {
243
243
  // A one-token-per-minute chat bucket: the card open consumes it, so the
244
- // next COSMETIC card edit sheds.
245
- const { lane, calls, clock } = makeGatedLane({ perChatPerSec: 1 / 60, perChatBurst: 1 })
244
+ // next COSMETIC card edit cannot be admitted.
245
+ const { lane, calls, clock, gate } = makeGatedLane({ perChatPerSec: 1 / 60, perChatBurst: 1 })
246
246
  const turn = makeLaneTurn(lane)
247
247
 
248
248
  lane.showNarrativeStep(turn, 'Opening the card')
@@ -250,17 +250,17 @@ describe('activity card ↔ send gate (#3620)', () => {
250
250
  await turn.activityInFlight
251
251
  expect(calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
252
252
 
253
- lane.showNarrativeStep(turn, 'A shed update')
253
+ lane.showNarrativeStep(turn, 'An update with no token available')
254
254
  await clock.advance(10)
255
255
  await turn.activityInFlight
256
- // Shed → nothing hit the API, and the render is still PENDING (not
257
- // mis-recorded as sent), so it is not silently lost.
256
+ // Nothing hits the API while the bucket is empty — unchanged. What changed
257
+ // is that the edit is now QUEUED rather than discarded, so the card can no
258
+ // longer be stranded on a stale body by a burst that ends under pressure.
258
259
  expect(calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
259
- expect(turn.activityPendingRender).not.toBeNull()
260
- expect(turn.activityPendingRender).not.toBe(turn.activityLastSentRender)
260
+ expect(gate.stats().global.shed).toBe(0)
261
261
 
262
262
  // Once the bucket refills, the next drain paints the NEWEST body — not the
263
- // shed intermediate one.
263
+ // superseded intermediate one.
264
264
  lane.showNarrativeStep(turn, 'The newest update')
265
265
  await clock.advance(120_000)
266
266
  await turn.activityInFlight
@@ -856,6 +856,48 @@ describe('buildSnapshotsFromState', () => {
856
856
  expect(snaps[2]!.quota).toBeNull();
857
857
  expect(snaps[2]!.quotaError).toBe('HTTP 401');
858
858
  });
859
+
860
+ /**
861
+ * Operator report 2026-07-27: the /usage card (which reaches the renderers
862
+ * through THIS builder — see gateway.ts) marked the configured pin active
863
+ * while the broker's soft-avoid roll had the fleet on another account.
864
+ */
865
+ it('marks the SERVING account active, not the pin, when the two diverge', () => {
866
+ const state = {
867
+ active: 'pinned@x',
868
+ serving: 'rolled-to@x',
869
+ fallback_order: ['pinned@x', 'rolled-to@x'],
870
+ accounts: [
871
+ { label: 'pinned@x', exhausted: false },
872
+ { label: 'rolled-to@x', exhausted: false },
873
+ ],
874
+ agents: [],
875
+ consumers: [],
876
+ } as unknown as ListStateData;
877
+ const snaps = buildSnapshotsFromState(state, [
878
+ { ok: true, data: quota({ sevenDayUtilizationPct: 96 }) },
879
+ { ok: true, data: quota({ sevenDayUtilizationPct: 10 }) },
880
+ ]);
881
+ expect(snaps.map((s) => s.isActive)).toEqual([false, true]);
882
+ });
883
+
884
+ it('falls back to the pin when the broker publishes no serving field', () => {
885
+ const state: ListStateData = {
886
+ active: 'pinned@x',
887
+ fallback_order: ['pinned@x', 'other@x'],
888
+ accounts: [
889
+ { label: 'pinned@x', exhausted: false },
890
+ { label: 'other@x', exhausted: false },
891
+ ],
892
+ agents: [],
893
+ consumers: [],
894
+ };
895
+ const snaps = buildSnapshotsFromState(state, [
896
+ { ok: true, data: quota({}) },
897
+ { ok: true, data: quota({}) },
898
+ ]);
899
+ expect(snaps.map((s) => s.isActive)).toEqual([true, false]);
900
+ });
859
901
  });
860
902
 
861
903
  // ── reviveLastQuota ──────────────────────────────────────────────────
@@ -0,0 +1,77 @@
1
+ /**
2
+ * #3725 — the "latest-ended" supersede anchor was the most-recently-STARTED turn.
3
+ *
4
+ * `recentTurnsById` is populated at turn START (`rememberRecentTurn` fires from
5
+ * the `enqueue` lifecycle event in `stream-render.ts`, and the atom is built with
6
+ * `endedAt: null`; `endedAt` is stamped later in `turn-end.ts`). The gateway's
7
+ * lookup had NO `endedAt` filter, so `findLatestEndedTurnForChat` returned the
8
+ * chat's tail entry even when that turn was still RUNNING — its own docstring
9
+ * admitted it ("the most-recently-STARTED turn") while the name and every caller
10
+ * said otherwise. Because the registry is chat-wide and thread-agnostic, a turn
11
+ * running in ANOTHER topic of the same chat was a live route into that state.
12
+ *
13
+ * These tests pin the OUTCOME of the scan (which turn comes back), not the
14
+ * shape of the code.
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest'
18
+ import { latestTurnForChat, type LatestTurnLookupAtom } from '../gateway/latest-turn-lookup.js'
19
+
20
+ interface Turn extends LatestTurnLookupAtom {
21
+ turnId: string
22
+ sessionThreadId?: number
23
+ }
24
+
25
+ const CHAT = '12345'
26
+ const OTHER_CHAT = '99999'
27
+
28
+ /** Registry insertion order = turn-START order (what `rememberRecentTurn` does). */
29
+ const registry = (...turns: Turn[]): Map<string, Turn> =>
30
+ new Map(turns.map((t) => [t.turnId, t]))
31
+
32
+ describe('latestTurnForChat — endedOnly (#3725)', () => {
33
+ it('does NOT return a still-running turn as the latest ENDED turn — it falls ' +
34
+ 'back to the most recent turn that actually ended', () => {
35
+ // Topic A ended at t=1000; topic B started after and is STILL RUNNING, so it
36
+ // sits at the registry tail. This is the exact incident shape.
37
+ const ended = { turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000, sessionThreadId: 11 }
38
+ const running = { turnId: 'B#2', sessionChatId: CHAT, endedAt: null, sessionThreadId: 22 }
39
+ const turns = registry(ended, running)
40
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })).toBe(ended)
41
+ // The routing consumer still wants the tail entry (running or not) — it only
42
+ // picks a topic to deliver into and deletes nothing.
43
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })).toBe(running)
44
+ })
45
+
46
+ it('returns NULL when the chat has only a running turn — the destructive tier ' +
47
+ 'gets no anchor at all rather than an unbounded one (fail closed)', () => {
48
+ const turns = registry({ turnId: 'B#2', sessionChatId: CHAT, endedAt: null })
49
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })).toBeNull()
50
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })).not.toBeNull()
51
+ })
52
+
53
+ it('picks the LAST ended turn in insertion order when several have ended', () => {
54
+ const turns = registry(
55
+ { turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000 },
56
+ { turnId: 'A#2', sessionChatId: CHAT, endedAt: 2_000 },
57
+ { turnId: 'B#3', sessionChatId: CHAT, endedAt: null },
58
+ )
59
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#2')
60
+ })
61
+
62
+ it('never crosses chats (the scan is chat-scoped in BOTH modes)', () => {
63
+ const turns = registry(
64
+ { turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000 },
65
+ { turnId: 'X#1', sessionChatId: OTHER_CHAT, endedAt: 2_000 },
66
+ { turnId: 'X#2', sessionChatId: OTHER_CHAT, endedAt: null },
67
+ )
68
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#1')
69
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })?.turnId).toBe('A#1')
70
+ expect(latestTurnForChat(turns.values(), 'nobody', { endedOnly: false })).toBeNull()
71
+ })
72
+
73
+ it('an endedAt of 0 counts as ENDED (null is the only "still running" marker)', () => {
74
+ const turns = registry({ turnId: 'A#1', sessionChatId: CHAT, endedAt: 0 })
75
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#1')
76
+ })
77
+ })
@@ -35,6 +35,28 @@ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
35
35
  import { BackstopDeliveryLedger } from '../gateway/backstop-delivery.js'
36
36
  import { redact } from '../secret-detect/redact.js'
37
37
  import type { CurrentTurn, NarrativeLaneDeps } from '../gateway/gateway.js'
38
+ import type { ReplyOwnerTier } from '../reply-owner-resolve.js'
39
+
40
+ /** The owner-resolution shape `resolveReplyOwnerTurn` returns, including the
41
+ * candidate set the content-gate bypass corroborates against. These fixtures
42
+ * never exercise the supersede path, so the candidates mirror the resolved turn
43
+ * (the corroborated shape) with no override needed. */
44
+ function ownerRes(turn: CurrentTurn | null, tier: ReplyOwnerTier) {
45
+ const id = turn?.turnId ?? null
46
+ return {
47
+ turn,
48
+ tier,
49
+ candidates: {
50
+ liveTurnId: tier === 'live' ? id : null,
51
+ originTurnId: null,
52
+ quotedTurnId: null,
53
+ latestEndedTurnId: id,
54
+ latestEndedAgeMs: 1_000,
55
+ latestEndedTtlMs: 60_000,
56
+ },
57
+ }
58
+ }
59
+
38
60
 
39
61
  const CHAT = '1001'
40
62
 
@@ -371,7 +393,7 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
371
393
  assertSendable: () => {},
372
394
  statusKey: key,
373
395
  streamKey: key,
374
- resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
396
+ resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
375
397
  getLastSubagentHandbackAt: () => null,
376
398
  findTurnByOriginId: () => null,
377
399
  findTurnByQuotedMessageId: () => null,
@@ -485,3 +485,53 @@ describe('renderUsageCard', () => {
485
485
  expect(out).not.toContain('- top ');
486
486
  });
487
487
  });
488
+
489
+ // ── serving vs pinned account ────────────────────────────────────────
490
+
491
+ /**
492
+ * Operator report 2026-07-27: the /usage card named the CONFIGURED pin
493
+ * (`auth.active`) as "(active)" after the broker's soft-avoid roll had
494
+ * already moved the fleet onto a different account. The broker now publishes
495
+ * `serving` alongside `active`; every "(active)" marker must follow `serving`.
496
+ */
497
+ describe('quota-bar "(active)" follows the SERVING account, not the pin', () => {
498
+ function stateWith(serving?: string): ListStateData {
499
+ const lq = {
500
+ fiveHourUtilizationPct: 0,
501
+ sevenDayUtilizationPct: 10,
502
+ fiveHourResetAt: new Date(NOW.getTime() + 60_000).toISOString(),
503
+ sevenDayResetAt: new Date(NOW.getTime() + 24 * 60 * 60_000).toISOString(),
504
+ representativeClaim: null,
505
+ overageStatus: null,
506
+ overageDisabledReason: null,
507
+ capturedAt: NOW.getTime(),
508
+ };
509
+ return {
510
+ active: 'pinned@example.com',
511
+ serving,
512
+ fallback_order: ['pinned@example.com', 'rolled-to@example.com'],
513
+ accounts: [
514
+ { label: 'pinned@example.com', exhausted: false, last_quota: lq },
515
+ { label: 'rolled-to@example.com', exhausted: false, last_quota: lq },
516
+ ],
517
+ agents: [],
518
+ consumers: [],
519
+ } as unknown as ListStateData;
520
+ }
521
+
522
+ it('marks the rolled-to account active and the rolled-off pin idle', () => {
523
+ const lines = renderQuotaBarBlockFromListState(stateWith('rolled-to@example.com'), {
524
+ now: NOW,
525
+ }).split('\n');
526
+ // The bug rendered exactly the inverse of these two assertions.
527
+ expect(lines).toContain('- **rolled-to@example.com** (active)');
528
+ expect(lines).toContain('- **pinned@example.com** (idle)');
529
+ expect(lines).not.toContain('- **pinned@example.com** (active)');
530
+ });
531
+
532
+ it('falls back to the pin when the broker publishes no serving field (pre-serving broker)', () => {
533
+ const lines = renderQuotaBarBlockFromListState(stateWith(undefined), { now: NOW }).split('\n');
534
+ expect(lines).toContain('- **pinned@example.com** (active)');
535
+ expect(lines).toContain('- **rolled-to@example.com** (idle)');
536
+ });
537
+ });