switchroom 0.18.6 → 0.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1172 -812
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +146 -50
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-runtime/SKILL.md +2 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  15. package/telegram-plugin/dist/gateway/gateway.js +2965 -862
  16. package/telegram-plugin/dist/server.js +24 -0
  17. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  18. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  19. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  20. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  21. package/telegram-plugin/gateway/boot-card.ts +27 -0
  22. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  23. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  24. package/telegram-plugin/gateway/gateway.ts +1618 -198
  25. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  26. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  27. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  28. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  29. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  30. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  31. package/telegram-plugin/gateway/model-command.ts +227 -54
  32. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  33. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  35. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  36. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  37. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  38. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  39. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  40. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  41. package/telegram-plugin/hooks/hooks.json +10 -10
  42. package/telegram-plugin/hooks/run-hook.sh +84 -0
  43. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  44. package/telegram-plugin/model-label.ts +69 -0
  45. package/telegram-plugin/model-unavailable.ts +26 -0
  46. package/telegram-plugin/operator-events.ts +24 -0
  47. package/telegram-plugin/permission-diff.ts +128 -0
  48. package/telegram-plugin/pty-partial-handler.ts +39 -0
  49. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  50. package/telegram-plugin/registry/subagents.test.ts +90 -0
  51. package/telegram-plugin/render/rich-render.ts +79 -1
  52. package/telegram-plugin/retry-api-call.ts +62 -0
  53. package/telegram-plugin/session-tail.ts +28 -0
  54. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  55. package/telegram-plugin/silence-poke.ts +14 -0
  56. package/telegram-plugin/silent-end.ts +49 -4
  57. package/telegram-plugin/stream-controller.ts +156 -38
  58. package/telegram-plugin/subagent-watcher.ts +222 -37
  59. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  60. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  61. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  62. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  63. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  64. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  65. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  66. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  67. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  68. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  69. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
  70. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  71. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  72. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  73. package/telegram-plugin/tests/model-command.test.ts +203 -43
  74. package/telegram-plugin/tests/model-label.test.ts +64 -0
  75. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  76. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  77. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  78. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  79. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  80. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  81. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  82. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  83. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  84. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  85. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  86. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  87. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  88. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  89. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  90. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  92. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  93. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  94. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  96. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  97. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  98. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  99. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  100. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  101. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  102. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  103. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  104. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  105. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  106. package/telegram-plugin/tool-activity-summary.ts +22 -2
  107. package/telegram-plugin/typing-wrap.ts +72 -25
  108. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  109. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  110. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  111. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  112. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  113. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  114. package/telegram-plugin/voice-ondemand.ts +25 -1
  115. package/telegram-plugin/voice-send.ts +154 -0
  116. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Outcome-based composition test for the approval/restart continuation
3
+ * contract in `reference/jobs/approve-what-my-agent-can-touch.md`:
4
+ *
5
+ * - "An approval card survives a gateway restart. A tap on a still-valid
6
+ * card after a restart works; a card that expired during the outage is
7
+ * re-offered on the operator's return."
8
+ * - "Cards time out (60-min default) and the timeout wakes the agent as a
9
+ * timeout, not a denial ... An expired card is re-offered when the
10
+ * operator returns."
11
+ *
12
+ * The sibling suites pin each module in isolation (pending-card-store.test.ts,
13
+ * pending-card-expiry.test.ts with mocked deps, approval-timeout-inbound-
14
+ * builders.test.ts) and pending-card-durability-wiring.test.ts pins the
15
+ * gateway wiring at the source-text level. This suite wires the REAL modules
16
+ * together — file-backed durable store + real missed-approvals store + real
17
+ * expiry sweep + real timeout builders — across a simulated gateway bounce
18
+ * (store re-instantiation from the same state dir), asserting the promised
19
+ * OUTCOMES rather than any one module's contract.
20
+ *
21
+ * SCOPE CAVEAT: the expiry predicate / remove / deliver / recordMiss closures
22
+ * below are TEST-SUPPLIED stand-ins for gateway.ts's inline closures (which
23
+ * close over module-level gateway state and can't be imported without a
24
+ * production refactor). This suite therefore proves the modules COMPOSE
25
+ * correctly; that gateway.ts actually wires them this way is pinned
26
+ * separately by pending-card-durability-wiring.test.ts's source greps. The
27
+ * user-visible acceptance twin is
28
+ * `uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts`.
29
+ */
30
+
31
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
32
+ import { mkdtempSync, rmSync } from 'node:fs'
33
+ import { tmpdir } from 'node:os'
34
+ import { join } from 'node:path'
35
+
36
+ import {
37
+ createPendingCardStore,
38
+ type PersistedApprovalCard,
39
+ type PersistedVaultAccessCard,
40
+ } from '../gateway/pending-card-store.js'
41
+ import { createMissedApprovalsStore } from '../gateway/missed-approvals-store.js'
42
+ import { expirePendingCard, sweepExpiredEntries } from '../gateway/pending-card-expiry.js'
43
+ import { buildVaultAccessTimeoutInbound } from '../gateway/approval-timeout-inbound-builders.js'
44
+ import type { InboundMessage } from '../gateway/ipc-protocol.js'
45
+
46
+ // Test-local stand-in for the 60-min default the spec names. The production
47
+ // TTL comes from approvalTtlMs() in gateway/permission-timeout.ts; this suite
48
+ // does NOT assert that config plumbing — only how the modules behave around
49
+ // whatever TTL the sweep predicate encodes.
50
+ const TTL_MS = 60 * 60_000
51
+ const NOW = 1_700_000_000_000
52
+
53
+ function accessCard(overrides: Partial<PersistedVaultAccessCard> = {}): PersistedVaultAccessCard {
54
+ return {
55
+ family: 'vault_request_access',
56
+ stageId: 'stage-1',
57
+ agent: 'test-agent',
58
+ chatId: '12345',
59
+ cardMessageId: 777,
60
+ stagedAt: NOW - 60_000, // staged a minute ago — still valid
61
+ key: 'example/api-key',
62
+ scope: 'read',
63
+ ttlSeconds: 30 * 86_400,
64
+ ...overrides,
65
+ }
66
+ }
67
+
68
+ describe('approval/restart continuation — spec outcomes across a gateway bounce', () => {
69
+ let stateDir: string
70
+
71
+ beforeEach(() => {
72
+ stateDir = mkdtempSync(join(tmpdir(), 'approval-card-restart-outcome-'))
73
+ })
74
+ afterEach(() => {
75
+ rmSync(stateDir, { recursive: true, force: true })
76
+ })
77
+
78
+ it('a still-valid card record survives the bounce intact, and removing it clears the durable store', () => {
79
+ // Gateway process #1 stages the card.
80
+ const before = createPendingCardStore(stateDir)
81
+ const card = accessCard()
82
+ before.add(card)
83
+
84
+ // ── gateway restarts: all in-memory maps are gone ──
85
+ const after = createPendingCardStore(stateDir)
86
+ const restored = after.loadAll()
87
+ expect(restored).toHaveLength(1)
88
+ // Metadata roundtrips byte-identical — stagedAt included, so the expiry
89
+ // deadline the sweep computes from it is unchanged by the bounce.
90
+ expect(restored[0]).toEqual(card)
91
+
92
+ // Post-restart resolution path (what a tap handler does with the store):
93
+ // remove() drops the durable record so a later boot can't resurrect it.
94
+ // The tap handler's Telegram/grant side is NOT exercised here — that's
95
+ // the UAT twin's job.
96
+ after.remove(card.stageId)
97
+ expect(after.loadAll()).toHaveLength(0)
98
+ })
99
+
100
+ it('a card that expired during the outage wakes the agent ONCE as timeout-not-denial and records the re-offer', () => {
101
+ // Staged well past the TTL before the bounce.
102
+ const expired = accessCard({ stageId: 'stage-expired', stagedAt: NOW - TTL_MS - 60_000 })
103
+ createPendingCardStore(stateDir).add(expired)
104
+
105
+ // ── restart: restore into the in-memory map, then the reaper sweeps ──
106
+ const store = createPendingCardStore(stateDir)
107
+ const missed = createMissedApprovalsStore(stateDir)
108
+ const map = new Map<string, PersistedVaultAccessCard>(
109
+ store.loadAll().map(e => [e.stageId, e as PersistedVaultAccessCard]),
110
+ )
111
+ expect(map.size).toBe(1)
112
+
113
+ const delivered: InboundMessage[] = []
114
+ const sweep = () =>
115
+ sweepExpiredEntries(
116
+ map,
117
+ (v, now) => v.stagedAt < now - TTL_MS,
118
+ (stageId, v) => {
119
+ expirePendingCard({
120
+ remove: () => {
121
+ map.delete(stageId)
122
+ store.remove(stageId)
123
+ },
124
+ editCard: () => {},
125
+ buildInbound: () =>
126
+ buildVaultAccessTimeoutInbound({
127
+ agent: v.agent,
128
+ chatId: v.chatId,
129
+ stageId,
130
+ timeoutMinutes: Math.round(TTL_MS / 60_000),
131
+ nowMs: NOW,
132
+ key: v.key,
133
+ scope: v.scope,
134
+ }),
135
+ deliver: m => {
136
+ delivered.push(m)
137
+ return true
138
+ },
139
+ recordMiss: () =>
140
+ missed.add({
141
+ requestId: `approval-card:${stageId}`,
142
+ toolName: 'vault_request_access',
143
+ action: `vault access to ${v.key}`,
144
+ chatId: v.chatId,
145
+ timedOutAt: NOW,
146
+ }),
147
+ log: () => {},
148
+ })
149
+ },
150
+ NOW,
151
+ () => {},
152
+ )
153
+
154
+ sweep()
155
+
156
+ // Exactly one wake, and it is a TIMEOUT, never a denial.
157
+ expect(delivered).toHaveLength(1)
158
+ expect(delivered[0].text).toMatch(/TIMEOUT, not a denial/)
159
+ expect(delivered[0].text).toContain('example/api-key')
160
+ expect(delivered[0].meta?.source).toBe('vault_grant_timeout')
161
+
162
+ // The re-offer is durably recorded for the operator's return.
163
+ const pending = missed.listPending()
164
+ expect(pending).toHaveLength(1)
165
+ expect(pending[0].requestId).toBe('approval-card:stage-expired')
166
+
167
+ // Durable record is gone — a later boot can't resurrect the card.
168
+ expect(store.loadAll()).toHaveLength(0)
169
+
170
+ // A second boot's sweep restores from the (now empty) durable store and
171
+ // therefore fires nothing — the remove() step cleared BOTH the in-memory
172
+ // map and the store, so no later process can re-deliver the wake. (The
173
+ // intra-process single-shot ordering guarantee — remove() before any
174
+ // fallible side effect — is pinned behaviorally in
175
+ // pending-card-expiry.test.ts, not here.)
176
+ const rebootMap = new Map<string, PersistedVaultAccessCard>(
177
+ createPendingCardStore(stateDir)
178
+ .loadAll()
179
+ .map(e => [e.stageId, e as PersistedVaultAccessCard]),
180
+ )
181
+ expect(rebootMap.size).toBe(0)
182
+ let secondBootExpiries = 0
183
+ sweepExpiredEntries(
184
+ rebootMap,
185
+ (v, now) => v.stagedAt < now - TTL_MS,
186
+ () => {
187
+ secondBootExpiries += 1
188
+ },
189
+ NOW,
190
+ () => {},
191
+ )
192
+ expect(secondBootExpiries).toBe(0)
193
+ expect(delivered).toHaveLength(1)
194
+ })
195
+
196
+ it('the post-restart sweep never expires a still-valid card (no false timeout on bounce)', () => {
197
+ const valid = accessCard({ stageId: 'stage-valid', stagedAt: NOW - 60_000 })
198
+ createPendingCardStore(stateDir).add(valid)
199
+
200
+ const store = createPendingCardStore(stateDir)
201
+ const map = new Map<string, PersistedApprovalCard>(store.loadAll().map(e => [e.stageId, e]))
202
+
203
+ let expiredCount = 0
204
+ sweepExpiredEntries(
205
+ map,
206
+ (v, now) => v.stagedAt < now - TTL_MS,
207
+ () => {
208
+ expiredCount += 1
209
+ },
210
+ NOW,
211
+ () => {},
212
+ )
213
+
214
+ expect(expiredCount).toBe(0)
215
+ expect(map.size).toBe(1) // the card stays live and tappable
216
+ expect(store.loadAll()).toHaveLength(1)
217
+ })
218
+ })
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pin the synthetic-inbound shapes the gateway injects when an agent-initiated
3
+ * approval card TTL-expires unanswered (the "wake the parked agent on timeout"
4
+ * half of the durability fix). A regression that drops/changes `meta.source`
5
+ * silently breaks the wake-up: the bridge wouldn't recognize the source and the
6
+ * model wouldn't know its card timed out.
7
+ *
8
+ * The load-bearing wording invariant: each message says TIMEOUT, not a denial,
9
+ * matching the permission-card philosophy so the model degrades gracefully
10
+ * instead of spam-re-requesting into an absent operator.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import {
15
+ buildVaultAccessTimeoutInbound,
16
+ buildVaultSaveTimeoutInbound,
17
+ buildSecretRequestTimeoutInbound,
18
+ buildMentalModelProposeTimeoutInbound,
19
+ } from '../gateway/approval-timeout-inbound-builders.js'
20
+
21
+ const FIXED_NOW = 1_700_000_000_000
22
+ const BASE = { agent: 'gymbro', chatId: '12345', stageId: 's1', timeoutMinutes: 60, nowMs: FIXED_NOW }
23
+
24
+ describe('timeout inbound builders — envelope', () => {
25
+ const cases = [
26
+ buildVaultAccessTimeoutInbound({ ...BASE, key: 'fatsecret/creds', scope: 'read' }),
27
+ buildVaultSaveTimeoutInbound({ ...BASE, key: 'brevo/api-key' }),
28
+ buildSecretRequestTimeoutInbound({ ...BASE, key: 'openai/token' }),
29
+ buildMentalModelProposeTimeoutInbound({ ...BASE, name: 'training-plan-state' }),
30
+ ]
31
+
32
+ it('all share the canonical vault-broker envelope', () => {
33
+ for (const m of cases) {
34
+ expect(m.type).toBe('inbound')
35
+ expect(m.chatId).toBe('12345')
36
+ expect(m.user).toBe('vault-broker')
37
+ expect(m.userId).toBe(0)
38
+ expect(m.ts).toBe(FIXED_NOW)
39
+ expect(m.messageId).toBe(FIXED_NOW)
40
+ expect(m.meta?.agent).toBe('gymbro')
41
+ expect(m.meta?.stage_id).toBe('s1')
42
+ }
43
+ })
44
+
45
+ it('every message says TIMEOUT, not a denial (degrade-gracefully wording)', () => {
46
+ for (const m of cases) {
47
+ expect(m.text).toMatch(/TIMEOUT, not a denial/)
48
+ expect(m.text).toMatch(/60 min/)
49
+ expect(m.text).toMatch(/Do NOT (re-request|loop|re-propose)/i)
50
+ }
51
+ })
52
+ })
53
+
54
+ describe('timeout inbound builders — distinct sources', () => {
55
+ it('pins each meta.source string (load-bearing for the bridge)', () => {
56
+ expect(buildVaultAccessTimeoutInbound({ ...BASE, key: 'k', scope: 'read' }).meta?.source).toBe('vault_grant_timeout')
57
+ expect(buildVaultSaveTimeoutInbound({ ...BASE, key: 'k' }).meta?.source).toBe('vault_save_timeout')
58
+ expect(buildSecretRequestTimeoutInbound({ ...BASE, key: 'k' }).meta?.source).toBe('secret_request_timeout')
59
+ expect(buildMentalModelProposeTimeoutInbound({ ...BASE, name: 'n' }).meta?.source).toBe('mental_model_propose_timeout')
60
+ })
61
+
62
+ it('sources are all disjoint', () => {
63
+ const sources = [
64
+ buildVaultAccessTimeoutInbound({ ...BASE, key: 'k', scope: 'read' }).meta?.source,
65
+ buildVaultSaveTimeoutInbound({ ...BASE, key: 'k' }).meta?.source,
66
+ buildSecretRequestTimeoutInbound({ ...BASE, key: 'k' }).meta?.source,
67
+ buildMentalModelProposeTimeoutInbound({ ...BASE, name: 'n' }).meta?.source,
68
+ ]
69
+ expect(new Set(sources).size).toBe(4)
70
+ })
71
+ })
72
+
73
+ describe('timeout inbound builders — topic routing', () => {
74
+ const THREAD = 4242
75
+ it('threadId set → top-level threadId + meta.message_thread_id (stringified)', () => {
76
+ const m = buildVaultAccessTimeoutInbound({ ...BASE, threadId: THREAD, key: 'k', scope: 'write' })
77
+ expect(m.threadId).toBe(THREAD)
78
+ expect(m.meta?.message_thread_id).toBe(String(THREAD))
79
+ expect(m.meta?.scope).toBe('write')
80
+ })
81
+ it('threadId absent → both omitted (DM stays thread-less)', () => {
82
+ const m = buildVaultSaveTimeoutInbound({ ...BASE, key: 'k' })
83
+ expect(m.threadId).toBeUndefined()
84
+ expect(m.meta?.message_thread_id).toBeUndefined()
85
+ })
86
+ it('defaults nowMs to Date.now() when omitted', () => {
87
+ const before = Date.now()
88
+ const m = buildSecretRequestTimeoutInbound({ agent: 'a', chatId: '1', stageId: 's', timeoutMinutes: 30, key: 'k' })
89
+ const after = Date.now()
90
+ expect(m.ts).toBeGreaterThanOrEqual(before)
91
+ expect(m.ts).toBeLessThanOrEqual(after)
92
+ expect(m.messageId).toBe(m.ts)
93
+ })
94
+ })
@@ -0,0 +1,111 @@
1
+ /**
2
+ * #2923 — boot-card flood-wait suppression. When a Telegram per-bot flood ban
3
+ * is active, a restart's boot card is a NON-ESSENTIAL send straight into the
4
+ * open window that can reset/extend the ban. startBootCard must SKIP the send
5
+ * (and log) while the flood-wait is active, and post normally once it lifts.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
9
+ import { mkdtempSync, rmSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { startBootCard } from '../gateway/boot-card.js'
13
+ import type { BotApiForBootCard } from '../gateway/boot-card.js'
14
+ import { computeFloodWait, writeFloodState, floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
15
+ import { createRetryApiCall } from '../retry-api-call.js'
16
+ import { errors } from './fake-bot-api.js'
17
+
18
+ let dir: string
19
+
20
+ beforeEach(() => {
21
+ dir = mkdtempSync(join(tmpdir(), 'boot-card-flood-'))
22
+ })
23
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
24
+
25
+ function makeBot() {
26
+ const sends: string[] = []
27
+ const bot: BotApiForBootCard = {
28
+ sendMessage: async (chatId, _text, _opts) => {
29
+ sends.push(chatId)
30
+ return { message_id: 1001 }
31
+ },
32
+ editMessageText: async () => ({}),
33
+ }
34
+ return { bot, sends }
35
+ }
36
+
37
+ function mkOpts(floodPath: string, nowMs: number, overrides: Record<string, unknown> = {}) {
38
+ return {
39
+ agentName: 'TestAgent',
40
+ agentSlug: 'test-agent',
41
+ version: 'v0.0.0-test',
42
+ agentDir: dir,
43
+ gatewayInfo: { pid: 1, startedAtMs: nowMs },
44
+ restartReason: 'graceful' as const,
45
+ agentLiveWindowMs: 0,
46
+ settleWindowMs: 1_000_000,
47
+ floodStatePath: floodPath,
48
+ nowMs: () => nowMs,
49
+ ...overrides,
50
+ } as unknown as Parameters<typeof startBootCard>[3]
51
+ }
52
+
53
+ it('SUPPRESSES the boot card while a flood-wait is active', async () => {
54
+ const p = floodStatePath(dir)
55
+ const now = 1_000_000
56
+ writeFloodState(p, computeFloodWait(null, 68 * 60, now)) // ~68 min ban
57
+ const logs: string[] = []
58
+ const { bot, sends } = makeBot()
59
+ const handle = await startBootCard('chat1', undefined, bot, mkOpts(p, now), undefined, (l) =>
60
+ logs.push(l),
61
+ )
62
+ expect(sends).toEqual([]) // nothing sent into the open ban window
63
+ expect(handle.messageId).toBe(-1)
64
+ expect(logs.join('')).toMatch(/SUPPRESSED/)
65
+ })
66
+
67
+ it('POSTS the boot card once the flood-wait has lifted', async () => {
68
+ const p = floodStatePath(dir)
69
+ const banStart = 1_000_000
70
+ writeFloodState(p, computeFloodWait(null, 60, banStart))
71
+ const afterBan = banStart + 60_000 + 1
72
+ const { bot, sends } = makeBot()
73
+ await startBootCard('chat1', undefined, bot, mkOpts(p, afterBan), undefined, () => {})
74
+ expect(sends).toEqual(['chat1'])
75
+ })
76
+
77
+ it('POSTS normally when no flood state file exists (back-compat)', async () => {
78
+ const p = floodStatePath(dir) // never written
79
+ const { bot, sends } = makeBot()
80
+ await startBootCard('chat1', undefined, bot, mkOpts(p, 1_000_000), undefined, () => {})
81
+ expect(sends).toEqual(['chat1'])
82
+ })
83
+
84
+ it('INTEGRATION: a 429 through the real retry path suppresses a later boot card', async () => {
85
+ // Wire the two halves exactly as gateway.ts does: the retry wrapper's
86
+ // onFloodWait recorder and the boot card BOTH point at the same file.
87
+ const p = floodStatePath(dir)
88
+ const now = 5_000_000
89
+ const retry = createRetryApiCall({
90
+ sleep: async () => {}, // don't actually wait out the flood
91
+ onFloodWait: makeFloodWaitRecorder(p, () => now),
92
+ })
93
+
94
+ // Drive a real GrammyError 429 through the wrapper (first call floods, then
95
+ // succeeds) — this is what records the window on disk.
96
+ let n = 0
97
+ await retry(async () => {
98
+ if (n++ === 0) throw errors.floodWait(4116) // ~68 min ban
99
+ return 'ok'
100
+ })
101
+
102
+ // Now a restart's boot card must be suppressed via the SAME file.
103
+ const { bot, sends } = makeBot()
104
+ const logs: string[] = []
105
+ const handle = await startBootCard('chat1', undefined, bot, mkOpts(p, now), undefined, (l) =>
106
+ logs.push(l),
107
+ )
108
+ expect(sends).toEqual([]) // not posted into the open ban window
109
+ expect(handle.messageId).toBe(-1)
110
+ expect(logs.join('')).toMatch(/SUPPRESSED/)
111
+ })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * #2995 — mid-flight busy ack: gateway wiring guards.
3
+ *
4
+ * The gateway IIFE is too entangled to instantiate in-process, so these
5
+ * are source-level assertions (the established pattern —
6
+ * multitopic-routing-wiring.test.ts, buffer-gate-broadened.test.ts). They
7
+ * pin the load-bearing wiring: the buffered-inbound and steer call sites,
8
+ * the silent send, the shared queuedStatusMsgIds lifecycle (promote/reap
9
+ * cleanup for free), the per-turn dedupe reset, and the kill switch. The
10
+ * POLICY and the rendered text are behaviourally tested in
11
+ * busy-ack.test.ts; the live end-to-end shape in
12
+ * uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts.
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest'
16
+ import { readFileSync } from 'node:fs'
17
+ import { resolve } from 'node:path'
18
+
19
+ const gatewaySrc = readFileSync(
20
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
21
+ 'utf-8',
22
+ )
23
+
24
+ describe('#2995 mid-flight busy ack — gateway wiring', () => {
25
+ it('the buffer-until-idle branch calls maybePostBusyAck for the inbound own chat/topic', () => {
26
+ expect(gatewaySrc).toMatch(
27
+ /maybePostBusyAck\('buffer-until-idle', chat_id, messageThreadId \?\? undefined\)/,
28
+ )
29
+ // …AFTER the pendingInboundBuffer.push (the ack narrates a real queue).
30
+ const branch = gatewaySrc.split("deliveryGate.decision === 'buffer-until-idle'")[1] ?? ''
31
+ const pushIdx = branch.indexOf('pendingInboundBuffer.push(selfAgent, inboundMsg)')
32
+ const ackIdx = branch.indexOf("maybePostBusyAck('buffer-until-idle'")
33
+ expect(pushIdx).toBeGreaterThanOrEqual(0)
34
+ expect(ackIdx).toBeGreaterThan(pushIdx)
35
+ })
36
+
37
+ it('cross-topic queued status and the busy ack are mutually exclusive (no double-card race)', () => {
38
+ // Both helpers record into queuedStatusMsgIds only AFTER their
39
+ // sendMessage awaits resolve, so calling both in the same handler pass
40
+ // would race past each other's has(key) check and double-card the
41
+ // topic. The buffer branch must pick exactly one.
42
+ expect(gatewaySrc).toMatch(
43
+ /if \(crossTopicQueuedCard\) \{\s*postQueuedStatus\(chat_id, messageThreadId, inFlightThread\)\s*\} else \{/,
44
+ )
45
+ })
46
+
47
+ it('a mid-turn steer gets the steer-worded variant only AFTER successful bridge dispatch', () => {
48
+ // A "Steer noted" card for a send that missed (bridge offline) would
49
+ // be untrue — the ack lives inside the `if (delivered)` branch.
50
+ expect(gatewaySrc).toMatch(
51
+ /if \(delivered\) \{[\s\S]{0,700}if \(isSteering\) \{\s*maybePostBusyAck\('steer', chat_id, messageThreadId \?\? undefined\)/,
52
+ )
53
+ })
54
+
55
+ it('an under-threshold miss arms ONE deferred re-check pinned to the running turn', () => {
56
+ const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
57
+ // Armed only for the young-step miss, for the remaining age gap.
58
+ expect(fn).toMatch(/stepAgeMs < BUSY_ACK_STEP_AGE_THRESHOLD_MS &&\s*!busyAckRecheckTimers\.has\(key\)/)
59
+ expect(fn).toMatch(/BUSY_ACK_STEP_AGE_THRESHOLD_MS - stepAgeMs \+ 250/)
60
+ // The re-fire is guarded on the SAME turn still running.
61
+ expect(fn).toMatch(/currentTurn\?\.turnId !== turnIdAtSchedule\) return/)
62
+ // A posted card cancels the pending re-check.
63
+ expect(fn).toMatch(/clearTimeout\(pendingRecheck\)/)
64
+ })
65
+
66
+ it('the decision is fed from live tool-flight + step-age readings (pure module owns policy)', () => {
67
+ const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
68
+ expect(fn).toMatch(/shouldPostBusyAck\(\{ gateDecision, midToolCall, stepAgeMs, alreadyAcked \}\)/)
69
+ expect(fn).toMatch(/const midToolCall = toolFlightTracker\.isMidToolCall\(\)/)
70
+ expect(fn).toMatch(/silencePoke\.longestInFlightTool\(/)
71
+ expect(fn).toMatch(/const stepAgeMs = step\?\.durationMs \?\? null/)
72
+ })
73
+
74
+ it('dedupe: alreadyAcked couples the live card map AND the per-turn key set', () => {
75
+ const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
76
+ expect(fn).toMatch(
77
+ /const alreadyAcked = queuedStatusMsgIds\.has\(key\) \|\| busyAckPostedKeys\.has\(key\)/,
78
+ )
79
+ expect(fn).toMatch(/busyAckPostedKeys\.add\(key\)/)
80
+ })
81
+
82
+ it('the card is sent SILENT (disable_notification: true) through the swallowing wrapper', () => {
83
+ const fn = gatewaySrc.split('function postBusyAck')[1]?.split('\nfunction ')[0] ?? ''
84
+ expect(fn).toMatch(/swallowingApiCall\(/)
85
+ expect(fn).toMatch(/disable_notification: true/)
86
+ // Thread-optional: a DM send must NOT pass message_thread_id.
87
+ expect(fn).toMatch(/\.\.\.\(threadId != null \? \{ message_thread_id: threadId \} : \{\}\)/)
88
+ })
89
+
90
+ it('the card shares queuedStatusMsgIds — promote/reap lifecycle cleans it up', () => {
91
+ const fn = gatewaySrc.split('function postBusyAck')[1]?.split('\nfunction ')[0] ?? ''
92
+ // Idempotent against the shared key (never stacks on the cross-topic card).
93
+ expect(fn).toMatch(/if \(queuedStatusMsgIds\.has\(key\)\) return/)
94
+ expect(fn).toMatch(/queuedStatusMsgIds\.set\(key, \{ chatId, threadId: threadId \?\? null, messageId \}\)/)
95
+ // Post-race orphan cleanup, same pattern as postQueuedStatus.
96
+ expect(fn).toMatch(/busy-ack\.post-race-cleanup/)
97
+ })
98
+
99
+ it('turn-end cleanup is PER-KEY: dedupe entry deleted and re-check timer cancelled for the ending turn only', () => {
100
+ const purge = gatewaySrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
101
+ // Per-key delete — a purge for topic A must not reset topic B's dedupe.
102
+ expect(purge).toMatch(/busyAckPostedKeys\.delete\(key\)/)
103
+ expect(purge).not.toMatch(/busyAckPostedKeys\.clear\(\)/)
104
+ expect(purge).toMatch(/busyAckRecheckTimers\.delete\(key\)/)
105
+ expect(purge).toMatch(/clearTimeout\(busyAckRecheck\)/)
106
+ })
107
+
108
+ it('DM cards are promoted too (promoteQueuedStatus no longer early-returns on a null thread)', () => {
109
+ const fn = gatewaySrc.split('function promoteQueuedStatus')[1]?.split('\nfunction ')[0] ?? ''
110
+ expect(fn).not.toMatch(/if \(thread == null\) return/)
111
+ })
112
+
113
+ it('kill switch defaults ON, independently disableable', () => {
114
+ expect(gatewaySrc).toMatch(/SWITCHROOM_MIDFLIGHT_BUSY_ACK !== '0'/)
115
+ const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
116
+ expect(fn).toMatch(/if \(!MIDFLIGHT_BUSY_ACK_ENABLED\) return/)
117
+ })
118
+ })
@@ -0,0 +1,121 @@
1
+ /**
2
+ * #2995 — mid-flight busy ack: pure decision module.
3
+ *
4
+ * Pins the deterministic policy (`shouldPostBusyAck`) and the rendered
5
+ * card text (`formatBusyAckText`) — the model-free ack a buffered/steered
6
+ * mid-turn inbound gets while the running turn sits inside one long tool
7
+ * step. Behavioral assertions on the RENDERED text (wording contract:
8
+ * "Queued" for the buffered path per the steer-or-queue classification-
9
+ * visibility invariant; never "Queued" for a steer).
10
+ */
11
+
12
+ import { describe, it, expect } from 'vitest'
13
+ import {
14
+ BUSY_ACK_STEP_AGE_THRESHOLD_MS,
15
+ shouldPostBusyAck,
16
+ formatBusyAckText,
17
+ } from '../gateway/busy-ack.js'
18
+
19
+ const base = {
20
+ gateDecision: 'buffer-until-idle' as const,
21
+ midToolCall: true,
22
+ stepAgeMs: BUSY_ACK_STEP_AGE_THRESHOLD_MS + 1,
23
+ alreadyAcked: false,
24
+ }
25
+
26
+ describe('shouldPostBusyAck', () => {
27
+ it('fires for a buffered inbound behind an old tool step', () => {
28
+ expect(shouldPostBusyAck(base)).toBe(true)
29
+ })
30
+
31
+ it('fires for a steer behind an old tool step', () => {
32
+ expect(shouldPostBusyAck({ ...base, gateDecision: 'steer' })).toBe(true)
33
+ })
34
+
35
+ it('never fires for a plain fresh-turn deliver', () => {
36
+ expect(shouldPostBusyAck({ ...base, gateDecision: 'deliver' })).toBe(false)
37
+ })
38
+
39
+ it('never fires when not mid-tool-call (turn thinking between tools)', () => {
40
+ expect(shouldPostBusyAck({ ...base, midToolCall: false })).toBe(false)
41
+ })
42
+
43
+ it('never fires below the step-age threshold (agent seconds from answering)', () => {
44
+ expect(
45
+ shouldPostBusyAck({ ...base, stepAgeMs: BUSY_ACK_STEP_AGE_THRESHOLD_MS - 1 }),
46
+ ).toBe(false)
47
+ // exactly at the threshold → fires (>= semantics)
48
+ expect(
49
+ shouldPostBusyAck({ ...base, stepAgeMs: BUSY_ACK_STEP_AGE_THRESHOLD_MS }),
50
+ ).toBe(true)
51
+ })
52
+
53
+ it('never fires with no tracked step age', () => {
54
+ expect(shouldPostBusyAck({ ...base, stepAgeMs: null })).toBe(false)
55
+ })
56
+
57
+ it('fires at most once per key — a second ping gets no second card', () => {
58
+ // The gateway feeds `alreadyAcked` from its per-turn key set + the live
59
+ // card map; the policy must refuse when either says "already acked".
60
+ expect(shouldPostBusyAck({ ...base, alreadyAcked: true })).toBe(false)
61
+ })
62
+
63
+ it('threshold constant sits between the fast-tool envelope and the ignored horizon', () => {
64
+ expect(BUSY_ACK_STEP_AGE_THRESHOLD_MS).toBeGreaterThanOrEqual(5_000)
65
+ expect(BUSY_ACK_STEP_AGE_THRESHOLD_MS).toBeLessThanOrEqual(30_000)
66
+ })
67
+ })
68
+
69
+ describe('formatBusyAckText — rendered card text', () => {
70
+ it('buffered path says "Queued", names the blocking activity, and promises the answer', () => {
71
+ const text = formatBusyAckText({
72
+ gateDecision: 'buffer-until-idle',
73
+ toolName: 'Bash',
74
+ toolLabel: 'gh pr checks --watch',
75
+ })
76
+ expect(text).toContain('Queued')
77
+ expect(text).toContain('`Bash: gh pr checks --watch`')
78
+ expect(text).toMatch(/I'll answer when this step finishes/)
79
+ })
80
+
81
+ it('carries no point-in-time elapsed figure (a static card must not decay)', () => {
82
+ // The card is posted once and never re-rendered while the blocking
83
+ // step runs — any "(Nm elapsed)" would silently go stale on screen.
84
+ const text = formatBusyAckText({
85
+ gateDecision: 'buffer-until-idle',
86
+ toolName: 'Bash',
87
+ toolLabel: 'sleep 90',
88
+ })
89
+ expect(text).not.toMatch(/elapsed|\b\d+[sm]\b/)
90
+ })
91
+
92
+ it('steer path never says "Queued" (classification-visibility invariant)', () => {
93
+ const text = formatBusyAckText({
94
+ gateDecision: 'steer',
95
+ toolName: 'Bash',
96
+ toolLabel: 'sleep 90',
97
+ })
98
+ expect(text).not.toContain('Queued')
99
+ expect(text).toContain('Steer noted')
100
+ expect(text).toContain('`Bash: sleep 90`')
101
+ })
102
+
103
+ it('degrades honestly when the tool label is unknown', () => {
104
+ const text = formatBusyAckText({
105
+ gateDecision: 'buffer-until-idle',
106
+ toolName: null,
107
+ toolLabel: null,
108
+ })
109
+ expect(text).toContain('Queued')
110
+ expect(text).toContain('a long-running step')
111
+ })
112
+
113
+ it('uses the bare tool name when no label was derived', () => {
114
+ const text = formatBusyAckText({
115
+ gateDecision: 'buffer-until-idle',
116
+ toolName: 'Bash',
117
+ toolLabel: null,
118
+ })
119
+ expect(text).toContain('`Bash`')
120
+ })
121
+ })