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.
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1172 -812
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +146 -50
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2965 -862
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1618 -198
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +227 -54
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +203 -43
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
- 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
|
+
})
|