switchroom 0.20.5 → 0.20.7

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.
@@ -9,6 +9,8 @@
9
9
  import { describe, it, expect } from 'vitest'
10
10
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick, planBufferedRedelivery, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
11
11
  import type { InboundMessage } from '../gateway/ipc-protocol.js'
12
+ import { ObligationLedger } from '../gateway/obligation-ledger.js'
13
+ import { makeRepresentRedeliveryGuard } from '../gateway/represent-delivery-guard.js'
12
14
 
13
15
  function inbound(source: string, ts = Date.now()): InboundMessage {
14
16
  return {
@@ -223,7 +225,7 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
223
225
  seen.push(m.messageId as number)
224
226
  return true
225
227
  })
226
- expect(r).toEqual({ drained: 2, redelivered: 2, rebuffered: 0 })
228
+ expect(r).toEqual({ drained: 2, redelivered: 2, rebuffered: 0, retracted: 0 })
227
229
  expect(seen).toEqual([1, 2]) // FIFO preserved
228
230
  expect(buf.depth('klanker')).toBe(0)
229
231
  })
@@ -233,7 +235,7 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
233
235
  buf.push('klanker', inbound('user', 1))
234
236
  buf.push('klanker', inbound('cron', 2))
235
237
  const r = redeliverBufferedInbound(buf, 'klanker', () => false)
236
- expect(r).toEqual({ drained: 2, redelivered: 0, rebuffered: 2 })
238
+ expect(r).toEqual({ drained: 2, redelivered: 0, rebuffered: 2, retracted: 0 })
237
239
  expect(buf.depth('klanker')).toBe(2) // still there, nothing lost
238
240
  expect(buf.drain('klanker').map((m) => m.meta?.source)).toEqual(['user', 'cron'])
239
241
  })
@@ -244,7 +246,7 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
244
246
  const r = redeliverBufferedInbound(buf, 'klanker', () => {
245
247
  throw new Error('bridge write failed')
246
248
  })
247
- expect(r).toEqual({ drained: 1, redelivered: 0, rebuffered: 1 })
249
+ expect(r).toEqual({ drained: 1, redelivered: 0, rebuffered: 1, retracted: 0 })
248
250
  expect(buf.depth('klanker')).toBe(1)
249
251
  })
250
252
 
@@ -258,7 +260,7 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
258
260
  n++
259
261
  return n !== 2 // 2nd send fails
260
262
  })
261
- expect(r).toEqual({ drained: 3, redelivered: 2, rebuffered: 1 })
263
+ expect(r).toEqual({ drained: 3, redelivered: 2, rebuffered: 1, retracted: 0 })
262
264
  expect(buf.drain('klanker').map((m) => m.meta?.source)).toEqual(['b'])
263
265
  })
264
266
 
@@ -269,7 +271,7 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
269
271
  calls++
270
272
  return true
271
273
  })
272
- expect(r).toEqual({ drained: 0, redelivered: 0, rebuffered: 0 })
274
+ expect(r).toEqual({ drained: 0, redelivered: 0, rebuffered: 0, retracted: 0 })
273
275
  expect(calls).toBe(0)
274
276
  })
275
277
 
@@ -334,7 +336,7 @@ describe('idleDrainTick — the 3rd drain trigger (finn orphan gap, 2026-05-19)'
334
336
  buf.push('finn', inbound('user', 2013)) // the orphaned "verify with mff-query.py" class
335
337
  const seen: number[] = []
336
338
  const r = idleDrainTick(buf, 'finn', () => true, (m) => { seen.push(m.messageId as number); return true })
337
- expect(r).toEqual({ drained: 1, redelivered: 1, rebuffered: 0 })
339
+ expect(r).toEqual({ drained: 1, redelivered: 1, rebuffered: 0, retracted: 0 })
338
340
  expect(seen).toEqual([2013])
339
341
  expect(buf.depth('finn')).toBe(0)
340
342
  })
@@ -343,7 +345,7 @@ describe('idleDrainTick — the 3rd drain trigger (finn orphan gap, 2026-05-19)'
343
345
  const buf = createPendingInboundBuffer({ log: () => {} })
344
346
  buf.push('finn', inbound('user', 1))
345
347
  const r = idleDrainTick(buf, 'finn', () => true, () => false)
346
- expect(r).toEqual({ drained: 1, redelivered: 0, rebuffered: 1 })
348
+ expect(r).toEqual({ drained: 1, redelivered: 0, rebuffered: 1, retracted: 0 })
347
349
  expect(buf.depth('finn')).toBe(1) // nothing lost
348
350
  expect(idleDrainTick(buf, '', () => true, () => true)).toBeNull() // empty agent guard
349
351
  })
@@ -419,6 +421,7 @@ describe('durable-spool integration (finn/carrie lost-on-restart fix)', () => {
419
421
  drained: 1,
420
422
  redelivered: 1,
421
423
  rebuffered: 0,
424
+ retracted: 0,
422
425
  })
423
426
  })
424
427
  })
@@ -589,7 +592,7 @@ describe('planBufferedRedelivery — merge-on-drain (forwarded-burst across a tu
589
592
  return true
590
593
  })
591
594
  expect(sent).toEqual(['part 1\npart 2\npart 3']) // ONE turn, not three
592
- expect(r).toEqual({ drained: 3, redelivered: 3, rebuffered: 0 })
595
+ expect(r).toEqual({ drained: 3, redelivered: 3, rebuffered: 0, retracted: 0 })
593
596
  expect(buf.depth('ziggy')).toBe(0)
594
597
  })
595
598
 
@@ -598,7 +601,7 @@ describe('planBufferedRedelivery — merge-on-drain (forwarded-burst across a tu
598
601
  buf.push('ziggy', userMsg({ text: 'part 1', ts: 1 }))
599
602
  buf.push('ziggy', userMsg({ text: 'part 2', ts: 2 }))
600
603
  const r = redeliverBufferedInbound(buf, 'ziggy', () => false)
601
- expect(r).toEqual({ drained: 2, redelivered: 0, rebuffered: 2 })
604
+ expect(r).toEqual({ drained: 2, redelivered: 0, rebuffered: 2, retracted: 0 })
602
605
  expect(buf.depth('ziggy')).toBe(2) // both originals back, nothing lost
603
606
  expect(buf.drain('ziggy').map((m) => m.text)).toEqual(['part 1', 'part 2'])
604
607
  })
@@ -713,3 +716,136 @@ describe('planBufferedRedelivery — seeded fuzz over random burst schedules', (
713
716
  }
714
717
  })
715
718
  })
719
+
720
+ /**
721
+ * F1 end-to-end: the delivery-time represent re-check wired into
722
+ * redeliverBufferedInbound via `beforeRedeliver`. These exercise the WHOLE
723
+ * drain path (buffer → plan → guard → send) against a real ObligationLedger,
724
+ * asserting OUTCOMES: a stale represent is dropped and the ledger closed; a
725
+ * truly-unanswered obligation fires exactly one represent.
726
+ */
727
+ describe('redeliverBufferedInbound + F1 delivery-time represent re-check', () => {
728
+ const CHAT = 'c1'
729
+ const ORIGIN = 'turn-abc'
730
+ const OPENED_AT = 1_000
731
+
732
+ function representInbound(): InboundMessage {
733
+ return {
734
+ type: 'inbound',
735
+ chatId: CHAT,
736
+ messageId: 5_000,
737
+ user: 'obligation-ledger',
738
+ userId: 0,
739
+ ts: 5_000,
740
+ text: 'You asked earlier and I want to make sure I did not miss it…',
741
+ meta: { source: 'obligation_represent', origin_turn_id: ORIGIN },
742
+ }
743
+ }
744
+
745
+ function openLedger(): ObligationLedger {
746
+ const l = new ObligationLedger()
747
+ l.openIfAbsent({
748
+ originTurnId: ORIGIN,
749
+ chatId: CHAT,
750
+ messageId: 42,
751
+ text: 'original question',
752
+ openedAt: OPENED_AT,
753
+ })
754
+ return l
755
+ }
756
+
757
+ function guardFor(
758
+ ledger: ObligationLedger,
759
+ hasOutboundDeliveredSince: (chatId: string, sinceMs: number) => boolean,
760
+ ) {
761
+ return makeRepresentRedeliveryGuard({
762
+ enabled: true,
763
+ historyEnabled: true,
764
+ ledger,
765
+ hasOutboundDeliveredSince: (chatId, sinceMs) => hasOutboundDeliveredSince(chatId, sinceMs),
766
+ minReplyChars: 1,
767
+ log: () => {},
768
+ })
769
+ }
770
+
771
+ it('drops a buffered represent whose reply landed since decision, and closes the ledger', () => {
772
+ const ledger = openLedger()
773
+ // A real answer was delivered AT/AFTER the obligation's cutoff (openedAt).
774
+ const buf = createPendingInboundBuffer({
775
+ log: () => {},
776
+ beforeRedeliver: guardFor(ledger, (_chat, sinceMs) => sinceMs <= 2_000),
777
+ })
778
+ buf.push('a', representInbound())
779
+
780
+ let sends = 0
781
+ const r = redeliverBufferedInbound(buf, 'a', () => {
782
+ sends++
783
+ return true
784
+ })
785
+
786
+ expect(sends).toBe(0) // never handed to the CLI bridge
787
+ expect(r.drained).toBe(1)
788
+ expect(r.retracted).toBe(1)
789
+ expect(r.redelivered).toBe(0)
790
+ expect(r.rebuffered).toBe(0)
791
+ expect(ledger.isOpen(ORIGIN)).toBe(false) // F1 closed the stale obligation
792
+ expect(buf.depth('a')).toBe(0) // dropped, not re-buffered
793
+ })
794
+
795
+ it('delivers exactly one represent for a truly-unanswered obligation (no outbound row)', () => {
796
+ const ledger = openLedger()
797
+ // Plain-text-no-reply / genuinely unanswered: no outbound recorded since cutoff.
798
+ const buf = createPendingInboundBuffer({
799
+ log: () => {},
800
+ beforeRedeliver: guardFor(ledger, () => false),
801
+ })
802
+ buf.push('a', representInbound())
803
+
804
+ let sends = 0
805
+ const r = redeliverBufferedInbound(buf, 'a', () => {
806
+ sends++
807
+ return true
808
+ })
809
+
810
+ expect(sends).toBe(1)
811
+ expect(r.drained).toBe(1)
812
+ expect(r.redelivered).toBe(1)
813
+ expect(r.retracted).toBe(0)
814
+ expect(r.rebuffered).toBe(0)
815
+ expect(ledger.isOpen(ORIGIN)).toBe(true) // still open — the represent will be answered
816
+ })
817
+ })
818
+
819
+ /**
820
+ * FAIL-OPEN safety: a `beforeRedeliver` predicate that THROWS must never silence
821
+ * or lose a real message, nor abort the drain loop. The throw is swallowed, the
822
+ * message is treated as "deliver", and every following buffered message in the
823
+ * same drain is still delivered. This test fails if the try/catch around the
824
+ * predicate is removed (the throw would propagate out of redeliverBufferedInbound).
825
+ */
826
+ describe('redeliverBufferedInbound — beforeRedeliver fail-open on throw', () => {
827
+ it('delivers the message and the rest of the drain when the predicate throws', () => {
828
+ const buf = createPendingInboundBuffer({
829
+ log: () => {},
830
+ beforeRedeliver: () => {
831
+ throw new Error('guard boom')
832
+ },
833
+ })
834
+ buf.push('a', inbound('cron', 1))
835
+ buf.push('a', inbound('vault_grant_approved', 2))
836
+
837
+ const seen: number[] = []
838
+ // Must not throw — the loop completes past a throwing predicate.
839
+ const r = redeliverBufferedInbound(buf, 'a', (m) => {
840
+ seen.push(m.messageId as number)
841
+ return true
842
+ })
843
+
844
+ expect(seen).toEqual([1, 2]) // both delivered, loop not aborted
845
+ expect(r.drained).toBe(2)
846
+ expect(r.redelivered).toBe(2) // failed open → delivered, not dropped
847
+ expect(r.retracted).toBe(0) // a throw is NOT a retract
848
+ expect(r.rebuffered).toBe(0)
849
+ expect(buf.depth('a')).toBe(0) // nothing stranded
850
+ })
851
+ })
@@ -4,6 +4,12 @@ import {
4
4
  type RepresentGuardObligation,
5
5
  } from "../gateway/represent-guard.js";
6
6
  import { ObligationLedger } from "../gateway/obligation-ledger.js";
7
+ import {
8
+ makeRepresentRedeliveryGuard,
9
+ makeSessionBusyDrainDeferral,
10
+ } from "../gateway/represent-delivery-guard.js";
11
+ import { createObligationWiring } from "../gateway/obligation-wiring.js";
12
+ import type { InboundMessage } from "../gateway/ipc-protocol.js";
7
13
 
8
14
  // Executable verification of the #2472 fix: obligation_represent must NOT re-fire
9
15
  // for an origin_turn_id that has already been answered by a reply since the last
@@ -171,6 +177,250 @@ describe("represent guard — terse genuine reply suppresses the duplicate (#247
171
177
  });
172
178
  });
173
179
 
180
+ // F1 (fix/represent-double-send-delivery-recheck) — the DELIVERY-time re-check.
181
+ // The decision-time guard (shouldSuppressRepresent) is consulted when the sweep
182
+ // buffers a represent, which can be BEFORE the reply exists. This layer re-runs it
183
+ // at the moment the buffered represent is handed to the CLI bridge, catching the
184
+ // reply that landed in between (the double-send race).
185
+ describe("makeRepresentRedeliveryGuard — F1 delivery-time represent re-check", () => {
186
+ const MSG_ID = 42;
187
+
188
+ function representInbound(originTurnId: string, chatId: string): InboundMessage {
189
+ return {
190
+ type: "inbound",
191
+ chatId,
192
+ messageId: MSG_ID,
193
+ user: "switchroom",
194
+ userId: 0,
195
+ ts: 1,
196
+ text: "you have an earlier message…",
197
+ meta: { source: "obligation_represent", origin_turn_id: originTurnId, chat_id: chatId },
198
+ };
199
+ }
200
+
201
+ function openRepresented(ledger: ObligationLedger, representedAt: number, openedAt = 1000): void {
202
+ ledger.openIfAbsent({ originTurnId: ORIGIN, chatId: CHAT, messageId: MSG_ID, text: "q", openedAt });
203
+ ledger.markRepresented(ORIGIN, representedAt); // mirror the sweep stamping lastRepresentedAt
204
+ }
205
+
206
+ it("RETRACTS (drops + closes) a represent whose reply landed since the decision", () => {
207
+ const ledger = new ObligationLedger();
208
+ openRepresented(ledger, 2000); // sweep decided this represent at t=2000
209
+ // The real reply was recorded at t=3000 (AFTER the decision) but its routing
210
+ // missed the normal close path — the exact double-send race.
211
+ const guard = makeRepresentRedeliveryGuard({
212
+ enabled: true,
213
+ historyEnabled: true,
214
+ ledger,
215
+ // Chat-scoped: only the obligation's OWN (resolved) chat id has the row —
216
+ // outbound was recorded under the fallback-resolved id, which is o.chatId.
217
+ hasOutboundDeliveredSince: (chat, sinceMs) => chat === CHAT && 3000 >= sinceMs,
218
+ minReplyChars: 1,
219
+ log: () => {},
220
+ });
221
+ expect(guard(representInbound(ORIGIN, CHAT))).toBe(false); // do NOT deliver
222
+ expect(ledger.isOpen(ORIGIN)).toBe(false); // ledger closed by the retract
223
+ });
224
+
225
+ it("RETRACTS a represent whose obligation was already closed since it was buffered", () => {
226
+ const ledger = new ObligationLedger();
227
+ openRepresented(ledger, 2000);
228
+ ledger.close(ORIGIN); // the normal close path DID fire after buffering
229
+ const guard = makeRepresentRedeliveryGuard({
230
+ enabled: true,
231
+ historyEnabled: true,
232
+ ledger,
233
+ hasOutboundDeliveredSince: () => false, // irrelevant — obligation is gone
234
+ minReplyChars: 1,
235
+ log: () => {},
236
+ });
237
+ expect(guard(representInbound(ORIGIN, CHAT))).toBe(false); // stale → drop
238
+ });
239
+
240
+ it("PROCEEDS (delivers once) when NO outbound row exists — plain-text-no-reply (#2788)", () => {
241
+ const ledger = new ObligationLedger();
242
+ openRepresented(ledger, 2000);
243
+ const guard = makeRepresentRedeliveryGuard({
244
+ enabled: true,
245
+ historyEnabled: true,
246
+ ledger,
247
+ hasOutboundDeliveredSince: () => false, // the agent never called the reply tool
248
+ minReplyChars: 1,
249
+ log: () => {},
250
+ });
251
+ expect(guard(representInbound(ORIGIN, CHAT))).toBe(true); // still fires exactly once
252
+ expect(ledger.isOpen(ORIGIN)).toBe(true); // guard did NOT close it
253
+ });
254
+
255
+ it("PROCEEDS when the only reply PREDATES lastRepresentedAt (#2472 second-represent cutoff)", () => {
256
+ const ledger = new ObligationLedger();
257
+ openRepresented(ledger, 5000); // second represent decided at t=5000
258
+ // A reply exists but only at t=3000 — it answered an EARLIER question, not
259
+ // this represent. The delivery re-check must use the obligation's own cutoff
260
+ // (lastRepresentedAt=5000), so this old reply does NOT suppress.
261
+ const guard = makeRepresentRedeliveryGuard({
262
+ enabled: true,
263
+ historyEnabled: true,
264
+ ledger,
265
+ hasOutboundDeliveredSince: (_chat, sinceMs) => 3000 >= sinceMs,
266
+ minReplyChars: 1,
267
+ log: () => {},
268
+ });
269
+ expect(guard(representInbound(ORIGIN, CHAT))).toBe(true); // legitimate new represent fires
270
+ expect(ledger.isOpen(ORIGIN)).toBe(true);
271
+ });
272
+
273
+ it("passes NON-represent inbounds through untouched", () => {
274
+ const ledger = new ObligationLedger();
275
+ openRepresented(ledger, 2000);
276
+ const guard = makeRepresentRedeliveryGuard({
277
+ enabled: true,
278
+ historyEnabled: true,
279
+ ledger,
280
+ hasOutboundDeliveredSince: () => true, // would suppress a represent — but this isn't one
281
+ minReplyChars: 1,
282
+ log: () => {},
283
+ });
284
+ const userMsg: InboundMessage = {
285
+ type: "inbound",
286
+ chatId: CHAT,
287
+ messageId: 7,
288
+ user: "ken",
289
+ userId: 1,
290
+ ts: 1,
291
+ text: "hello",
292
+ };
293
+ expect(guard(userMsg)).toBe(true);
294
+ expect(ledger.isOpen(ORIGIN)).toBe(true);
295
+ });
296
+
297
+ it("is inert when the obligation ledger is disabled", () => {
298
+ const ledger = new ObligationLedger();
299
+ openRepresented(ledger, 2000);
300
+ const guard = makeRepresentRedeliveryGuard({
301
+ enabled: false,
302
+ historyEnabled: true,
303
+ ledger,
304
+ hasOutboundDeliveredSince: () => true,
305
+ minReplyChars: 1,
306
+ log: () => {},
307
+ });
308
+ expect(guard(representInbound(ORIGIN, CHAT))).toBe(true); // never retracts when off
309
+ });
310
+ });
311
+
312
+ // F2 (fix/represent-double-send-delivery-recheck) — a poke-cleared session that is
313
+ // STILL busy must not be treated as idle. Two halves: the bounded drain-defer
314
+ // (drain half) and the sweep decision folding session-busy into the bounded
315
+ // background-work grace (decision half).
316
+ describe("makeSessionBusyDrainDeferral — F2 bounded busy-defer for the idle drain", () => {
317
+ it("defers while busy, then STOPS deferring once the bound elapses (red-team #2: no forever-silence)", () => {
318
+ const defer = makeSessionBusyDrainDeferral(1000);
319
+ expect(defer(true, 0)).toBe(true); // busy → defer the drain
320
+ expect(defer(true, 500)).toBe(true); // still within the bound
321
+ expect(defer(true, 999)).toBe(true);
322
+ expect(defer(true, 1000)).toBe(false); // bound reached → drain anyway (bounded)
323
+ });
324
+
325
+ it("resets the deferral clock whenever the session reads idle", () => {
326
+ const defer = makeSessionBusyDrainDeferral(1000);
327
+ expect(defer(true, 0)).toBe(true);
328
+ expect(defer(false, 400)).toBe(false); // idle → reset
329
+ expect(defer(true, 500)).toBe(true); // fresh window opens at 500
330
+ expect(defer(true, 1499)).toBe(true); // 999ms into the new window
331
+ expect(defer(true, 1500)).toBe(false); // new bound elapsed
332
+ });
333
+
334
+ it("is disabled when the bound is <= 0 (kill switch)", () => {
335
+ const off = makeSessionBusyDrainDeferral(0);
336
+ expect(off(true, 0)).toBe(false);
337
+ expect(off(true, 10_000)).toBe(false);
338
+ });
339
+ });
340
+
341
+ describe("obligationSweep — F2 decision half: a poke-cleared-but-busy session defers, then re-asks (bounded)", () => {
342
+ const GRACE = 20 * 60_000;
343
+ const SWEEP_ORIGIN = "12345:_#77001";
344
+
345
+ function makeWiring(opts: { ledger: ObligationLedger; pushed: InboundMessage[]; sessionBusy: boolean }) {
346
+ const liveTurn = opts.sessionBusy ? ({ turnId: "live", endedAt: null } as any) : null;
347
+ const deps = {
348
+ OBLIGATION_LEDGER_ENABLED: true,
349
+ HISTORY_ENABLED: true,
350
+ OBLIGATION_BACKGROUND_WORK_GRACE_MS: GRACE,
351
+ OBLIGATION_ESCALATE_GRACE_MS: 0,
352
+ OBLIGATION_REPRESENT_GRACE_MS: 0,
353
+ OBLIGATION_REPRESENT_MAX: 2,
354
+ OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS: 1,
355
+ OBLIGATION_ESCALATE_MAX: 3,
356
+ OBLIGATION_ESCALATE_SEND_DEADLINE_MS: 10_000,
357
+ obligationLedger: opts.ledger,
358
+ obligationEscalateInFlight: new Set<string>(),
359
+ pendingCrossTurnGate: { set: () => {} },
360
+ pendingInboundBuffer: {
361
+ depth: () => 0,
362
+ push: (_agent: string, m: InboundMessage) => {
363
+ opts.pushed.push(m);
364
+ return true;
365
+ },
366
+ },
367
+ capturedResume: { dispatch: () => {} },
368
+ getCurrentTurn: () => liveTurn,
369
+ // The ~5-min silence poke has ALREADY cleared the machine turn.
370
+ turnInFlightForGate: () => false,
371
+ agentHasInFlightBackgroundWork: () => false,
372
+ // No reply has been delivered — a genuinely unanswered turn.
373
+ hasOutboundDeliveredSince: () => false,
374
+ findTurnByOriginId: () => undefined,
375
+ bridgeAlive: () => true,
376
+ sendEscalationNudge: () => {},
377
+ };
378
+ // The wiring's dep type is derived from the gateway; the shape above matches
379
+ // the fields the sweep reads.
380
+ return createObligationWiring(deps as unknown as Parameters<typeof createObligationWiring>[0]);
381
+ }
382
+
383
+ function open(ledger: ObligationLedger, openedAt: number): void {
384
+ ledger.openIfAbsent({
385
+ originTurnId: SWEEP_ORIGIN,
386
+ chatId: CHAT,
387
+ messageId: 77001,
388
+ text: "an unanswered question",
389
+ openedAt,
390
+ });
391
+ }
392
+
393
+ it("DEFERS the represent while the session is busy and the obligation is younger than the grace", () => {
394
+ const ledger = new ObligationLedger(2);
395
+ const pushed: InboundMessage[] = [];
396
+ const wiring = makeWiring({ ledger, pushed, sessionBusy: true });
397
+ open(ledger, Date.now()); // age ~0 < GRACE
398
+ wiring.obligationSweep();
399
+ expect(pushed).toHaveLength(0); // deferred — no represent buffered
400
+ expect(ledger.isOpen(SWEEP_ORIGIN)).toBe(true);
401
+ });
402
+
403
+ it("RE-ASKS once the bound expires even though the session is still busy (bounded, not silent)", () => {
404
+ const ledger = new ObligationLedger(2);
405
+ const pushed: InboundMessage[] = [];
406
+ const wiring = makeWiring({ ledger, pushed, sessionBusy: true });
407
+ open(ledger, Date.now() - (GRACE + 60_000)); // age > GRACE → bound expired
408
+ wiring.obligationSweep();
409
+ expect(pushed).toHaveLength(1); // represent fires despite the still-busy session
410
+ expect(pushed[0]?.meta?.source).toBe("obligation_represent");
411
+ });
412
+
413
+ it("does NOT defer when the session is idle (represent fires immediately)", () => {
414
+ const ledger = new ObligationLedger(2);
415
+ const pushed: InboundMessage[] = [];
416
+ const wiring = makeWiring({ ledger, pushed, sessionBusy: false });
417
+ open(ledger, Date.now()); // young, but session is genuinely idle
418
+ wiring.obligationSweep();
419
+ expect(pushed).toHaveLength(1);
420
+ expect(pushed[0]?.meta?.source).toBe("obligation_represent");
421
+ });
422
+ });
423
+
174
424
  describe("represent_count cap is honored by the ledger — a misdetected obligation cannot loop", () => {
175
425
  it("escalates (stops re-presenting) once representCount reaches maxRepresents", () => {
176
426
  const L = new ObligationLedger(2); // maxRepresents = 2