switchroom 0.15.9 → 0.15.11

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.
@@ -105,14 +105,26 @@ describe("ObligationLedger", () => {
105
105
  expect(L.resolveCloseTarget(undefined, "c:3#715")).toBe("c:3#715");
106
106
  });
107
107
 
108
- it("no echo + MULTIPLE open → close NOTHING (never wrong-close/drop)", () => {
108
+ it("no echo + MULTIPLE open + live turn IS open → close the live turn's OWN obligation (Fix 2)", () => {
109
109
  const L = new ObligationLedger();
110
110
  L.openIfAbsent(input("c:635#713", 1000));
111
111
  L.openIfAbsent(input("c:3#715", 1100));
112
- // the marko race: 713's un-echoed reply lands while currentTurn=715.
113
- // Closing 715 would silently drop it resolveCloseTarget refuses.
114
- expect(L.resolveCloseTarget(undefined, "c:3#715")).toBeNull();
115
- expect(L.isOpen("c:3#715")).toBe(true); // 715 stays open → re-presented
112
+ // A substantive reply delivered during currentTurn=715 (no model echo, no
113
+ // routed origin) closes 715's own obligation. 713 stays open and is
114
+ // re-presented. The 713/715 invariant holds: this does NOT close 713.
115
+ expect(L.resolveCloseTarget(undefined, "c:3#715")).toBe("c:3#715");
116
+ expect(L.isOpen("c:635#713")).toBe(true); // 713 stays open
117
+ });
118
+
119
+ it("no echo + MULTIPLE open + live turn NOT one of them → close nothing (713/715 invariant preserved)", () => {
120
+ const L = new ObligationLedger();
121
+ L.openIfAbsent(input("c:635#713", 1000));
122
+ L.openIfAbsent(input("c:3#715", 1100));
123
+ // currentTurn=999 is NOT an open obligation. We can't identify the right
124
+ // target → refuse to close anything (a re-present is safer than a wrong close).
125
+ expect(L.resolveCloseTarget(undefined, "c:9#999")).toBeNull();
126
+ expect(L.isOpen("c:635#713")).toBe(true);
127
+ expect(L.isOpen("c:3#715")).toBe(true);
116
128
  });
117
129
 
118
130
  it("no echo + live turn not an open obligation → null", () => {
@@ -120,6 +132,35 @@ describe("ObligationLedger", () => {
120
132
  L.openIfAbsent(input("c:3#715", 1100));
121
133
  expect(L.resolveCloseTarget(undefined, "c:9#999")).toBeNull();
122
134
  });
135
+
136
+ it("routedOriginId (Fix 1) closes the routed target even with multiple open + no model echo", () => {
137
+ // Simulate the clerk incident: two obligations open, agent answers #14057
138
+ // via a quoted reply (via=quoted), no model echo. The gateway resolves
139
+ // routedOriginId=#14057 from the quote. That obligation closes; #14059 stays.
140
+ const L = new ObligationLedger();
141
+ L.openIfAbsent(input("c:0#14057", 1000));
142
+ L.openIfAbsent(input("c:0#14059", 1100));
143
+ expect(L.resolveCloseTarget(undefined, "c:0#14059", "c:0#14057")).toBe("c:0#14057");
144
+ // Close it to confirm only #14057 closes
145
+ L.close("c:0#14057");
146
+ expect(L.isOpen("c:0#14057")).toBe(false);
147
+ expect(L.isOpen("c:0#14059")).toBe(true);
148
+ });
149
+
150
+ it("echoedTurnId wins over routedOriginId when both present (echoed is authoritative)", () => {
151
+ const L = new ObligationLedger();
152
+ L.openIfAbsent(input("c:635#713", 1000));
153
+ L.openIfAbsent(input("c:3#715", 1100));
154
+ // Model explicitly echoed 713 AND router resolved 715 as routed origin.
155
+ // The echo is authoritative — close 713.
156
+ expect(L.resolveCloseTarget("c:635#713", "c:3#715", "c:3#715")).toBe("c:635#713");
157
+ });
158
+
159
+ it("routedOriginId=null falls through to live-turn fallback", () => {
160
+ const L = new ObligationLedger();
161
+ L.openIfAbsent(input("c:3#715", 1100));
162
+ expect(L.resolveCloseTarget(undefined, "c:3#715", null)).toBe("c:3#715");
163
+ });
123
164
  });
124
165
  });
125
166
 
@@ -411,3 +452,170 @@ describe("ObligationLedger — background-work grace (extended-autonomous fix, g
411
452
  ).toBe("represent");
412
453
  });
413
454
  });
455
+
456
+ describe("ObligationLedger — per-represent grace (Fix 3: clerk 2026-06-13 incident)", () => {
457
+ function input(id: string, openedAt: number) {
458
+ return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text: "x", openedAt };
459
+ }
460
+
461
+ const REPR_GRACE = 120_000; // 2 min, mirroring the default
462
+
463
+ it("a freshly re-presented obligation is ineligible until representGraceMs elapses", () => {
464
+ const L = new ObligationLedger(2);
465
+ L.openIfAbsent(input("c:3#1", 1000));
466
+ // Re-present fires at t=5000; markRepresented stamps lastRepresentedAt.
467
+ L.markRepresented("c:3#1", 5000);
468
+ // 10s later — still within 120s represent grace → ineligible → none.
469
+ expect(
470
+ L.decideAtIdle({ now: 15000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
471
+ ).toBe("none");
472
+ // 120s + 1ms after re-present → grace expired → act.
473
+ expect(
474
+ L.decideAtIdle({ now: 5000 + REPR_GRACE + 1, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
475
+ ).toBe("represent");
476
+ });
477
+
478
+ it("markRepresented stamps lastRepresentedAt and persists", () => {
479
+ const snapshots: Obligation[][] = [];
480
+ const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
481
+ L.openIfAbsent(input("c:3#1", 1000));
482
+ L.markRepresented("c:3#1", 9000);
483
+ const snap = L.list()[0];
484
+ expect(snap.lastRepresentedAt).toBe(9000);
485
+ expect(snap.representCount).toBe(1);
486
+ // Persisted: onChange fired for open + markRepresented = 2 snapshots
487
+ expect(snapshots.length).toBe(2);
488
+ expect(snapshots[1][0].lastRepresentedAt).toBe(9000);
489
+ });
490
+
491
+ it("representGraceMs=0 (kill switch) → no per-represent grace, acts immediately", () => {
492
+ const L = new ObligationLedger(2);
493
+ L.openIfAbsent(input("c:3#1", 1000));
494
+ L.markRepresented("c:3#1", 5000);
495
+ // Kill switch: representGraceMs=0 → the freshly-represented obligation is still eligible.
496
+ expect(
497
+ L.decideAtIdle({ now: 5001, graceMs: 45000, representGraceMs: 0 }).action,
498
+ ).toBe("represent");
499
+ });
500
+
501
+ it("an obligation that was NEVER re-presented has no per-represent grace (no lastRepresentedAt)", () => {
502
+ const L = new ObligationLedger(2);
503
+ L.openIfAbsent(input("c:3#1", 1000));
504
+ // No markRepresented call → no lastRepresentedAt → always eligible on this axis.
505
+ expect(
506
+ L.decideAtIdle({ now: 2000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
507
+ ).toBe("represent");
508
+ });
509
+
510
+ it("per-represent grace survives hydration (durable snapshot)", () => {
511
+ const L = new ObligationLedger(2);
512
+ const now = 10_000;
513
+ L.hydrate([
514
+ {
515
+ originTurnId: "c:3#1",
516
+ chatId: "-100123",
517
+ threadId: 3,
518
+ messageId: 1,
519
+ text: "x",
520
+ openedAt: 1000,
521
+ representCount: 1,
522
+ lastRepresentedAt: now - 5000, // represented 5s ago
523
+ },
524
+ ]);
525
+ // 5s after re-present, grace=120s → still within grace → none.
526
+ expect(
527
+ L.decideAtIdle({ now, graceMs: 0, representGraceMs: REPR_GRACE }).action,
528
+ ).toBe("none");
529
+ // 120s + 1ms after re-present → grace expired → escalate (count=1 < max=2 → represent).
530
+ expect(
531
+ L.decideAtIdle({ now: now - 5000 + REPR_GRACE + 1, graceMs: 0, representGraceMs: REPR_GRACE }).action,
532
+ ).toBe("represent");
533
+ });
534
+
535
+ it("per-represent grace composes with trailing-answer grace: both must clear", () => {
536
+ const L = new ObligationLedger(2);
537
+ L.openIfAbsent(input("c:3#1", 1000));
538
+ L.noteTurnEnded("c:3#1", 5000);
539
+ L.markRepresented("c:3#1", 5000);
540
+ // Both graces active; trailing: turn ended 5s ago (grace=45s → in grace);
541
+ // per-represent: re-presented 5s ago (grace=120s → in grace) → none.
542
+ expect(
543
+ L.decideAtIdle({ now: 10000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
544
+ ).toBe("none");
545
+ // Trailing grace cleared (50s after turn-end), per-represent NOT yet (only 45s after re-present).
546
+ expect(
547
+ L.decideAtIdle({ now: 5000 + 50000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
548
+ ).toBe("none");
549
+ // Both cleared (125s after re-present at t=5000 → t=130000).
550
+ expect(
551
+ L.decideAtIdle({ now: 5000 + REPR_GRACE + 5000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
552
+ ).toBe("represent");
553
+ });
554
+
555
+ it("the clerk incident: two messages arrive, re-present fires at T; the sweep ticks again at T+5s → grace prevents premature escalation", () => {
556
+ // This is the exact sequence from clerk 2026-06-13: obligation re-presented
557
+ // at T, sweep fires at T+5s before the re-present turn has even landed, would
558
+ // immediately fire AGAIN (burning representCount to 2 → escalate in 5 more
559
+ // seconds). With per-represent grace, the T+5s tick is a no-op.
560
+ const L = new ObligationLedger(2);
561
+ const T = 1_000_000;
562
+ L.openIfAbsent(input("c:0#14057", T));
563
+ // First represent at T
564
+ L.markRepresented("c:0#14057", T);
565
+ expect(L.list()[0].representCount).toBe(1);
566
+ // Sweep at T+5s: MUST be "none" (within per-represent grace)
567
+ expect(
568
+ L.decideAtIdle({ now: T + 5000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
569
+ ).toBe("none");
570
+ // Sweep at T+10s: still within grace
571
+ expect(
572
+ L.decideAtIdle({ now: T + 10000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
573
+ ).toBe("none");
574
+ // Sweep after grace: eligible for second represent (not escalate — count=1 < max=2)
575
+ expect(
576
+ L.decideAtIdle({ now: T + REPR_GRACE + 1000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
577
+ ).toBe("represent");
578
+ expect(L.list()[0].representCount).toBe(1); // still 1 — decideAtIdle is pure
579
+ });
580
+ });
581
+
582
+ describe("ObligationLedger — escalation suppression predicate (Fix 4)", () => {
583
+ // Fix 4 is implemented in the gateway (obligationSweep checks
584
+ // hasOutboundDeliveredSince before calling driveEscalation). We test the
585
+ // predicate seam via the pattern the sweep uses: if an outbound was delivered
586
+ // since openedAt, the obligation should be closed silently, not escalated.
587
+ // This suite tests the ledger behaviour that enables that path: after a
588
+ // silent close the ledger is empty and the next sweep sees 'none'.
589
+ function input(id: string, openedAt: number) {
590
+ return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text: "x", openedAt };
591
+ }
592
+
593
+ it("a silently-closed obligation (gateway closed on outbound-delivered check) leaves ledger empty", () => {
594
+ const L = new ObligationLedger(2);
595
+ L.openIfAbsent(input("c:3#1", 1000));
596
+ // Gateway's Fix 4 path: outbound delivered since openedAt → close silently
597
+ expect(L.close("c:3#1")).toBe(true);
598
+ expect(L.hasOpen()).toBe(false);
599
+ expect(L.decideAtIdle().action).toBe("none");
600
+ });
601
+
602
+ it("an escalation that reaches maxRepresents WITHOUT any known outbound still proceeds (escalate action)", () => {
603
+ const L = new ObligationLedger(2);
604
+ L.openIfAbsent(input("c:3#1", 1000));
605
+ L.markRepresented("c:3#1");
606
+ L.markRepresented("c:3#1");
607
+ // hasOutboundDeliveredSince returns false (no outbound recorded) → escalate
608
+ const d = L.decideAtIdle({ now: 9_999_999, graceMs: 0, representGraceMs: 0 });
609
+ expect(d.action).toBe("escalate");
610
+ expect(d.obligation?.originTurnId).toBe("c:3#1");
611
+ });
612
+
613
+ it("silent close fires onChange (persists the empty set)", () => {
614
+ const snapshots: Obligation[][] = [];
615
+ const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
616
+ L.openIfAbsent(input("c:3#1", 1000)); // snapshot[0]
617
+ L.close("c:3#1"); // snapshot[1] = []
618
+ expect(snapshots.length).toBe(2);
619
+ expect(snapshots[1]).toEqual([]);
620
+ });
621
+ });
@@ -101,6 +101,23 @@ describe("obligation-store", () => {
101
101
  expect(loaded.map((o) => o.originTurnId)).toEqual(["c:3#715", "c:5#900"]);
102
102
  });
103
103
 
104
+ it("round-trips lastRepresentedAt through parse → isObligationRow filter → hydrate", () => {
105
+ // Regression: isObligationRow only checks required fields; optional fields
106
+ // (lastRepresentedAt, lastTurnEndedAt, escalateAttempts) must survive the
107
+ // filter without being stripped. A missing check would silently drop the field
108
+ // and break the per-represent grace window across restarts.
109
+ const { fs } = memFs();
110
+ const snap: Obligation[] = [
111
+ ob("c:3#715", { representCount: 1, lastRepresentedAt: 1_700_000_000_000, lastTurnEndedAt: 1_700_000_001_000 }),
112
+ ];
113
+ persistObligations(PATH, fs, snap);
114
+ const loaded = loadObligations(PATH, fs);
115
+ expect(loaded).toHaveLength(1);
116
+ expect(loaded[0]!.lastRepresentedAt).toBe(1_700_000_000_000);
117
+ expect(loaded[0]!.lastTurnEndedAt).toBe(1_700_000_001_000);
118
+ expect(loaded[0]!.representCount).toBe(1);
119
+ });
120
+
104
121
  it("never throws on a write failure — degrades to in-memory (logs)", () => {
105
122
  const logs: string[] = [];
106
123
  const fs: ObligationStoreFsSeam = {