switchroom 0.7.15 → 0.10.0
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/README.md +51 -59
- package/bin/run-hook.sh +27 -11
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +410 -133
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +26937 -5601
- package/dist/host-control/main.js +12702 -0
- package/dist/vault/approvals/kernel-server.js +467 -184
- package/dist/vault/broker/server.js +1430 -724
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +7 -4
- package/profiles/_base/settings.json.hbs +20 -5
- package/profiles/_base/start.sh.hbs +16 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/_shared/telegram-style.md.hbs +20 -90
- package/profiles/_shared/vault-protocol.md.hbs +68 -0
- package/profiles/default/CLAUDE.md +50 -96
- package/profiles/default/CLAUDE.md.hbs +36 -6
- package/profiles/default/workspace/SOUL.md.hbs +12 -5
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +191 -0
- package/skills/switchroom-status/SKILL.md +27 -2
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/token-helpers/SKILL.md +24 -1
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/analytics-posthog.ts +191 -0
- package/telegram-plugin/bridge/bridge.ts +69 -0
- package/telegram-plugin/bridge/ipc-client.ts +4 -1
- package/telegram-plugin/dist/bridge/bridge.js +194 -119
- package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
- package/telegram-plugin/dist/server.js +245 -189
- package/telegram-plugin/first-paint.ts +3 -24
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -40
- package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
- package/telegram-plugin/gateway/boot-probes.ts +166 -123
- package/telegram-plugin/gateway/boot-reason.ts +41 -7
- package/telegram-plugin/gateway/boot-version.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +3499 -1885
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
- package/telegram-plugin/gateway/quarantine.ts +69 -0
- package/telegram-plugin/gateway/quota-cache.ts +9 -4
- package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
- package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
- package/telegram-plugin/gateway/recent-denials.ts +77 -0
- package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
- package/telegram-plugin/history.ts +91 -0
- package/telegram-plugin/hooks/hooks.json +10 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/inbound-classifier.ts +50 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +4 -2
- package/telegram-plugin/permission-rule.ts +51 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/registry/reaper.ts +223 -0
- package/telegram-plugin/retry-api-call.ts +80 -0
- package/telegram-plugin/runtime-metrics.ts +177 -0
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/secret-detect/index.ts +24 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
- package/telegram-plugin/secret-detect/vault-error.ts +78 -11
- package/telegram-plugin/secret-detect/vault-write.ts +14 -2
- package/telegram-plugin/server.js +41795 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +420 -0
- package/telegram-plugin/silent-end.ts +174 -0
- package/telegram-plugin/stream-controller.ts +13 -0
- package/telegram-plugin/stream-reply-handler.ts +7 -0
- package/telegram-plugin/subagent-watcher.ts +213 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
- package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
- package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
- package/telegram-plugin/tests/boot-probes.test.ts +216 -10
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
- package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
- package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
- package/telegram-plugin/tests/history-reaper.test.ts +378 -0
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
- package/telegram-plugin/tests/issues-card.test.ts +49 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
- package/telegram-plugin/tests/permission-rule.test.ts +80 -1
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/races.test.ts +179 -0
- package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
- package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
- package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
- package/telegram-plugin/tests/silence-poke.test.ts +493 -0
- package/telegram-plugin/tests/silent-end.test.ts +206 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
- package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
- package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
- package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
- package/telegram-plugin/turn-signal-tracker.ts +100 -24
- package/telegram-plugin/uat/SETUP.md +210 -35
- package/telegram-plugin/uat/assertions.ts +264 -37
- package/telegram-plugin/uat/driver-info.ts +57 -0
- package/telegram-plugin/uat/driver.ts +590 -51
- package/telegram-plugin/uat/harness.ts +140 -94
- package/telegram-plugin/uat/load-env.test.ts +72 -0
- package/telegram-plugin/uat/load-env.ts +48 -0
- package/telegram-plugin/uat/login.ts +96 -53
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
- package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
- package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
- package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
- package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
- package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
- package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
- package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
- package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
- package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
- package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
- package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
- package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
- package/telegram-plugin/vault-approval-posture.ts +42 -0
- package/telegram-plugin/welcome-text.ts +1 -0
- package/telegram-plugin/active-pins-sweep.ts +0 -204
- package/telegram-plugin/active-pins.ts +0 -146
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/card-event-log.ts +0 -138
- package/telegram-plugin/dist/foreman/foreman.js +0 -31106
- package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
- package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/pin-event-log.ts +0 -76
- package/telegram-plugin/progress-card-driver.ts +0 -2886
- package/telegram-plugin/progress-card-pin-manager.ts +0 -589
- package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
- package/telegram-plugin/progress-card.ts +0 -1409
- package/telegram-plugin/tests/HARNESS.md +0 -340
- package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
- package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
- package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
- package/telegram-plugin/tests/active-pins.test.ts +0 -187
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/card-event-log.test.ts +0 -145
- package/telegram-plugin/tests/first-paint.test.ts +0 -257
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
- package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
- package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
- package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
- package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
- package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
- package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
- package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
- package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
- package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
- package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
- package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
- package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
- package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
- package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
- package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
- package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
- package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
- package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
- package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
- package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
- package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
- package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
- package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
- package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
- package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
- package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
- package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
- package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
- package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
- package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
- package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
- package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
- package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
- package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
- package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
- package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
- package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
- package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
- package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
- package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
- package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
- package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
- package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
- package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
- package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
- package/telegram-plugin/two-zone-card.ts +0 -269
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the reaction-trigger primitives (#1074).
|
|
3
|
+
*
|
|
4
|
+
* Covers the synchronous predicate, the per-chat hour cap, the
|
|
5
|
+
* debounce buffer's single/collapse/batch behaviours, and the inbound
|
|
6
|
+
* text/meta builders. The integration test that exercises the full
|
|
7
|
+
* gateway handler lives in `reaction-trigger.gateway.test.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'bun:test'
|
|
11
|
+
import {
|
|
12
|
+
BATCH_INLINE_LIMIT,
|
|
13
|
+
DebounceBuffer,
|
|
14
|
+
HourCap,
|
|
15
|
+
REACTIONS_DEFAULTS,
|
|
16
|
+
buildReactionInboundMeta,
|
|
17
|
+
buildReactionInboundText,
|
|
18
|
+
evaluateTriggerCandidate,
|
|
19
|
+
isGroupChat,
|
|
20
|
+
resolveReactionsConfig,
|
|
21
|
+
truncatePreview,
|
|
22
|
+
type PendingReaction,
|
|
23
|
+
type ReactionBatch,
|
|
24
|
+
type ReactionsResolvedConfig,
|
|
25
|
+
} from '../gateway/reaction-trigger.ts'
|
|
26
|
+
|
|
27
|
+
// Helper — minimal candidate factory.
|
|
28
|
+
function candidate(
|
|
29
|
+
over: Partial<Parameters<typeof evaluateTriggerCandidate>[1]> = {},
|
|
30
|
+
): Parameters<typeof evaluateTriggerCandidate>[1] {
|
|
31
|
+
return {
|
|
32
|
+
chatId: 123,
|
|
33
|
+
messageId: 42,
|
|
34
|
+
emoji: '👎',
|
|
35
|
+
action: 'add',
|
|
36
|
+
botAuthored: true,
|
|
37
|
+
...over,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const FULL_CFG: ReactionsResolvedConfig = REACTIONS_DEFAULTS
|
|
42
|
+
|
|
43
|
+
describe('resolveReactionsConfig', () => {
|
|
44
|
+
it('returns built-in defaults for undefined / null input', () => {
|
|
45
|
+
expect(resolveReactionsConfig(undefined)).toBe(REACTIONS_DEFAULTS)
|
|
46
|
+
expect(resolveReactionsConfig(null)).toBe(REACTIONS_DEFAULTS)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('falls through to defaults for missing fields', () => {
|
|
50
|
+
const r = resolveReactionsConfig({ debounce_ms: 5000 })
|
|
51
|
+
expect(r.debounceMs).toBe(5000)
|
|
52
|
+
expect(r.enabled).toBe(REACTIONS_DEFAULTS.enabled)
|
|
53
|
+
expect(r.perHourCap).toBe(REACTIONS_DEFAULTS.perHourCap)
|
|
54
|
+
expect(r.triggerEmojis).toBe(REACTIONS_DEFAULTS.triggerEmojis)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('REPLACES trigger_emojis (not unions)', () => {
|
|
58
|
+
const r = resolveReactionsConfig({ trigger_emojis: ['🔥'] })
|
|
59
|
+
expect([...r.triggerEmojis]).toEqual(['🔥'])
|
|
60
|
+
// 👎 (a default) should NOT be present.
|
|
61
|
+
expect(r.triggerEmojis.has('👎')).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('supports trigger_emojis: [] as the empty allowlist', () => {
|
|
65
|
+
const r = resolveReactionsConfig({ trigger_emojis: [] })
|
|
66
|
+
expect(r.triggerEmojis.size).toBe(0)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('defaults match the locked design (Ken approved 2026-05-12)', () => {
|
|
70
|
+
expect(REACTIONS_DEFAULTS.enabled).toBe(true)
|
|
71
|
+
expect(REACTIONS_DEFAULTS.debounceMs).toBe(30_000)
|
|
72
|
+
expect(REACTIONS_DEFAULTS.perHourCap).toBe(10)
|
|
73
|
+
expect(REACTIONS_DEFAULTS.groupAdminOnly).toBe(true)
|
|
74
|
+
expect([...REACTIONS_DEFAULTS.triggerEmojis].sort()).toEqual(
|
|
75
|
+
['👍', '👎', '✅', '❌'].sort(),
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('evaluateTriggerCandidate', () => {
|
|
81
|
+
it('accepts a bot-authored 👎 (default allowlist)', () => {
|
|
82
|
+
expect(evaluateTriggerCandidate(FULL_CFG, candidate())).toEqual({ ok: true })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('rejects when enabled=false (master switch)', () => {
|
|
86
|
+
const cfg = resolveReactionsConfig({ enabled: false })
|
|
87
|
+
expect(evaluateTriggerCandidate(cfg, candidate())).toEqual({
|
|
88
|
+
ok: false,
|
|
89
|
+
reason: 'disabled',
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('rejects reactions on user-authored messages (no trigger)', () => {
|
|
94
|
+
expect(evaluateTriggerCandidate(FULL_CFG, candidate({ botAuthored: false }))).toEqual({
|
|
95
|
+
ok: false,
|
|
96
|
+
reason: 'not_bot_authored',
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('rejects emoji not in the allowlist (e.g. ❤️ on a bot reply)', () => {
|
|
101
|
+
expect(evaluateTriggerCandidate(FULL_CFG, candidate({ emoji: '❤️' }))).toEqual({
|
|
102
|
+
ok: false,
|
|
103
|
+
reason: 'emoji_not_in_allowlist',
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('rejects null emoji (custom emoji / non-emoji reaction)', () => {
|
|
108
|
+
expect(evaluateTriggerCandidate(FULL_CFG, candidate({ emoji: null }))).toEqual({
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: 'no_emoji',
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('accepts each default-allowlist emoji', () => {
|
|
115
|
+
for (const e of ['👎', '❌', '👍', '✅']) {
|
|
116
|
+
expect(evaluateTriggerCandidate(FULL_CFG, candidate({ emoji: e }))).toEqual({
|
|
117
|
+
ok: true,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('narrowed allowlist rejects previously-accepted emojis', () => {
|
|
123
|
+
const cfg = resolveReactionsConfig({ trigger_emojis: ['👎'] })
|
|
124
|
+
expect(evaluateTriggerCandidate(cfg, candidate({ emoji: '👍' }))).toEqual({
|
|
125
|
+
ok: false,
|
|
126
|
+
reason: 'emoji_not_in_allowlist',
|
|
127
|
+
})
|
|
128
|
+
expect(evaluateTriggerCandidate(cfg, candidate({ emoji: '👎' }))).toEqual({
|
|
129
|
+
ok: true,
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('empty-allowlist effectively disables triggering without enabled=false', () => {
|
|
134
|
+
const cfg = resolveReactionsConfig({ trigger_emojis: [] })
|
|
135
|
+
expect(evaluateTriggerCandidate(cfg, candidate({ emoji: '👎' }))).toEqual({
|
|
136
|
+
ok: false,
|
|
137
|
+
reason: 'emoji_not_in_allowlist',
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('isGroupChat', () => {
|
|
143
|
+
it('treats negative chat ids as groups (Bot API convention)', () => {
|
|
144
|
+
expect(isGroupChat(-100123)).toBe(true)
|
|
145
|
+
expect(isGroupChat(-1)).toBe(true)
|
|
146
|
+
})
|
|
147
|
+
it('treats positive chat ids as DMs', () => {
|
|
148
|
+
expect(isGroupChat(987654)).toBe(false)
|
|
149
|
+
expect(isGroupChat(1)).toBe(false)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('HourCap', () => {
|
|
154
|
+
it('admits up to `cap` events then refuses, scoped per chat', () => {
|
|
155
|
+
const cap = new HourCap(3)
|
|
156
|
+
expect(cap.tryConsume('A')).toBe(true)
|
|
157
|
+
expect(cap.tryConsume('A')).toBe(true)
|
|
158
|
+
expect(cap.tryConsume('A')).toBe(true)
|
|
159
|
+
expect(cap.tryConsume('A')).toBe(false)
|
|
160
|
+
expect(cap.tryConsume('A')).toBe(false)
|
|
161
|
+
// Different chat — independent budget.
|
|
162
|
+
expect(cap.tryConsume('B')).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('cap=0 always refuses', () => {
|
|
166
|
+
const cap = new HourCap(0)
|
|
167
|
+
expect(cap.tryConsume('A')).toBe(false)
|
|
168
|
+
expect(cap.tryConsume('A')).toBe(false)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('rolls forward after the 1-hour window passes', () => {
|
|
172
|
+
let now = 1_000_000
|
|
173
|
+
const cap = new HourCap(2, () => now)
|
|
174
|
+
expect(cap.tryConsume('A')).toBe(true)
|
|
175
|
+
expect(cap.tryConsume('A')).toBe(true)
|
|
176
|
+
expect(cap.tryConsume('A')).toBe(false)
|
|
177
|
+
// Roll past the hour window.
|
|
178
|
+
now += 60 * 60 * 1000 + 1
|
|
179
|
+
expect(cap.tryConsume('A')).toBe(true)
|
|
180
|
+
expect(cap.tryConsume('A')).toBe(true)
|
|
181
|
+
expect(cap.tryConsume('A')).toBe(false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('reports a trailing-hour count via size()', () => {
|
|
185
|
+
let now = 0
|
|
186
|
+
const cap = new HourCap(5, () => now)
|
|
187
|
+
cap.tryConsume('A')
|
|
188
|
+
cap.tryConsume('A')
|
|
189
|
+
expect(cap.size('A')).toBe(2)
|
|
190
|
+
now += 60 * 60 * 1000 + 1
|
|
191
|
+
expect(cap.size('A')).toBe(0)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('DebounceBuffer', () => {
|
|
196
|
+
// Fake scheduler — exposes timers so the test drives the clock.
|
|
197
|
+
function makeScheduler(): {
|
|
198
|
+
schedule: (fn: () => void, ms: number) => { id: number; fn: () => void; ms: number }
|
|
199
|
+
cancel: (h: { id: number }) => void
|
|
200
|
+
flushAll: () => void
|
|
201
|
+
pending: { id: number; fn: () => void; ms: number }[]
|
|
202
|
+
nextId: number
|
|
203
|
+
} {
|
|
204
|
+
let nextId = 1
|
|
205
|
+
const pending: { id: number; fn: () => void; ms: number }[] = []
|
|
206
|
+
return {
|
|
207
|
+
schedule(fn: () => void, ms: number) {
|
|
208
|
+
const h = { id: nextId++, fn, ms }
|
|
209
|
+
pending.push(h)
|
|
210
|
+
return h
|
|
211
|
+
},
|
|
212
|
+
cancel(h: { id: number }) {
|
|
213
|
+
const i = pending.findIndex((p) => p.id === h.id)
|
|
214
|
+
if (i >= 0) pending.splice(i, 1)
|
|
215
|
+
},
|
|
216
|
+
flushAll() {
|
|
217
|
+
// Snapshot then drain — running fn() may enqueue more.
|
|
218
|
+
const snap = pending.splice(0)
|
|
219
|
+
for (const p of snap) p.fn()
|
|
220
|
+
},
|
|
221
|
+
pending,
|
|
222
|
+
nextId,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function pending(over: Partial<PendingReaction> = {}): PendingReaction {
|
|
227
|
+
return {
|
|
228
|
+
targetMessageId: 7,
|
|
229
|
+
emoji: '👎',
|
|
230
|
+
action: 'add',
|
|
231
|
+
ts: 0,
|
|
232
|
+
preview: 'hello',
|
|
233
|
+
userId: 99,
|
|
234
|
+
user: 'tester',
|
|
235
|
+
...over,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
it('single enqueue fires the sink with batched=false after window', () => {
|
|
240
|
+
const sched = makeScheduler()
|
|
241
|
+
const batches: ReactionBatch[] = []
|
|
242
|
+
const buf = new DebounceBuffer(30_000, (b) => batches.push(b), {
|
|
243
|
+
schedule: sched.schedule as never,
|
|
244
|
+
cancel: sched.cancel as never,
|
|
245
|
+
})
|
|
246
|
+
buf.enqueue(123, pending({ emoji: '👎', targetMessageId: 1 }))
|
|
247
|
+
expect(batches.length).toBe(0)
|
|
248
|
+
sched.flushAll()
|
|
249
|
+
expect(batches.length).toBe(1)
|
|
250
|
+
expect(batches[0]!.batched).toBe(false)
|
|
251
|
+
expect(batches[0]!.reactions.length).toBe(1)
|
|
252
|
+
expect(batches[0]!.reactions[0]!.emoji).toBe('👎')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('two enqueues within window collapse into batched=true with 2 entries', () => {
|
|
256
|
+
const sched = makeScheduler()
|
|
257
|
+
const batches: ReactionBatch[] = []
|
|
258
|
+
const buf = new DebounceBuffer(30_000, (b) => batches.push(b), {
|
|
259
|
+
schedule: sched.schedule as never,
|
|
260
|
+
cancel: sched.cancel as never,
|
|
261
|
+
})
|
|
262
|
+
buf.enqueue(123, pending({ emoji: '👎', targetMessageId: 1 }))
|
|
263
|
+
buf.enqueue(123, pending({ emoji: '✅', targetMessageId: 2 }))
|
|
264
|
+
expect(batches.length).toBe(0)
|
|
265
|
+
sched.flushAll()
|
|
266
|
+
expect(batches.length).toBe(1)
|
|
267
|
+
expect(batches[0]!.batched).toBe(true)
|
|
268
|
+
expect(batches[0]!.reactions.length).toBe(2)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('separate chats do not collapse into each other', () => {
|
|
272
|
+
const sched = makeScheduler()
|
|
273
|
+
const batches: ReactionBatch[] = []
|
|
274
|
+
const buf = new DebounceBuffer(30_000, (b) => batches.push(b), {
|
|
275
|
+
schedule: sched.schedule as never,
|
|
276
|
+
cancel: sched.cancel as never,
|
|
277
|
+
})
|
|
278
|
+
buf.enqueue(123, pending({ targetMessageId: 1 }))
|
|
279
|
+
buf.enqueue(456, pending({ targetMessageId: 2 }))
|
|
280
|
+
sched.flushAll()
|
|
281
|
+
expect(batches.length).toBe(2)
|
|
282
|
+
expect(batches.map((b) => b.chatId).sort()).toEqual([123, 456])
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('manual flush() before timer is a no-op double-flush', () => {
|
|
286
|
+
const sched = makeScheduler()
|
|
287
|
+
const batches: ReactionBatch[] = []
|
|
288
|
+
const buf = new DebounceBuffer(30_000, (b) => batches.push(b), {
|
|
289
|
+
schedule: sched.schedule as never,
|
|
290
|
+
cancel: sched.cancel as never,
|
|
291
|
+
})
|
|
292
|
+
buf.enqueue(123, pending())
|
|
293
|
+
buf.flush(123)
|
|
294
|
+
expect(batches.length).toBe(1)
|
|
295
|
+
// No pending timer is left to fire.
|
|
296
|
+
sched.flushAll()
|
|
297
|
+
expect(batches.length).toBe(1)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('caps unbounded growth — extra entries past maxPending are dropped', () => {
|
|
301
|
+
const sched = makeScheduler()
|
|
302
|
+
const batches: ReactionBatch[] = []
|
|
303
|
+
const buf = new DebounceBuffer(
|
|
304
|
+
30_000,
|
|
305
|
+
(b) => batches.push(b),
|
|
306
|
+
{
|
|
307
|
+
schedule: sched.schedule as never,
|
|
308
|
+
cancel: sched.cancel as never,
|
|
309
|
+
maxPending: 3,
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
for (let i = 0; i < 10; i++) buf.enqueue(123, pending({ targetMessageId: i }))
|
|
313
|
+
sched.flushAll()
|
|
314
|
+
expect(batches.length).toBe(1)
|
|
315
|
+
expect(batches[0]!.reactions.length).toBe(3)
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('truncatePreview', () => {
|
|
320
|
+
it('returns "" for null / undefined / ""', () => {
|
|
321
|
+
expect(truncatePreview(null)).toBe('')
|
|
322
|
+
expect(truncatePreview(undefined)).toBe('')
|
|
323
|
+
expect(truncatePreview('')).toBe('')
|
|
324
|
+
})
|
|
325
|
+
it('returns short strings unchanged', () => {
|
|
326
|
+
expect(truncatePreview('hi')).toBe('hi')
|
|
327
|
+
})
|
|
328
|
+
it('truncates with ellipsis past 200 chars', () => {
|
|
329
|
+
const s = 'x'.repeat(500)
|
|
330
|
+
const out = truncatePreview(s)
|
|
331
|
+
expect(out.length).toBe(200)
|
|
332
|
+
expect(out.endsWith('…')).toBe(true)
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
describe('buildReactionInboundText / Meta', () => {
|
|
337
|
+
function batchOf(reactions: PendingReaction[]): ReactionBatch {
|
|
338
|
+
return { chatId: 1, reactions, batched: reactions.length > 1 }
|
|
339
|
+
}
|
|
340
|
+
function p(emoji: string, mid: number, preview = 'hi'): PendingReaction {
|
|
341
|
+
return {
|
|
342
|
+
targetMessageId: mid, emoji, action: 'add', ts: 0,
|
|
343
|
+
preview, userId: 1, user: 'u',
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
it('single produces a <channel source="reaction"> envelope', () => {
|
|
348
|
+
const text = buildReactionInboundText(batchOf([p('👎', 42, 'the bot said something')]))
|
|
349
|
+
expect(text).toContain('<channel source="reaction"')
|
|
350
|
+
expect(text).toContain('emoji="👎"')
|
|
351
|
+
expect(text).toContain('action="add"')
|
|
352
|
+
expect(text).toContain('target_message_id="42"')
|
|
353
|
+
expect(text).toContain('the bot said something')
|
|
354
|
+
expect(text.endsWith('</channel>')).toBe(true)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('single meta carries the discriminators', () => {
|
|
358
|
+
const meta = buildReactionInboundMeta(batchOf([p('👍', 7, 'ok')]))
|
|
359
|
+
expect(meta.source).toBe('reaction')
|
|
360
|
+
expect(meta.batched).toBe('false')
|
|
361
|
+
expect(meta.count).toBe('1')
|
|
362
|
+
expect(meta.reaction_emoji).toBe('👍')
|
|
363
|
+
expect(meta.target_message_id).toBe('7')
|
|
364
|
+
expect(meta.target_message_preview).toBe('ok')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('batched lists each reaction inline up to the limit + "+N more"', () => {
|
|
368
|
+
const reactions = Array.from({ length: BATCH_INLINE_LIMIT + 3 }, (_, i) =>
|
|
369
|
+
p('👎', i + 1, `m${i + 1}`),
|
|
370
|
+
)
|
|
371
|
+
const text = buildReactionInboundText(batchOf(reactions))
|
|
372
|
+
expect(text).toContain('batched="true"')
|
|
373
|
+
expect(text).toContain(`count="${reactions.length}"`)
|
|
374
|
+
expect(text).toContain('+3 more')
|
|
375
|
+
// Inline-listed first N entries.
|
|
376
|
+
expect(text).toContain('on msg 1')
|
|
377
|
+
expect(text).toContain(`on msg ${BATCH_INLINE_LIMIT}`)
|
|
378
|
+
// Past-limit entries are NOT inlined (only count is propagated).
|
|
379
|
+
expect(text).not.toContain(`on msg ${BATCH_INLINE_LIMIT + 1}`)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('escapes < and > in preview body and emoji attr', () => {
|
|
383
|
+
const text = buildReactionInboundText(
|
|
384
|
+
batchOf([p('👎', 1, '<script>alert(1)</script>')]),
|
|
385
|
+
)
|
|
386
|
+
expect(text).not.toContain('<script>')
|
|
387
|
+
expect(text).toContain('<script>')
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('persistence path is unchanged — buildReactionInboundText is pure', () => {
|
|
391
|
+
// Smoke check that the function does not throw / mutate inputs.
|
|
392
|
+
const b = batchOf([p('👍', 1)])
|
|
393
|
+
Object.freeze(b)
|
|
394
|
+
Object.freeze(b.reactions)
|
|
395
|
+
expect(() => buildReactionInboundText(b)).not.toThrow()
|
|
396
|
+
})
|
|
397
|
+
})
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
12
12
|
import { GrammyError } from 'grammy'
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
createRetryApiCall,
|
|
15
|
+
createSwallowingRetryApiCall,
|
|
16
|
+
retryWithThreadFallback,
|
|
17
|
+
type RetryObserver,
|
|
18
|
+
} from '../retry-api-call.js'
|
|
14
19
|
import { errors, makeGrammyError } from './fake-bot-api.js'
|
|
15
20
|
|
|
16
21
|
// vitest's vi.advanceTimersByTimeAsync isn't implemented by Bun's test runner.
|
|
@@ -285,3 +290,149 @@ describe('retryApiCall', () => {
|
|
|
285
290
|
})
|
|
286
291
|
})
|
|
287
292
|
})
|
|
293
|
+
|
|
294
|
+
// #1075 — coverage for the swallow + thread-fallback helpers that wrap
|
|
295
|
+
// the retry policy for the six previously-unwrapped outbound surfaces.
|
|
296
|
+
describe('createSwallowingRetryApiCall (#1075)', () => {
|
|
297
|
+
beforeEach(() => {
|
|
298
|
+
vi.useFakeTimers()
|
|
299
|
+
})
|
|
300
|
+
afterEach(() => {
|
|
301
|
+
vi.useRealTimers()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('resolves to the underlying value on success', async () => {
|
|
305
|
+
const retry = createRetryApiCall()
|
|
306
|
+
const swallow = createSwallowingRetryApiCall(retry)
|
|
307
|
+
const fn = vi.fn<() => Promise<string>>().mockResolvedValue('ok')
|
|
308
|
+
const result = await swallow(fn)
|
|
309
|
+
expect(result).toBe('ok')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('returns undefined and logs when THREAD_NOT_FOUND fires', async () => {
|
|
313
|
+
const retry = createRetryApiCall()
|
|
314
|
+
const log = vi.fn()
|
|
315
|
+
const swallow = createSwallowingRetryApiCall(retry, log)
|
|
316
|
+
const fn = vi.fn<() => Promise<void>>().mockRejectedValueOnce(errors.threadNotFound())
|
|
317
|
+
const result = await swallow(fn, { threadId: 42, chat_id: 'c', verb: 'test.send' })
|
|
318
|
+
expect(result).toBeUndefined()
|
|
319
|
+
expect(log).toHaveBeenCalledWith(expect.stringMatching(/test\.send.*THREAD_NOT_FOUND/))
|
|
320
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('returns undefined and logs on 403 forbidden', async () => {
|
|
324
|
+
const retry = createRetryApiCall()
|
|
325
|
+
const log = vi.fn()
|
|
326
|
+
const swallow = createSwallowingRetryApiCall(retry, log)
|
|
327
|
+
const fn = vi.fn<() => Promise<void>>().mockRejectedValueOnce(errors.forbidden())
|
|
328
|
+
const result = await swallow(fn, { chat_id: 'c', verb: 'forbidden.send' })
|
|
329
|
+
expect(result).toBeUndefined()
|
|
330
|
+
expect(log).toHaveBeenCalled()
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('returns undefined on benign not-modified (passes through retry semantics)', async () => {
|
|
334
|
+
const retry = createRetryApiCall()
|
|
335
|
+
const swallow = createSwallowingRetryApiCall(retry)
|
|
336
|
+
const fn = vi
|
|
337
|
+
.fn<() => Promise<void>>()
|
|
338
|
+
.mockRejectedValueOnce(errors.badRequest('Bad Request: message is not modified'))
|
|
339
|
+
const result = await swallow(fn)
|
|
340
|
+
// retry already swallows benign 400s to undefined, so swallowing wrapper
|
|
341
|
+
// resolves to undefined cleanly with NO error log fired.
|
|
342
|
+
expect(result).toBeUndefined()
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
describe('retryWithThreadFallback (#1075)', () => {
|
|
347
|
+
beforeEach(() => {
|
|
348
|
+
vi.useFakeTimers()
|
|
349
|
+
})
|
|
350
|
+
afterEach(() => {
|
|
351
|
+
vi.useRealTimers()
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('resolves on success with threadId passed through', async () => {
|
|
355
|
+
const retry = createRetryApiCall()
|
|
356
|
+
const send = vi
|
|
357
|
+
.fn<(tid: number | undefined) => Promise<{ message_id: number }>>()
|
|
358
|
+
.mockResolvedValue({ message_id: 1 })
|
|
359
|
+
const result = await retryWithThreadFallback(retry, send, {
|
|
360
|
+
threadId: 42,
|
|
361
|
+
chat_id: 'c',
|
|
362
|
+
verb: 'fallback.test',
|
|
363
|
+
})
|
|
364
|
+
expect(result.message_id).toBe(1)
|
|
365
|
+
expect(send).toHaveBeenCalledWith(42)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('drops the thread id and retries once on THREAD_NOT_FOUND', async () => {
|
|
369
|
+
const retry = createRetryApiCall()
|
|
370
|
+
const send = vi
|
|
371
|
+
.fn<(tid: number | undefined) => Promise<{ message_id: number }>>()
|
|
372
|
+
.mockRejectedValueOnce(errors.threadNotFound())
|
|
373
|
+
.mockResolvedValueOnce({ message_id: 2 })
|
|
374
|
+
const result = await retryWithThreadFallback(retry, send, {
|
|
375
|
+
threadId: 42,
|
|
376
|
+
chat_id: 'c',
|
|
377
|
+
})
|
|
378
|
+
expect(result.message_id).toBe(2)
|
|
379
|
+
expect(send).toHaveBeenCalledTimes(2)
|
|
380
|
+
expect(send.mock.calls[0][0]).toBe(42)
|
|
381
|
+
// Fallback call must drop the thread id.
|
|
382
|
+
expect(send.mock.calls[1][0]).toBeUndefined()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('propagates non-thread-not-found errors without retry', async () => {
|
|
386
|
+
const retry = createRetryApiCall()
|
|
387
|
+
const send = vi
|
|
388
|
+
.fn<(tid: number | undefined) => Promise<{ message_id: number }>>()
|
|
389
|
+
.mockRejectedValueOnce(errors.forbidden())
|
|
390
|
+
await expect(
|
|
391
|
+
retryWithThreadFallback(retry, send, { threadId: 42, chat_id: 'c' }),
|
|
392
|
+
).rejects.toMatchObject({ error_code: 403 })
|
|
393
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('handles delete on a thread-bearing message (THREAD_NOT_FOUND coverage for delete)', async () => {
|
|
397
|
+
const retry = createRetryApiCall()
|
|
398
|
+
const send = vi
|
|
399
|
+
.fn<(tid: number | undefined) => Promise<boolean>>()
|
|
400
|
+
.mockRejectedValueOnce(errors.threadNotFound())
|
|
401
|
+
.mockResolvedValueOnce(true)
|
|
402
|
+
const result = await retryWithThreadFallback(retry, send, {
|
|
403
|
+
threadId: 99,
|
|
404
|
+
chat_id: 'c',
|
|
405
|
+
verb: 'deleteMessage',
|
|
406
|
+
})
|
|
407
|
+
expect(result).toBe(true)
|
|
408
|
+
expect(send).toHaveBeenCalledTimes(2)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('handles pin on a thread-bearing message', async () => {
|
|
412
|
+
const retry = createRetryApiCall()
|
|
413
|
+
const send = vi
|
|
414
|
+
.fn<(tid: number | undefined) => Promise<boolean>>()
|
|
415
|
+
.mockRejectedValueOnce(errors.threadNotFound())
|
|
416
|
+
.mockResolvedValueOnce(true)
|
|
417
|
+
const result = await retryWithThreadFallback(retry, send, {
|
|
418
|
+
threadId: 7,
|
|
419
|
+
chat_id: 'c',
|
|
420
|
+
verb: 'pinChatMessage',
|
|
421
|
+
})
|
|
422
|
+
expect(result).toBe(true)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('handles edit on a thread-bearing message', async () => {
|
|
426
|
+
const retry = createRetryApiCall()
|
|
427
|
+
const send = vi
|
|
428
|
+
.fn<(tid: number | undefined) => Promise<boolean>>()
|
|
429
|
+
.mockRejectedValueOnce(errors.threadNotFound())
|
|
430
|
+
.mockResolvedValueOnce(true)
|
|
431
|
+
const result = await retryWithThreadFallback(retry, send, {
|
|
432
|
+
threadId: 7,
|
|
433
|
+
chat_id: 'c',
|
|
434
|
+
verb: 'editMessageText',
|
|
435
|
+
})
|
|
436
|
+
expect(result).toBe(true)
|
|
437
|
+
})
|
|
438
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
emitRuntimeMetric,
|
|
7
|
+
__setRuntimeMetricsPathForTests,
|
|
8
|
+
__getRuntimeMetricsPathForTests,
|
|
9
|
+
__isJsonlEnabledForTests,
|
|
10
|
+
} from '../runtime-metrics.js'
|
|
11
|
+
|
|
12
|
+
let tmpDir: string
|
|
13
|
+
let metricsPath: string
|
|
14
|
+
const ORIGINAL_TELEMETRY = process.env.SWITCHROOM_TELEMETRY_DISABLED
|
|
15
|
+
const ORIGINAL_JSONL_DISABLED = process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'runtime-metrics-test-'))
|
|
19
|
+
metricsPath = join(tmpDir, 'runtime-metrics.jsonl')
|
|
20
|
+
__setRuntimeMetricsPathForTests(metricsPath)
|
|
21
|
+
// Disable PostHog for unit tests — we only want to exercise the JSONL sink.
|
|
22
|
+
// Real PostHog wiring is covered indirectly by analytics-posthog itself.
|
|
23
|
+
process.env.SWITCHROOM_TELEMETRY_DISABLED = '1'
|
|
24
|
+
delete process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
__setRuntimeMetricsPathForTests(null)
|
|
29
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
30
|
+
if (ORIGINAL_TELEMETRY != null) process.env.SWITCHROOM_TELEMETRY_DISABLED = ORIGINAL_TELEMETRY
|
|
31
|
+
else delete process.env.SWITCHROOM_TELEMETRY_DISABLED
|
|
32
|
+
if (ORIGINAL_JSONL_DISABLED != null) process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED = ORIGINAL_JSONL_DISABLED
|
|
33
|
+
else delete process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('runtime-metrics — JSONL sink', () => {
|
|
37
|
+
it('writes one JSON line per event', () => {
|
|
38
|
+
emitRuntimeMetric({
|
|
39
|
+
kind: 'inbound_status_query',
|
|
40
|
+
chat_id: '123',
|
|
41
|
+
message_id: 42,
|
|
42
|
+
thread_id: null,
|
|
43
|
+
text_length: 7,
|
|
44
|
+
prior_turn_in_flight: true,
|
|
45
|
+
seconds_since_turn_start: 12,
|
|
46
|
+
})
|
|
47
|
+
emitRuntimeMetric({
|
|
48
|
+
kind: 'turn_started',
|
|
49
|
+
chat_id: '123',
|
|
50
|
+
message_id: 43,
|
|
51
|
+
thread_id: null,
|
|
52
|
+
inbound_classified_as_status_query: false,
|
|
53
|
+
})
|
|
54
|
+
const raw = readFileSync(metricsPath, 'utf-8')
|
|
55
|
+
const lines = raw.trim().split('\n')
|
|
56
|
+
expect(lines).toHaveLength(2)
|
|
57
|
+
const first = JSON.parse(lines[0]!)
|
|
58
|
+
expect(first.kind).toBe('inbound_status_query')
|
|
59
|
+
expect(first.chat_id).toBe('123')
|
|
60
|
+
expect(first.message_id).toBe(42)
|
|
61
|
+
expect(first.text_length).toBe(7)
|
|
62
|
+
expect(typeof first.ts).toBe('number')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('turn_ended carries TTFO + outbound gap fields', () => {
|
|
66
|
+
emitRuntimeMetric({
|
|
67
|
+
kind: 'turn_ended',
|
|
68
|
+
chat_id: 'c1',
|
|
69
|
+
thread_id: 7,
|
|
70
|
+
duration_ms: 8400,
|
|
71
|
+
ttfo_ms: 1200,
|
|
72
|
+
outbound_count: 3,
|
|
73
|
+
longest_silent_gap_ms: 5500,
|
|
74
|
+
ended_via: 'reply',
|
|
75
|
+
})
|
|
76
|
+
const raw = readFileSync(metricsPath, 'utf-8')
|
|
77
|
+
const parsed = JSON.parse(raw.trim())
|
|
78
|
+
expect(parsed.kind).toBe('turn_ended')
|
|
79
|
+
expect(parsed.ttfo_ms).toBe(1200)
|
|
80
|
+
expect(parsed.outbound_count).toBe(3)
|
|
81
|
+
expect(parsed.longest_silent_gap_ms).toBe(5500)
|
|
82
|
+
expect(parsed.ended_via).toBe('reply')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('appends — does not overwrite — across calls', () => {
|
|
86
|
+
for (let i = 0; i < 5; i++) {
|
|
87
|
+
emitRuntimeMetric({
|
|
88
|
+
kind: 'turn_started',
|
|
89
|
+
chat_id: 'c1',
|
|
90
|
+
message_id: i,
|
|
91
|
+
thread_id: null,
|
|
92
|
+
inbound_classified_as_status_query: false,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
const raw = readFileSync(metricsPath, 'utf-8')
|
|
96
|
+
const lines = raw.trim().split('\n')
|
|
97
|
+
expect(lines).toHaveLength(5)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('creates the parent directory if missing', () => {
|
|
101
|
+
const nested = join(tmpDir, 'a', 'b', 'c', 'runtime-metrics.jsonl')
|
|
102
|
+
__setRuntimeMetricsPathForTests(nested)
|
|
103
|
+
emitRuntimeMetric({
|
|
104
|
+
kind: 'turn_started',
|
|
105
|
+
chat_id: 'c1',
|
|
106
|
+
message_id: 1,
|
|
107
|
+
thread_id: null,
|
|
108
|
+
inbound_classified_as_status_query: false,
|
|
109
|
+
})
|
|
110
|
+
expect(existsSync(nested)).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED=1 skips the JSONL write', () => {
|
|
114
|
+
process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED = '1'
|
|
115
|
+
expect(__isJsonlEnabledForTests()).toBe(false)
|
|
116
|
+
emitRuntimeMetric({
|
|
117
|
+
kind: 'turn_started',
|
|
118
|
+
chat_id: 'c1',
|
|
119
|
+
message_id: 1,
|
|
120
|
+
thread_id: null,
|
|
121
|
+
inbound_classified_as_status_query: false,
|
|
122
|
+
})
|
|
123
|
+
expect(existsSync(metricsPath)).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('resolves SWITCHROOM_RUNTIME_METRICS_PATH override', () => {
|
|
127
|
+
const overridePath = join(tmpDir, 'override.jsonl')
|
|
128
|
+
__setRuntimeMetricsPathForTests(overridePath)
|
|
129
|
+
expect(__getRuntimeMetricsPathForTests()).toBe(overridePath)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('emit never throws even if all sinks are disabled', () => {
|
|
133
|
+
process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED = '1'
|
|
134
|
+
process.env.SWITCHROOM_TELEMETRY_DISABLED = '1'
|
|
135
|
+
expect(() => {
|
|
136
|
+
emitRuntimeMetric({
|
|
137
|
+
kind: 'turn_started',
|
|
138
|
+
chat_id: 'c1',
|
|
139
|
+
message_id: 1,
|
|
140
|
+
thread_id: null,
|
|
141
|
+
inbound_classified_as_status_query: false,
|
|
142
|
+
})
|
|
143
|
+
}).not.toThrow()
|
|
144
|
+
})
|
|
145
|
+
})
|