switchroom 0.18.11 → 0.18.13
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 +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -542,6 +542,121 @@ describe('hasOutboundDeliveredSince', () => {
|
|
|
542
542
|
})
|
|
543
543
|
})
|
|
544
544
|
|
|
545
|
+
// Review finding H5 regression: the original PRIMARY KEY (chat_id, thread_id, message_id) does
|
|
546
|
+
// NOT dedupe thread-less rows because SQLite treats NULL as distinct from NULL
|
|
547
|
+
// in a UNIQUE/PK index. The documented at-least-once boot replay re-records an
|
|
548
|
+
// already-stored DM/non-topic message, so `INSERT OR REPLACE` appended a
|
|
549
|
+
// DUPLICATE row instead of replacing — over-counting the silence / over-ping
|
|
550
|
+
// detectors. The COALESCE(thread_id,'') unique index makes the upsert
|
|
551
|
+
// idempotent. These tests fail (duplicate rows) without the fix.
|
|
552
|
+
describe('idempotent upsert for null-thread rows (H5)', () => {
|
|
553
|
+
beforeEach(() => initHistory(stateDir, 30))
|
|
554
|
+
|
|
555
|
+
it('re-recording the same null-thread inbound yields exactly ONE row', () => {
|
|
556
|
+
const msg = {
|
|
557
|
+
chat_id: '-100',
|
|
558
|
+
thread_id: null,
|
|
559
|
+
message_id: 7,
|
|
560
|
+
user: 'alice',
|
|
561
|
+
user_id: '111',
|
|
562
|
+
ts: 1000,
|
|
563
|
+
text: 'hello',
|
|
564
|
+
}
|
|
565
|
+
// Simulate the at-least-once replay: record the same message twice.
|
|
566
|
+
recordInbound(msg)
|
|
567
|
+
recordInbound(msg)
|
|
568
|
+
const rows = query({ chat_id: '-100' })
|
|
569
|
+
expect(rows).toHaveLength(1)
|
|
570
|
+
expect(rows[0]).toMatchObject({ message_id: 7, role: 'user', text: 'hello' })
|
|
571
|
+
// The over-count surface the finding calls out must stay accurate.
|
|
572
|
+
expect(getRecentOutboundCount('-100', 999_999)).toBe(0)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('re-recording the same null-thread outbound yields exactly ONE row', () => {
|
|
576
|
+
const now = Math.floor(Date.now() / 1000)
|
|
577
|
+
const send = {
|
|
578
|
+
chat_id: '-100',
|
|
579
|
+
thread_id: null,
|
|
580
|
+
message_ids: [42],
|
|
581
|
+
texts: ['the answer'],
|
|
582
|
+
ts: now,
|
|
583
|
+
}
|
|
584
|
+
recordOutbound(send)
|
|
585
|
+
recordOutbound(send)
|
|
586
|
+
const rows = query({ chat_id: '-100' })
|
|
587
|
+
expect(rows).toHaveLength(1)
|
|
588
|
+
// hasOutboundDeliveredSince / getRecentOutboundCount read this table; a
|
|
589
|
+
// duplicate here would double-count and over-ping.
|
|
590
|
+
expect(getRecentOutboundCount('-100', 60)).toBe(1)
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('a re-record REPLACES rather than appends (newest text wins)', () => {
|
|
594
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'first version' })
|
|
595
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'second version' })
|
|
596
|
+
const rows = query({ chat_id: '-100' })
|
|
597
|
+
expect(rows).toHaveLength(1)
|
|
598
|
+
expect(rows[0]?.text).toBe('second version')
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it('a topic row and a general row with the SAME message_id stay DISTINCT', () => {
|
|
602
|
+
// Same chat_id + message_id, but one is in a forum topic (thread 5) and one
|
|
603
|
+
// is the general/DM row (null thread). These are different logical messages
|
|
604
|
+
// and must both survive — the COALESCE sentinel keeps them separate.
|
|
605
|
+
recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in topic 5' })
|
|
606
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in general' })
|
|
607
|
+
expect(query({ chat_id: '-100' })).toHaveLength(2)
|
|
608
|
+
expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['in topic 5'])
|
|
609
|
+
expect(query({ chat_id: '-100', thread_id: null }).map(r => r.text)).toEqual(['in general'])
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('two distinct forum topics with the same message_id stay DISTINCT', () => {
|
|
613
|
+
recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic5' })
|
|
614
|
+
recordInbound({ chat_id: '-100', thread_id: 6, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic6' })
|
|
615
|
+
expect(query({ chat_id: '-100' })).toHaveLength(2)
|
|
616
|
+
expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic5'])
|
|
617
|
+
expect(query({ chat_id: '-100', thread_id: 6 }).map(r => r.text)).toEqual(['topic6'])
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('migration de-dupes pre-existing null-thread duplicates from a legacy DB', async () => {
|
|
621
|
+
// Simulate a DB written by the pre-fix build: insert duplicate null-thread
|
|
622
|
+
// rows DIRECTLY, bypassing the (now-fixed) upsert, to reproduce the exact
|
|
623
|
+
// corruption the finding describes. Then re-init to fire the migration.
|
|
624
|
+
const { Database } = await import('bun:sqlite')
|
|
625
|
+
const dbPath = join(stateDir, 'history.db')
|
|
626
|
+
const now = Math.floor(Date.now() / 1000) // recent ts so the retention sweep keeps them
|
|
627
|
+
_resetForTests()
|
|
628
|
+
const raw = new Database(dbPath)
|
|
629
|
+
// Drop the logical-key index so we're back to the legacy, dupe-permitting
|
|
630
|
+
// schema, then append two rows sharing the same (chat, null-thread, msg).
|
|
631
|
+
raw.exec('DROP INDEX IF EXISTS idx_messages_logical_key')
|
|
632
|
+
const insert = raw.prepare(
|
|
633
|
+
`INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
|
|
634
|
+
VALUES (?, NULL, ?, 'user', 'a', '1', ?, ?, NULL)`,
|
|
635
|
+
)
|
|
636
|
+
insert.run('-100', 7, now, 'stale copy')
|
|
637
|
+
insert.run('-100', 7, now + 1, 'newest copy')
|
|
638
|
+
// A distinct topic row with the same message_id must survive the migration.
|
|
639
|
+
raw.prepare(
|
|
640
|
+
`INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
|
|
641
|
+
VALUES (?, 5, ?, 'user', 'a', '1', ?, ?, NULL)`,
|
|
642
|
+
).run('-100', 7, now, 'topic row')
|
|
643
|
+
raw.close()
|
|
644
|
+
|
|
645
|
+
// Re-open through initHistory → runs the H5 migration (dedupe + index).
|
|
646
|
+
initHistory(stateDir, 30)
|
|
647
|
+
const general = query({ chat_id: '-100', thread_id: null })
|
|
648
|
+
expect(general).toHaveLength(1)
|
|
649
|
+
expect(general[0]?.text).toBe('newest copy') // kept the newest (highest ts/rowid)
|
|
650
|
+
// The topic row with the same message_id is untouched.
|
|
651
|
+
expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic row'])
|
|
652
|
+
expect(query({ chat_id: '-100' })).toHaveLength(2)
|
|
653
|
+
|
|
654
|
+
// And the upsert is idempotent going forward.
|
|
655
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: now + 2, text: 'replay' })
|
|
656
|
+
expect(query({ chat_id: '-100', thread_id: null })).toHaveLength(1)
|
|
657
|
+
})
|
|
658
|
+
})
|
|
659
|
+
|
|
545
660
|
describe('secret redaction at persistence (both directions)', () => {
|
|
546
661
|
beforeEach(() => initHistory(stateDir, 30))
|
|
547
662
|
|
|
@@ -1,17 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle auto-clear — what "idle" MEANS.
|
|
3
|
+
*
|
|
4
|
+
* On 2026-07-11 `overlord` worked for three hours straight (sub-agents, ~25 PRs
|
|
5
|
+
* merged) and was `/clear`ed 12 s after the window elapsed — three hours of
|
|
6
|
+
* session context destroyed. Root cause: the idle window was measured from the
|
|
7
|
+
* last turn START, and nothing in a turn (tool calls, sub-agent work, the turn
|
|
8
|
+
* ENDING) counted as activity. The invariant was inverted: the longer and more
|
|
9
|
+
* productive the work, the more certain the wipe.
|
|
10
|
+
*
|
|
11
|
+
* These tests assert the OUTCOME: an agent that is doing things is not idle,
|
|
12
|
+
* and an agent that is doing nothing still gets cleared.
|
|
13
|
+
*
|
|
14
|
+
* Deterministic virtual clock only (`T + n * H`) — no fake timers, no
|
|
15
|
+
* `Date.now()`. Everything schedulable takes the clock as an argument, so the
|
|
16
|
+
* whole 3h window is exercised in microseconds and the suite stays portable if
|
|
17
|
+
* this file is ever moved to the bun runner. (It runs under vitest today: it is
|
|
18
|
+
* not in `package.json`'s `test:bun` list and not excluded in
|
|
19
|
+
* `vitest.config.ts`.)
|
|
20
|
+
*/
|
|
21
|
+
|
|
1
22
|
import { describe, it, expect } from 'vitest'
|
|
2
23
|
import {
|
|
3
24
|
decideIdleClear,
|
|
25
|
+
classifyIdleEvent,
|
|
4
26
|
idleDurationToMs,
|
|
5
27
|
DEFAULT_IDLE_CLEAR_MS,
|
|
6
28
|
type IdleClearState,
|
|
7
29
|
} from '../gateway/idle-clear.js'
|
|
8
30
|
|
|
9
31
|
const H = 3_600_000
|
|
32
|
+
const M = 60_000
|
|
33
|
+
/** Gateway's IDLE_CLEAR_CHECK_MS default. */
|
|
34
|
+
const TICK = 60_000
|
|
35
|
+
|
|
10
36
|
function state(p: Partial<IdleClearState>): IdleClearState {
|
|
11
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
lastActivityAt: 0,
|
|
39
|
+
lastTurnEndedAt: null,
|
|
40
|
+
idleClearMs: 3 * H,
|
|
41
|
+
alreadyCleared: false,
|
|
42
|
+
turnInFlight: false,
|
|
43
|
+
...p,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A minimal model of the gateway's idle bookkeeping, driven by a virtual clock.
|
|
49
|
+
* Mirrors gateway.ts: `markIdleActivity()` on inbound / cron / ANY session
|
|
50
|
+
* event, `markIdleTurnEnd()` on a turn ending, `decideIdleClear()` on each
|
|
51
|
+
* IDLE_CLEAR_CHECK_MS tick, `alreadyCleared` latched on fire.
|
|
52
|
+
*/
|
|
53
|
+
class IdleModel {
|
|
54
|
+
lastActivityAt: number
|
|
55
|
+
lastTurnEndedAt: number | null = null
|
|
56
|
+
alreadyCleared = false
|
|
57
|
+
turnInFlight = false
|
|
58
|
+
clears: number[] = []
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
startedAt: number,
|
|
62
|
+
readonly idleClearMs = 3 * H,
|
|
63
|
+
) {
|
|
64
|
+
this.lastActivityAt = startedAt
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A claude session-stream event (the gateway's handleSessionEvent stamp). */
|
|
68
|
+
sessionEvent(kind: string, now: number, durationMs?: number): void {
|
|
69
|
+
const signal = classifyIdleEvent(kind, durationMs)
|
|
70
|
+
if (signal.activity) {
|
|
71
|
+
this.lastActivityAt = now
|
|
72
|
+
this.alreadyCleared = false
|
|
73
|
+
}
|
|
74
|
+
if (signal.turnEnded) this.lastTurnEndedAt = now
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Inbound / cron fire. */
|
|
78
|
+
activity(now: number): void {
|
|
79
|
+
this.lastActivityAt = now
|
|
80
|
+
this.alreadyCleared = false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
tick(now: number): boolean {
|
|
84
|
+
const { clear } = decideIdleClear(
|
|
85
|
+
{
|
|
86
|
+
lastActivityAt: this.lastActivityAt,
|
|
87
|
+
lastTurnEndedAt: this.lastTurnEndedAt,
|
|
88
|
+
idleClearMs: this.idleClearMs,
|
|
89
|
+
alreadyCleared: this.alreadyCleared,
|
|
90
|
+
turnInFlight: this.turnInFlight,
|
|
91
|
+
},
|
|
92
|
+
now,
|
|
93
|
+
)
|
|
94
|
+
if (clear) {
|
|
95
|
+
this.alreadyCleared = true
|
|
96
|
+
this.clears.push(now)
|
|
97
|
+
}
|
|
98
|
+
return clear
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Run the interval from `from` to `to` inclusive, one tick per TICK. */
|
|
102
|
+
ticksThrough(from: number, to: number): void {
|
|
103
|
+
for (let t = from; t <= to; t += TICK) this.tick(t)
|
|
104
|
+
}
|
|
12
105
|
}
|
|
13
106
|
|
|
14
|
-
describe('
|
|
107
|
+
describe('idle-clear: a working agent is not idle', () => {
|
|
108
|
+
it('HEADLINE — a turn that runs LONGER than the idle window is not cleared when it ends', () => {
|
|
109
|
+
// The overlord incident, replayed. Window 3h. Turn starts at T, runs 3h08m
|
|
110
|
+
// of real work (sub-agents, tool calls), ends at T+3h08m.
|
|
111
|
+
const T = 100 * H
|
|
112
|
+
const m = new IdleModel(T)
|
|
113
|
+
|
|
114
|
+
m.turnInFlight = true
|
|
115
|
+
m.sessionEvent('enqueue', T) // turn start
|
|
116
|
+
|
|
117
|
+
// ... 3h08m of work. The gate suppresses the clear while in flight.
|
|
118
|
+
m.ticksThrough(T, T + 3 * H + 8 * M)
|
|
119
|
+
expect(m.clears).toEqual([])
|
|
120
|
+
|
|
121
|
+
// Turn ends. `turnInFlight` flips false — the moment of truth.
|
|
122
|
+
const end = T + 3 * H + 8 * M
|
|
123
|
+
m.sessionEvent('turn_end', end, 11_314_000)
|
|
124
|
+
m.turnInFlight = false
|
|
125
|
+
|
|
126
|
+
// The very next tick (12 s later in production) must NOT clear...
|
|
127
|
+
expect(m.tick(end + 12_000)).toBe(false)
|
|
128
|
+
// ...nor the next several ticks, nor anything short of a full fresh window.
|
|
129
|
+
m.ticksThrough(end, end + 3 * H - TICK)
|
|
130
|
+
expect(m.clears).toEqual([])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('in-turn work keeps the timer warm even when the gateway sees no turn boundary', () => {
|
|
134
|
+
// What ACTUALLY happened to overlord: the last turn ended at 07:05 and the
|
|
135
|
+
// agent then worked for ~3h under stop-hook continuations that opened no new
|
|
136
|
+
// turn. `turnInFlight` was false the whole time, so only the per-event stamp
|
|
137
|
+
// can save it.
|
|
138
|
+
const T = 100 * H
|
|
139
|
+
const m = new IdleModel(T)
|
|
140
|
+
m.sessionEvent('turn_end', T, 34_596) // last turn boundary
|
|
141
|
+
m.turnInFlight = false
|
|
142
|
+
|
|
143
|
+
// 3h30m of real work with no turn boundary: tool calls + sub-agent activity
|
|
144
|
+
// every ~10 min, and a tick between each.
|
|
145
|
+
for (let t = T; t <= T + 3 * H + 30 * M; t += 10 * M) {
|
|
146
|
+
m.sessionEvent('tool_use', t)
|
|
147
|
+
m.sessionEvent('sub_agent_tool_use', t + 60_000)
|
|
148
|
+
m.ticksThrough(t, t + 10 * M - TICK)
|
|
149
|
+
}
|
|
150
|
+
expect(m.clears).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('every genuine session event counts as activity (sub-agents included)', () => {
|
|
154
|
+
for (const kind of [
|
|
155
|
+
'enqueue',
|
|
156
|
+
'thinking',
|
|
157
|
+
'model',
|
|
158
|
+
'tool_use',
|
|
159
|
+
'tool_result',
|
|
160
|
+
'text',
|
|
161
|
+
'sub_agent_started',
|
|
162
|
+
'sub_agent_tool_use',
|
|
163
|
+
'sub_agent_text',
|
|
164
|
+
'sub_agent_turn_end',
|
|
165
|
+
]) {
|
|
166
|
+
expect(classifyIdleEvent(kind).activity).toBe(true)
|
|
167
|
+
expect(classifyIdleEvent(kind).turnEnded).toBe(false)
|
|
168
|
+
}
|
|
169
|
+
// A real turn_end is activity AND a turn boundary.
|
|
170
|
+
expect(classifyIdleEvent('turn_end', 5_000)).toEqual({ activity: true, turnEnded: true })
|
|
171
|
+
// The gateway's own synthetic turn_end (orphaned-reply backstop) is not the
|
|
172
|
+
// model doing something — but it still ends the turn.
|
|
173
|
+
expect(classifyIdleEvent('turn_end', -1)).toEqual({ activity: false, turnEnded: true })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('a background sub-agent still emitting events after the main turn ends holds off the clear', () => {
|
|
177
|
+
const T = 100 * H
|
|
178
|
+
const m = new IdleModel(T)
|
|
179
|
+
m.sessionEvent('turn_end', T, 30_000)
|
|
180
|
+
m.turnInFlight = false
|
|
181
|
+
// Worker grinds for 4h past the main turn end.
|
|
182
|
+
for (let t = T; t <= T + 4 * H; t += 5 * M) {
|
|
183
|
+
m.sessionEvent('sub_agent_tool_use', t)
|
|
184
|
+
m.ticksThrough(t, t + 5 * M - TICK)
|
|
185
|
+
}
|
|
186
|
+
expect(m.clears).toEqual([])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('idle-clear: a genuinely idle agent is still cleared', () => {
|
|
191
|
+
it('no turn, no inbound, no session event for a full window → cleared exactly once', () => {
|
|
192
|
+
const T = 100 * H
|
|
193
|
+
const m = new IdleModel(T)
|
|
194
|
+
m.ticksThrough(T, T + 6 * H)
|
|
195
|
+
expect(m.clears).toEqual([T + 3 * H])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('cleared once per idle period, and re-arms on the next inbound', () => {
|
|
199
|
+
const T = 100 * H
|
|
200
|
+
const m = new IdleModel(T)
|
|
201
|
+
m.ticksThrough(T, T + 5 * H)
|
|
202
|
+
expect(m.clears).toEqual([T + 3 * H])
|
|
203
|
+
|
|
204
|
+
const inbound = T + 5 * H
|
|
205
|
+
m.activity(inbound) // operator comes back
|
|
206
|
+
m.ticksThrough(inbound, inbound + 3 * H - TICK)
|
|
207
|
+
expect(m.clears).toEqual([T + 3 * H]) // not yet
|
|
208
|
+
m.ticksThrough(inbound, inbound + 3 * H)
|
|
209
|
+
expect(m.clears).toEqual([T + 3 * H, inbound + 3 * H])
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('a turn that ends is cleared one full window after it ended (not before)', () => {
|
|
213
|
+
const T = 100 * H
|
|
214
|
+
const m = new IdleModel(T)
|
|
215
|
+
m.turnInFlight = true
|
|
216
|
+
m.sessionEvent('enqueue', T)
|
|
217
|
+
const end = T + 3 * H + 8 * M
|
|
218
|
+
m.sessionEvent('turn_end', end, 11_314_000)
|
|
219
|
+
m.turnInFlight = false
|
|
220
|
+
|
|
221
|
+
m.ticksThrough(end, end + 3 * H - TICK)
|
|
222
|
+
expect(m.clears).toEqual([]) // still warm
|
|
223
|
+
m.ticksThrough(end, end + 3 * H + TICK)
|
|
224
|
+
expect(m.clears).toEqual([end + 3 * H]) // now genuinely idle → cleared
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('decideIdleClear (pure gate)', () => {
|
|
15
229
|
it('fires once the idle window has elapsed', () => {
|
|
16
230
|
expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H).clear).toBe(true)
|
|
17
231
|
expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H + 1).clear).toBe(true)
|
|
@@ -32,10 +246,26 @@ describe('decideIdleClear', () => {
|
|
|
32
246
|
it('is disabled when idleClearMs <= 0', () => {
|
|
33
247
|
expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: 0 }), 10 * H).clear).toBe(false)
|
|
34
248
|
expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: -1 }), 10 * H).clear).toBe(false)
|
|
249
|
+
// ...even with a fresh turn end in play.
|
|
250
|
+
expect(
|
|
251
|
+
decideIdleClear(state({ lastActivityAt: 0, lastTurnEndedAt: 9 * H, idleClearMs: 0 }), 10 * H).clear,
|
|
252
|
+
).toBe(false)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('measures the window from max(lastActivityAt, lastTurnEndedAt)', () => {
|
|
256
|
+
// Turn started at 0 (lastActivityAt), ended at 5h. At 5h the window has long
|
|
257
|
+
// "elapsed" against the START — it must not be treated as idle.
|
|
258
|
+
const justEnded = state({ lastActivityAt: 0, lastTurnEndedAt: 5 * H })
|
|
259
|
+
expect(decideIdleClear(justEnded, 5 * H).clear).toBe(false)
|
|
260
|
+
expect(decideIdleClear(justEnded, 8 * H - 1).clear).toBe(false)
|
|
261
|
+
expect(decideIdleClear(justEnded, 8 * H).clear).toBe(true) // a full window after the END
|
|
262
|
+
// Symmetric: activity newer than the turn end wins.
|
|
263
|
+
const activeSince = state({ lastActivityAt: 6 * H, lastTurnEndedAt: 5 * H })
|
|
264
|
+
expect(decideIdleClear(activeSince, 8 * H).clear).toBe(false)
|
|
265
|
+
expect(decideIdleClear(activeSince, 9 * H).clear).toBe(true)
|
|
35
266
|
})
|
|
36
267
|
|
|
37
268
|
it('re-arms after activity (fresh lastActivityAt + alreadyCleared=false → waits again)', () => {
|
|
38
|
-
// Simulated: after a clear, activity resets lastActivityAt to `now` and the flag.
|
|
39
269
|
const now = 100 * H
|
|
40
270
|
const reArmed = state({ lastActivityAt: now, alreadyCleared: false })
|
|
41
271
|
expect(decideIdleClear(reArmed, now + 3 * H - 1).clear).toBe(false) // not yet
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2094 finding 4 — bridge-side messageId dedup.
|
|
3
|
+
*
|
|
4
|
+
* Outcome contract: a genuine user message re-delivered by the stranded-
|
|
5
|
+
* message sweep within one turn is forwarded to claude exactly ONCE; after
|
|
6
|
+
* the turn-complete clear (a successful reply for that chat) forwarding
|
|
7
|
+
* resumes. Synthetic / button / structured-event inbounds are never deduped,
|
|
8
|
+
* and per-chat memory is capped.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import {
|
|
12
|
+
InboundDedup,
|
|
13
|
+
shouldDedupInbound,
|
|
14
|
+
dedupChatKey,
|
|
15
|
+
INBOUND_DEDUP_MAX_PER_CHAT,
|
|
16
|
+
type DedupInboundLike,
|
|
17
|
+
} from '../bridge/inbound-dedup.js'
|
|
18
|
+
|
|
19
|
+
function userMsg(messageId: number, extra: Record<string, string> = {}): DedupInboundLike {
|
|
20
|
+
return { messageId, meta: { chat_id: '12345', message_id: String(messageId), ...extra } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('shouldDedupInbound gate', () => {
|
|
24
|
+
it('dedups a plain user message', () => {
|
|
25
|
+
expect(shouldDedupInbound(userMsg(500))).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
it('never dedups synthetic (meta.source), button, structured, or reaction inbounds', () => {
|
|
28
|
+
expect(shouldDedupInbound(userMsg(500, { source: 'cron' }))).toBe(false)
|
|
29
|
+
expect(shouldDedupInbound(userMsg(500, { button_callback: 'true' }))).toBe(false)
|
|
30
|
+
expect(shouldDedupInbound(userMsg(500, { kind: 'checklist_task_changed' }))).toBe(false)
|
|
31
|
+
expect(shouldDedupInbound(userMsg(500, { event: 'reaction' }))).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
it('never dedups a non-positive messageId (no real Telegram id)', () => {
|
|
34
|
+
expect(shouldDedupInbound(userMsg(0))).toBe(false)
|
|
35
|
+
expect(shouldDedupInbound({ messageId: -1, meta: {} })).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('InboundDedup checkAndRecord + clearChat', () => {
|
|
40
|
+
it('processes a duplicate messageId within one turn exactly once', () => {
|
|
41
|
+
const d = new InboundDedup()
|
|
42
|
+
const key = dedupChatKey('12345')
|
|
43
|
+
expect(d.checkAndRecord(key, 500)).toBe('fresh')
|
|
44
|
+
// Sweep re-delivers the SAME messageId before the turn completes.
|
|
45
|
+
expect(d.checkAndRecord(key, 500)).toBe('duplicate')
|
|
46
|
+
expect(d.checkAndRecord(key, 500)).toBe('duplicate')
|
|
47
|
+
// A different message in the same turn is still forwarded.
|
|
48
|
+
expect(d.checkAndRecord(key, 501)).toBe('fresh')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('resumes processing the same messageId after the turn-complete clear', () => {
|
|
52
|
+
const d = new InboundDedup()
|
|
53
|
+
const key = dedupChatKey('12345')
|
|
54
|
+
expect(d.checkAndRecord(key, 500)).toBe('fresh')
|
|
55
|
+
expect(d.checkAndRecord(key, 500)).toBe('duplicate')
|
|
56
|
+
// reply fired for the chat → turn-complete reset.
|
|
57
|
+
d.clearChat('12345')
|
|
58
|
+
expect(d.size(key)).toBe(0)
|
|
59
|
+
// A genuinely new turn may re-see the id (fresh again).
|
|
60
|
+
expect(d.checkAndRecord(key, 500)).toBe('fresh')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('clearChat is thread-agnostic — clears every thread key for the chat', () => {
|
|
64
|
+
const d = new InboundDedup()
|
|
65
|
+
const dm = dedupChatKey('12345')
|
|
66
|
+
const topic = dedupChatKey('12345', 42)
|
|
67
|
+
d.checkAndRecord(dm, 500)
|
|
68
|
+
d.checkAndRecord(topic, 700)
|
|
69
|
+
d.checkAndRecord(dedupChatKey('999', 1), 800) // unrelated chat, untouched
|
|
70
|
+
d.clearChat('12345')
|
|
71
|
+
expect(d.size(dm)).toBe(0)
|
|
72
|
+
expect(d.size(topic)).toBe(0)
|
|
73
|
+
expect(d.size(dedupChatKey('999', 1))).toBe(1)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('caps per-chat memory (FIFO-evicts the oldest id)', () => {
|
|
77
|
+
const d = new InboundDedup(3)
|
|
78
|
+
const key = dedupChatKey('12345')
|
|
79
|
+
d.checkAndRecord(key, 1)
|
|
80
|
+
d.checkAndRecord(key, 2)
|
|
81
|
+
d.checkAndRecord(key, 3)
|
|
82
|
+
d.checkAndRecord(key, 4) // evicts id 1
|
|
83
|
+
expect(d.size(key)).toBe(3)
|
|
84
|
+
// id 1 was evicted → treated as fresh again (bounded memory, accepted).
|
|
85
|
+
expect(d.checkAndRecord(key, 1)).toBe('fresh')
|
|
86
|
+
// ids still tracked are duplicates.
|
|
87
|
+
expect(d.checkAndRecord(key, 4)).toBe('duplicate')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('default cap is a sane bound', () => {
|
|
91
|
+
expect(INBOUND_DEDUP_MAX_PER_CHAT).toBeGreaterThanOrEqual(128)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -206,7 +206,11 @@ describe('inbound message-type helpers: handleAckOnly + handleRefusal', () => {
|
|
|
206
206
|
const fnStart = SRC.indexOf('async function handleAckOnly(')
|
|
207
207
|
const fnSlice = SRC.slice(fnStart, fnStart + 2000)
|
|
208
208
|
expect(fnSlice).toContain('gate(ctx)')
|
|
209
|
-
|
|
209
|
+
// #3155: the reaction now routes through the gated `sendReaction` helper
|
|
210
|
+
// (send gate + flood breaker, cosmetic) instead of a raw
|
|
211
|
+
// `bot.api.setMessageReaction`. The invariant is unchanged: it gates, THEN
|
|
212
|
+
// reacts.
|
|
213
|
+
expect(fnSlice).toContain('sendReaction(')
|
|
210
214
|
})
|
|
211
215
|
|
|
212
216
|
it('handleAckOnly drops non-allowlisted senders silently', () => {
|