switchroom 0.20.6 → 0.20.8
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.
- package/dist/agent-scheduler/index.js +106 -12
- package/dist/auth-broker/index.js +71 -9
- package/dist/cli/notion-write-pretool.mjs +68 -7
- package/dist/cli/switchroom.js +387 -30
- package/dist/host-control/main.js +72 -10
- package/dist/vault/approvals/kernel-server.js +71 -9
- package/dist/vault/broker/server.js +71 -9
- package/package.json +1 -1
- package/telegram-plugin/bridge/ipc-client.ts +17 -1
- package/telegram-plugin/dist/bridge/bridge.js +5 -2
- package/telegram-plugin/dist/gateway/gateway.js +342 -120
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/gateway/boot-reason.ts +61 -0
- package/telegram-plugin/gateway/cron-session.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +50 -54
- package/telegram-plugin/gateway/narrative-lane.ts +21 -1
- package/telegram-plugin/gateway/obligation-ledger.ts +9 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +19 -4
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +43 -3
- package/telegram-plugin/gateway/represent-delivery-guard.ts +188 -0
- package/telegram-plugin/gateway/stream-render.ts +24 -2
- package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
- package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
- package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +145 -9
- package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
- package/telegram-plugin/tests/represent-guard.test.ts +295 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
- package/telegram-plugin/turn-flush-safety.ts +79 -0
|
@@ -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
|
+
})
|
|
@@ -260,3 +260,69 @@ describe('Part B — handback-while-busy: exactly one card, never a frozen "Queu
|
|
|
260
260
|
expect(rec.edits).toHaveLength(0)
|
|
261
261
|
})
|
|
262
262
|
})
|
|
263
|
+
|
|
264
|
+
describe('Part B — synthetic (fabricated) message ids never 400 the queued card', () => {
|
|
265
|
+
/** Recording bot that enforces the REAL Telegram Bot API contract on
|
|
266
|
+
* `reply_parameters.message_id`: anything non-integer or beyond signed int32
|
|
267
|
+
* is hard-rejected with the exact 400 the live gateway hit
|
|
268
|
+
* (gateway-supervisor.log 2026-08-04, msg=1785846295635) — BEFORE recording,
|
|
269
|
+
* exactly like the wire call. `allow_sending_without_reply` does not bypass
|
|
270
|
+
* the range check, only the message-not-found case. */
|
|
271
|
+
function withTelegramStrictBot(h: Harness) {
|
|
272
|
+
const sends: SendRec[] = []
|
|
273
|
+
let nextId = 9001
|
|
274
|
+
;(h.deps as unknown as { bot: unknown }).bot = {
|
|
275
|
+
api: {
|
|
276
|
+
sendRichMessage: async (chatId: string, msg: { markdown: string }, opts: Record<string, unknown>) => {
|
|
277
|
+
const rp = opts.reply_parameters as { message_id?: unknown } | undefined
|
|
278
|
+
if (rp != null) {
|
|
279
|
+
const mid = rp.message_id
|
|
280
|
+
if (typeof mid !== 'number' || !Number.isInteger(mid) || mid <= 0 || mid >= 2 ** 31) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Call to 'sendRichMessage' failed! (400: Bad Request: field "message_id" must be a valid Number)`,
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const id = nextId++
|
|
287
|
+
sends.push({ chatId, markdown: msg.markdown, opts, id })
|
|
288
|
+
return { message_id: id }
|
|
289
|
+
},
|
|
290
|
+
editMessageText: async () => true,
|
|
291
|
+
deleteMessage: async () => true,
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
return { sends }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
it('sends the queued card UNANCHORED (no 400) when the parked message id is a fabricated Date.now() timestamp', async () => {
|
|
298
|
+
const h = makeHarness()
|
|
299
|
+
const rec = withTelegramStrictBot(h)
|
|
300
|
+
|
|
301
|
+
// Turn A mints on an idle session.
|
|
302
|
+
handleSessionEvent(h.deps, enqueue('501'))
|
|
303
|
+
expect(rec.sends).toHaveLength(0)
|
|
304
|
+
|
|
305
|
+
// A synthetic enqueue (subagent handback / boot resume) parks mid-turn with
|
|
306
|
+
// a fabricated ms-timestamp message id — finite, but NOT a Telegram id.
|
|
307
|
+
handleSessionEvent(h.deps, enqueue('1785846295635', 'handback: worker done'))
|
|
308
|
+
await settle()
|
|
309
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
310
|
+
|
|
311
|
+
// The card SENT (no 400 — RED on the pre-fix guard, which forwarded the
|
|
312
|
+
// 13-digit id into reply_parameters and lost the whole card)…
|
|
313
|
+
expect(rec.sends).toHaveLength(1)
|
|
314
|
+
// …and it sent WITHOUT reply-linkage: no reply_parameters at all.
|
|
315
|
+
expect(rec.sends[0]!.opts.reply_parameters).toBeUndefined()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('still reply-anchors when the parked message id is a real Telegram id', async () => {
|
|
319
|
+
const h = makeHarness()
|
|
320
|
+
const rec = withTelegramStrictBot(h)
|
|
321
|
+
|
|
322
|
+
handleSessionEvent(h.deps, enqueue('501'))
|
|
323
|
+
handleSessionEvent(h.deps, enqueue('502', 'real mid-turn message'))
|
|
324
|
+
await settle()
|
|
325
|
+
expect(rec.sends).toHaveLength(1)
|
|
326
|
+
expect((rec.sends[0]!.opts.reply_parameters as { message_id: number }).message_id).toBe(502)
|
|
327
|
+
})
|
|
328
|
+
})
|
|
@@ -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,295 @@ 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
|
+
it("starts a FRESH bound for a new deferral episode after a call gap — a buffer emptied by bridge re-register (no busy=false call) must not pin a stale t0 (#4341 follow-up)", () => {
|
|
341
|
+
const BOUND = 20_000; // small bound so a realistic 5s poll cadence spans it
|
|
342
|
+
const defer = makeSessionBusyDrainDeferral(BOUND); // default staleGap = 15s
|
|
343
|
+
|
|
344
|
+
// Episode A: a represent is buffered while the session is busy. The idle
|
|
345
|
+
// drain gate polls it every ~5s (< staleGap) and defers each time.
|
|
346
|
+
expect(defer(true, 0)).toBe(true); // t0 = 0
|
|
347
|
+
expect(defer(true, 5_000)).toBe(true);
|
|
348
|
+
expect(defer(true, 10_000)).toBe(true);
|
|
349
|
+
|
|
350
|
+
// The buffer is now emptied by a bridge re-register (onClientRegistered)
|
|
351
|
+
// while the session is STILL busy. That drain path does NOT consult this
|
|
352
|
+
// predicate, so there is no busy=false call — deferringSince stays at 0 on
|
|
353
|
+
// the buggy code. Time then advances well past the bound with the buffer
|
|
354
|
+
// empty (predicate not called).
|
|
355
|
+
|
|
356
|
+
// Episode B: a brand-new represent is buffered while the session is busy,
|
|
357
|
+
// long after t0. This is a NEW mid-answer session — the represent MUST stay
|
|
358
|
+
// deferred (its own bound has not elapsed), NOT be drained immediately.
|
|
359
|
+
// Buggy code: now - stalePinnedT0 (2_000_000 - 0) >= BOUND → false → the
|
|
360
|
+
// represent is drained into the mid-answer session, reopening the duplicate
|
|
361
|
+
// window. Fixed code: the >15s call gap starts a fresh episode clock at
|
|
362
|
+
// t=2_000_000, so it defers.
|
|
363
|
+
const B0 = 2_000_000;
|
|
364
|
+
expect(defer(true, B0)).toBe(true);
|
|
365
|
+
// ...and the fresh episode is still bounded from ITS OWN start, polled at the
|
|
366
|
+
// real ~5s cadence (each gap < staleGap, so no further reset).
|
|
367
|
+
expect(defer(true, B0 + 5_000)).toBe(true);
|
|
368
|
+
expect(defer(true, B0 + 10_000)).toBe(true);
|
|
369
|
+
expect(defer(true, B0 + 15_000)).toBe(true);
|
|
370
|
+
expect(defer(true, B0 + BOUND)).toBe(false); // fresh bound elapsed → drains
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("does NOT reset the clock on the normal poll cadence — a genuinely wedged busy session still drains at the bound (wedge budget preserved)", () => {
|
|
374
|
+
const BOUND = 20_000;
|
|
375
|
+
const defer = makeSessionBusyDrainDeferral(BOUND); // default staleGap = 15s
|
|
376
|
+
// Polls arrive every 5s (< staleGap) throughout one continuous episode, so
|
|
377
|
+
// the clock is never reset and the bound elapses on schedule.
|
|
378
|
+
expect(defer(true, 0)).toBe(true);
|
|
379
|
+
expect(defer(true, 5_000)).toBe(true);
|
|
380
|
+
expect(defer(true, 10_000)).toBe(true);
|
|
381
|
+
expect(defer(true, 15_000)).toBe(true);
|
|
382
|
+
expect(defer(true, 20_000)).toBe(false); // bound reached — drains, not silenced forever
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe("obligationSweep — F2 decision half: a poke-cleared-but-busy session defers, then re-asks (bounded)", () => {
|
|
387
|
+
const GRACE = 20 * 60_000;
|
|
388
|
+
const SWEEP_ORIGIN = "12345:_#77001";
|
|
389
|
+
|
|
390
|
+
function makeWiring(opts: { ledger: ObligationLedger; pushed: InboundMessage[]; sessionBusy: boolean }) {
|
|
391
|
+
const liveTurn = opts.sessionBusy ? ({ turnId: "live", endedAt: null } as any) : null;
|
|
392
|
+
const deps = {
|
|
393
|
+
OBLIGATION_LEDGER_ENABLED: true,
|
|
394
|
+
HISTORY_ENABLED: true,
|
|
395
|
+
OBLIGATION_BACKGROUND_WORK_GRACE_MS: GRACE,
|
|
396
|
+
OBLIGATION_ESCALATE_GRACE_MS: 0,
|
|
397
|
+
OBLIGATION_REPRESENT_GRACE_MS: 0,
|
|
398
|
+
OBLIGATION_REPRESENT_MAX: 2,
|
|
399
|
+
OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS: 1,
|
|
400
|
+
OBLIGATION_ESCALATE_MAX: 3,
|
|
401
|
+
OBLIGATION_ESCALATE_SEND_DEADLINE_MS: 10_000,
|
|
402
|
+
obligationLedger: opts.ledger,
|
|
403
|
+
obligationEscalateInFlight: new Set<string>(),
|
|
404
|
+
pendingCrossTurnGate: { set: () => {} },
|
|
405
|
+
pendingInboundBuffer: {
|
|
406
|
+
depth: () => 0,
|
|
407
|
+
push: (_agent: string, m: InboundMessage) => {
|
|
408
|
+
opts.pushed.push(m);
|
|
409
|
+
return true;
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
capturedResume: { dispatch: () => {} },
|
|
413
|
+
getCurrentTurn: () => liveTurn,
|
|
414
|
+
// The ~5-min silence poke has ALREADY cleared the machine turn.
|
|
415
|
+
turnInFlightForGate: () => false,
|
|
416
|
+
agentHasInFlightBackgroundWork: () => false,
|
|
417
|
+
// No reply has been delivered — a genuinely unanswered turn.
|
|
418
|
+
hasOutboundDeliveredSince: () => false,
|
|
419
|
+
findTurnByOriginId: () => undefined,
|
|
420
|
+
bridgeAlive: () => true,
|
|
421
|
+
sendEscalationNudge: () => {},
|
|
422
|
+
};
|
|
423
|
+
// The wiring's dep type is derived from the gateway; the shape above matches
|
|
424
|
+
// the fields the sweep reads.
|
|
425
|
+
return createObligationWiring(deps as unknown as Parameters<typeof createObligationWiring>[0]);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function open(ledger: ObligationLedger, openedAt: number): void {
|
|
429
|
+
ledger.openIfAbsent({
|
|
430
|
+
originTurnId: SWEEP_ORIGIN,
|
|
431
|
+
chatId: CHAT,
|
|
432
|
+
messageId: 77001,
|
|
433
|
+
text: "an unanswered question",
|
|
434
|
+
openedAt,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
it("DEFERS the represent while the session is busy and the obligation is younger than the grace", () => {
|
|
439
|
+
const ledger = new ObligationLedger(2);
|
|
440
|
+
const pushed: InboundMessage[] = [];
|
|
441
|
+
const wiring = makeWiring({ ledger, pushed, sessionBusy: true });
|
|
442
|
+
open(ledger, Date.now()); // age ~0 < GRACE
|
|
443
|
+
wiring.obligationSweep();
|
|
444
|
+
expect(pushed).toHaveLength(0); // deferred — no represent buffered
|
|
445
|
+
expect(ledger.isOpen(SWEEP_ORIGIN)).toBe(true);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("RE-ASKS once the bound expires even though the session is still busy (bounded, not silent)", () => {
|
|
449
|
+
const ledger = new ObligationLedger(2);
|
|
450
|
+
const pushed: InboundMessage[] = [];
|
|
451
|
+
const wiring = makeWiring({ ledger, pushed, sessionBusy: true });
|
|
452
|
+
open(ledger, Date.now() - (GRACE + 60_000)); // age > GRACE → bound expired
|
|
453
|
+
wiring.obligationSweep();
|
|
454
|
+
expect(pushed).toHaveLength(1); // represent fires despite the still-busy session
|
|
455
|
+
expect(pushed[0]?.meta?.source).toBe("obligation_represent");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("does NOT defer when the session is idle (represent fires immediately)", () => {
|
|
459
|
+
const ledger = new ObligationLedger(2);
|
|
460
|
+
const pushed: InboundMessage[] = [];
|
|
461
|
+
const wiring = makeWiring({ ledger, pushed, sessionBusy: false });
|
|
462
|
+
open(ledger, Date.now()); // young, but session is genuinely idle
|
|
463
|
+
wiring.obligationSweep();
|
|
464
|
+
expect(pushed).toHaveLength(1);
|
|
465
|
+
expect(pushed[0]?.meta?.source).toBe("obligation_represent");
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
174
469
|
describe("represent_count cap is honored by the ledger — a misdetected obligation cannot loop", () => {
|
|
175
470
|
it("escalates (stops re-presenting) once representCount reaches maxRepresents", () => {
|
|
176
471
|
const L = new ObligationLedger(2); // maxRepresents = 2
|