switchroom 0.16.46 → 0.17.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/dist/agent-scheduler/index.js +83 -81
- package/dist/auth-broker/index.js +104 -88
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -15
- package/dist/cli/notion-write-pretool.mjs +85 -83
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +1720 -1392
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +209 -173
- package/dist/vault/approvals/kernel-server.js +86 -83
- package/dist/vault/broker/server.js +284 -139
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +120 -155
- package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
- package/telegram-plugin/dist/server.js +168 -203
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +566 -631
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +24 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +8 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +18 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2787 Mechanism B — gateway-glue coverage for the `claudeBusyKeys` orphan
|
|
3
|
+
* reaper and its lockstep shadow timestamp map.
|
|
4
|
+
*
|
|
5
|
+
* `gateway.ts` cannot be imported by a unit test — it boots the Telegram bot and
|
|
6
|
+
* the IPC listener on module load. So this harness wires the SAME containers the
|
|
7
|
+
* gateway owns (`claudeBusyKeys: Set`, `claudeBusyKeySince: Map`), the SAME
|
|
8
|
+
* delivery-confirm queue used as the reaper's proof gate, and the REAL
|
|
9
|
+
* `flushOnAgentDisconnect` used at bridge death — exactly as the gateway does
|
|
10
|
+
* (see `markClaudeBusyForInbound`, `reapOrphanBusyKeysNow`, the delivery-confirm
|
|
11
|
+
* sweep, and the `flushOnAgentDisconnect({ claudeBusyKeys, claudeBusyKeySince })`
|
|
12
|
+
* call). It reproduces the two real defects the pure-helper tests could not:
|
|
13
|
+
*
|
|
14
|
+
* 1. Lockstep across a disconnect → reconnect → re-mark: a stale
|
|
15
|
+
* pre-disconnect timestamp must NOT survive to make a freshly-marked key
|
|
16
|
+
* look orphan-old.
|
|
17
|
+
* 2. Slow-delivery safety: a merely-slow (up to ~5-min, #1922) delivery whose
|
|
18
|
+
* key is still tracked in the confirm queue must NOT be reaped even long
|
|
19
|
+
* past the grace, because `currentTurn == null` during the eager-mark→
|
|
20
|
+
* enqueue window is not proof of idle.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
24
|
+
import {
|
|
25
|
+
markBusyKeyLockstep,
|
|
26
|
+
reapOrphanBusyKeys,
|
|
27
|
+
clearBusyKeyLockstep,
|
|
28
|
+
} from '../gateway/busy-key-reaper.js'
|
|
29
|
+
import { flushOnAgentDisconnect } from '../gateway/disconnect-flush.js'
|
|
30
|
+
import {
|
|
31
|
+
createDeliveryQueue,
|
|
32
|
+
trackDelivery,
|
|
33
|
+
ackDelivery,
|
|
34
|
+
} from '../gateway/inbound-delivery-confirm.js'
|
|
35
|
+
|
|
36
|
+
const TTL = 30_000
|
|
37
|
+
|
|
38
|
+
/** The gateway's `reapOrphanBusyKeysNow` glue, reproduced verbatim: no turn in
|
|
39
|
+
* flight, reap keyed on set membership + a delivery-queue proof gate. */
|
|
40
|
+
function reap(
|
|
41
|
+
keys: Set<string>,
|
|
42
|
+
since: Map<string, number>,
|
|
43
|
+
q: ReturnType<typeof createDeliveryQueue>,
|
|
44
|
+
now: number,
|
|
45
|
+
ttlMs = TTL,
|
|
46
|
+
): string[] {
|
|
47
|
+
return reapOrphanBusyKeys(keys, since, now, {
|
|
48
|
+
ttlMs,
|
|
49
|
+
hasPendingDelivery: (key) => q.pending.has(key),
|
|
50
|
+
log: () => {},
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Minimal disconnect-flush deps carrying the two containers under test — the
|
|
55
|
+
* real bridge-death path that clears busy keys. Mirrors the gateway's call. */
|
|
56
|
+
function flushDeps(claudeBusyKeys: Set<string>, claudeBusyKeySince: Map<string, number>) {
|
|
57
|
+
return {
|
|
58
|
+
agentName: 'clerk',
|
|
59
|
+
activeStatusReactions: new Map(),
|
|
60
|
+
activeReactionMsgIds: new Map(),
|
|
61
|
+
activeTurnStartedAt: new Map<string, number>(),
|
|
62
|
+
claudeBusyKeys,
|
|
63
|
+
claudeBusyKeySince,
|
|
64
|
+
activeDraftStreams: new Map(),
|
|
65
|
+
clearActiveReactions: vi.fn(),
|
|
66
|
+
disposeProgressDriver: vi.fn(),
|
|
67
|
+
log: vi.fn(),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('#2787 Mechanism B — orphan reaper (gateway glue)', () => {
|
|
72
|
+
it('does NOT reap a slow-but-real delivery still tracked in the confirm queue, even far past the grace', () => {
|
|
73
|
+
const keys = new Set<string>()
|
|
74
|
+
const since = new Map<string, number>()
|
|
75
|
+
const q = createDeliveryQueue<{ text: string }>()
|
|
76
|
+
|
|
77
|
+
// Eager mark at delivery + track for ack (the normal user-inbound glue).
|
|
78
|
+
markBusyKeyLockstep(keys, since, '-100:4', 0)
|
|
79
|
+
trackDelivery(q, '-100:4', { text: 'slow but real' }, 0, '9001')
|
|
80
|
+
|
|
81
|
+
// currentTurn is still null 6 MINUTES later (the #1922 tail) — but the
|
|
82
|
+
// delivery is still pending its enqueue ack. The proof gate must protect it.
|
|
83
|
+
expect(reap(keys, since, q, 360_000)).toEqual([])
|
|
84
|
+
expect(keys.has('-100:4')).toBe(true)
|
|
85
|
+
|
|
86
|
+
// Once claude finally acks (enqueue lands), the queue entry clears; the
|
|
87
|
+
// gateway would then hold currentTurn and clear busy at turn_end.
|
|
88
|
+
expect(ackDelivery(q, '-100:4', '9001')).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('reaps a TRUE orphan — busy-marked, no pending delivery, past the grace', () => {
|
|
92
|
+
const keys = new Set<string>()
|
|
93
|
+
const since = new Map<string, number>()
|
|
94
|
+
const q = createDeliveryQueue()
|
|
95
|
+
|
|
96
|
+
// A steer/interrupt inbound marks busy but is excluded from tracking
|
|
97
|
+
// (shouldTrackDelivery=false) — no queue entry. Its turn vanished without
|
|
98
|
+
// clearing busy: a genuine stuck marker.
|
|
99
|
+
markBusyKeyLockstep(keys, since, '555:_', 0)
|
|
100
|
+
expect(reap(keys, since, q, TTL)).toEqual(['555:_'])
|
|
101
|
+
expect(keys.has('555:_')).toBe(false)
|
|
102
|
+
expect(since.has('555:_')).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('does not reap before the grace elapses', () => {
|
|
106
|
+
const keys = new Set<string>()
|
|
107
|
+
const since = new Map<string, number>()
|
|
108
|
+
const q = createDeliveryQueue()
|
|
109
|
+
markBusyKeyLockstep(keys, since, '555:_', 0)
|
|
110
|
+
expect(reap(keys, since, q, TTL - 1)).toEqual([])
|
|
111
|
+
expect(keys.has('555:_')).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('disconnect → reconnect → re-mark: fresh timestamp, key NOT reaped as stale (the lockstep fix)', () => {
|
|
115
|
+
const keys = new Set<string>()
|
|
116
|
+
const since = new Map<string, number>()
|
|
117
|
+
const q = createDeliveryQueue()
|
|
118
|
+
|
|
119
|
+
// t=0 first mark.
|
|
120
|
+
markBusyKeyLockstep(keys, since, '-100:4', 0)
|
|
121
|
+
|
|
122
|
+
// Bridge dies at t=1000 — the REAL disconnect flush clears BOTH containers
|
|
123
|
+
// in lockstep (belt-and-suspenders to the set-membership guard).
|
|
124
|
+
flushOnAgentDisconnect(flushDeps(keys, since))
|
|
125
|
+
expect(keys.size).toBe(0)
|
|
126
|
+
expect(since.size).toBe(0)
|
|
127
|
+
|
|
128
|
+
// Reconnect and re-mark the SAME key at t=300_000 (well after the old stamp).
|
|
129
|
+
markBusyKeyLockstep(keys, since, '-100:4', 300_000)
|
|
130
|
+
|
|
131
|
+
// Pre-fix (stale t=0 stamp surviving the disconnect + a `!since.has` guard
|
|
132
|
+
// declining to re-stamp) the age would read 300_000ms ≫ grace and the key
|
|
133
|
+
// would be reaped THE INSTANT it was re-marked — re-opening the idle-drain
|
|
134
|
+
// while claude is about to process this very inbound. With the fix the stamp
|
|
135
|
+
// is fresh (t=300_000), so at t just past the re-mark it is NOT reaped.
|
|
136
|
+
expect(reap(keys, since, q, 305_000)).toEqual([]) // age 5s < 30s grace
|
|
137
|
+
expect(keys.has('-100:4')).toBe(true)
|
|
138
|
+
|
|
139
|
+
// ...and it DOES reap once the fresh grace genuinely elapses.
|
|
140
|
+
expect(reap(keys, since, q, 300_000 + TTL)).toEqual(['-100:4'])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('disconnect while a delivery is pending: reconnect re-mark stays protected by the proof gate', () => {
|
|
144
|
+
const keys = new Set<string>()
|
|
145
|
+
const since = new Map<string, number>()
|
|
146
|
+
const q = createDeliveryQueue<{ text: string }>()
|
|
147
|
+
|
|
148
|
+
markBusyKeyLockstep(keys, since, '-100:4', 0)
|
|
149
|
+
trackDelivery(q, '-100:4', { text: 'inflight' }, 0, '42')
|
|
150
|
+
|
|
151
|
+
// Bridge flap: disconnect clears busy state; the offline buffer / re-deliver
|
|
152
|
+
// loop still owns the inbound. Reconnect re-marks + re-tracks it.
|
|
153
|
+
flushOnAgentDisconnect(flushDeps(keys, since))
|
|
154
|
+
markBusyKeyLockstep(keys, since, '-100:4', 5_000)
|
|
155
|
+
trackDelivery(q, '-100:4', { text: 'inflight' }, 5_000, '42')
|
|
156
|
+
|
|
157
|
+
// Even minutes later, still pending its ack → never reaped.
|
|
158
|
+
expect(reap(keys, since, q, 5_000 + 400_000)).toEqual([])
|
|
159
|
+
expect(keys.has('-100:4')).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('prunes shadow-map entries whose key already left the set (no unbounded growth)', () => {
|
|
163
|
+
const keys = new Set<string>()
|
|
164
|
+
const since = new Map<string, number>()
|
|
165
|
+
const q = createDeliveryQueue()
|
|
166
|
+
// Simulate a direct clear that (hypothetically) left the map behind.
|
|
167
|
+
since.set('ghost:_', 0)
|
|
168
|
+
reap(keys, since, q, 999_999)
|
|
169
|
+
expect(since.has('ghost:_')).toBe(false)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('re-mark of an already-busy key does NOT reset its timestamp (measures the true dangle)', () => {
|
|
173
|
+
const keys = new Set<string>()
|
|
174
|
+
const since = new Map<string, number>()
|
|
175
|
+
const q = createDeliveryQueue()
|
|
176
|
+
markBusyKeyLockstep(keys, since, 'k:_', 0)
|
|
177
|
+
markBusyKeyLockstep(keys, since, 'k:_', 20_000) // still busy → no re-stamp
|
|
178
|
+
expect(since.get('k:_')).toBe(0)
|
|
179
|
+
// Orphaned (no pending delivery) → reaps on the ORIGINAL stamp's grace.
|
|
180
|
+
expect(reap(keys, since, q, TTL)).toEqual(['k:_'])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('clearBusyKeyLockstep removes from both containers', () => {
|
|
184
|
+
const keys = new Set<string>()
|
|
185
|
+
const since = new Map<string, number>()
|
|
186
|
+
markBusyKeyLockstep(keys, since, 'k:_', 0)
|
|
187
|
+
clearBusyKeyLockstep(keys, since, 'k:_')
|
|
188
|
+
expect(keys.has('k:_')).toBe(false)
|
|
189
|
+
expect(since.has('k:_')).toBe(false)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -263,20 +263,21 @@ describe('claimOrDowngradePing — PR-4c over-ping decision moves into the faça
|
|
|
263
263
|
expect(blockCode).not.toMatch(/\bawait\b/)
|
|
264
264
|
})
|
|
265
265
|
|
|
266
|
-
it('claimOrDowngradePing appears EXACTLY ONCE in the gateway, inside the executeReply window
|
|
267
|
-
// The over-ping net exists ONLY in executeReply.
|
|
268
|
-
// decideOverPing / firstPingAt /
|
|
269
|
-
//
|
|
266
|
+
it('claimOrDowngradePing appears EXACTLY ONCE in the gateway, inside the executeReply window', () => {
|
|
267
|
+
// The over-ping net exists ONLY in executeReply. The retired stream_reply
|
|
268
|
+
// tool (executeStreamReply) had no decideOverPing / firstPingAt /
|
|
269
|
+
// wasOverPingSuppressed and never called claimOrDowngradePing.
|
|
270
270
|
const calls = [...gatewaySrc.matchAll(/\.claimOrDowngradePing\(/g)]
|
|
271
271
|
expect(calls).toHaveLength(1)
|
|
272
272
|
const callIdx = gatewaySrc.indexOf('.claimOrDowngradePing(')
|
|
273
273
|
const execReplyIdx = gatewaySrc.indexOf('async function executeReply(')
|
|
274
|
-
|
|
274
|
+
// Upper bound: the next top-level function after executeReply.
|
|
275
|
+
const nextFnIdx = gatewaySrc.indexOf('\nasync function ', execReplyIdx + 1)
|
|
275
276
|
expect(execReplyIdx).toBeGreaterThan(-1)
|
|
276
|
-
expect(
|
|
277
|
-
// The single call is inside executeReply
|
|
277
|
+
expect(nextFnIdx).toBeGreaterThan(execReplyIdx)
|
|
278
|
+
// The single call is inside executeReply.
|
|
278
279
|
expect(callIdx).toBeGreaterThan(execReplyIdx)
|
|
279
|
-
expect(callIdx).toBeLessThan(
|
|
280
|
+
expect(callIdx).toBeLessThan(nextFnIdx)
|
|
280
281
|
})
|
|
281
282
|
})
|
|
282
283
|
|
|
@@ -370,20 +371,13 @@ describe('the 7 drain sites route through the façade with producers preserved v
|
|
|
370
371
|
})
|
|
371
372
|
})
|
|
372
373
|
|
|
373
|
-
describe('the
|
|
374
|
+
describe('the lever-2 finalize block routes through the façade', () => {
|
|
374
375
|
it('executeReply finalize routes via markSubstantiveFinalDelivered + finalizeCard (latch + clear preserved)', () => {
|
|
375
376
|
const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
|
|
376
|
-
const body = after.split('
|
|
377
|
+
const body = after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
|
|
377
378
|
expect(body).toMatch(/markSubstantiveFinalDelivered\(\(\) => \{\s*\n\s*finalizeTurn\.finalAnswerEverDelivered = true/)
|
|
378
379
|
expect(body).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(finalizeTurn\)/)
|
|
379
380
|
})
|
|
380
|
-
|
|
381
|
-
it('executeStreamReply finalize routes via markSubstantiveFinalDelivered + finalizeCard (latch + clear preserved)', () => {
|
|
382
|
-
const after = gatewaySrc.split('async function executeStreamReply(')[1] ?? ''
|
|
383
|
-
const body = after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
|
|
384
|
-
expect(body).toMatch(/markSubstantiveFinalDelivered\(\(\) => \{\s*\n\s*turn\.finalAnswerEverDelivered = true/)
|
|
385
|
-
expect(body).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(turn\)/)
|
|
386
|
-
})
|
|
387
381
|
})
|
|
388
382
|
|
|
389
383
|
describe('per-turn construction — one façade per turn, explicit chat/thread key (PR-4e seam)', () => {
|
|
@@ -52,8 +52,8 @@ describe('sticky finalAnswerEverDelivered latch (lever 1 precondition / R0)', ()
|
|
|
52
52
|
// The latch is set true only at the points that set finalAnswerDelivered=true,
|
|
53
53
|
// and only when the reply was substantive — so an ack never latches it.
|
|
54
54
|
const setTrue = [...gatewaySrc.matchAll(/finalAnswerEverDelivered\s*=\s*true/g)]
|
|
55
|
-
// executeReply,
|
|
56
|
-
//
|
|
55
|
+
// executeReply, silent-anchor merge, + the lever-2 finalize block
|
|
56
|
+
// (which is itself substantive-gated).
|
|
57
57
|
expect(setTrue.length).toBeGreaterThanOrEqual(3)
|
|
58
58
|
// Each `finalAnswerEverDelivered = true` must sit in a substantive context:
|
|
59
59
|
// either guarded by `if (turn.finalAnswerSubstantive)` or inside an
|
|
@@ -125,14 +125,9 @@ describe('drain producers — narrative may not OPEN, liveness + tool may', () =
|
|
|
125
125
|
})
|
|
126
126
|
|
|
127
127
|
describe('lever 2 — finalize the card BEFORE a substantive reply send', () => {
|
|
128
|
-
/** executeReply body up to
|
|
128
|
+
/** executeReply body up to the next top-level function. */
|
|
129
129
|
function executeReplySrc(): string {
|
|
130
130
|
const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
|
|
131
|
-
return after.split('async function executeStreamReply(')[0] ?? after
|
|
132
|
-
}
|
|
133
|
-
/** executeStreamReply body up to the next top-level function. */
|
|
134
|
-
function executeStreamReplySrc(): string {
|
|
135
|
-
const after = gatewaySrc.split('async function executeStreamReply(')[1] ?? ''
|
|
136
131
|
return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
|
|
137
132
|
}
|
|
138
133
|
|
|
@@ -148,27 +143,11 @@ describe('lever 2 — finalize the card BEFORE a substantive reply send', () =>
|
|
|
148
143
|
expect(window).toMatch(/isSubstantiveFinalReply/)
|
|
149
144
|
})
|
|
150
145
|
|
|
151
|
-
it('executeStreamReply finalizes before handleStreamReply, gated on substantive', () => {
|
|
152
|
-
const src = executeStreamReplySrc()
|
|
153
|
-
const clearIdx = src.indexOf('clearActivitySummary(')
|
|
154
|
-
const sendIdx = src.indexOf('const result = await handleStreamReply(')
|
|
155
|
-
expect(clearIdx).toBeGreaterThan(-1)
|
|
156
|
-
expect(sendIdx).toBeGreaterThan(-1)
|
|
157
|
-
expect(clearIdx).toBeLessThan(sendIdx)
|
|
158
|
-
// Window extended to 600 chars to account for the finalAnswerDeliveredAt stamp
|
|
159
|
-
// added inside the markSubstantiveFinalDelivered callback (Fix 2 / #2587).
|
|
160
|
-
const window = src.slice(Math.max(0, clearIdx - 600), clearIdx)
|
|
161
|
-
expect(window).toMatch(/isSubstantiveFinalReply/)
|
|
162
|
-
})
|
|
163
|
-
|
|
164
146
|
it('acks do NOT finalize early — no unconditional clearActivitySummary before the reply send', () => {
|
|
165
|
-
//
|
|
147
|
+
// The lever-2 finalize site sits inside an isSubstantiveFinalReply guard.
|
|
166
148
|
// An ack (non-substantive) falls through and never finalizes early, so the
|
|
167
149
|
// reopen path keeps owning the card (the #2141 ack-then-work feed).
|
|
168
|
-
const replySrc = (
|
|
169
|
-
const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
|
|
170
|
-
return after.split('async function executeStreamReply(')[0] ?? after
|
|
171
|
-
})()
|
|
150
|
+
const replySrc = executeReplySrc()
|
|
172
151
|
// The pre-loop clearActivitySummary must be the substantive-gated one.
|
|
173
152
|
const preLoop = replySrc.split('for (let i = 0; i < chunks.length')[0] ?? ''
|
|
174
153
|
const clears = [...preLoop.matchAll(/clearActivitySummary\(/g)]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { shouldDeferEscalationForBridge } from "../gateway/escalation-bridge-gate.js";
|
|
3
|
+
|
|
4
|
+
// Executable verification of #2788 Gap A: the obligation sweep's escalate branch
|
|
5
|
+
// DIRECT-SENDS (bypassing the bridge), so during a transient bridge outage it
|
|
6
|
+
// could fire a false "I may have missed this" even though the real reply is just
|
|
7
|
+
// queued behind the flap. The gate defers the nudge while the bridge is down;
|
|
8
|
+
// obligations survive bridge death, so the deferral is safe and self-healing.
|
|
9
|
+
|
|
10
|
+
describe("shouldDeferEscalationForBridge — #2788 Gap A bridge-flap gate", () => {
|
|
11
|
+
it("DEFERS escalation while the bridge is down (no false 'I may have missed this')", () => {
|
|
12
|
+
expect(shouldDeferEscalationForBridge({ bridgeAlive: false })).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("proceeds with escalation when the bridge is alive", () => {
|
|
16
|
+
expect(shouldDeferEscalationForBridge({ bridgeAlive: true })).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("models the gateway wiring: getClient(agent)?.isAlive() === true drives the gate", () => {
|
|
20
|
+
// Bridge registered + alive → do not defer.
|
|
21
|
+
const aliveClient = { isAlive: () => true };
|
|
22
|
+
expect(
|
|
23
|
+
shouldDeferEscalationForBridge({ bridgeAlive: aliveClient.isAlive() === true }),
|
|
24
|
+
).toBe(false);
|
|
25
|
+
|
|
26
|
+
// Bridge registered but a stale/dead socket → defer.
|
|
27
|
+
const deadClient = { isAlive: () => false };
|
|
28
|
+
expect(
|
|
29
|
+
shouldDeferEscalationForBridge({ bridgeAlive: deadClient.isAlive() === true }),
|
|
30
|
+
).toBe(true);
|
|
31
|
+
|
|
32
|
+
// No client registered at all (getClient returns undefined) → defer.
|
|
33
|
+
const noClient: { isAlive: () => boolean } | undefined = undefined;
|
|
34
|
+
expect(
|
|
35
|
+
shouldDeferEscalationForBridge({ bridgeAlive: noClient?.isAlive() === true }),
|
|
36
|
+
).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drift-catching scaffolding for the inbound-delivery state-machine
|
|
3
|
+
* cutover (#2794). The turn-in-flight gate is now machine-authoritative
|
|
4
|
+
* (`isMachineInTurn`), but the legacy imperative `claudeBusyKeys` set is
|
|
5
|
+
* still maintained in parallel. These tests assert the two AGREE on every
|
|
6
|
+
* well-formed turn schedule, and pin the ONE intended divergence (the
|
|
7
|
+
* orphan-dangle the machine self-heals) so future edits can't silently
|
|
8
|
+
* introduce a dangerous `machine_over_holds` drift without turning a test
|
|
9
|
+
* red.
|
|
10
|
+
*
|
|
11
|
+
* This is the "assertion that machine and shadow agree" from the #2794
|
|
12
|
+
* time-box plan: it turns the standing triple-maintenance drift risk into
|
|
13
|
+
* a CI gate.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it, beforeEach } from 'vitest'
|
|
17
|
+
import {
|
|
18
|
+
shadowEmit,
|
|
19
|
+
isMachineInTurn,
|
|
20
|
+
__shadowResetForTests,
|
|
21
|
+
} from '../gateway/inbound-delivery-machine-shadow.js'
|
|
22
|
+
import { TURN_TTL_MS, type ChatKey } from '../gateway/inbound-delivery-machine.js'
|
|
23
|
+
import {
|
|
24
|
+
gateParityDivergence,
|
|
25
|
+
isDangerousGateDivergence,
|
|
26
|
+
probeGateParity,
|
|
27
|
+
} from '../gateway/gate-parity-probe.js'
|
|
28
|
+
|
|
29
|
+
const KEY_A = '111:_' as ChatKey
|
|
30
|
+
const KEY_B = '222:_' as ChatKey
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reference model of the imperative `claudeBusyKeys` set — a per-delivery
|
|
34
|
+
* Set stamped on a fresh-turn delivery and deleted on the matching
|
|
35
|
+
* turnEnd. This mirrors gateway.ts's markBusyKeyLockstep / claudeBusyKeys.delete
|
|
36
|
+
* lifecycle closely enough to detect real drift in the gate view.
|
|
37
|
+
*/
|
|
38
|
+
class BusyKeysModel {
|
|
39
|
+
private readonly keys = new Set<string>()
|
|
40
|
+
stampFreshTurn(key: string) {
|
|
41
|
+
this.keys.add(key)
|
|
42
|
+
}
|
|
43
|
+
endTurn(key: string) {
|
|
44
|
+
this.keys.delete(key)
|
|
45
|
+
}
|
|
46
|
+
get size() {
|
|
47
|
+
return this.keys.size
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('gateParityDivergence classifier', () => {
|
|
52
|
+
it('agree → none (both idle, both busy)', () => {
|
|
53
|
+
expect(gateParityDivergence(false, 0)).toBe('none')
|
|
54
|
+
expect(gateParityDivergence(true, 1)).toBe('none')
|
|
55
|
+
expect(gateParityDivergence(true, 3)).toBe('none')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('machine idle, busyKeys held → busykeys_dangle (benign self-heal)', () => {
|
|
59
|
+
expect(gateParityDivergence(false, 1)).toBe('busykeys_dangle')
|
|
60
|
+
expect(isDangerousGateDivergence('busykeys_dangle')).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('machine in-flight, busyKeys empty → machine_over_holds (dangerous)', () => {
|
|
64
|
+
expect(gateParityDivergence(true, 0)).toBe('machine_over_holds')
|
|
65
|
+
expect(isDangerousGateDivergence('machine_over_holds')).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('probeGateParity returns the machine value unchanged (no behaviour change)', () => {
|
|
69
|
+
const lines: string[] = []
|
|
70
|
+
const log = (l: string) => lines.push(l)
|
|
71
|
+
expect(probeGateParity(true, 1, log)).toBe(true)
|
|
72
|
+
expect(probeGateParity(false, 0, log)).toBe(false)
|
|
73
|
+
expect(probeGateParity(false, 1, log)).toBe(false)
|
|
74
|
+
expect(probeGateParity(true, 0, log)).toBe(true)
|
|
75
|
+
// Only the dangerous over-hold emits a drift line.
|
|
76
|
+
expect(lines).toHaveLength(1)
|
|
77
|
+
expect(lines[0]).toContain('gw-trace gate-drift kind=machine_over_holds')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('machine ↔ claudeBusyKeys parity on well-formed schedules', () => {
|
|
82
|
+
beforeEach(() => __shadowResetForTests())
|
|
83
|
+
|
|
84
|
+
it('single turn: agree at every step', () => {
|
|
85
|
+
const busy = new BusyKeysModel()
|
|
86
|
+
shadowEmit({ kind: 'bridgeUp', at: 1000 })
|
|
87
|
+
expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
|
|
88
|
+
|
|
89
|
+
// Fresh inbound → machine goes in_turn; imperative stamps the key.
|
|
90
|
+
shadowEmit({ kind: 'inbound', key: KEY_A, msg: { msgId: 1, isSteering: false, payload: null }, at: 2000 })
|
|
91
|
+
busy.stampFreshTurn(KEY_A)
|
|
92
|
+
expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
|
|
93
|
+
expect(isMachineInTurn()).toBe(true)
|
|
94
|
+
|
|
95
|
+
// turnEnd → both reopen.
|
|
96
|
+
shadowEmit({ kind: 'turnEnd', key: KEY_A, at: 3000, outboundEmitted: true })
|
|
97
|
+
busy.endTurn(KEY_A)
|
|
98
|
+
expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
|
|
99
|
+
expect(isMachineInTurn()).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('sequential turns across two chats: no drift', () => {
|
|
103
|
+
const busy = new BusyKeysModel()
|
|
104
|
+
shadowEmit({ kind: 'bridgeUp', at: 1000 })
|
|
105
|
+
|
|
106
|
+
const schedule: Array<[ChatKey, number]> = [
|
|
107
|
+
[KEY_A, 2000],
|
|
108
|
+
[KEY_B, 4000],
|
|
109
|
+
[KEY_A, 6000],
|
|
110
|
+
]
|
|
111
|
+
let t = 2000
|
|
112
|
+
for (const [key, start] of schedule) {
|
|
113
|
+
shadowEmit({ kind: 'inbound', key, msg: { msgId: t, isSteering: false, payload: null }, at: start })
|
|
114
|
+
busy.stampFreshTurn(key)
|
|
115
|
+
expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
|
|
116
|
+
shadowEmit({ kind: 'turnEnd', key, at: start + 500, outboundEmitted: true })
|
|
117
|
+
busy.endTurn(key)
|
|
118
|
+
expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
|
|
119
|
+
t = start + 500
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('mid-turn sibling inbound is buffered, not a new turn: parity holds', () => {
|
|
124
|
+
const busy = new BusyKeysModel()
|
|
125
|
+
shadowEmit({ kind: 'bridgeUp', at: 1000 })
|
|
126
|
+
|
|
127
|
+
shadowEmit({ kind: 'inbound', key: KEY_A, msg: { msgId: 1, isSteering: false, payload: null }, at: 2000 })
|
|
128
|
+
busy.stampFreshTurn(KEY_A)
|
|
129
|
+
// Sibling arrives mid-turn: machine buffers (no new activeTurn); the
|
|
130
|
+
// imperative gate is already busy so it does NOT stamp a fresh key.
|
|
131
|
+
shadowEmit({ kind: 'inbound', key: KEY_B, msg: { msgId: 2, isSteering: false, payload: null }, at: 2500 })
|
|
132
|
+
expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
|
|
133
|
+
|
|
134
|
+
shadowEmit({ kind: 'turnEnd', key: KEY_A, at: 3000, outboundEmitted: true })
|
|
135
|
+
busy.endTurn(KEY_A)
|
|
136
|
+
expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('the ONE intended divergence: orphaned turnStart', () => {
|
|
141
|
+
beforeEach(() => __shadowResetForTests())
|
|
142
|
+
|
|
143
|
+
it('machine self-heals the dangle; imperative set holds the orphan (busykeys_dangle, benign)', () => {
|
|
144
|
+
const busy = new BusyKeysModel()
|
|
145
|
+
shadowEmit({ kind: 'bridgeUp', at: 1000 })
|
|
146
|
+
|
|
147
|
+
// Turn A opens; turn B opens before A's turnEnd ever lands. The
|
|
148
|
+
// imperative set stamps BOTH; only B's turnEnd arrives, leaving A as
|
|
149
|
+
// an orphan key — the gymbro/clerk 5-min dangle.
|
|
150
|
+
shadowEmit({ kind: 'turnStart', key: KEY_A, at: 2000 })
|
|
151
|
+
busy.stampFreshTurn(KEY_A)
|
|
152
|
+
shadowEmit({ kind: 'turnStart', key: KEY_B, at: 3000 })
|
|
153
|
+
busy.stampFreshTurn(KEY_B)
|
|
154
|
+
shadowEmit({ kind: 'turnEnd', key: KEY_B, at: 4000, outboundEmitted: true })
|
|
155
|
+
busy.endTurn(KEY_B)
|
|
156
|
+
|
|
157
|
+
// Machine still holds activeTurn=A (single-activeTurn); imperative
|
|
158
|
+
// holds A too → they agree here.
|
|
159
|
+
expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
|
|
160
|
+
|
|
161
|
+
// TTL tick past A's start: the machine self-heals to idle, but the
|
|
162
|
+
// imperative set NEVER gets A's turnEnd → orphan dangles forever.
|
|
163
|
+
shadowEmit({ kind: 'tick', now: 2000 + TURN_TTL_MS + 1 })
|
|
164
|
+
expect(isMachineInTurn()).toBe(false)
|
|
165
|
+
const d = gateParityDivergence(isMachineInTurn(), busy.size)
|
|
166
|
+
expect(d).toBe('busykeys_dangle')
|
|
167
|
+
// Benign: this is exactly the wedge the machine was made authoritative
|
|
168
|
+
// to kill — it must NOT trip the drift alarm.
|
|
169
|
+
expect(isDangerousGateDivergence(d)).toBe(false)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -58,6 +58,11 @@ function makeDeps(agentName: string | null) {
|
|
|
58
58
|
'chat1:thr1:msg1',
|
|
59
59
|
'chat2:thr2:msg2',
|
|
60
60
|
])
|
|
61
|
+
// #2787: shadow timestamp map, kept in lockstep with claudeBusyKeys.
|
|
62
|
+
const claudeBusyKeySince = new Map<string, number>([
|
|
63
|
+
['chat1:thr1:msg1', 100],
|
|
64
|
+
['chat2:thr2:msg2', 200],
|
|
65
|
+
])
|
|
61
66
|
const activeDraftStreams = new Map<string, FakeStream>([
|
|
62
67
|
['chat1:thr1:r1', { isFinal: () => false, finalize: finalizeA }],
|
|
63
68
|
['chat2:thr2:r2', { isFinal: () => true, finalize: finalizeB }],
|
|
@@ -74,6 +79,7 @@ function makeDeps(agentName: string | null) {
|
|
|
74
79
|
activeReactionMsgIds,
|
|
75
80
|
activeTurnStartedAt,
|
|
76
81
|
claudeBusyKeys,
|
|
82
|
+
claudeBusyKeySince,
|
|
77
83
|
activeDraftStreams,
|
|
78
84
|
clearActiveReactions,
|
|
79
85
|
disposeProgressDriver,
|
|
@@ -175,6 +181,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
175
181
|
]),
|
|
176
182
|
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
177
183
|
claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
|
|
184
|
+
claudeBusyKeySince: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
178
185
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
179
186
|
clearActiveReactions,
|
|
180
187
|
disposeProgressDriver,
|
|
@@ -232,6 +239,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
232
239
|
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
233
240
|
activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
|
|
234
241
|
claudeBusyKeys: new Set<string>(['real-turn:thr:msg']),
|
|
242
|
+
claudeBusyKeySince: new Map<string, number>([['real-turn:thr:msg', 100]]),
|
|
235
243
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
236
244
|
clearActiveReactions: vi.fn(),
|
|
237
245
|
disposeProgressDriver: vi.fn(),
|
|
@@ -266,6 +274,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
266
274
|
// activeTurnStartedAt was never set because cron bypasses
|
|
267
275
|
// handleInbound's fresh-turn branch.
|
|
268
276
|
claudeBusyKeys: new Set<string>(['cron-only-key:_']),
|
|
277
|
+
claudeBusyKeySince: new Map<string, number>([['cron-only-key:_', 100]]),
|
|
269
278
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
270
279
|
clearActiveReactions: vi.fn(),
|
|
271
280
|
disposeProgressDriver: vi.fn(),
|
|
@@ -306,6 +315,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
306
315
|
flushOnAgentDisconnect({
|
|
307
316
|
...baseDeps,
|
|
308
317
|
claudeBusyKeys: new Set<string>(['k1:_']),
|
|
318
|
+
claudeBusyKeySince: new Map<string, number>([['k1:_', 100]]),
|
|
309
319
|
log,
|
|
310
320
|
})
|
|
311
321
|
expect(log.mock.calls.some((c: unknown[]) =>
|
|
@@ -316,6 +326,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
316
326
|
flushOnAgentDisconnect({
|
|
317
327
|
...baseDeps,
|
|
318
328
|
claudeBusyKeys: new Set<string>(['k1:_', 'k2:1']),
|
|
329
|
+
claudeBusyKeySince: new Map<string, number>([['k1:_', 100], ['k2:1', 100]]),
|
|
319
330
|
log,
|
|
320
331
|
})
|
|
321
332
|
expect(log.mock.calls.some((c: unknown[]) =>
|
|
@@ -333,6 +344,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
333
344
|
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
334
345
|
activeTurnStartedAt: new Map<string, number>(),
|
|
335
346
|
claudeBusyKeys: new Set<string>(),
|
|
347
|
+
claudeBusyKeySince: new Map<string, number>(),
|
|
336
348
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
337
349
|
clearActiveReactions: vi.fn(),
|
|
338
350
|
disposeProgressDriver: vi.fn(),
|
|
@@ -352,6 +364,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
352
364
|
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
353
365
|
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
354
366
|
claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
|
|
367
|
+
claudeBusyKeySince: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
355
368
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
356
369
|
clearActiveReactions: vi.fn(),
|
|
357
370
|
disposeProgressDriver: vi.fn(),
|
|
@@ -8,10 +8,10 @@ import { readFileSync } from 'node:fs'
|
|
|
8
8
|
* echoed a secret it read from a file/env/not-yet-vaulted value would send
|
|
9
9
|
* the raw bytes to Telegram, log a preview to stderr, and store them in
|
|
10
10
|
* history. This pins that `redactOutboundText()` runs at the ENTRY of each
|
|
11
|
-
* agent-free-text tool (reply /
|
|
11
|
+
* agent-free-text tool (reply / edit_message), before the
|
|
12
12
|
* stderr preview, the dedup key, the send, and the history record.
|
|
13
13
|
*
|
|
14
|
-
* Why structural: executeReply/
|
|
14
|
+
* Why structural: executeReply/executeEditMessage are
|
|
15
15
|
* not exported (same constraint as gateway-secret-detect.test.ts). The
|
|
16
16
|
* masking itself — that `redact()` covers the Sanctum shape and every
|
|
17
17
|
* provider token — is exercised behaviorally in secret-detect-sanctum.test.ts
|
|
@@ -43,15 +43,6 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
|
|
|
43
43
|
expect(previewIdx).toBeGreaterThan(redactIdx) // mask BEFORE the preview is logged
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
-
it('stream_reply: scrubs at entry, before the voice scrub + dedup', () => {
|
|
47
|
-
const start = src.indexOf('async function executeStreamReply(')
|
|
48
|
-
const redactIdx = src.indexOf(`redactOutboundText(args.text as string, 'stream_reply')`, start)
|
|
49
|
-
const scrubIdx = src.indexOf(`site: 'stream_reply'`, start)
|
|
50
|
-
expect(start).toBeGreaterThan(0)
|
|
51
|
-
expect(redactIdx).toBeGreaterThan(start)
|
|
52
|
-
expect(scrubIdx).toBeGreaterThan(redactIdx)
|
|
53
|
-
})
|
|
54
|
-
|
|
55
46
|
it('edit_message: scrubs at entry, before the voice scrub + send', () => {
|
|
56
47
|
const start = src.indexOf('async function executeEditMessage(')
|
|
57
48
|
const redactIdx = src.indexOf(`redactOutboundText(editRawText, 'edit_message')`, start)
|
|
@@ -70,6 +61,18 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
|
|
|
70
61
|
expect(scrubSiteIdx).toBeGreaterThan(redactIdx) // mask BEFORE the voice scrub + send
|
|
71
62
|
})
|
|
72
63
|
|
|
64
|
+
it('progress_update: scrubs at entry, BEFORE the 300-char truncation', () => {
|
|
65
|
+
// progress_update was the only send site not calling redactOutboundText.
|
|
66
|
+
// The mask MUST run before the truncation so a secret straddling the
|
|
67
|
+
// 300-char cut can't be sliced apart and evade the token-shape detector.
|
|
68
|
+
const start = src.indexOf('async function executeProgressUpdate(')
|
|
69
|
+
const redactIdx = src.indexOf(`redactOutboundText(text, 'progress_update')`, start)
|
|
70
|
+
const truncIdx = src.indexOf('Truncate to 300 chars', start)
|
|
71
|
+
expect(start).toBeGreaterThan(0)
|
|
72
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
73
|
+
expect(truncIdx).toBeGreaterThan(redactIdx) // mask BEFORE the slice
|
|
74
|
+
})
|
|
75
|
+
|
|
73
76
|
it('does not log the secret value when a mask fires', () => {
|
|
74
77
|
const idx = src.indexOf('function redactOutboundText(')
|
|
75
78
|
const body = src.slice(idx, idx + 400)
|