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
|
@@ -380,41 +380,11 @@ describe('fetchAccountQuota — cache + token resolution', () => {
|
|
|
380
380
|
}
|
|
381
381
|
})
|
|
382
382
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
new Response('{}', {
|
|
389
|
-
status: 200,
|
|
390
|
-
headers: {
|
|
391
|
-
'anthropic-ratelimit-unified-5h-utilization': '0.42',
|
|
392
|
-
'anthropic-ratelimit-unified-7d-utilization': '0.17',
|
|
393
|
-
},
|
|
394
|
-
})
|
|
395
|
-
try {
|
|
396
|
-
const r = await fetchAccountQuota('work@example.com', {
|
|
397
|
-
home,
|
|
398
|
-
fetchImpl: fakeFetch as typeof fetch,
|
|
399
|
-
})
|
|
400
|
-
expect(r.ok).toBe(true)
|
|
401
|
-
const snapPath = join(
|
|
402
|
-
home,
|
|
403
|
-
'.switchroom',
|
|
404
|
-
'accounts',
|
|
405
|
-
'work@example.com',
|
|
406
|
-
'quota.json',
|
|
407
|
-
)
|
|
408
|
-
// The bug: writeAccountQuota was called without opts.home, so the
|
|
409
|
-
// snapshot landed under the real $HOME instead of the test home.
|
|
410
|
-
expect(existsSync(snapPath)).toBe(true)
|
|
411
|
-
const snap = JSON.parse(readFileSync(snapPath, 'utf-8'))
|
|
412
|
-
expect(snap.fiveHourPct).toBeCloseTo(42, 0)
|
|
413
|
-
expect(snap.sevenDayPct).toBeCloseTo(17, 0)
|
|
414
|
-
} finally {
|
|
415
|
-
rmSync(home, { recursive: true, force: true })
|
|
416
|
-
}
|
|
417
|
-
})
|
|
383
|
+
// Removed in RFC H: per-account quota.json disk persistence is gone.
|
|
384
|
+
// switchroom-auth-broker holds canonical quota state and exposes it
|
|
385
|
+
// via list-state; the gateway's in-process cache is enough between
|
|
386
|
+
// restarts (and the broker survives gateway restarts, so the state
|
|
387
|
+
// is preserved at the broker side anyway).
|
|
418
388
|
})
|
|
419
389
|
|
|
420
390
|
describe('getCachedAccountQuota + prefetchAccountQuotaIfStale', () => {
|
|
@@ -840,3 +840,182 @@ describe('Queue lifecycle: multiple queued messages overwrite notification', ()
|
|
|
840
840
|
expect(secondNotif!.messageId).not.toBe(firstMsgId)
|
|
841
841
|
})
|
|
842
842
|
})
|
|
843
|
+
|
|
844
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
845
|
+
// #1067 — currentTurn atom snapshot-at-entry race regression
|
|
846
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
847
|
+
//
|
|
848
|
+
// Pre-#1067: gateway.ts had ~13 module-level `let`s holding the implicit
|
|
849
|
+
// "current turn" state (currentSessionChatId, currentTurnStartedAt, etc).
|
|
850
|
+
// Async session-event handlers read those singletons directly. When a
|
|
851
|
+
// handler captured a value, awaited (e.g. sendMessage / progress-card
|
|
852
|
+
// edit), and resumed, an intervening `enqueue` for a different chat could
|
|
853
|
+
// swap the singletons — and the resumed handler would mis-route its
|
|
854
|
+
// effect to the new chat (wrong-chat progress card, wrong-chat status
|
|
855
|
+
// reaction, wrong-chat answer-stream update).
|
|
856
|
+
//
|
|
857
|
+
// Fix: consolidate the singletons into one atom and capture it at handler
|
|
858
|
+
// entry: `const turn = currentTurn; if (turn == null) return;` — every
|
|
859
|
+
// subsequent read uses `turn.*` so the snapshot is stable across awaits.
|
|
860
|
+
//
|
|
861
|
+
// This test models the pattern with a minimal atom + handler harness and
|
|
862
|
+
// asserts the effect lands on the *original* turn's chat, not the
|
|
863
|
+
// usurper's. If the fix is ever undone, this test fails — making the
|
|
864
|
+
// reattribution race detectable in CI.
|
|
865
|
+
|
|
866
|
+
type _Test1067_CurrentTurn = {
|
|
867
|
+
sessionChatId: string
|
|
868
|
+
startedAt: number
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
interface _Test1067_Effect {
|
|
872
|
+
chatId: string
|
|
873
|
+
reason: 'tool_use' | 'turn_end'
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
describe('#1067 — currentTurn snapshot-at-entry prevents cross-chat reattribution', () => {
|
|
877
|
+
/** A synchronous swap of the atom — models the enqueue handler. */
|
|
878
|
+
function enqueue(state: { atom: _Test1067_CurrentTurn | null }, chatId: string, now: number): void {
|
|
879
|
+
state.atom = { sessionChatId: chatId, startedAt: now }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* The pre-#1067 anti-pattern: re-read the singleton after the await.
|
|
884
|
+
* Any mid-await swap mis-attributes the effect to the new chat.
|
|
885
|
+
*/
|
|
886
|
+
async function legacyToolUseHandler(
|
|
887
|
+
state: { atom: _Test1067_CurrentTurn | null },
|
|
888
|
+
effects: _Test1067_Effect[],
|
|
889
|
+
midAwait: () => Promise<void>,
|
|
890
|
+
): Promise<void> {
|
|
891
|
+
const beforeChatId = state.atom?.sessionChatId
|
|
892
|
+
if (beforeChatId == null) return
|
|
893
|
+
// Simulate the await window (status-reaction edit, progress-card mutation,
|
|
894
|
+
// answer-stream API call — anything that yields the event loop).
|
|
895
|
+
await midAwait()
|
|
896
|
+
// Bug: re-read currentTurn after the await. A concurrent enqueue
|
|
897
|
+
// could have swapped the singletons; this effect now lands on the
|
|
898
|
+
// wrong chat.
|
|
899
|
+
const afterChatId = state.atom?.sessionChatId
|
|
900
|
+
if (afterChatId == null) return
|
|
901
|
+
effects.push({ chatId: afterChatId, reason: 'tool_use' })
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* The post-#1067 pattern: snapshot at handler entry, use the local
|
|
906
|
+
* through the rest of the handler — across awaits.
|
|
907
|
+
*/
|
|
908
|
+
async function fixedToolUseHandler(
|
|
909
|
+
state: { atom: _Test1067_CurrentTurn | null },
|
|
910
|
+
effects: _Test1067_Effect[],
|
|
911
|
+
midAwait: () => Promise<void>,
|
|
912
|
+
): Promise<void> {
|
|
913
|
+
const turn = state.atom // snapshot
|
|
914
|
+
if (turn == null) return
|
|
915
|
+
await midAwait()
|
|
916
|
+
// Use the captured `turn`, not state.atom. The effect goes to the
|
|
917
|
+
// chat that the handler was actually invoked for.
|
|
918
|
+
effects.push({ chatId: turn.sessionChatId, reason: 'tool_use' })
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
it('legacy pattern: mid-await enqueue reattributes the effect (regression baseline)', async () => {
|
|
922
|
+
const state: { atom: _Test1067_CurrentTurn | null } = { atom: null }
|
|
923
|
+
const effects: _Test1067_Effect[] = []
|
|
924
|
+
|
|
925
|
+
enqueue(state, 'A', 100)
|
|
926
|
+
// Start the handler; it will await midway.
|
|
927
|
+
let releaseAwait: () => void = () => {}
|
|
928
|
+
const blocker = new Promise<void>(resolve => { releaseAwait = resolve })
|
|
929
|
+
const handlerDone = legacyToolUseHandler(state, effects, () => blocker)
|
|
930
|
+
|
|
931
|
+
// While handler is suspended, an enqueue for chat B lands. Pre-#1067
|
|
932
|
+
// gateway would mutate the same singletons.
|
|
933
|
+
enqueue(state, 'B', 200)
|
|
934
|
+
|
|
935
|
+
releaseAwait()
|
|
936
|
+
await handlerDone
|
|
937
|
+
|
|
938
|
+
// Reattribution: the legacy pattern sends A's tool_use effect to B.
|
|
939
|
+
expect(effects).toEqual([{ chatId: 'B', reason: 'tool_use' }])
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it('fixed pattern: snapshot-at-entry keeps the effect on the original chat', async () => {
|
|
943
|
+
const state: { atom: _Test1067_CurrentTurn | null } = { atom: null }
|
|
944
|
+
const effects: _Test1067_Effect[] = []
|
|
945
|
+
|
|
946
|
+
enqueue(state, 'A', 100)
|
|
947
|
+
let releaseAwait: () => void = () => {}
|
|
948
|
+
const blocker = new Promise<void>(resolve => { releaseAwait = resolve })
|
|
949
|
+
const handlerDone = fixedToolUseHandler(state, effects, () => blocker)
|
|
950
|
+
|
|
951
|
+
// Mid-handler enqueue for chat B — the race-trigger.
|
|
952
|
+
enqueue(state, 'B', 200)
|
|
953
|
+
|
|
954
|
+
releaseAwait()
|
|
955
|
+
await handlerDone
|
|
956
|
+
|
|
957
|
+
// The effect lands on A, the turn the handler was invoked for.
|
|
958
|
+
// This is the regression guard for #1067.
|
|
959
|
+
expect(effects).toEqual([{ chatId: 'A', reason: 'tool_use' }])
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
it('fixed pattern: if currentTurn is cleared mid-handler, the captured snapshot still wins', async () => {
|
|
963
|
+
// turn_end (or context-exhaustion bail-out) nulls the atom. A handler
|
|
964
|
+
// already in flight should still complete against its snapshot rather
|
|
965
|
+
// than dropping the effect — losing the snapshot here would
|
|
966
|
+
// re-introduce the cross-attribution failure mode if a NEW enqueue
|
|
967
|
+
// races the cleanup.
|
|
968
|
+
const state: { atom: _Test1067_CurrentTurn | null } = { atom: null }
|
|
969
|
+
const effects: _Test1067_Effect[] = []
|
|
970
|
+
|
|
971
|
+
enqueue(state, 'A', 100)
|
|
972
|
+
let releaseAwait: () => void = () => {}
|
|
973
|
+
const blocker = new Promise<void>(resolve => { releaseAwait = resolve })
|
|
974
|
+
const handlerDone = fixedToolUseHandler(state, effects, () => blocker)
|
|
975
|
+
|
|
976
|
+
// turn_end nulls the atom; then an immediate enqueue for B swaps in
|
|
977
|
+
// a fresh atom. The legacy reader would see B; the fixed reader
|
|
978
|
+
// still sees A.
|
|
979
|
+
state.atom = null
|
|
980
|
+
enqueue(state, 'B', 200)
|
|
981
|
+
|
|
982
|
+
releaseAwait()
|
|
983
|
+
await handlerDone
|
|
984
|
+
|
|
985
|
+
expect(effects).toEqual([{ chatId: 'A', reason: 'tool_use' }])
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
it('fixed pattern: multiple concurrent handlers each see their own turn', async () => {
|
|
989
|
+
// Pipeline: handler 1 starts under turn A and awaits. While it's
|
|
990
|
+
// suspended, turn_end + enqueue B happens. Handler 2 then starts
|
|
991
|
+
// under turn B and awaits. Both eventually resume. Each handler's
|
|
992
|
+
// effect must reflect the turn it started under.
|
|
993
|
+
const state: { atom: _Test1067_CurrentTurn | null } = { atom: null }
|
|
994
|
+
const effects: _Test1067_Effect[] = []
|
|
995
|
+
|
|
996
|
+
enqueue(state, 'A', 100)
|
|
997
|
+
let release1: () => void = () => {}
|
|
998
|
+
const block1 = new Promise<void>(resolve => { release1 = resolve })
|
|
999
|
+
const h1 = fixedToolUseHandler(state, effects, () => block1)
|
|
1000
|
+
|
|
1001
|
+
state.atom = null
|
|
1002
|
+
enqueue(state, 'B', 200)
|
|
1003
|
+
|
|
1004
|
+
let release2: () => void = () => {}
|
|
1005
|
+
const block2 = new Promise<void>(resolve => { release2 = resolve })
|
|
1006
|
+
const h2 = fixedToolUseHandler(state, effects, () => block2)
|
|
1007
|
+
|
|
1008
|
+
// Resume in reverse order: handler 2 first, then handler 1. This
|
|
1009
|
+
// proves the snapshot is not order-dependent.
|
|
1010
|
+
release2()
|
|
1011
|
+
await h2
|
|
1012
|
+
release1()
|
|
1013
|
+
await h1
|
|
1014
|
+
|
|
1015
|
+
// Both handlers landed effects on their respective turns; neither
|
|
1016
|
+
// got reattributed to the other's chat.
|
|
1017
|
+
expect(effects).toContainEqual({ chatId: 'A', reason: 'tool_use' })
|
|
1018
|
+
expect(effects).toContainEqual({ chatId: 'B', reason: 'tool_use' })
|
|
1019
|
+
expect(effects).toHaveLength(2)
|
|
1020
|
+
})
|
|
1021
|
+
})
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end flow test for the reaction-trigger pipeline (#1074).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the gateway handler's decision flow (predicate → admin check
|
|
5
|
+
* → hour cap → debounce → InboundMessage build → dispatch) without
|
|
6
|
+
* pulling in the gateway module's heavy side-effects. The integration
|
|
7
|
+
* point that matters is the SHAPE of the InboundMessage emitted to the
|
|
8
|
+
* dispatcher — that's what the bridge sees as a synthetic turn.
|
|
9
|
+
*
|
|
10
|
+
* What this test pins:
|
|
11
|
+
* 1. A bot-authored 👎 dispatches a single inbound with
|
|
12
|
+
* `meta.source="reaction"` after the debounce window.
|
|
13
|
+
* 2. A ❤️ reaction (not in default allowlist) dispatches NOTHING.
|
|
14
|
+
* 3. A user-authored target message dispatches NOTHING.
|
|
15
|
+
* 4. Two reactions within the window collapse into one batched
|
|
16
|
+
* synthetic with the second emoji NOT lost.
|
|
17
|
+
* 5. The hour cap refuses past the limit (no inbound emitted).
|
|
18
|
+
* 6. Group + non-admin reacter dispatches NOTHING.
|
|
19
|
+
* 7. Group + admin reacter dispatches normally.
|
|
20
|
+
*
|
|
21
|
+
* Each scenario builds a fresh HourCap + DebounceBuffer so state never
|
|
22
|
+
* leaks across tests.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'bun:test'
|
|
26
|
+
import {
|
|
27
|
+
DebounceBuffer,
|
|
28
|
+
HourCap,
|
|
29
|
+
REACTIONS_DEFAULTS,
|
|
30
|
+
buildReactionInboundMeta,
|
|
31
|
+
buildReactionInboundText,
|
|
32
|
+
evaluateTriggerCandidate,
|
|
33
|
+
isGroupChat,
|
|
34
|
+
resolveReactionsConfig,
|
|
35
|
+
truncatePreview,
|
|
36
|
+
type PendingReaction,
|
|
37
|
+
type ReactionBatch,
|
|
38
|
+
type ReactionsResolvedConfig,
|
|
39
|
+
} from '../gateway/reaction-trigger.ts'
|
|
40
|
+
|
|
41
|
+
interface FakeInbound {
|
|
42
|
+
chatId: string
|
|
43
|
+
text: string
|
|
44
|
+
meta: Record<string, string>
|
|
45
|
+
userId: number
|
|
46
|
+
user: string
|
|
47
|
+
messageId: number
|
|
48
|
+
threadId?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Test driver that mirrors the gateway handler flow.
|
|
53
|
+
*/
|
|
54
|
+
interface DriverState {
|
|
55
|
+
cfg: ReactionsResolvedConfig
|
|
56
|
+
cap: HourCap
|
|
57
|
+
buf: DebounceBuffer
|
|
58
|
+
sched: FakeScheduler
|
|
59
|
+
dispatched: FakeInbound[]
|
|
60
|
+
/** Map from message_id → role + text. */
|
|
61
|
+
history: Map<number, { role: 'user' | 'assistant'; text: string }>
|
|
62
|
+
/** Set of user_ids treated as group admin (consulted on group chats). */
|
|
63
|
+
admins: Set<number>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface FakeScheduler {
|
|
67
|
+
pending: Array<{ id: number; fn: () => void }>
|
|
68
|
+
schedule: (fn: () => void, ms: number) => { id: number; fn: () => void }
|
|
69
|
+
cancel: (h: { id: number }) => void
|
|
70
|
+
flushAll: () => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeScheduler(): FakeScheduler {
|
|
74
|
+
let next = 1
|
|
75
|
+
const pending: Array<{ id: number; fn: () => void }> = []
|
|
76
|
+
return {
|
|
77
|
+
pending,
|
|
78
|
+
schedule(fn) {
|
|
79
|
+
const h = { id: next++, fn }
|
|
80
|
+
pending.push(h)
|
|
81
|
+
return h
|
|
82
|
+
},
|
|
83
|
+
cancel(h) {
|
|
84
|
+
const i = pending.findIndex((p) => p.id === h.id)
|
|
85
|
+
if (i >= 0) pending.splice(i, 1)
|
|
86
|
+
},
|
|
87
|
+
flushAll() {
|
|
88
|
+
const snap = pending.splice(0)
|
|
89
|
+
for (const p of snap) p.fn()
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeDriver(over: Partial<ReactionsResolvedConfig> = {}): DriverState {
|
|
95
|
+
const cfg = { ...REACTIONS_DEFAULTS, ...over }
|
|
96
|
+
const sched = makeScheduler()
|
|
97
|
+
const dispatched: FakeInbound[] = []
|
|
98
|
+
const buf = new DebounceBuffer(
|
|
99
|
+
cfg.debounceMs,
|
|
100
|
+
(b) => dispatchBatch(b, dispatched),
|
|
101
|
+
{ schedule: sched.schedule as never, cancel: sched.cancel as never },
|
|
102
|
+
)
|
|
103
|
+
return {
|
|
104
|
+
cfg,
|
|
105
|
+
cap: new HourCap(cfg.perHourCap),
|
|
106
|
+
buf,
|
|
107
|
+
sched,
|
|
108
|
+
dispatched,
|
|
109
|
+
history: new Map(),
|
|
110
|
+
admins: new Set(),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function dispatchBatch(batch: ReactionBatch, sink: FakeInbound[]): void {
|
|
115
|
+
const head = batch.reactions[batch.reactions.length - 1]!
|
|
116
|
+
sink.push({
|
|
117
|
+
chatId: String(batch.chatId),
|
|
118
|
+
text: buildReactionInboundText(batch),
|
|
119
|
+
meta: buildReactionInboundMeta(batch),
|
|
120
|
+
userId: head.userId,
|
|
121
|
+
user: head.user,
|
|
122
|
+
messageId: Date.now(),
|
|
123
|
+
...(head.threadId !== undefined ? { threadId: head.threadId } : {}),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Replays the gateway's handler logic against the driver.
|
|
129
|
+
*
|
|
130
|
+
* Returns the rejection reason (or 'enqueued') so the test can assert
|
|
131
|
+
* the exact branch taken without grepping stderr.
|
|
132
|
+
*/
|
|
133
|
+
function feedReaction(
|
|
134
|
+
d: DriverState,
|
|
135
|
+
args: {
|
|
136
|
+
chatId: number
|
|
137
|
+
messageId: number
|
|
138
|
+
emoji: string | null
|
|
139
|
+
action: 'add' | 'change' | 'remove'
|
|
140
|
+
reacterId: number
|
|
141
|
+
reacterName?: string
|
|
142
|
+
threadId?: number
|
|
143
|
+
},
|
|
144
|
+
):
|
|
145
|
+
| { kind: 'persisted_only'; reason: string }
|
|
146
|
+
| { kind: 'enqueued' } {
|
|
147
|
+
if (args.action === 'remove' || args.emoji === null) {
|
|
148
|
+
return { kind: 'persisted_only', reason: 'remove_or_null_emoji' }
|
|
149
|
+
}
|
|
150
|
+
const row = d.history.get(args.messageId)
|
|
151
|
+
const botAuthored = row?.role === 'assistant'
|
|
152
|
+
const preview = truncatePreview(row?.text ?? '')
|
|
153
|
+
const decision = evaluateTriggerCandidate(d.cfg, {
|
|
154
|
+
chatId: args.chatId,
|
|
155
|
+
messageId: args.messageId,
|
|
156
|
+
emoji: args.emoji,
|
|
157
|
+
action: args.action,
|
|
158
|
+
botAuthored,
|
|
159
|
+
})
|
|
160
|
+
if (!decision.ok) {
|
|
161
|
+
return { kind: 'persisted_only', reason: decision.reason }
|
|
162
|
+
}
|
|
163
|
+
if (d.cfg.groupAdminOnly && isGroupChat(args.chatId)) {
|
|
164
|
+
if (!d.admins.has(args.reacterId)) {
|
|
165
|
+
return { kind: 'persisted_only', reason: 'group_non_admin' }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!d.cap.tryConsume(String(args.chatId))) {
|
|
169
|
+
return { kind: 'persisted_only', reason: 'hour_cap_exhausted' }
|
|
170
|
+
}
|
|
171
|
+
const pending: PendingReaction = {
|
|
172
|
+
targetMessageId: args.messageId,
|
|
173
|
+
emoji: args.emoji,
|
|
174
|
+
action: args.action,
|
|
175
|
+
ts: Date.now(),
|
|
176
|
+
preview,
|
|
177
|
+
userId: args.reacterId,
|
|
178
|
+
user: args.reacterName ?? `u${args.reacterId}`,
|
|
179
|
+
...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
|
|
180
|
+
}
|
|
181
|
+
d.buf.enqueue(args.chatId, pending)
|
|
182
|
+
return { kind: 'enqueued' }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Scenarios ───────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
describe('reaction-trigger flow', () => {
|
|
188
|
+
it('bot-authored 👎 dispatches a synthetic inbound with meta.source="reaction"', () => {
|
|
189
|
+
const d = makeDriver()
|
|
190
|
+
d.history.set(42, { role: 'assistant', text: 'hello from bot' })
|
|
191
|
+
const r = feedReaction(d, {
|
|
192
|
+
chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
|
|
193
|
+
})
|
|
194
|
+
expect(r.kind).toBe('enqueued')
|
|
195
|
+
expect(d.dispatched.length).toBe(0)
|
|
196
|
+
d.sched.flushAll()
|
|
197
|
+
expect(d.dispatched.length).toBe(1)
|
|
198
|
+
const out = d.dispatched[0]!
|
|
199
|
+
expect(out.meta.source).toBe('reaction')
|
|
200
|
+
expect(out.meta.batched).toBe('false')
|
|
201
|
+
expect(out.meta.reaction_emoji).toBe('👎')
|
|
202
|
+
expect(out.meta.target_message_id).toBe('42')
|
|
203
|
+
expect(out.text).toContain('<channel source="reaction"')
|
|
204
|
+
expect(out.text).toContain('hello from bot')
|
|
205
|
+
expect(out.userId).toBe(7)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('❤️ (not in default allowlist) dispatches NOTHING (negative)', () => {
|
|
209
|
+
const d = makeDriver()
|
|
210
|
+
d.history.set(42, { role: 'assistant', text: 'hi' })
|
|
211
|
+
const r = feedReaction(d, {
|
|
212
|
+
chatId: 100, messageId: 42, emoji: '❤️', action: 'add', reacterId: 7,
|
|
213
|
+
})
|
|
214
|
+
expect(r.kind).toBe('persisted_only')
|
|
215
|
+
if (r.kind === 'persisted_only') expect(r.reason).toBe('emoji_not_in_allowlist')
|
|
216
|
+
d.sched.flushAll()
|
|
217
|
+
expect(d.dispatched.length).toBe(0)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('👎 on a USER-authored message dispatches NOTHING (no trigger)', () => {
|
|
221
|
+
const d = makeDriver()
|
|
222
|
+
d.history.set(42, { role: 'user', text: 'something the user said' })
|
|
223
|
+
const r = feedReaction(d, {
|
|
224
|
+
chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
|
|
225
|
+
})
|
|
226
|
+
expect(r.kind).toBe('persisted_only')
|
|
227
|
+
if (r.kind === 'persisted_only') expect(r.reason).toBe('not_bot_authored')
|
|
228
|
+
d.sched.flushAll()
|
|
229
|
+
expect(d.dispatched.length).toBe(0)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('two qualifying reactions within window collapse into one batched synthetic', () => {
|
|
233
|
+
const d = makeDriver()
|
|
234
|
+
d.history.set(42, { role: 'assistant', text: 'first bot msg' })
|
|
235
|
+
d.history.set(43, { role: 'assistant', text: 'second bot msg' })
|
|
236
|
+
feedReaction(d, { chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7 })
|
|
237
|
+
feedReaction(d, { chatId: 100, messageId: 43, emoji: '✅', action: 'add', reacterId: 7 })
|
|
238
|
+
d.sched.flushAll()
|
|
239
|
+
expect(d.dispatched.length).toBe(1)
|
|
240
|
+
const out = d.dispatched[0]!
|
|
241
|
+
expect(out.meta.batched).toBe('true')
|
|
242
|
+
expect(out.meta.count).toBe('2')
|
|
243
|
+
// Inline list includes both target msg ids.
|
|
244
|
+
expect(out.text).toContain('on msg 42')
|
|
245
|
+
expect(out.text).toContain('on msg 43')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('hour cap refuses past the limit (no inbound emitted)', () => {
|
|
249
|
+
const d = makeDriver({ perHourCap: 2, debounceMs: 100 })
|
|
250
|
+
d.history.set(42, { role: 'assistant', text: 'hi' })
|
|
251
|
+
// Three back-to-back, each in its own debounce window.
|
|
252
|
+
for (let i = 0; i < 3; i++) {
|
|
253
|
+
const r = feedReaction(d, {
|
|
254
|
+
chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
|
|
255
|
+
})
|
|
256
|
+
if (i < 2) expect(r.kind).toBe('enqueued')
|
|
257
|
+
else {
|
|
258
|
+
expect(r.kind).toBe('persisted_only')
|
|
259
|
+
if (r.kind === 'persisted_only') expect(r.reason).toBe('hour_cap_exhausted')
|
|
260
|
+
}
|
|
261
|
+
// Drain so each enqueue gets its own debounce flush.
|
|
262
|
+
d.sched.flushAll()
|
|
263
|
+
}
|
|
264
|
+
expect(d.dispatched.length).toBe(2)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('group reaction by non-admin dispatches NOTHING (fail-closed)', () => {
|
|
268
|
+
const d = makeDriver()
|
|
269
|
+
d.history.set(42, { role: 'assistant', text: 'group bot msg' })
|
|
270
|
+
// Negative chat_id → group; admins set is empty.
|
|
271
|
+
const r = feedReaction(d, {
|
|
272
|
+
chatId: -1001234, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
|
|
273
|
+
})
|
|
274
|
+
expect(r.kind).toBe('persisted_only')
|
|
275
|
+
if (r.kind === 'persisted_only') expect(r.reason).toBe('group_non_admin')
|
|
276
|
+
d.sched.flushAll()
|
|
277
|
+
expect(d.dispatched.length).toBe(0)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('group reaction by admin dispatches normally', () => {
|
|
281
|
+
const d = makeDriver()
|
|
282
|
+
d.admins.add(7)
|
|
283
|
+
d.history.set(42, { role: 'assistant', text: 'group bot msg' })
|
|
284
|
+
const r = feedReaction(d, {
|
|
285
|
+
chatId: -1001234, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
|
|
286
|
+
})
|
|
287
|
+
expect(r.kind).toBe('enqueued')
|
|
288
|
+
d.sched.flushAll()
|
|
289
|
+
expect(d.dispatched.length).toBe(1)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('group reaction with group_admin_only=false ignores admin status', () => {
|
|
293
|
+
const d = makeDriver({ groupAdminOnly: false })
|
|
294
|
+
d.history.set(42, { role: 'assistant', text: 'group bot msg' })
|
|
295
|
+
const r = feedReaction(d, {
|
|
296
|
+
chatId: -1001234, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
|
|
297
|
+
})
|
|
298
|
+
expect(r.kind).toBe('enqueued')
|
|
299
|
+
d.sched.flushAll()
|
|
300
|
+
expect(d.dispatched.length).toBe(1)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('reaction-remove (old: [👎], new: []) is persistence-only (action=remove)', () => {
|
|
304
|
+
const d = makeDriver()
|
|
305
|
+
d.history.set(42, { role: 'assistant', text: 'hi' })
|
|
306
|
+
// action=remove is filtered out before predicate evaluation.
|
|
307
|
+
const r = feedReaction(d, {
|
|
308
|
+
chatId: 100, messageId: 42, emoji: null, action: 'remove', reacterId: 7,
|
|
309
|
+
})
|
|
310
|
+
expect(r.kind).toBe('persisted_only')
|
|
311
|
+
d.sched.flushAll()
|
|
312
|
+
expect(d.dispatched.length).toBe(0)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('reaction on a message not in history (no row) fails closed — no trigger', () => {
|
|
316
|
+
const d = makeDriver()
|
|
317
|
+
// No history entry for 42 — bot-authored is unknown.
|
|
318
|
+
const r = feedReaction(d, {
|
|
319
|
+
chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
|
|
320
|
+
})
|
|
321
|
+
expect(r.kind).toBe('persisted_only')
|
|
322
|
+
if (r.kind === 'persisted_only') expect(r.reason).toBe('not_bot_authored')
|
|
323
|
+
d.sched.flushAll()
|
|
324
|
+
expect(d.dispatched.length).toBe(0)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('enabled=false dispatches NOTHING even on default allowlist match', () => {
|
|
328
|
+
const d = makeDriver({ enabled: false })
|
|
329
|
+
d.history.set(42, { role: 'assistant', text: 'hi' })
|
|
330
|
+
const r = feedReaction(d, {
|
|
331
|
+
chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
|
|
332
|
+
})
|
|
333
|
+
expect(r.kind).toBe('persisted_only')
|
|
334
|
+
if (r.kind === 'persisted_only') expect(r.reason).toBe('disabled')
|
|
335
|
+
d.sched.flushAll()
|
|
336
|
+
expect(d.dispatched.length).toBe(0)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('cascade: trigger_emojis: [] disables triggering without flipping enabled', () => {
|
|
340
|
+
// Operator narrows to empty allowlist — every qualifying emoji is
|
|
341
|
+
// now a miss. This is the "kill switch without bigger hammer" case.
|
|
342
|
+
const cfg = resolveReactionsConfig({ trigger_emojis: [] })
|
|
343
|
+
const d = makeDriver(cfg)
|
|
344
|
+
d.history.set(42, { role: 'assistant', text: 'hi' })
|
|
345
|
+
const r = feedReaction(d, {
|
|
346
|
+
chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
|
|
347
|
+
})
|
|
348
|
+
expect(r.kind).toBe('persisted_only')
|
|
349
|
+
if (r.kind === 'persisted_only') expect(r.reason).toBe('emoji_not_in_allowlist')
|
|
350
|
+
d.sched.flushAll()
|
|
351
|
+
expect(d.dispatched.length).toBe(0)
|
|
352
|
+
})
|
|
353
|
+
})
|