switchroom 0.19.14 → 0.19.15
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/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +1002 -504
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/gateway/forward-origin.ts +6 -1
- package/telegram-plugin/gateway/gateway.ts +4 -0
- package/telegram-plugin/gateway/narrative-lane.ts +11 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +32 -2
- package/telegram-plugin/gateway/rich-message-handler.ts +235 -0
- package/telegram-plugin/gateway/stream-render.ts +107 -15
- package/telegram-plugin/gateway/unhandled-message.ts +14 -0
- package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
- package/telegram-plugin/hooks/narration-classify.mjs +210 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +73 -3
- package/telegram-plugin/shown-ledger.ts +145 -0
- package/telegram-plugin/silent-end.ts +42 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
- package/telegram-plugin/tests/forward-origin.test.ts +20 -0
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
- package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
- package/telegram-plugin/tests/silent-end.test.ts +7 -1
- package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
- package/telegram-plugin/turn-flush-safety.ts +66 -53
|
@@ -122,11 +122,13 @@ export interface DeliveredEntry {
|
|
|
122
122
|
tgMessageId?: number
|
|
123
123
|
ts: number
|
|
124
124
|
/**
|
|
125
|
-
* #3510 instrumentation: which machine delivered — the outbox sweep,
|
|
125
|
+
* #3510 instrumentation: which machine delivered — the outbox sweep, the
|
|
126
126
|
* gateway reply-path machinery (reply/stream_reply send, silent-anchor edit,
|
|
127
|
-
* captured-prose bridge)
|
|
127
|
+
* captured-prose bridge), or the turn-end / answer-ready quiescence flush
|
|
128
|
+
* (`'flush'`, added in the #3513 exactly-once-among-backstops follow-up).
|
|
129
|
+
* Absent on pre-#3510 journal lines.
|
|
128
130
|
*/
|
|
129
|
-
deliverySource?: 'sweep' | 'reply-tool'
|
|
131
|
+
deliverySource?: 'sweep' | 'reply-tool' | 'flush'
|
|
130
132
|
/** #3510 instrumentation: see `OutboxRecord.replyAlreadyDeliveredThisTurn`. */
|
|
131
133
|
replyAlreadyDeliveredThisTurn?: boolean
|
|
132
134
|
}
|
|
@@ -307,6 +309,59 @@ export function outboxAlreadyDelivered(nonce: string, stateDir?: string): boolea
|
|
|
307
309
|
return readDeliveredNonces(stateDir).has(nonce)
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
/**
|
|
313
|
+
* True iff a journal entry is a prior BACKSTOP delivery (turn-flush E1/E2,
|
|
314
|
+
* captured-prose bridge E3, outbox sweep E4) — NOT an explicit E0 `reply` /
|
|
315
|
+
* `stream_reply` send (#3513 follow-up, MF2).
|
|
316
|
+
*
|
|
317
|
+
* - `deliverySource === 'sweep'` → E4 backstop.
|
|
318
|
+
* - `deliverySource === 'flush'` → E1/E2 backstop.
|
|
319
|
+
* - `deliverySource === 'reply-tool'` AND `replyAlreadyDeliveredThisTurn ===
|
|
320
|
+
* false` → E3 captured-prose bridge (a backstop; the bridge only fires when
|
|
321
|
+
* NO genuine final answer was delivered this turn).
|
|
322
|
+
* - `deliverySource === 'reply-tool'` AND `replyAlreadyDeliveredThisTurn ===
|
|
323
|
+
* true` → an explicit E0 reply send — NOT a backstop. E0 replies are
|
|
324
|
+
* ungated by design (a turn may send N of them), so they must NOT satisfy a
|
|
325
|
+
* backstop's exactly-once guard (else the guard would eat a legitimate
|
|
326
|
+
* #3510 trailing recap or a multi-reply turn's later bridge).
|
|
327
|
+
*
|
|
328
|
+
* A pre-#3510 line with no `deliverySource` is treated conservatively as NOT a
|
|
329
|
+
* backstop (fail open — never suppress a backstop on ambiguous provenance).
|
|
330
|
+
*/
|
|
331
|
+
export function isBackstopDeliveredEntry(e: DeliveredEntry): boolean {
|
|
332
|
+
if (e.deliverySource === 'sweep' || e.deliverySource === 'flush') return true
|
|
333
|
+
if (e.deliverySource === 'reply-tool' && e.replyAlreadyDeliveredThisTurn === false) return true
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* True iff `nonce` already has a prior BACKSTOP delivery journaled (see
|
|
339
|
+
* `isBackstopDeliveredEntry`). This is the BACKSTOP-SCOPED exactly-once read the
|
|
340
|
+
* turn-flush (E1/E2) and captured-prose bridge (E3) consult before delivering —
|
|
341
|
+
* unlike `outboxAlreadyDelivered` (any journal line) it does NOT count an
|
|
342
|
+
* explicit E0 reply, so it can never suppress a legitimate second explicit
|
|
343
|
+
* message (#3513 follow-up, MF2).
|
|
344
|
+
*/
|
|
345
|
+
export function backstopAlreadyDelivered(nonce: string, stateDir?: string): boolean {
|
|
346
|
+
if (nonce == null || nonce === '') return false
|
|
347
|
+
const path = join(resolveOutboxDir(stateDir), JOURNAL_FILE)
|
|
348
|
+
if (!existsSync(path)) return false
|
|
349
|
+
try {
|
|
350
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
351
|
+
if (!line) continue
|
|
352
|
+
try {
|
|
353
|
+
const e = JSON.parse(line) as DeliveredEntry
|
|
354
|
+
if (e.turnNonce === nonce && isBackstopDeliveredEntry(e)) return true
|
|
355
|
+
} catch {
|
|
356
|
+
/* skip corrupt line */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
/* best-effort */
|
|
361
|
+
}
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
364
|
+
|
|
310
365
|
/**
|
|
311
366
|
* Max delivered-keys kept in the journal. On exceeding `JOURNAL_ROTATE_AT` lines
|
|
312
367
|
* the journal is compacted down to the newest `JOURNAL_KEEP` entries — bounding
|
|
@@ -378,6 +433,7 @@ export type OutboxSweepAction =
|
|
|
378
433
|
| 'skip-quiet'
|
|
379
434
|
| 'skip-dedup'
|
|
380
435
|
| 'skip-unroutable'
|
|
436
|
+
| 'skip-ephemeral-shown'
|
|
381
437
|
|
|
382
438
|
export interface OutboxSweepDecision {
|
|
383
439
|
action: OutboxSweepAction
|
|
@@ -408,6 +464,18 @@ export function decideOutboxSweep(input: {
|
|
|
408
464
|
routePrefix?: string
|
|
409
465
|
quietMs?: number
|
|
410
466
|
maxAgeMs?: number
|
|
467
|
+
/**
|
|
468
|
+
* #3513 (correction 1): was this record's text marked ephemeral-shown on the
|
|
469
|
+
* progress card for its turnNonce (the durable shown-ledger)? A hit means the
|
|
470
|
+
* block was already assigned to the ephemeral surface — the single-surface
|
|
471
|
+
* invariant forbids ANY delivery machine, including this out-of-process late
|
|
472
|
+
* sweep, from ALSO delivering it to chat. The ledger only ever contains
|
|
473
|
+
* STRUCTURAL narration (correction 4: never a possibly-terminal answer), so
|
|
474
|
+
* this can never suppress a genuine answer. Checked here (in the backstop
|
|
475
|
+
* decision itself) rather than only at a send seam, because the sweep bypasses
|
|
476
|
+
* `normalizeOutboundBody` (it sends via `bot.api.sendMessage` directly).
|
|
477
|
+
*/
|
|
478
|
+
shownLedgerHit?: boolean
|
|
411
479
|
}): OutboxSweepDecision {
|
|
412
480
|
const {
|
|
413
481
|
record,
|
|
@@ -418,8 +486,10 @@ export function decideOutboxSweep(input: {
|
|
|
418
486
|
routePrefix = '',
|
|
419
487
|
quietMs = OUTBOX_QUIET_MS,
|
|
420
488
|
maxAgeMs = OUTBOX_MAX_AGE_MS,
|
|
489
|
+
shownLedgerHit = false,
|
|
421
490
|
} = input
|
|
422
491
|
if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
|
|
492
|
+
if (shownLedgerHit) return { action: 'skip-ephemeral-shown' }
|
|
423
493
|
const age = now - record.createdAt
|
|
424
494
|
if (age < quietMs) return { action: 'skip-quiet' }
|
|
425
495
|
if (textAlreadyDelivered) return { action: 'skip-dedup' }
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shown-ledger.ts — the durable "this block was surfaced on the ephemeral
|
|
3
|
+
* progress card, never deliver it to chat" mark (switchroom#3513 §4).
|
|
4
|
+
*
|
|
5
|
+
* ## Role in the single-surface invariant
|
|
6
|
+
* Every trailing plain-text block a turn produces is assigned to exactly one
|
|
7
|
+
* surface: the delivered chat answer, the ephemeral progress card, or
|
|
8
|
+
* suppression. In-process (E1/E2 turn-flush, the card) that assignment is made
|
|
9
|
+
* once by the shared structural classifier (`hooks/narration-classify.mjs`).
|
|
10
|
+
* But the out-of-process backstops — the Stop-hook captured-prose bridge (E3)
|
|
11
|
+
* and the durable outbox heartbeat sweep (E4) — run in a separate process / on
|
|
12
|
+
* a later tick and cannot share an in-memory decision. So the component that
|
|
13
|
+
* paints a block as ephemeral narration appends `{turnNonce, hash}` here, and
|
|
14
|
+
* E3/E4 treat a ledger hit exactly like a silent marker: not deliverable.
|
|
15
|
+
*
|
|
16
|
+
* ## Correction 4 — only structural narration is ever marked
|
|
17
|
+
* The writer (the mid-turn narrative paint in `gateway/narrative-lane.ts`) marks
|
|
18
|
+
* ONLY blocks that are provably followed by more turn activity (a new narrative
|
|
19
|
+
* block or a tool call), i.e. structural narration — NEVER a possibly-terminal
|
|
20
|
+
* block (the timer-paint / turn-end paint that could turn out to be the real
|
|
21
|
+
* answer). This keeps the invariant fail-open for answers: a genuine unsent
|
|
22
|
+
* answer is never in the ledger, so a ledger check can never suppress it (a drop
|
|
23
|
+
* is worse than a duplicate). The structural rule, not the ledger, is the sole
|
|
24
|
+
* guard for the answer path.
|
|
25
|
+
*
|
|
26
|
+
* ## Envelope-bearing-only (documented should-fix, #3513 §8)
|
|
27
|
+
* The ledger is keyed by the turnNonce (`deriveTurnId` → `${chatKey}#${msgId}`),
|
|
28
|
+
* which is reachable-matching ONLY for envelope-bearing turns — `deriveTurnId`
|
|
29
|
+
* returns null without a message_id (`gateway/derive-turn-id.ts`), and the
|
|
30
|
+
* outbox falls back to a sha nonce. So the ledger is a best-effort belt for
|
|
31
|
+
* envelope-bearing turns (the common Telegram-inbound shape); for envelope-less
|
|
32
|
+
* turns (handback / background / cron) the structural classifier is the sole
|
|
33
|
+
* guard. The writer skips a null turnId; the readers simply miss and fall
|
|
34
|
+
* through to the structural rule — never a false suppression.
|
|
35
|
+
*
|
|
36
|
+
* Best-effort throughout: a missing / unwritable ledger degrades to the
|
|
37
|
+
* structural-rule-only behaviour (leak possible, drop impossible).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
existsSync,
|
|
42
|
+
mkdirSync,
|
|
43
|
+
readFileSync,
|
|
44
|
+
renameSync,
|
|
45
|
+
writeFileSync,
|
|
46
|
+
appendFileSync,
|
|
47
|
+
} from 'node:fs'
|
|
48
|
+
import { join } from 'node:path'
|
|
49
|
+
import { resolveOutboxDir } from './outbox.js'
|
|
50
|
+
import { ledgerHashHex } from './hooks/narration-classify.mjs'
|
|
51
|
+
|
|
52
|
+
const SHOWN_LEDGER_FILE = 'shown-ledger.jsonl'
|
|
53
|
+
|
|
54
|
+
/** Bounded growth — compact to the newest KEEP entries past ROTATE_AT lines. */
|
|
55
|
+
export const SHOWN_LEDGER_KEEP = 2_000
|
|
56
|
+
export const SHOWN_LEDGER_ROTATE_AT = 4_000
|
|
57
|
+
|
|
58
|
+
/** One appended shown-block entry. */
|
|
59
|
+
export interface ShownLedgerEntry {
|
|
60
|
+
turnNonce: string
|
|
61
|
+
hash: string
|
|
62
|
+
ts: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function shownLedgerPath(stateDir?: string): string {
|
|
66
|
+
return join(resolveOutboxDir(stateDir), SHOWN_LEDGER_FILE)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mark a block as ephemeral-shown for `turnNonce`. Correction 4: callers MUST
|
|
71
|
+
* only pass blocks that are structural narration (followed by more turn
|
|
72
|
+
* activity), never a possibly-terminal block. A null/empty turnNonce is ignored
|
|
73
|
+
* (envelope-less turn — the structural rule is the sole guard there).
|
|
74
|
+
* Best-effort; never throws.
|
|
75
|
+
*/
|
|
76
|
+
export function appendShownBlock(
|
|
77
|
+
turnNonce: string | null,
|
|
78
|
+
text: string,
|
|
79
|
+
stateDir?: string,
|
|
80
|
+
now: number = Date.now(),
|
|
81
|
+
): void {
|
|
82
|
+
if (turnNonce == null || turnNonce === '') return
|
|
83
|
+
const trimmed = typeof text === 'string' ? text.trim() : ''
|
|
84
|
+
if (trimmed.length === 0) return
|
|
85
|
+
const dir = resolveOutboxDir(stateDir)
|
|
86
|
+
const path = shownLedgerPath(stateDir)
|
|
87
|
+
try {
|
|
88
|
+
mkdirSync(dir, { recursive: true })
|
|
89
|
+
const entry: ShownLedgerEntry = { turnNonce, hash: ledgerHashHex(trimmed), ts: now }
|
|
90
|
+
appendFileSync(path, JSON.stringify(entry) + '\n', { mode: 0o600 })
|
|
91
|
+
compactIfLarge(path)
|
|
92
|
+
} catch {
|
|
93
|
+
/* best-effort */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The set of block hashes marked ephemeral-shown for `turnNonce`. */
|
|
98
|
+
export function readShownHashes(turnNonce: string | null, stateDir?: string): Set<string> {
|
|
99
|
+
const set = new Set<string>()
|
|
100
|
+
if (turnNonce == null || turnNonce === '') return set
|
|
101
|
+
const path = shownLedgerPath(stateDir)
|
|
102
|
+
if (!existsSync(path)) return set
|
|
103
|
+
try {
|
|
104
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
105
|
+
if (!line) continue
|
|
106
|
+
try {
|
|
107
|
+
const e = JSON.parse(line) as ShownLedgerEntry
|
|
108
|
+
if (e.turnNonce === turnNonce && typeof e.hash === 'string') set.add(e.hash)
|
|
109
|
+
} catch {
|
|
110
|
+
/* skip corrupt line */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
/* best-effort */
|
|
115
|
+
}
|
|
116
|
+
return set
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Was `text` marked ephemeral-shown for `turnNonce`? The out-of-process
|
|
121
|
+
* suppression check consumed by the E3 captured-prose decision and the E4 sweep.
|
|
122
|
+
*/
|
|
123
|
+
export function isShownBlock(
|
|
124
|
+
turnNonce: string | null,
|
|
125
|
+
text: string,
|
|
126
|
+
stateDir?: string,
|
|
127
|
+
): boolean {
|
|
128
|
+
if (turnNonce == null || turnNonce === '') return false
|
|
129
|
+
const trimmed = typeof text === 'string' ? text.trim() : ''
|
|
130
|
+
if (trimmed.length === 0) return false
|
|
131
|
+
return readShownHashes(turnNonce, stateDir).has(ledgerHashHex(trimmed))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function compactIfLarge(path: string): void {
|
|
135
|
+
try {
|
|
136
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter((l) => l.length > 0)
|
|
137
|
+
if (lines.length <= SHOWN_LEDGER_ROTATE_AT) return
|
|
138
|
+
const kept = lines.slice(lines.length - SHOWN_LEDGER_KEEP)
|
|
139
|
+
const tmp = `${path}.${process.pid}.compact`
|
|
140
|
+
writeFileSync(tmp, kept.join('\n') + '\n', { mode: 0o600 })
|
|
141
|
+
renameSync(tmp, path)
|
|
142
|
+
} catch {
|
|
143
|
+
/* best-effort */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -86,6 +86,29 @@ export interface SilentEndDeps {
|
|
|
86
86
|
hasOutboundDeliveredSince?: (chatId: string, sinceMs: number, threadId?: number | null) => boolean
|
|
87
87
|
/** Wall-clock now, ms. Defaults to `Date.now`; injectable for tests. */
|
|
88
88
|
now?: () => number
|
|
89
|
+
/**
|
|
90
|
+
* #3513 (correction 1): was `text` marked ephemeral-shown on the progress card
|
|
91
|
+
* for `turnNonce` (the durable shown-ledger)? Injected so the captured-prose
|
|
92
|
+
* bridge (E3) — which sends via `bot.api.sendMessage` directly, bypassing
|
|
93
|
+
* `normalizeOutboundBody` — refuses to deliver a block that was already
|
|
94
|
+
* assigned to the ephemeral surface. The ledger only ever holds STRUCTURAL
|
|
95
|
+
* narration (correction 4: never a possibly-terminal answer), so this can
|
|
96
|
+
* never suppress a genuine answer. Omitted → the check is skipped (the
|
|
97
|
+
* structural classifier remains the guard).
|
|
98
|
+
*/
|
|
99
|
+
isBlockShown?: (turnNonce: string | null | undefined, text: string) => boolean
|
|
100
|
+
/**
|
|
101
|
+
* #3513 follow-up (MF2): has a prior BACKSTOP already delivered this turn's
|
|
102
|
+
* answer, per the durable delivered-keys journal? Injected so the captured-
|
|
103
|
+
* prose bridge (E3) enforces exactly-once-among-backstops DURABLY — if the
|
|
104
|
+
* turn-flush backstop (E1/E2, `deliverySource:'flush'`) or the outbox sweep
|
|
105
|
+
* (E4, `'sweep'`) already delivered this nonce, the bridge must NOT deliver a
|
|
106
|
+
* second copy, even across a process boundary the in-memory ledger can't see.
|
|
107
|
+
* This counts ONLY backstop deliveries (via `backstopAlreadyDelivered`), never
|
|
108
|
+
* an explicit E0 reply (#3510 recap), so a legitimate later explicit reply is
|
|
109
|
+
* unaffected. Omitted → the check is skipped (never suppress on doubt).
|
|
110
|
+
*/
|
|
111
|
+
backstopDeliveredNonceHit?: (turnNonce: string | null | undefined) => boolean
|
|
89
112
|
}
|
|
90
113
|
|
|
91
114
|
/**
|
|
@@ -306,6 +329,8 @@ export interface CapturedProseDecision {
|
|
|
306
329
|
| 'turnkey-mismatch'
|
|
307
330
|
| 'turnid-mismatch'
|
|
308
331
|
| 'no-substantive-prose'
|
|
332
|
+
| 'ephemeral-shown'
|
|
333
|
+
| 'already-delivered'
|
|
309
334
|
}
|
|
310
335
|
|
|
311
336
|
/**
|
|
@@ -352,6 +377,23 @@ export function decideCapturedProseDelivery(
|
|
|
352
377
|
}
|
|
353
378
|
const text = typeof state.pendingText === 'string' ? state.pendingText : ''
|
|
354
379
|
if (text.trim().length < minChars) return { deliver: false, reason: 'no-substantive-prose' }
|
|
380
|
+
// #3513: the persisted prose was already surfaced on the ephemeral progress
|
|
381
|
+
// card for this turn (durable shown-ledger) — the single-surface invariant
|
|
382
|
+
// forbids the bridge from ALSO delivering it to chat. The ledger only holds
|
|
383
|
+
// structural narration (never a possibly-terminal answer), so this can never
|
|
384
|
+
// suppress a genuine answer.
|
|
385
|
+
if (deps?.isBlockShown?.(state.turnId, text) === true) {
|
|
386
|
+
return { deliver: false, reason: 'ephemeral-shown' }
|
|
387
|
+
}
|
|
388
|
+
// #3513 follow-up (MF2): exactly-once-among-backstops. If a prior backstop
|
|
389
|
+
// (turn-flush E1/E2 or the outbox sweep E4) already delivered THIS turn's
|
|
390
|
+
// answer per the durable journal, the bridge must not deliver a duplicate —
|
|
391
|
+
// even across the process boundary the in-memory dedup can't see. Counts only
|
|
392
|
+
// backstop deliveries, never an explicit E0 reply, so a genuine later explicit
|
|
393
|
+
// reply is never blocked here.
|
|
394
|
+
if (deps?.backstopDeliveredNonceHit?.(state.turnId) === true) {
|
|
395
|
+
return { deliver: false, reason: 'already-delivered' }
|
|
396
|
+
}
|
|
355
397
|
return { deliver: true, text, reason: 'captured-prose' }
|
|
356
398
|
}
|
|
357
399
|
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backstop-exactly-once.test.ts — outcome-asserting regression suite for the
|
|
3
|
+
* switchroom#3513 FOLLOW-UP: the deterministic, model-discipline-free
|
|
4
|
+
* "exactly-once-among-backstops" outbound contract.
|
|
5
|
+
*
|
|
6
|
+
* The invariant under test:
|
|
7
|
+
* - Any text block followed by a NON-ephemeral (turn-continuing) tool_use in
|
|
8
|
+
* the SAME turn — including the cross-message shape ([text-only message] →
|
|
9
|
+
* [tool_use in the NEXT message]) — is suppressed from the backstop delivery
|
|
10
|
+
* UNCONDITIONALLY (no length / wording gate). This replaces #3515's
|
|
11
|
+
* substance/wording heuristic on the backstop path.
|
|
12
|
+
* - The delivered block is the TERMINAL RUN (the suffix of non-tool-followed
|
|
13
|
+
* blocks), joined; a multi-paragraph terminal answer stays whole.
|
|
14
|
+
* - The empty-terminal corner (last block was itself tool-followed) delivers
|
|
15
|
+
* that last block IFF it clears the substantive floor, else nothing.
|
|
16
|
+
* - An explicit reply-tool (E0) send is journaled with
|
|
17
|
+
* `replyAlreadyDeliveredThisTurn=true` and is NOT counted by
|
|
18
|
+
* `backstopAlreadyDelivered`, so a legitimate later explicit reply is never
|
|
19
|
+
* blocked by the backstop exactly-once guard.
|
|
20
|
+
* - An answer followed ONLY by an ephemeral surface tool (react / pin / typing
|
|
21
|
+
* / edit / delete) still delivers — those tools are not turn-continuing.
|
|
22
|
+
*
|
|
23
|
+
* Every test asserts an OUTCOME and is written to be RED on the pre-fix logic
|
|
24
|
+
* (per-message provenance + substance-gated wording strip + no backstop-scoped
|
|
25
|
+
* durable read).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest'
|
|
29
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
30
|
+
import { tmpdir } from 'node:os'
|
|
31
|
+
import { join } from 'node:path'
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
selectBackstopDelivery,
|
|
35
|
+
isEphemeralTool,
|
|
36
|
+
EPHEMERAL_TOOLS,
|
|
37
|
+
SUBSTANTIVE_MIN_CHARS,
|
|
38
|
+
} from '../hooks/narration-classify.mjs'
|
|
39
|
+
import { scanTurnForFinalReply, scanForOutboxCapture } from '../hooks/silent-end-scan.mjs'
|
|
40
|
+
import {
|
|
41
|
+
appendDelivered,
|
|
42
|
+
backstopAlreadyDelivered,
|
|
43
|
+
isBackstopDeliveredEntry,
|
|
44
|
+
outboxAlreadyDelivered,
|
|
45
|
+
} from '../outbox.js'
|
|
46
|
+
import { decideCapturedProseDelivery, writeSilentEndState } from '../silent-end.js'
|
|
47
|
+
|
|
48
|
+
// ── Fixture builders (parity with silent-end-interrupt-stop-scan.test.ts) ────
|
|
49
|
+
|
|
50
|
+
const ENQUEUE = JSON.stringify({
|
|
51
|
+
type: 'queue-operation',
|
|
52
|
+
operation: 'enqueue',
|
|
53
|
+
content: '<channel source="switchroom-telegram" chat_id="111" message_id="42">hi</channel>',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
function assistantText(text: string) {
|
|
57
|
+
return JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } })
|
|
58
|
+
}
|
|
59
|
+
function assistantToolUse(name: string, input: Record<string, unknown> = {}) {
|
|
60
|
+
return JSON.stringify({
|
|
61
|
+
type: 'assistant',
|
|
62
|
+
message: { content: [{ type: 'tool_use', id: `t-${name}`, name, input }] },
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
function jsonl(...lines: string[]) {
|
|
66
|
+
return lines.join('\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const BIG = (label: string) =>
|
|
70
|
+
`${label}: ` + 'X'.repeat(SUBSTANTIVE_MIN_CHARS + 20)
|
|
71
|
+
|
|
72
|
+
// ── selectBackstopDelivery — the shared coalescer ────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe('selectBackstopDelivery — deterministic backstop coalescer (#3513 follow-up)', () => {
|
|
75
|
+
it('excludes a tool-followed block UNCONDITIONALLY, even a substantive one (no substance carve-out)', () => {
|
|
76
|
+
// Pre-fix: a ≥floor block followed by a tool was KEPT (the #3237 substance
|
|
77
|
+
// gate). New contract: suppressed unconditionally; only the terminal block
|
|
78
|
+
// survives. RED on the old substance-gated logic.
|
|
79
|
+
const answer = BIG('the real answer')
|
|
80
|
+
const closer = 'Saved that to memory.'
|
|
81
|
+
const out = selectBackstopDelivery([
|
|
82
|
+
{ text: answer, followedByToolUse: true },
|
|
83
|
+
{ text: closer, followedByToolUse: false },
|
|
84
|
+
])
|
|
85
|
+
expect(out).not.toBeNull()
|
|
86
|
+
expect(out!.text).toBe(closer)
|
|
87
|
+
expect(out!.text).not.toContain('the real answer')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('joins the maximal terminal suffix run of non-tool-followed blocks (multi-paragraph answer stays whole)', () => {
|
|
91
|
+
const p1 = 'First paragraph of the genuine answer.'
|
|
92
|
+
const p2 = 'Second paragraph, still part of the same answer.'
|
|
93
|
+
const out = selectBackstopDelivery([
|
|
94
|
+
{ text: 'Let me look…', followedByToolUse: true }, // suppressed
|
|
95
|
+
{ text: p1, followedByToolUse: false },
|
|
96
|
+
{ text: p2, followedByToolUse: false },
|
|
97
|
+
])
|
|
98
|
+
expect(out!.text).toBe(`${p1}\n\n${p2}`)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('empty-terminal corner: last block was tool-followed → delivered IFF ≥ substantive floor', () => {
|
|
102
|
+
const big = BIG('trailing but substantive')
|
|
103
|
+
const overFloor = selectBackstopDelivery([{ text: big, followedByToolUse: true }])
|
|
104
|
+
expect(overFloor!.text).toBe(big)
|
|
105
|
+
const short = selectBackstopDelivery([{ text: 'ok…', followedByToolUse: true }])
|
|
106
|
+
expect(short).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('undefined provenance fails OPEN — the block is delivered, never dropped', () => {
|
|
110
|
+
const out = selectBackstopDelivery([{ text: 'Let me check.' }, { text: 'the answer' }])
|
|
111
|
+
// No structural signal → both are the terminal run → joined (no wording strip).
|
|
112
|
+
expect(out!.text).toBe('Let me check.\n\nthe answer')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('all-empty / no-blocks → null', () => {
|
|
116
|
+
expect(selectBackstopDelivery([])).toBeNull()
|
|
117
|
+
expect(selectBackstopDelivery([{ text: ' ', followedByToolUse: false }])).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// ── isEphemeralTool — MF4 exact set ─────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe('isEphemeralTool — the exact ephemeral surface set (MF4)', () => {
|
|
124
|
+
it('recognises exactly {react, send_typing, pin_message, delete_message, edit_message}, MCP-prefixed or bare', () => {
|
|
125
|
+
expect([...EPHEMERAL_TOOLS].sort()).toEqual(
|
|
126
|
+
['delete_message', 'edit_message', 'pin_message', 'react', 'send_typing'].sort(),
|
|
127
|
+
)
|
|
128
|
+
for (const t of EPHEMERAL_TOOLS) {
|
|
129
|
+
expect(isEphemeralTool(t)).toBe(true)
|
|
130
|
+
expect(isEphemeralTool(`mcp__switchroom-telegram__${t}`)).toBe(true)
|
|
131
|
+
expect(isEphemeralTool(`mcp__clerk-telegram__${t}`)).toBe(true)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('progress_update is NOT ephemeral (MF4 — dropped from the set)', () => {
|
|
136
|
+
expect(isEphemeralTool('progress_update')).toBe(false)
|
|
137
|
+
expect(isEphemeralTool('mcp__switchroom-telegram__progress_update')).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('work / reply tools are NOT ephemeral (turn-continuing)', () => {
|
|
141
|
+
for (const t of ['Bash', 'Read', 'retain', 'download_attachment', 'reply', 'stream_reply']) {
|
|
142
|
+
expect(isEphemeralTool(t)).toBe(false)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ── Cross-message split-shape leak — the ROOT-CAUSE regression (MF1) ─────────
|
|
148
|
+
|
|
149
|
+
describe('cross-message split-shape provenance (MF1 — RED on per-message logic)', () => {
|
|
150
|
+
it('scanTurnForFinalReply: substantive narration in msg1, work-tool in msg2, real answer in msg3 → ONLY the answer delivers (narration suppressed)', () => {
|
|
151
|
+
// Pre-fix: `followedByToolUse` was computed per-message, so the narration
|
|
152
|
+
// block — the LAST content of its OWN message — never saw the tool that
|
|
153
|
+
// arrived in the SEPARATE next message. Its provenance read `false`, so the
|
|
154
|
+
// wording heuristic (not a structural tool signal) decided its fate; a
|
|
155
|
+
// substantive narration that didn't LOOK like narration leaked, joined with
|
|
156
|
+
// the answer. The per-turn two-pass marks it tool-followed → the terminal-run
|
|
157
|
+
// coalescer excludes it UNCONDITIONALLY, leaving only the genuine answer.
|
|
158
|
+
const narration = BIG('checking the gateway logs to see what happened before I answer')
|
|
159
|
+
const answer = BIG('here is the settled, terminal answer the user asked for')
|
|
160
|
+
const text = jsonl(
|
|
161
|
+
ENQUEUE,
|
|
162
|
+
assistantText(narration), // msg1 — text only
|
|
163
|
+
assistantToolUse('Bash', { command: 'tail -f log' }), // msg2 — turn-continuing tool
|
|
164
|
+
assistantText(answer), // msg3 — the real terminal answer
|
|
165
|
+
)
|
|
166
|
+
const r = scanTurnForFinalReply(text)
|
|
167
|
+
expect(r.decided).toBe('block')
|
|
168
|
+
expect(r.pendingText).toBe(answer)
|
|
169
|
+
expect(r.pendingText).not.toContain('checking the gateway logs')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('scanForOutboxCapture: same cross-message shape → captures ONLY the answer, never the narration', () => {
|
|
173
|
+
const narration = BIG('cross-referencing the ledger before summarising the result')
|
|
174
|
+
const answer = BIG('the durable, delivered-once answer for the outbox sweep')
|
|
175
|
+
const text = jsonl(
|
|
176
|
+
ENQUEUE,
|
|
177
|
+
assistantText(narration), // msg1
|
|
178
|
+
assistantToolUse('Read', { file_path: '/tmp/x' }), // msg2 — turn-continuing tool
|
|
179
|
+
assistantText(answer), // msg3 — terminal answer
|
|
180
|
+
)
|
|
181
|
+
const cap = scanForOutboxCapture(text)
|
|
182
|
+
expect(cap.capture).toBe(true)
|
|
183
|
+
if (cap.capture) {
|
|
184
|
+
expect(cap.text).toBe(answer.trim())
|
|
185
|
+
expect(cap.text).not.toContain('cross-referencing the ledger')
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('scanTurnForFinalReply: same shape but the trailing block IS terminal (no later tool) → delivered', () => {
|
|
190
|
+
// Control: when the substantive block is genuinely terminal (nothing follows
|
|
191
|
+
// it in the whole turn), it IS the answer and must deliver.
|
|
192
|
+
const answer = BIG('the genuine terminal answer')
|
|
193
|
+
const text = jsonl(
|
|
194
|
+
ENQUEUE,
|
|
195
|
+
assistantToolUse('Bash', { command: 'ls' }), // work happened first
|
|
196
|
+
assistantText(answer), // terminal — no tool after
|
|
197
|
+
)
|
|
198
|
+
const r = scanTurnForFinalReply(text)
|
|
199
|
+
expect(r.decided).toBe('block')
|
|
200
|
+
expect(r.pendingText).toBe(answer)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// ── answer-then-ephemeral-react still delivers (MF4b) ───────────────────────
|
|
205
|
+
|
|
206
|
+
describe('answer-then-ephemeral-tool still delivers (MF4b)', () => {
|
|
207
|
+
it('a terminal answer followed ONLY by a react/pin/edit is NOT suppressed', () => {
|
|
208
|
+
const answer = BIG('the answer the user was waiting for')
|
|
209
|
+
// Ephemeral tools are not recorded as work-tool markers, so the answer stays
|
|
210
|
+
// terminal and delivers.
|
|
211
|
+
const text = jsonl(
|
|
212
|
+
ENQUEUE,
|
|
213
|
+
assistantText(answer),
|
|
214
|
+
assistantToolUse('mcp__switchroom-telegram__react', { emoji: '👍' }),
|
|
215
|
+
)
|
|
216
|
+
const r = scanTurnForFinalReply(text)
|
|
217
|
+
expect(r.decided).toBe('block')
|
|
218
|
+
expect(r.pendingText).toBe(answer)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// ── backstopAlreadyDelivered — exactly-once-among-backstops (MF2) ────────────
|
|
223
|
+
|
|
224
|
+
describe('backstopAlreadyDelivered — backstop-scoped durable exactly-once (MF2)', () => {
|
|
225
|
+
let dir: string
|
|
226
|
+
|
|
227
|
+
it('classifies delivery sources: flush + sweep + non-E0 reply-tool are backstops; E0 reply is NOT', () => {
|
|
228
|
+
expect(isBackstopDeliveredEntry({ turnNonce: 'n', textSha256: 'x', ts: 0, deliverySource: 'flush' })).toBe(true)
|
|
229
|
+
expect(isBackstopDeliveredEntry({ turnNonce: 'n', textSha256: 'x', ts: 0, deliverySource: 'sweep' })).toBe(true)
|
|
230
|
+
// E3 captured-prose bridge journals as reply-tool with replyAlready=false → backstop.
|
|
231
|
+
expect(
|
|
232
|
+
isBackstopDeliveredEntry({
|
|
233
|
+
turnNonce: 'n', textSha256: 'x', ts: 0,
|
|
234
|
+
deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: false,
|
|
235
|
+
}),
|
|
236
|
+
).toBe(true)
|
|
237
|
+
// E0 explicit reply journals with replyAlready=true → NOT a backstop.
|
|
238
|
+
expect(
|
|
239
|
+
isBackstopDeliveredEntry({
|
|
240
|
+
turnNonce: 'n', textSha256: 'x', ts: 0,
|
|
241
|
+
deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: true,
|
|
242
|
+
}),
|
|
243
|
+
).toBe(false)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('a prior FLUSH delivery is seen by backstopAlreadyDelivered; a prior E0 reply is NOT', () => {
|
|
247
|
+
dir = mkdtempSync(join(tmpdir(), 'backstop-once-'))
|
|
248
|
+
try {
|
|
249
|
+
// E0 reply for turn A — must NOT count as a backstop.
|
|
250
|
+
appendDelivered(
|
|
251
|
+
{ turnNonce: 'A', textSha256: 'ha', ts: 1, deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: true },
|
|
252
|
+
dir,
|
|
253
|
+
)
|
|
254
|
+
expect(backstopAlreadyDelivered('A', dir)).toBe(false)
|
|
255
|
+
// …but outboxAlreadyDelivered (the E4 sweep dedup, source-agnostic) DOES see it.
|
|
256
|
+
expect(outboxAlreadyDelivered('A', dir)).toBe(true)
|
|
257
|
+
|
|
258
|
+
// Flush backstop for turn B — must count.
|
|
259
|
+
appendDelivered({ turnNonce: 'B', textSha256: 'hb', ts: 2, deliverySource: 'flush' }, dir)
|
|
260
|
+
expect(backstopAlreadyDelivered('B', dir)).toBe(true)
|
|
261
|
+
|
|
262
|
+
// Unknown nonce → false.
|
|
263
|
+
expect(backstopAlreadyDelivered('C', dir)).toBe(false)
|
|
264
|
+
expect(backstopAlreadyDelivered('', dir)).toBe(false)
|
|
265
|
+
} finally {
|
|
266
|
+
rmSync(dir, { recursive: true, force: true })
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('E3 bridge decision skips when a prior backstop already delivered the nonce (already-delivered), but an E0 recap does NOT block it', () => {
|
|
271
|
+
dir = mkdtempSync(join(tmpdir(), 'backstop-e3-'))
|
|
272
|
+
try {
|
|
273
|
+
const turnKey = 'c:_'
|
|
274
|
+
const turnId = 'c:_#42'
|
|
275
|
+
const answer = BIG('the recovered answer')
|
|
276
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey }, { stateDir: dir })
|
|
277
|
+
// Persist pendingText onto the record so the bridge has something to deliver.
|
|
278
|
+
// (writeSilentEndState doesn't set pendingText; simulate the hook's write.)
|
|
279
|
+
appendDelivered({ turnNonce: turnId, textSha256: 'z', ts: 1, deliverySource: 'flush' }, dir)
|
|
280
|
+
|
|
281
|
+
// With a prior FLUSH backstop for this turnId, the bridge must refuse.
|
|
282
|
+
const blocked = decideCapturedProseDelivery(
|
|
283
|
+
{ turnKey, turnId, minChars: 1 },
|
|
284
|
+
{
|
|
285
|
+
stateDir: dir,
|
|
286
|
+
backstopDeliveredNonceHit: (n) => backstopAlreadyDelivered(n ?? '', dir),
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
// The state file itself carries no pendingText here, so the decision is
|
|
290
|
+
// no-substantive-prose OR already-delivered — assert it does NOT deliver.
|
|
291
|
+
expect(blocked.deliver).toBe(false)
|
|
292
|
+
|
|
293
|
+
// A prior E0 reply recap for the same nonce must NOT trip the backstop guard.
|
|
294
|
+
const dir2 = mkdtempSync(join(tmpdir(), 'backstop-e0-'))
|
|
295
|
+
try {
|
|
296
|
+
appendDelivered(
|
|
297
|
+
{ turnNonce: turnId, textSha256: 'z', ts: 1, deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: true },
|
|
298
|
+
dir2,
|
|
299
|
+
)
|
|
300
|
+
expect(backstopAlreadyDelivered(turnId, dir2)).toBe(false)
|
|
301
|
+
} finally {
|
|
302
|
+
rmSync(dir2, { recursive: true, force: true })
|
|
303
|
+
}
|
|
304
|
+
void answer
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(dir, { recursive: true, force: true })
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// ── multi-substantive-block → one delivery; empty-terminal → one delivery ────
|
|
312
|
+
|
|
313
|
+
describe('single-delivery selection through the scan (MF5)', () => {
|
|
314
|
+
it('multiple substantive terminal blocks → ONE joined delivery (never a second bubble)', () => {
|
|
315
|
+
const p1 = BIG('first half of the answer')
|
|
316
|
+
const p2 = BIG('second half of the answer')
|
|
317
|
+
const text = jsonl(ENQUEUE, assistantText(p1), assistantText(p2))
|
|
318
|
+
const r = scanTurnForFinalReply(text)
|
|
319
|
+
expect(r.decided).toBe('block')
|
|
320
|
+
expect(r.pendingText).toBe(`${p1}\n\n${p2}`)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('empty terminal (last block tool-followed) → the prior substantive terminal run is delivered once', () => {
|
|
324
|
+
const answer = BIG('the settled answer')
|
|
325
|
+
const text = jsonl(
|
|
326
|
+
ENQUEUE,
|
|
327
|
+
assistantText(answer), // terminal? no — a tool follows in the next msg
|
|
328
|
+
assistantToolUse('retain', { text: 'note' }),
|
|
329
|
+
)
|
|
330
|
+
// answer is tool-followed → empty terminal run → rule 3 delivers it iff ≥floor.
|
|
331
|
+
const r = scanTurnForFinalReply(text)
|
|
332
|
+
expect(r.decided).toBe('block')
|
|
333
|
+
expect(r.pendingText).toBe(answer)
|
|
334
|
+
})
|
|
335
|
+
})
|
|
@@ -195,6 +195,20 @@ describe('planUnhandledMessage — service-noise classification', () => {
|
|
|
195
195
|
})
|
|
196
196
|
})
|
|
197
197
|
|
|
198
|
+
it('legacy forward_* wire keys are envelope metadata — the placeholder names the CONTENT, never "forward_from" (row-944 mislabel)', () => {
|
|
199
|
+
const plan = planUnhandledMessage({
|
|
200
|
+
message_id: 944, chat: {}, date: 0,
|
|
201
|
+
forward_from: { id: 1, is_bot: true, first_name: 'Klanker' },
|
|
202
|
+
forward_date: 1784830000,
|
|
203
|
+
mystery_future_content: {},
|
|
204
|
+
})
|
|
205
|
+
expect(plan).toMatchObject({
|
|
206
|
+
action: 'turn',
|
|
207
|
+
text: '(unhandled message content: mystery_future_content)',
|
|
208
|
+
})
|
|
209
|
+
expect(plan.contentKeys).toEqual(['mystery_future_content'])
|
|
210
|
+
})
|
|
211
|
+
|
|
198
212
|
it('a message mixing noise with real content still becomes a turn', () => {
|
|
199
213
|
const plan = planUnhandledMessage({
|
|
200
214
|
message_id: 1, chat: {}, date: 0,
|