switchroom 0.20.12 → 0.20.13
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 +5 -1
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/self-improve-stop.mjs +13 -1
- package/dist/cli/switchroom.js +1472 -89
- package/dist/host-control/main.js +6 -2
- package/dist/vault/approvals/kernel-server.js +5 -1
- package/dist/vault/broker/server.js +5 -1
- package/package.json +7 -4
- package/skills/switchroom-release/SKILL.md +3 -2
- package/telegram-plugin/bunfig.toml +9 -5
- package/telegram-plugin/dist/gateway/gateway.js +169 -68
- package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
- package/telegram-plugin/gateway/gateway.ts +1 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
- package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
- package/telegram-plugin/gateway/stream-render.ts +4 -0
- package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
- package/telegram-plugin/hooks/audience-classify.mjs +193 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +8 -1
- package/telegram-plugin/outbox.ts +69 -3
- package/telegram-plugin/silent-end.ts +48 -5
- package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
- package/telegram-plugin/tests/hindsight-bank-preload.test.ts +50 -0
- package/telegram-plugin/tests/outbox-live-path-review-4490.test.ts +613 -0
- package/telegram-plugin/tests/outbox-self-improve-review.test.ts +401 -0
|
@@ -72,6 +72,8 @@ import {
|
|
|
72
72
|
import {
|
|
73
73
|
decideCaptureAudience,
|
|
74
74
|
resolveOpenObligation,
|
|
75
|
+
isReviewOriginatedSource,
|
|
76
|
+
isSelfImprovementCard,
|
|
75
77
|
AUDIENCE_INTERNAL,
|
|
76
78
|
} from './audience-classify.mjs'
|
|
77
79
|
|
|
@@ -129,6 +131,19 @@ function buildNextState(base, decision, retryCount) {
|
|
|
129
131
|
} else {
|
|
130
132
|
delete next.replyToolThrewThisTurn
|
|
131
133
|
}
|
|
134
|
+
// #4490: same carry-through for review-turn provenance. The outbox capture
|
|
135
|
+
// path (`writeOutboxRecord` above) stamps `reviewOriginated` from the SAME
|
|
136
|
+
// envelope `source` tag; the ELECTED path ('trailing-text-after-reply') never
|
|
137
|
+
// writes an outbox record, so the captured-prose bridge (the delivering
|
|
138
|
+
// machine on this path) has no other way to learn it. Explicitly deleted
|
|
139
|
+
// when this turn's scan saw no review-source envelope, so a spread of a
|
|
140
|
+
// prior turn's file can never mislabel a normal turn's prose as a review
|
|
141
|
+
// turn's (or vice versa).
|
|
142
|
+
if (isReviewOriginatedSource(decision.source)) {
|
|
143
|
+
next.reviewOriginated = true
|
|
144
|
+
} else {
|
|
145
|
+
delete next.reviewOriginated
|
|
146
|
+
}
|
|
132
147
|
return next
|
|
133
148
|
}
|
|
134
149
|
|
|
@@ -181,7 +196,7 @@ function outboxAlreadyDelivered(outboxDir, nonce) {
|
|
|
181
196
|
* `'unknown'` → the record delivers exactly as it does today.
|
|
182
197
|
*
|
|
183
198
|
* @param {string} stateDir
|
|
184
|
-
* @param {{ chatId: string|null, originChatId?: string|null, replyToolThrewThisTurn?: boolean }} capture
|
|
199
|
+
* @param {{ chatId: string|null, originChatId?: string|null, replyToolThrewThisTurn?: boolean, source?: string|null }} capture
|
|
185
200
|
* @returns {'user' | 'internal'}
|
|
186
201
|
*/
|
|
187
202
|
function classifyCaptureAudience(stateDir, capture) {
|
|
@@ -212,6 +227,14 @@ function classifyCaptureAudience(stateDir, capture) {
|
|
|
212
227
|
process.env.TELEGRAM_ACCESS_MODE !== 'static'
|
|
213
228
|
return decideCaptureAudience({
|
|
214
229
|
replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
|
|
230
|
+
// A self-improvement review turn has exactly ONE sanctioned operator-facing
|
|
231
|
+
// output: a well-formed self-improvement CARD. Deterministic on the enqueue
|
|
232
|
+
// envelope's `source` tag (the same signal the rest of the self-improve
|
|
233
|
+
// machinery routes on) plus the EXACT card-shape of the captured text. A
|
|
234
|
+
// card delivers; any other trailing prose (the raw-reasoning leak) is
|
|
235
|
+
// suppressed. Independent of the reply-throw / obligation state below.
|
|
236
|
+
reviewOriginated: isReviewOriginatedSource(capture.source),
|
|
237
|
+
reviewTextIsCard: isSelfImprovementCard(capture.text),
|
|
215
238
|
openInboundObligation: resolveOpenObligation({
|
|
216
239
|
snapshotRaw,
|
|
217
240
|
snapshotTrusted,
|
|
@@ -265,6 +288,13 @@ function writeOutboxRecord(stateDir, capture, audience) {
|
|
|
265
288
|
// not catch (the foreground case). Stamped here because this is the last
|
|
266
289
|
// point in the pipeline that can still see the transcript.
|
|
267
290
|
replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
|
|
291
|
+
// Ken 2026-08-07: did this turn originate from a self-improvement review
|
|
292
|
+
// inbound? `audience` already consumed it (with the card-shape signal) to
|
|
293
|
+
// deliver a well-formed card and suppress raw reasoning by default; the
|
|
294
|
+
// sweep consumes this raw flag again, on its own, to prepend the
|
|
295
|
+
// self-improvement TITLE to any review record delivered with the audience
|
|
296
|
+
// gate OFF, so review reasoning can never appear as raw, unlabelled prose.
|
|
297
|
+
reviewOriginated: isReviewOriginatedSource(capture.source),
|
|
268
298
|
}
|
|
269
299
|
const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
|
|
270
300
|
writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
|
|
@@ -259,6 +259,13 @@ function buildTurnId(chatId, threadId, messageId) {
|
|
|
259
259
|
*/
|
|
260
260
|
function buildBlockResult(envelope, reason, pendingText, hasTrailingProse, replyToolThrewThisTurn) {
|
|
261
261
|
const block = { decided: 'block', reason }
|
|
262
|
+
// #4490: the enqueue envelope's RAW `source` tag, carried through exactly
|
|
263
|
+
// like `replyToolThrewThisTurn` above — the single-writer election needs it
|
|
264
|
+
// to derive `reviewOriginated` for the captured-prose bridge (the ELECTED
|
|
265
|
+
// path's deliverer, which never writes an outbox record and so has no other
|
|
266
|
+
// way to learn a review turn's provenance). Omitted when the envelope carried
|
|
267
|
+
// none, so a spread of a prior state file is never read as positive evidence.
|
|
268
|
+
if (envelope.source != null) block.source = envelope.source
|
|
262
269
|
if (replyToolThrewThisTurn === true) block.replyToolThrewThisTurn = true
|
|
263
270
|
// Single-writer election input (#duplicate-message fix): does ANY
|
|
264
271
|
// non-empty, non-silent trailing text block exist after the last
|
|
@@ -360,7 +367,7 @@ function buildBlockResult(envelope, reason, pendingText, hasTrailingProse, reply
|
|
|
360
367
|
* false-positive that burned retry budget on healthy turns.
|
|
361
368
|
*
|
|
362
369
|
* @param {string} jsonl
|
|
363
|
-
* @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, turnId?: string, chatId?: string, threadId?: number | null, pendingText?: string }}
|
|
370
|
+
* @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, turnId?: string, chatId?: string, threadId?: number | null, pendingText?: string, source?: string, replyToolThrewThisTurn?: boolean }}
|
|
364
371
|
*/
|
|
365
372
|
export function scanTurnForFinalReply(jsonl) {
|
|
366
373
|
const lines = jsonl.split('\n')
|
|
@@ -75,6 +75,9 @@ import type { Audience } from './hooks/audience-classify.mjs'
|
|
|
75
75
|
import {
|
|
76
76
|
applyReplyThrowFraming,
|
|
77
77
|
shouldFrameReplyThrow,
|
|
78
|
+
applySelfImprovementFraming,
|
|
79
|
+
shouldFrameSelfImprovement,
|
|
80
|
+
isSelfImprovementCard,
|
|
78
81
|
} from './hooks/audience-classify.mjs'
|
|
79
82
|
|
|
80
83
|
export type { Audience }
|
|
@@ -162,6 +165,25 @@ export interface OutboxRecord {
|
|
|
162
165
|
* exact boolean `true` changes anything — see `shouldFrameReplyThrow`.
|
|
163
166
|
*/
|
|
164
167
|
replyToolThrewThisTurn?: boolean
|
|
168
|
+
/**
|
|
169
|
+
* Ken 2026-08-07: did the turn that produced this record originate from a
|
|
170
|
+
* self-improvement review inbound (`source="self_improve_review"`)? Stamped at
|
|
171
|
+
* capture by `hooks/silent-end-interrupt-stop.mjs` from the enqueue envelope's
|
|
172
|
+
* source tag.
|
|
173
|
+
*
|
|
174
|
+
* `audience` already consumed it once — a review turn classifies `'internal'`
|
|
175
|
+
* (`decideCaptureAudience`), so by default it is SUPPRESSED at the sweep's
|
|
176
|
+
* entry gate and never delivered. The sweep consumes this raw flag again, on
|
|
177
|
+
* its own, only for the residual: a review record that IS delivered (the
|
|
178
|
+
* audience gate turned off, or a legacy/`user` route) must carry the
|
|
179
|
+
* self-improvement TITLE so it can never appear as raw, unlabelled agent
|
|
180
|
+
* reasoning — the leak this closes.
|
|
181
|
+
*
|
|
182
|
+
* OPTIONAL: absent on every pre-change record and on non-review turns, which
|
|
183
|
+
* deliver unframed. Only an exact boolean `true` changes anything — see
|
|
184
|
+
* `shouldFrameSelfImprovement`.
|
|
185
|
+
*/
|
|
186
|
+
reviewOriginated?: boolean
|
|
165
187
|
}
|
|
166
188
|
|
|
167
189
|
/** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
|
|
@@ -204,6 +226,16 @@ export interface DeliveredEntry {
|
|
|
204
226
|
* framed delivery is a delivery.
|
|
205
227
|
*/
|
|
206
228
|
framedProvenance?: 'reply-throw'
|
|
229
|
+
/**
|
|
230
|
+
* Ken 2026-08-07: this delivery carried the self-improvement TITLE header
|
|
231
|
+
* (`SELF_IMPROVEMENT_TITLE`) in front of a review-originated record's prose.
|
|
232
|
+
* Durable and terminal, for the same reason `framedProvenance` is: framing
|
|
233
|
+
* changes what a human sees, so the outcome is recorded as an explicit named
|
|
234
|
+
* state on the journal line rather than being inferable only from a log line
|
|
235
|
+
* that may have rotated away. `tgMessageId` IS present on these lines — a
|
|
236
|
+
* framed delivery is a delivery.
|
|
237
|
+
*/
|
|
238
|
+
framedSelfImprovement?: 'self-improve'
|
|
207
239
|
}
|
|
208
240
|
|
|
209
241
|
export function sha256Hex(s: string): string {
|
|
@@ -519,6 +551,13 @@ export interface OutboxSweepDecision {
|
|
|
519
551
|
* the rule.
|
|
520
552
|
*/
|
|
521
553
|
framedProvenance?: 'reply-throw'
|
|
554
|
+
/**
|
|
555
|
+
* Ken 2026-08-07: `text` carries the self-improvement title header. Surfaced
|
|
556
|
+
* on the decision (rather than left implicit in a string compare) so the
|
|
557
|
+
* caller can journal the outcome and emit telemetry without re-deriving the
|
|
558
|
+
* rule — same shape as `framedProvenance`.
|
|
559
|
+
*/
|
|
560
|
+
framedSelfImprovement?: 'self-improve'
|
|
522
561
|
}
|
|
523
562
|
|
|
524
563
|
/**
|
|
@@ -538,7 +577,7 @@ export interface OutboxSweepDecision {
|
|
|
538
577
|
export function decideOutboxSweep(input: {
|
|
539
578
|
record: Pick<
|
|
540
579
|
OutboxRecord,
|
|
541
|
-
'turnNonce' | 'text' | 'createdAt' | 'replyToolThrewThisTurn'
|
|
580
|
+
'turnNonce' | 'text' | 'createdAt' | 'replyToolThrewThisTurn' | 'reviewOriginated'
|
|
542
581
|
>
|
|
543
582
|
now: number
|
|
544
583
|
deliveredNonces: Set<string>
|
|
@@ -566,6 +605,13 @@ export function decideOutboxSweep(input: {
|
|
|
566
605
|
* revert-check can flip it in-process.
|
|
567
606
|
*/
|
|
568
607
|
provenanceFraming?: boolean
|
|
608
|
+
/**
|
|
609
|
+
* Ken 2026-08-07 kill switch for the self-improvement TITLE header. `false`
|
|
610
|
+
* restores the pre-change body byte-for-byte; the default is ON. Passed in
|
|
611
|
+
* rather than read from env here so this core stays pure and a revert-check
|
|
612
|
+
* can flip it in-process.
|
|
613
|
+
*/
|
|
614
|
+
selfImprovementFraming?: boolean
|
|
569
615
|
}): OutboxSweepDecision {
|
|
570
616
|
const {
|
|
571
617
|
record,
|
|
@@ -578,6 +624,7 @@ export function decideOutboxSweep(input: {
|
|
|
578
624
|
maxAgeMs = OUTBOX_MAX_AGE_MS,
|
|
579
625
|
shownLedgerHit = false,
|
|
580
626
|
provenanceFraming = true,
|
|
627
|
+
selfImprovementFraming = true,
|
|
581
628
|
} = input
|
|
582
629
|
if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
|
|
583
630
|
if (shownLedgerHit) return { action: 'skip-ephemeral-shown' }
|
|
@@ -592,11 +639,30 @@ export function decideOutboxSweep(input: {
|
|
|
592
639
|
// the banner describes the TEXT. It is additive only: it can never turn a
|
|
593
640
|
// send into a skip, so this branch cannot manufacture silence.
|
|
594
641
|
const framed = shouldFrameReplyThrow(record, { frameEnabled: provenanceFraming })
|
|
595
|
-
const
|
|
642
|
+
const provenanceBody = framed ? applyReplyThrowFraming(record.text) : record.text
|
|
643
|
+
// Ken 2026-08-07: the self-improvement title is the OUTERMOST content line, so
|
|
644
|
+
// it is applied AFTER the reply-throw banner — a review record that somehow
|
|
645
|
+
// also threw reads "🔧 Self-improvement" first, then the provenance note, then
|
|
646
|
+
// the prose. Additive only: it can never turn a send into a skip.
|
|
647
|
+
//
|
|
648
|
+
// #4489: idempotency is gated on the RAW `record.text`, NOT on `provenanceBody`.
|
|
649
|
+
// A real card's `record.text` opens with the title; the reply-throw banner
|
|
650
|
+
// above is PREPENDED in front of it, so `provenanceBody` no longer opens with
|
|
651
|
+
// the title even though the underlying card does. `applySelfImprovementFraming`
|
|
652
|
+
// alone can't see that — its idempotency check only looks at what it was
|
|
653
|
+
// handed — so gating the whole decision on the raw text is what keeps a real
|
|
654
|
+
// card from acquiring a second, duplicated title when both framings compose.
|
|
655
|
+
const selfImproveFramed =
|
|
656
|
+
shouldFrameSelfImprovement(record, { frameEnabled: selfImprovementFraming }) &&
|
|
657
|
+
!isSelfImprovementCard(record.text)
|
|
658
|
+
const body = selfImproveFramed ? applySelfImprovementFraming(provenanceBody) : provenanceBody
|
|
596
659
|
return {
|
|
597
660
|
action: delayed ? 'send-delayed' : 'send',
|
|
598
661
|
text: prefix + body,
|
|
599
|
-
...(framed &&
|
|
662
|
+
...(framed && provenanceBody !== record.text ? { framedProvenance: 'reply-throw' as const } : {}),
|
|
663
|
+
...(selfImproveFramed && body !== provenanceBody
|
|
664
|
+
? { framedSelfImprovement: 'self-improve' as const }
|
|
665
|
+
: {}),
|
|
600
666
|
}
|
|
601
667
|
}
|
|
602
668
|
|
|
@@ -82,6 +82,26 @@ export interface SilentEndState {
|
|
|
82
82
|
* throw", which delivers exactly as before. It never suppresses.
|
|
83
83
|
*/
|
|
84
84
|
replyToolThrewThisTurn?: boolean
|
|
85
|
+
/**
|
|
86
|
+
* #4490 — did the turn that produced `pendingText` originate from a
|
|
87
|
+
* self-improvement review inbound (`source="self_improve_review"`)?
|
|
88
|
+
*
|
|
89
|
+
* Stamped by the Stop hook's single-writer election
|
|
90
|
+
* (`silent-end-interrupt-stop.mjs` → `buildNextState`) from the SAME
|
|
91
|
+
* enqueue-envelope `source` tag `writeOutboxRecord` reads for the outbox
|
|
92
|
+
* capture path; the gateway's own `writeSilentEndState` never sets it.
|
|
93
|
+
* Consumed by `deliverCapturedProse` (`gateway/outbound-send-path.ts`),
|
|
94
|
+
* which is the ELECTED path's deliverer — it never writes an outbox record,
|
|
95
|
+
* so the sweep's audience gate / self-improvement framing can never reach
|
|
96
|
+
* this message, and this is the only way that machine can learn the
|
|
97
|
+
* provenance. Restores the #4141-style symmetry #4485 left one-sided (card
|
|
98
|
+
* gate + title framing applied at the sweep only).
|
|
99
|
+
*
|
|
100
|
+
* Optional and only ever `true`: absent means "no positive evidence of a
|
|
101
|
+
* review-origin turn", which delivers exactly as before. It never widens
|
|
102
|
+
* suppression on a normal user turn.
|
|
103
|
+
*/
|
|
104
|
+
reviewOriginated?: boolean
|
|
85
105
|
}
|
|
86
106
|
|
|
87
107
|
export interface SilentEndDeps {
|
|
@@ -346,6 +366,15 @@ export interface CapturedProseDecision {
|
|
|
346
366
|
* `true` only on positive evidence; never affects `deliver`.
|
|
347
367
|
*/
|
|
348
368
|
replyToolThrewThisTurn?: boolean
|
|
369
|
+
/**
|
|
370
|
+
* #4490 — the persisted record says this turn originated from a
|
|
371
|
+
* self-improvement review inbound. Surfaced (not acted on) so
|
|
372
|
+
* `deliverCapturedProse` can apply the SAME card-gate / title-framing rules
|
|
373
|
+
* the outbox sweep applies. `true` only on positive evidence; never affects
|
|
374
|
+
* `deliver` here — the audience decision is made downstream, exactly as
|
|
375
|
+
* `replyToolThrewThisTurn`'s banner decision is.
|
|
376
|
+
*/
|
|
377
|
+
reviewOriginated?: boolean
|
|
349
378
|
/** Machine-readable reason (for logs / tests). */
|
|
350
379
|
reason:
|
|
351
380
|
| 'captured-prose'
|
|
@@ -426,17 +455,31 @@ export function decideCapturedProseDelivery(
|
|
|
426
455
|
// AFTER every `deliver:false` gate above — the banner is a labelling
|
|
427
456
|
// concern, and must never become an input to whether we deliver at all.
|
|
428
457
|
...(state.replyToolThrewThisTurn === true ? { replyToolThrewThisTurn: true } : {}),
|
|
458
|
+
// #4490: same discipline for the review-origin signal — placed after every
|
|
459
|
+
// `deliver:false` gate so it can never become an input to whether the
|
|
460
|
+
// (structurally different) "should we deliver at all" decision above
|
|
461
|
+
// fires. The caller decides suppression/framing from this raw flag.
|
|
462
|
+
...(state.reviewOriginated === true ? { reviewOriginated: true } : {}),
|
|
429
463
|
}
|
|
430
464
|
}
|
|
431
465
|
|
|
432
466
|
/**
|
|
433
467
|
* Outcome of a captured-prose send attempt (Option A transcript-prose bridge).
|
|
434
|
-
* - `sent`
|
|
435
|
-
* - `skipped-dedup
|
|
436
|
-
*
|
|
437
|
-
*
|
|
468
|
+
* - `sent` — the answer was delivered fresh this call.
|
|
469
|
+
* - `skipped-dedup` — the exact answer already went out (dedup hit);
|
|
470
|
+
* nothing new was sent, but the answer IS with the
|
|
471
|
+
* user.
|
|
472
|
+
* - `failed` — the send threw; the answer did NOT reach the
|
|
473
|
+
* user.
|
|
474
|
+
* - `suppressed-internal`— #4490: the audience gate classified this prose
|
|
475
|
+
* as internal (a non-card self-improvement review
|
|
476
|
+
* turn) and it was never sent. Falls through the
|
|
477
|
+
* SAME close-obligation + clear-state bookkeeping
|
|
478
|
+
* as `sent` / `skipped-dedup` below — no operator
|
|
479
|
+
* is waiting on a message that was never meant to
|
|
480
|
+
* reach them, so there is nothing to recover.
|
|
438
481
|
*/
|
|
439
|
-
export type CapturedProseSendOutcome = 'sent' | 'skipped-dedup' | 'failed'
|
|
482
|
+
export type CapturedProseSendOutcome = 'sent' | 'skipped-dedup' | 'failed' | 'suppressed-internal'
|
|
440
483
|
|
|
441
484
|
/**
|
|
442
485
|
* The bookkeeping effects the gateway applies after a captured-prose send
|
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it,
|
|
2
|
-
|
|
3
|
-
// The dispatcher's resume closure reaches for the durable outbound-text oracle
|
|
4
|
-
// (history.hasOutboundWithText) to reconcile crash-idempotency. Mock it so these
|
|
5
|
-
// stay pure vitest unit tests (no bun:sqlite / real DB) and we can control the
|
|
6
|
-
// "did this chunk's text land durably?" answer per scenario.
|
|
7
|
-
let mockOracle: (chatId: string, text: string, threadId: number | null, since: number) => boolean = () => false
|
|
8
|
-
vi.mock('../history.js', () => ({
|
|
9
|
-
hasOutboundWithText: (chatId: string, text: string, threadId: number | null, since: number) =>
|
|
10
|
-
mockOracle(chatId, text, threadId, since),
|
|
11
|
-
}))
|
|
1
|
+
import { describe, expect, it, beforeEach } from 'vitest'
|
|
12
2
|
|
|
13
3
|
import {
|
|
14
4
|
BackstopDeliveryLedger,
|
|
@@ -36,7 +26,24 @@ import {
|
|
|
36
26
|
* (c) a fully-landed answer's represent sends NOTHING.
|
|
37
27
|
* A test that would still pass if the resume re-sent a confirmed chunk is not a
|
|
38
28
|
* test — every scenario asserts the exact `sendChunk` index set.
|
|
29
|
+
*
|
|
30
|
+
* The dispatcher's resume closure reaches for the durable outbound-text oracle
|
|
31
|
+
* (history.hasOutboundWithText) to reconcile crash-idempotency. `mockOracle`
|
|
32
|
+
* below is injected per-scenario via `createCapturedResumeDispatcher`'s
|
|
33
|
+
* `hasOutboundWithText` port (see `gateway/captured-answer-resume.ts`) — NOT
|
|
34
|
+
* via `vi.mock('../history.js', ...)`. That used to module-mock `history.js`,
|
|
35
|
+
* which looks file-scoped under vitest but under bun's vitest-compat layer
|
|
36
|
+
* `vi.mock` maps onto the process-global `mock.module`: `bun test` runs the
|
|
37
|
+
* whole `telegram-plugin/` surface in one process
|
|
38
|
+
* (`telegram-plugin/scripts/bun-test-ci.sh`), so the mock retroactively
|
|
39
|
+
* rebound `hasOutboundWithText` for every OTHER file in the same sweep —
|
|
40
|
+
* including `tests/history.test.ts`'s calls to the REAL implementation. That
|
|
41
|
+
* was the actual root cause of #4488/#4491: a row `history.test.ts` had
|
|
42
|
+
* genuinely just written to its own real bun:sqlite DB was reported "not
|
|
43
|
+
* found" because its `hasOutboundWithText` import had been silently rebound
|
|
44
|
+
* to this file's `() => false` default. See check-bun-module-mock-scope.mjs.
|
|
39
45
|
*/
|
|
46
|
+
let mockOracle: (chatId: string, text: string, threadId: number | null, since: number) => boolean = () => false
|
|
40
47
|
|
|
41
48
|
beforeEach(() => {
|
|
42
49
|
mockOracle = () => false
|
|
@@ -192,6 +199,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
192
199
|
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
193
200
|
|
|
194
201
|
const dispatcher = createCapturedResumeDispatcher({
|
|
202
|
+
hasOutboundWithText: mockOracle,
|
|
195
203
|
deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
|
|
196
204
|
obligationLedger: oblLedger,
|
|
197
205
|
backstopDeliveryLedger: resumeLedger,
|
|
@@ -228,6 +236,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
228
236
|
mockOracle = (_c, txt) => txt === 'c0'
|
|
229
237
|
|
|
230
238
|
const dispatcher = createCapturedResumeDispatcher({
|
|
239
|
+
hasOutboundWithText: mockOracle,
|
|
231
240
|
deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
|
|
232
241
|
obligationLedger: oblLedger,
|
|
233
242
|
backstopDeliveryLedger: resumeLedger,
|
|
@@ -258,6 +267,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
258
267
|
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
259
268
|
|
|
260
269
|
const dispatcher = createCapturedResumeDispatcher({
|
|
270
|
+
hasOutboundWithText: mockOracle,
|
|
261
271
|
deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
|
|
262
272
|
obligationLedger: oblLedger,
|
|
263
273
|
backstopDeliveryLedger: resumeLedger,
|
|
@@ -302,6 +312,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
302
312
|
|
|
303
313
|
const lines: string[] = []
|
|
304
314
|
const dispatcher = createCapturedResumeDispatcher({
|
|
315
|
+
hasOutboundWithText: mockOracle,
|
|
305
316
|
deliverAnswer: async (a) => {
|
|
306
317
|
a.resume.hydrate(resumeLedger, a.turnId)
|
|
307
318
|
const res = await runBackstopDelivery(
|
|
@@ -351,6 +362,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
351
362
|
|
|
352
363
|
let probes = 0
|
|
353
364
|
const dispatcher = createCapturedResumeDispatcher({
|
|
365
|
+
hasOutboundWithText: mockOracle,
|
|
354
366
|
deliverAnswer: async (a) => {
|
|
355
367
|
a.resume.hydrate(resumeLedger, a.turnId)
|
|
356
368
|
const res = await runBackstopDelivery(
|
|
@@ -388,6 +400,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
388
400
|
|
|
389
401
|
// A resume whose tail send keeps throwing ⇒ not delivered ⇒ obligation stays OPEN.
|
|
390
402
|
const dispatcher = createCapturedResumeDispatcher({
|
|
403
|
+
hasOutboundWithText: mockOracle,
|
|
391
404
|
deliverAnswer: async (a) => {
|
|
392
405
|
a.resume.hydrate(resumeLedger, a.turnId)
|
|
393
406
|
const res = await runBackstopDelivery(
|
|
@@ -423,6 +436,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
423
436
|
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
424
437
|
|
|
425
438
|
const dispatcher = createCapturedResumeDispatcher({
|
|
439
|
+
hasOutboundWithText: mockOracle,
|
|
426
440
|
deliverAnswer: async (a) => {
|
|
427
441
|
deliverCalls++
|
|
428
442
|
a.resume.hydrate(resumeLedger, a.turnId)
|
|
@@ -448,6 +462,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
448
462
|
it('no captured snapshot ⇒ dispatch is a no-op (caller falls through to fresh generation)', async () => {
|
|
449
463
|
let delivered = false
|
|
450
464
|
const dispatcher = createCapturedResumeDispatcher({
|
|
465
|
+
hasOutboundWithText: mockOracle,
|
|
451
466
|
deliverAnswer: async () => { delivered = true; return { delivered: true, sentIds: [] } },
|
|
452
467
|
obligationLedger: new ObligationLedger(2),
|
|
453
468
|
backstopDeliveryLedger: new BackstopDeliveryLedger(),
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
HINDSIGHT_BANK_GUARD_MARKER,
|
|
5
|
+
hindsightBankGuardTrips,
|
|
6
|
+
resetHindsightBankGuardTrips,
|
|
7
|
+
} from '../../tests/vitest-setup/hindsight-bank-guard-core.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runtime alarm for the BUN half of the Hindsight bank hermeticity guard.
|
|
11
|
+
*
|
|
12
|
+
* vitest loads `tests/vitest-setup/hindsight-bank-guard.mjs` via
|
|
13
|
+
* `test.setupFiles`; `bun test` loads the same file via `[test] preload` in
|
|
14
|
+
* bunfig.toml (repo root) and telegram-plugin/bunfig.toml (CI's bun-test-run
|
|
15
|
+
* has `working-directory: telegram-plugin`, and bun reads the bunfig in its CWD
|
|
16
|
+
* only). Without the bun half, every bun-run test file can still reach the
|
|
17
|
+
* FLEET's Hindsight — which auto-creates a bank on miss, so one stray request
|
|
18
|
+
* mints a bank in the live instance. That is how eleven throwaway banks
|
|
19
|
+
* appeared there on 2026-07-30, one of them named `clerk`, colliding with a
|
|
20
|
+
* live agent and erasing the annotation that documented where that agent's
|
|
21
|
+
* memory actually lives.
|
|
22
|
+
*
|
|
23
|
+
* `npm run lint:hindsight-bank-hermeticity` pins the WIRING statically; this
|
|
24
|
+
* pins the EFFECT, so a bunfig that is present but no longer loading the guard
|
|
25
|
+
* (wrong relative path, bun config-discovery change) fails a test rather than
|
|
26
|
+
* silently un-protecting the runner.
|
|
27
|
+
*
|
|
28
|
+
* Imports the CORE, never the setup file — importing the setup file would
|
|
29
|
+
* INSTALL the guard and let this alarm heal itself. The replay target is
|
|
30
|
+
* 192.0.2.1 (TEST-NET-1, RFC 5737): unroutable, so an unwired run fails on the
|
|
31
|
+
* assertion rather than doing the thing the guard exists to prevent.
|
|
32
|
+
*/
|
|
33
|
+
describe('bun test runs with the fleet Hindsight blocked', () => {
|
|
34
|
+
it('rejects a bank request to a fleet Hindsight origin', async () => {
|
|
35
|
+
resetHindsightBankGuardTrips()
|
|
36
|
+
let err: unknown
|
|
37
|
+
try {
|
|
38
|
+
await fetch('http://192.0.2.1:18888/v1/default/banks/clerk/config', {
|
|
39
|
+
signal: AbortSignal.timeout(250),
|
|
40
|
+
})
|
|
41
|
+
} catch (e) {
|
|
42
|
+
err = e
|
|
43
|
+
}
|
|
44
|
+
expect(
|
|
45
|
+
String((err as Error | undefined)?.message ?? ''),
|
|
46
|
+
'bunfig.toml `[test] preload` did not install the Hindsight bank guard',
|
|
47
|
+
).toContain(HINDSIGHT_BANK_GUARD_MARKER)
|
|
48
|
+
expect(hindsightBankGuardTrips()).toBe(1)
|
|
49
|
+
})
|
|
50
|
+
})
|