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
|
@@ -1,773 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for the pin-lifecycle manager.
|
|
3
|
-
*
|
|
4
|
-
* Previously this logic lived inline in gateway.ts (progressDriver
|
|
5
|
-
* setup block) and was unreachable from tests — the full
|
|
6
|
-
* first-emit → pin → edit → turn-end → unpin sequence had no direct
|
|
7
|
-
* coverage. This suite pins all the behaviors the gateway depends on,
|
|
8
|
-
* plus failure branches that only exist in production until now.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
12
|
-
import {
|
|
13
|
-
createPinManager,
|
|
14
|
-
type PinManager,
|
|
15
|
-
type PinManagerDeps,
|
|
16
|
-
type ActivePinEntry,
|
|
17
|
-
type TimerHandle,
|
|
18
|
-
} from '../progress-card-pin-manager.js'
|
|
19
|
-
import { errors } from './fake-bot-api.js'
|
|
20
|
-
|
|
21
|
-
interface PendingTimer {
|
|
22
|
-
fn: () => void
|
|
23
|
-
ms: number
|
|
24
|
-
cancelled: boolean
|
|
25
|
-
fired: boolean
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface Harness {
|
|
29
|
-
mgr: PinManager
|
|
30
|
-
deps: {
|
|
31
|
-
pin: ReturnType<typeof vi.fn>
|
|
32
|
-
unpin: ReturnType<typeof vi.fn>
|
|
33
|
-
deleteMessage: ReturnType<typeof vi.fn>
|
|
34
|
-
addPin: ReturnType<typeof vi.fn>
|
|
35
|
-
removePin: ReturnType<typeof vi.fn>
|
|
36
|
-
log: ReturnType<typeof vi.fn>
|
|
37
|
-
}
|
|
38
|
-
/** Recorded sidecar state — tests assert on this directly. */
|
|
39
|
-
sidecar: ActivePinEntry[]
|
|
40
|
-
/** Captured pin-delay timers — tests fire them manually. */
|
|
41
|
-
timers: PendingTimer[]
|
|
42
|
-
/** Fire every pending (not-yet-fired, not-cancelled) timer. */
|
|
43
|
-
fireTimers(): void
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Build a harness with sensible defaults. `now` is fixed at 10_000. */
|
|
47
|
-
function mkHarness(overrides: Partial<PinManagerDeps> = {}): Harness {
|
|
48
|
-
const sidecar: ActivePinEntry[] = []
|
|
49
|
-
const timers: PendingTimer[] = []
|
|
50
|
-
|
|
51
|
-
const deps = {
|
|
52
|
-
pin: vi.fn(async () => true),
|
|
53
|
-
unpin: vi.fn(async () => true),
|
|
54
|
-
deleteMessage: vi.fn(async () => true),
|
|
55
|
-
addPin: vi.fn((entry: ActivePinEntry) => {
|
|
56
|
-
sidecar.push(entry)
|
|
57
|
-
}),
|
|
58
|
-
removePin: vi.fn((chatId: string, messageId: number) => {
|
|
59
|
-
const idx = sidecar.findIndex((e) => e.chatId === chatId && e.messageId === messageId)
|
|
60
|
-
if (idx >= 0) sidecar.splice(idx, 1)
|
|
61
|
-
}),
|
|
62
|
-
log: vi.fn(),
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const scheduleTimer = (fn: () => void, ms: number): TimerHandle => {
|
|
66
|
-
const entry: PendingTimer = { fn, ms, cancelled: false, fired: false }
|
|
67
|
-
timers.push(entry)
|
|
68
|
-
return {
|
|
69
|
-
cancel() {
|
|
70
|
-
entry.cancelled = true
|
|
71
|
-
},
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const mgr = createPinManager({
|
|
76
|
-
...deps,
|
|
77
|
-
now: () => 10_000,
|
|
78
|
-
scheduleTimer,
|
|
79
|
-
...overrides,
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
const fireTimers = (): void => {
|
|
83
|
-
// Snapshot so timers pushed during firing don't run this pass.
|
|
84
|
-
const snapshot = [...timers]
|
|
85
|
-
for (const t of snapshot) {
|
|
86
|
-
if (t.cancelled || t.fired) continue
|
|
87
|
-
t.fired = true
|
|
88
|
-
t.fn()
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return { mgr, deps, sidecar, timers, fireTimers }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
describe('createPinManager', () => {
|
|
96
|
-
describe('considerPin — first emit', () => {
|
|
97
|
-
it('pins the message, records the sidecar entry, tracks the turnKey', async () => {
|
|
98
|
-
const h = mkHarness()
|
|
99
|
-
h.mgr.considerPin({
|
|
100
|
-
chatId: 'chat-1',
|
|
101
|
-
threadId: '42',
|
|
102
|
-
turnKey: 'chat-1:42:1',
|
|
103
|
-
messageId: 500,
|
|
104
|
-
isFirstEmit: true,
|
|
105
|
-
})
|
|
106
|
-
h.fireTimers()
|
|
107
|
-
await h.mgr.drainInFlight()
|
|
108
|
-
|
|
109
|
-
// Bot API was called with the exact shape the gateway used inline.
|
|
110
|
-
expect(h.deps.pin).toHaveBeenCalledWith('chat-1', 500, { disable_notification: true })
|
|
111
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(1)
|
|
112
|
-
// Sidecar recorded the pin with the injected clock.
|
|
113
|
-
expect(h.sidecar).toEqual([
|
|
114
|
-
{ chatId: 'chat-1', messageId: 500, turnKey: 'chat-1:42:1', pinnedAt: 10_000, agentId: '__parent__' },
|
|
115
|
-
])
|
|
116
|
-
// In-memory index reflects the pin.
|
|
117
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual(['chat-1:42:1'])
|
|
118
|
-
expect(h.mgr.pinnedMessageId('chat-1:42:1')).toBe(500)
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('ignores emits where isFirstEmit=false', async () => {
|
|
122
|
-
const h = mkHarness()
|
|
123
|
-
h.mgr.considerPin({
|
|
124
|
-
chatId: 'chat-1',
|
|
125
|
-
turnKey: 'chat-1:1',
|
|
126
|
-
messageId: 500,
|
|
127
|
-
isFirstEmit: false,
|
|
128
|
-
})
|
|
129
|
-
h.fireTimers()
|
|
130
|
-
await h.mgr.drainInFlight()
|
|
131
|
-
|
|
132
|
-
expect(h.deps.pin).not.toHaveBeenCalled()
|
|
133
|
-
expect(h.deps.addPin).not.toHaveBeenCalled()
|
|
134
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual([])
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
it('is idempotent — a second isFirstEmit for the same turnKey does nothing', async () => {
|
|
138
|
-
const h = mkHarness()
|
|
139
|
-
const c = {
|
|
140
|
-
chatId: 'c',
|
|
141
|
-
turnKey: 'c:1',
|
|
142
|
-
messageId: 500,
|
|
143
|
-
isFirstEmit: true,
|
|
144
|
-
}
|
|
145
|
-
h.mgr.considerPin(c)
|
|
146
|
-
h.mgr.considerPin({ ...c, messageId: 501 })
|
|
147
|
-
h.fireTimers()
|
|
148
|
-
await h.mgr.drainInFlight()
|
|
149
|
-
|
|
150
|
-
// Only the first pin landed.
|
|
151
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(1)
|
|
152
|
-
expect(h.deps.pin).toHaveBeenCalledWith('c', 500, { disable_notification: true })
|
|
153
|
-
expect(h.deps.addPin).toHaveBeenCalledTimes(1)
|
|
154
|
-
expect(h.mgr.pinnedMessageId('c:1')).toBe(500)
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it('different turnKeys pin independently', async () => {
|
|
158
|
-
const h = mkHarness()
|
|
159
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
160
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:2', messageId: 501, isFirstEmit: true })
|
|
161
|
-
h.fireTimers()
|
|
162
|
-
await h.mgr.drainInFlight()
|
|
163
|
-
|
|
164
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(2)
|
|
165
|
-
expect(h.mgr.pinnedTurnKeys().sort()).toEqual(['c:1', 'c:2'])
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
it('works without a sidecar (no agentDir in production = no addPin wired)', async () => {
|
|
169
|
-
const h = mkHarness({ addPin: undefined, removePin: undefined })
|
|
170
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
171
|
-
h.fireTimers()
|
|
172
|
-
await h.mgr.drainInFlight()
|
|
173
|
-
|
|
174
|
-
expect(h.deps.pin).toHaveBeenCalled()
|
|
175
|
-
expect(h.mgr.pinnedMessageId('c:1')).toBe(500)
|
|
176
|
-
})
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
describe('considerPin — failure rollback', () => {
|
|
180
|
-
it('firePin API rejection deletes from pinned map and clears sidecar', async () => {
|
|
181
|
-
const h = mkHarness()
|
|
182
|
-
h.deps.pin.mockRejectedValueOnce(errors.forbidden('pinChatMessage'))
|
|
183
|
-
|
|
184
|
-
h.mgr.considerPin({
|
|
185
|
-
chatId: 'c',
|
|
186
|
-
turnKey: 'c:1',
|
|
187
|
-
messageId: 500,
|
|
188
|
-
isFirstEmit: true,
|
|
189
|
-
})
|
|
190
|
-
h.fireTimers()
|
|
191
|
-
await h.mgr.drainInFlight()
|
|
192
|
-
|
|
193
|
-
// Sidecar rolled back.
|
|
194
|
-
expect(h.deps.removePin).toHaveBeenCalledWith('c', 500)
|
|
195
|
-
expect(h.sidecar).toEqual([])
|
|
196
|
-
// Log captured the failure.
|
|
197
|
-
expect(h.deps.log).toHaveBeenCalledWith(
|
|
198
|
-
expect.stringMatching(/progress-card pin failed/),
|
|
199
|
-
)
|
|
200
|
-
// In-memory entry is dropped — the pin never landed, so a later
|
|
201
|
-
// completeTurn must not issue an unpin against a non-existent pin.
|
|
202
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual([])
|
|
203
|
-
expect(h.mgr.pinnedMessageId('c:1')).toBeUndefined()
|
|
204
|
-
|
|
205
|
-
// Trigger an unpin and assert deps.unpin was NOT called — the key
|
|
206
|
-
// was already removed from `pinned`, so completeTurn is a no-op.
|
|
207
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
208
|
-
await h.mgr.drainInFlight()
|
|
209
|
-
expect(h.deps.unpin).not.toHaveBeenCalled()
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
it('pin rejection with 429: log line still fires, no retry', async () => {
|
|
213
|
-
const h = mkHarness()
|
|
214
|
-
h.deps.pin.mockRejectedValueOnce(errors.floodWait(3, 'pinChatMessage'))
|
|
215
|
-
|
|
216
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
217
|
-
h.fireTimers()
|
|
218
|
-
await h.mgr.drainInFlight()
|
|
219
|
-
|
|
220
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(1)
|
|
221
|
-
expect(h.deps.log).toHaveBeenCalled()
|
|
222
|
-
})
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
describe('completeTurn — unpin', () => {
|
|
226
|
-
it('unpins the pinned message and clears the sidecar', async () => {
|
|
227
|
-
const h = mkHarness()
|
|
228
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
229
|
-
h.fireTimers()
|
|
230
|
-
await h.mgr.drainInFlight()
|
|
231
|
-
expect(h.sidecar).toHaveLength(1)
|
|
232
|
-
|
|
233
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
234
|
-
await h.mgr.drainInFlight()
|
|
235
|
-
|
|
236
|
-
expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
|
|
237
|
-
expect(h.sidecar).toEqual([])
|
|
238
|
-
expect(h.mgr.pinnedMessageId('c:1')).toBeUndefined()
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
it('no-op when the turn was never pinned', async () => {
|
|
242
|
-
const h = mkHarness()
|
|
243
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:never' })
|
|
244
|
-
await h.mgr.drainInFlight()
|
|
245
|
-
expect(h.deps.unpin).not.toHaveBeenCalled()
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
it('duplicate completeTurn does not double-unpin', async () => {
|
|
249
|
-
const h = mkHarness()
|
|
250
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
251
|
-
h.fireTimers()
|
|
252
|
-
await h.mgr.drainInFlight()
|
|
253
|
-
|
|
254
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
255
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
256
|
-
await h.mgr.drainInFlight()
|
|
257
|
-
|
|
258
|
-
expect(h.deps.unpin).toHaveBeenCalledTimes(1)
|
|
259
|
-
expect(h.deps.removePin).toHaveBeenCalledTimes(1)
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
it('unpin rejection still removes the sidecar entry', async () => {
|
|
263
|
-
const h = mkHarness()
|
|
264
|
-
h.deps.unpin.mockRejectedValueOnce(errors.badRequest('chat not found', 'unpinChatMessage'))
|
|
265
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
266
|
-
h.fireTimers()
|
|
267
|
-
await h.mgr.drainInFlight()
|
|
268
|
-
|
|
269
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
270
|
-
await h.mgr.drainInFlight()
|
|
271
|
-
|
|
272
|
-
// Sidecar is cleared on unpin-attempt regardless of outcome —
|
|
273
|
-
// the sidecar exists for crash recovery, so leaving stale entries
|
|
274
|
-
// would cause duplicate unpins on the next boot.
|
|
275
|
-
expect(h.sidecar).toEqual([])
|
|
276
|
-
expect(h.deps.log).toHaveBeenCalledWith(
|
|
277
|
-
expect.stringMatching(/progress-card unpin failed/),
|
|
278
|
-
)
|
|
279
|
-
})
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
describe('unpinForChat — external cancellation hook', () => {
|
|
283
|
-
it('unpins every pinned turn matching a chat+thread', async () => {
|
|
284
|
-
const h = mkHarness()
|
|
285
|
-
h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:1', messageId: 500, isFirstEmit: true })
|
|
286
|
-
h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:2', messageId: 501, isFirstEmit: true })
|
|
287
|
-
h.mgr.considerPin({ chatId: 'c', threadId: '99', turnKey: 'c:99:1', messageId: 502, isFirstEmit: true })
|
|
288
|
-
h.fireTimers()
|
|
289
|
-
await h.mgr.drainInFlight()
|
|
290
|
-
expect(h.mgr.pinnedTurnKeys()).toHaveLength(3)
|
|
291
|
-
|
|
292
|
-
h.mgr.unpinForChat('c', 42)
|
|
293
|
-
await h.mgr.drainInFlight()
|
|
294
|
-
|
|
295
|
-
// Thread 42's pins were cleared, thread 99's remains.
|
|
296
|
-
expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
|
|
297
|
-
expect(h.deps.unpin).toHaveBeenCalledWith('c', 501)
|
|
298
|
-
expect(h.deps.unpin).not.toHaveBeenCalledWith('c', 502)
|
|
299
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual(['c:99:1'])
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
it('unpinForChat with no threadId matches chat-root turns only', async () => {
|
|
303
|
-
const h = mkHarness()
|
|
304
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
305
|
-
h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:1', messageId: 501, isFirstEmit: true })
|
|
306
|
-
h.fireTimers()
|
|
307
|
-
await h.mgr.drainInFlight()
|
|
308
|
-
|
|
309
|
-
h.mgr.unpinForChat('c', undefined)
|
|
310
|
-
await h.mgr.drainInFlight()
|
|
311
|
-
|
|
312
|
-
// Only the chat-root turn (prefix "c:") was unpinned. The threaded
|
|
313
|
-
// turn (prefix "c:42:") also starts with "c:" in string terms —
|
|
314
|
-
// verify behaviour carefully. By current design, unpinForChat
|
|
315
|
-
// with no thread matches `c:` prefix — including threaded turns.
|
|
316
|
-
// This is the contract the gateway had before extraction.
|
|
317
|
-
// If we wanted to change it to chat-root-only, that'd be a
|
|
318
|
-
// deliberate spec change.
|
|
319
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual([])
|
|
320
|
-
})
|
|
321
|
-
|
|
322
|
-
it('unpinForChat on an empty manager is safe', async () => {
|
|
323
|
-
const h = mkHarness()
|
|
324
|
-
h.mgr.unpinForChat('c', 42)
|
|
325
|
-
await h.mgr.drainInFlight()
|
|
326
|
-
expect(h.deps.unpin).not.toHaveBeenCalled()
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
it('unpinForChat is safe mid-iteration when pins mutate the map', async () => {
|
|
330
|
-
const h = mkHarness()
|
|
331
|
-
for (let i = 1; i <= 5; i++) {
|
|
332
|
-
h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: `c:42:${i}`, messageId: 499 + i, isFirstEmit: true })
|
|
333
|
-
}
|
|
334
|
-
h.fireTimers()
|
|
335
|
-
await h.mgr.drainInFlight()
|
|
336
|
-
|
|
337
|
-
// Snapshot-before-iterate is important: the doUnpin path mutates
|
|
338
|
-
// the pinned map. If iteration used the live map directly, we'd
|
|
339
|
-
// miss entries.
|
|
340
|
-
h.mgr.unpinForChat('c', 42)
|
|
341
|
-
await h.mgr.drainInFlight()
|
|
342
|
-
|
|
343
|
-
expect(h.deps.unpin).toHaveBeenCalledTimes(5)
|
|
344
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual([])
|
|
345
|
-
})
|
|
346
|
-
})
|
|
347
|
-
|
|
348
|
-
describe('multi-turn lifecycle', () => {
|
|
349
|
-
it('pin → complete → new turn with same chat keeps things independent', async () => {
|
|
350
|
-
const h = mkHarness()
|
|
351
|
-
|
|
352
|
-
// Turn 1
|
|
353
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
354
|
-
h.fireTimers()
|
|
355
|
-
await h.mgr.drainInFlight()
|
|
356
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
357
|
-
await h.mgr.drainInFlight()
|
|
358
|
-
|
|
359
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual([])
|
|
360
|
-
|
|
361
|
-
// Turn 2 on the same chat
|
|
362
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:2', messageId: 501, isFirstEmit: true })
|
|
363
|
-
h.fireTimers()
|
|
364
|
-
await h.mgr.drainInFlight()
|
|
365
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:2' })
|
|
366
|
-
await h.mgr.drainInFlight()
|
|
367
|
-
|
|
368
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(2)
|
|
369
|
-
expect(h.deps.unpin).toHaveBeenCalledTimes(2)
|
|
370
|
-
expect(h.deps.pin.mock.calls[0][1]).toBe(500)
|
|
371
|
-
expect(h.deps.pin.mock.calls[1][1]).toBe(501)
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
it('concurrent turns across chats: each turn is pinned + unpinned independently', async () => {
|
|
375
|
-
const h = mkHarness()
|
|
376
|
-
|
|
377
|
-
h.mgr.considerPin({ chatId: 'A', turnKey: 'A:1', messageId: 500, isFirstEmit: true })
|
|
378
|
-
h.mgr.considerPin({ chatId: 'B', turnKey: 'B:1', messageId: 501, isFirstEmit: true })
|
|
379
|
-
h.fireTimers()
|
|
380
|
-
await h.mgr.drainInFlight()
|
|
381
|
-
expect(h.mgr.pinnedTurnKeys().sort()).toEqual(['A:1', 'B:1'])
|
|
382
|
-
|
|
383
|
-
h.mgr.completeTurn({ chatId: 'A', turnKey: 'A:1' })
|
|
384
|
-
await h.mgr.drainInFlight()
|
|
385
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual(['B:1'])
|
|
386
|
-
|
|
387
|
-
h.mgr.completeTurn({ chatId: 'B', turnKey: 'B:1' })
|
|
388
|
-
await h.mgr.drainInFlight()
|
|
389
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual([])
|
|
390
|
-
})
|
|
391
|
-
|
|
392
|
-
it('reused turnKey after complete starts fresh (unpinned set was cleared)', async () => {
|
|
393
|
-
const h = mkHarness()
|
|
394
|
-
|
|
395
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
396
|
-
h.fireTimers()
|
|
397
|
-
await h.mgr.drainInFlight()
|
|
398
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
399
|
-
await h.mgr.drainInFlight()
|
|
400
|
-
|
|
401
|
-
// Unlikely but defensive: if the driver ever reuses the same
|
|
402
|
-
// turnKey, the manager starts clean.
|
|
403
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 777, isFirstEmit: true })
|
|
404
|
-
h.fireTimers()
|
|
405
|
-
await h.mgr.drainInFlight()
|
|
406
|
-
|
|
407
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(2)
|
|
408
|
-
expect(h.mgr.pinnedMessageId('c:1')).toBe(777)
|
|
409
|
-
})
|
|
410
|
-
})
|
|
411
|
-
|
|
412
|
-
describe('captureServiceMessage — pin-service-msg deletion', () => {
|
|
413
|
-
it('deletes the service message when it wraps a tracked pin', async () => {
|
|
414
|
-
const h = mkHarness()
|
|
415
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
416
|
-
h.fireTimers()
|
|
417
|
-
await h.mgr.drainInFlight()
|
|
418
|
-
|
|
419
|
-
h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 500, serviceMessageId: 9001 })
|
|
420
|
-
await h.mgr.drainInFlight()
|
|
421
|
-
|
|
422
|
-
expect(h.deps.deleteMessage).toHaveBeenCalledWith('c', 9001)
|
|
423
|
-
})
|
|
424
|
-
|
|
425
|
-
it('ignores service messages wrapping pins we did not track', async () => {
|
|
426
|
-
const h = mkHarness()
|
|
427
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
428
|
-
h.fireTimers()
|
|
429
|
-
await h.mgr.drainInFlight()
|
|
430
|
-
|
|
431
|
-
h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 999, serviceMessageId: 9001 })
|
|
432
|
-
await h.mgr.drainInFlight()
|
|
433
|
-
|
|
434
|
-
expect(h.deps.deleteMessage).not.toHaveBeenCalled()
|
|
435
|
-
})
|
|
436
|
-
|
|
437
|
-
it('issue #94: deletes service messages for externally-tracked pins (worker card)', async () => {
|
|
438
|
-
// Worker / sub-agent cards are pinned via the gateway directly,
|
|
439
|
-
// not through `considerPin`. They register with `trackExternalPin`
|
|
440
|
-
// so `captureServiceMessage` recognises their service messages and
|
|
441
|
-
// suppresses the "Clerk pinned …" system noise (matching the main
|
|
442
|
-
// card's behaviour). Without this branch the worker card's pin
|
|
443
|
-
// event would slip through unmatched.
|
|
444
|
-
const h = mkHarness()
|
|
445
|
-
h.mgr.trackExternalPin('c', 777)
|
|
446
|
-
|
|
447
|
-
h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 777, serviceMessageId: 9002 })
|
|
448
|
-
await h.mgr.drainInFlight()
|
|
449
|
-
|
|
450
|
-
expect(h.deps.deleteMessage).toHaveBeenCalledWith('c', 9002)
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
it('issue #94: untrackExternalPin stops further captures', async () => {
|
|
454
|
-
const h = mkHarness()
|
|
455
|
-
h.mgr.trackExternalPin('c', 777)
|
|
456
|
-
h.mgr.untrackExternalPin('c', 777)
|
|
457
|
-
|
|
458
|
-
h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 777, serviceMessageId: 9002 })
|
|
459
|
-
await h.mgr.drainInFlight()
|
|
460
|
-
|
|
461
|
-
// Once untracked, the manager treats the pin as unknown again and
|
|
462
|
-
// declines to delete — same shape as the "ignores untracked pins"
|
|
463
|
-
// test above.
|
|
464
|
-
expect(h.deps.deleteMessage).not.toHaveBeenCalled()
|
|
465
|
-
})
|
|
466
|
-
|
|
467
|
-
it('no-op when deleteMessage is not wired', async () => {
|
|
468
|
-
const h = mkHarness({ deleteMessage: undefined })
|
|
469
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
470
|
-
h.fireTimers()
|
|
471
|
-
await h.mgr.drainInFlight()
|
|
472
|
-
|
|
473
|
-
expect(() => {
|
|
474
|
-
h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 500, serviceMessageId: 9001 })
|
|
475
|
-
}).not.toThrow()
|
|
476
|
-
await h.mgr.drainInFlight()
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
it('deleteMessage rejection is logged and does not throw', async () => {
|
|
480
|
-
const h = mkHarness()
|
|
481
|
-
h.deps.deleteMessage.mockRejectedValueOnce(errors.badRequest('message to delete not found', 'deleteMessage'))
|
|
482
|
-
|
|
483
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
484
|
-
h.fireTimers()
|
|
485
|
-
await h.mgr.drainInFlight()
|
|
486
|
-
|
|
487
|
-
h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 500, serviceMessageId: 9001 })
|
|
488
|
-
await h.mgr.drainInFlight()
|
|
489
|
-
|
|
490
|
-
expect(h.deps.log).toHaveBeenCalledWith(
|
|
491
|
-
expect.stringMatching(/pin service-msg delete failed/),
|
|
492
|
-
)
|
|
493
|
-
})
|
|
494
|
-
|
|
495
|
-
it('unpin deletes a service message that was captured but not yet deleted', async () => {
|
|
496
|
-
// Simulate: capture arrives, but deleteMessage is pending forever —
|
|
497
|
-
// then an unpin fires. Because captureServiceMessage already
|
|
498
|
-
// attempted the delete and removed its entry, unpin won't double-fire;
|
|
499
|
-
// this guards the inverse scenario where capture never arrived.
|
|
500
|
-
// Here we test the safety-net path: no capture → no stray delete.
|
|
501
|
-
const h = mkHarness()
|
|
502
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
503
|
-
h.fireTimers()
|
|
504
|
-
await h.mgr.drainInFlight()
|
|
505
|
-
|
|
506
|
-
// No captureServiceMessage call — simulates a lost/unmatched update.
|
|
507
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
508
|
-
await h.mgr.drainInFlight()
|
|
509
|
-
|
|
510
|
-
expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
|
|
511
|
-
expect(h.deps.deleteMessage).not.toHaveBeenCalled()
|
|
512
|
-
})
|
|
513
|
-
})
|
|
514
|
-
|
|
515
|
-
describe('drainInFlight', () => {
|
|
516
|
-
it('resolves even when no promises are pending', async () => {
|
|
517
|
-
const h = mkHarness()
|
|
518
|
-
await expect(h.mgr.drainInFlight()).resolves.toBeUndefined()
|
|
519
|
-
})
|
|
520
|
-
|
|
521
|
-
it('awaits both the pin catch-chain and the unpin finally-chain', async () => {
|
|
522
|
-
const h = mkHarness()
|
|
523
|
-
// Slow pin + slow unpin → drainInFlight should cover both.
|
|
524
|
-
let resolvePin!: () => void
|
|
525
|
-
let resolveUnpin!: () => void
|
|
526
|
-
h.deps.pin.mockImplementationOnce(() => new Promise<true>((r) => { resolvePin = () => r(true) }))
|
|
527
|
-
h.deps.unpin.mockImplementationOnce(() => new Promise<true>((r) => { resolveUnpin = () => r(true) }))
|
|
528
|
-
|
|
529
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
530
|
-
h.fireTimers()
|
|
531
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
532
|
-
|
|
533
|
-
const drained = h.mgr.drainInFlight()
|
|
534
|
-
resolvePin()
|
|
535
|
-
resolveUnpin()
|
|
536
|
-
await drained
|
|
537
|
-
|
|
538
|
-
// After drain, removePin should have fired (from unpin's finally).
|
|
539
|
-
expect(h.deps.removePin).toHaveBeenCalled()
|
|
540
|
-
})
|
|
541
|
-
})
|
|
542
|
-
|
|
543
|
-
describe('deferred pin timing — fast turns stay silent', () => {
|
|
544
|
-
it('considerPin does not call pin synchronously — timer is scheduled', async () => {
|
|
545
|
-
const h = mkHarness()
|
|
546
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
547
|
-
await h.mgr.drainInFlight()
|
|
548
|
-
|
|
549
|
-
// Timer scheduled, but not fired → no pin yet. Default pinDelayMs
|
|
550
|
-
// is now 0 (fast-turn suppression is owned upstream by the
|
|
551
|
-
// driver's initialDelayMs); the setTimeout indirection remains so
|
|
552
|
-
// completeTurn can still cancel a pin that hasn't landed yet.
|
|
553
|
-
expect(h.timers).toHaveLength(1)
|
|
554
|
-
expect(h.timers[0].ms).toBe(0)
|
|
555
|
-
expect(h.deps.pin).not.toHaveBeenCalled()
|
|
556
|
-
expect(h.deps.addPin).not.toHaveBeenCalled()
|
|
557
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual([])
|
|
558
|
-
})
|
|
559
|
-
|
|
560
|
-
it('fast turn: completeTurn before timer fires → never pins, never unpins', async () => {
|
|
561
|
-
const h = mkHarness()
|
|
562
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
563
|
-
// Turn completes before pinDelayMs elapses. The timer is cancelled
|
|
564
|
-
// and no pin/unpin ever touches Telegram.
|
|
565
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
566
|
-
// Even if the timer somehow fires later (belt-and-braces), it should
|
|
567
|
-
// be marked cancelled and skipped.
|
|
568
|
-
h.fireTimers()
|
|
569
|
-
await h.mgr.drainInFlight()
|
|
570
|
-
|
|
571
|
-
expect(h.deps.pin).not.toHaveBeenCalled()
|
|
572
|
-
expect(h.deps.unpin).not.toHaveBeenCalled()
|
|
573
|
-
expect(h.deps.addPin).not.toHaveBeenCalled()
|
|
574
|
-
expect(h.sidecar).toEqual([])
|
|
575
|
-
})
|
|
576
|
-
|
|
577
|
-
it('slow turn: pin fires when timer elapses, then completeTurn unpins', async () => {
|
|
578
|
-
const h = mkHarness()
|
|
579
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
580
|
-
await h.mgr.drainInFlight()
|
|
581
|
-
expect(h.deps.pin).not.toHaveBeenCalled()
|
|
582
|
-
|
|
583
|
-
// Timer elapses → pin lands.
|
|
584
|
-
h.fireTimers()
|
|
585
|
-
await h.mgr.drainInFlight()
|
|
586
|
-
expect(h.deps.pin).toHaveBeenCalledWith('c', 500, { disable_notification: true })
|
|
587
|
-
expect(h.mgr.pinnedMessageId('c:1')).toBe(500)
|
|
588
|
-
|
|
589
|
-
// completeTurn after the pin → unpin lands.
|
|
590
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
591
|
-
await h.mgr.drainInFlight()
|
|
592
|
-
expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
|
|
593
|
-
expect(h.mgr.pinnedMessageId('c:1')).toBeUndefined()
|
|
594
|
-
})
|
|
595
|
-
|
|
596
|
-
it('unpinForChat cancels pending (not-yet-fired) timers', async () => {
|
|
597
|
-
const h = mkHarness()
|
|
598
|
-
h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:1', messageId: 500, isFirstEmit: true })
|
|
599
|
-
h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:2', messageId: 501, isFirstEmit: true })
|
|
600
|
-
|
|
601
|
-
// Clear pending pins before any timer fires.
|
|
602
|
-
h.mgr.unpinForChat('c', 42)
|
|
603
|
-
h.fireTimers()
|
|
604
|
-
await h.mgr.drainInFlight()
|
|
605
|
-
|
|
606
|
-
// No pins, no unpins — the timers were cancelled and never fired.
|
|
607
|
-
expect(h.deps.pin).not.toHaveBeenCalled()
|
|
608
|
-
expect(h.deps.unpin).not.toHaveBeenCalled()
|
|
609
|
-
})
|
|
610
|
-
|
|
611
|
-
it('unpinForChat cancels pending timers AND unpins already-fired pins', async () => {
|
|
612
|
-
const h = mkHarness()
|
|
613
|
-
// First pin: fire its timer → already pinned.
|
|
614
|
-
h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:1', messageId: 500, isFirstEmit: true })
|
|
615
|
-
h.fireTimers()
|
|
616
|
-
await h.mgr.drainInFlight()
|
|
617
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(1)
|
|
618
|
-
|
|
619
|
-
// Second pin: timer still pending.
|
|
620
|
-
h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:2', messageId: 501, isFirstEmit: true })
|
|
621
|
-
|
|
622
|
-
h.mgr.unpinForChat('c', 42)
|
|
623
|
-
h.fireTimers() // noop — second timer was cancelled.
|
|
624
|
-
await h.mgr.drainInFlight()
|
|
625
|
-
|
|
626
|
-
// First pin got unpinned; second never pinned.
|
|
627
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(1)
|
|
628
|
-
expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
|
|
629
|
-
expect(h.deps.unpin).not.toHaveBeenCalledWith('c', 501)
|
|
630
|
-
})
|
|
631
|
-
|
|
632
|
-
it('custom pinDelayMs overrides the default', async () => {
|
|
633
|
-
const h = mkHarness({ pinDelayMs: 5_000 })
|
|
634
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
635
|
-
|
|
636
|
-
expect(h.timers).toHaveLength(1)
|
|
637
|
-
expect(h.timers[0].ms).toBe(5_000)
|
|
638
|
-
})
|
|
639
|
-
|
|
640
|
-
it('pinDelayMs=0 still defers through the timer (no sync pin)', async () => {
|
|
641
|
-
// Guards against a tempting optimization: "if pinDelayMs === 0,
|
|
642
|
-
// pin synchronously." We pass it through the timer anyway so the
|
|
643
|
-
// contract is uniform (considerPin never blocks, never pins
|
|
644
|
-
// before control returns).
|
|
645
|
-
const h = mkHarness({ pinDelayMs: 0 })
|
|
646
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
647
|
-
|
|
648
|
-
expect(h.deps.pin).not.toHaveBeenCalled()
|
|
649
|
-
expect(h.timers).toHaveLength(1)
|
|
650
|
-
|
|
651
|
-
h.fireTimers()
|
|
652
|
-
await h.mgr.drainInFlight()
|
|
653
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(1)
|
|
654
|
-
})
|
|
655
|
-
})
|
|
656
|
-
|
|
657
|
-
describe('per-agent composite key — one pin per (turnKey, agentId)', () => {
|
|
658
|
-
it('distinct agentIds under the same turnKey pin independently', async () => {
|
|
659
|
-
const h = mkHarness()
|
|
660
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true, agentId: 'parent' })
|
|
661
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 501, isFirstEmit: true, agentId: 'sub-a' })
|
|
662
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 502, isFirstEmit: true, agentId: 'sub-b' })
|
|
663
|
-
h.fireTimers()
|
|
664
|
-
await h.mgr.drainInFlight()
|
|
665
|
-
|
|
666
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(3)
|
|
667
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual(['c:1'])
|
|
668
|
-
expect(h.mgr.pinnedAgentIds('c:1').sort()).toEqual(['parent', 'sub-a', 'sub-b'])
|
|
669
|
-
expect(h.mgr.pinnedMessageId('c:1', 'parent')).toBe(500)
|
|
670
|
-
expect(h.mgr.pinnedMessageId('c:1', 'sub-a')).toBe(501)
|
|
671
|
-
expect(h.mgr.pinnedMessageId('c:1', 'sub-b')).toBe(502)
|
|
672
|
-
})
|
|
673
|
-
|
|
674
|
-
it('idempotent within a (turnKey, agentId) — second considerPin is a no-op', async () => {
|
|
675
|
-
const h = mkHarness()
|
|
676
|
-
const c = { chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true, agentId: 'sub-a' }
|
|
677
|
-
h.mgr.considerPin(c)
|
|
678
|
-
h.mgr.considerPin({ ...c, messageId: 999 })
|
|
679
|
-
h.fireTimers()
|
|
680
|
-
await h.mgr.drainInFlight()
|
|
681
|
-
|
|
682
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(1)
|
|
683
|
-
expect(h.deps.pin).toHaveBeenCalledWith('c', 500, { disable_notification: true })
|
|
684
|
-
expect(h.mgr.pinnedMessageId('c:1', 'sub-a')).toBe(500)
|
|
685
|
-
})
|
|
686
|
-
|
|
687
|
-
it('completeTurn for one agentId leaves siblings under the same turnKey untouched', async () => {
|
|
688
|
-
const h = mkHarness()
|
|
689
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true, agentId: 'parent' })
|
|
690
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 501, isFirstEmit: true, agentId: 'sub-a' })
|
|
691
|
-
h.fireTimers()
|
|
692
|
-
await h.mgr.drainInFlight()
|
|
693
|
-
|
|
694
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1', agentId: 'sub-a' })
|
|
695
|
-
await h.mgr.drainInFlight()
|
|
696
|
-
|
|
697
|
-
expect(h.deps.unpin).toHaveBeenCalledTimes(1)
|
|
698
|
-
expect(h.deps.unpin).toHaveBeenCalledWith('c', 501)
|
|
699
|
-
expect(h.mgr.pinnedAgentIds('c:1')).toEqual(['parent'])
|
|
700
|
-
expect(h.mgr.pinnedMessageId('c:1', 'parent')).toBe(500)
|
|
701
|
-
})
|
|
702
|
-
|
|
703
|
-
it('legacy callers (no agentId) get the parent-sentinel default', async () => {
|
|
704
|
-
const h = mkHarness()
|
|
705
|
-
// Old call shape: no agentId. Uses PARENT_AGENT_ID under the hood.
|
|
706
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
707
|
-
h.fireTimers()
|
|
708
|
-
await h.mgr.drainInFlight()
|
|
709
|
-
|
|
710
|
-
expect(h.mgr.pinnedAgentIds('c:1')).toEqual(['__parent__'])
|
|
711
|
-
// pinnedMessageId without agentId resolves the parent sentinel.
|
|
712
|
-
expect(h.mgr.pinnedMessageId('c:1')).toBe(500)
|
|
713
|
-
// completeTurn without agentId targets the parent sentinel too.
|
|
714
|
-
h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
715
|
-
await h.mgr.drainInFlight()
|
|
716
|
-
expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
|
|
717
|
-
})
|
|
718
|
-
|
|
719
|
-
it('parent and a sub-agent for the legacy turnKey pin under different sentinels', async () => {
|
|
720
|
-
// Mixed call shape: parent uses no agentId (sentinel), sub-agent
|
|
721
|
-
// passes an explicit one. They must not collide.
|
|
722
|
-
const h = mkHarness()
|
|
723
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
|
|
724
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 501, isFirstEmit: true, agentId: 'sub-a' })
|
|
725
|
-
h.fireTimers()
|
|
726
|
-
await h.mgr.drainInFlight()
|
|
727
|
-
|
|
728
|
-
expect(h.deps.pin).toHaveBeenCalledTimes(2)
|
|
729
|
-
expect(h.mgr.pinnedAgentIds('c:1').sort()).toEqual(['__parent__', 'sub-a'])
|
|
730
|
-
})
|
|
731
|
-
})
|
|
732
|
-
|
|
733
|
-
describe('completeAllForTurn — catastrophic cleanup', () => {
|
|
734
|
-
it('unpins every agentId pinned under a turnKey', async () => {
|
|
735
|
-
const h = mkHarness()
|
|
736
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true, agentId: 'parent' })
|
|
737
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 501, isFirstEmit: true, agentId: 'sub-a' })
|
|
738
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 502, isFirstEmit: true, agentId: 'sub-b' })
|
|
739
|
-
// A different turn — must not be affected.
|
|
740
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:2', messageId: 600, isFirstEmit: true, agentId: 'parent' })
|
|
741
|
-
h.fireTimers()
|
|
742
|
-
await h.mgr.drainInFlight()
|
|
743
|
-
|
|
744
|
-
h.mgr.completeAllForTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
745
|
-
await h.mgr.drainInFlight()
|
|
746
|
-
|
|
747
|
-
expect(h.deps.unpin).toHaveBeenCalledTimes(3)
|
|
748
|
-
const unpinnedIds = h.deps.unpin.mock.calls.map((args) => args[1]).sort((a, b) => Number(a) - Number(b))
|
|
749
|
-
expect(unpinnedIds).toEqual([500, 501, 502])
|
|
750
|
-
expect(h.mgr.pinnedTurnKeys()).toEqual(['c:2'])
|
|
751
|
-
})
|
|
752
|
-
|
|
753
|
-
it('cancels pending (not-yet-fired) timers under the turnKey', async () => {
|
|
754
|
-
const h = mkHarness()
|
|
755
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true, agentId: 'parent' })
|
|
756
|
-
h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 501, isFirstEmit: true, agentId: 'sub-a' })
|
|
757
|
-
// Timers scheduled but not fired yet.
|
|
758
|
-
h.mgr.completeAllForTurn({ chatId: 'c', turnKey: 'c:1' })
|
|
759
|
-
h.fireTimers() // noop — both timers were cancelled
|
|
760
|
-
await h.mgr.drainInFlight()
|
|
761
|
-
|
|
762
|
-
expect(h.deps.pin).not.toHaveBeenCalled()
|
|
763
|
-
expect(h.deps.unpin).not.toHaveBeenCalled()
|
|
764
|
-
})
|
|
765
|
-
|
|
766
|
-
it('safe on a turnKey with no pins', async () => {
|
|
767
|
-
const h = mkHarness()
|
|
768
|
-
h.mgr.completeAllForTurn({ chatId: 'c', turnKey: 'c:never' })
|
|
769
|
-
await h.mgr.drainInFlight()
|
|
770
|
-
expect(h.deps.unpin).not.toHaveBeenCalled()
|
|
771
|
-
})
|
|
772
|
-
})
|
|
773
|
-
})
|