switchroom 0.15.10 → 0.15.12

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.
@@ -546,6 +546,63 @@ export function getRecentOutboundCount(
546
546
  return row?.cnt ?? 0
547
547
  }
548
548
 
549
+ /**
550
+ * Returns true if at least one SUBSTANTIVE outbound (bot → user, role='assistant')
551
+ * message was delivered to `chatId` (and optionally `threadId`) AFTER `sinceMs`
552
+ * (wall-clock epoch milliseconds). Used by the obligation sweep to suppress a false
553
+ * "I may have missed this" escalation when the agent visibly answered: if a
554
+ * substantive outbound landed since the obligation was opened, the obligation is
555
+ * stale — close it silently rather than alarming the user.
556
+ *
557
+ * SUBSTANTIVE: we never suppress escalation on a bare ack ("on it", "give me a
558
+ * sec") — an agent that acks then ghosts must still escalate. The history schema
559
+ * does not store a done/substantive flag, so we approximate: a row counts only
560
+ * when LENGTH(text) >= 200 (the FINAL_ANSWER_MIN_CHARS constant from
561
+ * final-answer-detect.ts). This is false-negative-safe: a genuine substantive
562
+ * answer that happens to be < 200 chars will still fire an escalation, which is
563
+ * the conservative (safe) outcome. A schema column would be more precise but is
564
+ * disproportionate for this predicate; the reviewer accepted this approach.
565
+ *
566
+ * `threadId` semantics:
567
+ * - undefined → any message in the chat regardless of thread (DMs + supergroups)
568
+ * - explicit number → only that thread (precise for supergroups with topics)
569
+ * - explicit null → only chat-root (non-thread) messages
570
+ *
571
+ * Falls back to false (safe: never suppresses escalation) if history is not yet
572
+ * initialised or the query fails.
573
+ */
574
+ export function hasOutboundDeliveredSince(
575
+ chatId: string,
576
+ sinceMs: number,
577
+ threadId?: number | null,
578
+ ): boolean {
579
+ try {
580
+ const cutoffSec = Math.floor(sinceMs / 1000)
581
+ const params: unknown[] = [chatId, cutoffSec]
582
+ // LENGTH(text) >= 200 scopes to substantive replies only — never suppress
583
+ // escalation on a mere ack. Mirrors FINAL_ANSWER_MIN_CHARS (200) from
584
+ // final-answer-detect.ts; the `done` flag is not stored in the history
585
+ // schema, so length is the closest available proxy.
586
+ let sql =
587
+ "SELECT 1 FROM messages WHERE chat_id = ? AND role = 'assistant' AND ts >= ? AND LENGTH(text) >= 200"
588
+ if (threadId !== undefined) {
589
+ if (threadId === null) {
590
+ sql += ' AND thread_id IS NULL'
591
+ } else {
592
+ sql += ' AND thread_id = ?'
593
+ params.push(threadId)
594
+ }
595
+ }
596
+ sql += ' LIMIT 1'
597
+ const row = requireDb()
598
+ .prepare(sql)
599
+ .get(...(params as [unknown, ...unknown[]])) as Record<string, unknown> | undefined
600
+ return row != null
601
+ } catch {
602
+ return false
603
+ }
604
+ }
605
+
549
606
  export function query(opts: QueryOptions): RecordedMessage[] {
550
607
  const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT))
551
608
  const params: unknown[] = [opts.chat_id]
@@ -29,7 +29,7 @@ describe('request_secret — gateway wiring', () => {
29
29
  })
30
30
 
31
31
  it('is allow-listed and dispatched', () => {
32
- expect(gw).toMatch(/'request_secret',\n\]\)/)
32
+ expect(gw).toMatch(/'request_secret',\n/)
33
33
  expect(gw).toMatch(/case 'request_secret':\s*\n\s*return executeRequestSecret\(args\)/)
34
34
  })
35
35
 
@@ -10,6 +10,7 @@ import {
10
10
  query,
11
11
  getRecentOutboundCount,
12
12
  getLatestInboundMessageId,
13
+ hasOutboundDeliveredSince,
13
14
  _resetForTests,
14
15
  } from '../history.js'
15
16
 
@@ -363,6 +364,88 @@ describe('getRecentOutboundCount (backstop dedup helper)', () => {
363
364
  })
364
365
  })
365
366
 
367
+ // A substantive reply: 200+ chars (the FINAL_ANSWER_MIN_CHARS threshold).
368
+ const SUBSTANTIVE = 'A'.repeat(200)
369
+ // A non-substantive ack: short (<200 chars).
370
+ const ACK = 'On it.'
371
+
372
+ describe('hasOutboundDeliveredSince', () => {
373
+ beforeEach(() => initHistory(stateDir, 30))
374
+
375
+ it('returns true when a substantive outbound exists after openedAt', () => {
376
+ const openedAt = 1_000_000 * 1000 // ms
377
+ recordOutbound({
378
+ chat_id: '-100',
379
+ thread_id: null,
380
+ message_ids: [10],
381
+ texts: [SUBSTANTIVE],
382
+ ts: 1_000_001, // sec — 1s after openedAt
383
+ })
384
+ expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(true)
385
+ })
386
+
387
+ it('returns false when the only outbound is BEFORE openedAt', () => {
388
+ const openedAt = 1_000_002 * 1000 // ms — after the message
389
+ recordOutbound({
390
+ chat_id: '-100',
391
+ thread_id: null,
392
+ message_ids: [10],
393
+ texts: [SUBSTANTIVE],
394
+ ts: 1_000_001, // sec — before openedAt
395
+ })
396
+ expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(false)
397
+ })
398
+
399
+ it('returns false for a non-substantive ack after openedAt (blocker regression)', () => {
400
+ // An agent that sends a short ack ("on it") then ghosts must NOT have
401
+ // its escalation suppressed. The predicate must never match a bare ack.
402
+ const openedAt = 1_000_000 * 1000
403
+ recordOutbound({
404
+ chat_id: '-100',
405
+ thread_id: null,
406
+ message_ids: [10],
407
+ texts: [ACK], // < 200 chars — non-substantive
408
+ ts: 1_000_001,
409
+ })
410
+ expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(false)
411
+ })
412
+
413
+ it('thread_id=undefined matches any thread (DM semantics)', () => {
414
+ const openedAt = 1_000_000 * 1000
415
+ recordOutbound({
416
+ chat_id: '-100',
417
+ thread_id: 5,
418
+ message_ids: [10],
419
+ texts: [SUBSTANTIVE],
420
+ ts: 1_000_001,
421
+ })
422
+ // No thread filter → should find it
423
+ expect(hasOutboundDeliveredSince('-100', openedAt, undefined)).toBe(true)
424
+ })
425
+
426
+ it('thread_id=number scopes to that thread only', () => {
427
+ const openedAt = 1_000_000 * 1000
428
+ recordOutbound({ chat_id: '-100', thread_id: 5, message_ids: [10], texts: [SUBSTANTIVE], ts: 1_000_001 })
429
+ expect(hasOutboundDeliveredSince('-100', openedAt, 5)).toBe(true)
430
+ expect(hasOutboundDeliveredSince('-100', openedAt, 6)).toBe(false)
431
+ })
432
+
433
+ it('thread_id=null matches only chat-root (non-thread) messages', () => {
434
+ const openedAt = 1_000_000 * 1000
435
+ recordOutbound({ chat_id: '-100', thread_id: null, message_ids: [10], texts: [SUBSTANTIVE], ts: 1_000_001 })
436
+ expect(hasOutboundDeliveredSince('-100', openedAt, null)).toBe(true)
437
+ // A thread-scoped message should NOT match the root filter
438
+ recordOutbound({ chat_id: '-100', thread_id: 3, message_ids: [11], texts: [SUBSTANTIVE], ts: 1_000_002 })
439
+ expect(hasOutboundDeliveredSince('-100', openedAt, null)).toBe(true) // root still there
440
+ expect(hasOutboundDeliveredSince('-100', openedAt, 3)).toBe(true) // thread 3 also there
441
+ expect(hasOutboundDeliveredSince('-100', openedAt, 9)).toBe(false) // thread 9 not there
442
+ })
443
+
444
+ it('returns false when no history is present for the chat', () => {
445
+ expect(hasOutboundDeliveredSince('-999', 0)).toBe(false)
446
+ })
447
+ })
448
+
366
449
  describe('secret redaction at persistence (both directions)', () => {
367
450
  beforeEach(() => initHistory(stateDir, 30))
368
451
 
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import {
4
+ emitLinearAgentActivity,
5
+ type LinearTokenResult,
6
+ } from '../gateway/linear-activity.js'
7
+
8
+ /**
9
+ * Tests for the `linear_agent_activity` MCP tool (#2298).
10
+ *
11
+ * Structural part: assert the tool is declared in bridge/bridge.ts and
12
+ * allow-listed + dispatched in gateway/gateway.ts (the gateway IIFE can't be
13
+ * imported in a test, so wiring is verified by reading the source — same
14
+ * constraint as gateway-request-secret.test.ts).
15
+ *
16
+ * Behavioural part: the activity-emit logic lives in gateway/linear-activity.ts
17
+ * with injectable token-resolver + fetch, so the happy path and the
18
+ * vault-denied path are exercised without a broker or the network.
19
+ */
20
+
21
+ const okToken = (token: string) => async (): Promise<LinearTokenResult> => ({ ok: true, token })
22
+
23
+ function fakeFetch(status: number, jsonBody: unknown): {
24
+ fetchImpl: typeof fetch
25
+ calls: Array<{ url: string; init?: RequestInit }>
26
+ } {
27
+ const calls: Array<{ url: string; init?: RequestInit }> = []
28
+ const fetchImpl = (async (url: string, init?: RequestInit) => {
29
+ calls.push({ url, init })
30
+ return {
31
+ ok: status >= 200 && status < 300,
32
+ status,
33
+ json: async () => jsonBody,
34
+ text: async () => JSON.stringify(jsonBody),
35
+ } as unknown as Response
36
+ }) as unknown as typeof fetch
37
+ return { fetchImpl, calls }
38
+ }
39
+
40
+ describe('linear_agent_activity — gateway wiring (#2298)', () => {
41
+ const gw = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
42
+ const bridge = readFileSync(new URL('../bridge/bridge.ts', import.meta.url), 'utf8')
43
+
44
+ it('declares the MCP tool with required {agent_session_id,type}', () => {
45
+ const idx = bridge.indexOf(`name: 'linear_agent_activity'`)
46
+ expect(idx).toBeGreaterThan(0)
47
+ const schema = bridge.slice(idx, idx + 2000)
48
+ expect(schema).toMatch(/required: \['agent_session_id', 'type'\]/)
49
+ expect(schema).toMatch(/thought/)
50
+ expect(schema).toMatch(/complete/)
51
+ })
52
+
53
+ it('is allow-listed and dispatched', () => {
54
+ expect(gw).toMatch(/'linear_agent_activity',\n\]\)/)
55
+ expect(gw).toMatch(/case 'linear_agent_activity':\s*\n\s*return executeLinearAgentActivity\(args\)/)
56
+ })
57
+ })
58
+
59
+ describe('emitLinearAgentActivity — behaviour (#2298)', () => {
60
+ it('POSTs an agentActivityCreate mutation on the happy path', async () => {
61
+ const { fetchImpl, calls } = fakeFetch(200, { data: { agentActivityCreate: { success: true } } })
62
+ const r = await emitLinearAgentActivity(
63
+ { agent_session_id: 'sess_1', type: 'thought', body: 'On it.' },
64
+ { agent: 'carrie', resolveToken: okToken('lin_tok'), fetchImpl, log: () => {} },
65
+ )
66
+ expect(r.content[0].text).toMatch(/emitted on session sess_1/)
67
+ expect(calls).toHaveLength(1)
68
+ expect(calls[0].url).toBe('https://api.linear.app/graphql')
69
+ const sent = JSON.parse(calls[0].init!.body as string)
70
+ expect(sent.query).toMatch(/agentActivityCreate/)
71
+ expect(sent.variables.input.agentSessionId).toBe('sess_1')
72
+ expect(sent.variables.input.content).toEqual({ type: 'thought', body: 'On it.' })
73
+ expect((calls[0].init!.headers as Record<string, string>).Authorization).toBe('lin_tok')
74
+ })
75
+
76
+ it('allows complete with no body', async () => {
77
+ const { fetchImpl } = fakeFetch(200, { data: { agentActivityCreate: { success: true } } })
78
+ const r = await emitLinearAgentActivity(
79
+ { agent_session_id: 'sess_2', type: 'complete' },
80
+ { agent: 'carrie', resolveToken: okToken('lin_tok'), fetchImpl, log: () => {} },
81
+ )
82
+ expect(r.content[0].text).toMatch(/complete emitted/)
83
+ })
84
+
85
+ it('requires body for thought/message/error', async () => {
86
+ await expect(
87
+ emitLinearAgentActivity(
88
+ { agent_session_id: 'sess_3', type: 'message' },
89
+ { resolveToken: okToken('t'), log: () => {} },
90
+ ),
91
+ ).rejects.toThrow(/body is required/)
92
+ })
93
+
94
+ it('rejects an unknown type', async () => {
95
+ await expect(
96
+ emitLinearAgentActivity(
97
+ { agent_session_id: 'sess_4', type: 'banana', body: 'x' },
98
+ { resolveToken: okToken('t'), log: () => {} },
99
+ ),
100
+ ).rejects.toThrow(/type must be one of/)
101
+ })
102
+
103
+ it('returns vault_request_access guidance when the token is denied', async () => {
104
+ const r = await emitLinearAgentActivity(
105
+ { agent_session_id: 'sess_5', type: 'thought', body: 'hi' },
106
+ {
107
+ agent: 'carrie',
108
+ resolveToken: async () => ({ ok: false, reason: 'denied' }),
109
+ log: () => {},
110
+ },
111
+ )
112
+ expect(r.content[0].text).toMatch(/vault_request_access/)
113
+ expect(r.content[0].text).toMatch(/linear\/carrie\/token/)
114
+ })
115
+
116
+ it('surfaces a Linear API error status', async () => {
117
+ const { fetchImpl } = fakeFetch(401, { error: 'bad token' })
118
+ const r = await emitLinearAgentActivity(
119
+ { agent_session_id: 'sess_6', type: 'thought', body: 'hi' },
120
+ { agent: 'carrie', resolveToken: okToken('lin_tok'), fetchImpl, log: () => {} },
121
+ )
122
+ expect(r.content[0].text).toMatch(/Linear API 401/)
123
+ })
124
+ })
@@ -113,6 +113,46 @@ describe("isValidModelArg", () => {
113
113
  });
114
114
  });
115
115
 
116
+ // Regression for the 2026-06-13 fleet outage: defaults.model was pinned to
117
+ // the full codename `claude-fable-5`, which Anthropic retired server-side →
118
+ // every agent 4xx'd. The fix is to select models by ALIAS (durable) instead
119
+ // of pinned ids. This locks in that `fable` (and the other aliases) stay
120
+ // selectable, and documents the alias-vs-codename distinction.
121
+ describe("model selection: aliases stay selectable (incl. fable)", () => {
122
+ it("lists fable as a first-class alias", () => {
123
+ // `fable` is the latest flagship (Fable 5) and must remain pickable.
124
+ expect(MODEL_ALIASES).toContain("fable");
125
+ // The standard set is intact alongside it.
126
+ for (const a of ["opus", "sonnet", "haiku", "default"]) {
127
+ expect(MODEL_ALIASES, a).toContain(a);
128
+ }
129
+ });
130
+
131
+ it("each alias is a valid model arg and parses as a set", () => {
132
+ for (const alias of MODEL_ALIASES) {
133
+ expect(isValidModelArg(alias), alias).toBe(true);
134
+ expect(parseModelCommand(`/model ${alias}`)).toEqual({ kind: "set", model: alias });
135
+ }
136
+ });
137
+
138
+ it("the help text surfaces the fable alias", async () => {
139
+ const reply = await handleModelCommand({ kind: "help" }, makeDeps());
140
+ expect(reply.text).toContain("fable");
141
+ });
142
+
143
+ it("passthrough: a full id (incl. the retired claude-fable-5 codename) is shape-accepted, not allowlisted", () => {
144
+ // switchroom does NOT allowlist models — the SHAPE gate passes any
145
+ // well-formed id through to claude, which is the sole validator. So the
146
+ // retired `claude-fable-5` codename still parses here (it just 4xx's at
147
+ // claude); selection flexibility (any current/future model) is preserved.
148
+ expect(parseModelCommand("/model claude-fable-5")).toEqual({
149
+ kind: "set",
150
+ model: "claude-fable-5",
151
+ });
152
+ expect(isValidModelArg("claude-fable-5")).toBe(true);
153
+ });
154
+ });
155
+
116
156
  describe("handleModelCommand — show / help never inject (picker-wedge guard)", () => {
117
157
  it("show renders configured model + switch options without injecting", async () => {
118
158
  const { deps, calls } = makeDeps();
@@ -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 = {