switchroom 0.19.17 → 0.19.19
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/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +13 -4
- package/dist/auth-broker/index.js +32 -5
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +13 -4
- package/dist/cli/switchroom.js +10614 -8104
- package/dist/host-control/main.js +12849 -11446
- package/dist/vault/approvals/kernel-server.js +90 -12
- package/dist/vault/broker/server.js +277 -94
- package/package.json +5 -3
- package/profiles/_base/start.sh.hbs +69 -5
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +25 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +28 -20
- package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
- package/telegram-plugin/dist/server.js +32 -20
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/telegram-plugin/worker-activity-feed.ts +51 -1
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* switchroom#3551 — the 5-minute silence poke killed turns silently.
|
|
3
|
+
*
|
|
4
|
+
* `formatFrameworkFallbackText` returns a string on exactly one branch
|
|
5
|
+
* (`blockedOnApproval`); the stall-notice path was retired and every other
|
|
6
|
+
* branch returns null. So on a normal fire the gateway delivered NO text while
|
|
7
|
+
* still tearing the turn down and redelivering buffered inbound — from the
|
|
8
|
+
* user's seat, five minutes of nothing followed by the agent apparently
|
|
9
|
+
* restarting the same request. 110 fires / 14 days, p50 silence 302s.
|
|
10
|
+
*
|
|
11
|
+
* `decideFallbackTeardownNotice` makes that kill observable without
|
|
12
|
+
* re-introducing the banned cadence ping. These tests assert the decision
|
|
13
|
+
* OUTCOMES — which real situations speak and which stay quiet — plus the
|
|
14
|
+
* structural guarantee that the wiring actually sends on a speaking decision.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
17
|
+
import { readFileSync } from 'node:fs'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import {
|
|
20
|
+
decideFallbackTeardownNotice,
|
|
21
|
+
formatFrameworkFallbackText,
|
|
22
|
+
startTurn,
|
|
23
|
+
__setDepsForTests,
|
|
24
|
+
__tickForTests,
|
|
25
|
+
__resetAllForTests,
|
|
26
|
+
type FallbackTeardownNoticeInput,
|
|
27
|
+
} from '../silence-poke.js'
|
|
28
|
+
import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
|
|
29
|
+
import { makeLivenessFixture, makeTurn } from './helpers/liveness-wiring-fixture.js'
|
|
30
|
+
|
|
31
|
+
/** The situation the issue is about: a human asked, got nothing, turn killed. */
|
|
32
|
+
function killedUserTurn(over: Partial<FallbackTeardownNoticeInput> = {}): FallbackTeardownNoticeInput {
|
|
33
|
+
return {
|
|
34
|
+
tearsDownLiveTurn: true,
|
|
35
|
+
role: 'user',
|
|
36
|
+
finalAnswerDelivered: false,
|
|
37
|
+
userAddressedTextDelivered: false,
|
|
38
|
+
representWillFollow: true,
|
|
39
|
+
silenceMs: 302_000,
|
|
40
|
+
...over,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('#3551 — a fire that ends a user turn is never silent', () => {
|
|
45
|
+
it('speaks when the framework kills an undelivered user turn', () => {
|
|
46
|
+
const d = decideFallbackTeardownNotice(killedUserTurn())
|
|
47
|
+
expect(d.send).toBe(true)
|
|
48
|
+
if (!d.send) throw new Error('unreachable')
|
|
49
|
+
// Honest about WHAT happened (the turn was ended) and WHO did it (the
|
|
50
|
+
// framework, not the agent) — the two facts the user had no way to know.
|
|
51
|
+
expect(d.text).toContain('the framework ended that stalled turn')
|
|
52
|
+
expect(d.text).toContain('5 min')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('closes the exact gap: the branch where formatFrameworkFallbackText is null still produces a message', () => {
|
|
56
|
+
// This is the bug, stated as a test. On the non-approval branch the
|
|
57
|
+
// formatter yields nothing; before the fix, that meant the whole fire was
|
|
58
|
+
// user-invisible.
|
|
59
|
+
expect(formatFrameworkFallbackText('working', 302_000, [], false)).toBeNull()
|
|
60
|
+
expect(decideFallbackTeardownNotice(killedUserTurn({ userAddressedTextDelivered: false })).send).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('derives the minute count from the real silence, not the nominal threshold', () => {
|
|
64
|
+
const d = decideFallbackTeardownNotice(killedUserTurn({ silenceMs: 904_000 }))
|
|
65
|
+
if (!d.send) throw new Error('expected a notice')
|
|
66
|
+
expect(d.text).toContain('for 15 min')
|
|
67
|
+
expect(d.text).not.toContain('for 5 min')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('never promises a re-ask the obligation ledger cannot deliver', () => {
|
|
71
|
+
const on = decideFallbackTeardownNotice(killedUserTurn({ representWillFollow: true }))
|
|
72
|
+
const off = decideFallbackTeardownNotice(killedUserTurn({ representWillFollow: false }))
|
|
73
|
+
if (!on.send || !off.send) throw new Error('expected notices')
|
|
74
|
+
expect(on.text).toContain('being re-asked now')
|
|
75
|
+
expect(off.text).toContain('please re-send it')
|
|
76
|
+
expect(off.text).not.toContain('re-asked')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('#3551 — the gates that keep this from becoming the retired stall ping', () => {
|
|
81
|
+
it('stays quiet when a delivered approval re-ping already addressed the user', () => {
|
|
82
|
+
const d = decideFallbackTeardownNotice(killedUserTurn({ userAddressedTextDelivered: true }))
|
|
83
|
+
expect(d).toEqual({ send: false, reason: 'user-addressed-text-delivered' })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('stays quiet on a late fire that tore down nothing', () => {
|
|
87
|
+
const d = decideFallbackTeardownNotice(killedUserTurn({ tearsDownLiveTurn: false }))
|
|
88
|
+
expect(d).toEqual({ send: false, reason: 'no-live-turn-torn-down' })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('stays quiet on cron / system / sub-agent turns — nobody is waiting', () => {
|
|
92
|
+
expect(decideFallbackTeardownNotice(killedUserTurn({ role: 'system' })))
|
|
93
|
+
.toEqual({ send: false, reason: 'non-user-turn' })
|
|
94
|
+
expect(decideFallbackTeardownNotice(killedUserTurn({ role: 'sub-agent' })))
|
|
95
|
+
.toEqual({ send: false, reason: 'non-user-turn' })
|
|
96
|
+
expect(decideFallbackTeardownNotice(killedUserTurn({ role: null })))
|
|
97
|
+
.toEqual({ send: false, reason: 'non-user-turn' })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('stays quiet when the user already has their answer', () => {
|
|
101
|
+
const d = decideFallbackTeardownNotice(killedUserTurn({ finalAnswerDelivered: true }))
|
|
102
|
+
expect(d).toEqual({ send: false, reason: 'answer-already-delivered' })
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* #3575 review — BEHAVIOURAL wiring coverage.
|
|
108
|
+
*
|
|
109
|
+
* The three describe blocks below assert regexes against `liveness-wiring.ts`'s
|
|
110
|
+
* own SOURCE TEXT. They pass if `onFrameworkFallback` is never invoked, if the
|
|
111
|
+
* notice goes to the wrong chat, or if `sendSilenceText` throws. This block
|
|
112
|
+
* drives the REAL wiring through silence-poke's REAL tick: arm a turn, step the
|
|
113
|
+
* fake clock past the 300 s threshold, and assert on the send that happens.
|
|
114
|
+
*/
|
|
115
|
+
describe('#3551 — the teardown notice actually reaches the user (integration)', () => {
|
|
116
|
+
const CHAT = '-100999'
|
|
117
|
+
const THREAD = 11
|
|
118
|
+
const KEY = `${CHAT}:${THREAD}`
|
|
119
|
+
const TURN_ID = '-100999:11#42'
|
|
120
|
+
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
__resetAllForTests()
|
|
123
|
+
__setDepsForTests(null)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
async function fireFallback(opts: { obligationOpen: boolean }) {
|
|
127
|
+
const fx = makeLivenessFixture()
|
|
128
|
+
const turn = makeTurn({ sessionChatId: CHAT, sessionThreadId: THREAD, turnId: TURN_ID })
|
|
129
|
+
fx.activeTurnStartedAt.set(KEY, 0)
|
|
130
|
+
fx.setCurrentTurn(turn)
|
|
131
|
+
if (opts.obligationOpen) fx.openObligations.add(TURN_ID)
|
|
132
|
+
__setDepsForTests(buildSilencePokeOptions(fx.deps))
|
|
133
|
+
startTurn(KEY, 0)
|
|
134
|
+
__tickForTests(301_000)
|
|
135
|
+
// onFrameworkFallback is async; let its awaits settle.
|
|
136
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
137
|
+
return fx
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it('sends the notice — right chat, right thread, LOUD — after 300s of silence', async () => {
|
|
141
|
+
const fx = await fireFallback({ obligationOpen: true })
|
|
142
|
+
const notice = fx.sent.find((s) => s.text.includes('the framework ended that stalled turn'))
|
|
143
|
+
expect(notice, 'the teardown notice must actually be sent').toBeDefined()
|
|
144
|
+
expect(notice!.chatId).toBe(CHAT)
|
|
145
|
+
expect(notice!.threadId).toBe(THREAD)
|
|
146
|
+
// LOUD: a killed question is not a silent status surface.
|
|
147
|
+
expect(notice!.silent).toBe(false)
|
|
148
|
+
// ...and the load-bearing unwedge still ran.
|
|
149
|
+
expect(fx.endedKeys).toContain(KEY)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('promises the re-ask only when THIS turn has an open obligation', async () => {
|
|
153
|
+
// #3575 review B2. `representWillFollow` used to be the STATIC
|
|
154
|
+
// `OBLIGATION_LEDGER_ENABLED` env boolean, so the notice promised "being
|
|
155
|
+
// re-asked now" even when no re-ask could ever happen — the obligation
|
|
156
|
+
// already hit OBLIGATION_REPRESENT_MAX and escalated+closed, or was closed
|
|
157
|
+
// by an outbound-since-open, or was never opened. The user was told to wait,
|
|
158
|
+
// and waited forever.
|
|
159
|
+
//
|
|
160
|
+
// These two runs differ ONLY in whether this turn's obligation is open. Any
|
|
161
|
+
// process-wide constant fails one of them, so restoring the static flag
|
|
162
|
+
// cannot pass.
|
|
163
|
+
const open = await fireFallback({ obligationOpen: true })
|
|
164
|
+
const closed = await fireFallback({ obligationOpen: false })
|
|
165
|
+
const openText = open.sent.find((s) => s.text.includes('framework ended'))!.text
|
|
166
|
+
const closedText = closed.sent.find((s) => s.text.includes('framework ended'))!.text
|
|
167
|
+
expect(openText).toContain('being re-asked now')
|
|
168
|
+
expect(closedText).toContain('please re-send it')
|
|
169
|
+
expect(closedText).not.toContain('re-asked')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* #3580 L3 — `tearsDownLiveTurn` must be the SAME predicate as the teardown
|
|
175
|
+
* gate, not a looser restatement of it.
|
|
176
|
+
*
|
|
177
|
+
* The old wiring passed the bare `turnMatchesFallback` (chat + thread equality)
|
|
178
|
+
* while the teardown itself additionally required `turnLiveForItsTopic`. When
|
|
179
|
+
* those disagree the decider is told a live turn was torn down when it was not,
|
|
180
|
+
* and the user gets a terminal "the framework ended that stalled turn" notice
|
|
181
|
+
* about a turn that never ended.
|
|
182
|
+
*/
|
|
183
|
+
describe('#3580 L3 — the notice speaks for exactly the teardown that ran', () => {
|
|
184
|
+
const CHAT = '-100999'
|
|
185
|
+
const THREAD = 11
|
|
186
|
+
const KEY = `${CHAT}:${THREAD}`
|
|
187
|
+
|
|
188
|
+
afterEach(() => {
|
|
189
|
+
__resetAllForTests()
|
|
190
|
+
__setDepsForTests(null)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
async function fire(turnLiveForItsTopic: () => boolean) {
|
|
194
|
+
const fx = makeLivenessFixture({ turnLiveForItsTopic })
|
|
195
|
+
const turn = makeTurn({ sessionChatId: CHAT, sessionThreadId: THREAD, turnId: `${KEY}#42` })
|
|
196
|
+
fx.activeTurnStartedAt.set(KEY, 0)
|
|
197
|
+
fx.setCurrentTurn(turn)
|
|
198
|
+
fx.openObligations.add(`${KEY}#42`)
|
|
199
|
+
__setDepsForTests(buildSilencePokeOptions(fx.deps))
|
|
200
|
+
startTurn(KEY, 0)
|
|
201
|
+
__tickForTests(301_000)
|
|
202
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
203
|
+
return fx
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
it('stays quiet when the keyed liveness check SKIPPED the teardown', async () => {
|
|
207
|
+
// chat + thread match (so the loose `turnMatchesFallback` is true), but the
|
|
208
|
+
// keyed read says this turn is no longer live for its own topic — so no
|
|
209
|
+
// teardown happens. The user must not be told their turn was ended.
|
|
210
|
+
const fx = await fire(() => false)
|
|
211
|
+
expect(fx.endedKeys, 'no teardown must have run').toEqual([])
|
|
212
|
+
expect(fx.sent.filter((s) => s.text.includes('framework ended')), 'no teardown notice').toEqual([])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('still speaks when the teardown DID run (the guard did not mute the honest case)', async () => {
|
|
216
|
+
const fx = await fire(() => true)
|
|
217
|
+
expect(fx.endedKeys).toContain(KEY)
|
|
218
|
+
expect(fx.sent.some((s) => s.text.includes('the framework ended that stalled turn'))).toBe(true)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('the decider is fed the gate ITSELF — exactly one evaluation, no restatement', () => {
|
|
222
|
+
const code = readFileSync(join(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf8')
|
|
223
|
+
.split('\n')
|
|
224
|
+
.filter((l) => {
|
|
225
|
+
const t = l.trim()
|
|
226
|
+
return !(t.startsWith('//') || t.startsWith('*') || t.startsWith('/*'))
|
|
227
|
+
})
|
|
228
|
+
// Declared once...
|
|
229
|
+
expect(code.filter((l) => l.trim().startsWith('const tearsDownLiveTurn')).length).toBe(1)
|
|
230
|
+
// ...passed by shorthand (a `tearsDownLiveTurn: <anything>` restatement is
|
|
231
|
+
// exactly the L3 divergence, and would re-admit `: turnMatchesFallback`)...
|
|
232
|
+
expect(code.map((l) => l.trim())).toContain('tearsDownLiveTurn,')
|
|
233
|
+
expect(code.join('\n')).not.toMatch(/tearsDownLiveTurn:/)
|
|
234
|
+
// ...and it is the gate on the teardown block, not a parallel condition.
|
|
235
|
+
expect(code.map((l) => l.trim())).toContain('if (tearsDownLiveTurn && wedgedTurn != null) {')
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('#3551 — wiring: the teardown path actually sends the notice', () => {
|
|
240
|
+
// buildSilencePokeOptions needs the whole gateway module's dep object, which
|
|
241
|
+
// cannot be instantiated in a unit test (module-load side effects). Pin the
|
|
242
|
+
// wiring structurally instead — the same technique the repo uses for other
|
|
243
|
+
// gateway-resident callbacks.
|
|
244
|
+
const src = readFileSync(join(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf8')
|
|
245
|
+
const fallbackBody = src.slice(src.indexOf('onFrameworkFallback:'))
|
|
246
|
+
|
|
247
|
+
it('calls the decider inside onFrameworkFallback and sends on a speaking decision', () => {
|
|
248
|
+
expect(fallbackBody).toMatch(/decideFallbackTeardownNotice\(/)
|
|
249
|
+
expect(fallbackBody).toMatch(/if \(teardownNotice\.send\)[\s\S]{0,400}?sendSilenceText\(/)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('keeps the load-bearing unwedge — the notice does not replace the teardown', () => {
|
|
253
|
+
// The whole point of the 300s fallback is #1122: end the wedged turn so
|
|
254
|
+
// later inbound is not queued behind a dead turn forever. A "fix" that
|
|
255
|
+
// stopped killing the turn would trade dead air for a wedged conversation.
|
|
256
|
+
expect(fallbackBody).toMatch(/silencePoke\.endTurn\(fbKey\)/)
|
|
257
|
+
expect(fallbackBody).toMatch(/endCurrentTurnForKey\(wedgedTurn, fbKey\)/)
|
|
258
|
+
expect(fallbackBody).toMatch(/redeliverBufferedInbound\(/)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('sends the notice LOUD — a killed question is not a silent status surface', () => {
|
|
262
|
+
expect(fallbackBody).toMatch(/sendSilenceText\(fbChatId, ctx\.threadId \?\? null, teardownNotice\.text, false\)/)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('#3551 L1/L2 — the suppression signal is narrow, and means DELIVERED', () => {
|
|
267
|
+
// L1. `text` in onFrameworkFallback has TWO sources. Treating them alike
|
|
268
|
+
// reproduced the very bug #3551 closes: user asks something during an
|
|
269
|
+
// `update_apply`, gets no answer, the fallback emits a SILENT status line
|
|
270
|
+
// about the update, the turn is torn down — and the notice was suppressed as
|
|
271
|
+
// "other text sent". Silent kill, again.
|
|
272
|
+
it('a silent, unrelated update-status line does NOT buy silence — that IS the #3551 case', () => {
|
|
273
|
+
const d = decideFallbackTeardownNotice(killedUserTurn({ userAddressedTextDelivered: false }))
|
|
274
|
+
expect(d.send).toBe(true)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const src = readFileSync(join(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf8')
|
|
278
|
+
const fallbackBody = src.slice(src.indexOf('onFrameworkFallback:'))
|
|
279
|
+
|
|
280
|
+
it('the wiring feeds the decider the DELIVERY flag, never `text != null`', () => {
|
|
281
|
+
// The regression this pins: reverting to `otherTextSent: text != null`
|
|
282
|
+
// silently re-couples the silent status line (L1) and the failed send (L2).
|
|
283
|
+
expect(fallbackBody).toMatch(/userAddressedTextDelivered,/)
|
|
284
|
+
expect(fallbackBody).not.toMatch(/otherTextSent:\s*text != null/)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('L2 — the flag is set only AFTER a successful send, inside the try', () => {
|
|
288
|
+
// A throwing approval re-ping must not buy silence: the catch only logs, so
|
|
289
|
+
// assigning before/outside the await would leave the user with nothing at
|
|
290
|
+
// all while the turn was torn down.
|
|
291
|
+
expect(fallbackBody).toMatch(
|
|
292
|
+
/await sendSilenceText\(ctx\.chatId[^\n]*\n\s*userAddressedTextDelivered = blockedOnApproval/,
|
|
293
|
+
)
|
|
294
|
+
// and it is initialised false, so the catch path leaves it false
|
|
295
|
+
expect(fallbackBody).toMatch(/let userAddressedTextDelivered = false/)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('L1 — the flag is gated on blockedOnApproval, which is what makes it user-addressed', () => {
|
|
299
|
+
expect(fallbackBody).toMatch(/userAddressedTextDelivered = blockedOnApproval/)
|
|
300
|
+
})
|
|
301
|
+
})
|